Add bitmap font, UI primitives and a HUD with message log

Ports the CP437 font renderer from wds to the framebuffer: proportional
advance from measured ink bounds, luminance-keyed mask, word wrap. The
right 80 px strip becomes a HUD with name, HP bar, population and a log
fed from sim events; Attacked/Died now carry the type ids the log needs,
since the victim is gone from the world by the time events are read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 17:31:17 +02:00
co-authored by Claude Opus 5
parent 03a12c82dc
commit bb12f095d8
9 changed files with 415 additions and 104 deletions
+14 -12
View File
@@ -15,11 +15,13 @@ const SIGHT_RANGE: i32 = 8;
/// back — they are an output channel, drained with `Sim::take_events`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Event {
/// `by` bumped into `target` and dealt `damage`.
Attacked { by: u32, target: u32, damage: u16 },
/// An entity's hp reached zero; it has already been removed from the world. `at`
/// is where it stood, since the entity itself is gone.
Died { id: u32, at: (i16, i16) },
/// `by` bumped into `target` and dealt `damage`. `target_type` is carried so a
/// listener can still name a target that died of this hit.
Attacked { by: u32, target: u32, target_type: u16, damage: u16 },
/// An entity's hp reached zero and it has been removed from the world. Carries
/// what listeners need, since the entity itself is gone: what it was, where it
/// stood, who killed it.
Died { id: u32, type_id: u16, at: (i16, i16), by: u32 },
}
pub struct Sim {
@@ -174,11 +176,11 @@ impl Sim {
let damage = self.world.entities[&by].attack;
let t = self.world.entities.get_mut(&target).unwrap();
t.hp = t.hp.saturating_sub(damage);
let (hp, at) = (t.hp, t.pos);
self.events.push(Event::Attacked { by, target, damage });
let (hp, type_id, at) = (t.hp, t.type_id, t.pos);
self.events.push(Event::Attacked { by, target, target_type: type_id, damage });
if hp == 0 {
self.world.remove_entity(target);
self.events.push(Event::Died { id: target, at });
self.events.push(Event::Died { id: target, type_id, at, by });
}
}
}
@@ -290,7 +292,7 @@ mod tests {
sim.set_action(p, player_action::SOUTH);
step_window(&mut sim);
assert_eq!(sim.world.entities[&n].hp, 2);
assert_eq!(sim.take_events(), vec![Event::Attacked { by: p, target: n, damage: 3 }]);
assert_eq!(sim.take_events(), vec![Event::Attacked { by: p, target: n, target_type: 1, damage: 3 }]);
assert!(sim.take_events().is_empty()); // drained
sim.set_action(p, player_action::SOUTH);
@@ -298,8 +300,8 @@ mod tests {
assert!(!sim.world.entities.contains_key(&n));
assert!(!sim.world.blocked(4, 5));
assert_eq!(sim.take_events(), vec![
Event::Attacked { by: p, target: n, damage: 3 },
Event::Died { id: n, at: (4, 5) },
Event::Attacked { by: p, target: n, target_type: 1, damage: 3 },
Event::Died { id: n, type_id: 1, at: (4, 5), by: p },
]);
// The tile is free again: the same intent now moves.
@@ -346,7 +348,7 @@ mod tests {
assert_eq!(sim.world.entities[&hen].hp, 5);
step_window(&mut sim);
assert_eq!(sim.world.entities[&hen].hp, 3);
assert_eq!(sim.take_events(), vec![Event::Attacked { by: fox, target: hen, damage: 2 }]);
assert_eq!(sim.take_events(), vec![Event::Attacked { by: fox, target: hen, target_type: 1, damage: 2 }]);
}
#[test]