basic scripting
This commit is contained in:
+39
-12
@@ -29,6 +29,8 @@ Cargo **workspace**; root `members = ["kiln-core", "kiln-tui"]`. `kiln-egui` sta
|
||||
```
|
||||
kiln-core/src/ — the engine (UI-agnostic)
|
||||
game.rs — core data types and game logic
|
||||
log.rs — styled, UI-agnostic log messages (LogLine/LogSpan)
|
||||
script.rs — Rhai scripting runtime (ScriptHost); object init/tick hooks
|
||||
map_file.rs — TOML deserialization, Board load/save
|
||||
kiln-tui/src/ — terminal player (no editor)
|
||||
main.rs — CLI arg + play loop; TerminalCaps detection; Kitty flags
|
||||
@@ -76,8 +78,9 @@ In ZZT, each board tile had both a visual (character + color pair) and an elemen
|
||||
The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds:
|
||||
- `cells: Vec<(Glyph, Archetype)>` — row-major grid; each cell directly owns its visual and behavioral class
|
||||
- `player: Player` — current player position on this board
|
||||
- `objects: Vec<ObjectDef>` — scripted objects (parsed; scripts not yet runtime-wired, but `passable`/`opaque` are live and affect collision)
|
||||
- `objects: Vec<ObjectDef>` — scripted objects; their `init()`/`tick(dt)` hooks run via the scripting runtime, and `passable`/`opaque` affect collision
|
||||
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
|
||||
- `scripts: HashMap<String, String>` — named Rhai source, referenced by objects via `script_name`
|
||||
- `font: Option<FontSpec>` — per-board font override; `None` means use the app default
|
||||
|
||||
**`ObjectDef`**
|
||||
@@ -90,7 +93,21 @@ An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a p
|
||||
An earlier design had `GameMap { board: Board, player: Player, ... }`. This was eliminated because the split was artificial: there's no meaningful use of a `Board` without a player position, and no meaningful use of a player without a `Board`. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model.
|
||||
|
||||
**`GameState`**
|
||||
Currently a thin wrapper around `Board` that provides game-logic methods (`try_move`). It exists to keep mutation logic (collision checking, movement) separate from the data. As the game grows, event processing and scripting dispatch will live here.
|
||||
Owns the `Board`, the message `log: Vec<LogLine>`, and a `ScriptHost`. It keeps mutation logic (collision checking, movement) separate from the data and is where event processing/scripting dispatch lives: `try_move` for movement, `run_init()` to run object `init()` hooks once at startup, and `tick(dt)` to run object `tick(dt)` hooks every frame. After each script batch it flushes script-emitted log lines into `log`.
|
||||
|
||||
---
|
||||
|
||||
### `log.rs` — styled messages
|
||||
|
||||
`LogLine { spans: Vec<LogSpan> }`, where `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }`, is a **UI-agnostic** styled message: colors are core `Rgba8`, not a front-end type, so the engine stays UI-free. `LogLine::raw()`, a chainable `push()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time (kiln-tui's `render::logline_to_line`). The game log lives on `GameState`; both the engine (scripts) and front-ends append to it.
|
||||
|
||||
### `script.rs` — Rhai scripting runtime
|
||||
|
||||
`ScriptHost` owns the Rhai `Engine`, the scripts referenced by a board's objects (compiled once per name via `Engine::compile`), a persistent per-object `Scope`, and a shared **log sink**. Built with `ScriptHost::new(&board)`, which compiles scripts and records each object's available hooks (detected with `AST::iter_functions()` by name/arity) but **runs nothing** — compile errors and unknown-script references are reported into the log.
|
||||
|
||||
Two optional lifecycle hooks per object: `init()` (zero-arg, run once) and `tick(dt)` (elapsed seconds as `f64`, run per frame), driven by `run_init()` / `run_tick(dt)`. Runtime errors are logged, not fatal.
|
||||
|
||||
The one host function so far is `log(s)`, which appends an uncolored `LogLine`. The borrow problem — Rhai's registered closures must be `'static`, yet `log` must reach the game log — is solved by writing into an `Rc<RefCell<Vec<LogLine>>>` sink cloned into the closure; `GameState` drains it with `take_pending()` after each batch, so running scripts never needs a mutable borrow of `GameState`. (Default `call_fn` rewinds the scope per call, so script-local persistence across ticks is future work; the per-object `Scope` is the seam for that and for injecting object state.) `Engine`/`Scope` are not `Send` — fine for the single-threaded kiln-tui.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,11 +124,11 @@ A `ratatui::widgets::Widget` that draws the board into the frame buffer. The `ax
|
||||
**`term.rs` — capability detection + Kitty protocol.**
|
||||
`TerminalCaps { keyboard_enhancement, truecolor }` is detected once at startup: `keyboard_enhancement` via crossterm's `supports_keyboard_enhancement()` (a real query/response with the terminal), `truecolor` from `$COLORTERM`. When keyboard enhancement is supported, kiln-tui pushes the Kitty keyboard-protocol flags (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`) after `ratatui::init()` and pops them before `restore()`. This unlocks richer input — modifier-only keypresses, key release/repeat events, unambiguous Ctrl combos — which the engine intends to hand to scripts so they can choose bindings the terminal actually supports.
|
||||
|
||||
**Input loop.**
|
||||
Because `REPORT_EVENT_TYPES` makes the terminal emit Release (and Repeat) events, the loop acts on `Press` **and** `Repeat` — so holding a key moves the player continuously at the terminal's own repeat cadence — and skips `Release`, which would otherwise fire a second, spurious move per keypress. In legacy terminals no Repeat events arrive, so held-key movement falls back to terminal auto-repeat; same behavior, no special-casing.
|
||||
**Real-time loop.**
|
||||
The loop runs at ~30 FPS: it `event::poll`s only until the next frame deadline, so input stays responsive, and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick` (which drives object `tick(dt)` hooks). Because `REPORT_EVENT_TYPES` makes the terminal emit Release (and Repeat) events, it acts on `Press` **and** `Repeat` — so holding a key moves continuously at the terminal's own repeat cadence — and skips `Release`, which would otherwise fire a second, spurious move. Mouse capture is enabled so the log panel can be scrolled (wheel) and resized (drag the divider). `game.run_init()` runs object `init()` hooks after the board loads and before the loop.
|
||||
|
||||
**Capability badge.**
|
||||
`TerminalCaps::summary_line()` renders a one-line badge in the bottom border. When truecolor is available it shows off by drawing the word "truecolor" with each letter a different rainbow color (`hue_to_rgb`, a full-saturation HSV→RGB helper); otherwise it shows a plain "256-color", followed by the input descriptor ("enhanced input" / "legacy input") in the default color.
|
||||
**Log panel + capability line.**
|
||||
The bottom-left border previews the latest log line after a `[l]og:` label; pressing `l` opens a bottom panel listing the log newest-first (`render::logline_to_line` converts each `LogLine`). `TerminalCaps::status_logline()` returns a `LogLine` (not a border badge) seeded into the log at startup: when truecolor is available it draws "truecolor" with each letter a different rainbow color (`hue_to_rgb`, a full-saturation HSV→RGB helper), else a plain "256-color", followed by the input descriptor.
|
||||
|
||||
**Why a capability struct, not a terminal name.**
|
||||
Scripts should branch on *what works* (truecolor, keyboard enhancement), not on a brittle terminal brand string that is often unset over SSH or inside tmux. `TerminalCaps` deliberately carries no program-identity field.
|
||||
@@ -275,15 +292,23 @@ content = """
|
||||
[[objects]]
|
||||
x = 10
|
||||
y = 5
|
||||
script = """
|
||||
on_touch(|| { send_message("open"); });
|
||||
"""
|
||||
tile = "#"
|
||||
fg = "#aa3333"
|
||||
bg = "#000000"
|
||||
passable = true
|
||||
script_name = "greeter"
|
||||
|
||||
[[portals]]
|
||||
x = 59
|
||||
y = 12
|
||||
target_map = "cave"
|
||||
target_entry = "west_door"
|
||||
|
||||
[scripts]
|
||||
greeter = """
|
||||
fn init() { log("hello world"); }
|
||||
fn tick(dt) { }
|
||||
"""
|
||||
```
|
||||
|
||||
**Why TOML over a custom format:**
|
||||
@@ -306,9 +331,11 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f
|
||||
|
||||
## What's not yet implemented
|
||||
|
||||
**Object scripting** — `ObjectDef` and `PortalDef` are parsed from map files and stored on `Board`, but they have no runtime effect yet. The next step here is:
|
||||
1. Wire `ObjectDef` scripts to Rhai: when the player moves to an object's cell, fire its `on_touch` handler
|
||||
2. Define the Rhai API surface (what functions scripts can call: `send_message`, movement, board queries)
|
||||
**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks and can call `log(s)`. Still to come:
|
||||
1. More event hooks beyond lifecycle — e.g. fire `on_touch` when the player moves onto an object's cell
|
||||
2. Grow the Rhai API surface (movement, board queries, `send_message`, randomness) and give a script a handle to *its own* object
|
||||
3. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call)
|
||||
4. `PortalDef` is still parsed-only — no multi-board navigation yet
|
||||
|
||||
**Object behavior from scripts** — `ObjectDef.passable` and `ObjectDef.opaque` are live and editable in the editor, but they are static author-set values. Eventually they should be overridable by the object's Rhai script at runtime (e.g. a door script that flips `passable` when opened). `Board::is_passable` already has the right shape for this; the script runtime just needs to be wired in.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user