feat(ui): 게임 화면 및 설정 화면 개선

- GamePlayScreen 개선
- GameSessionController 확장
- MobileCarouselLayout 기능 추가
- SettingsScreen 테스트 기능 추가
This commit is contained in:
JiWoong Sul
2026-01-12 20:02:54 +09:00
parent 12f195bed7
commit 1d855b64a2
5 changed files with 275 additions and 32 deletions

View File

@@ -102,6 +102,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 사망/엔딩 상태 추적 (BGM 전환용)
bool _wasDead = false;
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
final Map<String, int> _lastSfxPlayTime = {};
bool _wasComplete = false;
// 사운드 볼륨 상태 (모바일 설정 UI용)
@@ -344,47 +347,53 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_wasInBattleTask = isInBattleTask;
}
/// 전투 이벤트별 SFX 재생 (채널 분리)
/// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
///
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
/// 사운드 충돌을 방지하고 완료를 보장합니다.
/// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다.
void _playCombatEventSfx(CombatEvent event) {
final audio = widget.audioService;
if (audio == null) return;
switch (event.type) {
// 플레이어 채널: 플레이어가 발생시키는 이펙트
case CombatEventType.playerAttack:
audio.playPlayerSfx('attack');
case CombatEventType.playerSkill:
audio.playPlayerSfx('skill');
case CombatEventType.playerHeal:
case CombatEventType.playerPotion:
case CombatEventType.potionDrop:
audio.playPlayerSfx('item');
case CombatEventType.playerBuff:
case CombatEventType.playerDebuff:
audio.playPlayerSfx('skill');
// 사운드 이름 결정
final sfxName = switch (event.type) {
CombatEventType.playerAttack => 'attack',
CombatEventType.playerSkill => 'skill',
CombatEventType.playerHeal => 'item',
CombatEventType.playerPotion => 'item',
CombatEventType.potionDrop => 'item',
CombatEventType.playerBuff => 'skill',
CombatEventType.playerDebuff => 'skill',
CombatEventType.monsterAttack => 'hit',
CombatEventType.playerEvade => 'evade',
CombatEventType.monsterEvade => 'evade',
CombatEventType.playerBlock => 'block',
CombatEventType.playerParry => 'parry',
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
};
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격)
case CombatEventType.monsterAttack:
audio.playMonsterSfx('hit');
if (sfxName == null) return;
// 회피/방어 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');
// 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지)
final now = DateTime.now().millisecondsSinceEpoch;
final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1;
// SFX 없음
case CombatEventType.dotTick:
// DOT 틱은 SFX 없음 (너무 자주 발생)
break;
// 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
final debounceMs = 50 + (speedMultiplier - 1) * 15;
if (now - lastTime < debounceMs) {
return; // 디바운스 기간 내 → 스킵
}
_lastSfxPlayTime[sfxName] = now;
// 채널별 재생
final isMonsterSfx = event.type == CombatEventType.monsterAttack;
if (isMonsterSfx) {
audio.playMonsterSfx(sfxName);
} else {
audio.playPlayerSfx(sfxName);
}
}
@@ -743,6 +752,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
setState(() => _sfxVolume = volume);
widget.audioService?.setSfxVolume(volume);
},
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
// 프론트 화면으로 이동
navigator.popUntil((route) => route.isFirst);
}
},
);
}
@@ -886,6 +903,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
onCheatQuest: () =>
widget.controller.loop?.cheatCompleteQuest(),
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
navigator.popUntil((route) => route.isFirst);
}
},
),
// 사망 오버레이
if (state.isDead && state.deathInfo != null)