1048 lines
48 KiB
Rust
1048 lines
48 KiB
Rust
use crate::action::{Action, BoardAction, SendArg};
|
||
use crate::board::Board;
|
||
use crate::log::LogLine;
|
||
use crate::script::ScriptHost;
|
||
use crate::utils::{Direction, ObjectId, PlayerPos};
|
||
use crate::world::World;
|
||
use std::cell::{Ref, RefMut};
|
||
use std::collections::HashSet;
|
||
use std::time::Duration;
|
||
|
||
/// The bump and send reactions produced while applying a batch of actions.
|
||
///
|
||
/// [`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)>,
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// 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)>;
|
||
|
||
/// How long a `say()` speech bubble stays on screen, in seconds.
|
||
pub const SAY_DURATION: f64 = 3.0;
|
||
|
||
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
|
||
// accessing the private `action` module directly.
|
||
pub use crate::action::ScrollLine;
|
||
use crate::player::{Player, PlayerRef};
|
||
|
||
/// An active scroll overlay opened by a scripted object via `scroll()`.
|
||
///
|
||
/// While a `Scroll` is present on [`GameState`], the front-end should display
|
||
/// the overlay and pause ticks. When the player selects a choice (or dismisses),
|
||
/// the front-end sets [`choice`](Scroll::choice); [`GameState::tick`] calls
|
||
/// [`GameState::handle_scroll`] at the start of each tick to dispatch and clear it.
|
||
pub struct Scroll {
|
||
/// The object whose `scroll()` call opened this overlay.
|
||
pub source: ObjectId,
|
||
/// The lines of content to display.
|
||
pub lines: Vec<ScrollLine>,
|
||
/// The choice key the player selected, or `None` if dismissed without a choice.
|
||
/// Set by the front-end; consumed by [`GameState::handle_scroll`].
|
||
pub choice: Option<String>,
|
||
}
|
||
|
||
/// An active speech bubble emitted by a scripted object via `say()`.
|
||
pub struct SpeechBubble {
|
||
/// The object whose `say()` call created this bubble.
|
||
pub object_id: ObjectId,
|
||
/// The text to display.
|
||
pub text: String,
|
||
/// Seconds remaining before the bubble disappears.
|
||
pub remaining: f64,
|
||
}
|
||
|
||
/// Holds the active game world and provides game-logic operations.
|
||
///
|
||
/// `GameState` is the boundary between the engine (rendering, input) and the
|
||
/// game data. It owns a [`World`] (all boards + world scripts) and tracks which
|
||
/// board is currently active. Front-ends reach the active board through
|
||
/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts
|
||
/// read the board and queue mutations via commands applied by `resolve` after
|
||
/// each script batch.
|
||
pub struct GameState {
|
||
/// All boards and world-level scripts.
|
||
world: World,
|
||
/// Key of the currently active board in [`world.boards`](World::boards).
|
||
current_board_name: String,
|
||
/// The in-game message log, oldest first (newest pushed at the end).
|
||
pub log: Vec<LogLine>,
|
||
/// The Rhai scripting runtime for the active board's objects.
|
||
scripts: ScriptHost,
|
||
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
|
||
pub speech_bubbles: Vec<SpeechBubble>,
|
||
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
|
||
/// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming.
|
||
pub active_scroll: Option<Scroll>,
|
||
/// Remaining seconds for the board-entry transition animation, set to `1.0`
|
||
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
|
||
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
|
||
pub board_transition: Option<f64>,
|
||
/// The game-global player state (health, gems, keys) — see [`Player`]. Not
|
||
/// per-board: it persists across board transitions, unlike the per-board
|
||
/// position in [`Board::player`](crate::board::Board::player). Scripts mutate
|
||
/// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it.
|
||
pub player: PlayerRef,
|
||
}
|
||
|
||
impl GameState {
|
||
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
|
||
///
|
||
/// The starting board's objects are compiled and registered with the
|
||
/// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here;
|
||
/// call [`run_init`](GameState::run_init) once the game is ready to start.
|
||
/// Compile errors are surfaced into the log immediately.
|
||
pub fn from_world(world: World) -> Self {
|
||
let name = world.start.clone();
|
||
let player = Player::new_ref();
|
||
let host = ScriptHost::new(
|
||
world
|
||
.boards
|
||
.get(&name)
|
||
.expect("world::load guarantees start board exists").clone(),
|
||
player.clone(),
|
||
&world.scripts,
|
||
);
|
||
let mut state = Self {
|
||
world,
|
||
current_board_name: name,
|
||
log: Vec::new(),
|
||
scripts: host,
|
||
speech_bubbles: Vec::new(),
|
||
active_scroll: None,
|
||
board_transition: None,
|
||
player
|
||
};
|
||
state.drain_log();
|
||
state
|
||
}
|
||
|
||
/// Creates a `GameState` from a single [`Board`] with no scripts.
|
||
///
|
||
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
|
||
#[cfg(test)]
|
||
pub fn new(board: Board) -> Self {
|
||
Self::with_scripts(board, std::collections::HashMap::new())
|
||
}
|
||
|
||
/// Creates a `GameState` from a single [`Board`] and an explicit script pool.
|
||
///
|
||
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
|
||
#[cfg(test)]
|
||
pub fn with_scripts(board: Board, scripts: std::collections::HashMap<String, String>) -> Self {
|
||
// Wrap the bare board in Rc<RefCell<Board>> to match World::boards storage.
|
||
let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board));
|
||
let world = World {
|
||
name: String::new(),
|
||
start: "board".to_string(),
|
||
torch: crate::fov::SIGHT_RADIUS as u32,
|
||
scripts,
|
||
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
|
||
};
|
||
Self::from_world(world)
|
||
}
|
||
|
||
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
||
pub fn board(&self) -> Ref<'_, Board> {
|
||
self.world.boards[&self.current_board_name].borrow()
|
||
}
|
||
|
||
/// Borrows the active board for mutation.
|
||
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
||
self.world.boards[&self.current_board_name].borrow_mut()
|
||
}
|
||
|
||
/// The name (key) of the currently active board within the world.
|
||
pub fn current_board_name(&self) -> &str {
|
||
&self.current_board_name
|
||
}
|
||
|
||
/// The player's torch radius (world-wide config; see [`World::torch`]),
|
||
/// passed to [`Board::lighting`] when rendering a dark board.
|
||
pub fn torch(&self) -> u32 {
|
||
self.world.torch
|
||
}
|
||
|
||
/// Returns a clone of the `Rc` for the active board.
|
||
///
|
||
/// Lets a front-end hold a reference to the current board across a board
|
||
/// transition — the old board stays alive in `world.boards`, so the clone
|
||
/// keeps it reachable even after `current_board_name` changes.
|
||
pub fn board_rc(&self) -> std::rc::Rc<std::cell::RefCell<Board>> {
|
||
self.world.boards[&self.current_board_name].clone()
|
||
}
|
||
|
||
/// Appends a styled message to the log.
|
||
pub fn log(&mut self, line: LogLine) {
|
||
self.log.push(line);
|
||
}
|
||
|
||
/// Runs the `init()` hook of every scripted object, pumps their queues, and
|
||
/// resolves the resulting actions. Call once, after the whole map is loaded and
|
||
/// the game is about to start — never during map deserialization, since a script
|
||
/// may inspect the board.
|
||
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));
|
||
}
|
||
// 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).
|
||
/// Called once per frame by the front-end's game loop. First processes any active
|
||
/// scroll (dispatching the player's choice if set), then drives object tick hooks
|
||
/// and resolves queued actions.
|
||
pub fn tick(&mut self, dt: Duration) {
|
||
// Process any pending scroll choice before running scripts; ticks are
|
||
// suppressed by the front-end while a scroll overlay is visible, so
|
||
// this runs exactly once per player interaction with a scroll.
|
||
self.handle_scroll();
|
||
let secs = dt.as_secs_f64();
|
||
// Expire speech bubbles once per frame, before applying new actions so a
|
||
// fresh say() this frame isn't immediately culled.
|
||
self.speech_bubbles.retain_mut(|b| {
|
||
b.remaining -= secs;
|
||
b.remaining > 0.0
|
||
});
|
||
// 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));
|
||
}
|
||
// 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
|
||
/// plus engine/runtime errors — into the game log.
|
||
fn drain_log(&mut self) {
|
||
// TODO: errors are only logged for now. This is the place to halt execution /
|
||
// set an error state when a script faults.
|
||
let lines = self.scripts.take_logs();
|
||
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 {
|
||
// 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()));
|
||
}
|
||
}
|
||
Action::SetTile(tile) => {
|
||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
||
obj.glyph.tile = tile;
|
||
}
|
||
}
|
||
Action::SetLight(radius) => {
|
||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
||
obj.light = 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.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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
log_sink.error(format!(
|
||
"teleport({target},{x},{y}): invalid target id"
|
||
));
|
||
}
|
||
}
|
||
// 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()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// Consumes the active scroll, dispatching the player's choice (if any) back
|
||
/// to the source object via `send`.
|
||
///
|
||
/// Called automatically by [`tick`](GameState::tick) as its first step. The
|
||
/// front-end sets [`Scroll::choice`] before resuming ticks; if no choice was
|
||
/// made (player dismissed), `choice` stays `None` and the scroll is cleared
|
||
/// without dispatching.
|
||
pub fn handle_scroll(&mut self) {
|
||
if let Some(scroll) = self.active_scroll.take()
|
||
&& let Some(choice) = scroll.choice
|
||
{
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
/// Switches the active board, placing the player at the named arrival portal,
|
||
/// rebuilding the script host, and running `init()` hooks on the new board's objects.
|
||
///
|
||
/// Called automatically by [`try_move`](GameState::try_move) when the player
|
||
/// steps onto a portal. Front-ends may check [`board_transition`](GameState::board_transition)
|
||
/// to play a visual effect during the switch.
|
||
pub fn enter_board(&mut self, target_map: &str, target_entry: &str) {
|
||
if !self.world.boards.contains_key(target_map) {
|
||
self.log.push(LogLine::error(format!(
|
||
"portal target board {target_map:?} not found"
|
||
)));
|
||
return;
|
||
}
|
||
// 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));
|
||
let (ax, ay) = match arrival {
|
||
Some(pos) => pos,
|
||
None => {
|
||
self.log.push(LogLine::error(format!(
|
||
"portal entry {target_entry:?} not found on board {target_map:?}"
|
||
)));
|
||
return;
|
||
}
|
||
};
|
||
// Clear per-board transient state.
|
||
self.speech_bubbles.clear();
|
||
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().clear_all_queues();
|
||
// Rebuild the script host for the new board's objects.
|
||
self.scripts = ScriptHost::new(
|
||
self.world.boards[&self.current_board_name].clone(),
|
||
self.player.clone(),
|
||
&self.world.scripts,
|
||
);
|
||
// Stub hook for the front-end transition animation (1 second).
|
||
self.board_transition = Some(1.0);
|
||
// Run init hooks and resolve their queued actions.
|
||
self.run_init();
|
||
}
|
||
|
||
/// Attempts to move the player one cell in `dir`.
|
||
///
|
||
/// The move is ignored if the target cell is out of bounds, or it is neither
|
||
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
|
||
/// caller does not need to check). If a solid object lies in the path — directly
|
||
/// 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);
|
||
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));
|
||
}
|
||
if let Some(idx) = bumped {
|
||
// 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);
|
||
self.drain_log();
|
||
}
|
||
}
|
||
|
||
/// 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. 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.solid)) else {
|
||
return out;
|
||
};
|
||
let target = (ox as i64 + dx, oy as i64 + dy);
|
||
if !board.in_bounds(target) {
|
||
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.
|
||
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::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::Builtin(Builtin::Gem, "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.borrow().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_does_not_grab() {
|
||
// A non-solid script object at (0,0) pushes the gem at (1,0) east toward the
|
||
// player at (2,0). Grab now fires only on player movement, so the gem is an
|
||
// ordinary solid here: the chain can't move (the player is backed against
|
||
// the board edge), so nothing happens and the gem is not collected.
|
||
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::Builtin(Builtin::Gem, "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();
|
||
|
||
game.tick(Duration::from_millis(16));
|
||
game.tick(Duration::from_millis(16));
|
||
|
||
// No grab: the gem is untouched and the player never moved.
|
||
assert_eq!(game.player.borrow().gems, 0);
|
||
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);
|
||
obj.solid = false;
|
||
obj.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())]);
|
||
let mut game = GameState::with_scripts(board, scripts);
|
||
game.run_init();
|
||
game
|
||
}
|
||
|
||
#[test]
|
||
fn script_shift_rotates_crates() {
|
||
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
|
||
// crate moves to (3,0), empty moves back to (2,0).
|
||
let mut game = game_with_object_script(
|
||
5,
|
||
"fn tick(m,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }",
|
||
);
|
||
game.tick(Duration::from_millis(16));
|
||
let b = game.board();
|
||
assert_eq!(b.get(2, 0).1, Archetype::Empty);
|
||
assert_eq!(b.get(3, 0).1, Archetype::Crate);
|
||
}
|
||
|
||
#[test]
|
||
fn script_passable_distinguishes_empty_solid_and_offboard() {
|
||
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
|
||
// object sits at (0,0); the player at (3,0). passable should report only the
|
||
// genuinely empty in-bounds cell.
|
||
let mut obj = ObjectDef::new(0, 0);
|
||
obj.solid = false;
|
||
obj.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) { \
|
||
log(if Board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
|
||
log(if Board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
|
||
log(if Board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
|
||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||
let mut game = GameState::with_scripts(board, scripts);
|
||
game.run_init();
|
||
|
||
let lines: Vec<String> = game
|
||
.log
|
||
.iter()
|
||
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
|
||
.collect();
|
||
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
|
||
}
|
||
|
||
#[test]
|
||
fn log_is_immediate_and_not_paced_by_the_queue() {
|
||
// `delay(5.0)` parks the object's action queue for 5 seconds, then `log()`
|
||
// fires. Because logging bypasses the queue entirely, the line must appear
|
||
// right after run_init (dt = 0) — under the old queued-Action behavior it
|
||
// would have been stuck behind the delay and absent here.
|
||
let mut obj = ObjectDef::new(0, 0);
|
||
obj.solid = false;
|
||
obj.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())]);
|
||
let mut game = GameState::with_scripts(board, scripts);
|
||
game.run_init();
|
||
|
||
assert!(
|
||
game.log
|
||
.iter()
|
||
.any(|l| l.spans.iter().any(|s| s.text == "immediate")),
|
||
"log() should hit the game log immediately, even behind a delay"
|
||
);
|
||
}
|
||
|
||
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
|
||
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
|
||
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
|
||
spinner_board_dir(crates, walls, false)
|
||
}
|
||
|
||
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
|
||
/// script spins counter-clockwise (clockwise is the default when no tag present).
|
||
fn spinner_board_dir(
|
||
crates: &[(usize, usize)],
|
||
walls: &[(usize, usize)],
|
||
ccw: bool,
|
||
) -> GameState {
|
||
let mut obj = ObjectDef::new(1, 1);
|
||
obj.solid = false;
|
||
obj.script_name = Some("spinner".to_string());
|
||
if ccw {
|
||
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
|
||
}
|
||
let mut board = open_board(3, 3, (1, 1), vec![obj]);
|
||
for &(x, y) in crates {
|
||
crate_at(&mut board, x, y);
|
||
}
|
||
for &(x, y) in walls {
|
||
wall_at(&mut board, x, y);
|
||
}
|
||
let scripts = HashMap::from([(
|
||
"spinner".to_string(),
|
||
include_str!("scripts/spinner.rhai").to_string(),
|
||
)]);
|
||
let mut game = GameState::with_scripts(board, scripts);
|
||
game.run_init();
|
||
game
|
||
}
|
||
|
||
#[test]
|
||
fn spinner_does_not_destroy_a_blocked_neighbour() {
|
||
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
|
||
// wall, so N must not rotate onto NE — the old (can_shift-only) script
|
||
// overwrote and destroyed NE's crate. The cascade leaves everything put.
|
||
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
|
||
game.tick(Duration::from_millis(16));
|
||
let b = game.board();
|
||
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept
|
||
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed)
|
||
assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept
|
||
}
|
||
|
||
#[test]
|
||
fn spinner_rotates_an_arc_into_a_hole() {
|
||
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
|
||
// Clockwise, every crate advances one slot and the hole ends up at W.
|
||
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
|
||
game.tick(Duration::from_millis(16));
|
||
let b = game.board();
|
||
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N
|
||
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW
|
||
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W
|
||
assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
|
||
}
|
||
|
||
#[test]
|
||
fn spinner_ccw_rotates_the_other_way() {
|
||
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
|
||
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
|
||
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
|
||
game.tick(Duration::from_millis(16));
|
||
let b = game.board();
|
||
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
|
||
assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched
|
||
assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
|
||
}
|
||
|
||
#[test]
|
||
fn spinner_animates_its_glyph_without_recoloring() {
|
||
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
|
||
// per 0.5s rotation, while the colours stay put.
|
||
let mut game = spinner_board(&[], &[]);
|
||
let id = *game.board().objects.keys().next().unwrap();
|
||
let (fg0, bg0) = {
|
||
let b = game.board();
|
||
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
|
||
};
|
||
let mut seq = Vec::new();
|
||
for _ in 0..5 {
|
||
game.tick(Duration::from_secs_f64(0.5));
|
||
let b = game.board();
|
||
seq.push(b.objects[&id].glyph.tile);
|
||
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
|
||
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
|
||
}
|
||
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
|
||
}
|
||
|
||
#[test]
|
||
fn set_key_gives_and_takes_keys() {
|
||
let mut sobj = ObjectDef::new(0, 0);
|
||
sobj.solid = false;
|
||
sobj.script_name = Some("s".to_string());
|
||
let board = open_board(2, 1, (1, 0), vec![sobj]);
|
||
let scripts = HashMap::from([(
|
||
"s".to_string(),
|
||
"fn init(m) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(),
|
||
)]);
|
||
let mut game = GameState::with_scripts(board, scripts);
|
||
game.run_init();
|
||
|
||
let keys = game.player.borrow().keys;
|
||
assert!(keys.blue);
|
||
assert!(keys.red);
|
||
assert!(!keys.cyan); // cyan was not set by the script
|
||
|
||
// A second script can take a key.
|
||
let mut sobj2 = ObjectDef::new(0, 0);
|
||
sobj2.solid = false;
|
||
sobj2.script_name = Some("t".to_string());
|
||
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
|
||
let scripts2 = HashMap::from([(
|
||
"t".to_string(),
|
||
"fn init(m) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(),
|
||
)]);
|
||
let mut game2 = GameState::with_scripts(board2, scripts2);
|
||
game2.run_init();
|
||
|
||
assert!(!game2.player.borrow().keys.blue);
|
||
}
|
||
|
||
#[test]
|
||
fn set_key_unknown_color_logs_error() {
|
||
let mut sobj = ObjectDef::new(0, 0);
|
||
sobj.solid = false;
|
||
sobj.script_name = Some("s".to_string());
|
||
let board = open_board(2, 1, (1, 0), vec![sobj]);
|
||
let scripts = HashMap::from([(
|
||
"s".to_string(),
|
||
r#"fn init(m) { set_key("chartreuse", true); }"#.to_string(),
|
||
)]);
|
||
let mut game = GameState::with_scripts(board, scripts);
|
||
game.run_init();
|
||
|
||
assert!(game.log.iter().any(|l| l.spans.iter().any(|s| s.text.contains("set_key"))));
|
||
}
|
||
}
|