spinners and gems

This commit is contained in:
2026-06-21 18:27:45 -05:00
parent 9b53552f4a
commit d4b9278838
17 changed files with 626 additions and 56 deletions
+168 -16
View File
@@ -210,6 +210,12 @@ impl GameState {
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
// Grab hooks to fire (player walked into / pushed a grab thing); fired after
// the board borrow drops, like bumps.
let mut grabs: Vec<ObjectId> = Vec::new();
// Net change to the player's gem count from AddGems actions; applied to
// `self` after the board borrow drops.
let mut gem_delta: i64 = 0;
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
@@ -219,9 +225,13 @@ impl GameState {
for ba in actions {
match ba.action {
Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
let outcome = step_object(&mut board, ba.source, dir);
if let Some(bumped) = outcome.bumped {
bumps.push((bumped, ba.source as i64));
}
if let Some(grabbed) = outcome.grabbed {
grabs.push(grabbed);
}
}
Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
@@ -299,15 +309,32 @@ impl GameState {
}
// 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)) {
board.push(x as usize, y as usize, dir);
} else {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
} else if let Some(grabbed) =
board.pushed_grab_into_player(x as usize, y as usize, dir)
{
// A grab thing shoved into the player is grabbed, not pushed.
grabs.push(grabbed);
} else {
board.push(x as usize, y as usize, dir);
}
}
// apply_swap reads all sources then writes all destinations, so a
// whole batch of moves resolves simultaneously (cycles included).
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
// A grab thing swapped onto the player is reported back for its
// grab() hook (fired in phase B with the other grabs).
Action::Swap(pairs) => {
let (errs, grabbed) = board.apply_swap(&pairs);
logs.extend(errs);
grabs.extend(grabbed);
}
// Accumulated and applied to `self.player_gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n,
// A grab thing despawns itself from its grab() hook.
Action::Die => {
board.remove_object(ba.source);
}
}
}
}
@@ -321,9 +348,18 @@ impl GameState {
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_gems = (self.player_gems as i64 + gem_delta).max(0) as u32;
}
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
}
// Fire grab() hooks (player walked into / pushed a grab thing). The hook
// typically adds a stat and calls die(); those actions resolve next pass.
for grabbed in grabs {
self.scripts.run_grab(grabbed);
}
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
}
@@ -403,6 +439,7 @@ impl GameState {
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
pub fn try_move(&mut self, dir: Direction) {
let bumped;
let grabbed;
let portal_target;
{
let (dx, dy): (i32, i32) = dir.into();
@@ -412,13 +449,21 @@ impl GameState {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// A solid object in the way is bumped by the player (id -1).
// 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 (id -1) — but a
// grab thing fires grab() instead of bump(), so don't also bump it.
bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
Some(Solid::Object(_)) if grabbed.is_none() => board.solid_object_id_at(nx, ny),
_ => None,
};
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
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() {
board.push(nx, ny, dir);
}
board.player.x = nx as i32;
board.player.y = ny as i32;
// Check for a portal at the new position; clone strings to release the borrow.
@@ -436,6 +481,12 @@ impl GameState {
self.enter_board(&target_map, &target_entry);
return;
}
// Fire the grab hook and resolve it immediately so the grabbed thing's
// die()/add_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed {
self.scripts.run_grab(id);
self.resolve();
}
if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1);
self.drain_errors();
@@ -443,22 +494,46 @@ impl GameState {
}
}
/// Moves object `id` one cell in `dir` on `board`, returning the [`ObjectId`] of a
/// solid object it bumped (its target cell was occupied by that object), if any.
/// The side effects of a [`step_object`] call that the caller resolves after the
/// board borrow drops: a `bump`ed object and/or a `grab`bed object.
#[derive(Default)]
struct StepOutcome {
/// A solid object the mover collided with (target cell was occupied by it).
bumped: Option<ObjectId>,
/// A grab object the move shoved into the player (its `grab()` hook should fire).
grabbed: Option<ObjectId>,
}
/// Moves object `id` one cell in `dir` on `board`, returning the objects it bumped
/// and/or grabbed (see [`StepOutcome`]).
///
/// 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 whenever a solid object occupies the target — whether it gets
/// pushed aside or blocks the move — since something tried to move into it. 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> {
///
/// If the push would shove a **grab** thing into the player (see
/// [`Board::pushed_grab_into_player`]), the push is skipped and the grab thing is
/// reported instead of sliding the player along.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> StepOutcome {
let (dx, dy): (i32, i32) = dir.into();
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
let Some((ox, oy)) = board.objects.get(&id).map(|o| (o.x, o.y)) else {
return StepOutcome::default();
};
let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) {
return None;
return StepOutcome::default();
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// A push that would drive a grab thing into the player grabs it instead of
// sliding the player; skip the move entirely (the grab thing despawns itself).
if let Some(grabbed) = board.pushed_grab_into_player(nx, ny, dir) {
return StepOutcome {
bumped: None,
grabbed: Some(grabbed),
};
}
// Capture the bumped object before any push relocates it (its id is stable).
let bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
@@ -470,18 +545,95 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
obj.x = nx;
obj.y = ny;
}
bumped
StepOutcome {
bumped,
grabbed: None,
}
}
#[cfg(test)]
mod tests {
use super::GameState;
use crate::Direction;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
#[test]
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::Gem);
board.expand_builtin_archetypes();
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
// The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player_gems, 1);
assert!(game.board().objects.is_empty());
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
}
#[test]
fn pushing_a_gem_into_the_player_grabs_it() {
// A non-solid script object at (0,0) pushes the gem at (1,0) east into the
// player at (2,0); the gem is grabbed rather than sliding the player.
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Gem);
board.expand_builtin_archetypes();
let scripts = HashMap::from([(
"s".to_string(),
"fn tick(dt) { if Queue.length() == 0 { push(1, 0, East); } }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
// First tick: the push resolves into a grab (gem's grab() is queued).
// Second tick: the gem's add_gems()/die() resolve.
game.tick(Duration::from_millis(16));
game.tick(Duration::from_millis(16));
assert_eq!(game.player_gems, 1);
// The gem (the only non-script object) is gone; the player never moved.
assert!(!game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
#[test]
fn swapping_a_gem_into_the_player_grabs_it() {
// A non-solid script object swaps the gem at (1,0) onto the player at (2,0).
// apply_swap reports the grab; the gem's grab() collects it and die()s.
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let mut board = open_board(3, 1, (2, 0), vec![sobj]);
stamp(&mut board, 1, 0, Archetype::Gem);
board.expand_builtin_archetypes();
let scripts = HashMap::from([(
"s".to_string(),
"fn tick(dt) { if Queue.length() == 0 { swap([[1, 0, 2, 0]]); } }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
// First tick: the swap reports the grab (gem's grab() is queued).
// Second tick: the gem's add_gems()/die() resolve.
game.tick(Duration::from_millis(16));
game.tick(Duration::from_millis(16));
assert_eq!(game.player_gems, 1);
assert!(!game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
/// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0);