feat: 초기 커밋
- Progress Quest 6.4 Flutter 포팅 프로젝트 - 게임 루프, 상태 관리, UI 구현 - 캐릭터 생성, 인벤토리, 장비, 주문 시스템 - 시장/판매/구매 메커니즘
This commit is contained in:
86
lib/src/core/engine/game_mutations.dart
Normal file
86
lib/src/core/engine/game_mutations.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// Game state mutations that mirror the original PQ win/reward logic.
|
||||
class GameMutations {
|
||||
const GameMutations(this.config);
|
||||
|
||||
final PqConfig config;
|
||||
|
||||
GameState winEquip(GameState state, int level, EquipmentSlot slot) {
|
||||
final rng = state.rng;
|
||||
final name = pq_logic.winEquip(config, rng, level, slot);
|
||||
final equip = state.equipment;
|
||||
final updatedEquip = switch (slot) {
|
||||
EquipmentSlot.weapon => equip.copyWith(
|
||||
weapon: name,
|
||||
bestIndex: EquipmentSlot.weapon.index,
|
||||
),
|
||||
EquipmentSlot.shield => equip.copyWith(
|
||||
shield: name,
|
||||
bestIndex: EquipmentSlot.shield.index,
|
||||
),
|
||||
EquipmentSlot.armor => equip.copyWith(
|
||||
armor: name,
|
||||
bestIndex: EquipmentSlot.armor.index,
|
||||
),
|
||||
};
|
||||
return state.copyWith(rng: rng, equipment: updatedEquip);
|
||||
}
|
||||
|
||||
GameState winStat(GameState state) {
|
||||
final updatedStats = pq_logic.winStat(state.stats, state.rng);
|
||||
return state.copyWith(rng: state.rng, stats: updatedStats);
|
||||
}
|
||||
|
||||
GameState winSpell(GameState state, int wisdom, int level) {
|
||||
final result = pq_logic.winSpell(config, state.rng, wisdom, level);
|
||||
final parts = result.split('|');
|
||||
final name = parts[0];
|
||||
final rank = parts.length > 1 ? parts[1] : 'I';
|
||||
|
||||
final spells = [...state.spellBook.spells];
|
||||
final index = spells.indexWhere((s) => s.name == name);
|
||||
if (index >= 0) {
|
||||
spells[index] = spells[index].copyWith(rank: rank);
|
||||
} else {
|
||||
spells.add(SpellEntry(name: name, rank: rank));
|
||||
}
|
||||
|
||||
return state.copyWith(
|
||||
rng: state.rng,
|
||||
spellBook: state.spellBook.copyWith(spells: spells),
|
||||
);
|
||||
}
|
||||
|
||||
GameState winItem(GameState state) {
|
||||
final rng = state.rng;
|
||||
final result = pq_logic.winItem(config, rng, state.inventory.items.length);
|
||||
final items = [...state.inventory.items];
|
||||
|
||||
if (result.isEmpty) {
|
||||
// Duplicate an existing item if possible.
|
||||
if (items.isNotEmpty) {
|
||||
final pickIndex = rng.nextInt(items.length);
|
||||
final picked = items[pickIndex];
|
||||
items[pickIndex] = picked.copyWith(count: picked.count + 1);
|
||||
}
|
||||
} else {
|
||||
final existing = items.indexWhere((e) => e.name == result);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: result, count: 1));
|
||||
}
|
||||
}
|
||||
|
||||
return state.copyWith(
|
||||
rng: rng,
|
||||
inventory: state.inventory.copyWith(items: items),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/src/core/engine/progress_loop.dart
Normal file
138
lib/src/core/engine/progress_loop.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:askiineverdie/src/core/engine/progress_service.dart';
|
||||
|
||||
class AutoSaveConfig {
|
||||
const AutoSaveConfig({
|
||||
this.onLevelUp = true,
|
||||
this.onQuestComplete = true,
|
||||
this.onActComplete = true,
|
||||
this.onStop = true,
|
||||
});
|
||||
|
||||
final bool onLevelUp;
|
||||
final bool onQuestComplete;
|
||||
final bool onActComplete;
|
||||
final bool onStop;
|
||||
|
||||
bool shouldSave(ProgressTickResult result) {
|
||||
return (onLevelUp && result.leveledUp) ||
|
||||
(onQuestComplete && result.completedQuest) ||
|
||||
(onActComplete && result.completedAct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the periodic timer loop that advances tasks/quests/plots.
|
||||
class ProgressLoop {
|
||||
ProgressLoop({
|
||||
required GameState initialState,
|
||||
required this.progressService,
|
||||
this.saveManager,
|
||||
Duration tickInterval = const Duration(milliseconds: 50),
|
||||
AutoSaveConfig autoSaveConfig = const AutoSaveConfig(),
|
||||
DateTime Function()? now,
|
||||
this.cheatsEnabled = false,
|
||||
}) : _state = initialState,
|
||||
_tickInterval = tickInterval,
|
||||
_autoSaveConfig = autoSaveConfig,
|
||||
_now = now ?? DateTime.now,
|
||||
_stateController = StreamController<GameState>.broadcast();
|
||||
|
||||
final ProgressService progressService;
|
||||
final SaveManager? saveManager;
|
||||
final Duration _tickInterval;
|
||||
final AutoSaveConfig _autoSaveConfig;
|
||||
final DateTime Function() _now;
|
||||
final StreamController<GameState> _stateController;
|
||||
bool cheatsEnabled;
|
||||
|
||||
Timer? _timer;
|
||||
int? _lastTickMs;
|
||||
int _speedMultiplier = 1;
|
||||
|
||||
GameState get current => _state;
|
||||
Stream<GameState> get stream => _stateController.stream;
|
||||
GameState _state;
|
||||
|
||||
/// 현재 배속 (1x, 2x, 5x)
|
||||
int get speedMultiplier => _speedMultiplier;
|
||||
|
||||
/// 배속 순환: 1 -> 2 -> 5 -> 1
|
||||
void cycleSpeed() {
|
||||
_speedMultiplier = switch (_speedMultiplier) {
|
||||
1 => 2,
|
||||
2 => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
void start() {
|
||||
_lastTickMs = _now().millisecondsSinceEpoch;
|
||||
_timer ??= Timer.periodic(_tickInterval, (_) => tickOnce());
|
||||
}
|
||||
|
||||
Future<void> stop({bool saveOnStop = false}) async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (saveOnStop && _autoSaveConfig.onStop && saveManager != null) {
|
||||
await saveManager!.saveState(_state);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_stateController.close();
|
||||
}
|
||||
|
||||
/// Run one iteration of the loop (used by Timer or manual stepping).
|
||||
GameState tickOnce({int? deltaMillis}) {
|
||||
final baseDelta = deltaMillis ?? _computeDelta();
|
||||
final delta = baseDelta * _speedMultiplier;
|
||||
final result = progressService.tick(_state, delta);
|
||||
_state = result.state;
|
||||
_stateController.add(_state);
|
||||
|
||||
if (saveManager != null && _autoSaveConfig.shouldSave(result)) {
|
||||
saveManager!.saveState(_state);
|
||||
}
|
||||
return _state;
|
||||
}
|
||||
|
||||
/// Replace state (e.g., after loading) and reset timing.
|
||||
void replaceState(GameState newState) {
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
_lastTickMs = _now().millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
// Developer-only helpers mirroring original cheat panel actions.
|
||||
void cheatCompleteTask() {
|
||||
if (!cheatsEnabled) return;
|
||||
_state = progressService.forceTaskComplete(_state);
|
||||
_stateController.add(_state);
|
||||
}
|
||||
|
||||
void cheatCompleteQuest() {
|
||||
if (!cheatsEnabled) return;
|
||||
_state = progressService.forceQuestComplete(_state);
|
||||
_stateController.add(_state);
|
||||
}
|
||||
|
||||
void cheatCompletePlot() {
|
||||
if (!cheatsEnabled) return;
|
||||
_state = progressService.forcePlotComplete(_state);
|
||||
_stateController.add(_state);
|
||||
}
|
||||
|
||||
int _computeDelta() {
|
||||
final nowMs = _now().millisecondsSinceEpoch;
|
||||
final last = _lastTickMs;
|
||||
_lastTickMs = nowMs;
|
||||
if (last == null) return 0;
|
||||
final delta = nowMs - last;
|
||||
if (delta < 0) return 0;
|
||||
return delta;
|
||||
}
|
||||
}
|
||||
712
lib/src/core/engine/progress_service.dart
Normal file
712
lib/src/core/engine/progress_service.dart
Normal file
@@ -0,0 +1,712 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:askiineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:askiineverdie/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,
|
||||
});
|
||||
|
||||
final GameState state;
|
||||
final bool leveledUp;
|
||||
final bool completedQuest;
|
||||
final bool completedAct;
|
||||
|
||||
bool get shouldAutosave => leveledUp || completedQuest || completedAct;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// 초기 큐 설정 (원본 753-757줄)
|
||||
final initialQueue = <QueueEntry>[
|
||||
const QueueEntry(
|
||||
kind: QueueKind.task,
|
||||
durationMillis: 10 * 1000,
|
||||
caption: 'Experiencing an enigmatic and foreboding night vision',
|
||||
taskType: TaskType.load,
|
||||
),
|
||||
const QueueEntry(
|
||||
kind: QueueKind.task,
|
||||
durationMillis: 6 * 1000,
|
||||
caption: "Much is revealed about that wise old bastard you'd "
|
||||
'underestimated',
|
||||
taskType: TaskType.load,
|
||||
),
|
||||
const QueueEntry(
|
||||
kind: QueueKind.task,
|
||||
durationMillis: 6 * 1000,
|
||||
caption: 'A shocking series of events leaves you alone and bewildered, '
|
||||
'but resolute',
|
||||
taskType: TaskType.load,
|
||||
),
|
||||
const QueueEntry(
|
||||
kind: QueueKind.task,
|
||||
durationMillis: 4 * 1000,
|
||||
caption: 'Drawing upon an unexpected reserve of determination, '
|
||||
'you set out on a long and dangerous journey',
|
||||
taskType: TaskType.load,
|
||||
),
|
||||
const QueueEntry(
|
||||
kind: QueueKind.plot,
|
||||
durationMillis: 2 * 1000,
|
||||
caption: 'Loading',
|
||||
taskType: TaskType.plot,
|
||||
),
|
||||
];
|
||||
|
||||
// 첫 번째 태스크 'Loading' 시작 (원본 752줄)
|
||||
final taskResult = pq_logic.startTask(
|
||||
state.progress,
|
||||
'Loading',
|
||||
2 * 1000,
|
||||
);
|
||||
|
||||
// ExpBar 초기화 (원본 743-746줄)
|
||||
final expBar = ProgressBarState(
|
||||
position: 0,
|
||||
max: pq_logic.levelUpTime(1),
|
||||
);
|
||||
|
||||
// PlotBar 초기화 (원본 759줄)
|
||||
final plotBar = const ProgressBarState(position: 0, max: 26 * 1000);
|
||||
|
||||
final progress = taskResult.progress.copyWith(
|
||||
exp: expBar,
|
||||
plot: plotBar,
|
||||
currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load),
|
||||
plotStageCount: 1, // Prologue
|
||||
questCount: 0,
|
||||
);
|
||||
|
||||
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) {
|
||||
final int clamped = elapsedMillis.clamp(0, 100).toInt();
|
||||
var progress = state.progress;
|
||||
var queue = state.queue;
|
||||
var nextState = state;
|
||||
var leveledUp = false;
|
||||
var questDone = false;
|
||||
var actDone = false;
|
||||
|
||||
// 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;
|
||||
progress = progress.copyWith(
|
||||
task: progress.task.copyWith(position: newTaskPos),
|
||||
);
|
||||
nextState = _recalculateEncumbrance(
|
||||
nextState.copyWith(progress: progress),
|
||||
);
|
||||
return ProgressTickResult(state: nextState);
|
||||
}
|
||||
|
||||
final gain = progress.currentTask.type == TaskType.kill;
|
||||
final incrementSeconds = progress.task.max ~/ 1000;
|
||||
|
||||
// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||
if (gain) {
|
||||
nextState = _winLoot(nextState);
|
||||
progress = nextState.progress;
|
||||
}
|
||||
|
||||
// 시장/판매/구매 태스크 완료 시 처리 (원본 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.
|
||||
if (gain) {
|
||||
if (progress.exp.position >= progress.exp.max) {
|
||||
nextState = _levelUp(nextState);
|
||||
leveledUp = true;
|
||||
progress = nextState.progress;
|
||||
} else {
|
||||
final uncappedExp = progress.exp.position + incrementSeconds;
|
||||
final int newExpPos = uncappedExp > progress.exp.max
|
||||
? progress.exp.max
|
||||
: uncappedExp;
|
||||
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);
|
||||
nextState = completeAct(nextState);
|
||||
actDone = true;
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 큐가 비어있을 때 다음 태스크 생성 (원본 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,
|
||||
'Heading to market to sell loot',
|
||||
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,
|
||||
'Negotiating purchase of better equipment',
|
||||
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,
|
||||
'Heading to the killing fields',
|
||||
4 * 1000,
|
||||
);
|
||||
progress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
);
|
||||
return (progress: progress, queue: queue);
|
||||
}
|
||||
|
||||
// 3. MonsterTask 실행 (원본 678-684줄)
|
||||
final level = state.traits.level;
|
||||
final monster = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
level,
|
||||
null, // questMonster
|
||||
null, // questLevel
|
||||
);
|
||||
|
||||
// 태스크 지속시간 계산 (원본 682줄)
|
||||
// n := (2 * InventoryLabelAlsoGameStyle.Tag * n * 1000) div l;
|
||||
// InventoryLabelAlsoGameStyle.Tag는 게임 스타일을 나타내는 값 (1이 기본)
|
||||
const gameStyleTag = 1;
|
||||
final durationMillis = (2 * gameStyleTag * level * 1000) ~/ level;
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
'Executing $monster',
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
progress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
),
|
||||
);
|
||||
|
||||
return (progress: progress, 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;
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
return _recalculateEncumbrance(
|
||||
nextState.copyWith(progress: progress, queue: updatedQueue),
|
||||
);
|
||||
}
|
||||
|
||||
/// Advances plot to next act and applies any act-level rewards.
|
||||
GameState completeAct(GameState state) {
|
||||
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;
|
||||
var updatedProgress = nextState.progress.copyWith(
|
||||
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
|
||||
plotStageCount: plotStages,
|
||||
);
|
||||
|
||||
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 _recalculateEncumbrance(nextState);
|
||||
}
|
||||
|
||||
/// 첫 퀘스트 시작 (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 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,
|
||||
);
|
||||
|
||||
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) {
|
||||
final progress = state.progress.copyWith(
|
||||
task: state.progress.task.copyWith(position: state.progress.task.max),
|
||||
plot: state.progress.plot.copyWith(position: state.progress.plot.max),
|
||||
);
|
||||
return state.copyWith(progress: progress);
|
||||
}
|
||||
|
||||
GameState _applyReward(GameState state, pq_logic.RewardKind reward) {
|
||||
final updated = rewards.applyReward(state, reward);
|
||||
return _recalculateEncumbrance(updated);
|
||||
}
|
||||
|
||||
GameState _levelUp(GameState 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: pq_logic.levelUpTime(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)
|
||||
GameState _winLoot(GameState state) {
|
||||
final taskCaption = state.progress.currentTask.caption;
|
||||
|
||||
// 몬스터 이름에서 전리품 아이템 생성
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "Executing a Goblin..." -> "goblin ear" 등의 아이템
|
||||
|
||||
// 태스크 캡션에서 몬스터 이름 추출 ("Executing ..." 형태)
|
||||
String monsterName = taskCaption;
|
||||
if (monsterName.startsWith('Executing ')) {
|
||||
monsterName = monsterName.substring('Executing '.length);
|
||||
}
|
||||
if (monsterName.endsWith('...')) {
|
||||
monsterName = monsterName.substring(0, monsterName.length - 3);
|
||||
}
|
||||
|
||||
// 몬스터 부위 선택 (원본에서는 몬스터별로 다르지만, 간단히 랜덤 선택)
|
||||
final parts = ['Skin', 'Tooth', 'Claw', 'Ear', 'Eye', 'Tail', 'Scale'];
|
||||
final part = pq_logic.pick(parts, state.rng);
|
||||
|
||||
// 아이템 이름 생성 (예: "Goblin Ear")
|
||||
final itemName = '${_extractBaseName(monsterName)} $part';
|
||||
|
||||
// 인벤토리에 추가
|
||||
final items = [...state.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));
|
||||
}
|
||||
|
||||
return state.copyWith(
|
||||
inventory: state.inventory.copyWith(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 이름에서 기본 이름 추출 (형용사 제거)
|
||||
String _extractBaseName(String name) {
|
||||
// "a Goblin", "an Orc", "2 Goblins" 등에서 기본 이름 추출
|
||||
final words = name.split(' ');
|
||||
if (words.isEmpty) return name;
|
||||
|
||||
// 관사나 숫자 제거
|
||||
var startIndex = 0;
|
||||
if (words[0] == 'a' || words[0] == 'an' || words[0] == 'the') {
|
||||
startIndex = 1;
|
||||
} else if (int.tryParse(words[0]) != null) {
|
||||
startIndex = 1;
|
||||
}
|
||||
|
||||
if (startIndex >= words.length) return name;
|
||||
|
||||
// 마지막 단어가 몬스터 이름 (형용사들 건너뛰기)
|
||||
final baseName = words.last;
|
||||
// 첫 글자 대문자로
|
||||
if (baseName.isEmpty) return name;
|
||||
return baseName[0].toUpperCase() + baseName.substring(1).toLowerCase();
|
||||
}
|
||||
|
||||
/// 인벤토리에서 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)
|
||||
nextState = mutations.winEquip(
|
||||
nextState,
|
||||
level,
|
||||
EquipmentSlot.values[nextState.rng.nextInt(EquipmentSlot.values.length)],
|
||||
);
|
||||
|
||||
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 taskResult = pq_logic.startTask(
|
||||
state.progress,
|
||||
'Selling ${pq_logic.indefinite(nextItem.name, nextItem.count)}',
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/src/core/engine/reward_service.dart
Normal file
26
lib/src/core/engine/reward_service.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/util/pq_logic.dart';
|
||||
|
||||
/// Applies quest/act rewards to the GameState using shared RNG.
|
||||
class RewardService {
|
||||
RewardService(this.mutations);
|
||||
|
||||
final GameMutations mutations;
|
||||
|
||||
GameState applyReward(GameState state, RewardKind reward) {
|
||||
switch (reward) {
|
||||
case RewardKind.spell:
|
||||
return mutations.winSpell(state, state.stats.wis, state.traits.level);
|
||||
case RewardKind.equip:
|
||||
final slot = EquipmentSlot
|
||||
.values[state.rng.nextInt(EquipmentSlot.values.length)];
|
||||
return mutations.winEquip(state, state.traits.level, slot);
|
||||
case RewardKind.stat:
|
||||
return mutations.winStat(state);
|
||||
case RewardKind.item:
|
||||
return mutations.winItem(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user