Add bump-to-attack, death and a sim event queue
A step onto an occupied tile is now an attack: the geometry rule is checked against terrain only and the target tile decides between move and bump. Entities carry an attack stat; at zero hp they are removed. The sim reports Attacked/Died through an event queue the game drains after every movement window — the seam for sound, log, camera effects and spawns. Click-to-move treats an occupied goal as a target but detours around entities in the way; the player's death ends the game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+50
-6
@@ -7,7 +7,7 @@ use crate::assets::{Image, Tile};
|
|||||||
use crate::input::{GameAction, InputState};
|
use crate::input::{GameAction, InputState};
|
||||||
use sim::entity::{Entity, EntityKind};
|
use sim::entity::{Entity, EntityKind};
|
||||||
use sim::map::TileMap;
|
use sim::map::TileMap;
|
||||||
use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE};
|
use sim::{delta_action, load_world, step_allowed, Event, Sim, TICK_HZ, TICKS_PER_MOVE};
|
||||||
|
|
||||||
pub enum GameSignal {
|
pub enum GameSignal {
|
||||||
Quit,
|
Quit,
|
||||||
@@ -87,8 +87,8 @@ 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);
|
let player_id = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 100, 3);
|
||||||
sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5);
|
sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5, 1);
|
||||||
|
|
||||||
let mut game = Game {
|
let mut game = Game {
|
||||||
tileset,
|
tileset,
|
||||||
@@ -127,6 +127,9 @@ impl Game {
|
|||||||
while self.tick_accum_ms >= TICK_MS {
|
while self.tick_accum_ms >= TICK_MS {
|
||||||
self.tick_accum_ms -= TICK_MS;
|
self.tick_accum_ms -= TICK_MS;
|
||||||
if self.sim.step() {
|
if self.sim.step() {
|
||||||
|
if self.handle_events() {
|
||||||
|
return Some(GameSignal::Quit);
|
||||||
|
}
|
||||||
self.track_lerp();
|
self.track_lerp();
|
||||||
self.sync_route();
|
self.sync_route();
|
||||||
self.step_movement(input);
|
self.step_movement(input);
|
||||||
@@ -144,6 +147,27 @@ impl Game {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
for ev in self.sim.take_events() {
|
||||||
|
match ev {
|
||||||
|
Event::Attacked { by, target, damage } => {
|
||||||
|
println!("#{by} hits #{target} for {damage}");
|
||||||
|
}
|
||||||
|
Event::Died { id, at } => {
|
||||||
|
println!("#{id} dies at {at:?}");
|
||||||
|
dead |= id == self.player_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dead {
|
||||||
|
println!("You died.");
|
||||||
|
}
|
||||||
|
dead
|
||||||
|
}
|
||||||
|
|
||||||
/// Where the camera wants to be: the viewport centered on the player's rendered
|
/// Where the camera wants to be: the viewport centered on the player's rendered
|
||||||
/// (interpolated) position, not the coarser sim tile — otherwise the target itself
|
/// (interpolated) position, not the coarser sim tile — otherwise the target itself
|
||||||
/// jumps in discrete per-tile steps and the easing below has nothing smooth to chase.
|
/// jumps in discrete per-tile steps and the easing below has nothing smooth to chase.
|
||||||
@@ -196,7 +220,7 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match pathfind::find_path(self.player_pos(), goal, |x, y| self.sim.world.blocked(x, y)) {
|
match self.plan(goal) {
|
||||||
Some(steps) => {
|
Some(steps) => {
|
||||||
self.route = steps.into();
|
self.route = steps.into();
|
||||||
self.goal = Some(goal);
|
self.goal = Some(goal);
|
||||||
@@ -241,7 +265,15 @@ impl Game {
|
|||||||
};
|
};
|
||||||
let Some((nx, ny)) = step else { return };
|
let Some((nx, ny)) = step else { return };
|
||||||
|
|
||||||
if step_allowed(from, (nx, ny), |x, y| self.sim.world.blocked(x, y)) {
|
// A keyboard step or the route's final step may bump into an entity (the sim
|
||||||
|
// turns that into an attack); an entity blocking the route midway is walked
|
||||||
|
// around instead.
|
||||||
|
let may_bump = dir.is_some() || self.goal == Some((nx, ny));
|
||||||
|
let legal = step_allowed(from, (nx, ny), |x, y| {
|
||||||
|
if may_bump && (x, y) == (nx, ny) { self.sim.world.solid(x, y) }
|
||||||
|
else { self.sim.world.blocked(x, y) }
|
||||||
|
});
|
||||||
|
if legal {
|
||||||
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
|
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
|
||||||
} else if dir.is_none() {
|
} else if dir.is_none() {
|
||||||
self.replan_route();
|
self.replan_route();
|
||||||
@@ -271,12 +303,24 @@ impl Game {
|
|||||||
self.goal = None;
|
self.goal = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match pathfind::find_path(pos, goal, |x, y| self.sim.world.blocked(x, y)) {
|
match self.plan(goal) {
|
||||||
Some(steps) => self.route = steps.into(),
|
Some(steps) => self.route = steps.into(),
|
||||||
None => self.goal = None,
|
None => self.goal = None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Route from the player to `goal`. An entity standing on the goal does not make
|
||||||
|
/// it unreachable — the route ends with a bump into it — but entities along the
|
||||||
|
/// way are walked around.
|
||||||
|
fn plan(&self, goal: (i32, i32)) -> Option<Vec<(i32, i32)>> {
|
||||||
|
if self.sim.world.solid(goal.0, goal.1) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pathfind::find_path(self.player_pos(), goal, |x, y| {
|
||||||
|
(x, y) != goal && self.sim.world.blocked(x, y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Fold the sim's entity positions into the interpolation table: a changed position
|
/// Fold the sim's entity positions into the interpolation table: a changed position
|
||||||
/// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a
|
/// starts a new lerp from the old one, a jump of more than one tile (Chebyshev — a
|
||||||
/// teleport) snaps, and vanished entities are dropped.
|
/// teleport) snaps, and vanished entities are dropped.
|
||||||
|
|||||||
+19
-4
@@ -58,13 +58,23 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou
|
|||||||
approach, τ = 120 ms), snapping only to whole pixels at render time (and outright
|
approach, τ = 120 ms), snapping only to whole pixels at render time (and outright
|
||||||
on teleport-sized corrections). The viewport renders with sub-tile offsets
|
on teleport-sized corrections). The viewport renders with sub-tile offsets
|
||||||
(31×31 tile pass + right-strip clip).
|
(31×31 tile pass + right-strip clip).
|
||||||
- [x] Entity blocking: `World::blocked` (solid tile ∨ out of world ∨ occupied) is the
|
- [x] Entity blocking: `World::solid` (collidable tile ∨ out of world) and
|
||||||
one predicate the sim's `entity_tick` and the pathfinder share. Intents execute
|
`World::blocked` (solid ∨ occupied). The sim checks the geometry rule against
|
||||||
in id order, so the player (id 1) wins a contested tile deterministically.
|
`solid` and resolves the target tile itself; the pathfinder plans with `blocked`.
|
||||||
|
Intents execute in id order, so the player (id 1) wins a contested tile
|
||||||
|
deterministically.
|
||||||
- [x] First NPC: `EntityKind { Player, Npc }` on `Entity`; `Sim::think` runs before
|
- [x] First NPC: `EntityKind { Player, Npc }` on `Entity`; `Sim::think` runs before
|
||||||
every movement window and schedules a random king move for each NPC one window
|
every movement window and schedules a random king move for each NPC one window
|
||||||
in four (seeded xorshift in `Sim`). Sprite = tile 1 in `entities.png` (a hen from
|
in four (seeded xorshift in `Sim`). Sprite = tile 1 in `entities.png` (a hen from
|
||||||
the Mini-Medieval pack). One is spawned at (6, 6) in `Game::start`.
|
the Mini-Medieval pack). One is spawned at (6, 6) in `Game::start`.
|
||||||
|
- [x] Bump-to-attack and death: the geometry rule is checked against terrain only
|
||||||
|
(`World::solid`); an entity on the target tile turns the step into an attack
|
||||||
|
(`Entity::attack` damage, `hp` saturating). At 0 hp the entity is removed. The
|
||||||
|
sim reports both through its event queue — `sim::Event { Attacked, Died }`,
|
||||||
|
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.
|
||||||
- [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
|
||||||
@@ -106,7 +116,12 @@ budget model goes once there are enough NPCs to need it. The action-point model
|
|||||||
06 is still pending and only matters once NPC speeds should differ.
|
06 is still pending and only matters once NPC speeds should differ.
|
||||||
|
|
||||||
- [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table
|
- [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table
|
||||||
- Bump-to-attack: a step onto an occupied tile becomes an attack instead of a refused move
|
- [x] Bump-to-attack, death, `Event` queue
|
||||||
|
- 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.
|
||||||
|
- NPC reactions (flee when hit, aggro on the player) — `think` would need to see the
|
||||||
|
previous window's events; today the game drains them right after `step`
|
||||||
- Per sim-tick: distribute a fixed `think_budget` across entities ordered by player proximity
|
- Per sim-tick: distribute a fixed `think_budget` across entities ordered by player proximity
|
||||||
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
||||||
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
||||||
|
|||||||
+4
-1
@@ -13,10 +13,12 @@ pub struct Entity {
|
|||||||
pub pos: (i16, i16),
|
pub pos: (i16, i16),
|
||||||
pub hp: u16,
|
pub hp: u16,
|
||||||
pub hp_max: u16,
|
pub hp_max: u16,
|
||||||
|
/// Damage dealt per bump.
|
||||||
|
pub attack: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entity {
|
impl Entity {
|
||||||
pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
|
pub fn new(id: u32, kind: EntityKind, type_id: u16, pos: (i16, i16), hp: u16, attack: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
@@ -24,6 +26,7 @@ impl Entity {
|
|||||||
pos,
|
pos,
|
||||||
hp,
|
hp,
|
||||||
hp_max: hp,
|
hp_max: hp,
|
||||||
|
attack,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ pub mod map;
|
|||||||
pub mod world;
|
pub mod world;
|
||||||
mod sim;
|
mod sim;
|
||||||
|
|
||||||
pub use sim::Sim;
|
pub use sim::{Event, Sim};
|
||||||
|
|
||||||
use chunk::Chunk;
|
use chunk::Chunk;
|
||||||
use map::TileMap;
|
use map::TileMap;
|
||||||
|
|||||||
+89
-15
@@ -7,6 +7,18 @@ 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;
|
||||||
|
|
||||||
|
/// 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`.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Event {
|
||||||
|
/// `by` bumped into `target` and dealt `damage`.
|
||||||
|
Attacked { by: u32, target: u32, damage: u16 },
|
||||||
|
/// An entity's hp reached zero; it has already been removed from the world. `at`
|
||||||
|
/// is where it stood, since the entity itself is gone.
|
||||||
|
Died { id: u32, at: (i16, i16) },
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Sim {
|
pub struct Sim {
|
||||||
pub world: World,
|
pub world: World,
|
||||||
/// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
|
/// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
|
||||||
@@ -17,6 +29,7 @@ pub struct Sim {
|
|||||||
intents: HashMap<u32, u16>,
|
intents: HashMap<u32, u16>,
|
||||||
/// xorshift state for NPC decisions. Seeded, so a run is reproducible.
|
/// xorshift state for NPC decisions. Seeded, so a run is reproducible.
|
||||||
rng: u64,
|
rng: u64,
|
||||||
|
events: Vec<Event>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sim {
|
impl Sim {
|
||||||
@@ -25,7 +38,12 @@ impl Sim {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_seed(world: World, seed: u64) -> Self {
|
pub fn with_seed(world: World, seed: u64) -> Self {
|
||||||
Self { world, tick: 0, intents: HashMap::new(), rng: seed.max(1) }
|
Self { world, tick: 0, intents: HashMap::new(), rng: seed.max(1), events: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events since the last call, in the order they happened.
|
||||||
|
pub fn take_events(&mut self) -> Vec<Event> {
|
||||||
|
std::mem::take(&mut self.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rand(&mut self) -> u32 {
|
fn rand(&mut self) -> u32 {
|
||||||
@@ -97,11 +115,27 @@ impl Sim {
|
|||||||
};
|
};
|
||||||
let from = (pos.0 as i32, pos.1 as i32);
|
let from = (pos.0 as i32, pos.1 as i32);
|
||||||
let to = (from.0 + delta.0, from.1 + delta.1);
|
let to = (from.0 + delta.0, from.1 + delta.1);
|
||||||
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
|
// The geometry rule (king move, no corner cutting) is checked against terrain
|
||||||
// cutting) — the pathfinder plans with the exact same function.
|
// only; what is on the target tile then decides between a move and a bump.
|
||||||
if crate::step_allowed(from, to, |x, y| self.world.blocked(x, y)) {
|
if !crate::step_allowed(from, to, |x, y| self.world.solid(x, y)) {
|
||||||
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
|
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)),
|
||||||
|
Some(target) => self.attack(entity_id, target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attack(&mut self, by: u32, target: u32) {
|
||||||
|
let damage = self.world.entities[&by].attack;
|
||||||
|
let t = self.world.entities.get_mut(&target).unwrap();
|
||||||
|
t.hp = t.hp.saturating_sub(damage);
|
||||||
|
let (hp, at) = (t.hp, t.pos);
|
||||||
|
self.events.push(Event::Attacked { by, target, damage });
|
||||||
|
if hp == 0 {
|
||||||
|
self.world.remove_entity(target);
|
||||||
|
self.events.push(Event::Died { id: target, at });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +162,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);
|
let id = sim.world.spawn_entity(EntityKind::Player, 0, (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
|
||||||
@@ -142,12 +176,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);
|
let id = sim.world.spawn_entity(EntityKind::Player, 0, (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);
|
let edge = sim.world.spawn_entity(EntityKind::Player, 0, (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));
|
||||||
@@ -156,7 +190,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);
|
let id = sim.world.spawn_entity(EntityKind::Player, 0, (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);
|
||||||
@@ -166,14 +200,13 @@ 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), 1);
|
let a = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 5, 0);
|
||||||
let b = sim.world.spawn_entity(EntityKind::Player, 0, (4, 5), 1);
|
let b = sim.world.spawn_entity(EntityKind::Player, 0, (4, 5), 5, 0);
|
||||||
sim.set_action(a, player_action::SOUTH); // onto b
|
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));
|
||||||
|
|
||||||
// Corner rule applies to entities too: b at (4,5) and a blocker at (5,4) seal
|
// The wall at (5,4) seals the diagonal (4,4) → (5,5) by the corner rule.
|
||||||
// the diagonal (4,4) → (5,5).
|
|
||||||
sim.set_action(a, player_action::SOUTH_EAST);
|
sim.set_action(a, player_action::SOUTH_EAST);
|
||||||
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));
|
||||||
@@ -189,7 +222,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);
|
let npc = sim.world.spawn_entity(EntityKind::Npc, 1, (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 {
|
||||||
@@ -204,4 +237,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(moved);
|
assert!(moved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
|
||||||
|
sim.set_action(p, player_action::SOUTH);
|
||||||
|
step_window(&mut sim);
|
||||||
|
assert_eq!(sim.world.entities[&n].hp, 2);
|
||||||
|
assert_eq!(sim.take_events(), vec![Event::Attacked { by: p, target: n, damage: 3 }]);
|
||||||
|
assert!(sim.take_events().is_empty()); // drained
|
||||||
|
|
||||||
|
sim.set_action(p, player_action::SOUTH);
|
||||||
|
step_window(&mut sim);
|
||||||
|
assert!(!sim.world.entities.contains_key(&n));
|
||||||
|
assert!(!sim.world.blocked(4, 5));
|
||||||
|
assert_eq!(sim.take_events(), vec![
|
||||||
|
Event::Attacked { by: p, target: n, damage: 3 },
|
||||||
|
Event::Died { id: n, at: (4, 5) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The tile is free again: the same intent now moves.
|
||||||
|
sim.set_action(p, player_action::SOUTH);
|
||||||
|
step_window(&mut sim);
|
||||||
|
assert_eq!(sim.world.entities[&p].pos, (4, 5));
|
||||||
|
assert!(sim.take_events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_diagonal_attack_around_a_corner() {
|
||||||
|
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);
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-12
@@ -52,28 +52,34 @@ 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) -> u32 {
|
pub fn spawn_entity(&mut self, kind: EntityKind, type_id: u16, 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);
|
let entity = Entity::new(id, kind, type_id, pos, hp, attack);
|
||||||
self.add_entity(entity);
|
self.add_entity(entity);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if any entity stands on `(wx, wy)`. Goes through the chunk index, so it is
|
/// The entity standing on `(wx, wy)`, if any. Goes through the chunk index, so it
|
||||||
/// cheap enough to call per pathfinder expansion.
|
/// is cheap enough to call per pathfinder expansion.
|
||||||
pub fn occupied(&self, wx: i16, wy: i16) -> bool {
|
pub fn entity_at(&self, wx: i32, wy: i32) -> Option<u32> {
|
||||||
|
let (Ok(wx), Ok(wy)) = (i16::try_from(wx), i16::try_from(wy)) else { return None };
|
||||||
let (cx, cy) = tile_to_chunk(wx, wy);
|
let (cx, cy) = tile_to_chunk(wx, wy);
|
||||||
self.entities_in_chunk(cx, cy).iter()
|
self.entities_in_chunk(cx, cy).iter().copied()
|
||||||
.any(|id| self.entities[id].pos == (wx, wy))
|
.find(|id| self.entities[id].pos == (wx, wy))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The movement blocker predicate: a tile blocks if it is solid, outside the loaded
|
/// True if the terrain at `(wx, wy)` blocks movement: a collidable tile or outside
|
||||||
/// world, or occupied by an entity. Both the sim and the pathfinder plan with this.
|
/// the loaded world.
|
||||||
|
pub fn solid(&self, wx: i32, wy: i32) -> bool {
|
||||||
|
let (Ok(wx), Ok(wy)) = (i16::try_from(wx), i16::try_from(wy)) else { return true };
|
||||||
|
self.tile_flags(wx, wy).map_or(true, |f| f.collidable())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The movement blocker predicate: solid terrain or an entity in the way. Both the
|
||||||
|
/// sim and the pathfinder plan with this.
|
||||||
pub fn blocked(&self, wx: i32, wy: i32) -> bool {
|
pub fn blocked(&self, wx: i32, wy: i32) -> bool {
|
||||||
let Ok(wx) = i16::try_from(wx) else { return true };
|
self.solid(wx, wy) || self.entity_at(wx, wy).is_some()
|
||||||
let Ok(wy) = i16::try_from(wy) else { return true };
|
|
||||||
self.tile_flags(wx, wy).map_or(true, |f| f.collidable()) || self.occupied(wx, wy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_entity(&mut self, entity: Entity) {
|
pub fn add_entity(&mut self, entity: Entity) {
|
||||||
|
|||||||
Reference in New Issue
Block a user