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:
Binary file not shown.
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 310 B |
+80
-8
@@ -1,11 +1,10 @@
|
|||||||
mod pathfind;
|
|
||||||
mod pixelhelper;
|
mod pixelhelper;
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
use crate::assets::{Image, Tile};
|
use crate::assets::{Image, Tile};
|
||||||
use crate::input::{GameAction, InputState};
|
use crate::input::{GameAction, InputState};
|
||||||
use sim::entity::{Entity, EntityKind};
|
use sim::entity::{Brain, Entity, EntityKind, Prey};
|
||||||
use sim::map::TileMap;
|
use sim::map::TileMap;
|
||||||
use sim::{delta_action, load_world, step_allowed, Event, Sim, TICK_HZ, TICKS_PER_MOVE};
|
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.
|
/// A position correction farther than this is a teleport — snap instead of easing.
|
||||||
const CAM_SNAP_PX: f32 = 96.0;
|
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
|
/// 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
|
/// current one was taken. Positions are tiles; rendering lerps between them in pixels
|
||||||
/// over one movement interval.
|
/// over one movement interval.
|
||||||
@@ -74,6 +85,10 @@ pub struct Game {
|
|||||||
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
|
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
|
||||||
/// logic keeps using the sim's tile positions.
|
/// logic keeps using the sim's tile positions.
|
||||||
lerp: HashMap<u32, EntityLerp>,
|
lerp: HashMap<u32, EntityLerp>,
|
||||||
|
/// Running totals for the population report.
|
||||||
|
attacks: u32,
|
||||||
|
deaths: u32,
|
||||||
|
report_ms: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
@@ -87,8 +102,25 @@ impl Game {
|
|||||||
println!("loaded {map_path}: {}×{} tiles in {:.1} ms",
|
println!("loaded {map_path}: {}×{} tiles in {:.1} ms",
|
||||||
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
|
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
|
||||||
let mut sim = Sim::new(world);
|
let mut sim = Sim::new(world);
|
||||||
let player_id = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 100, 3);
|
let player_id = sim.world.spawn_entity(
|
||||||
sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5, 1);
|
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 {
|
let mut game = Game {
|
||||||
tileset,
|
tileset,
|
||||||
@@ -100,6 +132,9 @@ impl Game {
|
|||||||
goal: None,
|
goal: None,
|
||||||
cam: (0.0, 0.0),
|
cam: (0.0, 0.0),
|
||||||
lerp: HashMap::new(),
|
lerp: HashMap::new(),
|
||||||
|
attacks: 0,
|
||||||
|
deaths: 0,
|
||||||
|
report_ms: 0,
|
||||||
};
|
};
|
||||||
game.cam = game.cam_target();
|
game.cam = game.cam_target();
|
||||||
game.track_lerp();
|
game.track_lerp();
|
||||||
@@ -144,21 +179,41 @@ impl Game {
|
|||||||
self.step_lerp(dt);
|
self.step_lerp(dt);
|
||||||
self.step_camera(dt);
|
self.step_camera(dt);
|
||||||
self.render_viewport(render_frame);
|
self.render_viewport(render_frame);
|
||||||
|
|
||||||
|
self.report_ms += dt;
|
||||||
|
if self.report_ms >= REPORT_MS {
|
||||||
|
self.report_ms = 0;
|
||||||
|
self.print_report();
|
||||||
|
}
|
||||||
None
|
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
|
/// 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.
|
/// player is dead — the one event the game cannot continue past.
|
||||||
fn handle_events(&mut self) -> bool {
|
fn handle_events(&mut self) -> bool {
|
||||||
let mut dead = false;
|
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() {
|
for ev in self.sim.take_events() {
|
||||||
match ev {
|
match ev {
|
||||||
Event::Attacked { by, target, damage } => {
|
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 } => {
|
Event::Died { id, at } => {
|
||||||
println!("#{id} dies at {at:?}");
|
self.deaths += 1;
|
||||||
dead |= id == self.player_id;
|
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) {
|
if self.sim.world.solid(goal.0, goal.1) {
|
||||||
return None;
|
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)
|
(x, y) != goal && self.sim.world.blocked(x, y)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -433,6 +488,23 @@ impl Game {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod snapshot {
|
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
|
/// 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:
|
/// eyeball the renderer without a window. `$SNAPSHOT_MAP` picks the map:
|
||||||
/// `SNAPSHOT=/tmp/frame.png cargo test -p game snapshot -- --ignored`
|
/// `SNAPSHOT=/tmp/frame.png cargo test -p game snapshot -- --ignored`
|
||||||
|
|||||||
+18
-2
@@ -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
|
(trees/rocks) and id 0 (empty / the invisible world border). A proper tile-data file
|
||||||
format replaces this table later.
|
format replaces this table later.
|
||||||
- [x] Click-to-move: framebuffer click → world tile, A* over the world
|
- [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
|
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
|
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
|
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
|
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.
|
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
|
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
|
- [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
|
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
|
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] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table
|
||||||
- [x] Bump-to-attack, death, `Event` queue
|
- [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
|
- 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
|
they crowd `Entity`. No ECS until entities actually differ in *which* components they
|
||||||
carry — see the discussion of 2026-09-19.
|
carry — see the discussion of 2026-09-19.
|
||||||
|
|||||||
+24
-2
@@ -6,10 +6,31 @@ pub enum EntityKind {
|
|||||||
Npc,
|
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 struct Entity {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub kind: EntityKind,
|
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 pos: (i16, i16),
|
||||||
pub hp: u16,
|
pub hp: u16,
|
||||||
pub hp_max: u16,
|
pub hp_max: u16,
|
||||||
@@ -18,11 +39,12 @@ pub struct Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
Self {
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
type_id,
|
type_id,
|
||||||
|
brain,
|
||||||
pos,
|
pos,
|
||||||
hp,
|
hp,
|
||||||
hp_max: hp,
|
hp_max: hp,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
pub mod chunk;
|
pub mod chunk;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
pub mod pathfind;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
mod sim;
|
mod sim;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Grid A* over the client's chunk cache. The client only *plans* here — every step
|
//! Grid A* over the world. Planning only: a route is a suggestion, every step of it
|
||||||
//! still goes to the server as a cardinal action, and the server stays authoritative.
|
//! is re-validated by `entity_tick` when it is actually taken.
|
||||||
|
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::{BinaryHeap, HashMap};
|
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;
|
const MAX_EXPANSIONS: usize = 4096;
|
||||||
|
|
||||||
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// excluding `start`, ending on `goal`, or `None` if the goal is unreachable within the
|
||||||
/// search budget.
|
/// search budget.
|
||||||
pub fn find_path(
|
pub fn find_path(
|
||||||
@@ -75,7 +75,7 @@ pub fn find_path(
|
|||||||
(1, -1), (1, 1), (-1, 1), (-1, -1),
|
(1, -1), (1, 1), (-1, 1), (-1, -1),
|
||||||
] {
|
] {
|
||||||
let next = (cur.0 + dx, cur.1 + dy);
|
let next = (cur.0 + dx, cur.1 + dy);
|
||||||
if !sim::step_allowed(cur, next, &blocked) {
|
if !crate::step_allowed(cur, next, &blocked) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST };
|
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) {
|
fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) {
|
||||||
let mut from = start;
|
let mut from = start;
|
||||||
for &to in path {
|
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;
|
from = to;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+122
-20
@@ -1,12 +1,15 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::TICKS_PER_MOVE;
|
use crate::TICKS_PER_MOVE;
|
||||||
use crate::entity::EntityKind;
|
use crate::entity::{Brain, EntityKind, Prey};
|
||||||
use crate::player_action;
|
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`.
|
/// How often a wandering NPC takes a step: one window in `WANDER_ODDS`.
|
||||||
const WANDER_ODDS: u32 = 4;
|
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,
|
/// 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
|
/// 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`.
|
/// 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
|
/// Schedule intents for every NPC according to its `Brain`. Blocked directions are
|
||||||
/// move, otherwise stand still. Blocked directions are simply refused by
|
/// simply refused by `entity_tick` — a wanderer bumping a wall just idles that
|
||||||
/// `entity_tick` — a wanderer bumping a wall just idles that window.
|
/// window.
|
||||||
fn think(&mut self) {
|
fn think(&mut self) {
|
||||||
let mut npcs: Vec<u32> = self.world.entities.values()
|
let mut npcs: Vec<u32> = self.world.entities.values()
|
||||||
.filter(|e| e.kind == EntityKind::Npc)
|
.filter(|e| e.kind == EntityKind::Npc)
|
||||||
@@ -91,14 +94,51 @@ impl Sim {
|
|||||||
.collect();
|
.collect();
|
||||||
npcs.sort_unstable();
|
npcs.sort_unstable();
|
||||||
for id in npcs {
|
for id in npcs {
|
||||||
if self.rand() % WANDER_ODDS != 0 {
|
let action = match self.world.entities[&id].brain {
|
||||||
continue;
|
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<u16> {
|
||||||
|
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<u16> {
|
||||||
|
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) {
|
fn entity_tick(&mut self) {
|
||||||
// Lowest id first: deterministic, and the player (spawned first) wins a
|
// Lowest id first: deterministic, and the player (spawned first) wins a
|
||||||
// contested tile.
|
// contested tile.
|
||||||
@@ -121,7 +161,10 @@ impl Sim {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.world.entity_at(to.0, to.1) {
|
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),
|
Some(target) => self.attack(entity_id, target),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +205,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(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::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
|
||||||
@@ -176,12 +219,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(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)
|
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(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
|
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));
|
||||||
@@ -190,7 +233,7 @@ 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(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::SOUTH);
|
||||||
sim.set_action(id, player_action::NOOP);
|
sim.set_action(id, player_action::NOOP);
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
@@ -200,8 +243,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn entities_block_each_other() {
|
fn entities_block_each_other() {
|
||||||
let mut sim = Sim::new(open_world());
|
let mut sim = Sim::new(open_world());
|
||||||
let a = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 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, (4, 5), 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
|
sim.set_action(a, player_action::SOUTH); // onto b: a bump, not a move
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
assert_eq!(sim.world.entities[&a].pos, (4, 4));
|
assert_eq!(sim.world.entities[&a].pos, (4, 4));
|
||||||
@@ -222,7 +265,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn npc_wanders_but_never_walks_through_walls() {
|
fn npc_wanders_but_never_walks_through_walls() {
|
||||||
let mut sim = Sim::new(open_world());
|
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 prev = (10, 10);
|
||||||
let mut moved = false;
|
let mut moved = false;
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
@@ -241,8 +284,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bump_attacks_and_kills() {
|
fn bump_attacks_and_kills() {
|
||||||
let mut sim = Sim::new(open_world());
|
let mut sim = Sim::new(open_world());
|
||||||
let p = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 10, 3);
|
let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3);
|
||||||
let n = sim.world.spawn_entity(EntityKind::Player, 1, (4, 5), 5, 0);
|
let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 0);
|
||||||
|
|
||||||
sim.set_action(p, player_action::SOUTH);
|
sim.set_action(p, player_action::SOUTH);
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
@@ -271,11 +314,70 @@ mod tests {
|
|||||||
let mut sim = Sim::new(open_world());
|
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
|
// 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.
|
// 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 p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3);
|
||||||
let n = sim.world.spawn_entity(EntityKind::Player, 1, (5, 5), 5, 0);
|
let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (5, 5), 5, 0);
|
||||||
sim.set_action(p, player_action::SOUTH_EAST);
|
sim.set_action(p, player_action::SOUTH_EAST);
|
||||||
step_window(&mut sim);
|
step_window(&mut sim);
|
||||||
assert_eq!(sim.world.entities[&n].hp, 5);
|
assert_eq!(sim.world.entities[&n].hp, 5);
|
||||||
assert!(sim.take_events().is_empty());
|
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:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-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, EntityKind};
|
use crate::entity::{Brain, 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,10 +52,10 @@ impl World {
|
|||||||
chunk.set_tile(lx, ly, id);
|
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;
|
let id = self.next_entity_id;
|
||||||
self.next_entity_id += 1;
|
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);
|
self.add_entity(entity);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user