From 7ea351f8e59a855d91b423771ba8db5737073aa0 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 19 Sep 2026 10:51:44 +0200 Subject: [PATCH] Add entity blocking and a wandering NPC Occupied tiles now block movement through World::blocked, the one predicate the sim and the pathfinder share. Entities carry an EntityKind so the sim knows which ones to think for; Sim::think gives each NPC a random king move one window in four. Intents execute in id order so contested tiles resolve deterministically. Co-Authored-By: Claude Opus 5 --- assets/tilesets/entities.png | Bin 127 -> 241 bytes sim/src/entity.rs | 14 ++++- sim/src/sim.rs | 103 +++++++++++++++++++++++++++++++---- sim/src/world.rs | 22 +++++++- 4 files changed, 123 insertions(+), 16 deletions(-) diff --git a/assets/tilesets/entities.png b/assets/tilesets/entities.png index 978a123666c753abfff157db3bd5b06540f81260..51eb4206e0fbf235c4de3c6441ab4b3902fa5970 100644 GIT binary patch literal 241 zcmVST5IjfpG-Ly32yYOlNM})G24{iPSpa58f#_U7HgLrRL|Gs-OzoQX{kYN|lL Self { + pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16) -> Self { Self { id, + kind, type_id, pos, hp, diff --git a/sim/src/sim.rs b/sim/src/sim.rs index 9a0b939..3be23f3 100755 --- a/sim/src/sim.rs +++ b/sim/src/sim.rs @@ -1,7 +1,12 @@ use std::collections::HashMap; use crate::TICKS_PER_MOVE; +use crate::entity::EntityKind; +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; + pub struct Sim { pub world: World, /// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick. @@ -10,11 +15,26 @@ pub struct Sim { /// 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, + /// xorshift state for NPC decisions. Seeded, so a run is reproducible. + rng: u64, } impl Sim { pub fn new(world: World) -> Self { - Self { world, tick: 0, intents: HashMap::new() } + 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) } + } + + 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. @@ -35,6 +55,7 @@ impl Sim { 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 { @@ -42,8 +63,30 @@ impl Sim { } } + /// Schedule intents for every NPC. Wandering for now: occasionally a random king + /// move, otherwise stand still. 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 = self.world.entities.values() + .filter(|e| e.kind == EntityKind::Npc) + .map(|e| e.id) + .collect(); + npcs.sort_unstable(); + for id in npcs { + if self.rand() % WANDER_ODDS != 0 { + continue; + } + let action = player_action::NORTH + (self.rand() % 8) as u16; + self.intents.insert(id, action); + } + } + fn entity_tick(&mut self) { - for (entity_id, action) in self.intents.drain() { + // 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, @@ -56,10 +99,7 @@ impl Sim { 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. - let allowed = crate::step_allowed(from, to, |x, y| { - self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable()) - }); - if allowed { + 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)); } } @@ -70,7 +110,6 @@ impl Sim { mod tests { use super::*; use crate::chunk::Chunk; - use crate::player_action; /// Open floor (id 1) with a single wall tile at (5, 4) — id 146 is collidable in /// the tile vocabulary. @@ -89,7 +128,7 @@ mod tests { #[test] fn one_step_per_window_and_replacement() { let mut sim = Sim::new(open_world()); - let id = sim.world.spawn_entity(0, (4, 4), 1); + let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1); sim.set_action(id, player_action::SOUTH); sim.set_action(id, player_action::NORTH); // replaces sim.step(); // no window yet @@ -103,12 +142,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(0, (4, 4), 1); + let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 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(0, (0, 0), 1); + let edge = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 1); sim.set_action(edge, player_action::WEST); // no chunk there step_window(&mut sim); assert_eq!(sim.world.entities[&edge].pos, (0, 0)); @@ -117,10 +156,52 @@ mod tests { #[test] fn noop_clears_intent() { let mut sim = Sim::new(open_world()); - let id = sim.world.spawn_entity(0, (4, 4), 1); + let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 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, (4, 4), 1); + let b = sim.world.spawn_entity(EntityKind::Player, 0, (4, 5), 1); + sim.set_action(a, player_action::SOUTH); // onto b + 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). + 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, (10, 10), 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); + } } diff --git a/sim/src/world.rs b/sim/src/world.rs index 25d7360..8ede7ff 100755 --- a/sim/src/world.rs +++ b/sim/src/world.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use crate::chunk::{Chunk, TileFlags}; -use crate::entity::Entity; +use crate::entity::{Entity, EntityKind}; pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) { (tx >> 5, ty >> 5) @@ -52,14 +52,30 @@ impl World { chunk.set_tile(lx, ly, id); } - pub fn spawn_entity(&mut self, 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) -> u32 { let id = self.next_entity_id; self.next_entity_id += 1; - let entity = Entity::new(id, type_id, pos, hp); + let entity = Entity::new(id, kind, type_id, pos, hp); 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 { + let (cx, cy) = tile_to_chunk(wx, wy); + self.entities_in_chunk(cx, cy).iter() + .any(|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. + 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) + } + pub fn add_entity(&mut self, entity: Entity) { let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1); let id = entity.id;