From f65bab6312b9bce2b906c1e1c03e0950fb82acd3 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 14 Jan 2026 00:18:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EA=B2=8C=EC=9E=84=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AsciiAnimationCard 확장 - EnhancedAnimationPanel 개선 - HpMpBar UI 개선 --- .../game/widgets/ascii_animation_card.dart | 102 ++++++++++++++-- .../widgets/enhanced_animation_panel.dart | 96 ++++++++++++--- lib/src/features/game/widgets/hp_mp_bar.dart | 114 +++++++++++------- 3 files changed, 242 insertions(+), 70 deletions(-) diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 6b006e2..68c59f4 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_data.dart'; +import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/animation/background_layer.dart'; import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart'; @@ -47,6 +48,8 @@ class AsciiAnimationCard extends StatefulWidget { this.monsterLevel, this.monsterGrade, this.isPaused = false, + this.isInCombat = true, + this.monsterDied = false, this.latestCombatEvent, this.raceId, this.weaponRarity, @@ -59,6 +62,12 @@ class AsciiAnimationCard extends StatefulWidget { /// 일시정지 상태 (true면 애니메이션 정지) final bool isPaused; + /// 전투 활성 상태 (false면 kill 태스크여도 walking 애니메이션) + final bool isInCombat; + + /// 몬스터 사망 여부 (true면 분해 애니메이션 재생) + final bool monsterDied; + /// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용) final String? monsterBaseName; final AsciiColorTheme colorTheme; @@ -162,6 +171,11 @@ class _AsciiAnimationCardState extends State { // 특수 애니메이션 프레임 수는 ascii_animation_type.dart의 // specialAnimationFrameCounts 상수 사용 + // 몬스터 사망 분해 애니메이션 상태 + bool _showDeathAnimation = false; + List? _deathAnimationMonsterLines; + String? _lastMonsterBaseName; + @override void initState() { super.initState(); @@ -204,12 +218,32 @@ class _AsciiAnimationCardState extends State { return; } + // 몬스터 사망 애니메이션 트리거 + if (!oldWidget.monsterDied && widget.monsterDied && !_showDeathAnimation) { + // 현재 몬스터 프레임 캡처 (분해 애니메이션용) + _deathAnimationMonsterLines = _captureMonsterFrame(); + if (_deathAnimationMonsterLines != null) { + setState(() { + _showDeathAnimation = true; + }); + return; // 사망 애니메이션 중에는 다른 업데이트 무시 + } + } + + // 사망 애니메이션 중에는 다른 업데이트 무시 + if (_showDeathAnimation) return; + // 전투 이벤트 동기화 (Phase 5) if (widget.latestCombatEvent != null && widget.latestCombatEvent!.timestamp != _lastEventTimestamp) { _handleCombatEvent(widget.latestCombatEvent!); } + // 몬스터 이름 저장 (사망 시 프레임 캡처용) + if (widget.monsterBaseName != null) { + _lastMonsterBaseName = widget.monsterBaseName; + } + if (oldWidget.taskType != widget.taskType || oldWidget.monsterBaseName != widget.monsterBaseName || oldWidget.weaponName != widget.weaponName || @@ -218,7 +252,8 @@ class _AsciiAnimationCardState extends State { oldWidget.raceId != widget.raceId || oldWidget.weaponRarity != widget.weaponRarity || oldWidget.opponentRaceId != widget.opponentRaceId || - oldWidget.opponentHasShield != widget.opponentHasShield) { + oldWidget.opponentHasShield != widget.opponentHasShield || + oldWidget.isInCombat != widget.isInCombat) { _updateAnimation(); } } @@ -505,12 +540,18 @@ class _AsciiAnimationCardState extends State { switch (animationType) { case AsciiAnimationType.battle: - _animationMode = AnimationMode.battle; - _setupBattleComposer(); - _battlePhase = BattlePhase.idle; - _battleSubFrame = 0; - _phaseIndex = 0; - _phaseFrameCount = 0; + // 전투 비활성 상태면 walking 모드로 전환 (몬스터 처치 후 이동 중) + if (!widget.isInCombat) { + _animationMode = AnimationMode.walking; + _walkingComposer = CanvasWalkingComposer(raceId: widget.raceId); + } else { + _animationMode = AnimationMode.battle; + _setupBattleComposer(); + _battlePhase = BattlePhase.idle; + _battleSubFrame = 0; + _phaseIndex = 0; + _phaseFrameCount = 0; + } case AsciiAnimationType.town: _animationMode = AnimationMode.town; @@ -556,6 +597,31 @@ class _AsciiAnimationCardState extends State { ); } + /// 현재 몬스터 프레임을 텍스트 라인으로 캡처 (분해 애니메이션용) + List? _captureMonsterFrame() { + final monsterName = _lastMonsterBaseName ?? widget.monsterBaseName; + if (monsterName == null) return null; + + final monsterCategory = getMonsterCategory(monsterName); + final monsterSize = getMonsterSize(widget.monsterLevel); + + // 몬스터 Idle 프레임 가져오기 + final frames = getMonsterIdleFrames(monsterCategory, monsterSize); + if (frames.isEmpty) return null; + + return frames.first; + } + + /// 사망 애니메이션 완료 콜백 + void _onDeathAnimationComplete() { + setState(() { + _showDeathAnimation = false; + _deathAnimationMonsterLines = null; + }); + // Walking 모드로 전환 + _updateAnimation(); + } + void _advanceBattleFrame() { _phaseFrameCount++; final currentPhase = _battlePhaseSequence[_phaseIndex]; @@ -617,6 +683,7 @@ class _AsciiAnimationCardState extends State { isDot: _showDotEffect, isBlock: _showBlockEffect, isParry: _showParryEffect, + hideMonster: _showDeathAnimation, ) ?? [AsciiLayer.empty()], AnimationMode.walking => @@ -676,7 +743,26 @@ class _AsciiAnimationCardState extends State { borderRadius: BorderRadius.circular(4), border: borderEffect, ), - child: AsciiCanvasWidget(layers: _composeLayers()), + child: Stack( + children: [ + // 기본 애니메이션 + AsciiCanvasWidget(layers: _composeLayers()), + + // 몬스터 사망 분해 애니메이션 오버레이 + // 몬스터 위치: 캔버스 60열 중 30~48열 (중앙값 41열) + // Alignment x = (41/60) * 2 - 1 = 0.37 + if (_showDeathAnimation && _deathAnimationMonsterLines != null) + Align( + alignment: const Alignment(0.37, 0.0), + child: AsciiDisintegrateWidget( + characterLines: _deathAnimationMonsterLines!, + duration: const Duration(milliseconds: 800), + textColor: widget.monsterGrade?.displayColor, + onComplete: _onDeathAnimationComplete, + ), + ), + ], + ), ); } } diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index 046a6a2..d1403b1 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -83,6 +83,10 @@ class _EnhancedAnimationPanelState extends State int _lastMp = 0; int _lastMonsterHp = 0; + // 몬스터 사망 상태 추적 + bool _monsterDied = false; + bool _wasInCombat = false; + @override void initState() { super.initState(); @@ -145,6 +149,27 @@ class _EnhancedAnimationPanelState extends State } else if (newMonsterHp == null) { _lastMonsterHp = 0; } + + // 몬스터 사망 감지: 전투 중 → 비전투 전환 시 몬스터 사망 + final combat = widget.progress.currentCombat; + final isNowInCombat = combat != null && combat.isActive; + if (_wasInCombat && !isNowInCombat) { + // 전투가 끝났고, 태스크가 여전히 kill이면 몬스터 사망 (플레이어 승리) + if (widget.progress.currentTask.type == TaskType.kill) { + setState(() { + _monsterDied = true; + }); + // 잠시 후 리셋 (애니메이션 완료 후) + Future.delayed(const Duration(milliseconds: 900), () { + if (mounted) { + setState(() { + _monsterDied = false; + }); + } + }); + } + } + _wasInCombat = isNowInCombat; } int get _currentHp => @@ -197,6 +222,8 @@ class _EnhancedAnimationPanelState extends State monsterLevel: widget.monsterLevel, monsterGrade: widget.monsterGrade, isPaused: widget.isPaused, + isInCombat: isInCombat, + monsterDied: _monsterDied, latestCombatEvent: widget.latestCombatEvent, raceId: widget.raceId, weaponRarity: widget.weaponRarity, @@ -480,10 +507,14 @@ class _EnhancedAnimationPanelState extends State } /// 몬스터 HP 바 (전투 중) + /// - HP바 중앙에 HP% 오버레이 + /// - 하단에 레벨.이름 표시 Widget _buildMonsterHpBar(CombatState combat) { final max = _currentMonsterHpMax ?? 1; final current = _currentMonsterHp ?? 0; final ratio = max > 0 ? current / max : 0.0; + final monsterName = combat.monsterStats.name; + final monsterLevel = widget.monsterLevel ?? combat.monsterStats.level; return AnimatedBuilder( animation: _monsterFlashAnimation, @@ -492,7 +523,7 @@ class _EnhancedAnimationPanelState extends State clipBehavior: Clip.none, children: [ Container( - height: 32, + height: 36, decoration: BoxDecoration( color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), @@ -501,27 +532,58 @@ class _EnhancedAnimationPanelState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // HP 바 + // HP 바 (HP% 중앙 오버레이) Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: ratio.clamp(0.0, 1.0), - backgroundColor: Colors.orange.withValues(alpha: 0.2), - valueColor: const AlwaysStoppedAnimation(Colors.orange), - minHeight: 8, - ), + child: Stack( + alignment: Alignment.center, + children: [ + // HP 바 + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.orange.withValues( + alpha: 0.2, + ), + valueColor: const AlwaysStoppedAnimation( + Colors.orange, + ), + minHeight: 12, + ), + ), + // HP% 중앙 오버레이 + Text( + '${(ratio * 100).toInt()}%', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.8), + blurRadius: 2, + ), + const Shadow(color: Colors.black, blurRadius: 4), + ], + ), + ), + ], ), ), const SizedBox(height: 2), - // 퍼센트 - Text( - '${(ratio * 100).toInt()}%', - style: const TextStyle( - fontSize: 9, - color: Colors.orange, - fontWeight: FontWeight.bold, + // 레벨.이름 표시 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Lv.$monsterLevel $monsterName', + style: const TextStyle( + fontSize: 9, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ), ], diff --git a/lib/src/features/game/widgets/hp_mp_bar.dart b/lib/src/features/game/widgets/hp_mp_bar.dart index 5892e75..1a84ce0 100644 --- a/lib/src/features/game/widgets/hp_mp_bar.dart +++ b/lib/src/features/game/widgets/hp_mp_bar.dart @@ -19,6 +19,7 @@ class HpMpBar extends StatefulWidget { this.monsterHpCurrent, this.monsterHpMax, this.monsterName, + this.monsterLevel, }); final int hpCurrent; @@ -30,6 +31,7 @@ class HpMpBar extends StatefulWidget { final int? monsterHpCurrent; final int? monsterHpMax; final String? monsterName; + final int? monsterLevel; @override State createState() => _HpMpBarState(); @@ -368,11 +370,17 @@ class _HpMpBarState extends State with TickerProviderStateMixin { } /// 몬스터 HP 바 (레트로 스타일) + /// - HP바 중앙에 HP% 오버레이 + /// - 하단에 레벨.이름 표시 Widget _buildMonsterBar() { final max = widget.monsterHpMax!; final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0; const segmentCount = 10; final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round(); + final levelPrefix = widget.monsterLevel != null + ? 'Lv.${widget.monsterLevel} ' + : ''; + final monsterName = widget.monsterName ?? ''; return AnimatedBuilder( animation: _monsterFlashAnimation, @@ -396,62 +404,78 @@ class _HpMpBarState extends State with TickerProviderStateMixin { child: Stack( clipBehavior: Clip.none, children: [ - Row( + Column( + mainAxisSize: MainAxisSize.min, children: [ - // 몬스터 아이콘 - const Icon( - Icons.pest_control, - size: 12, - color: RetroColors.gold, - ), - const SizedBox(width: 6), - // 세그먼트 HP 바 - Expanded( - child: Container( - height: 10, - decoration: BoxDecoration( - color: RetroColors.hpRedDark.withValues(alpha: 0.3), - border: Border.all( - color: RetroColors.panelBorderOuter, - width: 1, + // HP 바 (HP% 중앙 오버레이) + Stack( + alignment: Alignment.center, + children: [ + // 세그먼트 HP 바 + Container( + height: 12, + decoration: BoxDecoration( + color: RetroColors.hpRedDark.withValues(alpha: 0.3), + border: Border.all( + color: RetroColors.panelBorderOuter, + width: 1, + ), ), - ), - child: Row( - children: List.generate(segmentCount, (index) { - final isFilled = index < filledSegments; - return Expanded( - child: Container( - decoration: BoxDecoration( - color: isFilled - ? RetroColors.gold - : RetroColors.panelBorderOuter.withValues( - alpha: 0.3, - ), - border: Border( - right: index < segmentCount - 1 - ? BorderSide( - color: RetroColors.panelBorderOuter - .withValues(alpha: 0.3), - width: 1, - ) - : BorderSide.none, + child: Row( + children: List.generate(segmentCount, (index) { + final isFilled = index < filledSegments; + return Expanded( + child: Container( + decoration: BoxDecoration( + color: isFilled + ? RetroColors.gold + : RetroColors.panelBorderOuter.withValues( + alpha: 0.3, + ), + border: Border( + right: index < segmentCount - 1 + ? BorderSide( + color: RetroColors.panelBorderOuter + .withValues(alpha: 0.3), + width: 1, + ) + : BorderSide.none, + ), ), ), - ), - ); - }), + ); + }), + ), ), - ), + // HP% 중앙 오버레이 + Text( + '${(ratio * 100).toInt()}%', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textLight, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.8), + blurRadius: 2, + ), + const Shadow(color: Colors.black, blurRadius: 4), + ], + ), + ), + ], ), - const SizedBox(width: 6), - // HP 퍼센트 + const SizedBox(height: 4), + // 레벨.이름 표시 Text( - '${(ratio * 100).toInt()}%', + '$levelPrefix$monsterName', style: const TextStyle( fontFamily: 'PressStart2P', - fontSize: 8, + fontSize: 7, color: RetroColors.gold, ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ], ),