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

@@ -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(