diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 850d473..e90778e 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -52,6 +52,10 @@ pub(crate) enum Action { /// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// selection dispatches [`Send`](Action::Send) to the source object. Scroll(Vec), + /// 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 }, } // ── Direction helper ────────────────────────────────────────────────────────── @@ -125,6 +129,11 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { Action::Scroll(_) => { m.insert("type".into(), ds("Scroll")); } + Action::Teleport { x, y } => { + m.insert("type".into(), ds("Teleport")); + m.insert("x".into(), Dynamic::from(*x as i64)); + m.insert("y".into(), Dynamic::from(*y as i64)); + } } m } diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 8acdb55..2425114 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,11 +1,11 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use crate::archetype::Archetype; use crate::archetype::Archetype::Empty; use crate::glyph::Glyph; use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::Direction; -use crate::utils::{ObjectId, Player, PortalDef, Solid}; +use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid}; /// The complete state of one game board (a single room or screen). /// @@ -67,6 +67,11 @@ pub struct Board { /// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of /// the map file (purely a load diagnostic). pub(crate) load_errors: Vec, + /// Per-board key→value store written and read by Rhai scripts via the + /// `Registry` scope constant. Persists automatically across board transitions + /// because all boards live as `Rc>` in `World::boards` and are + /// never evicted. Not saved to disk in v1. + pub registry: HashMap, } impl Board { @@ -308,7 +313,7 @@ impl Board { #[cfg(test)] pub(crate) mod tests { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, HashMap}; use color::Rgba8; use crate::archetype::Archetype; use crate::glyph::Glyph; @@ -345,6 +350,7 @@ pub(crate) mod tests { portals: Vec::new(), board_script_name: None, load_errors: Vec::new(), + registry: HashMap::new(), } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index bf6efb0..e7556a3 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -223,6 +223,26 @@ impl GameState { Action::Scroll(lines) => { new_scroll = Some(Scroll { source: ba.source, lines }); } + Action::Teleport { x, y } => { + if !board.in_bounds((x, y)) { + logs.push(LogLine::error(format!( + "teleport({x},{y}): out of bounds" + ))); + } else { + let (ux, uy) = (x as usize, y as usize); + let source_solid = board.objects.get(&ba.source) + .map(|o| o.solid) + .unwrap_or(false); + if source_solid && board.solid_at(ux, uy).is_some() { + logs.push(LogLine::error(format!( + "teleport({x},{y}): destination is solid" + ))); + } else if let Some(obj) = board.objects.get_mut(&ba.source) { + obj.x = ux; + obj.y = uy; + } + } + } } } } diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 3377f2d..07cbb73 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -712,6 +712,7 @@ impl TryFrom for Board { portals, board_script_name: mf.map.board_script_name, load_errors, + registry: std::collections::HashMap::new(), }) } } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 92e66dd..301c47d 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -19,6 +19,7 @@ //! | `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 //! are visible to all functions at any call depth, including Rhai-to-Rhai calls: @@ -46,7 +47,7 @@ //! //! `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])` +//! `send(target_id, fn_name [, arg])`, `teleport(x, y)` //! //! ## Queue API //! @@ -57,7 +58,7 @@ use crate::game::SAY_DURATION; use crate::board::Board; use crate::log::LogLine; use crate::map_file::{color_to_hex, parse_color}; -use crate::utils::{Direction, ObjectId, ScriptArg}; +use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg}; use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, NativeCallContext, Scope, AST}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; @@ -133,6 +134,15 @@ struct Me { 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 +/// `Rc>`, so the struct itself can be shared as a scope constant. +#[derive(Clone)] +struct Registry { + board: BoardRef, +} + // ── ObjectInfo helper ───────────────────────────────────────────────────────── fn object_info_from(id: ObjectId, board: &Board) -> Option { @@ -187,6 +197,7 @@ impl ScriptHost { register_me_type(&mut engine); register_object_info_type(&mut engine); register_glyph_type(&mut engine); + register_registry_type(&mut engine); register_global_constants(&mut engine); let board = board_cell.borrow(); @@ -449,6 +460,43 @@ fn register_me_type(engine: &mut Engine) { }); } +// ── Register Registry type ──────────────────────────────────────────────────── + +fn register_registry_type(engine: &mut Engine) { + engine.register_type_with_name::("Registry"); + + // Registry.get(key) -> Dynamic — returns () if the key is absent. + engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic { + r.board.borrow() + .registry + .get(key.as_str()) + .map(|v| v.clone().into()) + .unwrap_or(Dynamic::UNIT) + }); + + // Registry.set(key, value) — stores a primitive value; () removes the key; + // unsupported types (closures, custom objects) are silently ignored. + engine.register_fn("set", |r: &mut Registry, key: ImmutableString, value: Dynamic| { + match RegistryValue::try_from(value.clone()) { + Ok(rv) => { r.board.borrow_mut().registry.insert(key.to_string(), rv); } + Err(()) if value.is_unit() => { r.board.borrow_mut().registry.remove(key.as_str()); } + Err(()) => {} // unsupported type — silently ignore + } + }); + + // Registry.get_or(key, default) — returns the stored value when present and the + // same Rhai type as `default`; falls back to `default` otherwise. + engine.register_fn("get_or", |r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic { + if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() { + let candidate: Dynamic = stored.into(); + // Only return the stored value if it matches the type of the default. + if candidate.type_id() == default.type_id() { candidate } else { default } + } else { + default + } + }); +} + // ── Register ObjectInfo type ────────────────────────────────────────────────── fn register_object_info_type(engine: &mut Engine) { @@ -725,6 +773,14 @@ 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(); + 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 }); + }); } // ── Queue API ───────────────────────────────────────────────────────────────── @@ -790,6 +846,7 @@ fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> 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 } diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 5a76ff5..9113e1a 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -22,6 +22,7 @@ fn make_board(px: i32, py: i32, portals: Vec) -> Board { portals, board_script_name: None, load_errors: Vec::new(), + registry: HashMap::new(), } } diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 59b575c..34b49d6 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -33,6 +33,7 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> ( portals: Vec::new(), board_script_name: None, load_errors: Vec::new(), + registry: HashMap::new(), }; (board, scripts_from(scripts)) } diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 3a5a9be..67bcaa5 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -1,6 +1,7 @@ use color::Rgba8; use crate::archetype::Archetype; use crate::glyph::Glyph; +use rhai::Dynamic; /// Which directions a solid may be pushed in. /// @@ -169,6 +170,45 @@ pub enum Direction { West, } +/// 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` +/// and are ready for TOML serialization if disk persistence is added later. +/// Arbitrary `Dynamic` values (closures, custom types) are rejected at the boundary. +#[derive(Debug, Clone)] +pub enum RegistryValue { + Bool(bool), + Int(i64), + Float(f64), + Str(String), +} + +impl TryFrom for RegistryValue { + type Error = (); + /// Returns `Err(())` for `()` (unit) and all unsupported types. + fn try_from(value: Dynamic) -> Result { + if value.is_bool() { return Ok(RegistryValue::Bool(value.as_bool().unwrap())); } + if value.is_int() { return Ok(RegistryValue::Int(value.as_int().unwrap())); } + if value.is_float() { return Ok(RegistryValue::Float(value.as_float().unwrap())); } + if value.is_string() { return Ok(RegistryValue::Str(value.into_string().unwrap())); } + Err(()) + } +} + +// `From for Dynamic` would violate orphan rules (Dynamic is foreign), +// so we implement Into directly — the documented exception. +#[allow(clippy::from_over_into)] +impl Into for RegistryValue { + fn into(self) -> Dynamic { + match self { + RegistryValue::Bool(b) => Dynamic::from(b), + RegistryValue::Int(n) => Dynamic::from(n), + RegistryValue::Float(f) => Dynamic::from(f), + RegistryValue::Str(s) => Dynamic::from(s), + } + } +} + impl From for (i32, i32) { /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). fn from(d: Direction) -> Self { diff --git a/maps/start.toml b/maps/start.toml index 49d5ead..fd60060 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -22,13 +22,22 @@ fn tick(dt) { """ mover = """ +fn init() { + if Registry.get("dancer_x") != () { + teleport(Registry.get("dancer_x"), Registry.get("dancer_y")); + } else { + 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 { init(); } + if Queue.length() == 0 { dance(); } } -fn init() { +fn dance() { move(East); move(South); move(North);