feat(core): 장비 시스템 및 게임 상태 모델 확장

- Equipment 클래스를 11개 슬롯으로 확장 (원본 Main.dfm 충실)
- TaskInfo에 몬스터 정보(baseName, part) 추가
- Stats에 현재 HP/MP 필드 추가
- 히스토리 기능 구현 (plotHistory, questHistory)
- pq_logic winEquip/winStatIndex 원본 로직 개선
- 퀘스트 몬스터 처리 로직 구현
- SaveData 직렬화 확장
This commit is contained in:
JiWoong Sul
2025-12-09 22:30:37 +09:00
parent b512fde1fb
commit b450bf2600
12 changed files with 571 additions and 208 deletions

View File

@@ -9,27 +9,22 @@ class GameMutations {
final PqConfig config;
GameState winEquip(GameState state, int level, EquipmentSlot slot) {
/// 장비 획득 (원본 Main.pas:791-830 WinEquip)
/// [slotIndex]: 0-10 (원본 Equips.Items.Count = 11)
GameState winEquipByIndex(GameState state, int level, int slotIndex) {
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,
),
};
final name = pq_logic.winEquip(config, rng, level, slotIndex);
final updatedEquip = state.equipment
.setByIndex(slotIndex, name)
.copyWith(bestIndex: slotIndex);
return state.copyWith(rng: rng, equipment: updatedEquip);
}
/// EquipmentSlot enum을 사용하는 편의 함수
GameState winEquip(GameState state, int level, EquipmentSlot slot) {
return winEquipByIndex(state, level, slot.index);
}
GameState winStat(GameState state) {
final updatedStats = pq_logic.winStat(state.stats, state.rng);
return state.copyWith(rng: state.rng, stats: updatedStats);

View File

@@ -2,7 +2,6 @@ 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;
@@ -49,21 +48,24 @@ class ProgressService {
const QueueEntry(
kind: QueueKind.task,
durationMillis: 6 * 1000,
caption: "Much is revealed about that wise old bastard you'd "
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, '
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, '
caption:
'Drawing upon an unexpected reserve of determination, '
'you set out on a long and dangerous journey',
taskType: TaskType.load,
),
@@ -76,17 +78,10 @@ class ProgressService {
];
// 첫 번째 태스크 'Loading' 시작 (원본 752줄)
final taskResult = pq_logic.startTask(
state.progress,
'Loading',
2 * 1000,
);
final taskResult = pq_logic.startTask(state.progress, 'Loading', 2 * 1000);
// ExpBar 초기화 (원본 743-746줄)
final expBar = ProgressBarState(
position: 0,
max: pq_logic.levelUpTime(1),
);
final expBar = ProgressBarState(position: 0, max: pq_logic.levelUpTime(1));
// PlotBar 초기화 (원본 759줄)
final plotBar = const ProgressBarState(position: 0, max: 26 * 1000);
@@ -97,6 +92,8 @@ class ProgressService {
currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load),
plotStageCount: 1, // Prologue
questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
);
return _recalculateEncumbrance(
@@ -348,12 +345,21 @@ class ProgressService {
// 3. MonsterTask 실행 (원본 678-684줄)
final level = state.traits.level;
final monster = pq_logic.monsterTask(
// 원본 Main.pas:548-551: 25% 확률로 Quest Monster 사용
// fQuest.Caption이 비어있지 않으면 해당 몬스터 데이터 전달
final questMonster = state.progress.currentQuestMonster;
final questMonsterData = questMonster?.monsterData;
final questLevel = questMonsterData != null
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ?? 0
: null;
final monsterResult = pq_logic.monsterTask(
config,
state.rng,
level,
null, // questMonster
null, // questLevel
questMonsterData,
questLevel,
);
// 태스크 지속시간 계산 (원본 682줄)
@@ -364,7 +370,7 @@ class ProgressService {
final taskResult = pq_logic.startTask(
progress,
'Executing $monster',
'Executing ${monsterResult.displayName}',
durationMillis,
);
@@ -372,6 +378,8 @@ class ProgressService {
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part,
),
);
@@ -389,6 +397,23 @@ class ProgressService {
var nextState = _applyReward(state, result.reward);
final questCount = nextState.progress.questCount + 1;
// 퀘스트 히스토리 업데이트: 이전 퀘스트 완료 표시, 새 퀘스트 추가
final updatedQuestHistory = [
...nextState.progress.questHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
// 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// Append quest entry to queue (task kind).
final updatedQueue = QueueState(
entries: [
@@ -409,6 +434,8 @@ class ProgressService {
max: 50 + nextState.rng.nextInt(100),
),
questCount: questCount,
questHistory: updatedQuestHistory,
currentQuestMonster: questMonster,
);
return _recalculateEncumbrance(
@@ -425,9 +452,19 @@ class ProgressService {
}
final plotStages = nextState.progress.plotStageCount + 1;
// 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가
final updatedPlotHistory = [
...nextState.progress.plotHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: actResult.actTitle, isComplete: false),
];
var updatedProgress = nextState.progress.copyWith(
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
plotStageCount: plotStages,
plotHistory: updatedPlotHistory,
);
nextState = nextState.copyWith(progress: updatedProgress);
@@ -455,6 +492,20 @@ class ProgressService {
max: 50 + state.rng.nextInt(100),
);
// 첫 퀘스트 히스토리 추가
final questHistory = [
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
// 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// 첫 퀘스트 추가
final updatedQueue = QueueState(
entries: [
@@ -471,6 +522,8 @@ class ProgressService {
final progress = state.progress.copyWith(
quest: questBar,
questCount: 1,
questHistory: questHistory,
currentQuestMonster: questMonster,
);
return state.copyWith(progress: progress, queue: updatedQueue);
@@ -550,28 +603,25 @@ class ProgressService {
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
GameState _winLoot(GameState state) {
final taskCaption = state.progress.currentTask.caption;
final taskInfo = state.progress.currentTask;
final monsterPart = taskInfo.monsterPart ?? '';
final monsterBaseName = taskInfo.monsterBaseName ?? '';
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
if (monsterPart == '*') {
return mutations.winItem(state);
}
// 부위가 비어있으면 전리품 없음
if (monsterPart.isEmpty || monsterBaseName.isEmpty) {
return state;
}
// 몬스터 이름에서 전리품 아이템 생성
// 원본: 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';
// 예: "goblin Claw" 형태로 인벤토리 추가
final itemName =
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
// 인벤토리에 추가
final items = [...state.inventory.items];
@@ -584,32 +634,13 @@ class ProgressService {
items.add(InventoryEntry(name: itemName, count: 1));
}
return state.copyWith(
inventory: state.inventory.copyWith(items: items),
);
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();
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
String _properCase(String s) {
if (s.isEmpty) return s;
return s[0].toUpperCase() + s.substring(1);
}
/// 인벤토리에서 Gold 수량 반환
@@ -635,11 +666,9 @@ class ProgressService {
);
// 장비 획득 (WinEquip)
nextState = mutations.winEquip(
nextState,
level,
EquipmentSlot.values[nextState.rng.nextInt(EquipmentSlot.values.length)],
);
// 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯)
final slotIndex = nextState.rng.nextInt(Equipment.slotCount);
nextState = mutations.winEquipByIndex(nextState, level, slotIndex);
return nextState;
}
@@ -662,7 +691,8 @@ class ProgressService {
// " of " 포함 시 보너스 (원본 639-640)
if (item.name.contains(' of ')) {
price = price *
price =
price *
(1 + pq_logic.randomLow(state.rng, 10)) *
(1 + pq_logic.randomLow(state.rng, level));
}
@@ -687,10 +717,7 @@ class ProgressService {
1 * 1000,
);
final progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.sell,
),
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell),
);
return (
state: state.copyWith(

View File

@@ -1,5 +1,4 @@
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';
@@ -14,9 +13,9 @@ class RewardService {
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);
// 원본 Main.pas:797 - Random(Equips.Items.Count) (11개 슬롯)
final slotIndex = state.rng.nextInt(Equipment.slotCount);
return mutations.winEquipByIndex(state, state.traits.level, slotIndex);
case RewardKind.stat:
return mutations.winStat(state);
case RewardKind.item:

View File

@@ -1 +1,29 @@
enum EquipmentSlot { weapon, shield, armor }
/// 장비 슬롯 (원본 Main.dfm Equips ListView 순서)
/// 0=Weapon, 1=Shield, 2-10=Armor 계열
enum EquipmentSlot {
weapon, // 0: 무기 → K.Weapons
shield, // 1: 방패 → K.Shields
helm, // 2: 투구 → K.Armors
hauberk, // 3: 사슬갑옷 → K.Armors
brassairts, // 4: 상완갑 → K.Armors
vambraces, // 5: 전완갑 → K.Armors
gauntlets, // 6: 건틀릿 → K.Armors
gambeson, // 7: 갬비슨(누비옷) → K.Armors
cuisses, // 8: 허벅지갑 → K.Armors
greaves, // 9: 정강이갑 → K.Armors
sollerets, // 10: 철제신발 → K.Armors
}
extension EquipmentSlotX on EquipmentSlot {
/// 무기 슬롯 여부
bool get isWeapon => this == EquipmentSlot.weapon;
/// 방패 슬롯 여부
bool get isShield => this == EquipmentSlot.shield;
/// 방어구(armor) 슬롯 여부 (2-10)
bool get isArmor => index >= 2;
/// 슬롯 인덱스 (0-10)
int get slotIndex => index;
}

View File

@@ -90,16 +90,37 @@ enum TaskType {
}
class TaskInfo {
const TaskInfo({required this.caption, required this.type});
const TaskInfo({
required this.caption,
required this.type,
this.monsterBaseName,
this.monsterPart,
});
final String caption;
final TaskType type;
/// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin")
final String? monsterBaseName;
/// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem)
final String? monsterPart;
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);
TaskInfo copyWith({
String? caption,
TaskType? type,
String? monsterBaseName,
String? monsterPart,
}) {
return TaskInfo(
caption: caption ?? this.caption,
type: type ?? this.type,
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
monsterPart: monsterPart ?? this.monsterPart,
);
}
}
@@ -158,6 +179,8 @@ class Stats {
required this.cha,
required this.hpMax,
required this.mpMax,
this.hpCurrent,
this.mpCurrent,
});
final int str;
@@ -169,6 +192,18 @@ class Stats {
final int hpMax;
final int mpMax;
/// 현재 HP (null이면 hpMax와 동일)
final int? hpCurrent;
/// 현재 MP (null이면 mpMax와 동일)
final int? mpCurrent;
/// 실제 현재 HP 값
int get hp => hpCurrent ?? hpMax;
/// 실제 현재 MP 값
int get mp => mpCurrent ?? mpMax;
factory Stats.empty() => const Stats(
str: 0,
con: 0,
@@ -189,6 +224,8 @@ class Stats {
int? cha,
int? hpMax,
int? mpMax,
int? hpCurrent,
int? mpCurrent,
}) {
return Stats(
str: str ?? this.str,
@@ -199,6 +236,8 @@ class Stats {
cha: cha ?? this.cha,
hpMax: hpMax ?? this.hpMax,
mpMax: mpMax ?? this.mpMax,
hpCurrent: hpCurrent ?? this.hpCurrent,
mpCurrent: mpCurrent ?? this.mpCurrent,
);
}
}
@@ -227,38 +266,118 @@ class Inventory {
}
}
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
class Equipment {
const Equipment({
required this.weapon,
required this.shield,
required this.armor,
required this.helm,
required this.hauberk,
required this.brassairts,
required this.vambraces,
required this.gauntlets,
required this.gambeson,
required this.cuisses,
required this.greaves,
required this.sollerets,
required this.bestIndex,
});
final String weapon;
final String shield;
final String armor;
final String weapon; // 0: 무기
final String shield; // 1: 방패
final String helm; // 2: 투구
final String hauberk; // 3: 사슬갑옷
final String brassairts; // 4: 상완갑
final String vambraces; // 5: 전완갑
final String gauntlets; // 6: 건틀릿
final String gambeson; // 7: 갬비슨
final String cuisses; // 8: 허벅지갑
final String greaves; // 9: 정강이갑
final String sollerets; // 10: 철제신발
/// Tracks best slot index (mirror of Equips.Tag in original code; 0=weapon,1=shield,2=armor).
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
final int bestIndex;
/// 슬롯 개수
static const slotCount = 11;
factory Equipment.empty() => const Equipment(
weapon: 'Sharp Stick',
shield: '',
armor: '',
helm: '',
hauberk: '',
brassairts: '',
vambraces: '',
gauntlets: '',
gambeson: '',
cuisses: '',
greaves: '',
sollerets: '',
bestIndex: 0,
);
/// 인덱스로 슬롯 값 가져오기
String getByIndex(int index) {
return switch (index) {
0 => weapon,
1 => shield,
2 => helm,
3 => hauberk,
4 => brassairts,
5 => vambraces,
6 => gauntlets,
7 => gambeson,
8 => cuisses,
9 => greaves,
10 => sollerets,
_ => '',
};
}
/// 인덱스로 슬롯 값 설정한 새 Equipment 반환
Equipment setByIndex(int index, String value) {
return switch (index) {
0 => copyWith(weapon: value),
1 => copyWith(shield: value),
2 => copyWith(helm: value),
3 => copyWith(hauberk: value),
4 => copyWith(brassairts: value),
5 => copyWith(vambraces: value),
6 => copyWith(gauntlets: value),
7 => copyWith(gambeson: value),
8 => copyWith(cuisses: value),
9 => copyWith(greaves: value),
10 => copyWith(sollerets: value),
_ => this,
};
}
Equipment copyWith({
String? weapon,
String? shield,
String? armor,
String? helm,
String? hauberk,
String? brassairts,
String? vambraces,
String? gauntlets,
String? gambeson,
String? cuisses,
String? greaves,
String? sollerets,
int? bestIndex,
}) {
return Equipment(
weapon: weapon ?? this.weapon,
shield: shield ?? this.shield,
armor: armor ?? this.armor,
helm: helm ?? this.helm,
hauberk: hauberk ?? this.hauberk,
brassairts: brassairts ?? this.brassairts,
vambraces: vambraces ?? this.vambraces,
gauntlets: gauntlets ?? this.gauntlets,
gambeson: gambeson ?? this.gambeson,
cuisses: cuisses ?? this.cuisses,
greaves: greaves ?? this.greaves,
sollerets: sollerets ?? this.sollerets,
bestIndex: bestIndex ?? this.bestIndex,
);
}
@@ -304,6 +423,40 @@ class ProgressBarState {
}
}
/// 히스토리 엔트리 (Plot/Quest 진행 기록)
class HistoryEntry {
const HistoryEntry({required this.caption, required this.isComplete});
/// 표시 텍스트 (예: "Prologue", "Act I", "Exterminate the Goblins")
final String caption;
/// 완료 여부 (원본 StateIndex: 0=진행중, 1=완료)
final bool isComplete;
HistoryEntry copyWith({String? caption, bool? isComplete}) {
return HistoryEntry(
caption: caption ?? this.caption,
isComplete: isComplete ?? this.isComplete,
);
}
}
/// 현재 퀘스트 몬스터 정보 (원본 fQuest)
class QuestMonsterInfo {
const QuestMonsterInfo({
required this.monsterData,
required this.monsterIndex,
});
/// 몬스터 데이터 문자열 (예: "Goblin|3|ear")
final String monsterData;
/// 몬스터 인덱스 (Config.monsters에서의 인덱스)
final int monsterIndex;
static const empty = QuestMonsterInfo(monsterData: '', monsterIndex: -1);
}
class ProgressState {
const ProgressState({
required this.task,
@@ -314,6 +467,9 @@ class ProgressState {
required this.currentTask,
required this.plotStageCount,
required this.questCount,
this.plotHistory = const [],
this.questHistory = const [],
this.currentQuestMonster,
});
final ProgressBarState task;
@@ -325,6 +481,15 @@ class ProgressState {
final int plotStageCount;
final int questCount;
/// 플롯 히스토리 (Prologue, Act I, Act II, ...)
final List<HistoryEntry> plotHistory;
/// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록)
final List<HistoryEntry> questHistory;
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
final QuestMonsterInfo? currentQuestMonster;
factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(),
quest: ProgressBarState.empty(),
@@ -334,6 +499,9 @@ class ProgressState {
currentTask: TaskInfo.empty(),
plotStageCount: 1, // Prologue
questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
currentQuestMonster: null,
);
ProgressState copyWith({
@@ -345,6 +513,9 @@ class ProgressState {
TaskInfo? currentTask,
int? plotStageCount,
int? questCount,
List<HistoryEntry>? plotHistory,
List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster,
}) {
return ProgressState(
task: task ?? this.task,
@@ -355,6 +526,9 @@ class ProgressState {
currentTask: currentTask ?? this.currentTask,
plotStageCount: plotStageCount ?? this.plotStageCount,
questCount: questCount ?? this.questCount,
plotHistory: plotHistory ?? this.plotHistory,
questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
);
}
}

View File

@@ -63,6 +63,8 @@ class GameSave {
'cha': stats.cha,
'hpMax': stats.hpMax,
'mpMax': stats.mpMax,
'hpCurrent': stats.hpCurrent,
'mpCurrent': stats.mpCurrent,
},
'inventory': {
'gold': inventory.gold,
@@ -73,7 +75,15 @@ class GameSave {
'equipment': {
'weapon': equipment.weapon,
'shield': equipment.shield,
'armor': equipment.armor,
'helm': equipment.helm,
'hauberk': equipment.hauberk,
'brassairts': equipment.brassairts,
'vambraces': equipment.vambraces,
'gauntlets': equipment.gauntlets,
'gambeson': equipment.gambeson,
'cuisses': equipment.cuisses,
'greaves': equipment.greaves,
'sollerets': equipment.sollerets,
'bestIndex': equipment.bestIndex,
},
'spells': spellBook.spells
@@ -88,9 +98,23 @@ class GameSave {
'taskInfo': {
'caption': progress.currentTask.caption,
'type': progress.currentTask.type.name,
'monsterBaseName': progress.currentTask.monsterBaseName,
'monsterPart': progress.currentTask.monsterPart,
},
'plotStages': progress.plotStageCount,
'questCount': progress.questCount,
'plotHistory': progress.plotHistory
.map((e) => {'caption': e.caption, 'complete': e.isComplete})
.toList(),
'questHistory': progress.questHistory
.map((e) => {'caption': e.caption, 'complete': e.isComplete})
.toList(),
'questMonster': progress.currentQuestMonster != null
? {
'data': progress.currentQuestMonster!.monsterData,
'index': progress.currentQuestMonster!.monsterIndex,
}
: null,
},
'queue': queue.entries
.map(
@@ -134,6 +158,8 @@ class GameSave {
cha: statsJson['cha'] as int? ?? 0,
hpMax: statsJson['hpMax'] as int? ?? 0,
mpMax: statsJson['mpMax'] as int? ?? 0,
hpCurrent: statsJson['hpCurrent'] as int?,
mpCurrent: statsJson['mpCurrent'] as int?,
),
inventory: Inventory(
gold: inventoryJson['gold'] as int? ?? 0,
@@ -149,7 +175,15 @@ class GameSave {
equipment: Equipment(
weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick',
shield: equipmentJson['shield'] as String? ?? '',
armor: equipmentJson['armor'] as String? ?? '',
helm: equipmentJson['helm'] as String? ?? '',
hauberk: equipmentJson['hauberk'] as String? ?? '',
brassairts: equipmentJson['brassairts'] as String? ?? '',
vambraces: equipmentJson['vambraces'] as String? ?? '',
gauntlets: equipmentJson['gauntlets'] as String? ?? '',
gambeson: equipmentJson['gambeson'] as String? ?? '',
cuisses: equipmentJson['cuisses'] as String? ?? '',
greaves: equipmentJson['greaves'] as String? ?? '',
sollerets: equipmentJson['sollerets'] as String? ?? '',
bestIndex: equipmentJson['bestIndex'] as int? ?? 0,
),
spellBook: SpellBook(
@@ -178,6 +212,15 @@ class GameSave {
),
plotStageCount: progressJson['plotStages'] as int? ?? 1,
questCount: progressJson['questCount'] as int? ?? 0,
plotHistory: _historyListFromJson(
progressJson['plotHistory'] as List<dynamic>?,
),
questHistory: _historyListFromJson(
progressJson['questHistory'] as List<dynamic>?,
),
currentQuestMonster: _questMonsterFromJson(
progressJson['questMonster'] as Map<String, dynamic>?,
),
),
queue: QueueState(
entries: Queue<QueueEntry>.from(
@@ -233,5 +276,29 @@ TaskInfo _taskInfoFromJson(Map<String, dynamic> json) {
(t) => t.name == typeName,
orElse: () => TaskType.neutral,
);
return TaskInfo(caption: json['caption'] as String? ?? '', type: type);
return TaskInfo(
caption: json['caption'] as String? ?? '',
type: type,
monsterBaseName: json['monsterBaseName'] as String?,
monsterPart: json['monsterPart'] as String?,
);
}
List<HistoryEntry> _historyListFromJson(List<dynamic>? json) {
if (json == null || json.isEmpty) return [];
return json.map((e) {
final m = e as Map<String, dynamic>;
return HistoryEntry(
caption: m['caption'] as String? ?? '',
isComplete: m['complete'] as bool? ?? false,
);
}).toList();
}
QuestMonsterInfo? _questMonsterFromJson(Map<String, dynamic>? json) {
if (json == null) return null;
return QuestMonsterInfo(
monsterData: json['data'] as String? ?? '',
monsterIndex: json['index'] as int? ?? -1,
);
}

View File

@@ -140,7 +140,6 @@ int _parseLevel(String entry) {
return int.tryParse(parts[1].replaceAll('+', '')) ?? 0;
}
String addModifier(
DeterministicRandom rng,
String baseName,
@@ -211,19 +210,27 @@ int random64Below(DeterministicRandom rng, int below) {
return (combined % below).toInt();
}
/// 장비 생성 (원본 Main.pas:791-830 WinEquip)
/// [slotIndex]: 0=Weapon, 1=Shield, 2-10=Armor 계열
String winEquip(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
int slotIndex,
) {
// 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,
};
// 원본 로직:
// posn = 0: Weapon → K.Weapons, OffenseAttrib
// posn = 1: Shield → K.Shields, DefenseAttrib
// posn >= 2: Armor → K.Armors, DefenseAttrib
final bool isWeapon = slotIndex == 0;
final List<String> items;
if (slotIndex == 0) {
items = config.weapons;
} else if (slotIndex == 1) {
items = config.shields;
} else {
items = config.armors;
}
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
final worse = isWeapon ? config.offenseBad : config.defenseBad;
@@ -239,21 +246,38 @@ String winEquip(
return addModifier(rng, baseName, modifiers, plus);
}
/// EquipmentSlot enum을 사용하는 편의 함수
String winEquipBySlot(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
return winEquip(config, rng, level, slot.index);
}
int winStatIndex(DeterministicRandom rng, List<int> statValues) {
// 원본 Main.pas:870-883: 50% 확률로 완전 랜덤, 50% 확률로 제곱 가중치
// 원본 Main.pas:870-883
// 50%: 모든 8개 스탯 중 랜덤
// 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택
if (rng.nextInt(2) == 0) {
// Odds(1,2): 완전 랜덤 선택
// 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);
// 원본: for i := 0 to 5 do Inc(t, Square(GetI(Stats,i)));
// 첫 6개(STR, CON, DEX, INT, WIS, CHA)만 제곱 가중치 적용
const firstSixCount = 6;
var total = 0;
for (var i = 0; i < firstSixCount && i < statValues.length; i++) {
total += statValues[i] * statValues[i];
}
if (total == 0) return rng.nextInt(firstSixCount);
var pickValue = random64Below(rng, total);
for (var i = 0; i < statValues.length; i++) {
for (var i = 0; i < firstSixCount; i++) {
pickValue -= statValues[i] * statValues[i];
if (pickValue < 0) return i;
}
return statValues.length - 1;
return firstSixCount - 1;
}
Stats winStat(Stats stats, DeterministicRandom rng) {
@@ -290,7 +314,7 @@ Stats winStat(Stats stats, DeterministicRandom rng) {
}
}
String monsterTask(
MonsterTaskResult monsterTask(
PqConfig config,
DeterministicRandom rng,
int level,
@@ -308,6 +332,7 @@ String monsterTask(
String monster;
int monsterLevel;
String part;
bool definite = false;
// 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용
@@ -324,11 +349,13 @@ String monsterTask(
definite = true;
}
monsterLevel = targetLevel;
monster = '$monster|$monsterLevel|*';
part = '*'; // NPC는 WinItem 호출
} else if (questMonster != null && rng.nextInt(4) == 0) {
// Use quest monster.
monster = questMonster;
final parts = questMonster.split('|');
monsterLevel = questLevel ?? targetLevel;
part = parts.length > 2 ? parts[2] : '';
} else {
// Pick closest level among random samples.
monster = pick(config.monsters, rng);
@@ -342,12 +369,18 @@ String monsterTask(
monsterLevel = candLevel;
}
}
// 몬스터 데이터에서 부위 정보 추출 (예: "Rat|0|tail")
final monsterParts = monster.split('|');
part = monsterParts.length > 2 ? monsterParts[2] : '';
}
// 기본 몬스터 이름 (부위 정보 제외)
final baseName = monster.split('|').first;
// Adjust quantity and adjectives based on level delta.
var qty = 1;
final levelDiff = targetLevel - monsterLevel;
var name = monster.split('|').first;
var name = baseName;
if (levelDiff > 10) {
qty =
@@ -385,7 +418,34 @@ String monsterTask(
name = indefinite(name, qty);
}
return name;
return MonsterTaskResult(
displayName: name,
baseName: baseName,
level: monsterLevel * qty,
part: part,
);
}
/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함)
class MonsterTaskResult {
const MonsterTaskResult({
required this.displayName,
required this.baseName,
required this.level,
required this.part,
});
/// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin")
final String displayName;
/// 기본 몬스터 이름 (형용사 제외, 예: "Goblin")
final String baseName;
/// 몬스터 레벨
final int level;
/// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출)
final String part;
}
enum RewardKind { spell, equip, stat, item }
@@ -396,12 +456,17 @@ class QuestResult {
required this.reward,
this.monsterName,
this.monsterLevel,
this.monsterIndex,
});
final String caption;
final RewardKind reward;
/// 몬스터 전체 데이터 (예: "Rat|5|tail") - 원본 fQuest.Caption
final String? monsterName;
/// 몬스터 레벨 (파싱된 값)
final int? monsterLevel;
/// 몬스터 인덱스 (config.monsters에서의 위치) - 원본 fQuest.Tag
final int? monsterIndex;
}
QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
@@ -416,14 +481,19 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
final questRoll = rng.nextInt(5);
switch (questRoll) {
case 0:
// Exterminate: 4번 시도하여 레벨에 가장 가까운 몬스터 선택
// 원본 Main.pas:936-954
var best = '';
var bestLevel = 0;
var bestIndex = 0;
for (var i = 0; i < 4; i++) {
final m = pick(config.monsters, rng);
final monsterIndex = rng.nextInt(config.monsters.length);
final m = config.monsters[monsterIndex];
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
bestIndex = monsterIndex;
}
}
final name = best.split('|').first;
@@ -432,6 +502,7 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
monsterIndex: bestIndex,
);
case 1:
final item = interestingItem(config, rng);
@@ -446,6 +517,8 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
reward: reward,
);
default:
// Placate: 2번 시도하여 레벨에 가장 가까운 몬스터 선택
// 원본 Main.pas:971-984 (fQuest.Caption := '' 처리됨)
var best = '';
var bestLevel = 0;
for (var i = 0; i < 2; i++) {
@@ -457,11 +530,10 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
}
}
final name = best.split('|').first;
// Placate는 fQuest.Caption := '' 로 비움 → monsterIndex 미저장
return QuestResult(
caption: 'Placate ${definite(name, 2)}',
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
);
}
}

View File

@@ -301,7 +301,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
child: Text(
'${speed}x',
style: TextStyle(
fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal,
fontWeight: speed > 1
? FontWeight.bold
: FontWeight.normal,
color: speed > 1
? Theme.of(context).colorScheme.primary
: null,
@@ -468,11 +470,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
Widget _buildEquipmentList(GameState state) {
// 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현
// 원본 Main.dfm Equips ListView - 11개 슬롯
final equipment = [
('Weapon', state.equipment.weapon),
('Shield', state.equipment.shield),
('Armor', state.equipment.armor),
('Helm', state.equipment.helm),
('Hauberk', state.equipment.hauberk),
('Brassairts', state.equipment.brassairts),
('Vambraces', state.equipment.vambraces),
('Gauntlets', state.equipment.gauntlets),
('Gambeson', state.equipment.gambeson),
('Cuisses', state.equipment.cuisses),
('Greaves', state.equipment.greaves),
('Sollerets', state.equipment.sollerets),
];
return ListView.builder(

View File

@@ -38,7 +38,8 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
int _wis = 0;
int _cha = 0;
// 롤 이력 (Unroll 기능용)
// 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox
static const int _maxRollHistory = 20; // 최대 저장 개수
final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장)
@@ -93,6 +94,11 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 현재 시드를 이력에 저장
_rollHistory.insert(0, _currentSeed);
// 최대 개수 초과 시 가장 오래된 항목 제거
if (_rollHistory.length > _maxRollHistory) {
_rollHistory.removeLast();
}
// 새 시드로 굴림
_currentSeed = math.Random().nextInt(0x7FFFFFFF);
_rollStats();
@@ -132,6 +138,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
}
/// Sold! 버튼 클릭 - 캐릭터 생성 완료
/// 원본 Main.pas:1371-1388 RollCharacter 로직
void _onSold() {
final name = _nameController.text.trim();
if (name.isEmpty) {
@@ -144,22 +151,23 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 게임에 사용할 새 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);
// 원본 Main.pas:1380-1381 - 기본 롤 값(CON.Tag, INT.Tag)만 사용
// 종족/직업 보너스는 스탯에 적용되지 않음 (UI 힌트용)
// Put(Stats,'HP Max',Random(8) + CON.Tag div 6);
// Put(Stats,'MP Max',Random(8) + INT.Tag div 6);
final hpMax = math.Random().nextInt(8) + _con ~/ 6;
final mpMax = math.Random().nextInt(8) + _int ~/ 6;
// 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스)
// 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음)
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),
str: _str,
con: _con,
dex: _dex,
intelligence: _int,
wis: _wis,
cha: _cha,
hpMax: hpMax,
mpMax: mpMax,
);
final traits = Traits(
@@ -186,24 +194,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
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(