enter hook
This commit is contained in:
+52
-8
@@ -7,6 +7,19 @@ use crate::utils::Direction;
|
||||
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// The result of [`Board::apply_shift`]: any error lines to log, plus the
|
||||
/// `(from, to)` cell relocations the shift actually performed.
|
||||
///
|
||||
/// The `moves` let the caller fire an `enter` hook on any non-solid object each
|
||||
/// shifted solid landed on; the direction is derived best-effort from `to - from`
|
||||
/// (a shift can rotate cells that aren't cardinally adjacent).
|
||||
pub struct ShiftOutcome {
|
||||
/// Error lines (e.g. an out-of-bounds cell) for the caller to log.
|
||||
pub errors: Vec<LogLine>,
|
||||
/// Each `(from, to)` relocation a non-blocked solid underwent.
|
||||
pub moves: Vec<((i64, i64), (i64, i64))>,
|
||||
}
|
||||
|
||||
/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the
|
||||
/// main grid, drawn only when the grid cell at `(x, y)` is empty.
|
||||
///
|
||||
@@ -373,9 +386,12 @@ impl Board {
|
||||
///
|
||||
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
|
||||
/// so it is safe to call unconditionally.
|
||||
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
|
||||
/// Returns the cells the shoved solids moved **into** (each chain cell stepped
|
||||
/// one cell in `dir`), so the caller can fire `enter` on any non-solid object a
|
||||
/// pushed solid landed on. Empty when nothing moved.
|
||||
pub fn push(&mut self, x: usize, y: usize, dir: Direction) -> Vec<(usize, usize)> {
|
||||
if !self.can_push(x, y, dir) {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
let (dx, dy): (i64, i64) = dir.into();
|
||||
// can_push guaranteed the chain ends at an in-bounds passable cell, so
|
||||
@@ -391,6 +407,12 @@ impl Board {
|
||||
for &(px, py) in chain.iter().rev() {
|
||||
self.shift_solid(px, py, dx, dy);
|
||||
}
|
||||
// Each solid ended up one step along `dir`; those destination cells are
|
||||
// where an `enter` may need to fire.
|
||||
chain
|
||||
.iter()
|
||||
.map(|&(px, py)| ((px as i64 + dx) as usize, (py as i64 + dy) as usize))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
|
||||
@@ -421,6 +443,19 @@ impl Board {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the [`ObjectId`]s of the **non-solid** objects at `(x, y)`.
|
||||
///
|
||||
/// These are the targets of an `enter` hook when a solid relocates onto the
|
||||
/// cell (terrain is always solid, so only objects can be non-solid). Mirrors
|
||||
/// [`object_ids_at`](Board::object_ids_at) / [`solid_object_id_at`](Board::solid_object_id_at).
|
||||
pub fn non_solid_object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
|
||||
self.objects
|
||||
.iter()
|
||||
.filter(|(_, o)| o.x == x && o.y == y && !o.solid)
|
||||
.map(|(&id, _)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns a borrow of the actual object at `(x, y)` if any
|
||||
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
|
||||
self.objects.iter().find_map(|(&id, o)| {
|
||||
@@ -552,11 +587,16 @@ impl Board {
|
||||
}
|
||||
|
||||
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
|
||||
/// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
|
||||
pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec<LogLine> {
|
||||
/// `shift()` fn. Returns a [`ShiftOutcome`] carrying any error [`LogLine`]s for
|
||||
/// the caller to log plus the `(from, to)` relocations it performed (so the
|
||||
/// caller can fire `enter` on non-solids each moved solid landed on).
|
||||
pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> ShiftOutcome {
|
||||
// Validate all the cells are in bounds, error if not:
|
||||
if cells.iter().any(|&c| !self.in_bounds(c)) {
|
||||
return vec![LogLine::error("Called shift() with a cell out of bounds")]
|
||||
return ShiftOutcome {
|
||||
errors: vec![LogLine::error("Called shift() with a cell out of bounds")],
|
||||
moves: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Get all the Solids at these cells:
|
||||
@@ -609,15 +649,19 @@ impl Board {
|
||||
}
|
||||
}
|
||||
|
||||
// Now, move anything that we've decided is not blocked:
|
||||
// Now, move anything that we've decided is not blocked, recording each
|
||||
// relocation so the caller can fire `enter` at every destination.
|
||||
let mut moves = Vec::new();
|
||||
for (curr_idx, curr) in solids.iter().enumerate() {
|
||||
if let Some(solid) = curr && !blocked.contains(&curr_idx) {
|
||||
let origin = cells[curr_idx];
|
||||
let target = cells[(curr_idx + 1) % cells.len()];
|
||||
solid.place(self, target.0 as usize, target.1 as usize);
|
||||
moves.push((origin, target));
|
||||
}
|
||||
}
|
||||
|
||||
vec![]
|
||||
ShiftOutcome { errors: Vec::new(), moves }
|
||||
}
|
||||
|
||||
/// Clear the queues of all objects on this board: called when entering a board, objects
|
||||
@@ -975,7 +1019,7 @@ pub(crate) mod tests {
|
||||
let mut board = open_board(3, 1, (2, 0), vec![]);
|
||||
crate_at(&mut board, 0, 0);
|
||||
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
|
||||
assert_eq!(errs.len(), 1);
|
||||
assert_eq!(errs.errors.len(), 1);
|
||||
assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged
|
||||
}
|
||||
|
||||
|
||||
+131
-22
@@ -17,6 +17,9 @@ use std::time::Duration;
|
||||
struct Events {
|
||||
/// `(bumped object, direction the bump came from)` for each triggered `bump`.
|
||||
bumps: Vec<(ObjectId, Direction)>,
|
||||
/// `(entered non-solid object, direction the entrant came from)` for each
|
||||
/// `enter` — a solid relocating onto a non-solid object's cell.
|
||||
enters: Vec<(ObjectId, Direction)>,
|
||||
/// `(target object, function name, argument)` for each `send`.
|
||||
sends: Vec<(ObjectId, String, SendArg)>,
|
||||
}
|
||||
@@ -25,12 +28,13 @@ impl Events {
|
||||
/// Appends `other`'s reactions onto `self`.
|
||||
fn merge(&mut self, other: Events) {
|
||||
self.bumps.extend(other.bumps);
|
||||
self.enters.extend(other.enters);
|
||||
self.sends.extend(other.sends);
|
||||
}
|
||||
|
||||
/// Whether there is anything left to fire.
|
||||
fn is_empty(&self) -> bool {
|
||||
self.bumps.is_empty() && self.sends.is_empty()
|
||||
self.bumps.is_empty() && self.enters.is_empty() && self.sends.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +271,8 @@ impl GameState {
|
||||
// also borrows `self`) is held.
|
||||
let log_sink = self.scripts.log_sink().clone();
|
||||
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
|
||||
// `enter` reactions: a solid relocating onto a non-solid object's cell.
|
||||
let mut enters: Vec<(ObjectId, Direction)> = Vec::new();
|
||||
// Net change to player stats from AddGems / AlterHealth actions; applied
|
||||
// to `self` after the board borrow drops.
|
||||
let mut gem_delta: i64 = 0;
|
||||
@@ -282,11 +288,16 @@ impl GameState {
|
||||
for ba in actions {
|
||||
match ba.action {
|
||||
Action::Move(dir) => {
|
||||
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
|
||||
// The bump "comes from" the side the mover advanced from,
|
||||
// i.e. the opposite of its travel direction.
|
||||
let StepOutcome { bumped, entered } =
|
||||
step_object(&mut board, ba.source, dir);
|
||||
// The bump / enter "comes from" the side the mover advanced
|
||||
// from, i.e. the opposite of its travel direction.
|
||||
if let Some(bumped) = bumped {
|
||||
bumps.push((bumped, dir.opposite()));
|
||||
}
|
||||
for id in entered {
|
||||
enters.push((id, dir.opposite()));
|
||||
}
|
||||
}
|
||||
Action::SetTile(tile) => {
|
||||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
||||
@@ -359,8 +370,19 @@ impl GameState {
|
||||
"teleport(player,{x},{y}): destination is solid"
|
||||
));
|
||||
} else {
|
||||
let (old_x, old_y) = (board.player.x, board.player.y);
|
||||
board.player.x = ux as i64;
|
||||
board.player.y = uy as i64;
|
||||
// The player is solid, so it may land on non-solids:
|
||||
// fire `enter` with a best-effort came-from direction
|
||||
// (the jump is arbitrary, so it has no exact cardinal).
|
||||
if let Some(from) =
|
||||
Direction::from_delta(old_x - ux as i64, old_y - uy as i64)
|
||||
{
|
||||
for id in board.non_solid_object_ids_at(ux, uy) {
|
||||
enters.push((id, from));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Ok(tid) = ObjectId::try_from(target) {
|
||||
// Move object `tid` to (x, y). A solid destination blocks
|
||||
@@ -372,6 +394,7 @@ impl GameState {
|
||||
)),
|
||||
Some(obj) => {
|
||||
let source_solid = obj.solid;
|
||||
let (old_x, old_y) = (obj.x as i64, obj.y as i64);
|
||||
let blocked = source_solid
|
||||
&& matches!(
|
||||
board.solid_at(ux, uy),
|
||||
@@ -381,9 +404,23 @@ impl GameState {
|
||||
log_sink.error(format!(
|
||||
"teleport({target},{x},{y}): destination is solid"
|
||||
));
|
||||
} else if let Some(obj) = board.objects.get_mut(&tid) {
|
||||
obj.x = ux;
|
||||
obj.y = uy;
|
||||
} else {
|
||||
if let Some(obj) = board.objects.get_mut(&tid) {
|
||||
obj.x = ux;
|
||||
obj.y = uy;
|
||||
}
|
||||
// Only a solid mover triggers `enter`; the
|
||||
// direction is best-effort (arbitrary jump).
|
||||
if source_solid
|
||||
&& let Some(from) = Direction::from_delta(
|
||||
old_x - ux as i64,
|
||||
old_y - uy as i64,
|
||||
)
|
||||
{
|
||||
for id in board.non_solid_object_ids_at(ux, uy) {
|
||||
enters.push((id, from));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,14 +435,35 @@ impl GameState {
|
||||
if !board.in_bounds((x, y)) {
|
||||
log_sink.error(format!("push({x},{y}): out of bounds"));
|
||||
} else {
|
||||
board.push(x as usize, y as usize, dir);
|
||||
// Each pushed solid stepped one cell in `dir`; fire `enter`
|
||||
// on any non-solid it landed on (came-from `dir.opposite()`).
|
||||
for (cx, cy) in board.push(x as usize, y as usize, dir) {
|
||||
for id in board.non_solid_object_ids_at(cx, cy) {
|
||||
enters.push((id, dir.opposite()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// apply_shift moves the named cells, returning any error lines.
|
||||
// apply_shift moves the named cells, returning error lines plus the
|
||||
// relocations it performed (for `enter` at each destination).
|
||||
Action::Shift(cells) => {
|
||||
for line in board.apply_shift(&cells) {
|
||||
let outcome = board.apply_shift(&cells);
|
||||
for line in outcome.errors {
|
||||
log_sink.line(line);
|
||||
}
|
||||
for (from, to) in outcome.moves {
|
||||
// A shift can rotate non-adjacent cells, so the came-from
|
||||
// direction is best-effort (dominant axis of the jump).
|
||||
if let Some(from_dir) =
|
||||
Direction::from_delta(from.0 - to.0, from.1 - to.1)
|
||||
{
|
||||
for id in
|
||||
board.non_solid_object_ids_at(to.0 as usize, to.1 as usize)
|
||||
{
|
||||
enters.push((id, from_dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Accumulated and applied to `self.player.gems` after the borrow drops.
|
||||
Action::AddGems(n) => gem_delta += n,
|
||||
@@ -443,7 +501,11 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
// Return the reactions for the caller's settle pass rather than firing them here.
|
||||
Events { bumps, sends }
|
||||
Events {
|
||||
bumps,
|
||||
enters,
|
||||
sends,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires the `bump` / `send` reactions in `events` (and any they cascade into)
|
||||
@@ -465,6 +527,14 @@ impl GameState {
|
||||
let actions = self.scripts.run_bump(id, dir);
|
||||
next.merge(self.apply_actions(actions));
|
||||
}
|
||||
for (id, dir) in std::mem::take(&mut pending.enters) {
|
||||
// Skip an enter already fired this pass (dedup key includes the direction).
|
||||
if !called.insert((id, "enter".to_string(), format!("{dir:?}"))) {
|
||||
continue;
|
||||
}
|
||||
let actions = self.scripts.run_enter(id, dir);
|
||||
next.merge(self.apply_actions(actions));
|
||||
}
|
||||
for (id, fn_name, arg) in std::mem::take(&mut pending.sends) {
|
||||
// Dedup key: target + function name + argument.
|
||||
if !called.insert((id, fn_name.clone(), format!("{arg:?}"))) {
|
||||
@@ -560,6 +630,9 @@ impl GameState {
|
||||
let bumped;
|
||||
let grabbed;
|
||||
let portal_target;
|
||||
// Non-solid objects the player (or the crates it shoved) landed on this move,
|
||||
// collected while the board is borrowed and fired as `enter` after the borrow.
|
||||
let mut entered: Vec<ObjectId> = Vec::new();
|
||||
{
|
||||
let (dx, dy): (i64, i64) = dir.into();
|
||||
let mut board = self.board_mut();
|
||||
@@ -583,10 +656,15 @@ impl GameState {
|
||||
// Don't push a grab thing aside — walk onto it. Otherwise shove any
|
||||
// pushable chain out of the way (no-op when there's nothing to push).
|
||||
if grabbed.is_none() {
|
||||
board.push(nx, ny, dir);
|
||||
// Each pushed solid lands on a cell that may hold a non-solid.
|
||||
for (cx, cy) in board.push(nx, ny, dir) {
|
||||
entered.extend(board.non_solid_object_ids_at(cx, cy));
|
||||
}
|
||||
}
|
||||
board.player.x = nx as i64;
|
||||
board.player.y = ny as i64;
|
||||
// The player is solid, so any non-solid on its new cell gets `enter`.
|
||||
entered.extend(board.non_solid_object_ids_at(nx, ny));
|
||||
// Check for a portal at the new position; clone strings to release the borrow.
|
||||
portal_target = board
|
||||
.portals
|
||||
@@ -597,7 +675,7 @@ impl GameState {
|
||||
portal_target = None;
|
||||
}
|
||||
}
|
||||
// A portal takes priority: board transitions skip the bump hook.
|
||||
// A portal takes priority: board transitions skip the bump/enter hooks.
|
||||
if let Some((target_map, target_entry)) = portal_target {
|
||||
self.enter_board(&target_map, &target_entry);
|
||||
return;
|
||||
@@ -613,6 +691,10 @@ impl GameState {
|
||||
// The player advanced in `dir`, so the bump arrives from the opposite side.
|
||||
ev.bumps.push((idx, dir.opposite()));
|
||||
}
|
||||
for id in entered {
|
||||
// The player advanced in `dir`, so it entered from the opposite side.
|
||||
ev.enters.push((id, dir.opposite()));
|
||||
}
|
||||
// Settle the grab/bump reactions (and any they cascade into) before returning.
|
||||
let mut called = CalledSet::new();
|
||||
self.settle(ev, &mut called);
|
||||
@@ -620,33 +702,60 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves object `id` one cell in `dir` on `board`, returning the object it bumped
|
||||
/// (if any) for the caller to resolve after the board borrow drops.
|
||||
/// What a [`step_object`] call produced, for the caller to resolve after the board
|
||||
/// borrow drops.
|
||||
struct StepOutcome {
|
||||
/// The solid object the move pressed into (directly or at a crate-chain's end),
|
||||
/// which receives a `bump`.
|
||||
bumped: Option<ObjectId>,
|
||||
/// Non-solid objects a solid landed on this move — the mover itself (only if it
|
||||
/// is solid) and every crate it shoved — each of which receives an `enter`.
|
||||
entered: Vec<ObjectId>,
|
||||
}
|
||||
|
||||
/// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter`
|
||||
/// reactions for the caller to resolve after the board borrow drops.
|
||||
///
|
||||
/// The move itself proceeds only if the target is in bounds and either passable or a
|
||||
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
|
||||
/// bump is recorded for the solid object the move presses into — directly, or at the
|
||||
/// end of a chain of crates being shoved (see [`Board::bump_target`]) — whether it
|
||||
/// gets pushed aside or blocks the move. Walls and crates carry no script, so only
|
||||
/// solid objects yield a bump.
|
||||
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
|
||||
/// solid objects yield a bump. An `enter` is recorded for every non-solid object a
|
||||
/// solid lands on: the mover's destination cell (only when the mover is itself solid)
|
||||
/// and each cell a pushed crate moved into (crates are always solid entrants).
|
||||
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
|
||||
let mut out = StepOutcome {
|
||||
bumped: None,
|
||||
entered: Vec::new(),
|
||||
};
|
||||
let (dx, dy): (i64, i64) = dir.into();
|
||||
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
|
||||
let Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.solid)) else {
|
||||
return out;
|
||||
};
|
||||
let target = (ox as i64 + dx, oy as i64 + dy);
|
||||
if !board.in_bounds(target) {
|
||||
return None;
|
||||
return out;
|
||||
}
|
||||
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
||||
// Capture the bumped object before any push relocates it (its id is stable).
|
||||
// Walks through a pushed crate chain to the object it presses against.
|
||||
let bumped = board.bump_target(nx, ny, dir);
|
||||
out.bumped = board.bump_target(nx, ny, dir);
|
||||
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
|
||||
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
|
||||
// Shove a crate/object out of the way (no-op otherwise); each pushed solid
|
||||
// may land on a non-solid, which gets `enter` regardless of the mover.
|
||||
for (cx, cy) in board.push(nx, ny, dir) {
|
||||
out.entered.extend(board.non_solid_object_ids_at(cx, cy));
|
||||
}
|
||||
let obj = board.objects.get_mut(&id).expect("id checked above");
|
||||
obj.x = nx;
|
||||
obj.y = ny;
|
||||
// Only a solid mover triggers `enter` on non-solids under its own new cell.
|
||||
if solid {
|
||||
out.entered.extend(board.non_solid_object_ids_at(nx, ny));
|
||||
}
|
||||
}
|
||||
bumped
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+15
-1
@@ -8,6 +8,9 @@
|
||||
//! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]).
|
||||
//! - `bump(me, dir)` — run when a solid (the player, an object, or a pushed crate) presses into this
|
||||
//! object's cell, with the [`Direction`] the bump came *from*; see [`ScriptHost::run_bump`].
|
||||
//! - `enter(me, dir)` — run when a solid (the player, an object, or a pushed crate) relocates *onto*
|
||||
//! this **non-solid** object's cell, with the [`Direction`] the entrant came *from* (best-effort for
|
||||
//! teleport/shift); see [`ScriptHost::run_enter`].
|
||||
//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`].
|
||||
//! Typically adds a stat + `die()`s.
|
||||
//!
|
||||
@@ -63,6 +66,7 @@ struct CompiledScript {
|
||||
has_tick: bool,
|
||||
has_bump: bool,
|
||||
has_grab: bool,
|
||||
has_enter: bool,
|
||||
}
|
||||
|
||||
impl CompiledScript {
|
||||
@@ -71,7 +75,8 @@ impl CompiledScript {
|
||||
Hook::Init => self.has_init,
|
||||
Hook::Tick => self.has_tick,
|
||||
Hook::Grab => self.has_grab,
|
||||
Hook::Bump => self.has_bump
|
||||
Hook::Bump => self.has_bump,
|
||||
Hook::Enter => self.has_enter
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +168,7 @@ impl ScriptHost {
|
||||
has_tick: defines("tick", 2),
|
||||
has_bump: defines("bump", 2),
|
||||
has_grab: defines("grab", 1),
|
||||
has_enter: defines("enter", 2),
|
||||
ast,
|
||||
},
|
||||
);
|
||||
@@ -262,6 +268,14 @@ impl ScriptHost {
|
||||
self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0)
|
||||
}
|
||||
|
||||
/// Calls `enter(dir)` on the object with [`ObjectId`] `id`, if it defines the
|
||||
/// hook, and returns the actions it drained. Fired when a solid (the player, an
|
||||
/// object, or a pushed crate) relocates onto this non-solid object's cell; `dir`
|
||||
/// is the [`Direction`] the entrant came *from* (best-effort for teleport/shift).
|
||||
pub(crate) fn run_enter(&mut self, id: ObjectId, dir: Direction) -> Vec<BoardAction> {
|
||||
self.run_hook_on_one(Hook::Enter, id, Some(Dynamic::from(dir)), 0.0)
|
||||
}
|
||||
|
||||
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
|
||||
/// hook, and returns the actions it drained.
|
||||
///
|
||||
|
||||
@@ -59,6 +59,14 @@ fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||
o
|
||||
}
|
||||
|
||||
/// Returns a **non-solid** `ObjectDef` at `(x, y)` bound to the named script — the
|
||||
/// kind that receives an `enter` hook when a solid relocates onto its cell.
|
||||
fn nonsolid_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||
let mut o = scripted_object(x, y, script);
|
||||
o.solid = false;
|
||||
o
|
||||
}
|
||||
|
||||
/// Flattens each log line into a single string for easy assertions.
|
||||
fn log_texts(game: &GameState) -> Vec<String> {
|
||||
game.log
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{board_with_object, log_texts, scripted_object, scripts_from};
|
||||
use crate::board::tests::open_board;
|
||||
use super::{board_with_object, log_texts, nonsolid_object, scripted_object, scripts_from};
|
||||
use crate::board::tests::{crate_at, open_board};
|
||||
use crate::game::{GameState, ScrollLine};
|
||||
use crate::utils::Direction;
|
||||
use std::time::Duration;
|
||||
@@ -406,3 +406,112 @@ fn a_send_cycle_terminates_via_the_called_guard() {
|
||||
// A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops.
|
||||
assert_eq!(log_texts(&game), vec!["B", "A"]);
|
||||
}
|
||||
|
||||
// ── enter hook ──────────────────────────────────────────────────────────────
|
||||
// `enter(me, dir)` fires on a non-solid object when a solid relocates onto its
|
||||
// cell. `dir` is the side the entrant came from (opposite the travel direction
|
||||
// for a cardinal move/push; best-effort for teleport/shift).
|
||||
|
||||
#[test]
|
||||
fn player_walking_onto_a_nonsolid_fires_enter_from_the_travel_side() {
|
||||
// The player walks East onto a non-solid trigger; it entered from the West.
|
||||
let board = open_board(3, 1, (0, 0), vec![nonsolid_object(1, 0, "e")]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
|
||||
);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
// The player is not blocked by a non-solid — it moves onto the cell.
|
||||
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_moving_onto_a_nonsolid_fires_enter() {
|
||||
// A solid mover ticks East onto a non-solid trigger sharing the destination.
|
||||
let mover = scripted_object(0, 0, "m");
|
||||
let trigger = nonsolid_object(1, 0, "e");
|
||||
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_a_crate_onto_a_nonsolid_fires_enter() {
|
||||
// Player pushes a crate East onto a non-solid trigger's cell; the crate is the
|
||||
// solid entrant, so `enter` fires (from West).
|
||||
let mut board = open_board(4, 1, (0, 0), vec![nonsolid_object(2, 0, "e")]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
|
||||
);
|
||||
game.run_init();
|
||||
game.try_move(Direction::East);
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleporting_onto_a_nonsolid_fires_enter() {
|
||||
// A solid object teleports itself from (0,0) onto the non-solid trigger at (1,0).
|
||||
let mover = scripted_object(0, 0, "t");
|
||||
let trigger = nonsolid_object(1, 0, "e");
|
||||
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("t", "fn init(m) { teleport(m.id, 1, 0); }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
// Best-effort direction: the jump was one cell east, so it entered from West.
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_a_crate_onto_a_nonsolid_fires_enter() {
|
||||
// An object shifts the ring [(1,0),(2,0)]: the crate at (1,0) rotates onto the
|
||||
// non-solid trigger at (2,0), firing `enter` (best-effort West).
|
||||
let shifter = nonsolid_object(0, 0, "s");
|
||||
let trigger = nonsolid_object(2, 0, "e");
|
||||
let mut board = open_board(4, 1, (3, 0), vec![shifter, trigger]);
|
||||
crate_at(&mut board, 1, 0);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("s", "fn init(m) { shift([[1, 0], [2, 0]]); }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonsolid_mover_onto_a_nonsolid_does_not_fire_enter() {
|
||||
// Only *solid* entrants trigger enter: a non-solid mover walking onto another
|
||||
// non-solid must NOT fire it.
|
||||
let mover = nonsolid_object(0, 0, "m");
|
||||
let trigger = nonsolid_object(1, 0, "e");
|
||||
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
|
||||
let mut game = GameState::with_scripts(
|
||||
board,
|
||||
scripts_from(&[
|
||||
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
|
||||
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
|
||||
]),
|
||||
);
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(16));
|
||||
assert!(!log_texts(&game).iter().any(|t| t.starts_with("entered")));
|
||||
}
|
||||
|
||||
+25
-2
@@ -376,6 +376,28 @@ impl Direction {
|
||||
Direction::West => Direction::East,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cardinal direction of the dominant axis of a `(dx, dy)` displacement,
|
||||
/// or `None` when the displacement is zero.
|
||||
///
|
||||
/// A one-cell cardinal move maps to its obvious direction; for an arbitrary
|
||||
/// relocation (e.g. a `teleport` or `shift` that jumps a solid several cells
|
||||
/// or diagonally) it picks whichever axis moved farther — a **best-effort**
|
||||
/// direction used to fill in the `enter` hook's `dir` when there is no exact
|
||||
/// cardinal travel direction. Ties (|dx| == |dy|, dx != 0) resolve to the
|
||||
/// horizontal axis.
|
||||
pub fn from_delta(dx: i64, dy: i64) -> Option<Direction> {
|
||||
if dx == 0 && dy == 0 {
|
||||
return None;
|
||||
}
|
||||
// Compare magnitudes so a longer horizontal jump reads as East/West and a
|
||||
// longer vertical jump as North/South; the horizontal axis wins ties.
|
||||
if dx.abs() >= dy.abs() {
|
||||
Some(if dx > 0 { Direction::East } else { Direction::West })
|
||||
} else {
|
||||
Some(if dy > 0 { Direction::South } else { Direction::North })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value that can be stored in a board's script registry across board transitions.
|
||||
@@ -439,7 +461,7 @@ impl From<Direction> for (i64, i64) {
|
||||
|
||||
#[derive(Copy,Clone,Debug, PartialEq)]
|
||||
pub enum Hook {
|
||||
Init, Tick, Bump, Grab
|
||||
Init, Tick, Bump, Grab, Enter
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
@@ -448,7 +470,8 @@ impl Hook {
|
||||
Hook::Init => "init",
|
||||
Hook::Bump => "bump",
|
||||
Hook::Grab => "grab",
|
||||
Hook::Tick => "tick"
|
||||
Hook::Tick => "tick",
|
||||
Hook::Enter => "enter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user