feat: 초기 커밋

- Progress Quest 6.4 Flutter 포팅 프로젝트
- 게임 루프, 상태 관리, UI 구현
- 캐릭터 생성, 인벤토리, 장비, 주문 시스템
- 시장/판매/구매 메커니즘
This commit is contained in:
JiWoong Sul
2025-12-09 17:24:04 +09:00
commit 08054d97c1
168 changed files with 12876 additions and 0 deletions

View 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),
);
}
}

View 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;
}
}

View 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,
);
}
}

View 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);
}
}
}