spinners and gems
This commit is contained in:
+168
-16
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user