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" }
```
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)`).
Generated
+4 -3
View File
@@ -754,6 +754,7 @@ name = "kiln-core"
version = "0.1.0"
dependencies = [
"color",
"log",
"rhai",
"serde",
"tinyrand",
@@ -850,9 +851,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
@@ -1109,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+269 -178
View File
@@ -1,17 +1,25 @@
# Kiln Script API Reference
Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map
file. Each scripted object references a script by name via `script_name`. A script may define up to
three lifecycle hooks; any or all may be omitted.
file. Each scripted object references a script by name via `script_name`. A script defines lifecycle
hooks (any may be omitted) and may define arbitrary handler functions reachable via `send()` and
scroll choices.
> **Status:** this document describes the API as it currently exists. It has grown organically and
> has known rough edges — duplication, naming inconsistency, and some unwieldy machinery. Those are
> catalogued in [Design notes](#design-notes-rough-edges) at the end, as the starting point for a
> redesign.
---
## Lifecycle hooks
Hooks are detected by name **and** arity (parameter count). A function with the right name but wrong
number of parameters is not recognized as a hook.
### `fn init()`
Called once for every scripted object after the entire map is loaded, before the first frame.
Use it to set up initial tile appearance, log a startup message, etc.
```rhai
fn init() {
@@ -22,9 +30,8 @@ fn init() {
### `fn tick(dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement
and other timed actions pace themselves through the queue; you do not need to track time manually
for simple walking patterns.
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Timed
actions pace themselves through the queue; you do not track time manually for simple walking.
```rhai
fn tick(dt) {
@@ -39,60 +46,155 @@ fn tick(dt) {
Called when another entity walks into this object's cell.
- `id` is the bumper's `ObjectId` (an `i64`).
- `id == -1` means the player bumped this object.
- `id == -1` means the **player** bumped this object.
```rhai
fn bump(id) {
if id == -1 {
say("Hey! Watch it.");
} else {
log(`object ${id} bumped me`);
}
if id == -1 { say("Hey! Watch it."); }
else { log(`object ${id} bumped me`); }
}
```
### `fn grab()`
Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts —
`solid + grab`). Unlike `bump`, the player ends up on the object's cell; the hook typically adjusts a
player stat and removes the object. It fires followed by an immediate resolve, so any `die()` /
`add_gems()` / `alter_health()` applies before the player's move returns.
```rhai
fn grab() {
add_gems(1);
die();
}
```
> Grab is **only** triggered by the player walking onto the object. A grab thing *pushed* or
> *shifted* into the player is treated as an ordinary solid (no `grab()`).
### Custom handler functions
Any other function can be invoked on an object two ways:
- Another object calls `send(target_id, "fn_name" [, arg])`.
- A `scroll()` **choice** is selected: the choice key is dispatched to the source object as a
zero-argument function call of that name.
A handler may take **zero or one** parameter; the host calls the 1-arg form when an arg is supplied,
else the 0-arg form. If neither arity exists, the call is silently dropped.
```rhai
// reached via send(my_id, "open") or a scroll choice keyed "open"
fn open() { set_tile(47); }
fn set_level(n){ set_tag(Me.id, "level", true); log(`level ${n}`); }
```
---
## Constants in scope
These are pre-set in every object's scope and cannot be reassigned.
Pre-set in every object's scope. The object handles are scope constants; direction/color constants
come from a global module visible at any call depth (including Rhai→Rhai calls).
| Name | Type | Description |
|------|------|-------------|
| `Board` | `Board` | Read-only handle to the game world. |
| `Queue` | `Queue` | Handle to this object's own pending-action queue. |
| `North` | `Direction` | Cardinal direction: up (y 1). |
| `South` | `Direction` | Cardinal direction: down (y + 1). |
| `East` | `Direction` | Cardinal direction: right (x + 1). |
| `West` | `Direction` | Cardinal direction: left (x 1). |
| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. |
| `Board` | `Board` | Read-only handle to the current board / world. |
| `Queue` | `Queue` | This object's own pending-action queue. |
| `Me` | `Me` | Self-reference (id, name, position, tags, glyph). |
| `Registry` | `Registry` | Board-scoped key→value store; persists across board transitions. |
| `Player` | `Player` | Read-only snapshot of the player's game-global stats. |
| `North` / `South` / `East` / `West` | `Direction` | Cardinal directions. |
| `Black` `White` (16) | `String` | EGA/VGA palette colors as `"#RRGGBB"` strings. |
The 16 color names, in palette order: `Black`, `Blue`, `Green`, `Cyan`, `Red`, `Magenta`, `Brown`,
`LightGray`, `DarkGray`, `BrightBlue`, `BrightGreen`, `BrightCyan`, `BrightRed`, `BrightMagenta`,
`Yellow`, `White`. They are plain hex strings, so they are accepted anywhere a color string is (e.g.
`set_fg(BrightRed)`).
> There is **no** `MY_ID` constant any more — use `Me.id`.
---
## Read functions
## Self-reference: `Me`
### `Board` getters
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `Me.id` | getter | `i64` | This object's stable `ObjectId`. |
| `Me.name` | getter | `String` | Name, or `""` if unnamed. |
| `Me.x` / `Me.y` | getter | `i64` | Current cell position. |
| `Me.has_tag(tag)` | method | `bool` | Whether this object carries `tag`. |
| `Me.tags()` | method | `Array` | All tag strings on this object. |
| `Me.glyph()` | method | `Glyph` | This object's glyph (`.tile`, `.fg`, `.bg`). |
Access via `Board.<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 |
|----------|------|-------------|
| `Board.player_x` | `i64` | Player's current column (0-indexed). |
| `Board.player_y` | `i64` | Player's current row (0-indexed). |
| `Board.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. |
| `Player.gems` | `i64` | Gems collected. |
| `Player.health` | `i64` | Current health. |
| `Player.max_health` | `i64` | Health ceiling. |
```rhai
let dist_x = (Board.player_x - 30).abs();
if Player.health < 2 { say("You look hurt."); }
```
### `blocked(dir) -> bool`
> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it).
Returns `true` if moving in `dir` from this object's current cell would be impossible — because
the target cell is out of bounds, holds a solid, or another object has already queued a move there.
---
Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so
an object cannot accidentally block itself.
## Board reads
### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Board.player_x` | `i64` | Player's column. |
| `Board.player_y` | `i64` | Player's row. |
| `Board.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. |
### Object lookups
| Call | Returns | Description |
|------|---------|-------------|
| `Board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag`. |
| `Board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `Board.get(id)` | `ObjectInfo` or `()` | Look up by id; `-1` returns the player; an invalid/unknown id logs an error and returns unit. |
An `ObjectInfo` exposes `.id`, `.x`, `.y`, `.name` (`""` if unnamed) and `.tags` (an `Array`). Note
it has **no** `has_tag()` or `glyph()` — those exist only on `Me`.
```rhai
let enemies = Board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);
let door = Board.named("door");
if door != () { send(door.id, "open"); }
```
---
## Cell queries (free functions)
| Call | Returns | Description |
|------|---------|-------------|
| `blocked(dir)` | `bool` | Would the **caller** be unable to move one step in `dir` (off-board, a solid, or another object's already-queued move targets that cell). The caller's own queued moves don't count. |
| `can_push(x, y, dir)` | `bool` | Does a pushable chain starting at `(x, y)` end in open space when shoved in `dir`? `false` off-board. |
| `can_shift(x, y, dir)` | `bool` | Like `can_push` but checks only the **one** cell ahead (pushable source + empty-or-pushable neighbor). The right gate for `shift()`. |
| `passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
| `combinable(x1, y1, x2, y2)` | `bool` | Can the solids at the two cells share a cell (one empty, or a grab thing + the player)? |
```rhai
fn tick(dt) {
@@ -100,157 +202,93 @@ fn tick(dt) {
}
```
### `has_tag(tag) -> bool`
---
Returns `true` if this object has the named tag.
## Registry (shared key→value store)
Board-scoped, persists across board transitions. Stores primitive values.
| Call | Description |
|------|-------------|
| `Registry.get(key)` | Stored value, or `()` if absent. |
| `Registry.set(key, value)` | Store a primitive; passing `()` removes the key; unsupported types are silently ignored. |
| `Registry.get_or(key, default)` | Stored value **only if** it has the same type as `default`; otherwise `default`. |
```rhai
if has_tag("enemy") { say("I'm an enemy!"); }
```
### `get_tags() -> Array`
Returns an array of strings listing every tag on this object.
```rhai
for t in get_tags() { log(t); }
```
### `objects_with_tag(tag) -> Array`
Returns an array of `i64` object IDs for every object on the board that currently carries `tag`.
```rhai
let enemies = objects_with_tag("enemy");
log(`there are ${enemies.len()} enemies`);
```
### `my_name() -> String`
Returns this object's name string, or `""` if the object has no name.
```rhai
log(`I am ${my_name()}`);
```
### `object_id_for_name(name) -> i64`
Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name.
`0` is never a valid id, so it can be used as a null check.
```rhai
let door_id = object_id_for_name("door");
if door_id != 0 {
set_tag(door_id, "open", true);
}
let count = Registry.get_or("visits", 0);
Registry.set("visits", count + 1);
```
---
## Write functions
Scripts do not mutate the world directly. Each write function appends an action to this object's
**output queue**. Actions are drained to the shared board queue between script calls and resolved
by the engine after the batch.
Scripts never mutate the world directly. Each write appends an `Action` to this object's **output
queue**; actions drain to a shared board queue between script calls and are resolved by the engine
after the batch. The *subject* of a write is implicit and varies by function (see the table).
### `move(dir)`
| Call | Subject | Cost | Description |
|------|---------|------|-------------|
| `move(dir)` | self | 0.25 s | Enqueue a one-cell move, plus a rate-limiting delay (~4/s). A blocked move still costs the cooldown. |
| `teleport(x, y)` | self | 0 | Jump to an arbitrary cell. Refused (error logged at resolve) if self is solid and the destination is occupied. |
| `push(x, y, dir)` | arbitrary cell | 0 | Shove the pushable chain at `(x, y)` one step in `dir`. |
| `shift([[x, y], …])` | arbitrary cells | 0 | Rotate cell contents: each listed cell moves to the next coordinate, last→first. Skips non-pushables and won't move into a non-vacated occupied cell. |
| `set_tile(n)` | self | 0 | Set glyph tile index (CP437 code point in the default font). |
| `set_fg(color)` | self | 0 | Set foreground color (hex string). |
| `set_bg(color)` | self | 0 | Set background color. |
| `set_color(fg, bg)` | self | 0 | Set both colors. |
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `Me.id` for self. |
| `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs`. Replaces any current bubble. |
| `log(msg)` | log | 0 | Append a plain-text line to the game log. |
| `scroll(lines)` | UI | 0 | Open a full-screen overlay (see below). |
| `send(target_id, fn_name [, arg])` | any object | 0 | Call a handler function on another object; optional string/number arg. |
| `delay(secs)` | self | — | Insert an explicit pause; integer and float overloads. Adjacent delays merge. |
| `now()` | self | — | Move the **most recently enqueued** action to the front of the queue. |
| `add_gems(n)` | player | 0 | Change the player's gem count (clamped at 0). |
| `alter_health(dh)` | player | 0 | Change the player's health (clamped to `[0, max_health]`). |
| `set_key(color, present)` | player | 0 | Give/take a key color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, `"orange"`, `"yellow"`, `"white"`). Unknown name logs and is ignored. |
| `die()` | self | 0 | Remove the calling object from the board. |
Enqueues a move one cell in `dir`. Also inserts a `Delay` of 0.25 s after the move, limiting this
object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown.
### `scroll(lines)`
```rhai
fn tick(dt) {
if !blocked(South) { move(South); }
}
```
Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element
of `lines` is either:
### `delay(secs)`
- a **string** — a plain text line, or
- a **2-element array** `[choice_key, display_text]` — a selectable choice.
Inserts an explicit pause of `secs` seconds into this object's queue. Adjacent delays are merged
(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause
between speech bubbles.
```rhai
fn init() {
say("One…");
delay(2.0);
say("Two!");
now(); // promote the last say() past the delay
}
```
### `now()`
Moves the most recently enqueued action to the **front** of the queue, bypassing any pending
delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even
if a move delay is in progress.
When the player selects a choice, `choice_key` is dispatched back to the **source object** as a
zero-arg function call named `choice_key`.
```rhai
fn bump(id) {
say("Ouch!");
now(); // show the bubble right away
scroll([
"The muffin looks delicious.",
"",
["eat", "Eat it"],
["ignore", "Walk away"],
]);
}
```
### `set_tile(n)`
Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437
code point when using the default kiln font.
```rhai
fn init() {
set_tile(1); // ☺
}
```
### `log(msg)`
Appends a plain-text line to the game log. The message is a string.
```rhai
log(`player is at ${Board.player_x}, ${Board.player_y}`);
```
### `say(msg)`
Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the
object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with
`now()` to surface it immediately from a `bump` or `init` hook.
```rhai
fn bump(id) {
say("Hey!");
now();
}
```
### `set_tag(target_id, tag, present)`
Adds (`present = true`) or removes (`present = false`) a tag on any object identified by
`target_id`. Use `MY_ID` to mutate the calling object's own tags.
```rhai
// Add a tag to self
set_tag(MY_ID, "activated", true);
// Remove a tag from another object by name
let lever_id = object_id_for_name("lever");
set_tag(lever_id, "pulled", true);
fn eat() { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore() { log("Wise."); }
```
---
## `Queue` object
Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own
pending-action queue.
Inspection and control of this object's own pending-action queue.
| Method | Returns | Description |
|--------|---------|-------------|
| `Queue.length()` | `i64` | Number of actions currently in the queue. |
| `Queue.clear()` | — | Discard all pending actions. |
| `Queue.length()` | `i64` | Number of pending actions. |
| `Queue.clear()` | — | Discard all pending actions (including delays). |
| `Queue.peek()` | map or `()` | Front action as a map, or unit if empty. |
| `Queue.pop()` | map or `()` | Same, removing the front. |
Use `Queue.length() == 0` in `tick` to avoid queuing new moves while a previous one is in flight:
`peek`/`pop` return an untyped map with a `"type"` key (`"Move"`, `"SetTile"`, `"Say"`, …) plus
variant-specific payload keys (e.g. `"dir"`, `"tile"`, `"msg"`). `Scroll` and `Shift` report `type`
only — their payloads are not inspectable through this API.
```rhai
fn tick(dt) {
@@ -266,18 +304,20 @@ fn tick(dt) {
## Rate limiting and timing
- `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max).
- `delay(secs)` adds an arbitrary pause.
- `now()` can bypass a delay for zero-cost actions already in the queue.
- `delay(secs)` adds an arbitrary pause; `now()` can bypass a delay for a zero-cost action already in
the queue.
- `Queue.clear()` cancels everything, including pending delays.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `Delay(1.0)` entry.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `1.0 s` delay.
- All other writes (`set_tile`, `say`, `push`, `shift`, `teleport`, stat changes, `die`, …) are
zero-cost: a run of them resolves together, and at most one *timed* action (a `move`) is released
per object per cycle.
---
## Error handling
Script errors (compile-time or runtime) are logged to the game log as plain-text lines and do not
crash the game. A script that throws an exception on `tick` will log the error and resume normally
on the next frame.
Script errors (compile-time or runtime) are appended to the game log as plain-text lines and do not
crash the game. A script that throws during `tick` logs the error and resumes on the next frame.
---
@@ -288,27 +328,78 @@ on the next frame.
fn init() {
set_tile(2);
log(`${my_name()} standing watch`);
log(`${Me.name} standing watch`);
}
fn tick(dt) {
if Queue.length() > 0 { return; } // still moving
if !blocked(East) {
move(East);
} else if !blocked(West) {
move(West);
}
// Stuck in a corner — do nothing this tick.
if Queue.length() > 0 { return; } // still moving
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
}
fn bump(id) {
if id == -1 {
say("Halt! Who goes there?");
now();
} else {
say("Watch where you're going!");
now();
}
if id == -1 { say("Halt! Who goes there?"); now(); }
else { say("Watch it!"); now(); }
}
```
---
## Design notes (rough edges)
Collected friction points in the current surface, as input to a redesign. None of these are bugs;
they are shape problems.
### Duplication — several ways to do one thing
- **Object introspection has two parallel types with different members.** Self uses `Me`
(`Me.has_tag()`, `Me.tags()`, `Me.glyph()`, plus getters); other objects come back as `ObjectInfo`
(`.id`, `.x`, `.y`, `.name`, `.tags`) which has **no** `has_tag()` and **no** `glyph()`. The same
conceptual "an object" is two incompatible types depending on whether it's you.
- **Player position/state is reachable three ways:** `Board.player_x` / `Board.player_y`,
`Board.get(-1).x` / `.y`, and the `Player` constant (stats only). Player *position* lives on
`Board`, player *stats* live on `Player` — the player is split across two surfaces.
- **Tag reads come in three shapes:** `Me.has_tag(t)` (method, bool), `Me.tags()` (method, array),
`ObjectInfo.tags` (property, array). Method-vs-property is inconsistent even for the same data.
### Inconsistency — naming and conventions
- **No consistent verb convention.** Setters by name (`set_tile`, `set_tag`, `set_fg`, `set_bg`,
`set_color`, `set_key`), delta verbs (`add_gems`, `alter_health`), and bare verbs (`move`, `die`,
`teleport`, `push`, `shift`, `scroll`, `say`, `log`) all coexist. Worse, the two "change by a
delta" operations use *different* verbs: `add_gems` vs `alter_health`.
- **The implicit subject of a write is unpredictable.** `set_tile`/`set_fg`/`say`/`die` act on
**self**; `add_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an
**arbitrary target**; `push`/`shift`/`teleport` act on **cells**. The `set_*` prefix in particular
means three different subjects.
- **Mixed addressing for movement.** `move`/`blocked`/`push` take a typed `Direction`, while
`teleport`/`passable`/`can_push`/`can_shift`/`shift` take raw `i64` coordinates. There's no cell or
position value type.
- **Self-mutation can't go through `Me`.** `Me` is read-only, so writing your own tag is
`set_tag(Me.id, …)` — you pass your own id back to a free function instead of `Me.set_tag(…)`.
- **Colors are stringly-typed** (`"#RRGGBB"`, with the named constants being strings) while tiles are
bare integers with no symbolic names; glyph reads hand back colors as hex strings.
- **Overload coverage is uneven.** `say` has a duration overload, `log` does not; `delay` has int and
float overloads, most numeric fns don't.
### Unwieldy — machinery that leaks
- **Queue introspection is stringly-typed.** `peek`/`pop` return untyped maps keyed by a `"type"`
string, and `Scroll`/`Shift` aren't introspectable at all (type only). There is no typed action
value.
- **The two-tier queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
get wrong; the object-queue → board-queue promotion plus per-object ready timer is a lot of
implicit state to reason about for "do X, wait, do Y".
- **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs;
cross-board state must pick one.
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
key is silently also a function name. Renaming a handler breaks callers with no signal.
- **Engine internals leak into the script API.** `combinable(x1,y1,x2,y2)` encodes a very specific
rule (a grab thing may share a cell with the player) that exists only to support `shift`/`swap`
edge cases.
- **Read/write asymmetry on player keys.** `set_key` writes keys, but no read exists — `Player`
exposes `gems`/`health`/`max_health` only (there is a literal `TODO` to expose keys).
- **Doc/name drift across the codebase.** The function is `shift([[x, y], …])`, but the engine's own
module docs and `CLAUDE.md` still describe a `swap([[src_x, src_y, dst_x, dst_y], …])`. The two
concepts (rotate-a-cycle vs. batch directed moves) are conflated under both names.
+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:
- `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.
- `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.
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable). All aliases in a family share one Rhai script; the script reads `Me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
**`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.
**`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`.
- `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.
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
- `Player { x: i32, y: i32 }`current player position.
- `PlayerPos { x: i32, y: i32 }`the player's current position on a board (held as `Board::player`). The broader player *state* (health, gems, keys) lives in [`player::Player`], not here.
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
@@ -59,16 +59,23 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
**`kiln-core/src/player.rs`** — game-global player state (`pub`):
- `Player { health: i64, max_health: i64, keys: Keyring, gems: i64 }` — the player's persistent stats, owned by `GameState::player` (separate from the per-board `PlayerPos` position in `utils.rs`). `Default` starts `health = max_health = 5`, `gems = 0`, no keys. `alter_gems(delta) -> bool` adds to `gems` but refuses (returns `false`, no change) if it would go negative. `alter_health(delta)` adds to `health` clamped to `[0, max_health]`. A `Copy` snapshot is handed to each script hook and exposed to Rhai (read-only) as the `Player` constant.
**`kiln-core/src/keys.rs`** — key inventory and colors (`pub`):
- `KeyType` (`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Cyan`/`Purple`/`White`) — the eight key colors. `glyph() -> Glyph` returns the key tile (12, `♀`) in that color's fg — the single source of truth for key colors, used by `Keyring::colors` and by kiln-tui's editor menu. (Moved here from `game.rs`; `archetype.rs`'s `Key` builtin aliases must match these glyphs — see its `key_aliases_have_distinct_fg_colors` test.)
- `Keyring { red, orange, yellow, green, blue, cyan, purple, white: bool }` — the player's key inventory (one bool per color), held by `Player::keys`. `set_by_name(name, value) -> bool` sets a slot by color name (returns `false` for an unknown name); `colors() -> [(bool, Rgba8); 8]` lists all eight in display order paired with their render color.
**`kiln-core/src/game.rs`** — game-loop logic only:
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one).
- `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds).
- `Scroll { source: ObjectId, lines: Vec<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:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
- `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it).
- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost), `AddGems(i64)` (change `GameState::player_gems`, clamped at 0; zero cost), `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.
**`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:
- `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.
- **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`.
- **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()`).
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), and `grab()` (zero-arg; fired when the player walks onto a `grab` thing — the only grab trigger), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, object_id, bumper)` / `run_grab(state, object_id)` — each takes a `ScriptState(pub Player)` bundle (a `Copy` wrapper that exists so more host context can be threaded through later without re-touching every signature); the host pulls its `Player` out and sets it into the object's scope as the `Player` constant before the call; runtime errors go to the error sink (drained to the log), not fatal.
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. The player's game-global stats are likewise exposed read-only as the `Player` constant (a `Copy` snapshot, carried in by the `ScriptState` bundle and set into scope before each hook call; getters `Player.gems`, `Player.health`, `Player.max_health` — keys not yet exposed), via `register_player_type`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)`, `add_gems(n)` (adjust the player's gem count), `alter_health(dh)` (adjust the player's health, clamped to `[0, max_health]`), `die()` (remove the calling object) append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
- **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta.
- `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"] }
tinyrand = "0.5"
toml = { version = "0.8", features = ["preserve_order"] }
log = "0.4.33"
+79 -118
View File
@@ -4,9 +4,9 @@
//! functions), the rate-limiting delay constant, and [`action_to_map`] which
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`.
use std::fmt::Debug;
use crate::log::LogLine;
use crate::map_file::color_to_hex;
use crate::utils::{Direction, ObjectId, ScriptArg};
use crate::utils::{Direction, ObjectId};
use color::Rgba8;
use rhai::Dynamic;
@@ -27,13 +27,47 @@ pub enum ScrollLine {
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`]
/// after it is promoted onto 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`
/// 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(Direction),
/// Set the source object's glyph tile index.
@@ -59,7 +93,7 @@ pub(crate) enum Action {
Send {
target: ObjectId,
fn_name: String,
arg: Option<ScriptArg>,
arg: SendArg,
},
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// 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
/// the source is solid and the destination already has a solid occupant.
/// 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
/// 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
/// 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.
/// Zero time cost.
Shift(Vec<(i32, i32)>),
Shift(Vec<(i64, i64)>),
/// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player_gems`.
/// clamped at 0). Zero time cost. Applied to `GameState::player.gems`.
AddGems(i64),
/// Add `dh` to the player's health (clamped to `[0, max_health]`). Zero time
/// cost. Applied to `GameState::player.health`.
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
/// things (e.g. gems) to despawn themselves from their `grab()` hook.
Die,
}
// ── Direction helper ──────────────────────────────────────────────────────────
fn dir_to_str(d: Direction) -> &'static str {
match d {
Direction::North => "North",
Direction::South => "South",
Direction::East => "East",
Direction::West => "West",
impl Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::Move(dir) => write!(f, "Move({:?})", dir),
Action::SetTile(i) => write!(f, "SetTile({i})"),
Action::Log(msg) => write!(f, "Log({:?})", msg),
Action::SetTag { .. } => write!(f, "SetTag"),
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 ─────────────────────────────────────────────────────────────
/// Converts an [`Action`] to a Rhai map for `Queue.peek()` / `Queue.pop()`.
///
/// The map always has a `"type"` key naming the variant; other keys carry the
/// payload. [`Action::Log`] is flattened to its first span's text.
pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
// 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
}
/// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
pub struct BoardAction {
/// The stable [`ObjectId`] of the object that issued the action.
pub(crate) source: ObjectId,
/// The action to apply.
pub(crate) action: Action,
}
+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::utils::{Behavior, Pushable};
use color::Rgba8;
use crate::keys::KeyType;
/// Declares the set of script-backed archetype families.
///
/// Each entry specifies:
/// - A `Variant` name (becomes a [`Builtin`] enum variant).
/// - A `["name" => tile, …]` list: one map-file keyword per alias with the tile
/// index for the editor glyph. All aliases in a family share `behavior`, `fg`,
/// `bg`, and embedded Rhai `script`.
/// - `behavior`, `fg`, `bg`: per-family defaults.
/// - A `["name" => Glyph { … }, …]` list: one map-file keyword per alias with the
/// default [`Glyph`] for the editor. Per-alias glyphs allow aliases in the same
/// family to differ in color (e.g. the eight `Key` variants).
/// - `behavior`: shared across all aliases in the family.
/// - `script`: the embedded Rhai source; `include_str!` paths are relative to this
/// file, so `include_str!("scripts/pusher.rhai")` resolves to
/// `kiln-core/src/scripts/pusher.rhai`.
@@ -21,10 +22,8 @@ use color::Rgba8;
macro_rules! builtins {
(
$(
$variant:ident => [ $( $name:literal => $tile:literal ),+ $(,)? ] {
$variant:ident => [ $( $name:literal => $glyph:expr ),+ $(,)? ] {
behavior: $behavior:expr,
fg: $fg:expr,
bg: $bg: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
/// happen in practice since aliases are all from the macro).
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 {
$(
$( $name => Glyph { tile: $tile, fg: $fg, bg: $bg }, )+
$( $name => $glyph, )+
)+
_ => Glyph::transparent(),
}
@@ -91,25 +88,54 @@ macro_rules! builtins {
};
}
builtins! {
Gem => ["gem" => 4] {
behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true },
fg: Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 },
// Shorthand helpers used only within the builtins! invocation below.
// `g(tile, r, g, b)` builds a Glyph with the given tile and fg on black bg.
const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph {
Glyph {
tile,
fg: Rgba8 { r, g: gr, b, 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"),
},
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 },
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"),
},
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 },
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"),
},
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.
@@ -310,6 +336,9 @@ mod tests {
("pusher_west", 17),
("spinner_cw", 47),
("spinner_ccw", 92),
("key_red", 12),
("key_blue", 12),
("key_white", 12),
] {
let arch = Archetype::try_from(name)
.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::object_def::ObjectDef;
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};
/// 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
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
pub(crate) layers: Vec<Layer>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Current player position on this board. See [`PlayerPos`] for caveats
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
pub player: PlayerPos,
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
/// (not a `Vec`) so an object can be removed without invalidating other
/// objects' ids; iteration is in ascending-id order, which equals load order
@@ -79,6 +80,11 @@ impl Board {
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`.
///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
@@ -97,11 +103,10 @@ impl Board {
/// Replace the solid (if any) at `(x, y)` with `Empty`
pub fn clear_solid(&mut self, x: usize, y: usize) {
if self.in_bounds((x as i32, y as i32)) {
if let Some(z) = self.solid_cell_layer(x, y) {
if self.in_bounds((x as i64, y as i64))
&& let Some(z) = self.solid_cell_layer(x, y) {
*self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty)
}
}
}
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
@@ -120,7 +125,7 @@ impl Board {
/// Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
// 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();
}
@@ -168,7 +173,7 @@ impl Board {
///
/// Takes signed coords so callers can pass a raw `pos + delta` without first
/// 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;
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.
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.
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));
}
// 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()
}
/// 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`.
///
/// 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
/// ahead shovable?" half of a "can I move here?" query.
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);
loop {
// This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) {
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) {
return false; // chain runs off the board
}
@@ -328,8 +300,8 @@ impl Board {
if !self.is_pushable(x, y, dir) {
return false;
}
let (dx, dy): (i32, i32) = dir.into();
let next = (x as i32 + dx, y as i32 + dy);
let (dx, dy): (i64, i64) = dir.into();
let next = (x as i64 + dx, y as i64 + dy);
if !self.in_bounds(next) {
return false; // nothing to shift into off the board
}
@@ -353,7 +325,7 @@ impl Board {
if !self.can_push(x, y, dir) {
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
// re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives.
@@ -361,8 +333,8 @@ impl Board {
let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) {
chain.push((cx, cy));
cx = (cx as i32 + dx) as usize;
cy = (cy as i32 + dy) as usize;
cx = (cx as i64 + dx) as usize;
cy = (cy as i64 + dy) as usize;
}
for &(px, py) in chain.iter().rev() {
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
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
let Some(solid) = self.solid_at(x, y) else {
return; // nothing to shift
};
@@ -540,7 +512,7 @@ impl Board {
/// 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.
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:
if cells.iter().any(|&c| !self.in_bounds(c)) {
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::object_def::ObjectDef;
use crate::utils::Direction;
use crate::utils::{ObjectId, Player};
use crate::utils::{ObjectId, PlayerPos};
use color::Rgba8;
use std::collections::{BTreeMap, HashMap};
@@ -636,7 +608,7 @@ pub(crate) mod tests {
pub(crate) fn open_board(
w: usize,
h: usize,
player: (i32, i32),
player: (i64, i64),
objects: Vec<ObjectDef>,
) -> Board {
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
@@ -653,7 +625,7 @@ pub(crate) mod tests {
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
}],
player: Player {
player: PlayerPos {
x: player.0,
y: player.1,
},
+1 -1
View File
@@ -17,7 +17,7 @@
/// space since a literal NUL is not displayable.
const CP437: [char; 256] = [
// 0x000x0F
' ', '☺', '☻', '', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
' ', '☺', '☻', '', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x100x1F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
// 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::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, Player, ScriptArg};
use crate::utils::{Direction, ObjectId, PlayerPos};
use crate::world::World;
use std::cell::{Ref, RefMut};
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
// accessing the private `action` module directly.
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()`.
///
@@ -66,14 +68,11 @@ pub struct GameState {
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>,
/// The player's current health. Game-global (not per-board), so it persists
/// across board transitions. Players start with `5`. There is no runtime
/// mutation path yet — front-ends only display it.
pub player_health: u32,
/// The number of gems the player has collected. Game-global like
/// [`player_health`](GameState::player_health); starts at `0` and is
/// display-only for now.
pub player_gems: u32,
/// The game-global player state (health, gems, keys) — see [`Player`]. Not
/// per-board: it persists across board transitions, unlike the per-board
/// position in [`Board::player`](crate::board::Board::player). Scripts mutate
/// it via `add_gems`/`alter_health`/`set_key` and read a snapshot of it.
pub player: Player,
}
impl GameState {
@@ -100,8 +99,7 @@ impl GameState {
speech_bubbles: Vec::new(),
active_scroll: None,
board_transition: None,
player_health: 5,
player_gems: 0,
player: Player::default()
};
state.drain_errors();
state
@@ -165,7 +163,7 @@ impl GameState {
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
pub fn run_init(&mut self) {
self.scripts.run_init();
self.scripts.run_init(ScriptState::from_game_state(self));
self.resolve();
}
@@ -179,7 +177,7 @@ impl GameState {
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
let secs = dt.as_secs_f64();
self.scripts.run_tick(secs);
self.scripts.run_tick(ScriptState::from_game_state(self), secs);
// Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| {
@@ -210,13 +208,16 @@ impl GameState {
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
// Net change to the player's gem count from AddGems actions; applied to
// `self` after the board borrow drops.
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
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();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{
let mut board = self.board_mut();
for ba in actions {
@@ -312,8 +313,12 @@ impl GameState {
Action::Shift(cells) => {
logs.extend(board.apply_shift(&cells));
}
// Accumulated and applied to `self.player_gems` after the borrow drops.
// Accumulated and applied to `self.player.gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n,
// Accumulated and applied to `self.player.health` after the borrow drops.
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.
Action::Die => {
board.remove_object(ba.source);
@@ -333,13 +338,23 @@ impl GameState {
}
// Apply the net gem change (clamped at 0, since the count is unsigned).
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 {
self.scripts.run_bump(bumped, bumper);
self.scripts.run_bump(state.clone(), bumped, bumper);
}
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();
}
@@ -355,7 +370,7 @@ impl GameState {
if let Some(scroll) = self.active_scroll.take()
&& 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();
}
}
@@ -394,9 +409,9 @@ impl GameState {
self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string();
self.board_mut().player = Player {
x: ax as i32,
y: ay as i32,
self.board_mut().player = PlayerPos {
x: ax as i64,
y: ay as i64,
};
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
@@ -420,7 +435,7 @@ impl GameState {
let grabbed;
let portal_target;
{
let (dx, dy): (i32, i32) = dir.into();
let (dx, dy): (i64, i64) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
@@ -442,8 +457,8 @@ impl GameState {
if grabbed.is_none() {
board.push(nx, ny, dir);
}
board.player.x = nx as i32;
board.player.y = ny as i32;
board.player.x = nx as i64;
board.player.y = ny as i64;
// Check for a portal at the new position; clone strings to release the borrow.
portal_target = board
.portals
@@ -461,12 +476,13 @@ impl GameState {
}
// Fire the grab hook and resolve it immediately so the grabbed thing's
// die()/add_gems() apply now — no player+object overlap survives this call.
let state = ScriptState::from_game_state(self);
if let Some(id) = grabbed {
self.scripts.run_grab(id);
self.scripts.run_grab(state.clone(), id);
self.resolve();
}
if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1);
self.scripts.run_bump(state, idx, -1);
self.drain_errors();
}
}
@@ -481,9 +497,9 @@ impl GameState {
/// 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.
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 target = (ox as i32 + dx, oy as i32 + dy);
let target = (ox as i64 + dx, oy as i64 + dy);
if !board.in_bounds(target) {
return None;
}
@@ -522,7 +538,7 @@ mod tests {
game.try_move(Direction::East);
// 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_eq!((game.board().player.x, game.board().player.y), (1, 0));
}
@@ -550,7 +566,7 @@ mod tests {
game.tick(Duration::from_millis(16));
// 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_eq!((game.board().player.x, game.board().player.y), (2, 0));
}
@@ -560,7 +576,7 @@ mod tests {
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
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);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
@@ -568,26 +584,13 @@ mod tests {
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]
fn script_shift_rotates_crates() {
// 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).
let mut game = game_with_object_script(
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));
let b = game.board();
@@ -605,10 +608,10 @@ mod tests {
obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init() { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let src = "fn init(m,s) { \
log(if s.board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if s.board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if s.board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
@@ -714,4 +717,52 @@ mod tests {
}
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 std::hash::{Hash, Hasher};
use rhai::Engine;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// 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 {
/// 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;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world;
pub mod player;
pub mod keys;
pub use archetype::{Archetype, Builtin};
pub use board::Board;
pub use utils::Direction;
#[cfg(test)]
mod tests;
mod api;
+6 -5
View File
@@ -17,7 +17,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::TryFrom;
use std::path::Path;
use tinyrand::{Seeded, StdRand};
use crate::api::queue::ObjQueue;
use crate::archetype::Archetype;
use crate::board::Board;
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::log::LogLine;
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.
///
@@ -245,6 +245,7 @@ impl TryFrom<MapFile> for Board {
// (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None,
tags: t.tags.into_iter().collect(),
queue: ObjQueue::new(),
name,
},
);
@@ -277,9 +278,9 @@ impl TryFrom<MapFile> for Board {
width: w,
height: h,
layers,
player: Player {
x: px as i32,
y: py as i32,
player: PlayerPos {
x: px as i64,
y: py as i64,
},
objects,
next_object_id,
+4
View File
@@ -2,6 +2,7 @@ use crate::glyph::Glyph;
use crate::utils::ObjectId;
use color::Rgba8;
use std::collections::HashSet;
use crate::api::queue::ObjQueue;
/// 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
/// cleared to `None` (the object survives but becomes anonymous).
pub name: Option<String>,
/// The output queue of actions for this object
pub queue: ObjQueue,
}
impl ObjectDef {
@@ -107,6 +110,7 @@ impl ObjectDef {
script_name: None,
builtin_script: None,
tags: HashSet::new(),
queue: ObjQueue::new(),
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
// player) fires this `grab()` hook instead of blocking. We bump the player's gem
// count and remove ourselves from the board.
fn grab() {
fn grab(me, state) {
add_gems(1);
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
// `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
// and is a no-op when blocked; the delay pads each step out to ~0.5s (move
// itself costs 0.25s), matching the old global pusher heartbeat.
//
// The direction is read here, at the top level of the hook, because `Me` is a
// per-object scope constant and is not visible inside other script functions.
if Queue.length() == 0 {
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_east") { East }
if !me.waiting {
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_east") { East }
else { West };
move(dir);
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
// 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.
fn tick(dt) {
fn tick(me, state, dt) {
// Only start a new rotation when the previous one (and its delay) has drained,
// exactly like the built-in pusher's pacing.
if Queue.length() != 0 { return; }
if me.waiting { return; }
// 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.
let cw_ring = [
[Me.x, Me.y-1], // N
[Me.x+1, Me.y-1], // NE
[Me.x+1, Me.y], // E
[Me.x+1, Me.y+1], // SE
[Me.x, Me.y+1], // S
[Me.x-1, Me.y+1], // SW
[Me.x-1, Me.y], // W
[Me.x-1, Me.y-1], // NW
[me.x, me.y-1], // N
[me.x+1, me.y-1], // NE
[me.x+1, me.y], // E
[me.x+1, me.y+1], // SE
[me.x, me.y+1], // S
[me.x-1, me.y+1], // SW
[me.x-1, me.y], // W
[me.x-1, me.y-1], // NW
];
// 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
// 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 fkey = `spin_${Me.id}`;
let fkey = `spin_${me.id}`;
let f = Registry.get_or(fkey, 0);
set_tile(frames[f % 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() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
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();
let b = game.board();
// 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.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
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();
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() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
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();
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")]);
crate_at(&mut board, 2, 0);
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();
let b = game.board();
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.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
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();
{
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")]);
wall_at(&mut board, 3, 0);
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();
let b = game.board();
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 mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init() { move(East); move(East); }")]),
scripts_from(&[("m", "fn init(m,s) { move(East); move(East); }")]),
);
game.run_init();
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);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init() { move(East); move(South); }")]),
scripts_from(&[("m", "fn init(m,s) { move(East); move(South); }")]),
);
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
@@ -136,7 +136,7 @@ fn queue_length_reports_pending_actions() {
board,
scripts_from(&[(
"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();
@@ -166,7 +166,7 @@ fn blocked_reports_solid_and_clear() {
board,
scripts_from(&[(
"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();
@@ -178,38 +178,9 @@ fn blocked_reports_solid_and_clear() {
board,
scripts_from(&[(
"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();
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(&[
(
"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",
"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::glyph::Glyph;
use crate::layer::Layer;
use crate::utils::{Direction, Player, PortalDef};
use crate::utils::{Direction, PlayerPos, PortalDef};
use crate::world::World;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
/// 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 {
name: "test".into(),
width: 3,
@@ -18,7 +18,7 @@ fn make_board(px: i32, py: i32, portals: Vec<PortalDef>) -> Board {
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); 9],
}],
player: Player { x: px, y: py },
player: PlayerPos { x: px, y: py },
objects: BTreeMap::new(),
next_object_id: 1,
portals,
@@ -112,7 +112,7 @@ fn enter_board_unknown_entry_logs_error() {
fn try_move_onto_portal_switches_board() {
let world = two_board_world();
// 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);
game.try_move(Direction::East);
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::layer::Layer;
use crate::object_def::ObjectDef;
use crate::utils::Player;
use crate::utils::PlayerPos;
use std::collections::{BTreeMap, HashMap};
/// 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 {
cells: vec![(Glyph::transparent(), Archetype::Empty)],
}],
player: Player { x: 0, y: 0 },
player: PlayerPos { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]),
next_object_id: 2,
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() {
let (board, scripts) = board_with_object(
Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)],
&[("greet", r#"fn init(m,s) { log("hello"); }"#)],
);
let mut game = GameState::with_scripts(board, scripts);
// 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() {
let (board, scripts) = board_with_object(
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);
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 mut game = GameState::with_scripts(
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();
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(
board,
scripts_from(&[
("e", "fn init() { move(East); }"),
("w", "fn init() { move(West); }"),
("e", "fn init(m,s) { move(East); }"),
("w", "fn init(m,s) { move(West); }"),
]),
);
game.run_init();
@@ -127,7 +127,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// present on the object afterward.
let (board, scripts) = board_with_object(
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);
game.run_init();
@@ -136,7 +136,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object(
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.
board2
@@ -158,8 +158,8 @@ fn has_tag_reads_own_tags() {
Some("t"),
&[(
"t",
r#"fn init() { set_tag(Me.id, "active", true); }
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#,
r#"fn init(m,s) { set_tag(m.id, "active", true); }
fn tick(m,s,dt) { log(m.has_tag("active").to_string()); }"#,
)],
);
let mut game = GameState::with_scripts(board, scripts);
@@ -182,8 +182,8 @@ fn objects_with_tag_returns_matching_ids() {
scripts_from(&[
(
"q",
r#"fn init() {
let infos = Board.tagged("enemy");
r#"fn init(m,s) {
let infos = s.board.tagged("enemy");
log(infos.len().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() {
// An object with a name set on its ObjectDef should see it via my_name().
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());
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get an empty string.
// An unnamed object should get ().
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);
game2.run_init();
assert_eq!(log_texts(&game2), vec![""]);
assert_eq!(log_texts(&game2), vec!["null"]);
}
#[test]
@@ -228,9 +228,9 @@ fn object_id_for_name_finds_by_name() {
scripts_from(&[
(
"q",
r#"fn init() {
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
r#"fn init(m,s) {
log(s.board.named("target").id.to_string());
let miss = s.board.named("missing");
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 mut game = GameState::with_scripts(
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.try_move(Direction::East);
@@ -271,7 +271,7 @@ fn scroll_opens_on_player_bump() {
board,
scripts_from(&[(
"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();
@@ -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 mut game = GameState::with_scripts(
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.try_move(Direction::East);
@@ -317,7 +317,7 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
scripts_from(&[(
"s",
r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn bump(m,s,id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
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::glyph::Glyph;
use color::Rgba8;
use rhai::Dynamic;
use crate::Board;
use crate::log::LogLine;
/// 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) {
match self.kind {
SolidKind::Player => {
board.player.x = x as i32;
board.player.y = y as i32;
board.player.x = x as i64;
board.player.y = y as i64;
}
SolidKind::Object(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
/// tensions this implies.
#[derive(Copy, Clone)]
pub struct Player {
pub struct PlayerPos {
/// Column position (0-indexed, increasing rightward).
pub x: i32,
pub x: i64,
/// Row position (0-indexed, increasing downward).
pub y: i32,
pub y: i64,
}
#[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.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction {
@@ -342,6 +338,37 @@ pub enum Direction {
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.
///
/// 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).
fn from(d: Direction) -> Self {
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:
- `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).
- 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.
@@ -61,7 +61,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact
- `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement.
**`kiln-tui/src/status.rs`** — play-mode status sidebar widget:
- `StatusSidebarWidget` — a `ratatui::widgets::Widget` built with `new(health, gems)` that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `MAX_HEARTS` (5) `♥` glyphs (first `health` bright red, rest dark red) and a `Gems: ♦ N` line. The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it.
- `StatusSidebarWidget(pub Player)` — a `ratatui::widgets::Widget` wrapping a `kiln_core::player::Player` snapshot that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `player.max_health` `♥` glyphs (first `player.health` bright red, rest dark red), a `Gems: ♦ N` line, and a `Keys:` row of 8 colored key glyphs (colored when held, near-black when absent, via `player.keys.colors()`). The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it.
**`kiln-tui/src/log.rs`** — log panel widget:
- `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`.
+18 -6
View File
@@ -13,7 +13,7 @@ use crate::editor_menu::{MenuEntry, MenuLevel};
use crate::log::{LogWidget, log_preview_line};
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
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::game::GameState;
use kiln_core::glyph::Glyph;
@@ -100,6 +100,10 @@ impl EditorState {
/// Builds an editor session for `world`, starting on its designated start board.
pub(crate) fn new(world: World) -> Self {
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 {
world,
board_name,
@@ -107,7 +111,7 @@ impl EditorState {
dialog: None,
code_editor: None,
glyph_dialog: None,
cursor: (0, 0),
cursor,
current_archetype: Archetype::Wall,
current_glyph: Archetype::Wall.default_glyph(),
draw_mode: false,
@@ -296,7 +300,7 @@ impl EditorState {
/// 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.
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 {
let target = (bx as i32, by as i32);
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);
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).
if ed.blink_on {
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))
{
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),
]));
}
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("")),
// A horizontal rule spanning the sidebar's inner width.
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).
Line::from(Span::styled(ed.current_archetype.name(), label_style)),
Line::from(vec![
Span::styled("[g] ", key_style),
Span::styled("[G] ", key_style),
Span::styled("Glyph ", label_style),
preview,
]),
+45 -4
View File
@@ -2,6 +2,8 @@ use crate::editor::EditorState;
use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode;
use kiln_core::{Archetype, Builtin};
use kiln_core::keys::KeyType;
use kiln_core::glyph::Glyph;
/// One level in the editor's sidebar menu tree.
///
@@ -100,10 +102,23 @@ impl MenuLevel {
ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west"))
}),
],
MenuLevel::Items => vec![MenuEntry::item('t', "♦ Gem", |ed| {
// 't' for 'treasure' — 'g' conflicts with glyph picker
ed.set_current(Archetype::Builtin(Builtin::Gem, "gem"))
})],
MenuLevel::Items => vec![
MenuEntry::glyph('t', Archetype::Builtin(Builtin::Gem, "gem").default_glyph(), "Gem", |ed| {
// 't' for 'treasure' — 'g' conflicts with glyph picker
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) {
let action = self.entries().into_iter().find_map(|entry| match entry {
MenuEntry::Item { key, action, .. } if key == ch => Some(action),
MenuEntry::Glyph { key, action, .. } if key == ch => Some(action),
_ => None,
});
if let Some(action) = action {
@@ -134,6 +150,17 @@ pub(crate) enum MenuEntry {
/// Run when the user presses `key`; mutates the editor session.
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.
#[allow(dead_code)]
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 {
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::Char('l') => ui.log.toggle(),
// `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.
KeyCode::Char(' ') => ed.place_current(),
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 {
// 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.
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))
.areas(frame.area());
frame.render_widget(
StatusSidebarWidget::new(game.player_health - 2, game.player_gems),
StatusSidebarWidget(game.player),
sidebar_area,
);
rest
+44 -18
View File
@@ -13,16 +13,27 @@ use ratatui::layout::Rect;
use ratatui::widgets::Widget;
/// 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> {
/// The board to render. Borrowed for the duration of the draw.
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> {
/// Creates a widget that renders `board`.
/// Creates a widget that renders `board`, scrolling to follow the player.
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.
@@ -51,17 +62,25 @@ impl<'a> BoardWidget<'a> {
}
/// Maps a board cell `(bx, by)` to its screen position within `area`, using the
/// same centering/scrolling layout as [`BoardWidget`]. Returns `None` when the
/// cell is scrolled out of view.
/// same centering/scrolling layout as [`BoardWidget`] with the given `focus`.
/// 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
/// stay consistent with how the board is actually drawn. (Unlike
/// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this
/// 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)> {
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
let (off_y, pad_y, rows) =
BoardWidget::axis(board.height, area.height as usize, board.player.y);
pub fn board_to_screen(
area: Rect,
board: &Board,
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.
if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows {
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`,
/// the inverse of [`board_to_screen`]. Returns `None` when the point lies outside
/// the drawn board (in the centering pad or past the visible cells).
pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> {
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
let (off_y, pad_y, rows) =
BoardWidget::axis(board.height, area.height as usize, board.player.y);
/// the inverse of [`board_to_screen`]. `focus` must match the focus used when
/// the board was rendered so the coordinate mapping is consistent.
/// Returns `None` when the point lies outside the drawn board (in the centering
/// pad or past the visible cells).
pub fn screen_to_board(
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.
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);
@@ -118,9 +144,9 @@ impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let board = self.board;
// Resolve each axis independently: a small board is centered, a large
// one scrolls to keep the player on screen.
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, board.player.x);
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, board.player.y);
// one scrolls to keep the focus cell on screen.
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, self.focus.1);
// Walk the visible board cells, placing each after the centering pad.
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;
/// `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) {
let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
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 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 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
//! never mutates game state.
use std::collections::VecDeque;
use crate::utils::rgba8_to_color;
use kiln_core::{Archetype, Builtin};
use kiln_core::cp437::tile_to_char;
@@ -14,9 +15,8 @@ use ratatui::style::{Color, Style};
use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span};
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.
const HEART_FULL: Color = Color::Rgb(255, 0, 0);
/// 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.
///
/// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs (the first
/// `health` filled bright red, the rest dark red) and a `Gems: ♦ N` line.
pub struct StatusSidebarWidget {
/// The player's current health, clamped to [`MAX_HEARTS`] when drawn.
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 }
}
}
/// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs, a
/// `Gems: ♦ N` line, and a `Keys:` row of 8 `♀` glyphs colored when held
/// and dark gray when absent.
pub struct StatusSidebarWidget(pub Player);
impl Widget for StatusSidebarWidget {
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
// partly-depleted bar reads at a glance.
let filled = self.health.min(MAX_HEARTS);
let hearts: Vec<Span> = (0..MAX_HEARTS)
let filled = player.health;
let hearts: Vec<Span> = (0..player.max_health)
.map(|i| {
let color = if i < filled { HEART_FULL } else { HEART_EMPTY };
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_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![
Line::from("Health:"),
Line::from(hearts),
@@ -65,8 +71,10 @@ impl Widget for StatusSidebarWidget {
Line::from(vec![
Span::raw("Gems: "),
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
+15
View File
@@ -1,6 +1,10 @@
use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph;
use ratatui::layout::Rect;
use ratatui::prelude::Color;
use ratatui::style::Style;
use ratatui::text::Span;
/// 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)
}
/// 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.
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
+32 -32
View File
@@ -6,65 +6,65 @@ start = "start"
# Objects reference a script by name via `script_name`.
[scripts]
greeter = """
fn init() {
log(`hello from object player at ${Board.player_x}, ${Board.player_y}`);
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(dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`);
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
fn tick(me, state, dt) {
if state.player.x == me.x && state.player.y == me.y && !me.waiting {
say("Hey! Get offa me!");
delay(5.0);
me.delay(5.0);
}
}
"""
mover = """
fn init() {
fn init(me, state) {
if Registry.get("dancer_x") != () {
let new_x = Registry.get("dancer_x");
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);
}
} else {
Registry.set("dancer_x", Me.x);
Registry.set("dancer_y", Me.y);
log(`Set reg to ${Me.x} ${Me.y}`);
Registry.set("dancer_x", me.x);
Registry.set("dancer_y", 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
// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking
// into a wall (or another object's already-queued move this tick).
fn tick(dt) {
if Queue.length() == 0 { dance(); }
fn tick(me, state, dt) {
if !me.waiting { dance(me); }
}
fn dance() {
fn dance(me) {
move(East);
move(South);
move(North);
move(East);
say("Hey!", 0.5);
delay(0.5);
delay(me, 0.5);
move(West);
move(South);
move(North);
move(West);
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,
// or -1 for the player.
fn bump(id) {
fn bump(me, state, id) {
log(`mover bumped by ${id}`);
say("Ow!");
}
"""
muffin = """
fn bump(id) {
fn bump(me, state, _id) {
scroll([
"You find a small muffin on the ground, your favorite kind.",
"It smells of cinnamon and warm mornings.",
@@ -72,9 +72,9 @@ fn bump(id) {
["ignore", "This is obviously a trap — ignore it."]
]);
}
fn eat() {
log("You ate the muffin. Delicious.");
say("Mmm!");
fn eat(me, state) {
say("Yeah it was poisoned.");
alter_health(-2);
}
fn ignore() {
log("You walk away from the muffin. Wise.");
@@ -83,7 +83,7 @@ fn ignore() {
"""
noticeboard = """
fn bump(id) {
fn bump(me, state, id) {
scroll([
" TOWN NOTICE BOARD",
" ",
@@ -110,13 +110,13 @@ fn bump(id) {
"dusk. All residents are warmly encouraged to attend.",
" ",
" Posted by order of the Town Council.",
" Unauthorised notices will be removed.",
" Unauthorized notices will be removed.",
]);
}
"""
bookshelf = """
fn bump(id) {
fn bump(me, state, id) {
scroll([
" THE BOOKSHELF",
" ",
@@ -135,13 +135,13 @@ fn bump(id) {
"""
fireplace = """
fn bump(id) {
fn bump(me, state, id) {
say("The fire crackles warmly.\\nYou feel at ease.");
}
"""
chest = """
fn bump(id) {
fn bump(me, state, id) {
scroll([
" THE CHEST",
" ",
@@ -159,8 +159,8 @@ fn bump(id) {
"""
yammerer = """
fn tick(dt) {
if Queue.length() == 0 {
fn tick(me, state, dt) {
if !me.waiting {
say("blahblahblah");
delay(1);
}
@@ -168,11 +168,11 @@ fn tick(dt) {
"""
shifter = """
fn tick(dt) {
let x = Me.x;
let y = Me.y;
fn tick(me, state, dt) {
let x = me.x;
let y = me.y;
if Queue.length() == 0 {
if !me.waiting {
shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]);
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" }