feat(canvas): Canvas 기반 ASCII 애니메이션 렌더러 구현
- JetBrains Mono 폰트 번들링 (Android/iOS 호환성) - Paragraph 캐싱으로 GC 압박 최소화 (최대 256개 캐시) - shouldRepaint layerVersion 기반 최적화 - willChange 동적 설정으로 메모리 절약 - 레이어 기반 합성 구조 (배경/캐릭터/몬스터/이펙트) - hp_mp_bar 몬스터 HP 숫자 오버플로우 수정
This commit is contained in:
@@ -453,7 +453,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
deathInfo: state.deathInfo!,
|
||||
traits: state.traits,
|
||||
onResurrect: () async {
|
||||
// 1. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음)
|
||||
await widget.controller.resurrect();
|
||||
|
||||
// 2. 부활 애니메이션 재생
|
||||
setState(() {
|
||||
_specialAnimation = AsciiAnimationType.resurrection;
|
||||
});
|
||||
|
||||
// 3. 애니메이션 종료 후 게임 재개 (5프레임 × 600ms = 3초)
|
||||
Future.delayed(const Duration(milliseconds: 3000), () async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_specialAnimation = null;
|
||||
});
|
||||
// 부활 후 게임 재개 (새 루프 시작)
|
||||
await widget.controller.resumeAfterResurrection();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -152,9 +152,10 @@ class GameSessionController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 플레이어 부활 처리
|
||||
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
|
||||
///
|
||||
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매, 게임 재개
|
||||
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매
|
||||
/// 게임 재개는 resumeAfterResurrection()으로 별도 호출 필요
|
||||
Future<void> resurrect() async {
|
||||
if (_state == null || !_state!.isDead) return;
|
||||
|
||||
@@ -164,11 +165,24 @@ class GameSessionController extends ChangeNotifier {
|
||||
|
||||
final resurrectedState = resurrectionService.processResurrection(_state!);
|
||||
|
||||
// 상태 업데이트 (게임 재개 없이)
|
||||
_state = resurrectedState;
|
||||
_status = GameSessionStatus.idle; // 사망 상태 해제
|
||||
|
||||
// 저장
|
||||
await saveManager.saveState(resurrectedState);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 부활 후 게임 재개
|
||||
///
|
||||
/// resurrect() 호출 후 애니메이션이 끝난 뒤 호출
|
||||
Future<void> resumeAfterResurrection() async {
|
||||
if (_state == null) return;
|
||||
|
||||
// 게임 재개
|
||||
await startNew(resurrectedState, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
||||
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
||||
}
|
||||
|
||||
/// 사망 상태 여부
|
||||
|
||||
@@ -5,7 +5,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
|
||||
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:askiineverdie/src/core/animation/background_layer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/battle_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_battle_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_special_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_town_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_walking_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:askiineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:askiineverdie/src/core/animation/weapon_category.dart';
|
||||
@@ -13,7 +18,15 @@ import 'package:askiineverdie/src/core/constants/ascii_colors.dart';
|
||||
import 'package:askiineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
/// ASCII 애니메이션 카드 위젯
|
||||
/// 애니메이션 모드
|
||||
enum AnimationMode {
|
||||
battle, // 전투
|
||||
walking, // 걷기
|
||||
town, // 마을/상점
|
||||
special, // 특수 이벤트
|
||||
}
|
||||
|
||||
/// ASCII 애니메이션 카드 위젯 (전체 Canvas 기반)
|
||||
///
|
||||
/// TaskType에 따라 다른 애니메이션을 표시.
|
||||
/// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택.
|
||||
@@ -69,14 +82,20 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
Timer? _timer;
|
||||
int _currentFrame = 0;
|
||||
late AsciiAnimationData _animationData;
|
||||
AsciiAnimationType? _currentSpecialAnimation;
|
||||
|
||||
// 애니메이션 모드
|
||||
AnimationMode _animationMode = AnimationMode.walking;
|
||||
|
||||
// Composer 인스턴스들
|
||||
CanvasBattleComposer? _battleComposer;
|
||||
final _walkingComposer = const CanvasWalkingComposer();
|
||||
final _townComposer = const CanvasTownComposer();
|
||||
final _specialComposer = const CanvasSpecialComposer();
|
||||
|
||||
// 전투 애니메이션 상태
|
||||
bool _isBattleMode = false;
|
||||
BattlePhase _battlePhase = BattlePhase.idle;
|
||||
int _battleSubFrame = 0;
|
||||
BattleComposer? _battleComposer;
|
||||
|
||||
// 글로벌 틱 (배경 스크롤용)
|
||||
int _globalTick = 0;
|
||||
@@ -99,6 +118,14 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
int? _lastEventTimestamp;
|
||||
bool _showCriticalEffect = false;
|
||||
|
||||
// 특수 애니메이션 프레임 수
|
||||
static const _specialAnimationFrameCounts = {
|
||||
AsciiAnimationType.levelUp: 5,
|
||||
AsciiAnimationType.questComplete: 4,
|
||||
AsciiAnimationType.actComplete: 4,
|
||||
AsciiAnimationType.resurrection: 5,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -153,7 +180,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
_lastEventTimestamp = event.timestamp;
|
||||
|
||||
// 전투 모드가 아니면 무시
|
||||
if (!_isBattleMode) return;
|
||||
if (_animationMode != AnimationMode.battle) return;
|
||||
|
||||
// 이벤트 타입에 따라 페이즈 강제 전환
|
||||
final (targetPhase, isCritical) = switch (event.type) {
|
||||
@@ -190,46 +217,34 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
/// 현재 상태를 유지하면서 타이머만 재시작
|
||||
void _restartTimer() {
|
||||
_timer?.cancel();
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
// 특수 애니메이션 타이머 재시작
|
||||
if (_currentSpecialAnimation != null) {
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame++;
|
||||
if (_currentFrame >= _animationData.frames.length) {
|
||||
_currentSpecialAnimation = null;
|
||||
_updateAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
/// 타이머 시작
|
||||
void _startTimer() {
|
||||
const tickInterval = Duration(milliseconds: 200);
|
||||
|
||||
// 전투 모드 타이머 재시작
|
||||
if (_isBattleMode) {
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 200),
|
||||
(_) => _advanceBattleFrame(),
|
||||
);
|
||||
} else {
|
||||
// 일반 애니메이션 타이머 재시작
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame =
|
||||
(_currentFrame + 1) % _animationData.frames.length;
|
||||
});
|
||||
_timer = Timer.periodic(tickInterval, (_) {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_globalTick++;
|
||||
|
||||
if (_animationMode == AnimationMode.special) {
|
||||
_currentFrame++;
|
||||
final maxFrames =
|
||||
_specialAnimationFrameCounts[_currentSpecialAnimation] ?? 5;
|
||||
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
||||
if (_currentFrame >= maxFrames) {
|
||||
_currentSpecialAnimation = null;
|
||||
_updateAnimation();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (_animationMode == AnimationMode.battle) {
|
||||
_advanceBattleFrame();
|
||||
}
|
||||
// walking, town은 globalTick만 증가하면 됨
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _updateAnimation() {
|
||||
@@ -237,71 +252,40 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
|
||||
// 특수 애니메이션이 있으면 우선 적용
|
||||
if (_currentSpecialAnimation != null) {
|
||||
_isBattleMode = false;
|
||||
_animationData = getAnimationData(_currentSpecialAnimation!);
|
||||
_animationMode = AnimationMode.special;
|
||||
_currentFrame = 0;
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
|
||||
// 특수 애니메이션은 한 번 재생 후 종료
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame++;
|
||||
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
||||
if (_currentFrame >= _animationData.frames.length) {
|
||||
_currentSpecialAnimation = null;
|
||||
_updateAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
// 특수 애니메이션은 게임 일시정지와 무관하게 항상 재생
|
||||
_startTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 애니메이션 처리
|
||||
final animationType = taskTypeToAnimation(widget.taskType);
|
||||
|
||||
// 전투 타입이면 새 BattleComposer 시스템 사용
|
||||
if (animationType == AsciiAnimationType.battle) {
|
||||
_isBattleMode = true;
|
||||
_setupBattleComposer();
|
||||
_battlePhase = BattlePhase.idle;
|
||||
_battleSubFrame = 0;
|
||||
_phaseIndex = 0;
|
||||
_phaseFrameCount = 0;
|
||||
switch (animationType) {
|
||||
case AsciiAnimationType.battle:
|
||||
_animationMode = AnimationMode.battle;
|
||||
_setupBattleComposer();
|
||||
_battlePhase = BattlePhase.idle;
|
||||
_battleSubFrame = 0;
|
||||
_phaseIndex = 0;
|
||||
_phaseFrameCount = 0;
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
case AsciiAnimationType.town:
|
||||
_animationMode = AnimationMode.town;
|
||||
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 200),
|
||||
(_) => _advanceBattleFrame(),
|
||||
);
|
||||
} else {
|
||||
_isBattleMode = false;
|
||||
_animationData = getAnimationData(animationType);
|
||||
_currentFrame = 0;
|
||||
case AsciiAnimationType.walking:
|
||||
_animationMode = AnimationMode.walking;
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame =
|
||||
(_currentFrame + 1) % _animationData.frames.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
default:
|
||||
_animationMode = AnimationMode.walking;
|
||||
}
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
void _setupBattleComposer() {
|
||||
@@ -311,7 +295,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
final monsterCategory = getMonsterCategory(widget.monsterBaseName);
|
||||
final monsterSize = getMonsterSize(widget.monsterLevel);
|
||||
|
||||
_battleComposer = BattleComposer(
|
||||
_battleComposer = CanvasBattleComposer(
|
||||
weaponCategory: weaponCategory,
|
||||
hasShield: hasShield,
|
||||
monsterCategory: monsterCategory,
|
||||
@@ -326,28 +310,21 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
}
|
||||
|
||||
void _advanceBattleFrame() {
|
||||
if (!mounted) return;
|
||||
_phaseFrameCount++;
|
||||
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||
|
||||
setState(() {
|
||||
// 글로벌 틱 증가 (배경 스크롤용)
|
||||
_globalTick++;
|
||||
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
|
||||
if (_phaseFrameCount >= currentPhase.$2) {
|
||||
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
|
||||
_phaseFrameCount = 0;
|
||||
_battleSubFrame = 0;
|
||||
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
|
||||
_showCriticalEffect = false;
|
||||
} else {
|
||||
_battleSubFrame++;
|
||||
}
|
||||
|
||||
_phaseFrameCount++;
|
||||
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||
|
||||
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
|
||||
if (_phaseFrameCount >= currentPhase.$2) {
|
||||
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
|
||||
_phaseFrameCount = 0;
|
||||
_battleSubFrame = 0;
|
||||
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
|
||||
_showCriticalEffect = false;
|
||||
} else {
|
||||
_battleSubFrame++;
|
||||
}
|
||||
|
||||
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
|
||||
});
|
||||
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -356,52 +333,24 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 이펙트 문자에 색상을 적용한 TextSpan 생성
|
||||
TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) {
|
||||
final spans = <TextSpan>[];
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// 이펙트 문자 정의
|
||||
const effectChars = {'*', '!', '=', '>', '<', '~'};
|
||||
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final char = text[i];
|
||||
|
||||
if (effectChars.contains(char)) {
|
||||
// 버퍼에 쌓인 일반 텍스트 추가
|
||||
if (buffer.isNotEmpty) {
|
||||
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
// 이펙트 문자에 색상 적용
|
||||
final effectColor = _getEffectColor(char);
|
||||
spans.add(TextSpan(
|
||||
text: char,
|
||||
style: baseStyle.copyWith(color: effectColor),
|
||||
));
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
|
||||
// 남은 일반 텍스트 추가
|
||||
if (buffer.isNotEmpty) {
|
||||
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
||||
}
|
||||
|
||||
return TextSpan(children: spans);
|
||||
}
|
||||
|
||||
/// 이펙트 문자별 색상 반환 (Phase 7: 4색 팔레트)
|
||||
Color _getEffectColor(String char) {
|
||||
return switch (char) {
|
||||
'*' => AsciiColors.negative, // 히트/폭발 (마젠타)
|
||||
'!' => AsciiColors.positive, // 강조 (시안)
|
||||
'=' || '>' || '<' => AsciiColors.positive, // 슬래시/찌르기 (시안)
|
||||
'~' => AsciiColors.negative, // 물결/디버프 (마젠타)
|
||||
'+' => AsciiColors.positive, // 회복/버프 (시안)
|
||||
_ => AsciiColors.object, // 오브젝트 (흰색)
|
||||
/// 현재 애니메이션 레이어 생성
|
||||
List<AsciiLayer> _composeLayers() {
|
||||
return switch (_animationMode) {
|
||||
AnimationMode.battle => _battleComposer?.composeLayers(
|
||||
_battlePhase,
|
||||
_battleSubFrame,
|
||||
widget.monsterBaseName,
|
||||
_environment,
|
||||
_globalTick,
|
||||
) ??
|
||||
[AsciiLayer.empty()],
|
||||
AnimationMode.walking => _walkingComposer.composeLayers(_globalTick),
|
||||
AnimationMode.town => _townComposer.composeLayers(_globalTick),
|
||||
AnimationMode.special => _specialComposer.composeLayers(
|
||||
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
|
||||
_currentFrame,
|
||||
_globalTick,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,38 +358,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
Widget build(BuildContext context) {
|
||||
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
|
||||
const bgColor = AsciiColors.background;
|
||||
const textColor = AsciiColors.object;
|
||||
|
||||
// 프레임 텍스트 결정
|
||||
String frameText;
|
||||
|
||||
if (_isBattleMode && _battleComposer != null) {
|
||||
// 새 배틀 시스템 사용 (배경 포함)
|
||||
frameText = _battleComposer!.composeFrameWithBackground(
|
||||
_battlePhase,
|
||||
_battleSubFrame,
|
||||
widget.monsterBaseName,
|
||||
_environment,
|
||||
_globalTick,
|
||||
);
|
||||
// 이펙트는 텍스트 자체로 구분 (*, !, =, ~ 등)
|
||||
// 전체 색상 변경 제거 - 기본 테마 색상 유지
|
||||
} else {
|
||||
// 기존 레거시 시스템 사용
|
||||
final frameIndex =
|
||||
_currentFrame.clamp(0, _animationData.frames.length - 1);
|
||||
frameText = _animationData.frames[frameIndex];
|
||||
}
|
||||
|
||||
// 테두리 효과 결정 (특수 애니메이션 또는 크리티컬 히트)
|
||||
final isSpecial = _currentSpecialAnimation != null;
|
||||
Border? borderEffect;
|
||||
if (_showCriticalEffect) {
|
||||
// 크리티컬 히트: 노란색 테두리 (Phase 5)
|
||||
borderEffect = Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
|
||||
borderEffect =
|
||||
Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
|
||||
} else if (isSpecial) {
|
||||
// 특수 애니메이션: 시안 테두리
|
||||
borderEffect = Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
|
||||
borderEffect =
|
||||
Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
|
||||
}
|
||||
|
||||
return Container(
|
||||
@@ -450,51 +379,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: borderEffect,
|
||||
),
|
||||
child: _isBattleMode
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 60x8 프레임에 맞게 폰트 크기 자동 계산
|
||||
// ASCII 문자 비율: 너비 = 높이 * 0.6 (모노스페이스)
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final maxHeight = constraints.maxHeight;
|
||||
// 60자 폭, 8줄 높이 기준
|
||||
final fontSizeByWidth = maxWidth / 60 / 0.6;
|
||||
final fontSizeByHeight = maxHeight / 8 / 1.2;
|
||||
final fontSize = (fontSizeByWidth < fontSizeByHeight
|
||||
? fontSizeByWidth
|
||||
: fontSizeByHeight)
|
||||
.clamp(6.0, 14.0);
|
||||
|
||||
return Center(
|
||||
child: RichText(
|
||||
text: _buildColoredTextSpan(
|
||||
frameText,
|
||||
TextStyle(
|
||||
fontFamily: 'Courier',
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
height: 1.2,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
frameText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: textColor,
|
||||
height: 1.1,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
child: AsciiCanvasWidget(layers: _composeLayers()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,41 +50,43 @@ class DeathOverlay extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 사망 타이틀
|
||||
_buildDeathTitle(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 캐릭터 정보
|
||||
_buildCharacterInfo(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 사망 원인
|
||||
_buildDeathCause(context),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 구분선
|
||||
Divider(color: colorScheme.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 상실 정보
|
||||
_buildLossInfo(context),
|
||||
|
||||
// 전투 로그 (있는 경우만 표시)
|
||||
if (deathInfo.lastCombatEvents.isNotEmpty) ...[
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 사망 타이틀
|
||||
_buildDeathTitle(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 캐릭터 정보
|
||||
_buildCharacterInfo(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 사망 원인
|
||||
_buildDeathCause(context),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 구분선
|
||||
Divider(color: colorScheme.outlineVariant),
|
||||
const SizedBox(height: 8),
|
||||
_buildCombatLog(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 상실 정보
|
||||
_buildLossInfo(context),
|
||||
|
||||
// 전투 로그 (있는 경우만 표시)
|
||||
if (deathInfo.lastCombatEvents.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Divider(color: colorScheme.outlineVariant),
|
||||
const SizedBox(height: 8),
|
||||
_buildCombatLog(context),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 부활 버튼
|
||||
_buildResurrectButton(context),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 부활 버튼
|
||||
_buildResurrectButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -369,11 +369,14 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// HP 숫자
|
||||
Text(
|
||||
'$current/$max',
|
||||
style: const TextStyle(fontSize: 8, color: Colors.orange),
|
||||
textAlign: TextAlign.right,
|
||||
// HP 숫자 (Flexible로 오버플로우 방지)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$current/$max',
|
||||
style: const TextStyle(fontSize: 8, color: Colors.orange),
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user