diff --git a/lib/src/core/engine/act_progression_service.dart b/lib/src/core/engine/act_progression_service.dart new file mode 100644 index 0000000..efa1bb9 --- /dev/null +++ b/lib/src/core/engine/act_progression_service.dart @@ -0,0 +1,262 @@ +import 'dart:math' as math; + +import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; +import 'package:asciineverdie/src/core/model/combat_state.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/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/model/pq_config.dart'; +import 'package:asciineverdie/src/core/util/balance_constants.dart'; +import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic; + +/// Act 진행 관련 로직을 처리하는 서비스 +/// +/// ProgressService에서 추출된 Act 완료, 보스 생성 등의 로직 담당. +class ActProgressionService { + const ActProgressionService({ + required this.config, + }); + + final PqConfig config; + + /// Act 완료 처리 + /// + /// 플롯 진행, Act Boss 시네마틱 후 호출. + /// Returns gameComplete=true if Final Boss was defeated (game ends). + ({GameState state, bool gameComplete}) completeAct(GameState state) { + // Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작 + // plotStageCount: 1=Prologue, 2=Act I, 3=Act II, 4=Act III, 5=Act IV, 6=Act V + if (state.progress.plotStageCount >= 6) { + // 이미 최종 보스가 처치되었으면 게임 클리어 + if (state.progress.finalBossState == FinalBossState.defeated) { + final updatedPlotHistory = [ + ...state.progress.plotHistory.map( + (e) => e.isComplete ? e : e.copyWith(isComplete: true), + ), + const HistoryEntry(caption: '*** THE END ***', isComplete: true), + ]; + + final updatedProgress = state.progress.copyWith( + plotHistory: updatedPlotHistory, + ); + + return ( + state: state.copyWith(progress: updatedProgress), + gameComplete: true, + ); + } + + // 최종 보스가 아직 등장하지 않았으면 보스 전투 시작 + if (state.progress.finalBossState == FinalBossState.notSpawned) { + final updatedProgress = state.progress.copyWith( + finalBossState: FinalBossState.fighting, + ); + + // 게임은 아직 끝나지 않음 - 보스 전투 진행 + return ( + state: state.copyWith(progress: updatedProgress), + gameComplete: false, + ); + } + + // 보스 전투 중이면 계속 진행 (게임 종료 안 함) + return (state: state, gameComplete: false); + } + + final actResult = pq_logic.completeAct(state.progress.plotStageCount); + var nextState = state; + + // 보상 처리는 호출자(ProgressService)가 담당 + // 여기서는 플롯 상태만 업데이트 + + final plotStages = nextState.progress.plotStageCount + 1; + + // 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가 + final updatedPlotHistory = [ + ...nextState.progress.plotHistory.map( + (e) => e.isComplete ? e : e.copyWith(isComplete: true), + ), + HistoryEntry(caption: actResult.actTitle, isComplete: false), + ]; + + var updatedProgress = nextState.progress.copyWith( + plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds), + plotStageCount: plotStages, + plotHistory: updatedPlotHistory, + ); + + nextState = nextState.copyWith(progress: updatedProgress); + + // Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직) + // plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미 + if (plotStages == 2) { + nextState = startFirstQuest(nextState); + } + + return (state: nextState, gameComplete: false); + } + + /// Act 완료 시 적용할 보상 목록 반환 + List getActRewards(int plotStageCount) { + final actResult = pq_logic.completeAct(plotStageCount); + return actResult.rewards; + } + + /// 첫 퀘스트 시작 (Act I 시작 시) + GameState startFirstQuest(GameState state) { + final result = pq_logic.completeQuest( + config, + state.rng, + state.traits.level, + ); + + // 퀘스트 바 초기화 + final questBar = ProgressBarState( + position: 0, + max: 50 + state.rng.nextInt(100), + ); + + // 첫 퀘스트 히스토리 추가 + final questHistory = [ + HistoryEntry(caption: result.caption, isComplete: false), + ]; + + // 퀘스트 몬스터 정보 저장 (Exterminate 타입용) + final questMonster = result.monsterIndex != null + ? QuestMonsterInfo( + monsterData: result.monsterName!, + monsterIndex: result.monsterIndex!, + ) + : null; + + // 첫 퀘스트 추가 + final updatedQueue = QueueState( + entries: [ + ...state.queue.entries, + QueueEntry( + kind: QueueKind.task, + durationMillis: 50 + state.rng.nextInt(100), + caption: result.caption, + taskType: TaskType.neutral, + ), + ], + ); + + final progress = state.progress.copyWith( + quest: questBar, + questCount: 1, + questHistory: questHistory, + currentQuestMonster: questMonster, + ); + + return state.copyWith(progress: progress, queue: updatedQueue); + } + + /// Act Boss 생성 (Act 완료 시) + /// + /// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여 + /// 플레이어가 이길 수 있는 수준 보장 + CombatState createActBoss(GameState state) { + final plotStage = state.progress.plotStageCount; + final actNumber = plotStage + 1; + + // 보스 레벨 = min(플레이어 레벨, Act 최소 레벨) + // → 플레이어가 현재 레벨보다 높은 보스를 만나지 않도록 보장 + final actMinLevel = ActMonsterLevel.forPlotStage(actNumber); + final bossLevel = math.min(state.traits.level, actMinLevel); + + // Named monster 생성 (pq_logic.namedMonster 활용) + final bossName = pq_logic.namedMonster(config, state.rng, bossLevel); + + final bossStats = MonsterBaseStats.forLevel(bossLevel); + + // 플레이어 전투 스탯 생성 + final playerCombatStats = CombatStats.fromStats( + stats: state.stats, + equipment: state.equipment, + level: state.traits.level, + monsterLevel: bossLevel, + ); + + // Boss 몬스터 스탯 생성 (일반 몬스터 대비 강화) + final monsterCombatStats = MonsterCombatStats( + name: bossName, + level: bossLevel, + atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배) + def: (bossStats.def * 1.5).round(), + hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간) + hpCurrent: (bossStats.hp * 2.0).round(), + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: (bossStats.exp * 2.5).round(), // 경험치 보상 증가 + ); + + // 전투 상태 초기화 + return CombatState.start( + playerStats: playerCombatStats, + monsterStats: monsterCombatStats, + ); + } + + /// 최종 보스(Glitch God) 전투 시작 + /// + /// Act V 플롯 완료 후 호출되며, 글리치 갓과의 전투를 설정합니다. + ({ProgressState progress, QueueState queue}) startFinalBossFight( + GameState state, + ProgressState progress, + QueueState queue, + ) { + final level = state.traits.level; + + // Glitch God 생성 (레벨 100 최종 보스) + final glitchGod = MonsterCombatStats.glitchGod(); + + // 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용) + final playerCombatStats = CombatStats.fromStats( + stats: state.stats, + equipment: state.equipment, + level: level, + monsterLevel: glitchGod.level, + ); + + // 전투 상태 초기화 + final combatState = CombatState.start( + playerStats: playerCombatStats, + monsterStats: glitchGod, + ); + + // 전투 시간 추정 (보스 전투는 더 길게) + final combatCalculator = CombatCalculator(rng: state.rng); + final baseDuration = combatCalculator.estimateCombatDurationMs( + player: playerCombatStats, + monster: glitchGod, + ); + // 최종 보스는 최소 10초, 최대 60초 + final durationMillis = baseDuration.clamp(10000, 60000); + + final taskResult = pq_logic.startTask( + progress, + l10n.taskFinalBoss(glitchGod.name), + durationMillis, + ); + + final updatedProgress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.kill, + monsterBaseName: 'Glitch God', + monsterPart: '*', // 특수 전리품 + monsterLevel: glitchGod.level, + monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급 + ), + currentCombat: combatState, + ); + + return (progress: updatedProgress, queue: queue); + } +} diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 8523bee..7ee4d40 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/engine/act_progression_service.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/combat_tick_service.dart'; import 'package:asciineverdie/src/core/engine/game_mutations.dart'; @@ -434,7 +435,8 @@ class ProgressService { progress.plot.position >= progress.plot.max && !progress.pendingActCompletion) { // Act Boss 소환 및 플래그 설정 - final actBoss = _createActBoss(nextState); + final actProgressionService = ActProgressionService(config: config); + final actBoss = actProgressionService.createActBoss(nextState); progress = progress.copyWith( plot: progress.plot.copyWith(position: 0), // Plot bar 리셋 currentCombat: actBoss, @@ -561,7 +563,8 @@ class ProgressService { // 3. Act Boss 리트라이 체크 // pendingActCompletion이 true면 Act Boss 재소환 if (state.progress.pendingActCompletion) { - final actBoss = _createActBoss(state); + final actProgressionService = ActProgressionService(config: config); + final actBoss = actProgressionService.createActBoss(state); final combatCalculator = CombatCalculator(rng: state.rng); final durationMillis = combatCalculator.estimateCombatDurationMs( player: actBoss.playerStats, @@ -601,7 +604,8 @@ class ProgressService { if (state.progress.bossLevelingEndTime != null) { progress = progress.copyWith(clearBossLevelingEndTime: true); } - return _startFinalBossFight(state, progress, queue); + final actProgressionService = ActProgressionService(config: config); + return actProgressionService.startFinalBossFight(state, progress, queue); } } @@ -684,63 +688,6 @@ class ProgressService { return (progress: progress, queue: queue); } - /// 최종 보스(Glitch God) 전투 시작 - /// - /// Act V 플롯 완료 후 호출되며, 글리치 갓과의 전투를 설정합니다. - ({ProgressState progress, QueueState queue}) _startFinalBossFight( - GameState state, - ProgressState progress, - QueueState queue, - ) { - final level = state.traits.level; - - // Glitch God 생성 (레벨 100 최종 보스) - final glitchGod = MonsterCombatStats.glitchGod(); - - // 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용) - final playerCombatStats = CombatStats.fromStats( - stats: state.stats, - equipment: state.equipment, - level: level, - monsterLevel: glitchGod.level, - ); - - // 전투 상태 초기화 - final combatState = CombatState.start( - playerStats: playerCombatStats, - monsterStats: glitchGod, - ); - - // 전투 시간 추정 (보스 전투는 더 길게) - final combatCalculator = CombatCalculator(rng: state.rng); - final baseDuration = combatCalculator.estimateCombatDurationMs( - player: playerCombatStats, - monster: glitchGod, - ); - // 최종 보스는 최소 10초, 최대 60초 - final durationMillis = baseDuration.clamp(10000, 60000); - - final taskResult = pq_logic.startTask( - progress, - l10n.taskFinalBoss(glitchGod.name), - durationMillis, - ); - - final updatedProgress = taskResult.progress.copyWith( - currentTask: TaskInfo( - caption: taskResult.caption, - type: TaskType.kill, - monsterBaseName: 'Glitch God', - monsterPart: '*', // 특수 전리품 - monsterLevel: glitchGod.level, - monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급 - ), - currentCombat: combatState, - ); - - return (progress: updatedProgress, queue: queue); - } - /// Advances quest completion, applies reward, and enqueues next quest task. GameState completeQuest(GameState state) { final result = pq_logic.completeQuest( @@ -801,127 +748,24 @@ class ProgressService { /// Advances plot to next act and applies any act-level rewards. /// Returns gameComplete=true if Final Boss was defeated (game ends). ({GameState state, bool gameComplete}) completeAct(GameState state) { - // Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작 - // plotStageCount: 1=Prologue, 2=Act I, 3=Act II, 4=Act III, 5=Act IV, 6=Act V - if (state.progress.plotStageCount >= 6) { - // 이미 최종 보스가 처치되었으면 게임 클리어 - if (state.progress.finalBossState == FinalBossState.defeated) { - final updatedPlotHistory = [ - ...state.progress.plotHistory.map( - (e) => e.isComplete ? e : e.copyWith(isComplete: true), - ), - const HistoryEntry(caption: '*** THE END ***', isComplete: true), - ]; + final actProgressionService = ActProgressionService(config: config); - final updatedProgress = state.progress.copyWith( - plotHistory: updatedPlotHistory, - ); - - return ( - state: state.copyWith(progress: updatedProgress), - gameComplete: true, - ); - } - - // 최종 보스가 아직 등장하지 않았으면 보스 전투 시작 - if (state.progress.finalBossState == FinalBossState.notSpawned) { - final updatedProgress = state.progress.copyWith( - finalBossState: FinalBossState.fighting, - ); - - // 게임은 아직 끝나지 않음 - 보스 전투 진행 - return ( - state: state.copyWith(progress: updatedProgress), - gameComplete: false, - ); - } - - // 보스 전투 중이면 계속 진행 (게임 종료 안 함) - return (state: state, gameComplete: false); - } - - final actResult = pq_logic.completeAct(state.progress.plotStageCount); + // Act 보상 먼저 적용 + final actRewards = actProgressionService.getActRewards( + state.progress.plotStageCount, + ); var nextState = state; - for (final reward in actResult.rewards) { + for (final reward in actRewards) { nextState = _applyReward(nextState, reward); } - final plotStages = nextState.progress.plotStageCount + 1; + // Act 완료 처리 (ActProgressionService 위임) + final result = actProgressionService.completeAct(nextState); - // 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가 - final updatedPlotHistory = [ - ...nextState.progress.plotHistory.map( - (e) => e.isComplete ? e : e.copyWith(isComplete: true), - ), - HistoryEntry(caption: actResult.actTitle, isComplete: false), - ]; - - var updatedProgress = nextState.progress.copyWith( - plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds), - plotStageCount: plotStages, - plotHistory: updatedPlotHistory, + return ( + state: _recalculateEncumbrance(result.state), + gameComplete: result.gameComplete, ); - - nextState = nextState.copyWith(progress: updatedProgress); - - // Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직) - // plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미 - if (plotStages == 2) { - nextState = _startFirstQuest(nextState); - } - - return (state: _recalculateEncumbrance(nextState), gameComplete: false); - } - - /// 첫 퀘스트 시작 (Act I 시작 시) - GameState _startFirstQuest(GameState state) { - final result = pq_logic.completeQuest( - config, - state.rng, - state.traits.level, - ); - - // 퀘스트 바 초기화 - final questBar = ProgressBarState( - position: 0, - max: 50 + state.rng.nextInt(100), - ); - - // 첫 퀘스트 히스토리 추가 - final questHistory = [ - HistoryEntry(caption: result.caption, isComplete: false), - ]; - - // 퀘스트 몬스터 정보 저장 (Exterminate 타입용) - // 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex - final questMonster = result.monsterIndex != null - ? QuestMonsterInfo( - monsterData: result.monsterName!, - monsterIndex: result.monsterIndex!, - ) - : null; - - // 첫 퀘스트 추가 - final updatedQueue = QueueState( - entries: [ - ...state.queue.entries, - QueueEntry( - kind: QueueKind.task, - durationMillis: 50 + state.rng.nextInt(100), - caption: result.caption, - taskType: TaskType.neutral, - ), - ], - ); - - final progress = state.progress.copyWith( - quest: questBar, - questCount: 1, - questHistory: questHistory, - currentQuestMonster: questMonster, - ); - - return state.copyWith(progress: progress, queue: updatedQueue); } /// Developer-only cheat hooks for quickly finishing bars. @@ -1096,55 +940,6 @@ class ProgressService { return s[0].toUpperCase() + s.substring(1); } - /// Act Boss 생성 (Act 완료 시) - /// - /// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여 - /// 플레이어가 이길 수 있는 수준 보장 - CombatState _createActBoss(GameState state) { - final plotStage = state.progress.plotStageCount; - final actNumber = plotStage + 1; - - // 보스 레벨 = min(플레이어 레벨, Act 최소 레벨) - // → 플레이어가 현재 레벨보다 높은 보스를 만나지 않도록 보장 - final actMinLevel = ActMonsterLevel.forPlotStage(actNumber); - final bossLevel = math.min(state.traits.level, actMinLevel); - - // Named monster 생성 (pq_logic.namedMonster 활용) - final bossName = pq_logic.namedMonster(config, state.rng, bossLevel); - - final bossStats = MonsterBaseStats.forLevel(bossLevel); - - // 플레이어 전투 스탯 생성 - final playerCombatStats = CombatStats.fromStats( - stats: state.stats, - equipment: state.equipment, - level: state.traits.level, - monsterLevel: bossLevel, - ); - - // Boss 몬스터 스탯 생성 (일반 몬스터 대비 강화) - final monsterCombatStats = MonsterCombatStats( - name: bossName, - level: bossLevel, - atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배) - def: (bossStats.def * 1.5).round(), - hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간) - hpCurrent: (bossStats.hp * 2.0).round(), - criRate: 0.05, - criDamage: 1.5, - evasion: 0.0, - accuracy: 0.8, - attackDelayMs: 1000, - expReward: (bossStats.exp * 2.5).round(), // 경험치 보상 증가 - ); - - // 전투 상태 초기화 - return CombatState.start( - playerStats: playerCombatStats, - monsterStats: monsterCombatStats, - ); - } - /// 플레이어 사망 처리 (Phase 4) /// /// 모든 장비 상실 및 사망 정보 기록 diff --git a/lib/src/features/game/controllers/combat_log_controller.dart b/lib/src/features/game/controllers/combat_log_controller.dart new file mode 100644 index 0000000..6e5bf51 --- /dev/null +++ b/lib/src/features/game/controllers/combat_log_controller.dart @@ -0,0 +1,184 @@ +import 'package:flutter/foundation.dart'; + +import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; +import 'package:asciineverdie/src/core/model/combat_event.dart'; +import 'package:asciineverdie/src/core/model/combat_state.dart'; +import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; + +/// 전투 로그 컨트롤러 +/// +/// GamePlayScreen에서 추출된 전투 로그 관련 로직 담당: +/// - 로그 엔트리 관리 (최대 50개 유지) +/// - 전투 이벤트 → 로그 메시지 변환 +/// - 태스크 변경 시 로그 추가 +class CombatLogController extends ChangeNotifier { + CombatLogController({ + this.onCombatEvent, + }); + + /// 전투 이벤트 발생 시 호출되는 콜백 (SFX 재생 등에 사용) + final void Function(CombatEvent event)? onCombatEvent; + + // 로그 엔트리 목록 + final List _entries = []; + + // 이벤트 처리 추적 + int _lastProcessedEventCount = 0; + String _lastTaskCaption = ''; + + /// 로그 엔트리 목록 (읽기 전용) + List get entries => List.unmodifiable(_entries); + + /// 로그 엔트리 추가 + void addLog(String message, CombatLogType type) { + _entries.add( + CombatLogEntry(message: message, timestamp: DateTime.now(), type: type), + ); + // 최대 50개 유지 + if (_entries.length > 50) { + _entries.removeAt(0); + } + notifyListeners(); + } + + /// 태스크 변경 처리 + /// + /// 새 태스크 시작 시 로그에 추가하고 이벤트 카운터 리셋 + void onTaskChanged(String caption) { + if (caption.isNotEmpty && caption != _lastTaskCaption) { + addLog(caption, CombatLogType.normal); + _lastTaskCaption = caption; + // 새 태스크 시작 시 이벤트 카운터 리셋 + _lastProcessedEventCount = 0; + } + } + + /// 전투 이벤트 처리 + /// + /// 새 전투 이벤트를 로그로 변환하고 콜백 호출 + void processCombatEvents(CombatState? combat) { + if (combat == null || !combat.isActive) { + _lastProcessedEventCount = 0; + return; + } + + final events = combat.recentEvents; + if (events.isEmpty || events.length <= _lastProcessedEventCount) { + return; + } + + // 새 이벤트만 처리 + final newEvents = events.skip(_lastProcessedEventCount); + for (final event in newEvents) { + final (message, type) = _formatCombatEvent(event); + addLog(message, type); + + // 오디오 콜백 호출 (SFX 재생) + onCombatEvent?.call(event); + } + + _lastProcessedEventCount = events.length; + } + + /// 레벨업 로그 추가 + void addLevelUpLog(int level) { + addLog( + '${game_l10n.uiLevelUp} Lv.$level', + CombatLogType.levelUp, + ); + } + + /// 퀘스트 완료 로그 추가 + void addQuestCompleteLog(String questCaption) { + addLog( + game_l10n.uiQuestComplete(questCaption), + CombatLogType.questComplete, + ); + } + + /// 전투 이벤트를 메시지와 타입으로 변환 + (String, CombatLogType) _formatCombatEvent(CombatEvent event) { + final target = event.targetName ?? ''; + // 스킬/포션 이름 번역 (전역 로케일 사용) + final skillName = event.skillName != null + ? game_l10n.translateSpell(event.skillName!) + : ''; + return switch (event.type) { + CombatEventType.playerAttack => + event.isCritical + ? ( + game_l10n.combatCritical(event.damage, target), + CombatLogType.critical, + ) + : ( + game_l10n.combatYouHit(target, event.damage), + CombatLogType.damage, + ), + CombatEventType.monsterAttack => ( + game_l10n.combatMonsterHitsYou(target, event.damage), + CombatLogType.monsterAttack, + ), + CombatEventType.playerEvade => ( + game_l10n.combatYouEvaded(target), + CombatLogType.evade, + ), + CombatEventType.monsterEvade => ( + game_l10n.combatMonsterEvaded(target), + CombatLogType.evade, + ), + CombatEventType.playerBlock => ( + game_l10n.combatBlocked(event.damage), + CombatLogType.block, + ), + CombatEventType.playerParry => ( + game_l10n.combatParried(event.damage), + CombatLogType.parry, + ), + CombatEventType.playerSkill => + event.isCritical + ? ( + game_l10n.combatSkillCritical(skillName, event.damage), + CombatLogType.critical, + ) + : ( + game_l10n.combatSkillDamage(skillName, event.damage), + CombatLogType.skill, + ), + CombatEventType.playerHeal => ( + game_l10n.combatSkillHeal( + skillName.isNotEmpty ? skillName : game_l10n.uiHeal, + event.healAmount, + ), + CombatLogType.heal, + ), + CombatEventType.playerBuff => ( + 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, + ), + CombatEventType.playerPotion => ( + game_l10n.combatPotionUsed(skillName, event.healAmount, target), + CombatLogType.potion, + ), + CombatEventType.potionDrop => ( + game_l10n.combatPotionDrop(skillName), + CombatLogType.potionDrop, + ), + }; + } + + /// 로그 초기화 + void reset() { + _entries.clear(); + _lastProcessedEventCount = 0; + _lastTaskCaption = ''; + notifyListeners(); + } +} diff --git a/lib/src/features/game/controllers/game_audio_controller.dart b/lib/src/features/game/controllers/game_audio_controller.dart new file mode 100644 index 0000000..220c55d --- /dev/null +++ b/lib/src/features/game/controllers/game_audio_controller.dart @@ -0,0 +1,251 @@ +import 'package:flutter/foundation.dart'; + +import 'package:asciineverdie/data/story_data.dart'; +import 'package:asciineverdie/src/core/audio/audio_service.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'; + +/// 게임 오디오 컨트롤러 +/// +/// GamePlayScreen에서 추출된 오디오 관련 로직 담당: +/// - BGM 전환 (전투/마을/사망/엔딩) +/// - 전투 이벤트 SFX 재생 +/// - 볼륨 관리 +class GameAudioController extends ChangeNotifier { + GameAudioController({ + required this.audioService, + this.getSpeedMultiplier, + }); + + final AudioService? audioService; + + /// 현재 배속 값을 가져오는 콜백 (디바운스 계산용) + final int Function()? getSpeedMultiplier; + + // 오디오 상태 추적 + bool _wasInBattleTask = false; + bool _wasDead = false; + bool _wasComplete = false; + + // 볼륨 상태 + double _bgmVolume = 0.7; + double _sfxVolume = 0.8; + + // SFX 디바운스 추적 + final Map _lastSfxPlayTime = {}; + + // Getters + double get bgmVolume => _bgmVolume; + double get sfxVolume => _sfxVolume; + + /// 오디오 볼륨 초기화 (AudioService에서 로드) + Future initVolumes() async { + final audio = audioService; + if (audio != null) { + _bgmVolume = audio.bgmVolume; + _sfxVolume = audio.sfxVolume; + notifyListeners(); + } + } + + /// BGM 볼륨 설정 + void setBgmVolume(double volume) { + _bgmVolume = volume; + audioService?.setBgmVolume(volume); + notifyListeners(); + } + + /// SFX 볼륨 설정 + void setSfxVolume(double volume) { + _sfxVolume = volume; + audioService?.setSfxVolume(volume); + notifyListeners(); + } + + /// 일시정지 + void pauseAll() { + audioService?.pauseAll(); + } + + /// 재개 + void resumeAll() { + audioService?.resumeAll(); + } + + /// 초기 BGM 재생 (게임 시작/로드 시) + void playInitialBgm(GameState state) { + final audio = audioService; + if (audio == null) return; + + final taskType = state.progress.currentTask.type; + final isInBattleTask = taskType == TaskType.kill; + + if (isInBattleTask) { + audio.playBgm(_getBattleBgm(state)); + } else { + // 비전투 태스크: 마을 BGM + audio.playBgm('town'); + } + + _wasInBattleTask = isInBattleTask; + } + + /// TaskType 기반 BGM 전환 (애니메이션과 동기화) + void updateBgmForTaskType(GameState state) { + final audio = audioService; + if (audio == null) return; + + final taskType = state.progress.currentTask.type; + final isInBattleTask = taskType == TaskType.kill; + + if (isInBattleTask) { + final expectedBgm = _getBattleBgm(state); + + // 전환 시점이거나 현재 BGM이 일치하지 않으면 재생 + if (!_wasInBattleTask || audio.currentBgm != expectedBgm) { + audio.playBgm(expectedBgm); + } + } else { + // 비전투 태스크: 항상 마을 BGM 유지 (이미 town이면 스킵) + if (audio.currentBgm != 'town') { + audio.playBgm('town'); + } + } + + _wasInBattleTask = isInBattleTask; + } + + /// 사망/엔딩 BGM 전환 처리 + void updateDeathEndingBgm(GameState state, {required bool isGameComplete}) { + final audio = audioService; + if (audio == null) return; + + final isDead = state.isDead; + + // 엔딩 BGM (게임 클리어 시) + if (isGameComplete && !_wasComplete) { + audio.playBgm('ending'); + _wasComplete = true; + return; + } + + // 사망 BGM (isDead 상태 진입 시) + if (isDead && !_wasDead) { + audio.playBgm('death'); + _wasDead = true; + return; + } + + // 부활 시 사망 상태 리셋 (다음 사망 감지 가능하도록) + if (!isDead && _wasDead) { + _wasDead = false; + // 부활 후 BGM은 updateBgmForTaskType에서 처리됨 + } + } + + /// 시네마틱 BGM 재생 + void playCinematicBgm() { + audioService?.playBgm('act_cinemetic'); + } + + /// 레벨업 SFX 재생 + void playLevelUpSfx() { + audioService?.playPlayerSfx('level_up'); + } + + /// 퀘스트 완료 SFX 재생 + void playQuestCompleteSfx() { + audioService?.playPlayerSfx('quest_complete'); + } + + /// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스) + void playCombatEventSfx(CombatEvent event) { + final audio = audioService; + if (audio == null) return; + + // 사운드 이름 결정 + 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 없음 + }; + + if (sfxName == null) return; + + // 디바운스 체크 (배속 시 같은 사운드 중복 재생 방지) + final now = DateTime.now().millisecondsSinceEpoch; + final lastTime = _lastSfxPlayTime[sfxName] ?? 0; + final speedMultiplier = getSpeedMultiplier?.call() ?? 1; + + // 배속이 높을수록 디바운스 간격 증가 (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); + } + } + + /// 전투 BGM 결정 (몬스터 등급 + 레벨 + Act 고려) + String _getBattleBgm(GameState state) { + final task = state.progress.currentTask; + final monsterGrade = task.monsterGrade; + final monsterLevel = task.monsterLevel ?? 0; + final playerLevel = state.traits.level; + + // 1. 등급 보스 (3% 확률로 등장하는 특수 보스) + if (monsterGrade == MonsterGrade.boss) { + return 'act_boss'; + } + + // 2. 레벨 기반 보스 (강적) + if (monsterLevel >= playerLevel + 5) { + return 'boss'; + } + + // 3. 엘리트 몬스터 (12% 확률) + if (monsterGrade == MonsterGrade.elite) { + return 'elite'; + } + + // 4. 일반 전투 (Act별 분기) + return _getBattleBgmForLevel(playerLevel); + } + + /// 레벨에 따른 전투 BGM 파일명 반환 (Act별 분기) + String _getBattleBgmForLevel(int playerLevel) { + final act = getActForLevel(playerLevel); + return switch (act) { + StoryAct.act4 => 'battle_act4', + StoryAct.act5 => 'battle_act5', + _ => 'battle', + }; + } + + /// 상태 리셋 (새 게임 시작 시) + void reset() { + _wasInBattleTask = false; + _wasDead = false; + _wasComplete = false; + _lastSfxPlayTime.clear(); + } +} diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 11c36e4..8e7965a 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -10,10 +10,8 @@ import 'package:asciineverdie/data/story_data.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/engine/story_service.dart'; -import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; -import 'package:asciineverdie/src/core/model/monster_grade.dart'; import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/core/notification/notification_service.dart'; import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic; @@ -36,6 +34,8 @@ import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart'; import 'package:asciineverdie/src/features/game/widgets/help_dialog.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart'; import 'package:asciineverdie/src/core/audio/audio_service.dart'; +import 'package:asciineverdie/src/features/game/controllers/combat_log_controller.dart'; +import 'package:asciineverdie/src/features/game/controllers/game_audio_controller.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; /// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃) @@ -85,58 +85,34 @@ class _GamePlayScreenState extends State StoryAct _lastAct = StoryAct.prologue; bool _showingCinematic = false; - // Phase 8: 전투 로그 (Combat Log) - final List _combatLogEntries = []; - String _lastTaskCaption = ''; - // 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용) int _lastLevel = 0; int _lastQuestCount = 0; int _lastPlotStageCount = 0; - // 전투 이벤트 추적 (마지막 처리된 이벤트 수) - int _lastProcessedEventCount = 0; + // Phase 2.4: 오디오 컨트롤러 + late final GameAudioController _audioController; - // 오디오 상태 추적 (TaskType 기반) - bool _wasInBattleTask = false; - - // 사망/엔딩 상태 추적 (BGM 전환용) - bool _wasDead = false; - - // 사운드 디바운스 추적 (배속 시 사운드 누락 방지) - final Map _lastSfxPlayTime = {}; - bool _wasComplete = false; - - // 사운드 볼륨 상태 (모바일 설정 UI용) - double _bgmVolume = 0.7; - double _sfxVolume = 0.8; + // Phase 2.5: 전투 로그 컨트롤러 + late final CombatLogController _combatLogController; void _checkSpecialEvents(GameState state) { // Phase 8: 태스크 변경 시 로그 추가 - final currentCaption = state.progress.currentTask.caption; - if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) { - _addCombatLog(currentCaption, CombatLogType.normal); - _lastTaskCaption = currentCaption; - // 새 태스크 시작 시 이벤트 카운터 리셋 - _lastProcessedEventCount = 0; - } + _combatLogController.onTaskChanged(state.progress.currentTask.caption); // 전투 이벤트 처리 (Combat Events) - _processCombatEvents(state); + _combatLogController.processCombatEvents(state.progress.currentCombat); // 오디오: TaskType 변경 시 BGM 전환 (애니메이션과 동기화) - _updateBgmForTaskType(state); + _audioController.updateBgmForTaskType(state); // 레벨업 감지 if (state.traits.level > _lastLevel && _lastLevel > 0) { _specialAnimation = AsciiAnimationType.levelUp; _notificationService.showLevelUp(state.traits.level); - _addCombatLog( - '${game_l10n.uiLevelUp} Lv.${state.traits.level}', - CombatLogType.levelUp, - ); + _combatLogController.addLevelUpLog(state.traits.level); // 오디오: 레벨업 SFX (플레이어 채널) - widget.audioService?.playPlayerSfx('level_up'); + _audioController.playLevelUpSfx(); _resetSpecialAnimationAfterFrame(); // Phase 9: Act 변경 감지 (레벨 기반) @@ -164,13 +140,10 @@ class _GamePlayScreenState extends State .lastOrNull; if (completedQuest != null) { _notificationService.showQuestComplete(completedQuest.caption); - _addCombatLog( - game_l10n.uiQuestComplete(completedQuest.caption), - CombatLogType.questComplete, - ); + _combatLogController.addQuestCompleteLog(completedQuest.caption); } // 오디오: 퀘스트 완료 SFX (플레이어 채널) - widget.audioService?.playPlayerSfx('quest_complete'); + _audioController.playQuestCompleteSfx(); _resetSpecialAnimationAfterFrame(); } _lastQuestCount = state.progress.questCount; @@ -187,292 +160,10 @@ class _GamePlayScreenState extends State _lastPlotStageCount = state.progress.plotStageCount; // 사망/엔딩 BGM 전환 (Death/Ending BGM Transition) - _updateDeathEndingBgm(state); - } - - /// 사망/엔딩 BGM 전환 처리 - void _updateDeathEndingBgm(GameState state) { - final audio = widget.audioService; - if (audio == null) return; - - final isDead = state.isDead; - final isComplete = widget.controller.isComplete; - - // 엔딩 BGM (게임 클리어 시) - if (isComplete && !_wasComplete) { - audio.playBgm('ending'); - _wasComplete = true; - return; - } - - // 사망 BGM (isDead 상태 진입 시) - if (isDead && !_wasDead) { - audio.playBgm('death'); - _wasDead = true; - return; - } - - // 부활 시 사망 상태 리셋 (다음 사망 감지 가능하도록) - if (!isDead && _wasDead) { - _wasDead = false; - // 부활 후 BGM은 _updateBgmForTaskType에서 처리됨 - } - } - - /// Phase 8: 전투 로그 추가 (Add Combat Log Entry) - void _addCombatLog(String message, CombatLogType type) { - _combatLogEntries.add( - CombatLogEntry(message: message, timestamp: DateTime.now(), type: type), + _audioController.updateDeathEndingBgm( + state, + isGameComplete: widget.controller.isComplete, ); - // 최대 50개 유지 - if (_combatLogEntries.length > 50) { - _combatLogEntries.removeAt(0); - } - } - - /// 전투 이벤트를 로그로 변환 (Convert Combat Events to Log) - void _processCombatEvents(GameState state) { - final combat = state.progress.currentCombat; - if (combat == null || !combat.isActive) { - _lastProcessedEventCount = 0; - return; - } - - final events = combat.recentEvents; - if (events.isEmpty || events.length <= _lastProcessedEventCount) { - return; - } - - // 새 이벤트만 처리 - final newEvents = events.skip(_lastProcessedEventCount); - for (final event in newEvents) { - final (message, type) = _formatCombatEvent(event); - _addCombatLog(message, type); - - // 오디오: 전투 이벤트에 따른 SFX 재생 - _playCombatEventSfx(event); - } - - _lastProcessedEventCount = events.length; - } - - /// 초기 BGM 재생 (게임 시작/로드 시) - /// - /// TaskType 기반으로 BGM 결정 (애니메이션과 동기화) - void _playInitialBgm(GameState state) { - final audio = widget.audioService; - if (audio == null) return; - - final taskType = state.progress.currentTask.type; - final isInBattleTask = taskType == TaskType.kill; - - if (isInBattleTask) { - audio.playBgm(_getBattleBgm(state)); - } else { - // 비전투 태스크: 마을 BGM - audio.playBgm('town'); - } - - _wasInBattleTask = isInBattleTask; - } - - /// 전투 BGM 결정 (몬스터 등급 + 레벨 + Act 고려) - /// - /// 우선순위: - /// 1. MonsterGrade.boss → 'act_boss' - /// 2. 레벨 기반 보스 (monsterLevel >= playerLevel + 5) → 'boss' - /// 3. MonsterGrade.elite → 'elite' - /// 4. Act별 일반 전투 → 'battle', 'battle_act4', 'battle_act5' - String _getBattleBgm(GameState state) { - final task = state.progress.currentTask; - final monsterGrade = task.monsterGrade; - final monsterLevel = task.monsterLevel ?? 0; - final playerLevel = state.traits.level; - - // 1. 등급 보스 (3% 확률로 등장하는 특수 보스) - if (monsterGrade == MonsterGrade.boss) { - return 'act_boss'; - } - - // 2. 레벨 기반 보스 (강적) - if (monsterLevel >= playerLevel + 5) { - return 'boss'; - } - - // 3. 엘리트 몬스터 (12% 확률) - if (monsterGrade == MonsterGrade.elite) { - return 'elite'; - } - - // 4. 일반 전투 (Act별 분기) - return _getBattleBgmForLevel(playerLevel); - } - - /// 레벨에 따른 전투 BGM 파일명 반환 (Act별 분기) - String _getBattleBgmForLevel(int playerLevel) { - final act = getActForLevel(playerLevel); - return switch (act) { - StoryAct.act4 => 'battle_act4', - StoryAct.act5 => 'battle_act5', - _ => 'battle', - }; - } - - /// TaskType 기반 BGM 전환 (애니메이션과 동기화) - /// - /// 애니메이션은 TaskType으로 결정되므로, BGM도 동일한 기준 사용 - /// 전환 감지 외에도 현재 BGM이 TaskType과 일치하는지 검증 - void _updateBgmForTaskType(GameState state) { - final audio = widget.audioService; - if (audio == null) return; - - final taskType = state.progress.currentTask.type; - final isInBattleTask = taskType == TaskType.kill; - - // 전투 태스크 상태 결정 - if (isInBattleTask) { - final expectedBgm = _getBattleBgm(state); - - // 전환 시점이거나 현재 BGM이 일치하지 않으면 재생 - if (!_wasInBattleTask || audio.currentBgm != expectedBgm) { - audio.playBgm(expectedBgm); - } - } else { - // 비전투 태스크: 항상 마을 BGM 유지 (이미 town이면 스킵) - if (audio.currentBgm != 'town') { - audio.playBgm('town'); - } - } - - _wasInBattleTask = isInBattleTask; - } - - /// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스) - /// - /// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여 - /// 사운드 충돌을 방지하고 완료를 보장합니다. - /// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다. - void _playCombatEventSfx(CombatEvent event) { - final audio = widget.audioService; - if (audio == null) return; - - // 사운드 이름 결정 - 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 없음 - }; - - if (sfxName == null) return; - - // 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지) - final now = DateTime.now().millisecondsSinceEpoch; - final lastTime = _lastSfxPlayTime[sfxName] ?? 0; - final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1; - - // 배속이 높을수록 디바운스 간격 증가 (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); - } - } - - /// 전투 이벤트를 메시지와 타입으로 변환 - (String, CombatLogType) _formatCombatEvent(CombatEvent event) { - final target = event.targetName ?? ''; - // 스킬/포션 이름 번역 (전역 로케일 사용) - final skillName = event.skillName != null - ? game_l10n.translateSpell(event.skillName!) - : ''; - return switch (event.type) { - CombatEventType.playerAttack => - event.isCritical - ? ( - game_l10n.combatCritical(event.damage, target), - CombatLogType.critical, - ) - : ( - game_l10n.combatYouHit(target, event.damage), - CombatLogType.damage, - ), - CombatEventType.monsterAttack => ( - game_l10n.combatMonsterHitsYou(target, event.damage), - CombatLogType.monsterAttack, - ), - CombatEventType.playerEvade => ( - game_l10n.combatYouEvaded(target), - CombatLogType.evade, - ), - CombatEventType.monsterEvade => ( - game_l10n.combatMonsterEvaded(target), - CombatLogType.evade, - ), - CombatEventType.playerBlock => ( - game_l10n.combatBlocked(event.damage), - CombatLogType.block, - ), - CombatEventType.playerParry => ( - game_l10n.combatParried(event.damage), - CombatLogType.parry, - ), - CombatEventType.playerSkill => - event.isCritical - ? ( - game_l10n.combatSkillCritical(skillName, event.damage), - CombatLogType.critical, - ) - : ( - game_l10n.combatSkillDamage(skillName, event.damage), - CombatLogType.skill, - ), - CombatEventType.playerHeal => ( - game_l10n.combatSkillHeal( - skillName.isNotEmpty ? skillName : game_l10n.uiHeal, - event.healAmount, - ), - CombatLogType.heal, - ), - CombatEventType.playerBuff => ( - 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, - ), - CombatEventType.playerPotion => ( - game_l10n.combatPotionUsed(skillName, event.healAmount, target), - CombatLogType.potion, - ), - CombatEventType.potionDrop => ( - game_l10n.combatPotionDrop(skillName), - CombatLogType.potionDrop, - ), - }; } /// Phase 9: Act 시네마틱 표시 (Show Act Cinematic) @@ -484,7 +175,7 @@ class _GamePlayScreenState extends State await widget.controller.pause(saveOnStop: false); // 시네마틱 BGM 재생 - widget.audioService?.playBgm('act_cinemetic'); + _audioController.playCinematicBgm(); if (mounted) { await showActCinematic(context, act); @@ -520,6 +211,18 @@ class _GamePlayScreenState extends State super.initState(); _notificationService = NotificationService(); _storyService = StoryService(); + + // 오디오 컨트롤러 초기화 + _audioController = GameAudioController( + audioService: widget.audioService, + getSpeedMultiplier: () => widget.controller.loop?.speedMultiplier ?? 1, + ); + + // 전투 로그 컨트롤러 초기화 + _combatLogController = CombatLogController( + onCombatEvent: (event) => _audioController.playCombatEventSfx(event), + ); + widget.controller.addListener(_onControllerChanged); WidgetsBinding.instance.addObserver(this); @@ -531,8 +234,8 @@ class _GamePlayScreenState extends State _lastPlotStageCount = state.progress.plotStageCount; _lastAct = getActForLevel(state.traits.level); - // 초기 BGM 재생 (TaskType 기반, _wasInBattleTask도 함께 설정) - _playInitialBgm(state); + // 초기 BGM 재생 (TaskType 기반) + _audioController.playInitialBgm(state); } else { // 상태가 없으면 기본 마을 BGM widget.audioService?.playBgm('town'); @@ -542,17 +245,7 @@ class _GamePlayScreenState extends State widget.controller.loadCumulativeStats(); // 오디오 볼륨 초기화 - _initAudioVolumes(); - } - - /// 오디오 볼륨 초기화 (설정에서 로드) - Future _initAudioVolumes() async { - final audio = widget.audioService; - if (audio != null) { - _bgmVolume = audio.bgmVolume; - _sfxVolume = audio.sfxVolume; - if (mounted) setState(() {}); - } + _audioController.initVolumes(); } @override @@ -592,13 +285,13 @@ class _GamePlayScreenState extends State // 모바일: 게임 일시정지 + 전체 오디오 정지 if (isMobile) { widget.controller.pause(saveOnStop: false); - widget.audioService?.pauseAll(); + _audioController.pauseAll(); } } // 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드 if (appState == AppLifecycleState.resumed && isMobile) { - widget.audioService?.resumeAll(); + _audioController.resumeAll(); _reloadGameScreen(); } } @@ -745,12 +438,12 @@ class _GamePlayScreenState extends State } }, onBgmVolumeChange: (volume) { - setState(() => _bgmVolume = volume); - widget.audioService?.setBgmVolume(volume); + _audioController.setBgmVolume(volume); + setState(() {}); }, onSfxVolumeChange: (volume) { - setState(() => _sfxVolume = volume); - widget.audioService?.setSfxVolume(volume); + _audioController.setSfxVolume(volume); + setState(() {}); }, onCreateTestCharacter: () async { final navigator = Navigator.of(context); @@ -824,7 +517,7 @@ class _GamePlayScreenState extends State children: [ MobileCarouselLayout( state: state, - combatLogEntries: _combatLogEntries, + combatLogEntries: _combatLogController.entries, speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1, onSpeedCycle: () { widget.controller.loop?.cycleSpeed(); @@ -884,15 +577,15 @@ class _GamePlayScreenState extends State currentThemeMode: widget.currentThemeMode, onThemeModeChange: widget.onThemeModeChange, // 사운드 설정 - bgmVolume: _bgmVolume, - sfxVolume: _sfxVolume, + bgmVolume: _audioController.bgmVolume, + sfxVolume: _audioController.sfxVolume, onBgmVolumeChange: (volume) { - setState(() => _bgmVolume = volume); - widget.audioService?.setBgmVolume(volume); + _audioController.setBgmVolume(volume); + setState(() {}); }, onSfxVolumeChange: (volume) { - setState(() => _sfxVolume = volume); - widget.audioService?.setSfxVolume(volume); + _audioController.setSfxVolume(volume); + setState(() {}); }, // 통계 및 도움말 onShowStatistics: () => _showStatisticsDialog(context), @@ -1256,7 +949,7 @@ class _GamePlayScreenState extends State // Phase 8: 전투 로그 (Combat Log) _buildPanelHeader(l10n.combatLog), - Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)), + Expanded(flex: 2, child: CombatLog(entries: _combatLogController.entries)), ], ), );