diff --git a/game/src/game.rs b/game/src/game.rs index b98c731..6805bf4 100755 --- a/game/src/game.rs +++ b/game/src/game.rs @@ -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> { + 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. diff --git a/notes/roadmap.md b/notes/roadmap.md index 1640959..28dda97 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -58,13 +58,23 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou approach, τ = 120 ms), snapping only to whole pixels at render time (and outright on teleport-sized corrections). The viewport renders with sub-tile offsets (31×31 tile pass + right-strip clip). -- [x] Entity blocking: `World::blocked` (solid tile ∨ out of world ∨ occupied) is the - one predicate the sim's `entity_tick` and the pathfinder share. Intents execute - in id order, so the player (id 1) wins a contested tile deterministically. +- [x] Entity blocking: `World::solid` (collidable tile ∨ out of world) and + `World::blocked` (solid ∨ occupied). The sim checks the geometry rule against + `solid` and resolves the target tile itself; the pathfinder plans with `blocked`. + Intents execute in id order, so the player (id 1) wins a contested tile + deterministically. - [x] First NPC: `EntityKind { Player, Npc }` on `Entity`; `Sim::think` runs before every movement window and schedules a random king move for each NPC one window in four (seeded xorshift in `Sim`). Sprite = tile 1 in `entities.png` (a hen from the Mini-Medieval pack). One is spawned at (6, 6) in `Game::start`. +- [x] Bump-to-attack and death: the geometry rule is checked against terrain only + (`World::solid`); an entity on the target tile turns the step into an attack + (`Entity::attack` damage, `hp` saturating). At 0 hp the entity is removed. The + sim reports both through its event queue — `sim::Event { Attacked, Died }`, + drained by the game with `Sim::take_events` after every window — which is the + seam for sound, message log, camera shake, spawns. Player death quits for now. + Click-to-move treats an occupied goal as a target (route ends in a bump) but + walks around entities in the way; keyboard steps always bump. - [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) — previous/current tile plus a clock, rendered as a pixel lerp over one movement interval. Jumps of more than one tile (Chebyshev) snap. Purely cosmetic; game logic keeps using the @@ -106,7 +116,12 @@ budget model goes once there are enough NPCs to need it. The action-point model 06 is still pending and only matters once NPC speeds should differ. - [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table -- Bump-to-attack: a step onto an occupied tile becomes an attack instead of a refused move +- [x] Bump-to-attack, death, `Event` queue +- Stats beyond `hp`/`attack` (defense, speed) as plain fields; a `Stats` substruct once + they crowd `Entity`. No ECS until entities actually differ in *which* components they + carry — see the discussion of 2026-09-19. +- NPC reactions (flee when hit, aggro on the player) — `think` would need to see the + previous window's events; today the game drains them right after `step` - Per sim-tick: distribute a fixed `think_budget` across entities ordered by player proximity - Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding. - No framework. No trait objects yet. A match on `EntityKind` is fine. diff --git a/sim/src/entity.rs b/sim/src/entity.rs index e4d5dec..98c138d 100755 --- a/sim/src/entity.rs +++ b/sim/src/entity.rs @@ -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, } } } diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 2eae2bc..dfa8a20 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -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; diff --git a/sim/src/sim.rs b/sim/src/sim.rs index 3be23f3..5736c91 100755 --- a/sim/src/sim.rs +++ b/sim/src/sim.rs @@ -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, /// xorshift state for NPC decisions. Seeded, so a run is reproducible. rng: u64, + events: Vec, } 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 { + 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()); + } } diff --git a/sim/src/world.rs b/sim/src/world.rs index 8ede7ff..c17bf08 100755 --- a/sim/src/world.rs +++ b/sim/src/world.rs @@ -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 { + 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) {