395 lines
16 KiB
Markdown
395 lines
16 KiB
Markdown
# Kiln Rhai Scripting API
|
|
|
|
This is the complete reference for scripting kiln game objects. Scripts are written in
|
|
[Rhai](https://rhai.rs), live in the world file's top-level `[scripts]` table, and are
|
|
attached to board objects by `script_name`. Each scripted object runs its own copy of the
|
|
script with its own persistent state.
|
|
|
|
```toml
|
|
[scripts]
|
|
greeter = """
|
|
fn init() { log("hello"); }
|
|
fn tick(dt) { if Queue.length() == 0 && !blocked(North) { move(North); } }
|
|
fn bump(id) { say("ouch!"); }
|
|
"""
|
|
|
|
[[boards.room1.layers]]
|
|
sparse = [ { x = 5, y = 3, ch = "G" } ]
|
|
palette = { "G" = { kind = "object", name = "greeter", script_name = "greeter" } }
|
|
```
|
|
|
|
---
|
|
|
|
## Mental model: how a script runs
|
|
|
|
A script does **not** mutate the world directly. Instead:
|
|
|
|
1. The engine calls one of your **callbacks** (`init`, `tick`, `bump`, `grab`, or a custom
|
|
function reached via `send`).
|
|
2. Inside the callback you **read** the world synchronously (`Board.*`, `Me.*`, `blocked()`,
|
|
etc.) and **enqueue actions** (`move`, `say`, `set_tile`, …).
|
|
3. Enqueued actions go into *your object's* private output queue. They are not applied while
|
|
your callback is running.
|
|
4. After the callback returns, the engine promotes queued actions onto a shared board queue
|
|
and applies them — applying actions until it hits a "delay" action.
|
|
5. Some actions (`move`) automatically queue `delay` actions after you add them to your queue.
|
|
|
|
So writes are **deferred and rate-limited**, while reads are **immediate**. This is the
|
|
single most important thing to internalize (see [Pitfalls](#pitfalls)).
|
|
|
|
---
|
|
|
|
## Callbacks (lifecycle hooks)
|
|
|
|
All are optional. Define only the ones you need. They are matched by **name and arity** — a
|
|
`tick` with the wrong number of parameters silently won't be called.
|
|
|
|
| Callback | When it fires |
|
|
|---|---|
|
|
| `fn init()` | Once, after the board is fully loaded (and again after a board transition that re-enters this board's script host). Use for setup. |
|
|
| `fn tick(dt)` | Every frame. `dt` is elapsed seconds since the last frame (a float). |
|
|
| `fn bump(id)` | When another mover steps into this object's cell. `id` is the bumper's object id, or `-1` if it was the player. |
|
|
| `fn grab()` | When the player walks onto this object — **only** if the object has the `grab` behavior (e.g. a gem). Typically grants something and `die()`s. |
|
|
|
|
```rhai
|
|
fn init() {
|
|
log(`I am object ${Me.id} at ${Me.x},${Me.y}`);
|
|
}
|
|
|
|
fn tick(dt) {
|
|
// Patrol north until blocked, then idle.
|
|
if Queue.length() == 0 && !blocked(North) {
|
|
move(North);
|
|
}
|
|
}
|
|
|
|
fn bump(id) {
|
|
if id == -1 { say("Hello, player!"); }
|
|
else { say("Hey, watch it!"); }
|
|
}
|
|
|
|
fn grab() {
|
|
alter_gems(1);
|
|
die();
|
|
}
|
|
```
|
|
|
|
You may also define **arbitrary functions** and invoke them on another object with
|
|
`send(target_id, "fn_name")` — see [`send`](#send). The target's matching function runs with
|
|
*that* object's identity and scope.
|
|
|
|
---
|
|
|
|
## Scope constants
|
|
|
|
These four handles are injected into every object's scope. They are only visible at the top
|
|
level of your callbacks — **not inside helper functions you define** (those run at a deeper
|
|
call depth). See the [Me/Registry pitfall](#pitfalls).
|
|
|
|
| Name | What it is |
|
|
|---|---|
|
|
| `Board` | Read-only handle to the current board. |
|
|
| `Me` | This object's self-reference (id, name, position, tags, glyph). |
|
|
| `Queue` | This object's pending-action queue. |
|
|
| `Registry` | A board-scoped key→value store that **persists across board transitions**. |
|
|
|
|
## Global constants
|
|
|
|
These are registered as a global module, so they *are* visible everywhere, including inside
|
|
helper functions and Rhai-to-Rhai calls.
|
|
|
|
- **Directions:** `North`, `South`, `East`, `West` (type `Direction`).
|
|
- **Colors:** the 16 EGA/VGA palette names as `"#RRGGBB"` strings: `Black`, `Blue`, `Green`,
|
|
`Cyan`, `Red`, `Magenta`, `Brown`, `LightGray`, `DarkGray`, `LightBlue`, `LightGreen`,
|
|
`LightCyan`, `LightRed`, `LightMagenta`, `Yellow`, `White`.
|
|
|
|
```rhai
|
|
move(East);
|
|
set_color(Yellow, Black);
|
|
```
|
|
|
|
---
|
|
|
|
## Reading the world
|
|
|
|
### `Board.*` properties
|
|
|
|
| Expression | Type | Meaning |
|
|
|---|---|---|
|
|
| `Board.player_x` | int | Player's X. |
|
|
| `Board.player_y` | int | Player's Y. |
|
|
| `Board.width` | int | Board width in cells. |
|
|
| `Board.height` | int | Board height in cells. |
|
|
|
|
### `Board` lookups
|
|
|
|
| Call | Returns |
|
|
|---|---|
|
|
| `Board.tagged(tag)` | Array of `ObjectInfo` for every object carrying `tag`. |
|
|
| `Board.named(name)` | The `ObjectInfo` with that name, or `()` (unit) if none. |
|
|
| `Board.get(id)` | The `ObjectInfo` for `id`; `Board.get(-1)` returns the **player**. Returns `()` and logs an error for an unknown/invalid id. |
|
|
|
|
`ObjectInfo` is a **snapshot** (read fields, it is not live):
|
|
|
|
| Field | Type |
|
|
|---|---|
|
|
| `.id` | int (the player's is `-1`) |
|
|
| `.x`, `.y` | int |
|
|
| `.name` | string (`""` if unnamed) |
|
|
| `.tags` | array of strings |
|
|
|
|
```rhai
|
|
let p = Board.get(-1);
|
|
log(`player at ${p.x},${p.y}`);
|
|
|
|
for g in Board.tagged("guard") {
|
|
if g.x == Me.x { say("aligned with a guard"); }
|
|
}
|
|
```
|
|
|
|
### Cell queries (free functions)
|
|
|
|
| Call | Returns true when |
|
|
|---|---|
|
|
| `blocked(dir)` | Moving the **calling object** one step in `dir` is impossible: off-board, an existing solid, or the destination of another mover's pending move this frame. |
|
|
| `passable(x, y)` | `(x, y)` is on-board and has **no** solid (an empty cell / hole a mover could enter). Off-board is not passable. |
|
|
| `can_push(x, y, dir)` | `(x, y)` holds a pushable whose entire chain can be shoved one step in `dir` (the chain ends in open space). False off-board. |
|
|
| `can_shift(x, y, dir)` | `(x, y)` holds a pushable **and the single next cell** is empty or another pushable — does *not* require the whole chain to clear. The right gate for a rotation/shift via `swap`. |
|
|
| `combinable(x1, y1, x2, y2)` | The solids at the two cells can share a cell: one is empty, or one is a `grab` thing and the other is the player. Off-board combines with nothing. |
|
|
|
|
`passable` vs `can_shift`: `can_shift == false` is ambiguous between "the next cell is a
|
|
wall" and "the next cell is empty but the source isn't pushable." Use `passable` to detect a
|
|
genuine hole.
|
|
|
|
### `Me.*` (self)
|
|
|
|
| Expression | Returns |
|
|
|---|---|
|
|
| `Me.id` | This object's id (int). |
|
|
| `Me.name` | This object's name, or `""`. |
|
|
| `Me.x`, `Me.y` | Current position (int). |
|
|
| `Me.has_tag(tag)` | bool. |
|
|
| `Me.tags()` | array of strings. |
|
|
| `Me.glyph()` | a `Glyph`: `.tile` (int), `.fg` (hex string), `.bg` (hex string). |
|
|
|
|
```rhai
|
|
if Me.has_tag("asleep") { return; }
|
|
let here = Me.glyph();
|
|
log(`my tile is ${here.tile}`);
|
|
```
|
|
|
|
### `Registry` (persistent key→value store)
|
|
|
|
Board-scoped storage that **survives board transitions** (unlike script-local variables,
|
|
which reset whenever the script host is rebuilt). Use it for any state that must outlive a
|
|
single `tick`. Values may be primitives (int, float, bool, string); unsupported types are
|
|
silently ignored.
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `Registry.get(key)` | The stored value, or `()` if absent. |
|
|
| `Registry.set(key, value)` | Store `value`. Passing `()` **removes** the key. |
|
|
| `Registry.get_or(key, default)` | The stored value **if present and the same type as** `default`; otherwise `default`. |
|
|
|
|
```rhai
|
|
fn tick(dt) {
|
|
// Per-object counter that survives across ticks.
|
|
let key = `count_${Me.id}`;
|
|
let n = Registry.get_or(key, 0);
|
|
Registry.set(key, n + 1);
|
|
}
|
|
```
|
|
|
|
Key your entries by `Me.id` (as above) if each object instance needs its own slot — the
|
|
Registry is shared by the whole board.
|
|
|
|
---
|
|
|
|
## Writing to the world (actions)
|
|
|
|
Every function below **enqueues** an action on the calling object's output queue; nothing
|
|
takes effect until your callback returns. Only `move` carries a time cost (0.25 s); the rest
|
|
are zero-cost and may all resolve in the same frame.
|
|
|
|
### Movement & timing
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `move(dir)` | Step one cell in `dir`, shoving any pushable chain (including the player) ahead; a no-op if blocked. **Costs 0.25 s** before this object can move again. |
|
|
| `delay(secs)` | Insert an explicit pause in this object's queue (accepts int or float). Adjacent delays merge. |
|
|
| `now()` | Promote the most-recently-enqueued action to the **front** of the queue (jump the delay). |
|
|
| `teleport(id, x, y)` | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Zero cost. Refused (with a logged error at resolve time) if that entity is solid and the destination already holds a *different* solid. |
|
|
|
|
### Appearance
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `set_tile(n)` | Set this object's glyph tile index (CP437; e.g. `64` is `@`). |
|
|
| `set_fg(hex)` | Set foreground color (`"#RRGGBB"` string or a color constant). |
|
|
| `set_bg(hex)` | Set background color. |
|
|
| `set_color(fg, bg)` | Set both at once. |
|
|
|
|
### Communication & UI
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `log(msg)` | Append a line to the in-game log. |
|
|
| `say(msg)` | Show a speech bubble above this object (default ~3 s). One bubble per object; a new `say` replaces the old. |
|
|
| `say(msg, secs)` | Same, with explicit duration. |
|
|
| `scroll(lines)` | Open a full-screen scrollable overlay (pauses ticks). See below. |
|
|
|
|
`scroll(lines)` takes an array whose elements are either a plain **string** (a text line) or
|
|
a two-element array `[choice_key, display_text]` (a selectable option). When the player
|
|
picks an option, the engine calls `send`-style back into *this* object: a function named
|
|
`choice_key` is invoked. Define that function to react.
|
|
|
|
```rhai
|
|
fn tick(dt) {
|
|
if Queue.length() == 0 && near_player() {
|
|
scroll([
|
|
"The old wizard studies you.",
|
|
"",
|
|
["yes", "Accept the quest"],
|
|
["no", "Decline"],
|
|
]);
|
|
}
|
|
}
|
|
fn yes() { say("Brave soul!"); alter_gems(5); }
|
|
fn no() { say("Coward."); }
|
|
```
|
|
|
|
### Tags & messaging
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `set_tag(target_id, tag, present)` | Add (`true`) or remove (`false`) `tag` on the object with `target_id`. |
|
|
| `send(target_id, fn_name)` | Call `fn_name()` on object `target_id`. |
|
|
| `send(target_id, fn_name, arg)` | Same, passing one string-or-number `arg`. |
|
|
|
|
<a name="send"></a>`send` runs the target's function with the **target's** identity and scope
|
|
(its `Me`, its `Queue`). The target must define a function of that name accepting 0 or 1
|
|
params (the engine picks the matching arity; if neither exists the call is silently dropped).
|
|
This is the building block for object-to-object coordination and scroll choices.
|
|
|
|
```rhai
|
|
// Object A wakes object B (id from a lookup) and passes it a word.
|
|
let b = Board.named("door");
|
|
if b != () { send(b.id, "open", "north"); }
|
|
```
|
|
|
|
### Pushing & batch moves
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `push(x, y, dir)` | Shove the pushable chain starting at `(x, y)` one step in `dir`. Acts on arbitrary cells, not the caller. Zero cost. |
|
|
| `swap(pairs)` | Apply a batch of one-way solid moves **simultaneously** (read-all then write-all, so cycles and two-cell swaps resolve). Zero cost. |
|
|
|
|
`swap(pairs)` takes an array of four-int arrays `[src_x, src_y, dst_x, dst_y]`. Each names a
|
|
solid (player, object, or terrain) to move from src to dst; all happen at once. A malformed
|
|
entry is skipped and logged. The **player is never destroyed** by a swap — a move that would
|
|
overwrite the player without relocating it is refused and logged. (This is how the built-in
|
|
spinner rotates its 8 neighbours in one step.)
|
|
|
|
```rhai
|
|
// Two-cell swap: exchange the solids at (3,3) and (3,4).
|
|
swap([[3, 3, 3, 4], [3, 4, 3, 3]]);
|
|
```
|
|
|
|
### Player stats & self-removal
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `alter_gems(n)` | Add `n` to the player's gem count (negative subtracts; clamped at 0). |
|
|
| `die()` | Remove the calling object from the board. Zero cost. |
|
|
|
|
---
|
|
|
|
## The `Queue` API
|
|
|
|
Your object's output queue is inspectable. Most scripts only need `Queue.length()` to check
|
|
"am I idle?" before issuing a new `move`.
|
|
|
|
| Call | Returns |
|
|
|---|---|
|
|
| `Queue.length()` | Number of pending actions (int). |
|
|
| `Queue.clear()` | Empty the queue (cancel pending actions). |
|
|
| `Queue.peek()` | The front action as a map (see below), or `()` if empty. |
|
|
| `Queue.pop()` | Same, but removes the front. |
|
|
|
|
A peeked/popped action is a Rhai map with a `"type"` key (`"Move"`, `"SetTile"`, `"Say"`,
|
|
`"Delay"`, `"Push"`, `"Swap"`, `"Teleport"`, `"AddGems"`, `"Die"`, `"Log"`, `"SetTag"`,
|
|
`"SetColor"`, `"Send"`, `"Scroll"`) plus payload keys. `Scroll` and `Swap` expose only their
|
|
`type`; `Log` is flattened to its text.
|
|
|
|
```rhai
|
|
fn tick(dt) {
|
|
// Idle guard: don't pile up moves.
|
|
if Queue.length() == 0 {
|
|
if !blocked(South) { move(South); }
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Pitfalls and sharp edges
|
|
|
|
**Writes are deferred — you can't read back what you just wrote.** After `move(North)`,
|
|
`Me.y` is *unchanged* for the rest of this callback; the move resolves later. Don't write
|
|
then immediately read the same state expecting the new value.
|
|
|
|
**Moves are rate-limited to one per 0.25 s.** If you call `move` every `tick` without guarding
|
|
on `Queue.length()`, you'll pile up a backlog of moves that drain slowly and ignore newer
|
|
decisions. The idiom is `if Queue.length() == 0 { move(...); }`. The built-in pusher/spinner
|
|
both gate on `Queue.length()`.
|
|
|
|
**`Board`, `Me`, `Queue`, and `Registry` are not visible inside your own helper functions.**
|
|
They're scope constants injected only at the top level of the engine-called callback. A
|
|
helper `fn step() { move(North); }` works (the host *functions* are global), but
|
|
`fn where_am_i() { return Me.x; }` will error — `Me` is undefined there. Read those handles
|
|
at the top of your callback and pass the values down as arguments. (The built-in scripts read
|
|
`Me.has_tag(...)`, `Me.x`, etc. only inside `tick`/`grab`, never in a sub-function.)
|
|
|
|
**Direction and color constants *are* global, so they're fine inside helpers.** Only the four
|
|
scope handles have this restriction.
|
|
|
|
**Script-local variables reset across board transitions.** When the player leaves and the
|
|
script host is rebuilt, every `let` in your script is gone. Object state baked into the board
|
|
(position, tags, glyph) persists; anything else you need to keep must go in the `Registry` or
|
|
a tag.
|
|
|
|
**Callbacks are matched by name *and* arity.** `fn tick()` (no param) is **not** the tick
|
|
hook — it must be `fn tick(dt)`. Likewise `bump` needs exactly one param and `init`/`grab`
|
|
exactly zero. A mismatch fails silently (the hook just never runs).
|
|
|
|
**`grab()` only fires for objects with the grab behavior.** That behavior comes from the
|
|
archetype (only `gem` sets it today). A plain object's `grab()` will never be called; use
|
|
`bump(id)` to react to being walked into. Also: grab is **only** triggered by the *player*
|
|
walking onto the thing — pushing/swapping a grab object into the player treats it as an
|
|
ordinary solid.
|
|
|
|
**`bump` distinguishes the player by `id == -1`.** Other movers pass their real object id.
|
|
|
|
**`Board.get` / `Board.named` return `()` (unit), not an empty object, when absent.** Check
|
|
`if x != () { ... }` before reading fields, or you'll error on a missing lookup. `Board.get`
|
|
also logs an error for an invalid id (≤ 0 other than the special `-1`).
|
|
|
|
**`ObjectInfo` is a snapshot.** The `.x`/`.y`/`.tags` you read are the values at lookup time;
|
|
they don't update if the object moves later in the same frame. Re-query when you need fresh
|
|
data.
|
|
|
|
**`Registry` is shared by the whole board.** If each object instance needs private storage,
|
|
namespace your keys (e.g. `` `count_${Me.id}` ``). `get_or` only returns the stored value
|
|
when it matches the default's type, so a key reused for different types falls back safely.
|
|
|
|
**`teleport` and `swap` can be silently refused.** A solid can't teleport onto another solid,
|
|
and a swap never overwrites the player. These log an error rather than throwing — watch the
|
|
log if a move "didn't happen."
|
|
|
|
**Runtime and compile errors are non-fatal.** A script that throws (or fails to compile) is
|
|
logged and skipped; it won't crash the game, but the object will quietly do nothing. Check
|
|
the in-game log when an object misbehaves.
|
|
|
|
**Rhai caps expression complexity.** Very deeply nested expressions are rejected by the
|
|
engine; pull sub-expressions into `let` locals (the built-in spinner does this deliberately).
|