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

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