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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/src/core/model/equipment_slot.dart
Normal file
1
lib/src/core/model/equipment_slot.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum EquipmentSlot { weapon, shield, armor }
|
||||
389
lib/src/core/model/game_state.dart
Normal file
389
lib/src/core/model/game_state.dart
Normal file
@@ -0,0 +1,389 @@
|
||||
import 'dart:collection';
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// Minimal skeletal state to mirror Progress Quest structures.
|
||||
///
|
||||
/// Logic will be ported faithfully from the Delphi source; this file only
|
||||
/// defines containers and helpers for deterministic RNG.
|
||||
class GameState {
|
||||
GameState({
|
||||
required DeterministicRandom rng,
|
||||
Traits? traits,
|
||||
Stats? stats,
|
||||
Inventory? inventory,
|
||||
Equipment? equipment,
|
||||
SpellBook? spellBook,
|
||||
ProgressState? progress,
|
||||
QueueState? queue,
|
||||
}) : rng = DeterministicRandom.clone(rng),
|
||||
traits = traits ?? Traits.empty(),
|
||||
stats = stats ?? Stats.empty(),
|
||||
inventory = inventory ?? Inventory.empty(),
|
||||
equipment = equipment ?? Equipment.empty(),
|
||||
spellBook = spellBook ?? SpellBook.empty(),
|
||||
progress = progress ?? ProgressState.empty(),
|
||||
queue = queue ?? QueueState.empty();
|
||||
|
||||
factory GameState.withSeed({
|
||||
required int seed,
|
||||
Traits? traits,
|
||||
Stats? stats,
|
||||
Inventory? inventory,
|
||||
Equipment? equipment,
|
||||
SpellBook? spellBook,
|
||||
ProgressState? progress,
|
||||
QueueState? queue,
|
||||
}) {
|
||||
return GameState(
|
||||
rng: DeterministicRandom(seed),
|
||||
traits: traits,
|
||||
stats: stats,
|
||||
inventory: inventory,
|
||||
equipment: equipment,
|
||||
spellBook: spellBook,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
);
|
||||
}
|
||||
|
||||
final DeterministicRandom rng;
|
||||
final Traits traits;
|
||||
final Stats stats;
|
||||
final Inventory inventory;
|
||||
final Equipment equipment;
|
||||
final SpellBook spellBook;
|
||||
final ProgressState progress;
|
||||
final QueueState queue;
|
||||
|
||||
GameState copyWith({
|
||||
DeterministicRandom? rng,
|
||||
Traits? traits,
|
||||
Stats? stats,
|
||||
Inventory? inventory,
|
||||
Equipment? equipment,
|
||||
SpellBook? spellBook,
|
||||
ProgressState? progress,
|
||||
QueueState? queue,
|
||||
}) {
|
||||
return GameState(
|
||||
rng: rng ?? DeterministicRandom.clone(this.rng),
|
||||
traits: traits ?? this.traits,
|
||||
stats: stats ?? this.stats,
|
||||
inventory: inventory ?? this.inventory,
|
||||
equipment: equipment ?? this.equipment,
|
||||
spellBook: spellBook ?? this.spellBook,
|
||||
progress: progress ?? this.progress,
|
||||
queue: queue ?? this.queue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
|
||||
enum TaskType {
|
||||
neutral, // heading 등 일반 이동
|
||||
kill, // 몬스터 처치
|
||||
load, // 로딩/초기화
|
||||
plot, // 플롯 진행
|
||||
market, // 시장으로 이동 중
|
||||
sell, // 아이템 판매 중
|
||||
buying, // 장비 구매 중
|
||||
}
|
||||
|
||||
class TaskInfo {
|
||||
const TaskInfo({required this.caption, required this.type});
|
||||
|
||||
final String caption;
|
||||
final TaskType type;
|
||||
|
||||
factory TaskInfo.empty() =>
|
||||
const TaskInfo(caption: '', type: TaskType.neutral);
|
||||
|
||||
TaskInfo copyWith({String? caption, TaskType? type}) {
|
||||
return TaskInfo(caption: caption ?? this.caption, type: type ?? this.type);
|
||||
}
|
||||
}
|
||||
|
||||
class Traits {
|
||||
const Traits({
|
||||
required this.name,
|
||||
required this.race,
|
||||
required this.klass,
|
||||
required this.level,
|
||||
required this.motto,
|
||||
required this.guild,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String race;
|
||||
final String klass;
|
||||
final int level;
|
||||
final String motto;
|
||||
final String guild;
|
||||
|
||||
factory Traits.empty() => const Traits(
|
||||
name: '',
|
||||
race: '',
|
||||
klass: '',
|
||||
level: 1,
|
||||
motto: '',
|
||||
guild: '',
|
||||
);
|
||||
|
||||
Traits copyWith({
|
||||
String? name,
|
||||
String? race,
|
||||
String? klass,
|
||||
int? level,
|
||||
String? motto,
|
||||
String? guild,
|
||||
}) {
|
||||
return Traits(
|
||||
name: name ?? this.name,
|
||||
race: race ?? this.race,
|
||||
klass: klass ?? this.klass,
|
||||
level: level ?? this.level,
|
||||
motto: motto ?? this.motto,
|
||||
guild: guild ?? this.guild,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Stats {
|
||||
const Stats({
|
||||
required this.str,
|
||||
required this.con,
|
||||
required this.dex,
|
||||
required this.intelligence,
|
||||
required this.wis,
|
||||
required this.cha,
|
||||
required this.hpMax,
|
||||
required this.mpMax,
|
||||
});
|
||||
|
||||
final int str;
|
||||
final int con;
|
||||
final int dex;
|
||||
final int intelligence;
|
||||
final int wis;
|
||||
final int cha;
|
||||
final int hpMax;
|
||||
final int mpMax;
|
||||
|
||||
factory Stats.empty() => const Stats(
|
||||
str: 0,
|
||||
con: 0,
|
||||
dex: 0,
|
||||
intelligence: 0,
|
||||
wis: 0,
|
||||
cha: 0,
|
||||
hpMax: 0,
|
||||
mpMax: 0,
|
||||
);
|
||||
|
||||
Stats copyWith({
|
||||
int? str,
|
||||
int? con,
|
||||
int? dex,
|
||||
int? intelligence,
|
||||
int? wis,
|
||||
int? cha,
|
||||
int? hpMax,
|
||||
int? mpMax,
|
||||
}) {
|
||||
return Stats(
|
||||
str: str ?? this.str,
|
||||
con: con ?? this.con,
|
||||
dex: dex ?? this.dex,
|
||||
intelligence: intelligence ?? this.intelligence,
|
||||
wis: wis ?? this.wis,
|
||||
cha: cha ?? this.cha,
|
||||
hpMax: hpMax ?? this.hpMax,
|
||||
mpMax: mpMax ?? this.mpMax,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InventoryEntry {
|
||||
const InventoryEntry({required this.name, required this.count});
|
||||
|
||||
final String name;
|
||||
final int count;
|
||||
|
||||
InventoryEntry copyWith({String? name, int? count}) {
|
||||
return InventoryEntry(name: name ?? this.name, count: count ?? this.count);
|
||||
}
|
||||
}
|
||||
|
||||
class Inventory {
|
||||
const Inventory({required this.gold, required this.items});
|
||||
|
||||
final int gold;
|
||||
final List<InventoryEntry> items;
|
||||
|
||||
factory Inventory.empty() => const Inventory(gold: 0, items: []);
|
||||
|
||||
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
|
||||
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
|
||||
}
|
||||
}
|
||||
|
||||
class Equipment {
|
||||
const Equipment({
|
||||
required this.weapon,
|
||||
required this.shield,
|
||||
required this.armor,
|
||||
required this.bestIndex,
|
||||
});
|
||||
|
||||
final String weapon;
|
||||
final String shield;
|
||||
final String armor;
|
||||
|
||||
/// Tracks best slot index (mirror of Equips.Tag in original code; 0=weapon,1=shield,2=armor).
|
||||
final int bestIndex;
|
||||
|
||||
factory Equipment.empty() => const Equipment(
|
||||
weapon: 'Sharp Stick',
|
||||
shield: '',
|
||||
armor: '',
|
||||
bestIndex: 0,
|
||||
);
|
||||
|
||||
Equipment copyWith({
|
||||
String? weapon,
|
||||
String? shield,
|
||||
String? armor,
|
||||
int? bestIndex,
|
||||
}) {
|
||||
return Equipment(
|
||||
weapon: weapon ?? this.weapon,
|
||||
shield: shield ?? this.shield,
|
||||
armor: armor ?? this.armor,
|
||||
bestIndex: bestIndex ?? this.bestIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpellEntry {
|
||||
const SpellEntry({required this.name, required this.rank});
|
||||
|
||||
final String name;
|
||||
final String rank; // e.g., Roman numerals
|
||||
|
||||
SpellEntry copyWith({String? name, String? rank}) {
|
||||
return SpellEntry(name: name ?? this.name, rank: rank ?? this.rank);
|
||||
}
|
||||
}
|
||||
|
||||
class SpellBook {
|
||||
const SpellBook({required this.spells});
|
||||
|
||||
final List<SpellEntry> spells;
|
||||
|
||||
factory SpellBook.empty() => const SpellBook(spells: []);
|
||||
|
||||
SpellBook copyWith({List<SpellEntry>? spells}) {
|
||||
return SpellBook(spells: spells ?? this.spells);
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressBarState {
|
||||
const ProgressBarState({required this.position, required this.max});
|
||||
|
||||
final int position;
|
||||
final int max;
|
||||
|
||||
factory ProgressBarState.empty() =>
|
||||
const ProgressBarState(position: 0, max: 1);
|
||||
|
||||
ProgressBarState copyWith({int? position, int? max}) {
|
||||
return ProgressBarState(
|
||||
position: position ?? this.position,
|
||||
max: max ?? this.max,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressState {
|
||||
const ProgressState({
|
||||
required this.task,
|
||||
required this.quest,
|
||||
required this.plot,
|
||||
required this.exp,
|
||||
required this.encumbrance,
|
||||
required this.currentTask,
|
||||
required this.plotStageCount,
|
||||
required this.questCount,
|
||||
});
|
||||
|
||||
final ProgressBarState task;
|
||||
final ProgressBarState quest;
|
||||
final ProgressBarState plot;
|
||||
final ProgressBarState exp;
|
||||
final ProgressBarState encumbrance;
|
||||
final TaskInfo currentTask;
|
||||
final int plotStageCount;
|
||||
final int questCount;
|
||||
|
||||
factory ProgressState.empty() => ProgressState(
|
||||
task: ProgressBarState.empty(),
|
||||
quest: ProgressBarState.empty(),
|
||||
plot: ProgressBarState.empty(),
|
||||
exp: ProgressBarState.empty(),
|
||||
encumbrance: ProgressBarState.empty(),
|
||||
currentTask: TaskInfo.empty(),
|
||||
plotStageCount: 1, // Prologue
|
||||
questCount: 0,
|
||||
);
|
||||
|
||||
ProgressState copyWith({
|
||||
ProgressBarState? task,
|
||||
ProgressBarState? quest,
|
||||
ProgressBarState? plot,
|
||||
ProgressBarState? exp,
|
||||
ProgressBarState? encumbrance,
|
||||
TaskInfo? currentTask,
|
||||
int? plotStageCount,
|
||||
int? questCount,
|
||||
}) {
|
||||
return ProgressState(
|
||||
task: task ?? this.task,
|
||||
quest: quest ?? this.quest,
|
||||
plot: plot ?? this.plot,
|
||||
exp: exp ?? this.exp,
|
||||
encumbrance: encumbrance ?? this.encumbrance,
|
||||
currentTask: currentTask ?? this.currentTask,
|
||||
plotStageCount: plotStageCount ?? this.plotStageCount,
|
||||
questCount: questCount ?? this.questCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueueEntry {
|
||||
const QueueEntry({
|
||||
required this.kind,
|
||||
required this.durationMillis,
|
||||
required this.caption,
|
||||
this.taskType = TaskType.neutral,
|
||||
});
|
||||
|
||||
final QueueKind kind;
|
||||
final int durationMillis;
|
||||
final String caption;
|
||||
final TaskType taskType;
|
||||
}
|
||||
|
||||
enum QueueKind { task, plot }
|
||||
|
||||
class QueueState {
|
||||
QueueState({Iterable<QueueEntry>? entries})
|
||||
: entries = Queue<QueueEntry>.from(entries ?? const []);
|
||||
|
||||
final Queue<QueueEntry> entries;
|
||||
|
||||
factory QueueState.empty() => QueueState(entries: const []);
|
||||
|
||||
QueueState copyWith({Iterable<QueueEntry>? entries}) {
|
||||
return QueueState(entries: Queue<QueueEntry>.from(entries ?? this.entries));
|
||||
}
|
||||
}
|
||||
31
lib/src/core/model/pq_config.dart
Normal file
31
lib/src/core/model/pq_config.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:askiineverdie/data/pq_config_data.dart';
|
||||
|
||||
/// Typed accessors for Progress Quest static data extracted from Config.dfm.
|
||||
class PqConfig {
|
||||
const PqConfig();
|
||||
|
||||
List<String> get spells => _copy('Spells');
|
||||
List<String> get offenseAttrib => _copy('OffenseAttrib');
|
||||
List<String> get defenseAttrib => _copy('DefenseAttrib');
|
||||
List<String> get offenseBad => _copy('OffenseBad');
|
||||
List<String> get defenseBad => _copy('DefenseBad');
|
||||
List<String> get shields => _copy('Shields');
|
||||
List<String> get armors => _copy('Armors');
|
||||
List<String> get weapons => _copy('Weapons');
|
||||
List<String> get specials => _copy('Specials');
|
||||
List<String> get itemAttrib => _copy('ItemAttrib');
|
||||
List<String> get itemOfs => _copy('ItemOfs');
|
||||
List<String> get boringItems => _copy('BoringItems');
|
||||
List<String> get monsters => _copy('Monsters');
|
||||
List<String> get monMods => _copy('MonMods');
|
||||
List<String> get races => _copy('Races');
|
||||
List<String> get klasses => _copy('Klasses');
|
||||
List<String> get titles => _copy('Titles');
|
||||
List<String> get impressiveTitles => _copy('ImpressiveTitles');
|
||||
|
||||
List<String> _copy(String key) {
|
||||
final values = pqConfigData[key];
|
||||
if (values == null) return const [];
|
||||
return List<String>.from(values);
|
||||
}
|
||||
}
|
||||
237
lib/src/core/model/save_data.dart
Normal file
237
lib/src/core/model/save_data.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
const int kSaveVersion = 2;
|
||||
|
||||
class GameSave {
|
||||
GameSave({
|
||||
required this.version,
|
||||
required this.rngState,
|
||||
required this.traits,
|
||||
required this.stats,
|
||||
required this.inventory,
|
||||
required this.equipment,
|
||||
required this.spellBook,
|
||||
required this.progress,
|
||||
required this.queue,
|
||||
});
|
||||
|
||||
factory GameSave.fromState(GameState state) {
|
||||
return GameSave(
|
||||
version: kSaveVersion,
|
||||
rngState: state.rng.state,
|
||||
traits: state.traits,
|
||||
stats: state.stats,
|
||||
inventory: state.inventory,
|
||||
equipment: state.equipment,
|
||||
spellBook: state.spellBook,
|
||||
progress: state.progress,
|
||||
queue: state.queue,
|
||||
);
|
||||
}
|
||||
|
||||
final int version;
|
||||
final int rngState;
|
||||
final Traits traits;
|
||||
final Stats stats;
|
||||
final Inventory inventory;
|
||||
final Equipment equipment;
|
||||
final SpellBook spellBook;
|
||||
final ProgressState progress;
|
||||
final QueueState queue;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'version': version,
|
||||
'rng': rngState,
|
||||
'traits': {
|
||||
'name': traits.name,
|
||||
'race': traits.race,
|
||||
'klass': traits.klass,
|
||||
'level': traits.level,
|
||||
'motto': traits.motto,
|
||||
'guild': traits.guild,
|
||||
},
|
||||
'stats': {
|
||||
'str': stats.str,
|
||||
'con': stats.con,
|
||||
'dex': stats.dex,
|
||||
'int': stats.intelligence,
|
||||
'wis': stats.wis,
|
||||
'cha': stats.cha,
|
||||
'hpMax': stats.hpMax,
|
||||
'mpMax': stats.mpMax,
|
||||
},
|
||||
'inventory': {
|
||||
'gold': inventory.gold,
|
||||
'items': inventory.items
|
||||
.map((e) => {'name': e.name, 'count': e.count})
|
||||
.toList(),
|
||||
},
|
||||
'equipment': {
|
||||
'weapon': equipment.weapon,
|
||||
'shield': equipment.shield,
|
||||
'armor': equipment.armor,
|
||||
'bestIndex': equipment.bestIndex,
|
||||
},
|
||||
'spells': spellBook.spells
|
||||
.map((e) => {'name': e.name, 'rank': e.rank})
|
||||
.toList(),
|
||||
'progress': {
|
||||
'task': _barToJson(progress.task),
|
||||
'quest': _barToJson(progress.quest),
|
||||
'plot': _barToJson(progress.plot),
|
||||
'exp': _barToJson(progress.exp),
|
||||
'encumbrance': _barToJson(progress.encumbrance),
|
||||
'taskInfo': {
|
||||
'caption': progress.currentTask.caption,
|
||||
'type': progress.currentTask.type.name,
|
||||
},
|
||||
'plotStages': progress.plotStageCount,
|
||||
'questCount': progress.questCount,
|
||||
},
|
||||
'queue': queue.entries
|
||||
.map(
|
||||
(e) => {
|
||||
'kind': e.kind.name,
|
||||
'duration': e.durationMillis,
|
||||
'caption': e.caption,
|
||||
'taskType': e.taskType.name,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static GameSave fromJson(Map<String, dynamic> json) {
|
||||
final traitsJson = json['traits'] as Map<String, dynamic>;
|
||||
final statsJson = json['stats'] as Map<String, dynamic>;
|
||||
final inventoryJson = json['inventory'] as Map<String, dynamic>;
|
||||
final equipmentJson = json['equipment'] as Map<String, dynamic>;
|
||||
final progressJson = json['progress'] as Map<String, dynamic>;
|
||||
final queueJson = (json['queue'] as List<dynamic>? ?? []).cast<dynamic>();
|
||||
final spellsJson = (json['spells'] as List<dynamic>? ?? []).cast<dynamic>();
|
||||
|
||||
return GameSave(
|
||||
version: json['version'] as int? ?? kSaveVersion,
|
||||
rngState: json['rng'] as int? ?? 0,
|
||||
traits: Traits(
|
||||
name: traitsJson['name'] as String? ?? '',
|
||||
race: traitsJson['race'] as String? ?? '',
|
||||
klass: traitsJson['klass'] as String? ?? '',
|
||||
level: traitsJson['level'] as int? ?? 1,
|
||||
motto: traitsJson['motto'] as String? ?? '',
|
||||
guild: traitsJson['guild'] as String? ?? '',
|
||||
),
|
||||
stats: Stats(
|
||||
str: statsJson['str'] as int? ?? 0,
|
||||
con: statsJson['con'] as int? ?? 0,
|
||||
dex: statsJson['dex'] as int? ?? 0,
|
||||
intelligence: statsJson['int'] as int? ?? 0,
|
||||
wis: statsJson['wis'] as int? ?? 0,
|
||||
cha: statsJson['cha'] as int? ?? 0,
|
||||
hpMax: statsJson['hpMax'] as int? ?? 0,
|
||||
mpMax: statsJson['mpMax'] as int? ?? 0,
|
||||
),
|
||||
inventory: Inventory(
|
||||
gold: inventoryJson['gold'] as int? ?? 0,
|
||||
items: (inventoryJson['items'] as List<dynamic>? ?? [])
|
||||
.map(
|
||||
(e) => InventoryEntry(
|
||||
name: (e as Map<String, dynamic>)['name'] as String? ?? '',
|
||||
count: (e)['count'] as int? ?? 0,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
equipment: Equipment(
|
||||
weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick',
|
||||
shield: equipmentJson['shield'] as String? ?? '',
|
||||
armor: equipmentJson['armor'] as String? ?? '',
|
||||
bestIndex: equipmentJson['bestIndex'] as int? ?? 0,
|
||||
),
|
||||
spellBook: SpellBook(
|
||||
spells: spellsJson
|
||||
.map(
|
||||
(e) => SpellEntry(
|
||||
name: (e as Map<String, dynamic>)['name'] as String? ?? '',
|
||||
rank: (e)['rank'] as String? ?? 'I',
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
progress: ProgressState(
|
||||
task: _barFromJson(progressJson['task'] as Map<String, dynamic>? ?? {}),
|
||||
quest: _barFromJson(
|
||||
progressJson['quest'] as Map<String, dynamic>? ?? {},
|
||||
),
|
||||
plot: _barFromJson(progressJson['plot'] as Map<String, dynamic>? ?? {}),
|
||||
exp: _barFromJson(progressJson['exp'] as Map<String, dynamic>? ?? {}),
|
||||
encumbrance: _barFromJson(
|
||||
progressJson['encumbrance'] as Map<String, dynamic>? ?? {},
|
||||
),
|
||||
currentTask: _taskInfoFromJson(
|
||||
progressJson['taskInfo'] as Map<String, dynamic>? ??
|
||||
<String, dynamic>{},
|
||||
),
|
||||
plotStageCount: progressJson['plotStages'] as int? ?? 1,
|
||||
questCount: progressJson['questCount'] as int? ?? 0,
|
||||
),
|
||||
queue: QueueState(
|
||||
entries: Queue<QueueEntry>.from(
|
||||
queueJson.map((e) {
|
||||
final m = e as Map<String, dynamic>;
|
||||
final kind = QueueKind.values.firstWhere(
|
||||
(k) => k.name == m['kind'],
|
||||
orElse: () => QueueKind.task,
|
||||
);
|
||||
final taskType = TaskType.values.firstWhere(
|
||||
(t) => t.name == m['taskType'],
|
||||
orElse: () => TaskType.neutral,
|
||||
);
|
||||
return QueueEntry(
|
||||
kind: kind,
|
||||
durationMillis: m['duration'] as int? ?? 0,
|
||||
caption: m['caption'] as String? ?? '',
|
||||
taskType: taskType,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GameState toState() {
|
||||
return GameState(
|
||||
rng: DeterministicRandom.fromState(rngState),
|
||||
traits: traits,
|
||||
stats: stats,
|
||||
inventory: inventory,
|
||||
equipment: equipment,
|
||||
spellBook: spellBook,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _barToJson(ProgressBarState bar) => {
|
||||
'pos': bar.position,
|
||||
'max': bar.max,
|
||||
};
|
||||
|
||||
ProgressBarState _barFromJson(Map<String, dynamic> json) => ProgressBarState(
|
||||
position: json['pos'] as int? ?? 0,
|
||||
max: json['max'] as int? ?? 1,
|
||||
);
|
||||
|
||||
TaskInfo _taskInfoFromJson(Map<String, dynamic> json) {
|
||||
final typeName = json['type'] as String?;
|
||||
final type = TaskType.values.firstWhere(
|
||||
(t) => t.name == typeName,
|
||||
orElse: () => TaskType.neutral,
|
||||
);
|
||||
return TaskInfo(caption: json['caption'] as String? ?? '', type: type);
|
||||
}
|
||||
33
lib/src/core/storage/save_manager.dart
Normal file
33
lib/src/core/storage/save_manager.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/save_data.dart';
|
||||
import 'package:askiineverdie/src/core/storage/save_repository.dart';
|
||||
import 'package:askiineverdie/src/core/storage/save_service.dart'
|
||||
show SaveFileInfo;
|
||||
|
||||
/// Coordinates saving/loading GameState using SaveRepository.
|
||||
class SaveManager {
|
||||
SaveManager(this._repo);
|
||||
|
||||
final SaveRepository _repo;
|
||||
static const String defaultFileName = 'progress.pqf';
|
||||
|
||||
/// Save current game state to disk. [fileName] may be absolute or relative.
|
||||
/// Returns outcome with error on failure.
|
||||
Future<SaveOutcome> saveState(GameState state, {String? fileName}) {
|
||||
final save = GameSave.fromState(state);
|
||||
return _repo.save(save, fileName ?? defaultFileName);
|
||||
}
|
||||
|
||||
/// Load game state from disk. [fileName] may be absolute (e.g., file picker).
|
||||
/// Returns outcome + optional state.
|
||||
Future<(SaveOutcome, GameState?)> loadState({String? fileName}) async {
|
||||
final (outcome, save) = await _repo.load(fileName ?? defaultFileName);
|
||||
if (!outcome.success || save == null) {
|
||||
return (outcome, null);
|
||||
}
|
||||
return (outcome, save.toState());
|
||||
}
|
||||
|
||||
/// 저장 파일 목록 조회
|
||||
Future<List<SaveFileInfo>> listSaves() => _repo.listSaves();
|
||||
}
|
||||
64
lib/src/core/storage/save_repository.dart
Normal file
64
lib/src/core/storage/save_repository.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/save_data.dart';
|
||||
import 'package:askiineverdie/src/core/storage/save_service.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class SaveOutcome {
|
||||
const SaveOutcome.success([this.error]) : success = true;
|
||||
const SaveOutcome.failure(this.error) : success = false;
|
||||
|
||||
final bool success;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
/// High-level save/load wrapper that resolves platform storage paths.
|
||||
class SaveRepository {
|
||||
SaveRepository() : _service = null;
|
||||
|
||||
SaveService? _service;
|
||||
|
||||
Future<void> _ensureService() async {
|
||||
if (_service != null) return;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_service = SaveService(baseDir: dir);
|
||||
}
|
||||
|
||||
Future<SaveOutcome> save(GameSave save, String fileName) async {
|
||||
try {
|
||||
await _ensureService();
|
||||
await _service!.save(save, fileName);
|
||||
return const SaveOutcome.success();
|
||||
} on FileSystemException catch (e) {
|
||||
final reason = e.osError?.message ?? e.message;
|
||||
return SaveOutcome.failure('Unable to save file: $reason');
|
||||
} catch (e) {
|
||||
return SaveOutcome.failure(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<(SaveOutcome, GameSave?)> load(String fileName) async {
|
||||
try {
|
||||
await _ensureService();
|
||||
final data = await _service!.load(fileName);
|
||||
return (const SaveOutcome.success(), data);
|
||||
} on FileSystemException catch (e) {
|
||||
final reason = e.osError?.message ?? e.message;
|
||||
return (SaveOutcome.failure('Unable to load save: $reason'), null);
|
||||
} on FormatException catch (e) {
|
||||
return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null);
|
||||
} catch (e) {
|
||||
return (SaveOutcome.failure(e.toString()), null);
|
||||
}
|
||||
}
|
||||
|
||||
/// 저장 파일 목록 조회
|
||||
Future<List<SaveFileInfo>> listSaves() async {
|
||||
try {
|
||||
await _ensureService();
|
||||
return await _service!.listSaves();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
84
lib/src/core/storage/save_service.dart
Normal file
84
lib/src/core/storage/save_service.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/save_data.dart';
|
||||
|
||||
/// Persists GameSave as JSON compressed with GZipCodec.
|
||||
class SaveService {
|
||||
SaveService({required this.baseDir});
|
||||
|
||||
final Directory baseDir;
|
||||
final GZipCodec _gzip = GZipCodec();
|
||||
|
||||
Future<File> save(GameSave save, String fileName) async {
|
||||
final path = _resolvePath(fileName);
|
||||
final file = File(path);
|
||||
await file.parent.create(recursive: true);
|
||||
final jsonStr = jsonEncode(save.toJson());
|
||||
final bytes = utf8.encode(jsonStr);
|
||||
final compressed = _gzip.encode(bytes);
|
||||
return file.writeAsBytes(compressed);
|
||||
}
|
||||
|
||||
Future<GameSave> load(String fileName) async {
|
||||
final path = _resolvePath(fileName);
|
||||
final file = File(path);
|
||||
final compressed = await file.readAsBytes();
|
||||
final decompressed = _gzip.decode(compressed);
|
||||
final jsonStr = utf8.decode(decompressed);
|
||||
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
return GameSave.fromJson(map);
|
||||
}
|
||||
|
||||
String _resolvePath(String fileName) {
|
||||
final normalized = fileName.endsWith('.pqf') ? fileName : '$fileName.pqf';
|
||||
final file = File(normalized);
|
||||
if (file.isAbsolute) return file.path;
|
||||
return '${baseDir.path}/$normalized';
|
||||
}
|
||||
|
||||
/// 저장 디렉토리의 모든 .pqf 파일 목록 반환
|
||||
Future<List<SaveFileInfo>> listSaves() async {
|
||||
if (!await baseDir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final files = <SaveFileInfo>[];
|
||||
await for (final entity in baseDir.list()) {
|
||||
if (entity is File && entity.path.endsWith('.pqf')) {
|
||||
final stat = await entity.stat();
|
||||
final name = entity.uri.pathSegments.last;
|
||||
files.add(
|
||||
SaveFileInfo(
|
||||
fileName: name,
|
||||
fullPath: entity.path,
|
||||
modifiedAt: stat.modified,
|
||||
sizeBytes: stat.size,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 수정된 파일 순으로 정렬
|
||||
files.sort((a, b) => b.modifiedAt.compareTo(a.modifiedAt));
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
/// 저장 파일 정보
|
||||
class SaveFileInfo {
|
||||
const SaveFileInfo({
|
||||
required this.fileName,
|
||||
required this.fullPath,
|
||||
required this.modifiedAt,
|
||||
required this.sizeBytes,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final String fullPath;
|
||||
final DateTime modifiedAt;
|
||||
final int sizeBytes;
|
||||
|
||||
/// 확장자 없는 표시용 이름
|
||||
String get displayName => fileName.replaceAll('.pqf', '');
|
||||
}
|
||||
38
lib/src/core/util/deterministic_random.dart
Normal file
38
lib/src/core/util/deterministic_random.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// Simple deterministic RNG (xorshift32) with serializable state.
|
||||
class DeterministicRandom {
|
||||
DeterministicRandom(int seed) : _state = seed & _mask;
|
||||
|
||||
DeterministicRandom.clone(DeterministicRandom other)
|
||||
: _state = other._state & _mask;
|
||||
|
||||
DeterministicRandom.fromState(int state) : _state = state & _mask;
|
||||
|
||||
static const int _mask = 0xFFFFFFFF;
|
||||
|
||||
int _state;
|
||||
|
||||
int get state => _state;
|
||||
|
||||
/// Returns next unsigned 32-bit value.
|
||||
int nextUint32() {
|
||||
var x = _state;
|
||||
x ^= (x << 13) & _mask;
|
||||
x ^= (x >> 17) & _mask;
|
||||
x ^= (x << 5) & _mask;
|
||||
_state = x & _mask;
|
||||
return _state;
|
||||
}
|
||||
|
||||
int nextInt(int maxExclusive) {
|
||||
if (maxExclusive <= 0) {
|
||||
throw ArgumentError.value(maxExclusive, 'maxExclusive', 'must be > 0');
|
||||
}
|
||||
return nextUint32() % maxExclusive;
|
||||
}
|
||||
|
||||
double nextDouble() {
|
||||
// 2^32 as double.
|
||||
const double denom = 4294967296.0;
|
||||
return nextUint32() / denom;
|
||||
}
|
||||
}
|
||||
820
lib/src/core/util/pq_logic.dart
Normal file
820
lib/src/core/util/pq_logic.dart
Normal file
@@ -0,0 +1,820 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:askiineverdie/src/core/util/roman.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
// Mirrors core utility functions from the original Delphi sources (Main.pas / NewGuy.pas).
|
||||
|
||||
int levelUpTimeSeconds(int level) {
|
||||
// ~20 minutes for level 1, then exponential growth (same as LevelUpTime in Main.pas).
|
||||
final seconds = (20.0 + math.pow(1.15, level)) * 60.0;
|
||||
return seconds.round();
|
||||
}
|
||||
|
||||
/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271)
|
||||
String roughTime(int seconds) {
|
||||
if (seconds < 120) {
|
||||
return '$seconds seconds';
|
||||
} else if (seconds < 60 * 120) {
|
||||
return '${seconds ~/ 60} minutes';
|
||||
} else if (seconds < 60 * 60 * 48) {
|
||||
return '${seconds ~/ 3600} hours';
|
||||
} else {
|
||||
return '${seconds ~/ (3600 * 24)} days';
|
||||
}
|
||||
}
|
||||
|
||||
String pluralize(String s) {
|
||||
if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies';
|
||||
if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i';
|
||||
if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es';
|
||||
if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves';
|
||||
if (_ends(s, 'man') || _ends(s, 'Man')) {
|
||||
return '${s.substring(0, s.length - 2)}en';
|
||||
}
|
||||
return '${s}s';
|
||||
}
|
||||
|
||||
String indefinite(String s, int qty) {
|
||||
if (qty == 1) {
|
||||
const vowels = 'AEIOUÜaeiouü';
|
||||
final first = s.isNotEmpty ? s[0] : 'a';
|
||||
final article = vowels.contains(first) ? 'an' : 'a';
|
||||
return '$article $s';
|
||||
}
|
||||
return '$qty ${pluralize(s)}';
|
||||
}
|
||||
|
||||
String definite(String s, int qty) {
|
||||
if (qty > 1) {
|
||||
s = pluralize(s);
|
||||
}
|
||||
return 'the $s';
|
||||
}
|
||||
|
||||
String generateName(DeterministicRandom rng) {
|
||||
const kParts = [
|
||||
'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z',
|
||||
'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou',
|
||||
'b|ck|d|g|k|m|n|p|t|v|x|z',
|
||||
];
|
||||
|
||||
var result = '';
|
||||
for (var i = 0; i <= 5; i++) {
|
||||
result += _pick(kParts[i % 3], rng);
|
||||
}
|
||||
if (result.isEmpty) return result;
|
||||
return '${result[0].toUpperCase()}${result.substring(1)}';
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
int randomLow(DeterministicRandom rng, int below) {
|
||||
return math.min(rng.nextInt(below), rng.nextInt(below));
|
||||
}
|
||||
|
||||
String pick(List<String> values, DeterministicRandom rng) {
|
||||
if (values.isEmpty) return '';
|
||||
return values[rng.nextInt(values.length)];
|
||||
}
|
||||
|
||||
String pickLow(List<String> values, DeterministicRandom rng) {
|
||||
if (values.isEmpty) return '';
|
||||
return values[randomLow(rng, values.length)];
|
||||
}
|
||||
|
||||
// Item name generators (match Main.pas)
|
||||
String boringItem(PqConfig config, DeterministicRandom rng) {
|
||||
return pick(config.boringItems, rng);
|
||||
}
|
||||
|
||||
String interestingItem(PqConfig config, DeterministicRandom rng) {
|
||||
final attr = pick(config.itemAttrib, rng);
|
||||
final special = pick(config.specials, rng);
|
||||
return '$attr $special';
|
||||
}
|
||||
|
||||
String specialItem(PqConfig config, DeterministicRandom rng) {
|
||||
return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}';
|
||||
}
|
||||
|
||||
String pickWeapon(PqConfig config, DeterministicRandom rng, int level) {
|
||||
return _lPick(config.weapons, rng, level);
|
||||
}
|
||||
|
||||
String pickShield(PqConfig config, DeterministicRandom rng, int level) {
|
||||
return _lPick(config.shields, rng, level);
|
||||
}
|
||||
|
||||
String pickArmor(PqConfig config, DeterministicRandom rng, int level) {
|
||||
return _lPick(config.armors, rng, level);
|
||||
}
|
||||
|
||||
String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) {
|
||||
return _lPick(config.spells, rng, goalLevel);
|
||||
}
|
||||
|
||||
/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택
|
||||
String _lPick(List<String> items, DeterministicRandom rng, int goal) {
|
||||
if (items.isEmpty) return '';
|
||||
var result = pick(items, rng);
|
||||
var bestLevel = _parseLevel(result);
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final candidate = pick(items, rng);
|
||||
final candLevel = _parseLevel(candidate);
|
||||
if ((goal - candLevel).abs() < (goal - bestLevel).abs()) {
|
||||
result = candidate;
|
||||
bestLevel = candLevel;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int _parseLevel(String entry) {
|
||||
final parts = entry.split('|');
|
||||
if (parts.length < 2) return 0;
|
||||
return int.tryParse(parts[1].replaceAll('+', '')) ?? 0;
|
||||
}
|
||||
|
||||
|
||||
String addModifier(
|
||||
DeterministicRandom rng,
|
||||
String baseName,
|
||||
List<String> modifiers,
|
||||
int plus,
|
||||
) {
|
||||
var name = baseName;
|
||||
var remaining = plus;
|
||||
var count = 0;
|
||||
|
||||
while (count < 2 && remaining != 0) {
|
||||
final modifier = pick(modifiers, rng);
|
||||
final parts = modifier.split('|');
|
||||
if (parts.isEmpty) break;
|
||||
final label = parts[0];
|
||||
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
|
||||
if (name.contains(label)) break; // avoid repeats
|
||||
if (remaining.abs() < qual.abs()) break;
|
||||
name = '$label $name';
|
||||
remaining -= qual;
|
||||
count++;
|
||||
}
|
||||
|
||||
if (remaining != 0) {
|
||||
name = '${remaining > 0 ? '+' : ''}$remaining $name';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Character/stat growth
|
||||
int levelUpTime(int level) => levelUpTimeSeconds(level);
|
||||
|
||||
String winSpell(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int wisdom,
|
||||
int level,
|
||||
) {
|
||||
// 원본 Main.pas:770-774: RandomLow로 인덱스 선택 (리스트 앞쪽 선호)
|
||||
final maxIndex = math.min(wisdom + level, config.spells.length);
|
||||
if (maxIndex <= 0) return '';
|
||||
final index = randomLow(rng, maxIndex);
|
||||
final entry = config.spells[index];
|
||||
final parts = entry.split('|');
|
||||
final name = parts[0];
|
||||
final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I');
|
||||
final nextRank = math.max(1, currentRank + 1);
|
||||
return '$name|${intToRoman(nextRank)}';
|
||||
}
|
||||
|
||||
String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) {
|
||||
// If inventory is already very large, signal caller to duplicate an existing item.
|
||||
final threshold = math.max(250, rng.nextInt(999));
|
||||
if (inventoryCount > threshold) return '';
|
||||
return specialItem(config, rng);
|
||||
}
|
||||
|
||||
int rollStat(DeterministicRandom rng) {
|
||||
// 3d6 roll.
|
||||
return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6);
|
||||
}
|
||||
|
||||
int random64Below(DeterministicRandom rng, int below) {
|
||||
if (below <= 0) return 0;
|
||||
final hi = rng.nextUint32();
|
||||
final lo = rng.nextUint32();
|
||||
final combined = (hi << 32) | lo;
|
||||
return (combined % below).toInt();
|
||||
}
|
||||
|
||||
String winEquip(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int level,
|
||||
EquipmentSlot slot,
|
||||
) {
|
||||
// Decide item set and modifiers based on slot.
|
||||
final bool isWeapon = slot == EquipmentSlot.weapon;
|
||||
final items = switch (slot) {
|
||||
EquipmentSlot.weapon => config.weapons,
|
||||
EquipmentSlot.shield => config.shields,
|
||||
EquipmentSlot.armor => config.armors,
|
||||
};
|
||||
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
|
||||
final worse = isWeapon ? config.offenseBad : config.defenseBad;
|
||||
|
||||
final base = _lPick(items, rng, level);
|
||||
final parts = base.split('|');
|
||||
final baseName = parts[0];
|
||||
final qual = parts.length > 1
|
||||
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
|
||||
: 0;
|
||||
|
||||
final plus = level - qual;
|
||||
final modifiers = plus >= 0 ? better : worse;
|
||||
return addModifier(rng, baseName, modifiers, plus);
|
||||
}
|
||||
|
||||
int winStatIndex(DeterministicRandom rng, List<int> statValues) {
|
||||
// 원본 Main.pas:870-883: 50% 확률로 완전 랜덤, 50% 확률로 제곱 가중치
|
||||
if (rng.nextInt(2) == 0) {
|
||||
// Odds(1,2): 완전 랜덤 선택
|
||||
return rng.nextInt(statValues.length);
|
||||
}
|
||||
// 제곱 가중치로 높은 스탯 선호
|
||||
final total = statValues.fold<int>(0, (sum, v) => sum + v * v);
|
||||
if (total == 0) return rng.nextInt(statValues.length);
|
||||
var pickValue = random64Below(rng, total);
|
||||
for (var i = 0; i < statValues.length; i++) {
|
||||
pickValue -= statValues[i] * statValues[i];
|
||||
if (pickValue < 0) return i;
|
||||
}
|
||||
return statValues.length - 1;
|
||||
}
|
||||
|
||||
Stats winStat(Stats stats, DeterministicRandom rng) {
|
||||
final values = <int>[
|
||||
stats.str,
|
||||
stats.con,
|
||||
stats.dex,
|
||||
stats.intelligence,
|
||||
stats.wis,
|
||||
stats.cha,
|
||||
stats.hpMax,
|
||||
stats.mpMax,
|
||||
];
|
||||
final idx = winStatIndex(rng, values);
|
||||
switch (idx) {
|
||||
case 0:
|
||||
return stats.copyWith(str: stats.str + 1);
|
||||
case 1:
|
||||
return stats.copyWith(con: stats.con + 1);
|
||||
case 2:
|
||||
return stats.copyWith(dex: stats.dex + 1);
|
||||
case 3:
|
||||
return stats.copyWith(intelligence: stats.intelligence + 1);
|
||||
case 4:
|
||||
return stats.copyWith(wis: stats.wis + 1);
|
||||
case 5:
|
||||
return stats.copyWith(cha: stats.cha + 1);
|
||||
case 6:
|
||||
return stats.copyWith(hpMax: stats.hpMax + 1);
|
||||
case 7:
|
||||
return stats.copyWith(mpMax: stats.mpMax + 1);
|
||||
default:
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
String monsterTask(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int level,
|
||||
String? questMonster, // optional monster name from quest
|
||||
int? questLevel,
|
||||
) {
|
||||
var targetLevel = level;
|
||||
|
||||
for (var i = level; i > 0; i--) {
|
||||
if (rng.nextInt(5) < 2) {
|
||||
targetLevel += rng.nextInt(2) * 2 - 1; // RandSign
|
||||
}
|
||||
}
|
||||
if (targetLevel < 1) targetLevel = 1;
|
||||
|
||||
String monster;
|
||||
int monsterLevel;
|
||||
bool definite = false;
|
||||
|
||||
// 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용
|
||||
if (rng.nextInt(25) == 0) {
|
||||
final race = pick(config.races, rng).split('|').first;
|
||||
if (rng.nextInt(2) == 0) {
|
||||
// 'passing Race Class' 형태
|
||||
final klass = pick(config.klasses, rng).split('|').first;
|
||||
monster = 'passing $race $klass';
|
||||
} else {
|
||||
// 'Title Name the Race' 형태 (원본은 PickLow(Titles) 사용)
|
||||
final title = pickLow(config.titles, rng);
|
||||
monster = '$title ${generateName(rng)} the $race';
|
||||
definite = true;
|
||||
}
|
||||
monsterLevel = targetLevel;
|
||||
monster = '$monster|$monsterLevel|*';
|
||||
} else if (questMonster != null && rng.nextInt(4) == 0) {
|
||||
// Use quest monster.
|
||||
monster = questMonster;
|
||||
monsterLevel = questLevel ?? targetLevel;
|
||||
} else {
|
||||
// Pick closest level among random samples.
|
||||
monster = pick(config.monsters, rng);
|
||||
monsterLevel = _monsterLevel(monster);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final candidate = pick(config.monsters, rng);
|
||||
final candLevel = _monsterLevel(candidate);
|
||||
if ((targetLevel - candLevel).abs() <
|
||||
(targetLevel - monsterLevel).abs()) {
|
||||
monster = candidate;
|
||||
monsterLevel = candLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust quantity and adjectives based on level delta.
|
||||
var qty = 1;
|
||||
final levelDiff = targetLevel - monsterLevel;
|
||||
var name = monster.split('|').first;
|
||||
|
||||
if (levelDiff > 10) {
|
||||
qty =
|
||||
(targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/
|
||||
(monsterLevel == 0 ? 1 : monsterLevel);
|
||||
if (qty < 1) qty = 1;
|
||||
targetLevel ~/= qty;
|
||||
}
|
||||
|
||||
if (levelDiff <= -10) {
|
||||
name = 'imaginary $name';
|
||||
} else if (levelDiff < -5) {
|
||||
final i = 5 - rng.nextInt(10 + levelDiff + 1);
|
||||
name = _sick(i, _young((monsterLevel - targetLevel) - i, name));
|
||||
} else if (levelDiff < 0) {
|
||||
if (rng.nextInt(2) == 1) {
|
||||
name = _sick(levelDiff, name);
|
||||
} else {
|
||||
name = _young(levelDiff, name);
|
||||
}
|
||||
} else if (levelDiff >= 10) {
|
||||
name = 'messianic $name';
|
||||
} else if (levelDiff > 5) {
|
||||
final i = 5 - rng.nextInt(10 - levelDiff + 1);
|
||||
name = _big(i, _special((levelDiff) - i, name));
|
||||
} else if (levelDiff > 0) {
|
||||
if (rng.nextInt(2) == 1) {
|
||||
name = _big(levelDiff, name);
|
||||
} else {
|
||||
name = _special(levelDiff, name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!definite) {
|
||||
name = indefinite(name, qty);
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
enum RewardKind { spell, equip, stat, item }
|
||||
|
||||
class QuestResult {
|
||||
const QuestResult({
|
||||
required this.caption,
|
||||
required this.reward,
|
||||
this.monsterName,
|
||||
this.monsterLevel,
|
||||
});
|
||||
|
||||
final String caption;
|
||||
final RewardKind reward;
|
||||
final String? monsterName;
|
||||
final int? monsterLevel;
|
||||
}
|
||||
|
||||
QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
|
||||
final rewardRoll = rng.nextInt(4);
|
||||
final reward = switch (rewardRoll) {
|
||||
0 => RewardKind.spell,
|
||||
1 => RewardKind.equip,
|
||||
2 => RewardKind.stat,
|
||||
_ => RewardKind.item,
|
||||
};
|
||||
|
||||
final questRoll = rng.nextInt(5);
|
||||
switch (questRoll) {
|
||||
case 0:
|
||||
var best = '';
|
||||
var bestLevel = 0;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
final m = pick(config.monsters, rng);
|
||||
final l = _monsterLevel(m);
|
||||
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
|
||||
best = m;
|
||||
bestLevel = l;
|
||||
}
|
||||
}
|
||||
final name = best.split('|').first;
|
||||
return QuestResult(
|
||||
caption: 'Exterminate ${definite(name, 2)}',
|
||||
reward: reward,
|
||||
monsterName: best,
|
||||
monsterLevel: bestLevel,
|
||||
);
|
||||
case 1:
|
||||
final item = interestingItem(config, rng);
|
||||
return QuestResult(caption: 'Seek ${definite(item, 1)}', reward: reward);
|
||||
case 2:
|
||||
final item = boringItem(config, rng);
|
||||
return QuestResult(caption: 'Deliver this $item', reward: reward);
|
||||
case 3:
|
||||
final item = boringItem(config, rng);
|
||||
return QuestResult(
|
||||
caption: 'Fetch me ${indefinite(item, 1)}',
|
||||
reward: reward,
|
||||
);
|
||||
default:
|
||||
var best = '';
|
||||
var bestLevel = 0;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final m = pick(config.monsters, rng);
|
||||
final l = _monsterLevel(m);
|
||||
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
|
||||
best = m;
|
||||
bestLevel = l;
|
||||
}
|
||||
}
|
||||
final name = best.split('|').first;
|
||||
return QuestResult(
|
||||
caption: 'Placate ${definite(name, 2)}',
|
||||
reward: reward,
|
||||
monsterName: best,
|
||||
monsterLevel: bestLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActResult {
|
||||
const ActResult({
|
||||
required this.actTitle,
|
||||
required this.plotBarMaxSeconds,
|
||||
required this.rewards,
|
||||
});
|
||||
|
||||
final String actTitle;
|
||||
final int plotBarMaxSeconds;
|
||||
final List<RewardKind> rewards;
|
||||
}
|
||||
|
||||
ActResult completeAct(int existingActCount) {
|
||||
final nextActIndex = existingActCount;
|
||||
final title = 'Act ${intToRoman(nextActIndex)}';
|
||||
final plotBarMax = 60 * 60 * (1 + 5 * existingActCount);
|
||||
|
||||
final rewards = <RewardKind>[];
|
||||
if (existingActCount > 1) {
|
||||
rewards.add(RewardKind.item);
|
||||
}
|
||||
if (existingActCount > 2) {
|
||||
rewards.add(RewardKind.equip);
|
||||
}
|
||||
|
||||
return ActResult(
|
||||
actTitle: title,
|
||||
plotBarMaxSeconds: plotBarMax,
|
||||
rewards: rewards,
|
||||
);
|
||||
}
|
||||
|
||||
class TaskResult {
|
||||
const TaskResult({
|
||||
required this.caption,
|
||||
required this.durationMillis,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
final String caption;
|
||||
final int durationMillis;
|
||||
final ProgressState progress;
|
||||
}
|
||||
|
||||
/// Starts a task: resets task bar and sets caption.
|
||||
TaskResult startTask(
|
||||
ProgressState progress,
|
||||
String caption,
|
||||
int durationMillis,
|
||||
) {
|
||||
final updated = progress.copyWith(
|
||||
task: ProgressBarState(position: 0, max: durationMillis),
|
||||
);
|
||||
return TaskResult(
|
||||
caption: '$caption...',
|
||||
durationMillis: durationMillis,
|
||||
progress: updated,
|
||||
);
|
||||
}
|
||||
|
||||
class DequeueResult {
|
||||
const DequeueResult({
|
||||
required this.progress,
|
||||
required this.queue,
|
||||
required this.caption,
|
||||
required this.taskType,
|
||||
required this.kind,
|
||||
});
|
||||
|
||||
final ProgressState progress;
|
||||
final QueueState queue;
|
||||
final String caption;
|
||||
final TaskType taskType;
|
||||
final QueueKind kind;
|
||||
}
|
||||
|
||||
/// Process the queue when current task is done. Returns null if nothing to do.
|
||||
DequeueResult? dequeue(ProgressState progress, QueueState queue) {
|
||||
// Only act when the task bar is finished.
|
||||
if (progress.task.position < progress.task.max) return null;
|
||||
if (queue.entries.isEmpty) return null;
|
||||
|
||||
final entries = Queue<QueueEntry>.from(queue.entries);
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final next = entries.removeFirst();
|
||||
final taskResult = startTask(progress, next.caption, next.durationMillis);
|
||||
return DequeueResult(
|
||||
progress: taskResult.progress,
|
||||
queue: QueueState(entries: entries.toList()),
|
||||
caption: taskResult.caption,
|
||||
taskType: next.taskType,
|
||||
kind: next.kind,
|
||||
);
|
||||
}
|
||||
|
||||
int _monsterLevel(String entry) {
|
||||
final parts = entry.split('|');
|
||||
if (parts.length < 2) return 0;
|
||||
return int.tryParse(parts[1]) ?? 0;
|
||||
}
|
||||
|
||||
String _sick(int m, String s) {
|
||||
switch (m) {
|
||||
case -5:
|
||||
case 5:
|
||||
return 'dead $s';
|
||||
case -4:
|
||||
case 4:
|
||||
return 'comatose $s';
|
||||
case -3:
|
||||
case 3:
|
||||
return 'crippled $s';
|
||||
case -2:
|
||||
case 2:
|
||||
return 'sick $s';
|
||||
case -1:
|
||||
case 1:
|
||||
return 'undernourished $s';
|
||||
default:
|
||||
return '$m$s';
|
||||
}
|
||||
}
|
||||
|
||||
String _young(int m, String s) {
|
||||
switch (-m) {
|
||||
case -5:
|
||||
case 5:
|
||||
return 'foetal $s';
|
||||
case -4:
|
||||
case 4:
|
||||
return 'baby $s';
|
||||
case -3:
|
||||
case 3:
|
||||
return 'preadolescent $s';
|
||||
case -2:
|
||||
case 2:
|
||||
return 'teenage $s';
|
||||
case -1:
|
||||
case 1:
|
||||
return 'underage $s';
|
||||
default:
|
||||
return '$m$s';
|
||||
}
|
||||
}
|
||||
|
||||
String _big(int m, String s) {
|
||||
switch (m) {
|
||||
case 1:
|
||||
case -1:
|
||||
return 'greater $s';
|
||||
case 2:
|
||||
case -2:
|
||||
return 'massive $s';
|
||||
case 3:
|
||||
case -3:
|
||||
return 'enormous $s';
|
||||
case 4:
|
||||
case -4:
|
||||
return 'giant $s';
|
||||
case 5:
|
||||
case -5:
|
||||
return 'titanic $s';
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
String _special(int m, String s) {
|
||||
switch (-m) {
|
||||
case 1:
|
||||
case -1:
|
||||
return s.contains(' ') ? 'veteran $s' : 'Battle-$s';
|
||||
case 2:
|
||||
case -2:
|
||||
return 'cursed $s';
|
||||
case 3:
|
||||
case -3:
|
||||
return s.contains(' ') ? 'warrior $s' : 'Were-$s';
|
||||
case 4:
|
||||
case -4:
|
||||
return 'undead $s';
|
||||
case 5:
|
||||
case -5:
|
||||
return 'demon $s';
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
bool _ends(String s, String suffix) {
|
||||
return s.length >= suffix.length &&
|
||||
s.substring(s.length - suffix.length) == suffix;
|
||||
}
|
||||
|
||||
String _pick(String pipeSeparated, DeterministicRandom rng) {
|
||||
final parts = pipeSeparated.split('|');
|
||||
if (parts.isEmpty) return '';
|
||||
final idx = rng.nextInt(parts.length);
|
||||
return parts[idx];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// InterplotCinematic 관련 함수들 (Main.pas:456-521)
|
||||
// =============================================================================
|
||||
|
||||
/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521)
|
||||
/// 인상적인 타이틀 + 종족 또는 이름 조합
|
||||
String impressiveGuy(PqConfig config, DeterministicRandom rng) {
|
||||
var result = pick(config.impressiveTitles, rng);
|
||||
switch (rng.nextInt(2)) {
|
||||
case 0:
|
||||
// "the King of the Elves" 형태
|
||||
final race = pick(config.races, rng).split('|').first;
|
||||
result = 'the $result of the ${pluralize(race)}';
|
||||
break;
|
||||
case 1:
|
||||
// "King Vrognak of Zoxzik" 형태
|
||||
result = '$result ${generateName(rng)} of ${generateName(rng)}';
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512)
|
||||
/// 레벨에 맞는 몬스터 선택 후 GenerateName으로 이름 붙이기
|
||||
String namedMonster(PqConfig config, DeterministicRandom rng, int level) {
|
||||
String best = '';
|
||||
int bestLevel = 0;
|
||||
|
||||
// 5번 시도해서 레벨에 가장 가까운 몬스터 선택
|
||||
for (var i = 0; i < 5; i++) {
|
||||
final m = pick(config.monsters, rng);
|
||||
final parts = m.split('|');
|
||||
final name = parts.first;
|
||||
final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
|
||||
if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) {
|
||||
best = name;
|
||||
bestLevel = lev;
|
||||
}
|
||||
}
|
||||
|
||||
return '${generateName(rng)} the $best';
|
||||
}
|
||||
|
||||
/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495)
|
||||
/// 3가지 시나리오 중 하나를 랜덤 선택
|
||||
List<QueueEntry> interplotCinematic(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int level,
|
||||
int plotCount,
|
||||
) {
|
||||
final entries = <QueueEntry>[];
|
||||
|
||||
// 헬퍼: 큐 엔트리 추가 (원본의 Q 함수 역할)
|
||||
void q(QueueKind kind, int seconds, String caption) {
|
||||
entries.add(
|
||||
QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption),
|
||||
);
|
||||
}
|
||||
|
||||
switch (rng.nextInt(3)) {
|
||||
case 0:
|
||||
// 시나리오 1: 우호적 오아시스
|
||||
q(
|
||||
QueueKind.task,
|
||||
1,
|
||||
'Exhausted, you arrive at a friendly oasis in a hostile land',
|
||||
);
|
||||
q(QueueKind.task, 2, 'You greet old friends and meet new allies');
|
||||
q(QueueKind.task, 2, 'You are privy to a council of powerful do-gooders');
|
||||
q(QueueKind.task, 1, 'There is much to be done. You are chosen!');
|
||||
break;
|
||||
|
||||
case 1:
|
||||
// 시나리오 2: 강력한 적과의 전투
|
||||
q(
|
||||
QueueKind.task,
|
||||
1,
|
||||
'Your quarry is in sight, but a mighty enemy bars your path!',
|
||||
);
|
||||
final nemesis = namedMonster(config, rng, level + 3);
|
||||
q(QueueKind.task, 4, 'A desperate struggle commences with $nemesis');
|
||||
|
||||
var s = rng.nextInt(3);
|
||||
final combatRounds = rng.nextInt(1 + plotCount);
|
||||
for (var i = 0; i < combatRounds; i++) {
|
||||
s += 1 + rng.nextInt(2);
|
||||
switch (s % 3) {
|
||||
case 0:
|
||||
q(QueueKind.task, 2, 'Locked in grim combat with $nemesis');
|
||||
break;
|
||||
case 1:
|
||||
q(QueueKind.task, 2, '$nemesis seems to have the upper hand');
|
||||
break;
|
||||
case 2:
|
||||
q(
|
||||
QueueKind.task,
|
||||
2,
|
||||
'You seem to gain the advantage over $nemesis',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
q(
|
||||
QueueKind.task,
|
||||
3,
|
||||
'Victory! $nemesis is slain! Exhausted, you lose conciousness',
|
||||
);
|
||||
q(
|
||||
QueueKind.task,
|
||||
2,
|
||||
'You awake in a friendly place, but the road awaits',
|
||||
);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// 시나리오 3: 배신 발견
|
||||
final guy = impressiveGuy(config, rng);
|
||||
q(
|
||||
QueueKind.task,
|
||||
2,
|
||||
"Oh sweet relief! You've reached the kind protection of $guy",
|
||||
);
|
||||
q(
|
||||
QueueKind.task,
|
||||
3,
|
||||
'There is rejoicing, and an unnerving encouter with $guy in private',
|
||||
);
|
||||
q(
|
||||
QueueKind.task,
|
||||
2,
|
||||
'You forget your ${boringItem(config, rng)} and go back to get it',
|
||||
);
|
||||
q(QueueKind.task, 2, "What's this!? You overhear something shocking!");
|
||||
q(QueueKind.task, 2, 'Could $guy be a dirty double-dealer?');
|
||||
q(
|
||||
QueueKind.task,
|
||||
3,
|
||||
'Who can possibly be trusted with this news!? -- Oh yes, of course',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// 마지막에 plot|2|Loading 추가
|
||||
q(QueueKind.plot, 2, 'Loading');
|
||||
|
||||
return entries;
|
||||
}
|
||||
83
lib/src/core/util/roman.dart
Normal file
83
lib/src/core/util/roman.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
const _romanMap = <String, int>{
|
||||
'T': 10000,
|
||||
'A': 5000,
|
||||
'P': 100000,
|
||||
'E': 100000, // not used but kept for completeness
|
||||
'M': 1000,
|
||||
'D': 500,
|
||||
'C': 100,
|
||||
'L': 50,
|
||||
'X': 10,
|
||||
'V': 5,
|
||||
'I': 1,
|
||||
};
|
||||
|
||||
String intToRoman(int n) {
|
||||
final buffer = StringBuffer();
|
||||
void emit(int value, String numeral) {
|
||||
while (n >= value) {
|
||||
buffer.write(numeral);
|
||||
n -= value;
|
||||
}
|
||||
}
|
||||
|
||||
emit(10000, 'T');
|
||||
if (n >= 9000) {
|
||||
buffer.write('MT');
|
||||
n -= 9000;
|
||||
}
|
||||
if (n >= 5000) {
|
||||
buffer.write('A');
|
||||
n -= 5000;
|
||||
}
|
||||
if (n >= 4000) {
|
||||
buffer.write('MA');
|
||||
n -= 4000;
|
||||
}
|
||||
|
||||
emit(1000, 'M');
|
||||
_subtract(ref: n, target: 900, numeral: 'CM', buffer: buffer);
|
||||
_subtract(ref: n, target: 500, numeral: 'D', buffer: buffer);
|
||||
_subtract(ref: n, target: 400, numeral: 'CD', buffer: buffer);
|
||||
|
||||
emit(100, 'C');
|
||||
_subtract(ref: n, target: 90, numeral: 'XC', buffer: buffer);
|
||||
_subtract(ref: n, target: 50, numeral: 'L', buffer: buffer);
|
||||
_subtract(ref: n, target: 40, numeral: 'XL', buffer: buffer);
|
||||
|
||||
emit(10, 'X');
|
||||
_subtract(ref: n, target: 9, numeral: 'IX', buffer: buffer);
|
||||
_subtract(ref: n, target: 5, numeral: 'V', buffer: buffer);
|
||||
_subtract(ref: n, target: 4, numeral: 'IV', buffer: buffer);
|
||||
emit(1, 'I');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
void _subtract({
|
||||
required int ref,
|
||||
required int target,
|
||||
required String numeral,
|
||||
required StringBuffer buffer,
|
||||
}) {
|
||||
if (ref >= target) {
|
||||
buffer.write(numeral);
|
||||
ref -= target;
|
||||
}
|
||||
}
|
||||
|
||||
int romanToInt(String n) {
|
||||
var result = 0;
|
||||
var i = 0;
|
||||
while (i < n.length) {
|
||||
final one = _romanMap[n[i]] ?? 0;
|
||||
final two = i + 1 < n.length ? _romanMap[n[i + 1]] ?? 0 : 0;
|
||||
if (two > one) {
|
||||
result += (two - one);
|
||||
i += 2;
|
||||
} else {
|
||||
result += one;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user