This commit is contained in:
2026-06-25 19:16:46 -05:00
parent db8c8e615d
commit db9a5d37b6
8 changed files with 328 additions and 213 deletions
+266 -175
View File
@@ -1,17 +1,25 @@
# Kiln Script API Reference # Kiln Script API Reference
Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map
file. Each scripted object references a script by name via `script_name`. A script may define up to file. Each scripted object references a script by name via `script_name`. A script defines lifecycle
three lifecycle hooks; any or all may be omitted. hooks (any may be omitted) and may define arbitrary handler functions reachable via `send()` and
scroll choices.
> **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
> redesign.
--- ---
## Lifecycle hooks ## 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.
### `fn init()` ### `fn init()`
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.
Use it to set up initial tile appearance, log a startup message, etc.
```rhai ```rhai
fn init() { fn init() {
@@ -22,9 +30,8 @@ fn init() {
### `fn tick(dt)` ### `fn tick(dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Timed
and other timed actions pace themselves through the queue; you do not need to track time manually actions pace themselves through the queue; you do not track time manually for simple walking.
for simple walking patterns.
```rhai ```rhai
fn tick(dt) { fn tick(dt) {
@@ -39,60 +46,155 @@ fn tick(dt) {
Called when another entity walks into this object's cell. Called when another entity walks into this object's cell.
- `id` is the bumper's `ObjectId` (an `i64`). - `id` is the bumper's `ObjectId` (an `i64`).
- `id == -1` means the player bumped this object. - `id == -1` means the **player** bumped this object.
```rhai ```rhai
fn bump(id) { fn bump(id) {
if id == -1 { if id == -1 { say("Hey! Watch it."); }
say("Hey! Watch it."); else { log(`object ${id} bumped me`); }
} else {
log(`object ${id} bumped me`);
}
} }
``` ```
### `fn grab()`
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
player stat and removes the object. It fires followed by an immediate resolve, so any `die()` /
`add_gems()` / `alter_health()` applies before the player's move returns.
```rhai
fn grab() {
add_gems(1);
die();
}
```
> Grab is **only** triggered by the player walking onto the object. A grab thing *pushed* or
> *shifted* into the player is treated as an ordinary solid (no `grab()`).
### Custom handler functions
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.
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.
```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}`); }
```
--- ---
## Constants in scope ## Constants in scope
These are pre-set in every object's scope and cannot be reassigned. 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).
| Name | Type | Description | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| `Board` | `Board` | Read-only handle to the game world. | | `Board` | `Board` | Read-only handle to the current board / world. |
| `Queue` | `Queue` | Handle to this object's own pending-action queue. | | `Queue` | `Queue` | This object's own pending-action queue. |
| `North` | `Direction` | Cardinal direction: up (y 1). | | `Me` | `Me` | Self-reference (id, name, position, tags, glyph). |
| `South` | `Direction` | Cardinal direction: down (y + 1). | | `Registry` | `Registry` | Board-scoped key→value store; persists across board transitions. |
| `East` | `Direction` | Cardinal direction: right (x + 1). | | `Player` | `Player` | Read-only snapshot of the player's game-global stats. |
| `West` | `Direction` | Cardinal direction: left (x 1). | | `North` / `South` / `East` / `West` | `Direction` | Cardinal directions. |
| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. | | `Black` `White` (16) | `String` | EGA/VGA palette colors as `"#RRGGBB"` strings. |
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`.
--- ---
## Read functions ## Self-reference: `Me`
### `Board` getters | 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`). |
Access via `Board.<property>`: ```rhai
if Me.has_tag("locked") { say(`${Me.name} is locked`); }
set_tag(Me.id, "seen", true); // self-mutation still needs the explicit id
```
A `Glyph` value (from `Me.glyph()` or `ObjectInfo` lookups) exposes `.tile` (`i64`), `.fg`, `.bg`
(both `"#RRGGBB"` strings).
---
## Player stats: `Player`
A read-only snapshot of the game-global player state, refreshed before each hook call.
| Property | Type | Description | | Property | Type | Description |
|----------|------|-------------| |----------|------|-------------|
| `Board.player_x` | `i64` | Player's current column (0-indexed). | | `Player.gems` | `i64` | Gems collected. |
| `Board.player_y` | `i64` | Player's current row (0-indexed). | | `Player.health` | `i64` | Current health. |
| `Player.max_health` | `i64` | Health ceiling. |
```rhai
if Player.health < 2 { say("You look hurt."); }
```
> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it).
---
## Board reads
### 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.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. | | `Board.height` | `i64` | Board height in cells. |
### 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`.
```rhai ```rhai
let dist_x = (Board.player_x - 30).abs(); let enemies = Board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);
let door = Board.named("door");
if door != () { send(door.id, "open"); }
``` ```
### `blocked(dir) -> bool` ---
Returns `true` if moving in `dir` from this object's current cell would be impossible — because ## Cell queries (free functions)
the target cell is out of bounds, holds a solid, or another object has already queued a move there.
Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so | Call | Returns | Description |
an object cannot accidentally block itself. |------|---------|-------------|
| `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)? |
```rhai ```rhai
fn tick(dt) { fn tick(dt) {
@@ -100,157 +202,93 @@ fn tick(dt) {
} }
``` ```
### `has_tag(tag) -> bool` ---
Returns `true` if this object has the named tag. ## Registry (shared key→value store)
Board-scoped, persists across board transitions. Stores primitive values.
| Call | Description |
|------|-------------|
| `Registry.get(key)` | Stored value, or `()` if absent. |
| `Registry.set(key, value)` | Store a primitive; passing `()` removes the key; unsupported types are silently ignored. |
| `Registry.get_or(key, default)` | Stored value **only if** it has the same type as `default`; otherwise `default`. |
```rhai ```rhai
if has_tag("enemy") { say("I'm an enemy!"); } let count = Registry.get_or("visits", 0);
``` Registry.set("visits", count + 1);
### `get_tags() -> Array`
Returns an array of strings listing every tag on this object.
```rhai
for t in get_tags() { log(t); }
```
### `objects_with_tag(tag) -> Array`
Returns an array of `i64` object IDs for every object on the board that currently carries `tag`.
```rhai
let enemies = objects_with_tag("enemy");
log(`there are ${enemies.len()} enemies`);
```
### `my_name() -> String`
Returns this object's name string, or `""` if the object has no name.
```rhai
log(`I am ${my_name()}`);
```
### `object_id_for_name(name) -> i64`
Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name.
`0` is never a valid id, so it can be used as a null check.
```rhai
let door_id = object_id_for_name("door");
if door_id != 0 {
set_tag(door_id, "open", true);
}
``` ```
--- ---
## Write functions ## Write functions
Scripts do not mutate the world directly. Each write function appends an action to this object's Scripts never mutate the world directly. Each write appends an `Action` to this object's **output
**output queue**. Actions are drained to the shared board queue between script calls and resolved queue**; actions drain to a shared board queue between script calls and are resolved by the engine
by the engine after the batch. after the batch. The *subject* of a write is implicit and varies by function (see the table).
### `move(dir)` | Call | Subject | Cost | Description |
|------|---------|------|-------------|
| `move(dir)` | self | 0.25 s | Enqueue a one-cell move, plus a rate-limiting delay (~4/s). A blocked move still costs the cooldown. |
| `teleport(x, y)` | self | 0 | Jump to an arbitrary cell. Refused (error logged at resolve) if self is solid and the destination is occupied. |
| `push(x, y, dir)` | arbitrary cell | 0 | Shove the pushable chain at `(x, y)` one step in `dir`. |
| `shift([[x, y], …])` | arbitrary cells | 0 | Rotate cell contents: each listed cell moves to the next coordinate, last→first. Skips non-pushables and won't move into a non-vacated occupied cell. |
| `set_tile(n)` | self | 0 | Set glyph tile index (CP437 code point in the default font). |
| `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. |
| `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). |
| `send(target_id, fn_name [, arg])` | any object | 0 | Call a handler function on another object; optional string/number arg. |
| `delay(secs)` | self | — | Insert an explicit pause; integer and float overloads. Adjacent delays merge. |
| `now()` | self | — | Move the **most recently enqueued** action to the front of the queue. |
| `add_gems(n)` | player | 0 | Change the player's gem count (clamped at 0). |
| `alter_health(dh)` | player | 0 | Change the player's health (clamped to `[0, max_health]`). |
| `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. |
Enqueues a move one cell in `dir`. Also inserts a `Delay` of 0.25 s after the move, limiting this ### `scroll(lines)`
object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown.
```rhai Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element
fn tick(dt) { of `lines` is either:
if !blocked(South) { move(South); }
}
```
### `delay(secs)` - a **string** — a plain text line, or
- a **2-element array** `[choice_key, display_text]` — a selectable choice.
Inserts an explicit pause of `secs` seconds into this object's queue. Adjacent delays are merged When the player selects a choice, `choice_key` is dispatched back to the **source object** as a
(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause zero-arg function call named `choice_key`.
between speech bubbles.
```rhai
fn init() {
say("One…");
delay(2.0);
say("Two!");
now(); // promote the last say() past the delay
}
```
### `now()`
Moves the most recently enqueued action to the **front** of the queue, bypassing any pending
delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even
if a move delay is in progress.
```rhai ```rhai
fn bump(id) { fn bump(id) {
say("Ouch!"); scroll([
now(); // show the bubble right away "The muffin looks delicious.",
"",
["eat", "Eat it"],
["ignore", "Walk away"],
]);
} }
``` fn eat() { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore() { log("Wise."); }
### `set_tile(n)`
Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437
code point when using the default kiln font.
```rhai
fn init() {
set_tile(1); // ☺
}
```
### `log(msg)`
Appends a plain-text line to the game log. The message is a string.
```rhai
log(`player is at ${Board.player_x}, ${Board.player_y}`);
```
### `say(msg)`
Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the
object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with
`now()` to surface it immediately from a `bump` or `init` hook.
```rhai
fn bump(id) {
say("Hey!");
now();
}
```
### `set_tag(target_id, tag, present)`
Adds (`present = true`) or removes (`present = false`) a tag on any object identified by
`target_id`. Use `MY_ID` to mutate the calling object's own tags.
```rhai
// Add a tag to self
set_tag(MY_ID, "activated", true);
// Remove a tag from another object by name
let lever_id = object_id_for_name("lever");
set_tag(lever_id, "pulled", true);
``` ```
--- ---
## `Queue` object ## `Queue` object
Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own Inspection and control of this object's own pending-action queue.
pending-action queue.
| Method | Returns | Description | | Method | Returns | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `Queue.length()` | `i64` | Number of actions currently in the queue. | | `Queue.length()` | `i64` | Number of pending actions. |
| `Queue.clear()` | — | Discard all 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. |
Use `Queue.length() == 0` in `tick` to avoid queuing new moves while a previous one is in flight: `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.
```rhai ```rhai
fn tick(dt) { fn tick(dt) {
@@ -266,18 +304,20 @@ fn tick(dt) {
## Rate limiting and timing ## Rate limiting and timing
- `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max). - `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max).
- `delay(secs)` adds an arbitrary pause. - `delay(secs)` adds an arbitrary pause; `now()` can bypass a delay for a zero-cost action already in
- `now()` can bypass a delay for zero-cost actions already in the queue. the queue.
- `Queue.clear()` cancels everything, including pending delays. - `Queue.clear()` cancels everything, including pending delays.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `Delay(1.0)` entry. - 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.
--- ---
## Error handling ## Error handling
Script errors (compile-time or runtime) are logged to the game log as plain-text lines and do not Script errors (compile-time or runtime) are appended to the game log as plain-text lines and do not
crash the game. A script that throws an exception on `tick` will log the error and resume normally crash the game. A script that throws during `tick` logs the error and resumes on the next frame.
on the next frame.
--- ---
@@ -288,27 +328,78 @@ on the next frame.
fn init() { fn init() {
set_tile(2); set_tile(2);
log(`${my_name()} standing watch`); log(`${Me.name} standing watch`);
} }
fn tick(dt) { fn tick(dt) {
if Queue.length() > 0 { return; } // still moving if Queue.length() > 0 { return; } // still moving
if !blocked(East) { move(East); }
if !blocked(East) { else if !blocked(West) { move(West); }
move(East);
} else if !blocked(West) {
move(West);
}
// Stuck in a corner — do nothing this tick.
} }
fn bump(id) { fn bump(id) {
if id == -1 { if id == -1 { say("Halt! Who goes there?"); now(); }
say("Halt! Who goes there?"); else { say("Watch it!"); now(); }
now();
} else {
say("Watch where you're going!");
now();
}
} }
``` ```
---
## Design notes (rough edges)
Collected friction points in the current surface, as input to a redesign. None of these are bugs;
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.
### Inconsistency — naming and conventions
- **No consistent verb convention.** Setters by name (`set_tile`, `set_tag`, `set_fg`, `set_bg`,
`set_color`, `set_key`), delta verbs (`add_gems`, `alter_health`), and bare verbs (`move`, `die`,
`teleport`, `push`, `shift`, `scroll`, `say`, `log`) all coexist. Worse, the two "change by a
delta" operations use *different* verbs: `add_gems` vs `alter_health`.
- **The implicit subject of a write is unpredictable.** `set_tile`/`set_fg`/`say`/`die` act on
**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(…)`.
- **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
float overloads, most numeric fns don't.
### 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
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.
+12 -5
View File
@@ -30,7 +30,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag). - `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag).
- `ObjectId = u32` — stable identifier for board objects. - `ObjectId = u32` — stable identifier for board objects.
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`. - `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
- `Player { x: i32, y: i32 }`current player position. - `PlayerPos { x: i32, y: i32 }`the player's current position on a board (held as `Board::player`). The broader player *state* (health, gems, keys) lives in [`player::Player`], not here.
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired. - `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)`): **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
@@ -59,16 +59,23 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
**`kiln-core/src/player.rs`** — game-global player state (`pub`):
- `Player { health: i64, max_health: i64, keys: Keyring, gems: i64 }` — the player's persistent stats, owned by `GameState::player` (separate from the per-board `PlayerPos` position in `utils.rs`). `Default` starts `health = max_health = 5`, `gems = 0`, no keys. `alter_gems(delta) -> bool` adds to `gems` but refuses (returns `false`, no change) if it would go negative. `alter_health(delta)` adds to `health` clamped to `[0, max_health]`. A `Copy` snapshot is handed to each script hook and exposed to Rhai (read-only) as the `Player` constant.
**`kiln-core/src/keys.rs`** — key inventory and colors (`pub`):
- `KeyType` (`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Cyan`/`Purple`/`White`) — the eight key colors. `glyph() -> Glyph` returns the key tile (12, `♀`) in that color's fg — the single source of truth for key colors, used by `Keyring::colors` and by kiln-tui's editor menu. (Moved here from `game.rs`; `archetype.rs`'s `Key` builtin aliases must match these glyphs — see its `key_aliases_have_distinct_fg_colors` test.)
- `Keyring { red, orange, yellow, green, blue, cyan, purple, white: bool }` — the player's key inventory (one bool per color), held by `Player::keys`. `set_by_name(name, value) -> bool` sets a slot by color name (returns `false` for an unknown name); `colors() -> [(bool, Rgba8); 8]` lists all eight in display order paired with their render color.
**`kiln-core/src/game.rs`** — game-loop logic only: **`kiln-core/src/game.rs`** — game-loop logic only:
- `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). - `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). - `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`. - `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>`, `pub player_health: u32` (starts 5), `pub max_health: u32` (default 5), and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions. `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). 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 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.
**`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion: **`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). - `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). - `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` (`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. - `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.
**`kiln-core/src/floor.rs`** — procedural floor generators: **`kiln-core/src/floor.rs`** — procedural floor generators:
@@ -80,8 +87,8 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/script.rs`** — Rhai scripting runtime: **`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. - `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()` / `run_tick(dt)` / `run_bump(object_id, bumper)` / `run_grab(object_id)`; runtime errors go to the error sink (drained to the log), not fatal. - 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`. `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`. - **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()`). - **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`). - 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. - **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.
+3 -3
View File
@@ -77,16 +77,16 @@ pub(crate) enum Action {
/// Zero time cost. /// Zero time cost.
Shift(Vec<(i32, i32)>), Shift(Vec<(i32, i32)>),
/// Add `n` to the player's gem count (negative subtracts; the count is /// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player_gems`. /// clamped at 0). Zero time cost. Applied to `GameState::player.gems`.
AddGems(i64), AddGems(i64),
/// Add `dh` to the player's health (clamped to `[0, max_health]`). Zero time /// Add `dh` to the player's health (clamped to `[0, max_health]`). Zero time
/// cost. Applied to `GameState::player_health`. /// cost. Applied to `GameState::player.health`.
AlterHealth(i64), AlterHealth(i64),
/// Give (`true`) or take (`false`) the named key color from the player. /// Give (`true`) or take (`false`) the named key color from the player.
/// ///
/// Color must be one of `"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, /// Color must be one of `"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`,
/// `"orange"`, `"yellow"`, `"white"`. An unrecognized name is logged and /// `"orange"`, `"yellow"`, `"white"`. An unrecognized name is logged and
/// ignored. Zero time cost. Applied to `GameState::player_keys`. /// ignored. Zero time cost. Applied to `GameState::player.keys`.
SetKey(String, bool), SetKey(String, bool),
/// Remove the source object from the board. Zero time cost. Used by grab /// Remove the source object from the board. Zero time cost. Used by grab
/// things (e.g. gems) to despawn themselves from their `grab()` hook. /// things (e.g. gems) to despawn themselves from their `grab()` hook.
+2 -1
View File
@@ -43,7 +43,8 @@ pub struct Board {
/// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single /// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`. /// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
pub(crate) layers: Vec<Layer>, pub(crate) layers: Vec<Layer>,
/// Current player position. See [`Player`] for caveats about its future. /// Current player position on this board. See [`PlayerPos`] for caveats
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
pub player: PlayerPos, pub player: PlayerPos,
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap` /// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
/// (not a `Vec`) so an object can be removed without invalidating other /// (not a `Vec`) so an object can be removed without invalidating other
+15 -12
View File
@@ -1,7 +1,7 @@
use crate::action::Action; use crate::action::Action;
use crate::board::Board; use crate::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::ScriptHost; use crate::script::{ScriptHost, ScriptState};
use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg}; use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg};
use crate::world::World; use crate::world::World;
use std::cell::{Ref, RefMut}; use std::cell::{Ref, RefMut};
@@ -67,7 +67,10 @@ pub struct GameState {
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`. /// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>, pub board_transition: Option<f64>,
/// The player's state /// The game-global player state (health, gems, keys) — see [`Player`]. Not
/// per-board: it persists across board transitions, unlike the per-board
/// position in [`Board::player`](crate::board::Board::player). Scripts mutate
/// it via `add_gems`/`alter_health`/`set_key` and read a snapshot of it.
pub player: Player, pub player: Player,
} }
@@ -159,7 +162,7 @@ impl GameState {
/// the game is about to start — never during map deserialization, since a script /// the game is about to start — never during map deserialization, since a script
/// may inspect the board. /// may inspect the board.
pub fn run_init(&mut self) { pub fn run_init(&mut self) {
self.scripts.run_init(self.player); self.scripts.run_init(ScriptState(self.player));
self.resolve(); self.resolve();
} }
@@ -173,7 +176,7 @@ impl GameState {
// this runs exactly once per player interaction with a scroll. // this runs exactly once per player interaction with a scroll.
self.handle_scroll(); self.handle_scroll();
let secs = dt.as_secs_f64(); let secs = dt.as_secs_f64();
self.scripts.run_tick(self.player, secs); self.scripts.run_tick(ScriptState(self.player), secs);
// Expire speech bubbles before resolving new actions so a fresh say() // Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled. // this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| { self.speech_bubbles.retain_mut(|b| {
@@ -308,11 +311,11 @@ impl GameState {
Action::Shift(cells) => { Action::Shift(cells) => {
logs.extend(board.apply_shift(&cells)); logs.extend(board.apply_shift(&cells));
} }
// Accumulated and applied to `self.player_gems` after the borrow drops. // Accumulated and applied to `self.player.gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n, Action::AddGems(n) => gem_delta += n,
// Accumulated and applied to `self.player_health` after the borrow drops. // Accumulated and applied to `self.player.health` after the borrow drops.
Action::AlterHealth(dh) => health_delta += dh, Action::AlterHealth(dh) => health_delta += dh,
// Collected and applied to `self.player_keys` after the borrow drops. // Collected and applied to `self.player.keys` after the borrow drops.
Action::SetKey(color, present) => key_changes.push((color, present)), Action::SetKey(color, present) => key_changes.push((color, present)),
// A grab thing despawns itself from its grab() hook. // A grab thing despawns itself from its grab() hook.
Action::Die => { Action::Die => {
@@ -345,10 +348,10 @@ impl GameState {
} }
} }
for (bumped, bumper) in bumps { for (bumped, bumper) in bumps {
self.scripts.run_bump(self.player, bumped, bumper); self.scripts.run_bump(ScriptState(self.player), bumped, bumper);
} }
for (target, fn_name, arg) in sends { for (target, fn_name, arg) in sends {
self.scripts.run_send(self.player, target, &fn_name, arg); self.scripts.run_send(ScriptState(self.player), target, &fn_name, arg);
} }
self.drain_errors(); self.drain_errors();
} }
@@ -364,7 +367,7 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take() if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice && let Some(choice) = scroll.choice
{ {
self.scripts.run_send(self.player, scroll.source, &choice, None); self.scripts.run_send(ScriptState(self.player), scroll.source, &choice, None);
self.drain_errors(); self.drain_errors();
} }
} }
@@ -471,11 +474,11 @@ impl GameState {
// Fire the grab hook and resolve it immediately so the grabbed thing's // Fire the grab hook and resolve it immediately so the grabbed thing's
// die()/add_gems() apply now — no player+object overlap survives this call. // die()/add_gems() apply now — no player+object overlap survives this call.
if let Some(id) = grabbed { if let Some(id) = grabbed {
self.scripts.run_grab(self.player, id); self.scripts.run_grab(ScriptState(self.player), id);
self.resolve(); self.resolve();
} }
if let Some(idx) = bumped { if let Some(idx) = bumped {
self.scripts.run_bump(self.player, idx, -1); self.scripts.run_bump(ScriptState(self.player), idx, -1);
self.drain_errors(); self.drain_errors();
} }
} }
+8 -1
View File
@@ -1,5 +1,12 @@
use crate::keys::Keyring; use crate::keys::Keyring;
/// The game-global player state: stats that follow the player across boards.
///
/// Owned by [`GameState::player`](crate::game::GameState) (a single value, not
/// per-board), so health, gems, and keys persist through board transitions.
/// Distinct from [`PlayerPos`](crate::utils::PlayerPos), which is the player's
/// position *on a particular board*. A `Copy` snapshot is handed to each script
/// hook and exposed to Rhai (read-only) as the `Player` constant.
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct Player { pub struct Player {
/// The player's current health. Game-global (not per-board), so it persists /// The player's current health. Game-global (not per-board), so it persists
@@ -15,7 +22,7 @@ pub struct Player {
pub keys: Keyring, pub keys: Keyring,
/// The number of gems the player has collected. Game-global like /// The number of gems the player has collected. Game-global like
/// [`player_health`](GameState::player_health); starts at `0`. /// [`health`](Player::health); starts at `0`.
pub gems: i64, pub gems: i64,
} }
+18 -12
View File
@@ -81,6 +81,12 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc; use std::rc::Rc;
use crate::player::Player; use crate::player::Player;
/// The host-provided context handed to every script hook for the duration of one
/// call. Currently just the player snapshot, but it exists so more host state can
/// be threaded through the `run_*` methods without changing each signature again.
#[derive(Copy, Clone)]
pub struct ScriptState(pub Player);
/// An action promoted from an object's output queue onto the board queue, tagged /// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it. /// with the object that issued it.
pub(crate) struct BoardAction { pub(crate) struct BoardAction {
@@ -318,18 +324,18 @@ impl ScriptHost {
} }
/// Calls `init()` on every scripted object that defines it and drains each queue. /// Calls `init()` on every scripted object that defines it and drains each queue.
pub fn run_init(&mut self, player: Player) { pub fn run_init(&mut self, state: ScriptState) {
self.run("init", |c| c.has_init, (), 0.0, player); self.run("init", |c| c.has_init, (), 0.0, state);
} }
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues. /// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
pub fn run_tick(&mut self, player: Player, dt: f64) { pub fn run_tick(&mut self, state: ScriptState, dt: f64) {
self.run("tick", |c| c.has_tick, (dt,), dt, player); self.run("tick", |c| c.has_tick, (dt,), dt, state);
} }
/// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines /// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines
/// the hook. After the hook, drains the object's queue with `dt = 0`. /// the hook. After the hook, drains the object's queue with `dt = 0`.
pub fn run_bump(&mut self, player: Player, object_id: ObjectId, bumper: i64) { pub fn run_bump(&mut self, state: ScriptState, object_id: ObjectId, bumper: i64) {
let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else { let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else {
return; return;
}; };
@@ -349,7 +355,7 @@ impl ScriptHost {
return; return;
} }
let options = CallFnOptions::default().with_tag(object_id as i64); let options = CallFnOptions::default().with_tag(object_id as i64);
obj.scope.set_or_push("Player", player); obj.scope.set_or_push("Player", state.0);
if let Err(err) = engine.call_fn_with_options::<()>( if let Err(err) = engine.call_fn_with_options::<()>(
options, options,
&mut obj.scope, &mut obj.scope,
@@ -372,7 +378,7 @@ impl ScriptHost {
/// Fired when the player walks onto a grab object or a grab object is pushed /// Fired when the player walks onto a grab object or a grab object is pushed
/// into the player (see [`GameState`](crate::game::GameState)). The hook /// into the player (see [`GameState`](crate::game::GameState)). The hook
/// typically increments a player stat and removes the object via `die()`. /// typically increments a player stat and removes the object via `die()`.
pub fn run_grab(&mut self, player: Player, object_id: ObjectId) { pub fn run_grab(&mut self, state: ScriptState, object_id: ObjectId) {
let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else { let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else {
return; return;
}; };
@@ -392,7 +398,7 @@ impl ScriptHost {
return; return;
} }
let options = CallFnOptions::default().with_tag(object_id as i64); let options = CallFnOptions::default().with_tag(object_id as i64);
obj.scope.set_or_push("Player", player); obj.scope.set_or_push("Player", state.0);
if let Err(err) = engine.call_fn_with_options::<()>( if let Err(err) = engine.call_fn_with_options::<()>(
options, options,
&mut obj.scope, &mut obj.scope,
@@ -415,7 +421,7 @@ impl ScriptHost {
/// If the function accepts zero parameters (or arg is `None`), it is called with /// If the function accepts zero parameters (or arg is `None`), it is called with
/// no args. If neither arity exists, the call is silently skipped. /// no args. If neither arity exists, the call is silently skipped.
/// After the call, drains the object's queue with `dt = 0`. /// After the call, drains the object's queue with `dt = 0`.
pub(crate) fn run_send(&mut self, player: Player, target_id: ObjectId, fn_name: &str, arg: Option<ScriptArg>) { pub(crate) fn run_send(&mut self, state: ScriptState, target_id: ObjectId, fn_name: &str, arg: Option<ScriptArg>) {
let Some(i) = self.objects.iter().position(|o| o.object_id == target_id) else { let Some(i) = self.objects.iter().position(|o| o.object_id == target_id) else {
return; return;
}; };
@@ -442,7 +448,7 @@ impl ScriptHost {
.any(|f| f.name == fn_name && f.params.is_empty()); .any(|f| f.name == fn_name && f.params.is_empty());
let options = CallFnOptions::default().with_tag(obj.object_id as i64); let options = CallFnOptions::default().with_tag(obj.object_id as i64);
obj.scope.set_or_push("Player", player); obj.scope.set_or_push("Player", state.0);
let result = if has_1 { let result = if has_1 {
// Call with arg (or unit if arg is absent). // Call with arg (or unit if arg is absent).
let dyn_arg: Dynamic = match &arg { let dyn_arg: Dynamic = match &arg {
@@ -530,7 +536,7 @@ impl ScriptHost {
defined: fn(&CompiledScript) -> bool, defined: fn(&CompiledScript) -> bool,
args: A, args: A,
drain_dt: f64, drain_dt: f64,
player: Player state: ScriptState,
) { ) {
for i in 0..self.objects.len() { for i in 0..self.objects.len() {
{ {
@@ -546,7 +552,7 @@ impl ScriptHost {
&& defined(compiled) && defined(compiled)
{ {
let options = CallFnOptions::default().with_tag(obj.object_id as i64); let options = CallFnOptions::default().with_tag(obj.object_id as i64);
obj.scope.set_or_push("Player", player); obj.scope.set_or_push("Player", state.0);
if let Err(err) = engine.call_fn_with_options::<()>( if let Err(err) = engine.call_fn_with_options::<()>(
options, options,
&mut obj.scope, &mut obj.scope,
+1 -1
View File
@@ -61,7 +61,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
- `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement. - `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement.
**`kiln-tui/src/status.rs`** — play-mode status sidebar widget: **`kiln-tui/src/status.rs`** — play-mode status sidebar widget:
- `StatusSidebarWidget` — a `ratatui::widgets::Widget` built with `new(health, gems)` that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `MAX_HEARTS` (5, local constant; the game ceiling is `GameState::max_health`) `♥` glyphs (first `health` bright red, rest dark red) and a `Gems: ♦ N` line. The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it. - `StatusSidebarWidget(pub Player)` — a `ratatui::widgets::Widget` wrapping a `kiln_core::player::Player` snapshot that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `player.max_health` `♥` glyphs (first `player.health` bright red, rest dark red), a `Gems: ♦ N` line, and a `Keys:` row of 8 colored key glyphs (colored when held, near-black when absent, via `player.keys.colors()`). The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it.
**`kiln-tui/src/log.rs`** — log panel widget: **`kiln-tui/src/log.rs`** — log panel widget:
- `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`. - `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`.