Compare commits

...

10 Commits

Author SHA1 Message Date
randrews c16b21c603 Removed action_to_map 2026-06-28 00:22:33 -05:00
randrews 83409a5c25 clippy 2026-06-28 00:17:08 -05:00
randrews 9de31d933b Big API refactor 2026-06-28 00:12:52 -05:00
randrews db9a5d37b6 refactor 2026-06-25 19:16:46 -05:00
randrews db8c8e615d player object 2026-06-25 00:04:42 -05:00
randrews d8a3f17379 heart containers 2026-06-23 22:52:25 -05:00
randrews 8637c0a52a map editor scroll 2026-06-23 21:59:40 -05:00
randrews cca56a6153 keys 2 2026-06-23 21:51:31 -05:00
randrews cbbe522fb1 keys 1 2026-06-23 20:07:53 -05:00
randrews 2ea2ce0212 key inv display 2026-06-23 01:56:45 -05:00
45 changed files with 1813 additions and 1403 deletions
+2 -2
View File
@@ -128,9 +128,9 @@ content = """
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" } "1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" }
``` ```
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph. Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph.
Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, `bump(id)`, and `grab()` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, `can_push(x, y, dir) -> bool`, `can_shift(x, y, dir) -> bool`, and `passable(x, y) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously), `add_gems(n)` (change the player's gem count), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`. Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, `bump(id)`, and `grab()` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, `can_push(x, y, dir) -> bool`, `can_shift(x, y, dir) -> bool`, and `passable(x, y) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously), `add_gems(n)` (change the player's gem count), `alter_health(dh)` (change the player's health, clamped to `[0, max_health]`), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`.
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`).
Generated
+4 -3
View File
@@ -754,6 +754,7 @@ name = "kiln-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"color", "color",
"log",
"rhai", "rhai",
"serde", "serde",
"tinyrand", "tinyrand",
@@ -850,9 +851,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "lru" name = "lru"
@@ -1109,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+265 -174
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.
+16 -9
View File
@@ -19,18 +19,18 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/archetype.rs`** — element taxonomy: **`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). - `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Pusher`, `Spinner`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key. - `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `Me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality. - `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `Me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`): **`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers. - `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`): **`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`. - `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
- `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, and only `Gem` sets 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) 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 still display-only. 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()` 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`, `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), `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,9 +87,9 @@ 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), `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.
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
+1
View File
@@ -9,3 +9,4 @@ rhai = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tinyrand = "0.5" tinyrand = "0.5"
toml = { version = "0.8", features = ["preserve_order"] } toml = { version = "0.8", features = ["preserve_order"] }
log = "0.4.33"
+78 -117
View File
@@ -4,9 +4,9 @@
//! functions), the rate-limiting delay constant, and [`action_to_map`] which //! functions), the rate-limiting delay constant, and [`action_to_map`] which
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. //! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`.
use std::fmt::Debug;
use crate::log::LogLine; use crate::log::LogLine;
use crate::map_file::color_to_hex; use crate::utils::{Direction, ObjectId};
use crate::utils::{Direction, ObjectId, ScriptArg};
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
@@ -27,13 +27,47 @@ pub enum ScrollLine {
Choice { choice: String, display: String }, Choice { choice: String, display: String },
} }
#[derive(Clone, PartialEq)]
pub enum SendArg {
None,
Int(i64),
Float(f64),
String(String),
}
impl From<Dynamic> for SendArg {
fn from(value: Dynamic) -> Self {
if value.is_int() {
Self::Int(value.as_int().unwrap())
} else if value.is_float() {
Self::Float(value.as_float().unwrap())
} else if value.is_string() {
Self::String(value.into_string().unwrap())
} else {
Self::None
}
}
}
impl From<SendArg> for Dynamic {
fn from(val: SendArg) -> Self {
match val {
SendArg::None => Dynamic::UNIT,
SendArg::Int(value) => Dynamic::from(value),
SendArg::Float(value) => Dynamic::from(value),
SendArg::String(value) => Dynamic::from(value),
}
}
}
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] /// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue. /// after it is promoted onto the board queue.
/// ///
/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue. /// [`Action::Delay`] is the exception: it is **never** promoted to the board queue.
/// It lives only in the per-object output queue and is consumed by `ScriptHost::drain` /// It lives only in the per-object output queue and is consumed by `ScriptHost::drain`
/// to pace how quickly other actions are released. /// to pace how quickly other actions are released.
pub(crate) enum Action { #[derive(Clone)]
pub enum Action {
/// Move the source object one cell in a direction (subject to passability). /// Move the source object one cell in a direction (subject to passability).
Move(Direction), Move(Direction),
/// Set the source object's glyph tile index. /// Set the source object's glyph tile index.
@@ -59,7 +93,7 @@ pub(crate) enum Action {
Send { Send {
target: ObjectId, target: ObjectId,
fn_name: String, fn_name: String,
arg: Option<ScriptArg>, arg: SendArg,
}, },
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object. /// selection dispatches [`Send`](Action::Send) to the source object.
@@ -67,133 +101,60 @@ pub(crate) enum Action {
/// Teleport the source object to `(x, y)`. Blocked (with a logged error) if /// Teleport the source object to `(x, y)`. Blocked (with a logged error) if
/// the source is solid and the destination already has a solid occupant. /// the source is solid and the destination already has a solid occupant.
/// Zero time cost — multiple teleports may fire in one frame. /// Zero time cost — multiple teleports may fire in one frame.
Teleport { x: i32, y: i32 }, Teleport { x: i64, y: i64 },
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on /// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost. /// arbitrary cells, not the source object). Zero time cost.
Push { x: i32, y: i32, dir: Direction }, Push { x: i64, y: i64, dir: Direction },
/// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the /// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the
/// next coordinate in the list, unless it can't move, or that cell is blocked. /// next coordinate in the list, unless it can't move, or that cell is blocked.
/// Rotates the contents of the last cell back to the beginning. /// Rotates the contents of the last cell back to the beginning.
/// Zero time cost. /// Zero time cost.
Shift(Vec<(i32, i32)>), Shift(Vec<(i64, i64)>),
/// 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
/// 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`.
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.
Die, Die,
} }
// ── Direction helper ────────────────────────────────────────────────────────── impl Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn dir_to_str(d: Direction) -> &'static str { match self {
match d { Action::Move(dir) => write!(f, "Move({:?})", dir),
Direction::North => "North", Action::SetTile(i) => write!(f, "SetTile({i})"),
Direction::South => "South", Action::Log(msg) => write!(f, "Log({:?})", msg),
Direction::East => "East", Action::SetTag { .. } => write!(f, "SetTag"),
Direction::West => "West", Action::Say(_, _) => write!(f, "Say"),
Action::Delay(t) => write!(f, "Delay({t})"),
Action::SetColor { .. } => write!(f, "SetColor"),
Action::Send { .. } => write!(f, "Send"),
Action::Scroll(_) => write!(f, "Scroll"),
Action::Teleport { .. } => write!(f, "Teleport"),
Action::Push { .. } => write!(f, "Push"),
Action::Shift(_) => write!(f, "Shift"),
Action::AddGems(n) => write!(f, "AddGems({n}"),
Action::AlterHealth(health) => write!(f, "AlterHealth({health})"),
Action::SetKey(_, _) => write!(f, "SetKey"),
Action::Die => write!(f, "Die")
}
} }
} }
// ── action_to_map ───────────────────────────────────────────────────────────── /// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
/// Converts an [`Action`] to a Rhai map for `Queue.peek()` / `Queue.pop()`. pub struct BoardAction {
/// /// The stable [`ObjectId`] of the object that issued the action.
/// The map always has a `"type"` key naming the variant; other keys carry the pub(crate) source: ObjectId,
/// payload. [`Action::Log`] is flattened to its first span's text. /// The action to apply.
pub(crate) fn action_to_map(action: &Action) -> rhai::Map { pub(crate) action: Action,
// Dynamic::from(String) avoids rhai 1.x's From<&str> lifetime restriction.
let ds = |v: &'static str| Dynamic::from(v.to_string());
let mut m = rhai::Map::new();
match action {
Action::Move(dir) => {
m.insert("type".into(), ds("Move"));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
Action::SetTile(n) => {
m.insert("type".into(), ds("SetTile"));
m.insert("tile".into(), Dynamic::from(*n as i64));
}
Action::Log(line) => {
let text = line
.spans
.first()
.map(|s| s.text.as_str())
.unwrap_or("")
.to_string();
m.insert("type".into(), ds("Log"));
m.insert("msg".into(), Dynamic::from(text));
}
Action::SetTag {
target,
tag,
present,
} => {
m.insert("type".into(), ds("SetTag"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("tag".into(), Dynamic::from(tag.clone()));
m.insert("present".into(), Dynamic::from(*present));
}
Action::Say(text, dur) => {
m.insert("type".into(), ds("Say"));
m.insert("msg".into(), Dynamic::from(text.clone()));
m.insert("duration".into(), Dynamic::from(*dur));
}
Action::Delay(secs) => {
m.insert("type".into(), ds("Delay"));
m.insert("secs".into(), Dynamic::from(*secs));
}
Action::SetColor { fg, bg } => {
m.insert("type".into(), ds("SetColor"));
if let Some(c) = fg {
m.insert("fg".into(), Dynamic::from(color_to_hex(*c)));
}
if let Some(c) = bg {
m.insert("bg".into(), Dynamic::from(color_to_hex(*c)));
}
}
Action::Send {
target,
fn_name,
arg,
} => {
m.insert("type".into(), ds("Send"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
if let Some(a) = arg {
let dyn_arg = match a {
ScriptArg::Str(s) => Dynamic::from(s.clone()),
ScriptArg::Num(n) => Dynamic::from(*n),
};
m.insert("arg".into(), dyn_arg);
}
}
// Scroll lines are not inspectable via the queue map API; emit type only.
Action::Scroll(_) => {
m.insert("type".into(), ds("Scroll"));
}
Action::Teleport { x, y } => {
m.insert("type".into(), ds("Teleport"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
}
Action::Push { x, y, dir } => {
m.insert("type".into(), ds("Push"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
// Shift cells are not inspectable via the queue map API; emit type only.
Action::Shift(_) => {
m.insert("type".into(), ds("Shift"));
}
Action::AddGems(n) => {
m.insert("type".into(), ds("AddGems"));
m.insert("n".into(), Dynamic::from(*n));
}
Action::Die => {
m.insert("type".into(), ds("Die"));
}
}
m
} }
+94
View File
@@ -0,0 +1,94 @@
//! ## Board read API (`state.board.*`)
//!
//! - `board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag
//! - `board.named(name) -> ObjectInfo | ()` — object with that name (or unit)
//! - `board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info
//! - `board.width` - Board width
//! - `board.height` - Board height
//!
//! ## Cell queries
//!
//! - `board.can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir`
//! - `board.passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole)
//! cell (one empty, or a grab thing and the player)
use std::cell::RefCell;
use std::rc::Rc;
use rhai::{Dynamic, Engine, ImmutableString};
use crate::{Board, Direction};
use crate::api::object_info::ObjectInfo;
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
/// A read-only handle to the world, exposed to scripts as `Board`.
pub type BoardRef = Rc<RefCell<Board>>;
impl Registerable for BoardRef {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
engine.register_type_with_name::<BoardRef>("Board");
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
// can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be
// shoved one step in dir (the read-only half of push()). False off-board.
engine.register_fn("can_push", move |board: BoardRef, x: i64, y: i64, dir: Direction| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.can_push(x as usize, y as usize, dir)
});
// passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell
// a mover could enter). Off-board is not passable. Lets a script distinguish a
// hole from a blocked solid (which can_shift/can_push alone cannot).
engine.register_fn("passable", move |board: BoardRef, x: i64, y: i64| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.is_passable(x as usize, y as usize)
});
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
if id <= 0 {
error_sink.error(format!("Board.get: invalid id {id}"));
return Dynamic::UNIT;
}
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
Dynamic::from(obj)
} else {
error_sink.error(format!("Board.get: no object with id {id}"));
Dynamic::UNIT
}
});
// Board.named(name) -> ObjectInfo | ()
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = board_ref.borrow();
board
.objects
.iter()
.find_map(|(_id, def)| {
if def.name.as_deref() == Some(name.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.unwrap_or(Dynamic::UNIT)
},
);
// Board.tagged(tag) -> Array[ObjectInfo]
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = board_ref.borrow();
board
.objects.values().filter_map(|def| {
if def.tags.contains(tag.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.collect()
},
);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod state;
pub mod player;
pub mod board;
pub mod object_info;
pub mod queue;
+137
View File
@@ -0,0 +1,137 @@
//! ## Object Rhai API
//!
//! ### Getters
//!
//! - x, y -> Board location of object
//! - id -> The object's board-unique id
//! - name -> The object's name, or ()
//! - waiting -> bool for whether or not the front of this object's queue is a delay action
//! - queue -> the action queue for this object
//! - tags -> Array of tags for this object
//! - glyph -> The object's current glyph
//!
//! ### Functions
//!
//! - has_tag(tag) -> Whether the object has this tag
//! - delay(secs) -> Queue a delay action
//! - can_push(dir) -> Whether a push in this direction would succeed
//! - blocked(dir) -> Whether a solid neighbors me in this direction
//!
//! Note on blocking: can_push and blocked only take into account the contents of the board
//! at the start of the call; if another thing moves before your actions are flushed to the
//! gamestate, a move might still fail. This is a TODO.
use rhai::{Dynamic, Engine};
use crate::api::board::BoardRef;
use crate::api::queue::ObjQueue;
use crate::Direction;
use crate::object_def::ObjectDef;
use crate::script::{BoardQueue, Registerable};
use crate::utils::{ErrorSink, ObjectId};
/// A snapshot of one board object, returned by `Board.tagged`, `Board.named`,
/// and `Board.get`. Passed by value — scripts read fields, not a live reference.
/// It also contains things like script_name used by ScriptHost, since we can't keep a live
/// borrow of the board while doing anything: we create one of these loose and then
/// it borrows the board only for the duration of what it needs.
#[derive(Clone)]
pub struct ObjectInfo {
pub id: ObjectId,
pub x: i64,
pub y: i64,
pub board: BoardRef,
pub script_name: Option<String>,
pub queue: ObjQueue
}
impl ObjectInfo {
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
let b = board.borrow();
let obj = b.objects.get(&id)?;
Some(ObjectInfo {
id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
})
}
pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo {
Self {
id: obj.id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
}
}
pub fn drain(&mut self, target: BoardQueue, dt: f64) {
let mut b = self.board.borrow_mut();
if let Some(def) = b.objects.get_mut(&self.id) {
def.queue.drain(self.id, target, dt)
}
}
}
impl Registerable for ObjectInfo {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
.register_get("x", |obj: &mut ObjectInfo| obj.x)
.register_get("y", |obj: &mut ObjectInfo| obj.y)
.register_get("id", |obj: &mut ObjectInfo| obj.id as i64)
.register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting())
.register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone());
engine.register_get("name", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { name: Some(name), ..}) = obj {
Dynamic::from(name.clone())
} else {
Dynamic::UNIT
}
});
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { tags, .. }) = obj {
tags.iter().map(|t| Dynamic::from(t.clone())).collect()
} else {
rhai::Array::new()
}
});
engine.register_get("glyph", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id).unwrap();
obj.glyph
});
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
tags.contains(&t)
} else {
false
}
});
engine.register_fn("delay", |o: &mut ObjectInfo, dt: f64| o.queue.delay(dt));
engine.register_fn("can_push", move |o: &mut ObjectInfo, dir: Direction| {
let board = o.board.borrow();
board.in_bounds((o.x, o.y)) && board.can_push(o.x as usize, o.y as usize, dir)
});
engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool {
let board = o.board.borrow();
let tx = o.x + dir.dx();
let ty = o.y + dir.dy();
board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize)
});
}
}
+21
View File
@@ -0,0 +1,21 @@
use rhai::Engine;
use crate::player::Player;
use crate::script::Registerable;
use crate::utils::{ErrorSink, PlayerPos};
/// GameState stores player state but Board stores its position, and we want one
/// object to register with Rhai
#[derive(Copy, Clone)]
pub struct PlayerWithPos(pub Player, pub PlayerPos);
impl Registerable for PlayerWithPos {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<PlayerWithPos>("Player")
.register_get("gems", |player: &mut PlayerWithPos| player.0.gems)
.register_get("health", |player: &mut PlayerWithPos| player.0.health)
.register_get("max_health", |player: &mut PlayerWithPos| player.0.max_health)
.register_get("keys", |player: &mut PlayerWithPos| player.0.keys)
.register_get("x", |player: &mut PlayerWithPos| player.1.x)
.register_get("y", |player: &mut PlayerWithPos| player.1.y);
}
}
+101
View File
@@ -0,0 +1,101 @@
//! ## Queue API
//!
//! `queue.length`, `queue.clear()`, `queue.peek()`, `queue.pop()`, `queue.delay()`
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use rhai::{Dynamic, Engine};
use crate::action::{Action, BoardAction};
use crate::script::{BoardQueue, Registerable};
use crate::utils::{ErrorSink, ObjectId};
/// A single object's output queue.
#[derive(Clone)]
pub struct ObjQueue(Rc<RefCell<VecDeque<Action>>>);
impl ObjQueue {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(VecDeque::new())))
}
/// Add a delay to the tail of the queue, or alter the value already there by the given amount.
/// A delay action's value can never be less than 0.0
/// TODO This really ought to be Duration
pub fn delay(&mut self, delay: f64) {
let mut q = self.0.borrow_mut();
if let Some(Action::Delay(r)) = q.back_mut() {
*r = (*r + delay).max(0.0)
} else {
q.push_back(Action::Delay(delay.max(0.0)))
}
}
/// Add an action to the tail of the queue
pub fn act(&mut self, action: Action) {
self.0.borrow_mut().push_back(action);
}
/// promote the most recently enqueued action to the front.
pub fn now(&mut self) {
let mut q = self.0.borrow_mut();
if let Some(back) = q.pop_back() {
q.push_front(back);
}
}
/// Drains object `i`'s output queue onto a target queue: first advances
/// leading `Delay` actions by dt, then drains actions into the target
/// queue until we run out (or hit another delay)
pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) {
let mut queue = self.0.borrow_mut();
loop {
match queue.front_mut() {
None => break, // No more actions, we're done
Some(Action::Delay(time)) => { // A delay, decrease it by dt
if dt < *time { // Delay is too long, bail out
*time -= dt;
break;
} else { // dt eats the delay, pop it and continue
dt -= *time;
queue.pop_front();
}
}
Some(_) => {
let action = queue.pop_front().unwrap();
target.borrow_mut().push(BoardAction { source, action });
}
}
}
}
/// Return whether this queue has an `Action::Delay` in the front
pub fn waiting(&mut self) -> bool {
matches!(self.0.borrow().front(), Some(Action::Delay(_)))
}
}
impl Registerable for ObjQueue {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
engine.register_fn("clear", |q: &mut ObjQueue| q.0.borrow_mut().clear());
// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing
// trailing delay to prevent adjacent delays from accumulating.
engine.register_fn("delay", move |q: &mut ObjQueue, secs: Dynamic| {
let secs: Result<f64, &str> = if secs.is_float() {
secs.as_float()
} else if secs.is_int() {
secs.as_int().map(|i| i as f64)
} else {
Err("delay() must be an int or float")
};
match secs {
Ok(secs) => q.delay(secs),
Err(msg) => error_sink.error(msg.to_string())
}
});
}
}
+32
View File
@@ -0,0 +1,32 @@
use rhai::Engine;
use crate::api::board::BoardRef;
use crate::api::player::PlayerWithPos;
use crate::game::GameState;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// 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(Clone)]
pub struct ScriptState {
pub player: PlayerWithPos,
pub board: BoardRef,
}
impl ScriptState {
pub fn from_game_state(game_state: &GameState) -> Self {
Self {
player: PlayerWithPos(game_state.player, game_state.board().player),
board: game_state.board_rc(),
}
}
}
impl Registerable for ScriptState {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<ScriptState>("State")
.register_get("player", |state: &mut ScriptState| state.player)
.register_get("board", |state: &mut ScriptState| state.board.clone());
}
}
+76 -22
View File
@@ -1,15 +1,16 @@
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::utils::{Behavior, Pushable}; use crate::utils::{Behavior, Pushable};
use color::Rgba8; use color::Rgba8;
use crate::keys::KeyType;
/// Declares the set of script-backed archetype families. /// Declares the set of script-backed archetype families.
/// ///
/// Each entry specifies: /// Each entry specifies:
/// - A `Variant` name (becomes a [`Builtin`] enum variant). /// - A `Variant` name (becomes a [`Builtin`] enum variant).
/// - A `["name" => tile, …]` list: one map-file keyword per alias with the tile /// - A `["name" => Glyph { … }, …]` list: one map-file keyword per alias with the
/// index for the editor glyph. All aliases in a family share `behavior`, `fg`, /// default [`Glyph`] for the editor. Per-alias glyphs allow aliases in the same
/// `bg`, and embedded Rhai `script`. /// family to differ in color (e.g. the eight `Key` variants).
/// - `behavior`, `fg`, `bg`: per-family defaults. /// - `behavior`: shared across all aliases in the family.
/// - `script`: the embedded Rhai source; `include_str!` paths are relative to this /// - `script`: the embedded Rhai source; `include_str!` paths are relative to this
/// file, so `include_str!("scripts/pusher.rhai")` resolves to /// file, so `include_str!("scripts/pusher.rhai")` resolves to
/// `kiln-core/src/scripts/pusher.rhai`. /// `kiln-core/src/scripts/pusher.rhai`.
@@ -21,10 +22,8 @@ use color::Rgba8;
macro_rules! builtins { macro_rules! builtins {
( (
$( $(
$variant:ident => [ $( $name:literal => $tile:literal ),+ $(,)? ] { $variant:ident => [ $( $name:literal => $glyph:expr ),+ $(,)? ] {
behavior: $behavior:expr, behavior: $behavior:expr,
fg: $fg:expr,
bg: $bg:expr,
script: $script:expr $(,)? script: $script:expr $(,)?
} }
),+ $(,)? ),+ $(,)?
@@ -66,16 +65,14 @@ macro_rules! builtins {
} }
} }
/// Returns the glyph for `alias`: the family's fg/bg with the alias's tile. /// Returns the default glyph for `alias`. Each alias owns its own glyph,
/// so aliases within a family can differ in color (e.g. colored keys).
/// Falls back to a transparent glyph for unrecognized aliases (shouldn't /// Falls back to a transparent glyph for unrecognized aliases (shouldn't
/// happen in practice since aliases are all from the macro). /// happen in practice since aliases are all from the macro).
pub(crate) fn default_glyph_for(self, alias: &str) -> Glyph { pub(crate) fn default_glyph_for(self, alias: &str) -> Glyph {
// The outer repetition brings $fg/$bg into scope; the inner one selects
// the tile for the specific alias. Both are statically resolved at
// compile time.
match alias { match alias {
$( $(
$( $name => Glyph { tile: $tile, fg: $fg, bg: $bg }, )+ $( $name => $glyph, )+
)+ )+
_ => Glyph::transparent(), _ => Glyph::transparent(),
} }
@@ -91,25 +88,54 @@ macro_rules! builtins {
}; };
} }
builtins! { // Shorthand helpers used only within the builtins! invocation below.
Gem => ["gem" => 4] { // `g(tile, r, g, b)` builds a Glyph with the given tile and fg on black bg.
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true }, const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph {
fg: Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 }, Glyph {
tile,
fg: Rgba8 { r, g: gr, b, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
builtins! {
Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
script: include_str!("scripts/gem.rhai"), script: include_str!("scripts/gem.rhai"),
}, },
Pusher => ["pusher_north" => 30, "pusher_south" => 31, "pusher_east" => 16, "pusher_west" => 17] { Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
script: include_str!("scripts/heart.rhai"),
},
Pusher => [
"pusher_north" => g(30, 0xAA, 0xAA, 0xAA),
"pusher_south" => g(31, 0xAA, 0xAA, 0xAA),
"pusher_east" => g(16, 0xAA, 0xAA, 0xAA),
"pusher_west" => g(17, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false }, behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
script: include_str!("scripts/pusher.rhai"), script: include_str!("scripts/pusher.rhai"),
}, },
Spinner => ["spinner_cw" => 47, "spinner_ccw" => 92] { Spinner => [
"spinner_cw" => g(47, 0xAA, 0xAA, 0xAA),
"spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA),
] {
behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false }, behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false },
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
script: include_str!("scripts/spinner.rhai"), script: include_str!("scripts/spinner.rhai"),
}, },
Key => [ // TODO these should refer to the key colors in Keyring
"key_blue" => KeyType::Blue.glyph(),
"key_green" => KeyType::Green.glyph(),
"key_cyan" => KeyType::Cyan.glyph(),
"key_red" => KeyType::Red.glyph(),
"key_purple" => KeyType::Purple.glyph(),
"key_orange" => KeyType::Orange.glyph(),
"key_yellow" => KeyType::Yellow.glyph(),
"key_white" => KeyType::White.glyph(),
] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
script: include_str!("scripts/key.rhai"),
}
} }
/// A class of board cell, encoding its default behavior and appearance. /// A class of board cell, encoding its default behavior and appearance.
@@ -310,6 +336,9 @@ mod tests {
("pusher_west", 17), ("pusher_west", 17),
("spinner_cw", 47), ("spinner_cw", 47),
("spinner_ccw", 92), ("spinner_ccw", 92),
("key_red", 12),
("key_blue", 12),
("key_white", 12),
] { ] {
let arch = Archetype::try_from(name) let arch = Archetype::try_from(name)
.unwrap_or_else(|_| panic!("'{name}' should parse as a builtin")); .unwrap_or_else(|_| panic!("'{name}' should parse as a builtin"));
@@ -321,4 +350,29 @@ mod tests {
); );
} }
} }
#[test]
fn key_aliases_have_distinct_fg_colors() {
use crate::keys::KeyType;
// Each alias must match the corresponding KeyType glyph — single source of truth.
let cases = [
("key_blue", KeyType::Blue),
("key_green", KeyType::Green),
("key_cyan", KeyType::Cyan),
("key_red", KeyType::Red),
("key_purple", KeyType::Purple),
("key_orange", KeyType::Orange),
("key_yellow", KeyType::Yellow),
("key_white", KeyType::White),
];
for (name, key_type) in cases {
let arch = Archetype::try_from(name)
.unwrap_or_else(|_| panic!("'{name}' should parse"));
assert_eq!(
arch.default_glyph().fg,
key_type.glyph().fg,
"'{name}' fg doesn't match KeyType"
);
}
}
} }
+27 -55
View File
@@ -4,7 +4,7 @@ use crate::layer::Layer;
use crate::log::LogLine; use crate::log::LogLine;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::Direction; use crate::utils::Direction;
use crate::utils::{Behavior, ObjectId, Player, PortalDef, Pushable, RegistryValue, Solid}; use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
/// The complete state of one game board (a single room or screen). /// The complete state of one game board (a single room or screen).
@@ -43,8 +43,9 @@ 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
pub player: Player, /// 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` /// 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
/// objects' ids; iteration is in ascending-id order, which equals load order /// objects' ids; iteration is in ascending-id order, which equals load order
@@ -79,6 +80,11 @@ impl Board {
self.layers.len() self.layers.len()
} }
/// Return a list of all `ObjectId`s currently on the board.
pub fn all_ids(&self) -> Vec<ObjectId> {
self.objects.keys().cloned().collect()
}
/// Returns a reference to the cell at `(x, y)` on layer `z`. /// Returns a reference to the cell at `(x, y)` on layer `z`.
/// ///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are /// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
@@ -97,12 +103,11 @@ impl Board {
/// Replace the solid (if any) at `(x, y)` with `Empty` /// Replace the solid (if any) at `(x, y)` with `Empty`
pub fn clear_solid(&mut self, x: usize, y: usize) { pub fn clear_solid(&mut self, x: usize, y: usize) {
if self.in_bounds((x as i32, y as i32)) { if self.in_bounds((x as i64, y as i64))
if let Some(z) = self.solid_cell_layer(x, y) { && let Some(z) = self.solid_cell_layer(x, y) {
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty) *self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
} }
} }
}
/// Returns the glyph to display at `(x, y)`, honoring layer draw order. /// Returns the glyph to display at `(x, y)`, honoring layer draw order.
/// ///
@@ -120,7 +125,7 @@ impl Board {
/// Panics if out of bounds. /// Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph { pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
// The player is rendered above the whole stack (see the `Player` notes). // The player is rendered above the whole stack (see the `Player` notes).
if self.player.x == x as i32 && self.player.y == y as i32 { if self.player.x == x as i64 && self.player.y == y as i64 {
return Glyph::player(); return Glyph::player();
} }
@@ -168,7 +173,7 @@ impl Board {
/// ///
/// Takes signed coords so callers can pass a raw `pos + delta` without first /// Takes signed coords so callers can pass a raw `pos + delta` without first
/// checking for negatives. /// checking for negatives.
pub fn in_bounds(&self, pos: (i32, i32)) -> bool { pub fn in_bounds(&self, pos: (i64, i64)) -> bool {
let (x, y) = pos; let (x, y) = pos;
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
} }
@@ -194,7 +199,7 @@ impl Board {
/// Panics if `x` or `y` are out of bounds. /// Panics if `x` or `y` are out of bounds.
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> { pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> {
// The player wins its cell (load-time invariant), so it is the solid there. // The player wins its cell (load-time invariant), so it is the solid there.
if self.player.x == x as i32 && self.player.y == y as i32 { if self.player.x == x as i64 && self.player.y == y as i64 {
return Some(Solid::player_at(x, y)); return Some(Solid::player_at(x, y));
} }
// A solid object shadows the cell it sits on; capture its behavior now. // A solid object shadows the cell it sits on; capture its behavior now.
@@ -235,39 +240,6 @@ impl Board {
self.solid_at(x, y).is_none() self.solid_at(x, y).is_none()
} }
/// Returns `true` if the solids at `(x1, y1)` and `(x2, y2)` may share a cell —
/// i.e. moving one onto the other wouldn't break the "one solid per cell"
/// invariant. That holds when at least one cell is empty, or one holds the
/// player and the other a **grab** thing (the player collects it).
///
/// Off-board coordinates are never combinable (returns `false` rather than
/// panicking). Backs the script-facing `combinable(x1, y1, x2, y2)` fn.
pub fn is_combinable(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> bool {
// Are either out of bounds?
if !self.in_bounds((x1 as i32, y1 as i32)) || !self.in_bounds((x2 as i32, y2 as i32)) {
return false
}
// Grab the solids
let solid1 = self.solid_at(x1, y1);
let solid2 = self.solid_at(x2, y2);
// Is one cell empty?
if solid1.is_none() || solid2.is_none() { return true }
// They're both present, unwrap them:
let solid1 = solid1.unwrap();
let solid2 = solid2.unwrap();
// This is probably disallowed then, but let's check for a player coexisting with a grab:
if solid1.player() && solid2.grab() || solid2.player() && solid1.grab() {
return true
}
// Nope, two solids that can't coexist:
false
}
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`. /// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
/// ///
/// Non-solid things are never pushable: `pushable` only matters for solids. /// Non-solid things are never pushable: `pushable` only matters for solids.
@@ -288,14 +260,14 @@ impl Board {
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell /// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
/// ahead shovable?" half of a "can I move here?" query. /// ahead shovable?" half of a "can I move here?" query.
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool { pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let (mut cx, mut cy) = (x, y); let (mut cx, mut cy) = (x, y);
loop { loop {
// This cell must hold a solid pushable in `dir` to advance the chain. // This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) { if !self.is_pushable(cx, cy, dir) {
return false; return false;
} }
let next = (cx as i32 + dx, cy as i32 + dy); let next = (cx as i64 + dx, cy as i64 + dy);
if !self.in_bounds(next) { if !self.in_bounds(next) {
return false; // chain runs off the board return false; // chain runs off the board
} }
@@ -328,8 +300,8 @@ impl Board {
if !self.is_pushable(x, y, dir) { if !self.is_pushable(x, y, dir) {
return false; return false;
} }
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let next = (x as i32 + dx, y as i32 + dy); let next = (x as i64 + dx, y as i64 + dy);
if !self.in_bounds(next) { if !self.in_bounds(next) {
return false; // nothing to shift into off the board return false; // nothing to shift into off the board
} }
@@ -353,7 +325,7 @@ impl Board {
if !self.can_push(x, y, dir) { if !self.can_push(x, y, dir) {
return; return;
} }
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
// can_push guaranteed the chain ends at an in-bounds passable cell, so // can_push guaranteed the chain ends at an in-bounds passable cell, so
// re-walk it (no bounds checks needed) and shift the far end first, which // re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives. // keeps each destination cell vacated before its occupant arrives.
@@ -361,8 +333,8 @@ impl Board {
let (mut cx, mut cy) = (x, y); let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) { while !self.is_passable(cx, cy) {
chain.push((cx, cy)); chain.push((cx, cy));
cx = (cx as i32 + dx) as usize; cx = (cx as i64 + dx) as usize;
cy = (cy as i32 + dy) as usize; cy = (cy as i64 + dy) as usize;
} }
for &(px, py) in chain.iter().rev() { for &(px, py) in chain.iter().rev() {
self.shift_solid(px, py, dx, dy); self.shift_solid(px, py, dx, dy);
@@ -375,8 +347,8 @@ impl Board {
/// terrain archetype (a crate) is moved within its own layer, leaving a /// terrain archetype (a crate) is moved within its own layer, leaving a
/// transparent cell behind so the layer beneath (e.g. floor) shows through. /// transparent cell behind so the layer beneath (e.g. floor) shows through.
/// The caller guarantees the destination is already clear. /// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) { fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize); let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
let Some(solid) = self.solid_at(x, y) else { let Some(solid) = self.solid_at(x, y) else {
return; // nothing to shift return; // nothing to shift
}; };
@@ -540,7 +512,7 @@ impl Board {
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
/// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log. /// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
pub fn apply_shift(&mut self, cells: &[(i32, i32)]) -> Vec<LogLine> { pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec<LogLine> {
// Validate all the cells are in bounds, error if not: // Validate all the cells are in bounds, error if not:
if cells.iter().any(|&c| !self.in_bounds(c)) { if cells.iter().any(|&c| !self.in_bounds(c)) {
return vec![LogLine::error("Called shift() with a cell out of bounds")] return vec![LogLine::error("Called shift() with a cell out of bounds")]
@@ -623,7 +595,7 @@ pub(crate) mod tests {
use crate::layer::Layer; use crate::layer::Layer;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::Direction; use crate::utils::Direction;
use crate::utils::{ObjectId, Player}; use crate::utils::{ObjectId, PlayerPos};
use color::Rgba8; use color::Rgba8;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
@@ -636,7 +608,7 @@ pub(crate) mod tests {
pub(crate) fn open_board( pub(crate) fn open_board(
w: usize, w: usize,
h: usize, h: usize,
player: (i32, i32), player: (i64, i64),
objects: Vec<ObjectDef>, objects: Vec<ObjectDef>,
) -> Board { ) -> Board {
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new(); let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
@@ -653,7 +625,7 @@ pub(crate) mod tests {
layers: vec![Layer { layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h], cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
}], }],
player: Player { player: PlayerPos {
x: player.0, x: player.0,
y: player.1, y: player.1,
}, },
+1 -1
View File
@@ -17,7 +17,7 @@
/// space since a literal NUL is not displayable. /// space since a literal NUL is not displayable.
const CP437: [char; 256] = [ const CP437: [char; 256] = [
// 0x000x0F // 0x000x0F
' ', '☺', '☻', '', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', ' ', '☺', '☻', '', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x100x1F // 0x100x1F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼', '►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
// 0x200x2F (ASCII space onward) // 0x200x2F (ASCII space onward)
+104 -53
View File
@@ -1,8 +1,8 @@
use crate::action::Action; use crate::action::{Action, SendArg};
use crate::board::Board; use crate::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::ScriptHost; use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, Player, ScriptArg}; use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World; use crate::world::World;
use std::cell::{Ref, RefMut}; use std::cell::{Ref, RefMut};
use std::time::Duration; use std::time::Duration;
@@ -13,6 +13,8 @@ pub const SAY_DURATION: f64 = 3.0;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without // Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly. // accessing the private `action` module directly.
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
use crate::api::state::ScriptState;
use crate::player::Player;
/// An active scroll overlay opened by a scripted object via `scroll()`. /// An active scroll overlay opened by a scripted object via `scroll()`.
/// ///
@@ -66,14 +68,11 @@ 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 current health. Game-global (not per-board), so it persists /// The game-global player state (health, gems, keys) — see [`Player`]. Not
/// across board transitions. Players start with `5`. There is no runtime /// per-board: it persists across board transitions, unlike the per-board
/// mutation path yet — front-ends only display it. /// position in [`Board::player`](crate::board::Board::player). Scripts mutate
pub player_health: u32, /// it via `add_gems`/`alter_health`/`set_key` and read a snapshot of it.
/// The number of gems the player has collected. Game-global like pub player: Player,
/// [`player_health`](GameState::player_health); starts at `0` and is
/// display-only for now.
pub player_gems: u32,
} }
impl GameState { impl GameState {
@@ -100,8 +99,7 @@ impl GameState {
speech_bubbles: Vec::new(), speech_bubbles: Vec::new(),
active_scroll: None, active_scroll: None,
board_transition: None, board_transition: None,
player_health: 5, player: Player::default()
player_gems: 0,
}; };
state.drain_errors(); state.drain_errors();
state state
@@ -165,7 +163,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.scripts.run_init(ScriptState::from_game_state(self));
self.resolve(); self.resolve();
} }
@@ -179,7 +177,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(secs); self.scripts.run_tick(ScriptState::from_game_state(self), 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| {
@@ -210,13 +208,16 @@ impl GameState {
// below also borrows `self`. // below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new(); let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
// Net change to the player's gem count from AddGems actions; applied to // Net change to player stats from AddGems / AlterHealth actions; applied
// `self` after the board borrow drops. // to `self` after the board borrow drops.
let mut gem_delta: i64 = 0; let mut gem_delta: i64 = 0;
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new(); let mut health_delta: i64 = 0;
let mut key_changes: Vec<(String, bool)> = Vec::new();
let mut sends: Vec<(ObjectId, String, SendArg)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new(); let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll. // Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None; let mut new_scroll: Option<Scroll> = None;
{ {
let mut board = self.board_mut(); let mut board = self.board_mut();
for ba in actions { for ba in actions {
@@ -312,8 +313,12 @@ 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.
Action::AlterHealth(dh) => health_delta += dh,
// 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. // A grab thing despawns itself from its grab() hook.
Action::Die => { Action::Die => {
board.remove_object(ba.source); board.remove_object(ba.source);
@@ -333,13 +338,23 @@ impl GameState {
} }
// Apply the net gem change (clamped at 0, since the count is unsigned). // Apply the net gem change (clamped at 0, since the count is unsigned).
if gem_delta != 0 { if gem_delta != 0 {
self.player_gems = (self.player_gems as i64 + gem_delta).max(0) as u32; self.player.alter_gems(gem_delta);
} }
// Apply the net health change (clamped to [0, max_health]).
if health_delta != 0 {
self.player.alter_health(health_delta);
}
for (color, present) in key_changes {
if !self.player.keys.set_by_name(&color, present) {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
}
}
let state = ScriptState::from_game_state(self);
for (bumped, bumper) in bumps { for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper); self.scripts.run_bump(state.clone(), bumped, bumper);
} }
for (target, fn_name, arg) in sends { for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg); self.scripts.run_send(state.clone(), target, &fn_name, arg);
} }
self.drain_errors(); self.drain_errors();
} }
@@ -355,7 +370,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(scroll.source, &choice, None); self.scripts.run_send(ScriptState::from_game_state(self), scroll.source, &choice, SendArg::None);
self.drain_errors(); self.drain_errors();
} }
} }
@@ -394,9 +409,9 @@ impl GameState {
self.active_scroll = None; self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal. // Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string(); self.current_board_name = target_map.to_string();
self.board_mut().player = Player { self.board_mut().player = PlayerPos {
x: ax as i32, x: ax as i64,
y: ay as i32, y: ay as i64,
}; };
// Rebuild the script host for the new board's objects. // Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new( self.scripts = ScriptHost::new(
@@ -420,7 +435,7 @@ impl GameState {
let grabbed; let grabbed;
let portal_target; let portal_target;
{ {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let mut board = self.board_mut(); let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy); let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
@@ -442,8 +457,8 @@ impl GameState {
if grabbed.is_none() { if grabbed.is_none() {
board.push(nx, ny, dir); board.push(nx, ny, dir);
} }
board.player.x = nx as i32; board.player.x = nx as i64;
board.player.y = ny as i32; board.player.y = ny as i64;
// Check for a portal at the new position; clone strings to release the borrow. // Check for a portal at the new position; clone strings to release the borrow.
portal_target = board portal_target = board
.portals .portals
@@ -461,12 +476,13 @@ 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.
let state = ScriptState::from_game_state(self);
if let Some(id) = grabbed { if let Some(id) = grabbed {
self.scripts.run_grab(id); self.scripts.run_grab(state.clone(), id);
self.resolve(); self.resolve();
} }
if let Some(idx) = bumped { if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1); self.scripts.run_bump(state, idx, -1);
self.drain_errors(); self.drain_errors();
} }
} }
@@ -481,9 +497,9 @@ impl GameState {
/// pushed aside or blocks the move — since something tried to move into it. Walls and /// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump. /// crates carry no script, so only solid objects yield a bump.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> { fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i32, i32) = dir.into(); let (dx, dy): (i64, i64) = dir.into();
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
let target = (ox as i32 + dx, oy as i32 + dy); let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) { if !board.in_bounds(target) {
return None; return None;
} }
@@ -522,7 +538,7 @@ mod tests {
game.try_move(Direction::East); game.try_move(Direction::East);
// The gem was grabbed: gem count up, gem object gone, player on its cell. // The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player_gems, 1); assert_eq!(game.player.gems, 1);
assert!(game.board().objects.is_empty()); assert!(game.board().objects.is_empty());
assert_eq!((game.board().player.x, game.board().player.y), (1, 0)); assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
} }
@@ -550,7 +566,7 @@ mod tests {
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
// No grab: the gem is untouched and the player never moved. // No grab: the gem is untouched and the player never moved.
assert_eq!(game.player_gems, 0); assert_eq!(game.player.gems, 0);
assert!(game.board().objects.values().any(|o| o.grab)); assert!(game.board().objects.values().any(|o| o.grab));
assert_eq!((game.board().player.x, game.board().player.y), (2, 0)); assert_eq!((game.board().player.x, game.board().player.y), (2, 0));
} }
@@ -560,7 +576,7 @@ mod tests {
let mut obj = ObjectDef::new(0, 0); let mut obj = ObjectDef::new(0, 0);
obj.solid = false; obj.solid = false;
obj.script_name = Some("s".to_string()); obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]); let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -568,26 +584,13 @@ mod tests {
game game
} }
#[test]
fn script_push_shoves_a_crate() {
// The object pushes the crate at (2,0) one step east on its first tick.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test] #[test]
fn script_shift_rotates_crates() { fn script_shift_rotates_crates() {
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle: // Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
// crate moves to (3,0), empty moves back to (2,0). // crate moves to (3,0), empty moves back to (2,0).
let mut game = game_with_object_script( let mut game = game_with_object_script(
5, 5,
"fn tick(dt) { if Queue.length() == 0 { shift([[2, 0], [3, 0]]); } }", "fn tick(m,s,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }",
); );
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
let b = game.board(); let b = game.board();
@@ -605,10 +608,10 @@ mod tests {
obj.script_name = Some("s".to_string()); obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]); let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let src = "fn init() { \ let src = "fn init(m,s) { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \ log(if s.board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \ log(if s.board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }"; log(if s.board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]); let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -714,4 +717,52 @@ mod tests {
} }
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]); assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
} }
#[test]
fn set_key_gives_and_takes_keys() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
"fn init(m, s) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.player.keys.blue);
assert!(game.player.keys.red);
assert!(!game.player.keys.cyan); // cyan was not set by the script
// A second script can take a key.
let mut sobj2 = ObjectDef::new(0, 0);
sobj2.solid = false;
sobj2.script_name = Some("t".to_string());
let board2 = open_board(2, 1, (1, 0), vec![sobj2]);
let scripts2 = HashMap::from([(
"t".to_string(),
"fn init(m, s) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(),
)]);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.player.keys.blue);
}
#[test]
fn set_key_unknown_color_logs_error() {
let mut sobj = ObjectDef::new(0, 0);
sobj.solid = false;
sobj.script_name = Some("s".to_string());
let board = open_board(2, 1, (1, 0), vec![sobj]);
let scripts = HashMap::from([(
"s".to_string(),
r#"fn init(m,s) { set_key("chartreuse", true); }"#.to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.log.iter().any(|l| l.spans.iter().any(|s| s.text.contains("set_key"))));
}
} }
+16
View File
@@ -1,5 +1,8 @@
use color::Rgba8; use color::Rgba8;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use rhai::Engine;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// The visual representation of a single board cell. /// The visual representation of a single board cell.
/// ///
@@ -32,6 +35,19 @@ impl Hash for Glyph {
} }
} }
impl Registerable for Glyph {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<Glyph>("Glyph");
engine.register_get("tile", |g: &mut Glyph| g.tile);
engine.register_get("fg", |g: &mut Glyph| {
format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b)
});
engine.register_get("bg", |g: &mut Glyph| {
format!("#{:02x}{:02x}{:02x}", g.bg.r, g.bg.g, g.bg.b)
});
}
}
impl Glyph { impl Glyph {
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue. /// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
/// ///
+102
View File
@@ -0,0 +1,102 @@
use color::Rgba8;
use rhai::Engine;
use crate::glyph::Glyph;
use crate::script::Registerable;
use crate::utils::ErrorSink;
#[derive(Copy, Clone, Debug)]
pub enum KeyType {
Red, Orange, Yellow, Green, Blue, Cyan, Purple, White
}
impl KeyType {
pub fn glyph(self) -> Glyph {
let fg = match self {
KeyType::Red => Rgba8 { r: 0xFF, g: 0x50, b: 0x50, a: 255 },
KeyType::Orange => Rgba8 { r: 0xFF, g: 0x88, b: 0x00, a: 255 },
KeyType::Yellow => Rgba8 { r: 0xFF, g: 0xFF, b: 0x50, a: 255 },
KeyType::Green => Rgba8 { r: 0x50, g: 0xFF, b: 0x50, a: 255 },
KeyType::Blue => Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 },
KeyType::Cyan => Rgba8 { r: 0x00, g: 0xAA, b: 0xAA, a: 255 },
KeyType::Purple => Rgba8 { r: 0xAA, g: 0x00, b: 0xAA, a: 255 },
KeyType::White => Rgba8 { r: 0xFF, g: 0xFF, b: 0xFF, a: 255 }
};
Glyph { tile: 12, fg, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }
}
}
/// The player's key inventory: one boolean per key color.
///
/// Each field is `true` when the player holds a key of that color. Use
/// [`colors`](Keyring::colors) to iterate all eight in display order, each
/// paired with its [`Rgba8`] color for rendering.
#[derive(Copy, Clone, Default)]
pub struct Keyring {
/// Red key.
pub red: bool,
/// Orange key (custom color, not in the standard EGA palette).
pub orange: bool,
/// Yellow key.
pub yellow: bool,
/// Green key.
pub green: bool,
/// Blue key.
pub blue: bool,
/// Cyan key.
pub cyan: bool,
/// Purple key.
pub purple: bool,
/// White key.
pub white: bool,
}
impl Keyring {
/// Sets the named key slot to `value`. Returns `false` if `name` is not a
/// recognized color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`,
/// `"orange"`, `"yellow"`, `"white"`).
pub fn set_by_name(&mut self, name: &str, value: bool) -> bool {
match name {
"blue" => self.blue = value,
"green" => self.green = value,
"cyan" => self.cyan = value,
"red" => self.red = value,
"purple" => self.purple = value,
"orange" => self.orange = value,
"yellow" => self.yellow = value,
"white" => self.white = value,
_ => return false,
}
true
}
/// Returns all eight keys in display order, each paired with its color.
///
/// Order: blue, green, cyan, red, purple, orange, yellow, white.
pub fn colors(&self) -> [(bool, Rgba8); 8] {
[
(self.blue, KeyType::Blue.glyph().fg),
(self.green, KeyType::Green.glyph().fg),
(self.cyan, KeyType::Cyan.glyph().fg),
(self.red, KeyType::Red.glyph().fg),
(self.purple, KeyType::Purple.glyph().fg),
(self.orange, KeyType::Orange.glyph().fg),
(self.yellow, KeyType::Yellow.glyph().fg),
(self.white, KeyType::White.glyph().fg),
]
}
}
impl Registerable for Keyring {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<Keyring>("Keyring")
.register_get("red", |keyring: &mut Keyring| keyring.red)
.register_get("orange", |keyring: &mut Keyring| keyring.orange)
.register_get("yellow", |keyring: &mut Keyring| keyring.yellow)
.register_get("green", |keyring: &mut Keyring| keyring.green)
.register_get("blue", |keyring: &mut Keyring| keyring.blue)
.register_get("cyan", |keyring: &mut Keyring| keyring.cyan)
.register_get("purple", |keyring: &mut Keyring| keyring.purple)
.register_get("white", |keyring: &mut Keyring| keyring.white);
}
}
+3 -1
View File
@@ -22,10 +22,12 @@ pub mod script;
mod utils; mod utils;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). /// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world; pub mod world;
pub mod player;
pub mod keys;
pub use archetype::{Archetype, Builtin}; pub use archetype::{Archetype, Builtin};
pub use board::Board; pub use board::Board;
pub use utils::Direction; pub use utils::Direction;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod api;
+6 -5
View File
@@ -17,7 +17,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom; use std::convert::TryFrom;
use std::path::Path; use std::path::Path;
use tinyrand::{Seeded, StdRand}; use tinyrand::{Seeded, StdRand};
use crate::api::queue::ObjQueue;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::builtin_scripts::archetype_from_builtin_tag; use crate::builtin_scripts::archetype_from_builtin_tag;
@@ -26,7 +26,7 @@ use crate::glyph::Glyph;
use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer}; use crate::layer::{Layer, LayerData, PaletteEntry, Placement, build_layer};
use crate::log::LogLine; use crate::log::LogLine;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, Player, PortalDef}; use crate::utils::{ObjectId, PlayerPos, PortalDef};
/// The serde shell for one board in a `.toml` file: a header and its layers. /// The serde shell for one board in a `.toml` file: a header and its layers.
/// ///
@@ -245,6 +245,7 @@ impl TryFrom<MapFile> for Board {
// (see `expand_builtin_archetypes` below), not via templates. // (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None, builtin_script: None,
tags: t.tags.into_iter().collect(), tags: t.tags.into_iter().collect(),
queue: ObjQueue::new(),
name, name,
}, },
); );
@@ -277,9 +278,9 @@ impl TryFrom<MapFile> for Board {
width: w, width: w,
height: h, height: h,
layers, layers,
player: Player { player: PlayerPos {
x: px as i32, x: px as i64,
y: py as i32, y: py as i64,
}, },
objects, objects,
next_object_id, next_object_id,
+4
View File
@@ -2,6 +2,7 @@ use crate::glyph::Glyph;
use crate::utils::ObjectId; use crate::utils::ObjectId;
use color::Rgba8; use color::Rgba8;
use std::collections::HashSet; use std::collections::HashSet;
use crate::api::queue::ObjQueue;
/// A scripted object placed on the board, loaded from a map file. /// A scripted object placed on the board, loaded from a map file.
/// ///
@@ -75,6 +76,8 @@ pub struct ObjectDef {
/// Names are validated for uniqueness at map-load time; a duplicate name is /// Names are validated for uniqueness at map-load time; a duplicate name is
/// cleared to `None` (the object survives but becomes anonymous). /// cleared to `None` (the object survives but becomes anonymous).
pub name: Option<String>, pub name: Option<String>,
/// The output queue of actions for this object
pub queue: ObjQueue,
} }
impl ObjectDef { impl ObjectDef {
@@ -107,6 +110,7 @@ impl ObjectDef {
script_name: None, script_name: None,
builtin_script: None, builtin_script: None,
tags: HashSet::new(), tags: HashSet::new(),
queue: ObjQueue::new(),
name: None, name: None,
} }
} }
+56
View File
@@ -0,0 +1,56 @@
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
/// across board transitions. Scripts change it via `alter_health(dh)`, which
/// clamps to `[0, max_health]`. Front-ends display it via the status sidebar.
pub health: i64,
/// The maximum health the player can have. Scripts clamp `alter_health` to
/// this ceiling; the status sidebar uses it to size the heart row.
pub max_health: i64,
/// The player's key inventory. Game-global; persists across board transitions.
pub keys: Keyring,
/// The number of gems the player has collected. Game-global like
/// [`health`](Player::health); starts at `0`.
pub gems: i64,
}
impl Player {
/// Attempt to modify the gem total by the given amount, but maintain a minimum of zero.
/// If the modification would take up below zero, leave it alone and return false.
pub fn alter_gems(&mut self, delta: i64) -> bool {
if delta + self.gems < 0 {
false
} else {
self.gems += delta;
true
}
}
/// Modify the player's health by the given delta, clamped to within the range `[0, max_health]`
pub fn alter_health(&mut self, delta: i64) {
self.health = (self.health + delta).clamp(0, self.max_health);
}
}
impl Default for Player {
fn default() -> Player {
Self {
health: 5,
max_health: 5,
gems: 0,
keys: Keyring::default(),
}
}
}
+250 -766
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
// A gem is a grabbable collectible: walking onto it (or pushing it into the // A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem // player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board. // count and remove ourselves from the board.
fn grab() { fn grab(me, state) {
add_gems(1); add_gems(1);
die(); die();
} }
+8
View File
@@ -0,0 +1,8 @@
// Built-in script for the `heart` archetype.
//
// A heart is a grabbable collectible: walking onto it fires `grab()` instead
// of blocking. It restores 1 health and removes itself from the board.
fn grab(me, state) {
alter_health(1);
die();
}
+25
View File
@@ -0,0 +1,25 @@
// Built-in script for the `gem` archetype (see kiln-core/src/builtin_scripts.rs).
//
// A gem is a grabbable collectible: walking onto it (or pushing it into the
// player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board.
fn grab(me, state) {
let colors = [
"red",
"orange",
"yellow",
"green",
"blue",
"cyan",
"purple",
"white"
];
for c in colors {
if me.has_tag(`BUILTIN_key_${c}`) {
set_key(c, true);
}
}
die();
}
+5 -5
View File
@@ -6,17 +6,17 @@
// pusher shares one compiled copy and learns its direction from the // pusher shares one compiled copy and learns its direction from the
// `BUILTIN_pusher_<dir>` tag the map loader attached to it. // `BUILTIN_pusher_<dir>` tag the map loader attached to it.
fn tick(dt) { fn tick(me, state, dt) {
// Only queue a step when idle. `move` shoves the chain ahead via step_object // Only queue a step when idle. `move` shoves the chain ahead via step_object
// and is a no-op when blocked; the delay pads each step out to ~0.5s (move // and is a no-op when blocked; the delay pads each step out to ~0.5s (move
// itself costs 0.25s), matching the old global pusher heartbeat. // itself costs 0.25s), matching the old global pusher heartbeat.
// //
// The direction is read here, at the top level of the hook, because `Me` is a // The direction is read here, at the top level of the hook, because `Me` is a
// per-object scope constant and is not visible inside other script functions. // per-object scope constant and is not visible inside other script functions.
if Queue.length() == 0 { if !me.waiting {
let dir = if Me.has_tag("BUILTIN_pusher_north") { North } let dir = if me.has_tag("BUILTIN_pusher_north") { North }
else if Me.has_tag("BUILTIN_pusher_south") { South } else if me.has_tag("BUILTIN_pusher_south") { South }
else if Me.has_tag("BUILTIN_pusher_east") { East } else if me.has_tag("BUILTIN_pusher_east") { East }
else { West }; else { West };
move(dir); move(dir);
delay(0.25); delay(0.25);
+13 -13
View File
@@ -8,24 +8,24 @@
// can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous // can't decide that with `can_shift` alone — `can_shift(j) == false` is ambiguous
// between "j is empty" and "j is a blocked solid" — so we seed with `can_shift` // between "j is empty" and "j is a blocked solid" — so we seed with `can_shift`
// and then cascade-demote using `passable` to spot the genuine holes. // and then cascade-demote using `passable` to spot the genuine holes.
fn tick(dt) { fn tick(me, state, dt) {
// Only start a new rotation when the previous one (and its delay) has drained, // Only start a new rotation when the previous one (and its delay) has drained,
// exactly like the built-in pusher's pacing. // exactly like the built-in pusher's pacing.
if Queue.length() != 0 { return; } if me.waiting { return; }
// Direction from the builtin tag; clockwise unless explicitly counter-clockwise. // Direction from the builtin tag; clockwise unless explicitly counter-clockwise.
let cw = !Me.has_tag("BUILTIN_spinner_ccw"); let cw = !me.has_tag("BUILTIN_spinner_ccw");
// The 8 neighbour offsets in clockwise order, starting at North. // The 8 neighbour offsets in clockwise order, starting at North.
let cw_ring = [ let cw_ring = [
[Me.x, Me.y-1], // N [me.x, me.y-1], // N
[Me.x+1, Me.y-1], // NE [me.x+1, me.y-1], // NE
[Me.x+1, Me.y], // E [me.x+1, me.y], // E
[Me.x+1, Me.y+1], // SE [me.x+1, me.y+1], // SE
[Me.x, Me.y+1], // S [me.x, me.y+1], // S
[Me.x-1, Me.y+1], // SW [me.x-1, me.y+1], // SW
[Me.x-1, Me.y], // W [me.x-1, me.y], // W
[Me.x-1, Me.y-1], // NW [me.x-1, me.y-1], // NW
]; ];
// For CCW, it's the same list in reverse // For CCW, it's the same list in reverse
@@ -45,10 +45,10 @@ fn tick(dt) {
// line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in
// the board Registry, keyed per spinner, since script scope resets each tick. // the board Registry, keyed per spinner, since script scope resets each tick.
let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] }; let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] };
let fkey = `spin_${Me.id}`; let fkey = `spin_${me.id}`;
let f = Registry.get_or(fkey, 0); let f = Registry.get_or(fkey, 0);
set_tile(frames[f % 4]); set_tile(frames[f % 4]);
Registry.set(fkey, (f + 1) % 4); Registry.set(fkey, (f + 1) % 4);
delay(0.5); me.delay(0.5);
} }
+11 -40
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
fn move_command_relocates_the_source_object() { fn move_command_relocates_the_source_object() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
// East increments x by one; the object started at (2, 1). // East increments x by one; the object started at (2, 1).
@@ -20,7 +20,7 @@ fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored. // Object at the west edge moving west: out of bounds, ignored.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(West); }")]));
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].x, 0); assert_eq!(game.board().objects[&1].x, 0);
} }
@@ -29,7 +29,7 @@ fn move_into_a_wall_or_edge_is_a_noop() {
fn set_tile_command_changes_the_source_glyph() { fn set_tile_command_changes_the_source_glyph() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); GameState::with_scripts(board, scripts_from(&[("s", "fn init(m,s) { set_tile(7); }")]));
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7); assert_eq!(game.board().objects[&1].glyph.tile, 7);
} }
@@ -40,7 +40,7 @@ fn object_pushes_crate_on_init() {
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
@@ -53,7 +53,7 @@ fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room. // A scripted object moving into the player pushes the player when there's room.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
{ {
let b = game.board(); let b = game.board();
@@ -65,7 +65,7 @@ fn object_push_into_player() {
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]); let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
wall_at(&mut board, 3, 0); wall_at(&mut board, 3, 0);
let mut game = let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); GameState::with_scripts(board, scripts_from(&[("m", "fn init(m,s) { move(East); }")]));
game.run_init(); game.run_init();
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
@@ -79,7 +79,7 @@ fn move_cost_rate_limits_repeated_moves() {
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init() { move(East); move(East); }")]), scripts_from(&[("m", "fn init(m,s) { move(East); move(East); }")]),
); );
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2) assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
@@ -102,7 +102,7 @@ fn inline_delay_paces_subsequent_moves() {
wall_at(&mut board, 2, 1); wall_at(&mut board, 2, 1);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init() { move(East); move(South); }")]), scripts_from(&[("m", "fn init(m,s) { move(East); move(South); }")]),
); );
game.run_init(); game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved. // First (eastward) move is blocked by the wall: object hasn't moved.
@@ -136,7 +136,7 @@ fn queue_length_reports_pending_actions() {
board, board,
scripts_from(&[( scripts_from(&[(
"q", "q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", "fn init(m,s) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -166,7 +166,7 @@ fn blocked_reports_solid_and_clear() {
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", "fn init(m, s) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]), )]),
); );
game.run_init(); game.run_init();
@@ -178,38 +178,9 @@ fn blocked_reports_solid_and_clear() {
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", "fn init(m, s) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]), )]),
); );
game.run_init(); game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7); assert_eq!(game.board().objects[&1].glyph.tile, 7);
} }
#[test]
fn blocked_sees_earlier_objects_pending_move() {
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
// toward that same cell and must see the pending move.
let board = open_board(
5,
3,
(0, 0),
vec![
scripted_object(1, 1, "mover"),
scripted_object(3, 1, "checker"),
],
);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
]),
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1));
assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move
}
+2 -2
View File
@@ -18,11 +18,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() {
scripts_from(&[ scripts_from(&[
( (
"e", "e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", "fn init(m,s) { move(East); } fn bump(m,s,id) { log(`o0 by ${id}`); }",
), ),
( (
"w", "w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", "fn init(m,s) { move(West); } fn bump(m,s,id) { log(`o1 by ${id}`); }",
), ),
]), ]),
); );
+4 -4
View File
@@ -3,14 +3,14 @@ use crate::board::Board;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::layer::Layer; use crate::layer::Layer;
use crate::utils::{Direction, Player, PortalDef}; use crate::utils::{Direction, PlayerPos, PortalDef};
use crate::world::World; use crate::world::World;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::rc::Rc; use std::rc::Rc;
/// Builds a 3×3 board with the player at `(px, py)` and the given portals. /// Builds a 3×3 board with the player at `(px, py)` and the given portals.
fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board { fn make_board(px: i64, py: i64, portals: Vec<PortalDef>) -> Board {
Board { Board {
name: "test".into(), name: "test".into(),
width: 3, width: 3,
@@ -18,7 +18,7 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
layers: vec![Layer { layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); 9], cells: vec![(Glyph::transparent(), Archetype::Empty); 9],
}], }],
player: Player { x: px, y: py }, player: PlayerPos { x: px, y: py },
objects: BTreeMap::new(), objects: BTreeMap::new(),
next_object_id: 1, next_object_id: 1,
portals, portals,
@@ -112,7 +112,7 @@ fn enter_board_unknown_entry_logs_error() {
fn try_move_onto_portal_switches_board() { fn try_move_onto_portal_switches_board() {
let world = two_board_world(); let world = two_board_world();
// Start the player one cell west of the portal at (2, 0). // Start the player one cell west of the portal at (2, 0).
world.boards["b1"].borrow_mut().player = Player { x: 1, y: 0 }; world.boards["b1"].borrow_mut().player = PlayerPos { x: 1, y: 0 };
let mut game = GameState::from_world(world); let mut game = GameState::from_world(world);
game.try_move(Direction::East); game.try_move(Direction::East);
assert_eq!(game.current_board_name(), "b2"); assert_eq!(game.current_board_name(), "b2");
+2 -2
View File
@@ -11,7 +11,7 @@ use crate::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::layer::Layer; use crate::layer::Layer;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::Player; use crate::utils::PlayerPos;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
/// Builds a 1×1 board with a single object that optionally references a script, /// Builds a 1×1 board with a single object that optionally references a script,
@@ -32,7 +32,7 @@ fn board_with_object(
layers: vec![Layer { layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty)], cells: vec![(Glyph::transparent(), Archetype::Empty)],
}], }],
player: Player { x: 0, y: 0 }, player: PlayerPos { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]), objects: BTreeMap::from([(1, object)]),
next_object_id: 2, next_object_id: 2,
portals: Vec::new(), portals: Vec::new(),
+22 -22
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
fn init_runs_only_on_run_init_not_at_construction() { fn init_runs_only_on_run_init_not_at_construction() {
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("greet"), Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)], &[("greet", r#"fn init(m,s) { log("hello"); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
// init must not fire during construction / deserialization. // init must not fire during construction / deserialization.
@@ -22,7 +22,7 @@ fn init_runs_only_on_run_init_not_at_construction() {
fn tick_calls_script_tick_with_elapsed_seconds() { fn tick_calls_script_tick_with_elapsed_seconds() {
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("t"), Some("t"),
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], &[("t", r#"fn tick(m,s,dt) { log(dt.to_string()); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -69,7 +69,7 @@ fn script_reads_board_through_view() {
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]); let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")]), scripts_from(&[("r", "fn init(m,s) { log(s.player.x.to_string()); }")]),
); );
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["3"]); assert_eq!(log_texts(&game), vec!["3"]);
@@ -88,8 +88,8 @@ fn commands_are_routed_to_their_own_source_object() {
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
("e", "fn init() { move(East); }"), ("e", "fn init(m,s) { move(East); }"),
("w", "fn init() { move(West); }"), ("w", "fn init(m,s) { move(West); }"),
]), ]),
); );
game.run_init(); game.run_init();
@@ -127,7 +127,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// present on the object afterward. // present on the object afterward.
let (board, scripts) = board_with_object( let (board, scripts) = board_with_object(
Some("t"), Some("t"),
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)], &[("t", r#"fn init(m,s) { set_tag(m.id, "active", true); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -136,7 +136,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// A script removes a pre-existing tag. // A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object( let (mut board2, scripts2) = board_with_object(
Some("t2"), Some("t2"),
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)], &[("t2", r#"fn init(m,s) { set_tag(m.id, "active", false); }"#)],
); );
// Seed the tag before construction. // Seed the tag before construction.
board2 board2
@@ -158,8 +158,8 @@ fn has_tag_reads_own_tags() {
Some("t"), Some("t"),
&[( &[(
"t", "t",
r#"fn init() { set_tag(Me.id, "active", true); } r#"fn init(m,s) { set_tag(m.id, "active", true); }
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#, fn tick(m,s,dt) { log(m.has_tag("active").to_string()); }"#,
)], )],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -182,8 +182,8 @@ fn objects_with_tag_returns_matching_ids() {
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init() { r#"fn init(m,s) {
let infos = Board.tagged("enemy"); let infos = s.board.tagged("enemy");
log(infos.len().to_string()); log(infos.len().to_string());
log(infos[0].id.to_string()); log(infos[0].id.to_string());
}"#, }"#,
@@ -201,18 +201,18 @@ fn objects_with_tag_returns_matching_ids() {
fn my_name_returns_name_or_empty_string() { fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name(). // An object with a name set on its ObjectDef should see it via my_name().
let (mut board, scripts) = let (mut board, scripts) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m,s) { log(m.name); }"#)]);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]); assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string. // An unnamed object should get ().
let (board2, scripts2) = let (board2, scripts2) =
board_with_object(Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(m,s) { if m.name == () { log("null"); }}"#)]);
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
assert_eq!(log_texts(&game2), vec![""]); assert_eq!(log_texts(&game2), vec!["null"]);
} }
#[test] #[test]
@@ -228,9 +228,9 @@ fn object_id_for_name_finds_by_name() {
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init() { r#"fn init(m,s) {
log(Board.named("target").id.to_string()); log(s.board.named("target").id.to_string());
let miss = Board.named("missing"); let miss = s.board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() }); log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#, }"#,
), ),
@@ -251,7 +251,7 @@ fn player_bump_fires_with_negative_one() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")]), scripts_from(&[("b", "fn bump(m,s,id) { log(`bumped by ${id}`); }")]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
@@ -271,7 +271,7 @@ fn scroll_opens_on_player_bump() {
board, board,
scripts_from(&[( scripts_from(&[(
"s", "s",
r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#, r#"fn bump(m,s,id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]), )]),
); );
game.run_init(); game.run_init();
@@ -295,7 +295,7 @@ fn handle_scroll_without_choice_clears_it() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)]), scripts_from(&[("s", r#"fn bump(m,s,id) { scroll(["Hello"]); }"#)]),
); );
game.run_init(); game.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
@@ -317,7 +317,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[( scripts_from(&[(
"s", "s",
r#" r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn bump(m,s,id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); } fn eat() { log("eaten"); }
"#, "#,
)]), )]),
+81 -14
View File
@@ -1,8 +1,12 @@
use std::cell::RefCell;
use std::fmt::Display;
use std::rc::Rc;
use crate::archetype::Archetype; use crate::archetype::Archetype;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use color::Rgba8; use color::Rgba8;
use rhai::Dynamic; use rhai::Dynamic;
use crate::Board; use crate::Board;
use crate::log::LogLine;
/// Which directions a solid may be pushed in. /// Which directions a solid may be pushed in.
/// ///
@@ -215,8 +219,8 @@ impl Solid {
pub fn place(self, board: &mut Board, x: usize, y: usize) { pub fn place(self, board: &mut Board, x: usize, y: usize) {
match self.kind { match self.kind {
SolidKind::Player => { SolidKind::Player => {
board.player.x = x as i32; board.player.x = x as i64;
board.player.y = y as i32; board.player.y = y as i64;
} }
SolidKind::Object(id) => { SolidKind::Object(id) => {
if let Some(obj) = board.objects.get_mut(&id) { if let Some(obj) = board.objects.get_mut(&id) {
@@ -291,11 +295,11 @@ impl PortalDef {
/// See the "player may become an object" notes in `CLAUDE.md` for the design /// See the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies. /// tensions this implies.
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct Player { pub struct PlayerPos {
/// Column position (0-indexed, increasing rightward). /// Column position (0-indexed, increasing rightward).
pub x: i32, pub x: i64,
/// Row position (0-indexed, increasing downward). /// Row position (0-indexed, increasing downward).
pub y: i32, pub y: i64,
} }
#[cfg(test)] #[cfg(test)]
@@ -325,14 +329,6 @@ mod tests {
} }
} }
/// An optional argument passed to a script function via [`crate::action::Action::Send`].
pub(crate) enum ScriptArg {
/// A string argument.
Str(String),
/// A numeric argument (stored as `f64` to cover both integer and float callers).
Num(f64),
}
/// A cardinal movement direction. /// A cardinal movement direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction { pub enum Direction {
@@ -342,6 +338,37 @@ pub enum Direction {
West, West,
} }
impl Direction {
pub fn char(self) -> char {
match self {
Direction::North => 'n',
Direction::South => 's',
Direction::East => 'e',
Direction::West => 'w',
}
}
pub fn all() -> [Direction; 4] {
[Direction::North, Direction::South, Direction::East, Direction::West]
}
pub fn dx(self) -> i64 {
match self {
Direction::North | Direction::South => 0,
Direction::East => 1,
Direction::West => -1,
}
}
pub fn dy(self) -> i64 {
match self {
Direction::East | Direction::West => 0,
Direction::South => 1,
Direction::North => -1,
}
}
}
/// A value that can be stored in a board's script registry across board transitions. /// A value that can be stored in a board's script registry across board transitions.
/// ///
/// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic` /// Restricted to primitive types that convert cleanly to and from `rhai::Dynamic`
@@ -389,7 +416,7 @@ impl Into<Dynamic> for RegistryValue {
} }
} }
impl From<Direction> for (i32, i32) { impl From<Direction> for (i64, i64) {
/// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down).
fn from(d: Direction) -> Self { fn from(d: Direction) -> Self {
match d { match d {
@@ -400,3 +427,43 @@ impl From<Direction> for (i32, i32) {
} }
} }
} }
#[derive(Copy,Clone,Debug, PartialEq)]
pub enum Hook {
Init, Tick, Bump, Grab
}
impl Hook {
pub fn to_str(self) -> &'static str {
match self {
Hook::Init => "init",
Hook::Bump => "bump",
Hook::Grab => "grab",
Hook::Tick => "tick"
}
}
}
impl Display for Hook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_str())
}
}
/// Channel for engine/compile/runtime errors.
#[derive(Clone)]
pub struct ErrorSink(Rc<RefCell<Vec<LogLine>>>);
impl ErrorSink {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(Vec::new())))
}
pub fn error(&self, msg: String) {
self.0.borrow_mut().push(LogLine::error(msg));
}
pub fn take(&mut self) -> Vec<LogLine> {
std::mem::take(&mut self.0.borrow_mut())
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
**`kiln-tui/src/editor.rs`** — world editor mode: **`kiln-tui/src/editor.rs`** — world editor mode:
- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`). - `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Items`) — a sidebar menu level; (`Main``[i] Items``[t] ♦ Gem`, which `set_current(Archetype::Builtin(Builtin::Gem, "gem"))`); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`), **sets the drawing tool** (`Terrain`/`Pushers``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor. - `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Items`) — a sidebar menu level; (`Main``[i] Items``[t] ♦ Gem` / `[h] ♥ Heart`, which call `set_current(Archetype::Builtin(Builtin::Gem, "gem"))` / `set_current(Archetype::Builtin(Builtin::Heart, "heart"))` respectively); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main``World`/`Floor`/`Terrain`/`Machines`; `Machines``Pushers`), **sets the drawing tool** (`Terrain`/`Pushers``set_current(arch)`, plus `Machines``[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main``[p] Play board``playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). - Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`. - Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. - `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
@@ -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) `♥` 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)`.
+18 -6
View File
@@ -13,7 +13,7 @@ use crate::editor_menu::{MenuEntry, MenuLevel};
use crate::log::{LogWidget, log_preview_line}; use crate::log::{LogWidget, log_preview_line};
use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui; use crate::ui::Ui;
use crate::utils::rgba8_to_color; use crate::utils::{glyph_to_span, rgba8_to_color};
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
use kiln_core::game::GameState; use kiln_core::game::GameState;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
@@ -100,6 +100,10 @@ impl EditorState {
/// Builds an editor session for `world`, starting on its designated start board. /// Builds an editor session for `world`, starting on its designated start board.
pub(crate) fn new(world: World) -> Self { pub(crate) fn new(world: World) -> Self {
let board_name = world.start.clone(); let board_name = world.start.clone();
let cursor = {
let b = world.boards[&board_name].borrow();
(b.width as i32 / 2, b.height as i32 / 2)
};
Self { Self {
world, world,
board_name, board_name,
@@ -107,7 +111,7 @@ impl EditorState {
dialog: None, dialog: None,
code_editor: None, code_editor: None,
glyph_dialog: None, glyph_dialog: None,
cursor: (0, 0), cursor,
current_archetype: Archetype::Wall, current_archetype: Archetype::Wall,
current_glyph: Archetype::Wall.default_glyph(), current_glyph: Archetype::Wall.default_glyph(),
draw_mode: false, draw_mode: false,
@@ -296,7 +300,7 @@ impl EditorState {
/// board (right-click handling). No-op when the click is off the board. When draw /// board (right-click handling). No-op when the click is off the board. When draw
/// mode is on and the cursor lands on a new cell, stamps the current thing there. /// mode is on and the cursor lands on a new cell, stamps the current thing there.
pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) { pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) {
let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy); let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy, self.cursor);
if let Some((bx, by)) = cell { if let Some((bx, by)) = cell {
let target = (bx as i32, by as i32); let target = (bx as i32, by as i32);
let moved = target != self.cursor; let moved = target != self.cursor;
@@ -413,12 +417,12 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
} }
let inner = block.inner(board_area); let inner = block.inner(board_area);
frame.render_widget(block, board_area); frame.render_widget(block, board_area);
frame.render_widget(BoardWidget::new(&board), inner); frame.render_widget(BoardWidget::new(&board).with_focus(ed.cursor.0, ed.cursor.1), inner);
// Overlay the cursor during its visible phase (a light-gray solid block). // Overlay the cursor during its visible phase (a light-gray solid block).
if ed.blink_on { if ed.blink_on {
let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize); let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize);
if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy) if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy, (ed.cursor.0, ed.cursor.1))
&& let Some(cell) = frame.buffer_mut().cell_mut((sx, sy)) && let Some(cell) = frame.buffer_mut().cell_mut((sx, sy))
{ {
cell.set_char(CURSOR_CHAR) cell.set_char(CURSOR_CHAR)
@@ -454,6 +458,14 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
Span::styled(label, label_style), Span::styled(label, label_style),
])); ]));
} }
MenuEntry::Glyph { key, glyph, label, .. } => {
lines.push(Line::from(vec![
Span::styled(format!("[{}] ", key), key_style),
glyph_to_span(glyph),
Span::raw(" "),
Span::styled(label, label_style),
]));
}
MenuEntry::Blank => lines.push(Line::from("")), MenuEntry::Blank => lines.push(Line::from("")),
// A horizontal rule spanning the sidebar's inner width. // A horizontal rule spanning the sidebar's inner width.
MenuEntry::Separator => lines.push(Line::from(Span::styled( MenuEntry::Separator => lines.push(Line::from(Span::styled(
@@ -510,7 +522,7 @@ fn draw_footer_lines<'a>(
// The archetype currently being drawn (no UI to change it yet). // The archetype currently being drawn (no UI to change it yet).
Line::from(Span::styled(ed.current_archetype.name(), label_style)), Line::from(Span::styled(ed.current_archetype.name(), label_style)),
Line::from(vec![ Line::from(vec![
Span::styled("[g] ", key_style), Span::styled("[G] ", key_style),
Span::styled("Glyph ", label_style), Span::styled("Glyph ", label_style),
preview, preview,
]), ]),
+43 -2
View File
@@ -2,6 +2,8 @@ use crate::editor::EditorState;
use crate::menu::{MenuItem, MenuKey}; use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode; use crate::mode::PendingMode;
use kiln_core::{Archetype, Builtin}; use kiln_core::{Archetype, Builtin};
use kiln_core::keys::KeyType;
use kiln_core::glyph::Glyph;
/// One level in the editor's sidebar menu tree. /// One level in the editor's sidebar menu tree.
/// ///
@@ -100,10 +102,23 @@ impl MenuLevel {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west")) ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
}), }),
], ],
MenuLevel::Items => vec![MenuEntry::item('t', "♦ Gem", |ed| { MenuLevel::Items => vec![
MenuEntry::glyph('t', Archetype::Builtin(Builtin::Gem, "gem").default_glyph(), "Gem", |ed| {
// 't' for 'treasure' — 'g' conflicts with glyph picker // 't' for 'treasure' — 'g' conflicts with glyph picker
ed.set_current(Archetype::Builtin(Builtin::Gem, "gem")) ed.set_current(Archetype::Builtin(Builtin::Gem, "gem"))
})], }),
MenuEntry::glyph('h', Archetype::Builtin(Builtin::Heart, "heart").default_glyph(), "Heart",
|ed| ed.set_current(Archetype::Builtin(Builtin::Heart, "heart"))),
MenuEntry::Separator,
MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_blue"))),
MenuEntry::glyph('g', KeyType::Green.glyph(),"Green key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_green"))),
MenuEntry::glyph('c', KeyType::Cyan.glyph(),"Cyan key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_cyan"))),
MenuEntry::glyph('r', KeyType::Red.glyph(),"Red key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_red"))),
MenuEntry::glyph('p', KeyType::Purple.glyph(),"Purple key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_purple"))),
MenuEntry::glyph('o', KeyType::Orange.glyph(), "Orange key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_orange"))),
MenuEntry::glyph('y', KeyType::Yellow.glyph(), "Yellow key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_yellow"))),
MenuEntry::glyph('w', KeyType::White.glyph(), "White key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_white"))),
],
} }
} }
@@ -113,6 +128,7 @@ impl MenuLevel {
pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) { pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) {
let action = self.entries().into_iter().find_map(|entry| match entry { let action = self.entries().into_iter().find_map(|entry| match entry {
MenuEntry::Item { key, action, .. } if key == ch => Some(action), MenuEntry::Item { key, action, .. } if key == ch => Some(action),
MenuEntry::Glyph { key, action, .. } if key == ch => Some(action),
_ => None, _ => None,
}); });
if let Some(action) = action { if let Some(action) = action {
@@ -134,6 +150,17 @@ pub(crate) enum MenuEntry {
/// Run when the user presses `key`; mutates the editor session. /// Run when the user presses `key`; mutates the editor session.
action: Box<dyn FnOnce(&mut EditorState)>, action: Box<dyn FnOnce(&mut EditorState)>,
}, },
/// An item labeled by a styled Glyph
Glyph {
/// The letter the user presses to activate this item.
key: char,
/// The glyph to show next to the label
glyph: Glyph,
/// Display label shown after the `[key]` in the sidebar.
label: String,
/// Run when the user presses `key`; mutates the editor session.
action: Box<dyn FnOnce(&mut EditorState)>,
},
/// A blank spacer line. Part of the menu API; no current level uses one yet. /// A blank spacer line. Part of the menu API; no current level uses one yet.
#[allow(dead_code)] #[allow(dead_code)]
Blank, Blank,
@@ -156,6 +183,20 @@ impl MenuEntry {
} }
} }
pub(crate) fn glyph<F: FnOnce(&mut EditorState) + 'static>(
key: char,
glyph: Glyph,
label: impl Into<String>,
action: F,
) -> Self {
MenuEntry::Glyph {
key,
glyph,
label: label.into(),
action: Box::new(action),
}
}
pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self { pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self {
MenuEntry::CurrentValue(Box::new(action)) MenuEntry::CurrentValue(Box::new(action))
} }
+2 -2
View File
@@ -131,7 +131,7 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat
KeyCode::Right => ed.move_cursor(1, 0), KeyCode::Right => ed.move_cursor(1, 0),
KeyCode::Char('l') => ui.log.toggle(), KeyCode::Char('l') => ui.log.toggle(),
// `g` opens the glyph picker to edit the current drawing glyph. // `g` opens the glyph picker to edit the current drawing glyph.
KeyCode::Char('g') => ed.open_glyph_picker(), KeyCode::Char('G') => ed.open_glyph_picker(),
// Space stamps the current thing; Tab toggles draw mode. // Space stamps the current thing; Tab toggles draw mode.
KeyCode::Char(' ') => ed.place_current(), KeyCode::Char(' ') => ed.place_current(),
KeyCode::Tab => ed.toggle_draw_mode(), KeyCode::Tab => ed.toggle_draw_mode(),
@@ -143,7 +143,7 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat
}, },
Event::Mouse(m) => match m.kind { Event::Mouse(m) => match m.kind {
// Right-click moves the cursor to the clicked cell (if on the board). // Right-click moves the cursor to the clicked cell (if on the board).
MouseEventKind::Down(MouseButton::Right) => ed.set_cursor_at_screen(m.column, m.row), MouseEventKind::Down(MouseButton::Left) => ed.set_cursor_at_screen(m.column, m.row),
// Dragging the divider resizes the sidebar. // Dragging the divider resizes the sidebar.
MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w), MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w),
_ => {} _ => {}
+1 -1
View File
@@ -342,7 +342,7 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
.spacing(Spacing::Overlap(1)) .spacing(Spacing::Overlap(1))
.areas(frame.area()); .areas(frame.area());
frame.render_widget( frame.render_widget(
StatusSidebarWidget::new(game.player_health - 2, game.player_gems), StatusSidebarWidget(game.player),
sidebar_area, sidebar_area,
); );
rest rest
+44 -18
View File
@@ -13,16 +13,27 @@ use ratatui::layout::Rect;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a /// A ratatui widget that draws a [`Board`] (with objects and the player) into a
/// rectangular area, scrolled so the player stays visible on larger boards. /// rectangular area, scrolled so a chosen focus cell stays visible on larger boards.
pub struct BoardWidget<'a> { pub struct BoardWidget<'a> {
/// The board to render. Borrowed for the duration of the draw. /// The board to render. Borrowed for the duration of the draw.
pub board: &'a Board, pub board: &'a Board,
/// The board cell (x, y) the viewport scrolls to keep visible. Defaults to
/// the player position so play mode requires no extra setup.
focus: (i32, i32),
} }
impl<'a> BoardWidget<'a> { impl<'a> BoardWidget<'a> {
/// Creates a widget that renders `board`. /// Creates a widget that renders `board`, scrolling to follow the player.
pub fn new(board: &'a Board) -> Self { pub fn new(board: &'a Board) -> Self {
Self { board } let focus = (board.player.x as i32, board.player.y as i32);
Self { board, focus }
}
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
/// cursor instead of the player.
pub fn with_focus(mut self, x: i32, y: i32) -> Self {
self.focus = (x, y);
self
} }
/// Lays out one axis of the board within a viewport `view` cells long. /// Lays out one axis of the board within a viewport `view` cells long.
@@ -51,17 +62,25 @@ impl<'a> BoardWidget<'a> {
} }
/// Maps a board cell `(bx, by)` to its screen position within `area`, using the /// Maps a board cell `(bx, by)` to its screen position within `area`, using the
/// same centering/scrolling layout as [`BoardWidget`]. Returns `None` when the /// same centering/scrolling layout as [`BoardWidget`] with the given `focus`.
/// cell is scrolled out of view. /// Returns `None` when the cell is scrolled out of view.
///
/// Pass `(board.player.x, board.player.y)` as `focus` in play mode, or the
/// editor cursor coordinates in editor mode, to match the active viewport.
/// ///
/// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they /// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they
/// stay consistent with how the board is actually drawn. (Unlike /// stay consistent with how the board is actually drawn. (Unlike
/// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this /// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this
/// reports off-screen cells as `None` — needed for exact cursor placement.) /// reports off-screen cells as `None` — needed for exact cursor placement.)
pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { pub fn board_to_screen(
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); area: Rect,
let (off_y, pad_y, rows) = board: &Board,
BoardWidget::axis(board.height, area.height as usize, board.player.y); bx: usize,
by: usize,
focus: (i32, i32),
) -> Option<(u16, u16)> {
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, focus.0);
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, focus.1);
// Cell must fall within the visible window on both axes. // Cell must fall within the visible window on both axes.
if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows { if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows {
return None; return None;
@@ -72,12 +91,19 @@ pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Optio
} }
/// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`, /// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`,
/// the inverse of [`board_to_screen`]. Returns `None` when the point lies outside /// the inverse of [`board_to_screen`]. `focus` must match the focus used when
/// the drawn board (in the centering pad or past the visible cells). /// the board was rendered so the coordinate mapping is consistent.
pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> { /// Returns `None` when the point lies outside the drawn board (in the centering
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); /// pad or past the visible cells).
let (off_y, pad_y, rows) = pub fn screen_to_board(
BoardWidget::axis(board.height, area.height as usize, board.player.y); area: Rect,
board: &Board,
sx: u16,
sy: u16,
focus: (i32, i32),
) -> Option<(usize, usize)> {
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, focus.0);
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, focus.1);
// Screen-space offset from the first drawn cell; reject anything before it. // Screen-space offset from the first drawn cell; reject anything before it.
let col = (sx as i32) - (area.x as i32 + pad_x as i32); let col = (sx as i32) - (area.x as i32 + pad_x as i32);
let row = (sy as i32) - (area.y as i32 + pad_y as i32); let row = (sy as i32) - (area.y as i32 + pad_y as i32);
@@ -118,9 +144,9 @@ impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let board = self.board; let board = self.board;
// Resolve each axis independently: a small board is centered, a large // Resolve each axis independently: a small board is centered, a large
// one scrolls to keep the player on screen. // one scrolls to keep the focus cell on screen.
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, board.player.x); let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, self.focus.0);
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, board.player.y); let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, self.focus.1);
// Walk the visible board cells, placing each after the centering pad. // Walk the visible board cells, placing each after the centering pad.
for row in 0..rows { for row in 0..rows {
+2 -2
View File
@@ -409,8 +409,8 @@ fn center_top(obj_sy: u16, box_h: u16, area: Rect) -> u16 {
/// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge; /// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge;
/// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble). /// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble).
fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) { fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) {
let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x); let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y); let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32);
let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32); let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32);
let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32); let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32);
let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16; let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16;
+28 -20
View File
@@ -5,6 +5,7 @@
//! presentational: it reads `health`/`gems` values handed in at construction and //! presentational: it reads `health`/`gems` values handed in at construction and
//! never mutates game state. //! never mutates game state.
use std::collections::VecDeque;
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use kiln_core::{Archetype, Builtin}; use kiln_core::{Archetype, Builtin};
use kiln_core::cp437::tile_to_char; use kiln_core::cp437::tile_to_char;
@@ -14,9 +15,8 @@ use ratatui::style::{Color, Style};
use ratatui::symbols::merge::MergeStrategy; use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use kiln_core::player::Player;
/// How many hearts the health row always shows (the player's max health).
const MAX_HEARTS: u32 = 5;
/// A filled heart: bright red. /// A filled heart: bright red.
const HEART_FULL: Color = Color::Rgb(255, 0, 0); const HEART_FULL: Color = Color::Rgb(255, 0, 0);
/// A depleted heart: dark red. /// A depleted heart: dark red.
@@ -24,28 +24,18 @@ const HEART_EMPTY: Color = Color::Rgb(90, 0, 0);
/// A ratatui widget that renders the play-mode status sidebar. /// A ratatui widget that renders the play-mode status sidebar.
/// ///
/// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs (the first /// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs, a
/// `health` filled bright red, the rest dark red) and a `Gems: ♦ N` line. /// `Gems: ♦ N` line, and a `Keys:` row of 8 `♀` glyphs colored when held
pub struct StatusSidebarWidget { /// and dark gray when absent.
/// The player's current health, clamped to [`MAX_HEARTS`] when drawn. pub struct StatusSidebarWidget(pub Player);
health: u32,
/// The number of gems collected, shown as a count.
gems: u32,
}
impl StatusSidebarWidget {
/// Builds a sidebar widget for the given player stats.
pub fn new(health: u32, gems: u32) -> Self {
Self { health, gems }
}
}
impl Widget for StatusSidebarWidget { impl Widget for StatusSidebarWidget {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let player = self.0;
// One bright-red span per filled heart, dark-red for the rest, so a // One bright-red span per filled heart, dark-red for the rest, so a
// partly-depleted bar reads at a glance. // partly-depleted bar reads at a glance.
let filled = self.health.min(MAX_HEARTS); let filled = player.health;
let hearts: Vec<Span> = (0..MAX_HEARTS) let hearts: Vec<Span> = (0..player.max_health)
.map(|i| { .map(|i| {
let color = if i < filled { HEART_FULL } else { HEART_EMPTY }; let color = if i < filled { HEART_FULL } else { HEART_EMPTY };
Span::styled("", Style::default().fg(color)) Span::styled("", Style::default().fg(color))
@@ -58,6 +48,22 @@ impl Widget for StatusSidebarWidget {
let gem_char = tile_to_char(gem_glyph.tile).to_string(); let gem_char = tile_to_char(gem_glyph.tile).to_string();
let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg)); let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg));
// Build the key row: 8 ♀ glyphs, colored when held, near-black when absent.
let mut key_spans: VecDeque<_> = player
.keys
.colors()
.iter()
.filter_map(|(held, color)| {
if *held {
let fg = rgba8_to_color(*color);
Some(Span::styled("", Style::default().fg(fg)))
} else {
None
}
})
.collect();
key_spans.push_front(Span::raw("Keys: "));
let lines = vec![ let lines = vec![
Line::from("Health:"), Line::from("Health:"),
Line::from(hearts), Line::from(hearts),
@@ -65,8 +71,10 @@ impl Widget for StatusSidebarWidget {
Line::from(vec![ Line::from(vec![
Span::raw("Gems: "), Span::raw("Gems: "),
Span::styled(gem_char, gem_style), Span::styled(gem_char, gem_style),
Span::raw(format!(" {}", self.gems)), Span::raw(format!("{} ", player.gems)),
]), ]),
Line::from(""),
Line::from_iter(key_spans.into_iter()),
]; ];
// A bordered block; `merge_borders` joins its right edge with the board // A bordered block; `merge_borders` joins its right edge with the board
+15
View File
@@ -1,6 +1,10 @@
use color::Rgba8; use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::Color; use ratatui::prelude::Color;
use ratatui::style::Style;
use ratatui::text::Span;
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
/// ///
@@ -10,6 +14,17 @@ pub fn rgba8_to_color(c: Rgba8) -> Color {
Color::Rgb(c.r, c.g, c.b) Color::Rgb(c.r, c.g, c.b)
} }
/// Converts a [`Glyph`] to a single-character styled [`Span`].
///
/// The tile index is mapped to a CP437 character; fg and bg are both applied.
pub fn glyph_to_span(glyph: Glyph) -> Span<'static> {
let ch = tile_to_char(glyph.tile).to_string();
let style = Style::default()
.fg(rgba8_to_color(glyph.fg))
.bg(rgba8_to_color(glyph.bg));
Span::styled(ch, style)
}
/// Returns true if two `Rect`s share at least one cell. /// Returns true if two `Rect`s share at least one cell.
pub fn rects_overlap(a: Rect, b: Rect) -> bool { pub fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
+32 -32
View File
@@ -6,65 +6,65 @@ start = "start"
# Objects reference a script by name via `script_name`. # Objects reference a script by name via `script_name`.
[scripts] [scripts]
greeter = """ greeter = """
fn init() { fn init(me, state) {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`); log(`hello from object player at ${state.player.x}, ${state.player.y}`);
set_tile(2); // change my glyph to proves the write path set_tile(2); // change my glyph to proves the write path
say("Hello there,\\ntraveller!"); say("Hello there,\\ntraveller!");
log(`Player health: ${state.player.health}`);
} }
fn tick(dt) { fn tick(me, state, dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`); if state.player.x == me.x && state.player.y == me.y && !me.waiting {
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
say("Hey! Get offa me!"); say("Hey! Get offa me!");
delay(5.0); me.delay(5.0);
} }
} }
""" """
mover = """ mover = """
fn init() { fn init(me, state) {
if Registry.get("dancer_x") != () { if Registry.get("dancer_x") != () {
let new_x = Registry.get("dancer_x"); let new_x = Registry.get("dancer_x");
let new_y = Registry.get("dancer_y"); let new_y = Registry.get("dancer_y");
if Me.x != new_x || Me.y != new_y { if me.x != new_x || me.y != new_y {
teleport(new_x, new_y); teleport(new_x, new_y);
} }
} else { } else {
Registry.set("dancer_x", Me.x); Registry.set("dancer_x", me.x);
Registry.set("dancer_y", Me.y); Registry.set("dancer_y", me.y);
log(`Set reg to ${Me.x} ${Me.y}`); log(`Set reg to ${me.x} ${me.y}`);
} }
} }
// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often // move() costs 250 ms, so the object steps ~4 cells/sec no matter how often
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking // tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick). // into a wall (or another object's already-queued move this tick).
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 { dance(); } if !me.waiting { dance(me); }
} }
fn dance() { fn dance(me) {
move(East); move(East);
move(South); move(South);
move(North); move(North);
move(East); move(East);
say("Hey!", 0.5); say("Hey!", 0.5);
delay(0.5); delay(me, 0.5);
move(West); move(West);
move(South); move(South);
move(North); move(North);
move(West); move(West);
say("Ho!", 0.5); say("Ho!", 0.5);
delay(0.5); me.delay(0.5);
} }
// Fires when something steps into this object; id is the bumper's object id, // Fires when something steps into this object; id is the bumper's object id,
// or -1 for the player. // or -1 for the player.
fn bump(id) { fn bump(me, state, id) {
log(`mover bumped by ${id}`); log(`mover bumped by ${id}`);
say("Ow!"); say("Ow!");
} }
""" """
muffin = """ muffin = """
fn bump(id) { fn bump(me, state, _id) {
scroll([ scroll([
"You find a small muffin on the ground, your favorite kind.", "You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.", "It smells of cinnamon and warm mornings.",
@@ -72,9 +72,9 @@ fn bump(id) {
["ignore", "This is obviously a trap — ignore it."] ["ignore", "This is obviously a trap — ignore it."]
]); ]);
} }
fn eat() { fn eat(me, state) {
log("You ate the muffin. Delicious."); say("Yeah it was poisoned.");
say("Mmm!"); alter_health(-2);
} }
fn ignore() { fn ignore() {
log("You walk away from the muffin. Wise."); log("You walk away from the muffin. Wise.");
@@ -83,7 +83,7 @@ fn ignore() {
""" """
noticeboard = """ noticeboard = """
fn bump(id) { fn bump(me, state, id) {
scroll([ scroll([
" TOWN NOTICE BOARD", " TOWN NOTICE BOARD",
" ", " ",
@@ -110,13 +110,13 @@ fn bump(id) {
"dusk. All residents are warmly encouraged to attend.", "dusk. All residents are warmly encouraged to attend.",
" ", " ",
" Posted by order of the Town Council.", " Posted by order of the Town Council.",
" Unauthorised notices will be removed.", " Unauthorized notices will be removed.",
]); ]);
} }
""" """
bookshelf = """ bookshelf = """
fn bump(id) { fn bump(me, state, id) {
scroll([ scroll([
" THE BOOKSHELF", " THE BOOKSHELF",
" ", " ",
@@ -135,13 +135,13 @@ fn bump(id) {
""" """
fireplace = """ fireplace = """
fn bump(id) { fn bump(me, state, id) {
say("The fire crackles warmly.\\nYou feel at ease."); say("The fire crackles warmly.\\nYou feel at ease.");
} }
""" """
chest = """ chest = """
fn bump(id) { fn bump(me, state, id) {
scroll([ scroll([
" THE CHEST", " THE CHEST",
" ", " ",
@@ -159,8 +159,8 @@ fn bump(id) {
""" """
yammerer = """ yammerer = """
fn tick(dt) { fn tick(me, state, dt) {
if Queue.length() == 0 { if !me.waiting {
say("blahblahblah"); say("blahblahblah");
delay(1); delay(1);
} }
@@ -168,11 +168,11 @@ fn tick(dt) {
""" """
shifter = """ shifter = """
fn tick(dt) { fn tick(me, state, dt) {
let x = Me.x; let x = me.x;
let y = Me.y; let y = me.y;
if Queue.length() == 0 { if !me.waiting {
shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]); shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]);
delay(0.25); delay(0.25);
} }
+44
View File
@@ -0,0 +1,44 @@
[world]
name = "Tiny"
start = "start"
# Named Rhai scripts shared across all boards in this world.
# Objects reference a script by name via `script_name`.
[scripts]
greeter = """
fn init(me, state) {
log(`hello from object player at ${state.player.x}, ${state.player.y}`);
set_tile(2); // change my glyph to proves the write path
//say("Hello there,\\ntraveller!");
log(`Player health: ${state.player.health}`);
}
fn tick(me, state, dt) {
if state.player.x == me.x && state.player.y == me.y && !me.waiting {
say("Hey! Get offa me!");
me.queue.delay(5.0);
}
}
"""
[boards.start.map]
name = "Starting Room"
width = 21
height = 6
# Layer 1: terrain, the player, objects and the portal. A space is always a
# transparent empty cell, so the floor below shows through.
[[boards.start.layers]]
content = """
#####################
# #
# G @ #
# oo #
# #
#####################
"""
[boards.start.layers.palette]
"#" = { kind = "wall", tile = "#", fg = "#808080", bg = "#606060" }
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
"@" = { kind = "player" }
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000", solid = false, script_name = "greeter" }