From 20421dafd7113fe0de8ec427f7003c2ebf472677 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 5 Jan 2026 17:53:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=AA=AC=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EA=B8=89=20UI=20=EB=B0=8F=20SFX=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GamePlayScreen 회피/방어/패리 SFX 추가 - TaskProgressPanel 몬스터 등급 표시 - EnhancedAnimationPanel/AsciiAnimationCard 개선 - MobileCarouselLayout 몬스터 등급 전달 --- lib/src/features/game/game_play_screen.dart | 18 ++- .../game/layouts/mobile_carousel_layout.dart | 2 + .../game/widgets/ascii_animation_card.dart | 116 +++++++++--------- .../widgets/enhanced_animation_panel.dart | 41 ++++++- .../game/widgets/task_progress_panel.dart | 48 +++++++- 5 files changed, 154 insertions(+), 71 deletions(-) diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 448359e..0482b03 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -302,16 +302,21 @@ class _GamePlayScreenState extends State case CombatEventType.monsterAttack: audio.playMonsterSfx('hit'); + // 회피/방어 SFX (Phase 11) + case CombatEventType.playerEvade: + audio.playPlayerSfx('evade'); + case CombatEventType.monsterEvade: + // 몬스터 회피 = 플레이어 공격 빗나감 (evade SFX) + audio.playPlayerSfx('evade'); + case CombatEventType.playerBlock: + audio.playPlayerSfx('block'); + case CombatEventType.playerParry: + audio.playPlayerSfx('parry'); + // SFX 없음 case CombatEventType.dotTick: // DOT 틱은 SFX 없음 (너무 자주 발생) break; - case CombatEventType.playerEvade: - case CombatEventType.monsterEvade: - case CombatEventType.playerBlock: - case CombatEventType.playerParry: - // 회피/방어는 별도 SFX 없음 - break; } } @@ -925,6 +930,7 @@ class _GamePlayScreenState extends State shieldName: state.equipment.shield, characterLevel: state.traits.level, monsterLevel: state.progress.currentTask.monsterLevel, + monsterGrade: state.progress.currentTask.monsterGrade, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, raceId: state.traits.raceId, diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 284b330..37a5a48 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -663,9 +663,11 @@ class _MobileCarouselLayoutState extends State { shieldName: state.equipment.shield, characterLevel: state.traits.level, monsterLevel: state.progress.currentTask.monsterLevel, + monsterGrade: state.progress.currentTask.monsterGrade, latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull, raceId: state.traits.raceId, + weaponRarity: state.equipment.weaponItem.rarity, ), // 중앙: 캐로셀 (PageView) diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 16c7063..025495f 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -17,6 +17,8 @@ import 'package:asciineverdie/src/core/animation/weapon_category.dart'; import 'package:asciineverdie/src/core/constants/ascii_colors.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; /// 애니메이션 모드 enum AnimationMode { @@ -43,9 +45,11 @@ class AsciiAnimationCard extends StatefulWidget { this.shieldName, this.characterLevel, this.monsterLevel, + this.monsterGrade, this.isPaused = false, this.latestCombatEvent, this.raceId, + this.weaponRarity, }); final TaskType taskType; @@ -73,12 +77,18 @@ class AsciiAnimationCard extends StatefulWidget { /// 몬스터 레벨 (몬스터 크기 결정용) final int? monsterLevel; + /// 몬스터 등급 (Normal/Elite/Boss) - 색상/접두사 표시용 + final MonsterGrade? monsterGrade; + /// 최근 전투 이벤트 (애니메이션 동기화용) final CombatEvent? latestCombatEvent; /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) final String? raceId; + /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) + final ItemRarity? weaponRarity; + @override State createState() => _AsciiAnimationCardState(); } @@ -128,6 +138,12 @@ class _AsciiAnimationCardState extends State { bool _showParryEffect = false; bool _showSkillEffect = false; + // 추가 전투 이펙트 (Phase 11) + bool _showEvadeEffect = false; + bool _showMissEffect = false; + bool _showDebuffEffect = false; + bool _showDotEffect = false; + // 공격 속도 기반 동적 페이즈 프레임 수 (Phase 6) int _eventDrivenPhaseFrames = 0; bool _isEventDrivenPhase = false; @@ -191,12 +207,13 @@ class _AsciiAnimationCardState extends State { oldWidget.weaponName != widget.weaponName || oldWidget.shieldName != widget.shieldName || oldWidget.monsterLevel != widget.monsterLevel || - oldWidget.raceId != widget.raceId) { + oldWidget.raceId != widget.raceId || + oldWidget.weaponRarity != widget.weaponRarity) { _updateAnimation(); } } - /// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5) + /// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5, 11) void _handleCombatEvent(CombatEvent event) { _lastEventTimestamp = event.timestamp; @@ -204,121 +221,90 @@ class _AsciiAnimationCardState extends State { if (_animationMode != AnimationMode.battle) return; // 이벤트 타입에 따라 페이즈 및 효과 결정 + // (targetPhase, isCritical, isBlock, isParry, isSkill, isEvade, isMiss, isDebuff, isDot) final ( targetPhase, isCritical, isBlock, isParry, isSkill, + isEvade, + isMiss, + isDebuff, + isDot, ) = switch (event.type) { // 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시) CombatEventType.playerAttack => ( BattlePhase.prepare, event.isCritical, - false, - false, - false, + false, false, false, false, false, false, false, ), // 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트 CombatEventType.playerSkill => ( BattlePhase.prepare, event.isCritical, - false, - false, - true, + false, false, true, false, false, false, false, ), // 몬스터 공격 → prepare 페이즈부터 시작 CombatEventType.monsterAttack => ( BattlePhase.prepare, - false, - false, - false, - false, + false, false, false, false, false, false, false, false, ), - // 블록 → hit 페이즈 + 블록 이펙트 + // 블록 → hit 페이즈 + 블록 이펙트 + 텍스트 CombatEventType.playerBlock => ( BattlePhase.hit, - false, - true, - false, - false, + false, true, false, false, false, false, false, false, ), - // 패리 → hit 페이즈 + 패리 이펙트 + // 패리 → hit 페이즈 + 패리 이펙트 + 텍스트 CombatEventType.playerParry => ( BattlePhase.hit, - false, - false, - true, - false, + false, false, true, false, false, false, false, false, ), - // 회피 → recover 페이즈 (빠른 회피 동작) + // 플레이어 회피 → recover 페이즈 + 회피 텍스트 CombatEventType.playerEvade => ( BattlePhase.recover, - false, - false, - false, - false, + false, false, false, false, true, false, false, false, ), + // 몬스터 회피 → idle 페이즈 + 미스 텍스트 CombatEventType.monsterEvade => ( BattlePhase.idle, - false, - false, - false, - false, + false, false, false, false, false, true, false, false, ), // 회복/버프 → idle 페이즈 유지 CombatEventType.playerHeal => ( BattlePhase.idle, - false, - false, - false, - false, + false, false, false, false, false, false, false, false, ), CombatEventType.playerBuff => ( BattlePhase.idle, - false, - false, - false, - false, + false, false, false, false, false, false, false, false, ), - // 디버프 적용 → idle 페이즈 유지 + // 디버프 적용 → idle 페이즈 + 디버프 텍스트 CombatEventType.playerDebuff => ( BattlePhase.idle, - false, - false, - false, - false, + false, false, false, false, false, false, true, false, ), - // DOT 틱 → attack 페이즈 (지속 피해) + // DOT 틱 → attack 페이즈 + DOT 텍스트 CombatEventType.dotTick => ( BattlePhase.attack, - false, - false, - false, - false, + false, false, false, false, false, false, false, true, ), // 물약 사용 → idle 페이즈 유지 CombatEventType.playerPotion => ( BattlePhase.idle, - false, - false, - false, - false, + false, false, false, false, false, false, false, false, ), // 물약 드랍 → idle 페이즈 유지 CombatEventType.potionDrop => ( BattlePhase.idle, - false, - false, - false, - false, + false, false, false, false, false, false, false, false, ), }; @@ -330,6 +316,10 @@ class _AsciiAnimationCardState extends State { _showBlockEffect = isBlock; _showParryEffect = isParry; _showSkillEffect = isSkill; + _showEvadeEffect = isEvade; + _showMissEffect = isMiss; + _showDebuffEffect = isDebuff; + _showDotEffect = isDot; // 페이즈 인덱스 동기화 _phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase); @@ -454,6 +444,7 @@ class _AsciiAnimationCardState extends State { monsterCategory: monsterCategory, monsterSize: monsterSize, raceId: widget.raceId, + weaponRarity: widget.weaponRarity, ); // 환경 타입 추론 @@ -483,6 +474,10 @@ class _AsciiAnimationCardState extends State { _showBlockEffect = false; _showParryEffect = false; _showSkillEffect = false; + _showEvadeEffect = false; + _showMissEffect = false; + _showDebuffEffect = false; + _showDotEffect = false; // 공격자 타입 및 이벤트 기반 페이즈 리셋 (idle 페이즈 진입 시에만) // 공격 사이클(prepare→attack→hit→recover) 동안 유지 (Bug fix) if (_battlePhaseSequence[_phaseIndex].$1 == BattlePhase.idle) { @@ -513,6 +508,13 @@ class _AsciiAnimationCardState extends State { _environment, _globalTick, attacker: _currentAttacker, + isCritical: _showCriticalEffect, + isEvade: _showEvadeEffect, + isMiss: _showMissEffect, + isDebuff: _showDebuffEffect, + isDot: _showDotEffect, + isBlock: _showBlockEffect, + isParry: _showParryEffect, ) ?? [AsciiLayer.empty()], AnimationMode.walking => diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index 8ccedfa..91dfcda 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -5,6 +5,8 @@ import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/combat_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; /// 모바일용 확장 애니메이션 패널 @@ -29,8 +31,10 @@ class EnhancedAnimationPanel extends StatefulWidget { this.shieldName, this.characterLevel, this.monsterLevel, + this.monsterGrade, this.latestCombatEvent, this.raceId, + this.weaponRarity, }); final ProgressState progress; @@ -45,11 +49,17 @@ class EnhancedAnimationPanel extends StatefulWidget { final String? shieldName; final int? characterLevel; final int? monsterLevel; + + /// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용 + final MonsterGrade? monsterGrade; final CombatEvent? latestCombatEvent; /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) final String? raceId; + /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) + final ItemRarity? weaponRarity; + @override State createState() => _EnhancedAnimationPanelState(); } @@ -185,9 +195,11 @@ class _EnhancedAnimationPanelState extends State shieldName: widget.shieldName, characterLevel: widget.characterLevel, monsterLevel: widget.monsterLevel, + monsterGrade: widget.monsterGrade, isPaused: widget.isPaused, latestCombatEvent: widget.latestCombatEvent, raceId: widget.raceId, + weaponRarity: widget.weaponRarity, ), ), @@ -630,11 +642,34 @@ class _EnhancedAnimationPanelState extends State ? (task.position / task.max).clamp(0.0, 1.0) : 0.0; + // 몬스터 등급에 따른 접두사와 색상 + final grade = widget.monsterGrade; + final isKillTask = widget.progress.currentTask.type == TaskType.kill; + final gradePrefix = + (isKillTask && grade != null) ? grade.displayPrefix : ''; + final gradeColor = + (isKillTask && grade != null) ? grade.displayColor : null; + return Column( children: [ - // 캡션 - Text( - _getStatusMessage(), + // 캡션 (등급에 따른 접두사 및 색상) + Text.rich( + TextSpan( + children: [ + if (gradePrefix.isNotEmpty) + TextSpan( + text: gradePrefix, + style: TextStyle( + color: gradeColor, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: _getStatusMessage(), + style: gradeColor != null ? TextStyle(color: gradeColor) : null, + ), + ], + ), style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, maxLines: 1, diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart index 65a1ea9..6b5f239 100644 --- a/lib/src/features/game/widgets/task_progress_panel.dart +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -5,6 +5,7 @@ import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; /// 상단 패널: ASCII 애니메이션 + Task Progress 바 @@ -23,6 +24,7 @@ class TaskProgressPanel extends StatelessWidget { this.shieldName, this.characterLevel, this.monsterLevel, + this.monsterGrade, this.latestCombatEvent, this.raceId, }); @@ -44,6 +46,9 @@ class TaskProgressPanel extends StatelessWidget { final int? characterLevel; final int? monsterLevel; + /// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용 + final MonsterGrade? monsterGrade; + /// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5) final CombatEvent? latestCombatEvent; @@ -74,6 +79,7 @@ class TaskProgressPanel extends StatelessWidget { shieldName: shieldName, characterLevel: characterLevel, monsterLevel: monsterLevel, + monsterGrade: monsterGrade, isPaused: isPaused, latestCombatEvent: latestCombatEvent, raceId: raceId, @@ -87,11 +93,7 @@ class TaskProgressPanel extends StatelessWidget { _buildPauseButton(context), const SizedBox(width: 8), Expanded( - child: Text( - _getStatusMessage(context), - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), + child: _buildStatusMessage(context), ), const SizedBox(width: 8), _buildSpeedButton(context), @@ -153,6 +155,42 @@ class TaskProgressPanel extends StatelessWidget { ); } + /// 상태 메시지 위젯 (등급에 따른 접두사 및 색상 적용) + Widget _buildStatusMessage(BuildContext context) { + final message = _getStatusMessage(context); + + // 몬스터 등급에 따른 접두사와 색상 + final grade = monsterGrade; + final isKillTask = progress.currentTask.type == TaskType.kill; + final gradePrefix = + (isKillTask && grade != null) ? grade.displayPrefix : ''; + final gradeColor = + (isKillTask && grade != null) ? grade.displayColor : null; + + return Text.rich( + TextSpan( + children: [ + if (gradePrefix.isNotEmpty) + TextSpan( + text: gradePrefix, + style: TextStyle( + color: gradeColor, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: message, + style: gradeColor != null ? TextStyle(color: gradeColor) : null, + ), + ], + ), + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + /// 현재 상태에 맞는 메시지 반환 /// /// 특수 애니메이션(부활 등) 중에는 해당 메시지 표시