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>
386 lines
16 KiB
Rust
Executable File
386 lines
16 KiB
Rust
Executable File
use std::collections::HashMap;
|
|
use crate::TICKS_PER_MOVE;
|
|
use crate::entity::{Brain, EntityKind, Prey};
|
|
use crate::player_action;
|
|
use crate::world::World;
|
|
|
|
/// How often a wandering NPC takes a step: one window in `WANDER_ODDS`.
|
|
const WANDER_ODDS: u32 = 4;
|
|
|
|
/// How far (Chebyshev) a hunter notices prey.
|
|
const SIGHT_RANGE: i32 = 8;
|
|
|
|
/// Something that happened during a tick and that the outside (rendering, sound,
|
|
/// message log, spawning) may want to react to. The sim itself never reads these
|
|
/// 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`. `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 {
|
|
pub world: World,
|
|
/// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
|
|
pub tick: u32,
|
|
/// Each entity's intent for the next movement window. Setting it again replaces
|
|
/// it, executing the window consumes it — so an actor moves at most one tile per
|
|
/// window no matter how often it changes its mind in between.
|
|
intents: HashMap<u32, u16>,
|
|
/// xorshift state for NPC decisions. Seeded, so a run is reproducible.
|
|
rng: u64,
|
|
events: Vec<Event>,
|
|
}
|
|
|
|
impl Sim {
|
|
pub fn new(world: World) -> Self {
|
|
Self::with_seed(world, 0x9E37_79B9_7F4A_7C15)
|
|
}
|
|
|
|
pub fn with_seed(world: World, seed: u64) -> Self {
|
|
Self { world, tick: 0, intents: HashMap::new(), rng: seed.max(1), events: Vec::new() }
|
|
}
|
|
|
|
/// Events since the last call, in the order they happened.
|
|
pub fn take_events(&mut self) -> Vec<Event> {
|
|
std::mem::take(&mut self.events)
|
|
}
|
|
|
|
fn rand(&mut self) -> u32 {
|
|
let mut x = self.rng;
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
self.rng = x;
|
|
(x >> 32) as u32
|
|
}
|
|
|
|
/// Schedule `action` for `entity_id`'s next movement window. NOOP clears the intent.
|
|
pub fn set_action(&mut self, entity_id: u32, action: u16) {
|
|
if crate::action_delta(action).is_some() {
|
|
self.intents.insert(entity_id, action);
|
|
} else {
|
|
self.intents.remove(&entity_id);
|
|
}
|
|
}
|
|
|
|
pub fn clear_action(&mut self, entity_id: u32) {
|
|
self.intents.remove(&entity_id);
|
|
}
|
|
|
|
/// Advance the world by one base tick. Returns `true` when this tick executed a
|
|
/// movement window, i.e. entity positions may have changed.
|
|
pub fn step(&mut self) -> bool {
|
|
self.tick = self.tick.wrapping_add(1);
|
|
if self.tick.is_multiple_of(TICKS_PER_MOVE) {
|
|
self.think();
|
|
self.entity_tick();
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Schedule intents for every NPC according to its `Brain`. Blocked directions are
|
|
/// simply refused by `entity_tick` — a wanderer bumping a wall just idles that
|
|
/// window.
|
|
fn think(&mut self) {
|
|
let mut npcs: Vec<u32> = self.world.entities.values()
|
|
.filter(|e| e.kind == EntityKind::Npc)
|
|
.map(|e| e.id)
|
|
.collect();
|
|
npcs.sort_unstable();
|
|
for id in npcs {
|
|
let action = match self.world.entities[&id].brain {
|
|
Brain::Idle => None,
|
|
Brain::Wander => self.wander(),
|
|
Brain::Hunt(prey) => self.hunt(id, prey).or_else(|| self.wander()),
|
|
};
|
|
if let Some(action) = action {
|
|
self.intents.insert(id, action);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One window in `WANDER_ODDS`: a random king move.
|
|
fn wander(&mut self) -> Option<u16> {
|
|
if self.rand() % WANDER_ODDS != 0 {
|
|
return None;
|
|
}
|
|
Some(player_action::NORTH + (self.rand() % 8) as u16)
|
|
}
|
|
|
|
/// Pursuit: the first step of a route to the nearest prey within `SIGHT_RANGE`.
|
|
/// Stepping onto the prey is the bump that attacks it. Replanned every window, so
|
|
/// a moving target and other entities in the way are handled by construction;
|
|
/// unreachable prey (walled off, search budget exceeded) is ignored.
|
|
fn hunt(&mut self, id: u32, prey: Prey) -> Option<u16> {
|
|
let me = &self.world.entities[&id];
|
|
let from = (me.pos.0 as i32, me.pos.1 as i32);
|
|
let dist = |p: (i16, i16)| (p.0 as i32 - from.0).abs().max((p.1 as i32 - from.1).abs());
|
|
|
|
let (_, _, goal) = self.world.entities.values()
|
|
.filter(|e| e.id != id && match prey {
|
|
Prey::Player => e.kind == EntityKind::Player,
|
|
Prey::Kind(t) => e.type_id == t,
|
|
})
|
|
.map(|e| (dist(e.pos), e.id, (e.pos.0 as i32, e.pos.1 as i32)))
|
|
.filter(|&(d, ..)| d <= SIGHT_RANGE)
|
|
.min()?;
|
|
|
|
let w = &self.world;
|
|
let route = crate::pathfind::find_path(from, goal, |x, y| {
|
|
(x, y) != goal && w.blocked(x, y)
|
|
})?;
|
|
let step = route[0];
|
|
Some(crate::delta_action(step.0 - from.0, step.1 - from.1))
|
|
}
|
|
|
|
fn entity_tick(&mut self) {
|
|
// Lowest id first: deterministic, and the player (spawned first) wins a
|
|
// contested tile.
|
|
let mut intents: Vec<(u32, u16)> = self.intents.drain().collect();
|
|
intents.sort_unstable();
|
|
for (entity_id, action) in intents {
|
|
let delta = match crate::action_delta(action) {
|
|
Some(d) => d,
|
|
None => continue,
|
|
};
|
|
let pos = match self.world.entities.get(&entity_id) {
|
|
Some(e) => e.pos,
|
|
None => continue,
|
|
};
|
|
let from = (pos.0 as i32, pos.1 as i32);
|
|
let to = (from.0 + delta.0, from.1 + delta.1);
|
|
// The geometry rule (king move, no corner cutting) is checked against terrain
|
|
// only; what is on the target tile then decides between a move and a bump.
|
|
if !crate::step_allowed(from, to, |x, y| self.world.solid(x, y)) {
|
|
continue;
|
|
}
|
|
match self.world.entity_at(to.0, to.1) {
|
|
None => self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16)),
|
|
// Bumping into one's own species is a refused move, not a fight.
|
|
Some(target) if self.world.entities[&target].type_id
|
|
== self.world.entities[&entity_id].type_id => {}
|
|
Some(target) => self.attack(entity_id, target),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn attack(&mut self, by: u32, target: u32) {
|
|
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, 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, type_id, at, by });
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::chunk::Chunk;
|
|
|
|
/// Open floor (id 1) with a single wall tile at (5, 4) — id 146 is collidable in
|
|
/// the tile vocabulary.
|
|
fn open_world() -> World {
|
|
let mut w = World::new();
|
|
w.set_chunk(0, 0, Chunk::generate(|lx, ly| if (lx, ly) == (5, 4) { 146 } else { 1 }));
|
|
w
|
|
}
|
|
|
|
fn step_window(sim: &mut Sim) {
|
|
for _ in 0..TICKS_PER_MOVE {
|
|
sim.step();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn one_step_per_window_and_replacement() {
|
|
let mut sim = Sim::new(open_world());
|
|
let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1);
|
|
sim.set_action(id, player_action::SOUTH);
|
|
sim.set_action(id, player_action::NORTH); // replaces
|
|
sim.step(); // no window yet
|
|
assert_eq!(sim.world.entities[&id].pos, (4, 4));
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&id].pos, (4, 3));
|
|
step_window(&mut sim); // intent consumed — no second step
|
|
assert_eq!(sim.world.entities[&id].pos, (4, 3));
|
|
}
|
|
|
|
#[test]
|
|
fn blocked_and_out_of_world_steps_are_rejected() {
|
|
let mut sim = Sim::new(open_world());
|
|
let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1);
|
|
sim.set_action(id, player_action::EAST); // into the wall at (5, 4)
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&id].pos, (4, 4));
|
|
|
|
let edge = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (0, 0), 1, 1);
|
|
sim.set_action(edge, player_action::WEST); // no chunk there
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&edge].pos, (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn noop_clears_intent() {
|
|
let mut sim = Sim::new(open_world());
|
|
let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1);
|
|
sim.set_action(id, player_action::SOUTH);
|
|
sim.set_action(id, player_action::NOOP);
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&id].pos, (4, 4));
|
|
}
|
|
|
|
#[test]
|
|
fn entities_block_each_other() {
|
|
let mut sim = Sim::new(open_world());
|
|
let a = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 5, 0);
|
|
let b = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 5), 5, 0);
|
|
sim.set_action(a, player_action::SOUTH); // onto b: a bump, not a move
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&a].pos, (4, 4));
|
|
|
|
// The wall at (5,4) seals the diagonal (4,4) → (5,5) by the corner rule.
|
|
sim.set_action(a, player_action::SOUTH_EAST);
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&a].pos, (4, 4));
|
|
|
|
// Contested tile: lower id (a) wins, b is refused in the same window.
|
|
sim.set_action(a, player_action::WEST);
|
|
sim.set_action(b, player_action::NORTH_WEST); // both → (3,4)
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&a].pos, (3, 4));
|
|
assert_eq!(sim.world.entities[&b].pos, (4, 5));
|
|
}
|
|
|
|
#[test]
|
|
fn npc_wanders_but_never_walks_through_walls() {
|
|
let mut sim = Sim::new(open_world());
|
|
let npc = sim.world.spawn_entity(EntityKind::Npc, 1, Brain::Wander, (10, 10), 1, 1);
|
|
let mut prev = (10, 10);
|
|
let mut moved = false;
|
|
for _ in 0..200 {
|
|
step_window(&mut sim);
|
|
let pos = sim.world.entities[&npc].pos;
|
|
let d = ((pos.0 - prev.0).abs()).max((pos.1 - prev.1).abs());
|
|
assert!(d <= 1, "more than one tile per window: {prev:?} → {pos:?}");
|
|
assert_ne!(pos, (5, 4), "walked into the wall");
|
|
assert!(sim.world.tile_flags(pos.0, pos.1).is_some(), "left the world: {pos:?}");
|
|
moved |= pos != prev;
|
|
prev = pos;
|
|
}
|
|
assert!(moved);
|
|
}
|
|
|
|
#[test]
|
|
fn bump_attacks_and_kills() {
|
|
let mut sim = Sim::new(open_world());
|
|
let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3);
|
|
let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 0);
|
|
|
|
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, target_type: 1, damage: 3 }]);
|
|
assert!(sim.take_events().is_empty()); // drained
|
|
|
|
sim.set_action(p, player_action::SOUTH);
|
|
step_window(&mut sim);
|
|
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, 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.
|
|
sim.set_action(p, player_action::SOUTH);
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&p].pos, (4, 5));
|
|
assert!(sim.take_events().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn no_diagonal_attack_around_a_corner() {
|
|
let mut sim = Sim::new(open_world());
|
|
// Wall at (5,4), n at (4,5): the diagonal (4,4) → (5,5) is sealed on one side
|
|
// by terrain — by the corner rule, a bump there is as illegal as a move.
|
|
let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3);
|
|
let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (5, 5), 5, 0);
|
|
sim.set_action(p, player_action::SOUTH_EAST);
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&n].hp, 5);
|
|
assert!(sim.take_events().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn same_species_bump_is_not_a_fight() {
|
|
let mut sim = Sim::new(open_world());
|
|
let a = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 4), 5, 3);
|
|
let b = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 3);
|
|
sim.set_action(a, player_action::SOUTH);
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&b].hp, 5);
|
|
assert_eq!(sim.world.entities[&a].pos, (4, 4));
|
|
assert!(sim.take_events().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn hunter_closes_in_and_bites() {
|
|
let mut sim = Sim::new(open_world());
|
|
let hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (10, 10), 5, 1);
|
|
let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (14, 7), 8, 2);
|
|
// Chebyshev distance 4: three steps to get adjacent, the fourth is the bite.
|
|
for _ in 0..3 { step_window(&mut sim); }
|
|
let p = sim.world.entities[&fox].pos;
|
|
assert_eq!((p.0 - 10).abs().max((p.1 - 10).abs()), 1, "{p:?}");
|
|
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, target_type: 1, damage: 2 }]);
|
|
}
|
|
|
|
#[test]
|
|
fn hunter_sidesteps_an_obstacle() {
|
|
let mut sim = Sim::new(open_world());
|
|
// Wall at (5,4); fox at (6,4) wants (3,4) straight west. The diagonals past
|
|
// the wall are illegal (corner rule), so it slides sideways, walks along the
|
|
// wall and bites diagonally once it is past.
|
|
let hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (3, 4), 5, 1);
|
|
let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (6, 4), 8, 2);
|
|
step_window(&mut sim);
|
|
let p = sim.world.entities[&fox].pos;
|
|
assert!(p == (6, 3) || p == (6, 5), "{p:?}");
|
|
step_window(&mut sim);
|
|
step_window(&mut sim);
|
|
let p = sim.world.entities[&fox].pos;
|
|
assert!(p == (4, 3) || p == (4, 5), "{p:?}");
|
|
step_window(&mut sim);
|
|
assert_eq!(sim.world.entities[&hen].hp, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn hunter_ignores_prey_out_of_sight() {
|
|
let mut sim = Sim::with_seed(open_world(), 7);
|
|
let _hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (2, 2), 5, 1);
|
|
let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (25, 25), 8, 2);
|
|
// Out of sight it wanders: over a few windows it must not walk a straight line
|
|
// to the hen. Weak check, but it pins down that hunting is range-limited.
|
|
let start = sim.world.entities[&fox].pos;
|
|
for _ in 0..8 { step_window(&mut sim); }
|
|
let p = sim.world.entities[&fox].pos;
|
|
assert!((p.0 - start.0).abs().max((p.1 - start.1).abs()) < 8, "{p:?}");
|
|
}
|
|
}
|