Fixing tests and comments

This commit is contained in:
2026-07-05 23:41:19 -05:00
parent c16b21c603
commit b7063277e7
8 changed files with 214 additions and 174 deletions
+8 -8
View File
@@ -75,14 +75,14 @@ start = "room1" # key of the starting board
# by script_name = "key". Scripts do NOT live on individual boards.
[scripts]
greeter = """
fn init() { # optional, run once at startup
log(`player at ${Board.player_x}, ${Board.player_y}`);
fn init(me, state) { # optional, run once at startup
log(`player at ${state.player.x}, ${state.player.y}`);
set_tile(2); # write: change my glyph
}
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) { move(North); }
fn tick(me, state, dt) {
if !me.waiting && !me.blocked(North) { move(North); }
}
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
fn bump(me, state, id) { log(`bumped by ${id}`); say("Ouch!"); }
"""
# Each board is a named subtable under [boards]. The board key ("room1") is
@@ -130,9 +130,9 @@ content = """
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, `bump(id)`, and `grab()` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, `can_push(x, y, dir) -> bool`, `can_shift(x, y, dir) -> bool`, and `passable(x, y) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously), `add_gems(n)` (change the player's gem count), `alter_health(dh)` (change the player's health, clamped to `[0, max_health]`), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`.
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(x, y)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `add_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump`. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md).
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`).
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions).
### Key design decisions
@@ -158,6 +158,6 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
### Other not-yet-implemented threads
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates, `remove_object` deletes — used by `apply_swap` to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — runtime until the host is rebuilt).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates, `remove_object` deletes — used by `apply_shift` to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the `ScriptHost` (it compiles one script + builds one persistent `Scope` per object when constructed, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — scope until the host is rebuilt).
- **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.)
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).
+160 -130
View File
@@ -5,6 +5,10 @@ file. Each scripted object references a script by name via `script_name`. A scri
hooks (any may be omitted) and may define arbitrary handler functions reachable via `send()` and
scroll choices.
Every hook receives the same first two parameters — `me` (this object) and `state` (the world) —
through which all reads happen. Writes go through free functions (`move`, `say`, `set_tile`, …) that
implicitly act on the calling object.
> **Status:** this document describes the API as it currently exists. It has grown organically and
> has known rough edges — duplication, naming inconsistency, and some unwieldy machinery. Those are
> catalogued in [Design notes](#design-notes-rough-edges) at the end, as the starting point for a
@@ -15,33 +19,36 @@ scroll choices.
## Lifecycle hooks
Hooks are detected by name **and** arity (parameter count). A function with the right name but wrong
number of parameters is not recognized as a hook.
number of parameters is not recognized as a hook. Every hook takes `me` (an [`ObjectInfo`](#self-and-other-objects-objectinfo)
for the object running the script) and `state` (a [`State`](#the-world-state) handle to the world),
in that order, before any hook-specific argument.
### `fn init()`
### `fn init(me, state)`
Called once for every scripted object after the entire map is loaded, before the first frame.
Called once for every scripted object after the entire map is loaded, before the first frame (and
again after a board transition re-enters this board's script host).
```rhai
fn init() {
fn init(me, state) {
set_tile(42);
log("ready");
}
```
### `fn tick(dt)`
### `fn tick(me, state, dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Timed
actions pace themselves through the queue; you do not track time manually for simple walking.
```rhai
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) {
fn tick(me, state, dt) {
if !me.waiting && !me.blocked(North) {
move(North);
}
}
```
### `fn bump(id)`
### `fn bump(me, state, id)`
Called when another entity walks into this object's cell.
@@ -49,13 +56,13 @@ Called when another entity walks into this object's cell.
- `id == -1` means the **player** bumped this object.
```rhai
fn bump(id) {
fn bump(me, state, id) {
if id == -1 { say("Hey! Watch it."); }
else { log(`object ${id} bumped me`); }
}
```
### `fn grab()`
### `fn grab(me, state)`
Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts —
`solid + grab`). Unlike `bump`, the player ends up on the object's cell; the hook typically adjusts a
@@ -63,7 +70,7 @@ player stat and removes the object. It fires followed by an immediate resolve, s
`add_gems()` / `alter_health()` applies before the player's move returns.
```rhai
fn grab() {
fn grab(me, state) {
add_gems(1);
die();
}
@@ -78,129 +85,148 @@ Any other function can be invoked on an object two ways:
- Another object calls `send(target_id, "fn_name" [, arg])`.
- A `scroll()` **choice** is selected: the choice key is dispatched to the source object as a
zero-argument function call of that name.
function call of that name.
A handler may take **zero or one** parameter; the host calls the 1-arg form when an arg is supplied,
else the 0-arg form. If neither arity exists, the call is silently dropped.
The host picks the handler's parameter list by arity, in this order:
- **3 params** → `(me, state, arg)``arg` is the value passed to `send` (or unit for a scroll choice).
- **2 params** → `(me, state)`.
- **1 param** → `(arg)`.
- **0 params** → `()`.
If no matching function name exists, the call is silently dropped.
```rhai
// reached via send(my_id, "open") or a scroll choice keyed "open"
fn open() { set_tile(47); }
fn set_level(n){ set_tag(Me.id, "level", true); log(`level ${n}`); }
fn open(me, state) { set_tile(47); }
fn set_level(me, state, n) { set_tag(me.id, "level", true); log(`level ${n}`); }
```
---
## Constants in scope
Pre-set in every object's scope. The object handles are scope constants; direction/color constants
come from a global module visible at any call depth (including Rhai→Rhai calls).
Unlike the `me`/`state` parameters (which every hook receives), these are injected directly into
every object's scope.
| Name | Type | Description |
|------|------|-------------|
| `Board` | `Board` | Read-only handle to the current board / world. |
| `Queue` | `Queue` | This object's own pending-action queue. |
| `Me` | `Me` | Self-reference (id, name, position, tags, glyph). |
| `Registry` | `Registry` | Board-scoped key→value store; persists across board transitions. |
| `Player` | `Player` | Read-only snapshot of the player's game-global stats. |
| `North` / `South` / `East` / `West` | `Direction` | Cardinal directions. |
| `Black``White` (16) | `String` | EGA/VGA palette colors as `"#RRGGBB"` strings. |
`Registry` is a **scope constant**: it is only visible at the top level of an engine-called hook, not
inside helper functions you define (those run at a deeper call depth — see [Pitfalls](#pitfalls-and-sharp-edges)).
The direction/color constants come from a global module and *are* visible at any call depth (including
Rhai→Rhai calls).
The 16 color names, in palette order: `Black`, `Blue`, `Green`, `Cyan`, `Red`, `Magenta`, `Brown`,
`LightGray`, `DarkGray`, `BrightBlue`, `BrightGreen`, `BrightCyan`, `BrightRed`, `BrightMagenta`,
`Yellow`, `White`. They are plain hex strings, so they are accepted anywhere a color string is (e.g.
`set_fg(BrightRed)`).
> There is **no** `MY_ID` constant any more — use `Me.id`.
---
## Self-reference: `Me`
## Self and other objects: `ObjectInfo`
`me` is an `ObjectInfo` — a snapshot of one board object. The same type comes back from
[`state.board.get` / `.named` / `.tagged`](#board-reads), so an object you look up has exactly the
same members as `me`.
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `Me.id` | getter | `i64` | This object's stable `ObjectId`. |
| `Me.name` | getter | `String` | Name, or `""` if unnamed. |
| `Me.x` / `Me.y` | getter | `i64` | Current cell position. |
| `Me.has_tag(tag)` | method | `bool` | Whether this object carries `tag`. |
| `Me.tags()` | method | `Array` | All tag strings on this object. |
| `Me.glyph()` | method | `Glyph` | This object's glyph (`.tile`, `.fg`, `.bg`). |
| `.id` | getter | `i64` | The object's stable `ObjectId`. |
| `.name` | getter | `String` or `()` | Name, or unit if unnamed. |
| `.x` / `.y` | getter | `i64` | Current cell position. |
| `.tags` | getter | `Array` | All tag strings on this object. |
| `.glyph` | getter | `Glyph` | The object's glyph (`.tile`, `.fg`, `.bg`). |
| `.waiting` | getter | `bool` | Whether the front of the object's queue is a delay (i.e. it is "busy"). |
| `.queue` | getter | `Queue` | The object's [pending-action queue](#queue-object). |
| `.has_tag(tag)` | method | `bool` | Whether the object carries `tag`. |
| `.delay(secs)` | method | — | Append a pause to the object's queue. |
| `.blocked(dir)` | method | `bool` | Whether moving the object one step in `dir` is impossible (off-board or a solid). |
| `.can_push(dir)` | method | `bool` | Whether the pushable chain ahead of the object in `dir` can be shoved. |
```rhai
if Me.has_tag("locked") { say(`${Me.name} is locked`); }
set_tag(Me.id, "seen", true); // self-mutation still needs the explicit id
fn tick(me, state, dt) {
if me.has_tag("asleep") { return; }
let here = me.glyph;
log(`my tile is ${here.tile}`);
if !me.waiting && !me.blocked(East) { move(East); }
}
```
A `Glyph` value (from `Me.glyph()` or `ObjectInfo` lookups) exposes `.tile` (`i64`), `.fg`, `.bg`
(both `"#RRGGBB"` strings).
A `Glyph` value (from `me.glyph` or a looked-up object) exposes `.tile` (`i64`), `.fg`, `.bg` (both
`"#RRGGBB"` strings).
> Because hooks receive `me`/`state` as **parameters**, they are local to the hook. To use them in a
> helper function you define, pass them down explicitly: `fn dance(me) { … }` called as `dance(me)`.
---
## Player stats: `Player`
## The world: `state`
`state` is a handle bundling the two read surfaces:
| Member | Type | Description |
|--------|------|-------------|
| `state.player` | `Player` | The player's game-global stats and position. |
| `state.board` | `Board` | Read-only handle to the current board. |
### Player stats: `state.player`
A read-only snapshot of the game-global player state, refreshed before each hook call.
| Property | Type | Description |
|----------|------|-------------|
| `Player.gems` | `i64` | Gems collected. |
| `Player.health` | `i64` | Current health. |
| `Player.max_health` | `i64` | Health ceiling. |
| `state.player.gems` | `i64` | Gems collected. |
| `state.player.health` | `i64` | Current health. |
| `state.player.max_health` | `i64` | Health ceiling. |
| `state.player.x` / `.y` | `i64` | Player's current cell. |
| `state.player.keys` | `Keyring` | The player's key inventory (see below). |
```rhai
if Player.health < 2 { say("You look hurt."); }
if state.player.health < 2 { say("You look hurt."); }
if state.player.keys.red { say("You have the red key."); }
```
> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it).
The `Keyring` from `state.player.keys` exposes one `bool` getter per color: `.red`, `.orange`,
`.yellow`, `.green`, `.blue`, `.cyan`, `.purple`, `.white`.
---
### Board reads
## Board reads
### Properties
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Board.player_x` | `i64` | Player's column. |
| `Board.player_y` | `i64` | Player's row. |
| `Board.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. |
| `state.board.width` | `i64` | Board width in cells. |
| `state.board.height` | `i64` | Board height in cells. |
### Object lookups
#### Object lookups
| Call | Returns | Description |
|------|---------|-------------|
| `Board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag`. |
| `Board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `Board.get(id)` | `ObjectInfo` or `()` | Look up by id; `-1` returns the player; an invalid/unknown id logs an error and returns unit. |
An `ObjectInfo` exposes `.id`, `.x`, `.y`, `.name` (`""` if unnamed) and `.tags` (an `Array`). Note
it has **no** `has_tag()` or `glyph()` — those exist only on `Me`.
| `state.board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag`. |
| `state.board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `state.board.get(id)` | `ObjectInfo` or `()` | Look up by id; an invalid (`id <= 0`) or unknown id logs an error and returns unit. |
```rhai
let enemies = Board.tagged("enemy");
let enemies = state.board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);
let door = Board.named("door");
let door = state.board.named("door");
if door != () { send(door.id, "open"); }
```
---
## Cell queries (free functions)
#### Cell queries
| Call | Returns | Description |
|------|---------|-------------|
| `blocked(dir)` | `bool` | Would the **caller** be unable to move one step in `dir` (off-board, a solid, or another object's already-queued move targets that cell). The caller's own queued moves don't count. |
| `can_push(x, y, dir)` | `bool` | Does a pushable chain starting at `(x, y)` end in open space when shoved in `dir`? `false` off-board. |
| `can_shift(x, y, dir)` | `bool` | Like `can_push` but checks only the **one** cell ahead (pushable source + empty-or-pushable neighbor). The right gate for `shift()`. |
| `passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
| `combinable(x1, y1, x2, y2)` | `bool` | Can the solids at the two cells share a cell (one empty, or a grab thing + the player)? |
| `state.board.can_push(x, y, dir)` | `bool` | Does a pushable chain starting at `(x, y)` end in open space when shoved in `dir`? `false` off-board. |
| `state.board.passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
```rhai
fn tick(dt) {
if !blocked(East) { move(East); }
}
```
For queries about the calling object itself, use `me.blocked(dir)` and `me.can_push(dir)`
([above](#self-and-other-objects-objectinfo)).
---
@@ -215,17 +241,23 @@ Board-scoped, persists across board transitions. Stores primitive values.
| `Registry.get_or(key, default)` | Stored value **only if** it has the same type as `default`; otherwise `default`. |
```rhai
let count = Registry.get_or("visits", 0);
Registry.set("visits", count + 1);
fn tick(me, state, dt) {
// Per-object counter that survives across ticks and board transitions.
let key = `count_${me.id}`;
let n = Registry.get_or(key, 0);
Registry.set(key, n + 1);
}
```
The Registry is shared by the whole board, so namespace per-instance keys by `me.id` as above.
---
## Write functions
Scripts never mutate the world directly. Each write appends an `Action` to this object's **output
queue**; actions drain to a shared board queue between script calls and are resolved by the engine
after the batch. The *subject* of a write is implicit and varies by function (see the table).
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
**output queue**; actions drain to a shared board queue between script calls and are resolved by the
engine after the batch. The *subject* of a write is implicit and varies by function (see the table).
| Call | Subject | Cost | Description |
|------|---------|------|-------------|
@@ -237,7 +269,7 @@ after the batch. The *subject* of a write is implicit and varies by function (se
| `set_fg(color)` | self | 0 | Set foreground color (hex string). |
| `set_bg(color)` | self | 0 | Set background color. |
| `set_color(fg, bg)` | self | 0 | Set both colors. |
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `Me.id` for self. |
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `me.id` for self. |
| `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs`. Replaces any current bubble. |
| `log(msg)` | log | 0 | Append a plain-text line to the game log. |
| `scroll(lines)` | UI | 0 | Open a full-screen overlay (see below). |
@@ -249,6 +281,9 @@ after the batch. The *subject* of a write is implicit and varies by function (se
| `set_key(color, present)` | player | 0 | Give/take a key color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, `"orange"`, `"yellow"`, `"white"`). Unknown name logs and is ignored. |
| `die()` | self | 0 | Remove the calling object from the board. |
`delay`/`now` also exist as methods on `me` and on a queue handle: `me.delay(secs)` and
`me.queue.delay(secs)` are equivalent to the free `delay(secs)` for the calling object.
### `scroll(lines)`
Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element
@@ -258,10 +293,11 @@ of `lines` is either:
- a **2-element array** `[choice_key, display_text]` — a selectable choice.
When the player selects a choice, `choice_key` is dispatched back to the **source object** as a
zero-arg function call named `choice_key`.
function call named `choice_key` (see [custom handler functions](#custom-handler-functions) for the
arity rules — a choice handler is called with no `arg`).
```rhai
fn bump(id) {
fn bump(me, state, id) {
scroll([
"The muffin looks delicious.",
"",
@@ -269,32 +305,28 @@ fn bump(id) {
["ignore", "Walk away"],
]);
}
fn eat() { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore() { log("Wise."); }
fn eat(me, state) { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore(me, state) { log("Wise."); }
```
---
## `Queue` object
Inspection and control of this object's own pending-action queue.
Inspection and control of an object's own pending-action queue, reached via `me.queue`. Most scripts
do not need it directly — `me.waiting` covers the common "am I busy?" check.
| Method | Returns | Description |
|--------|---------|-------------|
| `Queue.length()` | `i64` | Number of pending actions. |
| `Queue.clear()` | — | Discard all pending actions (including delays). |
| `Queue.peek()` | map or `()` | Front action as a map, or unit if empty. |
| `Queue.pop()` | map or `()` | Same, removing the front. |
`peek`/`pop` return an untyped map with a `"type"` key (`"Move"`, `"SetTile"`, `"Say"`, …) plus
variant-specific payload keys (e.g. `"dir"`, `"tile"`, `"msg"`). `Scroll` and `Shift` report `type`
only — their payloads are not inspectable through this API.
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `me.queue.length` | getter | `i64` | Number of pending actions. |
| `me.queue.clear()` | method | — | Discard all pending actions (including delays). |
| `me.queue.delay(secs)` | method | — | Append a pause to the queue. |
```rhai
fn tick(dt) {
if Queue.length() == 0 {
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
fn tick(me, state, dt) {
if !me.waiting {
if !me.blocked(East) { move(East); }
else if !me.blocked(West) { move(West); }
}
}
```
@@ -306,11 +338,13 @@ fn tick(dt) {
- `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 a zero-cost action already in
the queue.
- `Queue.clear()` cancels everything, including pending delays.
- `me.queue.clear()` cancels everything, including pending delays.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `1.0 s` delay.
- All other writes (`set_tile`, `say`, `push`, `shift`, `teleport`, stat changes, `die`, …) are
zero-cost: a run of them resolves together, and at most one *timed* action (a `move`) is released
per object per cycle.
- `me.waiting` is `true` while a delay sits at the front of the queue — the idiom for "don't issue a
new action until the last one finished" is `if !me.waiting { … }`.
---
@@ -326,18 +360,18 @@ crash the game. A script that throws during `tick` logs the error and resumes on
```rhai
// A guard that patrols east/west and greets the player when bumped.
fn init() {
fn init(me, state) {
set_tile(2);
log(`${Me.name} standing watch`);
log(`${me.name} standing watch`);
}
fn tick(dt) {
if Queue.length() > 0 { return; } // still moving
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
fn tick(me, state, dt) {
if me.waiting { return; } // still moving
if !me.blocked(East) { move(East); }
else if !me.blocked(West) { move(West); }
}
fn bump(id) {
fn bump(me, state, id) {
if id == -1 { say("Halt! Who goes there?"); now(); }
else { say("Watch it!"); now(); }
}
@@ -352,15 +386,13 @@ they are shape problems.
### Duplication — several ways to do one thing
- **Object introspection has two parallel types with different members.** Self uses `Me`
(`Me.has_tag()`, `Me.tags()`, `Me.glyph()`, plus getters); other objects come back as `ObjectInfo`
(`.id`, `.x`, `.y`, `.name`, `.tags`) which has **no** `has_tag()` and **no** `glyph()`. The same
conceptual "an object" is two incompatible types depending on whether it's you.
- **Player position/state is reachable three ways:** `Board.player_x` / `Board.player_y`,
`Board.get(-1).x` / `.y`, and the `Player` constant (stats only). Player *position* lives on
`Board`, player *stats* live on `Player` — the player is split across two surfaces.
- **Tag reads come in three shapes:** `Me.has_tag(t)` (method, bool), `Me.tags()` (method, array),
`ObjectInfo.tags` (property, array). Method-vs-property is inconsistent even for the same data.
- **Player position/state is reachable two ways.** Player *stats* and *position* both live on
`state.player` now (`.gems`/`.health`/`.x`/`.y`/`.keys`), which is an improvement — but a looked-up
player no longer exists (`state.board.get(-1)` is gone), so code that wants "the player as an
object" has nothing to look up, while every other entity is an `ObjectInfo`.
- **Reads are split across three handles** (`me`, `state.player`, `state.board`) with no single
"query" entry point; which handle owns a given fact (e.g. cell queries on `state.board` vs. self
queries on `me`) has to be memorized.
### Inconsistency — naming and conventions
@@ -372,11 +404,14 @@ they are shape problems.
**self**; `add_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an
**arbitrary target**; `push`/`shift`/`teleport` act on **cells**. The `set_*` prefix in particular
means three different subjects.
- **Mixed addressing for movement.** `move`/`blocked`/`push` take a typed `Direction`, while
`teleport`/`passable`/`can_push`/`can_shift`/`shift` take raw `i64` coordinates. There's no cell or
position value type.
- **Self-mutation can't go through `Me`.** `Me` is read-only, so writing your own tag is
`set_tag(Me.id, …)` — you pass your own id back to a free function instead of `Me.set_tag(…)`.
- **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads,
but `move(dir)` (the matching write) is a bare global that acts on `me` implicitly — the symmetry is
invisible.
- **Mixed addressing for movement.** `move`/`push` take a typed `Direction`, while
`teleport`/`passable`/`can_push`/`shift` take raw `i64` coordinates. There's no cell or position
value type.
- **Self-mutation can't go through `me`.** `me` is read-only, so writing your own tag is
`set_tag(me.id, …)` — you pass your own id back to a free function instead of `me.set_tag(…)`.
- **Colors are stringly-typed** (`"#RRGGBB"`, with the named constants being strings) while tiles are
bare integers with no symbolic names; glyph reads hand back colors as hex strings.
- **Overload coverage is uneven.** `say` has a duration overload, `log` does not; `delay` has int and
@@ -384,22 +419,17 @@ they are shape problems.
### Unwieldy — machinery that leaks
- **Queue introspection is stringly-typed.** `peek`/`pop` return untyped maps keyed by a `"type"`
string, and `Scroll`/`Shift` aren't introspectable at all (type only). There is no typed action
value.
- **The two-tier queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
get wrong; the object-queue → board-queue promotion plus per-object ready timer is a lot of
get wrong; the object-queue → board-queue promotion plus per-object delay draining is a lot of
implicit state to reason about for "do X, wait, do Y".
- **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs;
cross-board state must pick one.
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
key is silently also a function name. Renaming a handler breaks callers with no signal.
- **Engine internals leak into the script API.** `combinable(x1,y1,x2,y2)` encodes a very specific
rule (a grab thing may share a cell with the player) that exists only to support `shift`/`swap`
edge cases.
- **Read/write asymmetry on player keys.** `set_key` writes keys, but no read exists — `Player`
exposes `gems`/`health`/`max_health` only (there is a literal `TODO` to expose keys).
- **Doc/name drift across the codebase.** The function is `shift([[x, y], …])`, but the engine's own
module docs and `CLAUDE.md` still describe a `swap([[src_x, src_y, dst_x, dst_y], …])`. The two
concepts (rotate-a-cycle vs. batch directed moves) are conflated under both names.
- **`Registry` alone is a scope constant.** Now that `me`/`state` are parameters, `Registry` is the
one handle with the "invisible inside helper functions" gotcha — an inconsistency with everything
else a hook can reach.
- **Read/write asymmetry on player keys is now closed** (`state.player.keys.*` reads, `set_key`
writes) — but reads are per-color getters while the write is a stringly-named color, so the two
sides still don't mirror each other.
+28 -17
View File
@@ -20,7 +20,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `Me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
@@ -34,7 +34,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained onto the board queue between script calls — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit):
- `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency.
@@ -49,11 +49,11 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
- `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The right gate for a simultaneous shift/rotation via `apply_swap`. The **player** is always a blocker (a shift can't relocate it and `apply_swap` refuses to overwrite it).
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/swapped into the player is treated as an ordinary solid (no special-case). (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.)
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine>`applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged (a grab object is no exception: it is refused like any other solid). Because a refused solid stays at its source cell, which another entry may also target, a final **overlap sweep** over the swapped cells keeps one solid per cell and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn.
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.)
- `apply_shift(cells: &[(i32, i32)]) -> Vec<LogLine>`rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring (the cell that would feed an immobile cell is also held), so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift** (returned as a `LogLine`). The player rotates like any other solid.
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` terrain cell with the scripted object it expands to. For each such cell: vacates the terrain slot (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_<alias>` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom<MapFile> for Board` after cross-layer validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run). Builtin objects get ids after hand-placed ones (layer-then-reading order among themselves).
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
@@ -70,13 +70,14 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
- `Scroll { source: ObjectId, lines: Vec<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `add_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a snapshot of `player` wrapped in a `ScriptState` bundle (read-only, exposed to Rhai as the `Player` constant — see `script.rs`). Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_gems()`/`alter_health()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player.gems`, `AlterHealth(dh)``player.health` clamped to `[0, max_health]`, `Die``remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `add_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_gems()`/`alter_health()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile`/`SetColor` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player.gems`, `AlterHealth(dh)``player.health` clamped to `[0, max_health]`, `SetKey``player.keys`, `Shift``apply_shift`, `Push`/`Teleport` → board moves, `Send``run_send`, `Die``remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
**`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion:
**`kiln-core/src/action.rs`** — the `Action` enum and its supporting types:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
- `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it).
- `Action` (`pub(crate)`)deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `Die` (remove the source object; zero cost).
- `action_to_map(action) -> rhai::Map`converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll`/`Swap` emit `type` only (their payloads are not inspectable via the map API). `Log` is flattened to its first span's text.
- `SendArg`the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
- `Action` (`pub(crate)`)deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never reaches the board queue — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
- `BoardAction { source, action }` — an `Action` promoted onto the board queue, tagged with the `ObjectId` that issued it.
**`kiln-core/src/floor.rs`** — procedural floor generators:
- A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load.
@@ -85,15 +86,25 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/log.rs`** — styled log messages:
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time.
**`kiln-core/src/script.rs`** — Rhai scripting runtime:
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: the key is the object's `script_name` (a world-pool name for a named script, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins, e.g. all pushers, share one AST); the source is the pool entry for that name, or the object's embedded `builtin_script` when present. Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab()` (zero-arg; fired when the player walks onto a `grab` thing — the only grab trigger), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, object_id, bumper)` / `run_grab(state, object_id)` — each takes a `ScriptState(pub Player)` bundle (a `Copy` wrapper that exists so more host context can be threaded through later without re-touching every signature); the host pulls its `Player` out and sets it into the object's scope as the `Player` constant before the call; runtime errors go to the error sink (drained to the log), not fatal.
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. The player's game-global stats are likewise exposed read-only as the `Player` constant (a `Copy` snapshot, carried in by the `ScriptState` bundle and set into scope before each hook call; getters `Player.gems`, `Player.health`, `Player.max_health` — keys not yet exposed), via `register_player_type`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)`, `add_gems(n)` (adjust the player's gem count), `alter_health(dh)` (adjust the player's health, clamped to `[0, max_health]`), `die()` (remove the calling object) append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
- **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta.
**`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md).
- `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), the shared board queue, and the error sink. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (2/3/3/2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, id, bumper)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal.
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
- **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(x, y)`, `push(x, y, dir)`, `shift([[x, y], …])`, `add_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`.
- **Queue draining (two-tier):** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(board_queue, dt)` promotes them onto the shared **board queue** (`Vec<BoardAction>`): it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. `GameState` takes the board queue with `take_board_queue()` and applies it after the batch, so scripts mutate without a `&mut GameState` borrow. Errors bypass the queues via the error sink (`take_errors()`).
- **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`.
- `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap<String, RegistryValue>`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type.
- **Sender identity uses stable ids:** the issuing `ObjectId` rides the per-call **tag**`run_*` calls `call_fn_with_options(...with_tag(id)...)` and the write fns read it via `NativeCallContext::tag()` (decoded by `source_of`; id 0, never valid, is the no-source fallback). It matches `Board::objects`' `BTreeMap` keys, so it survives object spawn/destroy/reorder.
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
- **Sender identity uses stable ids:** `BoardAction.source`, `ObjectRuntime.object_id`, and the `QueueMap` keys are all the object's `ObjectId` (matching `Board::objects`' `BTreeMap` keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an `i64` (id 0 — never valid — is the no-source fallback). `ScriptHost::objects` is still a `Vec<ObjectRuntime>` built by iterating the board map, so it stays in ascending-id order.
**`kiln-core/src/api/`** — the Rhai **script API**: the types scripts actually touch, each implementing `script::Registerable`. Split out of `script.rs` so the host (engine/queues/dispatch) and the script-facing surface can evolve separately. (Author-facing reference: [`docs/script-api.md`](../docs/script-api.md).)
- `api::state``ScriptState { player: PlayerWithPos, board: BoardRef }` (Rhai type **`State`**), the `state` parameter of every hook. `from_game_state` snapshots the player and clones the board handle; getters `state.player` / `state.board`.
- `api::player``PlayerWithPos(Player, PlayerPos)` (Rhai type **`Player`**), a `Copy` bundle of the game-global `Player` stats and the per-board `PlayerPos` so they register as one Rhai object. Getters `gems`, `health`, `max_health`, `keys` (a `Keyring`, exposing one `bool` getter per color), `x`, `y`.
- `api::board``BoardRef = Rc<RefCell<Board>>` (Rhai type **`Board`**), a read-only handle. Getters `width`/`height`; methods `can_push(x, y, dir)`, `passable(x, y)`, `get(id)` (→ `ObjectInfo` or `()`; `id <= 0` logs an error), `named(name)` (→ `ObjectInfo` or `()`), `tagged(tag)` (→ array of `ObjectInfo`).
- `api::object_info``ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(target, dt)` promotes the underlying object's queued actions onto the board queue.
- `api::queue``ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then promote ready actions), `waiting` (front is a `Delay`).
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`):
- `MapFile { map: MapHeader, layers: Vec<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`).
+4 -3
View File
@@ -1,8 +1,9 @@
//! The [`Action`] enum and its Rhai-facing conversions.
//! The [`Action`] enum and supporting types.
//!
//! This module owns the `Action` type (which scripts enqueue via write host
//! functions), the rate-limiting delay constant, and [`action_to_map`] which
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`.
//! functions), the rate-limiting delay constant [`MOVE_COST`], the [`ScrollLine`]
//! and [`SendArg`] payload types, and [`BoardAction`] (an action tagged with the
//! object that issued it).
use std::fmt::Debug;
use crate::log::LogLine;
+1 -1
View File
@@ -1,6 +1,6 @@
//! ## Queue API
//!
//! `queue.length`, `queue.clear()`, `queue.peek()`, `queue.pop()`, `queue.delay()`
//! `queue.length`, `queue.clear()`, `queue.delay()`
use std::cell::RefCell;
use std::collections::VecDeque;
+4 -3
View File
@@ -28,7 +28,7 @@
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
//! `swap([[src_x, src_y, dst_x, dst_y], …])`, `add_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
//! `shift([[x, y], …])`, `add_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
use crate::game::SAY_DURATION;
@@ -691,8 +691,9 @@ fn register_global_constants(engine: &mut Engine) {
engine.register_global_module(m.into());
}
/// Builds a fresh per-object scope containing only the object-specific handles:
/// the Board view, the object's Queue, and the Me self-reference.
/// Builds a fresh per-object scope containing only the `Registry` handle. The
/// per-object `me` (an [`ObjectInfo`]) and `state` (a [`ScriptState`]) views are
/// passed as hook parameters, not scope constants.
fn new_object_scope(board: &BoardRef) -> Scope<'static> {
let mut scope = Scope::new();
scope.push_constant("Registry", Registry { board: board.clone() });
+2 -2
View File
@@ -11,8 +11,8 @@ fn tick(me, state, dt) {
// and is a no-op when blocked; the delay pads each step out to ~0.5s (move
// itself costs 0.25s), matching the old global pusher heartbeat.
//
// The direction is read here, at the top level of the hook, because `Me` is a
// per-object scope constant and is not visible inside other script functions.
// The direction is read from `me` (the hook's first parameter); to use it in a
// helper function you would pass `me` down explicitly.
if !me.waiting {
let dir = if me.has_tag("BUILTIN_pusher_north") { North }
else if me.has_tag("BUILTIN_pusher_south") { South }
+7 -10
View File
@@ -1,13 +1,10 @@
// Rotates this object's 8 neighbours one step around a ring every 0.5s, shoving
// each pushable into the next ring slot via a single simultaneous `swap`. The spin
// direction comes from the `BUILTIN_spinner_*` tag the map loader attaches (it
// defaults to clockwise when no tag is present).
//
// A neighbour may only rotate if its destination slot will actually be free: the
// slot is a hole (`passable`), or that slot's own occupant also rotates out. We
// can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous
// between "j is empty" and "j is a blocked solid" — so we seed with `can_shift`
// and then cascade-demote using `passable` to spot the genuine holes.
// Rotates this object's 8 neighbours one step around a ring every 0.5s via a single
// `shift()` of the ring coordinates. `shift` moves each pushable to the next slot,
// skips non-pushables, and won't move anything into a cell that isn't vacant (or
// vacated by this same rotation) — so the "is the slot free?" decision lives in the
// engine and the script just hands it the ring. The spin direction comes from the
// `BUILTIN_spinner_*` tag the map loader attaches (it defaults to clockwise when no
// tag is present).
fn tick(me, state, dt) {
// Only start a new rotation when the previous one (and its delay) has drained,
// exactly like the built-in pusher's pacing.