refactor(engine): ActProgressionService 및 UI 컨트롤러 분리
- ActProgressionService: Act 진행 로직 추출 - GameAudioController: 오디오 제어 로직 분리 - CombatLogController: 전투 로그 관리 분리 - ProgressService, GamePlayScreen 경량화
This commit is contained in:
262
lib/src/core/engine/act_progression_service.dart
Normal file
262
lib/src/core/engine/act_progression_service.dart
Normal file
@@ -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<pq_logic.RewardKind> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
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_calculator.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/combat_tick_service.dart';
|
import 'package:asciineverdie/src/core/engine/combat_tick_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
@@ -434,7 +435,8 @@ class ProgressService {
|
|||||||
progress.plot.position >= progress.plot.max &&
|
progress.plot.position >= progress.plot.max &&
|
||||||
!progress.pendingActCompletion) {
|
!progress.pendingActCompletion) {
|
||||||
// Act Boss 소환 및 플래그 설정
|
// Act Boss 소환 및 플래그 설정
|
||||||
final actBoss = _createActBoss(nextState);
|
final actProgressionService = ActProgressionService(config: config);
|
||||||
|
final actBoss = actProgressionService.createActBoss(nextState);
|
||||||
progress = progress.copyWith(
|
progress = progress.copyWith(
|
||||||
plot: progress.plot.copyWith(position: 0), // Plot bar 리셋
|
plot: progress.plot.copyWith(position: 0), // Plot bar 리셋
|
||||||
currentCombat: actBoss,
|
currentCombat: actBoss,
|
||||||
@@ -561,7 +563,8 @@ class ProgressService {
|
|||||||
// 3. Act Boss 리트라이 체크
|
// 3. Act Boss 리트라이 체크
|
||||||
// pendingActCompletion이 true면 Act Boss 재소환
|
// pendingActCompletion이 true면 Act Boss 재소환
|
||||||
if (state.progress.pendingActCompletion) {
|
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 combatCalculator = CombatCalculator(rng: state.rng);
|
||||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||||
player: actBoss.playerStats,
|
player: actBoss.playerStats,
|
||||||
@@ -601,7 +604,8 @@ class ProgressService {
|
|||||||
if (state.progress.bossLevelingEndTime != null) {
|
if (state.progress.bossLevelingEndTime != null) {
|
||||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
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);
|
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.
|
/// Advances quest completion, applies reward, and enqueues next quest task.
|
||||||
GameState completeQuest(GameState state) {
|
GameState completeQuest(GameState state) {
|
||||||
final result = pq_logic.completeQuest(
|
final result = pq_logic.completeQuest(
|
||||||
@@ -801,127 +748,24 @@ class ProgressService {
|
|||||||
/// Advances plot to next act and applies any act-level rewards.
|
/// Advances plot to next act and applies any act-level rewards.
|
||||||
/// Returns gameComplete=true if Final Boss was defeated (game ends).
|
/// Returns gameComplete=true if Final Boss was defeated (game ends).
|
||||||
({GameState state, bool gameComplete}) completeAct(GameState state) {
|
({GameState state, bool gameComplete}) completeAct(GameState state) {
|
||||||
// Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작
|
final actProgressionService = ActProgressionService(config: config);
|
||||||
// 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(
|
// Act 보상 먼저 적용
|
||||||
plotHistory: updatedPlotHistory,
|
final actRewards = actProgressionService.getActRewards(
|
||||||
);
|
state.progress.plotStageCount,
|
||||||
|
);
|
||||||
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;
|
var nextState = state;
|
||||||
for (final reward in actResult.rewards) {
|
for (final reward in actRewards) {
|
||||||
nextState = _applyReward(nextState, reward);
|
nextState = _applyReward(nextState, reward);
|
||||||
}
|
}
|
||||||
|
|
||||||
final plotStages = nextState.progress.plotStageCount + 1;
|
// Act 완료 처리 (ActProgressionService 위임)
|
||||||
|
final result = actProgressionService.completeAct(nextState);
|
||||||
|
|
||||||
// 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가
|
return (
|
||||||
final updatedPlotHistory = [
|
state: _recalculateEncumbrance(result.state),
|
||||||
...nextState.progress.plotHistory.map(
|
gameComplete: result.gameComplete,
|
||||||
(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: _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.
|
/// Developer-only cheat hooks for quickly finishing bars.
|
||||||
@@ -1096,55 +940,6 @@ class ProgressService {
|
|||||||
return s[0].toUpperCase() + s.substring(1);
|
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)
|
/// 플레이어 사망 처리 (Phase 4)
|
||||||
///
|
///
|
||||||
/// 모든 장비 상실 및 사망 정보 기록
|
/// 모든 장비 상실 및 사망 정보 기록
|
||||||
|
|||||||
184
lib/src/features/game/controllers/combat_log_controller.dart
Normal file
184
lib/src/features/game/controllers/combat_log_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
251
lib/src/features/game/controllers/game_audio_controller.dart
Normal file
251
lib/src/features/game/controllers/game_audio_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,8 @@ import 'package:asciineverdie/data/story_data.dart';
|
|||||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.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/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/l10n/game_data_l10n.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.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/model/skill.dart';
|
||||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
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/features/game/widgets/help_dialog.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||||
import 'package:asciineverdie/src/core/audio/audio_service.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';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
||||||
@@ -85,58 +85,34 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
StoryAct _lastAct = StoryAct.prologue;
|
StoryAct _lastAct = StoryAct.prologue;
|
||||||
bool _showingCinematic = false;
|
bool _showingCinematic = false;
|
||||||
|
|
||||||
// Phase 8: 전투 로그 (Combat Log)
|
|
||||||
final List<CombatLogEntry> _combatLogEntries = [];
|
|
||||||
String _lastTaskCaption = '';
|
|
||||||
|
|
||||||
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
||||||
int _lastLevel = 0;
|
int _lastLevel = 0;
|
||||||
int _lastQuestCount = 0;
|
int _lastQuestCount = 0;
|
||||||
int _lastPlotStageCount = 0;
|
int _lastPlotStageCount = 0;
|
||||||
|
|
||||||
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
|
// Phase 2.4: 오디오 컨트롤러
|
||||||
int _lastProcessedEventCount = 0;
|
late final GameAudioController _audioController;
|
||||||
|
|
||||||
// 오디오 상태 추적 (TaskType 기반)
|
// Phase 2.5: 전투 로그 컨트롤러
|
||||||
bool _wasInBattleTask = false;
|
late final CombatLogController _combatLogController;
|
||||||
|
|
||||||
// 사망/엔딩 상태 추적 (BGM 전환용)
|
|
||||||
bool _wasDead = false;
|
|
||||||
|
|
||||||
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
|
|
||||||
final Map<String, int> _lastSfxPlayTime = {};
|
|
||||||
bool _wasComplete = false;
|
|
||||||
|
|
||||||
// 사운드 볼륨 상태 (모바일 설정 UI용)
|
|
||||||
double _bgmVolume = 0.7;
|
|
||||||
double _sfxVolume = 0.8;
|
|
||||||
|
|
||||||
void _checkSpecialEvents(GameState state) {
|
void _checkSpecialEvents(GameState state) {
|
||||||
// Phase 8: 태스크 변경 시 로그 추가
|
// Phase 8: 태스크 변경 시 로그 추가
|
||||||
final currentCaption = state.progress.currentTask.caption;
|
_combatLogController.onTaskChanged(state.progress.currentTask.caption);
|
||||||
if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) {
|
|
||||||
_addCombatLog(currentCaption, CombatLogType.normal);
|
|
||||||
_lastTaskCaption = currentCaption;
|
|
||||||
// 새 태스크 시작 시 이벤트 카운터 리셋
|
|
||||||
_lastProcessedEventCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전투 이벤트 처리 (Combat Events)
|
// 전투 이벤트 처리 (Combat Events)
|
||||||
_processCombatEvents(state);
|
_combatLogController.processCombatEvents(state.progress.currentCombat);
|
||||||
|
|
||||||
// 오디오: TaskType 변경 시 BGM 전환 (애니메이션과 동기화)
|
// 오디오: TaskType 변경 시 BGM 전환 (애니메이션과 동기화)
|
||||||
_updateBgmForTaskType(state);
|
_audioController.updateBgmForTaskType(state);
|
||||||
|
|
||||||
// 레벨업 감지
|
// 레벨업 감지
|
||||||
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
||||||
_specialAnimation = AsciiAnimationType.levelUp;
|
_specialAnimation = AsciiAnimationType.levelUp;
|
||||||
_notificationService.showLevelUp(state.traits.level);
|
_notificationService.showLevelUp(state.traits.level);
|
||||||
_addCombatLog(
|
_combatLogController.addLevelUpLog(state.traits.level);
|
||||||
'${game_l10n.uiLevelUp} Lv.${state.traits.level}',
|
|
||||||
CombatLogType.levelUp,
|
|
||||||
);
|
|
||||||
// 오디오: 레벨업 SFX (플레이어 채널)
|
// 오디오: 레벨업 SFX (플레이어 채널)
|
||||||
widget.audioService?.playPlayerSfx('level_up');
|
_audioController.playLevelUpSfx();
|
||||||
_resetSpecialAnimationAfterFrame();
|
_resetSpecialAnimationAfterFrame();
|
||||||
|
|
||||||
// Phase 9: Act 변경 감지 (레벨 기반)
|
// Phase 9: Act 변경 감지 (레벨 기반)
|
||||||
@@ -164,13 +140,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
.lastOrNull;
|
.lastOrNull;
|
||||||
if (completedQuest != null) {
|
if (completedQuest != null) {
|
||||||
_notificationService.showQuestComplete(completedQuest.caption);
|
_notificationService.showQuestComplete(completedQuest.caption);
|
||||||
_addCombatLog(
|
_combatLogController.addQuestCompleteLog(completedQuest.caption);
|
||||||
game_l10n.uiQuestComplete(completedQuest.caption),
|
|
||||||
CombatLogType.questComplete,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// 오디오: 퀘스트 완료 SFX (플레이어 채널)
|
// 오디오: 퀘스트 완료 SFX (플레이어 채널)
|
||||||
widget.audioService?.playPlayerSfx('quest_complete');
|
_audioController.playQuestCompleteSfx();
|
||||||
_resetSpecialAnimationAfterFrame();
|
_resetSpecialAnimationAfterFrame();
|
||||||
}
|
}
|
||||||
_lastQuestCount = state.progress.questCount;
|
_lastQuestCount = state.progress.questCount;
|
||||||
@@ -187,292 +160,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_lastPlotStageCount = state.progress.plotStageCount;
|
_lastPlotStageCount = state.progress.plotStageCount;
|
||||||
|
|
||||||
// 사망/엔딩 BGM 전환 (Death/Ending BGM Transition)
|
// 사망/엔딩 BGM 전환 (Death/Ending BGM Transition)
|
||||||
_updateDeathEndingBgm(state);
|
_audioController.updateDeathEndingBgm(
|
||||||
}
|
state,
|
||||||
|
isGameComplete: widget.controller.isComplete,
|
||||||
/// 사망/엔딩 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),
|
|
||||||
);
|
);
|
||||||
// 최대 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)
|
/// Phase 9: Act 시네마틱 표시 (Show Act Cinematic)
|
||||||
@@ -484,7 +175,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
await widget.controller.pause(saveOnStop: false);
|
await widget.controller.pause(saveOnStop: false);
|
||||||
|
|
||||||
// 시네마틱 BGM 재생
|
// 시네마틱 BGM 재생
|
||||||
widget.audioService?.playBgm('act_cinemetic');
|
_audioController.playCinematicBgm();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await showActCinematic(context, act);
|
await showActCinematic(context, act);
|
||||||
@@ -520,6 +211,18 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_notificationService = NotificationService();
|
_notificationService = NotificationService();
|
||||||
_storyService = StoryService();
|
_storyService = StoryService();
|
||||||
|
|
||||||
|
// 오디오 컨트롤러 초기화
|
||||||
|
_audioController = GameAudioController(
|
||||||
|
audioService: widget.audioService,
|
||||||
|
getSpeedMultiplier: () => widget.controller.loop?.speedMultiplier ?? 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 로그 컨트롤러 초기화
|
||||||
|
_combatLogController = CombatLogController(
|
||||||
|
onCombatEvent: (event) => _audioController.playCombatEventSfx(event),
|
||||||
|
);
|
||||||
|
|
||||||
widget.controller.addListener(_onControllerChanged);
|
widget.controller.addListener(_onControllerChanged);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
@@ -531,8 +234,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_lastPlotStageCount = state.progress.plotStageCount;
|
_lastPlotStageCount = state.progress.plotStageCount;
|
||||||
_lastAct = getActForLevel(state.traits.level);
|
_lastAct = getActForLevel(state.traits.level);
|
||||||
|
|
||||||
// 초기 BGM 재생 (TaskType 기반, _wasInBattleTask도 함께 설정)
|
// 초기 BGM 재생 (TaskType 기반)
|
||||||
_playInitialBgm(state);
|
_audioController.playInitialBgm(state);
|
||||||
} else {
|
} else {
|
||||||
// 상태가 없으면 기본 마을 BGM
|
// 상태가 없으면 기본 마을 BGM
|
||||||
widget.audioService?.playBgm('town');
|
widget.audioService?.playBgm('town');
|
||||||
@@ -542,17 +245,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
widget.controller.loadCumulativeStats();
|
widget.controller.loadCumulativeStats();
|
||||||
|
|
||||||
// 오디오 볼륨 초기화
|
// 오디오 볼륨 초기화
|
||||||
_initAudioVolumes();
|
_audioController.initVolumes();
|
||||||
}
|
|
||||||
|
|
||||||
/// 오디오 볼륨 초기화 (설정에서 로드)
|
|
||||||
Future<void> _initAudioVolumes() async {
|
|
||||||
final audio = widget.audioService;
|
|
||||||
if (audio != null) {
|
|
||||||
_bgmVolume = audio.bgmVolume;
|
|
||||||
_sfxVolume = audio.sfxVolume;
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -592,13 +285,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
// 모바일: 게임 일시정지 + 전체 오디오 정지
|
// 모바일: 게임 일시정지 + 전체 오디오 정지
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
widget.controller.pause(saveOnStop: false);
|
widget.controller.pause(saveOnStop: false);
|
||||||
widget.audioService?.pauseAll();
|
_audioController.pauseAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드
|
// 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드
|
||||||
if (appState == AppLifecycleState.resumed && isMobile) {
|
if (appState == AppLifecycleState.resumed && isMobile) {
|
||||||
widget.audioService?.resumeAll();
|
_audioController.resumeAll();
|
||||||
_reloadGameScreen();
|
_reloadGameScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -745,12 +438,12 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBgmVolumeChange: (volume) {
|
onBgmVolumeChange: (volume) {
|
||||||
setState(() => _bgmVolume = volume);
|
_audioController.setBgmVolume(volume);
|
||||||
widget.audioService?.setBgmVolume(volume);
|
setState(() {});
|
||||||
},
|
},
|
||||||
onSfxVolumeChange: (volume) {
|
onSfxVolumeChange: (volume) {
|
||||||
setState(() => _sfxVolume = volume);
|
_audioController.setSfxVolume(volume);
|
||||||
widget.audioService?.setSfxVolume(volume);
|
setState(() {});
|
||||||
},
|
},
|
||||||
onCreateTestCharacter: () async {
|
onCreateTestCharacter: () async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
@@ -824,7 +517,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
children: [
|
children: [
|
||||||
MobileCarouselLayout(
|
MobileCarouselLayout(
|
||||||
state: state,
|
state: state,
|
||||||
combatLogEntries: _combatLogEntries,
|
combatLogEntries: _combatLogController.entries,
|
||||||
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
|
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
|
||||||
onSpeedCycle: () {
|
onSpeedCycle: () {
|
||||||
widget.controller.loop?.cycleSpeed();
|
widget.controller.loop?.cycleSpeed();
|
||||||
@@ -884,15 +577,15 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
currentThemeMode: widget.currentThemeMode,
|
currentThemeMode: widget.currentThemeMode,
|
||||||
onThemeModeChange: widget.onThemeModeChange,
|
onThemeModeChange: widget.onThemeModeChange,
|
||||||
// 사운드 설정
|
// 사운드 설정
|
||||||
bgmVolume: _bgmVolume,
|
bgmVolume: _audioController.bgmVolume,
|
||||||
sfxVolume: _sfxVolume,
|
sfxVolume: _audioController.sfxVolume,
|
||||||
onBgmVolumeChange: (volume) {
|
onBgmVolumeChange: (volume) {
|
||||||
setState(() => _bgmVolume = volume);
|
_audioController.setBgmVolume(volume);
|
||||||
widget.audioService?.setBgmVolume(volume);
|
setState(() {});
|
||||||
},
|
},
|
||||||
onSfxVolumeChange: (volume) {
|
onSfxVolumeChange: (volume) {
|
||||||
setState(() => _sfxVolume = volume);
|
_audioController.setSfxVolume(volume);
|
||||||
widget.audioService?.setSfxVolume(volume);
|
setState(() {});
|
||||||
},
|
},
|
||||||
// 통계 및 도움말
|
// 통계 및 도움말
|
||||||
onShowStatistics: () => _showStatisticsDialog(context),
|
onShowStatistics: () => _showStatisticsDialog(context),
|
||||||
@@ -1256,7 +949,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
|
|
||||||
// Phase 8: 전투 로그 (Combat Log)
|
// Phase 8: 전투 로그 (Combat Log)
|
||||||
_buildPanelHeader(l10n.combatLog),
|
_buildPanelHeader(l10n.combatLog),
|
||||||
Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)),
|
Expanded(flex: 2, child: CombatLog(entries: _combatLogController.entries)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user