Files
asciinevrdie/lib/src/core/model/game_state.dart
JiWoong Sul df5fdbaac2 feat(hall-of-fame): 명예의 전당 상세 UI 및 전투 스탯 저장 추가
- CombatStats에 toJson/fromJson 직렬화 메서드 추가
- HallOfFameEntry에 finalStats(CombatStats) 필드 추가
- 명예의 전당 상세 다이얼로그에서 전투 스탯, 장비, 스펠 표시
- GameState에 combatStats 접근자 추가
- game_text_l10n에 명예의 전당 관련 텍스트 추가
2025-12-24 17:20:52 +09:00

853 lines
24 KiB
Dart

import 'dart:collection';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/item_stats.dart';
import 'package:askiineverdie/src/core/model/potion.dart';
import 'package:askiineverdie/src/core/model/skill.dart';
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,
SkillSystemState? skillSystem,
PotionInventory? potionInventory,
this.deathInfo,
}) : 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(),
skillSystem = skillSystem ?? SkillSystemState.empty(),
potionInventory = potionInventory ?? const PotionInventory();
factory GameState.withSeed({
required int seed,
Traits? traits,
Stats? stats,
Inventory? inventory,
Equipment? equipment,
SpellBook? spellBook,
ProgressState? progress,
QueueState? queue,
SkillSystemState? skillSystem,
PotionInventory? potionInventory,
DeathInfo? deathInfo,
}) {
return GameState(
rng: DeterministicRandom(seed),
traits: traits,
stats: stats,
inventory: inventory,
equipment: equipment,
spellBook: spellBook,
progress: progress,
queue: queue,
skillSystem: skillSystem,
potionInventory: potionInventory,
deathInfo: deathInfo,
);
}
final DeterministicRandom rng;
final Traits traits;
final Stats stats;
final Inventory inventory;
final Equipment equipment;
final SpellBook spellBook;
final ProgressState progress;
final QueueState queue;
/// 스킬 시스템 상태 (Phase 3)
final SkillSystemState skillSystem;
/// 물약 인벤토리
final PotionInventory potionInventory;
/// 사망 정보 (Phase 4, null이면 생존 중)
final DeathInfo? deathInfo;
/// 사망 여부
bool get isDead => deathInfo != null;
GameState copyWith({
DeterministicRandom? rng,
Traits? traits,
Stats? stats,
Inventory? inventory,
Equipment? equipment,
SpellBook? spellBook,
ProgressState? progress,
QueueState? queue,
SkillSystemState? skillSystem,
PotionInventory? potionInventory,
DeathInfo? deathInfo,
bool clearDeathInfo = false,
}) {
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,
skillSystem: skillSystem ?? this.skillSystem,
potionInventory: potionInventory ?? this.potionInventory,
deathInfo: clearDeathInfo ? null : (deathInfo ?? this.deathInfo),
);
}
}
/// 사망 정보 (Phase 4)
///
/// 사망 시점의 정보와 상실한 아이템을 기록
class DeathInfo {
const DeathInfo({
required this.cause,
required this.killerName,
required this.lostEquipmentCount,
required this.goldAtDeath,
required this.levelAtDeath,
required this.timestamp,
this.lostItemName,
this.lastCombatEvents = const [],
});
/// 사망 원인
final DeathCause cause;
/// 사망시킨 몬스터/원인 이름
final String killerName;
/// 상실한 장비 개수 (0 또는 1)
final int lostEquipmentCount;
/// 제물로 바친 아이템 이름 (null이면 없음)
final String? lostItemName;
/// 사망 시점 골드
final int goldAtDeath;
/// 사망 시점 레벨
final int levelAtDeath;
/// 사망 시각 (밀리초)
final int timestamp;
/// 사망 직전 전투 이벤트 (최대 10개)
final List<CombatEvent> lastCombatEvents;
DeathInfo copyWith({
DeathCause? cause,
String? killerName,
int? lostEquipmentCount,
String? lostItemName,
int? goldAtDeath,
int? levelAtDeath,
int? timestamp,
List<CombatEvent>? lastCombatEvents,
}) {
return DeathInfo(
cause: cause ?? this.cause,
killerName: killerName ?? this.killerName,
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
lostItemName: lostItemName ?? this.lostItemName,
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
timestamp: timestamp ?? this.timestamp,
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
);
}
}
/// 사망 원인
enum DeathCause {
/// 몬스터에 의한 사망
monster,
/// 자해 스킬에 의한 사망
selfDamage,
/// 환경 피해에 의한 사망
environment,
}
/// 스킬 시스템 상태 (Phase 3)
///
/// 스킬 쿨타임, 활성 버프, 게임 경과 시간 등을 관리
class SkillSystemState {
const SkillSystemState({
required this.skillStates,
required this.activeBuffs,
required this.elapsedMs,
});
/// 스킬별 쿨타임 상태
final List<SkillState> skillStates;
/// 현재 활성화된 버프 목록
final List<ActiveBuff> activeBuffs;
/// 게임 진행 경과 시간 (밀리초, 스킬 쿨타임 계산용)
final int elapsedMs;
factory SkillSystemState.empty() =>
const SkillSystemState(skillStates: [], activeBuffs: [], elapsedMs: 0);
/// 특정 스킬 상태 가져오기
SkillState? getSkillState(String skillId) {
for (final state in skillStates) {
if (state.skillId == skillId) return state;
}
return null;
}
/// 버프 효과 합산 (동일 버프는 중복 적용 안 됨)
({double atkMod, double defMod, double criMod, double evasionMod})
get totalBuffModifiers {
double atkMod = 0;
double defMod = 0;
double criMod = 0;
double evasionMod = 0;
final seenBuffIds = <String>{};
for (final buff in activeBuffs) {
if (seenBuffIds.contains(buff.effect.id)) continue;
seenBuffIds.add(buff.effect.id);
if (!buff.isExpired(elapsedMs)) {
atkMod += buff.effect.atkModifier;
defMod += buff.effect.defModifier;
criMod += buff.effect.criRateModifier;
evasionMod += buff.effect.evasionModifier;
}
}
return (
atkMod: atkMod,
defMod: defMod,
criMod: criMod,
evasionMod: evasionMod,
);
}
SkillSystemState copyWith({
List<SkillState>? skillStates,
List<ActiveBuff>? activeBuffs,
int? elapsedMs,
}) {
return SkillSystemState(
skillStates: skillStates ?? this.skillStates,
activeBuffs: activeBuffs ?? this.activeBuffs,
elapsedMs: elapsedMs ?? this.elapsedMs,
);
}
}
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
enum TaskType {
neutral, // heading 등 일반 이동
kill, // 몬스터 처치
load, // 로딩/초기화
plot, // 플롯 진행
market, // 시장으로 이동 중
sell, // 아이템 판매 중
buying, // 장비 구매 중
}
class TaskInfo {
const TaskInfo({
required this.caption,
required this.type,
this.monsterBaseName,
this.monsterPart,
this.monsterLevel,
});
final String caption;
final TaskType type;
/// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin")
final String? monsterBaseName;
/// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem)
final String? monsterPart;
/// 킬 태스크의 몬스터 레벨 (애니메이션 크기 결정용)
final int? monsterLevel;
factory TaskInfo.empty() =>
const TaskInfo(caption: '', type: TaskType.neutral);
TaskInfo copyWith({
String? caption,
TaskType? type,
String? monsterBaseName,
String? monsterPart,
int? monsterLevel,
}) {
return TaskInfo(
caption: caption ?? this.caption,
type: type ?? this.type,
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
monsterPart: monsterPart ?? this.monsterPart,
monsterLevel: monsterLevel ?? this.monsterLevel,
);
}
}
class Traits {
const Traits({
required this.name,
required this.race,
required this.klass,
required this.level,
required this.motto,
required this.guild,
this.raceId = '',
this.classId = '',
});
final String name;
/// 종족 표시 이름 (예: "Kernel Giant")
final String race;
/// 클래스 표시 이름 (예: "Bug Hunter")
final String klass;
final int level;
final String motto;
final String guild;
/// 종족 ID (Phase 5, 예: "kernel_giant")
final String raceId;
/// 클래스 ID (Phase 5, 예: "bug_hunter")
final String classId;
factory Traits.empty() => const Traits(
name: '',
race: '',
klass: '',
level: 1,
motto: '',
guild: '',
raceId: '',
classId: '',
);
Traits copyWith({
String? name,
String? race,
String? klass,
int? level,
String? motto,
String? guild,
String? raceId,
String? classId,
}) {
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,
raceId: raceId ?? this.raceId,
classId: classId ?? this.classId,
);
}
}
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,
this.hpCurrent,
this.mpCurrent,
});
final int str;
final int con;
final int dex;
final int intelligence;
final int wis;
final int cha;
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,
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,
int? hpCurrent,
int? mpCurrent,
}) {
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,
hpCurrent: hpCurrent ?? this.hpCurrent,
mpCurrent: mpCurrent ?? this.mpCurrent,
);
}
}
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);
}
}
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
///
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
class Equipment {
Equipment({required this.items, required this.bestIndex})
: assert(items.length == slotCount, 'Equipment must have $slotCount items');
/// 장비 아이템 목록 (11개 슬롯)
final List<EquipmentItem> items;
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
final int bestIndex;
/// 슬롯 개수
static const slotCount = 11;
// ============================================================================
// 문자열 API (기존 코드 호환성)
// ============================================================================
String get weapon => items[0].name; // 0: 무기
String get shield => items[1].name; // 1: 방패
String get helm => items[2].name; // 2: 투구
String get hauberk => items[3].name; // 3: 사슬갑옷
String get brassairts => items[4].name; // 4: 상완갑
String get vambraces => items[5].name; // 5: 전완갑
String get gauntlets => items[6].name; // 6: 건틀릿
String get gambeson => items[7].name; // 7: 갬비슨
String get cuisses => items[8].name; // 8: 허벅지갑
String get greaves => items[9].name; // 9: 정강이갑
String get sollerets => items[10].name; // 10: 철제신발
// ============================================================================
// EquipmentItem API
// ============================================================================
EquipmentItem get weaponItem => items[0];
EquipmentItem get shieldItem => items[1];
EquipmentItem get helmItem => items[2];
EquipmentItem get hauberkItem => items[3];
EquipmentItem get brassairtsItem => items[4];
EquipmentItem get vambracesItem => items[5];
EquipmentItem get gauntletsItem => items[6];
EquipmentItem get gambesonItem => items[7];
EquipmentItem get cuissesItem => items[8];
EquipmentItem get greavesItem => items[9];
EquipmentItem get solleretsItem => items[10];
/// 모든 장비 스탯 합산
ItemStats get totalStats {
return items.fold(ItemStats.empty, (sum, item) => sum + item.stats);
}
/// 모든 장비 무게 합산
int get totalWeight {
return items.fold(0, (sum, item) => sum + item.weight);
}
/// 장착된 아이템 목록 (빈 슬롯 제외)
List<EquipmentItem> get equippedItems {
return items.where((item) => item.isNotEmpty).toList();
}
// ============================================================================
// 팩토리 메서드
// ============================================================================
factory Equipment.empty() {
return Equipment(
items: [
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
EquipmentItem.empty(EquipmentSlot.shield), // 1: 방패
EquipmentItem.empty(EquipmentSlot.helm), // 2: 투구
EquipmentItem.empty(EquipmentSlot.hauberk), // 3: 사슬갑옷
EquipmentItem.empty(EquipmentSlot.brassairts), // 4: 상완갑
EquipmentItem.empty(EquipmentSlot.vambraces), // 5: 전완갑
EquipmentItem.empty(EquipmentSlot.gauntlets), // 6: 건틀릿
EquipmentItem.empty(EquipmentSlot.gambeson), // 7: 갬비슨
EquipmentItem.empty(EquipmentSlot.cuisses), // 8: 허벅지갑
EquipmentItem.empty(EquipmentSlot.greaves), // 9: 정강이갑
EquipmentItem.empty(EquipmentSlot.sollerets), // 10: 철제신발
],
bestIndex: 0,
);
}
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
factory Equipment.fromStrings({
required String weapon,
required String shield,
required String helm,
required String hauberk,
required String brassairts,
required String vambraces,
required String gauntlets,
required String gambeson,
required String cuisses,
required String greaves,
required String sollerets,
required int bestIndex,
}) {
return Equipment(
items: [
_itemFromString(weapon, EquipmentSlot.weapon),
_itemFromString(shield, EquipmentSlot.shield),
_itemFromString(helm, EquipmentSlot.helm),
_itemFromString(hauberk, EquipmentSlot.hauberk),
_itemFromString(brassairts, EquipmentSlot.brassairts),
_itemFromString(vambraces, EquipmentSlot.vambraces),
_itemFromString(gauntlets, EquipmentSlot.gauntlets),
_itemFromString(gambeson, EquipmentSlot.gambeson),
_itemFromString(cuisses, EquipmentSlot.cuisses),
_itemFromString(greaves, EquipmentSlot.greaves),
_itemFromString(sollerets, EquipmentSlot.sollerets),
],
bestIndex: bestIndex,
);
}
/// 문자열에서 기본 EquipmentItem 생성 (레거시 호환)
static EquipmentItem _itemFromString(String name, EquipmentSlot slot) {
if (name.isEmpty) {
return EquipmentItem.empty(slot);
}
// 레거시 아이템: 레벨 1, Common, 기본 스탯
return EquipmentItem(
name: name,
slot: slot,
level: 1,
weight: 5,
stats: ItemStats.empty,
rarity: ItemRarity.common,
);
}
// ============================================================================
// 유틸리티 메서드
// ============================================================================
/// 인덱스로 슬롯 이름 가져오기 (기존 API 호환)
String getByIndex(int index) {
if (index < 0 || index >= slotCount) return '';
return items[index].name;
}
/// 인덱스로 EquipmentItem 가져오기
EquipmentItem getItemByIndex(int index) {
if (index < 0 || index >= slotCount) {
return EquipmentItem.empty(EquipmentSlot.weapon);
}
return items[index];
}
/// 인덱스로 슬롯 값 설정 (문자열, 기존 API 호환)
Equipment setByIndex(int index, String value) {
if (index < 0 || index >= slotCount) return this;
final slot = EquipmentSlot.values[index];
final newItem = _itemFromString(value, slot);
return setItemByIndex(index, newItem);
}
/// 인덱스로 EquipmentItem 설정
Equipment setItemByIndex(int index, EquipmentItem item) {
if (index < 0 || index >= slotCount) return this;
final newItems = List<EquipmentItem>.from(items);
newItems[index] = item;
return Equipment(items: newItems, bestIndex: bestIndex);
}
Equipment copyWith({List<EquipmentItem>? items, int? bestIndex}) {
return Equipment(
items: items ?? List<EquipmentItem>.from(this.items),
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,
);
}
}
/// 히스토리 엔트리 (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,
required this.quest,
required this.plot,
required this.exp,
required this.encumbrance,
required this.currentTask,
required this.plotStageCount,
required this.questCount,
this.plotHistory = const [],
this.questHistory = const [],
this.currentQuestMonster,
this.currentCombat,
this.monstersKilled = 0,
this.deathCount = 0,
});
final ProgressBarState task;
final ProgressBarState quest;
final ProgressBarState plot;
final ProgressBarState exp;
final ProgressBarState encumbrance;
final TaskInfo currentTask;
final int plotStageCount;
final int questCount;
/// 플롯 히스토리 (Prologue, Act I, Act II, ...)
final List<HistoryEntry> plotHistory;
/// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록)
final List<HistoryEntry> questHistory;
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
final QuestMonsterInfo? currentQuestMonster;
/// 현재 전투 상태 (킬 태스크 진행 중)
final CombatState? currentCombat;
/// 처치한 몬스터 수
final int monstersKilled;
/// 사망 횟수
final int deathCount;
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,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
currentQuestMonster: null,
currentCombat: null,
);
ProgressState copyWith({
ProgressBarState? task,
ProgressBarState? quest,
ProgressBarState? plot,
ProgressBarState? exp,
ProgressBarState? encumbrance,
TaskInfo? currentTask,
int? plotStageCount,
int? questCount,
List<HistoryEntry>? plotHistory,
List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster,
CombatState? currentCombat,
int? monstersKilled,
int? deathCount,
}) {
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,
plotHistory: plotHistory ?? this.plotHistory,
questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
currentCombat: currentCombat ?? this.currentCombat,
monstersKilled: monstersKilled ?? this.monstersKilled,
deathCount: deathCount ?? this.deathCount,
);
}
}
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));
}
}