refactor(engine): ActProgressionService 및 UI 컨트롤러 분리

- ActProgressionService: Act 진행 로직 추출
- GameAudioController: 오디오 제어 로직 분리
- CombatLogController: 전투 로그 관리 분리
- ProgressService, GamePlayScreen 경량화
This commit is contained in:
JiWoong Sul
2026-01-15 17:05:19 +09:00
parent a41984d998
commit f466e1c408
5 changed files with 761 additions and 576 deletions

View File

@@ -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<CombatLogEntry> _entries = [];
// 이벤트 처리 추적
int _lastProcessedEventCount = 0;
String _lastTaskCaption = '';
/// 로그 엔트리 목록 (읽기 전용)
List<CombatLogEntry> 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();
}
}

View File

@@ -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<String, int> _lastSfxPlayTime = {};
// Getters
double get bgmVolume => _bgmVolume;
double get sfxVolume => _sfxVolume;
/// 오디오 볼륨 초기화 (AudioService에서 로드)
Future<void> 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();
}
}

View File

@@ -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<GamePlayScreen>
StoryAct _lastAct = StoryAct.prologue;
bool _showingCinematic = false;
// Phase 8: 전투 로그 (Combat Log)
final List<CombatLogEntry> _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<String, int> _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<GamePlayScreen>
.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<GamePlayScreen>
_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<GamePlayScreen>
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<GamePlayScreen>
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<GamePlayScreen>
_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<GamePlayScreen>
widget.controller.loadCumulativeStats();
// 오디오 볼륨 초기화
_initAudioVolumes();
}
/// 오디오 볼륨 초기화 (설정에서 로드)
Future<void> _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<GamePlayScreen>
// 모바일: 게임 일시정지 + 전체 오디오 정지
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<GamePlayScreen>
}
},
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<GamePlayScreen>
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<GamePlayScreen>
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<GamePlayScreen>
// Phase 8: 전투 로그 (Combat Log)
_buildPanelHeader(l10n.combatLog),
Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)),
Expanded(flex: 2, child: CombatLog(entries: _combatLogController.entries)),
],
),
);