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

145
lib/src/app.dart Normal file
View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/storage/save_manager.dart';
import 'package:askiineverdie/src/core/storage/save_repository.dart';
import 'package:askiineverdie/src/features/front/front_screen.dart';
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/new_character/new_character_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
const AskiiNeverDieApp({super.key});
@override
State<AskiiNeverDieApp> createState() => _AskiiNeverDieAppState();
}
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
late final GameSessionController _controller;
@override
void initState() {
super.initState();
const config = PqConfig();
final mutations = GameMutations(config);
final rewards = RewardService(mutations);
_controller = GameSessionController(
progressService: ProgressService(
config: config,
mutations: mutations,
rewards: rewards,
),
saveManager: SaveManager(SaveRepository()),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Ascii Never Die',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)),
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
useMaterial3: true,
),
home: FrontScreen(
onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave,
),
);
}
void _navigateToNewCharacter(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => NewCharacterScreen(
onCharacterCreated: (initialState) {
_startGame(context, initialState);
},
),
),
);
}
Future<void> _loadSave(BuildContext context) async {
// 저장 파일 목록 조회
final saves = await _controller.saveManager.listSaves();
if (!context.mounted) return;
String? selectedFileName;
if (saves.isEmpty) {
// 저장 파일이 없으면 안내 메시지
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
return;
} else if (saves.length == 1) {
// 파일이 하나면 바로 선택
selectedFileName = saves.first.fileName;
} else {
// 여러 개면 다이얼로그 표시
selectedFileName = await SavePickerDialog.show(context, saves);
}
if (selectedFileName == null || !context.mounted) return;
// 선택된 파일 로드
await _controller.loadAndStart(
fileName: selectedFileName,
cheatsEnabled: false,
);
if (_controller.status == GameSessionStatus.running) {
if (context.mounted) {
_navigateToGame(context);
}
} else if (_controller.status == GameSessionStatus.error) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'저장 파일을 불러올 수 없습니다: ${_controller.error ?? "알 수 없는 오류"}',
),
),
);
}
}
}
Future<void> _startGame(BuildContext context, GameState initialState) async {
await _controller.startNew(initialState, cheatsEnabled: false);
if (context.mounted) {
// NewCharacterScreen을 pop하고 GamePlayScreen으로 이동
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(controller: _controller),
),
);
}
}
void _navigateToGame(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(controller: _controller),
),
);
}
}

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

View File

@@ -0,0 +1 @@
enum EquipmentSlot { weapon, shield, armor }

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
class FrontScreen extends StatelessWidget {
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
/// "New character" 버튼 클릭 시 호출
final void Function(BuildContext context)? onNewCharacter;
/// "Load save" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onLoadSave;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colorScheme.surfaceContainerHighest, colorScheme.surface],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_HeroHeader(theme: theme, colorScheme: colorScheme),
const SizedBox(height: 24),
_ActionRow(
onNewCharacter: onNewCharacter != null
? () => onNewCharacter!(context)
: () => _showPlaceholder(context),
onLoadSave: onLoadSave != null
? () => onLoadSave!(context)
: () => _showPlaceholder(context),
),
const SizedBox(height: 24),
const _StatusCards(),
],
),
),
),
),
),
),
);
}
}
void _showPlaceholder(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.',
),
),
);
}
class _HeroHeader extends StatelessWidget {
const _HeroHeader({required this.theme, required this.colorScheme});
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
colors: [
colorScheme.primary.withValues(alpha: 0.9),
colorScheme.primaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ascii Never Die',
style: theme.textTheme.headlineSmall?.copyWith(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Offline Progress Quest (PQ 6.4) rebuilt with Flutter.',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onPrimary.withValues(alpha: 0.9),
),
),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
_Tag(icon: Icons.cloud_off_outlined, label: 'No network'),
_Tag(icon: Icons.timer_outlined, label: 'Idle RPG loop'),
_Tag(icon: Icons.storage_rounded, label: 'Local saves'),
],
),
],
),
),
);
}
}
class _ActionRow extends StatelessWidget {
const _ActionRow({required this.onNewCharacter, required this.onLoadSave});
final VoidCallback onNewCharacter;
final VoidCallback onLoadSave;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: onNewCharacter,
icon: const Icon(Icons.casino_outlined),
label: const Text('New character'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
textStyle: theme.textTheme.titleMedium,
),
),
OutlinedButton.icon(
onPressed: onLoadSave,
icon: const Icon(Icons.folder_open),
label: const Text('Load save'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
textStyle: theme.textTheme.titleMedium,
),
),
TextButton.icon(
onPressed: () => _showPlaceholder(context),
icon: const Icon(Icons.menu_book_outlined),
label: const Text('View build plan'),
),
],
);
}
}
class _StatusCards extends StatelessWidget {
const _StatusCards();
@override
Widget build(BuildContext context) {
return Column(
children: const [
_InfoCard(
icon: Icons.route_outlined,
title: 'Build roadmap',
points: [
'Port PQ 6.4 data set (Config.dfm) into Dart constants.',
'Recreate quest/task loop with deterministic RNG + saves.',
'Deliver offline-first storage (GZip JSON) across platforms.',
],
),
SizedBox(height: 16),
_InfoCard(
icon: Icons.auto_fix_high_outlined,
title: 'Tech stack',
points: [
'Flutter (Material 3) with multiplatform targets enabled.',
'path_provider + shared_preferences for local storage hooks.',
'Strict lints with package imports enforced from day one.',
],
),
SizedBox(height: 16),
_InfoCard(
icon: Icons.checklist_rtl,
title: 'Todays focus',
points: [
'Set up scaffold + lints.',
'Wire seed theme and initial navigation shell.',
'Keep reference assets under example/pq for parity.',
],
),
],
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({required this.title, required this.points, this.icon});
final String title;
final List<String> points;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 3,
shadowColor: colorScheme.shadow.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, color: colorScheme.primary),
const SizedBox(width: 10),
],
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
...points.map(
(point) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 3),
child: Icon(Icons.check_circle_outline, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Text(point, style: theme.textTheme.bodyMedium),
),
],
),
),
),
],
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Chip(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.14),
avatar: Icon(icon, color: colorScheme.onPrimary, size: 16),
label: Text(
label,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w600,
),
),
side: BorderSide.none,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:askiineverdie/src/core/storage/save_service.dart'
show SaveFileInfo;
/// 저장 파일 선택 다이얼로그
/// 선택된 파일명을 반환하거나, 취소 시 null 반환
class SavePickerDialog extends StatelessWidget {
const SavePickerDialog({super.key, required this.saves});
final List<SaveFileInfo> saves;
/// 다이얼로그 표시 및 결과 반환
static Future<String?> show(
BuildContext context,
List<SaveFileInfo> saves,
) async {
if (saves.isEmpty) {
// 저장 파일이 없으면 안내 메시지
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
return null;
}
return showDialog<String>(
context: context,
builder: (context) => SavePickerDialog(saves: saves),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AlertDialog(
title: Row(
children: [
Icon(Icons.folder_open, color: colorScheme.primary),
const SizedBox(width: 12),
const Text('Load Game'),
],
),
content: SizedBox(
width: 400,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
shrinkWrap: true,
itemCount: saves.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final save = saves[index];
return _SaveListTile(
save: save,
onTap: () => Navigator.of(context).pop(save.fileName),
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
],
);
}
}
class _SaveListTile extends StatelessWidget {
const _SaveListTile({required this.save, required this.onTap});
final SaveFileInfo save;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
return ListTile(
leading: const Icon(Icons.save),
title: Text(
save.displayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'${dateFormat.format(save.modifiedAt)} · ${_formatSize(save.sizeBytes)}',
style: theme.textTheme.bodySmall,
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}

View File

@@ -0,0 +1,654 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
class GamePlayScreen extends StatefulWidget {
const GamePlayScreen({super.key, required this.controller});
final GameSessionController controller;
@override
State<GamePlayScreen> createState() => _GamePlayScreenState();
}
class _GamePlayScreenState extends State<GamePlayScreen>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
widget.controller.removeListener(_onControllerChanged);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// 앱이 백그라운드로 가거나 비활성화될 때 자동 저장
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
_saveGameState();
}
}
Future<void> _saveGameState() async {
final currentState = widget.controller.state;
if (currentState == null || !widget.controller.isRunning) return;
await widget.controller.saveManager.saveState(currentState);
}
/// 뒤로가기 시 저장 확인 다이얼로그
Future<bool> _onPopInvoked() async {
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit Game'),
content: const Text('Save your progress before leaving?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Exit without saving'),
),
FilledButton(
onPressed: () async {
await _saveGameState();
if (context.mounted) {
Navigator.of(context).pop(true);
}
},
child: const Text('Save and Exit'),
),
],
),
);
return shouldPop ?? false;
}
void _onControllerChanged() {
setState(() {});
}
@override
Widget build(BuildContext context) {
final state = widget.controller.state;
if (state == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onPopInvoked();
if (shouldPop && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
child: Scaffold(
appBar: AppBar(
title: Text('Progress Quest - ${state.traits.name}'),
actions: [
// 치트 버튼 (디버그용)
if (widget.controller.cheatsEnabled) ...[
IconButton(
icon: const Text('L+1'),
tooltip: 'Level Up',
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
),
IconButton(
icon: const Text('Q!'),
tooltip: 'Complete Quest',
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
),
IconButton(
icon: const Text('P!'),
tooltip: 'Complete Plot',
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
),
],
],
),
body: Column(
children: [
// 메인 3패널 영역
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측 패널: Character Sheet
Expanded(flex: 2, child: _buildCharacterPanel(state)),
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
),
),
// 하단: Task Progress
_buildBottomPanel(state),
],
),
),
);
}
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
Widget _buildCharacterPanel(GameState state) {
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Character Sheet'),
// Traits 목록
_buildSectionHeader('Traits'),
_buildTraitsList(state),
// Stats 목록
_buildSectionHeader('Stats'),
Expanded(flex: 2, child: _buildStatsList(state)),
// Experience 바
_buildSectionHeader('Experience'),
_buildProgressBar(
state.progress.exp.position,
state.progress.exp.max,
Colors.blue,
tooltip:
'${state.progress.exp.max - state.progress.exp.position} '
'XP needed for next level',
),
// Spell Book
_buildSectionHeader('Spell Book'),
Expanded(flex: 2, child: _buildSpellsList(state)),
],
),
);
}
/// 중앙 패널: Equipment/Inventory
Widget _buildEquipmentPanel(GameState state) {
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Equipment'),
// Equipment 목록
Expanded(flex: 2, child: _buildEquipmentList(state)),
// Inventory
_buildPanelHeader('Inventory'),
Expanded(flex: 3, child: _buildInventoryList(state)),
// Encumbrance 바
_buildSectionHeader('Encumbrance'),
_buildProgressBar(
state.progress.encumbrance.position,
state.progress.encumbrance.max,
Colors.orange,
),
],
),
);
}
/// 우측 패널: Plot/Quest
Widget _buildQuestPanel(GameState state) {
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Plot Development'),
// Plot 목록
Expanded(child: _buildPlotList(state)),
// Plot 바
_buildProgressBar(
state.progress.plot.position,
state.progress.plot.max,
Colors.purple,
tooltip: state.progress.plot.max > 0
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
: null,
),
_buildPanelHeader('Quests'),
// Quest 목록
Expanded(child: _buildQuestList(state)),
// Quest 바
_buildProgressBar(
state.progress.quest.position,
state.progress.quest.max,
Colors.green,
tooltip: state.progress.quest.max > 0
? '${(100 * state.progress.quest.position ~/ state.progress.quest.max)}% complete'
: null,
),
],
),
);
}
/// 하단 패널: Task Progress + Status
Widget _buildBottomPanel(GameState state) {
final speed = widget.controller.loop?.speedMultiplier ?? 1;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 상태 메시지 + 배속 버튼
Row(
children: [
Expanded(
child: Text(
state.progress.currentTask.caption.isNotEmpty
? state.progress.currentTask.caption
: 'Welcome to Progress Quest!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
// 배속 버튼
SizedBox(
height: 28,
child: OutlinedButton(
onPressed: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
child: Text(
'${speed}x',
style: TextStyle(
fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal,
color: speed > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
),
],
),
const SizedBox(height: 4),
// Task Progress 바
_buildProgressBar(
state.progress.task.position,
state.progress.task.max,
Theme.of(context).colorScheme.primary,
),
],
),
);
}
Widget _buildPanelHeader(String title) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: Theme.of(context).colorScheme.primaryContainer,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Text(title, style: Theme.of(context).textTheme.labelSmall),
);
}
Widget _buildProgressBar(
int position,
int max,
Color color, {
String? tooltip,
}) {
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
final bar = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: color.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 12,
),
);
if (tooltip != null && tooltip.isNotEmpty) {
return Tooltip(message: tooltip, child: bar);
}
return bar;
}
Widget _buildTraitsList(GameState state) {
final traits = [
('Name', state.traits.name),
('Race', state.traits.race),
('Class', state.traits.klass),
('Level', '${state.traits.level}'),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: traits.map((t) {
return Row(
children: [
SizedBox(
width: 50,
child: Text(t.$1, style: const TextStyle(fontSize: 11)),
),
Expanded(
child: Text(
t.$2,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}).toList(),
),
);
}
Widget _buildStatsList(GameState state) {
final stats = [
('STR', state.stats.str),
('CON', state.stats.con),
('DEX', state.stats.dex),
('INT', state.stats.intelligence),
('WIS', state.stats.wis),
('CHA', state.stats.cha),
('HP Max', state.stats.hpMax),
('MP Max', state.stats.mpMax),
];
return ListView.builder(
itemCount: stats.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final stat = stats[index];
return Row(
children: [
SizedBox(
width: 50,
child: Text(stat.$1, style: const TextStyle(fontSize: 11)),
),
Text(
'${stat.$2}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildSpellsList(GameState state) {
if (state.spellBook.spells.isEmpty) {
return const Center(
child: Text('No spells yet', style: TextStyle(fontSize: 11)),
);
}
return ListView.builder(
itemCount: state.spellBook.spells.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final spell = state.spellBook.spells[index];
return Row(
children: [
Expanded(
child: Text(
spell.name,
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
Text(
spell.rank,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildEquipmentList(GameState state) {
// 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현
final equipment = [
('Weapon', state.equipment.weapon),
('Shield', state.equipment.shield),
('Armor', state.equipment.armor),
];
return ListView.builder(
itemCount: equipment.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final equip = equipment[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 60,
child: Text(equip.$1, style: const TextStyle(fontSize: 11)),
),
Expanded(
child: Text(
equip.$2.isNotEmpty ? equip.$2 : '-',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
);
}
Widget _buildInventoryList(GameState state) {
if (state.inventory.items.isEmpty) {
return Center(
child: Text(
'Gold: ${state.inventory.gold}',
style: const TextStyle(fontSize: 11),
),
);
}
return ListView.builder(
itemCount: state.inventory.items.length + 1, // +1 for gold
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
if (index == 0) {
return Row(
children: [
const Expanded(
child: Text('Gold', style: TextStyle(fontSize: 11)),
),
Text(
'${state.inventory.gold}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
);
}
final item = state.inventory.items[index - 1];
return Row(
children: [
Expanded(
child: Text(
item.name,
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
Text(
'${item.count}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildPlotList(GameState state) {
// 플롯 단계를 표시 (Act I, Act II, ...)
final plotCount = state.progress.plotStageCount;
if (plotCount == 0) {
return const Center(
child: Text('Prologue', style: TextStyle(fontSize: 11)),
);
}
return ListView.builder(
itemCount: plotCount,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final isCompleted = index < plotCount - 1;
return Row(
children: [
Icon(
isCompleted ? Icons.check_box : Icons.check_box_outline_blank,
size: 14,
color: isCompleted ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
index == 0 ? 'Prologue' : 'Act ${_toRoman(index)}',
style: TextStyle(
fontSize: 11,
decoration: isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
],
);
},
);
}
Widget _buildQuestList(GameState state) {
final questCount = state.progress.questCount;
if (questCount == 0) {
return const Center(
child: Text('No active quests', style: TextStyle(fontSize: 11)),
);
}
// 현재 퀘스트 캡션이 있으면 표시
final currentTask = state.progress.currentTask;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
Row(
children: [
const Icon(Icons.arrow_right, size: 14),
Expanded(
child: Text(
currentTask.caption.isNotEmpty
? currentTask.caption
: 'Quest #$questCount',
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
);
}
/// 로마 숫자 변환 (간단 버전)
String _toRoman(int number) {
const romanNumerals = [
(1000, 'M'),
(900, 'CM'),
(500, 'D'),
(400, 'CD'),
(100, 'C'),
(90, 'XC'),
(50, 'L'),
(40, 'XL'),
(10, 'X'),
(9, 'IX'),
(5, 'V'),
(4, 'IV'),
(1, 'I'),
];
var result = '';
var remaining = number;
for (final (value, numeral) in romanNumerals) {
while (remaining >= value) {
result += numeral;
remaining -= value;
}
}
return result;
}
}

View File

@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:askiineverdie/src/core/engine/progress_loop.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/storage/save_manager.dart';
import 'package:flutter/foundation.dart';
enum GameSessionStatus { idle, loading, running, error }
/// Presentation-friendly wrapper that owns ProgressLoop and SaveManager.
class GameSessionController extends ChangeNotifier {
GameSessionController({
required this.progressService,
required this.saveManager,
this.autoSaveConfig = const AutoSaveConfig(),
Duration tickInterval = const Duration(milliseconds: 50),
DateTime Function()? now,
}) : _tickInterval = tickInterval,
_now = now ?? DateTime.now;
final ProgressService progressService;
final SaveManager saveManager;
final AutoSaveConfig autoSaveConfig;
final Duration _tickInterval;
final DateTime Function() _now;
ProgressLoop? _loop;
StreamSubscription<GameState>? _subscription;
bool _cheatsEnabled = false;
GameSessionStatus _status = GameSessionStatus.idle;
GameState? _state;
String? _error;
GameSessionStatus get status => _status;
GameState? get state => _state;
String? get error => _error;
bool get isRunning => _status == GameSessionStatus.running;
bool get cheatsEnabled => _cheatsEnabled;
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
ProgressLoop? get loop => _loop;
Future<void> startNew(
GameState initialState, {
bool cheatsEnabled = false,
bool isNewGame = true,
}) async {
await _stopLoop(saveOnStop: false);
// 새 게임인 경우 초기화 (프롤로그 태스크 설정)
final state = isNewGame
? progressService.initializeNewGame(initialState)
: initialState;
_state = state;
_error = null;
_status = GameSessionStatus.running;
_cheatsEnabled = cheatsEnabled;
_loop = ProgressLoop(
initialState: state,
progressService: progressService,
saveManager: saveManager,
autoSaveConfig: autoSaveConfig,
tickInterval: _tickInterval,
now: _now,
cheatsEnabled: cheatsEnabled,
);
_subscription = _loop!.stream.listen((next) {
_state = next;
notifyListeners();
});
_loop!.start();
notifyListeners();
}
Future<void> loadAndStart({
String? fileName,
bool cheatsEnabled = false,
}) async {
_status = GameSessionStatus.loading;
_error = null;
notifyListeners();
final (outcome, loaded) = await saveManager.loadState(fileName: fileName);
if (!outcome.success || loaded == null) {
_status = GameSessionStatus.error;
_error = outcome.error ?? 'Unknown error';
notifyListeners();
return;
}
await startNew(loaded, cheatsEnabled: cheatsEnabled, isNewGame: false);
}
Future<void> pause({bool saveOnStop = false}) async {
await _stopLoop(saveOnStop: saveOnStop);
_status = GameSessionStatus.idle;
notifyListeners();
}
@override
void dispose() {
final stop = _stopLoop(saveOnStop: false);
if (stop != null) {
unawaited(stop);
}
super.dispose();
}
Future<void>? _stopLoop({required bool saveOnStop}) {
final loop = _loop;
final sub = _subscription;
_loop = null;
_subscription = null;
sub?.cancel();
if (loop == null) return null;
return loop.stop(saveOnStop: saveOnStop);
}
}

View File

@@ -0,0 +1,484 @@
import 'dart:math' as math;
import 'package:flutter/material.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/deterministic_random.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart';
/// 캐릭터 생성 화면 (NewGuy.pas 포팅)
class NewCharacterScreen extends StatefulWidget {
const NewCharacterScreen({super.key, this.onCharacterCreated});
/// 캐릭터 생성 완료 시 호출되는 콜백
final void Function(GameState initialState)? onCharacterCreated;
@override
State<NewCharacterScreen> createState() => _NewCharacterScreenState();
}
class _NewCharacterScreenState extends State<NewCharacterScreen> {
final PqConfig _config = const PqConfig();
final TextEditingController _nameController = TextEditingController();
// 종족(races)과 직업(klasses) 목록
late final List<String> _races;
late final List<String> _klasses;
// 선택된 종족/직업 인덱스
int _selectedRaceIndex = 0;
int _selectedKlassIndex = 0;
// 능력치(stats)
int _str = 0;
int _con = 0;
int _dex = 0;
int _int = 0;
int _wis = 0;
int _cha = 0;
// 롤 이력 (Unroll 기능용)
final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장)
int _currentSeed = 0;
// 이름 생성용 RNG
late DeterministicRandom _nameRng;
@override
void initState() {
super.initState();
// 종족/직업 목록 로드 (name|attribute 형식에서 name만 추출)
_races = _config.races.map((e) => e.split('|').first).toList();
_klasses = _config.klasses.map((e) => e.split('|').first).toList();
// 초기 랜덤화
final random = math.Random();
_selectedRaceIndex = random.nextInt(_races.length);
_selectedKlassIndex = random.nextInt(_klasses.length);
// 초기 스탯 굴림
_currentSeed = random.nextInt(0x7FFFFFFF);
_nameRng = DeterministicRandom(random.nextInt(0x7FFFFFFF));
_rollStats();
// 초기 이름 생성
_nameController.text = generateName(_nameRng);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
/// 스탯 굴림 (3d6 × 6)
void _rollStats() {
final rng = DeterministicRandom(_currentSeed);
setState(() {
_str = rollStat(rng);
_con = rollStat(rng);
_dex = rollStat(rng);
_int = rollStat(rng);
_wis = rollStat(rng);
_cha = rollStat(rng);
});
}
/// Re-Roll 버튼 클릭
void _onReroll() {
// 현재 시드를 이력에 저장
_rollHistory.insert(0, _currentSeed);
// 새 시드로 굴림
_currentSeed = math.Random().nextInt(0x7FFFFFFF);
_rollStats();
}
/// Unroll 버튼 클릭 (이전 롤로 복원)
void _onUnroll() {
if (_rollHistory.isEmpty) return;
setState(() {
_currentSeed = _rollHistory.removeAt(0);
});
_rollStats();
}
/// 이름 생성 버튼 클릭
void _onGenerateName() {
setState(() {
_nameController.text = generateName(_nameRng);
});
}
/// Total 값 계산
int get _total => _str + _con + _dex + _int + _wis + _cha;
/// Total 색상 결정 (원본 규칙)
/// 63+18(81) 이상 = 빨강, 4*18(72) 초과 = 노랑
/// 63-18(45) 이하 = 회색, 3*18(54) 미만 = 은색
/// 그 외 = 흰색
Color _getTotalColor() {
final total = _total;
if (total >= 81) return Colors.red;
if (total > 72) return Colors.yellow;
if (total <= 45) return Colors.grey;
if (total < 54) return Colors.grey.shade400;
return Colors.white;
}
/// Sold! 버튼 클릭 - 캐릭터 생성 완료
void _onSold() {
final name = _nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('이름을 입력해주세요.')));
return;
}
// 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// 종족/직업의 보너스 스탯 파싱
final raceEntry = _config.races[_selectedRaceIndex];
final klassEntry = _config.klasses[_selectedKlassIndex];
final raceBonus = _parseStatBonus(raceEntry);
final klassBonus = _parseStatBonus(klassEntry);
// 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스)
final finalStats = Stats(
str: _str + (raceBonus['STR'] ?? 0) + (klassBonus['STR'] ?? 0),
con: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0),
dex: _dex + (raceBonus['DEX'] ?? 0) + (klassBonus['DEX'] ?? 0),
intelligence: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0),
wis: _wis + (raceBonus['WIS'] ?? 0) + (klassBonus['WIS'] ?? 0),
cha: _cha + (raceBonus['CHA'] ?? 0) + (klassBonus['CHA'] ?? 0),
hpMax: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0),
mpMax: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0),
);
final traits = Traits(
name: name,
race: _races[_selectedRaceIndex],
klass: _klasses[_selectedKlassIndex],
level: 1,
motto: '',
guild: '',
);
// 초기 게임 상태 생성
final initialState = GameState.withSeed(
seed: gameSeed,
traits: traits,
stats: finalStats,
inventory: const Inventory(gold: 0, items: []),
equipment: Equipment.empty(),
spellBook: SpellBook.empty(),
progress: ProgressState.empty(),
queue: QueueState.empty(),
);
widget.onCharacterCreated?.call(initialState);
}
/// 종족/직업 보너스 파싱 (예: "Half Orc|STR+2,INT-1")
Map<String, int> _parseStatBonus(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return {};
final bonuses = <String, int>{};
final bonusPart = parts[1];
// STR+2,INT-1 형식 파싱
final regex = RegExp(r'([A-Z]+)([+-]\d+)');
for (final match in regex.allMatches(bonusPart)) {
final stat = match.group(1)!;
final value = int.parse(match.group(2)!);
bonuses[stat] = value;
}
return bonuses;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Progress Quest - New Character'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 이름 입력 섹션
_buildNameSection(),
const SizedBox(height: 16),
// 능력치 섹션
_buildStatsSection(),
const SizedBox(height: 16),
// 종족/직업 선택 섹션
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildRaceSection()),
const SizedBox(width: 16),
Expanded(child: _buildKlassSection()),
],
),
const SizedBox(height: 24),
// Sold! 버튼
FilledButton.icon(
onPressed: _onSold,
icon: const Icon(Icons.check),
label: const Text('Sold!'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
);
}
Widget _buildNameSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
maxLength: 30,
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _onGenerateName,
icon: const Icon(Icons.casino),
tooltip: 'Generate Name',
),
],
),
),
);
}
Widget _buildStatsSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Stats', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 스탯 그리드
Row(
children: [
Expanded(child: _buildStatTile('STR', _str)),
Expanded(child: _buildStatTile('CON', _con)),
Expanded(child: _buildStatTile('DEX', _dex)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildStatTile('INT', _int)),
Expanded(child: _buildStatTile('WIS', _wis)),
Expanded(child: _buildStatTile('CHA', _cha)),
],
),
const SizedBox(height: 12),
// Total
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getTotalColor().withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _getTotalColor()),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
'$_total',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _getTotalColor() == Colors.white
? Colors.black
: _getTotalColor(),
),
),
],
),
),
const SizedBox(height: 12),
// Roll 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _onUnroll,
icon: const Icon(Icons.undo),
label: const Text('Unroll'),
style: OutlinedButton.styleFrom(
foregroundColor: _rollHistory.isEmpty ? Colors.grey : null,
),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: _onReroll,
icon: const Icon(Icons.casino),
label: const Text('Roll'),
),
],
),
if (_rollHistory.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${_rollHistory.length} roll(s) in history',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _buildStatTile(String label, int value) {
return Container(
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 4),
Text(
'$value',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildRaceSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Race', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 300,
child: ListView.builder(
itemCount: _races.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedRaceIndex;
return ListTile(
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
_races[index],
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedRaceIndex = index),
);
},
),
),
],
),
),
);
}
Widget _buildKlassSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Class', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 300,
child: ListView.builder(
itemCount: _klasses.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedKlassIndex;
return ListTile(
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
_klasses[index],
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedKlassIndex = index),
);
},
),
),
],
),
),
);
}
}