diff --git a/lib/data/game_text_l10n.dart b/lib/data/game_text_l10n.dart index 1050f64..9d50959 100644 --- a/lib/data/game_text_l10n.dart +++ b/lib/data/game_text_l10n.dart @@ -435,6 +435,12 @@ String combatBuffActivated(String skillName) { return '$skillName activated!'; } +String combatDebuffApplied(String skillName, String targetName) { + if (isKoreanLocale) return '$skillName → $targetName에 적용!'; + if (isJapaneseLocale) return '$skillName → $targetNameに適用!'; + return '$skillName applied to $targetName!'; +} + String combatDotTick(String skillName, int damage) { if (isKoreanLocale) return '$skillName: $damage 지속 데미지'; if (isJapaneseLocale) return '$skillName: $damage 継続ダメージ'; @@ -1803,6 +1809,18 @@ String get uiSettings { return 'Settings'; } +String get uiStatistics { + if (isKoreanLocale) return '통계'; + if (isJapaneseLocale) return '統計'; + return 'Statistics'; +} + +String get uiHelp { + if (isKoreanLocale) return '도움말'; + if (isJapaneseLocale) return 'ヘルプ'; + return 'Help'; +} + String get uiTheme { if (isKoreanLocale) return '테마'; if (isJapaneseLocale) return 'テーマ'; @@ -1851,6 +1869,12 @@ String get uiSfxVolume { return 'SFX Volume'; } +String get uiSoundOff { + if (isKoreanLocale) return '음소거'; + if (isJapaneseLocale) return 'ミュート'; + return 'Muted'; +} + String get uiAnimationSpeed { if (isKoreanLocale) return '애니메이션 속도'; if (isJapaneseLocale) return 'アニメーション速度'; diff --git a/lib/src/app.dart b/lib/src/app.dart index f784cfa..b508404 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -161,10 +161,12 @@ class _AskiiNeverDieAppState extends State { }, currentThemeMode: _themeMode, onThemeModeChange: _changeThemeMode, + audioService: _audioService, ); } - // 세이브 파일이 없으면 기존 프론트 화면 + // 세이브 파일이 없으면 기존 프론트 화면 (타이틀 BGM 재생) + _audioService.playBgm('title'); return FrontScreen( onNewCharacter: _navigateToNewCharacter, onLoadSave: _loadSave, @@ -238,6 +240,7 @@ class _AskiiNeverDieAppState extends State { MaterialPageRoute( builder: (context) => GamePlayScreen( controller: _controller, + audioService: _audioService, forceCarouselLayout: testMode, currentThemeMode: _themeMode, onThemeModeChange: _changeThemeMode, @@ -252,6 +255,7 @@ class _AskiiNeverDieAppState extends State { MaterialPageRoute( builder: (context) => GamePlayScreen( controller: _controller, + audioService: _audioService, currentThemeMode: _themeMode, onThemeModeChange: _changeThemeMode, ), @@ -298,12 +302,14 @@ class _AutoLoadScreen extends StatefulWidget { required this.onLoadFailed, required this.currentThemeMode, required this.onThemeModeChange, + this.audioService, }); final GameSessionController controller; final VoidCallback onLoadFailed; final ThemeMode currentThemeMode; final void Function(ThemeMode mode) onThemeModeChange; + final AudioService? audioService; @override State<_AutoLoadScreen> createState() => _AutoLoadScreenState(); @@ -313,6 +319,8 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> { @override void initState() { super.initState(); + // 로딩 중에도 타이틀 BGM 재생 + widget.audioService?.playBgm('title'); _autoLoad(); } @@ -327,6 +335,7 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> { MaterialPageRoute( builder: (context) => GamePlayScreen( controller: widget.controller, + audioService: widget.audioService, currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, ), diff --git a/lib/src/core/audio/audio_service.dart b/lib/src/core/audio/audio_service.dart index c2712e4..2251002 100644 --- a/lib/src/core/audio/audio_service.dart +++ b/lib/src/core/audio/audio_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:just_audio/just_audio.dart'; import 'package:askiineverdie/src/core/storage/settings_repository.dart'; @@ -5,6 +6,7 @@ import 'package:askiineverdie/src/core/storage/settings_repository.dart'; /// 게임 오디오 서비스 /// /// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다. +/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다. class AudioService { AudioService({SettingsRepository? settingsRepository}) : _settingsRepository = settingsRepository ?? SettingsRepository(); @@ -28,48 +30,58 @@ class AudioService { // 초기화 여부 bool _initialized = false; + // 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨) + bool _initFailed = false; + /// 서비스 초기화 Future init() async { - if (_initialized) return; + if (_initialized || _initFailed) return; - // 설정에서 볼륨 불러오기 - _bgmVolume = await _settingsRepository.loadBgmVolume(); - _sfxVolume = await _settingsRepository.loadSfxVolume(); + try { + // 설정에서 볼륨 불러오기 + _bgmVolume = await _settingsRepository.loadBgmVolume(); + _sfxVolume = await _settingsRepository.loadSfxVolume(); - // BGM 플레이어 초기화 - _bgmPlayer = AudioPlayer(); - await _bgmPlayer!.setLoopMode(LoopMode.one); - await _bgmPlayer!.setVolume(_bgmVolume); + // BGM 플레이어 초기화 + _bgmPlayer = AudioPlayer(); + await _bgmPlayer!.setLoopMode(LoopMode.one); + await _bgmPlayer!.setVolume(_bgmVolume); - // SFX 플레이어 풀 초기화 - for (var i = 0; i < _maxSfxPlayers; i++) { - final player = AudioPlayer(); - await player.setVolume(_sfxVolume); - _sfxPlayers.add(player); + // SFX 플레이어 풀 초기화 + for (var i = 0; i < _maxSfxPlayers; i++) { + final player = AudioPlayer(); + await player.setVolume(_sfxVolume); + _sfxPlayers.add(player); + } + + _initialized = true; + if (kIsWeb) { + debugPrint('[AudioService] Initialized on Web platform'); + } + } catch (e) { + _initFailed = true; + debugPrint('[AudioService] Init failed (likely WASM): $e'); } - - _initialized = true; } /// BGM 재생 /// /// [name]은 assets/audio/bgm/ 폴더 내 파일명 (확장자 제외) - /// 예: playBgm('battle') → assets/audio/bgm/battle.wav 또는 battle.mp3 + /// 예: playBgm('battle') → assets/audio/bgm/battle.mp3 Future playBgm(String name) async { + if (_initFailed) return; // 초기화 실패 시 무시 if (!_initialized) await init(); + if (_initFailed || !_initialized) return; if (_currentBgm == name) return; // 이미 재생 중 try { _currentBgm = name; - // WAV 먼저 시도, 실패하면 MP3 시도 - try { - await _bgmPlayer!.setAsset('assets/audio/bgm/$name.wav'); - } catch (_) { - await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3'); - } + await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3'); await _bgmPlayer!.play(); + debugPrint('[AudioService] Playing BGM: $name'); } catch (e) { // 파일이 없으면 무시 (개발 중 에셋 미추가 상태) + debugPrint('[AudioService] Failed to play BGM $name: $e'); _currentBgm = null; } } @@ -99,10 +111,13 @@ class AudioService { /// SFX 재생 /// /// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외) - /// 예: playSfx('attack') → assets/audio/sfx/attack.wav 또는 attack.mp3 + /// 예: playSfx('attack') → assets/audio/sfx/attack.mp3 Future playSfx(String name) async { + if (_initFailed) return; // 초기화 실패 시 무시 if (!_initialized) await init(); + if (_initFailed || !_initialized) return; if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함 + if (_sfxPlayers.isEmpty) return; // 사용 가능한 플레이어 찾기 AudioPlayer? availablePlayer; @@ -117,16 +132,12 @@ class AudioService { availablePlayer ??= _sfxPlayers.first; try { - // WAV 먼저 시도, 실패하면 MP3 시도 - try { - await availablePlayer.setAsset('assets/audio/sfx/$name.wav'); - } catch (_) { - await availablePlayer.setAsset('assets/audio/sfx/$name.mp3'); - } + await availablePlayer.setAsset('assets/audio/sfx/$name.mp3'); await availablePlayer.seek(Duration.zero); await availablePlayer.play(); } catch (e) { // 파일이 없으면 무시 + debugPrint('[AudioService] Failed to play SFX $name: $e'); } } @@ -172,6 +183,9 @@ class AudioService { /// BGM 타입 열거형 enum BgmType { + /// 타이틀 화면 BGM + title, + /// 마을/상점 BGM town, diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 570100d..4e19868 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -32,7 +32,10 @@ import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart'; import 'package:askiineverdie/src/features/game/layouts/mobile_carousel_layout.dart'; import 'package:askiineverdie/src/features/settings/settings_screen.dart'; +import 'package:askiineverdie/src/features/game/widgets/statistics_dialog.dart'; +import 'package:askiineverdie/src/features/game/widgets/help_dialog.dart'; import 'package:askiineverdie/src/core/storage/settings_repository.dart'; +import 'package:askiineverdie/src/core/audio/audio_service.dart'; /// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃) /// @@ -41,6 +44,7 @@ class GamePlayScreen extends StatefulWidget { const GamePlayScreen({ super.key, required this.controller, + this.audioService, this.forceCarouselLayout = false, this.forceDesktopLayout = false, this.onThemeModeChange, @@ -49,6 +53,9 @@ class GamePlayScreen extends StatefulWidget { final GameSessionController controller; + /// 오디오 서비스 (BGM/SFX 재생) + final AudioService? audioService; + /// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용 final bool forceCarouselLayout; @@ -89,6 +96,13 @@ class _GamePlayScreenState extends State // 전투 이벤트 추적 (마지막 처리된 이벤트 수) int _lastProcessedEventCount = 0; + // 오디오 상태 추적 + bool _wasInCombat = false; + + // 사운드 볼륨 상태 (모바일 설정 UI용) + double _bgmVolume = 0.7; + double _sfxVolume = 0.8; + void _checkSpecialEvents(GameState state) { // Phase 8: 태스크 변경 시 로그 추가 final currentCaption = state.progress.currentTask.caption; @@ -102,6 +116,9 @@ class _GamePlayScreenState extends State // 전투 이벤트 처리 (Combat Events) _processCombatEvents(state); + // 오디오: 전투 상태 변경 시 BGM 전환 + _updateBgmForCombatState(state); + // 레벨업 감지 if (state.traits.level > _lastLevel && _lastLevel > 0) { _specialAnimation = AsciiAnimationType.levelUp; @@ -110,6 +127,8 @@ class _GamePlayScreenState extends State '${game_l10n.uiLevelUp} Lv.${state.traits.level}', CombatLogType.levelUp, ); + // 오디오: 레벨업 SFX + widget.audioService?.playSfx('level_up'); _resetSpecialAnimationAfterFrame(); // Phase 9: Act 변경 감지 (레벨 기반) @@ -147,6 +166,8 @@ class _GamePlayScreenState extends State CombatLogType.questComplete, ); } + // 오디오: 퀘스트 완료 SFX + widget.audioService?.playSfx('quest_complete'); _resetSpecialAnimationAfterFrame(); } _lastQuestCount = state.progress.questCount; @@ -192,11 +213,74 @@ class _GamePlayScreenState extends State for (final event in newEvents) { final (message, type) = _formatCombatEvent(event); _addCombatLog(message, type); + + // 오디오: 전투 이벤트에 따른 SFX 재생 + _playCombatEventSfx(event); } _lastProcessedEventCount = events.length; } + /// 전투 상태에 따른 BGM 전환 + void _updateBgmForCombatState(GameState state) { + final audio = widget.audioService; + if (audio == null) return; + + final combat = state.progress.currentCombat; + final isInCombat = combat != null && combat.isActive; + + if (isInCombat && !_wasInCombat) { + // 전투 시작: 보스 여부에 따라 BGM 선택 + // 몬스터 레벨이 플레이어보다 5 이상 높으면 보스로 간주 + final monsterLevel = state.progress.currentTask.monsterLevel ?? 0; + final playerLevel = state.traits.level; + final isBoss = monsterLevel >= playerLevel + 5; + + if (isBoss) { + audio.playBgm('boss'); + } else { + audio.playBgm('battle'); + } + } else if (!isInCombat && _wasInCombat) { + // 전투 종료: 마을 BGM으로 복귀 + audio.playBgm('town'); + } + + _wasInCombat = isInCombat; + } + + /// 전투 이벤트에 따른 SFX 재생 + void _playCombatEventSfx(CombatEvent event) { + final audio = widget.audioService; + if (audio == null) return; + + switch (event.type) { + case CombatEventType.playerAttack: + audio.playSfx('attack'); + case CombatEventType.monsterAttack: + audio.playSfx('hit'); + case CombatEventType.playerSkill: + audio.playSfx('skill'); + case CombatEventType.playerHeal: + case CombatEventType.playerPotion: + audio.playSfx('item'); + case CombatEventType.potionDrop: + audio.playSfx('item'); + case CombatEventType.playerBuff: + case CombatEventType.playerDebuff: + audio.playSfx('skill'); + case CombatEventType.dotTick: + // DOT 틱은 SFX 없음 (너무 자주 발생) + break; + case CombatEventType.playerEvade: + case CombatEventType.monsterEvade: + case CombatEventType.playerBlock: + case CombatEventType.playerParry: + // 회피/방어는 별도 SFX 없음 + break; + } + } + /// 전투 이벤트를 메시지와 타입으로 변환 (String, CombatLogType) _formatCombatEvent(CombatEvent event) { final target = event.targetName ?? ''; @@ -256,6 +340,10 @@ class _GamePlayScreenState extends State game_l10n.combatBuffActivated(skillName), CombatLogType.buff, ), + CombatEventType.playerDebuff => ( + game_l10n.combatDebuffApplied(skillName, target), + CombatLogType.debuff, + ), CombatEventType.dotTick => ( game_l10n.combatDotTick(skillName, event.damage), CombatLogType.dotTick, @@ -361,6 +449,29 @@ class _GamePlayScreenState extends State _lastQuestCount = state.progress.questCount; _lastPlotStageCount = state.progress.plotStageCount; _lastAct = getActForLevel(state.traits.level); + + // 초기 전투 상태 확인 + final combat = state.progress.currentCombat; + _wasInCombat = combat != null && combat.isActive; + } + + // 누적 통계 로드 + widget.controller.loadCumulativeStats(); + + // 초기 BGM 재생 (마을 테마) + widget.audioService?.playBgm('town'); + + // 오디오 볼륨 초기화 + _initAudioVolumes(); + } + + /// 오디오 볼륨 초기화 (설정에서 로드) + Future _initAudioVolumes() async { + final audio = widget.audioService; + if (audio != null) { + _bgmVolume = audio.bgmVolume; + _sfxVolume = audio.sfxVolume; + if (mounted) setState(() {}); } } @@ -465,6 +576,15 @@ class _GamePlayScreenState extends State return platform == TargetPlatform.iOS || platform == TargetPlatform.android; } + /// 통계 다이얼로그 표시 + void _showStatisticsDialog(BuildContext context) { + StatisticsDialog.show( + context, + session: widget.controller.sessionStats, + cumulative: widget.controller.cumulativeStats, + ); + } + /// 설정 화면 표시 void _showSettingsScreen(BuildContext context) { final settingsRepo = SettingsRepository(); @@ -614,6 +734,17 @@ class _GamePlayScreenState extends State }, currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, + // 사운드 설정 + bgmVolume: _bgmVolume, + sfxVolume: _sfxVolume, + onBgmVolumeChange: (volume) { + setState(() => _bgmVolume = volume); + widget.audioService?.setBgmVolume(volume); + }, + onSfxVolumeChange: (volume) { + setState(() => _sfxVolume = volume); + widget.audioService?.setSfxVolume(volume); + }, ), // 사망 오버레이 if (state.isDead && state.deathInfo != null) @@ -666,6 +797,18 @@ class _GamePlayScreenState extends State onPressed: () => widget.controller.loop?.cheatCompletePlot(), ), ], + // 통계 버튼 + IconButton( + icon: const Icon(Icons.bar_chart), + tooltip: game_l10n.uiStatistics, + onPressed: () => _showStatisticsDialog(context), + ), + // 도움말 버튼 + IconButton( + icon: const Icon(Icons.help_outline), + tooltip: game_l10n.uiHelp, + onPressed: () => HelpDialog.show(context), + ), // 설정 버튼 IconButton( icon: const Icon(Icons.settings), diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index 4f7fcdb..24f6645 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -5,7 +5,9 @@ import 'package:askiineverdie/src/core/engine/progress_service.dart'; import 'package:askiineverdie/src/core/engine/resurrection_service.dart'; import 'package:askiineverdie/src/core/engine/shop_service.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/game_statistics.dart'; import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:askiineverdie/src/core/storage/statistics_storage.dart'; import 'package:flutter/foundation.dart'; enum GameSessionStatus { idle, loading, running, error, dead } @@ -18,12 +20,15 @@ class GameSessionController extends ChangeNotifier { this.autoSaveConfig = const AutoSaveConfig(), Duration tickInterval = const Duration(milliseconds: 50), DateTime Function()? now, + StatisticsStorage? statisticsStorage, }) : _tickInterval = tickInterval, - _now = now ?? DateTime.now; + _now = now ?? DateTime.now, + _statisticsStorage = statisticsStorage ?? StatisticsStorage(); final ProgressService progressService; final SaveManager saveManager; final AutoSaveConfig autoSaveConfig; + final StatisticsStorage _statisticsStorage; final Duration _tickInterval; final DateTime Function() _now; @@ -36,12 +41,26 @@ class GameSessionController extends ChangeNotifier { GameState? _state; String? _error; + // 통계 관련 필드 + SessionStatistics _sessionStats = SessionStatistics.empty(); + CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty(); + int _previousLevel = 0; + int _previousGold = 0; + int _previousMonstersKilled = 0; + int _previousQuestsCompleted = 0; + GameSessionStatus get status => _status; GameState? get state => _state; String? get error => _error; bool get isRunning => _status == GameSessionStatus.running; bool get cheatsEnabled => _cheatsEnabled; + /// 현재 세션 통계 + SessionStatistics get sessionStats => _sessionStats; + + /// 누적 통계 + CumulativeStatistics get cumulativeStats => _cumulativeStats; + /// 현재 ProgressLoop 인스턴스 (치트 기능용) ProgressLoop? get loop => _loop; @@ -62,6 +81,13 @@ class GameSessionController extends ChangeNotifier { _status = GameSessionStatus.running; _cheatsEnabled = cheatsEnabled; + // 통계 초기화 + if (isNewGame) { + _sessionStats = SessionStatistics.empty(); + await _statisticsStorage.recordGameStart(); + } + _initPreviousValues(state); + _loop = ProgressLoop( initialState: state, progressService: progressService, @@ -74,6 +100,7 @@ class GameSessionController extends ChangeNotifier { ); _subscription = _loop!.stream.listen((next) { + _updateStatistics(next); _state = next; notifyListeners(); }); @@ -82,6 +109,76 @@ class GameSessionController extends ChangeNotifier { notifyListeners(); } + /// 이전 값 초기화 (통계 변화 추적용) + void _initPreviousValues(GameState state) { + _previousLevel = state.traits.level; + _previousGold = state.inventory.gold; + _previousMonstersKilled = state.progress.monstersKilled; + _previousQuestsCompleted = state.progress.questCount; + } + + /// 상태 변화에 따른 통계 업데이트 + void _updateStatistics(GameState next) { + // 플레이 시간 업데이트 + _sessionStats = _sessionStats.updatePlayTime(next.skillSystem.elapsedMs); + + // 레벨업 감지 + if (next.traits.level > _previousLevel) { + final levelUps = next.traits.level - _previousLevel; + for (var i = 0; i < levelUps; i++) { + _sessionStats = _sessionStats.recordLevelUp(); + } + _previousLevel = next.traits.level; + + // 최고 레벨 업데이트 + unawaited(_statisticsStorage.updateHighestLevel(next.traits.level)); + } + + // 골드 변화 감지 + if (next.inventory.gold > _previousGold) { + final earned = next.inventory.gold - _previousGold; + _sessionStats = _sessionStats.recordGoldEarned(earned); + + // 최대 골드 업데이트 + unawaited(_statisticsStorage.updateHighestGold(next.inventory.gold)); + } else if (next.inventory.gold < _previousGold) { + final spent = _previousGold - next.inventory.gold; + _sessionStats = _sessionStats.recordGoldSpent(spent); + } + _previousGold = next.inventory.gold; + + // 몬스터 처치 감지 + if (next.progress.monstersKilled > _previousMonstersKilled) { + final kills = next.progress.monstersKilled - _previousMonstersKilled; + for (var i = 0; i < kills; i++) { + _sessionStats = _sessionStats.recordKill(); + } + _previousMonstersKilled = next.progress.monstersKilled; + } + + // 퀘스트 완료 감지 + if (next.progress.questCount > _previousQuestsCompleted) { + final quests = next.progress.questCount - _previousQuestsCompleted; + for (var i = 0; i < quests; i++) { + _sessionStats = _sessionStats.recordQuestComplete(); + } + _previousQuestsCompleted = next.progress.questCount; + } + } + + /// 누적 통계 로드 + Future loadCumulativeStats() async { + _cumulativeStats = await _statisticsStorage.loadCumulative(); + notifyListeners(); + } + + /// 세션 통계를 누적 통계에 병합 + Future mergeSessionStats() async { + await _statisticsStorage.mergeSession(_sessionStats); + _cumulativeStats = await _statisticsStorage.loadCumulative(); + notifyListeners(); + } + Future loadAndStart({ String? fileName, bool cheatsEnabled = false, @@ -148,6 +245,7 @@ class GameSessionController extends ChangeNotifier { /// 플레이어 사망 콜백 (ProgressLoop에서 호출) void _onPlayerDied() { + _sessionStats = _sessionStats.recordDeath(); _status = GameSessionStatus.dead; notifyListeners(); } diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 80e132b..1716deb 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -39,6 +39,10 @@ class MobileCarouselLayout extends StatefulWidget { this.specialAnimation, this.currentThemeMode = ThemeMode.system, this.onThemeModeChange, + this.bgmVolume = 0.7, + this.sfxVolume = 0.8, + this.onBgmVolumeChange, + this.onSfxVolumeChange, }); final GameState state; @@ -56,6 +60,18 @@ class MobileCarouselLayout extends StatefulWidget { final ThemeMode currentThemeMode; final void Function(ThemeMode mode)? onThemeModeChange; + /// BGM 볼륨 (0.0 ~ 1.0) + final double bgmVolume; + + /// SFX 볼륨 (0.0 ~ 1.0) + final double sfxVolume; + + /// BGM 볼륨 변경 콜백 + final void Function(double volume)? onBgmVolumeChange; + + /// SFX 볼륨 변경 콜백 + final void Function(double volume)? onSfxVolumeChange; + @override State createState() => _MobileCarouselLayoutState(); } @@ -200,6 +216,108 @@ class _MobileCarouselLayoutState extends State { ); } + /// 사운드 상태 텍스트 가져오기 + String _getSoundStatus() { + final bgmPercent = (widget.bgmVolume * 100).round(); + final sfxPercent = (widget.sfxVolume * 100).round(); + if (bgmPercent == 0 && sfxPercent == 0) { + return l10n.uiSoundOff; + } + return 'BGM $bgmPercent% / SFX $sfxPercent%'; + } + + /// 사운드 설정 다이얼로그 표시 + void _showSoundDialog(BuildContext context) { + // StatefulBuilder를 사용하여 다이얼로그 내 상태 관리 + var bgmVolume = widget.bgmVolume; + var sfxVolume = widget.sfxVolume; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(l10n.uiSound), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // BGM 볼륨 + Row( + children: [ + Icon( + bgmVolume == 0 ? Icons.music_off : Icons.music_note, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.uiBgmVolume), + Text('${(bgmVolume * 100).round()}%'), + ], + ), + Slider( + value: bgmVolume, + onChanged: (value) { + setDialogState(() => bgmVolume = value); + widget.onBgmVolumeChange?.call(value); + }, + divisions: 10, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + // SFX 볼륨 + Row( + children: [ + Icon( + sfxVolume == 0 ? Icons.volume_off : Icons.volume_up, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.uiSfxVolume), + Text('${(sfxVolume * 100).round()}%'), + ], + ), + Slider( + value: sfxVolume, + onChanged: (value) { + setDialogState(() => sfxVolume = value); + widget.onSfxVolumeChange?.call(value); + }, + divisions: 10, + ), + ], + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.buttonConfirm), + ), + ], + ), + ), + ); + } + /// 세이브 삭제 확인 다이얼로그 표시 void _showDeleteConfirmDialog(BuildContext context) { showDialog( @@ -324,6 +442,27 @@ class _MobileCarouselLayoutState extends State { }, ), + // 사운드 설정 + if (widget.onBgmVolumeChange != null || + widget.onSfxVolumeChange != null) + ListTile( + leading: Icon( + widget.bgmVolume == 0 && widget.sfxVolume == 0 + ? Icons.volume_off + : Icons.volume_up, + color: Colors.indigo, + ), + title: Text(l10n.uiSound), + trailing: Text( + _getSoundStatus(), + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + onTap: () { + Navigator.pop(context); + _showSoundDialog(context); + }, + ), + const Divider(), // 저장 @@ -381,7 +520,7 @@ class _MobileCarouselLayoutState extends State { actions: [ // 옵션 버튼 IconButton( - icon: const Icon(Icons.more_vert), + icon: const Icon(Icons.settings), onPressed: () => _showOptionsMenu(context), tooltip: l10n.menuOptions, ), diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 5f00481..d45b946 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -285,6 +285,15 @@ class _AsciiAnimationCardState extends State { false, ), + // 디버프 적용 → idle 페이즈 유지 + CombatEventType.playerDebuff => ( + BattlePhase.idle, + false, + false, + false, + false, + ), + // DOT 틱 → attack 페이즈 (지속 피해) CombatEventType.dotTick => ( BattlePhase.attack, diff --git a/lib/src/features/game/widgets/combat_log.dart b/lib/src/features/game/widgets/combat_log.dart index cfa9eaa..66b7e7e 100644 --- a/lib/src/features/game/widgets/combat_log.dart +++ b/lib/src/features/game/widgets/combat_log.dart @@ -28,6 +28,7 @@ enum CombatLogType { parry, // 무기 쳐내기 monsterAttack, // 몬스터 공격 buff, // 버프 활성화 + debuff, // 디버프 적용 dotTick, // DOT 틱 데미지 potion, // 물약 사용 potionDrop, // 물약 드랍 @@ -166,6 +167,7 @@ class _LogEntryTile extends StatelessWidget { Icons.dangerous, ), CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up), + CombatLogType.debuff => (Colors.deepOrange.shade300, Icons.trending_down), CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot), CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink), CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard), diff --git a/lib/src/features/game/widgets/death_overlay.dart b/lib/src/features/game/widgets/death_overlay.dart index b34bce9..e02d851 100644 --- a/lib/src/features/game/widgets/death_overlay.dart +++ b/lib/src/features/game/widgets/death_overlay.dart @@ -429,6 +429,11 @@ class DeathOverlay extends StatelessWidget { Colors.lightBlue.shade300, l10n.combatBuffActivated(event.skillName ?? ''), ), + CombatEventType.playerDebuff => ( + Icons.trending_down, + Colors.deepOrange.shade300, + l10n.combatDebuffApplied(event.skillName ?? '', target), + ), CombatEventType.dotTick => ( Icons.whatshot, Colors.deepOrange.shade300, diff --git a/lib/src/features/game/widgets/help_dialog.dart b/lib/src/features/game/widgets/help_dialog.dart new file mode 100644 index 0000000..fddc356 --- /dev/null +++ b/lib/src/features/game/widgets/help_dialog.dart @@ -0,0 +1,553 @@ +import 'package:flutter/material.dart'; + +/// 도움말 다이얼로그 (Help Dialog) +/// +/// 게임 메카닉과 UI 설명을 제공 +class HelpDialog extends StatefulWidget { + const HelpDialog({super.key}); + + /// 다이얼로그 표시 + static Future show(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const HelpDialog(), + ); + } + + @override + State createState() => _HelpDialogState(); +} + +class _HelpDialogState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isKorean = Localizations.localeOf(context).languageCode == 'ko'; + final isJapanese = Localizations.localeOf(context).languageCode == 'ja'; + + return Dialog( + child: Container( + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 600), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 헤더 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(28), + ), + ), + child: Row( + children: [ + Icon( + Icons.help_outline, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isKorean + ? '게임 도움말' + : isJapanese + ? 'ゲームヘルプ' + : 'Game Help', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + color: theme.colorScheme.onPrimaryContainer, + ), + ], + ), + ), + // 탭 바 + TabBar( + controller: _tabController, + isScrollable: true, + tabs: [ + Tab( + text: isKorean + ? '기본' + : isJapanese + ? '基本' + : 'Basics', + ), + Tab( + text: isKorean + ? '전투' + : isJapanese + ? '戦闘' + : 'Combat', + ), + Tab( + text: isKorean + ? '스킬' + : isJapanese + ? 'スキル' + : 'Skills', + ), + Tab( + text: isKorean + ? 'UI' + : isJapanese + ? 'UI' + : 'UI', + ), + ], + ), + // 탭 내용 + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _BasicsHelpView( + isKorean: isKorean, + isJapanese: isJapanese, + ), + _CombatHelpView( + isKorean: isKorean, + isJapanese: isJapanese, + ), + _SkillsHelpView( + isKorean: isKorean, + isJapanese: isJapanese, + ), + _UIHelpView( + isKorean: isKorean, + isJapanese: isJapanese, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 기본 도움말 뷰 +class _BasicsHelpView extends StatelessWidget { + const _BasicsHelpView({ + required this.isKorean, + required this.isJapanese, + }); + + final bool isKorean; + final bool isJapanese; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _HelpSection( + icon: Icons.info_outline, + title: isKorean + ? '게임 소개' + : isJapanese + ? 'ゲーム紹介' + : 'About the Game', + content: isKorean + ? 'Askii Never Die는 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, ' + '퀘스트를 완료하며, 레벨업합니다. 여러분은 장비와 스킬을 관리하면 됩니다.' + : isJapanese + ? 'Askii Never Dieは自動進行RPGです。キャラクターが自動でモンスターと戦い、' + 'クエストを完了し、レベルアップします。装備とスキルの管理だけで大丈夫です。' + : 'Askii Never Die is an idle RPG. Your character automatically fights monsters, ' + 'completes quests, and levels up. You manage equipment and skills.', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.trending_up, + title: isKorean + ? '진행 방식' + : isJapanese + ? '進行方式' + : 'Progression', + content: isKorean + ? '• 몬스터 처치 → 전리품 획득 → 장비 업그레이드\n' + '• 경험치 획득 → 레벨업 → 스탯 상승\n' + '• 퀘스트 완료 → 보상 획득\n' + '• 플롯 진행 → 새로운 Act 해금' + : isJapanese + ? '• モンスター討伐 → 戦利品獲得 → 装備アップグレード\n' + '• 経験値獲得 → レベルアップ → ステータス上昇\n' + '• クエスト完了 → 報酬獲得\n' + '• プロット進行 → 新しいAct解放' + : '• Kill monsters → Get loot → Upgrade equipment\n' + '• Gain XP → Level up → Stats increase\n' + '• Complete quests → Get rewards\n' + '• Progress plot → Unlock new Acts', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.save, + title: isKorean + ? '저장' + : isJapanese + ? 'セーブ' + : 'Saving', + content: isKorean + ? '게임은 자동으로 저장됩니다. 레벨업, 퀘스트 완료, Act 진행 시 자동 저장됩니다. ' + '뒤로 가기 시 저장 여부를 선택할 수 있습니다.' + : isJapanese + ? 'ゲームは自動保存されます。レベルアップ、クエスト完了、Act進行時に自動保存されます。' + '戻る時に保存するかどうか選択できます。' + : 'The game auto-saves. It saves on level up, quest completion, and Act progression. ' + 'When exiting, you can choose whether to save.', + ), + ], + ); + } +} + +/// 전투 도움말 뷰 +class _CombatHelpView extends StatelessWidget { + const _CombatHelpView({ + required this.isKorean, + required this.isJapanese, + }); + + final bool isKorean; + final bool isJapanese; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _HelpSection( + icon: Icons.sports_mma, + title: isKorean + ? '전투 시스템' + : isJapanese + ? '戦闘システム' + : 'Combat System', + content: isKorean + ? '전투는 자동으로 진행됩니다. 플레이어와 몬스터가 번갈아 공격하며, ' + '공격 속도(Attack Speed)에 따라 공격 빈도가 결정됩니다.' + : isJapanese + ? '戦闘は自動で進行します。プレイヤーとモンスターが交互に攻撃し、' + '攻撃速度(Attack Speed)によって攻撃頻度が決まります。' + : 'Combat is automatic. Player and monster take turns attacking, ' + 'with attack frequency based on Attack Speed.', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.shield, + title: isKorean + ? '방어 메카닉' + : isJapanese + ? '防御メカニック' + : 'Defense Mechanics', + content: isKorean + ? '• 회피(Evasion): DEX 기반, 공격을 완전히 피함\n' + '• 방패 방어(Block): 방패 장착 시, 피해 감소\n' + '• 무기 쳐내기(Parry): 무기로 공격 일부 막음\n' + '• 방어력(DEF): 모든 피해에서 차감' + : isJapanese + ? '• 回避(Evasion): DEX基準、攻撃を完全に回避\n' + '• 盾防御(Block): 盾装備時、ダメージ軽減\n' + '• 武器受け流し(Parry): 武器で攻撃を一部防ぐ\n' + '• 防御力(DEF): 全ダメージから差し引き' + : '• Evasion: DEX-based, completely avoid attacks\n' + '• Block: With shield, reduce damage\n' + '• Parry: Deflect some damage with weapon\n' + '• DEF: Subtracted from all damage', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.favorite, + title: isKorean + ? '사망과 부활' + : isJapanese + ? '死亡と復活' + : 'Death & Revival', + content: isKorean + ? 'HP가 0이 되면 사망합니다. 사망 시 장비 하나를 제물로 바쳐 부활할 수 있습니다. ' + '부활 후 HP/MP가 완전 회복되고 빈 장비 슬롯에 기본 장비가 지급됩니다.' + : isJapanese + ? 'HPが0になると死亡します。死亡時に装備1つを捧げて復活できます。' + '復活後HP/MPが完全回復し、空の装備スロットに基本装備が支給されます。' + : 'You die when HP reaches 0. Sacrifice one equipment piece to revive. ' + 'After revival, HP/MP fully restore and empty slots get basic equipment.', + ), + ], + ); + } +} + +/// 스킬 도움말 뷰 +class _SkillsHelpView extends StatelessWidget { + const _SkillsHelpView({ + required this.isKorean, + required this.isJapanese, + }); + + final bool isKorean; + final bool isJapanese; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _HelpSection( + icon: Icons.auto_awesome, + title: isKorean + ? '스킬 종류' + : isJapanese + ? 'スキル種類' + : 'Skill Types', + content: isKorean + ? '• 공격(Attack): 적에게 직접 피해\n' + '• 회복(Heal): HP/MP 회복\n' + '• 버프(Buff): 자신에게 유리한 효과\n' + '• 디버프(Debuff): 적에게 불리한 효과\n' + '• DOT: 시간에 걸쳐 지속 피해' + : isJapanese + ? '• 攻撃(Attack): 敵に直接ダメージ\n' + '• 回復(Heal): HP/MP回復\n' + '• バフ(Buff): 自分に有利な効果\n' + '• デバフ(Debuff): 敵に不利な効果\n' + '• DOT: 時間経過でダメージ' + : '• Attack: Deal direct damage\n' + '• Heal: Restore HP/MP\n' + '• Buff: Beneficial effects on self\n' + '• Debuff: Harmful effects on enemies\n' + '• DOT: Damage over time', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.psychology, + title: isKorean + ? '자동 스킬 선택' + : isJapanese + ? '自動スキル選択' + : 'Auto Skill Selection', + content: isKorean + ? '스킬은 AI가 자동으로 선택합니다:\n' + '1. HP 낮음 → 회복 스킬 우선\n' + '2. HP/MP 충분 → 버프 스킬 사용\n' + '3. 몬스터 HP 높음 → 디버프 적용\n' + '4. 공격 스킬로 마무리' + : isJapanese + ? 'スキルはAIが自動選択します:\n' + '1. HP低い → 回復スキル優先\n' + '2. HP/MP十分 → バフスキル使用\n' + '3. モンスターHP高い → デバフ適用\n' + '4. 攻撃スキルで仕上げ' + : 'Skills are auto-selected by AI:\n' + '1. Low HP → Heal skills priority\n' + '2. HP/MP sufficient → Use buff skills\n' + '3. Monster HP high → Apply debuffs\n' + '4. Finish with attack skills', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.upgrade, + title: isKorean + ? '스킬 랭크' + : isJapanese + ? 'スキルランク' + : 'Skill Ranks', + content: isKorean + ? '스킬은 I ~ IX 랭크가 있습니다. 랭크가 높을수록:\n' + '• 데미지/회복량 증가\n' + '• MP 소모량 증가\n' + '• 쿨타임 증가\n' + '레벨업 시 랜덤하게 스킬을 배웁니다.' + : isJapanese + ? 'スキルにはI~IXランクがあります。ランクが高いほど:\n' + '• ダメージ/回復量増加\n' + '• MP消費量増加\n' + '• クールタイム増加\n' + 'レベルアップ時にランダムでスキルを習得します。' + : 'Skills have ranks I~IX. Higher rank means:\n' + '• More damage/healing\n' + '• More MP cost\n' + '• Longer cooldown\n' + 'Learn random skills on level up.', + ), + ], + ); + } +} + +/// UI 도움말 뷰 +class _UIHelpView extends StatelessWidget { + const _UIHelpView({ + required this.isKorean, + required this.isJapanese, + }); + + final bool isKorean; + final bool isJapanese; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _HelpSection( + icon: Icons.view_column, + title: isKorean + ? '화면 구성' + : isJapanese + ? '画面構成' + : 'Screen Layout', + content: isKorean + ? '• 상단: 전투 애니메이션, 태스크 진행바\n' + '• 좌측: 캐릭터 정보, HP/MP, 스탯\n' + '• 중앙: 장비, 인벤토리\n' + '• 우측: 플롯/퀘스트 진행, 스펠북' + : isJapanese + ? '• 上部: 戦闘アニメーション、タスク進行バー\n' + '• 左側: キャラクター情報、HP/MP、ステータス\n' + '• 中央: 装備、インベントリ\n' + '• 右側: プロット/クエスト進行、スペルブック' + : '• Top: Combat animation, task progress bar\n' + '• Left: Character info, HP/MP, stats\n' + '• Center: Equipment, inventory\n' + '• Right: Plot/quest progress, spellbook', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.speed, + title: isKorean + ? '속도 조절' + : isJapanese + ? '速度調整' + : 'Speed Control', + content: isKorean + ? '태스크 진행바 옆 속도 버튼으로 게임 속도를 조절할 수 있습니다:\n' + '• 1x: 기본 속도\n' + '• 2x: 2배 속도\n' + '• 5x: 5배 속도\n' + '• 10x: 10배 속도' + : isJapanese + ? 'タスク進行バー横の速度ボタンでゲーム速度を調整できます:\n' + '• 1x: 基本速度\n' + '• 2x: 2倍速\n' + '• 5x: 5倍速\n' + '• 10x: 10倍速' + : 'Use the speed button next to task bar to adjust game speed:\n' + '• 1x: Normal speed\n' + '• 2x: 2x speed\n' + '• 5x: 5x speed\n' + '• 10x: 10x speed', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.pause, + title: isKorean + ? '일시정지' + : isJapanese + ? '一時停止' + : 'Pause', + content: isKorean + ? '일시정지 버튼으로 게임을 멈출 수 있습니다. ' + '일시정지 중에도 UI를 확인하고 설정을 변경할 수 있습니다.' + : isJapanese + ? '一時停止ボタンでゲームを止められます。' + '一時停止中もUIを確認し設定を変更できます。' + : 'Use the pause button to stop the game. ' + 'You can still view UI and change settings while paused.', + ), + const SizedBox(height: 16), + _HelpSection( + icon: Icons.bar_chart, + title: isKorean + ? '통계' + : isJapanese + ? '統計' + : 'Statistics', + content: isKorean + ? '통계 버튼에서 현재 세션과 누적 게임 통계를 확인할 수 있습니다. ' + '처치한 몬스터, 획득 골드, 플레이 시간 등을 추적합니다.' + : isJapanese + ? '統計ボタンで現在のセッションと累積ゲーム統計を確認できます。' + '倒したモンスター、獲得ゴールド、プレイ時間などを追跡します。' + : 'View current session and cumulative stats in the statistics button. ' + 'Track monsters killed, gold earned, play time, etc.', + ), + ], + ); + } +} + +/// 도움말 섹션 위젯 +class _HelpSection extends StatelessWidget { + const _HelpSection({ + required this.icon, + required this.title, + required this.content, + }); + + final IconData icon; + final String title; + final String content; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 섹션 헤더 + Row( + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + // 내용 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + content, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.5, + ), + ), + ), + ], + ); + } +}