From 9de31d933b814029d08f3bb6076515471290d009 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 28 Jun 2026 00:12:52 -0500 Subject: [PATCH] Big API refactor --- Cargo.lock | 7 +- kiln-core/Cargo.toml | 1 + kiln-core/src/action.rs | 92 ++- kiln-core/src/api/board.rs | 96 +++ kiln-core/src/api/mod.rs | 5 + kiln-core/src/api/object_info.rs | 137 ++++ kiln-core/src/api/player.rs | 21 + kiln-core/src/api/queue.rs | 118 ++++ kiln-core/src/api/state.rs | 32 + kiln-core/src/board.rs | 68 +- kiln-core/src/game.rs | 73 +- kiln-core/src/glyph.rs | 16 + kiln-core/src/keys.rs | 17 + kiln-core/src/lib.rs | 2 +- kiln-core/src/map_file.rs | 7 +- kiln-core/src/object_def.rs | 4 + kiln-core/src/script.rs | 1020 ++++++--------------------- kiln-core/src/scripts/gem.rhai | 2 +- kiln-core/src/scripts/heart.rhai | 2 +- kiln-core/src/scripts/key.rhai | 4 +- kiln-core/src/scripts/pusher.rhai | 10 +- kiln-core/src/scripts/spinner.rhai | 26 +- kiln-core/src/tests/actions.rs | 51 +- kiln-core/src/tests/collision.rs | 4 +- kiln-core/src/tests/game_portals.rs | 2 +- kiln-core/src/tests/scripting.rs | 44 +- kiln-core/src/utils.rs | 93 ++- kiln-tui/src/render.rs | 2 +- kiln-tui/src/speech.rs | 4 +- maps/start.toml | 61 +- maps/tiny.toml | 44 ++ 31 files changed, 1036 insertions(+), 1029 deletions(-) create mode 100644 kiln-core/src/api/board.rs create mode 100644 kiln-core/src/api/mod.rs create mode 100644 kiln-core/src/api/object_info.rs create mode 100644 kiln-core/src/api/player.rs create mode 100644 kiln-core/src/api/queue.rs create mode 100644 kiln-core/src/api/state.rs create mode 100644 maps/tiny.toml diff --git a/Cargo.lock b/Cargo.lock index 33e2ccc..c459799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,6 +754,7 @@ name = "kiln-core" version = "0.1.0" dependencies = [ "color", + "log", "rhai", "serde", "tinyrand", @@ -850,9 +851,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -1109,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/kiln-core/Cargo.toml b/kiln-core/Cargo.toml index e8e2bcf..76a3916 100644 --- a/kiln-core/Cargo.toml +++ b/kiln-core/Cargo.toml @@ -9,3 +9,4 @@ rhai = "1" serde = { version = "1", features = ["derive"] } tinyrand = "0.5" toml = { version = "0.8", features = ["preserve_order"] } +log = "0.4.33" diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index ec6a89a..4238452 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -4,9 +4,10 @@ //! functions), the rate-limiting delay constant, and [`action_to_map`] which //! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. +use std::fmt::Debug; use crate::log::LogLine; use crate::map_file::color_to_hex; -use crate::utils::{Direction, ObjectId, ScriptArg}; +use crate::utils::{Direction, ObjectId}; use color::Rgba8; use rhai::Dynamic; @@ -27,13 +28,47 @@ pub enum ScrollLine { Choice { choice: String, display: String }, } +#[derive(Clone, PartialEq)] +pub enum SendArg { + None, + Int(i64), + Float(f64), + String(String), +} + +impl From for SendArg { + fn from(value: Dynamic) -> Self { + if value.is_int() { + Self::Int(value.as_int().unwrap()) + } else if value.is_float() { + Self::Float(value.as_float().unwrap()) + } else if value.is_string() { + Self::String(value.into_string().unwrap()) + } else { + Self::None + } + } +} + +impl Into for SendArg { + fn into(self) -> Dynamic { + match self { + SendArg::None => Dynamic::UNIT, + SendArg::Int(value) => Dynamic::from(value), + SendArg::Float(value) => Dynamic::from(value), + SendArg::String(value) => Dynamic::from(value), + } + } +} + /// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] /// after it is promoted onto the board queue. /// /// [`Action::Delay`] is the exception: it is **never** promoted to the board queue. /// It lives only in the per-object output queue and is consumed by `ScriptHost::drain` /// to pace how quickly other actions are released. -pub(crate) enum Action { +#[derive(Clone)] +pub enum Action { /// Move the source object one cell in a direction (subject to passability). Move(Direction), /// Set the source object's glyph tile index. @@ -59,7 +94,7 @@ pub(crate) enum Action { Send { target: ObjectId, fn_name: String, - arg: Option, + arg: SendArg, }, /// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// selection dispatches [`Send`](Action::Send) to the source object. @@ -67,15 +102,15 @@ pub(crate) enum Action { /// Teleport the source object to `(x, y)`. Blocked (with a logged error) if /// the source is solid and the destination already has a solid occupant. /// Zero time cost — multiple teleports may fire in one frame. - Teleport { x: i32, y: i32 }, + Teleport { x: i64, y: i64 }, /// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on /// arbitrary cells, not the source object). Zero time cost. - Push { x: i32, y: i32, dir: Direction }, + Push { x: i64, y: i64, dir: Direction }, /// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the /// next coordinate in the list, unless it can't move, or that cell is blocked. /// Rotates the contents of the last cell back to the beginning. /// Zero time cost. - Shift(Vec<(i32, i32)>), + Shift(Vec<(i64, i64)>), /// Add `n` to the player's gem count (negative subtracts; the count is /// clamped at 0). Zero time cost. Applied to `GameState::player.gems`. AddGems(i64), @@ -93,6 +128,29 @@ pub(crate) enum Action { Die, } +impl Debug for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Action::Move(dir) => write!(f, "Move({:?})", dir), + Action::SetTile(i) => write!(f, "SetTile({i})"), + Action::Log(msg) => write!(f, "Log({:?})", msg), + Action::SetTag { .. } => write!(f, "SetTag"), + Action::Say(_, _) => write!(f, "Say"), + Action::Delay(t) => write!(f, "Delay({t})"), + Action::SetColor { .. } => write!(f, "SetColor"), + Action::Send { .. } => write!(f, "Send"), + Action::Scroll(_) => write!(f, "Scroll"), + Action::Teleport { .. } => write!(f, "Teleport"), + Action::Push { .. } => write!(f, "Push"), + Action::Shift(_) => write!(f, "Shift"), + Action::AddGems(n) => write!(f, "AddGems({n}"), + Action::AlterHealth(health) => write!(f, "AlterHealth({health})"), + Action::SetKey(_, _) => write!(f, "SetKey"), + Action::Die => write!(f, "Die") + } + } +} + // ── Direction helper ────────────────────────────────────────────────────────── fn dir_to_str(d: Direction) -> &'static str { @@ -169,13 +227,12 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { m.insert("type".into(), ds("Send")); m.insert("target".into(), Dynamic::from(*target as i64)); m.insert("name".into(), Dynamic::from(fn_name.clone())); - if let Some(a) = arg { - let dyn_arg = match a { - ScriptArg::Str(s) => Dynamic::from(s.clone()), - ScriptArg::Num(n) => Dynamic::from(*n), - }; - m.insert("arg".into(), dyn_arg); - } + match arg { + SendArg::Int(i) => m.insert("arg".into(), Dynamic::from(*i)), + SendArg::Float(f) => m.insert("arg".into(), Dynamic::from(*f)), + SendArg::String(s) => m.insert("arg".into(), Dynamic::from(s.clone())), + SendArg::None => m.insert("arg".into(), Dynamic::UNIT) + }; } // Scroll lines are not inspectable via the queue map API; emit type only. Action::Scroll(_) => { @@ -215,3 +272,12 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { } m } + +/// An action promoted from an object's output queue onto the board queue, tagged +/// with the object that issued it. +pub struct BoardAction { + /// The stable [`ObjectId`] of the object that issued the action. + pub(crate) source: ObjectId, + /// The action to apply. + pub(crate) action: Action, +} \ No newline at end of file diff --git a/kiln-core/src/api/board.rs b/kiln-core/src/api/board.rs new file mode 100644 index 0000000..3c6fb57 --- /dev/null +++ b/kiln-core/src/api/board.rs @@ -0,0 +1,96 @@ +//! ## Board read API (`state.board.*`) +//! +//! - `board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag +//! - `board.named(name) -> ObjectInfo | ()` — object with that name (or unit) +//! - `board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info +//! - `board.width` - Board width +//! - `board.height` - Board height +//! +//! ## Cell queries +//! +//! - `board.can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir` +//! - `board.passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole) +//! cell (one empty, or a grab thing and the player) + +use std::cell::RefCell; +use std::rc::Rc; +use rhai::{Dynamic, Engine, ImmutableString}; +use crate::{Board, Direction}; +use crate::api::object_info::ObjectInfo; +use crate::script::Registerable; +use crate::utils::{ErrorSink, ObjectId}; + +/// A read-only handle to the world, exposed to scripts as `Board`. +pub type BoardRef = Rc>; + +impl Registerable for BoardRef { + fn register(engine: &mut Engine, error_sink: ErrorSink) { + engine.register_type_with_name::("Board"); + engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64); + engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64); + + // can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be + // shoved one step in dir (the read-only half of push()). False off-board. + engine.register_fn("can_push", move |board: BoardRef, x: i64, y: i64, dir: Direction| -> bool { + let board = board.borrow(); + board.in_bounds((x, y)) && board.can_push(x as usize, y as usize, dir) + }); + + // passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell + // a mover could enter). Off-board is not passable. Lets a script distinguish a + // hole from a blocked solid (which can_shift/can_push alone cannot). + engine.register_fn("passable", move |board: BoardRef, x: i64, y: i64| -> bool { + let board = board.borrow(); + board.in_bounds((x, y)) && board.is_passable(x as usize, y as usize) + }); + + // Board.get(id) -> ObjectInfo | () (unknown id logs error) + engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic { + if id <= 0 { + error_sink.error(format!("Board.get: invalid id {id}")); + return Dynamic::UNIT; + } + + if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) { + Dynamic::from(obj) + } else { + error_sink.error(format!("Board.get: no object with id {id}")); + Dynamic::UNIT + } + }); + + // Board.named(name) -> ObjectInfo | () + engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic { + let board = board_ref.borrow(); + board + .objects + .iter() + .find_map(|(_id, def)| { + if def.name.as_deref() == Some(name.as_str()) { + Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone()))) + } else { + None + } + }) + .unwrap_or(Dynamic::UNIT) + }, + ); + + // Board.tagged(tag) -> Array[ObjectInfo] + engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array { + let board = board_ref.borrow(); + board + .objects + .iter() + .filter_map(|(_id, def)| { + if def.tags.contains(tag.as_str()) { + Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone()))) + } else { + None + } + }) + .collect() + }, + ); + } +} diff --git a/kiln-core/src/api/mod.rs b/kiln-core/src/api/mod.rs new file mode 100644 index 0000000..fdedc2e --- /dev/null +++ b/kiln-core/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod state; +pub mod player; +pub mod board; +pub mod object_info; +pub mod queue; diff --git a/kiln-core/src/api/object_info.rs b/kiln-core/src/api/object_info.rs new file mode 100644 index 0000000..cfafaa6 --- /dev/null +++ b/kiln-core/src/api/object_info.rs @@ -0,0 +1,137 @@ +//! ## Object Rhai API +//! +//! ### Getters +//! +//! - x, y -> Board location of object +//! - id -> The object's board-unique id +//! - name -> The object's name, or () +//! - waiting -> bool for whether or not the front of this object's queue is a delay action +//! - queue -> the action queue for this object +//! - tags -> Array of tags for this object +//! - glyph -> The object's current glyph +//! +//! ### Functions +//! +//! - has_tag(tag) -> Whether the object has this tag +//! - delay(secs) -> Queue a delay action +//! - can_push(dir) -> Whether a push in this direction would succeed +//! - blocked(dir) -> Whether a solid neighbors me in this direction +//! +//! Note on blocking: can_push and blocked only take into account the contents of the board +//! at the start of the call; if another thing moves before your actions are flushed to the +//! gamestate, a move might still fail. This is a TODO. + +use rhai::{Dynamic, Engine}; +use crate::api::board::BoardRef; +use crate::api::queue::ObjQueue; +use crate::Direction; +use crate::object_def::ObjectDef; +use crate::script::{BoardQueue, Registerable}; +use crate::utils::{ErrorSink, ObjectId}; + +/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, +/// and `Board.get`. Passed by value — scripts read fields, not a live reference. +/// It also contains things like script_name used by ScriptHost, since we can't keep a live +/// borrow of the board while doing anything: we create one of these loose and then +/// it borrows the board only for the duration of what it needs. +#[derive(Clone)] +pub struct ObjectInfo { + pub id: ObjectId, + pub x: i64, + pub y: i64, + pub board: BoardRef, + pub script_name: Option, + pub queue: ObjQueue +} + +impl ObjectInfo { + pub fn from_id(id: ObjectId, board: BoardRef) -> Option { + let b = board.borrow(); + let obj = b.objects.get(&id)?; + Some(ObjectInfo { + id, + x: obj.x as i64, + y: obj.y as i64, + board: board.clone(), + script_name: obj.script_name.clone(), + queue: obj.queue.clone() + }) + } + + pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo { + Self { + id: obj.id, + x: obj.x as i64, + y: obj.y as i64, + board: board.clone(), + script_name: obj.script_name.clone(), + queue: obj.queue.clone() + } + } + + pub fn drain(&mut self, target: BoardQueue, dt: f64) { + let mut b = self.board.borrow_mut(); + if let Some(def) = b.objects.get_mut(&self.id) { + def.queue.drain(self.id, target, dt) + } + } +} + +impl Registerable for ObjectInfo { + fn register(engine: &mut Engine, _error_sink: ErrorSink) { + engine.register_type_with_name::("ObjectInfo") + .register_get("x", |obj: &mut ObjectInfo| obj.x) + .register_get("y", |obj: &mut ObjectInfo| obj.y) + .register_get("id", |obj: &mut ObjectInfo| obj.id as i64) + .register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting()) + .register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone()); + + engine.register_get("name", |o: &mut ObjectInfo| { + let board = o.board.borrow(); + let obj = board.objects.get(&o.id); + if let Some(ObjectDef { name: Some(name), ..}) = obj { + Dynamic::from(name.clone()) + } else { + Dynamic::UNIT + } + }); + + engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array { + let board = o.board.borrow(); + let obj = board.objects.get(&o.id); + if let Some(ObjectDef { tags, .. }) = obj { + tags.iter().map(|t| Dynamic::from(t.clone())).collect() + } else { + rhai::Array::new() + } + }); + + engine.register_get("glyph", |o: &mut ObjectInfo| { + let board = o.board.borrow(); + let obj = board.objects.get(&o.id).unwrap(); + obj.glyph + }); + + engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| { + if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) { + tags.contains(&t) + } else { + false + } + }); + + engine.register_fn("delay", |o: &mut ObjectInfo, dt: f64| o.queue.delay(dt)); + + engine.register_fn("can_push", move |o: &mut ObjectInfo, dir: Direction| { + let board = o.board.borrow(); + board.in_bounds((o.x, o.y)) && board.can_push(o.x as usize, o.y as usize, dir) + }); + + engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool { + let board = o.board.borrow(); + let tx = o.x + dir.dx(); + let ty = o.y + dir.dy(); + board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize) + }); + } +} \ No newline at end of file diff --git a/kiln-core/src/api/player.rs b/kiln-core/src/api/player.rs new file mode 100644 index 0000000..9d36615 --- /dev/null +++ b/kiln-core/src/api/player.rs @@ -0,0 +1,21 @@ +use rhai::Engine; +use crate::player::Player; +use crate::script::Registerable; +use crate::utils::{ErrorSink, PlayerPos}; + +/// GameState stores player state but Board stores its position, and we want one +/// object to register with Rhai +#[derive(Copy, Clone)] +pub struct PlayerWithPos(pub Player, pub PlayerPos); + +impl Registerable for PlayerWithPos { + fn register(engine: &mut Engine, _error_sink: ErrorSink) { + engine.register_type_with_name::("Player") + .register_get("gems", |player: &mut PlayerWithPos| player.0.gems) + .register_get("health", |player: &mut PlayerWithPos| player.0.health) + .register_get("max_health", |player: &mut PlayerWithPos| player.0.max_health) + .register_get("keys", |player: &mut PlayerWithPos| player.0.keys) + .register_get("x", |player: &mut PlayerWithPos| player.1.x) + .register_get("y", |player: &mut PlayerWithPos| player.1.y); + } +} diff --git a/kiln-core/src/api/queue.rs b/kiln-core/src/api/queue.rs new file mode 100644 index 0000000..a3b6488 --- /dev/null +++ b/kiln-core/src/api/queue.rs @@ -0,0 +1,118 @@ +//! ## Queue API +//! +//! `queue.length`, `queue.clear()`, `queue.peek()`, `queue.pop()`, `queue.delay()` + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; +use rhai::{Dynamic, Engine}; +use crate::action::{action_to_map, Action, BoardAction}; +use crate::script::{BoardQueue, Registerable}; +use crate::utils::{ErrorSink, ObjectId}; + +/// A single object's output queue. +#[derive(Clone)] +pub struct ObjQueue(Rc>>); + +impl ObjQueue { + pub fn new() -> Self { + Self(Rc::new(RefCell::new(VecDeque::new()))) + } + + /// Add a delay to the tail of the queue, or alter the value already there by the given amount. + /// A delay action's value can never be less than 0.0 + /// TODO This really ought to be Duration + pub fn delay(&mut self, delay: f64) { + let mut q = self.0.borrow_mut(); + if let Some(Action::Delay(r)) = q.back_mut() { + *r = (*r + delay).max(0.0) + } else { + q.push_back(Action::Delay(delay.max(0.0))) + } + } + + /// Add an action to the tail of the queue + pub fn act(&mut self, action: Action) { + self.0.borrow_mut().push_back(action); + } + + /// promote the most recently enqueued action to the front. + pub fn now(&mut self) { + let mut q = self.0.borrow_mut(); + if let Some(back) = q.pop_back() { + q.push_front(back); + } + } + + /// Drains object `i`'s output queue onto a target queue: first advances + /// leading `Delay` actions by dt, then drains actions into the target + /// queue until we run out (or hit another delay) + pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) { + let mut queue = self.0.borrow_mut(); + loop { + match queue.front_mut() { + None => break, // No more actions, we're done + Some(Action::Delay(time)) => { // A delay, decrease it by dt + if dt < *time { // Delay is too long, bail out + *time -= dt; + break; + } else { // dt eats the delay, pop it and continue + dt -= *time; + queue.pop_front(); + } + } + Some(_) => { + let action = queue.pop_front().unwrap(); + target.borrow_mut().push(BoardAction { source, action }); + } + } + } + } + + /// Return whether this queue has an `Action::Delay` in the front + pub fn waiting(&mut self) -> bool { + matches!(self.0.borrow().front(), Some(Action::Delay(_))) + } +} + +impl Registerable for ObjQueue { + fn register(engine: &mut Engine, error_sink: ErrorSink) { + engine.register_type_with_name::("Queue"); + engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64); + engine.register_fn("clear", |q: &mut ObjQueue| q.0.borrow_mut().clear()); + + // peek(): front action as a Rhai map, or () if empty. + engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic { + q.0.borrow() + .front() + .map(|a| Dynamic::from(action_to_map(a))) + .unwrap_or(Dynamic::UNIT) + }); + + // pop(): same, but removes the front. + engine.register_fn("pop", |q: &mut ObjQueue| -> Dynamic { + q.0.borrow_mut() + .pop_front() + .as_ref() + .map(|a| Dynamic::from(action_to_map(a))) + .unwrap_or(Dynamic::UNIT) + }); + + // Appends a [`Action::Delay`] to the back of `queue`, merging with an existing + // trailing delay to prevent adjacent delays from accumulating. + engine.register_fn("delay", move |q: &mut ObjQueue, secs: Dynamic| { + let secs: Result = if secs.is_float() { + secs.as_float() + } else if secs.is_int() { + secs.as_int().map(|i| i as f64) + } else { + Err("delay() must be an int or float") + }; + + match secs { + Ok(secs) => q.delay(secs), + Err(msg) => error_sink.error(msg.to_string()) + } + }); + } +} \ No newline at end of file diff --git a/kiln-core/src/api/state.rs b/kiln-core/src/api/state.rs new file mode 100644 index 0000000..8011121 --- /dev/null +++ b/kiln-core/src/api/state.rs @@ -0,0 +1,32 @@ +use rhai::Engine; +use crate::api::board::BoardRef; +use crate::api::player::PlayerWithPos; +use crate::game::GameState; +use crate::script::Registerable; +use crate::utils::ErrorSink; + +/// The host-provided context handed to every script hook for the duration of one +/// call. Currently just the player snapshot, but it exists so more host state can +/// be threaded through the `run_*` methods without changing each signature again. +#[derive(Clone)] +pub struct ScriptState { + pub player: PlayerWithPos, + pub board: BoardRef, +} + +impl ScriptState { + pub fn from_game_state(game_state: &GameState) -> Self { + Self { + player: PlayerWithPos(game_state.player, game_state.board().player), + board: game_state.board_rc(), + } + } +} + +impl Registerable for ScriptState { + fn register(engine: &mut Engine, _error_sink: ErrorSink) { + engine.register_type_with_name::("State") + .register_get("player", |state: &mut ScriptState| state.player) + .register_get("board", |state: &mut ScriptState| state.board.clone()); + } +} diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 735e011..c2fc600 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -80,6 +80,11 @@ impl Board { self.layers.len() } + /// Return a list of all `ObjectId`s currently on the board. + pub fn all_ids(&self) -> Vec { + self.objects.keys().cloned().collect() + } + /// Returns a reference to the cell at `(x, y)` on layer `z`. /// /// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are @@ -98,7 +103,7 @@ impl Board { /// Replace the solid (if any) at `(x, y)` with `Empty` pub fn clear_solid(&mut self, x: usize, y: usize) { - if self.in_bounds((x as i32, y as i32)) { + if self.in_bounds((x as i64, y as i64)) { if let Some(z) = self.solid_cell_layer(x, y) { *self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty) } @@ -121,7 +126,7 @@ impl Board { /// Panics if out of bounds. pub fn glyph_at(&self, x: usize, y: usize) -> Glyph { // The player is rendered above the whole stack (see the `Player` notes). - if self.player.x == x as i32 && self.player.y == y as i32 { + if self.player.x == x as i64 && self.player.y == y as i64 { return Glyph::player(); } @@ -169,7 +174,7 @@ impl Board { /// /// Takes signed coords so callers can pass a raw `pos + delta` without first /// checking for negatives. - pub fn in_bounds(&self, pos: (i32, i32)) -> bool { + pub fn in_bounds(&self, pos: (i64, i64)) -> bool { let (x, y) = pos; x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height } @@ -195,7 +200,7 @@ impl Board { /// Panics if `x` or `y` are out of bounds. pub fn solid_at(&self, x: usize, y: usize) -> Option { // The player wins its cell (load-time invariant), so it is the solid there. - if self.player.x == x as i32 && self.player.y == y as i32 { + if self.player.x == x as i64 && self.player.y == y as i64 { return Some(Solid::player_at(x, y)); } // A solid object shadows the cell it sits on; capture its behavior now. @@ -236,39 +241,6 @@ impl Board { self.solid_at(x, y).is_none() } - /// Returns `true` if the solids at `(x1, y1)` and `(x2, y2)` may share a cell — - /// i.e. moving one onto the other wouldn't break the "one solid per cell" - /// invariant. That holds when at least one cell is empty, or one holds the - /// player and the other a **grab** thing (the player collects it). - /// - /// Off-board coordinates are never combinable (returns `false` rather than - /// panicking). Backs the script-facing `combinable(x1, y1, x2, y2)` fn. - pub fn is_combinable(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> bool { - // Are either out of bounds? - if !self.in_bounds((x1 as i32, y1 as i32)) || !self.in_bounds((x2 as i32, y2 as i32)) { - return false - } - - // Grab the solids - let solid1 = self.solid_at(x1, y1); - let solid2 = self.solid_at(x2, y2); - - // Is one cell empty? - if solid1.is_none() || solid2.is_none() { return true } - - // They're both present, unwrap them: - let solid1 = solid1.unwrap(); - let solid2 = solid2.unwrap(); - - // This is probably disallowed then, but let's check for a player coexisting with a grab: - if solid1.player() && solid2.grab() || solid2.player() && solid1.grab() { - return true - } - - // Nope, two solids that can't coexist: - false - } - /// Whether the cell's single solid occupant (if any) can be pushed in `dir`. /// /// Non-solid things are never pushable: `pushable` only matters for solids. @@ -289,14 +261,14 @@ impl Board { /// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell /// ahead shovable?" half of a "can I move here?" query. pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool { - let (dx, dy): (i32, i32) = dir.into(); + let (dx, dy): (i64, i64) = dir.into(); let (mut cx, mut cy) = (x, y); loop { // This cell must hold a solid pushable in `dir` to advance the chain. if !self.is_pushable(cx, cy, dir) { return false; } - let next = (cx as i32 + dx, cy as i32 + dy); + let next = (cx as i64 + dx, cy as i64 + dy); if !self.in_bounds(next) { return false; // chain runs off the board } @@ -329,8 +301,8 @@ impl Board { if !self.is_pushable(x, y, dir) { return false; } - let (dx, dy): (i32, i32) = dir.into(); - let next = (x as i32 + dx, y as i32 + dy); + let (dx, dy): (i64, i64) = dir.into(); + let next = (x as i64 + dx, y as i64 + dy); if !self.in_bounds(next) { return false; // nothing to shift into off the board } @@ -354,7 +326,7 @@ impl Board { if !self.can_push(x, y, dir) { return; } - let (dx, dy): (i32, i32) = dir.into(); + let (dx, dy): (i64, i64) = dir.into(); // can_push guaranteed the chain ends at an in-bounds passable cell, so // re-walk it (no bounds checks needed) and shift the far end first, which // keeps each destination cell vacated before its occupant arrives. @@ -362,8 +334,8 @@ impl Board { let (mut cx, mut cy) = (x, y); while !self.is_passable(cx, cy) { chain.push((cx, cy)); - cx = (cx as i32 + dx) as usize; - cy = (cy as i32 + dy) as usize; + cx = (cx as i64 + dx) as usize; + cy = (cy as i64 + dy) as usize; } for &(px, py) in chain.iter().rev() { self.shift_solid(px, py, dx, dy); @@ -376,8 +348,8 @@ impl Board { /// terrain archetype (a crate) is moved within its own layer, leaving a /// transparent cell behind so the layer beneath (e.g. floor) shows through. /// The caller guarantees the destination is already clear. - fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) { - let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize); + fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) { + let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize); let Some(solid) = self.solid_at(x, y) else { return; // nothing to shift }; @@ -541,7 +513,7 @@ impl Board { /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script /// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log. - pub fn apply_shift(&mut self, cells: &[(i32, i32)]) -> Vec { + pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec { // Validate all the cells are in bounds, error if not: if cells.iter().any(|&c| !self.in_bounds(c)) { return vec![LogLine::error("Called shift() with a cell out of bounds")] @@ -637,7 +609,7 @@ pub(crate) mod tests { pub(crate) fn open_board( w: usize, h: usize, - player: (i32, i32), + player: (i64, i64), objects: Vec, ) -> Board { let mut object_map: BTreeMap = BTreeMap::new(); diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 7bb530b..364c0fd 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,8 +1,8 @@ -use crate::action::Action; +use crate::action::{Action, SendArg}; use crate::board::Board; use crate::log::LogLine; -use crate::script::{ScriptHost, ScriptState}; -use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg}; +use crate::script::ScriptHost; +use crate::utils::{Direction, ObjectId, PlayerPos}; use crate::world::World; use std::cell::{Ref, RefMut}; use std::time::Duration; @@ -13,6 +13,7 @@ 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::api::state::ScriptState; use crate::player::Player; /// An active scroll overlay opened by a scripted object via `scroll()`. @@ -162,7 +163,7 @@ impl GameState { /// the game is about to start — never during map deserialization, since a script /// may inspect the board. pub fn run_init(&mut self) { - self.scripts.run_init(ScriptState(self.player)); + self.scripts.run_init(ScriptState::from_game_state(&self)); self.resolve(); } @@ -176,7 +177,7 @@ impl GameState { // this runs exactly once per player interaction with a scroll. self.handle_scroll(); let secs = dt.as_secs_f64(); - self.scripts.run_tick(ScriptState(self.player), secs); + self.scripts.run_tick(ScriptState::from_game_state(&self), secs); // Expire speech bubbles before resolving new actions so a fresh say() // this frame isn't immediately culled. self.speech_bubbles.retain_mut(|b| { @@ -212,10 +213,11 @@ impl GameState { 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, Option)> = Vec::new(); + let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new(); let mut new_bubbles: Vec = Vec::new(); // Collected outside the board borrow so we can assign to self.active_scroll. let mut new_scroll: Option = None; + { let mut board = self.board_mut(); for ba in actions { @@ -347,11 +349,12 @@ impl GameState { self.log.push(LogLine::error(format!("set_key: unknown color {color:?}"))); } } + let state = ScriptState::from_game_state(self); for (bumped, bumper) in bumps { - self.scripts.run_bump(ScriptState(self.player), bumped, bumper); + self.scripts.run_bump(state.clone(), bumped, bumper); } for (target, fn_name, arg) in sends { - self.scripts.run_send(ScriptState(self.player), target, &fn_name, arg); + self.scripts.run_send(state.clone(), target, &fn_name, arg); } self.drain_errors(); } @@ -367,7 +370,7 @@ impl GameState { if let Some(scroll) = self.active_scroll.take() && let Some(choice) = scroll.choice { - self.scripts.run_send(ScriptState(self.player), scroll.source, &choice, None); + self.scripts.run_send(ScriptState::from_game_state(&self), scroll.source, &choice, SendArg::None); self.drain_errors(); } } @@ -407,8 +410,8 @@ impl GameState { // 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 i32, - y: ay as i32, + x: ax as i64, + y: ay as i64, }; // Rebuild the script host for the new board's objects. self.scripts = ScriptHost::new( @@ -432,9 +435,9 @@ impl GameState { let grabbed; let portal_target; { - let (dx, dy): (i32, i32) = dir.into(); + let (dx, dy): (i64, i64) = dir.into(); let mut board = self.board_mut(); - let target = (board.player.x + dx, board.player.y + dy); + let target = (board.player.x as i64 + dx, board.player.y as i64 + dy); if !board.in_bounds(target) { return; } @@ -454,8 +457,8 @@ impl GameState { if grabbed.is_none() { board.push(nx, ny, dir); } - board.player.x = nx as i32; - board.player.y = ny as i32; + board.player.x = nx as i64; + board.player.y = ny as i64; // Check for a portal at the new position; clone strings to release the borrow. portal_target = board .portals @@ -473,12 +476,13 @@ impl GameState { } // 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. + let state = ScriptState::from_game_state(&self); if let Some(id) = grabbed { - self.scripts.run_grab(ScriptState(self.player), id); + self.scripts.run_grab(state.clone(), id); self.resolve(); } if let Some(idx) = bumped { - self.scripts.run_bump(ScriptState(self.player), idx, -1); + self.scripts.run_bump(state, idx, -1); self.drain_errors(); } } @@ -493,9 +497,9 @@ impl GameState { /// 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 { - let (dx, dy): (i32, i32) = dir.into(); + let (dx, dy): (i64, i64) = dir.into(); let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; - let target = (ox as i32 + dx, oy as i32 + dy); + let target = (ox as i64 + dx, oy as i64 + dy); if !board.in_bounds(target) { return None; } @@ -572,7 +576,7 @@ mod tests { 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]); + 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); @@ -580,26 +584,13 @@ mod tests { 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] 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(dt) { if Queue.length() == 0 { shift([[2, 0], [3, 0]]); } }", + "fn tick(m,s,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }", ); game.tick(Duration::from_millis(16)); let b = game.board(); @@ -617,10 +608,10 @@ mod tests { 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 src = "fn init(m,s) { \ + log(if s.board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \ + log(if s.board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \ + log(if s.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(); @@ -735,7 +726,7 @@ mod tests { let board = open_board(2, 1, (1, 0), vec![sobj]); let scripts = HashMap::from([( "s".to_string(), - "fn init() { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(), + "fn init(m, s) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(), )]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); @@ -751,7 +742,7 @@ mod tests { let board2 = open_board(2, 1, (1, 0), vec![sobj2]); let scripts2 = HashMap::from([( "t".to_string(), - "fn init() { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(), + "fn init(m, s) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(), )]); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); @@ -767,7 +758,7 @@ mod tests { let board = open_board(2, 1, (1, 0), vec![sobj]); let scripts = HashMap::from([( "s".to_string(), - r#"fn init() { set_key("chartreuse", true); }"#.to_string(), + r#"fn init(m,s) { set_key("chartreuse", true); }"#.to_string(), )]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); diff --git a/kiln-core/src/glyph.rs b/kiln-core/src/glyph.rs index c0046e3..4b80604 100644 --- a/kiln-core/src/glyph.rs +++ b/kiln-core/src/glyph.rs @@ -1,5 +1,8 @@ use color::Rgba8; use std::hash::{Hash, Hasher}; +use rhai::Engine; +use crate::script::Registerable; +use crate::utils::ErrorSink; /// The visual representation of a single board cell. /// @@ -32,6 +35,19 @@ impl Hash for Glyph { } } +impl Registerable for Glyph { + fn register(engine: &mut Engine, _error_sink: ErrorSink) { + engine.register_type_with_name::("Glyph"); + engine.register_get("tile", |g: &mut Glyph| g.tile); + engine.register_get("fg", |g: &mut Glyph| { + format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b) + }); + engine.register_get("bg", |g: &mut Glyph| { + format!("#{:02x}{:02x}{:02x}", g.bg.r, g.bg.g, g.bg.b) + }); + } +} + impl Glyph { /// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue. /// diff --git a/kiln-core/src/keys.rs b/kiln-core/src/keys.rs index 74cf4b9..08fab12 100644 --- a/kiln-core/src/keys.rs +++ b/kiln-core/src/keys.rs @@ -1,5 +1,8 @@ use color::Rgba8; +use rhai::Engine; use crate::glyph::Glyph; +use crate::script::Registerable; +use crate::utils::ErrorSink; #[derive(Copy, Clone, Debug)] pub enum KeyType { @@ -82,4 +85,18 @@ impl Keyring { (self.white, KeyType::White.glyph().fg), ] } +} + +impl Registerable for Keyring { + fn register(engine: &mut Engine, _error_sink: ErrorSink) { + engine.register_type_with_name::("Keyring") + .register_get("red", |keyring: &mut Keyring| keyring.red) + .register_get("orange", |keyring: &mut Keyring| keyring.orange) + .register_get("yellow", |keyring: &mut Keyring| keyring.yellow) + .register_get("green", |keyring: &mut Keyring| keyring.green) + .register_get("blue", |keyring: &mut Keyring| keyring.blue) + .register_get("cyan", |keyring: &mut Keyring| keyring.cyan) + .register_get("purple", |keyring: &mut Keyring| keyring.purple) + .register_get("white", |keyring: &mut Keyring| keyring.white); + } } \ No newline at end of file diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index a9c9133..17085b2 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -24,10 +24,10 @@ mod utils; pub mod world; pub mod player; pub mod keys; - pub use archetype::{Archetype, Builtin}; pub use board::Board; pub use utils::Direction; #[cfg(test)] mod tests; +mod api; diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 29909d5..0f9bffc 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -17,7 +17,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::TryFrom; use std::path::Path; use tinyrand::{Seeded, StdRand}; - +use crate::api::queue::ObjQueue; use crate::archetype::Archetype; use crate::board::Board; use crate::builtin_scripts::archetype_from_builtin_tag; @@ -245,6 +245,7 @@ impl TryFrom for Board { // (see `expand_builtin_archetypes` below), not via templates. builtin_script: None, tags: t.tags.into_iter().collect(), + queue: ObjQueue::new(), name, }, ); @@ -278,8 +279,8 @@ impl TryFrom for Board { height: h, layers, player: PlayerPos { - x: px as i32, - y: py as i32, + x: px as i64, + y: py as i64, }, objects, next_object_id, diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index 12468f1..de8c540 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -2,6 +2,7 @@ use crate::glyph::Glyph; use crate::utils::ObjectId; use color::Rgba8; use std::collections::HashSet; +use crate::api::queue::ObjQueue; /// A scripted object placed on the board, loaded from a map file. /// @@ -75,6 +76,8 @@ pub struct ObjectDef { /// Names are validated for uniqueness at map-load time; a duplicate name is /// cleared to `None` (the object survives but becomes anonymous). pub name: Option, + /// The output queue of actions for this object + pub queue: ObjQueue, } impl ObjectDef { @@ -107,6 +110,7 @@ impl ObjectDef { script_name: None, builtin_script: None, tags: HashSet::new(), + queue: ObjQueue::new(), name: None, } } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 8036cad..96050a6 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -4,23 +4,15 @@ //! board's objects, and a persistent per-object [`Scope`]. It drives the optional //! lifecycle hooks on each scripted object: //! -//! - `init()` — run once after the whole map is loaded (see [`ScriptHost::run_init`]). -//! - `tick(dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). -//! - `bump(id)` — run when another mover steps into this object's cell, with the +//! - `init(me, state)` — run once after the whole map is loaded (see [`ScriptHost::run_init`]). +//! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). +//! - `bump(me, state, id)` — run when another mover steps into this object's cell, with the //! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`]. -//! - `grab()` — run when the player walks onto (or pushes into themselves) a -//! `grab` object; see [`ScriptHost::run_grab`]. Typically adds a stat + `die()`s. -//! -//! ## Script constants -//! -//! Object-specific handles live in the per-object scope (visible to the -//! top-level hook Rust calls): +//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`]. +//! Typically adds a stat + `die()`s. //! //! | Name | Type | Description | //! |---|---|---| -//! | `Board` | `Board` | Read-only world handle. | -//! | `Queue` | `Queue` | This object's pending-action queue. | -//! | `Me` | `Me` | Self-reference (id, name, x, y, tags, glyph). | //! | `Registry` | `Registry` | Board-scoped key→value store persisting across board transitions. | //! //! Direction and color constants are registered as a global Rhai module and @@ -31,85 +23,42 @@ //! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. | //! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. | //! -//! ## Board read API (`Board.*`) -//! -//! - `Board.player_x / player_y / width / height` — world properties -//! - `Board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag -//! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit) -//! - `Board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info -//! -//! ## Cell queries (free fns) -//! -//! - `blocked(dir) -> bool` — would the caller be blocked moving one step in `dir` -//! - `can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir` -//! - `can_shift(x, y, dir) -> bool` — like `can_push`, but only checks the cell ahead -//! - `passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole) -//! - `combinable(x1, y1, x2, y2) -> bool` — can the solids at the two cells share a -//! cell (one empty, or a grab thing and the player) -//! -//! ## Self-reference API (`Me.*`) -//! -//! - `Me.id / name / x / y` — getters -//! - `Me.has_tag(s) -> bool` -//! - `Me.tags() -> Array` -//! - `Me.glyph() -> Glyph` (`Glyph.tile`, `Glyph.fg`, `Glyph.bg`) -//! //! ## Write functions //! //! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`, //! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`, //! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`, //! `swap([[src_x, src_y, dst_x, dst_y], …])`, `add_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()` -//! -//! ## Queue API -//! -//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()` -use crate::action::{Action, MOVE_COST, ScrollLine, action_to_map}; -use crate::board::Board; -use crate::game::{SAY_DURATION}; +use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST}; +use crate::game::SAY_DURATION; use crate::log::LogLine; -use crate::map_file::{color_to_hex, parse_color}; +use crate::map_file::parse_color; use crate::object_def::ObjectDef; -use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg}; +use crate::utils::{Direction, ErrorSink, Hook, ObjectId, RegistryValue}; use rhai::{ - AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, - NativeCallContext, Scope, + Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext, + Scope, AST, }; use std::cell::RefCell; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use crate::player::Player; +use crate::api::board::BoardRef; +use crate::api::object_info::ObjectInfo; +use crate::api::player::PlayerWithPos; +use crate::api::queue::ObjQueue; +use crate::api::state::ScriptState; +use crate::glyph::Glyph; +use crate::keys::Keyring; -/// The host-provided context handed to every script hook for the duration of one -/// call. Currently just the player snapshot, but it exists so more host state can -/// be threaded through the `run_*` methods without changing each signature again. -#[derive(Copy, Clone)] -pub struct ScriptState(pub Player); - -/// An action promoted from an object's output queue onto the board queue, tagged -/// with the object that issued it. -pub(crate) struct BoardAction { - /// The stable [`ObjectId`] of the object that issued the action. - pub(crate) source: ObjectId, - /// The action to apply. - pub(crate) action: Action, +/// Types which can be registered to be sent to Rhai +pub trait Registerable { + /// Register this type and relevant getters / setters with a Rhai engine + fn register(engine: &mut Engine, error_sink: ErrorSink); } -/// A read-only handle to the world, exposed to scripts as `Board`. -type BoardRef = Rc>; - -/// A single object's output queue. -type ObjQueue = Rc>>; - /// The board queue: actions promoted from object queues, awaiting resolution. -type BoardQueue = Rc>>; - -/// Maps an [`ObjectId`] to its output queue. -type QueueMap = Rc>>; - -/// Channel for engine/compile/runtime errors. -type ErrorSink = Rc>>; +pub type BoardQueue = Rc>>; /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { @@ -120,15 +69,15 @@ struct CompiledScript { has_grab: bool, } -/// The runtime state of one scripted object. -struct ObjectRuntime { - object_id: ObjectId, - /// Key into [`ScriptHost::scripts`] for this object's compiled script — the - /// world-pool name for a named script, or the synthetic `BUILTIN_*` name for an - /// expanded built-in. - script_key: String, - scope: Scope<'static>, - output_queue: ObjQueue, +impl CompiledScript { + pub fn has(&self, hook: Hook) -> bool { + match hook { + Hook::Init => self.has_init, + Hook::Tick => self.has_tick, + Hook::Grab => self.has_grab, + Hook::Bump => self.has_bump + } + } } /// The compile-key for an object's script: the object's `script_name` — a @@ -143,34 +92,6 @@ fn script_key(obj: &ObjectDef) -> Option { // ── Rhai types exposed to scripts ──────────────────────────────────────────── -/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, -/// and `Board.get`. Passed by value — scripts read fields, not a live reference. -#[derive(Clone)] -struct ObjectInfo { - id: i64, - x: i64, - y: i64, - name: Option, - tags: Vec, -} - -/// The glyph (tile + colors) of a board object, returned by `Me.glyph()`. -/// Colors are `"#RRGGBB"` hex strings matching the map-file format. -#[derive(Clone)] -struct RhaiGlyph { - tile: i64, - fg: String, - bg: String, -} - -/// A script's read-only reference to itself, pushed into scope as the constant `Me`. -/// Holds the object id and a shared board handle so getters can look up live values. -#[derive(Clone)] -struct Me { - object_id: ObjectId, - board: BoardRef, -} - /// The board's script registry, pushed into scope as the constant `Registry`. /// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs /// that persist across board transitions. Mutation goes through the inner @@ -180,28 +101,6 @@ struct Registry { board: BoardRef, } -// ── ObjectInfo helper ───────────────────────────────────────────────────────── - -fn object_info_from(id: ObjectId, board: &Board) -> Option { - let obj = board.objects.get(&id)?; - Some(ObjectInfo { - id: id as i64, - x: obj.x as i64, - y: obj.y as i64, - name: obj.name.clone(), - tags: obj.tags.iter().cloned().collect(), - }) -} - -fn object_info_player(board: &Board) -> ObjectInfo { - ObjectInfo { - id: -1, - x: board.player.x as i64, - y: board.player.y as i64, - name: None, - tags: Vec::new(), - } -} // ── ScriptHost ──────────────────────────────────────────────────────────────── @@ -209,7 +108,7 @@ fn object_info_player(board: &Board) -> ObjectInfo { pub struct ScriptHost { engine: Engine, scripts: HashMap, - objects: Vec, + scopes: HashMap>, board_queue: BoardQueue, errors: ErrorSink, } @@ -224,19 +123,22 @@ impl ScriptHost { /// read only during construction and not retained afterward. pub fn new(board_cell: &BoardRef, script_sources: &HashMap) -> Self { let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new())); - let queues: QueueMap = Rc::new(RefCell::new(HashMap::new())); - let errors: ErrorSink = Rc::new(RefCell::new(Vec::new())); + let errors = ErrorSink::new(); + let mut scopes = HashMap::new(); let mut engine = Engine::new(); - register_read_api(&mut engine, board_cell, &board_queue, &errors); - register_write_api(&mut engine, &queues); - register_queue_api(&mut engine); - register_me_type(&mut engine); - register_object_info_type(&mut engine); - register_glyph_type(&mut engine); + + PlayerWithPos::register(&mut engine, errors.clone()); + Keyring::register(&mut engine, errors.clone()); + BoardRef::register(&mut engine, errors.clone()); + ScriptState::register(&mut engine, errors.clone()); + ObjectInfo::register(&mut engine, errors.clone()); + Glyph::register(&mut engine, errors.clone()); + ObjQueue::register(&mut engine, errors.clone()); + + register_write_api(&mut engine, board_cell.clone()); register_registry_type(&mut engine); register_global_constants(&mut engine); - register_player_type(&mut engine); let board = board_cell.borrow(); @@ -261,9 +163,7 @@ impl ScriptHost { Some(src) => src, None => { failed.insert(key.clone()); - errors.borrow_mut().push(LogLine::error(format!( - "object references unknown script '{key}'" - ))); + errors.error(format!("object references unknown script '{key}'")); continue; } } @@ -277,25 +177,22 @@ impl ScriptHost { scripts.insert( key, CompiledScript { - has_init: defines("init", 0), - has_tick: defines("tick", 1), - has_bump: defines("bump", 1), - has_grab: defines("grab", 0), + has_init: defines("init", 2), + has_tick: defines("tick", 3), + has_bump: defines("bump", 3), + has_grab: defines("grab", 2), ast, }, ); } Err(err) => { failed.insert(key.clone()); - errors.borrow_mut().push(LogLine::error(format!( - "script '{key}' failed to compile: {err}" - ))); + errors.error(format!("script '{key}' failed to compile: {err}")); } } } // One runtime per object whose script compiled. - let mut objects = Vec::new(); for (&id, obj) in board.objects.iter() { let Some(key) = script_key(obj) else { continue; @@ -303,73 +200,80 @@ impl ScriptHost { if !scripts.contains_key(&key) { continue; } - let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); - queues.borrow_mut().insert(id, queue.clone()); - objects.push(ObjectRuntime { - object_id: id, - script_key: key, - scope: new_object_scope(board_cell, &queue, id), - output_queue: queue, - }); + scopes.insert(id, new_object_scope(board_cell)); } drop(board); Self { engine, scripts, - objects, board_queue, errors, + scopes, } } - /// Calls `init()` on every scripted object that defines it and drains each queue. - pub fn run_init(&mut self, state: ScriptState) { - self.run("init", |c| c.has_init, (), 0.0, state); - } - /// Calls `tick(dt)` on every scripted object that defines it, then drains queues. pub fn run_tick(&mut self, state: ScriptState, dt: f64) { - self.run("tick", |c| c.has_tick, (dt,), dt, state); + self.run_hook_on_all(Hook::Tick, state, dt); + } + + pub fn run_init(&mut self, state: ScriptState) { + self.run_hook_on_all(Hook::Init, state, 0.0) + } + + /// Run the given hook on every object that defines it. For hooks other than + /// `Tick`, dt should just be 0.0 + fn run_hook_on_all(&mut self, hook: Hook, state: ScriptState, dt: f64) { + let all_ids = state.board.borrow().all_ids(); + let arg = match hook { + Hook::Tick => Some(Dynamic::from(dt)), + _ => None, + }; + + for id in all_ids { + self.run_hook_on_one(hook, state.clone(), id, arg.clone(), dt) + } + } + + fn run_hook_on_one(&mut self, hook: Hook, state: ScriptState, id: ObjectId, arg: Option, dt: f64) { + if let Some(mut info) = ObjectInfo::from_id(id, state.board.clone()) { + if let Some(script_key) = info.script_name.as_ref() + && let Some(script) = self.scripts.get(script_key) + && let Some(scope) = self.scopes.get_mut(&id) { + + if script.has(hook) { + let mut args = vec![ + Dynamic::from(info.clone()), + Dynamic::from(state.clone()) + ]; + arg.map(|d| args.push(d)); + + // Call this with opts tagging this call as our id. The write API will read + // that to find what queue to emit events to + if let Err(err) = self.engine.call_fn_with_options::<()>( + CallFnOptions::default().with_tag(id as i64), + scope, + &script.ast, + hook.to_str(), + args, + ) { + self.errors.error(format!("script '{}' {} error: {err}", script_key, hook)); + } + } + // Run the drain regardless of if we have the hook, otherwise + // things with no tick will never advance past a delay + info.drain(self.board_queue.clone(), dt) + } + } else { + unreachable!("Object not found"); + } } /// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines /// the hook. After the hook, drains the object's queue with `dt = 0`. - pub fn run_bump(&mut self, state: ScriptState, object_id: ObjectId, bumper: i64) { - let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else { - return; - }; - { - let Self { - engine, - scripts, - objects, - errors, - .. - } = self; - let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_key) else { - return; - }; - if !compiled.has_bump { - return; - } - let options = CallFnOptions::default().with_tag(object_id as i64); - obj.scope.set_or_push("Player", state.0); - if let Err(err) = engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, - "bump", - (bumper,), - ) { - errors.borrow_mut().push(LogLine::error(format!( - "script '{}' bump error: {err}", - obj.script_key - ))); - } - } - self.drain(i, 0.0); + pub fn run_bump(&mut self, state: ScriptState, id: ObjectId, bumper: i64) { + self.run_hook_on_one(Hook::Bump, state, id, Some(Dynamic::from(bumper)), 0.0); } /// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the @@ -379,110 +283,68 @@ impl ScriptHost { /// into the player (see [`GameState`](crate::game::GameState)). The hook /// typically increments a player stat and removes the object via `die()`. pub fn run_grab(&mut self, state: ScriptState, object_id: ObjectId) { - let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else { - return; - }; - { - let Self { - engine, - scripts, - objects, - errors, - .. - } = self; - let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_key) else { - return; - }; - if !compiled.has_grab { - return; - } - let options = CallFnOptions::default().with_tag(object_id as i64); - obj.scope.set_or_push("Player", state.0); - if let Err(err) = engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, - "grab", - (), - ) { - errors.borrow_mut().push(LogLine::error(format!( - "script '{}' grab error: {err}", - obj.script_key - ))); - } - } - self.drain(i, 0.0); + self.run_hook_on_one(Hook::Grab, state, object_id, None, 0.0); } /// Calls the named function on the object with [`ObjectId`] `target_id`. /// - /// If `arg` is `Some` and the function accepts one parameter, the arg is passed. - /// If the function accepts zero parameters (or arg is `None`), it is called with - /// no args. If neither arity exists, the call is silently skipped. - /// After the call, drains the object's queue with `dt = 0`. - pub(crate) fn run_send(&mut self, state: ScriptState, target_id: ObjectId, fn_name: &str, arg: Option) { - let Some(i) = self.objects.iter().position(|o| o.object_id == target_id) else { - return; - }; - { - let Self { - engine, - scripts, - objects, - errors, - .. - } = self; - let obj = &mut objects[i]; - let Some(compiled) = scripts.get(&obj.script_key) else { - return; - }; + /// What we pass depends on arity, in this order: + /// - If it has arity 3, we pass `[ObjectInfo, ScriptState, SendArg]` + /// - If it has arity 2, we pass `[ObjectInfo, ScriptState]` + /// - If it has arity 1, we pass the arg + /// - If it has arity 0, we pass nothing + /// + /// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT` + pub(crate) fn run_send(&mut self, state: ScriptState, id: ObjectId, fn_name: &str, arg: SendArg) { + if let Some(mut info) = ObjectInfo::from_id(id, state.board.clone()) { + if let Some(script_key) = info.script_name.as_ref() + && let Some(script) = self.scripts.get(script_key) + && let Some(scope) = self.scopes.get_mut(&id) { - let has_1 = compiled - .ast - .iter_functions() - .any(|f| f.name == fn_name && f.params.len() == 1); - let has_0 = compiled - .ast - .iter_functions() - .any(|f| f.name == fn_name && f.params.is_empty()); + // Find the function: + let arities: HashSet<_> = script.ast.iter_functions().filter_map(|f| { + if f.name == fn_name { + Some(f.params.len()) + } else { + None + } + }).collect(); - let options = CallFnOptions::default().with_tag(obj.object_id as i64); - obj.scope.set_or_push("Player", state.0); - let result = if has_1 { - // Call with arg (or unit if arg is absent). - let dyn_arg: Dynamic = match &arg { - Some(ScriptArg::Str(s)) => Dynamic::from(s.clone()), - Some(ScriptArg::Num(n)) => Dynamic::from(*n), - None => Dynamic::UNIT, - }; - engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, + // If it's not there at all, just bail: + if arities.is_empty() { + self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); + return + } + + // Assemble the args + let mut args = vec![]; + if arities.contains(&3) { + args.push(Dynamic::from(info.clone())); + args.push(Dynamic::from(state.clone())); + args.push(arg.into()); + } else if arities.contains(&2) { + args.push(Dynamic::from(info.clone())); + args.push(Dynamic::from(state.clone())); + } else if arities.contains(&1) { + args.push(arg.into()); + } + + // Call this with opts tagging this call as our id. The write API will read + // that to find what queue to emit events to + if let Err(err) = self.engine.call_fn_with_options::<()>( + CallFnOptions::default().with_tag(id as i64), + scope, + &script.ast, fn_name, - (dyn_arg,), - ) - } else if has_0 { - engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, - fn_name, - (), - ) - } else { - return; // Function not defined on this object - }; - - if let Err(err) = result { - errors.borrow_mut().push(LogLine::error(format!( - "script '{}' send('{}') error: {err}", - obj.script_key, fn_name - ))); + args, + ) { + self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); + } + info.drain(self.board_queue.clone(), 0.0) } + } else { + unreachable!("Object id not found, tried to send"); } - self.drain(i, 0.0); } /// Removes and returns the actions promoted onto the board queue. @@ -492,162 +354,8 @@ impl ScriptHost { /// Removes and returns the errors collected since the last drain. pub fn take_errors(&mut self) -> Vec { - std::mem::take(&mut self.errors.borrow_mut()) + self.errors.take() } - - /// Drains object `i`'s output queue onto the board queue, advancing inline - /// [`Action::Delay`]s by `dt`. - fn drain(&mut self, i: usize, mut dt: f64) { - let oid = self.objects[i].object_id; - loop { - let queue = self.objects[i].output_queue.clone(); - let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_))); - if is_delay { - let expired = { - let mut q = queue.borrow_mut(); - let Some(Action::Delay(r)) = q.front_mut() else { - break; - }; - *r -= dt; - dt = 0.0; - *r <= 0.0 - }; - if expired { - queue.borrow_mut().pop_front(); - } else { - break; - } - } else { - let Some(action) = queue.borrow_mut().pop_front() else { - break; - }; - self.board_queue.borrow_mut().push(BoardAction { - source: oid, - action, - }); - } - } - } - - /// Shared driver for `init`/`tick`. - fn run( - &mut self, - hook: &str, - defined: fn(&CompiledScript) -> bool, - args: A, - drain_dt: f64, - state: ScriptState, - ) { - for i in 0..self.objects.len() { - { - let Self { - engine, - scripts, - objects, - errors, - .. - } = self; - let obj = &mut objects[i]; - if let Some(compiled) = scripts.get(&obj.script_key) - && defined(compiled) - { - let options = CallFnOptions::default().with_tag(obj.object_id as i64); - obj.scope.set_or_push("Player", state.0); - if let Err(err) = engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, - hook, - args, - ) { - errors.borrow_mut().push(LogLine::error(format!( - "script '{}' {hook} error: {err}", - obj.script_key - ))); - } - } - } - self.drain(i, drain_dt); - } - } -} - -fn register_player_type(engine: &mut Engine) { - // TODO also expose keys somehow - engine.register_type_with_name::("Player") - .register_get("gems", |player: &mut Player| player.gems) - .register_get("health", |player: &mut Player| player.health) - .register_get("max_health", |player: &mut Player| player.max_health); -} - -// ── Register Me type ────────────────────────────────────────────────────────── - -fn register_me_type(engine: &mut Engine) { - engine.register_type_with_name::("Me"); - - engine.register_get("id", |me: &mut Me| me.object_id as i64); - - engine.register_get("name", |me: &mut Me| { - me.board - .borrow() - .objects - .get(&me.object_id) - .and_then(|o| o.name.as_deref()) - .unwrap_or("") - .to_string() - }); - - engine.register_get("x", |me: &mut Me| { - me.board - .borrow() - .objects - .get(&me.object_id) - .map(|o| o.x as i64) - .unwrap_or(0) - }); - - engine.register_get("y", |me: &mut Me| { - me.board - .borrow() - .objects - .get(&me.object_id) - .map(|o| o.y as i64) - .unwrap_or(0) - }); - - engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| { - me.board - .borrow() - .objects - .get(&me.object_id) - .is_some_and(|o| o.tags.contains(tag.as_str())) - }); - - engine.register_fn("tags", |me: &mut Me| -> rhai::Array { - me.board - .borrow() - .objects - .get(&me.object_id) - .map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect()) - .unwrap_or_default() - }); - - engine.register_fn("glyph", |me: &mut Me| -> RhaiGlyph { - let board = me.board.borrow(); - if let Some(obj) = board.objects.get(&me.object_id) { - RhaiGlyph { - tile: obj.glyph.tile as i64, - fg: color_to_hex(obj.glyph.fg), - bg: color_to_hex(obj.glyph.bg), - } - } else { - RhaiGlyph { - tile: 0, - fg: "#000000".to_string(), - bg: "#000000".to_string(), - } - } - }); } // ── Register Registry type ──────────────────────────────────────────────────── @@ -702,286 +410,101 @@ fn register_registry_type(engine: &mut Engine) { ); } -// ── Register ObjectInfo type ────────────────────────────────────────────────── - -fn register_object_info_type(engine: &mut Engine) { - engine.register_type_with_name::("ObjectInfo"); - - engine.register_get("id", |o: &mut ObjectInfo| o.id); - engine.register_get("x", |o: &mut ObjectInfo| o.x); - engine.register_get("y", |o: &mut ObjectInfo| o.y); - - engine.register_get("name", |o: &mut ObjectInfo| { - o.name.as_deref().unwrap_or("").to_string() - }); - - engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array { - o.tags.iter().map(|t| Dynamic::from(t.clone())).collect() - }); -} - -// ── Register RhaiGlyph type ─────────────────────────────────────────────────── - -fn register_glyph_type(engine: &mut Engine) { - engine.register_type_with_name::("Glyph"); - engine.register_get("tile", |g: &mut RhaiGlyph| g.tile); - engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone()); - engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone()); -} - -// ── Board read API ──────────────────────────────────────────────────────────── - -fn register_read_api( - engine: &mut Engine, - board: &BoardRef, - board_queue: &BoardQueue, - errors: &ErrorSink, -) { - engine.register_type_with_name::("Board"); - engine.register_get("player_x", |b: &mut BoardRef| b.borrow().player.x as i64); - engine.register_get("player_y", |b: &mut BoardRef| b.borrow().player.y as i64); - engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64); - engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64); - - // blocked(dir) — true if moving in dir would be impossible for the calling object. - let b = board.clone(); - let bq = board_queue.clone(); - engine.register_fn("blocked", move |ctx: NativeCallContext, dir: Direction| { - is_blocked(&b, &bq, source_of(&ctx), dir) - }); - - // can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be - // shoved one step in dir (the read-only half of push()). False off-board. - let b = board.clone(); - engine.register_fn("can_push", move |x: i64, y: i64, dir: Direction| -> bool { - let board = b.borrow(); - board.in_bounds((x as i32, y as i32)) && board.can_push(x as usize, y as usize, dir) - }); - - // can_shift(x, y, dir) — like can_push but inspects only the cell ahead: (x, y) - // holds a pushable and the next cell is empty or another pushable. Shifting - // onto the player counts only when the source is a grab thing (it gets - // grabbed). False off-board. The right gate for a shift/rotation via swap(). - let b = board.clone(); - engine.register_fn("can_shift", move |x: i64, y: i64, dir: Direction| -> bool { - let board = b.borrow(); - board.in_bounds((x as i32, y as i32)) && board.can_shift(x as usize, y as usize, dir) - }); - - // passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell - // a mover could enter). Off-board is not passable. Lets a script distinguish a - // hole from a blocked solid (which can_shift/can_push alone cannot). - let b = board.clone(); - engine.register_fn("passable", move |x: i64, y: i64| -> bool { - let board = b.borrow(); - board.in_bounds((x as i32, y as i32)) && board.is_passable(x as usize, y as usize) - }); - - // combinable(x1, y1, x2, y2) — true if the solids at (x1, y1) and (x2, y2) can coexist in a cell - // (meaning, either one contains no solid, or one contains a grab solid and the other contains the - // player). Off-board is not combinable with anything. - let b = board.clone(); - engine.register_fn("combinable", move |x1: i64, y1: i64, x2: i64, y2: i64| -> bool { - let board = b.borrow(); - board.is_combinable(x1 as usize, y1 as usize, x2 as usize, y2 as usize) - }); - - // Board.tagged(tag) -> Array[ObjectInfo] - let b = board.clone(); - engine.register_fn( - "tagged", - move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array { - let board = b.borrow(); - board - .objects - .iter() - .filter(|(_, o)| o.tags.contains(tag.as_str())) - .filter_map(|(&id, _)| object_info_from(id, &board)) - .map(Dynamic::from) - .collect() - }, - ); - - // Board.named(name) -> ObjectInfo | () - let b = board.clone(); - engine.register_fn( - "named", - move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic { - let board = b.borrow(); - board - .objects - .iter() - .find(|(_, o)| o.name.as_deref() == Some(name.as_str())) - .and_then(|(&id, _)| object_info_from(id, &board)) - .map(Dynamic::from) - .unwrap_or(Dynamic::UNIT) - }, - ); - - // Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error) - let b = board.clone(); - let errs = errors.clone(); - engine.register_fn("get", move |_board: &mut BoardRef, id: i64| -> Dynamic { - let board = b.borrow(); - if id == -1 { - return Dynamic::from(object_info_player(&board)); - } - if id <= 0 { - errs.borrow_mut() - .push(LogLine::error(format!("Board.get: invalid id {id}"))); - return Dynamic::UNIT; - } - match object_info_from(id as ObjectId, &board) { - Some(info) => Dynamic::from(info), - None => { - errs.borrow_mut() - .push(LogLine::error(format!("Board.get: no object with id {id}"))); - Dynamic::UNIT - } - } - }); -} - -/// Whether the object at `source` would be blocked moving in `dir`. -fn is_blocked( - board: &BoardRef, - board_queue: &BoardQueue, - source: ObjectId, - dir: Direction, -) -> bool { - let board = board.borrow(); - let Some(obj) = board.objects.get(&source) else { - return true; - }; - let (dx, dy): (i32, i32) = dir.into(); - let target = (obj.x as i32 + dx, obj.y as i32 + dy); - if !board.in_bounds(target) { - return true; - } - let (tx, ty) = (target.0 as usize, target.1 as usize); - if board.solid_at(tx, ty).is_some() { - return true; - } - board_queue.borrow().iter().any(|ba| { - if let Action::Move(d) = &ba.action - && let Some(mover) = board.objects.get(&ba.source) - { - let (mdx, mdy): (i32, i32) = (*d).into(); - return (mover.x as i32 + mdx, mover.y as i32 + mdy) == target; - } - false - }) -} - // ── Write API ───────────────────────────────────────────────────────────────── -fn register_write_api(engine: &mut Engine, queues: &QueueMap) { +fn register_write_api(engine: &mut Engine, board: BoardRef) { engine.register_type_with_name::("Direction"); // move(dir): enqueue a Move followed by a rate-limiting Delay. - let q = queues.clone(); + let b = board.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { let src = source_of(&ctx); - if let Some(queue) = q.borrow().get(&src) { - queue.borrow_mut().push_back(Action::Move(dir)); - push_delay(queue, MOVE_COST); + if let Some(def) = b.borrow_mut().objects.get_mut(&src) { + def.queue.act(Action::Move(dir)); + def.queue.delay(MOVE_COST); } }); - // delay(secs): insert an explicit pause. Two overloads so integer literals work. - let q = queues.clone(); - engine.register_fn("delay", move |ctx: NativeCallContext, secs: f64| { + let b = board.clone(); + engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| { let src = source_of(&ctx); - if let Some(queue) = q.borrow().get(&src) { - push_delay(queue, secs.max(0.0)); - } - }); - let q = queues.clone(); - engine.register_fn("delay", move |ctx: NativeCallContext, secs: i64| { - let src = source_of(&ctx); - if let Some(queue) = q.borrow().get(&src) { - push_delay(queue, (secs.max(0)) as f64); + if let Some(def) = b.borrow_mut().objects.get_mut(&src) + && let Ok(dt) = dt.as_float() { + def.queue.delay(dt); } }); - // now(): promote the most recently enqueued action to the front. - let q = queues.clone(); + let b = board.clone(); engine.register_fn("now", move |ctx: NativeCallContext| { let src = source_of(&ctx); - if let Some(queue) = q.borrow().get(&src) { - let mut q = queue.borrow_mut(); - if let Some(back) = q.pop_back() { - q.push_front(back); - } + if let Some(def) = b.borrow_mut().objects.get_mut(&src) { + def.queue.now() } }); - let q = queues.clone(); + let b = board.clone(); engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { - emit(&q, source_of(&ctx), Action::SetTile(tile as u32)); + emit(&b, source_of(&ctx), Action::SetTile(tile as u32)); }); // add_gems(n): change the player's gem count (negative subtracts; clamped at 0). - let q = queues.clone(); + let b = board.clone(); engine.register_fn("add_gems", move |ctx: NativeCallContext, n: i64| { - emit(&q, source_of(&ctx), Action::AddGems(n)); + emit(&b, source_of(&ctx), Action::AddGems(n)); }); // alter_health(dh): change the player's health by dh (clamped to [0, max_health]). - let q = queues.clone(); + let b = board.clone(); engine.register_fn("alter_health", move |ctx: NativeCallContext, dh: i64| { - emit(&q, source_of(&ctx), Action::AlterHealth(dh)); + emit(&b, source_of(&ctx), Action::AlterHealth(dh)); }); // set_key(color, present): give (true) or take (false) the named key color. - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "set_key", move |ctx: NativeCallContext, color: ImmutableString, present: bool| { - emit(&q, source_of(&ctx), Action::SetKey(color.into(), present)); + emit(&b, source_of(&ctx), Action::SetKey(color.into(), present)); }, ); // die(): remove the calling object from the board. - let q = queues.clone(); + let b = board.clone(); engine.register_fn("die", move |ctx: NativeCallContext| { - emit(&q, source_of(&ctx), Action::Die); + emit(&b, source_of(&ctx), Action::Die); }); - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "log", move |ctx: NativeCallContext, msg: ImmutableString| { - emit( - &q, - source_of(&ctx), - Action::Log(LogLine::raw(msg.to_string())), - ); + let id = source_of(&ctx); + emit(&b, id, Action::Log(LogLine::raw(msg.to_string()))); }, ); - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "say", move |ctx: NativeCallContext, msg: ImmutableString| { emit( - &q, + &b, source_of(&ctx), Action::Say(msg.to_string(), SAY_DURATION), ); }, ); - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| { - emit(&q, source_of(&ctx), Action::Say(msg.to_string(), dur)); + emit(&b, source_of(&ctx), Action::Say(msg.to_string(), dur)); }, ); // scroll(lines): open a full-screen scrollable overlay. Each element of `lines` // is either a plain string (text) or a 2-element array [choice_key, display_text]. - let q = queues.clone(); + let b = board.clone(); engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| { let lines = arr .into_iter() @@ -1000,32 +523,32 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { } }) .collect(); - emit(&q, source_of(&ctx), Action::Scroll(lines)); + emit(&b, source_of(&ctx), Action::Scroll(lines)); }); - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "set_tag", move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| { - emit( - &q, - source_of(&ctx), - Action::SetTag { - target: target as ObjectId, - tag: tag.to_string(), - present, - }, - ); - }, + emit(&b, source_of(&ctx), Action::SetTag { target: target as ObjectId, tag: tag.to_string(), present }) + } + ); + + let b = board.clone(); + engine.register_fn( + "set_tag", + move |ctx: NativeCallContext, target: ObjectId, tag: ImmutableString, present: bool| { + emit(&b, source_of(&ctx), Action::SetTag { target, tag: tag.to_string(), present }) + } ); // set_fg(fg): change foreground color only. - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "set_fg", move |ctx: NativeCallContext, fg: ImmutableString| { emit( - &q, + &b, source_of(&ctx), Action::SetColor { fg: Some(parse_color(fg.as_str())), @@ -1036,12 +559,12 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { ); // set_bg(bg): change background color only. - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "set_bg", move |ctx: NativeCallContext, bg: ImmutableString| { emit( - &q, + &b, source_of(&ctx), Action::SetColor { fg: None, @@ -1052,12 +575,12 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { ); // set_color(fg, bg): change both colors. - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "set_color", move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| { emit( - &q, + &b, source_of(&ctx), Action::SetColor { fg: Some(parse_color(fg.as_str())), @@ -1068,43 +591,34 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { ); // send(target, fn_name): call a named function on another object. - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString| { emit( - &q, + &b, source_of(&ctx), Action::Send { target: target as ObjectId, fn_name: name.to_string(), - arg: None, + arg: SendArg::None, }, ); }, ); // send(target, fn_name, arg): same, with an optional string or number argument. - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString, arg: Dynamic| { - let script_arg = if let Ok(n) = arg.clone().as_int() { - Some(ScriptArg::Num(n as f64)) - } else if let Ok(f) = arg.clone().as_float() { - Some(ScriptArg::Num(f)) - } else if let Ok(s) = arg.into_string() { - Some(ScriptArg::Str(s)) - } else { - None - }; emit( - &q, + &b, source_of(&ctx), Action::Send { target: target as ObjectId, fn_name: name.to_string(), - arg: script_arg, + arg: arg.into(), }, ); }, @@ -1113,89 +627,50 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { // teleport(x, y): move the calling object to an arbitrary cell. Zero time cost. // Blocked (with an error logged at resolve time) if the object is solid and the // destination already has a solid occupant. - let q = queues.clone(); + let b = board.clone(); engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| { - emit( - &q, - source_of(&ctx), - Action::Teleport { - x: x as i32, - y: y as i32, - }, - ); + emit(&b, source_of(&ctx), Action::Teleport { x, y }); }); // push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on // arbitrary cells, not the caller, and adds no delay (zero time cost). - let q = queues.clone(); + let b = board.clone(); engine.register_fn( "push", move |ctx: NativeCallContext, x: i64, y: i64, dir: Direction| { - emit( - &q, - source_of(&ctx), - Action::Push { - x: x as i32, - y: y as i32, - dir, - }, - ); - }, + emit(&b, source_of(&ctx), Action::Push { x, y, dir, }); + } ); // shift([[x, y], ...): Emits a shift action which moves the contents of each given cell in // a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable, // and won't move anything into a cell that's not vacant (or vacated by this shift). - let q = queues.clone(); + let b = board.clone(); engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| { let src = source_of(&ctx); match read_coord_array(&arr) { - Ok(pairs) => emit(&q, src, Action::Shift(pairs)), - Err(_) => emit(&q, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string()))) + Ok(pairs) => emit(&b, src, Action::Shift(pairs)), + Err(_) => emit(&b, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string()))) } }); } /// Read a `Vec<(i32, i32)>` from a Rhai array, to receive a list of coordinates from a script. /// Returns Err if the array isn't `[[x, y], ...]` -fn read_coord_array(arr: &Array) -> Result, ()> { - let mut pairs: Vec<(i32, i32)> = Vec::new(); +fn read_coord_array(arr: &Array) -> Result, ()> { + let mut pairs = Vec::new(); for elem in arr { let coords: Option> = elem.read_lock::().and_then(|inner| { inner.iter().map(|v| v.as_int().ok()).collect() }); if let Some(coords) = coords && coords.len() == 2 { - pairs.push((coords[0] as i32, coords[1] as i32)) + pairs.push((coords[0], coords[1])) } else { return Err(()); } } Ok(pairs) } -// ── Queue API ───────────────────────────────────────────────────────────────── - -fn register_queue_api(engine: &mut Engine) { - engine.register_type_with_name::("Queue"); - engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64); - engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear()); - - // peek(): front action as a Rhai map, or () if empty. - engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic { - q.borrow() - .front() - .map(|a| Dynamic::from(action_to_map(a))) - .unwrap_or(Dynamic::UNIT) - }); - - // pop(): same, but removes the front. - engine.register_fn("pop", |q: &mut ObjQueue| -> Dynamic { - q.borrow_mut() - .pop_front() - .as_ref() - .map(|a| Dynamic::from(action_to_map(a))) - .unwrap_or(Dynamic::UNIT) - }); -} // ── Scope construction & global constants ───────────────────────────────────── @@ -1218,43 +693,16 @@ fn register_global_constants(engine: &mut Engine) { /// Builds a fresh per-object scope containing only the object-specific handles: /// the Board view, the object's Queue, and the Me self-reference. -fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> Scope<'static> { +fn new_object_scope(board: &BoardRef) -> Scope<'static> { let mut scope = Scope::new(); - scope.push_constant("Board", board.clone()); - scope.push_constant("Queue", queue.clone()); - scope.push_constant( - "Me", - Me { - object_id, - board: board.clone(), - }, - ); - scope.push_constant( - "Registry", - Registry { - board: board.clone(), - }, - ); + scope.push_constant("Registry", Registry { board: board.clone() }); scope } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing -/// trailing delay to prevent adjacent delays from accumulating. -fn push_delay(queue: &ObjQueue, secs: f64) { - let mut q = queue.borrow_mut(); - if let Some(Action::Delay(r)) = q.back_mut() { - *r += secs; - } else { - q.push_back(Action::Delay(secs)); - } -} - /// Appends `action` to the output queue of the object identified by `source`. -fn emit(queues: &QueueMap, source: ObjectId, action: Action) { - if let Some(queue) = queues.borrow().get(&source) { - queue.borrow_mut().push_back(action); +fn emit(board: &BoardRef, source: ObjectId, action: Action) { + if let Some(def) = board.borrow_mut().objects.get_mut(&source) { + def.queue.act(action); } } diff --git a/kiln-core/src/scripts/gem.rhai b/kiln-core/src/scripts/gem.rhai index f10dc1a..0d78b1c 100644 --- a/kiln-core/src/scripts/gem.rhai +++ b/kiln-core/src/scripts/gem.rhai @@ -3,7 +3,7 @@ // A gem is a grabbable collectible: walking onto it (or pushing it into the // player) fires this `grab()` hook instead of blocking. We bump the player's gem // count and remove ourselves from the board. -fn grab() { +fn grab(me, state) { add_gems(1); die(); } diff --git a/kiln-core/src/scripts/heart.rhai b/kiln-core/src/scripts/heart.rhai index 0c8fa17..bb1a666 100644 --- a/kiln-core/src/scripts/heart.rhai +++ b/kiln-core/src/scripts/heart.rhai @@ -2,7 +2,7 @@ // // A heart is a grabbable collectible: walking onto it fires `grab()` instead // of blocking. It restores 1 health and removes itself from the board. -fn grab() { +fn grab(me, state) { alter_health(1); die(); } diff --git a/kiln-core/src/scripts/key.rhai b/kiln-core/src/scripts/key.rhai index d6fba41..99f2d9b 100644 --- a/kiln-core/src/scripts/key.rhai +++ b/kiln-core/src/scripts/key.rhai @@ -3,7 +3,7 @@ // A gem is a grabbable collectible: walking onto it (or pushing it into the // player) fires this `grab()` hook instead of blocking. We bump the player's gem // count and remove ourselves from the board. -fn grab() { +fn grab(me, state) { let colors = [ "red", "orange", @@ -16,7 +16,7 @@ fn grab() { ]; for c in colors { - if Me.has_tag(`BUILTIN_key_${c}`) { + if me.has_tag(`BUILTIN_key_${c}`) { set_key(c, true); } } diff --git a/kiln-core/src/scripts/pusher.rhai b/kiln-core/src/scripts/pusher.rhai index 9824db5..a0aff19 100644 --- a/kiln-core/src/scripts/pusher.rhai +++ b/kiln-core/src/scripts/pusher.rhai @@ -6,17 +6,17 @@ // pusher shares one compiled copy and learns its direction from the // `BUILTIN_pusher_` tag the map loader attached to it. -fn tick(dt) { +fn tick(me, state, dt) { // Only queue a step when idle. `move` shoves the chain ahead via step_object // and is a no-op when blocked; the delay pads each step out to ~0.5s (move // itself costs 0.25s), matching the old global pusher heartbeat. // // The direction is read here, at the top level of the hook, because `Me` is a // per-object scope constant and is not visible inside other script functions. - if Queue.length() == 0 { - let dir = if Me.has_tag("BUILTIN_pusher_north") { North } - else if Me.has_tag("BUILTIN_pusher_south") { South } - else if Me.has_tag("BUILTIN_pusher_east") { East } + if !me.waiting { + let dir = if me.has_tag("BUILTIN_pusher_north") { North } + else if me.has_tag("BUILTIN_pusher_south") { South } + else if me.has_tag("BUILTIN_pusher_east") { East } else { West }; move(dir); delay(0.25); diff --git a/kiln-core/src/scripts/spinner.rhai b/kiln-core/src/scripts/spinner.rhai index d2ccea3..a7db58a 100644 --- a/kiln-core/src/scripts/spinner.rhai +++ b/kiln-core/src/scripts/spinner.rhai @@ -8,24 +8,24 @@ // can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous // between "j is empty" and "j is a blocked solid" — so we seed with `can_shift` // and then cascade-demote using `passable` to spot the genuine holes. -fn tick(dt) { +fn tick(me, state, dt) { // Only start a new rotation when the previous one (and its delay) has drained, // exactly like the built-in pusher's pacing. - if Queue.length() != 0 { return; } + if me.waiting { return; } // Direction from the builtin tag; clockwise unless explicitly counter-clockwise. - let cw = !Me.has_tag("BUILTIN_spinner_ccw"); + let cw = !me.has_tag("BUILTIN_spinner_ccw"); // The 8 neighbour offsets in clockwise order, starting at North. let cw_ring = [ - [Me.x, Me.y-1], // N - [Me.x+1, Me.y-1], // NE - [Me.x+1, Me.y], // E - [Me.x+1, Me.y+1], // SE - [Me.x, Me.y+1], // S - [Me.x-1, Me.y+1], // SW - [Me.x-1, Me.y], // W - [Me.x-1, Me.y-1], // NW + [me.x, me.y-1], // N + [me.x+1, me.y-1], // NE + [me.x+1, me.y], // E + [me.x+1, me.y+1], // SE + [me.x, me.y+1], // S + [me.x-1, me.y+1], // SW + [me.x-1, me.y], // W + [me.x-1, me.y-1], // NW ]; // For CCW, it's the same list in reverse @@ -45,10 +45,10 @@ fn tick(dt) { // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // the board Registry, keyed per spinner, since script scope resets each tick. let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] }; - let fkey = `spin_${Me.id}`; + let fkey = `spin_${me.id}`; let f = Registry.get_or(fkey, 0); set_tile(frames[f % 4]); Registry.set(fkey, (f + 1) % 4); - delay(0.5); + me.delay(0.5); } diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 1e5aa6d..cfb3514 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -8,7 +8,7 @@ use std::time::Duration; fn move_command_relocates_the_source_object() { let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); let mut game = - GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")])); game.run_init(); let b = game.board(); // East increments x by one; the object started at (2, 1). @@ -20,7 +20,7 @@ fn move_into_a_wall_or_edge_is_a_noop() { // Object at the west edge moving west: out of bounds, ignored. let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]); let mut game = - GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); + GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(West); }")])); game.run_init(); assert_eq!(game.board().objects[&1].x, 0); } @@ -29,7 +29,7 @@ fn move_into_a_wall_or_edge_is_a_noop() { fn set_tile_command_changes_the_source_glyph() { let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); let mut game = - GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); + GameState::with_scripts(board, scripts_from(&[("s", "fn init(m,s) { set_tile(7); }")])); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 7); } @@ -40,7 +40,7 @@ fn object_pushes_crate_on_init() { let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); crate_at(&mut board, 2, 0); let mut game = - GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")])); game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); @@ -53,7 +53,7 @@ fn object_push_into_player() { // A scripted object moving into the player pushes the player when there's room. let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let mut game = - GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")])); game.run_init(); { let b = game.board(); @@ -65,7 +65,7 @@ fn object_push_into_player() { let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]); wall_at(&mut board, 3, 0); let mut game = - GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); + GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")])); game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); @@ -79,7 +79,7 @@ fn move_cost_rate_limits_repeated_moves() { let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let mut game = GameState::with_scripts( board, - scripts_from(&[("m", "fn init() { move(East); move(East); }")]), + scripts_from(&[("m", "fn init(m,s) { move(East); move(East); }")]), ); game.run_init(); assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2) @@ -102,7 +102,7 @@ fn inline_delay_paces_subsequent_moves() { wall_at(&mut board, 2, 1); let mut game = GameState::with_scripts( board, - scripts_from(&[("m", "fn init() { move(East); move(South); }")]), + scripts_from(&[("m", "fn init(m,s) { move(East); move(South); }")]), ); game.run_init(); // First (eastward) move is blocked by the wall: object hasn't moved. @@ -136,7 +136,7 @@ fn queue_length_reports_pending_actions() { board, scripts_from(&[( "q", - "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", + "fn init(m,s) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }", )]), ); game.run_init(); @@ -166,7 +166,7 @@ fn blocked_reports_solid_and_clear() { board, scripts_from(&[( "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + "fn init(m, s) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }", )]), ); game.run_init(); @@ -178,38 +178,9 @@ fn blocked_reports_solid_and_clear() { board, scripts_from(&[( "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + "fn init(m, s) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }", )]), ); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 7); } - -#[test] -fn blocked_sees_earlier_objects_pending_move() { - // obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked() - // toward that same cell and must see the pending move. - let board = open_board( - 5, - 3, - (0, 0), - vec![ - scripted_object(1, 1, "mover"), - scripted_object(3, 1, "checker"), - ], - ); - let mut game = GameState::with_scripts( - board, - scripts_from(&[ - ("mover", "fn tick(dt) { move(East); }"), - ( - "checker", - "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }", - ), - ]), - ); - game.tick(Duration::from_millis(16)); - let b = game.board(); - assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1)); - assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move -} diff --git a/kiln-core/src/tests/collision.rs b/kiln-core/src/tests/collision.rs index d174746..3a1c24c 100644 --- a/kiln-core/src/tests/collision.rs +++ b/kiln-core/src/tests/collision.rs @@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() { scripts_from(&[ ( "e", - "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", + "fn init(m,s) { move(East); } fn bump(m,s,id) { log(`o0 by ${id}`); }", ), ( "w", - "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", + "fn init(m,s) { move(West); } fn bump(m,s,id) { log(`o1 by ${id}`); }", ), ]), ); diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 891e895..0561de0 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, HashMap}; use std::rc::Rc; /// Builds a 3×3 board with the player at `(px, py)` and the given portals. -fn make_board(px: i32, py: i32, portals: Vec) -> Board { +fn make_board(px: i64, py: i64, portals: Vec) -> Board { Board { name: "test".into(), width: 3, diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 0016972..41ee1c3 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -8,7 +8,7 @@ use std::time::Duration; fn init_runs_only_on_run_init_not_at_construction() { let (board, scripts) = board_with_object( Some("greet"), - &[("greet", r#"fn init() { log("hello"); }"#)], + &[("greet", r#"fn init(m,s) { log("hello"); }"#)], ); let mut game = GameState::with_scripts(board, scripts); // init must not fire during construction / deserialization. @@ -22,7 +22,7 @@ fn init_runs_only_on_run_init_not_at_construction() { fn tick_calls_script_tick_with_elapsed_seconds() { let (board, scripts) = board_with_object( Some("t"), - &[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], + &[("t", r#"fn tick(m,s,dt) { log(dt.to_string()); }"#)], ); let mut game = GameState::with_scripts(board, scripts); game.run_init(); @@ -69,7 +69,7 @@ fn script_reads_board_through_view() { let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]); let mut game = GameState::with_scripts( board, - scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]), + scripts_from(&[("r", "fn init(m,s) { log(s.player.x.to_string()); }")]), ); game.run_init(); assert_eq!(log_texts(&game), vec!["3"]); @@ -88,8 +88,8 @@ fn commands_are_routed_to_their_own_source_object() { let mut game = GameState::with_scripts( board, scripts_from(&[ - ("e", "fn init() { move(East); }"), - ("w", "fn init() { move(West); }"), + ("e", "fn init(m,s) { move(East); }"), + ("w", "fn init(m,s) { move(West); }"), ]), ); game.run_init(); @@ -127,7 +127,7 @@ fn set_tag_adds_and_removes_via_my_id() { // present on the object afterward. let (board, scripts) = board_with_object( Some("t"), - &[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)], + &[("t", r#"fn init(m,s) { set_tag(m.id, "active", true); }"#)], ); let mut game = GameState::with_scripts(board, scripts); game.run_init(); @@ -136,7 +136,7 @@ fn set_tag_adds_and_removes_via_my_id() { // A script removes a pre-existing tag. let (mut board2, scripts2) = board_with_object( Some("t2"), - &[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)], + &[("t2", r#"fn init(m,s) { set_tag(m.id, "active", false); }"#)], ); // Seed the tag before construction. board2 @@ -158,8 +158,8 @@ fn has_tag_reads_own_tags() { Some("t"), &[( "t", - r#"fn init() { set_tag(Me.id, "active", true); } - fn tick(dt) { log(Me.has_tag("active").to_string()); }"#, + r#"fn init(m,s) { set_tag(m.id, "active", true); } + fn tick(m,s,dt) { log(m.has_tag("active").to_string()); }"#, )], ); let mut game = GameState::with_scripts(board, scripts); @@ -182,8 +182,8 @@ fn objects_with_tag_returns_matching_ids() { scripts_from(&[ ( "q", - r#"fn init() { - let infos = Board.tagged("enemy"); + r#"fn init(m,s) { + let infos = s.board.tagged("enemy"); log(infos.len().to_string()); log(infos[0].id.to_string()); }"#, @@ -201,18 +201,18 @@ fn objects_with_tag_returns_matching_ids() { fn my_name_returns_name_or_empty_string() { // An object with a name set on its ObjectDef should see it via my_name(). let (mut board, scripts) = - board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); + board_with_object(Some("n"), &[("n", r#"fn init(m,s) { log(m.name); }"#)]); board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); let mut game = GameState::with_scripts(board, scripts); game.run_init(); assert_eq!(log_texts(&game), vec!["beacon"]); - // An unnamed object should get an empty string. + // An unnamed object should get (). let (board2, scripts2) = - board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); + board_with_object(Some("n"), &[("n", r#"fn init(m,s) { if m.name == () { log("null"); }}"#)]); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); - assert_eq!(log_texts(&game2), vec![""]); + assert_eq!(log_texts(&game2), vec!["null"]); } #[test] @@ -228,9 +228,9 @@ fn object_id_for_name_finds_by_name() { scripts_from(&[ ( "q", - r#"fn init() { - log(Board.named("target").id.to_string()); - let miss = Board.named("missing"); + r#"fn init(m,s) { + log(s.board.named("target").id.to_string()); + let miss = s.board.named("missing"); log(if miss == () { "not_found" } else { miss.id.to_string() }); }"#, ), @@ -251,7 +251,7 @@ fn player_bump_fires_with_negative_one() { let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); let mut game = GameState::with_scripts( board, - scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]), + scripts_from(&[("b", "fn bump(m,s,id) { log(`bumped by ${id}`); }")]), ); game.run_init(); game.try_move(Direction::East); @@ -271,7 +271,7 @@ fn scroll_opens_on_player_bump() { board, scripts_from(&[( "s", - r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, + r#"fn bump(m,s,id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, )]), ); game.run_init(); @@ -295,7 +295,7 @@ fn handle_scroll_without_choice_clears_it() { let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let mut game = GameState::with_scripts( board, - scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]), + scripts_from(&[("s", r#"fn bump(m,s,id) { scroll(["Hello"]); }"#)]), ); game.run_init(); game.try_move(Direction::East); @@ -317,7 +317,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { scripts_from(&[( "s", r#" - fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } + fn bump(m,s,id) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn eat() { log("eaten"); } "#, )]), diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index a287ed7..40642ea 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -1,8 +1,12 @@ +use std::cell::RefCell; +use std::fmt::Display; +use std::rc::Rc; use crate::archetype::Archetype; use crate::glyph::Glyph; use color::Rgba8; use rhai::Dynamic; use crate::Board; +use crate::log::LogLine; /// Which directions a solid may be pushed in. /// @@ -215,8 +219,8 @@ impl Solid { pub fn place(self, board: &mut Board, x: usize, y: usize) { match self.kind { SolidKind::Player => { - board.player.x = x as i32; - board.player.y = y as i32; + board.player.x = x as i64; + board.player.y = y as i64; } SolidKind::Object(id) => { if let Some(obj) = board.objects.get_mut(&id) { @@ -293,9 +297,9 @@ impl PortalDef { #[derive(Copy, Clone)] pub struct PlayerPos { /// Column position (0-indexed, increasing rightward). - pub x: i32, + pub x: i64, /// Row position (0-indexed, increasing downward). - pub y: i32, + pub y: i64, } #[cfg(test)] @@ -325,14 +329,6 @@ mod tests { } } -/// An optional argument passed to a script function via [`crate::action::Action::Send`]. -pub(crate) enum ScriptArg { - /// A string argument. - Str(String), - /// A numeric argument (stored as `f64` to cover both integer and float callers). - Num(f64), -} - /// A cardinal movement direction. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Direction { @@ -342,6 +338,37 @@ pub enum Direction { West, } +impl Direction { + pub fn char(self) -> char { + match self { + Direction::North => 'n', + Direction::South => 's', + Direction::East => 'e', + Direction::West => 'w', + } + } + + pub fn all() -> [Direction; 4] { + [Direction::North, Direction::South, Direction::East, Direction::West] + } + + pub fn dx(self) -> i64 { + match self { + Direction::North | Direction::South => 0, + Direction::East => 1, + Direction::West => -1, + } + } + + pub fn dy(self) -> i64 { + match self { + Direction::East | Direction::West => 0, + Direction::South => 1, + Direction::North => -1, + } + } +} + /// A value that can be stored in a board's script registry across board transitions. /// /// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic` @@ -389,7 +416,7 @@ impl Into for RegistryValue { } } -impl From for (i32, i32) { +impl From for (i64, i64) { /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). fn from(d: Direction) -> Self { match d { @@ -400,3 +427,43 @@ impl From for (i32, i32) { } } } + +#[derive(Copy,Clone,Debug, PartialEq)] +pub enum Hook { + Init, Tick, Bump, Grab +} + +impl Hook { + pub fn to_str(&self) -> &'static str { + match self { + Hook::Init => "init", + Hook::Bump => "bump", + Hook::Grab => "grab", + Hook::Tick => "tick" + } + } +} + +impl Display for Hook { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_str()) + } +} + +/// Channel for engine/compile/runtime errors. +#[derive(Clone)] +pub struct ErrorSink(Rc>>); + +impl ErrorSink { + pub fn new() -> Self { + Self(Rc::new(RefCell::new(Vec::new()))) + } + + pub fn error(&self, msg: String) { + self.0.borrow_mut().push(LogLine::error(msg)); + } + + pub fn take(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut()) + } +} \ No newline at end of file diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 9fd6ac6..e56137f 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -25,7 +25,7 @@ pub struct BoardWidget<'a> { impl<'a> BoardWidget<'a> { /// Creates a widget that renders `board`, scrolling to follow the player. pub fn new(board: &'a Board) -> Self { - let focus = (board.player.x, board.player.y); + let focus = (board.player.x as i32, board.player.y as i32); Self { board, focus } } diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index daf50ad..124d3a1 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -409,8 +409,8 @@ fn center_top(obj_sy: u16, box_h: u16, area: Rect) -> u16 { /// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge; /// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble). fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) { - let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x); - let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32); + let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32); let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32); let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32); let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16; diff --git a/maps/start.toml b/maps/start.toml index a4339db..deb88a5 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -6,66 +6,65 @@ start = "start" # Objects reference a script by name via `script_name`. [scripts] greeter = """ -fn init() { - log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`); +fn init(me, state) { + log(`hello from object — player at ${state.player.x}, ${state.player.y}`); set_tile(2); // change my glyph to ☻ — proves the write path say("Hello there,\\ntraveller!"); - log(`Player health: ${Player.health}`); + log(`Player health: ${state.player.health}`); } -fn tick(dt) { - //log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`); - if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 { +fn tick(me, state, dt) { + if state.player.x == me.x && state.player.y == me.y && !me.waiting { say("Hey! Get offa me!"); - delay(5.0); + me.delay(5.0); } } """ mover = """ -fn init() { +fn init(me, state) { if Registry.get("dancer_x") != () { let new_x = Registry.get("dancer_x"); let new_y = Registry.get("dancer_y"); - if Me.x != new_x || Me.y != new_y { + if me.x != new_x || me.y != new_y { teleport(new_x, new_y); } } else { - Registry.set("dancer_x", Me.x); - Registry.set("dancer_y", Me.y); - log(`Set reg to ${Me.x} ${Me.y}`); + Registry.set("dancer_x", me.x); + Registry.set("dancer_y", me.y); + log(`Set reg to ${me.x} ${me.y}`); } } // move() costs 250 ms, so the object steps ~4 cells/sec no matter how often // tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking // into a wall (or another object's already-queued move this tick). -fn tick(dt) { - if Queue.length() == 0 { dance(); } +fn tick(me, state, dt) { + if !me.waiting { dance(me); } } -fn dance() { +fn dance(me) { move(East); move(South); move(North); move(East); say("Hey!", 0.5); - delay(0.5); + delay(me, 0.5); move(West); move(South); move(North); move(West); say("Ho!", 0.5); - delay(0.5); + me.delay(0.5); } // Fires when something steps into this object; id is the bumper's object id, // or -1 for the player. -fn bump(id) { +fn bump(me, state, id) { log(`mover bumped by ${id}`); say("Ow!"); } """ muffin = """ -fn bump(id) { +fn bump(me, state, _id) { scroll([ "You find a small muffin on the ground, your favorite kind.", "It smells of cinnamon and warm mornings.", @@ -73,7 +72,7 @@ fn bump(id) { ["ignore", "This is obviously a trap — ignore it."] ]); } -fn eat() { +fn eat(me, state) { say("Yeah it was poisoned."); alter_health(-2); } @@ -84,7 +83,7 @@ fn ignore() { """ noticeboard = """ -fn bump(id) { +fn bump(me, state, id) { scroll([ " TOWN NOTICE BOARD", " ", @@ -111,13 +110,13 @@ fn bump(id) { "dusk. All residents are warmly encouraged to attend.", " ", " Posted by order of the Town Council.", - " Unauthorised notices will be removed.", + " Unauthorized notices will be removed.", ]); } """ bookshelf = """ -fn bump(id) { +fn bump(me, state, id) { scroll([ " THE BOOKSHELF", " ", @@ -136,13 +135,13 @@ fn bump(id) { """ fireplace = """ -fn bump(id) { +fn bump(me, state, id) { say("The fire crackles warmly.\\nYou feel at ease."); } """ chest = """ -fn bump(id) { +fn bump(me, state, id) { scroll([ " THE CHEST", " ", @@ -160,8 +159,8 @@ fn bump(id) { """ yammerer = """ -fn tick(dt) { - if Queue.length() == 0 { +fn tick(me, state, dt) { + if !me.waiting { say("blahblahblah"); delay(1); } @@ -169,11 +168,11 @@ fn tick(dt) { """ shifter = """ -fn tick(dt) { - let x = Me.x; - let y = Me.y; +fn tick(me, state, dt) { + let x = me.x; + let y = me.y; - if Queue.length() == 0 { + if !me.waiting { shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]); delay(0.25); } diff --git a/maps/tiny.toml b/maps/tiny.toml new file mode 100644 index 0000000..2aecb5d --- /dev/null +++ b/maps/tiny.toml @@ -0,0 +1,44 @@ +[world] +name = "Tiny" +start = "start" + +# Named Rhai scripts shared across all boards in this world. +# Objects reference a script by name via `script_name`. +[scripts] +greeter = """ +fn init(me, state) { + log(`hello from object — player at ${state.player.x}, ${state.player.y}`); + set_tile(2); // change my glyph to ☻ — proves the write path + //say("Hello there,\\ntraveller!"); + log(`Player health: ${state.player.health}`); +} + +fn tick(me, state, dt) { + if state.player.x == me.x && state.player.y == me.y && !me.waiting { + say("Hey! Get offa me!"); + me.queue.delay(5.0); + } +} +""" + +[boards.start.map] +name = "Starting Room" +width = 21 +height = 6 + +# Layer 1: terrain, the player, objects and the portal. A space is always a +# transparent empty cell, so the floor below shows through. +[[boards.start.layers]] +content = """ +##################### +# # +# G @ # +# oo # +# # +##################### +""" +[boards.start.layers.palette] +"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" } +"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } +"@" = { kind = "player" } +"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" }