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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 11:27:47 +02:00
co-authored by Claude Opus 5
parent b64c83fce0
commit 03a12c82dc
8 changed files with 254 additions and 41 deletions
+80 -8
View File
@@ -1,11 +1,10 @@
mod pathfind;
mod pixelhelper;
use std::collections::{HashMap, VecDeque};
use crate::assets::{Image, Tile};
use crate::input::{GameAction, InputState};
use sim::entity::{Entity, EntityKind};
use sim::entity::{Brain, Entity, EntityKind, Prey};
use sim::map::TileMap;
use sim::{delta_action, load_world, step_allowed, Event, Sim, TICK_HZ, TICKS_PER_MOVE};
@@ -44,6 +43,18 @@ const CAM_SMOOTH_TAU_MS: f32 = 120.0;
/// A position correction farther than this is a teleport — snap instead of easing.
const CAM_SNAP_PX: f32 = 96.0;
/// Animals scattered over the map at start, one per free tile picked at random.
const HEN_COUNT: usize = 150;
const FOX_COUNT: usize = 6;
/// Sprite indices in `entities.png`.
const SPRITE_PLAYER: u16 = 0;
const SPRITE_HEN: u16 = 1;
const SPRITE_FOX: u16 = 2;
/// Interval of the population report on stdout.
const REPORT_MS: usize = 5000;
/// Render-side smoothing state for one entity: the previous tile and how long ago the
/// current one was taken. Positions are tiles; rendering lerps between them in pixels
/// over one movement interval.
@@ -74,6 +85,10 @@ pub struct Game {
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
/// logic keeps using the sim's tile positions.
lerp: HashMap<u32, EntityLerp>,
/// 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`
-166
View File
@@ -1,166 +0,0 @@
//! 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.
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.
const MAX_EXPANSIONS: usize = 4096;
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
const MAX_ROUTE_LEN: usize = 64;
/// Step costs. In game terms every step costs the same (Chebyshev geometry) — the +1 on
/// diagonals is purely a tie-breaker so that among equally short paths the one with the
/// fewest diagonals wins (straight lines stay straight instead of zigzagging). Because a
/// route is at most `MAX_ROUTE_LEN` steps, the accumulated surcharge (≤ 64) can never
/// outweigh one extra step (100): the step count always stays Chebyshev-minimal.
const CARDINAL_COST: i32 = 100;
const DIAGONAL_COST: i32 = 101;
/// 8-connected A* from `start` to `goal`. World geometry is chessboard (Chebyshev):
/// 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,
/// excluding `start`, ending on `goal`, or `None` if the goal is unreachable within the
/// search budget.
pub fn find_path(
start: (i32, i32),
goal: (i32, i32),
blocked: impl Fn(i32, i32) -> bool,
) -> Option<Vec<(i32, i32)>> {
if start == goal || blocked(goal.0, goal.1) {
return None;
}
// Exact open-field cost: `max` steps, of which `min` must be diagonal — admissible
// and consistent, since obstacles can only make a path more expensive.
let h = |p: (i32, i32)| {
let dx = (p.0 - goal.0).abs();
let dy = (p.1 - goal.1).abs();
CARDINAL_COST * dx.max(dy) + (DIAGONAL_COST - CARDINAL_COST) * dx.min(dy)
};
// (f, tile) min-heap; g and parent per visited tile.
let mut open = BinaryHeap::new();
let mut best: HashMap<(i32, i32), (i32, (i32, i32))> = HashMap::new();
open.push(Reverse((h(start), start)));
best.insert(start, (0, start));
let mut expanded = 0;
while let Some(Reverse((_, cur))) = open.pop() {
if cur == goal {
let mut route = Vec::new();
let mut p = goal;
while p != start {
route.push(p);
p = best[&p].1;
}
if route.len() > MAX_ROUTE_LEN {
return None;
}
route.reverse();
return Some(route);
}
expanded += 1;
if expanded > MAX_EXPANSIONS {
return None;
}
let g = best[&cur].0;
for (dx, dy) in [
(0, -1), (0, 1), (-1, 0), (1, 0),
(1, -1), (1, 1), (-1, 1), (-1, -1),
] {
let next = (cur.0 + dx, cur.1 + dy);
if !sim::step_allowed(cur, next, &blocked) {
continue;
}
let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST };
if best.get(&next).is_none_or(|&(og, _)| ng < og) {
best.insert(next, (ng, cur));
open.push(Reverse((ng + h(next), next)));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::find_path;
/// Every consecutive pair must be a legal step under the shared movement rule.
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:?}");
from = to;
}
}
#[test]
fn straight_line_stays_straight() {
// The diagonal tie-breaker rules out equal-length zigzags.
let path = find_path((0, 0), (3, 0), |_, _| false).unwrap();
assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]);
}
#[test]
fn diagonal_is_one_step_per_tile() {
let path = find_path((0, 0), (3, 3), |_, _| false).unwrap();
assert_eq!(path, vec![(1, 1), (2, 2), (3, 3)]); // not an L of length 6
}
#[test]
fn mixed_route_is_step_minimal() {
// (5,2): 5 steps — 2 diagonal, 3 cardinal, never more.
let path = find_path((0, 0), (5, 2), |_, _| false).unwrap();
assert_eq!(path.len(), 5);
assert_eq!(path.last(), Some(&(5, 2)));
let diagonals = std::iter::once((0, 0)).chain(path.iter().copied())
.zip(path.iter().copied())
.filter(|(a, b)| a.0 != b.0 && a.1 != b.1)
.count();
assert_eq!(diagonals, 2);
assert_valid((0, 0), &path, |_, _| false);
}
#[test]
fn detours_around_wall() {
// Vertical wall at x=2 with a gap at y=5. The corner rule forces the gap to be
// entered and left orthogonally: 5 steps down to (1,5), through (2,5) and (3,5),
// 5 steps back up to (4,0).
let blocked = |x: i32, y: i32| x == 2 && y != 5;
let path = find_path((0, 0), (4, 0), blocked).unwrap();
assert_eq!(path.last(), Some(&(4, 0)));
assert!(path.contains(&(2, 5)));
assert_valid((0, 0), &path, blocked);
assert_eq!(path.len(), 12);
}
#[test]
fn no_corner_cutting() {
// Blockers at (1,0) and (0,1) touch diagonally; the direct king move
// (0,0) → (1,1) must not squeeze between them.
let blocked = |x: i32, y: i32| (x, y) == (1, 0) || (x, y) == (0, 1);
let path = find_path((0, 0), (1, 1), blocked).unwrap();
assert_valid((0, 0), &path, blocked);
assert_eq!(path.len(), 6); // shortest legal detour around either blocker
}
#[test]
fn unreachable_is_none() {
// Goal sealed in by a ring (Chebyshev radius 1 — also seals diagonals).
let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1;
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
}
#[test]
fn degenerate_cases() {
assert_eq!(find_path((5, 5), (5, 5), |_, _| false), None);
assert_eq!(find_path((0, 0), (1, 0), |x, y| (x, y) == (1, 0)), None);
}
}