better scripting api
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
# Kiln Script API Reference
|
||||
|
||||
Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map
|
||||
file. Each scripted object references a script by name via `script_name`. A script may define up to
|
||||
three lifecycle hooks; any or all may be omitted.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
### `fn init()`
|
||||
|
||||
Called once for every scripted object after the entire map is loaded, before the first frame.
|
||||
Use it to set up initial tile appearance, log a startup message, etc.
|
||||
|
||||
```rhai
|
||||
fn init() {
|
||||
set_tile(42);
|
||||
log("ready");
|
||||
}
|
||||
```
|
||||
|
||||
### `fn tick(dt)`
|
||||
|
||||
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement
|
||||
and other timed actions pace themselves through the queue; you do not need to track time manually
|
||||
for simple walking patterns.
|
||||
|
||||
```rhai
|
||||
fn tick(dt) {
|
||||
if Queue.length() == 0 && !blocked(North) {
|
||||
move(North);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `fn bump(id)`
|
||||
|
||||
Called when another entity walks into this object's cell.
|
||||
|
||||
- `id` is the bumper's `ObjectId` (an `i64`).
|
||||
- `id == -1` means the player bumped this object.
|
||||
|
||||
```rhai
|
||||
fn bump(id) {
|
||||
if id == -1 {
|
||||
say("Hey! Watch it.");
|
||||
} else {
|
||||
log(`object ${id} bumped me`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants in scope
|
||||
|
||||
These are pre-set in every object's scope and cannot be reassigned.
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `Board` | `Board` | Read-only handle to the game world. |
|
||||
| `Queue` | `Queue` | Handle to this object's own pending-action queue. |
|
||||
| `North` | `Direction` | Cardinal direction: up (y − 1). |
|
||||
| `South` | `Direction` | Cardinal direction: down (y + 1). |
|
||||
| `East` | `Direction` | Cardinal direction: right (x + 1). |
|
||||
| `West` | `Direction` | Cardinal direction: left (x − 1). |
|
||||
| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. |
|
||||
|
||||
---
|
||||
|
||||
## Read functions
|
||||
|
||||
### `Board` getters
|
||||
|
||||
Access via `Board.<property>`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Board.player_x` | `i64` | Player's current column (0-indexed). |
|
||||
| `Board.player_y` | `i64` | Player's current row (0-indexed). |
|
||||
| `Board.width` | `i64` | Board width in cells. |
|
||||
| `Board.height` | `i64` | Board height in cells. |
|
||||
|
||||
```rhai
|
||||
let dist_x = (Board.player_x - 30).abs();
|
||||
```
|
||||
|
||||
### `blocked(dir) -> bool`
|
||||
|
||||
Returns `true` if moving in `dir` from this object's current cell would be impossible — because
|
||||
the target cell is out of bounds, holds a solid, or another object has already queued a move there.
|
||||
|
||||
Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so
|
||||
an object cannot accidentally block itself.
|
||||
|
||||
```rhai
|
||||
fn tick(dt) {
|
||||
if !blocked(East) { move(East); }
|
||||
}
|
||||
```
|
||||
|
||||
### `has_tag(tag) -> bool`
|
||||
|
||||
Returns `true` if this object has the named tag.
|
||||
|
||||
```rhai
|
||||
if has_tag("enemy") { say("I'm an enemy!"); }
|
||||
```
|
||||
|
||||
### `get_tags() -> Array`
|
||||
|
||||
Returns an array of strings listing every tag on this object.
|
||||
|
||||
```rhai
|
||||
for t in get_tags() { log(t); }
|
||||
```
|
||||
|
||||
### `objects_with_tag(tag) -> Array`
|
||||
|
||||
Returns an array of `i64` object IDs for every object on the board that currently carries `tag`.
|
||||
|
||||
```rhai
|
||||
let enemies = objects_with_tag("enemy");
|
||||
log(`there are ${enemies.len()} enemies`);
|
||||
```
|
||||
|
||||
### `my_name() -> String`
|
||||
|
||||
Returns this object's name string, or `""` if the object has no name.
|
||||
|
||||
```rhai
|
||||
log(`I am ${my_name()}`);
|
||||
```
|
||||
|
||||
### `object_id_for_name(name) -> i64`
|
||||
|
||||
Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name.
|
||||
`0` is never a valid id, so it can be used as a null check.
|
||||
|
||||
```rhai
|
||||
let door_id = object_id_for_name("door");
|
||||
if door_id != 0 {
|
||||
set_tag(door_id, "open", true);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Write functions
|
||||
|
||||
Scripts do not mutate the world directly. Each write function appends an action to this object's
|
||||
**output queue**. Actions are drained to the shared board queue between script calls and resolved
|
||||
by the engine after the batch.
|
||||
|
||||
### `move(dir)`
|
||||
|
||||
Enqueues a move one cell in `dir`. Also inserts a `Delay` of 0.25 s after the move, limiting this
|
||||
object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown.
|
||||
|
||||
```rhai
|
||||
fn tick(dt) {
|
||||
if !blocked(South) { move(South); }
|
||||
}
|
||||
```
|
||||
|
||||
### `delay(secs)`
|
||||
|
||||
Inserts an explicit pause of `secs` seconds into this object's queue. Adjacent delays are merged
|
||||
(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause
|
||||
between speech bubbles.
|
||||
|
||||
```rhai
|
||||
fn init() {
|
||||
say("One…");
|
||||
delay(2.0);
|
||||
say("Two!");
|
||||
now(); // promote the last say() past the delay
|
||||
}
|
||||
```
|
||||
|
||||
### `now()`
|
||||
|
||||
Moves the most recently enqueued action to the **front** of the queue, bypassing any pending
|
||||
delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even
|
||||
if a move delay is in progress.
|
||||
|
||||
```rhai
|
||||
fn bump(id) {
|
||||
say("Ouch!");
|
||||
now(); // show the bubble right away
|
||||
}
|
||||
```
|
||||
|
||||
### `set_tile(n)`
|
||||
|
||||
Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437
|
||||
code point when using the default kiln font.
|
||||
|
||||
```rhai
|
||||
fn init() {
|
||||
set_tile(1); // ☺
|
||||
}
|
||||
```
|
||||
|
||||
### `log(msg)`
|
||||
|
||||
Appends a plain-text line to the game log. The message is a string.
|
||||
|
||||
```rhai
|
||||
log(`player is at ${Board.player_x}, ${Board.player_y}`);
|
||||
```
|
||||
|
||||
### `say(msg)`
|
||||
|
||||
Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the
|
||||
object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with
|
||||
`now()` to surface it immediately from a `bump` or `init` hook.
|
||||
|
||||
```rhai
|
||||
fn bump(id) {
|
||||
say("Hey!");
|
||||
now();
|
||||
}
|
||||
```
|
||||
|
||||
### `set_tag(target_id, tag, present)`
|
||||
|
||||
Adds (`present = true`) or removes (`present = false`) a tag on any object identified by
|
||||
`target_id`. Use `MY_ID` to mutate the calling object's own tags.
|
||||
|
||||
```rhai
|
||||
// Add a tag to self
|
||||
set_tag(MY_ID, "activated", true);
|
||||
|
||||
// Remove a tag from another object by name
|
||||
let lever_id = object_id_for_name("lever");
|
||||
set_tag(lever_id, "pulled", true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `Queue` object
|
||||
|
||||
Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own
|
||||
pending-action queue.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `Queue.length()` | `i64` | Number of actions currently in the queue. |
|
||||
| `Queue.clear()` | — | Discard all pending actions. |
|
||||
|
||||
Use `Queue.length() == 0` in `tick` to avoid queuing new moves while a previous one is in flight:
|
||||
|
||||
```rhai
|
||||
fn tick(dt) {
|
||||
if Queue.length() == 0 {
|
||||
if !blocked(East) { move(East); }
|
||||
else if !blocked(West) { move(West); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate limiting and timing
|
||||
|
||||
- `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max).
|
||||
- `delay(secs)` adds an arbitrary pause.
|
||||
- `now()` can bypass a delay for zero-cost actions already in the queue.
|
||||
- `Queue.clear()` cancels everything, including pending delays.
|
||||
- Adjacent delays are always merged: two `delay(0.5)` calls become one `Delay(1.0)` entry.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
Script errors (compile-time or runtime) are logged to the game log as plain-text lines and do not
|
||||
crash the game. A script that throws an exception on `tick` will log the error and resume normally
|
||||
on the next frame.
|
||||
|
||||
---
|
||||
|
||||
## Full example
|
||||
|
||||
```rhai
|
||||
// A guard that patrols east/west and greets the player when bumped.
|
||||
|
||||
fn init() {
|
||||
set_tile(2);
|
||||
log(`${my_name()} standing watch`);
|
||||
}
|
||||
|
||||
fn tick(dt) {
|
||||
if Queue.length() > 0 { return; } // still moving
|
||||
|
||||
if !blocked(East) {
|
||||
move(East);
|
||||
} else if !blocked(West) {
|
||||
move(West);
|
||||
}
|
||||
// Stuck in a corner — do nothing this tick.
|
||||
}
|
||||
|
||||
fn bump(id) {
|
||||
if id == -1 {
|
||||
say("Halt! Who goes there?");
|
||||
now();
|
||||
} else {
|
||||
say("Watch where you're going!");
|
||||
now();
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,364 @@
|
||||
//! The [`Action`] enum and its Rhai-facing conversions.
|
||||
//!
|
||||
//! This module owns the `Action` type (which scripts enqueue via write host
|
||||
//! functions), the rate-limiting delay constant, and the Rhai bridge that
|
||||
//! lets scripts introspect and compose action queues via
|
||||
//! `Queue.peek()`/`Queue.pop()`/`Queue.push()`.
|
||||
//!
|
||||
//! ## Rhai `"Action"` module
|
||||
//!
|
||||
//! [`rhai_action_module`] builds a static Rhai module registered as `"Action"`
|
||||
//! on the engine. Scripts can import it and call constructor functions:
|
||||
//!
|
||||
//! ```rhai
|
||||
//! import "Action" as A;
|
||||
//! Queue.push(A::Move(North));
|
||||
//! Queue.push(A::Say("Hello!"));
|
||||
//! ```
|
||||
//!
|
||||
//! Constructors return Rhai maps (`#{ type: "...", ... }`). [`action_to_map`]
|
||||
//! converts in the same direction for `Queue.peek()` / `Queue.pop()`.
|
||||
//! [`TryFrom<rhai::Map>`] converts back, used by `Queue.push()`.
|
||||
|
||||
use crate::log::LogLine;
|
||||
use crate::map_file::{color_to_hex, parse_color};
|
||||
use crate::utils::{Direction, ObjectId, ScriptArg};
|
||||
use color::Rgba8;
|
||||
use rhai::{Dynamic, ImmutableString, Module};
|
||||
|
||||
/// How long a move occupies an object before it can act again, in seconds.
|
||||
pub(crate) const MOVE_COST: f64 = 0.25;
|
||||
|
||||
/// 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 {
|
||||
/// Move the source object one cell in a direction (subject to passability).
|
||||
Move(Direction),
|
||||
/// Set the source object's glyph tile index.
|
||||
SetTile(u32),
|
||||
/// Append a styled line to the game log.
|
||||
Log(LogLine),
|
||||
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
|
||||
SetTag { target: ObjectId, tag: String, present: bool },
|
||||
/// Display a speech bubble above the source object for [`crate::game::SAY_DURATION`] seconds.
|
||||
Say(String),
|
||||
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
|
||||
Delay(f64),
|
||||
/// Set the source object's foreground and/or background color.
|
||||
SetColor { fg: Option<Rgba8>, bg: Option<Rgba8> },
|
||||
/// Call `fn_name` on `target` object, optionally passing `arg`.
|
||||
Send { target: ObjectId, fn_name: String, arg: Option<ScriptArg> },
|
||||
}
|
||||
|
||||
// ── Direction helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
fn dir_to_str(d: Direction) -> &'static str {
|
||||
match d {
|
||||
Direction::North => "North",
|
||||
Direction::South => "South",
|
||||
Direction::East => "East",
|
||||
Direction::West => "West",
|
||||
}
|
||||
}
|
||||
|
||||
fn str_to_dir(s: &str) -> Option<Direction> {
|
||||
match s {
|
||||
"North" => Some(Direction::North),
|
||||
"South" => Some(Direction::South),
|
||||
"East" => Some(Direction::East),
|
||||
"West" => Some(Direction::West),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── action_to_map ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Converts an [`Action`] to the Rhai map form used by `Queue.peek()` / `Queue.pop()`.
|
||||
///
|
||||
/// The map always has a `"type"` key naming the variant; other keys carry the
|
||||
/// payload. [`Action::Log`] is flattened to its first span's text.
|
||||
pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
|
||||
let mut m = rhai::Map::new();
|
||||
match action {
|
||||
Action::Move(dir) => {
|
||||
ins_str(&mut m, "type", "Move");
|
||||
ins_str(&mut m, "dir", dir_to_str(*dir));
|
||||
}
|
||||
Action::SetTile(n) => {
|
||||
ins_str(&mut m, "type", "SetTile");
|
||||
m.insert("tile".into(), Dynamic::from(*n as i64));
|
||||
}
|
||||
Action::Log(line) => {
|
||||
let text = line.spans.first().map(|s| s.text.as_str()).unwrap_or("").to_string();
|
||||
ins_str(&mut m, "type", "Log");
|
||||
m.insert("msg".into(), Dynamic::from(text));
|
||||
}
|
||||
Action::SetTag { target, tag, present } => {
|
||||
ins_str(&mut m, "type", "SetTag");
|
||||
m.insert("target".into(), Dynamic::from(*target as i64));
|
||||
m.insert("tag".into(), Dynamic::from(tag.clone()));
|
||||
m.insert("present".into(), Dynamic::from(*present));
|
||||
}
|
||||
Action::Say(text) => {
|
||||
ins_str(&mut m, "type", "Say");
|
||||
m.insert("msg".into(), Dynamic::from(text.clone()));
|
||||
}
|
||||
Action::Delay(secs) => {
|
||||
ins_str(&mut m, "type", "Delay");
|
||||
m.insert("secs".into(), Dynamic::from(*secs));
|
||||
}
|
||||
Action::SetColor { fg, bg } => {
|
||||
ins_str(&mut m, "type", "SetColor");
|
||||
if let Some(c) = fg { m.insert("fg".into(), Dynamic::from(color_to_hex(*c))); }
|
||||
if let Some(c) = bg { m.insert("bg".into(), Dynamic::from(color_to_hex(*c))); }
|
||||
}
|
||||
Action::Send { target, fn_name, arg } => {
|
||||
ins_str(&mut m, "type", "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
// ── TryFrom<rhai::Map> ────────────────────────────────────────────────────────
|
||||
|
||||
impl TryFrom<rhai::Map> for Action {
|
||||
type Error = String;
|
||||
|
||||
/// Converts a Rhai map (as produced by the `"Action"` module constructors or
|
||||
/// [`action_to_map`]) back into a Rust [`Action`]. Returns `Err(msg)` when
|
||||
/// the map is structurally invalid or names an unknown variant.
|
||||
fn try_from(map: rhai::Map) -> Result<Self, Self::Error> {
|
||||
let type_str = map
|
||||
.get("type")
|
||||
.ok_or_else(|| "missing 'type' key".to_string())?
|
||||
.clone()
|
||||
.into_string()
|
||||
.map_err(|_| "'type' must be a string".to_string())?;
|
||||
|
||||
match type_str.as_str() {
|
||||
"Move" => {
|
||||
let dir_str = get_str(&map, "dir", "Move")?;
|
||||
let dir = str_to_dir(&dir_str)
|
||||
.ok_or_else(|| format!("Move: unknown direction '{dir_str}'"))?;
|
||||
Ok(Action::Move(dir))
|
||||
}
|
||||
"SetTile" => {
|
||||
let n = get_int(&map, "tile", "SetTile")?;
|
||||
Ok(Action::SetTile(n as u32))
|
||||
}
|
||||
"Log" => {
|
||||
let msg = get_str(&map, "msg", "Log")?;
|
||||
Ok(Action::Log(LogLine::raw(msg)))
|
||||
}
|
||||
"Say" => {
|
||||
let msg = get_str(&map, "msg", "Say")?;
|
||||
Ok(Action::Say(msg))
|
||||
}
|
||||
"Delay" => {
|
||||
let secs = get_float(&map, "secs", "Delay")?;
|
||||
Ok(Action::Delay(secs))
|
||||
}
|
||||
"SetTag" => {
|
||||
let target = get_int(&map, "target", "SetTag")?;
|
||||
let tag = get_str(&map, "tag", "SetTag")?;
|
||||
let present = get_bool(&map, "present", "SetTag")?;
|
||||
Ok(Action::SetTag { target: target as ObjectId, tag, present })
|
||||
}
|
||||
"SetColor" => {
|
||||
let fg = opt_color(&map, "fg", "SetColor")?;
|
||||
let bg = opt_color(&map, "bg", "SetColor")?;
|
||||
Ok(Action::SetColor { fg, bg })
|
||||
}
|
||||
"Send" => {
|
||||
let target = get_int(&map, "target", "Send")?;
|
||||
let fn_name = get_str(&map, "name", "Send")?;
|
||||
// Accept int, float, or string as the optional arg.
|
||||
let arg = if let Some(v) = map.get("arg") {
|
||||
let v = v.clone();
|
||||
if let Ok(n) = v.clone().as_int() {
|
||||
Some(ScriptArg::Num(n as f64))
|
||||
} else if let Ok(f) = v.clone().as_float() {
|
||||
Some(ScriptArg::Num(f))
|
||||
} else if let Ok(s) = v.into_string() {
|
||||
Some(ScriptArg::Str(s))
|
||||
} else {
|
||||
None // unrecognised arg type — ignored
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Action::Send { target: target as ObjectId, fn_name, arg })
|
||||
}
|
||||
other => Err(format!("unknown action type: '{other}'")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rhai "Action" module ──────────────────────────────────────────────────────
|
||||
|
||||
/// Builds the `"Action"` Rhai static module.
|
||||
///
|
||||
/// Register on the engine with
|
||||
/// `engine.register_static_module("Action", rhai_action_module().into())`.
|
||||
/// Scripts can then write `import "Action" as A; Queue.push(A::Move(North));`.
|
||||
pub(crate) fn rhai_action_module() -> Module {
|
||||
let mut m = Module::new();
|
||||
|
||||
m.set_native_fn("Move", |dir: Direction| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Move");
|
||||
ins_str(&mut map, "dir", dir_to_str(dir));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
m.set_native_fn("Delay", |secs: f64| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Delay");
|
||||
map.insert("secs".into(), Dynamic::from(secs));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
// Integer overload so A::Delay(2) works as well as A::Delay(2.0).
|
||||
m.set_native_fn("Delay", |secs: i64| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Delay");
|
||||
map.insert("secs".into(), Dynamic::from(secs as f64));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
m.set_native_fn("SetTile", |n: i64| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "SetTile");
|
||||
map.insert("tile".into(), Dynamic::from(n));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
m.set_native_fn("Log", |msg: ImmutableString| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Log");
|
||||
map.insert("msg".into(), Dynamic::from(msg));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
m.set_native_fn("Say", |msg: ImmutableString| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Say");
|
||||
map.insert("msg".into(), Dynamic::from(msg));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
// SetColor — fg only
|
||||
m.set_native_fn("SetColor", |fg: ImmutableString| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "SetColor");
|
||||
map.insert("fg".into(), Dynamic::from(fg));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
// SetColor — fg + bg
|
||||
m.set_native_fn("SetColor", |fg: ImmutableString, bg: ImmutableString| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "SetColor");
|
||||
map.insert("fg".into(), Dynamic::from(fg));
|
||||
map.insert("bg".into(), Dynamic::from(bg));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
m.set_native_fn("SetTag", |target: i64, tag: ImmutableString, present: bool| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "SetTag");
|
||||
map.insert("target".into(), Dynamic::from(target));
|
||||
map.insert("tag".into(), Dynamic::from(tag));
|
||||
map.insert("present".into(), Dynamic::from(present));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
// Send — no arg
|
||||
m.set_native_fn("Send", |target: i64, name: ImmutableString| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Send");
|
||||
map.insert("target".into(), Dynamic::from(target));
|
||||
map.insert("name".into(), Dynamic::from(name));
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
// Send — with arg
|
||||
m.set_native_fn("Send", |target: i64, name: ImmutableString, arg: Dynamic| {
|
||||
let mut map = rhai::Map::new();
|
||||
ins_str(&mut map, "type", "Send");
|
||||
map.insert("target".into(), Dynamic::from(target));
|
||||
map.insert("name".into(), Dynamic::from(name));
|
||||
map.insert("arg".into(), arg);
|
||||
Ok(Dynamic::from(map))
|
||||
});
|
||||
|
||||
m
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a `&'static str` value into a [`rhai::Map`] by converting via `String`.
|
||||
/// Using `String` avoids the `From<&str>` lifetime restriction in rhai 1.x.
|
||||
fn ins_str(m: &mut rhai::Map, key: &'static str, value: &'static str) {
|
||||
m.insert(key.into(), Dynamic::from(value.to_string()));
|
||||
}
|
||||
|
||||
fn get_str(map: &rhai::Map, key: &str, ctx: &str) -> Result<String, String> {
|
||||
map.get(key)
|
||||
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
|
||||
.clone()
|
||||
.into_string()
|
||||
.map_err(|_| format!("{ctx}: '{key}' must be a string"))
|
||||
}
|
||||
|
||||
fn get_int(map: &rhai::Map, key: &str, ctx: &str) -> Result<i64, String> {
|
||||
map.get(key)
|
||||
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
|
||||
.clone()
|
||||
.as_int()
|
||||
.map_err(|_| format!("{ctx}: '{key}' must be an integer"))
|
||||
}
|
||||
|
||||
fn get_float(map: &rhai::Map, key: &str, ctx: &str) -> Result<f64, String> {
|
||||
let v = map.get(key)
|
||||
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
|
||||
.clone();
|
||||
// Accept an integer literal passed where a float is expected.
|
||||
v.clone()
|
||||
.as_float()
|
||||
.or_else(|_| v.as_int().map(|n| n as f64))
|
||||
.map_err(|_| format!("{ctx}: '{key}' must be a number"))
|
||||
}
|
||||
|
||||
fn get_bool(map: &rhai::Map, key: &str, ctx: &str) -> Result<bool, String> {
|
||||
map.get(key)
|
||||
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
|
||||
.clone()
|
||||
.as_bool()
|
||||
.map_err(|_| format!("{ctx}: '{key}' must be a bool"))
|
||||
}
|
||||
|
||||
/// Returns `Some(Rgba8)` if `key` is present in the map, or `None` if absent.
|
||||
fn opt_color(map: &rhai::Map, key: &str, ctx: &str) -> Result<Option<Rgba8>, String> {
|
||||
match map.get(key) {
|
||||
None => Ok(None),
|
||||
Some(v) => {
|
||||
let s = v.clone()
|
||||
.into_string()
|
||||
.map_err(|_| format!("{ctx}: '{key}' must be a string"))?;
|
||||
Ok(Some(parse_color(&s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -1,10 +1,11 @@
|
||||
use crate::action::Action;
|
||||
use crate::log::LogLine;
|
||||
use crate::script::ScriptHost;
|
||||
use std::cell::{Ref, RefCell, RefMut};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use crate::board::Board;
|
||||
use crate::utils::{Action, Direction, ObjectId, Solid};
|
||||
use crate::utils::{Direction, ObjectId, ScriptArg, Solid};
|
||||
|
||||
/// How long a `say()` speech bubble stays on screen, in seconds.
|
||||
pub const SAY_DURATION: f64 = 3.0;
|
||||
@@ -105,17 +106,19 @@ impl GameState {
|
||||
}
|
||||
|
||||
/// Resolves the board queue: applies each promoted action to the board, then fires
|
||||
/// the `bump` hooks the moves triggered.
|
||||
/// the `bump` and `send` hooks the moves triggered.
|
||||
///
|
||||
/// Done in two phases so no `board_mut` borrow is held while `bump` scripts run
|
||||
/// Done in two phases so no `board_mut` borrow is held while scripts run
|
||||
/// (they read the board through its getters): phase A mutates the board and records
|
||||
/// `(bumped_object, bumper)` pairs; phase B fires the bumps after the borrow drops.
|
||||
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the
|
||||
/// hooks after the borrow drops.
|
||||
fn resolve(&mut self) {
|
||||
let actions = self.scripts.take_board_queue();
|
||||
// Logs are collected here rather than pushed inline, since the board borrow
|
||||
// below also borrows `self`.
|
||||
let mut logs: Vec<LogLine> = Vec::new();
|
||||
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
|
||||
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
|
||||
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
|
||||
{
|
||||
let mut board = self.board_mut();
|
||||
@@ -146,7 +149,16 @@ impl GameState {
|
||||
}),
|
||||
// Delays are consumed by ScriptHost::drain and never reach the board queue.
|
||||
Action::Delay(_) => {}
|
||||
|
||||
Action::SetColor { fg, bg } => {
|
||||
if let Some(obj) = board.objects.get_mut(&ba.source) {
|
||||
if let Some(c) = fg { obj.glyph.fg = c; }
|
||||
if let Some(c) = bg { obj.glyph.bg = c; }
|
||||
}
|
||||
}
|
||||
// Collected and fired after the board borrow drops, like bumps.
|
||||
Action::Send { target, fn_name, arg } => {
|
||||
sends.push((target, fn_name, arg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +171,9 @@ impl GameState {
|
||||
for (bumped, bumper) in bumps {
|
||||
self.scripts.run_bump(bumped, bumper);
|
||||
}
|
||||
for (target, fn_name, arg) in sends {
|
||||
self.scripts.run_send(target, &fn_name, arg);
|
||||
}
|
||||
self.drain_errors();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod map_file;
|
||||
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
|
||||
pub mod script;
|
||||
pub mod glyph;
|
||||
mod action;
|
||||
mod font;
|
||||
mod utils;
|
||||
mod archetype;
|
||||
|
||||
+471
-257
@@ -9,186 +9,217 @@
|
||||
//! - `bump(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`].
|
||||
//!
|
||||
//! ## Actions, queues, and rate limiting
|
||||
//! ## Script constants in scope
|
||||
//!
|
||||
//! Scripts don't mutate the world directly. Each write host function (`move`,
|
||||
//! `set_tile`, `log`, `say`) appends an [`Action`] to the issuing object's **output
|
||||
//! queue** (a per-object FIFO).
|
||||
//! Every object scope contains:
|
||||
//!
|
||||
//! Timing is encoded **inline** in the queue as [`Action::Delay`] entries rather than
|
||||
//! as an external per-object timer. `move(dir)` appends both a `Move` and a
|
||||
//! `Delay(MOVE_COST)` so the object pauses before its next action. Adjacent delays are
|
||||
//! merged at push time so the queue never contains two consecutive `Delay`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). |
|
||||
//! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. |
|
||||
//! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. |
|
||||
//!
|
||||
//! Each tick, [`ScriptHost::drain`] advances the queue for each object:
|
||||
//! it promotes non-delay actions onto the shared **board queue** until it hits a live
|
||||
//! `Delay`, which it decrements (and removes when expired). `dt` is consumed on the
|
||||
//! first `Delay` encountered so multiple delays in one tick are not double-decremented.
|
||||
//! ## Board read API (`Board.*`)
|
||||
//!
|
||||
//! The `now()` host function pops the most recently enqueued action to the front of
|
||||
//! the queue, letting scripts prioritize a zero-cost action (e.g. `say`) over a
|
||||
//! pending delay.
|
||||
//! - `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
|
||||
//!
|
||||
//! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies
|
||||
//! each action *after* the script batch, preserving the borrow discipline (scripts only
|
||||
//! ever read the board during execution). Which object issued an action is read from the
|
||||
//! per-call **tag** (set to the object index in [`ScriptHost::run`]).
|
||||
//! ## Self-reference API (`Me.*`)
|
||||
//!
|
||||
//! [`take_board_queue`]: ScriptHost::take_board_queue
|
||||
//! [`GameState`]: crate::game::GameState
|
||||
//! - `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])`
|
||||
//!
|
||||
//! ## Queue API
|
||||
//!
|
||||
//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`, `Queue.push(map)`
|
||||
|
||||
use crate::action::{action_to_map, rhai_action_module, Action, MOVE_COST};
|
||||
use crate::board::Board;
|
||||
use crate::log::LogLine;
|
||||
use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
|
||||
use crate::map_file::{color_to_hex, parse_color};
|
||||
use crate::utils::{Direction, ObjectId, ScriptArg};
|
||||
use rhai::{CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use crate::utils::{Action, Direction, ObjectId, MOVE_COST};
|
||||
|
||||
/// An action promoted from an object's output queue onto the board queue, tagged
|
||||
/// with the object that issued it.
|
||||
pub struct BoardAction {
|
||||
pub(crate) struct BoardAction {
|
||||
/// The stable [`ObjectId`] of the object that issued the action.
|
||||
pub source: ObjectId,
|
||||
pub(crate) source: ObjectId,
|
||||
/// The action to apply.
|
||||
pub action: Action,
|
||||
pub(crate) action: Action,
|
||||
}
|
||||
|
||||
/// A read-only handle to the world, exposed to scripts as `Board`. Holds a shared
|
||||
/// reference to the [`Board`]; only getters are registered, so it is
|
||||
/// read-only by construction.
|
||||
/// A read-only handle to the world, exposed to scripts as `Board`.
|
||||
type BoardRef = Rc<RefCell<Board>>;
|
||||
|
||||
/// A single object's output queue, shared between the host (which pumps it between
|
||||
/// script calls) and the script (which reads/mutates it through the `Queue` object).
|
||||
/// A single object's output queue.
|
||||
type ObjQueue = Rc<RefCell<VecDeque<Action>>>;
|
||||
|
||||
/// The board queue: actions promoted from object queues, awaiting resolution. Shared
|
||||
/// so the `blocked` host function can scan pending moves during script execution.
|
||||
/// The board queue: actions promoted from object queues, awaiting resolution.
|
||||
type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
|
||||
|
||||
/// Maps an [`ObjectId`] to its output queue, so the global write host functions can
|
||||
/// route an emitted action to the right object via the per-call tag.
|
||||
/// Maps an [`ObjectId`] to its output queue.
|
||||
type QueueMap = Rc<RefCell<HashMap<ObjectId, ObjQueue>>>;
|
||||
|
||||
/// Channel for engine/compile/runtime errors, surfaced straight to the game log
|
||||
/// (errors never go through the action queues).
|
||||
/// Channel for engine/compile/runtime errors.
|
||||
type ErrorSink = Rc<RefCell<Vec<LogLine>>>;
|
||||
|
||||
/// A compiled script plus which lifecycle hooks it defines.
|
||||
struct CompiledScript {
|
||||
/// The compiled Rhai AST (function definitions plus any top-level code).
|
||||
ast: AST,
|
||||
/// Whether the script defines `fn init()` (zero parameters).
|
||||
has_init: bool,
|
||||
/// Whether the script defines `fn tick(dt)` (one parameter).
|
||||
has_tick: bool,
|
||||
/// Whether the script defines `fn bump(id)` (one parameter).
|
||||
has_bump: bool,
|
||||
}
|
||||
|
||||
/// The runtime state of one scripted object: which script it runs, its persistent
|
||||
/// Rhai scope, and its pending action queue.
|
||||
/// The runtime state of one scripted object.
|
||||
struct ObjectRuntime {
|
||||
/// The stable [`ObjectId`] of this object; passed to scripts as the per-call
|
||||
/// tag so host functions know which object issued an action.
|
||||
object_id: ObjectId,
|
||||
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
|
||||
script_name: String,
|
||||
/// Per-object scope (holds the `Board`/`Queue` views + direction constants, and
|
||||
/// can hold object-local state in the future). Persists across calls.
|
||||
scope: Scope<'static>,
|
||||
/// This object's output queue (shared with its scope's `Queue` object).
|
||||
/// May contain [`Action::Delay`] entries that pace the release of other actions.
|
||||
output_queue: ObjQueue,
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// ── ObjectInfo helper ─────────────────────────────────────────────────────────
|
||||
|
||||
fn object_info_from(id: ObjectId, board: &Board) -> Option<ObjectInfo> {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Owns the Rhai engine and per-object script state for a board.
|
||||
pub struct ScriptHost {
|
||||
/// The Rhai engine, with the read getters and write host functions registered.
|
||||
engine: Engine,
|
||||
/// Compiled scripts, keyed by script name; compiled once per referenced name.
|
||||
scripts: HashMap<String, CompiledScript>,
|
||||
/// One entry per object that has a successfully compiled script.
|
||||
objects: Vec<ObjectRuntime>,
|
||||
/// Actions promoted from object queues, drained by [`Self::take_board_queue`].
|
||||
board_queue: BoardQueue,
|
||||
/// Errors collected during compile/run, drained by [`Self::take_errors`].
|
||||
errors: ErrorSink,
|
||||
}
|
||||
|
||||
impl ScriptHost {
|
||||
/// Builds a host for the board behind `board_cell`: registers the read/write API,
|
||||
/// Builds a host for the board behind `board_cell`: registers the full script API,
|
||||
/// compiles every script referenced by an object (once each), and creates a fresh
|
||||
/// scope and output queue per scripted object. Compile errors and references to
|
||||
/// unknown scripts are queued onto the error sink (retrievable via
|
||||
/// [`Self::take_errors`]); no script is run here.
|
||||
/// unknown scripts are queued onto the error sink; no script is run here.
|
||||
pub fn new(board_cell: &BoardRef) -> 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 mut engine = Engine::new();
|
||||
register_read_api(&mut engine, board_cell, &board_queue);
|
||||
engine.register_static_module("Action", rhai_action_module().into());
|
||||
register_read_api(&mut engine, board_cell, &board_queue, &errors);
|
||||
register_write_api(&mut engine, &queues);
|
||||
register_queue_api(&mut engine);
|
||||
register_queue_api(&mut engine, &errors);
|
||||
register_me_type(&mut engine);
|
||||
register_object_info_type(&mut engine);
|
||||
register_glyph_type(&mut engine);
|
||||
|
||||
let board = board_cell.borrow();
|
||||
|
||||
// Compile each referenced script once; attribute failures to the first
|
||||
// object that references the script.
|
||||
// Compile each referenced script once.
|
||||
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
||||
let mut failed: HashSet<String> = HashSet::new();
|
||||
for obj in board.objects.values() {
|
||||
let Some(name) = obj.script_name.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if scripts.contains_key(name) || failed.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = obj.script_name.as_ref() else { continue; };
|
||||
if scripts.contains_key(name) || failed.contains(name) { continue; }
|
||||
match board.scripts.get(name) {
|
||||
Some(src) => match engine.compile(src) {
|
||||
Ok(ast) => {
|
||||
// Detect the optional hooks by name and arity.
|
||||
let defines = |n: &str, params: usize| {
|
||||
ast.iter_functions()
|
||||
.any(|f| f.name == n && f.params.len() == params)
|
||||
ast.iter_functions().any(|f| f.name == n && f.params.len() == params)
|
||||
};
|
||||
let compiled = CompiledScript {
|
||||
scripts.insert(name.clone(), CompiledScript {
|
||||
has_init: defines("init", 0),
|
||||
has_tick: defines("tick", 1),
|
||||
has_bump: defines("bump", 1),
|
||||
ast,
|
||||
};
|
||||
scripts.insert(name.clone(), compiled);
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
failed.insert(name.clone());
|
||||
errors.borrow_mut().push(LogLine::raw(format!(
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"script '{name}' failed to compile: {err}"
|
||||
)));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
failed.insert(name.clone());
|
||||
errors.borrow_mut().push(LogLine::raw(format!(
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"object references unknown script '{name}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One runtime (independent scope + output queue) per object whose script
|
||||
// compiled. The queue is registered in `queues` so host functions can find
|
||||
// it by the object's index, and shared into the scope as the `Queue` object.
|
||||
// One runtime per object whose script compiled.
|
||||
let mut objects = Vec::new();
|
||||
for (&id, obj) in board.objects.iter() {
|
||||
let Some(name) = obj.script_name.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if !scripts.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = obj.script_name.as_ref() else { continue; };
|
||||
if !scripts.contains_key(name) { continue; }
|
||||
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
|
||||
queues.borrow_mut().insert(id, queue.clone());
|
||||
objects.push(ObjectRuntime {
|
||||
@@ -200,35 +231,22 @@ impl ScriptHost {
|
||||
}
|
||||
|
||||
drop(board);
|
||||
|
||||
Self {
|
||||
engine,
|
||||
scripts,
|
||||
objects,
|
||||
board_queue,
|
||||
errors,
|
||||
}
|
||||
Self { engine, scripts, objects, board_queue, errors }
|
||||
}
|
||||
|
||||
/// Calls `init()` on every scripted object that defines it and drains each queue
|
||||
/// with `dt = 0` (no time advance — just flush any zero-cost front actions).
|
||||
/// Calls `init()` on every scripted object that defines it and drains each queue.
|
||||
pub fn run_init(&mut self) {
|
||||
self.run("init", |c| c.has_init, (), 0.0);
|
||||
}
|
||||
|
||||
/// Calls `tick(dt)` on every scripted object that defines it, then drains each
|
||||
/// queue, advancing inline [`Action::Delay`]s by `dt`.
|
||||
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
|
||||
pub fn run_tick(&mut self, dt: f64) {
|
||||
self.run("tick", |c| c.has_tick, (dt,), dt);
|
||||
}
|
||||
|
||||
/// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines the hook.
|
||||
///
|
||||
/// `bumper` is the id of the object that moved into it, or `-1` for the player.
|
||||
/// After the hook, drains the object's queue with `dt = 0` so any zero-cost actions
|
||||
/// the hook enqueued (e.g. `say(); now()`) take effect in the same frame.
|
||||
/// 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, object_id: ObjectId, bumper: i64) {
|
||||
// Find the index first so we can drain after the split-borrow block.
|
||||
let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -241,17 +259,64 @@ impl ScriptHost {
|
||||
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||
options, &mut obj.scope, &compiled.ast, "bump", (bumper,),
|
||||
) {
|
||||
errors.borrow_mut().push(LogLine::raw(format!(
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"script '{}' bump error: {err}", obj.script_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
// dt=0: flush zero-cost front actions without advancing any delays.
|
||||
self.drain(i, 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, target_id: ObjectId, fn_name: &str, arg: Option<ScriptArg>) {
|
||||
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_name) else { return; };
|
||||
|
||||
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.len() == 0);
|
||||
|
||||
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
||||
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, 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_name, fn_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
self.drain(i, 0.0);
|
||||
}
|
||||
|
||||
/// Removes and returns the actions promoted onto the board queue.
|
||||
pub fn take_board_queue(&mut self) -> Vec<BoardAction> {
|
||||
pub(crate) fn take_board_queue(&mut self) -> Vec<BoardAction> {
|
||||
std::mem::take(&mut self.board_queue.borrow_mut())
|
||||
}
|
||||
|
||||
@@ -262,31 +327,23 @@ impl ScriptHost {
|
||||
|
||||
/// Drains object `i`'s output queue onto the board queue, advancing inline
|
||||
/// [`Action::Delay`]s by `dt`.
|
||||
///
|
||||
/// Non-delay actions are promoted immediately. When a `Delay` is encountered it is
|
||||
/// decremented by the remaining `dt` (which is then consumed so subsequent delays
|
||||
/// in the same call are not also decremented). An expired delay is removed and
|
||||
/// draining continues; a live delay stops the loop for this call.
|
||||
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();
|
||||
// Peek at the front to decide what to do without holding the borrow.
|
||||
let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_)));
|
||||
if is_delay {
|
||||
// Decrement the delay; pop it only if it expires.
|
||||
let expired = {
|
||||
let mut q = queue.borrow_mut();
|
||||
let Some(Action::Delay(r)) = q.front_mut() else { break };
|
||||
*r -= dt;
|
||||
dt = 0.0; // consume so later delays in this call aren't also decremented
|
||||
dt = 0.0;
|
||||
*r <= 0.0
|
||||
};
|
||||
if expired {
|
||||
queue.borrow_mut().pop_front();
|
||||
// Continue — drain actions after the expired delay.
|
||||
} else {
|
||||
break; // Live delay: stop draining.
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let Some(action) = queue.borrow_mut().pop_front() else { break };
|
||||
@@ -295,10 +352,7 @@ impl ScriptHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared driver for `init`/`tick`: for each object, call the hook if its script
|
||||
/// defines it (tagging the call with the object's id so host functions know the
|
||||
/// source), then drain that object's queue so actions reach the board queue before
|
||||
/// the next object's `blocked` check runs.
|
||||
/// Shared driver for `init`/`tick`.
|
||||
fn run<A: FuncArgs + Copy>(
|
||||
&mut self,
|
||||
hook: &str,
|
||||
@@ -307,7 +361,6 @@ impl ScriptHost {
|
||||
drain_dt: f64,
|
||||
) {
|
||||
for i in 0..self.objects.len() {
|
||||
// Scope the split borrow so it ends before the drain reborrow below.
|
||||
{
|
||||
let Self { engine, scripts, objects, errors, .. } = self;
|
||||
let obj = &mut objects[i];
|
||||
@@ -318,7 +371,7 @@ impl ScriptHost {
|
||||
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||
options, &mut obj.scope, &compiled.ast, hook, args,
|
||||
) {
|
||||
errors.borrow_mut().push(LogLine::raw(format!(
|
||||
errors.borrow_mut().push(LogLine::error(format!(
|
||||
"script '{}' {hook} error: {err}", obj.script_name
|
||||
)));
|
||||
}
|
||||
@@ -329,91 +382,155 @@ impl ScriptHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the read-only `Board` API (getters plus the `blocked` query).
|
||||
///
|
||||
/// The getters operate on the `Board` value in scope; `blocked` instead captures a
|
||||
/// clone of the world and the board queue, so it can resolve the caller's position and
|
||||
/// scan pending moves.
|
||||
fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQueue) {
|
||||
// ── Register Me type ──────────────────────────────────────────────────────────
|
||||
|
||||
fn register_me_type(engine: &mut Engine) {
|
||||
engine.register_type_with_name::<Me>("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 ObjectInfo type ──────────────────────────────────────────────────
|
||||
|
||||
fn register_object_info_type(engine: &mut Engine) {
|
||||
engine.register_type_with_name::<ObjectInfo>("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::<RhaiGlyph>("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::<BoardRef>("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);
|
||||
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)
|
||||
});
|
||||
|
||||
// has_tag(s) -> bool: does the calling object have this tag?
|
||||
// Board.tagged(tag) -> Array[ObjectInfo]
|
||||
let b = board.clone();
|
||||
engine.register_fn("has_tag", move |ctx: NativeCallContext, tag: ImmutableString| {
|
||||
let src = source_of(&ctx);
|
||||
b.borrow().objects.get(&src).is_some_and(|o| o.tags.contains(tag.as_str()))
|
||||
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()
|
||||
});
|
||||
|
||||
// get_tags() -> Array of strings: the calling object's current tag set.
|
||||
// Board.named(name) -> ObjectInfo | ()
|
||||
let b = board.clone();
|
||||
engine.register_fn("get_tags", move |ctx: NativeCallContext| -> rhai::Array {
|
||||
let src = source_of(&ctx);
|
||||
b.borrow()
|
||||
.objects
|
||||
.get(&src)
|
||||
.map(|o| o.tags.iter().map(|t| rhai::Dynamic::from(t.clone())).collect())
|
||||
.unwrap_or_default()
|
||||
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)
|
||||
});
|
||||
|
||||
// objects_with_tag(s) -> Array of i64: ids of all objects carrying the tag.
|
||||
// Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error)
|
||||
let b = board.clone();
|
||||
engine.register_fn(
|
||||
"objects_with_tag",
|
||||
move |_ctx: NativeCallContext, tag: ImmutableString| -> rhai::Array {
|
||||
b.borrow()
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|(_, o)| o.tags.contains(tag.as_str()))
|
||||
.map(|(&id, _)| rhai::Dynamic::from(id as i64))
|
||||
.collect()
|
||||
},
|
||||
);
|
||||
|
||||
// my_name() -> String: the calling object's name, or "" if unnamed.
|
||||
let b = board.clone();
|
||||
engine.register_fn("my_name", move |ctx: NativeCallContext| -> String {
|
||||
let src = source_of(&ctx);
|
||||
b.borrow()
|
||||
.objects
|
||||
.get(&src)
|
||||
.and_then(|o| o.name.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// object_id_for_name(s) -> i64: id of the object with the given name, or 0 if none.
|
||||
let b = board.clone();
|
||||
engine.register_fn(
|
||||
"object_id_for_name",
|
||||
move |_ctx: NativeCallContext, name: ImmutableString| -> i64 {
|
||||
b.borrow()
|
||||
.objects
|
||||
.iter()
|
||||
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
|
||||
.map(|(&id, _)| id as i64)
|
||||
.unwrap_or(0)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the object at `source` would bump something by moving in `dir`.
|
||||
///
|
||||
/// Returns `true` if the target cell is off the board, already holds a solid, or is
|
||||
/// the destination of any pending move on the board queue. The caller's own move is
|
||||
/// never on the queue yet (its pump runs after its script), so it can't block itself.
|
||||
///
|
||||
/// Pending moves are matched by their *immediate* target cell only; a queued move that
|
||||
/// would push a chain of solids into the cell is not accounted for (an approximation).
|
||||
/// Whether the object at `source` would be blocked moving in `dir`.
|
||||
fn is_blocked(
|
||||
board: &BoardRef,
|
||||
board_queue: &BoardQueue,
|
||||
@@ -421,19 +538,12 @@ fn is_blocked(
|
||||
dir: Direction,
|
||||
) -> bool {
|
||||
let board = board.borrow();
|
||||
let Some(obj) = board.objects.get(&source) else {
|
||||
return true;
|
||||
};
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Any pending move whose immediate target is this cell would put a solid here.
|
||||
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)
|
||||
@@ -445,12 +555,8 @@ fn is_blocked(
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`/
|
||||
/// `say`/`now` host functions.
|
||||
///
|
||||
/// `move(dir)` pushes both a `Move` and a `Delay(MOVE_COST)` so the object's queue
|
||||
/// paces itself automatically. `now()` promotes the most recently enqueued action to
|
||||
/// the front of the queue, letting a zero-cost action bypass a pending delay.
|
||||
// ── Write API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
engine.register_type_with_name::<Direction>("Direction");
|
||||
|
||||
@@ -464,7 +570,7 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
}
|
||||
});
|
||||
|
||||
// delay(secs): insert an explicit pause into the queue, merged with any trailing Delay.
|
||||
// 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 src = source_of(&ctx);
|
||||
@@ -472,9 +578,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// now(): pop the back of the queue and push it to the front.
|
||||
// Use after a zero-cost action (say, log) to bypass any pending Delay.
|
||||
// now(): promote the most recently enqueued action to the front.
|
||||
let q = queues.clone();
|
||||
engine.register_fn("now", move |ctx: NativeCallContext| {
|
||||
let src = source_of(&ctx);
|
||||
@@ -492,53 +604,173 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||
});
|
||||
|
||||
let q = queues.clone();
|
||||
engine.register_fn(
|
||||
"log",
|
||||
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(
|
||||
&q,
|
||||
source_of(&ctx),
|
||||
Action::Log(LogLine::raw(msg.to_string())),
|
||||
);
|
||||
},
|
||||
);
|
||||
engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string())));
|
||||
});
|
||||
|
||||
let q = queues.clone();
|
||||
engine.register_fn(
|
||||
"say",
|
||||
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Say(msg.to_string()));
|
||||
},
|
||||
);
|
||||
engine.register_fn("say", move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Say(msg.to_string()));
|
||||
});
|
||||
|
||||
// set_tag(target_id, tag, present): add (true) or remove (false) a tag on any object.
|
||||
// Use MY_ID as target_id to mutate the calling object's own tags.
|
||||
let q = queues.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(&q, source_of(&ctx), Action::SetTag {
|
||||
target: target as ObjectId,
|
||||
tag: tag.to_string(),
|
||||
present,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// set_fg(fg): change foreground color only.
|
||||
let q = queues.clone();
|
||||
engine.register_fn("set_fg", move |ctx: NativeCallContext, fg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::SetColor {
|
||||
fg: Some(parse_color(fg.as_str())),
|
||||
bg: None,
|
||||
});
|
||||
});
|
||||
|
||||
// set_bg(bg): change background color only.
|
||||
let q = queues.clone();
|
||||
engine.register_fn("set_bg", move |ctx: NativeCallContext, bg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::SetColor {
|
||||
fg: None,
|
||||
bg: Some(parse_color(bg.as_str())),
|
||||
});
|
||||
});
|
||||
|
||||
// set_color(fg, bg): change both colors.
|
||||
let q = queues.clone();
|
||||
engine.register_fn(
|
||||
"set_color",
|
||||
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::SetColor {
|
||||
fg: Some(parse_color(fg.as_str())),
|
||||
bg: Some(parse_color(bg.as_str())),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// send(target, fn_name): call a named function on another object.
|
||||
let q = queues.clone();
|
||||
engine.register_fn(
|
||||
"send",
|
||||
move |ctx: NativeCallContext, target: i64, name: ImmutableString| {
|
||||
emit(&q, source_of(&ctx), Action::Send {
|
||||
target: target as ObjectId,
|
||||
fn_name: name.to_string(),
|
||||
arg: None,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// send(target, fn_name, arg): same, with an optional string or number argument.
|
||||
let q = queues.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, source_of(&ctx), Action::Send {
|
||||
target: target as ObjectId,
|
||||
fn_name: name.to_string(),
|
||||
arg: script_arg,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Registers the `Queue` object: a handle to an object's output queue with `length()`
|
||||
/// and `clear()`. Safe to mutate from a script because the host only touches these
|
||||
/// queues between script calls (during [`ScriptHost::pump`]).
|
||||
fn register_queue_api(engine: &mut Engine) {
|
||||
// ── Queue API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn register_queue_api(engine: &mut Engine, errors: &ErrorSink) {
|
||||
engine.register_type_with_name::<ObjQueue>("Queue");
|
||||
engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64);
|
||||
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
|
||||
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)
|
||||
});
|
||||
|
||||
// push(map): parse the map into an Action and enqueue; log an error on failure.
|
||||
let errs = errors.clone();
|
||||
// We need the queues map here to route push to the right object's queue.
|
||||
// Since push() is called on the Queue handle directly, we use that handle.
|
||||
engine.register_fn("push", move |q: &mut ObjQueue, map: Dynamic| {
|
||||
let rhai_map = match map.try_cast::<rhai::Map>() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
errs.borrow_mut().push(LogLine::error(
|
||||
"Queue.push: argument must be an Action map".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match Action::try_from(rhai_map) {
|
||||
Ok(action) => q.borrow_mut().push_back(action),
|
||||
Err(msg) => errs.borrow_mut().push(LogLine::error(format!("Queue.push: {msg}"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scope construction ────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds a fresh per-object scope with the Board view, Queue, Me, direction
|
||||
/// constants, and the 16 EGA/VGA named color constants.
|
||||
fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> 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("North", Direction::North);
|
||||
scope.push_constant("South", Direction::South);
|
||||
scope.push_constant("East", Direction::East);
|
||||
scope.push_constant("West", Direction::West);
|
||||
// 16 EGA/VGA color constants as "#RRGGBB" strings.
|
||||
scope.push_constant("Black", "#000000");
|
||||
scope.push_constant("Blue", "#0000AA");
|
||||
scope.push_constant("Green", "#00AA00");
|
||||
scope.push_constant("Cyan", "#00AAAA");
|
||||
scope.push_constant("Red", "#AA0000");
|
||||
scope.push_constant("Magenta", "#AA00AA");
|
||||
scope.push_constant("Brown", "#AA5500");
|
||||
scope.push_constant("LightGray", "#AAAAAA");
|
||||
scope.push_constant("DarkGray", "#555555");
|
||||
scope.push_constant("BrightBlue", "#5555FF");
|
||||
scope.push_constant("BrightGreen", "#55FF55");
|
||||
scope.push_constant("BrightCyan", "#55FFFF");
|
||||
scope.push_constant("BrightRed", "#FF5555");
|
||||
scope.push_constant("BrightMagenta","#FF55FF");
|
||||
scope.push_constant("Yellow", "#FFFF55");
|
||||
scope.push_constant("White", "#FFFFFF");
|
||||
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) {
|
||||
@@ -557,28 +789,10 @@ fn emit(queues: &QueueMap, source: ObjectId, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the issuing object's [`ObjectId`] from the call tag (set in [`ScriptHost::run`]).
|
||||
///
|
||||
/// Falls back to `0` (never a valid id, since ids start at 1) if the tag is missing
|
||||
/// or out of range, which makes [`emit`] a harmless no-op for an unknown source.
|
||||
/// Reads the issuing object's [`ObjectId`] from the call tag.
|
||||
fn source_of(ctx: &NativeCallContext) -> ObjectId {
|
||||
ctx.tag()
|
||||
.and_then(|t| t.as_int().ok())
|
||||
.and_then(|n| ObjectId::try_from(n).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Builds a fresh per-object scope, seeded with the read-only `Board` view, this
|
||||
/// object's `Queue`, the four direction constants, and `MY_ID` (the object's own
|
||||
/// stable [`ObjectId`] as `i64`, for use with `set_tag` and similar calls).
|
||||
fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> Scope<'static> {
|
||||
let mut scope = Scope::new();
|
||||
scope.push_constant("Board", board.clone());
|
||||
scope.push_constant("Queue", queue.clone());
|
||||
scope.push_constant("North", Direction::North);
|
||||
scope.push_constant("South", Direction::South);
|
||||
scope.push_constant("East", Direction::East);
|
||||
scope.push_constant("West", Direction::West);
|
||||
scope.push_constant("MY_ID", object_id as i64);
|
||||
scope
|
||||
}
|
||||
|
||||
@@ -119,11 +119,11 @@ fn start_map_greeter_runs_init() {
|
||||
|
||||
#[test]
|
||||
fn set_tag_adds_and_removes_via_my_id() {
|
||||
// A script calls set_tag(MY_ID, "active", true) in init; the tag must be
|
||||
// A script calls set_tag(Me.id, "active", true) in init; the tag must be
|
||||
// present on the object afterward.
|
||||
let board = board_with_object(
|
||||
Some("t"),
|
||||
&[("t", r#"fn init() { set_tag(MY_ID, "active", true); }"#)],
|
||||
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
@@ -132,7 +132,7 @@ fn set_tag_adds_and_removes_via_my_id() {
|
||||
// A script removes a pre-existing tag.
|
||||
let board2 = board_with_object(
|
||||
Some("t2"),
|
||||
&[("t2", r#"fn init() { set_tag(MY_ID, "active", false); }"#)],
|
||||
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)],
|
||||
);
|
||||
// Seed the tag before construction.
|
||||
let mut game2 = GameState::new({
|
||||
@@ -152,8 +152,8 @@ fn has_tag_reads_own_tags() {
|
||||
Some("t"),
|
||||
&[(
|
||||
"t",
|
||||
r#"fn init() { set_tag(MY_ID, "active", true); }
|
||||
fn tick(dt) { log(has_tag("active").to_string()); }"#,
|
||||
r#"fn init() { set_tag(Me.id, "active", true); }
|
||||
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#,
|
||||
)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
@@ -177,9 +177,9 @@ fn objects_with_tag_returns_matching_ids() {
|
||||
vec![obj1, obj2],
|
||||
&[
|
||||
("q", r#"fn init() {
|
||||
let ids = objects_with_tag("enemy");
|
||||
log(ids.len().to_string());
|
||||
log(ids[0].to_string());
|
||||
let infos = Board.tagged("enemy");
|
||||
log(infos.len().to_string());
|
||||
log(infos[0].id.to_string());
|
||||
}"#),
|
||||
("none", ""),
|
||||
],
|
||||
@@ -196,7 +196,7 @@ 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 = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init() { log(my_name()); }"#)],
|
||||
&[("n", r#"fn init() { log(Me.name); }"#)],
|
||||
);
|
||||
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
|
||||
let mut game = GameState::new(board);
|
||||
@@ -206,7 +206,7 @@ fn my_name_returns_name_or_empty_string() {
|
||||
// An unnamed object should get an empty string.
|
||||
let board2 = board_with_object(
|
||||
Some("n"),
|
||||
&[("n", r#"fn init() { log(my_name()); }"#)],
|
||||
&[("n", r#"fn init() { log(Me.name); }"#)],
|
||||
);
|
||||
let mut game2 = GameState::new(board2);
|
||||
game2.run_init();
|
||||
@@ -227,8 +227,9 @@ fn object_id_for_name_finds_by_name() {
|
||||
vec![obj1, obj2],
|
||||
&[
|
||||
("q", r#"fn init() {
|
||||
log(object_id_for_name("target").to_string());
|
||||
log(object_id_for_name("missing").to_string());
|
||||
log(Board.named("target").id.to_string());
|
||||
let miss = Board.named("missing");
|
||||
log(if miss == () { "not_found" } else { miss.id.to_string() });
|
||||
}"#),
|
||||
("none", ""),
|
||||
],
|
||||
@@ -236,8 +237,8 @@ fn object_id_for_name_finds_by_name() {
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let texts = log_texts(&game);
|
||||
assert_eq!(texts[0], "2"); // obj2 is id 2
|
||||
assert_eq!(texts[1], "0"); // 0 = not found
|
||||
assert_eq!(texts[0], "2"); // obj2 is id 2
|
||||
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
|
||||
}
|
||||
|
||||
// Ensure try_move from the player side also triggers scripted bump
|
||||
|
||||
+6
-24
@@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::archetype::Archetype;
|
||||
use crate::log::LogLine;
|
||||
|
||||
/// Which directions a solid may be pushed in.
|
||||
///
|
||||
@@ -134,29 +133,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a move occupies an object before it can act again, in seconds.
|
||||
pub const MOVE_COST: f64 = 0.25;
|
||||
|
||||
/// 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 enum Action {
|
||||
/// Move the source object one cell in a direction (subject to passability).
|
||||
Move(Direction),
|
||||
/// Set the source object's glyph tile index.
|
||||
SetTile(u32),
|
||||
/// Append a plain (uncolored) line to the game log.
|
||||
Log(LogLine),
|
||||
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
|
||||
SetTag { target: ObjectId, tag: String, present: bool },
|
||||
/// Display a speech bubble above the source object for [`crate::game::SAY_DURATION`] seconds.
|
||||
Say(String),
|
||||
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
|
||||
/// Adjacent delays are merged at push time (see [`script::push_delay`]).
|
||||
Delay(f64),
|
||||
/// 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.
|
||||
|
||||
@@ -109,6 +109,14 @@ fn init() {
|
||||
set_tile(2); // change my glyph to ☻ — proves the write path
|
||||
say("Hello there, traveller!");
|
||||
}
|
||||
|
||||
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 {
|
||||
say("Hey! Get offa me!");
|
||||
delay(5.0);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
mover = """
|
||||
|
||||
Reference in New Issue
Block a user