feat: 초기 커밋

- Progress Quest 6.4 Flutter 포팅 프로젝트
- 게임 루프, 상태 관리, UI 구현
- 캐릭터 생성, 인벤토리, 장비, 주문 시스템
- 시장/판매/구매 메커니즘
This commit is contained in:
JiWoong Sul
2025-12-09 17:24:04 +09:00
commit 08054d97c1
168 changed files with 12876 additions and 0 deletions

View File

@@ -0,0 +1,820 @@
import 'dart:collection';
import 'dart:math' as math;
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/roman.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
// Mirrors core utility functions from the original Delphi sources (Main.pas / NewGuy.pas).
int levelUpTimeSeconds(int level) {
// ~20 minutes for level 1, then exponential growth (same as LevelUpTime in Main.pas).
final seconds = (20.0 + math.pow(1.15, level)) * 60.0;
return seconds.round();
}
/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271)
String roughTime(int seconds) {
if (seconds < 120) {
return '$seconds seconds';
} else if (seconds < 60 * 120) {
return '${seconds ~/ 60} minutes';
} else if (seconds < 60 * 60 * 48) {
return '${seconds ~/ 3600} hours';
} else {
return '${seconds ~/ (3600 * 24)} days';
}
}
String pluralize(String s) {
if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies';
if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i';
if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es';
if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves';
if (_ends(s, 'man') || _ends(s, 'Man')) {
return '${s.substring(0, s.length - 2)}en';
}
return '${s}s';
}
String indefinite(String s, int qty) {
if (qty == 1) {
const vowels = 'AEIOUÜaeiouü';
final first = s.isNotEmpty ? s[0] : 'a';
final article = vowels.contains(first) ? 'an' : 'a';
return '$article $s';
}
return '$qty ${pluralize(s)}';
}
String definite(String s, int qty) {
if (qty > 1) {
s = pluralize(s);
}
return 'the $s';
}
String generateName(DeterministicRandom rng) {
const kParts = [
'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z',
'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou',
'b|ck|d|g|k|m|n|p|t|v|x|z',
];
var result = '';
for (var i = 0; i <= 5; i++) {
result += _pick(kParts[i % 3], rng);
}
if (result.isEmpty) return result;
return '${result[0].toUpperCase()}${result.substring(1)}';
}
// Random helpers
int randomLow(DeterministicRandom rng, int below) {
return math.min(rng.nextInt(below), rng.nextInt(below));
}
String pick(List<String> values, DeterministicRandom rng) {
if (values.isEmpty) return '';
return values[rng.nextInt(values.length)];
}
String pickLow(List<String> values, DeterministicRandom rng) {
if (values.isEmpty) return '';
return values[randomLow(rng, values.length)];
}
// Item name generators (match Main.pas)
String boringItem(PqConfig config, DeterministicRandom rng) {
return pick(config.boringItems, rng);
}
String interestingItem(PqConfig config, DeterministicRandom rng) {
final attr = pick(config.itemAttrib, rng);
final special = pick(config.specials, rng);
return '$attr $special';
}
String specialItem(PqConfig config, DeterministicRandom rng) {
return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}';
}
String pickWeapon(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.weapons, rng, level);
}
String pickShield(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.shields, rng, level);
}
String pickArmor(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.armors, rng, level);
}
String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) {
return _lPick(config.spells, rng, goalLevel);
}
/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택
String _lPick(List<String> items, DeterministicRandom rng, int goal) {
if (items.isEmpty) return '';
var result = pick(items, rng);
var bestLevel = _parseLevel(result);
for (var i = 0; i < 5; i++) {
final candidate = pick(items, rng);
final candLevel = _parseLevel(candidate);
if ((goal - candLevel).abs() < (goal - bestLevel).abs()) {
result = candidate;
bestLevel = candLevel;
}
}
return result;
}
int _parseLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1].replaceAll('+', '')) ?? 0;
}
String addModifier(
DeterministicRandom rng,
String baseName,
List<String> modifiers,
int plus,
) {
var name = baseName;
var remaining = plus;
var count = 0;
while (count < 2 && remaining != 0) {
final modifier = pick(modifiers, rng);
final parts = modifier.split('|');
if (parts.isEmpty) break;
final label = parts[0];
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
if (name.contains(label)) break; // avoid repeats
if (remaining.abs() < qual.abs()) break;
name = '$label $name';
remaining -= qual;
count++;
}
if (remaining != 0) {
name = '${remaining > 0 ? '+' : ''}$remaining $name';
}
return name;
}
// Character/stat growth
int levelUpTime(int level) => levelUpTimeSeconds(level);
String winSpell(
PqConfig config,
DeterministicRandom rng,
int wisdom,
int level,
) {
// 원본 Main.pas:770-774: RandomLow로 인덱스 선택 (리스트 앞쪽 선호)
final maxIndex = math.min(wisdom + level, config.spells.length);
if (maxIndex <= 0) return '';
final index = randomLow(rng, maxIndex);
final entry = config.spells[index];
final parts = entry.split('|');
final name = parts[0];
final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I');
final nextRank = math.max(1, currentRank + 1);
return '$name|${intToRoman(nextRank)}';
}
String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) {
// If inventory is already very large, signal caller to duplicate an existing item.
final threshold = math.max(250, rng.nextInt(999));
if (inventoryCount > threshold) return '';
return specialItem(config, rng);
}
int rollStat(DeterministicRandom rng) {
// 3d6 roll.
return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6);
}
int random64Below(DeterministicRandom rng, int below) {
if (below <= 0) return 0;
final hi = rng.nextUint32();
final lo = rng.nextUint32();
final combined = (hi << 32) | lo;
return (combined % below).toInt();
}
String winEquip(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
// 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,
};
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
final worse = isWeapon ? config.offenseBad : config.defenseBad;
final base = _lPick(items, rng, level);
final parts = base.split('|');
final baseName = parts[0];
final qual = parts.length > 1
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
: 0;
final plus = level - qual;
final modifiers = plus >= 0 ? better : worse;
return addModifier(rng, baseName, modifiers, plus);
}
int winStatIndex(DeterministicRandom rng, List<int> statValues) {
// 원본 Main.pas:870-883: 50% 확률로 완전 랜덤, 50% 확률로 제곱 가중치
if (rng.nextInt(2) == 0) {
// 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);
var pickValue = random64Below(rng, total);
for (var i = 0; i < statValues.length; i++) {
pickValue -= statValues[i] * statValues[i];
if (pickValue < 0) return i;
}
return statValues.length - 1;
}
Stats winStat(Stats stats, DeterministicRandom rng) {
final values = <int>[
stats.str,
stats.con,
stats.dex,
stats.intelligence,
stats.wis,
stats.cha,
stats.hpMax,
stats.mpMax,
];
final idx = winStatIndex(rng, values);
switch (idx) {
case 0:
return stats.copyWith(str: stats.str + 1);
case 1:
return stats.copyWith(con: stats.con + 1);
case 2:
return stats.copyWith(dex: stats.dex + 1);
case 3:
return stats.copyWith(intelligence: stats.intelligence + 1);
case 4:
return stats.copyWith(wis: stats.wis + 1);
case 5:
return stats.copyWith(cha: stats.cha + 1);
case 6:
return stats.copyWith(hpMax: stats.hpMax + 1);
case 7:
return stats.copyWith(mpMax: stats.mpMax + 1);
default:
return stats;
}
}
String monsterTask(
PqConfig config,
DeterministicRandom rng,
int level,
String? questMonster, // optional monster name from quest
int? questLevel,
) {
var targetLevel = level;
for (var i = level; i > 0; i--) {
if (rng.nextInt(5) < 2) {
targetLevel += rng.nextInt(2) * 2 - 1; // RandSign
}
}
if (targetLevel < 1) targetLevel = 1;
String monster;
int monsterLevel;
bool definite = false;
// 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용
if (rng.nextInt(25) == 0) {
final race = pick(config.races, rng).split('|').first;
if (rng.nextInt(2) == 0) {
// 'passing Race Class' 형태
final klass = pick(config.klasses, rng).split('|').first;
monster = 'passing $race $klass';
} else {
// 'Title Name the Race' 형태 (원본은 PickLow(Titles) 사용)
final title = pickLow(config.titles, rng);
monster = '$title ${generateName(rng)} the $race';
definite = true;
}
monsterLevel = targetLevel;
monster = '$monster|$monsterLevel|*';
} else if (questMonster != null && rng.nextInt(4) == 0) {
// Use quest monster.
monster = questMonster;
monsterLevel = questLevel ?? targetLevel;
} else {
// Pick closest level among random samples.
monster = pick(config.monsters, rng);
monsterLevel = _monsterLevel(monster);
for (var i = 0; i < 5; i++) {
final candidate = pick(config.monsters, rng);
final candLevel = _monsterLevel(candidate);
if ((targetLevel - candLevel).abs() <
(targetLevel - monsterLevel).abs()) {
monster = candidate;
monsterLevel = candLevel;
}
}
}
// Adjust quantity and adjectives based on level delta.
var qty = 1;
final levelDiff = targetLevel - monsterLevel;
var name = monster.split('|').first;
if (levelDiff > 10) {
qty =
(targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/
(monsterLevel == 0 ? 1 : monsterLevel);
if (qty < 1) qty = 1;
targetLevel ~/= qty;
}
if (levelDiff <= -10) {
name = 'imaginary $name';
} else if (levelDiff < -5) {
final i = 5 - rng.nextInt(10 + levelDiff + 1);
name = _sick(i, _young((monsterLevel - targetLevel) - i, name));
} else if (levelDiff < 0) {
if (rng.nextInt(2) == 1) {
name = _sick(levelDiff, name);
} else {
name = _young(levelDiff, name);
}
} else if (levelDiff >= 10) {
name = 'messianic $name';
} else if (levelDiff > 5) {
final i = 5 - rng.nextInt(10 - levelDiff + 1);
name = _big(i, _special((levelDiff) - i, name));
} else if (levelDiff > 0) {
if (rng.nextInt(2) == 1) {
name = _big(levelDiff, name);
} else {
name = _special(levelDiff, name);
}
}
if (!definite) {
name = indefinite(name, qty);
}
return name;
}
enum RewardKind { spell, equip, stat, item }
class QuestResult {
const QuestResult({
required this.caption,
required this.reward,
this.monsterName,
this.monsterLevel,
});
final String caption;
final RewardKind reward;
final String? monsterName;
final int? monsterLevel;
}
QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
final rewardRoll = rng.nextInt(4);
final reward = switch (rewardRoll) {
0 => RewardKind.spell,
1 => RewardKind.equip,
2 => RewardKind.stat,
_ => RewardKind.item,
};
final questRoll = rng.nextInt(5);
switch (questRoll) {
case 0:
var best = '';
var bestLevel = 0;
for (var i = 0; i < 4; i++) {
final m = pick(config.monsters, rng);
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
}
}
final name = best.split('|').first;
return QuestResult(
caption: 'Exterminate ${definite(name, 2)}',
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
);
case 1:
final item = interestingItem(config, rng);
return QuestResult(caption: 'Seek ${definite(item, 1)}', reward: reward);
case 2:
final item = boringItem(config, rng);
return QuestResult(caption: 'Deliver this $item', reward: reward);
case 3:
final item = boringItem(config, rng);
return QuestResult(
caption: 'Fetch me ${indefinite(item, 1)}',
reward: reward,
);
default:
var best = '';
var bestLevel = 0;
for (var i = 0; i < 2; i++) {
final m = pick(config.monsters, rng);
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
}
}
final name = best.split('|').first;
return QuestResult(
caption: 'Placate ${definite(name, 2)}',
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
);
}
}
class ActResult {
const ActResult({
required this.actTitle,
required this.plotBarMaxSeconds,
required this.rewards,
});
final String actTitle;
final int plotBarMaxSeconds;
final List<RewardKind> rewards;
}
ActResult completeAct(int existingActCount) {
final nextActIndex = existingActCount;
final title = 'Act ${intToRoman(nextActIndex)}';
final plotBarMax = 60 * 60 * (1 + 5 * existingActCount);
final rewards = <RewardKind>[];
if (existingActCount > 1) {
rewards.add(RewardKind.item);
}
if (existingActCount > 2) {
rewards.add(RewardKind.equip);
}
return ActResult(
actTitle: title,
plotBarMaxSeconds: plotBarMax,
rewards: rewards,
);
}
class TaskResult {
const TaskResult({
required this.caption,
required this.durationMillis,
required this.progress,
});
final String caption;
final int durationMillis;
final ProgressState progress;
}
/// Starts a task: resets task bar and sets caption.
TaskResult startTask(
ProgressState progress,
String caption,
int durationMillis,
) {
final updated = progress.copyWith(
task: ProgressBarState(position: 0, max: durationMillis),
);
return TaskResult(
caption: '$caption...',
durationMillis: durationMillis,
progress: updated,
);
}
class DequeueResult {
const DequeueResult({
required this.progress,
required this.queue,
required this.caption,
required this.taskType,
required this.kind,
});
final ProgressState progress;
final QueueState queue;
final String caption;
final TaskType taskType;
final QueueKind kind;
}
/// Process the queue when current task is done. Returns null if nothing to do.
DequeueResult? dequeue(ProgressState progress, QueueState queue) {
// Only act when the task bar is finished.
if (progress.task.position < progress.task.max) return null;
if (queue.entries.isEmpty) return null;
final entries = Queue<QueueEntry>.from(queue.entries);
if (entries.isEmpty) return null;
final next = entries.removeFirst();
final taskResult = startTask(progress, next.caption, next.durationMillis);
return DequeueResult(
progress: taskResult.progress,
queue: QueueState(entries: entries.toList()),
caption: taskResult.caption,
taskType: next.taskType,
kind: next.kind,
);
}
int _monsterLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1]) ?? 0;
}
String _sick(int m, String s) {
switch (m) {
case -5:
case 5:
return 'dead $s';
case -4:
case 4:
return 'comatose $s';
case -3:
case 3:
return 'crippled $s';
case -2:
case 2:
return 'sick $s';
case -1:
case 1:
return 'undernourished $s';
default:
return '$m$s';
}
}
String _young(int m, String s) {
switch (-m) {
case -5:
case 5:
return 'foetal $s';
case -4:
case 4:
return 'baby $s';
case -3:
case 3:
return 'preadolescent $s';
case -2:
case 2:
return 'teenage $s';
case -1:
case 1:
return 'underage $s';
default:
return '$m$s';
}
}
String _big(int m, String s) {
switch (m) {
case 1:
case -1:
return 'greater $s';
case 2:
case -2:
return 'massive $s';
case 3:
case -3:
return 'enormous $s';
case 4:
case -4:
return 'giant $s';
case 5:
case -5:
return 'titanic $s';
default:
return s;
}
}
String _special(int m, String s) {
switch (-m) {
case 1:
case -1:
return s.contains(' ') ? 'veteran $s' : 'Battle-$s';
case 2:
case -2:
return 'cursed $s';
case 3:
case -3:
return s.contains(' ') ? 'warrior $s' : 'Were-$s';
case 4:
case -4:
return 'undead $s';
case 5:
case -5:
return 'demon $s';
default:
return s;
}
}
bool _ends(String s, String suffix) {
return s.length >= suffix.length &&
s.substring(s.length - suffix.length) == suffix;
}
String _pick(String pipeSeparated, DeterministicRandom rng) {
final parts = pipeSeparated.split('|');
if (parts.isEmpty) return '';
final idx = rng.nextInt(parts.length);
return parts[idx];
}
// =============================================================================
// InterplotCinematic 관련 함수들 (Main.pas:456-521)
// =============================================================================
/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521)
/// 인상적인 타이틀 + 종족 또는 이름 조합
String impressiveGuy(PqConfig config, DeterministicRandom rng) {
var result = pick(config.impressiveTitles, rng);
switch (rng.nextInt(2)) {
case 0:
// "the King of the Elves" 형태
final race = pick(config.races, rng).split('|').first;
result = 'the $result of the ${pluralize(race)}';
break;
case 1:
// "King Vrognak of Zoxzik" 형태
result = '$result ${generateName(rng)} of ${generateName(rng)}';
break;
}
return result;
}
/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512)
/// 레벨에 맞는 몬스터 선택 후 GenerateName으로 이름 붙이기
String namedMonster(PqConfig config, DeterministicRandom rng, int level) {
String best = '';
int bestLevel = 0;
// 5번 시도해서 레벨에 가장 가까운 몬스터 선택
for (var i = 0; i < 5; i++) {
final m = pick(config.monsters, rng);
final parts = m.split('|');
final name = parts.first;
final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0;
if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) {
best = name;
bestLevel = lev;
}
}
return '${generateName(rng)} the $best';
}
/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495)
/// 3가지 시나리오 중 하나를 랜덤 선택
List<QueueEntry> interplotCinematic(
PqConfig config,
DeterministicRandom rng,
int level,
int plotCount,
) {
final entries = <QueueEntry>[];
// 헬퍼: 큐 엔트리 추가 (원본의 Q 함수 역할)
void q(QueueKind kind, int seconds, String caption) {
entries.add(
QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption),
);
}
switch (rng.nextInt(3)) {
case 0:
// 시나리오 1: 우호적 오아시스
q(
QueueKind.task,
1,
'Exhausted, you arrive at a friendly oasis in a hostile land',
);
q(QueueKind.task, 2, 'You greet old friends and meet new allies');
q(QueueKind.task, 2, 'You are privy to a council of powerful do-gooders');
q(QueueKind.task, 1, 'There is much to be done. You are chosen!');
break;
case 1:
// 시나리오 2: 강력한 적과의 전투
q(
QueueKind.task,
1,
'Your quarry is in sight, but a mighty enemy bars your path!',
);
final nemesis = namedMonster(config, rng, level + 3);
q(QueueKind.task, 4, 'A desperate struggle commences with $nemesis');
var s = rng.nextInt(3);
final combatRounds = rng.nextInt(1 + plotCount);
for (var i = 0; i < combatRounds; i++) {
s += 1 + rng.nextInt(2);
switch (s % 3) {
case 0:
q(QueueKind.task, 2, 'Locked in grim combat with $nemesis');
break;
case 1:
q(QueueKind.task, 2, '$nemesis seems to have the upper hand');
break;
case 2:
q(
QueueKind.task,
2,
'You seem to gain the advantage over $nemesis',
);
break;
}
}
q(
QueueKind.task,
3,
'Victory! $nemesis is slain! Exhausted, you lose conciousness',
);
q(
QueueKind.task,
2,
'You awake in a friendly place, but the road awaits',
);
break;
case 2:
// 시나리오 3: 배신 발견
final guy = impressiveGuy(config, rng);
q(
QueueKind.task,
2,
"Oh sweet relief! You've reached the kind protection of $guy",
);
q(
QueueKind.task,
3,
'There is rejoicing, and an unnerving encouter with $guy in private',
);
q(
QueueKind.task,
2,
'You forget your ${boringItem(config, rng)} and go back to get it',
);
q(QueueKind.task, 2, "What's this!? You overhear something shocking!");
q(QueueKind.task, 2, 'Could $guy be a dirty double-dealer?');
q(
QueueKind.task,
3,
'Who can possibly be trusted with this news!? -- Oh yes, of course',
);
break;
}
// 마지막에 plot|2|Loading 추가
q(QueueKind.plot, 2, 'Loading');
return entries;
}