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

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