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
+50 -6
View File
@@ -7,7 +7,7 @@ use crate::assets::{Image, Tile};
use crate::input::{GameAction, InputState};
use sim::entity::{Entity, EntityKind};
use sim::map::TileMap;
use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE};
use sim::{delta_action, load_world, step_allowed, Event, Sim, TICK_HZ, TICKS_PER_MOVE};
pub enum GameSignal {
Quit,
@@ -87,8 +87,8 @@ impl Game {
println!("loaded {map_path}: {}×{} tiles in {:.1} ms",
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
let mut sim = Sim::new(world);
let player_id = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 100);
sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5);
let player_id = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 100, 3);
sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5, 1);
let mut game = Game {
tileset,
@@ -127,6 +127,9 @@ impl Game {
while self.tick_accum_ms >= TICK_MS {
self.tick_accum_ms -= TICK_MS;
if self.sim.step() {
if self.handle_events() {
return Some(GameSignal::Quit);
}
self.track_lerp();
self.sync_route();
self.step_movement(input);
@@ -144,6 +147,27 @@ impl Game {
None
}
/// React to what the sim reported for the last window. Returns `true` once the
/// player is dead — the one event the game cannot continue past.
fn handle_events(&mut self) -> bool {
let mut dead = false;
for ev in self.sim.take_events() {
match ev {
Event::Attacked { by, target, damage } => {
println!("#{by} hits #{target} for {damage}");
}
Event::Died { id, at } => {
println!("#{id} dies at {at:?}");
dead |= id == self.player_id;
}
}
}
if dead {
println!("You died.");
}
dead
}
/// Where the camera wants to be: the viewport centered on the player's rendered
/// (interpolated) position, not the coarser sim tile — otherwise the target itself
/// jumps in discrete per-tile steps and the easing below has nothing smooth to chase.
@@ -196,7 +220,7 @@ impl Game {
return;
}
match pathfind::find_path(self.player_pos(), goal, |x, y| self.sim.world.blocked(x, y)) {
match self.plan(goal) {
Some(steps) => {
self.route = steps.into();
self.goal = Some(goal);
@@ -241,7 +265,15 @@ impl Game {
};
let Some((nx, ny)) = step else { return };
if step_allowed(from, (nx, ny), |x, y| self.sim.world.blocked(x, y)) {
// A keyboard step or the route's final step may bump into an entity (the sim
// turns that into an attack); an entity blocking the route midway is walked
// around instead.
let may_bump = dir.is_some() || self.goal == Some((nx, ny));
let legal = step_allowed(from, (nx, ny), |x, y| {
if may_bump && (x, y) == (nx, ny) { self.sim.world.solid(x, y) }
else { self.sim.world.blocked(x, y) }
});
if legal {
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
} else if dir.is_none() {
self.replan_route();
@@ -271,12 +303,24 @@ impl Game {
self.goal = None;
return;
}
match pathfind::find_path(pos, goal, |x, y| self.sim.world.blocked(x, y)) {
match self.plan(goal) {
Some(steps) => self.route = steps.into(),
None => self.goal = None,
}
}
/// Route from the player to `goal`. An entity standing on the goal does not make
/// it unreachable — the route ends with a bump into it — but entities along the
/// way are walked around.
fn plan(&self, goal: (i32, i32)) -> Option<Vec<(i32, i32)>> {
if self.sim.world.solid(goal.0, goal.1) {
return None;
}
pathfind::find_path(self.player_pos(), goal, |x, y| {
(x, y) != goal && self.sim.world.blocked(x, y)
})
}
/// Fold the sim's entity positions into the interpolation table: a changed position
/// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a
/// teleport) snaps, and vanished entities are dropped.