Fixing tests and comments
This commit is contained in:
@@ -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
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user