diff --git a/lib/src/app.dart b/lib/src/app.dart index 768ce61..da0b10b 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -61,7 +61,7 @@ class _AskiiNeverDieAppState extends State { super.initState(); const config = PqConfig(); final mutations = GameMutations(config); - final rewards = RewardService(mutations); + final rewards = RewardService(mutations, config); _controller = GameSessionController( progressService: ProgressService( diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 36999e6..e555679 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -102,6 +102,9 @@ class _GamePlayScreenState extends State // 사망/엔딩 상태 추적 (BGM 전환용) bool _wasDead = false; + + // 사운드 디바운스 추적 (배속 시 사운드 누락 방지) + final Map _lastSfxPlayTime = {}; bool _wasComplete = false; // 사운드 볼륨 상태 (모바일 설정 UI용) @@ -344,47 +347,53 @@ class _GamePlayScreenState extends State _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 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 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) diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index dad5e96..76f3cde 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/engine/progress_loop.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart'; import 'package:asciineverdie/src/core/engine/resurrection_service.dart'; import 'package:asciineverdie/src/core/engine/shop_service.dart'; +import 'package:asciineverdie/src/core/engine/test_character_service.dart'; import 'package:asciineverdie/src/core/model/combat_stats.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_statistics.dart'; @@ -367,6 +368,58 @@ class GameSessionController extends ChangeNotifier { } } + /// 테스트 캐릭터 생성 (디버그 모드 전용) + /// + /// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진 + /// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제함. + Future createTestCharacter() async { + if (_state == null) { + debugPrint('[TestCharacter] _state is null'); + return false; + } + + try { + debugPrint('[TestCharacter] Creating test character...'); + + // 게임 일시정지 + await _stopLoop(saveOnStop: false); + + // TestCharacterService로 테스트 캐릭터 생성 + final testService = TestCharacterService( + config: progressService.config, + rng: _state!.rng, + ); + + final entry = testService.createTestCharacter(_state!); + + debugPrint( + '[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}', + ); + + // 명예의 전당에 등록 + final success = await _hallOfFameStorage.addEntry(entry); + debugPrint('[TestCharacter] HallOfFame save result: $success'); + + if (success) { + // 세이브 파일 삭제 + final deleteResult = await saveManager.deleteSave(); + debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}'); + } + + // 상태 초기화 + _state = null; + _status = GameSessionStatus.idle; + notifyListeners(); + + debugPrint('[TestCharacter] Complete'); + return success; + } catch (e, st) { + debugPrint('[TestCharacter] ERROR: $e'); + debugPrint('[TestCharacter] StackTrace: $st'); + return false; + } + } + /// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로) /// /// HP/MP 회복, 빈 슬롯에 장비 자동 구매 diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 0c53037..7def71a 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:asciineverdie/src/core/notification/notification_service.dart'; @@ -50,6 +51,7 @@ class MobileCarouselLayout extends StatefulWidget { this.onCheatTask, this.onCheatQuest, this.onCheatPlot, + this.onCreateTestCharacter, }); final GameState state; @@ -97,6 +99,9 @@ class MobileCarouselLayout extends StatefulWidget { /// 치트: 액트(플롯) 완료 final VoidCallback? onCheatPlot; + /// 테스트 캐릭터 생성 콜백 (디버그 모드 전용) + final Future Function()? onCreateTestCharacter; + @override State createState() => _MobileCarouselLayoutState(); } @@ -364,6 +369,39 @@ class _MobileCarouselLayoutState extends State { ); } + /// 테스트 캐릭터 생성 확인 다이얼로그 + Future _showTestCharacterDialog(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create Test Character?'), + content: const Text( + '현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n' + '⚠️ 현재 세이브 파일이 삭제됩니다.\n' + '이 작업은 되돌릴 수 없습니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: const Text('Create'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + await widget.onCreateTestCharacter?.call(); + } + } + /// 옵션 메뉴 표시 void _showOptionsMenu(BuildContext context) { final localizations = L10n.of(context); @@ -609,6 +647,34 @@ class _MobileCarouselLayoutState extends State { ), ], + // 디버그 도구 섹션 (kDebugMode에서만 표시) + if (kDebugMode && widget.onCreateTestCharacter != null) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'DEBUG TOOLS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: Colors.orange.shade300, + ), + ), + ), + ListTile( + leading: const Icon(Icons.science, color: Colors.orange), + title: const Text('Create Test Character'), + subtitle: const Text('레벨 100 캐릭터를 명예의 전당에 등록'), + onTap: () { + Navigator.pop(context); + _showTestCharacterDialog(context); + }, + ), + ], + const SizedBox(height: 8), ], ), diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart index 65c6425..49ecd23 100644 --- a/lib/src/features/settings/settings_screen.dart +++ b/lib/src/features/settings/settings_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; @@ -15,6 +16,7 @@ class SettingsScreen extends StatefulWidget { this.onLocaleChange, this.onBgmVolumeChange, this.onSfxVolumeChange, + this.onCreateTestCharacter, }); final SettingsRepository settingsRepository; @@ -28,6 +30,11 @@ class SettingsScreen extends StatefulWidget { /// SFX 볼륨 변경 콜백 (AudioService 연동용) final void Function(double volume)? onSfxVolumeChange; + /// 테스트 캐릭터 생성 콜백 (디버그 모드 전용) + /// + /// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제 + final Future Function()? onCreateTestCharacter; + @override State createState() => _SettingsScreenState(); @@ -40,6 +47,7 @@ class SettingsScreen extends StatefulWidget { void Function(String locale)? onLocaleChange, void Function(double volume)? onBgmVolumeChange, void Function(double volume)? onSfxVolumeChange, + Future Function()? onCreateTestCharacter, }) { return showModalBottomSheet( context: context, @@ -57,6 +65,7 @@ class SettingsScreen extends StatefulWidget { onLocaleChange: onLocaleChange, onBgmVolumeChange: onBgmVolumeChange, onSfxVolumeChange: onSfxVolumeChange, + onCreateTestCharacter: onCreateTestCharacter, ), ), ); @@ -180,6 +189,13 @@ class _SettingsScreenState extends State { // 정보 _buildSectionTitle(game_l10n.uiAbout), _buildAboutCard(), + + // 디버그 섹션 (디버그 모드에서만 표시) + if (kDebugMode && widget.onCreateTestCharacter != null) ...[ + const SizedBox(height: 24), + _buildSectionTitle('Debug'), + _buildDebugSection(), + ], ], ), ), @@ -188,6 +204,90 @@ class _SettingsScreenState extends State { ); } + Widget _buildDebugSection() { + return Card( + color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.3), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.bug_report, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Text( + 'Developer Tools', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. ' + '등록 후 현재 세이브 파일이 삭제됩니다.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _handleCreateTestCharacter, + icon: const Icon(Icons.science), + label: const Text('Create Test Character'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + ), + ), + ], + ), + ), + ); + } + + Future _handleCreateTestCharacter() async { + // 확인 다이얼로그 표시 + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create Test Character?'), + content: const Text( + '현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n' + '⚠️ 현재 세이브 파일이 삭제됩니다.\n' + '이 작업은 되돌릴 수 없습니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: const Text('Create'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + await widget.onCreateTestCharacter?.call(); + if (mounted) { + Navigator.of(context).pop(); // 설정 화면 닫기 + } + } + } + Widget _buildSectionTitle(String title) { return Padding( padding: const EdgeInsets.only(bottom: 8),