2026-06-09 22:38:17 -05:00
|
|
|
|
use crate::action::Action;
|
2026-06-13 01:25:58 -05:00
|
|
|
|
use crate::board::Board;
|
2026-06-03 22:46:54 -05:00
|
|
|
|
use crate::log::LogLine;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
use crate::script::ScriptHost;
|
2026-06-21 19:15:43 -05:00
|
|
|
|
use crate::utils::{Direction, ObjectId, Player, ScriptArg};
|
2026-06-13 01:25:58 -05:00
|
|
|
|
use crate::world::World;
|
2026-06-23 01:56:45 -05:00
|
|
|
|
use color::Rgba8;
|
2026-06-13 01:25:58 -05:00
|
|
|
|
use std::cell::{Ref, RefMut};
|
2026-06-04 20:11:55 -05:00
|
|
|
|
use std::time::Duration;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
|
|
|
|
|
|
/// How long a `say()` speech bubble stays on screen, in seconds.
|
|
|
|
|
|
pub const SAY_DURATION: f64 = 3.0;
|
|
|
|
|
|
|
2026-06-23 01:56:45 -05:00
|
|
|
|
/// The player's key inventory: one boolean per key color.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Each field is `true` when the player holds a key of that color. Use
|
|
|
|
|
|
/// [`colors`](Keyring::colors) to iterate all eight in display order, each
|
|
|
|
|
|
/// paired with its [`Rgba8`] color for rendering.
|
|
|
|
|
|
#[derive(Copy, Clone, Default)]
|
|
|
|
|
|
pub struct Keyring {
|
|
|
|
|
|
/// Blue key.
|
|
|
|
|
|
pub blue: bool,
|
|
|
|
|
|
/// Green key.
|
|
|
|
|
|
pub green: bool,
|
|
|
|
|
|
/// Cyan key.
|
|
|
|
|
|
pub cyan: bool,
|
|
|
|
|
|
/// Red key.
|
|
|
|
|
|
pub red: bool,
|
|
|
|
|
|
/// Purple key.
|
|
|
|
|
|
pub purple: bool,
|
|
|
|
|
|
/// Orange key (custom color, not in the standard EGA palette).
|
|
|
|
|
|
pub orange: bool,
|
|
|
|
|
|
/// Yellow key.
|
|
|
|
|
|
pub yellow: bool,
|
|
|
|
|
|
/// White key.
|
|
|
|
|
|
pub white: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Keyring {
|
|
|
|
|
|
/// Returns all eight keys in display order, each paired with its color.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Order: blue, green, cyan, red, purple, orange, yellow, white.
|
|
|
|
|
|
pub fn colors(&self) -> [(bool, Rgba8); 8] {
|
|
|
|
|
|
[
|
|
|
|
|
|
(self.blue, Rgba8 { r: 0x00, g: 0x00, b: 0xAA, a: 255 }),
|
|
|
|
|
|
(self.green, Rgba8 { r: 0x00, g: 0xAA, b: 0x00, a: 255 }),
|
|
|
|
|
|
(self.cyan, Rgba8 { r: 0x00, g: 0xAA, b: 0xAA, a: 255 }),
|
|
|
|
|
|
(self.red, Rgba8 { r: 0xAA, g: 0x00, b: 0x00, a: 255 }),
|
|
|
|
|
|
(self.purple, Rgba8 { r: 0xAA, g: 0x00, b: 0xAA, a: 255 }),
|
|
|
|
|
|
(self.orange, Rgba8 { r: 0xFF, g: 0x88, b: 0x00, a: 255 }),
|
|
|
|
|
|
(self.yellow, Rgba8 { r: 0xFF, g: 0xFF, b: 0x55, a: 255 }),
|
|
|
|
|
|
(self.white, Rgba8 { r: 0xFF, g: 0xFF, b: 0xFF, a: 255 }),
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 01:42:33 -05:00
|
|
|
|
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
|
|
|
|
|
|
// accessing the private `action` module directly.
|
|
|
|
|
|
pub use crate::action::ScrollLine;
|
|
|
|
|
|
|
|
|
|
|
|
/// An active scroll overlay opened by a scripted object via `scroll()`.
|
|
|
|
|
|
///
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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.
|
2026-06-10 01:42:33 -05:00
|
|
|
|
pub struct Scroll {
|
|
|
|
|
|
/// The object whose `scroll()` call opened this overlay.
|
|
|
|
|
|
pub source: ObjectId,
|
|
|
|
|
|
/// The lines of content to display.
|
|
|
|
|
|
pub lines: Vec<ScrollLine>,
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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>,
|
2026-06-10 01:42:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 22:15:44 -05:00
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
2026-05-16 01:10:04 -05:00
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// Holds the active game world and provides game-logic operations.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// `GameState` is the boundary between the engine (rendering, input) and the
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// 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.
|
2026-05-16 01:10:04 -05:00
|
|
|
|
pub struct GameState {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// All boards and world-level scripts.
|
|
|
|
|
|
world: World,
|
|
|
|
|
|
/// Key of the currently active board in [`world.boards`](World::boards).
|
|
|
|
|
|
current_board_name: String,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// The in-game message log, oldest first (newest pushed at the end).
|
|
|
|
|
|
pub log: Vec<LogLine>,
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// The Rhai scripting runtime for the active board's objects.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
scripts: ScriptHost,
|
2026-06-08 22:15:44 -05:00
|
|
|
|
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
|
|
|
|
|
|
pub speech_bubbles: Vec<SpeechBubble>,
|
2026-06-10 01:42:33 -05:00
|
|
|
|
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming.
|
2026-06-10 01:42:33 -05:00
|
|
|
|
pub active_scroll: Option<Scroll>,
|
2026-06-13 16:24:29 -05:00
|
|
|
|
/// 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>,
|
2026-06-21 12:32:09 -05:00
|
|
|
|
/// The player's current health. Game-global (not per-board), so it persists
|
|
|
|
|
|
/// across board transitions. Players start with `5`. There is no runtime
|
|
|
|
|
|
/// mutation path yet — front-ends only display it.
|
|
|
|
|
|
pub player_health: u32,
|
|
|
|
|
|
/// The number of gems the player has collected. Game-global like
|
|
|
|
|
|
/// [`player_health`](GameState::player_health); starts at `0` and is
|
|
|
|
|
|
/// display-only for now.
|
|
|
|
|
|
pub player_gems: u32,
|
2026-06-23 01:56:45 -05:00
|
|
|
|
/// The player's key inventory. Game-global; persists across board transitions.
|
|
|
|
|
|
pub player_keys: Keyring,
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl GameState {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
///
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// 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 host = ScriptHost::new(
|
2026-06-15 23:35:18 -05:00
|
|
|
|
world
|
|
|
|
|
|
.boards
|
|
|
|
|
|
.get(&name)
|
|
|
|
|
|
.expect("world::load guarantees start board exists"),
|
2026-06-13 01:25:58 -05:00
|
|
|
|
&world.scripts,
|
|
|
|
|
|
);
|
2026-06-04 20:11:55 -05:00
|
|
|
|
let mut state = Self {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
world,
|
|
|
|
|
|
current_board_name: name,
|
2026-06-03 22:46:54 -05:00
|
|
|
|
log: Vec::new(),
|
2026-06-13 01:25:58 -05:00
|
|
|
|
scripts: host,
|
2026-06-08 22:15:44 -05:00
|
|
|
|
speech_bubbles: Vec::new(),
|
2026-06-10 01:42:33 -05:00
|
|
|
|
active_scroll: None,
|
2026-06-13 16:24:29 -05:00
|
|
|
|
board_transition: None,
|
2026-06-21 12:32:09 -05:00
|
|
|
|
player_health: 5,
|
|
|
|
|
|
player_gems: 0,
|
2026-06-23 01:56:45 -05:00
|
|
|
|
// Test starting keys: red, green, orange, white.
|
|
|
|
|
|
player_keys: Keyring { red: true, green: true, orange: true, white: true, ..Default::default() },
|
2026-06-04 20:11:55 -05:00
|
|
|
|
};
|
2026-06-06 17:36:00 -05:00
|
|
|
|
state.drain_errors();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
state
|
2026-06-03 22:46:54 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 01:25:58 -05:00
|
|
|
|
/// 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(),
|
|
|
|
|
|
scripts,
|
|
|
|
|
|
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
|
|
|
|
|
|
};
|
|
|
|
|
|
Self::from_world(world)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
|
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
|
|
|
|
|
pub fn board(&self) -> Ref<'_, Board> {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
self.world.boards[&self.current_board_name].borrow()
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Borrows the active board for mutation.
|
|
|
|
|
|
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
2026-06-13 01:25:58 -05:00
|
|
|
|
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
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 18:43:29 -05:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
|
/// Appends a styled message to the log.
|
|
|
|
|
|
pub fn log(&mut self, line: LogLine) {
|
|
|
|
|
|
self.log.push(line);
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// 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.
|
2026-06-04 20:11:55 -05:00
|
|
|
|
pub fn run_init(&mut self) {
|
|
|
|
|
|
self.scripts.run_init();
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.resolve();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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.
|
2026-06-03 23:21:05 -05:00
|
|
|
|
pub fn tick(&mut self, dt: Duration) {
|
2026-06-15 09:15:30 -05:00
|
|
|
|
// 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();
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let secs = dt.as_secs_f64();
|
|
|
|
|
|
self.scripts.run_tick(secs);
|
2026-06-08 22:15:44 -05:00
|
|
|
|
// Expire speech bubbles before resolving new actions so a fresh say()
|
|
|
|
|
|
// this frame isn't immediately culled.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.speech_bubbles.retain_mut(|b| {
|
|
|
|
|
|
b.remaining -= secs;
|
|
|
|
|
|
b.remaining > 0.0
|
|
|
|
|
|
});
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.resolve();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Drains errors collected by the script host into the game log.
|
|
|
|
|
|
fn drain_errors(&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 errors = self.scripts.take_errors();
|
|
|
|
|
|
self.log.extend(errors);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolves the board queue: applies each promoted action to the board, then fires
|
2026-06-09 22:38:17 -05:00
|
|
|
|
/// the `bump` and `send` hooks the moves triggered.
|
2026-06-06 17:36:00 -05:00
|
|
|
|
///
|
2026-06-09 22:38:17 -05:00
|
|
|
|
/// Done in two phases so no `board_mut` borrow is held while scripts run
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// (they read the board through its getters): phase A mutates the board and records
|
2026-06-09 22:38:17 -05:00
|
|
|
|
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the
|
|
|
|
|
|
/// hooks after the borrow drops.
|
2026-06-06 17:36:00 -05:00
|
|
|
|
fn resolve(&mut self) {
|
|
|
|
|
|
let actions = self.scripts.take_board_queue();
|
|
|
|
|
|
// Logs are collected here rather than pushed inline, since the board borrow
|
|
|
|
|
|
// below also borrows `self`.
|
|
|
|
|
|
let mut logs: Vec<LogLine> = Vec::new();
|
2026-06-06 20:01:07 -05:00
|
|
|
|
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
|
2026-06-21 18:27:45 -05:00
|
|
|
|
// 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;
|
2026-06-09 22:38:17 -05:00
|
|
|
|
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
|
2026-06-08 22:15:44 -05:00
|
|
|
|
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
|
2026-06-10 01:42:33 -05:00
|
|
|
|
// Collected outside the board borrow so we can assign to self.active_scroll.
|
|
|
|
|
|
let mut new_scroll: Option<Scroll> = None;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
{
|
|
|
|
|
|
let mut board = self.board_mut();
|
|
|
|
|
|
for ba in actions {
|
|
|
|
|
|
match ba.action {
|
|
|
|
|
|
Action::Move(dir) => {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
bumps.push((bumped, ba.source as i64));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Action::SetTile(tile) => {
|
2026-06-06 20:01:07 -05:00
|
|
|
|
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
obj.glyph.tile = tile;
|
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
Action::Log(line) => logs.push(line),
|
2026-06-15 23:35:18 -05:00
|
|
|
|
Action::SetTag {
|
|
|
|
|
|
target,
|
|
|
|
|
|
tag,
|
|
|
|
|
|
present,
|
|
|
|
|
|
} => {
|
2026-06-07 01:26:18 -05:00
|
|
|
|
if let Some(obj) = board.objects.get_mut(&target) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
if present {
|
|
|
|
|
|
obj.tags.insert(tag);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
obj.tags.remove(&tag);
|
|
|
|
|
|
}
|
2026-06-07 01:26:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-08 22:15:44 -05:00
|
|
|
|
// Replace any existing bubble from this object so repeated say() calls
|
|
|
|
|
|
// don't stack visually — the new text resets the timer.
|
2026-06-13 15:33:04 -05:00
|
|
|
|
Action::Say(text, duration) => new_bubbles.push(SpeechBubble {
|
2026-06-08 22:15:44 -05:00
|
|
|
|
object_id: ba.source,
|
|
|
|
|
|
text,
|
2026-06-13 15:33:04 -05:00
|
|
|
|
remaining: duration,
|
2026-06-08 22:15:44 -05:00
|
|
|
|
}),
|
|
|
|
|
|
// Delays are consumed by ScriptHost::drain and never reach the board queue.
|
|
|
|
|
|
Action::Delay(_) => {}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
Action::SetColor { fg, bg } => {
|
|
|
|
|
|
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
if let Some(c) = fg {
|
|
|
|
|
|
obj.glyph.fg = c;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(c) = bg {
|
|
|
|
|
|
obj.glyph.bg = c;
|
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Collected and fired after the board borrow drops, like bumps.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
Action::Send {
|
|
|
|
|
|
target,
|
|
|
|
|
|
fn_name,
|
|
|
|
|
|
arg,
|
|
|
|
|
|
} => {
|
2026-06-09 22:38:17 -05:00
|
|
|
|
sends.push((target, fn_name, arg));
|
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
|
// Later scrolls overwrite earlier ones from the same tick.
|
|
|
|
|
|
Action::Scroll(lines) => {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
new_scroll = Some(Scroll {
|
|
|
|
|
|
source: ba.source,
|
|
|
|
|
|
lines,
|
|
|
|
|
|
choice: None,
|
|
|
|
|
|
});
|
2026-06-10 01:42:33 -05:00
|
|
|
|
}
|
2026-06-13 17:58:04 -05:00
|
|
|
|
Action::Teleport { x, y } => {
|
|
|
|
|
|
if !board.in_bounds((x, y)) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
|
2026-06-13 17:58:04 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
let (ux, uy) = (x as usize, y as usize);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let source_solid = board
|
|
|
|
|
|
.objects
|
|
|
|
|
|
.get(&ba.source)
|
2026-06-13 17:58:04 -05:00
|
|
|
|
.map(|o| o.solid)
|
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
if source_solid && board.solid_at(ux, uy).is_some() {
|
|
|
|
|
|
logs.push(LogLine::error(format!(
|
|
|
|
|
|
"teleport({x},{y}): destination is solid"
|
|
|
|
|
|
)));
|
|
|
|
|
|
} else if let Some(obj) = board.objects.get_mut(&ba.source) {
|
|
|
|
|
|
obj.x = ux;
|
|
|
|
|
|
obj.y = uy;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 01:32:47 -05:00
|
|
|
|
// push() self-checks can_push, so an in-bounds guard is all we add.
|
|
|
|
|
|
Action::Push { x, y, dir } => {
|
2026-06-21 18:27:45 -05:00
|
|
|
|
if !board.in_bounds((x, y)) {
|
2026-06-21 01:32:47 -05:00
|
|
|
|
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
board.push(x as usize, y as usize, dir);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// apply_shift moves the named cells.
|
|
|
|
|
|
Action::Shift(cells) => {
|
|
|
|
|
|
logs.extend(board.apply_shift(&cells));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.log.extend(logs);
|
2026-06-08 22:15:44 -05:00
|
|
|
|
for bubble in new_bubbles {
|
|
|
|
|
|
// One bubble per object: replace the existing one if present.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.speech_bubbles
|
|
|
|
|
|
.retain(|b| b.object_id != bubble.object_id);
|
2026-06-08 22:15:44 -05:00
|
|
|
|
self.speech_bubbles.push(bubble);
|
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
|
if let Some(scroll) = new_scroll {
|
|
|
|
|
|
self.active_scroll = Some(scroll);
|
|
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
for (bumped, bumper) in bumps {
|
|
|
|
|
|
self.scripts.run_bump(bumped, bumper);
|
2026-06-04 23:52:33 -05:00
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
for (target, fn_name, arg) in sends {
|
|
|
|
|
|
self.scripts.run_send(target, &fn_name, arg);
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
self.drain_errors();
|
2026-06-03 23:21:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// Consumes the active scroll, dispatching the player's choice (if any) back
|
|
|
|
|
|
/// to the source object via `send`.
|
2026-06-10 01:42:33 -05:00
|
|
|
|
///
|
2026-06-15 09:15:30 -05:00
|
|
|
|
/// 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
|
|
|
|
|
|
{
|
|
|
|
|
|
self.scripts.run_send(scroll.source, &choice, None);
|
2026-06-10 01:42:33 -05:00
|
|
|
|
self.drain_errors();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:24:29 -05:00
|
|
|
|
/// 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) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.log.push(LogLine::error(format!(
|
|
|
|
|
|
"portal target board {target_map:?} not found"
|
|
|
|
|
|
)));
|
2026-06-13 16:24:29 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Find the named arrival portal on the target board (borrow then release).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let arrival = self.world.boards[target_map]
|
|
|
|
|
|
.borrow()
|
|
|
|
|
|
.portals
|
|
|
|
|
|
.iter()
|
2026-06-13 16:24:29 -05:00
|
|
|
|
.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();
|
2026-06-15 23:35:18 -05:00
|
|
|
|
self.board_mut().player = Player {
|
|
|
|
|
|
x: ax as i32,
|
|
|
|
|
|
y: ay as i32,
|
|
|
|
|
|
};
|
2026-06-13 16:24:29 -05:00
|
|
|
|
// Rebuild the script host for the new board's objects.
|
|
|
|
|
|
self.scripts = ScriptHost::new(
|
|
|
|
|
|
&self.world.boards[&self.current_board_name],
|
|
|
|
|
|
&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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// Attempts to move the player one cell in `dir`.
|
2026-05-17 12:48:52 -05:00
|
|
|
|
///
|
2026-06-06 14:11:20 -05:00
|
|
|
|
/// The move is ignored if the target cell is out of bounds, or it is neither
|
2026-06-06 17:36:00 -05:00
|
|
|
|
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
|
|
|
|
|
|
/// caller does not need to check). If the target cell holds a solid object, that
|
|
|
|
|
|
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
|
2026-06-06 14:11:20 -05:00
|
|
|
|
pub fn try_move(&mut self, dir: Direction) {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let bumped;
|
2026-06-21 18:27:45 -05:00
|
|
|
|
let grabbed;
|
2026-06-13 16:24:29 -05:00
|
|
|
|
let portal_target;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
{
|
|
|
|
|
|
let (dx, dy): (i32, i32) = 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);
|
2026-06-21 18:27:45 -05:00
|
|
|
|
// 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.
|
2026-06-21 19:15:43 -05:00
|
|
|
|
bumped = board
|
|
|
|
|
|
.solid_at(nx, ny)
|
|
|
|
|
|
.filter(|_| grabbed.is_none())
|
|
|
|
|
|
.and_then(|s| s.object_id());
|
2026-06-21 18:27:45 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
board.player.x = nx as i32;
|
|
|
|
|
|
board.player.y = ny as i32;
|
2026-06-13 16:24:29 -05:00
|
|
|
|
// Check for a portal at the new position; clone strings to release the borrow.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
portal_target = board
|
|
|
|
|
|
.portals
|
|
|
|
|
|
.iter()
|
2026-06-13 16:24:29 -05:00
|
|
|
|
.find(|p| p.x == nx && p.y == ny)
|
|
|
|
|
|
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
portal_target = None;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
2026-06-06 14:11:20 -05:00
|
|
|
|
}
|
2026-06-13 16:24:29 -05:00
|
|
|
|
// A portal takes priority: board transitions skip the bump hook.
|
|
|
|
|
|
if let Some((target_map, target_entry)) = portal_target {
|
|
|
|
|
|
self.enter_board(&target_map, &target_entry);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
|
if let Some(idx) = bumped {
|
|
|
|
|
|
self.scripts.run_bump(idx, -1);
|
|
|
|
|
|
self.drain_errors();
|
2026-05-16 01:10:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
|
|
2026-06-21 19:15:43 -05:00
|
|
|
|
/// Moves object `id` one cell in `dir` on `board`, returning the object it bumped
|
|
|
|
|
|
/// (if any) for the caller to resolve after the board borrow drops.
|
2026-06-06 17:36:00 -05:00
|
|
|
|
///
|
|
|
|
|
|
/// 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.
|
2026-06-21 19:15:43 -05:00
|
|
|
|
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let (dx, dy): (i32, i32) = dir.into();
|
2026-06-21 19:15:43 -05:00
|
|
|
|
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
let target = (ox as i32 + dx, oy as i32 + dy);
|
|
|
|
|
|
if !board.in_bounds(target) {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
return None;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
|
|
|
|
|
let (nx, ny) = (target.0 as usize, target.1 as usize);
|
2026-06-06 20:01:07 -05:00
|
|
|
|
// Capture the bumped object before any push relocates it (its id is stable).
|
2026-06-21 19:15:43 -05:00
|
|
|
|
let bumped = board.solid_at(nx, ny).and_then(|s| s.object_id());
|
2026-06-06 17:36:00 -05:00
|
|
|
|
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
|
2026-06-06 20:01:07 -05:00
|
|
|
|
let obj = board.objects.get_mut(&id).expect("id checked above");
|
2026-06-06 17:36:00 -05:00
|
|
|
|
obj.x = nx;
|
|
|
|
|
|
obj.y = ny;
|
|
|
|
|
|
}
|
2026-06-21 19:15:43 -05:00
|
|
|
|
bumped
|
2026-06-06 17:36:00 -05:00
|
|
|
|
}
|
2026-06-21 01:32:47 -05:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::GameState;
|
2026-06-21 18:27:45 -05:00
|
|
|
|
use crate::Direction;
|
2026-06-23 01:08:01 -05:00
|
|
|
|
use crate::archetype::{Archetype, Builtin};
|
2026-06-21 18:27:45 -05:00
|
|
|
|
use crate::board::tests::{crate_at, open_board, stamp, wall_at};
|
2026-06-21 01:32:47 -05:00
|
|
|
|
use crate::object_def::ObjectDef;
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
|
#[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![]);
|
2026-06-23 01:08:01 -05:00
|
|
|
|
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
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]
|
2026-06-21 19:15:43 -05:00
|
|
|
|
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.
|
2026-06-21 18:27:45 -05:00
|
|
|
|
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]);
|
2026-06-23 01:08:01 -05:00
|
|
|
|
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem"));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
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));
|
|
|
|
|
|
|
2026-06-21 19:15:43 -05:00
|
|
|
|
// No grab: the gem is untouched and the player never moved.
|
|
|
|
|
|
assert_eq!(game.player_gems, 0);
|
|
|
|
|
|
assert!(game.board().objects.values().any(|o| o.grab));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 01:32:47 -05:00
|
|
|
|
/// 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 i32 - 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_push_shoves_a_crate() {
|
|
|
|
|
|
// The object pushes the crate at (2,0) one step east on its first tick.
|
|
|
|
|
|
let mut game = game_with_object_script(
|
|
|
|
|
|
5,
|
|
|
|
|
|
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
|
|
|
|
|
|
);
|
|
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
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).
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let mut game = game_with_object_script(
|
|
|
|
|
|
5,
|
2026-06-21 22:35:35 -05:00
|
|
|
|
"fn tick(dt) { if Queue.length() == 0 { shift([[2, 0], [3, 0]]); } }",
|
2026-06-21 01:32:47 -05:00
|
|
|
|
);
|
|
|
|
|
|
game.tick(Duration::from_millis(16));
|
|
|
|
|
|
let b = game.board();
|
|
|
|
|
|
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(b.get(0, 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() { \
|
|
|
|
|
|
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
|
|
|
|
|
|
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
|
|
|
|
|
|
log(if 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"]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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(0, 1, 0).1, Archetype::Crate); // N kept
|
|
|
|
|
|
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
|
|
|
|
|
|
assert_eq!(b.get(0, 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(0, 2, 0).1, Archetype::Crate); // NE: filled by N
|
|
|
|
|
|
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
|
|
|
|
|
|
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
|
|
|
|
|
|
assert_eq!(b.get(0, 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, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
|
|
|
|
|
|
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
|
|
|
|
|
|
assert_eq!(b.get(0, 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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|