pub struct BattlerState<P: BattleProvider + ?Sized> {
pub species: P::Species,
pub hp: u16,
pub max_hp: u16,
pub level: u8,
pub stats: EnumMap<P::Stat, u16>,
pub stat_stages: EnumMap<P::Stat, i8>,
pub status: Option<P::Status>,
pub moves: Vec<P::Move>,
pub resources: ResourcePool,
}Expand description
The battle state of a single monster/character.
Tracks HP, stats, stat-stage modifiers, status condition, and known
moves. Generic over the BattleProvider that supplies the concrete
type identifiers.
Fields§
§species: P::SpeciesSpecies of this monster.
hp: u16Current hit points.
max_hp: u16Maximum hit points.
level: u8The battler’s level. Defaults to 50 in new (set it via
with_level or the field directly). The engine never
interprets it; a provider’s damage/effect logic reads it as needed (e.g. the
level term in a damage formula). Additive — existing callers that ignore it
keep the prior fixed-50 behaviour.
stats: EnumMap<P::Stat, u16>Base stat values, keyed by stat ID.
stat_stages: EnumMap<P::Stat, i8>Stat-stage modifiers (-6 to +6), keyed by stat ID.
status: Option<P::Status>Current status condition, if any.
moves: Vec<P::Move>Known moves.
resources: ResourcePoolGeneric, game-defined consumable resources (MP / SP / mana / charge —
doc 13 §4). Defaults to EMPTY, so a battler that declares no resource
behaves exactly as before. Keyed by an opaque game-assigned u16 id; the
engine never interprets a resource’s meaning (it is not “MP” to the
engine). See ResourcePool.
Implementations§
Source§impl<P: BattleProvider + ?Sized> BattlerState<P>
impl<P: BattleProvider + ?Sized> BattlerState<P>
Sourcepub fn new(
species: P::Species,
hp: u16,
max_hp: u16,
stats: EnumMap<P::Stat, u16>,
moves: Vec<P::Move>,
) -> Self
pub fn new( species: P::Species, hp: u16, max_hp: u16, stats: EnumMap<P::Stat, u16>, moves: Vec<P::Move>, ) -> Self
Create a new battler state.
Examples found in repository?
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 }Sourcepub fn with_level(self, level: u8) -> Self
pub fn with_level(self, level: u8) -> Self
Builder: set the battler’s level.
Sourcepub fn take_damage(&mut self, amount: u16)
pub fn take_damage(&mut self, amount: u16)
Apply damage, clamping HP to zero (never negative).
Examples found in repository?
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
260// ── BattleAI ───────────────────────────────────────────────────────
261
262impl 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
278// ── EffectHandler ──────────────────────────────────────────────────
279
280impl 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 // Delegate to the BattleProvider impl.
289 self.apply_move_effect(effect, user, target)
290 }
291}
292
293// ── ItemProvider ──────────────────────────────────────────────────
294
295impl 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
353// ── ShopProvider ───────────────────────────────────────────────────
354
355impl 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
366// ── TextProvider — ASCII charmap ──────────────────────────────────
367
368impl 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// ── SaveData — binary format: [1B name_len][0..32B name][1B level] ─
427
428#[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
465// ═══════════════════════════════════════════════════════════════════════════
466// Overworld collision — simple provider: only tile 0xFF is a wall
467// ═══════════════════════════════════════════════════════════════════════════
468
469struct 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
506// ═══════════════════════════════════════════════════════════════════════════
507// Map builder — 5×5 town with walls, floor, NPC
508// ═══════════════════════════════════════════════════════════════════════════
509// Layout: # = wall (0xFF), . = floor (0x01), N=NPC, P=player start
510// #####
511// #...#
512// #.N.#
513// #.P.#
514// #####
515
516fn 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
544/// Build NPC runtime states from the map's NPC definitions.
545fn 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
573/// Print the map as ASCII with player position.
574fn 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
593// ═══════════════════════════════════════════════════════════════════════════
594// Demo functions
595// ═══════════════════════════════════════════════════════════════════════════
596
597fn 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 // Start at (3, 3) — bottom center
606 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 // Move right → blocked by wall at (4, 3)
612 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 // Move up to (3, 2) — floor is passable
629 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 // Advance the walk step
650 dotzuki_engine::overworld::advance_step(&mut state);
651 dotzuki_engine::overworld::advance_step(&mut state);
652 // ...walking finishes after WALK_COUNTER_INIT (8) frames
653 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 // NPC interaction: check if NPC is nearby
662 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 // "Greetings, young hero!" + DONE
681 let text: &[u8] = b"Greetings, young hero!";
682 let mut full = text.to_vec();
683 full.push(0xFF); // DONE
684
685 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 // Warrior attacks with Slash → super effective (2×) vs Mage
718 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 // Mage attacks with Fireball → not very effective (0.5×) vs Warrior
731 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 // EffectHandler
749 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 // BattleAI
760 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}Sourcepub fn heal(&mut self, amount: u16)
pub fn heal(&mut self, amount: u16)
Heal HP, clamping to max_hp.
Examples found in repository?
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 }Sourcepub fn with_resource(self, id: u16, max: u16) -> Self
pub fn with_resource(self, id: u16, max: u16) -> Self
Builder: declare a generic resource (id, current = max) on this battler.
Returns self so it chains after new without changing the
constructor’s signature (the additivity invariant).
Sourcepub fn can_pay_resource(&self, id: u16, amount: u16) -> bool
pub fn can_pay_resource(&self, id: u16, amount: u16) -> bool
Whether this battler can pay amount of resource id (delegates to
ResourcePool::can_pay). A 0 cost is always payable; a positive cost
on an undeclared resource is not. Pure — consumes no randomness.
Sourcepub fn pay_resource(&mut self, id: u16, amount: u16) -> bool
pub fn pay_resource(&mut self, id: u16, amount: u16) -> bool
Deduct amount of resource id (saturating). Pure arithmetic — no rng.