From 03a12c82dcbf49661e073297ef107bcb06af969a Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 19 Sep 2026 11:27:47 +0200 Subject: [PATCH] Add Brain enum with hunting foxes; move the A* into the sim NPC strategy is now a Brain field on the entity (Idle, Wander, Hunt(Prey)) that Sim::think matches on, so behavior is data independent of species. Hunt follows the first step of an A* route to the nearest prey in sight, replanned every window; the pathfinder moves from game/ to sim/ for that. A greedy step-toward-target was tried first and oscillated at walls. Bumps between the same species no longer fight, and six foxes join the hens. Co-Authored-By: Claude Opus 5 --- assets/tilesets/entities.png | Bin 241 -> 310 bytes game/src/game.rs | 88 +++++++++++++-- notes/roadmap.md | 20 +++- sim/src/entity.rs | 26 ++++- sim/src/lib.rs | 1 + {game/src/game => sim/src}/pathfind.rs | 12 +-- sim/src/sim.rs | 142 +++++++++++++++++++++---- sim/src/world.rs | 6 +- 8 files changed, 254 insertions(+), 41 deletions(-) rename {game/src/game => sim/src}/pathfind.rs (92%) diff --git a/assets/tilesets/entities.png b/assets/tilesets/entities.png index 51eb4206e0fbf235c4de3c6441ab4b3902fa5970..eb592856d574fab42b590eaafb77cbc8379de8bb 100644 GIT binary patch literal 310 zcmV-60m=S}P)twXf&5T7Nnk>TX*A&~?QmvYO5pXE!ghB>?^43h7P7!5s`>w=KAQe*)m~ z`c=ijeb_#SFtUi;h!5YdKe@Pj0H_A|&*2X-9~-G0n9y=RIc+~r7Xnn5q&F2UW9ST5IjfpG-Ly32yYOlNM})G24{iPSpa58f#_U7HgLrRL|Gs-Oz, + /// Running totals for the population report. + attacks: u32, + deaths: u32, + report_ms: usize, } impl Game { @@ -87,8 +102,25 @@ 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, 3); - sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5, 1); + let player_id = sim.world.spawn_entity( + EntityKind::Player, SPRITE_PLAYER, Brain::Idle, (0, 0), 100, 3); + + // Scatter animals over free tiles. A cheap LCG is plenty for placement. + let mut seed: u32 = 12345; + let mut scatter = |sim: &mut Sim, count: usize, sprite: u16, brain: Brain, hp: u16, attack: u16| { + let mut placed = 0; + while placed < count { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let x = ((seed >> 8) % map.width as u32) as i32; + let y = ((seed >> 20) % map.height as u32) as i32; + if !sim.world.blocked(x, y) { + sim.world.spawn_entity(EntityKind::Npc, sprite, brain, (x as i16, y as i16), hp, attack); + placed += 1; + } + } + }; + scatter(&mut sim, HEN_COUNT, SPRITE_HEN, Brain::Wander, 5, 1); + scatter(&mut sim, FOX_COUNT, SPRITE_FOX, Brain::Hunt(Prey::Kind(SPRITE_HEN)), 8, 2); let mut game = Game { tileset, @@ -100,6 +132,9 @@ impl Game { goal: None, cam: (0.0, 0.0), lerp: HashMap::new(), + attacks: 0, + deaths: 0, + report_ms: 0, }; game.cam = game.cam_target(); game.track_lerp(); @@ -144,21 +179,41 @@ impl Game { self.step_lerp(dt); self.step_camera(dt); self.render_viewport(render_frame); + + self.report_ms += dt; + if self.report_ms >= REPORT_MS { + self.report_ms = 0; + self.print_report(); + } None } + fn print_report(&self) { + let count = |sprite| self.sim.world.entities.values().filter(|e| e.type_id == sprite).count(); + println!("hens: {:3} foxes: {:2} attacks: {:4} deaths: {:3}", + count(SPRITE_HEN), count(SPRITE_FOX), self.attacks, self.deaths); + } + /// 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; + // Only the player's own fights are logged; the rest is tallied for the report. for ev in self.sim.take_events() { match ev { Event::Attacked { by, target, damage } => { - println!("#{by} hits #{target} for {damage}"); + self.attacks += 1; + if by == self.player_id || target == self.player_id { + println!("#{by} hits #{target} for {damage}"); + } } Event::Died { id, at } => { - println!("#{id} dies at {at:?}"); - dead |= id == self.player_id; + self.deaths += 1; + if id == self.player_id { + dead = true; + } else { + println!("#{id} dies at {at:?}"); + } } } } @@ -316,7 +371,7 @@ impl Game { if self.sim.world.solid(goal.0, goal.1) { return None; } - pathfind::find_path(self.player_pos(), goal, |x, y| { + sim::pathfind::find_path(self.player_pos(), goal, |x, y| { (x, y) != goal && self.sim.world.blocked(x, y) }) } @@ -433,6 +488,23 @@ impl Game { #[cfg(test)] mod snapshot { + /// Runs the world headlessly for `$SECONDS` (default 60) of sim time and prints the + /// population report — to watch the hens without a window: + /// `cargo test -p game population -- --ignored --nocapture` + #[test] + #[ignore] + fn population() { + std::env::set_current_dir("..").unwrap(); + let secs: usize = std::env::var("SECONDS").ok().and_then(|s| s.parse().ok()).unwrap_or(60); + let mut g = super::Game::start("assets/map_test"); + let input = crate::input::InputState::new(); + let mut frame = vec![0u16; 320 * 240]; + for _ in 0..secs * 60 { + g.update(&mut frame, 16, &input); + } + g.print_report(); + } + /// Renders the first frame headlessly to the PNG named by `$SNAPSHOT` — a way to /// eyeball the renderer without a window. `$SNAPSHOT_MAP` picks the map: /// `SNAPSHOT=/tmp/frame.png cargo test -p game snapshot -- --ignored` diff --git a/notes/roadmap.md b/notes/roadmap.md index 28dda97..4c6fe04 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -47,7 +47,7 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou (trees/rocks) and id 0 (empty / the invisible world border). A proper tile-data file format replaces this table later. - [x] Click-to-move: framebuffer click → world tile, A* over the world - (`game/src/game/pathfind.rs`, 8-connected, Chebyshev heuristic), route fed into the + (`sim/src/pathfind.rs`, 8-connected, Chebyshev heuristic), route fed into the sim one step per movement window. Keyboard input cancels the route; each step is re-validated at scheduling time and a blocked step replans toward the persistent goal. Click-and-hold steers continuously: while the button is held the route keeps @@ -74,7 +74,17 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou 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. + walks around entities in the way; keyboard steps always bump. A bump into the + same `type_id` (species) is a refused move, not a fight. +- [x] NPC brains: `Brain { Idle, Wander, Hunt(Prey) }` as a field on `Entity` — strategy + is data, independent of species and swappable at runtime. `Sim::think` matches on + it. `Hunt` takes the first step of an A* route (the pathfinder moved into `sim/` + for this) to the nearest prey within `SIGHT_RANGE` (8), replanned every window; + out of sight or unreachable it wanders. A greedy step-toward-target was tried + first and oscillated at any wall between hunter and prey. Six foxes + (`Hunt(Kind(hen))`, 8 hp / 2 dmg) clear 150 wandering hens off `map_test` in + about a minute; `cargo test -p game population -- --ignored --nocapture` watches + it headlessly. - [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 @@ -117,6 +127,12 @@ budget model goes once there are enough NPCs to need it. The action-point model - [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table - [x] Bump-to-attack, death, `Event` queue +- [x] `Brain` enum, `Hunt` via A* +- A* per hunter per window is fine for six foxes; the think budget below becomes + necessary once hunters number in the dozens (or `hunt` caches its route and replans + only when the target moved). +- Hunters don't defend themselves: a fox bitten by a hen does not retaliate unless + the hen happens to be its nearest prey. `Brain::Hunt` could switch target on `Attacked`. - 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. diff --git a/sim/src/entity.rs b/sim/src/entity.rs index 98c138d..ebaa0b3 100755 --- a/sim/src/entity.rs +++ b/sim/src/entity.rs @@ -6,10 +6,31 @@ pub enum EntityKind { Npc, } +/// What a hunter goes after. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Prey { + Player, + Kind(u16), +} + +/// The decision strategy `Sim::think` runs for an NPC. Data, not code: it can be +/// swapped at runtime (a hit hen may start fleeing) and serializes with the entity. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Brain { + /// Never schedules anything — the player, furniture. + Idle, + /// Occasionally a random king move. + Wander, + /// Step toward the nearest prey in sight, bumping into it on contact. Wanders + /// when nothing is in sight. + Hunt(Prey), +} + pub struct Entity { pub id: u32, pub kind: EntityKind, - pub type_id: u16, // sprite index into the entity tileset + pub type_id: u16, // sprite index into the entity tileset; also the species + pub brain: Brain, pub pos: (i16, i16), pub hp: u16, pub hp_max: u16, @@ -18,11 +39,12 @@ pub struct Entity { } impl Entity { - pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16, attack: u16) -> Self { + pub fn new(id: u32, kind: EntityKind, type_id: u16, brain: Brain, pos: (i16, i16), hp: u16, attack: u16) -> Self { Self { id, kind, type_id, + brain, pos, hp, hp_max: hp, diff --git a/sim/src/lib.rs b/sim/src/lib.rs index dfa8a20..3b7cb4a 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -4,6 +4,7 @@ pub mod chunk; pub mod entity; pub mod map; +pub mod pathfind; pub mod world; mod sim; diff --git a/game/src/game/pathfind.rs b/sim/src/pathfind.rs similarity index 92% rename from game/src/game/pathfind.rs rename to sim/src/pathfind.rs index 17cd0e5..b058891 100644 --- a/game/src/game/pathfind.rs +++ b/sim/src/pathfind.rs @@ -1,10 +1,10 @@ -//! Grid A* over the client's chunk cache. The client only *plans* here — every step -//! still goes to the server as a cardinal action, and the server stays authoritative. +//! Grid A* over the world. Planning only: a route is a suggestion, every step of it +//! is re-validated by `entity_tick` when it is actually taken. use std::cmp::Reverse; use std::collections::{BinaryHeap, HashMap}; -/// Hard cap on expanded nodes, so a click on an unreachable tile can't stall the frame. +/// Hard cap on expanded nodes, so a search for an unreachable tile can't stall the tick. const MAX_EXPANSIONS: usize = 4096; /// Longest route we will plan. Keeps the reply small and bounds replan cost. @@ -22,7 +22,7 @@ const DIAGONAL_COST: i32 = 101; /// every step is one game turn, so routes are minimal in step count; the tiny diagonal /// surcharge (see `DIAGONAL_COST`) only breaks ties among equally short paths. Each /// expansion goes through the shared `step_allowed` rule — so the plan can never contain -/// a step the server would refuse (including corner cutting). Returns the tiles to walk, +/// a step the sim would refuse (including corner cutting). Returns the tiles to walk, /// excluding `start`, ending on `goal`, or `None` if the goal is unreachable within the /// search budget. pub fn find_path( @@ -75,7 +75,7 @@ pub fn find_path( (1, -1), (1, 1), (-1, 1), (-1, -1), ] { let next = (cur.0 + dx, cur.1 + dy); - if !sim::step_allowed(cur, next, &blocked) { + if !crate::step_allowed(cur, next, &blocked) { continue; } let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST }; @@ -96,7 +96,7 @@ mod tests { fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) { let mut from = start; for &to in path { - assert!(sim::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}"); + assert!(crate::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}"); from = to; } } diff --git a/sim/src/sim.rs b/sim/src/sim.rs index 5736c91..18a8002 100755 --- a/sim/src/sim.rs +++ b/sim/src/sim.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; use crate::TICKS_PER_MOVE; -use crate::entity::EntityKind; +use crate::entity::{Brain, EntityKind, Prey}; 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; +/// How far (Chebyshev) a hunter notices prey. +const SIGHT_RANGE: i32 = 8; + /// 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`. @@ -81,9 +84,9 @@ 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. + /// Schedule intents for every NPC according to its `Brain`. 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) @@ -91,14 +94,51 @@ impl Sim { .collect(); npcs.sort_unstable(); for id in npcs { - if self.rand() % WANDER_ODDS != 0 { - continue; + let action = match self.world.entities[&id].brain { + Brain::Idle => None, + Brain::Wander => self.wander(), + Brain::Hunt(prey) => self.hunt(id, prey).or_else(|| self.wander()), + }; + if let Some(action) = action { + self.intents.insert(id, action); } - let action = player_action::NORTH + (self.rand() % 8) as u16; - self.intents.insert(id, action); } } + /// One window in `WANDER_ODDS`: a random king move. + fn wander(&mut self) -> Option { + if self.rand() % WANDER_ODDS != 0 { + return None; + } + Some(player_action::NORTH + (self.rand() % 8) as u16) + } + + /// Pursuit: the first step of a route to the nearest prey within `SIGHT_RANGE`. + /// Stepping onto the prey is the bump that attacks it. Replanned every window, so + /// a moving target and other entities in the way are handled by construction; + /// unreachable prey (walled off, search budget exceeded) is ignored. + fn hunt(&mut self, id: u32, prey: Prey) -> Option { + let me = &self.world.entities[&id]; + let from = (me.pos.0 as i32, me.pos.1 as i32); + let dist = |p: (i16, i16)| (p.0 as i32 - from.0).abs().max((p.1 as i32 - from.1).abs()); + + let (_, _, goal) = self.world.entities.values() + .filter(|e| e.id != id && match prey { + Prey::Player => e.kind == EntityKind::Player, + Prey::Kind(t) => e.type_id == t, + }) + .map(|e| (dist(e.pos), e.id, (e.pos.0 as i32, e.pos.1 as i32))) + .filter(|&(d, ..)| d <= SIGHT_RANGE) + .min()?; + + let w = &self.world; + let route = crate::pathfind::find_path(from, goal, |x, y| { + (x, y) != goal && w.blocked(x, y) + })?; + let step = route[0]; + Some(crate::delta_action(step.0 - from.0, step.1 - from.1)) + } + fn entity_tick(&mut self) { // Lowest id first: deterministic, and the player (spawned first) wins a // contested tile. @@ -121,7 +161,10 @@ impl Sim { 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)), + None => self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16)), + // Bumping into one's own species is a refused move, not a fight. + Some(target) if self.world.entities[&target].type_id + == self.world.entities[&entity_id].type_id => {} Some(target) => self.attack(entity_id, target), } } @@ -162,7 +205,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, 1); + let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1); sim.set_action(id, player_action::SOUTH); sim.set_action(id, player_action::NORTH); // replaces sim.step(); // no window yet @@ -176,12 +219,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, 1); + let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (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, 1); + let edge = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (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)); @@ -190,7 +233,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, 1); + let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1); sim.set_action(id, player_action::SOUTH); sim.set_action(id, player_action::NOOP); step_window(&mut sim); @@ -200,8 +243,8 @@ 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), 5, 0); - let b = sim.world.spawn_entity(EntityKind::Player, 0, (4, 5), 5, 0); + let a = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 5, 0); + let b = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (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)); @@ -222,7 +265,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, 1); + let npc = sim.world.spawn_entity(EntityKind::Npc, 1, Brain::Wander, (10, 10), 1, 1); let mut prev = (10, 10); let mut moved = false; for _ in 0..200 { @@ -241,8 +284,8 @@ mod tests { #[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); + let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3); + let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 0); sim.set_action(p, player_action::SOUTH); step_window(&mut sim); @@ -271,11 +314,70 @@ mod tests { 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); + let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3); + let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (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()); } + + #[test] + fn same_species_bump_is_not_a_fight() { + let mut sim = Sim::new(open_world()); + let a = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 4), 5, 3); + let b = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 3); + sim.set_action(a, player_action::SOUTH); + step_window(&mut sim); + assert_eq!(sim.world.entities[&b].hp, 5); + assert_eq!(sim.world.entities[&a].pos, (4, 4)); + assert!(sim.take_events().is_empty()); + } + + #[test] + fn hunter_closes_in_and_bites() { + let mut sim = Sim::new(open_world()); + let hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (10, 10), 5, 1); + let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (14, 7), 8, 2); + // Chebyshev distance 4: three steps to get adjacent, the fourth is the bite. + for _ in 0..3 { step_window(&mut sim); } + let p = sim.world.entities[&fox].pos; + assert_eq!((p.0 - 10).abs().max((p.1 - 10).abs()), 1, "{p:?}"); + assert_eq!(sim.world.entities[&hen].hp, 5); + step_window(&mut sim); + assert_eq!(sim.world.entities[&hen].hp, 3); + assert_eq!(sim.take_events(), vec![Event::Attacked { by: fox, target: hen, damage: 2 }]); + } + + #[test] + fn hunter_sidesteps_an_obstacle() { + let mut sim = Sim::new(open_world()); + // Wall at (5,4); fox at (6,4) wants (3,4) straight west. The diagonals past + // the wall are illegal (corner rule), so it slides sideways, walks along the + // wall and bites diagonally once it is past. + let hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (3, 4), 5, 1); + let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (6, 4), 8, 2); + step_window(&mut sim); + let p = sim.world.entities[&fox].pos; + assert!(p == (6, 3) || p == (6, 5), "{p:?}"); + step_window(&mut sim); + step_window(&mut sim); + let p = sim.world.entities[&fox].pos; + assert!(p == (4, 3) || p == (4, 5), "{p:?}"); + step_window(&mut sim); + assert_eq!(sim.world.entities[&hen].hp, 3); + } + + #[test] + fn hunter_ignores_prey_out_of_sight() { + let mut sim = Sim::with_seed(open_world(), 7); + let _hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (2, 2), 5, 1); + let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (25, 25), 8, 2); + // Out of sight it wanders: over a few windows it must not walk a straight line + // to the hen. Weak check, but it pins down that hunting is range-limited. + let start = sim.world.entities[&fox].pos; + for _ in 0..8 { step_window(&mut sim); } + let p = sim.world.entities[&fox].pos; + assert!((p.0 - start.0).abs().max((p.1 - start.1).abs()) < 8, "{p:?}"); + } } diff --git a/sim/src/world.rs b/sim/src/world.rs index c17bf08..6e0d0d4 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, EntityKind}; +use crate::entity::{Brain, Entity, EntityKind}; pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) { (tx >> 5, ty >> 5) @@ -52,10 +52,10 @@ impl World { chunk.set_tile(lx, ly, id); } - pub fn spawn_entity(&mut self, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16, attack: u16) -> u32 { + pub fn spawn_entity(&mut self, kind: EntityKind, type_id: u16, brain: Brain, 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, attack); + let entity = Entity::new(id, kind, type_id, brain, pos, hp, attack); self.add_entity(entity); id }