Add bump-to-attack, death and a sim event queue

A step onto an occupied tile is now an attack: the geometry rule is checked
against terrain only and the target tile decides between move and bump.
Entities carry an attack stat; at zero hp they are removed. The sim reports
Attacked/Died through an event queue the game drains after every movement
window — the seam for sound, log, camera effects and spawns. Click-to-move
treats an occupied goal as a target but detours around entities in the way;
the player's death ends the game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:59:14 +02:00
co-authored by Claude Opus 5
parent c512bf49da
commit b64c83fce0
6 changed files with 181 additions and 39 deletions
+4 -1
View File
@@ -13,10 +13,12 @@ pub struct Entity {
pub pos: (i16, i16),
pub hp: u16,
pub hp_max: u16,
/// Damage dealt per bump.
pub attack: u16,
}
impl Entity {
pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16, attack: u16) -> Self {
Self {
id,
kind,
@@ -24,6 +26,7 @@ impl Entity {
pos,
hp,
hp_max: hp,
attack,
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod map;
pub mod world;
mod sim;
pub use sim::Sim;
pub use sim::{Event, Sim};
use chunk::Chunk;
use map::TileMap;
+89 -15
View File
@@ -7,6 +7,18 @@ use crate::world::World;
/// How often a wandering NPC takes a step: one window in `WANDER_ODDS`.
const WANDER_ODDS: u32 = 4;
/// 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`.
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) },
}
pub struct Sim {
pub world: World,
/// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
@@ -17,6 +29,7 @@ pub struct Sim {
intents: HashMap<u32, u16>,
/// xorshift state for NPC decisions. Seeded, so a run is reproducible.
rng: u64,
events: Vec<Event>,
}
impl Sim {
@@ -25,7 +38,12 @@ impl Sim {
}
pub fn with_seed(world: World, seed: u64) -> Self {
Self { world, tick: 0, intents: HashMap::new(), rng: seed.max(1) }
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 {
@@ -97,11 +115,27 @@ impl Sim {
};
let from = (pos.0 as i32, pos.1 as i32);
let to = (from.0 + delta.0, from.1 + delta.1);
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
// cutting) — the pathfinder plans with the exact same function.
if crate::step_allowed(from, to, |x, y| self.world.blocked(x, y)) {
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
// 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)),
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, at) = (t.hp, t.pos);
self.events.push(Event::Attacked { by, target, damage });
if hp == 0 {
self.world.remove_entity(target);
self.events.push(Event::Died { id: target, at });
}
}
}
@@ -128,7 +162,7 @@ mod tests {
#[test]
fn one_step_per_window_and_replacement() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1);
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1, 1);
sim.set_action(id, player_action::SOUTH);
sim.set_action(id, player_action::NORTH); // replaces
sim.step(); // no window yet
@@ -142,12 +176,12 @@ mod tests {
#[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, (4, 4), 1);
let id = sim.world.spawn_entity(EntityKind::Player, 0, (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, (0, 0), 1);
let edge = sim.world.spawn_entity(EntityKind::Player, 0, (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));
@@ -156,7 +190,7 @@ mod tests {
#[test]
fn noop_clears_intent() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1);
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1, 1);
sim.set_action(id, player_action::SOUTH);
sim.set_action(id, player_action::NOOP);
step_window(&mut sim);
@@ -166,14 +200,13 @@ mod tests {
#[test]
fn entities_block_each_other() {
let mut sim = Sim::new(open_world());
let a = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1);
let b = sim.world.spawn_entity(EntityKind::Player, 0, (4, 5), 1);
sim.set_action(a, player_action::SOUTH); // onto b
let a = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 5, 0);
let b = sim.world.spawn_entity(EntityKind::Player, 0, (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));
// Corner rule applies to entities too: b at (4,5) and a blocker at (5,4) seal
// the diagonal (4,4) → (5,5).
// 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));
@@ -189,7 +222,7 @@ mod tests {
#[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, (10, 10), 1);
let npc = sim.world.spawn_entity(EntityKind::Npc, 1, (10, 10), 1, 1);
let mut prev = (10, 10);
let mut moved = false;
for _ in 0..200 {
@@ -204,4 +237,45 @@ mod tests {
}
assert!(moved);
}
#[test]
fn bump_attacks_and_kills() {
let mut sim = Sim::new(open_world());
let p = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 10, 3);
let n = sim.world.spawn_entity(EntityKind::Player, 1, (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, 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, damage: 3 },
Event::Died { id: n, at: (4, 5) },
]);
// 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, (4, 4), 10, 3);
let n = sim.world.spawn_entity(EntityKind::Player, 1, (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());
}
}
+18 -12
View File
@@ -52,28 +52,34 @@ impl World {
chunk.set_tile(lx, ly, id);
}
pub fn spawn_entity(&mut self, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16) -> u32 {
pub fn spawn_entity(&mut self, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16, attack: u16) -> u32 {
let id = self.next_entity_id;
self.next_entity_id += 1;
let entity = Entity::new(id, kind, type_id, pos, hp);
let entity = Entity::new(id, kind, type_id, pos, hp, attack);
self.add_entity(entity);
id
}
/// True if any entity stands on `(wx, wy)`. Goes through the chunk index, so it is
/// cheap enough to call per pathfinder expansion.
pub fn occupied(&self, wx: i16, wy: i16) -> bool {
/// The entity standing on `(wx, wy)`, if any. Goes through the chunk index, so it
/// is cheap enough to call per pathfinder expansion.
pub fn entity_at(&self, wx: i32, wy: i32) -> Option<u32> {
let (Ok(wx), Ok(wy)) = (i16::try_from(wx), i16::try_from(wy)) else { return None };
let (cx, cy) = tile_to_chunk(wx, wy);
self.entities_in_chunk(cx, cy).iter()
.any(|id| self.entities[id].pos == (wx, wy))
self.entities_in_chunk(cx, cy).iter().copied()
.find(|id| self.entities[id].pos == (wx, wy))
}
/// The movement blocker predicate: a tile blocks if it is solid, outside the loaded
/// world, or occupied by an entity. Both the sim and the pathfinder plan with this.
/// True if the terrain at `(wx, wy)` blocks movement: a collidable tile or outside
/// the loaded world.
pub fn solid(&self, wx: i32, wy: i32) -> bool {
let (Ok(wx), Ok(wy)) = (i16::try_from(wx), i16::try_from(wy)) else { return true };
self.tile_flags(wx, wy).map_or(true, |f| f.collidable())
}
/// The movement blocker predicate: solid terrain or an entity in the way. Both the
/// sim and the pathfinder plan with this.
pub fn blocked(&self, wx: i32, wy: i32) -> bool {
let Ok(wx) = i16::try_from(wx) else { return true };
let Ok(wy) = i16::try_from(wy) else { return true };
self.tile_flags(wx, wy).map_or(true, |f| f.collidable()) || self.occupied(wx, wy)
self.solid(wx, wy) || self.entity_at(wx, wy).is_some()
}
pub fn add_entity(&mut self, entity: Entity) {