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:
+24
-2
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
pub mod chunk;
|
||||
pub mod entity;
|
||||
pub mod map;
|
||||
pub mod pathfind;
|
||||
pub mod world;
|
||||
mod sim;
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 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 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.
|
||||
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 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(
|
||||
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 !crate::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!(crate::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);
|
||||
}
|
||||
}
|
||||
+122
-20
@@ -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<u32> = 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<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) {
|
||||
// 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user