the huge refactor
This commit is contained in:
+237
-449
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user