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,674 @@
// GENERATED CODE - DO NOT EDIT BY HAND.
// Generated by tool/dfm_extract.dart from example/pq/Config.dfm
const Map<String, List<String>> pqConfigData = {
'Armors': [
'Lace|1',
'Macrame|2',
'Burlap|3',
'Canvas|4',
'Flannel|5',
'Chamois|6',
'Pleathers|7',
'Leathers|8',
'Bearskin|9',
'Ringmail|10',
'Scale Mail|12',
'Chainmail|14',
'Splint Mail|15',
'Platemail|16',
'ABS|17',
'Kevlar|18',
'Titanium|19',
'Mithril Mail|20',
'Diamond Mail|25',
'Plasma|30',
],
'BoringItems': [
'nail',
'lunchpail',
'sock',
'I.O.U.',
'cookie',
'pint',
'toothpick',
'writ',
'newspaper',
'letter',
'plank',
'hat',
'egg',
'coin',
'needle',
'bucket',
'ladder',
'chicken',
'twig',
'dirtclod',
'counterpane',
'vest',
'teratoma',
'bunny',
'rock',
'pole',
'carrot',
'canoe',
'inkwell',
'hoe',
'bandage',
'trowel',
'towel',
'planter box',
'anvil',
'axle',
'tuppence',
'casket',
'nosegay',
'trinket',
'credenza',
'writ',
],
'DefenseAttrib': [
'Studded|+1',
'Banded|+2',
'Gilded|+2',
'Festooned|+3',
'Holy|+4',
'Cambric|+1',
'Fine|+4',
'Impressive|+5',
'Custom|+3',
],
'DefenseBad': [
'Holey|-1',
'Patched|-1',
'Threadbare|-2',
'Faded|-1',
'Rusty|-3',
'Motheaten|-3',
'Mildewed|-2',
'Torn|-3',
'Dented|-3',
'Cursed|-5',
'Plastic|-4',
'Cracked|-4',
'Warped|-3',
'Corroded|-3',
],
'ImpressiveTitles': [
'King',
'Queen',
'Lord',
'Lady',
'Viceroy',
'Mayor',
'Prince',
'Princess',
'Chief',
'Boss',
'Archbishop',
'Baron',
'Comptroller',
],
'ItemAttrib': [
'Golden',
'Gilded',
'Spectral',
'Astral',
'Garlanded',
'Precious',
'Crafted',
'Dual',
'Filigreed',
'Cruciate',
'Arcane',
'Blessed',
'Reverential',
'Lucky',
'Enchanted',
'Gleaming',
'Grandiose',
'Sacred',
'Legendary',
'Mythic',
'Crystalline',
'Austere',
'Ostentatious',
'One True',
'Proverbial',
'Fearsome',
'Deadly',
'Benevolent',
'Unearthly',
'Magnificent',
'Iron',
'Ormolu',
'Puissant',
],
'ItemOfs': [
'Foreboding',
'Foreshadowing',
'Nervousness',
'Happiness',
'Torpor',
'Danger',
'Craft',
'Silence',
'Invisibility',
'Rapidity',
'Pleasure',
'Practicality',
'Hurting',
'Joy',
'Petulance',
'Intrusion',
'Chaos',
'Suffering',
'Extroversion',
'Frenzy',
'Solitude',
'Punctuality',
'Efficiency',
'Comfort',
'Patience',
'Internment',
'Incarceration',
'Misapprehension',
'Loyalty',
'Envy',
'Acrimony',
'Worry',
'Fear',
'Awe',
'Guile',
'Prurience',
'Fortune',
'Perspicacity',
'Domination',
'Submission',
'Fealty',
'Hunger',
'Despair',
'Cruelty',
'Grob',
'Dignard',
'Ra',
'the Bone',
'Diamonique',
'Electrum',
'Hydragyrum',
],
'Klasses': [
'Ur-Paladin|WIS,CON',
'Voodoo Princess|INT,CHA',
'Robot Monk|STR',
'Mu-Fu Monk|DEX',
'Mage Illusioner|INT,MP Max',
'Shiv-Knight|DEX',
'Inner Mason|CON',
'Fighter/Organist|CHA,STR',
'Puma Burgular|DEX',
'Runeloremaster|WIS',
'Hunter Strangler|DEX,INT',
'Battle-Felon|STR',
'Tickle-Mimic|WIS,INT',
'Slow Poisoner|CON',
'Bastard Lunatic|CON',
'Jungle Clown|DEX,CHA',
'Birdrider|WIS',
'Vermineer|INT',
],
'MonMods': [
'-4 fœtal *',
'-4 dying *',
'-3 crippled *',
'-3 baby *',
'-2 adolescent',
'-2 very sick *',
'-1 lesser *',
'-1 undernourished *',
'+1 greater *',
'+1 * Elder',
'+2 war *',
'+2 Battle-*',
'+3 Were-*',
'+3 undead *',
'+4 giant *',
'+4 * Rex',
],
'Monsters': [
'Anhkheg|6|chitin',
'Ant|0|antenna',
'Ape|4|ass',
'Baluchitherium|14|ear',
'Beholder|10|eyestalk',
'Black Pudding|10|saliva',
'Blink Dog|4|eyelid',
'Cub Scout|1|neckerchief',
'Girl Scout|2|cookie',
'Boy Scout|3|merit badge',
'Eagle Scout|4|merit badge',
'Bugbear|3|skin',
'Bugboar|3|tusk',
'Boogie|3|slime',
'Camel|2|hump',
'Carrion Crawler|3|egg',
'Catoblepas|6|neck',
'Centaur|4|rib',
'Centipede|0|leg',
'Cockatrice|5|wattle',
'Couatl|9|wing',
'Crayfish|0|antenna',
'Demogorgon|53|tentacle',
'Jubilex|17|gel',
'Manes|1|tooth',
'Orcus|27|wand',
'Succubus|6|bra',
'Vrock|8|neck',
'Hezrou|9|leg',
'Glabrezu|10|collar',
'Nalfeshnee|11|tusk',
'Marilith|7|arm',
'Balor|8|whip',
'Yeenoghu|25|flail',
'Asmodeus|52|leathers',
'Baalzebul|43|pants',
'Barbed Devil|8|flame',
'Bone Devil|9|hook',
'Dispater|30|matches',
'Erinyes|6|thong',
'Geryon|30|cornucopia',
'Malebranche|5|fork',
'Ice Devil|11|snow',
'Lemure|3|blob',
'Pit Fiend|13|seed',
'Ankylosaurus|9|tail',
'Brontosaurus|30|brain',
'Diplodocus|24|fin',
'Elasmosaurus|15|neck',
'Gorgosaurus|13|arm',
'Iguanadon|6|thumb',
'Megalosaurus|12|jaw',
'Monoclonius|8|horn',
'Pentasaurus|12|head',
'Stegosaurus|18|plate',
'Triceratops|16|horn',
'Tyrannosaurus Rex|18|forearm',
'Djinn|7|lamp',
'Doppelganger|4|face',
'Black Dragon|7|*',
'Plaid Dragon|7|sporrin',
'Blue Dragon|9|*',
'Beige Dragon|9|*',
'Brass Dragon|7|pole',
'Tin Dragon|8|*',
'Bronze Dragon|9|medal',
'Chromatic Dragon|16|scale',
'Copper Dragon|8|loafer',
'Gold Dragon|8|filling',
'Green Dragon|8|*',
'Platinum Dragon|21|*',
'Red Dragon|10|cocktail',
'Silver Dragon|10|*',
'White Dragon|6|tooth',
'Dragon Turtle|13|shell',
'Dryad|2|acorn',
'Dwarf|1|drawers',
'Eel|2|sashimi',
'Efreet|10|cinder',
'Sand Elemental|8|glass',
'Bacon Elemental|10|bit',
'Porn Elemental|12|lube',
'Cheese Elemental|14|curd',
'Hair Elemental|16|follicle',
'Swamp Elf|1|lilypad',
'Brown Elf|1|tusk',
'Sea Elf|1|jerkin',
'Ettin|10|fur',
'Frog|0|leg',
'Violet Fungi|3|spore',
'Gargoyle|4|gravel',
'Gelatinous Cube|4|jam',
'Ghast|4|vomit',
'Ghost|10|*',
'Ghoul|2|muscle',
'Humidity Giant|12|drops',
'Beef Giant|11|steak',
'Quartz Giant|10|crystal',
'Porcelain Giant|9|fixture',
'Rice Giant|8|grain',
'Cloud Giant|12|condensation',
'Fire Giant|11|cigarettes',
'Frost Giant|10|snowman',
'Hill Giant|8|corpse',
'Stone Giant|9|hatchling',
'Storm Giant|15|barometer',
'Mini Giant|4|pompadour',
'Gnoll|2|collar',
'Gnome|1|hat',
'Goblin|1|ear',
'Grid Bug|1|carapace',
'Jellyrock|9|seedling',
'Beer Golem|15|foam',
'Oxygen Golem|17|platelet',
'Cardboard Golem|14|recycling',
'Rubber Golem|16|ball',
'Leather Golem|15|fob',
'Gorgon|8|testicle',
'Gray Ooze|3|gravy',
'Green Slime|2|sample',
'Griffon|7|nest',
'Banshee|7|larynx',
'Harpy|3|mascara',
'Hell Hound|5|tongue',
'Hippocampus|4|mane',
'Hippogriff|3|egg',
'Hobgoblin|1|patella',
'Homunculus|2|fluid',
'Hydra|8|gyrum',
'Imp|2|tail',
'Invisible Stalker|8|*',
'Iron Peasant|3|chaff',
'Jumpskin|3|shin',
'Kobold|1|penis',
'Leprechaun|1|wallet',
'Leucrotta|6|hoof',
'Lich|11|crown',
'Lizard Man|2|tail',
'Lurker|10|sac',
'Manticore|6|spike',
'Mastodon|12|tusk',
'Medusa|6|eye',
'Multicell|2|dendrite',
'Pirate|1|booty',
'Berserker|1|shirt',
'Caveman|2|club',
'Dervish|1|robe',
'Merman|1|trident',
'Mermaid|1|gills',
'Mimic|9|hinge',
'Mind Flayer|8|tentacle',
'Minotaur|6|map',
'Yellow Mold|1|spore',
'Morkoth|7|teeth',
'Mummy|6|gauze',
'Naga|9|rattle',
'Nebbish|1|belly',
'Neo-Otyugh|11|organ ',
'Nixie|1|webbing',
'Nymph|3|hanky',
'Ochre Jelly|6|nucleus',
'Octopus|2|beak',
'Ogre|4|talon',
'Ogre Mage|5|apparel',
'Orc|1|snout',
'Otyugh|7|organ',
'Owlbear|5|feather',
'Pegasus|4|aileron',
'Peryton|4|antler',
'Piercer|3|tip',
'Pixie|1|dust',
'Man-o-war|3|tentacle',
'Purple Worm|15|dung',
'Quasit|3|tail',
'Rakshasa|7|pajamas',
'Rat|0|tail',
'Remorhaz|11|protrusion',
'Roc|18|wing',
'Roper|11|twine',
'Rot Grub|1|eggsac',
'Rust Monster|5|shavings',
'Satyr|5|hoof',
'Sea Hag|3|wart',
'Silkie|3|fur',
'Shadow|3|silhouette',
'Shambling Mound|10|mulch',
'Shedu|9|hoof',
'Shrieker|3|stalk',
'Skeleton|1|clavicle',
'Spectre|7|vestige',
'Sphinx|10|paw',
'Spider|0|web',
'Sprite|1|can',
'Stirge|1|proboscis',
'Stun Bear|5|tooth',
'Stun Worm|2|trode',
'Su-monster|5|tail',
'Sylph|3|thigh',
'Titan|20|sandal',
'Trapper|12|shag',
'Treant|10|acorn',
'Triton|3|scale',
'Troglodyte|2|tail',
'Troll|6|hide',
'Umber Hulk|8|claw',
'Unicorn|4|blood',
'Vampire|8|pancreas',
'Wight|4|lung',
'Will-o-the-Wisp|9|wisp',
'Wraith|5|finger',
'Wyvern|7|wing',
'Xorn|7|jaw',
'Yeti|4|fur',
'Zombie|2|forehead',
'Wasp|0|stinger',
'Rat|1|tail',
'Bunny|0|ear',
'Moth|0|dust',
'Beagle|0|collar',
'Midge|0|corpse',
'Ostrich|1|beak',
'Billy Goat|1|beard',
'Bat|1|wing',
'Koala|2|heart',
'Wolf|2|paw',
'Whippet|2|collar',
'Uruk|2|boot',
'Poroid|4|node',
'Moakum|8|frenum',
'Fly|0|*',
'Hogbird|3|curl',
],
'OffenseAttrib': [
'Polished|+1',
'Serrated|+1',
'Heavy|+1',
'Pronged|+2',
'Steely|+2',
'Vicious|+3',
'Venomed|+4',
'Stabbity|+4',
'Dancing|+5',
'Invisible|+6',
'Vorpal|+7',
],
'OffenseBad': [
'Dull|-2',
'Tarnished|-1',
'Rusty|-3',
'Padded|-5',
'Bent|-4',
'Mini|-4',
'Rubber|-6',
'Nerf|-7',
'Unbalanced|-2',
],
'Races': [
'Half Orc|HP Max',
'Half Man|CHA',
'Half Halfling|DEX',
'Double Hobbit|STR',
'Hob-Hobbit|DEX,CON',
'Low Elf|CON',
'Dung Elf|WIS',
'Talking Pony|MP Max,INT',
'Gyrognome|DEX',
'Lesser Dwarf|CON',
'Crested Dwarf|CHA',
'Eel Man|DEX',
'Panda Man|CON,STR',
'Trans-Kobold|WIS',
'Enchanted Motorcycle|MP Max',
"Will o' the Wisp|WIS",
'Battle-Finch|DEX,INT',
'Double Wookiee|STR',
'Skraeling|WIS',
'Demicanadian|CON',
'Land Squid|STR,HP Max',
],
'Shields': [
'Parasol|0',
'Pie Plate|1',
'Garbage Can Lid|2',
'Buckler|3',
'Plexiglass|4',
'Fender|4',
'Round Shield|5',
'Carapace|5',
'Scutum|6',
'Propugner|6',
'Kite Shield|7',
'Pavise|8',
'Tower Shield|9',
'Baroque Shield|11',
'Aegis|12',
'Magnetic Field|18',
],
'Specials': [
'Diadem',
'Festoon',
'Gemstone',
'Phial',
'Tiara',
'Scabbard',
'Arrow',
'Lens',
'Lamp',
'Hymnal',
'Fleece',
'Laurel',
'Brooch',
'Gimlet',
'Cobble',
'Albatross',
'Brazier',
'Bandolier',
'Tome',
'Garnet',
'Amethyst',
'Candelabra',
'Corset',
'Sphere',
'Sceptre',
'Ankh',
'Talisman',
'Orb',
'Gammel',
'Ornament',
'Brocade',
'Galoon',
'Bijou',
'Spangle',
'Gimcrack',
'Hood',
'Vulpeculum',
],
'Spells': [
'Slime Finger',
'Rabbit Punch',
'Hastiness',
'Good Move',
'Sadness',
'Seasick',
'Shoelaces',
'Inoculate',
'Cone of Annoyance',
'Magnetic Orb',
'Invisible Hands',
'Revolting Cloud',
'Aqueous Humor',
'Spectral Miasma',
'Clever Fellow',
'Lockjaw',
'History Lesson',
'Hydrophobia',
'Big Sister',
'Cone of Paste',
'Mulligan',
"Nestor's Bright Idea",
'Holy Batpole',
'Tumor (Benign)',
'Braingate',
'Nonplus',
'Animate Nightstand',
'Eye of the Troglodyte',
'Curse Name',
'Dropsy',
'Vitreous Humor',
"Roger's Grand Illusion",
'Covet',
'Astral Miasma',
'Spectral Oyster',
'Acrid Hands',
'Angioplasty',
"Grognor's Big Day Off",
'Tumor (Malignant)',
'Animate Tunic',
'Ursine Armor',
'Holy Roller',
'Tonsillectomy',
'Curse Family',
'Infinite Confusion',
],
'Titles': [
'Mr.',
'Mrs.',
'Sir',
'Sgt.',
'Ms.',
'Captain',
'Chief',
'Admiral',
'Saint',
],
'Weapons': [
'Stick|0',
'Broken Bottle|1',
'Shiv|1',
'Sprig|1',
'Oxgoad|1',
'Eelspear|2',
'Bowie Knife|2',
'Claw Hammer|2',
'Handpeen|2',
'Andiron|3',
'Hatchet|3',
'Tomahawk|3',
'Hackbarm|3',
'Crowbar|4',
'Mace|4',
'Battleadze|4',
'Leafmace|5',
'Shortsword|5',
'Longiron|5',
'Poachard|5',
'Baselard|5',
'Whinyard|6',
'Blunderbuss|6',
'Longsword|6',
'Crankbow|6',
'Blibo|7',
'Broadsword|7',
'Kreen|7',
'Morning Star|8',
'Pole-adze|8',
'Spontoon|8',
'Bastard Sword|9',
'Peen-arm|9',
'Culverin|10',
'Lance|10',
'Halberd|11',
'Poleax|12',
'Bandyclef|15',
],
};

6
lib/main.dart Normal file
View File

@@ -0,0 +1,6 @@
import 'package:askiineverdie/src/app.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const AskiiNeverDieApp());
}

145
lib/src/app.dart Normal file
View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/storage/save_manager.dart';
import 'package:askiineverdie/src/core/storage/save_repository.dart';
import 'package:askiineverdie/src/features/front/front_screen.dart';
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/new_character/new_character_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
const AskiiNeverDieApp({super.key});
@override
State<AskiiNeverDieApp> createState() => _AskiiNeverDieAppState();
}
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
late final GameSessionController _controller;
@override
void initState() {
super.initState();
const config = PqConfig();
final mutations = GameMutations(config);
final rewards = RewardService(mutations);
_controller = GameSessionController(
progressService: ProgressService(
config: config,
mutations: mutations,
rewards: rewards,
),
saveManager: SaveManager(SaveRepository()),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Ascii Never Die',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)),
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
useMaterial3: true,
),
home: FrontScreen(
onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave,
),
);
}
void _navigateToNewCharacter(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => NewCharacterScreen(
onCharacterCreated: (initialState) {
_startGame(context, initialState);
},
),
),
);
}
Future<void> _loadSave(BuildContext context) async {
// 저장 파일 목록 조회
final saves = await _controller.saveManager.listSaves();
if (!context.mounted) return;
String? selectedFileName;
if (saves.isEmpty) {
// 저장 파일이 없으면 안내 메시지
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
return;
} else if (saves.length == 1) {
// 파일이 하나면 바로 선택
selectedFileName = saves.first.fileName;
} else {
// 여러 개면 다이얼로그 표시
selectedFileName = await SavePickerDialog.show(context, saves);
}
if (selectedFileName == null || !context.mounted) return;
// 선택된 파일 로드
await _controller.loadAndStart(
fileName: selectedFileName,
cheatsEnabled: false,
);
if (_controller.status == GameSessionStatus.running) {
if (context.mounted) {
_navigateToGame(context);
}
} else if (_controller.status == GameSessionStatus.error) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'저장 파일을 불러올 수 없습니다: ${_controller.error ?? "알 수 없는 오류"}',
),
),
);
}
}
}
Future<void> _startGame(BuildContext context, GameState initialState) async {
await _controller.startNew(initialState, cheatsEnabled: false);
if (context.mounted) {
// NewCharacterScreen을 pop하고 GamePlayScreen으로 이동
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(controller: _controller),
),
);
}
}
void _navigateToGame(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(controller: _controller),
),
);
}
}

View File

@@ -0,0 +1,86 @@
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;
/// Game state mutations that mirror the original PQ win/reward logic.
class GameMutations {
const GameMutations(this.config);
final PqConfig config;
GameState winEquip(GameState state, int level, EquipmentSlot slot) {
final rng = state.rng;
final name = pq_logic.winEquip(config, rng, level, slot);
final equip = state.equipment;
final updatedEquip = switch (slot) {
EquipmentSlot.weapon => equip.copyWith(
weapon: name,
bestIndex: EquipmentSlot.weapon.index,
),
EquipmentSlot.shield => equip.copyWith(
shield: name,
bestIndex: EquipmentSlot.shield.index,
),
EquipmentSlot.armor => equip.copyWith(
armor: name,
bestIndex: EquipmentSlot.armor.index,
),
};
return state.copyWith(rng: rng, equipment: updatedEquip);
}
GameState winStat(GameState state) {
final updatedStats = pq_logic.winStat(state.stats, state.rng);
return state.copyWith(rng: state.rng, stats: updatedStats);
}
GameState winSpell(GameState state, int wisdom, int level) {
final result = pq_logic.winSpell(config, state.rng, wisdom, level);
final parts = result.split('|');
final name = parts[0];
final rank = parts.length > 1 ? parts[1] : 'I';
final spells = [...state.spellBook.spells];
final index = spells.indexWhere((s) => s.name == name);
if (index >= 0) {
spells[index] = spells[index].copyWith(rank: rank);
} else {
spells.add(SpellEntry(name: name, rank: rank));
}
return state.copyWith(
rng: state.rng,
spellBook: state.spellBook.copyWith(spells: spells),
);
}
GameState winItem(GameState state) {
final rng = state.rng;
final result = pq_logic.winItem(config, rng, state.inventory.items.length);
final items = [...state.inventory.items];
if (result.isEmpty) {
// Duplicate an existing item if possible.
if (items.isNotEmpty) {
final pickIndex = rng.nextInt(items.length);
final picked = items[pickIndex];
items[pickIndex] = picked.copyWith(count: picked.count + 1);
}
} else {
final existing = items.indexWhere((e) => e.name == result);
if (existing >= 0) {
items[existing] = items[existing].copyWith(
count: items[existing].count + 1,
);
} else {
items.add(InventoryEntry(name: result, count: 1));
}
}
return state.copyWith(
rng: rng,
inventory: state.inventory.copyWith(items: items),
);
}
}

View File

@@ -0,0 +1,138 @@
import 'dart:async';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/storage/save_manager.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart';
class AutoSaveConfig {
const AutoSaveConfig({
this.onLevelUp = true,
this.onQuestComplete = true,
this.onActComplete = true,
this.onStop = true,
});
final bool onLevelUp;
final bool onQuestComplete;
final bool onActComplete;
final bool onStop;
bool shouldSave(ProgressTickResult result) {
return (onLevelUp && result.leveledUp) ||
(onQuestComplete && result.completedQuest) ||
(onActComplete && result.completedAct);
}
}
/// Runs the periodic timer loop that advances tasks/quests/plots.
class ProgressLoop {
ProgressLoop({
required GameState initialState,
required this.progressService,
this.saveManager,
Duration tickInterval = const Duration(milliseconds: 50),
AutoSaveConfig autoSaveConfig = const AutoSaveConfig(),
DateTime Function()? now,
this.cheatsEnabled = false,
}) : _state = initialState,
_tickInterval = tickInterval,
_autoSaveConfig = autoSaveConfig,
_now = now ?? DateTime.now,
_stateController = StreamController<GameState>.broadcast();
final ProgressService progressService;
final SaveManager? saveManager;
final Duration _tickInterval;
final AutoSaveConfig _autoSaveConfig;
final DateTime Function() _now;
final StreamController<GameState> _stateController;
bool cheatsEnabled;
Timer? _timer;
int? _lastTickMs;
int _speedMultiplier = 1;
GameState get current => _state;
Stream<GameState> get stream => _stateController.stream;
GameState _state;
/// 현재 배속 (1x, 2x, 5x)
int get speedMultiplier => _speedMultiplier;
/// 배속 순환: 1 -> 2 -> 5 -> 1
void cycleSpeed() {
_speedMultiplier = switch (_speedMultiplier) {
1 => 2,
2 => 5,
_ => 1,
};
}
void start() {
_lastTickMs = _now().millisecondsSinceEpoch;
_timer ??= Timer.periodic(_tickInterval, (_) => tickOnce());
}
Future<void> stop({bool saveOnStop = false}) async {
_timer?.cancel();
_timer = null;
if (saveOnStop && _autoSaveConfig.onStop && saveManager != null) {
await saveManager!.saveState(_state);
}
}
void dispose() {
_timer?.cancel();
_stateController.close();
}
/// Run one iteration of the loop (used by Timer or manual stepping).
GameState tickOnce({int? deltaMillis}) {
final baseDelta = deltaMillis ?? _computeDelta();
final delta = baseDelta * _speedMultiplier;
final result = progressService.tick(_state, delta);
_state = result.state;
_stateController.add(_state);
if (saveManager != null && _autoSaveConfig.shouldSave(result)) {
saveManager!.saveState(_state);
}
return _state;
}
/// Replace state (e.g., after loading) and reset timing.
void replaceState(GameState newState) {
_state = newState;
_stateController.add(newState);
_lastTickMs = _now().millisecondsSinceEpoch;
}
// Developer-only helpers mirroring original cheat panel actions.
void cheatCompleteTask() {
if (!cheatsEnabled) return;
_state = progressService.forceTaskComplete(_state);
_stateController.add(_state);
}
void cheatCompleteQuest() {
if (!cheatsEnabled) return;
_state = progressService.forceQuestComplete(_state);
_stateController.add(_state);
}
void cheatCompletePlot() {
if (!cheatsEnabled) return;
_state = progressService.forcePlotComplete(_state);
_stateController.add(_state);
}
int _computeDelta() {
final nowMs = _now().millisecondsSinceEpoch;
final last = _lastTickMs;
_lastTickMs = nowMs;
if (last == null) return 0;
final delta = nowMs - last;
if (delta < 0) return 0;
return delta;
}
}

View File

@@ -0,0 +1,712 @@
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;
class ProgressTickResult {
const ProgressTickResult({
required this.state,
this.leveledUp = false,
this.completedQuest = false,
this.completedAct = false,
});
final GameState state;
final bool leveledUp;
final bool completedQuest;
final bool completedAct;
bool get shouldAutosave => leveledUp || completedQuest || completedAct;
}
/// Drives quest/plot/task progression by applying queued actions and rewards.
class ProgressService {
ProgressService({
required this.config,
required this.mutations,
required this.rewards,
});
final PqConfig config;
final GameMutations mutations;
final RewardService rewards;
/// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767)
/// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작
GameState initializeNewGame(GameState state) {
// 초기 큐 설정 (원본 753-757줄)
final initialQueue = <QueueEntry>[
const QueueEntry(
kind: QueueKind.task,
durationMillis: 10 * 1000,
caption: 'Experiencing an enigmatic and foreboding night vision',
taskType: TaskType.load,
),
const QueueEntry(
kind: QueueKind.task,
durationMillis: 6 * 1000,
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, '
'but resolute',
taskType: TaskType.load,
),
const QueueEntry(
kind: QueueKind.task,
durationMillis: 4 * 1000,
caption: 'Drawing upon an unexpected reserve of determination, '
'you set out on a long and dangerous journey',
taskType: TaskType.load,
),
const QueueEntry(
kind: QueueKind.plot,
durationMillis: 2 * 1000,
caption: 'Loading',
taskType: TaskType.plot,
),
];
// 첫 번째 태스크 'Loading' 시작 (원본 752줄)
final taskResult = pq_logic.startTask(
state.progress,
'Loading',
2 * 1000,
);
// ExpBar 초기화 (원본 743-746줄)
final expBar = ProgressBarState(
position: 0,
max: pq_logic.levelUpTime(1),
);
// PlotBar 초기화 (원본 759줄)
final plotBar = const ProgressBarState(position: 0, max: 26 * 1000);
final progress = taskResult.progress.copyWith(
exp: expBar,
plot: plotBar,
currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load),
plotStageCount: 1, // Prologue
questCount: 0,
);
return _recalculateEncumbrance(
state.copyWith(
progress: progress,
queue: QueueState(entries: initialQueue),
),
);
}
/// Starts a task and tags its type (kill, plot, load, neutral).
GameState startTask(
GameState state, {
required String caption,
required int durationMillis,
TaskType taskType = TaskType.neutral,
}) {
final taskResult = pq_logic.startTask(
state.progress,
caption,
durationMillis,
);
final progress = taskResult.progress.copyWith(
currentTask: TaskInfo(caption: taskResult.caption, type: taskType),
);
return state.copyWith(progress: progress);
}
/// Tick the timer loop (equivalent to Timer1Timer in the original code).
ProgressTickResult tick(GameState state, int elapsedMillis) {
final int clamped = elapsedMillis.clamp(0, 100).toInt();
var progress = state.progress;
var queue = state.queue;
var nextState = state;
var leveledUp = false;
var questDone = false;
var actDone = false;
// Advance task bar if still running.
if (progress.task.position < progress.task.max) {
final uncapped = progress.task.position + clamped;
final int newTaskPos = uncapped > progress.task.max
? progress.task.max
: uncapped;
progress = progress.copyWith(
task: progress.task.copyWith(position: newTaskPos),
);
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress),
);
return ProgressTickResult(state: nextState);
}
final gain = progress.currentTask.type == TaskType.kill;
final incrementSeconds = progress.task.max ~/ 1000;
// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
if (gain) {
nextState = _winLoot(nextState);
progress = nextState.progress;
}
// 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649)
final taskType = progress.currentTask.type;
if (taskType == TaskType.buying) {
// 장비 구매 완료 (원본 631-634)
nextState = _completeBuying(nextState);
progress = nextState.progress;
} else if (taskType == TaskType.market || taskType == TaskType.sell) {
// 시장 도착 또는 판매 완료 (원본 635-649)
final sellResult = _processSell(nextState);
nextState = sellResult.state;
progress = nextState.progress;
queue = nextState.queue;
// 판매 중이면 다른 로직 건너뛰기
if (sellResult.continuesSelling) {
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: queue),
);
return ProgressTickResult(
state: nextState,
leveledUp: false,
completedQuest: false,
completedAct: false,
);
}
}
// Gain XP / level up.
if (gain) {
if (progress.exp.position >= progress.exp.max) {
nextState = _levelUp(nextState);
leveledUp = true;
progress = nextState.progress;
} else {
final uncappedExp = progress.exp.position + incrementSeconds;
final int newExpPos = uncappedExp > progress.exp.max
? progress.exp.max
: uncappedExp;
progress = progress.copyWith(
exp: progress.exp.copyWith(position: newExpPos),
);
}
}
// Advance quest bar after Act I.
final canQuestProgress =
gain &&
progress.plotStageCount > 1 &&
progress.questCount > 0 &&
progress.quest.max > 0;
if (canQuestProgress) {
if (progress.quest.position + incrementSeconds >= progress.quest.max) {
nextState = completeQuest(nextState);
questDone = true;
progress = nextState.progress;
queue = nextState.queue;
} else {
progress = progress.copyWith(
quest: progress.quest.copyWith(
position: progress.quest.position + incrementSeconds,
),
);
}
}
// 플롯(plot) 바가 완료되면 InterplotCinematic 트리거
// (원본 Main.pas:1301-1304)
if (gain &&
progress.plot.max > 0 &&
progress.plot.position >= progress.plot.max) {
// InterplotCinematic을 호출하여 시네마틱 이벤트 큐에 추가
final cinematicEntries = pq_logic.interplotCinematic(
config,
nextState.rng,
nextState.traits.level,
nextState.progress.plotStageCount,
);
queue = QueueState(entries: [...queue.entries, ...cinematicEntries]);
// 플롯 바를 0으로 리셋하지 않음 - completeAct에서 처리됨
} else if (progress.currentTask.type != TaskType.load &&
progress.plot.max > 0) {
final uncappedPlot = progress.plot.position + incrementSeconds;
final int newPlotPos = uncappedPlot > progress.plot.max
? progress.plot.max
: uncappedPlot;
progress = progress.copyWith(
plot: progress.plot.copyWith(position: newPlotPos),
);
}
// Dequeue next scripted task if available.
final dq = pq_logic.dequeue(progress, queue);
if (dq != null) {
progress = dq.progress.copyWith(
currentTask: TaskInfo(caption: dq.caption, type: dq.taskType),
);
queue = dq.queue;
// plot 타입이 dequeue 되면 completeAct 실행 (원본 Main.pas 로직)
if (dq.kind == QueueKind.plot) {
nextState = nextState.copyWith(progress: progress, queue: queue);
nextState = completeAct(nextState);
actDone = true;
progress = nextState.progress;
queue = nextState.queue;
}
} else {
// 큐가 비어있으면 새 태스크 생성 (원본 Dequeue 667-684줄)
nextState = nextState.copyWith(progress: progress, queue: queue);
final newTaskResult = _generateNextTask(nextState);
progress = newTaskResult.progress;
queue = newTaskResult.queue;
}
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: queue),
);
return ProgressTickResult(
state: nextState,
leveledUp: leveledUp,
completedQuest: questDone,
completedAct: actDone,
);
}
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
({ProgressState progress, QueueState queue}) _generateNextTask(
GameState state,
) {
var progress = state.progress;
final queue = state.queue;
final oldTaskType = progress.currentTask.type;
// 1. Encumbrance가 가득 찼으면 시장으로 이동 (원본 667-669줄)
if (progress.encumbrance.position >= progress.encumbrance.max &&
progress.encumbrance.max > 0) {
final taskResult = pq_logic.startTask(
progress,
'Heading to market to sell loot',
4 * 1000,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.market,
),
);
return (progress: progress, queue: queue);
}
// 2. kill 태스크가 아니었고 heading도 아니면 heading 또는 buying 태스크 실행
// (원본 670-677줄)
if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral) {
// Gold가 충분하면 장비 구매 (원본 671-673줄)
final gold = _getGold(state);
final equipPrice = _equipPrice(state.traits.level);
if (gold > equipPrice) {
final taskResult = pq_logic.startTask(
progress,
'Negotiating purchase of better equipment',
5 * 1000,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.buying,
),
);
return (progress: progress, queue: queue);
}
// Gold가 부족하면 전장으로 이동 (원본 674-676줄)
final taskResult = pq_logic.startTask(
progress,
'Heading to the killing fields',
4 * 1000,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.neutral,
),
);
return (progress: progress, queue: queue);
}
// 3. MonsterTask 실행 (원본 678-684줄)
final level = state.traits.level;
final monster = pq_logic.monsterTask(
config,
state.rng,
level,
null, // questMonster
null, // questLevel
);
// 태스크 지속시간 계산 (원본 682줄)
// n := (2 * InventoryLabelAlsoGameStyle.Tag * n * 1000) div l;
// InventoryLabelAlsoGameStyle.Tag는 게임 스타일을 나타내는 값 (1이 기본)
const gameStyleTag = 1;
final durationMillis = (2 * gameStyleTag * level * 1000) ~/ level;
final taskResult = pq_logic.startTask(
progress,
'Executing $monster',
durationMillis,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
),
);
return (progress: progress, queue: queue);
}
/// Advances quest completion, applies reward, and enqueues next quest task.
GameState completeQuest(GameState state) {
final result = pq_logic.completeQuest(
config,
state.rng,
state.traits.level,
);
var nextState = _applyReward(state, result.reward);
final questCount = nextState.progress.questCount + 1;
// Append quest entry to queue (task kind).
final updatedQueue = QueueState(
entries: [
...nextState.queue.entries,
QueueEntry(
kind: QueueKind.task,
durationMillis: 50 + nextState.rng.nextInt(100),
caption: result.caption,
taskType: TaskType.neutral,
),
],
);
// Update quest progress bar with reset position.
final progress = nextState.progress.copyWith(
quest: ProgressBarState(
position: 0,
max: 50 + nextState.rng.nextInt(100),
),
questCount: questCount,
);
return _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: updatedQueue),
);
}
/// Advances plot to next act and applies any act-level rewards.
GameState completeAct(GameState state) {
final actResult = pq_logic.completeAct(state.progress.plotStageCount);
var nextState = state;
for (final reward in actResult.rewards) {
nextState = _applyReward(nextState, reward);
}
final plotStages = nextState.progress.plotStageCount + 1;
var updatedProgress = nextState.progress.copyWith(
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
plotStageCount: plotStages,
);
nextState = nextState.copyWith(progress: updatedProgress);
// Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직)
// plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미
if (plotStages == 2) {
nextState = _startFirstQuest(nextState);
}
return _recalculateEncumbrance(nextState);
}
/// 첫 퀘스트 시작 (Act I 시작 시)
GameState _startFirstQuest(GameState state) {
final result = pq_logic.completeQuest(
config,
state.rng,
state.traits.level,
);
// 퀘스트 바 초기화
final questBar = ProgressBarState(
position: 0,
max: 50 + state.rng.nextInt(100),
);
// 첫 퀘스트 추가
final updatedQueue = QueueState(
entries: [
...state.queue.entries,
QueueEntry(
kind: QueueKind.task,
durationMillis: 50 + state.rng.nextInt(100),
caption: result.caption,
taskType: TaskType.neutral,
),
],
);
final progress = state.progress.copyWith(
quest: questBar,
questCount: 1,
);
return state.copyWith(progress: progress, queue: updatedQueue);
}
/// Developer-only cheat hooks for quickly finishing bars.
GameState forceTaskComplete(GameState state) {
final progress = state.progress.copyWith(
task: state.progress.task.copyWith(position: state.progress.task.max),
);
return state.copyWith(progress: progress);
}
GameState forceQuestComplete(GameState state) {
final progress = state.progress.copyWith(
task: state.progress.task.copyWith(position: state.progress.task.max),
quest: state.progress.quest.copyWith(position: state.progress.quest.max),
);
return state.copyWith(progress: progress);
}
GameState forcePlotComplete(GameState state) {
final progress = state.progress.copyWith(
task: state.progress.task.copyWith(position: state.progress.task.max),
plot: state.progress.plot.copyWith(position: state.progress.plot.max),
);
return state.copyWith(progress: progress);
}
GameState _applyReward(GameState state, pq_logic.RewardKind reward) {
final updated = rewards.applyReward(state, reward);
return _recalculateEncumbrance(updated);
}
GameState _levelUp(GameState state) {
final nextLevel = state.traits.level + 1;
final rng = state.rng;
final hpGain = state.stats.con ~/ 3 + 1 + rng.nextInt(4);
final mpGain = state.stats.intelligence ~/ 3 + 1 + rng.nextInt(4);
var nextState = state.copyWith(
traits: state.traits.copyWith(level: nextLevel),
stats: state.stats.copyWith(
hpMax: state.stats.hpMax + hpGain,
mpMax: state.stats.mpMax + mpGain,
),
);
// Win two stats and a spell, matching the original leveling rules.
nextState = mutations.winStat(nextState);
nextState = mutations.winStat(nextState);
nextState = mutations.winSpell(nextState, nextState.stats.wis, nextLevel);
final expBar = ProgressBarState(
position: 0,
max: pq_logic.levelUpTime(nextLevel),
);
final progress = nextState.progress.copyWith(exp: expBar);
nextState = nextState.copyWith(progress: progress);
return _recalculateEncumbrance(nextState);
}
GameState _recalculateEncumbrance(GameState state) {
// items에는 Gold가 포함되지 않음 (inventory.gold 필드로 관리)
final encumValue = state.inventory.items.fold<int>(
0,
(sum, item) => sum + item.count,
);
final encumMax = 10 + state.stats.str;
final encumBar = state.progress.encumbrance.copyWith(
position: encumValue,
max: encumMax,
);
final progress = state.progress.copyWith(encumbrance: encumBar);
return state.copyWith(progress: progress);
}
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
GameState _winLoot(GameState state) {
final taskCaption = state.progress.currentTask.caption;
// 몬스터 이름에서 전리품 아이템 생성
// 원본: 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';
// 인벤토리에 추가
final items = [...state.inventory.items];
final existing = items.indexWhere((e) => e.name == itemName);
if (existing >= 0) {
items[existing] = items[existing].copyWith(
count: items[existing].count + 1,
);
} else {
items.add(InventoryEntry(name: itemName, count: 1));
}
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();
}
/// 인벤토리에서 Gold 수량 반환
int _getGold(GameState state) {
return state.inventory.gold;
}
/// 장비 가격 계산 (원본 Main.pas:612-616)
/// Result := 5 * Level^2 + 10 * Level + 20
int _equipPrice(int level) {
return 5 * level * level + 10 * level + 20;
}
/// 장비 구매 완료 처리 (원본 Main.pas:631-634)
GameState _completeBuying(GameState state) {
final level = state.traits.level;
final price = _equipPrice(level);
// Gold 차감 (inventory.gold 필드 사용)
final newGold = math.max(0, state.inventory.gold - price);
var nextState = state.copyWith(
inventory: state.inventory.copyWith(gold: newGold),
);
// 장비 획득 (WinEquip)
nextState = mutations.winEquip(
nextState,
level,
EquipmentSlot.values[nextState.rng.nextInt(EquipmentSlot.values.length)],
);
return nextState;
}
/// 판매 처리 결과
({GameState state, bool continuesSelling}) _processSell(GameState state) {
final taskType = state.progress.currentTask.type;
var items = [...state.inventory.items];
var goldAmount = state.inventory.gold;
// sell 태스크 완료 시 아이템 판매 (원본 Main.pas:636-643)
if (taskType == TaskType.sell) {
// 첫 번째 아이템 찾기 (items에는 Gold가 없음)
if (items.isNotEmpty) {
final item = items.first;
final level = state.traits.level;
// 가격 계산: 수량 * 레벨
var price = item.count * level;
// " of " 포함 시 보너스 (원본 639-640)
if (item.name.contains(' of ')) {
price = price *
(1 + pq_logic.randomLow(state.rng, 10)) *
(1 + pq_logic.randomLow(state.rng, level));
}
// 아이템 삭제
items.removeAt(0);
// Gold 추가 (inventory.gold 필드 사용)
goldAmount += price;
}
}
// 판매할 아이템이 남아있는지 확인
final hasItemsToSell = items.isNotEmpty;
if (hasItemsToSell) {
// 다음 아이템 판매 태스크 시작
final nextItem = items.first;
final taskResult = pq_logic.startTask(
state.progress,
'Selling ${pq_logic.indefinite(nextItem.name, nextItem.count)}',
1 * 1000,
);
final progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.sell,
),
);
return (
state: state.copyWith(
inventory: state.inventory.copyWith(gold: goldAmount, items: items),
progress: progress,
),
continuesSelling: true,
);
}
// 판매 완료 - 인벤토리 업데이트만 하고 다음 태스크로
return (
state: state.copyWith(
inventory: state.inventory.copyWith(gold: goldAmount, items: items),
),
continuesSelling: false,
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart';
/// Applies quest/act rewards to the GameState using shared RNG.
class RewardService {
RewardService(this.mutations);
final GameMutations mutations;
GameState applyReward(GameState state, RewardKind reward) {
switch (reward) {
case RewardKind.spell:
return mutations.winSpell(state, state.stats.wis, state.traits.level);
case RewardKind.equip:
final slot = EquipmentSlot
.values[state.rng.nextInt(EquipmentSlot.values.length)];
return mutations.winEquip(state, state.traits.level, slot);
case RewardKind.stat:
return mutations.winStat(state);
case RewardKind.item:
return mutations.winItem(state);
}
}
}

View File

@@ -0,0 +1 @@
enum EquipmentSlot { weapon, shield, armor }

View File

@@ -0,0 +1,389 @@
import 'dart:collection';
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,
}) : 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();
factory GameState.withSeed({
required int seed,
Traits? traits,
Stats? stats,
Inventory? inventory,
Equipment? equipment,
SpellBook? spellBook,
ProgressState? progress,
QueueState? queue,
}) {
return GameState(
rng: DeterministicRandom(seed),
traits: traits,
stats: stats,
inventory: inventory,
equipment: equipment,
spellBook: spellBook,
progress: progress,
queue: queue,
);
}
final DeterministicRandom rng;
final Traits traits;
final Stats stats;
final Inventory inventory;
final Equipment equipment;
final SpellBook spellBook;
final ProgressState progress;
final QueueState queue;
GameState copyWith({
DeterministicRandom? rng,
Traits? traits,
Stats? stats,
Inventory? inventory,
Equipment? equipment,
SpellBook? spellBook,
ProgressState? progress,
QueueState? queue,
}) {
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,
);
}
}
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
enum TaskType {
neutral, // heading 등 일반 이동
kill, // 몬스터 처치
load, // 로딩/초기화
plot, // 플롯 진행
market, // 시장으로 이동 중
sell, // 아이템 판매 중
buying, // 장비 구매 중
}
class TaskInfo {
const TaskInfo({required this.caption, required this.type});
final String caption;
final TaskType type;
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);
}
}
class Traits {
const Traits({
required this.name,
required this.race,
required this.klass,
required this.level,
required this.motto,
required this.guild,
});
final String name;
final String race;
final String klass;
final int level;
final String motto;
final String guild;
factory Traits.empty() => const Traits(
name: '',
race: '',
klass: '',
level: 1,
motto: '',
guild: '',
);
Traits copyWith({
String? name,
String? race,
String? klass,
int? level,
String? motto,
String? guild,
}) {
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,
);
}
}
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,
});
final int str;
final int con;
final int dex;
final int intelligence;
final int wis;
final int cha;
final int hpMax;
final int 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,
}) {
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,
);
}
}
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);
}
}
class Equipment {
const Equipment({
required this.weapon,
required this.shield,
required this.armor,
required this.bestIndex,
});
final String weapon;
final String shield;
final String armor;
/// Tracks best slot index (mirror of Equips.Tag in original code; 0=weapon,1=shield,2=armor).
final int bestIndex;
factory Equipment.empty() => const Equipment(
weapon: 'Sharp Stick',
shield: '',
armor: '',
bestIndex: 0,
);
Equipment copyWith({
String? weapon,
String? shield,
String? armor,
int? bestIndex,
}) {
return Equipment(
weapon: weapon ?? this.weapon,
shield: shield ?? this.shield,
armor: armor ?? this.armor,
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,
);
}
}
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,
});
final ProgressBarState task;
final ProgressBarState quest;
final ProgressBarState plot;
final ProgressBarState exp;
final ProgressBarState encumbrance;
final TaskInfo currentTask;
final int plotStageCount;
final int questCount;
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,
);
ProgressState copyWith({
ProgressBarState? task,
ProgressBarState? quest,
ProgressBarState? plot,
ProgressBarState? exp,
ProgressBarState? encumbrance,
TaskInfo? currentTask,
int? plotStageCount,
int? questCount,
}) {
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,
);
}
}
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));
}
}

View File

@@ -0,0 +1,31 @@
import 'package:askiineverdie/data/pq_config_data.dart';
/// Typed accessors for Progress Quest static data extracted from Config.dfm.
class PqConfig {
const PqConfig();
List<String> get spells => _copy('Spells');
List<String> get offenseAttrib => _copy('OffenseAttrib');
List<String> get defenseAttrib => _copy('DefenseAttrib');
List<String> get offenseBad => _copy('OffenseBad');
List<String> get defenseBad => _copy('DefenseBad');
List<String> get shields => _copy('Shields');
List<String> get armors => _copy('Armors');
List<String> get weapons => _copy('Weapons');
List<String> get specials => _copy('Specials');
List<String> get itemAttrib => _copy('ItemAttrib');
List<String> get itemOfs => _copy('ItemOfs');
List<String> get boringItems => _copy('BoringItems');
List<String> get monsters => _copy('Monsters');
List<String> get monMods => _copy('MonMods');
List<String> get races => _copy('Races');
List<String> get klasses => _copy('Klasses');
List<String> get titles => _copy('Titles');
List<String> get impressiveTitles => _copy('ImpressiveTitles');
List<String> _copy(String key) {
final values = pqConfigData[key];
if (values == null) return const [];
return List<String>.from(values);
}
}

View File

@@ -0,0 +1,237 @@
import 'dart:collection';
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
const int kSaveVersion = 2;
class GameSave {
GameSave({
required this.version,
required this.rngState,
required this.traits,
required this.stats,
required this.inventory,
required this.equipment,
required this.spellBook,
required this.progress,
required this.queue,
});
factory GameSave.fromState(GameState state) {
return GameSave(
version: kSaveVersion,
rngState: state.rng.state,
traits: state.traits,
stats: state.stats,
inventory: state.inventory,
equipment: state.equipment,
spellBook: state.spellBook,
progress: state.progress,
queue: state.queue,
);
}
final int version;
final int rngState;
final Traits traits;
final Stats stats;
final Inventory inventory;
final Equipment equipment;
final SpellBook spellBook;
final ProgressState progress;
final QueueState queue;
Map<String, dynamic> toJson() {
return {
'version': version,
'rng': rngState,
'traits': {
'name': traits.name,
'race': traits.race,
'klass': traits.klass,
'level': traits.level,
'motto': traits.motto,
'guild': traits.guild,
},
'stats': {
'str': stats.str,
'con': stats.con,
'dex': stats.dex,
'int': stats.intelligence,
'wis': stats.wis,
'cha': stats.cha,
'hpMax': stats.hpMax,
'mpMax': stats.mpMax,
},
'inventory': {
'gold': inventory.gold,
'items': inventory.items
.map((e) => {'name': e.name, 'count': e.count})
.toList(),
},
'equipment': {
'weapon': equipment.weapon,
'shield': equipment.shield,
'armor': equipment.armor,
'bestIndex': equipment.bestIndex,
},
'spells': spellBook.spells
.map((e) => {'name': e.name, 'rank': e.rank})
.toList(),
'progress': {
'task': _barToJson(progress.task),
'quest': _barToJson(progress.quest),
'plot': _barToJson(progress.plot),
'exp': _barToJson(progress.exp),
'encumbrance': _barToJson(progress.encumbrance),
'taskInfo': {
'caption': progress.currentTask.caption,
'type': progress.currentTask.type.name,
},
'plotStages': progress.plotStageCount,
'questCount': progress.questCount,
},
'queue': queue.entries
.map(
(e) => {
'kind': e.kind.name,
'duration': e.durationMillis,
'caption': e.caption,
'taskType': e.taskType.name,
},
)
.toList(),
};
}
static GameSave fromJson(Map<String, dynamic> json) {
final traitsJson = json['traits'] as Map<String, dynamic>;
final statsJson = json['stats'] as Map<String, dynamic>;
final inventoryJson = json['inventory'] as Map<String, dynamic>;
final equipmentJson = json['equipment'] as Map<String, dynamic>;
final progressJson = json['progress'] as Map<String, dynamic>;
final queueJson = (json['queue'] as List<dynamic>? ?? []).cast<dynamic>();
final spellsJson = (json['spells'] as List<dynamic>? ?? []).cast<dynamic>();
return GameSave(
version: json['version'] as int? ?? kSaveVersion,
rngState: json['rng'] as int? ?? 0,
traits: Traits(
name: traitsJson['name'] as String? ?? '',
race: traitsJson['race'] as String? ?? '',
klass: traitsJson['klass'] as String? ?? '',
level: traitsJson['level'] as int? ?? 1,
motto: traitsJson['motto'] as String? ?? '',
guild: traitsJson['guild'] as String? ?? '',
),
stats: Stats(
str: statsJson['str'] as int? ?? 0,
con: statsJson['con'] as int? ?? 0,
dex: statsJson['dex'] as int? ?? 0,
intelligence: statsJson['int'] as int? ?? 0,
wis: statsJson['wis'] as int? ?? 0,
cha: statsJson['cha'] as int? ?? 0,
hpMax: statsJson['hpMax'] as int? ?? 0,
mpMax: statsJson['mpMax'] as int? ?? 0,
),
inventory: Inventory(
gold: inventoryJson['gold'] as int? ?? 0,
items: (inventoryJson['items'] as List<dynamic>? ?? [])
.map(
(e) => InventoryEntry(
name: (e as Map<String, dynamic>)['name'] as String? ?? '',
count: (e)['count'] as int? ?? 0,
),
)
.toList(),
),
equipment: Equipment(
weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick',
shield: equipmentJson['shield'] as String? ?? '',
armor: equipmentJson['armor'] as String? ?? '',
bestIndex: equipmentJson['bestIndex'] as int? ?? 0,
),
spellBook: SpellBook(
spells: spellsJson
.map(
(e) => SpellEntry(
name: (e as Map<String, dynamic>)['name'] as String? ?? '',
rank: (e)['rank'] as String? ?? 'I',
),
)
.toList(),
),
progress: ProgressState(
task: _barFromJson(progressJson['task'] as Map<String, dynamic>? ?? {}),
quest: _barFromJson(
progressJson['quest'] as Map<String, dynamic>? ?? {},
),
plot: _barFromJson(progressJson['plot'] as Map<String, dynamic>? ?? {}),
exp: _barFromJson(progressJson['exp'] as Map<String, dynamic>? ?? {}),
encumbrance: _barFromJson(
progressJson['encumbrance'] as Map<String, dynamic>? ?? {},
),
currentTask: _taskInfoFromJson(
progressJson['taskInfo'] as Map<String, dynamic>? ??
<String, dynamic>{},
),
plotStageCount: progressJson['plotStages'] as int? ?? 1,
questCount: progressJson['questCount'] as int? ?? 0,
),
queue: QueueState(
entries: Queue<QueueEntry>.from(
queueJson.map((e) {
final m = e as Map<String, dynamic>;
final kind = QueueKind.values.firstWhere(
(k) => k.name == m['kind'],
orElse: () => QueueKind.task,
);
final taskType = TaskType.values.firstWhere(
(t) => t.name == m['taskType'],
orElse: () => TaskType.neutral,
);
return QueueEntry(
kind: kind,
durationMillis: m['duration'] as int? ?? 0,
caption: m['caption'] as String? ?? '',
taskType: taskType,
);
}),
),
),
);
}
GameState toState() {
return GameState(
rng: DeterministicRandom.fromState(rngState),
traits: traits,
stats: stats,
inventory: inventory,
equipment: equipment,
spellBook: spellBook,
progress: progress,
queue: queue,
);
}
}
Map<String, dynamic> _barToJson(ProgressBarState bar) => {
'pos': bar.position,
'max': bar.max,
};
ProgressBarState _barFromJson(Map<String, dynamic> json) => ProgressBarState(
position: json['pos'] as int? ?? 0,
max: json['max'] as int? ?? 1,
);
TaskInfo _taskInfoFromJson(Map<String, dynamic> json) {
final typeName = json['type'] as String?;
final type = TaskType.values.firstWhere(
(t) => t.name == typeName,
orElse: () => TaskType.neutral,
);
return TaskInfo(caption: json['caption'] as String? ?? '', type: type);
}

View File

@@ -0,0 +1,33 @@
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/save_data.dart';
import 'package:askiineverdie/src/core/storage/save_repository.dart';
import 'package:askiineverdie/src/core/storage/save_service.dart'
show SaveFileInfo;
/// Coordinates saving/loading GameState using SaveRepository.
class SaveManager {
SaveManager(this._repo);
final SaveRepository _repo;
static const String defaultFileName = 'progress.pqf';
/// Save current game state to disk. [fileName] may be absolute or relative.
/// Returns outcome with error on failure.
Future<SaveOutcome> saveState(GameState state, {String? fileName}) {
final save = GameSave.fromState(state);
return _repo.save(save, fileName ?? defaultFileName);
}
/// Load game state from disk. [fileName] may be absolute (e.g., file picker).
/// Returns outcome + optional state.
Future<(SaveOutcome, GameState?)> loadState({String? fileName}) async {
final (outcome, save) = await _repo.load(fileName ?? defaultFileName);
if (!outcome.success || save == null) {
return (outcome, null);
}
return (outcome, save.toState());
}
/// 저장 파일 목록 조회
Future<List<SaveFileInfo>> listSaves() => _repo.listSaves();
}

View File

@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:askiineverdie/src/core/model/save_data.dart';
import 'package:askiineverdie/src/core/storage/save_service.dart';
import 'package:path_provider/path_provider.dart';
class SaveOutcome {
const SaveOutcome.success([this.error]) : success = true;
const SaveOutcome.failure(this.error) : success = false;
final bool success;
final String? error;
}
/// High-level save/load wrapper that resolves platform storage paths.
class SaveRepository {
SaveRepository() : _service = null;
SaveService? _service;
Future<void> _ensureService() async {
if (_service != null) return;
final dir = await getApplicationSupportDirectory();
_service = SaveService(baseDir: dir);
}
Future<SaveOutcome> save(GameSave save, String fileName) async {
try {
await _ensureService();
await _service!.save(save, fileName);
return const SaveOutcome.success();
} on FileSystemException catch (e) {
final reason = e.osError?.message ?? e.message;
return SaveOutcome.failure('Unable to save file: $reason');
} catch (e) {
return SaveOutcome.failure(e.toString());
}
}
Future<(SaveOutcome, GameSave?)> load(String fileName) async {
try {
await _ensureService();
final data = await _service!.load(fileName);
return (const SaveOutcome.success(), data);
} on FileSystemException catch (e) {
final reason = e.osError?.message ?? e.message;
return (SaveOutcome.failure('Unable to load save: $reason'), null);
} on FormatException catch (e) {
return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null);
} catch (e) {
return (SaveOutcome.failure(e.toString()), null);
}
}
/// 저장 파일 목록 조회
Future<List<SaveFileInfo>> listSaves() async {
try {
await _ensureService();
return await _service!.listSaves();
} catch (e) {
return [];
}
}
}

View File

@@ -0,0 +1,84 @@
import 'dart:convert';
import 'dart:io';
import 'package:askiineverdie/src/core/model/save_data.dart';
/// Persists GameSave as JSON compressed with GZipCodec.
class SaveService {
SaveService({required this.baseDir});
final Directory baseDir;
final GZipCodec _gzip = GZipCodec();
Future<File> save(GameSave save, String fileName) async {
final path = _resolvePath(fileName);
final file = File(path);
await file.parent.create(recursive: true);
final jsonStr = jsonEncode(save.toJson());
final bytes = utf8.encode(jsonStr);
final compressed = _gzip.encode(bytes);
return file.writeAsBytes(compressed);
}
Future<GameSave> load(String fileName) async {
final path = _resolvePath(fileName);
final file = File(path);
final compressed = await file.readAsBytes();
final decompressed = _gzip.decode(compressed);
final jsonStr = utf8.decode(decompressed);
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
return GameSave.fromJson(map);
}
String _resolvePath(String fileName) {
final normalized = fileName.endsWith('.pqf') ? fileName : '$fileName.pqf';
final file = File(normalized);
if (file.isAbsolute) return file.path;
return '${baseDir.path}/$normalized';
}
/// 저장 디렉토리의 모든 .pqf 파일 목록 반환
Future<List<SaveFileInfo>> listSaves() async {
if (!await baseDir.exists()) {
return [];
}
final files = <SaveFileInfo>[];
await for (final entity in baseDir.list()) {
if (entity is File && entity.path.endsWith('.pqf')) {
final stat = await entity.stat();
final name = entity.uri.pathSegments.last;
files.add(
SaveFileInfo(
fileName: name,
fullPath: entity.path,
modifiedAt: stat.modified,
sizeBytes: stat.size,
),
);
}
}
// 최근 수정된 파일 순으로 정렬
files.sort((a, b) => b.modifiedAt.compareTo(a.modifiedAt));
return files;
}
}
/// 저장 파일 정보
class SaveFileInfo {
const SaveFileInfo({
required this.fileName,
required this.fullPath,
required this.modifiedAt,
required this.sizeBytes,
});
final String fileName;
final String fullPath;
final DateTime modifiedAt;
final int sizeBytes;
/// 확장자 없는 표시용 이름
String get displayName => fileName.replaceAll('.pqf', '');
}

View File

@@ -0,0 +1,38 @@
/// Simple deterministic RNG (xorshift32) with serializable state.
class DeterministicRandom {
DeterministicRandom(int seed) : _state = seed & _mask;
DeterministicRandom.clone(DeterministicRandom other)
: _state = other._state & _mask;
DeterministicRandom.fromState(int state) : _state = state & _mask;
static const int _mask = 0xFFFFFFFF;
int _state;
int get state => _state;
/// Returns next unsigned 32-bit value.
int nextUint32() {
var x = _state;
x ^= (x << 13) & _mask;
x ^= (x >> 17) & _mask;
x ^= (x << 5) & _mask;
_state = x & _mask;
return _state;
}
int nextInt(int maxExclusive) {
if (maxExclusive <= 0) {
throw ArgumentError.value(maxExclusive, 'maxExclusive', 'must be > 0');
}
return nextUint32() % maxExclusive;
}
double nextDouble() {
// 2^32 as double.
const double denom = 4294967296.0;
return nextUint32() / denom;
}
}

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;
}

View File

@@ -0,0 +1,83 @@
const _romanMap = <String, int>{
'T': 10000,
'A': 5000,
'P': 100000,
'E': 100000, // not used but kept for completeness
'M': 1000,
'D': 500,
'C': 100,
'L': 50,
'X': 10,
'V': 5,
'I': 1,
};
String intToRoman(int n) {
final buffer = StringBuffer();
void emit(int value, String numeral) {
while (n >= value) {
buffer.write(numeral);
n -= value;
}
}
emit(10000, 'T');
if (n >= 9000) {
buffer.write('MT');
n -= 9000;
}
if (n >= 5000) {
buffer.write('A');
n -= 5000;
}
if (n >= 4000) {
buffer.write('MA');
n -= 4000;
}
emit(1000, 'M');
_subtract(ref: n, target: 900, numeral: 'CM', buffer: buffer);
_subtract(ref: n, target: 500, numeral: 'D', buffer: buffer);
_subtract(ref: n, target: 400, numeral: 'CD', buffer: buffer);
emit(100, 'C');
_subtract(ref: n, target: 90, numeral: 'XC', buffer: buffer);
_subtract(ref: n, target: 50, numeral: 'L', buffer: buffer);
_subtract(ref: n, target: 40, numeral: 'XL', buffer: buffer);
emit(10, 'X');
_subtract(ref: n, target: 9, numeral: 'IX', buffer: buffer);
_subtract(ref: n, target: 5, numeral: 'V', buffer: buffer);
_subtract(ref: n, target: 4, numeral: 'IV', buffer: buffer);
emit(1, 'I');
return buffer.toString();
}
void _subtract({
required int ref,
required int target,
required String numeral,
required StringBuffer buffer,
}) {
if (ref >= target) {
buffer.write(numeral);
ref -= target;
}
}
int romanToInt(String n) {
var result = 0;
var i = 0;
while (i < n.length) {
final one = _romanMap[n[i]] ?? 0;
final two = i + 1 < n.length ? _romanMap[n[i + 1]] ?? 0 : 0;
if (two > one) {
result += (two - one);
i += 2;
} else {
result += one;
i += 1;
}
}
return result;
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
class FrontScreen extends StatelessWidget {
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
/// "New character" 버튼 클릭 시 호출
final void Function(BuildContext context)? onNewCharacter;
/// "Load save" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onLoadSave;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colorScheme.surfaceContainerHighest, colorScheme.surface],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_HeroHeader(theme: theme, colorScheme: colorScheme),
const SizedBox(height: 24),
_ActionRow(
onNewCharacter: onNewCharacter != null
? () => onNewCharacter!(context)
: () => _showPlaceholder(context),
onLoadSave: onLoadSave != null
? () => onLoadSave!(context)
: () => _showPlaceholder(context),
),
const SizedBox(height: 24),
const _StatusCards(),
],
),
),
),
),
),
),
);
}
}
void _showPlaceholder(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.',
),
),
);
}
class _HeroHeader extends StatelessWidget {
const _HeroHeader({required this.theme, required this.colorScheme});
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
colors: [
colorScheme.primary.withValues(alpha: 0.9),
colorScheme.primaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ascii Never Die',
style: theme.textTheme.headlineSmall?.copyWith(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Offline Progress Quest (PQ 6.4) rebuilt with Flutter.',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onPrimary.withValues(alpha: 0.9),
),
),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
_Tag(icon: Icons.cloud_off_outlined, label: 'No network'),
_Tag(icon: Icons.timer_outlined, label: 'Idle RPG loop'),
_Tag(icon: Icons.storage_rounded, label: 'Local saves'),
],
),
],
),
),
);
}
}
class _ActionRow extends StatelessWidget {
const _ActionRow({required this.onNewCharacter, required this.onLoadSave});
final VoidCallback onNewCharacter;
final VoidCallback onLoadSave;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: onNewCharacter,
icon: const Icon(Icons.casino_outlined),
label: const Text('New character'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
textStyle: theme.textTheme.titleMedium,
),
),
OutlinedButton.icon(
onPressed: onLoadSave,
icon: const Icon(Icons.folder_open),
label: const Text('Load save'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
textStyle: theme.textTheme.titleMedium,
),
),
TextButton.icon(
onPressed: () => _showPlaceholder(context),
icon: const Icon(Icons.menu_book_outlined),
label: const Text('View build plan'),
),
],
);
}
}
class _StatusCards extends StatelessWidget {
const _StatusCards();
@override
Widget build(BuildContext context) {
return Column(
children: const [
_InfoCard(
icon: Icons.route_outlined,
title: 'Build roadmap',
points: [
'Port PQ 6.4 data set (Config.dfm) into Dart constants.',
'Recreate quest/task loop with deterministic RNG + saves.',
'Deliver offline-first storage (GZip JSON) across platforms.',
],
),
SizedBox(height: 16),
_InfoCard(
icon: Icons.auto_fix_high_outlined,
title: 'Tech stack',
points: [
'Flutter (Material 3) with multiplatform targets enabled.',
'path_provider + shared_preferences for local storage hooks.',
'Strict lints with package imports enforced from day one.',
],
),
SizedBox(height: 16),
_InfoCard(
icon: Icons.checklist_rtl,
title: 'Todays focus',
points: [
'Set up scaffold + lints.',
'Wire seed theme and initial navigation shell.',
'Keep reference assets under example/pq for parity.',
],
),
],
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({required this.title, required this.points, this.icon});
final String title;
final List<String> points;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 3,
shadowColor: colorScheme.shadow.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, color: colorScheme.primary),
const SizedBox(width: 10),
],
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
...points.map(
(point) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 3),
child: Icon(Icons.check_circle_outline, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Text(point, style: theme.textTheme.bodyMedium),
),
],
),
),
),
],
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Chip(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.14),
avatar: Icon(icon, color: colorScheme.onPrimary, size: 16),
label: Text(
label,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w600,
),
),
side: BorderSide.none,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:askiineverdie/src/core/storage/save_service.dart'
show SaveFileInfo;
/// 저장 파일 선택 다이얼로그
/// 선택된 파일명을 반환하거나, 취소 시 null 반환
class SavePickerDialog extends StatelessWidget {
const SavePickerDialog({super.key, required this.saves});
final List<SaveFileInfo> saves;
/// 다이얼로그 표시 및 결과 반환
static Future<String?> show(
BuildContext context,
List<SaveFileInfo> saves,
) async {
if (saves.isEmpty) {
// 저장 파일이 없으면 안내 메시지
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
return null;
}
return showDialog<String>(
context: context,
builder: (context) => SavePickerDialog(saves: saves),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AlertDialog(
title: Row(
children: [
Icon(Icons.folder_open, color: colorScheme.primary),
const SizedBox(width: 12),
const Text('Load Game'),
],
),
content: SizedBox(
width: 400,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
shrinkWrap: true,
itemCount: saves.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final save = saves[index];
return _SaveListTile(
save: save,
onTap: () => Navigator.of(context).pop(save.fileName),
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
],
);
}
}
class _SaveListTile extends StatelessWidget {
const _SaveListTile({required this.save, required this.onTap});
final SaveFileInfo save;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
return ListTile(
leading: const Icon(Icons.save),
title: Text(
save.displayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'${dateFormat.format(save.modifiedAt)} · ${_formatSize(save.sizeBytes)}',
style: theme.textTheme.bodySmall,
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}

View File

@@ -0,0 +1,654 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
class GamePlayScreen extends StatefulWidget {
const GamePlayScreen({super.key, required this.controller});
final GameSessionController controller;
@override
State<GamePlayScreen> createState() => _GamePlayScreenState();
}
class _GamePlayScreenState extends State<GamePlayScreen>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
widget.controller.removeListener(_onControllerChanged);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// 앱이 백그라운드로 가거나 비활성화될 때 자동 저장
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
_saveGameState();
}
}
Future<void> _saveGameState() async {
final currentState = widget.controller.state;
if (currentState == null || !widget.controller.isRunning) return;
await widget.controller.saveManager.saveState(currentState);
}
/// 뒤로가기 시 저장 확인 다이얼로그
Future<bool> _onPopInvoked() async {
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit Game'),
content: const Text('Save your progress before leaving?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Exit without saving'),
),
FilledButton(
onPressed: () async {
await _saveGameState();
if (context.mounted) {
Navigator.of(context).pop(true);
}
},
child: const Text('Save and Exit'),
),
],
),
);
return shouldPop ?? false;
}
void _onControllerChanged() {
setState(() {});
}
@override
Widget build(BuildContext context) {
final state = widget.controller.state;
if (state == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onPopInvoked();
if (shouldPop && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
child: Scaffold(
appBar: AppBar(
title: Text('Progress Quest - ${state.traits.name}'),
actions: [
// 치트 버튼 (디버그용)
if (widget.controller.cheatsEnabled) ...[
IconButton(
icon: const Text('L+1'),
tooltip: 'Level Up',
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
),
IconButton(
icon: const Text('Q!'),
tooltip: 'Complete Quest',
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
),
IconButton(
icon: const Text('P!'),
tooltip: 'Complete Plot',
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
),
],
],
),
body: Column(
children: [
// 메인 3패널 영역
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측 패널: Character Sheet
Expanded(flex: 2, child: _buildCharacterPanel(state)),
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
),
),
// 하단: Task Progress
_buildBottomPanel(state),
],
),
),
);
}
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
Widget _buildCharacterPanel(GameState state) {
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Character Sheet'),
// Traits 목록
_buildSectionHeader('Traits'),
_buildTraitsList(state),
// Stats 목록
_buildSectionHeader('Stats'),
Expanded(flex: 2, child: _buildStatsList(state)),
// Experience 바
_buildSectionHeader('Experience'),
_buildProgressBar(
state.progress.exp.position,
state.progress.exp.max,
Colors.blue,
tooltip:
'${state.progress.exp.max - state.progress.exp.position} '
'XP needed for next level',
),
// Spell Book
_buildSectionHeader('Spell Book'),
Expanded(flex: 2, child: _buildSpellsList(state)),
],
),
);
}
/// 중앙 패널: Equipment/Inventory
Widget _buildEquipmentPanel(GameState state) {
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Equipment'),
// Equipment 목록
Expanded(flex: 2, child: _buildEquipmentList(state)),
// Inventory
_buildPanelHeader('Inventory'),
Expanded(flex: 3, child: _buildInventoryList(state)),
// Encumbrance 바
_buildSectionHeader('Encumbrance'),
_buildProgressBar(
state.progress.encumbrance.position,
state.progress.encumbrance.max,
Colors.orange,
),
],
),
);
}
/// 우측 패널: Plot/Quest
Widget _buildQuestPanel(GameState state) {
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Plot Development'),
// Plot 목록
Expanded(child: _buildPlotList(state)),
// Plot 바
_buildProgressBar(
state.progress.plot.position,
state.progress.plot.max,
Colors.purple,
tooltip: state.progress.plot.max > 0
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
: null,
),
_buildPanelHeader('Quests'),
// Quest 목록
Expanded(child: _buildQuestList(state)),
// Quest 바
_buildProgressBar(
state.progress.quest.position,
state.progress.quest.max,
Colors.green,
tooltip: state.progress.quest.max > 0
? '${(100 * state.progress.quest.position ~/ state.progress.quest.max)}% complete'
: null,
),
],
),
);
}
/// 하단 패널: Task Progress + Status
Widget _buildBottomPanel(GameState state) {
final speed = widget.controller.loop?.speedMultiplier ?? 1;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 상태 메시지 + 배속 버튼
Row(
children: [
Expanded(
child: Text(
state.progress.currentTask.caption.isNotEmpty
? state.progress.currentTask.caption
: 'Welcome to Progress Quest!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
// 배속 버튼
SizedBox(
height: 28,
child: OutlinedButton(
onPressed: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
child: Text(
'${speed}x',
style: TextStyle(
fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal,
color: speed > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
),
],
),
const SizedBox(height: 4),
// Task Progress 바
_buildProgressBar(
state.progress.task.position,
state.progress.task.max,
Theme.of(context).colorScheme.primary,
),
],
),
);
}
Widget _buildPanelHeader(String title) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: Theme.of(context).colorScheme.primaryContainer,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Text(title, style: Theme.of(context).textTheme.labelSmall),
);
}
Widget _buildProgressBar(
int position,
int max,
Color color, {
String? tooltip,
}) {
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
final bar = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: color.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 12,
),
);
if (tooltip != null && tooltip.isNotEmpty) {
return Tooltip(message: tooltip, child: bar);
}
return bar;
}
Widget _buildTraitsList(GameState state) {
final traits = [
('Name', state.traits.name),
('Race', state.traits.race),
('Class', state.traits.klass),
('Level', '${state.traits.level}'),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: traits.map((t) {
return Row(
children: [
SizedBox(
width: 50,
child: Text(t.$1, style: const TextStyle(fontSize: 11)),
),
Expanded(
child: Text(
t.$2,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}).toList(),
),
);
}
Widget _buildStatsList(GameState state) {
final stats = [
('STR', state.stats.str),
('CON', state.stats.con),
('DEX', state.stats.dex),
('INT', state.stats.intelligence),
('WIS', state.stats.wis),
('CHA', state.stats.cha),
('HP Max', state.stats.hpMax),
('MP Max', state.stats.mpMax),
];
return ListView.builder(
itemCount: stats.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final stat = stats[index];
return Row(
children: [
SizedBox(
width: 50,
child: Text(stat.$1, style: const TextStyle(fontSize: 11)),
),
Text(
'${stat.$2}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildSpellsList(GameState state) {
if (state.spellBook.spells.isEmpty) {
return const Center(
child: Text('No spells yet', style: TextStyle(fontSize: 11)),
);
}
return ListView.builder(
itemCount: state.spellBook.spells.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final spell = state.spellBook.spells[index];
return Row(
children: [
Expanded(
child: Text(
spell.name,
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
Text(
spell.rank,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildEquipmentList(GameState state) {
// 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현
final equipment = [
('Weapon', state.equipment.weapon),
('Shield', state.equipment.shield),
('Armor', state.equipment.armor),
];
return ListView.builder(
itemCount: equipment.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final equip = equipment[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 60,
child: Text(equip.$1, style: const TextStyle(fontSize: 11)),
),
Expanded(
child: Text(
equip.$2.isNotEmpty ? equip.$2 : '-',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
);
}
Widget _buildInventoryList(GameState state) {
if (state.inventory.items.isEmpty) {
return Center(
child: Text(
'Gold: ${state.inventory.gold}',
style: const TextStyle(fontSize: 11),
),
);
}
return ListView.builder(
itemCount: state.inventory.items.length + 1, // +1 for gold
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
if (index == 0) {
return Row(
children: [
const Expanded(
child: Text('Gold', style: TextStyle(fontSize: 11)),
),
Text(
'${state.inventory.gold}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
);
}
final item = state.inventory.items[index - 1];
return Row(
children: [
Expanded(
child: Text(
item.name,
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
Text(
'${item.count}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
],
);
},
);
}
Widget _buildPlotList(GameState state) {
// 플롯 단계를 표시 (Act I, Act II, ...)
final plotCount = state.progress.plotStageCount;
if (plotCount == 0) {
return const Center(
child: Text('Prologue', style: TextStyle(fontSize: 11)),
);
}
return ListView.builder(
itemCount: plotCount,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final isCompleted = index < plotCount - 1;
return Row(
children: [
Icon(
isCompleted ? Icons.check_box : Icons.check_box_outline_blank,
size: 14,
color: isCompleted ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
index == 0 ? 'Prologue' : 'Act ${_toRoman(index)}',
style: TextStyle(
fontSize: 11,
decoration: isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
],
);
},
);
}
Widget _buildQuestList(GameState state) {
final questCount = state.progress.questCount;
if (questCount == 0) {
return const Center(
child: Text('No active quests', style: TextStyle(fontSize: 11)),
);
}
// 현재 퀘스트 캡션이 있으면 표시
final currentTask = state.progress.currentTask;
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
Row(
children: [
const Icon(Icons.arrow_right, size: 14),
Expanded(
child: Text(
currentTask.caption.isNotEmpty
? currentTask.caption
: 'Quest #$questCount',
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
);
}
/// 로마 숫자 변환 (간단 버전)
String _toRoman(int number) {
const romanNumerals = [
(1000, 'M'),
(900, 'CM'),
(500, 'D'),
(400, 'CD'),
(100, 'C'),
(90, 'XC'),
(50, 'L'),
(40, 'XL'),
(10, 'X'),
(9, 'IX'),
(5, 'V'),
(4, 'IV'),
(1, 'I'),
];
var result = '';
var remaining = number;
for (final (value, numeral) in romanNumerals) {
while (remaining >= value) {
result += numeral;
remaining -= value;
}
}
return result;
}
}

View File

@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:askiineverdie/src/core/engine/progress_loop.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/storage/save_manager.dart';
import 'package:flutter/foundation.dart';
enum GameSessionStatus { idle, loading, running, error }
/// Presentation-friendly wrapper that owns ProgressLoop and SaveManager.
class GameSessionController extends ChangeNotifier {
GameSessionController({
required this.progressService,
required this.saveManager,
this.autoSaveConfig = const AutoSaveConfig(),
Duration tickInterval = const Duration(milliseconds: 50),
DateTime Function()? now,
}) : _tickInterval = tickInterval,
_now = now ?? DateTime.now;
final ProgressService progressService;
final SaveManager saveManager;
final AutoSaveConfig autoSaveConfig;
final Duration _tickInterval;
final DateTime Function() _now;
ProgressLoop? _loop;
StreamSubscription<GameState>? _subscription;
bool _cheatsEnabled = false;
GameSessionStatus _status = GameSessionStatus.idle;
GameState? _state;
String? _error;
GameSessionStatus get status => _status;
GameState? get state => _state;
String? get error => _error;
bool get isRunning => _status == GameSessionStatus.running;
bool get cheatsEnabled => _cheatsEnabled;
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
ProgressLoop? get loop => _loop;
Future<void> startNew(
GameState initialState, {
bool cheatsEnabled = false,
bool isNewGame = true,
}) async {
await _stopLoop(saveOnStop: false);
// 새 게임인 경우 초기화 (프롤로그 태스크 설정)
final state = isNewGame
? progressService.initializeNewGame(initialState)
: initialState;
_state = state;
_error = null;
_status = GameSessionStatus.running;
_cheatsEnabled = cheatsEnabled;
_loop = ProgressLoop(
initialState: state,
progressService: progressService,
saveManager: saveManager,
autoSaveConfig: autoSaveConfig,
tickInterval: _tickInterval,
now: _now,
cheatsEnabled: cheatsEnabled,
);
_subscription = _loop!.stream.listen((next) {
_state = next;
notifyListeners();
});
_loop!.start();
notifyListeners();
}
Future<void> loadAndStart({
String? fileName,
bool cheatsEnabled = false,
}) async {
_status = GameSessionStatus.loading;
_error = null;
notifyListeners();
final (outcome, loaded) = await saveManager.loadState(fileName: fileName);
if (!outcome.success || loaded == null) {
_status = GameSessionStatus.error;
_error = outcome.error ?? 'Unknown error';
notifyListeners();
return;
}
await startNew(loaded, cheatsEnabled: cheatsEnabled, isNewGame: false);
}
Future<void> pause({bool saveOnStop = false}) async {
await _stopLoop(saveOnStop: saveOnStop);
_status = GameSessionStatus.idle;
notifyListeners();
}
@override
void dispose() {
final stop = _stopLoop(saveOnStop: false);
if (stop != null) {
unawaited(stop);
}
super.dispose();
}
Future<void>? _stopLoop({required bool saveOnStop}) {
final loop = _loop;
final sub = _subscription;
_loop = null;
_subscription = null;
sub?.cancel();
if (loop == null) return null;
return loop.stop(saveOnStop: saveOnStop);
}
}

View File

@@ -0,0 +1,484 @@
import 'dart:math' as math;
import 'package:flutter/material.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/deterministic_random.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart';
/// 캐릭터 생성 화면 (NewGuy.pas 포팅)
class NewCharacterScreen extends StatefulWidget {
const NewCharacterScreen({super.key, this.onCharacterCreated});
/// 캐릭터 생성 완료 시 호출되는 콜백
final void Function(GameState initialState)? onCharacterCreated;
@override
State<NewCharacterScreen> createState() => _NewCharacterScreenState();
}
class _NewCharacterScreenState extends State<NewCharacterScreen> {
final PqConfig _config = const PqConfig();
final TextEditingController _nameController = TextEditingController();
// 종족(races)과 직업(klasses) 목록
late final List<String> _races;
late final List<String> _klasses;
// 선택된 종족/직업 인덱스
int _selectedRaceIndex = 0;
int _selectedKlassIndex = 0;
// 능력치(stats)
int _str = 0;
int _con = 0;
int _dex = 0;
int _int = 0;
int _wis = 0;
int _cha = 0;
// 롤 이력 (Unroll 기능용)
final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장)
int _currentSeed = 0;
// 이름 생성용 RNG
late DeterministicRandom _nameRng;
@override
void initState() {
super.initState();
// 종족/직업 목록 로드 (name|attribute 형식에서 name만 추출)
_races = _config.races.map((e) => e.split('|').first).toList();
_klasses = _config.klasses.map((e) => e.split('|').first).toList();
// 초기 랜덤화
final random = math.Random();
_selectedRaceIndex = random.nextInt(_races.length);
_selectedKlassIndex = random.nextInt(_klasses.length);
// 초기 스탯 굴림
_currentSeed = random.nextInt(0x7FFFFFFF);
_nameRng = DeterministicRandom(random.nextInt(0x7FFFFFFF));
_rollStats();
// 초기 이름 생성
_nameController.text = generateName(_nameRng);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
/// 스탯 굴림 (3d6 × 6)
void _rollStats() {
final rng = DeterministicRandom(_currentSeed);
setState(() {
_str = rollStat(rng);
_con = rollStat(rng);
_dex = rollStat(rng);
_int = rollStat(rng);
_wis = rollStat(rng);
_cha = rollStat(rng);
});
}
/// Re-Roll 버튼 클릭
void _onReroll() {
// 현재 시드를 이력에 저장
_rollHistory.insert(0, _currentSeed);
// 새 시드로 굴림
_currentSeed = math.Random().nextInt(0x7FFFFFFF);
_rollStats();
}
/// Unroll 버튼 클릭 (이전 롤로 복원)
void _onUnroll() {
if (_rollHistory.isEmpty) return;
setState(() {
_currentSeed = _rollHistory.removeAt(0);
});
_rollStats();
}
/// 이름 생성 버튼 클릭
void _onGenerateName() {
setState(() {
_nameController.text = generateName(_nameRng);
});
}
/// Total 값 계산
int get _total => _str + _con + _dex + _int + _wis + _cha;
/// Total 색상 결정 (원본 규칙)
/// 63+18(81) 이상 = 빨강, 4*18(72) 초과 = 노랑
/// 63-18(45) 이하 = 회색, 3*18(54) 미만 = 은색
/// 그 외 = 흰색
Color _getTotalColor() {
final total = _total;
if (total >= 81) return Colors.red;
if (total > 72) return Colors.yellow;
if (total <= 45) return Colors.grey;
if (total < 54) return Colors.grey.shade400;
return Colors.white;
}
/// Sold! 버튼 클릭 - 캐릭터 생성 완료
void _onSold() {
final name = _nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('이름을 입력해주세요.')));
return;
}
// 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// 종족/직업의 보너스 스탯 파싱
final raceEntry = _config.races[_selectedRaceIndex];
final klassEntry = _config.klasses[_selectedKlassIndex];
final raceBonus = _parseStatBonus(raceEntry);
final klassBonus = _parseStatBonus(klassEntry);
// 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스)
final finalStats = Stats(
str: _str + (raceBonus['STR'] ?? 0) + (klassBonus['STR'] ?? 0),
con: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0),
dex: _dex + (raceBonus['DEX'] ?? 0) + (klassBonus['DEX'] ?? 0),
intelligence: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0),
wis: _wis + (raceBonus['WIS'] ?? 0) + (klassBonus['WIS'] ?? 0),
cha: _cha + (raceBonus['CHA'] ?? 0) + (klassBonus['CHA'] ?? 0),
hpMax: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0),
mpMax: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0),
);
final traits = Traits(
name: name,
race: _races[_selectedRaceIndex],
klass: _klasses[_selectedKlassIndex],
level: 1,
motto: '',
guild: '',
);
// 초기 게임 상태 생성
final initialState = GameState.withSeed(
seed: gameSeed,
traits: traits,
stats: finalStats,
inventory: const Inventory(gold: 0, items: []),
equipment: Equipment.empty(),
spellBook: SpellBook.empty(),
progress: ProgressState.empty(),
queue: QueueState.empty(),
);
widget.onCharacterCreated?.call(initialState);
}
/// 종족/직업 보너스 파싱 (예: "Half Orc|STR+2,INT-1")
Map<String, int> _parseStatBonus(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return {};
final bonuses = <String, int>{};
final bonusPart = parts[1];
// STR+2,INT-1 형식 파싱
final regex = RegExp(r'([A-Z]+)([+-]\d+)');
for (final match in regex.allMatches(bonusPart)) {
final stat = match.group(1)!;
final value = int.parse(match.group(2)!);
bonuses[stat] = value;
}
return bonuses;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Progress Quest - New Character'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 이름 입력 섹션
_buildNameSection(),
const SizedBox(height: 16),
// 능력치 섹션
_buildStatsSection(),
const SizedBox(height: 16),
// 종족/직업 선택 섹션
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildRaceSection()),
const SizedBox(width: 16),
Expanded(child: _buildKlassSection()),
],
),
const SizedBox(height: 24),
// Sold! 버튼
FilledButton.icon(
onPressed: _onSold,
icon: const Icon(Icons.check),
label: const Text('Sold!'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
);
}
Widget _buildNameSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
maxLength: 30,
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _onGenerateName,
icon: const Icon(Icons.casino),
tooltip: 'Generate Name',
),
],
),
),
);
}
Widget _buildStatsSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Stats', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 스탯 그리드
Row(
children: [
Expanded(child: _buildStatTile('STR', _str)),
Expanded(child: _buildStatTile('CON', _con)),
Expanded(child: _buildStatTile('DEX', _dex)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildStatTile('INT', _int)),
Expanded(child: _buildStatTile('WIS', _wis)),
Expanded(child: _buildStatTile('CHA', _cha)),
],
),
const SizedBox(height: 12),
// Total
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getTotalColor().withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _getTotalColor()),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
'$_total',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _getTotalColor() == Colors.white
? Colors.black
: _getTotalColor(),
),
),
],
),
),
const SizedBox(height: 12),
// Roll 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _onUnroll,
icon: const Icon(Icons.undo),
label: const Text('Unroll'),
style: OutlinedButton.styleFrom(
foregroundColor: _rollHistory.isEmpty ? Colors.grey : null,
),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: _onReroll,
icon: const Icon(Icons.casino),
label: const Text('Roll'),
),
],
),
if (_rollHistory.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${_rollHistory.length} roll(s) in history',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _buildStatTile(String label, int value) {
return Container(
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 4),
Text(
'$value',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildRaceSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Race', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 300,
child: ListView.builder(
itemCount: _races.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedRaceIndex;
return ListTile(
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
_races[index],
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedRaceIndex = index),
);
},
),
),
],
),
),
);
}
Widget _buildKlassSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Class', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 300,
child: ListView.builder(
itemCount: _klasses.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedKlassIndex;
return ListTile(
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
_klasses[index],
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedKlassIndex = index),
);
},
),
),
],
),
),
);
}
}