- SkillData 조정 - CombatCalculator 개선 - ItemService 업데이트 - ProgressService 개선 - SkillService 정리
1593 lines
53 KiB
Dart
1593 lines
53 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
|
import 'package:asciineverdie/data/skill_data.dart';
|
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
|
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
|
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
|
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
|
import 'package:asciineverdie/src/core/model/combat_event.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/equipment_item.dart';
|
|
import 'package:asciineverdie/src/core/model/equipment_slot.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/potion.dart';
|
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
|
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
|
|
|
class ProgressTickResult {
|
|
const ProgressTickResult({
|
|
required this.state,
|
|
this.leveledUp = false,
|
|
this.completedQuest = false,
|
|
this.completedAct = false,
|
|
this.playerDied = false,
|
|
this.gameComplete = false,
|
|
});
|
|
|
|
final GameState state;
|
|
final bool leveledUp;
|
|
final bool completedQuest;
|
|
final bool completedAct;
|
|
|
|
/// 플레이어 사망 여부 (Phase 4)
|
|
final bool playerDied;
|
|
|
|
/// 게임 클리어 여부 (Act V 완료)
|
|
final bool gameComplete;
|
|
|
|
bool get shouldAutosave =>
|
|
leveledUp || completedQuest || completedAct || playerDied || gameComplete;
|
|
}
|
|
|
|
/// Drives quest/plot/task progression by applying queued actions and rewards.
|
|
class ProgressService {
|
|
ProgressService({
|
|
required this.config,
|
|
required this.mutations,
|
|
required this.rewards,
|
|
});
|
|
|
|
final PqConfig config;
|
|
final GameMutations mutations;
|
|
final RewardService rewards;
|
|
|
|
/// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767)
|
|
/// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작
|
|
GameState initializeNewGame(GameState state) {
|
|
// 초기 큐 설정 - ASCII NEVER DIE 세계관 프롤로그 (l10n 지원)
|
|
final prologueTexts = l10n.prologueTexts;
|
|
final initialQueue = <QueueEntry>[
|
|
QueueEntry(
|
|
kind: QueueKind.task,
|
|
durationMillis: 10 * 1000,
|
|
caption: prologueTexts[0],
|
|
taskType: TaskType.load,
|
|
),
|
|
QueueEntry(
|
|
kind: QueueKind.task,
|
|
durationMillis: 6 * 1000,
|
|
caption: prologueTexts[1],
|
|
taskType: TaskType.load,
|
|
),
|
|
QueueEntry(
|
|
kind: QueueKind.task,
|
|
durationMillis: 6 * 1000,
|
|
caption: prologueTexts[2],
|
|
taskType: TaskType.load,
|
|
),
|
|
QueueEntry(
|
|
kind: QueueKind.task,
|
|
durationMillis: 4 * 1000,
|
|
caption: prologueTexts[3],
|
|
taskType: TaskType.load,
|
|
),
|
|
QueueEntry(
|
|
kind: QueueKind.plot,
|
|
durationMillis: 2 * 1000,
|
|
caption: l10n.taskCompiling,
|
|
taskType: TaskType.plot,
|
|
),
|
|
];
|
|
|
|
// 첫 번째 태스크 시작 (원본 752줄)
|
|
final taskResult = pq_logic.startTask(
|
|
state.progress,
|
|
l10n.taskCompiling,
|
|
2 * 1000,
|
|
);
|
|
|
|
// ExpBar 초기화 (원본 743-746줄)
|
|
final expBar =
|
|
ProgressBarState(position: 0, max: ExpConstants.requiredExp(1));
|
|
|
|
// PlotBar 초기화 - Prologue 5분 (300초)
|
|
final plotBar = const ProgressBarState(position: 0, max: 300);
|
|
|
|
final progress = taskResult.progress.copyWith(
|
|
exp: expBar,
|
|
plot: plotBar,
|
|
currentTask: TaskInfo(
|
|
caption: '${l10n.taskCompiling}...',
|
|
type: TaskType.load,
|
|
),
|
|
plotStageCount: 1, // Prologue
|
|
questCount: 0,
|
|
plotHistory: [
|
|
HistoryEntry(caption: l10n.taskPrologue, isComplete: false),
|
|
],
|
|
questHistory: const [],
|
|
);
|
|
|
|
return _recalculateEncumbrance(
|
|
state.copyWith(
|
|
progress: progress,
|
|
queue: QueueState(entries: initialQueue),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Starts a task and tags its type (kill, plot, load, neutral).
|
|
GameState startTask(
|
|
GameState state, {
|
|
required String caption,
|
|
required int durationMillis,
|
|
TaskType taskType = TaskType.neutral,
|
|
}) {
|
|
final taskResult = pq_logic.startTask(
|
|
state.progress,
|
|
caption,
|
|
durationMillis,
|
|
);
|
|
final progress = taskResult.progress.copyWith(
|
|
currentTask: TaskInfo(caption: taskResult.caption, type: taskType),
|
|
);
|
|
return state.copyWith(progress: progress);
|
|
}
|
|
|
|
/// Tick the timer loop (equivalent to Timer1Timer in the original code).
|
|
ProgressTickResult tick(GameState state, int elapsedMillis) {
|
|
// 10000ms 제한: 100x 배속 (50ms * 100 = 5000ms) + 여유 공간
|
|
// 디버그 터보 모드(100x) 지원을 위해 확장
|
|
final int clamped = elapsedMillis.clamp(0, 10000).toInt();
|
|
var progress = state.progress;
|
|
var queue = state.queue;
|
|
var nextState = state;
|
|
var leveledUp = false;
|
|
var questDone = false;
|
|
var actDone = false;
|
|
var gameComplete = false;
|
|
|
|
// 스킬 시스템 시간 업데이트 (Phase 3)
|
|
final skillService = SkillService(rng: state.rng);
|
|
var skillSystem = skillService.updateElapsedTime(
|
|
state.skillSystem,
|
|
clamped,
|
|
);
|
|
|
|
// 만료된 버프 정리
|
|
skillSystem = skillService.cleanupExpiredBuffs(skillSystem);
|
|
|
|
// 비전투 시 MP 회복
|
|
final isInCombat =
|
|
progress.currentTask.type == TaskType.kill &&
|
|
progress.currentCombat != null &&
|
|
progress.currentCombat!.isActive;
|
|
|
|
if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) {
|
|
final mpRegen = skillService.calculateMpRegen(
|
|
elapsedMs: clamped,
|
|
isInCombat: false,
|
|
wis: nextState.stats.wis,
|
|
);
|
|
if (mpRegen > 0) {
|
|
final newMp = (nextState.stats.mp + mpRegen).clamp(
|
|
0,
|
|
nextState.stats.mpMax,
|
|
);
|
|
nextState = nextState.copyWith(
|
|
stats: nextState.stats.copyWith(mpCurrent: newMp),
|
|
);
|
|
}
|
|
}
|
|
|
|
nextState = nextState.copyWith(skillSystem: skillSystem);
|
|
|
|
// Advance task bar if still running.
|
|
if (progress.task.position < progress.task.max) {
|
|
final uncapped = progress.task.position + clamped;
|
|
final int newTaskPos = uncapped > progress.task.max
|
|
? progress.task.max
|
|
: uncapped;
|
|
|
|
// 킬 태스크 중 전투 진행 (스킬 자동 사용, DOT, 물약 포함)
|
|
var updatedCombat = progress.currentCombat;
|
|
var updatedSkillSystem = nextState.skillSystem;
|
|
var updatedPotionInventory = nextState.potionInventory;
|
|
if (progress.currentTask.type == TaskType.kill &&
|
|
updatedCombat != null &&
|
|
updatedCombat.isActive) {
|
|
final combatResult = _processCombatTickWithSkills(
|
|
nextState,
|
|
updatedCombat,
|
|
updatedSkillSystem,
|
|
clamped,
|
|
);
|
|
updatedCombat = combatResult.combat;
|
|
updatedSkillSystem = combatResult.skillSystem;
|
|
if (combatResult.potionInventory != null) {
|
|
updatedPotionInventory = combatResult.potionInventory!;
|
|
}
|
|
|
|
// Phase 4: 플레이어 사망 체크
|
|
if (!updatedCombat.playerStats.isAlive) {
|
|
final monsterName = updatedCombat.monsterStats.name;
|
|
nextState = _processPlayerDeath(
|
|
nextState,
|
|
killerName: monsterName,
|
|
cause: DeathCause.monster,
|
|
);
|
|
return ProgressTickResult(state: nextState, playerDied: true);
|
|
}
|
|
}
|
|
|
|
progress = progress.copyWith(
|
|
task: progress.task.copyWith(position: newTaskPos),
|
|
currentCombat: updatedCombat,
|
|
);
|
|
nextState = _recalculateEncumbrance(
|
|
nextState.copyWith(
|
|
progress: progress,
|
|
skillSystem: updatedSkillSystem,
|
|
potionInventory: updatedPotionInventory,
|
|
),
|
|
);
|
|
return ProgressTickResult(state: nextState);
|
|
}
|
|
|
|
final gain = progress.currentTask.type == TaskType.kill;
|
|
final incrementSeconds = progress.task.max ~/ 1000;
|
|
|
|
// 몬스터 경험치 미리 저장 (currentCombat이 null되기 전)
|
|
final int monsterExpReward =
|
|
progress.currentCombat?.monsterStats.expReward ?? 0;
|
|
|
|
// 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득
|
|
if (gain) {
|
|
// 전투 결과에 따라 플레이어 HP 업데이트 + 전투 후 회복
|
|
final combat = progress.currentCombat;
|
|
if (combat != null && combat.isActive) {
|
|
// 전투 중 남은 HP
|
|
final remainingHp = combat.playerStats.hpCurrent;
|
|
final maxHp = combat.playerStats.hpMax;
|
|
|
|
// 전투 승리 시 HP 회복 (50% + CON/2)
|
|
// 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능
|
|
final conBonus = nextState.stats.con ~/ 2;
|
|
final healAmount = (maxHp * 0.5).round() + conBonus;
|
|
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
|
|
|
|
nextState = nextState.copyWith(
|
|
stats: nextState.stats.copyWith(hpCurrent: newHp),
|
|
);
|
|
}
|
|
|
|
// 전리품 획득 (원본 Main.pas:625-630)
|
|
final lootResult = _winLoot(nextState);
|
|
nextState = lootResult.state;
|
|
|
|
// 물약 드랍 시 전투 로그에 이벤트 추가
|
|
var combatForReset = progress.currentCombat;
|
|
if (lootResult.droppedPotion != null && combatForReset != null) {
|
|
final potionDropEvent = CombatEvent.potionDrop(
|
|
timestamp: nextState.skillSystem.elapsedMs,
|
|
potionName: lootResult.droppedPotion!.name,
|
|
isHp: lootResult.droppedPotion!.isHpPotion,
|
|
);
|
|
final updatedEvents = [...combatForReset.recentEvents, potionDropEvent];
|
|
combatForReset = combatForReset.copyWith(
|
|
recentEvents: updatedEvents.length > 10
|
|
? updatedEvents.sublist(updatedEvents.length - 10)
|
|
: updatedEvents,
|
|
);
|
|
progress = progress.copyWith(currentCombat: combatForReset);
|
|
}
|
|
|
|
// 전투 상태 초기화, 몬스터 처치 수 증가 및 물약 사용 기록 초기화
|
|
progress = progress.copyWith(
|
|
currentCombat: null,
|
|
monstersKilled: progress.monstersKilled + 1,
|
|
);
|
|
final resetPotionInventory = nextState.potionInventory.resetBattleUsage();
|
|
nextState = nextState.copyWith(
|
|
progress: progress,
|
|
potionInventory: resetPotionInventory,
|
|
);
|
|
|
|
// 최종 보스 처치 체크
|
|
if (progress.finalBossState == FinalBossState.fighting) {
|
|
// 글리치 갓 처치 완료 - 게임 클리어
|
|
progress = progress.copyWith(
|
|
finalBossState: FinalBossState.defeated,
|
|
);
|
|
nextState = nextState.copyWith(progress: progress);
|
|
|
|
// completeAct를 호출하여 게임 완료 처리
|
|
final actResult = completeAct(nextState);
|
|
nextState = actResult.state;
|
|
|
|
return ProgressTickResult(
|
|
state: nextState,
|
|
leveledUp: false,
|
|
completedQuest: false,
|
|
completedAct: true,
|
|
gameComplete: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649)
|
|
final taskType = progress.currentTask.type;
|
|
if (taskType == TaskType.buying) {
|
|
// 장비 구매 완료 (원본 631-634)
|
|
nextState = _completeBuying(nextState);
|
|
progress = nextState.progress;
|
|
} else if (taskType == TaskType.market || taskType == TaskType.sell) {
|
|
// 시장 도착 또는 판매 완료 (원본 635-649)
|
|
final sellResult = _processSell(nextState);
|
|
nextState = sellResult.state;
|
|
progress = nextState.progress;
|
|
queue = nextState.queue;
|
|
|
|
// 판매 중이면 다른 로직 건너뛰기
|
|
if (sellResult.continuesSelling) {
|
|
nextState = _recalculateEncumbrance(
|
|
nextState.copyWith(progress: progress, queue: queue),
|
|
);
|
|
return ProgressTickResult(
|
|
state: nextState,
|
|
leveledUp: false,
|
|
completedQuest: false,
|
|
completedAct: false,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Gain XP / level up (몬스터 경험치 기반)
|
|
// 최대 레벨(100) 제한: 100레벨에서는 더 이상 레벨업하지 않음
|
|
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
|
|
final newExpPos = progress.exp.position + monsterExpReward;
|
|
|
|
// 레벨업 체크 (경험치가 필요량 이상일 때)
|
|
if (newExpPos >= progress.exp.max) {
|
|
// 초과 경험치 계산
|
|
final overflowExp = newExpPos - progress.exp.max;
|
|
nextState = _levelUp(nextState);
|
|
leveledUp = true;
|
|
progress = nextState.progress;
|
|
|
|
// 초과 경험치를 다음 레벨에 적용
|
|
if (overflowExp > 0 && nextState.traits.level < 100) {
|
|
progress = progress.copyWith(
|
|
exp: progress.exp.copyWith(position: overflowExp),
|
|
);
|
|
}
|
|
} else {
|
|
progress = progress.copyWith(
|
|
exp: progress.exp.copyWith(position: newExpPos),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Advance quest bar after Act I.
|
|
final canQuestProgress =
|
|
gain &&
|
|
progress.plotStageCount > 1 &&
|
|
progress.questCount > 0 &&
|
|
progress.quest.max > 0;
|
|
if (canQuestProgress) {
|
|
if (progress.quest.position + incrementSeconds >= progress.quest.max) {
|
|
nextState = completeQuest(nextState);
|
|
questDone = true;
|
|
progress = nextState.progress;
|
|
queue = nextState.queue;
|
|
} else {
|
|
progress = progress.copyWith(
|
|
quest: progress.quest.copyWith(
|
|
position: progress.quest.position + incrementSeconds,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 플롯(plot) 바가 완료되면 InterplotCinematic 트리거
|
|
// (원본 Main.pas:1301-1304)
|
|
if (gain &&
|
|
progress.plot.max > 0 &&
|
|
progress.plot.position >= progress.plot.max) {
|
|
// InterplotCinematic을 호출하여 시네마틱 이벤트 큐에 추가
|
|
final cinematicEntries = pq_logic.interplotCinematic(
|
|
config,
|
|
nextState.rng,
|
|
nextState.traits.level,
|
|
nextState.progress.plotStageCount,
|
|
);
|
|
queue = QueueState(entries: [...queue.entries, ...cinematicEntries]);
|
|
// 플롯 바를 0으로 리셋하지 않음 - completeAct에서 처리됨
|
|
} else if (progress.currentTask.type != TaskType.load &&
|
|
progress.plot.max > 0) {
|
|
final uncappedPlot = progress.plot.position + incrementSeconds;
|
|
final int newPlotPos = uncappedPlot > progress.plot.max
|
|
? progress.plot.max
|
|
: uncappedPlot;
|
|
progress = progress.copyWith(
|
|
plot: progress.plot.copyWith(position: newPlotPos),
|
|
);
|
|
}
|
|
|
|
// Dequeue next scripted task if available.
|
|
final dq = pq_logic.dequeue(progress, queue);
|
|
if (dq != null) {
|
|
progress = dq.progress.copyWith(
|
|
currentTask: TaskInfo(caption: dq.caption, type: dq.taskType),
|
|
);
|
|
queue = dq.queue;
|
|
|
|
// plot 타입이 dequeue 되면 completeAct 실행 (원본 Main.pas 로직)
|
|
if (dq.kind == QueueKind.plot) {
|
|
nextState = nextState.copyWith(progress: progress, queue: queue);
|
|
final actResult = completeAct(nextState);
|
|
nextState = actResult.state;
|
|
actDone = true;
|
|
gameComplete = actResult.gameComplete;
|
|
progress = nextState.progress;
|
|
queue = nextState.queue;
|
|
}
|
|
} else {
|
|
// 큐가 비어있으면 새 태스크 생성 (원본 Dequeue 667-684줄)
|
|
nextState = nextState.copyWith(progress: progress, queue: queue);
|
|
final newTaskResult = _generateNextTask(nextState);
|
|
progress = newTaskResult.progress;
|
|
queue = newTaskResult.queue;
|
|
}
|
|
|
|
nextState = _recalculateEncumbrance(
|
|
nextState.copyWith(progress: progress, queue: queue),
|
|
);
|
|
|
|
return ProgressTickResult(
|
|
state: nextState,
|
|
leveledUp: leveledUp,
|
|
completedQuest: questDone,
|
|
completedAct: actDone,
|
|
gameComplete: gameComplete,
|
|
);
|
|
}
|
|
|
|
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
|
({ProgressState progress, QueueState queue}) _generateNextTask(
|
|
GameState state,
|
|
) {
|
|
var progress = state.progress;
|
|
final queue = state.queue;
|
|
final oldTaskType = progress.currentTask.type;
|
|
|
|
// 1. Encumbrance가 가득 찼으면 시장으로 이동 (원본 667-669줄)
|
|
if (progress.encumbrance.position >= progress.encumbrance.max &&
|
|
progress.encumbrance.max > 0) {
|
|
final taskResult = pq_logic.startTask(
|
|
progress,
|
|
l10n.taskHeadingToMarket(),
|
|
4 * 1000,
|
|
);
|
|
progress = taskResult.progress.copyWith(
|
|
currentTask: TaskInfo(
|
|
caption: taskResult.caption,
|
|
type: TaskType.market,
|
|
),
|
|
);
|
|
return (progress: progress, queue: queue);
|
|
}
|
|
|
|
// 2. kill 태스크가 아니었고 heading도 아니면 heading 또는 buying 태스크 실행
|
|
// (원본 670-677줄)
|
|
if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral) {
|
|
// Gold가 충분하면 장비 구매 (원본 671-673줄)
|
|
final gold = _getGold(state);
|
|
final equipPrice = _equipPrice(state.traits.level);
|
|
if (gold > equipPrice) {
|
|
final taskResult = pq_logic.startTask(
|
|
progress,
|
|
l10n.taskUpgradingHardware(),
|
|
5 * 1000,
|
|
);
|
|
progress = taskResult.progress.copyWith(
|
|
currentTask: TaskInfo(
|
|
caption: taskResult.caption,
|
|
type: TaskType.buying,
|
|
),
|
|
);
|
|
return (progress: progress, queue: queue);
|
|
}
|
|
|
|
// Gold가 부족하면 전장으로 이동 (원본 674-676줄)
|
|
final taskResult = pq_logic.startTask(
|
|
progress,
|
|
l10n.taskEnteringDebugZone(),
|
|
4 * 1000,
|
|
);
|
|
progress = taskResult.progress.copyWith(
|
|
currentTask: TaskInfo(
|
|
caption: taskResult.caption,
|
|
type: TaskType.neutral,
|
|
),
|
|
);
|
|
return (progress: progress, queue: queue);
|
|
}
|
|
|
|
// 3. 최종 보스 전투 체크
|
|
// finalBossState == fighting이면 Glitch God 스폰
|
|
if (state.progress.finalBossState == FinalBossState.fighting) {
|
|
return _startFinalBossFight(state, progress, queue);
|
|
}
|
|
|
|
// 4. MonsterTask 실행 (원본 678-684줄)
|
|
final level = state.traits.level;
|
|
|
|
// 원본 Main.pas:548-551: 25% 확률로 Quest Monster 사용
|
|
// fQuest.Caption이 비어있지 않으면 해당 몬스터 데이터 전달
|
|
final questMonster = state.progress.currentQuestMonster;
|
|
final questMonsterData = questMonster?.monsterData;
|
|
final questLevel = questMonsterData != null
|
|
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
|
0
|
|
: null;
|
|
|
|
final monsterResult = pq_logic.monsterTask(
|
|
config,
|
|
state.rng,
|
|
level,
|
|
questMonsterData,
|
|
questLevel,
|
|
);
|
|
|
|
// 전투용 몬스터 레벨 조정 (밸런스)
|
|
// Act별 최소 레벨과 플레이어 레벨 중 큰 값을 기준으로 ±3 범위 제한
|
|
final actMinLevel = ActMonsterLevel.forPlotStage(
|
|
state.progress.plotStageCount,
|
|
);
|
|
final baseLevel = math.max(level, actMinLevel);
|
|
final effectiveMonsterLevel = monsterResult.level
|
|
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
|
.toInt();
|
|
|
|
// 전투 스탯 생성 (Phase 12: 몬스터 레벨 기반 페널티 적용)
|
|
final playerCombatStats = CombatStats.fromStats(
|
|
stats: state.stats,
|
|
equipment: state.equipment,
|
|
level: level,
|
|
monsterLevel: effectiveMonsterLevel,
|
|
);
|
|
|
|
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
|
name: monsterResult.displayName,
|
|
level: effectiveMonsterLevel,
|
|
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
|
);
|
|
|
|
// 전투 상태 초기화
|
|
final combatState = CombatState.start(
|
|
playerStats: playerCombatStats,
|
|
monsterStats: monsterCombatStats,
|
|
);
|
|
|
|
// 태스크 지속시간 계산 (CombatCalculator 기반)
|
|
final combatCalculator = CombatCalculator(rng: state.rng);
|
|
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
|
player: playerCombatStats,
|
|
monster: monsterCombatStats,
|
|
);
|
|
|
|
final taskResult = pq_logic.startTask(
|
|
progress,
|
|
l10n.taskDebugging(monsterResult.displayName),
|
|
durationMillis,
|
|
);
|
|
|
|
progress = taskResult.progress.copyWith(
|
|
currentTask: TaskInfo(
|
|
caption: taskResult.caption,
|
|
type: TaskType.kill,
|
|
monsterBaseName: monsterResult.baseName,
|
|
monsterPart: monsterResult.part,
|
|
monsterLevel: monsterResult.level,
|
|
monsterGrade: monsterResult.grade,
|
|
),
|
|
currentCombat: combatState,
|
|
);
|
|
|
|
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(
|
|
config,
|
|
state.rng,
|
|
state.traits.level,
|
|
);
|
|
|
|
var nextState = _applyReward(state, result.reward);
|
|
final questCount = nextState.progress.questCount + 1;
|
|
|
|
// 퀘스트 히스토리 업데이트: 이전 퀘스트 완료 표시, 새 퀘스트 추가
|
|
final updatedQuestHistory = [
|
|
...nextState.progress.questHistory.map(
|
|
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
|
|
),
|
|
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;
|
|
|
|
// Append quest entry to queue (task kind).
|
|
final updatedQueue = QueueState(
|
|
entries: [
|
|
...nextState.queue.entries,
|
|
QueueEntry(
|
|
kind: QueueKind.task,
|
|
durationMillis: 50 + nextState.rng.nextInt(100),
|
|
caption: result.caption,
|
|
taskType: TaskType.neutral,
|
|
),
|
|
],
|
|
);
|
|
|
|
// Update quest progress bar with reset position.
|
|
final progress = nextState.progress.copyWith(
|
|
quest: ProgressBarState(
|
|
position: 0,
|
|
max: 50 + nextState.rng.nextInt(100),
|
|
),
|
|
questCount: questCount,
|
|
questHistory: updatedQuestHistory,
|
|
currentQuestMonster: questMonster,
|
|
);
|
|
|
|
return _recalculateEncumbrance(
|
|
nextState.copyWith(progress: progress, queue: updatedQueue),
|
|
);
|
|
}
|
|
|
|
/// 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 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;
|
|
for (final reward in actResult.rewards) {
|
|
nextState = _applyReward(nextState, reward);
|
|
}
|
|
|
|
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: _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.
|
|
GameState forceTaskComplete(GameState state) {
|
|
final progress = state.progress.copyWith(
|
|
task: state.progress.task.copyWith(position: state.progress.task.max),
|
|
);
|
|
return state.copyWith(progress: progress);
|
|
}
|
|
|
|
GameState forceQuestComplete(GameState state) {
|
|
final progress = state.progress.copyWith(
|
|
task: state.progress.task.copyWith(position: state.progress.task.max),
|
|
quest: state.progress.quest.copyWith(position: state.progress.quest.max),
|
|
);
|
|
return state.copyWith(progress: progress);
|
|
}
|
|
|
|
GameState forcePlotComplete(GameState state) {
|
|
// 다음 Act의 최소 몬스터 레벨까지 레벨업
|
|
final nextPlotStage = state.progress.plotStageCount + 1;
|
|
final targetLevel = ActMonsterLevel.forPlotStage(nextPlotStage);
|
|
var nextState = state;
|
|
|
|
// 현재 레벨이 목표 레벨보다 낮으면 레벨업 (최대 100레벨)
|
|
while (nextState.traits.level < targetLevel && nextState.traits.level < 100) {
|
|
nextState = _levelUp(nextState);
|
|
}
|
|
|
|
// 모든 장비 슬롯을 목표 레벨에 맞는 장비로 교체 (전투 보상 드랍 공식 사용)
|
|
final equipLevel = nextState.traits.level;
|
|
for (var slotIndex = 0; slotIndex < Equipment.slotCount; slotIndex++) {
|
|
nextState = mutations.winEquipByIndex(nextState, equipLevel, slotIndex);
|
|
}
|
|
|
|
// 태스크 바 완료 처리
|
|
var progress = nextState.progress.copyWith(
|
|
task: nextState.progress.task.copyWith(
|
|
position: nextState.progress.task.max,
|
|
),
|
|
);
|
|
nextState = nextState.copyWith(progress: progress);
|
|
|
|
// 디버그 모드에서는 completeAct 직접 호출하여 plotStageCount 즉시 업데이트
|
|
// 시네마틱은 생략하고 바로 다음 Act로 진입
|
|
final actResult = completeAct(nextState);
|
|
return actResult.state;
|
|
}
|
|
|
|
GameState _applyReward(GameState state, pq_logic.RewardKind reward) {
|
|
final updated = rewards.applyReward(state, reward);
|
|
return _recalculateEncumbrance(updated);
|
|
}
|
|
|
|
GameState _levelUp(GameState state) {
|
|
// 최대 레벨(100) 안전장치: 이미 100레벨이면 레벨업하지 않음
|
|
if (state.traits.level >= 100) {
|
|
return state;
|
|
}
|
|
|
|
final nextLevel = state.traits.level + 1;
|
|
final rng = state.rng;
|
|
final hpGain = state.stats.con ~/ 3 + 1 + rng.nextInt(4);
|
|
final mpGain = state.stats.intelligence ~/ 3 + 1 + rng.nextInt(4);
|
|
|
|
var nextState = state.copyWith(
|
|
traits: state.traits.copyWith(level: nextLevel),
|
|
stats: state.stats.copyWith(
|
|
hpMax: state.stats.hpMax + hpGain,
|
|
mpMax: state.stats.mpMax + mpGain,
|
|
),
|
|
);
|
|
|
|
// Win two stats and a spell, matching the original leveling rules.
|
|
nextState = mutations.winStat(nextState);
|
|
nextState = mutations.winStat(nextState);
|
|
nextState = mutations.winSpell(nextState, nextState.stats.wis, nextLevel);
|
|
|
|
final expBar = ProgressBarState(
|
|
position: 0,
|
|
max: ExpConstants.requiredExp(nextLevel),
|
|
);
|
|
final progress = nextState.progress.copyWith(exp: expBar);
|
|
nextState = nextState.copyWith(progress: progress);
|
|
return _recalculateEncumbrance(nextState);
|
|
}
|
|
|
|
GameState _recalculateEncumbrance(GameState state) {
|
|
// items에는 Gold가 포함되지 않음 (inventory.gold 필드로 관리)
|
|
final encumValue = state.inventory.items.fold<int>(
|
|
0,
|
|
(sum, item) => sum + item.count,
|
|
);
|
|
final encumMax = 10 + state.stats.str;
|
|
final encumBar = state.progress.encumbrance.copyWith(
|
|
position: encumValue,
|
|
max: encumMax,
|
|
);
|
|
final progress = state.progress.copyWith(encumbrance: encumBar);
|
|
return state.copyWith(progress: progress);
|
|
}
|
|
|
|
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
|
/// 전리품 획득 결과
|
|
///
|
|
/// [state] 업데이트된 게임 상태
|
|
/// [droppedPotion] 드랍된 물약 (없으면 null)
|
|
({GameState state, Potion? droppedPotion}) _winLoot(GameState state) {
|
|
final taskInfo = state.progress.currentTask;
|
|
final monsterPart = taskInfo.monsterPart ?? '';
|
|
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
|
|
|
var resultState = state;
|
|
|
|
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
|
if (monsterPart == '*') {
|
|
resultState = mutations.winItem(resultState);
|
|
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
|
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
|
// ProperCase(Split(fTask.Caption,3))), 1);
|
|
// 예: "goblin Claw" 형태로 인벤토리 추가
|
|
final itemName =
|
|
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
|
|
|
// 인벤토리에 추가
|
|
final items = [...resultState.inventory.items];
|
|
final existing = items.indexWhere((e) => e.name == itemName);
|
|
if (existing >= 0) {
|
|
items[existing] = items[existing].copyWith(
|
|
count: items[existing].count + 1,
|
|
);
|
|
} else {
|
|
items.add(InventoryEntry(name: itemName, count: 1));
|
|
}
|
|
|
|
resultState = resultState.copyWith(
|
|
inventory: resultState.inventory.copyWith(items: items),
|
|
);
|
|
}
|
|
|
|
// 물약 드랍 시도
|
|
final potionService = const PotionService();
|
|
final rng = resultState.rng;
|
|
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
|
|
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
|
|
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
|
|
playerLevel: resultState.traits.level,
|
|
monsterLevel: monsterLevel,
|
|
monsterGrade: monsterGrade,
|
|
inventory: resultState.potionInventory,
|
|
roll: rng.nextInt(100),
|
|
typeRoll: rng.nextInt(100),
|
|
);
|
|
|
|
return (
|
|
state: resultState.copyWith(
|
|
rng: rng,
|
|
potionInventory: updatedPotionInventory,
|
|
),
|
|
droppedPotion: droppedPotion,
|
|
);
|
|
}
|
|
|
|
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
|
String _properCase(String s) {
|
|
if (s.isEmpty) return s;
|
|
return s[0].toUpperCase() + s.substring(1);
|
|
}
|
|
|
|
/// 인벤토리에서 Gold 수량 반환
|
|
int _getGold(GameState state) {
|
|
return state.inventory.gold;
|
|
}
|
|
|
|
/// 장비 가격 계산 (원본 Main.pas:612-616)
|
|
/// Result := 5 * Level^2 + 10 * Level + 20
|
|
int _equipPrice(int level) {
|
|
return 5 * level * level + 10 * level + 20;
|
|
}
|
|
|
|
/// 장비 구매 완료 처리 (원본 Main.pas:631-634)
|
|
GameState _completeBuying(GameState state) {
|
|
final level = state.traits.level;
|
|
final price = _equipPrice(level);
|
|
|
|
// Gold 차감 (inventory.gold 필드 사용)
|
|
final newGold = math.max(0, state.inventory.gold - price);
|
|
var nextState = state.copyWith(
|
|
inventory: state.inventory.copyWith(gold: newGold),
|
|
);
|
|
|
|
// 장비 획득 (WinEquip)
|
|
// 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯)
|
|
final slotIndex = nextState.rng.nextInt(Equipment.slotCount);
|
|
nextState = mutations.winEquipByIndex(nextState, level, slotIndex);
|
|
|
|
// 물약 자동 구매 (남은 골드의 20% 사용)
|
|
final potionService = const PotionService();
|
|
final purchaseResult = potionService.autoPurchasePotions(
|
|
playerLevel: level,
|
|
inventory: nextState.potionInventory,
|
|
gold: nextState.inventory.gold,
|
|
spendRatio: 0.20,
|
|
);
|
|
|
|
if (purchaseResult.success && purchaseResult.newInventory != null) {
|
|
nextState = nextState.copyWith(
|
|
inventory: nextState.inventory.copyWith(gold: purchaseResult.newGold),
|
|
potionInventory: purchaseResult.newInventory,
|
|
);
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
|
|
/// 판매 처리 결과
|
|
({GameState state, bool continuesSelling}) _processSell(GameState state) {
|
|
final taskType = state.progress.currentTask.type;
|
|
var items = [...state.inventory.items];
|
|
var goldAmount = state.inventory.gold;
|
|
|
|
// sell 태스크 완료 시 아이템 판매 (원본 Main.pas:636-643)
|
|
if (taskType == TaskType.sell) {
|
|
// 첫 번째 아이템 찾기 (items에는 Gold가 없음)
|
|
if (items.isNotEmpty) {
|
|
final item = items.first;
|
|
final level = state.traits.level;
|
|
|
|
// 가격 계산: 수량 * 레벨
|
|
var price = item.count * level;
|
|
|
|
// " of " 포함 시 보너스 (원본 639-640)
|
|
if (item.name.contains(' of ')) {
|
|
price =
|
|
price *
|
|
(1 + pq_logic.randomLow(state.rng, 10)) *
|
|
(1 + pq_logic.randomLow(state.rng, level));
|
|
}
|
|
|
|
// 아이템 삭제
|
|
items.removeAt(0);
|
|
|
|
// Gold 추가 (inventory.gold 필드 사용)
|
|
goldAmount += price;
|
|
}
|
|
}
|
|
|
|
// 판매할 아이템이 남아있는지 확인
|
|
final hasItemsToSell = items.isNotEmpty;
|
|
|
|
if (hasItemsToSell) {
|
|
// 다음 아이템 판매 태스크 시작
|
|
final nextItem = items.first;
|
|
final translatedName = l10n.translateItemNameL10n(nextItem.name);
|
|
final itemDesc = l10n.indefiniteL10n(translatedName, nextItem.count);
|
|
final taskResult = pq_logic.startTask(
|
|
state.progress,
|
|
l10n.taskSelling(itemDesc),
|
|
1 * 1000,
|
|
);
|
|
final progress = taskResult.progress.copyWith(
|
|
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell),
|
|
);
|
|
return (
|
|
state: state.copyWith(
|
|
inventory: state.inventory.copyWith(gold: goldAmount, items: items),
|
|
progress: progress,
|
|
),
|
|
continuesSelling: true,
|
|
);
|
|
}
|
|
|
|
// 판매 완료 - 인벤토리 업데이트만 하고 다음 태스크로
|
|
return (
|
|
state: state.copyWith(
|
|
inventory: state.inventory.copyWith(gold: goldAmount, items: items),
|
|
),
|
|
continuesSelling: false,
|
|
);
|
|
}
|
|
|
|
/// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함)
|
|
///
|
|
/// [state] 현재 게임 상태
|
|
/// [combat] 현재 전투 상태
|
|
/// [skillSystem] 스킬 시스템 상태
|
|
/// [elapsedMs] 경과 시간 (밀리초)
|
|
/// Returns: 업데이트된 전투 상태, 스킬 시스템 상태, 물약 인벤토리
|
|
({
|
|
CombatState combat,
|
|
SkillSystemState skillSystem,
|
|
PotionInventory? potionInventory,
|
|
})
|
|
_processCombatTickWithSkills(
|
|
GameState state,
|
|
CombatState combat,
|
|
SkillSystemState skillSystem,
|
|
int elapsedMs,
|
|
) {
|
|
if (!combat.isActive || combat.isCombatOver) {
|
|
return (combat: combat, skillSystem: skillSystem, potionInventory: null);
|
|
}
|
|
|
|
final calculator = CombatCalculator(rng: state.rng);
|
|
final skillService = SkillService(rng: state.rng);
|
|
final potionService = const PotionService();
|
|
var playerStats = combat.playerStats;
|
|
var monsterStats = combat.monsterStats;
|
|
var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs;
|
|
var monsterAccumulator = combat.monsterAttackAccumulatorMs + elapsedMs;
|
|
var totalDamageDealt = combat.totalDamageDealt;
|
|
var totalDamageTaken = combat.totalDamageTaken;
|
|
var turnsElapsed = combat.turnsElapsed;
|
|
var updatedSkillSystem = skillSystem;
|
|
var activeDoTs = [...combat.activeDoTs];
|
|
var usedPotionTypes = {...combat.usedPotionTypes};
|
|
var activeDebuffs = [...combat.activeDebuffs];
|
|
PotionInventory? updatedPotionInventory;
|
|
|
|
// 새 전투 이벤트 수집
|
|
final newEvents = <CombatEvent>[];
|
|
final timestamp = updatedSkillSystem.elapsedMs;
|
|
|
|
// =========================================================================
|
|
// 만료된 디버프 정리
|
|
// =========================================================================
|
|
activeDebuffs = activeDebuffs
|
|
.where((debuff) => !debuff.isExpired(timestamp))
|
|
.toList();
|
|
|
|
// =========================================================================
|
|
// DOT 틱 처리
|
|
// =========================================================================
|
|
var dotDamageThisTick = 0;
|
|
final updatedDoTs = <DotEffect>[];
|
|
|
|
for (final dot in activeDoTs) {
|
|
final (updatedDot, ticksTriggered) = dot.tick(elapsedMs);
|
|
|
|
if (ticksTriggered > 0) {
|
|
final damage = dot.damagePerTick * ticksTriggered;
|
|
dotDamageThisTick += damage;
|
|
|
|
// DOT 데미지 이벤트 생성 (skillId → name 변환)
|
|
final dotSkillName =
|
|
SkillData.getSkillById(dot.skillId)?.name ?? dot.skillId;
|
|
newEvents.add(
|
|
CombatEvent.dotTick(
|
|
timestamp: timestamp,
|
|
skillName: dotSkillName,
|
|
damage: damage,
|
|
targetName: monsterStats.name,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 만료되지 않은 DOT만 유지
|
|
if (updatedDot.isActive) {
|
|
updatedDoTs.add(updatedDot);
|
|
}
|
|
}
|
|
|
|
// DOT 데미지 적용
|
|
if (dotDamageThisTick > 0 && monsterStats.isAlive) {
|
|
final newMonsterHp = (monsterStats.hpCurrent - dotDamageThisTick).clamp(
|
|
0,
|
|
monsterStats.hpMax,
|
|
);
|
|
monsterStats = monsterStats.copyWith(hpCurrent: newMonsterHp);
|
|
totalDamageDealt += dotDamageThisTick;
|
|
}
|
|
|
|
activeDoTs = updatedDoTs;
|
|
|
|
// =========================================================================
|
|
// 긴급 물약 자동 사용 (HP < 30%)
|
|
// =========================================================================
|
|
final hpRatio = playerStats.hpCurrent / playerStats.hpMax;
|
|
if (hpRatio <= PotionService.emergencyHpThreshold) {
|
|
final emergencyPotion = potionService.selectEmergencyHpPotion(
|
|
currentHp: playerStats.hpCurrent,
|
|
maxHp: playerStats.hpMax,
|
|
inventory: state.potionInventory,
|
|
playerLevel: state.traits.level,
|
|
);
|
|
|
|
if (emergencyPotion != null && !usedPotionTypes.contains(PotionType.hp)) {
|
|
final result = potionService.usePotion(
|
|
potionId: emergencyPotion.id,
|
|
inventory: state.potionInventory,
|
|
currentHp: playerStats.hpCurrent,
|
|
maxHp: playerStats.hpMax,
|
|
currentMp: playerStats.mpCurrent,
|
|
maxMp: playerStats.mpMax,
|
|
);
|
|
|
|
if (result.success) {
|
|
playerStats = playerStats.copyWith(hpCurrent: result.newHp);
|
|
usedPotionTypes = {...usedPotionTypes, PotionType.hp};
|
|
updatedPotionInventory = result.newInventory;
|
|
|
|
newEvents.add(
|
|
CombatEvent.playerPotion(
|
|
timestamp: timestamp,
|
|
potionName: emergencyPotion.name,
|
|
healAmount: result.healedAmount,
|
|
isHp: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 플레이어 공격 체크
|
|
if (playerAccumulator >= playerStats.attackDelayMs) {
|
|
// SkillBook에서 사용 가능한 스킬 ID 목록 조회
|
|
var availableSkillIds = skillService.getAvailableSkillIdsFromSkillBook(
|
|
state.skillBook,
|
|
);
|
|
// SkillBook에 스킬이 없으면 기본 스킬 사용
|
|
if (availableSkillIds.isEmpty) {
|
|
availableSkillIds = SkillData.defaultSkillIds;
|
|
}
|
|
|
|
final selectedSkill = skillService.selectAutoSkill(
|
|
player: playerStats,
|
|
monster: monsterStats,
|
|
skillSystem: updatedSkillSystem,
|
|
availableSkillIds: availableSkillIds,
|
|
activeDoTs: activeDoTs,
|
|
activeDebuffs: activeDebuffs,
|
|
);
|
|
|
|
if (selectedSkill != null && selectedSkill.isAttack) {
|
|
// 스킬 랭크 조회 (SkillBook 기반)
|
|
final skillRank = skillService.getSkillRankFromSkillBook(
|
|
state.skillBook,
|
|
selectedSkill.id,
|
|
);
|
|
// 랭크 스케일링 적용된 공격 스킬 사용
|
|
final skillResult = skillService.useAttackSkillWithRank(
|
|
skill: selectedSkill,
|
|
player: playerStats,
|
|
monster: monsterStats,
|
|
skillSystem: updatedSkillSystem,
|
|
rank: skillRank,
|
|
);
|
|
playerStats = skillResult.updatedPlayer;
|
|
monsterStats = skillResult.updatedMonster;
|
|
totalDamageDealt += skillResult.result.damage;
|
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
|
|
|
// 스킬 공격 이벤트 생성
|
|
newEvents.add(
|
|
CombatEvent.playerSkill(
|
|
timestamp: timestamp,
|
|
skillName: selectedSkill.name,
|
|
damage: skillResult.result.damage,
|
|
targetName: monsterStats.name,
|
|
attackDelayMs: playerStats.attackDelayMs,
|
|
),
|
|
);
|
|
} else if (selectedSkill != null && selectedSkill.isDot) {
|
|
// DOT 스킬 사용
|
|
final skillResult = skillService.useDotSkill(
|
|
skill: selectedSkill,
|
|
player: playerStats,
|
|
skillSystem: updatedSkillSystem,
|
|
playerInt: state.stats.intelligence,
|
|
playerWis: state.stats.wis,
|
|
);
|
|
playerStats = skillResult.updatedPlayer;
|
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
|
|
|
// DOT 효과 추가
|
|
if (skillResult.dotEffect != null) {
|
|
activeDoTs.add(skillResult.dotEffect!);
|
|
}
|
|
|
|
// DOT 스킬 사용 이벤트 생성
|
|
newEvents.add(
|
|
CombatEvent.playerSkill(
|
|
timestamp: timestamp,
|
|
skillName: selectedSkill.name,
|
|
damage: skillResult.result.damage,
|
|
targetName: monsterStats.name,
|
|
attackDelayMs: playerStats.attackDelayMs,
|
|
),
|
|
);
|
|
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
|
// 회복 스킬 사용
|
|
final skillResult = skillService.useHealSkill(
|
|
skill: selectedSkill,
|
|
player: playerStats,
|
|
skillSystem: updatedSkillSystem,
|
|
);
|
|
playerStats = skillResult.updatedPlayer;
|
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
|
|
|
// 회복 이벤트 생성
|
|
newEvents.add(
|
|
CombatEvent.playerHeal(
|
|
timestamp: timestamp,
|
|
healAmount: skillResult.result.healedAmount,
|
|
skillName: selectedSkill.name,
|
|
),
|
|
);
|
|
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
|
// 버프 스킬 사용
|
|
final skillResult = skillService.useBuffSkill(
|
|
skill: selectedSkill,
|
|
player: playerStats,
|
|
skillSystem: updatedSkillSystem,
|
|
);
|
|
playerStats = skillResult.updatedPlayer;
|
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
|
|
|
// 버프 이벤트 생성
|
|
newEvents.add(
|
|
CombatEvent.playerBuff(
|
|
timestamp: timestamp,
|
|
skillName: selectedSkill.name,
|
|
),
|
|
);
|
|
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
|
// 디버프 스킬 사용
|
|
final skillResult = skillService.useDebuffSkill(
|
|
skill: selectedSkill,
|
|
player: playerStats,
|
|
skillSystem: updatedSkillSystem,
|
|
currentDebuffs: activeDebuffs,
|
|
);
|
|
playerStats = skillResult.updatedPlayer;
|
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
|
|
|
// 디버프 효과 추가 (기존 같은 디버프 제거 후)
|
|
if (skillResult.debuffEffect != null) {
|
|
activeDebuffs = activeDebuffs
|
|
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
|
.toList()
|
|
..add(skillResult.debuffEffect!);
|
|
}
|
|
|
|
// 디버프 이벤트 생성
|
|
newEvents.add(
|
|
CombatEvent.playerDebuff(
|
|
timestamp: timestamp,
|
|
skillName: selectedSkill.name,
|
|
targetName: monsterStats.name,
|
|
),
|
|
);
|
|
} else {
|
|
// 일반 공격
|
|
final attackResult = calculator.playerAttackMonster(
|
|
attacker: playerStats,
|
|
defender: monsterStats,
|
|
);
|
|
monsterStats = attackResult.updatedDefender;
|
|
totalDamageDealt += attackResult.result.damage;
|
|
|
|
// 일반 공격 이벤트 생성
|
|
final result = attackResult.result;
|
|
if (result.isEvaded) {
|
|
newEvents.add(
|
|
CombatEvent.monsterEvade(
|
|
timestamp: timestamp,
|
|
targetName: monsterStats.name,
|
|
),
|
|
);
|
|
} else {
|
|
newEvents.add(
|
|
CombatEvent.playerAttack(
|
|
timestamp: timestamp,
|
|
damage: result.damage,
|
|
targetName: monsterStats.name,
|
|
isCritical: result.isCritical,
|
|
attackDelayMs: playerStats.attackDelayMs,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
playerAccumulator -= playerStats.attackDelayMs;
|
|
turnsElapsed++;
|
|
}
|
|
|
|
// 몬스터가 살아있으면 반격
|
|
if (monsterStats.isAlive &&
|
|
monsterAccumulator >= monsterStats.attackDelayMs) {
|
|
// 디버프 효과 적용된 몬스터 스탯 계산
|
|
var debuffedMonster = monsterStats;
|
|
if (activeDebuffs.isNotEmpty) {
|
|
double atkMod = 0;
|
|
for (final debuff in activeDebuffs) {
|
|
if (!debuff.isExpired(timestamp)) {
|
|
atkMod += debuff.effect.atkModifier; // 음수 값
|
|
}
|
|
}
|
|
// ATK 감소 적용 (최소 10% ATK 유지)
|
|
final newAtk = (monsterStats.atk * (1 + atkMod)).round().clamp(
|
|
monsterStats.atk ~/ 10,
|
|
monsterStats.atk,
|
|
);
|
|
debuffedMonster = monsterStats.copyWith(atk: newAtk);
|
|
}
|
|
|
|
final attackResult = calculator.monsterAttackPlayer(
|
|
attacker: debuffedMonster,
|
|
defender: playerStats,
|
|
);
|
|
playerStats = attackResult.updatedDefender;
|
|
totalDamageTaken += attackResult.result.damage;
|
|
monsterAccumulator -= monsterStats.attackDelayMs;
|
|
|
|
// 몬스터 공격 이벤트 생성
|
|
final result = attackResult.result;
|
|
if (result.isEvaded) {
|
|
newEvents.add(
|
|
CombatEvent.playerEvade(
|
|
timestamp: timestamp,
|
|
attackerName: monsterStats.name,
|
|
),
|
|
);
|
|
} else if (result.isBlocked) {
|
|
newEvents.add(
|
|
CombatEvent.playerBlock(
|
|
timestamp: timestamp,
|
|
reducedDamage: result.damage,
|
|
attackerName: monsterStats.name,
|
|
),
|
|
);
|
|
} else if (result.isParried) {
|
|
newEvents.add(
|
|
CombatEvent.playerParry(
|
|
timestamp: timestamp,
|
|
reducedDamage: result.damage,
|
|
attackerName: monsterStats.name,
|
|
),
|
|
);
|
|
} else {
|
|
newEvents.add(
|
|
CombatEvent.monsterAttack(
|
|
timestamp: timestamp,
|
|
damage: result.damage,
|
|
attackerName: monsterStats.name,
|
|
attackDelayMs: monsterStats.attackDelayMs,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 전투 종료 체크
|
|
final isActive = playerStats.isAlive && monsterStats.isAlive;
|
|
|
|
// 기존 이벤트와 합쳐서 최대 10개 유지
|
|
final combinedEvents = [...combat.recentEvents, ...newEvents];
|
|
final recentEvents = combinedEvents.length > 10
|
|
? combinedEvents.sublist(combinedEvents.length - 10)
|
|
: combinedEvents;
|
|
|
|
return (
|
|
combat: combat.copyWith(
|
|
playerStats: playerStats,
|
|
monsterStats: monsterStats,
|
|
playerAttackAccumulatorMs: playerAccumulator,
|
|
monsterAttackAccumulatorMs: monsterAccumulator,
|
|
totalDamageDealt: totalDamageDealt,
|
|
totalDamageTaken: totalDamageTaken,
|
|
turnsElapsed: turnsElapsed,
|
|
isActive: isActive,
|
|
recentEvents: recentEvents,
|
|
activeDoTs: activeDoTs,
|
|
usedPotionTypes: usedPotionTypes,
|
|
activeDebuffs: activeDebuffs,
|
|
),
|
|
skillSystem: updatedSkillSystem,
|
|
potionInventory: updatedPotionInventory,
|
|
);
|
|
}
|
|
|
|
/// 플레이어 사망 처리 (Phase 4)
|
|
///
|
|
/// 모든 장비 상실 및 사망 정보 기록
|
|
GameState _processPlayerDeath(
|
|
GameState state, {
|
|
required String killerName,
|
|
required DeathCause cause,
|
|
}) {
|
|
// 상실할 장비 개수 계산
|
|
final lostCount = state.equipment.equippedItems.length;
|
|
|
|
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
|
final lastCombatEvents =
|
|
state.progress.currentCombat?.recentEvents ?? const [];
|
|
|
|
// 빈 장비 생성 (기본 무기만 유지)
|
|
final emptyEquipment = Equipment(
|
|
items: [
|
|
EquipmentItem.defaultWeapon(),
|
|
EquipmentItem.empty(EquipmentSlot.shield),
|
|
EquipmentItem.empty(EquipmentSlot.helm),
|
|
EquipmentItem.empty(EquipmentSlot.hauberk),
|
|
EquipmentItem.empty(EquipmentSlot.brassairts),
|
|
EquipmentItem.empty(EquipmentSlot.vambraces),
|
|
EquipmentItem.empty(EquipmentSlot.gauntlets),
|
|
EquipmentItem.empty(EquipmentSlot.gambeson),
|
|
EquipmentItem.empty(EquipmentSlot.cuisses),
|
|
EquipmentItem.empty(EquipmentSlot.greaves),
|
|
EquipmentItem.empty(EquipmentSlot.sollerets),
|
|
],
|
|
bestIndex: 0,
|
|
);
|
|
|
|
// 사망 정보 생성 (전투 로그 포함)
|
|
final deathInfo = DeathInfo(
|
|
cause: cause,
|
|
killerName: killerName,
|
|
lostEquipmentCount: lostCount,
|
|
goldAtDeath: state.inventory.gold,
|
|
levelAtDeath: state.traits.level,
|
|
timestamp: state.skillSystem.elapsedMs,
|
|
lastCombatEvents: lastCombatEvents,
|
|
);
|
|
|
|
// 전투 상태 초기화 및 사망 횟수 증가
|
|
final progress = state.progress.copyWith(
|
|
currentCombat: null,
|
|
deathCount: state.progress.deathCount + 1,
|
|
);
|
|
|
|
return state.copyWith(
|
|
equipment: emptyEquipment,
|
|
progress: progress,
|
|
deathInfo: deathInfo,
|
|
);
|
|
}
|
|
}
|