feat(ui): 몬스터 등급 UI 및 SFX 연동

- GamePlayScreen 회피/방어/패리 SFX 추가
- TaskProgressPanel 몬스터 등급 표시
- EnhancedAnimationPanel/AsciiAnimationCard 개선
- MobileCarouselLayout 몬스터 등급 전달
This commit is contained in:
JiWoong Sul
2026-01-05 17:53:02 +09:00
parent 7570a4205c
commit 20421dafd7
5 changed files with 154 additions and 71 deletions

View File

@@ -302,16 +302,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
case CombatEventType.monsterAttack: case CombatEventType.monsterAttack:
audio.playMonsterSfx('hit'); 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 없음 // SFX 없음
case CombatEventType.dotTick: case CombatEventType.dotTick:
// DOT 틱은 SFX 없음 (너무 자주 발생) // DOT 틱은 SFX 없음 (너무 자주 발생)
break; break;
case CombatEventType.playerEvade:
case CombatEventType.monsterEvade:
case CombatEventType.playerBlock:
case CombatEventType.playerParry:
// 회피/방어는 별도 SFX 없음
break;
} }
} }
@@ -925,6 +930,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
shieldName: state.equipment.shield, shieldName: state.equipment.shield,
characterLevel: state.traits.level, characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel, monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
latestCombatEvent: latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull, state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId, raceId: state.traits.raceId,

View File

@@ -663,9 +663,11 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
shieldName: state.equipment.shield, shieldName: state.equipment.shield,
characterLevel: state.traits.level, characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel, monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
latestCombatEvent: latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull, state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId, raceId: state.traits.raceId,
weaponRarity: state.equipment.weaponItem.rarity,
), ),
// 중앙: 캐로셀 (PageView) // 중앙: 캐로셀 (PageView)

View File

@@ -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/constants/ascii_colors.dart';
import 'package:asciineverdie/src/core/model/combat_event.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/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
/// 애니메이션 모드 /// 애니메이션 모드
enum AnimationMode { enum AnimationMode {
@@ -43,9 +45,11 @@ class AsciiAnimationCard extends StatefulWidget {
this.shieldName, this.shieldName,
this.characterLevel, this.characterLevel,
this.monsterLevel, this.monsterLevel,
this.monsterGrade,
this.isPaused = false, this.isPaused = false,
this.latestCombatEvent, this.latestCombatEvent,
this.raceId, this.raceId,
this.weaponRarity,
}); });
final TaskType taskType; final TaskType taskType;
@@ -73,12 +77,18 @@ class AsciiAnimationCard extends StatefulWidget {
/// 몬스터 레벨 (몬스터 크기 결정용) /// 몬스터 레벨 (몬스터 크기 결정용)
final int? monsterLevel; final int? monsterLevel;
/// 몬스터 등급 (Normal/Elite/Boss) - 색상/접두사 표시용
final MonsterGrade? monsterGrade;
/// 최근 전투 이벤트 (애니메이션 동기화용) /// 최근 전투 이벤트 (애니메이션 동기화용)
final CombatEvent? latestCombatEvent; final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId; final String? raceId;
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
@override @override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState(); State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
} }
@@ -128,6 +138,12 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
bool _showParryEffect = false; bool _showParryEffect = false;
bool _showSkillEffect = false; bool _showSkillEffect = false;
// 추가 전투 이펙트 (Phase 11)
bool _showEvadeEffect = false;
bool _showMissEffect = false;
bool _showDebuffEffect = false;
bool _showDotEffect = false;
// 공격 속도 기반 동적 페이즈 프레임 수 (Phase 6) // 공격 속도 기반 동적 페이즈 프레임 수 (Phase 6)
int _eventDrivenPhaseFrames = 0; int _eventDrivenPhaseFrames = 0;
bool _isEventDrivenPhase = false; bool _isEventDrivenPhase = false;
@@ -191,12 +207,13 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
oldWidget.weaponName != widget.weaponName || oldWidget.weaponName != widget.weaponName ||
oldWidget.shieldName != widget.shieldName || oldWidget.shieldName != widget.shieldName ||
oldWidget.monsterLevel != widget.monsterLevel || oldWidget.monsterLevel != widget.monsterLevel ||
oldWidget.raceId != widget.raceId) { oldWidget.raceId != widget.raceId ||
oldWidget.weaponRarity != widget.weaponRarity) {
_updateAnimation(); _updateAnimation();
} }
} }
/// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5) /// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5, 11)
void _handleCombatEvent(CombatEvent event) { void _handleCombatEvent(CombatEvent event) {
_lastEventTimestamp = event.timestamp; _lastEventTimestamp = event.timestamp;
@@ -204,121 +221,90 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
if (_animationMode != AnimationMode.battle) return; if (_animationMode != AnimationMode.battle) return;
// 이벤트 타입에 따라 페이즈 및 효과 결정 // 이벤트 타입에 따라 페이즈 및 효과 결정
// (targetPhase, isCritical, isBlock, isParry, isSkill, isEvade, isMiss, isDebuff, isDot)
final ( final (
targetPhase, targetPhase,
isCritical, isCritical,
isBlock, isBlock,
isParry, isParry,
isSkill, isSkill,
isEvade,
isMiss,
isDebuff,
isDot,
) = switch (event.type) { ) = switch (event.type) {
// 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시) // 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시)
CombatEventType.playerAttack => ( CombatEventType.playerAttack => (
BattlePhase.prepare, BattlePhase.prepare,
event.isCritical, event.isCritical,
false, false, false, false, false, false, false, false,
false,
false,
), ),
// 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트 // 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트
CombatEventType.playerSkill => ( CombatEventType.playerSkill => (
BattlePhase.prepare, BattlePhase.prepare,
event.isCritical, event.isCritical,
false, false, false, true, false, false, false, false,
false,
true,
), ),
// 몬스터 공격 → prepare 페이즈부터 시작 // 몬스터 공격 → prepare 페이즈부터 시작
CombatEventType.monsterAttack => ( CombatEventType.monsterAttack => (
BattlePhase.prepare, BattlePhase.prepare,
false, false, false, false, false, false, false, false, false,
false,
false,
false,
), ),
// 블록 → hit 페이즈 + 블록 이펙트 // 블록 → hit 페이즈 + 블록 이펙트 + 텍스트
CombatEventType.playerBlock => ( CombatEventType.playerBlock => (
BattlePhase.hit, BattlePhase.hit,
false, false, true, false, false, false, false, false, false,
true,
false,
false,
), ),
// 패리 → hit 페이즈 + 패리 이펙트 // 패리 → hit 페이즈 + 패리 이펙트 + 텍스트
CombatEventType.playerParry => ( CombatEventType.playerParry => (
BattlePhase.hit, BattlePhase.hit,
false, false, false, true, false, false, false, false, false,
false,
true,
false,
), ),
// 회피 → recover 페이즈 (빠른 회피 동작) // 플레이어 회피 → recover 페이즈 + 회피 텍스트
CombatEventType.playerEvade => ( CombatEventType.playerEvade => (
BattlePhase.recover, BattlePhase.recover,
false, false, false, false, false, true, false, false, false,
false,
false,
false,
), ),
// 몬스터 회피 → idle 페이즈 + 미스 텍스트
CombatEventType.monsterEvade => ( CombatEventType.monsterEvade => (
BattlePhase.idle, BattlePhase.idle,
false, false, false, false, false, false, true, false, false,
false,
false,
false,
), ),
// 회복/버프 → idle 페이즈 유지 // 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => ( CombatEventType.playerHeal => (
BattlePhase.idle, BattlePhase.idle,
false, false, false, false, false, false, false, false, false,
false,
false,
false,
), ),
CombatEventType.playerBuff => ( CombatEventType.playerBuff => (
BattlePhase.idle, BattlePhase.idle,
false, false, false, false, false, false, false, false, false,
false,
false,
false,
), ),
// 디버프 적용 → idle 페이즈 유지 // 디버프 적용 → idle 페이즈 + 디버프 텍스트
CombatEventType.playerDebuff => ( CombatEventType.playerDebuff => (
BattlePhase.idle, BattlePhase.idle,
false, false, false, false, false, false, false, true, false,
false,
false,
false,
), ),
// DOT 틱 → attack 페이즈 (지속 피해) // DOT 틱 → attack 페이즈 + DOT 텍스트
CombatEventType.dotTick => ( CombatEventType.dotTick => (
BattlePhase.attack, BattlePhase.attack,
false, false, false, false, false, false, false, false, true,
false,
false,
false,
), ),
// 물약 사용 → idle 페이즈 유지 // 물약 사용 → idle 페이즈 유지
CombatEventType.playerPotion => ( CombatEventType.playerPotion => (
BattlePhase.idle, BattlePhase.idle,
false, false, false, false, false, false, false, false, false,
false,
false,
false,
), ),
// 물약 드랍 → idle 페이즈 유지 // 물약 드랍 → idle 페이즈 유지
CombatEventType.potionDrop => ( CombatEventType.potionDrop => (
BattlePhase.idle, BattlePhase.idle,
false, false, false, false, false, false, false, false, false,
false,
false,
false,
), ),
}; };
@@ -330,6 +316,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_showBlockEffect = isBlock; _showBlockEffect = isBlock;
_showParryEffect = isParry; _showParryEffect = isParry;
_showSkillEffect = isSkill; _showSkillEffect = isSkill;
_showEvadeEffect = isEvade;
_showMissEffect = isMiss;
_showDebuffEffect = isDebuff;
_showDotEffect = isDot;
// 페이즈 인덱스 동기화 // 페이즈 인덱스 동기화
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase); _phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
@@ -454,6 +444,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
monsterCategory: monsterCategory, monsterCategory: monsterCategory,
monsterSize: monsterSize, monsterSize: monsterSize,
raceId: widget.raceId, raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
); );
// 환경 타입 추론 // 환경 타입 추론
@@ -483,6 +474,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_showBlockEffect = false; _showBlockEffect = false;
_showParryEffect = false; _showParryEffect = false;
_showSkillEffect = false; _showSkillEffect = false;
_showEvadeEffect = false;
_showMissEffect = false;
_showDebuffEffect = false;
_showDotEffect = false;
// 공격자 타입 및 이벤트 기반 페이즈 리셋 (idle 페이즈 진입 시에만) // 공격자 타입 및 이벤트 기반 페이즈 리셋 (idle 페이즈 진입 시에만)
// 공격 사이클(prepare→attack→hit→recover) 동안 유지 (Bug fix) // 공격 사이클(prepare→attack→hit→recover) 동안 유지 (Bug fix)
if (_battlePhaseSequence[_phaseIndex].$1 == BattlePhase.idle) { if (_battlePhaseSequence[_phaseIndex].$1 == BattlePhase.idle) {
@@ -513,6 +508,13 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_environment, _environment,
_globalTick, _globalTick,
attacker: _currentAttacker, attacker: _currentAttacker,
isCritical: _showCriticalEffect,
isEvade: _showEvadeEffect,
isMiss: _showMissEffect,
isDebuff: _showDebuffEffect,
isDot: _showDotEffect,
isBlock: _showBlockEffect,
isParry: _showParryEffect,
) ?? ) ??
[AsciiLayer.empty()], [AsciiLayer.empty()],
AnimationMode.walking => AnimationMode.walking =>

View File

@@ -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_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.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/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'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 모바일용 확장 애니메이션 패널 /// 모바일용 확장 애니메이션 패널
@@ -29,8 +31,10 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.shieldName, this.shieldName,
this.characterLevel, this.characterLevel,
this.monsterLevel, this.monsterLevel,
this.monsterGrade,
this.latestCombatEvent, this.latestCombatEvent,
this.raceId, this.raceId,
this.weaponRarity,
}); });
final ProgressState progress; final ProgressState progress;
@@ -45,11 +49,17 @@ class EnhancedAnimationPanel extends StatefulWidget {
final String? shieldName; final String? shieldName;
final int? characterLevel; final int? characterLevel;
final int? monsterLevel; final int? monsterLevel;
/// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용
final MonsterGrade? monsterGrade;
final CombatEvent? latestCombatEvent; final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId; final String? raceId;
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
@override @override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState(); State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
} }
@@ -185,9 +195,11 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
shieldName: widget.shieldName, shieldName: widget.shieldName,
characterLevel: widget.characterLevel, characterLevel: widget.characterLevel,
monsterLevel: widget.monsterLevel, monsterLevel: widget.monsterLevel,
monsterGrade: widget.monsterGrade,
isPaused: widget.isPaused, isPaused: widget.isPaused,
latestCombatEvent: widget.latestCombatEvent, latestCombatEvent: widget.latestCombatEvent,
raceId: widget.raceId, raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
), ),
), ),
@@ -630,11 +642,34 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
? (task.position / task.max).clamp(0.0, 1.0) ? (task.position / task.max).clamp(0.0, 1.0)
: 0.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( return Column(
children: [ children: [
// 캡션 // 캡션 (등급에 따른 접두사 및 색상)
Text( Text.rich(
_getStatusMessage(), 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, style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,

View File

@@ -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/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/model/combat_event.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/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 상단 패널: ASCII 애니메이션 + Task Progress 바 /// 상단 패널: ASCII 애니메이션 + Task Progress 바
@@ -23,6 +24,7 @@ class TaskProgressPanel extends StatelessWidget {
this.shieldName, this.shieldName,
this.characterLevel, this.characterLevel,
this.monsterLevel, this.monsterLevel,
this.monsterGrade,
this.latestCombatEvent, this.latestCombatEvent,
this.raceId, this.raceId,
}); });
@@ -44,6 +46,9 @@ class TaskProgressPanel extends StatelessWidget {
final int? characterLevel; final int? characterLevel;
final int? monsterLevel; final int? monsterLevel;
/// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용
final MonsterGrade? monsterGrade;
/// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5) /// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5)
final CombatEvent? latestCombatEvent; final CombatEvent? latestCombatEvent;
@@ -74,6 +79,7 @@ class TaskProgressPanel extends StatelessWidget {
shieldName: shieldName, shieldName: shieldName,
characterLevel: characterLevel, characterLevel: characterLevel,
monsterLevel: monsterLevel, monsterLevel: monsterLevel,
monsterGrade: monsterGrade,
isPaused: isPaused, isPaused: isPaused,
latestCombatEvent: latestCombatEvent, latestCombatEvent: latestCombatEvent,
raceId: raceId, raceId: raceId,
@@ -87,11 +93,7 @@ class TaskProgressPanel extends StatelessWidget {
_buildPauseButton(context), _buildPauseButton(context),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: _buildStatusMessage(context),
_getStatusMessage(context),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildSpeedButton(context), _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,
);
}
/// 현재 상태에 맞는 메시지 반환 /// 현재 상태에 맞는 메시지 반환
/// ///
/// 특수 애니메이션(부활 등) 중에는 해당 메시지 표시 /// 특수 애니메이션(부활 등) 중에는 해당 메시지 표시