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 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 127 B After Width: | Height: | Size: 241 B |
+12
-2
@@ -1,15 +1,25 @@
|
|||||||
|
/// Who decides an entity's actions. The sim only schedules intents for `Npc`s; a
|
||||||
|
/// `Player`'s intents come from outside (input) through `Sim::set_action`.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum EntityKind {
|
||||||
|
Player,
|
||||||
|
Npc,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Entity {
|
pub struct Entity {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub type_id: u16, // 0 = player
|
pub kind: EntityKind,
|
||||||
|
pub type_id: u16, // sprite index into the entity tileset
|
||||||
pub pos: (i16, i16),
|
pub pos: (i16, i16),
|
||||||
pub hp: u16,
|
pub hp: u16,
|
||||||
pub hp_max: u16,
|
pub hp_max: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entity {
|
impl Entity {
|
||||||
pub fn new(id: u32, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
|
pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
|
kind,
|
||||||
type_id,
|
type_id,
|
||||||
pos,
|
pos,
|
||||||
hp,
|
hp,
|
||||||
|
|||||||
+92
-11
@@ -1,7 +1,12 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::TICKS_PER_MOVE;
|
use crate::TICKS_PER_MOVE;
|
||||||
|
use crate::entity::EntityKind;
|
||||||
|
use crate::player_action;
|
||||||
use crate::world::World;
|
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 struct Sim {
|
||||||
pub world: World,
|
pub world: World,
|
||||||
/// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
|
/// 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
|
/// 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.
|
/// window no matter how often it changes its mind in between.
|
||||||
intents: HashMap<u32, u16>,
|
intents: HashMap<u32, u16>,
|
||||||
|
/// xorshift state for NPC decisions. Seeded, so a run is reproducible.
|
||||||
|
rng: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sim {
|
impl Sim {
|
||||||
pub fn new(world: World) -> Self {
|
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.
|
/// 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 {
|
pub fn step(&mut self) -> bool {
|
||||||
self.tick = self.tick.wrapping_add(1);
|
self.tick = self.tick.wrapping_add(1);
|
||||||
if self.tick.is_multiple_of(TICKS_PER_MOVE) {
|
if self.tick.is_multiple_of(TICKS_PER_MOVE) {
|
||||||
|
self.think();
|
||||||
self.entity_tick();
|
self.entity_tick();
|
||||||
true
|
true
|
||||||
} else {
|
} 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<u32> = 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) {
|
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) {
|
let delta = match crate::action_delta(action) {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -56,10 +99,7 @@ impl Sim {
|
|||||||
let to = (from.0 + delta.0, from.1 + delta.1);
|
let to = (from.0 + delta.0, from.1 + delta.1);
|
||||||
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
|
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
|
||||||
// cutting) — the pathfinder plans with the exact same function.
|
// cutting) — the pathfinder plans with the exact same function.
|
||||||
let allowed = crate::step_allowed(from, to, |x, y| {
|
if crate::step_allowed(from, to, |x, y| self.world.blocked(x, y)) {
|
||||||
self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable())
|
|
||||||
});
|
|
||||||
if allowed {
|
|
||||||
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
|
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +110,6 @@ impl Sim {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::chunk::Chunk;
|
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
|
/// Open floor (id 1) with a single wall tile at (5, 4) — id 146 is collidable in
|
||||||
/// the tile vocabulary.
|
/// the tile vocabulary.
|
||||||
@@ -89,7 +128,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn one_step_per_window_and_replacement() {
|
fn one_step_per_window_and_replacement() {
|
||||||
let mut sim = Sim::new(open_world());
|
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::SOUTH);
|
||||||
sim.set_action(id, player_action::NORTH); // replaces
|
sim.set_action(id, player_action::NORTH); // replaces
|
||||||
sim.step(); // no window yet
|
sim.step(); // no window yet
|
||||||
@@ -103,12 +142,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blocked_and_out_of_world_steps_are_rejected() {
|
fn blocked_and_out_of_world_steps_are_rejected() {
|
||||||
let mut sim = Sim::new(open_world());
|
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)
|
sim.set_action(id, player_action::EAST); // into the wall at (5, 4)
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
assert_eq!(sim.world.entities[&id].pos, (4, 4));
|
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
|
sim.set_action(edge, player_action::WEST); // no chunk there
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
assert_eq!(sim.world.entities[&edge].pos, (0, 0));
|
assert_eq!(sim.world.entities[&edge].pos, (0, 0));
|
||||||
@@ -117,10 +156,52 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn noop_clears_intent() {
|
fn noop_clears_intent() {
|
||||||
let mut sim = Sim::new(open_world());
|
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::SOUTH);
|
||||||
sim.set_action(id, player_action::NOOP);
|
sim.set_action(id, player_action::NOOP);
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
assert_eq!(sim.world.entities[&id].pos, (4, 4));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::chunk::{Chunk, TileFlags};
|
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) {
|
pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) {
|
||||||
(tx >> 5, ty >> 5)
|
(tx >> 5, ty >> 5)
|
||||||
@@ -52,14 +52,30 @@ impl World {
|
|||||||
chunk.set_tile(lx, ly, id);
|
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;
|
let id = self.next_entity_id;
|
||||||
self.next_entity_id += 1;
|
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);
|
self.add_entity(entity);
|
||||||
id
|
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) {
|
pub fn add_entity(&mut self, entity: Entity) {
|
||||||
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
|
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||||
let id = entity.id;
|
let id = entity.id;
|
||||||
|
|||||||
Reference in New Issue
Block a user