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`