diff --git a/docs/script-api.md b/docs/script-api.md index 045057f..c8a82f6 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -1,17 +1,25 @@ # Kiln Script API Reference Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map -file. Each scripted object references a script by name via `script_name`. A script may define up to -three lifecycle hooks; any or all may be omitted. +file. Each scripted object references a script by name via `script_name`. A script defines lifecycle +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 +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()` Called once for every scripted object after the entire map is loaded, before the first frame. -Use it to set up initial tile appearance, log a startup message, etc. ```rhai fn init() { @@ -22,9 +30,8 @@ fn init() { ### `fn tick(dt)` -Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement -and other timed actions pace themselves through the queue; you do not need to track time manually -for simple walking patterns. +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) { @@ -39,60 +46,155 @@ fn tick(dt) { Called when another entity walks into this object's cell. - `id` is the bumper's `ObjectId` (an `i64`). -- `id == -1` means the player bumped this object. +- `id == -1` means the **player** bumped this object. ```rhai fn bump(id) { - if id == -1 { - say("Hey! Watch it."); - } else { - log(`object ${id} bumped me`); - } + if id == -1 { say("Hey! Watch it."); } + 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 -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 | |------|------|-------------| -| `Board` | `Board` | Read-only handle to the game world. | -| `Queue` | `Queue` | Handle to this object's own pending-action queue. | -| `North` | `Direction` | Cardinal direction: up (y − 1). | -| `South` | `Direction` | Cardinal direction: down (y + 1). | -| `East` | `Direction` | Cardinal direction: right (x + 1). | -| `West` | `Direction` | Cardinal direction: left (x − 1). | -| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. | +| `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. | + +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.`: +```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 | |----------|------|-------------| -| `Board.player_x` | `i64` | Player's current column (0-indexed). | -| `Board.player_y` | `i64` | Player's current row (0-indexed). | -| `Board.width` | `i64` | Board width in cells. | -| `Board.height` | `i64` | Board height in cells. | +| `Player.gems` | `i64` | Gems collected. | +| `Player.health` | `i64` | Current health. | +| `Player.max_health` | `i64` | Health ceiling. | ```rhai -let dist_x = (Board.player_x - 30).abs(); +if Player.health < 2 { say("You look hurt."); } ``` -### `blocked(dir) -> bool` +> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it). -Returns `true` if moving in `dir` from this object's current cell would be impossible — because -the target cell is out of bounds, holds a solid, or another object has already queued a move there. +--- -Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so -an object cannot accidentally block itself. +## 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.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 +let enemies = Board.tagged("enemy"); +log(`there are ${enemies.len()} enemies`); + +let door = Board.named("door"); +if door != () { send(door.id, "open"); } +``` + +--- + +## Cell queries (free functions) + +| 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)? | ```rhai 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 -if has_tag("enemy") { say("I'm an enemy!"); } -``` - -### `get_tags() -> Array` - -Returns an array of strings listing every tag on this object. - -```rhai -for t in get_tags() { log(t); } -``` - -### `objects_with_tag(tag) -> Array` - -Returns an array of `i64` object IDs for every object on the board that currently carries `tag`. - -```rhai -let enemies = objects_with_tag("enemy"); -log(`there are ${enemies.len()} enemies`); -``` - -### `my_name() -> String` - -Returns this object's name string, or `""` if the object has no name. - -```rhai -log(`I am ${my_name()}`); -``` - -### `object_id_for_name(name) -> i64` - -Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name. -`0` is never a valid id, so it can be used as a null check. - -```rhai -let door_id = object_id_for_name("door"); -if door_id != 0 { - set_tag(door_id, "open", true); -} +let count = Registry.get_or("visits", 0); +Registry.set("visits", count + 1); ``` --- ## Write functions -Scripts do not mutate the world directly. Each write function appends an action to this object's -**output queue**. Actions are drained to the shared board queue between script calls and resolved -by the engine after the batch. +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). -### `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 -object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown. +### `scroll(lines)` -```rhai -fn tick(dt) { - if !blocked(South) { move(South); } -} -``` +Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element +of `lines` is either: -### `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 -(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause -between speech bubbles. - -```rhai -fn init() { - say("One…"); - delay(2.0); - say("Two!"); - now(); // promote the last say() past the delay -} -``` - -### `now()` - -Moves the most recently enqueued action to the **front** of the queue, bypassing any pending -delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even -if a move delay is in progress. +When the player selects a choice, `choice_key` is dispatched back to the **source object** as a +zero-arg function call named `choice_key`. ```rhai fn bump(id) { - say("Ouch!"); - now(); // show the bubble right away + scroll([ + "The muffin looks delicious.", + "", + ["eat", "Eat it"], + ["ignore", "Walk away"], + ]); } -``` - -### `set_tile(n)` - -Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437 -code point when using the default kiln font. - -```rhai -fn init() { - set_tile(1); // ☺ -} -``` - -### `log(msg)` - -Appends a plain-text line to the game log. The message is a string. - -```rhai -log(`player is at ${Board.player_x}, ${Board.player_y}`); -``` - -### `say(msg)` - -Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the -object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with -`now()` to surface it immediately from a `bump` or `init` hook. - -```rhai -fn bump(id) { - say("Hey!"); - now(); -} -``` - -### `set_tag(target_id, tag, present)` - -Adds (`present = true`) or removes (`present = false`) a tag on any object identified by -`target_id`. Use `MY_ID` to mutate the calling object's own tags. - -```rhai -// Add a tag to self -set_tag(MY_ID, "activated", true); - -// Remove a tag from another object by name -let lever_id = object_id_for_name("lever"); -set_tag(lever_id, "pulled", true); +fn eat() { say("Yeah it was poisoned."); alter_health(-2); } +fn ignore() { log("Wise."); } ``` --- ## `Queue` object -Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own -pending-action queue. +Inspection and control of this object's own pending-action queue. | Method | Returns | Description | |--------|---------|-------------| -| `Queue.length()` | `i64` | Number of actions currently in the queue. | -| `Queue.clear()` | — | Discard all pending actions. | +| `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. | -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 fn tick(dt) { @@ -266,18 +304,20 @@ fn tick(dt) { ## Rate limiting and timing - `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max). -- `delay(secs)` adds an arbitrary pause. -- `now()` can bypass a delay for zero-cost actions already in the queue. +- `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. -- 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 -Script errors (compile-time or runtime) are logged to the game log as plain-text lines and do not -crash the game. A script that throws an exception on `tick` will log the error and resume normally -on the next frame. +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 during `tick` logs the error and resumes on the next frame. --- @@ -288,27 +328,78 @@ on the next frame. fn init() { set_tile(2); - log(`${my_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); - } - // Stuck in a corner — do nothing this tick. + if Queue.length() > 0 { return; } // still moving + if !blocked(East) { move(East); } + else if !blocked(West) { move(West); } } fn bump(id) { - if id == -1 { - say("Halt! Who goes there?"); - now(); - } else { - say("Watch where you're going!"); - now(); - } + if id == -1 { say("Halt! Who goes there?"); now(); } + else { say("Watch it!"); 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. diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index f7f8c51..b8b8c6e 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -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). - `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)`. -- `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. **`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. - `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: - `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 }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option`; 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>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, `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_mut() -> RefMut` (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>` 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>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, 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_mut() -> RefMut` (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>` 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: - `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)`, `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)`, `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. **`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: - `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>, &HashMap)`: 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. -- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc>` (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`. +- 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>` (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`). `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`): 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 for (i32,i32)` gives the delta. diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 0dad09f..ec6a89a 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -77,16 +77,16 @@ pub(crate) enum Action { /// Zero time cost. Shift(Vec<(i32, i32)>), /// 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), /// 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), /// Give (`true`) or take (`false`) the named key color from the player. /// /// Color must be one of `"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, /// `"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), /// Remove the source object from the board. Zero time cost. Used by grab /// things (e.g. gems) to despawn themselves from their `grab()` hook. diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 6ea70b0..735e011 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -43,7 +43,8 @@ pub struct Board { /// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single /// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`. pub(crate) layers: Vec, - /// 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, /// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap` /// (not a `Vec`) so an object can be removed without invalidating other diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 84166dc..7bb530b 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,7 +1,7 @@ use crate::action::Action; use crate::board::Board; use crate::log::LogLine; -use crate::script::ScriptHost; +use crate::script::{ScriptHost, ScriptState}; use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg}; use crate::world::World; use std::cell::{Ref, RefMut}; @@ -67,7 +67,10 @@ pub struct GameState { /// 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`. pub board_transition: Option, - /// 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, } @@ -159,7 +162,7 @@ impl GameState { /// the game is about to start — never during map deserialization, since a script /// may inspect the board. pub fn run_init(&mut self) { - self.scripts.run_init(self.player); + self.scripts.run_init(ScriptState(self.player)); self.resolve(); } @@ -173,7 +176,7 @@ impl GameState { // this runs exactly once per player interaction with a scroll. self.handle_scroll(); 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() // this frame isn't immediately culled. self.speech_bubbles.retain_mut(|b| { @@ -308,11 +311,11 @@ impl GameState { Action::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, - // 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, - // 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)), // A grab thing despawns itself from its grab() hook. Action::Die => { @@ -345,10 +348,10 @@ impl GameState { } } 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 { - self.scripts.run_send(self.player, target, &fn_name, arg); + self.scripts.run_send(ScriptState(self.player), target, &fn_name, arg); } self.drain_errors(); } @@ -364,7 +367,7 @@ impl GameState { if let Some(scroll) = self.active_scroll.take() && 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(); } } @@ -471,11 +474,11 @@ impl GameState { // 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. if let Some(id) = grabbed { - self.scripts.run_grab(self.player, id); + self.scripts.run_grab(ScriptState(self.player), id); self.resolve(); } 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(); } } diff --git a/kiln-core/src/player.rs b/kiln-core/src/player.rs index 71a0bf4..66e725e 100644 --- a/kiln-core/src/player.rs +++ b/kiln-core/src/player.rs @@ -1,5 +1,12 @@ 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)] pub struct Player { /// The player's current health. Game-global (not per-board), so it persists @@ -15,7 +22,7 @@ pub struct Player { pub keys: Keyring, /// 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, } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 4f77aab..8036cad 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -81,6 +81,12 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; 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 /// with the object that issued it. pub(crate) struct BoardAction { @@ -318,18 +324,18 @@ impl ScriptHost { } /// Calls `init()` on every scripted object that defines it and drains each queue. - pub fn run_init(&mut self, player: Player) { - self.run("init", |c| c.has_init, (), 0.0, player); + pub fn run_init(&mut self, state: ScriptState) { + self.run("init", |c| c.has_init, (), 0.0, state); } /// Calls `tick(dt)` on every scripted object that defines it, then drains queues. - pub fn run_tick(&mut self, player: Player, dt: f64) { - self.run("tick", |c| c.has_tick, (dt,), dt, player); + pub fn run_tick(&mut self, state: ScriptState, dt: f64) { + self.run("tick", |c| c.has_tick, (dt,), dt, state); } /// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines /// the hook. After the hook, drains the object's queue with `dt = 0`. - pub fn run_bump(&mut self, 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 { return; }; @@ -349,7 +355,7 @@ impl ScriptHost { return; } 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::<()>( options, &mut obj.scope, @@ -372,7 +378,7 @@ impl ScriptHost { /// 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 /// 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 { return; }; @@ -392,7 +398,7 @@ impl ScriptHost { return; } 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::<()>( options, &mut obj.scope, @@ -415,7 +421,7 @@ impl ScriptHost { /// If the function accepts zero parameters (or arg is `None`), it is called with /// no args. If neither arity exists, the call is silently skipped. /// After the call, drains the object's queue with `dt = 0`. - pub(crate) fn run_send(&mut self, player: Player, target_id: ObjectId, fn_name: &str, arg: Option) { + pub(crate) fn run_send(&mut self, state: ScriptState, target_id: ObjectId, fn_name: &str, arg: Option) { let Some(i) = self.objects.iter().position(|o| o.object_id == target_id) else { return; }; @@ -442,7 +448,7 @@ impl ScriptHost { .any(|f| f.name == fn_name && f.params.is_empty()); 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 { // Call with arg (or unit if arg is absent). let dyn_arg: Dynamic = match &arg { @@ -530,7 +536,7 @@ impl ScriptHost { defined: fn(&CompiledScript) -> bool, args: A, drain_dt: f64, - player: Player + state: ScriptState, ) { for i in 0..self.objects.len() { { @@ -546,7 +552,7 @@ impl ScriptHost { && defined(compiled) { 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::<()>( options, &mut obj.scope, diff --git a/kiln-tui/CLAUDE.md b/kiln-tui/CLAUDE.md index 5425cc1..f6201b0 100644 --- a/kiln-tui/CLAUDE.md +++ b/kiln-tui/CLAUDE.md @@ -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. **`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: - `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`.