1use dotzuki_engine::battle::{
8 BattleAI, BattleProvider, BattleState, BattlerState, DamageResult, EffectHandler, EffectResult,
9 EnumMap, MoveEffect, TypeChart,
10};
11use dotzuki_engine::items::{ItemProvider, ItemResult, ShopProvider};
12use dotzuki_engine::map::MapTrait;
13use dotzuki_engine::menu::{MenuInput, MenuLayout, MenuOption, MenuProvider, MenuSystem};
14use dotzuki_engine::overworld::{
15 try_move, CollisionProvider, Direction, MapConnections, MapData, NpcDefinition,
16 NpcMovementType, NpcRuntimeState, OverworldState,
17};
18use dotzuki_engine::save::{InMemoryStorage, SaveData, SaveError, SaveManager, SaveSlot};
19use dotzuki_engine::text::{ControlAction, DialogEngine, DialogState, TextProvider, TileBuffer};
20use dotzuki_engine::tileset::TilesetTrait;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25enum Element {
26 Physical,
27 Magical,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31enum Species {
32 Warrior,
33 Mage,
34}
35
36impl Species {
37 fn name(&self) -> &str {
38 match self {
39 Self::Warrior => "Warrior",
40 Self::Mage => "Mage",
41 }
42 }
43 fn element(&self) -> Element {
44 match self {
45 Self::Warrior => Element::Physical,
46 Self::Mage => Element::Magical,
47 }
48 }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52enum MoveKind {
53 Slash,
54 Fireball,
55}
56
57impl MoveKind {
58 fn name(&self) -> &str {
59 match self {
60 Self::Slash => "Slash",
61 Self::Fireball => "Fireball",
62 }
63 }
64 fn power(&self) -> u16 {
65 match self {
66 Self::Slash => 10,
67 Self::Fireball => 12,
68 }
69 }
70 fn element(&self) -> Element {
71 match self {
72 Self::Slash => Element::Physical,
73 Self::Fireball => Element::Magical,
74 }
75 }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79enum StatId {
80 HP,
81 ATK,
82 DEF,
83 SPD,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
87enum StatusKind {
88 Healthy,
89 Burned,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
93enum Ability {
94 Brave,
95 Arcane,
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99enum ItemKind {
100 Potion,
101 Elixir,
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
105enum ItemEffect {
106 Heal(u16),
107 CureStatus,
108 None,
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
112struct MonsterData {
113 species: Species,
114 max_hp: u16,
115 current_hp: u16,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq)]
119enum AsciiChar {
120 Char(char),
121 Newline,
122 WaitInput,
123 Done,
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
127struct MapId(u8);
128impl MapTrait for MapId {}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
131struct Tileset(u8);
132impl TilesetTrait for Tileset {
133 fn id(&self) -> u8 {
134 self.0
135 }
136 fn name(&self) -> &'static str {
137 "TownTiles"
138 }
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142enum MenuScreen {
143 Main,
144}
145
146struct HelloConfig {
149 main_menu_options: Vec<MenuOption>,
150}
151
152impl HelloConfig {
153 fn new() -> Self {
154 Self {
155 main_menu_options: vec![
156 MenuOption::new("Hello"),
157 MenuOption::new("Battle"),
158 MenuOption::new("Quit"),
159 ],
160 }
161 }
162}
163
164impl Default for HelloConfig {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl TypeChart for HelloConfig {
173 type Type = Element;
174
175 fn effectiveness(attacking: &Self::Type, defending: &[Self::Type]) -> f32 {
176 let def = defending.first().copied().unwrap_or(Element::Physical);
177 match (attacking, def) {
178 (Element::Physical, Element::Magical) => 2.0,
179 (Element::Magical, Element::Physical) => 0.5,
180 _ => 1.0,
181 }
182 }
183}
184
185impl BattleProvider for HelloConfig {
188 type Monster = MonsterData;
189 type Move = MoveKind;
190 type Ability = Ability;
191 type Status = StatusKind;
192 type Stat = StatId;
193 type Species = Species;
194 type Type = Element;
195 type Item = ItemKind;
196
197 fn calculate_damage(
198 &self,
199 move_: &MoveKind,
200 attacker: &BattlerState<Self>,
201 defender: &BattlerState<Self>,
202 _random: u8,
203 _is_critical: bool,
204 ) -> DamageResult {
205 let atk = attacker.stats.get(StatId::ATK).copied().unwrap_or(10);
206 let def = defender.stats.get(StatId::DEF).copied().unwrap_or(5).max(1);
207 let eff = HelloConfig::effectiveness(&move_.element(), &[defender.species.element()]);
208 let raw = (move_.power() as f32 * eff * atk as f32 / def as f32) as u16;
209 DamageResult {
210 damage: raw.max(1),
211 effectiveness: eff,
212 is_miss: false,
213 }
214 }
215
216 fn select_move(&self, battler: &BattlerState<Self>, _state: &BattleState<Self>) -> MoveKind {
217 battler.moves.first().cloned().unwrap_or(MoveKind::Slash)
218 }
219
220 fn apply_move_effect(
221 &self,
222 effect: MoveEffect,
223 user: &mut BattlerState<Self>,
224 target: &mut BattlerState<Self>,
225 ) -> EffectResult {
226 match effect {
227 MoveEffect::Damage => {
228 let mv = user.moves.first().cloned().unwrap_or(MoveKind::Slash);
229 let result = self.calculate_damage(&mv, user, target, 100, false);
230 target.take_damage(result.damage);
231 EffectResult::DamageDealt {
232 amount: result.damage,
233 }
234 }
235 MoveEffect::Heal => {
236 let amount = target.max_hp / 4;
237 target.heal(amount);
238 EffectResult::Healed { amount }
239 }
240 MoveEffect::StatusCondition => EffectResult::StatusInflicted,
241 MoveEffect::StatChange => EffectResult::StatModified { stages: 1 },
242 _ => EffectResult::NoEffect,
243 }
244 }
245
246 fn create_monster(&self, species: Species, level: u8) -> BattlerState<Self> {
247 let (hp, atk, def, spd, moves) = match species {
248 Species::Warrior => (100u16, 15u16, 10u16, 8u16, vec![MoveKind::Slash]),
249 Species::Mage => (80u16, 20u16, 5u16, 12u16, vec![MoveKind::Fireball]),
250 };
251 let mut stats = EnumMap::new();
252 stats.set(StatId::HP, hp);
253 stats.set(StatId::ATK, atk);
254 stats.set(StatId::DEF, def);
255 stats.set(StatId::SPD, spd + level as u16);
256 BattlerState::new(species, hp, hp, stats, moves)
257 }
258}
259
260impl BattleAI<HelloConfig> for HelloConfig {
263 fn select_move(
264 &self,
265 battler: &BattlerState<HelloConfig>,
266 _state: &BattleState<HelloConfig>,
267 ) -> MoveKind {
268 battler.moves.first().cloned().unwrap_or(MoveKind::Slash)
269 }
270 fn should_switch(&self, _battler: &BattlerState<HelloConfig>) -> bool {
271 false
272 }
273 fn should_use_item(&self, _battler: &BattlerState<HelloConfig>) -> Option<ItemKind> {
274 None
275 }
276}
277
278impl EffectHandler<HelloConfig> for HelloConfig {
281 fn handle_effect(
282 &self,
283 effect: MoveEffect,
284 user: &mut BattlerState<HelloConfig>,
285 target: &mut BattlerState<HelloConfig>,
286 _provider: &HelloConfig,
287 ) -> EffectResult {
288 self.apply_move_effect(effect, user, target)
290 }
291}
292
293impl ItemProvider for HelloConfig {
296 type Item = ItemKind;
297 type Effect = ItemEffect;
298 type Monster = MonsterData;
299 type CustomKind = ();
300
301 fn item_name(&self, item: &ItemKind) -> &str {
302 match item {
303 ItemKind::Potion => "Potion",
304 ItemKind::Elixir => "Elixir",
305 }
306 }
307 fn item_description(&self, item: &ItemKind) -> &str {
308 match item {
309 ItemKind::Potion => "Restores 20 HP.",
310 ItemKind::Elixir => "Cures status.",
311 }
312 }
313 fn item_effect(&self, item: &ItemKind) -> ItemEffect {
314 match item {
315 ItemKind::Potion => ItemEffect::Heal(20),
316 ItemKind::Elixir => ItemEffect::CureStatus,
317 }
318 }
319 fn item_price(&self, item: &ItemKind) -> u32 {
320 match item {
321 ItemKind::Potion => 100,
322 ItemKind::Elixir => 300,
323 }
324 }
325 fn can_use_outside_battle(&self, _item: &ItemKind) -> bool {
326 true
327 }
328 fn can_use_in_battle(&self, _item: &ItemKind) -> bool {
329 true
330 }
331 fn use_on_monster(&self, item: &ItemKind, monster: &mut MonsterData) -> ItemResult {
332 match self.item_effect(item) {
333 ItemEffect::Heal(amount) => {
334 if monster.current_hp >= monster.max_hp {
335 return ItemResult::NoEffect;
336 }
337 monster.current_hp = (monster.current_hp + amount).min(monster.max_hp);
338 ItemResult::Used
339 }
340 ItemEffect::CureStatus => ItemResult::Used,
341 ItemEffect::None => ItemResult::NoEffect,
342 }
343 }
344 fn consume(&self, _item: &ItemKind) -> bool {
345 true
346 }
347 fn item_kind(&self, item: &ItemKind) -> dotzuki_engine::items::ItemKind<()> {
348 let _ = item;
349 dotzuki_engine::items::ItemKind::Consumable
350 }
351}
352
353impl ShopProvider for HelloConfig {
356 type Item = ItemKind;
357 type ShopId = ();
358 fn shop_inventory(&self, _shop_id: &()) -> Vec<(ItemKind, u32)> {
359 vec![(ItemKind::Potion, 100), (ItemKind::Elixir, 300)]
360 }
361 fn shop_name(&self, _shop_id: &()) -> &str {
362 "Town Shop"
363 }
364}
365
366impl TextProvider for HelloConfig {
369 type Char = AsciiChar;
370
371 fn decode_byte(&self, byte: u8) -> Option<AsciiChar> {
372 match byte {
373 0xFE => Some(AsciiChar::Newline),
374 0xFF => Some(AsciiChar::Done),
375 0xFD => Some(AsciiChar::WaitInput),
376 b @ 0x20..=0x7E => Some(AsciiChar::Char(b as char)),
377 _ => None,
378 }
379 }
380 fn render_char(&self, c: &AsciiChar, buffer: &mut TileBuffer) {
381 if let AsciiChar::Char(ch) = c {
382 let pos = buffer.cursor;
383 buffer.set_tile(pos, *ch as u16, 0);
384 buffer.cursor.x += 1;
385 }
386 }
387 fn string_width(&self, text: &[AsciiChar]) -> u16 {
388 text.iter()
389 .filter(|c| matches!(c, AsciiChar::Char(_)))
390 .count() as u16
391 * 8
392 }
393 fn is_control_code(&self, c: &AsciiChar) -> bool {
394 !matches!(c, AsciiChar::Char(_))
395 }
396 fn process_control(&self, c: &AsciiChar, _state: &mut DialogState) -> ControlAction {
397 match c {
398 AsciiChar::Newline => ControlAction::Newline,
399 AsciiChar::Done => ControlAction::Done,
400 AsciiChar::WaitInput => ControlAction::WaitInput,
401 _ => ControlAction::None,
402 }
403 }
404}
405
406impl MenuProvider for HelloConfig {
407 type MenuId = MenuScreen;
408
409 fn title(&self, _menu: MenuScreen) -> &str {
410 "Hello JRPG"
411 }
412 fn options(&self, _menu: MenuScreen) -> &[MenuOption] {
413 &self.main_menu_options
414 }
415 fn option_count(&self, _menu: MenuScreen) -> u8 {
416 self.main_menu_options.len() as u8
417 }
418 fn scrollable(&self, _menu: MenuScreen) -> bool {
419 false
420 }
421 fn layout(&self, _menu: MenuScreen) -> MenuLayout {
422 MenuLayout::new(5, 5, 10, 8)
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
429struct GameSave {
430 player_name: String,
431 player_level: u8,
432}
433
434impl SaveData for GameSave {
435 fn serialize(&self) -> Vec<u8> {
436 let name = self.player_name.as_bytes();
437 let len = name.len().min(32);
438 let mut v = Vec::with_capacity(1 + len + 1);
439 v.push(len as u8);
440 v.extend_from_slice(&name[..len]);
441 v.push(self.player_level);
442 v
443 }
444 fn deserialize(data: &[u8]) -> Result<Self, SaveError> {
445 if data.len() < 2 {
446 return Err(SaveError::InvalidData);
447 }
448 let name_len = data[0] as usize;
449 if data.len() < 1 + name_len + 1 {
450 return Err(SaveError::InvalidData);
451 }
452 let name = String::from_utf8(data[1..1 + name_len].to_vec())
453 .map_err(|_| SaveError::InvalidData)?;
454 let level = data[1 + name_len];
455 Ok(GameSave {
456 player_name: name,
457 player_level: level,
458 })
459 }
460 fn save_size() -> usize {
461 35
462 }
463}
464
465struct SimpleCollision;
470
471impl CollisionProvider<Tileset> for SimpleCollision {
472 fn is_tile_passable(&self, _tileset: Tileset, tile_id: u8) -> bool {
473 tile_id != 0xFF
474 }
475 fn check_tile_pair_collision(&self, _t: Tileset, _a: u8, _b: u8, _w: bool) -> bool {
476 false
477 }
478 fn check_ledge_jump(&self, _t: Tileset, _f: u8, _s: u8, _tg: u8, _i: u8) -> bool {
479 false
480 }
481 fn is_counter_tile(&self, _t: Tileset, _id: u8) -> bool {
482 false
483 }
484 fn get_tile_at_position(&self, _t: Tileset, blk: &[u8], w: u8, x: u16, y: u16) -> u8 {
485 blk.get((y as usize * w as usize + x as usize).min(blk.len().saturating_sub(1)))
486 .copied()
487 .unwrap_or(0)
488 }
489 fn is_door_tile(&self, _t: Tileset, _id: u8) -> bool {
490 false
491 }
492 fn is_warp_tile(&self, _t: Tileset, _id: u8) -> bool {
493 false
494 }
495 fn is_warp_carpet_tile_in_front(&self, _t: Tileset, _f: u8, _id: u8) -> bool {
496 false
497 }
498 fn uses_warp_tile_in_front_check(&self, _t: Tileset) -> bool {
499 false
500 }
501 fn check_extra_warp_special(&self, _t: Tileset, _id: u8) -> Option<bool> {
502 None
503 }
504}
505
506fn build_town_map() -> MapData<MapId, Tileset, ()> {
517 let blocks: Vec<u8> = vec![
518 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x01, 0x01, 0xFF, 0xFF, 0x01, 0x01, 0x01, 0xFF,
519 0xFF, 0x01, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
520 ];
521 let npcs = vec![NpcDefinition::new(
522 0,
523 2,
524 2,
525 NpcMovementType::Stationary,
526 Direction::Down,
527 0,
528 0,
529 )];
530 MapData::new(
531 MapId(0),
532 5,
533 5,
534 Tileset(0),
535 (),
536 blocks,
537 vec![],
538 npcs,
539 vec![],
540 MapConnections::default(),
541 )
542}
543
544fn build_npc_states(npcs: &[NpcDefinition]) -> Vec<NpcRuntimeState> {
546 npcs.iter()
547 .enumerate()
548 .map(|(i, def)| {
549 let s = NpcRuntimeState {
550 npc_index: i as u8,
551 sprite_id: def.sprite_id,
552 x: def.x as u16,
553 y: def.y as u16,
554 home_x: def.x as u16,
555 home_y: def.y as u16,
556 facing: def.facing,
557 scripted_frame: None,
558 movement_type: def.movement,
559 range: def.range,
560 walk_counter: 0,
561 delay_counter: 0,
562 text_id: def.text_id,
563 defeated: false,
564 visible: true,
565 scripted_path: std::collections::VecDeque::new(),
566 wander_axis: dotzuki_engine::overworld::NpcWanderAxis::Any,
567 };
568 s
569 })
570 .collect()
571}
572
573fn show_map(blocks: &[u8], _w: u8, px: u16, py: u16, npcs: &[NpcRuntimeState]) {
575 for y in 0..5u16 {
576 print!(" ");
577 for x in 0..5u16 {
578 let idx = (y * 5 + x) as usize;
579 if px == x && py == y {
580 print!("P");
581 } else if npcs.iter().any(|n| n.x == x && n.y == y) {
582 print!("N");
583 } else if idx < blocks.len() && blocks[idx] == 0xFF {
584 print!("#");
585 } else {
586 print!(".");
587 }
588 }
589 println!();
590 }
591}
592
593fn demo_overworld() {
598 println!("\n╔══ OVERWORLD MOVEMENT ═══════════════════╗");
599 let map = build_town_map();
600 let mut state = OverworldState::new(MapId(0));
601 let collision = SimpleCollision;
602 let npcs = build_npc_states(&map.npcs);
603 let npc_positions = dotzuki_engine::overworld::get_npc_positions(&npcs);
604
605 state.player.x = 3;
607 state.player.y = 3;
608 println!("║ Initial map:");
609 show_map(&map.blocks, 5, state.player.x, state.player.y, &npcs);
610
611 let standing = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 3);
613 let target = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 4, 3);
614 let result = try_move(
615 &mut state,
616 Direction::Right,
617 Tileset(0),
618 5,
619 5,
620 standing,
621 target,
622 &npc_positions,
623 0,
624 &collision,
625 );
626 println!("║ Move Right → {:?}", result);
627
628 let standing = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 3);
630 let target = collision.get_tile_at_position(Tileset(0), &map.blocks, 5, 3, 2);
631 let result = try_move(
632 &mut state,
633 Direction::Up,
634 Tileset(0),
635 5,
636 5,
637 standing,
638 target,
639 &npc_positions,
640 0,
641 &collision,
642 );
643 println!("║ Move Up → {:?}", result);
644 println!(
645 "║ Player at ({}, {}), facing {:?}",
646 state.player.x, state.player.y, state.player.facing
647 );
648
649 dotzuki_engine::overworld::advance_step(&mut state);
651 dotzuki_engine::overworld::advance_step(&mut state);
652 for _ in 0..8 {
654 dotzuki_engine::overworld::advance_step(&mut state);
655 }
656 println!(
657 "║ After walk: player at ({}, {})",
658 state.player.x, state.player.y
659 );
660
661 let interaction = dotzuki_engine::overworld::try_interact(
663 &npcs,
664 state.player.x,
665 state.player.y,
666 state.player.facing,
667 Some(&map),
668 &collision,
669 );
670 println!("║ Interact → {:?}", interaction);
671 println!("╚══════════════════════════════════════════╝");
672}
673
674fn demo_dialog() {
675 println!("\n╔══ NPC DIALOG ═══════════════════════════╗");
676 let provider = HelloConfig::new();
677 let mut engine = DialogEngine::new(provider);
678 let mut buffer = TileBuffer::new(20, 18);
679
680 let text: &[u8] = b"Greetings, young hero!";
682 let mut full = text.to_vec();
683 full.push(0xFF); engine.open_dialog(&full);
686 while engine.is_active() {
687 engine.update(&mut buffer);
688 }
689
690 print!("║ Merlin: \"");
691 for i in 0..20usize {
692 let t = buffer.tiles[i].tile_id;
693 if (0x20..=0x7E).contains(&t) {
694 print!("{}", t as u8 as char);
695 }
696 }
697 println!("\"");
698 println!("╚══════════════════════════════════════════╝");
699}
700
701fn demo_battle() {
702 println!("\n╔══ BATTLE ENCOUNTER ═════════════════════╗");
703 let provider = HelloConfig::new();
704 let warrior = provider.create_monster(Species::Warrior, 5);
705 let mage = provider.create_monster(Species::Mage, 5);
706
707 println!(
708 "║ {} (HP:{}/{}) vs {} (HP:{}/{})",
709 warrior.species.name(),
710 warrior.hp,
711 warrior.max_hp,
712 mage.species.name(),
713 mage.hp,
714 mage.max_hp
715 );
716
717 let result = provider.calculate_damage(&MoveKind::Slash, &warrior, &mage, 100, false);
719 println!(
720 "║ {} uses Slash! {} damage ({}x effective)",
721 warrior.species.name(),
722 result.damage,
723 result.effectiveness
724 );
725
726 let mut enemy = mage.clone();
727 enemy.take_damage(result.damage);
728 println!("║ {} HP: {} → {}", mage.species.name(), mage.hp, enemy.hp);
729
730 let result = provider.calculate_damage(&MoveKind::Fireball, &mage, &warrior, 100, false);
732 println!(
733 "║ {} uses Fireball! {} damage ({}x effective)",
734 mage.species.name(),
735 result.damage,
736 result.effectiveness
737 );
738
739 let mut hero = warrior.clone();
740 hero.take_damage(result.damage);
741 println!(
742 "║ {} HP: {} → {}",
743 warrior.species.name(),
744 warrior.hp,
745 hero.hp
746 );
747
748 let mut user = warrior.clone();
750 let mut target = mage.clone();
751 let eff_result = HelloConfig::new().handle_effect(
752 MoveEffect::Damage,
753 &mut user,
754 &mut target,
755 &HelloConfig::new(),
756 );
757 println!("║ EffectHandler(Damage) → {:?}", eff_result);
758
759 let state = BattleState::new(vec![warrior.clone()], vec![mage.clone()]);
761 let ai_move = BattleAI::select_move(&HelloConfig::default(), &warrior, &state);
762 println!("║ BattleAI chose: {}", ai_move.name());
763 println!("╚══════════════════════════════════════════╝");
764}
765
766fn demo_menu() {
767 println!("\n╔══ MENU NAVIGATION ═════════════════════╗");
768 let provider = HelloConfig::new();
769 let mut menu = MenuSystem::new(&provider);
770 menu.open(MenuScreen::Main);
771
772 println!("║ Menu '{}' — 3 options", provider.title(MenuScreen::Main));
773 for (i, opt) in provider.main_menu_options.iter().enumerate() {
774 let marker = if i == menu.cursor as usize { ">" } else { " " };
775 println!(
776 "║ {} {}{}",
777 marker,
778 opt.label,
779 if opt.enabled { "" } else { " (disabled)" }
780 );
781 }
782
783 menu.handle_input(&MenuInput {
785 down: true,
786 ..Default::default()
787 });
788 menu.handle_input(&MenuInput {
789 down: true,
790 ..Default::default()
791 });
792 println!("║ Cursor after 2× Down: {}", menu.cursor);
793
794 let action = menu.handle_input(&MenuInput {
796 confirm: true,
797 ..Default::default()
798 });
799 println!(
800 "║ Confirm → {:?} ('{}')",
801 action,
802 menu.selected_option()
803 .map(|o| o.label.as_str())
804 .unwrap_or("?")
805 );
806 println!("╚══════════════════════════════════════════╝");
807}
808
809fn demo_items() {
810 println!("\n╔══ ITEM SYSTEM ═════════════════════════╗");
811 let provider = HelloConfig::new();
812
813 let mut monster = MonsterData {
814 species: Species::Warrior,
815 max_hp: 100,
816 current_hp: 30,
817 };
818 println!(
819 "║ {} HP before: {}/{}",
820 monster.species.name(),
821 monster.current_hp,
822 monster.max_hp
823 );
824 let r = provider.use_on_monster(&ItemKind::Potion, &mut monster);
825 println!(
826 "║ Used Potion → {:?}, HP now: {}/{}",
827 r, monster.current_hp, monster.max_hp
828 );
829
830 let shop = provider.shop_inventory(&());
831 println!("║ Shop inventory: {:?}", shop);
832 println!("╚══════════════════════════════════════════╝");
833}
834
835fn demo_save_load() {
836 println!("\n╔══ SAVE / LOAD ═════════════════════════╗");
837 let storage = Box::new(InMemoryStorage::new());
838 let manager = SaveManager::<GameSave>::new(storage);
839
840 let save = GameSave {
841 player_name: "Hero".to_string(),
842 player_level: 7,
843 };
844 manager.save(SaveSlot::Slot1, &save).expect("save");
845 let loaded = manager.load(SaveSlot::Slot1).expect("load");
846 println!("║ Saved: {:?}", save);
847 println!("║ Loaded: {:?}", loaded);
848 assert_eq!(save, loaded);
849 println!("║ ✓ Round-trip verified");
850 println!("╚══════════════════════════════════════════╝");
851}
852
853fn main() {
858 println!("╔══════════════════════════════════════════╗");
859 println!("║ Hello JRPG — Engine Demo v0.2 ║");
860 println!("║ Zero pokered-* imports. Pure engine. ║");
861 println!("╚══════════════════════════════════════════╝");
862
863 demo_overworld();
864 demo_dialog();
865 demo_battle();
866 demo_items();
867 demo_menu();
868 demo_save_load();
869
870 println!("\nAll demos complete. ✓");
871}
872
873#[cfg(test)]
878mod tests {
879 use super::*;
880 use dotzuki_engine::items::Inventory;
881 use dotzuki_engine::menu::MenuAction;
882
883 #[test]
886 fn test_warrior_beats_mage() {
887 let provider = HelloConfig::new();
888 let w = provider.create_monster(Species::Warrior, 5);
889 let m = provider.create_monster(Species::Mage, 5);
890 assert_eq!(w.hp, 100);
891 assert_eq!(m.hp, 80);
892
893 let r = provider.calculate_damage(&MoveKind::Slash, &w, &m, 100, false);
895 assert!((r.effectiveness - 2.0).abs() < f32::EPSILON);
896 assert!(r.damage > 0);
897 }
898
899 #[test]
900 fn test_mage_weak_to_warrior() {
901 let provider = HelloConfig::new();
902 let w = provider.create_monster(Species::Warrior, 5);
903 let m = provider.create_monster(Species::Mage, 5);
904
905 let r = provider.calculate_damage(&MoveKind::Fireball, &m, &w, 100, false);
907 assert!((r.effectiveness - 0.5).abs() < f32::EPSILON);
908 }
909
910 #[test]
913 fn test_dialog_stream() {
914 let provider = HelloConfig::new();
915 let mut engine = DialogEngine::new(provider);
916 let mut buffer = TileBuffer::new(20, 18);
917
918 engine.open_dialog(&[b'H', b'I', 0xFF]);
919 assert!(engine.is_active());
920 engine.update(&mut buffer); engine.update(&mut buffer); engine.update(&mut buffer); assert!(!engine.is_active());
924 assert_eq!(buffer.tiles[0].tile_id, b'H' as u16);
925 assert_eq!(buffer.tiles[1].tile_id, b'I' as u16);
926 }
927
928 #[test]
929 fn test_dialog_newline_control() {
930 let provider = HelloConfig::new();
931 let mut engine = DialogEngine::new(provider);
932 let mut buffer = TileBuffer::new(20, 18);
933
934 engine.open_dialog(&[b'A', 0xFE, b'B', 0xFF]);
936 for _ in 0..4 {
937 engine.update(&mut buffer);
938 }
939 assert_eq!(buffer.tiles[0].tile_id, b'A' as u16);
940 assert_eq!(buffer.tiles[20].tile_id, b'B' as u16); }
942
943 #[test]
946 fn test_menu_cursor_movement() {
947 let provider = HelloConfig::new();
948 let mut menu = MenuSystem::new(&provider);
949 menu.open(MenuScreen::Main);
950 assert_eq!(menu.cursor, 0);
951
952 let a = menu.handle_input(&MenuInput {
953 down: true,
954 ..Default::default()
955 });
956 assert_eq!(a, MenuAction::Down);
957 assert_eq!(menu.cursor, 1);
958
959 let a = menu.handle_input(&MenuInput {
960 down: true,
961 ..Default::default()
962 });
963 assert_eq!(a, MenuAction::Down);
964 assert_eq!(menu.cursor, 2);
965
966 let a = menu.handle_input(&MenuInput {
968 down: true,
969 ..Default::default()
970 });
971 assert_eq!(a, MenuAction::None);
972 }
973
974 #[test]
975 fn test_menu_selection_and_cancel() {
976 let provider = HelloConfig::new();
977 let mut menu = MenuSystem::new(&provider);
978 menu.open(MenuScreen::Main);
979
980 menu.handle_input(&MenuInput {
982 down: true,
983 ..Default::default()
984 });
985 let a = menu.handle_input(&MenuInput {
986 confirm: true,
987 ..Default::default()
988 });
989 assert_eq!(a, MenuAction::Selected(1));
990
991 menu.open(MenuScreen::Main);
993 let a = menu.handle_input(&MenuInput {
994 cancel: true,
995 ..Default::default()
996 });
997 assert_eq!(a, MenuAction::Cancelled);
998 assert!(!menu.is_open());
999 }
1000
1001 #[test]
1004 fn test_save_load_roundtrip() {
1005 let storage = Box::new(InMemoryStorage::new());
1006 let manager = SaveManager::<GameSave>::new(storage);
1007 let save = GameSave {
1008 player_name: "Hero".to_string(),
1009 player_level: 42,
1010 };
1011
1012 manager.save(SaveSlot::Slot1, &save).expect("save");
1013 let loaded = manager.load(SaveSlot::Slot1).expect("load");
1014 assert_eq!(loaded, save);
1015 }
1016
1017 #[test]
1018 fn test_save_empty_slot_error() {
1019 let storage = Box::new(InMemoryStorage::new());
1020 let manager = SaveManager::<GameSave>::new(storage);
1021 let result = manager.load(SaveSlot::Slot1);
1022 assert!(result.is_err());
1023 assert_eq!(result.unwrap_err(), SaveError::SlotEmpty);
1024 }
1025
1026 #[test]
1029 fn test_potion_heals() {
1030 let provider = HelloConfig::new();
1031 let mut m = MonsterData {
1032 species: Species::Warrior,
1033 max_hp: 100,
1034 current_hp: 50,
1035 };
1036 assert_eq!(
1037 provider.use_on_monster(&ItemKind::Potion, &mut m),
1038 ItemResult::Used
1039 );
1040 assert_eq!(m.current_hp, 70);
1041 }
1042
1043 #[test]
1044 fn test_potion_no_effect_full_hp() {
1045 let provider = HelloConfig::new();
1046 let mut m = MonsterData {
1047 species: Species::Warrior,
1048 max_hp: 100,
1049 current_hp: 100,
1050 };
1051 assert_eq!(
1052 provider.use_on_monster(&ItemKind::Potion, &mut m),
1053 ItemResult::NoEffect
1054 );
1055 }
1056
1057 #[test]
1060 fn test_physical_beats_magical() {
1061 let eff = HelloConfig::effectiveness(&Element::Physical, &[Element::Magical]);
1062 assert!((eff - 2.0).abs() < f32::EPSILON);
1063 }
1064
1065 #[test]
1066 fn test_magical_weak_to_physical() {
1067 let eff = HelloConfig::effectiveness(&Element::Magical, &[Element::Physical]);
1068 assert!((eff - 0.5).abs() < f32::EPSILON);
1069 }
1070
1071 #[test]
1074 fn test_inventory_ops() {
1075 let mut inv: Inventory<ItemKind, 64> = Inventory::new();
1076 inv.add(ItemKind::Potion, 5);
1077 assert!(inv.contains(&ItemKind::Potion, 3));
1078 assert!(!inv.contains(&ItemKind::Potion, 6));
1079 assert!(inv.remove(&ItemKind::Potion, 2));
1080 assert!(inv.contains(&ItemKind::Potion, 3));
1081 }
1082}