portals 1

This commit is contained in:
2026-06-13 17:58:04 -05:00
parent 49d32eedf5
commit 6818f0545a
9 changed files with 151 additions and 7 deletions
+9
View File
@@ -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<ScrollLine>),
/// 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
}
+9 -3
View File
@@ -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<LogLine>,
/// 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<RefCell<Board>>` in `World::boards` and are
/// never evicted. Not saved to disk in v1.
pub registry: HashMap<String, RegistryValue>,
}
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(),
}
}
+20
View File
@@ -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;
}
}
}
}
}
}
+1
View File
@@ -712,6 +712,7 @@ impl TryFrom<MapFile> for Board {
portals,
board_script_name: mf.map.board_script_name,
load_errors,
registry: std::collections::HashMap::new(),
})
}
}
+59 -2
View File
@@ -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<RefCell<Board>>`, 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<ObjectInfo> {
@@ -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");
// 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
}
+1
View File
@@ -22,6 +22,7 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
portals,
board_script_name: None,
load_errors: Vec::new(),
registry: HashMap::new(),
}
}
+1
View File
@@ -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))
}
+40
View File
@@ -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<Dynamic> for RegistryValue {
type Error = ();
/// Returns `Err(())` for `()` (unit) and all unsupported types.
fn try_from(value: Dynamic) -> Result<Self, ()> {
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<RegistryValue> for Dynamic` would violate orphan rules (Dynamic is foreign),
// so we implement Into directly — the documented exception.
#[allow(clippy::from_over_into)]
impl Into<Dynamic> 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<Direction> for (i32, i32) {
/// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down).
fn from(d: Direction) -> Self {
+11 -2
View File
@@ -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);