the huge refactor

This commit is contained in:
2026-07-24 21:52:05 -05:00
parent 5146cc9bcc
commit 917f4b1bf0
43 changed files with 1807 additions and 3267 deletions
+237 -449
View File
@@ -1,48 +1,39 @@
use crate::action::{Action, BoardAction, SendArg};
use crate::action::{apply_push, apply_shift, apply_teleport, Action, BoardAction, Consequence, SendArg};
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::utils::{Direction, ObjectId};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::collections::HashSet;
use std::collections::{BTreeSet, HashSet, VecDeque};
use std::hash::Hash;
use std::time::Duration;
/// The bump and send reactions produced while applying a batch of actions.
/// A single `send` to an object, with an arg.
///
/// [`GameState::apply_actions`] collects these but does not fire them; the
/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a
/// bumped object reacts to the fully-updated board.
#[derive(Default)]
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)>,
}
/// Most things (bump, etc) are only triggered by player actions now.
/// However, sends still might trigger other sends! So we need to keep a
/// list of sends that we trigger in the process of resolving a list of
/// actions. When we resolve these sends, we'll keep a list of things we've
/// resolved, so we refuse to do the same send twice in a tick: this prevents
/// us from accidentally doing an infinite recursion.
#[derive(Clone, Debug)]
struct SendAction(ObjectId, String, SendArg);
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.enters.is_empty() && self.sends.is_empty()
impl Hash for SendAction {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
self.1.hash(state);
}
}
/// Records which `(object, hook/fn, args)` reactions have already fired during a
/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire
/// a key twice, so a bump/send cascade always terminates — even if two objects
/// bump each other in a cycle, each side fires at most once. Reset per invocation.
type CalledSet = HashSet<(ObjectId, String, String)>;
impl PartialEq for SendAction {
fn eq(&self, other: &Self) -> bool {
self.1 == other.1 && self.0 == other.0
}
}
impl Eq for SendAction {}
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
@@ -51,6 +42,8 @@ pub const SAY_DURATION: f64 = 3.0;
// accessing the private `action` module directly.
pub use crate::action::ScrollLine;
use crate::player::{Player, PlayerRef};
use crate::portal::Portal;
use crate::tile::{EnterResponse, LocatedObject, Tile};
/// An active scroll overlay opened by a scripted object via `scroll()`.
///
@@ -210,16 +203,11 @@ impl GameState {
pub fn run_init(&mut self) {
// Run each object's init hook in ascending id order, applying its actions
// immediately so a later object's init sees what an earlier one did.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_init_on(id);
ev.merge(self.apply_actions(actions));
self.apply_actions(actions);
}
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -240,16 +228,11 @@ impl GameState {
});
// Run each object's tick in ascending id order, applying its drained
// actions immediately so the next object sees the updated board.
let mut ev = Events::default();
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_tick_on(id, secs);
ev.merge(self.apply_actions(actions));
self.apply_actions(actions);
}
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
/// Drains the log lines collected by the script host — script `log()` output
@@ -261,302 +244,105 @@ impl GameState {
self.log.extend(lines);
}
/// Applies one object's drained `actions` to the board and returns the `bump`
/// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)).
///
/// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B applies the
/// player-stat / bubble / scroll changes after the borrow drops. The collected
/// reactions are returned rather than fired here, so the caller can run them once
/// all object hooks in this pass have applied.
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
/// Applies one object's drained `actions` to the board
fn apply_actions(&mut self, actions: Vec<BoardAction>) {
// Application-time errors (teleport/push/shift failures) go straight onto
// the shared LogSink — the same immediate channel as script `log()` output,
// so everything lands in the log in one emission order and is flushed by
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// 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;
let mut health_delta: i64 = 0;
let mut key_changes: Vec<(String, bool)> = Vec::new();
let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{
let mut board = self.board_mut();
for ba in actions {
match ba.action {
Action::Move(dir) => {
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()));
}
for ba in actions {
match ba.action {
Action::Move(dir) => {
step_object(&mut self.board_mut(), ba.source, dir);
}
Action::SetTile(tile) => {
if let Some(scr) = self.board_mut().scripting_mut(ba.source) {
scr.glyph.tile = tile;
}
Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.glyph.tile = tile;
}
}
Action::SetLight(radius) => {
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
obj.optics.glow = radius;
}
Action::SetLight(radius) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.behavior.glow = radius;
}
}
Action::SetTag {
target,
tag,
present,
} => {
if let Some(obj) = board.objects.get_mut(&target) {
if present {
obj.tags.insert(tag);
} else {
obj.tags.remove(&tag);
}
}
}
// Replace any existing bubble from this object so repeated say() calls
// don't stack visually — the new text resets the timer.
Action::Say(text, duration) => new_bubbles.push(SpeechBubble {
object_id: ba.source,
text,
remaining: duration,
}),
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
}
// Collected and fired after the board borrow drops, like bumps.
Action::Send {
target,
fn_name,
arg,
} => {
sends.push((target, fn_name, arg));
}
// Later scrolls overwrite earlier ones from the same tick.
Action::Scroll(lines) => {
new_scroll = Some(Scroll {
source: ba.source,
lines,
choice: None,
});
}
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
log_sink.error(format!(
"teleport({target},{x},{y}): out of bounds"
));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
let (ux, uy) = (x as usize, y as usize);
let blocked = matches!(
board.solid_at(ux, uy),
Some(s) if !s.player()
);
if blocked {
log_sink.error(format!(
"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
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => log_sink.error(format!(
"teleport({target},{x},{y}): no such object"
)),
Some(obj) => {
let source_solid = obj.behavior.solid;
let (old_x, old_y) = (obj.x as i64, obj.y as i64);
let blocked = source_solid
&& matches!(
board.solid_at(ux, uy),
Some(s) if s.object_id() != Some(tid)
);
if blocked {
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;
}
// 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));
}
}
}
}
}
}
Action::SetTag {
target,
tag,
present,
} => {
if let Some(obj) = self.board_mut().scripting_mut(target) {
if present {
obj.tags.insert(tag);
} else {
log_sink.error(format!(
"teleport({target},{x},{y}): invalid target id"
));
obj.tags.remove(&tag);
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if !board.in_bounds((x, y)) {
log_sink.error(format!("push({x},{y}): out of bounds"));
} else {
// 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()));
}
}
}
// Replace any existing bubble from this object so repeated say() calls
// don't stack visually — the new text resets the timer.
Action::Say(text, duration) => {
// One bubble per object: replace the existing one if present.
self.speech_bubbles
.retain(|b| b.object_id != ba.source);
self.speech_bubbles.push(
SpeechBubble {
object_id: ba.source,
text,
remaining: duration,
}
);
},
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
// apply_shift moves the named cells, returning error lines plus the
// relocations it performed (for `enter` at each destination).
Action::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,
// Accumulated and applied to `self.player.health` after the borrow drops.
Action::AlterHealth(dh) => health_delta += dh,
// Collected and applied to `self.player.keys` after the borrow drops.
Action::SetKey(color, present) => key_changes.push((color, present)),
// A grab thing despawns itself from its grab() hook.
Action::Die => {
board.remove_object(ba.source);
}
Action::Send { target, fn_name, arg} => {
self.scripts.run_send(target, &fn_name, arg);
}
Action::Scroll(lines) => {
self.active_scroll.replace(Scroll {
source: ba.source,
lines,
choice: None,
});
}
Action::Teleport { target, x, y } => {
apply_teleport(&mut self.board_mut(), target, x, y).unwrap_or_else(|e| log_sink.error(e))
}
Action::Push { x, y, dir } => {
apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e))
}
Action::Shift(cells) => {
apply_shift(&mut self.board_mut(), &cells).unwrap_or_else(|e| log_sink.error(e))
}
Action::AddGems(n) => {
self.player.borrow_mut().alter_gems(n);
},
Action::AlterHealth(dh) => {
self.player.borrow_mut().alter_health(dh)
},
Action::SetKey(color, present) => {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
log_sink.error(format!("set_key: unknown color {color:?}"));
}
},
Action::Die => {
self.board_mut().remove_object(ba.source);
}
}
}
for bubble in new_bubbles {
// One bubble per object: replace the existing one if present.
self.speech_bubbles
.retain(|b| b.object_id != bubble.object_id);
self.speech_bubbles.push(bubble);
}
if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll);
}
// Apply the net gem change (clamped at 0, since the count is unsigned).
if gem_delta != 0 {
self.player.borrow_mut().alter_gems(gem_delta);
}
// Apply the net health change (clamped to [0, max_health]).
if health_delta != 0 {
self.player.borrow_mut().alter_health(health_delta);
}
for (color, present) in key_changes {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
log_sink.error(format!("set_key: unknown color {color:?}"));
}
}
// Return the reactions for the caller's settle pass rather than firing them here.
Events {
bumps,
enters,
sends,
}
}
/// Fires the `bump` / `send` reactions in `events` (and any they cascade into)
/// until the board is quiescent, applying each hook's actions as it runs.
///
/// `called` records every `(object, hook/fn, args)` already fired this
/// invocation; a reaction whose key is already present is skipped. Since each
/// key fires at most once, the loop is finite even when objects bump each
/// other in a cycle — the guard is what makes bump-loops impossible.
fn settle(&mut self, initial: Events, called: &mut CalledSet) {
let mut pending = initial;
while !pending.is_empty() {
let mut next = Events::default();
for (id, dir) in std::mem::take(&mut pending.bumps) {
// Skip a bump already fired this pass (dedup key includes the direction).
if !called.insert((id, "bump".to_string(), format!("{dir:?}"))) {
continue;
}
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:?}"))) {
continue;
}
let actions = self.scripts.run_send(id, &fn_name, arg);
next.merge(self.apply_actions(actions));
}
pending = next;
}
self.drain_log();
}
/// Consumes the active scroll, dispatching the player's choice (if any) back
@@ -573,10 +359,6 @@ impl GameState {
// Dispatch the choice back to the source object and apply whatever it
// queues (plus any bump/send cascade), the same as a tick.
let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None);
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_log();
}
}
@@ -596,10 +378,7 @@ impl GameState {
// Find the named arrival portal on the target board (borrow then release).
let arrival = self.world.boards[target_map]
.borrow()
.portals
.iter()
.find(|p| p.name == target_entry)
.map(|p| (p.x, p.y));
.named_portal(target_entry).map(Portal::location);
let (ax, ay) = match arrival {
Some(pos) => pos,
None => {
@@ -614,10 +393,7 @@ impl GameState {
self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string();
self.board_mut().player = PlayerPos {
x: ax as i64,
y: ay as i64,
};
self.board_mut().get_mut(ax, ay).replace(Tile::Player);
self.board_mut().clear_all_queues();
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
@@ -639,77 +415,109 @@ impl GameState {
/// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) {
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();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Walking onto a grab thing (e.g. a gem) is never blocked: the player
// moves onto it and its grab() hook fires (the thing despawns itself).
grabbed = board.grab_object_at(nx, ny);
// A solid object in the way is bumped by the player — possibly through a
// chain of crates the player is shoving (see `bump_target`) — but a grab
// thing fires grab() instead of bump(), so don't also bump it.
bumped = if grabbed.is_none() {
board.bump_target(nx, ny, dir)
} else {
None
};
if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// 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() {
// 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
.iter()
.find(|p| p.x == nx && p.y == ny)
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
} else {
portal_target = None;
}
}
// 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);
let (dx, dy): (i64, i64) = dir.into();
let player_loc = self.board().player_pos();
let target = (player_loc.0 as i64 + dx, player_loc.1 as i64 + dy);
if !self.board().in_bounds(target) {
return;
}
let mut ev = Events::default();
// Fire the grab hook and apply it immediately so the grabbed thing's
// die()/alter_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed {
let actions = self.scripts.run_grab(id);
ev.merge(self.apply_actions(actions));
let (nx, ny) = (target.0 as usize, target.1 as usize);
// We need to be able to call hooks on objects, so we can't hold a mut reference to the
// board going into this match
let obj_data = {
if let Some(Tile::Object(b)) = self.board_mut().get(nx, ny) && let def = b.as_ref() {
Some((def.scripting.id, def.enter_response))
} else {
None
}
};
if let Some((id, enter_response)) = obj_data {
let actions = match enter_response {
EnterResponse::Block => {
// Call the bump hook
self.scripts.run_bump(id, dir.opposite())
}
EnterResponse::Grab => {
// Call the hook to get the actions and then stamp the player on top
let a = self.scripts.run_grab(id);
{
let mut board = self.board_mut();
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
}
a
}
EnterResponse::Push(pushable) => {
// First, can we push?
if self.board().can_push(nx, ny, dir) {
// The player is pushable, so, this amounts to the same thing and saves a
// couple replace()s
self.board_mut().push(player_loc.0, player_loc.1, dir);
vec![] // There's no push hook, just pushing doesn't call scripts
} else {
// This is actually not pushable in this way, so we're gonna bump instead:
self.scripts.run_bump(id, dir.opposite())
}
}
EnterResponse::Hook => {
vec![] // TODO this hook needs to exist and work. It's documented in tile.rb. Has implications for push as well
// plan: get rid of can_push in board. Make a board::pushes_into_hook or something, find the hook-enter
// object at the end of this chain. Trying to push calls that, if there's no hook object then it pushes, if
// that returns false then it bumps. If there is a hook object, call the hook, run the actions. If it _leaves
// the cell empty,_ then call push. Otherwise bump.
// Maybe have a pushresult enum or something that board::push returns, "hook(id, bumpid)" or "bump(id)" or "moved".
// If the situation is: `@b++h` then moving to the east, the bumpid would be b (the thing you actually touched),
// hook id would be h (the thing with the hook enterresponse). Board can identify object chains but not actually
// call hooks.
}
EnterResponse::Swap => {
let mut board = self.board_mut();
// Swap the two
let tgt = board.get_mut(nx, ny).replace(Tile::Player);
*board.get_mut(player_loc.0, player_loc.1) = tgt;
vec![]
}
EnterResponse::Squish => {
let mut board = self.board_mut();
// Stamp over it, squishing it
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
vec![]
}
};
self.apply_actions(actions);
} else {
// There's not an object there, we can just move the player
let mut board = self.board_mut();
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
}
if let Some(idx) = bumped {
// The player advanced in `dir`, so the bump arrives from the opposite side.
ev.bumps.push((idx, dir.opposite()));
// Check if we actually moved
let new_loc = self.board().player_pos();
if new_loc != player_loc {
// Portals take priority: if we're on a portal it doesn't matter what else we entered:
let portal_info = {
if let Some(Portal { target_board, target_name, ..}) = self.board().portal_at(new_loc.0, new_loc.1) {
Some((target_board.clone(), target_name.clone()))
} else { None }
};
if let Some((target_board, target_name)) = portal_info {
self.enter_board(&target_board, &target_name)
} else {
// We're still on the board, so see if we stepped on any sensors:
let sensor_ids = self.board().sensor_ids_at(new_loc.0, new_loc.1);
for id in sensor_ids {
let actions = self.scripts.run_enter(id, dir.opposite());
self.apply_actions(actions)
}
}
}
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);
self.drain_log();
}
}
@@ -736,46 +544,28 @@ struct StepOutcome {
/// 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 Some((ox, oy, solid)) = board.objects.get(&id).map(|o| (o.x, o.y, o.behavior.solid)) else {
return out;
};
let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) {
return out;
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) {
// TODO when an object pushes the player, it should still trigger actions on
// what the player is pushed into. But, for right now, just call board::push
let (loc, solid) = if let Some(obj) = board.get_hookable(id) {
(obj.location(), obj.solid())
} else { return };
if solid {
// This is a real object on the board, try and push it
board.push(loc.0, loc.1, dir);
} else {
// This is a sensor, we can just teleport it
board.move_sensor(id, dir);
}
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.
out.bumped = board.bump_target(nx, ny, dir);
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
// 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));
}
}
out
}
#[cfg(test)]
mod tests {
use super::GameState;
use crate::Direction;
use crate::archetype::{Archetype, Builtin};
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
use crate::builtin::Builtin;
use crate::board::tests::{crate_at, gem_at, open_board, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
@@ -784,9 +574,8 @@ mod tests {
fn walking_onto_a_gem_grabs_it() {
// A gem terrain cell at (1,0); expanding turns it into the builtin gem
// object running scripts/gem.rhai. The player starts at (0,0).
let mut board = open_board(3, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
board.expand_builtin_archetypes();
let mut board = open_board(3, 1, (0, 0));
gem_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.run_init();
@@ -794,8 +583,7 @@ mod tests {
// The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player.borrow().gems, 1);
assert!(game.board().objects.is_empty());
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
assert_eq!(game.board().player_pos(), (1, 0));
}
#[test]
@@ -806,7 +594,7 @@ mod tests {
// the board edge), so nothing happens and the gem is not collected.
let mut sobj = ObjectDef::new(0, 0);
sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string());
sobj.scripting.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
board.expand_builtin_archetypes();
@@ -830,7 +618,7 @@ mod tests {
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0);
obj.behavior.solid = false;
obj.script_name = Some("s".to_string());
obj.scripting.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
@@ -860,7 +648,7 @@ mod tests {
// genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0);
obj.behavior.solid = false;
obj.script_name = Some("s".to_string());
obj.scripting.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init(m) { \
@@ -887,7 +675,7 @@ mod tests {
// would have been stuck behind the delay and absent here.
let mut obj = ObjectDef::new(0, 0);
obj.behavior.solid = false;
obj.script_name = Some("s".to_string());
obj.scripting.script_name = Some("s".to_string());
let board = open_board(4, 1, (3, 0), vec![obj]);
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
@@ -917,9 +705,9 @@ mod tests {
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.behavior.solid = false;
obj.script_name = Some("spinner".to_string());
obj.scripting.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
obj.scripting.tags.insert("BUILTIN_spinner_ccw".to_string());
}
let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates {
@@ -1000,7 +788,7 @@ mod tests {
fn set_key_gives_and_takes_keys() {
let mut sobj = ObjectDef::new(0, 0);
sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string());
sobj.scripting.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
@@ -1017,7 +805,7 @@ mod tests {
// A second script can take a key.
let mut sobj2 = ObjectDef::new(0, 0);
sobj2.behavior.solid = false;
sobj2.script_name = Some("t".to_string());
sobj2.scripting.script_name = Some("t".to_string());
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([(
"t".to_string(),
@@ -1033,7 +821,7 @@ mod tests {
fn set_key_unknown_color_logs_error() {
let mut sobj = ObjectDef::new(0, 0);
sobj.behavior.solid = false;
sobj.script_name = Some("s".to_string());
sobj.scripting.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),