spinners and fmt

This commit is contained in:
2026-06-21 01:32:47 -05:00
parent d32914d99d
commit 8e90084b53
31 changed files with 1445 additions and 284 deletions
+17 -14
View File
@@ -59,10 +59,10 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift. - `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift.
**`kiln-core/src/archetype.rs`** — element taxonomy: **`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `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. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`). - `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `Spinner(SpinDirection)`, `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. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `Spinner(SpinDirection)` (`SpinDirection::{Clockwise,CounterClockwise}`; keywords `spinner_cw|spinner_ccw`) is the same kind of script-backed keyword: it expands into a `spinner.rhai` object that rotates its 8 neighbours each 0.5 s and animates its glyph (default glyph is the first frame, `/` CW / `\` CCW, gray on black). `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
**`kiln-core/src/builtin_scripts.rs`** — archetypes that are really scripted objects (`pub(crate)`): **`kiln-core/src/builtin_scripts.rs`** — archetypes that are really scripted objects (`pub(crate)`):
- `archetype_script(arch) -> Option<&'static str>` — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (`include_str!`); currently `Pusher(_) -> scripts/pusher.rhai`. When `resolve_entry` (in `layer.rs`) hits such an archetype it emits an `ObjectDef` carrying that source in `builtin_script` plus a `BUILTIN_<archetype>` tag instead of a terrain cell. - `archetype_script(arch) -> Option<&'static str>` — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (`include_str!`); currently `Pusher(_) -> scripts/pusher.rhai` and `Spinner(_) -> scripts/spinner.rhai`. Such an archetype loads as a plain terrain cell (`layer::resolve_entry`); `Board::expand_builtin_archetypes` (called from `TryFrom<MapFile>` at load and again before a playtest) is the **single** place that turns it into an `ObjectDef` carrying that source in `builtin_script` plus a `BUILTIN_<archetype>` tag. The spinner reads its `BUILTIN_spinner_cw`/`_ccw` tag for spin direction (defaulting clockwise) and stores per-instance animation frame state in the board `Registry`.
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since `Me` isn't visible inside Rhai helper functions); `map_file::save` uses it to collapse the object back into its archetype keyword so maps round-trip. `scripts/pusher.rhai` self-propels via `move(dir)` (which already shoves chains via `step_object`/`push`), pacing itself with `delay` to the old ~0.5 s cadence. - `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since `Me` isn't visible inside Rhai helper functions); `map_file::save` uses it to collapse the object back into its archetype keyword so maps round-trip. `scripts/pusher.rhai` self-propels via `move(dir)` (which already shoves chains via `step_object`/`push`), pacing itself with `delay` to the old ~0.5 s cadence.
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`): **`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
@@ -89,9 +89,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`). - `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
- `is_passable(x, y)` — convenience inverse of `solid_at`. - `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space? - `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The right gate for a simultaneous shift/rotation via `apply_swap`.
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`). - `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via `solid_cell_layer`).
- `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries. - `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.)
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). - `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine>` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn.
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every script-backed terrain cell (e.g. `Spinner`/`Pusher`, those with an `archetype_script`) with the scripted object it expands to (embedded script + `BUILTIN_*` tag + behavior, copying the cell's glyph so palette overrides survive). Idempotent (cells already loaded as objects are skipped). Called by `TryFrom<MapFile> for Board` after cross-layer validation (so disk loads get working machines) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still layer-then-reading order among themselves).
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord. - `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
@@ -104,8 +107,8 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion: **`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion:
- `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
- `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it). - `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it).
- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`. - `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).
- `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` emits `type` only (lines are not inspectable via the map API). `Log` is flattened to its first span's text. - `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll`/`Swap` emit `type` only (their payloads are not inspectable via the map API). `Log` is flattened to its first span's text.
**`kiln-core/src/floor.rs`** — procedural floor generators: **`kiln-core/src/floor.rs`** — procedural floor generators:
- A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load. - A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load.
@@ -117,8 +120,8 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/script.rs`** — Rhai scripting runtime: **`kiln-core/src/script.rs`** — Rhai scripting runtime:
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: a named script keys by its pool name; an object's embedded `builtin_script` keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing. - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: a named script keys by its pool name; an object's embedded `builtin_script` keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing.
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal. - Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; 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). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. - **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `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)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`; `MOVE_COST = 0.25` s for `Move`, else 0) 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). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). - **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0`push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`). - The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
- **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta. - **Sender identity:** `source` (which object issued an action) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta.
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
@@ -127,7 +130,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`): **`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`):
- `MapFile { map: MapHeader, layers: Vec<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`). - `MapFile { map: MapHeader, layers: Vec<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`).
- `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`. - `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`.
- `impl TryFrom<MapFile> for Board` — the load conversion: builds each layer via `layer::build_layer` (collecting object/portal/player placements with their layer `z`), then runs the cross-layer validations. **Best-effort/nonfatal**: only a layer grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order. - `impl TryFrom<MapFile> for Board` — the load conversion: builds each layer via `layer::build_layer` (collecting object/portal/player placements with their layer `z`), then runs the cross-layer validations. **Best-effort/nonfatal**: only a layer grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells (pushers/spinners) into their scripted objects (these get ids after the hand-placed objects).
- `impl From<&Board> for MapFile` — save: emits one `[[layers]]` per board layer (deduping each unique `(Glyph, Archetype)` to a palette char), with objects/portals grouped by `z` and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed `floor` glyphs (the generator name is not recovered). - `impl From<&Board> for MapFile` — save: emits one `[[layers]]` per board layer (deduping each unique `(Glyph, Archetype)` to a palette char), with objects/portals grouped by `z` and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed `floor` glyphs (the generator name is not recovered).
- `pub fn load(path: &str) -> Result<Board, …>` — reads a single-board `.toml` file. Production code uses `world::load` instead. - `pub fn load(path: &str) -> Result<Board, …>` — reads a single-board `.toml` file. Production code uses `world::load` instead.
- `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file. - `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file.
@@ -190,8 +193,8 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln-
**`kiln-tui/src/editor.rs`** — world editor mode: **`kiln-tui/src/editor.rs`** — world editor mode:
- `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`). - `EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`) — a sidebar menu level; `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)`), 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`) — a sidebar menu level; `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), 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, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). - Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`. - Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
- `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. - `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
@@ -324,9 +327,9 @@ content = """
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" } "1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" }
``` ```
Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`). 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`). 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)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> 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)`, and `scroll(lines)`. 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)`, and `bump(id)` 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), and `swap([[src_x, src_y, dst_x, dst_y], …])` (move several solids simultaneously). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump(id)`.
**Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`).
@@ -354,6 +357,6 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
### Other not-yet-implemented threads ### Other not-yet-implemented threads
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call). - **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates), so references survive reordering. What's still missing: a `remove_object`, and wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime until the host is rebuilt). - **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates, `remove_object` deletes — used by `apply_swap` to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — runtime until the host is rebuilt).
- **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.) - **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.)
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup). - **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).
+17
View File
@@ -68,6 +68,13 @@ pub(crate) enum Action {
/// the source is solid and the destination already has a solid occupant. /// the source is solid and the destination already has a solid occupant.
/// Zero time cost — multiple teleports may fire in one frame. /// Zero time cost — multiple teleports may fire in one frame.
Teleport { x: i32, y: i32 }, Teleport { x: i32, y: i32 },
/// 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 },
/// Apply a batch of one-way solid moves simultaneously (read-all, then
/// write-all, so cycles/swaps work). Each tuple is `(src_x, src_y, dst_x,
/// dst_y)`. Zero time cost.
Swap(Vec<(i32, i32, i32, i32)>),
} }
// ── Direction helper ────────────────────────────────────────────────────────── // ── Direction helper ──────────────────────────────────────────────────────────
@@ -163,6 +170,16 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
m.insert("x".into(), Dynamic::from(*x as i64)); m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y 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)));
}
// Swap pairs are not inspectable via the queue map API; emit type only.
Action::Swap(_) => {
m.insert("type".into(), ds("Swap"));
}
} }
m m
} }
+69 -1
View File
@@ -38,11 +38,27 @@ pub enum Archetype {
/// [`crate::builtin_scripts`]). The script self-propels it one cell at a time in /// [`crate::builtin_scripts`]). The script self-propels it one cell at a time in
/// the facing direction, shoving any pushable chain ahead — no engine special-case. /// the facing direction, shoving any pushable chain ahead — no engine special-case.
Pusher(Direction), Pusher(Direction),
/// A rotating machine — solid, opaque, non-pushable. Like [`Pusher`](Archetype::Pusher)
/// this is a map-file *keyword* only: at load it expands into a scripted
/// [`ObjectDef`] carrying the embedded `spinner.rhai` and a `BUILTIN_spinner_<dir>`
/// tag (see [`crate::builtin_scripts`]). The script reads the tag for its spin
/// direction, rotates the 8 neighbouring cells one step each 0.5 s, and animates
/// its own glyph through a spinning line `/ ─ \ │` (slash-swapped for counter-clockwise).
Spinner(SpinDirection),
/// Sentinel for map files that reference an unknown archetype name. /// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game. /// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock, ErrorBlock,
} }
/// Which way a [`Spinner`](Archetype::Spinner) rotates the ring of cells around it.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum SpinDirection {
/// Rotate neighbours clockwise (N→NE→E→…).
Clockwise,
/// Rotate neighbours counter-clockwise (N→NW→W→…).
CounterClockwise,
}
impl Archetype { impl Archetype {
/// Returns the default [`Behavior`] for this archetype. /// Returns the default [`Behavior`] for this archetype.
/// ///
@@ -79,6 +95,11 @@ impl Archetype {
opaque: true, opaque: true,
pushable: Pushable::No, pushable: Pushable::No,
}, },
Archetype::Spinner(_) => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
Archetype::ErrorBlock => Behavior { Archetype::ErrorBlock => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
@@ -99,6 +120,8 @@ impl Archetype {
Archetype::Pusher(Direction::South) => "pusher_south", Archetype::Pusher(Direction::South) => "pusher_south",
Archetype::Pusher(Direction::East) => "pusher_east", Archetype::Pusher(Direction::East) => "pusher_east",
Archetype::Pusher(Direction::West) => "pusher_west", Archetype::Pusher(Direction::West) => "pusher_west",
Archetype::Spinner(SpinDirection::Clockwise) => "spinner_cw",
Archetype::Spinner(SpinDirection::CounterClockwise) => "spinner_ccw",
Archetype::ErrorBlock => "error_block", Archetype::ErrorBlock => "error_block",
} }
} }
@@ -148,6 +171,18 @@ impl Archetype {
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
} }
} }
Archetype::Spinner(dir) => {
// First frame of the spin animation: '/' clockwise, '\' counter.
let tile = match dir {
SpinDirection::Clockwise => 47, // '/'
SpinDirection::CounterClockwise => 92, // '\'
};
Glyph {
tile,
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
// Visually distinct so malformed map files are immediately obvious. // Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph { Archetype::ErrorBlock => Glyph {
tile: 63, tile: 63,
@@ -178,6 +213,8 @@ impl TryFrom<&str> for Archetype {
"pusher_south" => Ok(Archetype::Pusher(Direction::South)), "pusher_south" => Ok(Archetype::Pusher(Direction::South)),
"pusher_east" => Ok(Archetype::Pusher(Direction::East)), "pusher_east" => Ok(Archetype::Pusher(Direction::East)),
"pusher_west" => Ok(Archetype::Pusher(Direction::West)), "pusher_west" => Ok(Archetype::Pusher(Direction::West)),
"spinner_cw" => Ok(Archetype::Spinner(SpinDirection::Clockwise)),
"spinner_ccw" => Ok(Archetype::Spinner(SpinDirection::CounterClockwise)),
_ => Err(format!("unknown archetype: {name}")), _ => Err(format!("unknown archetype: {name}")),
} }
} }
@@ -185,11 +222,42 @@ impl TryFrom<&str> for Archetype {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Archetype; use super::{Archetype, SpinDirection};
#[test] #[test]
fn directional_crate_default_glyph_tiles() { fn directional_crate_default_glyph_tiles() {
assert_eq!(Archetype::HCrate.default_glyph().tile, 29); assert_eq!(Archetype::HCrate.default_glyph().tile, 29);
assert_eq!(Archetype::VCrate.default_glyph().tile, 18); assert_eq!(Archetype::VCrate.default_glyph().tile, 18);
} }
#[test]
fn spinner_names_round_trip() {
for arch in [
Archetype::Spinner(SpinDirection::Clockwise),
Archetype::Spinner(SpinDirection::CounterClockwise),
] {
assert_eq!(Archetype::try_from(arch.name()), Ok(arch));
}
assert_eq!(
Archetype::try_from("spinner_cw"),
Ok(Archetype::Spinner(SpinDirection::Clockwise))
);
}
#[test]
fn spinner_default_glyph_first_frame() {
// First animation frame: '/' (47) clockwise, '\' (92) counter-clockwise.
assert_eq!(
Archetype::Spinner(SpinDirection::Clockwise)
.default_glyph()
.tile,
47
);
assert_eq!(
Archetype::Spinner(SpinDirection::CounterClockwise)
.default_glyph()
.tile,
92
);
}
} }
+393 -2
View File
@@ -5,7 +5,28 @@ use crate::log::LogLine;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
use crate::utils::Direction; use crate::utils::Direction;
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid}; use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
/// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read
/// every source cell before writing any destination (so cyclic moves work).
#[derive(Clone)]
enum SolidSnapshot {
/// The player occupied the cell.
Player,
/// A solid scripted object occupied the cell, identified by its stable id.
Object(ObjectId),
/// A solid terrain cell — its visual/behavior plus the layer it lived on.
Terrain {
/// Layer the terrain lived on (restored on the destination).
z: usize,
/// The cell's glyph.
glyph: Glyph,
/// The cell's archetype.
arch: Archetype,
},
/// No solid occupant.
Empty,
}
/// The complete state of one game board (a single room or screen). /// The complete state of one game board (a single room or screen).
/// ///
@@ -256,6 +277,31 @@ impl Board {
} }
} }
/// Whether the solid at `(x, y)` can be **shifted** one step in `dir`: it is
/// itself a pushable solid *and* the next cell is either empty or holds another
/// pushable solid.
///
/// Unlike [`can_push`](Board::can_push) this inspects only the single cell
/// ahead — it does **not** verify the whole chain ends in open space. It is the
/// right test for a simultaneous rotation/shift (applied via
/// [`apply_swap`](Board::apply_swap)), where a destination is occupied by
/// another pushable that is itself moving the same frame. Returns `false` if
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
// The source must hold a solid pushable in `dir`.
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);
if !self.in_bounds(next) {
return false; // nothing to shift into off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
// The cell ahead is acceptable if it is empty or another pushable solid.
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
}
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`, /// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
/// leaving `Empty` floor behind each moved cell. /// leaving `Empty` floor behind each moved cell.
/// ///
@@ -336,6 +382,18 @@ impl Board {
id id
} }
/// Removes the object with `id`, returning its [`ObjectDef`] if it existed.
///
/// This only touches the board's `objects` map. A live [`ScriptHost`] built
/// before the removal keeps a stale `ObjectRuntime` for the gone object; its
/// subsequent host-fn calls resolve to a missing id and become no-ops, so the
/// removal is benign even mid-game (see CLAUDE.md's runtime spawn/destroy note).
///
/// [`ScriptHost`]: crate::script::ScriptHost
pub fn remove_object(&mut self, id: ObjectId) -> Option<ObjectDef> {
self.objects.remove(&id)
}
/// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at /// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at
/// `(x, y)`, applying the editor's placement/removal rules. /// `(x, y)`, applying the editor's placement/removal rules.
/// ///
@@ -373,6 +431,194 @@ impl Board {
*self.get_mut(z, x, y) = (glyph, arch); *self.get_mut(z, x, y) = (glyph, arch);
} }
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
/// `Spinner` or `Pusher`) with the scripted object it expands to — the same
/// transformation the map loader applies in [`layer::build_layer`](crate::layer),
/// but run against a live, already-built board.
///
/// The editor stamps these archetypes as plain terrain cells (via
/// [`place_archetype`](Board::place_archetype)); they only come alive once
/// expanded into objects carrying their embedded script + `BUILTIN_*` tag. Call
/// this before running a board assembled in memory (e.g. entering a playtest), so
/// editor-placed machines actually run. A save→reload round-trip expands them via
/// the normal load path, so this is only needed for the in-memory path. Cells
/// already loaded as objects are untouched, so it is safe to call more than once.
pub fn expand_builtin_archetypes(&mut self) {
use crate::builtin_scripts::{archetype_script, builtin_tag};
// Collect first: the loop below mutates both layers and the object map.
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
for z in 0..self.layers.len() {
for y in 0..self.height {
for x in 0..self.width {
let (glyph, arch) = *self.get(z, x, y);
if archetype_script(arch).is_some() {
found.push((z, x, y, glyph, arch));
}
}
}
}
for (z, x, y, glyph, arch) in found {
// Vacate the terrain cell (revealing any floor beneath), then spawn the
// object — mirroring `resolve_entry`'s object template.
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
let b = arch.behavior();
let mut obj = ObjectDef::new(x, y);
obj.z = z;
obj.glyph = glyph;
obj.solid = b.solid;
obj.opaque = b.opaque;
obj.pushable = false;
obj.builtin_script = archetype_script(arch);
obj.tags.insert(builtin_tag(arch));
self.add_object(obj);
}
}
/// Applies a batch of one-way solid moves **simultaneously**, returning any
/// nonfatal error lines (out-of-bounds entries / a write blocked by the player).
///
/// Each tuple is `(src_x, src_y, dst_x, dst_y)`: the solid occupant of `(src_x,
/// src_y)` — the player, a solid object, or a terrain crate/wall — moves to
/// `(dst_x, dst_y)`. A source with no solid moves an "empty", which **removes**
/// whatever solid was at the destination. Every source is read before any
/// destination is written, so cyclic permutations and two-cell swaps resolve
/// correctly (e.g. `[a→b],[b→a]` swaps `a` and `b`).
///
/// Displacement rules: a destination's prior solid that isn't itself being moved
/// is removed (terrain cleared; a scripted object despawned via
/// [`remove_object`](Board::remove_object)). The **player is never destroyed** —
/// a write that would overwrite the player without relocating it is skipped and
/// logged (the player wins its cell, per the one-solid-per-cell invariant).
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> Vec<LogLine> {
let mut errors = Vec::new();
// 1. Validate: keep only entries whose source and destination are in bounds.
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
for &(sx, sy, dx, dy) in pairs {
if !self.in_bounds((sx, sy)) || !self.in_bounds((dx, dy)) {
errors.push(LogLine::error(format!(
"swap: out-of-bounds entry ({sx},{sy})->({dx},{dy})"
)));
continue;
}
valid.push(((sx, sy), (dx, dy)));
}
// 2. Snapshot the solid at each unique source (read phase). Reading every
// source before any write is what lets cycles/swaps resolve.
let mut snapshots: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
for &(src, _) in &valid {
snapshots
.entry(src)
.or_insert_with(|| self.snapshot_solid(src));
}
// 3. Compute the final occupant of every affected cell. A source that is not
// anyone's destination is vacated (Empty); each entry writes its source's
// snapshot into its destination (a later entry wins a repeated destination).
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
let mut final_state: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
for &(src, _) in &valid {
if !dsts.contains(&src) {
final_state.insert(src, SolidSnapshot::Empty);
}
}
for &(src, dst) in &valid {
final_state.insert(dst, snapshots[&src].clone());
}
// The player's final cell: where a Player snapshot is installed, else its
// current cell (it stays put). Used to protect the player from being
// overwritten — computed up front so it's stable across the write loop.
let player_final = final_state
.iter()
.find(|(_, s)| matches!(s, SolidSnapshot::Player))
.map(|(&c, _)| c)
.unwrap_or((self.player.x, self.player.y));
// 4a. Clear each affected cell's current occupant, despawning any object that
// doesn't survive (isn't reused in final_state). The player and surviving
// objects keep their entity and are repositioned by the install step.
let survivors: HashSet<ObjectId> = final_state
.values()
.filter_map(|s| match s {
SolidSnapshot::Object(id) => Some(*id),
_ => None,
})
.collect();
let affected: HashSet<(i32, i32)> = snapshots
.keys()
.copied()
.chain(final_state.keys().copied())
.collect();
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
match self.solid_at(ux, uy) {
Some(Solid::Object(id)) if !survivors.contains(&id) => {
self.remove_object(id);
}
Some(Solid::Cell(_)) => {
if let Some(z) = self.solid_cell_layer(ux, uy) {
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
}
}
_ => {}
}
}
// 4b. Install each cell's computed occupant.
for (&(cx, cy), snap) in &final_state {
let (ux, uy) = (cx as usize, cy as usize);
// The player wins its cell: never overwrite player_final with anything
// other than the player itself.
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
errors.push(LogLine::error(format!(
"swap: cannot overwrite the player at ({cx},{cy})"
)));
continue;
}
match snap {
SolidSnapshot::Empty => {} // already cleared
SolidSnapshot::Terrain { z, glyph, arch } => {
*self.get_mut(*z, ux, uy) = (*glyph, *arch);
}
SolidSnapshot::Object(id) => {
if let Some(obj) = self.objects.get_mut(id) {
obj.x = ux;
obj.y = uy;
}
}
SolidSnapshot::Player => {
self.player.x = cx;
self.player.y = cy;
}
}
}
errors
}
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
/// [`apply_swap`](Board::apply_swap)). Coordinates must be in bounds.
fn snapshot_solid(&self, (x, y): (i32, i32)) -> SolidSnapshot {
let (ux, uy) = (x as usize, y as usize);
match self.solid_at(ux, uy) {
Some(Solid::Player) => SolidSnapshot::Player,
Some(Solid::Object(id)) => SolidSnapshot::Object(id),
Some(Solid::Cell(arch)) => {
let z = self
.solid_cell_layer(ux, uy)
.expect("a solid terrain cell has a layer");
SolidSnapshot::Terrain {
z,
glyph: self.get(z, ux, uy).0,
arch,
}
}
None => SolidSnapshot::Empty,
}
}
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty` /// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell /// (the cell's single terrain archetype, if any). By the one-solid-per-cell
/// invariant there is at most one such layer. /// invariant there is at most one such layer.
@@ -384,7 +630,7 @@ impl Board {
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use super::Board; use super::Board;
use crate::archetype::Archetype; use crate::archetype::{Archetype, SpinDirection};
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::layer::Layer; use crate::layer::Layer;
use crate::object_def::ObjectDef; use crate::object_def::ObjectDef;
@@ -538,6 +784,38 @@ pub(crate) mod tests {
assert!(!board.can_push(1, 0, Direction::East)); assert!(!board.can_push(1, 0, Direction::East));
} }
#[test]
fn can_shift_only_checks_the_cell_ahead() {
// Source must be pushable.
let mut board = open_board(4, 1, (3, 0), vec![]);
assert!(!board.can_shift(0, 0, Direction::East)); // empty source
// Crate with open space ahead: shiftable.
crate_at(&mut board, 0, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
// which would follow the chain to the wall and fail).
let mut board = open_board(4, 1, (3, 0), vec![]);
crate_at(&mut board, 0, 0);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert!(!board.can_push(0, 0, Direction::East));
// Crate with a non-pushable wall ahead: not shiftable.
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0);
assert!(!board.can_shift(0, 0, Direction::East));
// Crate at the board edge facing off-board: not shiftable.
let mut board = open_board(2, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
assert!(!board.can_shift(1, 0, Direction::East));
}
#[test] #[test]
fn push_into_player_pushes_player() { fn push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space. // Crate shoved east into the player slides the player along into open space.
@@ -673,4 +951,117 @@ pub(crate) mod tests {
assert!(!board.is_valid()); assert!(!board.is_valid());
assert_eq!(board.load_errors.len(), 1); assert_eq!(board.load_errors.len(), 1);
} }
#[test]
fn expand_builtin_archetypes_replaces_a_spinner_cell_with_an_object() {
let mut board = open_board(3, 1, (2, 0), vec![]);
stamp(
&mut board,
0,
0,
Archetype::Spinner(SpinDirection::Clockwise),
);
board.expand_builtin_archetypes();
// The terrain cell is vacated and a scripted object takes its place.
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
let obj = board.objects.values().next().expect("spinner object");
assert_eq!((obj.x, obj.y), (0, 0));
assert!(obj.solid);
assert!(obj.builtin_script.is_some());
assert!(obj.tags.contains("BUILTIN_spinner_cw"));
// Idempotent: nothing left to expand on a second pass.
board.expand_builtin_archetypes();
assert_eq!(board.objects.len(), 1);
}
#[test]
fn remove_object_deletes_from_map() {
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(0, 0)]);
assert!(board.solid_object_id_at(0, 0).is_some());
let removed = board.remove_object(1);
assert!(removed.is_some());
assert!(board.solid_object_id_at(0, 0).is_none());
assert!(board.remove_object(1).is_none()); // already gone
}
#[test]
fn apply_swap_swaps_two_terrain_cells() {
// A crate and a wall trade places in one batch (read-all then write-all).
let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 2, 0);
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_cycle_propagates_empty() {
// The spec example: move a->b and b->c with a empty ⇒ a empty, b empty,
// c holds what b held (the empty written into b doesn't block b->c).
let mut board = open_board(4, 1, (3, 0), vec![]);
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_empty_onto_object_removes_it() {
// Moving an empty source onto an object despawns the object.
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty());
assert!(board.solid_object_id_at(1, 0).is_none());
assert!(board.objects.is_empty());
}
#[test]
fn apply_swap_terrain_onto_terrain_removes_displaced() {
// Moving a crate onto a wall clears the source and removes the wall.
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_moves_object_and_player() {
// An object and the player relocate (swap places) in one batch.
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
assert!(errs.is_empty());
assert_eq!((board.player.x, board.player.y), (2, 0));
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
}
#[test]
fn apply_swap_out_of_bounds_skips_and_logs() {
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
let errs = board.apply_swap(&[(0, 0, 9, 0)]);
assert_eq!(errs.len(), 1);
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
}
#[test]
fn apply_swap_will_not_overwrite_player() {
// A crate moved onto the (non-relocating) player is rejected and logged.
let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
let errs = board.apply_swap(&[(0, 0, 1, 0)]);
assert_eq!(errs.len(), 1);
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
}
} }
+5
View File
@@ -22,6 +22,10 @@ pub(crate) const BUILTIN_TAG_PREFIX: &str = "BUILTIN_";
/// direction comes from the object's tag, so all pushers share one compiled AST. /// direction comes from the object's tag, so all pushers share one compiled AST.
const PUSHER: &str = include_str!("scripts/pusher.rhai"); const PUSHER: &str = include_str!("scripts/pusher.rhai");
/// The spinner behavior, embedded into the binary. Shared by both spin directions —
/// direction comes from the object's tag, so all spinners share one compiled AST.
const SPINNER: &str = include_str!("scripts/spinner.rhai");
/// The tag identifying an object as the expanded form of `arch`, e.g. /// The tag identifying an object as the expanded form of `arch`, e.g.
/// `"BUILTIN_pusher_east"`. /// `"BUILTIN_pusher_east"`.
pub(crate) fn builtin_tag(arch: Archetype) -> String { pub(crate) fn builtin_tag(arch: Archetype) -> String {
@@ -41,6 +45,7 @@ pub(crate) fn archetype_from_builtin_tag(tag: &str) -> Option<Archetype> {
pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> { pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> {
match arch { match arch {
Archetype::Pusher(_) => Some(PUSHER), Archetype::Pusher(_) => Some(PUSHER),
Archetype::Spinner(_) => Some(SPINNER),
_ => None, _ => None,
} }
} }
+181
View File
@@ -287,6 +287,17 @@ impl GameState {
} }
} }
} }
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if board.in_bounds((x, y)) {
board.push(x as usize, y as usize, dir);
} else {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
}
}
// apply_swap reads all sources then writes all destinations, so a
// whole batch of moves resolves simultaneously (cycles included).
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
} }
} }
} }
@@ -451,3 +462,173 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
} }
bumped bumped
} }
#[cfg(test)]
mod tests {
use super::GameState;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
/// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
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]);
crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
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_swap_moves_crates_simultaneously() {
// Swap the crate at (2,0) with the empty cell at (3,0): one ends up empty,
// the other holds the crate, applied in a single batch.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }",
);
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_passable_distinguishes_empty_solid_and_offboard() {
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
// object sits at (0,0); the player at (3,0). passable should report only the
// genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
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 scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let lines: Vec<String> = game
.log
.iter()
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
.collect();
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
spinner_board_dir(crates, walls, false)
}
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
/// script spins counter-clockwise (clockwise is the default when no tag present).
fn spinner_board_dir(
crates: &[(usize, usize)],
walls: &[(usize, usize)],
ccw: bool,
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.solid = false;
obj.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
}
let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates {
crate_at(&mut board, x, y);
}
for &(x, y) in walls {
wall_at(&mut board, x, y);
}
let scripts = HashMap::from([(
"spinner".to_string(),
include_str!("scripts/spinner.rhai").to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn spinner_does_not_destroy_a_blocked_neighbour() {
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
// wall, so N must not rotate onto NE — the old (can_shift-only) script
// overwrote and destroyed NE's crate. The cascade leaves everything put.
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
}
#[test]
fn spinner_rotates_an_arc_into_a_hole() {
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
// Clockwise, every crate advances one slot and the hole ends up at W.
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
fn spinner_ccw_rotates_the_other_way() {
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
}
#[test]
fn spinner_animates_its_glyph_without_recoloring() {
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
// per 0.5s rotation, while the colours stay put.
let mut game = spinner_board(&[], &[]);
let id = *game.board().objects.keys().next().unwrap();
let (fg0, bg0) = {
let b = game.board();
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
};
let mut seq = Vec::new();
for _ in 0..5 {
game.tick(Duration::from_secs_f64(0.5));
let b = game.board();
seq.push(b.objects[&id].glyph.tile);
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
}
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
}
}
+5 -21
View File
@@ -144,7 +144,6 @@ pub(crate) struct ObjectTemplate {
pub opaque: bool, pub opaque: bool,
pub pushable: bool, pub pushable: bool,
pub script_name: Option<String>, pub script_name: Option<String>,
pub builtin_script: Option<&'static str>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub name: Option<String>, pub name: Option<String>,
} }
@@ -214,7 +213,6 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
opaque: e.opaque.unwrap_or(true), opaque: e.opaque.unwrap_or(true),
pushable: e.pushable.unwrap_or(false), pushable: e.pushable.unwrap_or(false),
script_name: e.script_name.clone(), script_name: e.script_name.clone(),
builtin_script: None,
tags: e.tags.clone().unwrap_or_default(), tags: e.tags.clone().unwrap_or_default(),
name: e.name.clone(), name: e.name.clone(),
}), }),
@@ -234,26 +232,12 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
} }
}, },
"player" => Resolved::Player, "player" => Resolved::Player,
// Any other kind must be an archetype name. // Any other kind must be an archetype name. Script-backed archetypes (e.g.
// pushers/spinners) load as a plain terrain cell here and are turned into
// their scripted objects afterward by `Board::expand_builtin_archetypes`
// (called from `TryFrom<MapFile>`), so the expansion lives in one place.
other => match Archetype::try_from(other) { other => match Archetype::try_from(other) {
// Script-backed archetypes (e.g. pushers) expand into an object carrying Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
// the embedded script plus a `BUILTIN_<archetype>` tag the script reads.
Ok(a) => match crate::builtin_scripts::archetype_script(a) {
Some(src) => {
let b = a.behavior();
Resolved::Object(ObjectTemplate {
glyph: glyph_with_default(a.default_glyph()),
solid: b.solid,
opaque: b.opaque,
pushable: false,
script_name: None,
builtin_script: Some(src),
tags: vec![crate::builtin_scripts::builtin_tag(a)],
name: None,
})
}
None => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
},
Err(msg) => { Err(msg) => {
errors.push(LogLine::error(format!( errors.push(LogLine::error(format!(
"palette '{ch}': {msg}; using error block" "palette '{ch}': {msg}; using error block"
+1 -1
View File
@@ -23,7 +23,7 @@ mod utils;
/// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). /// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]).
pub mod world; pub mod world;
pub use archetype::Archetype; pub use archetype::{Archetype, SpinDirection};
pub use board::Board; pub use board::Board;
pub use utils::Direction; pub use utils::Direction;
+10 -3
View File
@@ -238,7 +238,9 @@ impl TryFrom<MapFile> for Board {
opaque: t.opaque, opaque: t.opaque,
pushable: t.pushable, pushable: t.pushable,
script_name: t.script_name, script_name: t.script_name,
builtin_script: t.builtin_script, // Script-backed archetypes are expanded after the board is built
// (see `expand_builtin_archetypes` below), not via templates.
builtin_script: None,
tags: t.tags.into_iter().collect(), tags: t.tags.into_iter().collect(),
name, name,
}, },
@@ -267,7 +269,7 @@ impl TryFrom<MapFile> for Board {
}); });
} }
Ok(Board { let mut board = Board {
name: mf.map.name, name: mf.map.name,
width: w, width: w,
height: h, height: h,
@@ -282,7 +284,12 @@ impl TryFrom<MapFile> for Board {
board_script_name: mf.map.board_script_name, board_script_name: mf.map.board_script_name,
load_errors, load_errors,
registry: HashMap::new(), registry: HashMap::new(),
}) };
// Turn script-backed archetype cells (pushers/spinners) into their scripted
// objects. Runs after the cross-layer validation above, so board invariants
// hold; the same call also fixes editor-placed machines before a playtest.
board.expand_builtin_archetypes();
Ok(board)
} }
} }
+83 -1
View File
@@ -36,6 +36,13 @@
//! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit) //! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit)
//! - `Board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info //! - `Board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info
//! //!
//! ## Cell queries (free fns)
//!
//! - `blocked(dir) -> bool` — would the caller be blocked moving one step in `dir`
//! - `can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir`
//! - `can_shift(x, y, dir) -> bool` — like `can_push`, but only checks the cell ahead
//! - `passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole)
//!
//! ## Self-reference API (`Me.*`) //! ## Self-reference API (`Me.*`)
//! //!
//! - `Me.id / name / x / y` — getters //! - `Me.id / name / x / y` — getters
@@ -47,7 +54,8 @@
//! //!
//! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`, //! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`,
//! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`, //! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`,
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)` //! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
//! `swap([[src_x, src_y, dst_x, dst_y], …])`
//! //!
//! ## Queue API //! ## Queue API
//! //!
@@ -672,6 +680,32 @@ fn register_read_api(
is_blocked(&b, &bq, source_of(&ctx), dir) is_blocked(&b, &bq, source_of(&ctx), dir)
}); });
// 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.
let b = board.clone();
engine.register_fn("can_push", move |x: i64, y: i64, dir: Direction| -> bool {
let board = b.borrow();
board.in_bounds((x as i32, y as i32)) && board.can_push(x as usize, y as usize, dir)
});
// can_shift(x, y, dir) — like can_push but inspects only the cell ahead: (x, y)
// holds a pushable and the next cell is empty or another pushable. False
// off-board. The right gate for a simultaneous shift/rotation via swap().
let b = board.clone();
engine.register_fn("can_shift", move |x: i64, y: i64, dir: Direction| -> bool {
let board = b.borrow();
board.in_bounds((x as i32, y as i32)) && board.can_shift(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).
let b = board.clone();
engine.register_fn("passable", move |x: i64, y: i64| -> bool {
let board = b.borrow();
board.in_bounds((x as i32, y as i32)) && board.is_passable(x as usize, y as usize)
});
// Board.tagged(tag) -> Array[ObjectInfo] // Board.tagged(tag) -> Array[ObjectInfo]
let b = board.clone(); let b = board.clone();
engine.register_fn( engine.register_fn(
@@ -983,6 +1017,54 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
}, },
); );
}); });
// push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on
// arbitrary cells, not the caller, and adds no delay (zero time cost).
let q = queues.clone();
engine.register_fn(
"push",
move |ctx: NativeCallContext, x: i64, y: i64, dir: Direction| {
emit(
&q,
source_of(&ctx),
Action::Push {
x: x as i32,
y: y as i32,
dir,
},
);
},
);
// swap(pairs): apply a batch of one-way solid moves simultaneously. `pairs` is
// an array whose elements are four-int arrays `[src_x, src_y, dst_x, dst_y]`. A
// malformed entry is skipped with a logged error. Zero time cost.
let q = queues.clone();
engine.register_fn("swap", move |ctx: NativeCallContext, arr: Array| {
let src = source_of(&ctx);
let mut pairs: Vec<(i32, i32, i32, i32)> = Vec::new();
for elem in &arr {
// Each entry must be an array of exactly four integers.
let coords: Option<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| {
if inner.len() == 4 {
inner.iter().map(|v| v.as_int().ok()).collect()
} else {
None
}
});
match coords {
Some(c) => pairs.push((c[0] as i32, c[1] as i32, c[2] as i32, c[3] as i32)),
None => emit(
&q,
src,
Action::Log(LogLine::error(
"swap: each entry must be [src_x, src_y, dst_x, dst_y]".to_string(),
)),
),
}
}
emit(&q, src, Action::Swap(pairs));
});
} }
// ── Queue API ───────────────────────────────────────────────────────────────── // ── Queue API ─────────────────────────────────────────────────────────────────
+98
View File
@@ -0,0 +1,98 @@
// Rotates this object's 8 neighbours one step around a ring every 0.5s, shoving
// each pushable into the next ring slot via a single simultaneous `swap`. The spin
// direction comes from the `BUILTIN_spinner_*` tag the map loader attaches (it
// defaults to clockwise when no tag is present).
//
// A neighbour may only rotate if its destination slot will actually be free: the
// slot is a hole (`passable`), or that slot's own occupant also rotates out. We
// 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) {
// 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; }
// Direction from the builtin tag; clockwise unless explicitly counter-clockwise.
let cw = !Me.has_tag("BUILTIN_spinner_ccw");
let ox = Me.x;
let oy = Me.y;
// The 8 neighbour offsets in clockwise order, starting at North.
let ring = [
[ 0, -1], // N
[ 1, -1], // NE
[ 1, 0], // E
[ 1, 1], // SE
[ 0, 1], // S
[-1, 1], // SW
[-1, 0], // W
[-1, -1], // NW
];
// The cardinal step that carries each ring cell to the *next* slot in the spin
// direction, and the index offset to that slot (+1 clockwise, -1 == +7 counter).
// CW: N->NE is East, NE->E is South, ... CCW: N->NW is West, NE->N is West, ...
let dirs = if cw {
[East, South, South, West, West, North, North, East]
} else {
[West, West, North, North, East, East, South, South]
};
let step = if cw { 1 } else { 7 };
// Animate the glyph one frame per rotation (changing only the character): the
// 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 f = Registry.get_or(fkey, 0);
set_tile(frames[f % 4]);
Registry.set(fkey, (f + 1) % 4);
// moves[i]: will ring cell i rotate into its destination slot? Seed with
// can_shift, which is true only when cell i holds a pushable whose immediate
// next cell isn't a wall (so it could move *if* that next cell ends up free).
let moves = [];
for i in 0..8 {
let sx = ox + ring[i][0];
let sy = oy + ring[i][1];
moves.push(can_shift(sx, sy, dirs[i]));
}
// Cascade: a cell may only move if its destination is a hole (`passable`), or
// the occupant of that destination also moves. Demote until stable. A broken
// arc collapses back from the jam; a full pushable cycle never demotes (every
// destination's occupant moves) and rotates whole via swap's cycle handling.
loop {
let changed = false;
for i in 0..8 {
let n = (i + step) % 8;
let dx = ox + ring[n][0];
let dy = oy + ring[n][1];
if moves[i] && !passable(dx, dy) && !moves[n] {
moves[i] = false;
changed = true;
}
}
if !changed { break; }
}
// Emit the surviving rotations as one simultaneous batch, then pace to twice
// a second. Coordinates are pulled into locals first to keep each expression
// simple (Rhai caps expression complexity).
let batch = [];
for i in 0..8 {
if moves[i] {
let n = (i + step) % 8;
let sx = ox + ring[i][0];
let sy = oy + ring[i][1];
let dx = ox + ring[n][0];
let dy = oy + ring[n][1];
batch.push([sx, sy, dx, dy]);
}
}
swap(batch);
delay(0.5);
}
+1
View File
@@ -5,6 +5,7 @@ mod player_placement;
mod portal_placement; mod portal_placement;
mod pushers; mod pushers;
mod round_trip; mod round_trip;
mod spinners;
use crate::board::Board; use crate::board::Board;
use crate::map_file::MapFile; use crate::map_file::MapFile;
+4 -1
View File
@@ -148,7 +148,10 @@ mod tests {
); );
// The copy changed; the original is still empty at that cell. // The copy changed; the original is still empty at that cell.
assert_eq!(copy.boards["start"].borrow().get(0, 1, 0).1, Archetype::Wall); assert_eq!(
copy.boards["start"].borrow().get(0, 1, 0).1,
Archetype::Wall
);
assert_eq!( assert_eq!(
world.boards["start"].borrow().get(0, 1, 0).1, world.boards["start"].borrow().get(0, 1, 0).1,
Archetype::Empty Archetype::Empty
+4 -2
View File
@@ -9,10 +9,10 @@
//! but open animations may advertise an interactive mode so the overlay is //! but open animations may advertise an interactive mode so the overlay is
//! responsive before the animation completes. //! responsive before the animation completes.
use crate::input::InputMode;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use crate::input::InputMode;
/// Which region of the terminal an animation occupies. /// Which region of the terminal an animation occupies.
pub enum AnimationLayer { pub enum AnimationLayer {
@@ -32,7 +32,9 @@ pub trait Animation {
fn layer(&self) -> AnimationLayer; fn layer(&self) -> AnimationLayer;
/// Which [`InputMode`] is active while this animation is running. /// Which [`InputMode`] is active while this animation is running.
/// Defaults to [`InputMode::Ignore`] (all input discarded). /// Defaults to [`InputMode::Ignore`] (all input discarded).
fn input_mode_during(&self) -> InputMode { InputMode::Ignore } fn input_mode_during(&self) -> InputMode {
InputMode::Ignore
}
/// Which [`InputMode`] to enter when the animation finishes. /// Which [`InputMode`] to enter when the animation finishes.
fn input_mode_after(&self) -> InputMode; fn input_mode_after(&self) -> InputMode;
} }
+6
View File
@@ -326,6 +326,12 @@ impl EditorState {
let mut world = self.world.deep_clone(); let mut world = self.world.deep_clone();
// Start the playtest on the board open in the editor, not world.start. // Start the playtest on the board open in the editor, not world.start.
world.start = self.board_name.clone(); world.start = self.board_name.clone();
// Editor-stamped machines (spinners, pushers) are plain terrain cells; expand
// them into their scripted objects so they actually run, just as the map loader
// does on disk-loaded worlds.
for board in world.boards.values() {
board.borrow_mut().expand_builtin_archetypes();
}
let mut game = GameState::from_world(world); let mut game = GameState::from_world(world);
game.log(LogLine::raw("[esc] to return to the editor")); game.log(LogLine::raw("[esc] to return to the editor"));
game.run_init(); game.run_init();
+30 -9
View File
@@ -1,7 +1,7 @@
use kiln_core::{Archetype, Direction};
use crate::editor::EditorState; use crate::editor::EditorState;
use crate::menu::{MenuItem, MenuKey}; use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode; use crate::mode::PendingMode;
use kiln_core::{Archetype, Direction, SpinDirection};
/// One level in the editor's sidebar menu tree. /// One level in the editor's sidebar menu tree.
/// ///
@@ -72,16 +72,29 @@ impl MenuLevel {
MenuEntry::item('c', "■ Crate", |ed| ed.set_current(Archetype::Crate)), MenuEntry::item('c', "■ Crate", |ed| ed.set_current(Archetype::Crate)),
MenuEntry::item('v', "↕ Crate", |ed| ed.set_current(Archetype::VCrate)), MenuEntry::item('v', "↕ Crate", |ed| ed.set_current(Archetype::VCrate)),
MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::HCrate)), MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::HCrate)),
], ],
MenuLevel::Machines => vec![ MenuLevel::Machines => vec![
MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)), MenuEntry::item('p', "Pushers...", |ed| ed.menu.push(MenuLevel::Pushers)),
MenuEntry::item('s', "/ CW spinner", |ed| {
ed.set_current(Archetype::Spinner(SpinDirection::Clockwise))
}),
MenuEntry::item('c', "\\ CCW spinner", |ed| {
ed.set_current(Archetype::Spinner(SpinDirection::CounterClockwise))
}),
], ],
MenuLevel::Pushers => vec![ MenuLevel::Pushers => vec![
MenuEntry::item('n', "▲ Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::North))), MenuEntry::item('n', "▲ Pusher", |ed| {
MenuEntry::item('s', "▼ Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::South))), ed.set_current(Archetype::Pusher(Direction::North))
MenuEntry::item('e', "► Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::East))), }),
MenuEntry::item('w', " Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::West))), MenuEntry::item('s', " Pusher", |ed| {
ed.set_current(Archetype::Pusher(Direction::South))
}),
MenuEntry::item('e', "► Pusher", |ed| {
ed.set_current(Archetype::Pusher(Direction::East))
}),
MenuEntry::item('w', "◄ Pusher", |ed| {
ed.set_current(Archetype::Pusher(Direction::West))
}),
], ],
} }
} }
@@ -119,12 +132,20 @@ pub(crate) enum MenuEntry {
/// A horizontal rule spanning the sidebar width. /// A horizontal rule spanning the sidebar width.
Separator, Separator,
/// The current value of some field /// The current value of some field
CurrentValue(Box<dyn FnOnce(& EditorState) -> String>) CurrentValue(Box<dyn FnOnce(&EditorState) -> String>),
} }
impl MenuEntry { impl MenuEntry {
pub(crate) fn item<F: FnOnce(&mut EditorState) + 'static>(key: char, label: impl Into<String>, action: F) -> Self { pub(crate) fn item<F: FnOnce(&mut EditorState) + 'static>(
MenuEntry::Item { key, label: label.into(), action: Box::new(action) } key: char,
label: impl Into<String>,
action: F,
) -> Self {
MenuEntry::Item {
key,
label: label.into(),
action: Box::new(action),
}
} }
pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self { pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self {
+10 -3
View File
@@ -3,6 +3,7 @@
//! [`LogWidget`] renders the scrollable log panel as a ratatui [`StatefulWidget`]; //! [`LogWidget`] renders the scrollable log panel as a ratatui [`StatefulWidget`];
//! [`LogState`] holds whether the panel is open, its height, and scroll position. //! [`LogState`] holds whether the panel is open, its height, and scroll position.
use crate::utils::rgba8_to_color;
use kiln_core::log::{LogLine, LogSpan}; use kiln_core::log::{LogLine, LogSpan};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
@@ -10,7 +11,6 @@ use ratatui::style::{Color, Style};
use ratatui::symbols::merge::MergeStrategy; use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget, Wrap}; use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget, Wrap};
use crate::utils::rgba8_to_color;
/// View state for the log panel. /// View state for the log panel.
pub struct LogState { pub struct LogState {
@@ -24,7 +24,11 @@ pub struct LogState {
impl Default for LogState { impl Default for LogState {
fn default() -> Self { fn default() -> Self {
Self { open: false, height: 10, scroll: 0 } Self {
open: false,
height: 10,
scroll: 0,
}
} }
} }
@@ -74,7 +78,10 @@ impl StatefulWidget for LogWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer, state: &mut LogState) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut LogState) {
let block = Block::bordered() let block = Block::bordered()
.merge_borders(MergeStrategy::Exact) .merge_borders(MergeStrategy::Exact)
.title(Span::styled(" [l]og: ", Style::default().fg(Color::DarkGray))); .title(Span::styled(
" [l]og: ",
Style::default().fg(Color::DarkGray),
));
let lines: Vec<Line> = self.logs.iter().rev().map(logline_to_line).collect(); let lines: Vec<Line> = self.logs.iter().rev().map(logline_to_line).collect();
Paragraph::new(lines) Paragraph::new(lines)
.block(block) .block(block)
+1 -1
View File
@@ -9,6 +9,7 @@
mod animation; mod animation;
mod editor; mod editor;
mod editor_menu;
mod input; mod input;
mod log; mod log;
mod menu; mod menu;
@@ -21,7 +22,6 @@ mod term;
mod transition; mod transition;
mod ui; mod ui;
mod utils; mod utils;
mod editor_menu;
use crate::animation::{AnimWidget, AnimationLayer}; use crate::animation::{AnimWidget, AnimationLayer};
use crate::editor::{ use crate::editor::{
+82 -32
View File
@@ -6,7 +6,11 @@
//! the closure body — this lets [`handle_menu_input`](crate::input::handle_menu_input) clone //! the closure body — this lets [`handle_menu_input`](crate::input::handle_menu_input) clone
//! the action reference before releasing the borrow on [`Ui`](crate::ui::Ui). //! the action reference before releasing the borrow on [`Ui`](crate::ui::Ui).
use std::rc::Rc; use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode;
use crate::mode::PendingMode;
use crate::overlay::{OverlayStyle, render_overlay_frame};
use crate::ui::Ui;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
@@ -14,11 +18,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{ use ratatui::widgets::{
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
}; };
use crate::animation::{Animation, AnimationLayer}; use std::rc::Rc;
use crate::input::InputMode;
use crate::mode::PendingMode;
use crate::overlay::{OverlayStyle, render_overlay_frame};
use crate::ui::Ui;
/// Shared-ownership reference to a menu item action closure. /// Shared-ownership reference to a menu item action closure.
/// ///
@@ -65,12 +65,12 @@ pub struct MenuItem {
impl MenuItem { impl MenuItem {
/// Creates a menu item with the given key, label, and action closure. /// Creates a menu item with the given key, label, and action closure.
pub fn new( pub fn new(key: MenuKey, label: impl Into<String>, action: impl Fn(&mut Ui) + 'static) -> Self {
key: MenuKey, Self {
label: impl Into<String>, key,
action: impl Fn(&mut Ui) + 'static, label: label.into(),
) -> Self { action: Rc::new(action),
Self { key, label: label.into(), action: Rc::new(action) } }
} }
/// Returns a cloned `Rc` to the action closure so the caller can release any /// Returns a cloned `Rc` to the action closure so the caller can release any
@@ -117,7 +117,14 @@ impl StatefulWidget for MenuWidget {
type State = MenuState; type State = MenuState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut MenuState) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut MenuState) {
render_menu_at(&state.items, 1.0, state.offset, area, buf, Some(&mut state.max_scroll)); render_menu_at(
&state.items,
1.0,
state.offset,
area,
buf,
Some(&mut state.max_scroll),
);
} }
} }
@@ -133,7 +140,10 @@ pub struct MenuOpenAnimation {
impl MenuOpenAnimation { impl MenuOpenAnimation {
/// Creates a new open animation from a snapshot of the menu items. /// Creates a new open animation from a snapshot of the menu items.
pub fn new(items: Vec<MenuItem>) -> Self { pub fn new(items: Vec<MenuItem>) -> Self {
Self { items, progress: 0.0 } Self {
items,
progress: 0.0,
}
} }
} }
@@ -144,15 +154,28 @@ impl Animation for MenuOpenAnimation {
} }
fn draw(&self, area: Rect, buf: &mut Buffer) { fn draw(&self, area: Rect, buf: &mut Buffer) {
render_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None); render_menu_at(
&self.items,
self.progress.clamp(0.0, 1.0),
0,
area,
buf,
None,
);
} }
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } fn layer(&self) -> AnimationLayer {
AnimationLayer::Overlay
}
/// The menu is interactive from the first frame of the open animation. /// The menu is interactive from the first frame of the open animation.
fn input_mode_during(&self) -> InputMode { InputMode::Menu } fn input_mode_during(&self) -> InputMode {
InputMode::Menu
}
fn input_mode_after(&self) -> InputMode { InputMode::Menu } fn input_mode_after(&self) -> InputMode {
InputMode::Menu
}
} }
/// Closes a menu overlay with an animated collapse. /// Closes a menu overlay with an animated collapse.
@@ -167,7 +190,10 @@ pub struct MenuCloseAnimation {
impl MenuCloseAnimation { impl MenuCloseAnimation {
/// Creates a new close animation from a snapshot of the menu items. /// Creates a new close animation from a snapshot of the menu items.
pub fn new(items: Vec<MenuItem>) -> Self { pub fn new(items: Vec<MenuItem>) -> Self {
Self { items, progress: 1.0 } Self {
items,
progress: 1.0,
}
} }
} }
@@ -178,12 +204,23 @@ impl Animation for MenuCloseAnimation {
} }
fn draw(&self, area: Rect, buf: &mut Buffer) { fn draw(&self, area: Rect, buf: &mut Buffer) {
render_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None); render_menu_at(
&self.items,
self.progress.clamp(0.0, 1.0),
0,
area,
buf,
None,
);
} }
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } fn layer(&self) -> AnimationLayer {
AnimationLayer::Overlay
}
fn input_mode_after(&self) -> InputMode { InputMode::Board } fn input_mode_after(&self) -> InputMode {
InputMode::Board
}
} }
/// Renders the menu overlay at a given animation progress. /// Renders the menu overlay at a given animation progress.
@@ -202,14 +239,20 @@ fn render_menu_at(
let Some((content_area, scrollbar_area)) = let Some((content_area, scrollbar_area)) =
render_overlay_frame(&MENU_STYLE, progress, area, buf) render_overlay_frame(&MENU_STYLE, progress, area, buf)
else { else {
if let Some(m) = out_max_scroll { *m = 0; } if let Some(m) = out_max_scroll {
*m = 0;
}
return; return;
}; };
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(MENU_STYLE.bg); let key_style = Style::default()
.fg(Color::Rgb(150, 200, 255))
.bg(MENU_STYLE.bg);
let label_style = Style::default().fg(Color::White).bg(MENU_STYLE.bg); let label_style = Style::default().fg(Color::White).bg(MENU_STYLE.bg);
let lines: Vec<Line> = items.iter().map(|item| { let lines: Vec<Line> = items
.iter()
.map(|item| {
let key_text = match &item.key { let key_text = match &item.key {
MenuKey::Char(c) => format!("[{c}] "), MenuKey::Char(c) => format!("[{c}] "),
MenuKey::Esc => "[esc] ".to_string(), MenuKey::Esc => "[esc] ".to_string(),
@@ -218,7 +261,8 @@ fn render_menu_at(
Span::styled(key_text, key_style), Span::styled(key_text, key_style),
Span::styled(item.label.clone(), label_style), Span::styled(item.label.clone(), label_style),
]) ])
}).collect(); })
.collect();
let content_lines = lines.len() as u16; let content_lines = lines.len() as u16;
let max_scroll = content_lines.saturating_sub(content_area.height); let max_scroll = content_lines.saturating_sub(content_area.height);
@@ -233,18 +277,24 @@ fn render_menu_at(
Constraint::Length(pad), Constraint::Length(pad),
Constraint::Length(content_lines.min(content_area.height)), Constraint::Length(content_lines.min(content_area.height)),
Constraint::Fill(1), Constraint::Fill(1),
]).areas(content_area); ])
.areas(content_area);
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
para.render(content_rect, buf); para.render(content_rect, buf);
if content_lines > content_area.height { if content_lines > content_area.height {
let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) let mut sb_state =
.position(clamped as usize); ScrollbarState::new((max_scroll + 1) as usize).position(clamped as usize);
Scrollbar::new(ScrollbarOrientation::VerticalRight) Scrollbar::new(ScrollbarOrientation::VerticalRight).render(
.render(scrollbar_area, buf, &mut sb_state); scrollbar_area,
buf,
&mut sb_state,
);
} }
if let Some(m) = out_max_scroll { *m = max_scroll; } if let Some(m) = out_max_scroll {
*m = max_scroll;
}
} }
/// Returns the items for the standard (play-mode) pause menu. /// Returns the items for the standard (play-mode) pause menu.
+1 -1
View File
@@ -6,8 +6,8 @@
//! while editing: entering the editor drops the running game, and returning to //! while editing: entering the editor drops the running game, and returning to
//! play rebuilds it fresh from the world file. //! play rebuilds it fresh from the world file.
use kiln_core::game::GameState;
use crate::editor::EditorState; use crate::editor::EditorState;
use kiln_core::game::GameState;
/// The top-level application state: exactly one variant is live at any moment. /// The top-level application state: exactly one variant is live at any moment.
/// ///
+6 -5
View File
@@ -5,12 +5,12 @@
//! and background colors. Objects are drawn over their floor cell, and the //! and background colors. Objects are drawn over their floor cell, and the
//! player is drawn on top of everything. //! player is drawn on top of everything.
use kiln_core::cp437::tile_to_char; use crate::utils::rgba8_to_color;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use crate::utils::rgba8_to_color;
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a /// A ratatui widget that draws a [`Board`] (with objects and the player) into a
/// rectangular area, scrolled so the player stays visible on larger boards. /// rectangular area, scrolled so the player stays visible on larger boards.
@@ -60,7 +60,8 @@ impl<'a> BoardWidget<'a> {
/// reports off-screen cells as `None` — needed for exact cursor placement.) /// reports off-screen cells as `None` — needed for exact cursor placement.)
pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { pub fn board_to_screen(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_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); let (off_y, pad_y, rows) =
BoardWidget::axis(board.height, area.height as usize, board.player.y);
// Cell must fall within the visible window on both axes. // Cell must fall within the visible window on both axes.
if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows { if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows {
return None; return None;
@@ -75,7 +76,8 @@ pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Optio
/// the drawn board (in the centering pad or past the visible cells). /// 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)> { 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_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); let (off_y, pad_y, rows) =
BoardWidget::axis(board.height, area.height as usize, board.player.y);
// Screen-space offset from the first drawn cell; reject anything before it. // Screen-space offset from the first drawn cell; reject anything before it.
let col = (sx as i32) - (area.x as i32 + pad_x as i32); let col = (sx as i32) - (area.x as i32 + pad_x as i32);
let row = (sy as i32) - (area.y as i32 + pad_y as i32); let row = (sy as i32) - (area.y as i32 + pad_y as i32);
@@ -140,4 +142,3 @@ impl Widget for BoardWidget<'_> {
} }
} }
} }
+41 -17
View File
@@ -5,6 +5,9 @@
//! and handle the cosmetic open/close transitions; while either plays, all input //! and handle the cosmetic open/close transitions; while either plays, all input
//! is suppressed. //! is suppressed.
use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode;
use crate::overlay::{OverlayStyle, render_overlay_frame};
use kiln_core::game::ScrollLine; use kiln_core::game::ScrollLine;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
@@ -13,9 +16,6 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{ use ratatui::widgets::{
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap,
}; };
use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode;
use crate::overlay::{OverlayStyle, render_overlay_frame};
/// Duration of the open and close animations, in seconds. /// Duration of the open and close animations, in seconds.
const ANIM_SECS: f32 = 0.25; const ANIM_SECS: f32 = 0.25;
@@ -77,7 +77,10 @@ pub struct ScrollOpenAnimation {
impl ScrollOpenAnimation { impl ScrollOpenAnimation {
/// Creates a new open animation from a snapshot of the scroll's lines. /// Creates a new open animation from a snapshot of the scroll's lines.
pub fn new(lines: Vec<ScrollLine>) -> Self { pub fn new(lines: Vec<ScrollLine>) -> Self {
Self { lines, progress: 0.0 } Self {
lines,
progress: 0.0,
}
} }
} }
@@ -91,9 +94,13 @@ impl Animation for ScrollOpenAnimation {
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None); render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
} }
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } fn layer(&self) -> AnimationLayer {
AnimationLayer::Overlay
}
fn input_mode_after(&self) -> InputMode { InputMode::Scroll } fn input_mode_after(&self) -> InputMode {
InputMode::Scroll
}
} }
/// Closes a scroll overlay with an animated collapse. /// Closes a scroll overlay with an animated collapse.
@@ -112,7 +119,10 @@ pub struct ScrollCloseAnimation {
impl ScrollCloseAnimation { impl ScrollCloseAnimation {
/// Creates a new close animation from a snapshot of the scroll's lines. /// Creates a new close animation from a snapshot of the scroll's lines.
pub fn new(lines: Vec<ScrollLine>) -> Self { pub fn new(lines: Vec<ScrollLine>) -> Self {
Self { lines, progress: 1.0 } Self {
lines,
progress: 1.0,
}
} }
} }
@@ -126,9 +136,13 @@ impl Animation for ScrollCloseAnimation {
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None); render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
} }
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } fn layer(&self) -> AnimationLayer {
AnimationLayer::Overlay
}
fn input_mode_after(&self) -> InputMode { InputMode::Board } fn input_mode_after(&self) -> InputMode {
InputMode::Board
}
} }
/// Renders the scroll overlay at a given animation progress using the shared /// Renders the scroll overlay at a given animation progress using the shared
@@ -146,13 +160,17 @@ fn render_scroll_at(
let Some((content_area, scrollbar_area)) = let Some((content_area, scrollbar_area)) =
render_overlay_frame(&SCROLL_STYLE, progress, area, buf) render_overlay_frame(&SCROLL_STYLE, progress, area, buf)
else { else {
if let Some(s) = state { s.max_scroll = 0; } if let Some(s) = state {
s.max_scroll = 0;
}
return; return;
}; };
let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg); let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg);
let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg); let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg);
let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(SCROLL_STYLE.bg); let choice_style = Style::default()
.fg(Color::Rgb(200, 200, 100))
.bg(SCROLL_STYLE.bg);
// Build Lines — whitespace-leading lines are centered, others left-aligned. // Build Lines — whitespace-leading lines are centered, others left-aligned.
let mut ratatui_lines: Vec<Line> = Vec::new(); let mut ratatui_lines: Vec<Line> = Vec::new();
@@ -192,15 +210,21 @@ fn render_scroll_at(
Constraint::Length(pad), Constraint::Length(pad),
Constraint::Length(content_lines.min(content_area.height)), Constraint::Length(content_lines.min(content_area.height)),
Constraint::Fill(1), Constraint::Fill(1),
]).areas(content_area); ])
.areas(content_area);
para.render(content_rect, buf); para.render(content_rect, buf);
if content_lines > content_area.height { if content_lines > content_area.height {
let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) let mut sb_state =
.position(clamped as usize); ScrollbarState::new((max_scroll + 1) as usize).position(clamped as usize);
Scrollbar::new(ScrollbarOrientation::VerticalRight) Scrollbar::new(ScrollbarOrientation::VerticalRight).render(
.render(scrollbar_area, buf, &mut sb_state); scrollbar_area,
buf,
&mut sb_state,
);
} }
if let Some(s) = state { s.max_scroll = max_scroll; } if let Some(s) = state {
s.max_scroll = max_scroll;
}
} }
+109 -47
View File
@@ -1,11 +1,11 @@
use crate::render::BoardWidget;
use crate::utils::rects_overlap;
use kiln_core::game::SpeechBubble;
use kiln_core::{Board, Direction};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::{Color, Line, Style, Widget}; use ratatui::prelude::{Color, Line, Style, Widget};
use ratatui::widgets::{Block, Fill, Paragraph}; use ratatui::widgets::{Block, Fill, Paragraph};
use kiln_core::game::SpeechBubble;
use kiln_core::{Board, Direction};
use crate::render::BoardWidget;
use crate::utils::rects_overlap;
/// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board. /// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board.
/// ///
@@ -57,7 +57,12 @@ impl<'a> SpeechBubblesWidget<'a> {
placed: &mut Vec<Rect>, placed: &mut Vec<Rect>,
player: Option<(u16, u16)>, player: Option<(u16, u16)>,
) -> (Rect, Direction) { ) -> (Rect, Direction) {
let dirs = [Direction::North, Direction::South, Direction::East, Direction::West]; let dirs = [
Direction::North,
Direction::South,
Direction::East,
Direction::West,
];
// Collect (rect, tail_length, dir) for every direction that can be placed. // Collect (rect, tail_length, dir) for every direction that can be placed.
// stable min_by_key preserves the first element on ties, so N > S > E > W wins. // stable min_by_key preserves the first element on ties, so N > S > E > W wins.
let best = dirs let best = dirs
@@ -70,7 +75,17 @@ impl<'a> SpeechBubblesWidget<'a> {
let (rect, dir) = best.map_or_else( let (rect, dir) = best.map_or_else(
// Forced fallback: place at top-left, caller suppresses tail via `on_screen`. // Forced fallback: place at top-left, caller suppresses tail via `on_screen`.
|| (Rect { x: area.x + 1, y: area.y + 1, width: box_w, height: box_h }, Direction::North), || {
(
Rect {
x: area.x + 1,
y: area.y + 1,
width: box_w,
height: box_h,
},
Direction::North,
)
},
|(r, _, d)| (r, d), |(r, _, d)| (r, d),
); );
@@ -81,9 +96,7 @@ impl<'a> SpeechBubblesWidget<'a> {
impl Widget for SpeechBubblesWidget<'_> { impl Widget for SpeechBubblesWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let bubble_style = Style::default() let bubble_style = Style::default().fg(Color::White).bg(Color::Rgb(20, 20, 40));
.fg(Color::White)
.bg(Color::Rgb(20, 20, 40));
let border_style = Style::default() let border_style = Style::default()
.fg(Color::Rgb(160, 160, 220)) .fg(Color::Rgb(160, 160, 220))
.bg(Color::Rgb(20, 20, 40)); .bg(Color::Rgb(20, 20, 40));
@@ -98,7 +111,8 @@ impl Widget for SpeechBubblesWidget<'_> {
let player = player_vis.then_some((px, py)); let player = player_vis.then_some((px, py));
// Map each bubble to its speaker's (clamped) screen position. // Map each bubble to its speaker's (clamped) screen position.
let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self.bubbles let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self
.bubbles
.iter() .iter()
.filter_map(|b| { .filter_map(|b| {
let obj = self.board.objects.get(&b.object_id)?; let obj = self.board.objects.get(&b.object_id)?;
@@ -120,7 +134,14 @@ impl Widget for SpeechBubblesWidget<'_> {
let box_h = lines.len() as u16 + 2; let box_h = lines.len() as u16 + 2;
let (rect, dir) = let (rect, dir) =
SpeechBubblesWidget::place_bubble(area, sx, sy, box_w, box_h, &mut placed, player); SpeechBubblesWidget::place_bubble(area, sx, sy, box_w, box_h, &mut placed, player);
placements.push(Placement { sx, sy, on_screen, lines, rect, dir }); placements.push(Placement {
sx,
sy,
on_screen,
lines,
rect,
dir,
});
} }
draw_tails(&placements, buf, border_style); draw_tails(&placements, buf, border_style);
@@ -137,32 +158,42 @@ impl Widget for SpeechBubblesWidget<'_> {
/// Skipped for off-screen speakers. /// Skipped for off-screen speakers.
fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
for p in placements { for p in placements {
if !p.on_screen { continue; } if !p.on_screen {
continue;
}
let r = p.rect; let r = p.rect;
match p.dir { match p.dir {
Direction::North => { Direction::North => {
let col = tail_col(p.sx, r); let col = tail_col(p.sx, r);
for y in (r.y + r.height)..p.sy { for y in (r.y + r.height)..p.sy {
if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); } if let Some(c) = buf.cell_mut((col, y)) {
c.set_char('│').set_style(style);
}
} }
} }
Direction::South => { Direction::South => {
let col = tail_col(p.sx, r); let col = tail_col(p.sx, r);
for y in (p.sy + 1)..r.y { for y in (p.sy + 1)..r.y {
if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); } if let Some(c) = buf.cell_mut((col, y)) {
c.set_char('│').set_style(style);
}
} }
} }
Direction::East => { Direction::East => {
let row = join_row(p.sy, r); let row = join_row(p.sy, r);
for x in (p.sx + 1)..r.x { for x in (p.sx + 1)..r.x {
if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); } if let Some(c) = buf.cell_mut((x, row)) {
c.set_char('─').set_style(style);
}
} }
} }
Direction::West => { Direction::West => {
// Box right border is at r.x + r.width - 1; first tail col is r.x + r.width. // Box right border is at r.x + r.width - 1; first tail col is r.x + r.width.
let row = join_row(p.sy, r); let row = join_row(p.sy, r);
for x in (r.x + r.width)..p.sx { for x in (r.x + r.width)..p.sx {
if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); } if let Some(c) = buf.cell_mut((x, row)) {
c.set_char('─').set_style(style);
}
} }
} }
} }
@@ -173,14 +204,23 @@ fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
/// ///
/// Overwrites any stray tail chars that passed through another box's area. /// Overwrites any stray tail chars that passed through another box's area.
/// Order within this pass doesn't matter since placed rects are non-overlapping. /// Order within this pass doesn't matter since placed rects are non-overlapping.
fn draw_boxes(placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Style, border_style: Style) { fn draw_boxes(
placements: &[Placement<'_>],
buf: &mut Buffer,
bubble_style: Style,
border_style: Style,
) {
for p in placements { for p in placements {
let box_rect = p.rect; let box_rect = p.rect;
let block = Block::bordered().border_style(border_style).style(bubble_style); let block = Block::bordered()
.border_style(border_style)
.style(bubble_style);
let text_rect = block.inner(box_rect); let text_rect = block.inner(box_rect);
block.render(box_rect, buf); block.render(box_rect, buf);
Fill::new(" ").style(bubble_style).render(text_rect, buf); Fill::new(" ").style(bubble_style).render(text_rect, buf);
let para_lines: Vec<Line> = p.lines.iter() let para_lines: Vec<Line> = p
.lines
.iter()
.map(|l| Line::styled(format!(" {l}"), bubble_style)) .map(|l| Line::styled(format!(" {l}"), bubble_style))
.collect(); .collect();
Paragraph::new(para_lines).render(text_rect, buf); Paragraph::new(para_lines).render(text_rect, buf);
@@ -193,7 +233,9 @@ fn draw_boxes(placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Styl
/// Skipped for off-screen speakers. /// Skipped for off-screen speakers.
fn draw_join_chars(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { fn draw_join_chars(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
for p in placements { for p in placements {
if !p.on_screen { continue; } if !p.on_screen {
continue;
}
let r = p.rect; let r = p.rect;
match p.dir { match p.dir {
Direction::North => { Direction::North => {
@@ -274,7 +316,12 @@ fn try_place_direction(
break; break;
} }
let r = Rect { x: left as u16, y: top as u16, width: box_w, height: box_h }; let r = Rect {
x: left as u16,
y: top as u16,
width: box_w,
height: box_h,
};
let overlaps_placed = placed.iter().any(|p| rects_overlap(r, *p)); let overlaps_placed = placed.iter().any(|p| rects_overlap(r, *p));
let blocks_player = player.is_some_and(|(px, py)| { let blocks_player = player.is_some_and(|(px, py)| {
@@ -376,16 +423,20 @@ mod tests {
use super::*; use super::*;
fn big_area() -> Rect { fn big_area() -> Rect {
Rect { x: 0, y: 0, width: 100, height: 40 } Rect {
x: 0,
y: 0,
width: 100,
height: 40,
}
} }
// ── Initial positions: each direction leaves exactly 1 tail cell ────────── // ── Initial positions: each direction leaves exactly 1 tail cell ──────────
#[test] #[test]
fn north_places_box_above_with_one_tail_row() { fn north_places_box_above_with_one_tail_row() {
let (r, len) = try_place_direction( let (r, len) =
Direction::North, big_area(), 50, 20, 10, 5, &[], None, try_place_direction(Direction::North, big_area(), 50, 20, 10, 5, &[], None).unwrap();
).unwrap();
assert_eq!(r.y, 14); // 20 - 5 - 1 assert_eq!(r.y, 14); // 20 - 5 - 1
assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row
assert_eq!(len, 1); assert_eq!(len, 1);
@@ -393,27 +444,24 @@ mod tests {
#[test] #[test]
fn south_places_box_below_with_one_tail_row() { fn south_places_box_below_with_one_tail_row() {
let (r, len) = try_place_direction( let (r, len) =
Direction::South, big_area(), 50, 20, 10, 5, &[], None, try_place_direction(Direction::South, big_area(), 50, 20, 10, 5, &[], None).unwrap();
).unwrap();
assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row
assert_eq!(len, 1); assert_eq!(len, 1);
} }
#[test] #[test]
fn east_places_box_right_with_one_tail_col() { fn east_places_box_right_with_one_tail_col() {
let (r, len) = try_place_direction( let (r, len) =
Direction::East, big_area(), 50, 20, 10, 5, &[], None, try_place_direction(Direction::East, big_area(), 50, 20, 10, 5, &[], None).unwrap();
).unwrap();
assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col
assert_eq!(len, 1); assert_eq!(len, 1);
} }
#[test] #[test]
fn west_places_box_left_with_one_tail_col() { fn west_places_box_left_with_one_tail_col() {
let (r, len) = try_place_direction( let (r, len) =
Direction::West, big_area(), 50, 20, 10, 5, &[], None, try_place_direction(Direction::West, big_area(), 50, 20, 10, 5, &[], None).unwrap();
).unwrap();
assert_eq!(r.x, 39); // 50 - 10 - 1 assert_eq!(r.x, 39); // 50 - 10 - 1
assert_eq!(r.x + r.width, 49); // box right at 49; tail at col 49; speaker at 50 assert_eq!(r.x + r.width, 49); // box right at 49; tail at col 49; speaker at 50
assert_eq!(len, 1); assert_eq!(len, 1);
@@ -425,10 +473,23 @@ mod tests {
fn north_shifts_up_when_initial_position_blocked() { fn north_shifts_up_when_initial_position_blocked() {
// Blocking rect touches only the bottom row of the initial box (rows 1418), // Blocking rect touches only the bottom row of the initial box (rows 1418),
// so the box needs to shift up by exactly 1 to clear it. // so the box needs to shift up by exactly 1 to clear it.
let blocking = Rect { x: 0, y: 18, width: 100, height: 1 }; let blocking = Rect {
x: 0,
y: 18,
width: 100,
height: 1,
};
let (r, len) = try_place_direction( let (r, len) = try_place_direction(
Direction::North, big_area(), 50, 20, 10, 5, &[blocking], None, Direction::North,
).unwrap(); big_area(),
50,
20,
10,
5,
&[blocking],
None,
)
.unwrap();
assert_eq!(r.y, 13); // shifted up one row from initial 14 assert_eq!(r.y, 13); // shifted up one row from initial 14
assert_eq!(len, 2); // tail is now 2 rows (rows 1819) assert_eq!(len, 2); // tail is now 2 rows (rows 1819)
} }
@@ -438,9 +499,7 @@ mod tests {
#[test] #[test]
fn north_returns_none_when_no_room_above() { fn north_returns_none_when_no_room_above() {
// Speaker at row 4; North needs box_h(5) + 1(tail gap) = 6 rows, only 4 available. // Speaker at row 4; North needs box_h(5) + 1(tail gap) = 6 rows, only 4 available.
let result = try_place_direction( let result = try_place_direction(Direction::North, big_area(), 50, 4, 10, 5, &[], None);
Direction::North, big_area(), 50, 4, 10, 5, &[], None,
);
assert!(result.is_none()); assert!(result.is_none());
} }
@@ -450,9 +509,8 @@ mod tests {
fn place_bubble_prefers_north_when_all_equal() { fn place_bubble_prefers_north_when_all_equal() {
// All four directions have tail_length = 1; North wins by preference order. // All four directions have tail_length = 1; North wins by preference order.
let mut placed = vec![]; let mut placed = vec![];
let (_, dir) = SpeechBubblesWidget::place_bubble( let (_, dir) =
big_area(), 50, 20, 10, 5, &mut placed, None, SpeechBubblesWidget::place_bubble(big_area(), 50, 20, 10, 5, &mut placed, None);
);
assert_eq!(dir, Direction::North); assert_eq!(dir, Direction::North);
} }
@@ -460,9 +518,8 @@ mod tests {
fn place_bubble_falls_back_to_south_when_north_blocked() { fn place_bubble_falls_back_to_south_when_north_blocked() {
// Speaker at row 4: North can't fit (same condition as the None test above). // Speaker at row 4: North can't fit (same condition as the None test above).
let mut placed = vec![]; let mut placed = vec![];
let (_, dir) = SpeechBubblesWidget::place_bubble( let (_, dir) =
big_area(), 50, 4, 10, 5, &mut placed, None, SpeechBubblesWidget::place_bubble(big_area(), 50, 4, 10, 5, &mut placed, None);
);
assert_eq!(dir, Direction::South); assert_eq!(dir, Direction::South);
} }
@@ -471,7 +528,12 @@ mod tests {
#[test] #[test]
fn join_row_clamps_speaker_to_box_interior() { fn join_row_clamps_speaker_to_box_interior() {
// Box: y=10, height=5 → top border row 10, interior rows 1113, bottom border row 14. // Box: y=10, height=5 → top border row 10, interior rows 1113, bottom border row 14.
let r = Rect { x: 0, y: 10, width: 10, height: 5 }; let r = Rect {
x: 0,
y: 10,
width: 10,
height: 5,
};
assert_eq!(join_row(8, r), 11); // speaker above box → first interior row assert_eq!(join_row(8, r), 11); // speaker above box → first interior row
assert_eq!(join_row(12, r), 12); // speaker inside box → unchanged assert_eq!(join_row(12, r), 12); // speaker inside box → unchanged
assert_eq!(join_row(20, r), 13); // speaker below box → last interior row assert_eq!(join_row(20, r), 13); // speaker below box → last interior row
+16 -13
View File
@@ -6,15 +6,11 @@
//! [`Animation::draw`] blends the two buffers — old cells for unrevealed positions, //! [`Animation::draw`] blends the two buffers — old cells for unrevealed positions,
//! new cells for revealed ones. //! new cells for revealed ones.
use kiln_core::Board;
use ratatui::{
buffer::Buffer,
layout::Rect,
widgets::Widget,
};
use crate::animation::{Animation, AnimationLayer}; use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode; use crate::input::InputMode;
use crate::render::BoardWidget; use crate::render::BoardWidget;
use kiln_core::Board;
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
/// How long the wipe lasts in seconds. /// How long the wipe lasts in seconds.
pub const TRANSITION_DURATION: f32 = 0.25; pub const TRANSITION_DURATION: f32 = 0.25;
@@ -41,10 +37,14 @@ fn lfsr_sequence(total: usize) -> Vec<u32> {
let idx = state as u32 - 1; let idx = state as u32 - 1;
if (idx as usize) < total { if (idx as usize) < total {
seq.push(idx); seq.push(idx);
if seq.len() == total { break; } if seq.len() == total {
break;
}
} }
state = lfsr_step(state); state = lfsr_step(state);
if state == 1 { break; } // full cycle — shouldn't happen for total <= 65535 if state == 1 {
break;
} // full cycle — shouldn't happen for total <= 65535
} }
seq seq
} }
@@ -99,8 +99,7 @@ impl Animation for TransitionAnimation {
fn update(&mut self, dt: f32) -> bool { fn update(&mut self, dt: f32) -> bool {
self.elapsed += dt; self.elapsed += dt;
let total = self.revealed.len(); let total = self.revealed.len();
let target = let target = ((self.elapsed / TRANSITION_DURATION) * total as f32) as usize;
((self.elapsed / TRANSITION_DURATION) * total as f32) as usize;
let target = target.min(total); let target = target.min(total);
// Mark each newly revealed cell in the bitmap. // Mark each newly revealed cell in the bitmap.
@@ -131,7 +130,11 @@ impl Animation for TransitionAnimation {
} }
} }
fn layer(&self) -> AnimationLayer { AnimationLayer::Board } fn layer(&self) -> AnimationLayer {
AnimationLayer::Board
fn input_mode_after(&self) -> InputMode { InputMode::Board } }
fn input_mode_after(&self) -> InputMode {
InputMode::Board
}
} }
+29 -13
View File
@@ -1,15 +1,15 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use kiln_core::Board;
use kiln_core::game::GameState;
use crate::animation::Animation; use crate::animation::Animation;
use crate::editor::EditorState; use crate::editor::EditorState;
use crate::log::LogState; use crate::log::LogState;
use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState}; use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState};
use crate::mode::{Mode, PendingMode}; use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
use crate::transition::TransitionAnimation; use crate::transition::TransitionAnimation;
use kiln_core::Board;
use kiln_core::game::GameState;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
/// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering. /// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering.
type PendingTransition = Option<(Rc<RefCell<Board>>, Rc<RefCell<Board>>)>; type PendingTransition = Option<(Rc<RefCell<Board>>, Rc<RefCell<Board>>)>;
@@ -66,8 +66,8 @@ impl Default for Ui {
impl Ui { impl Ui {
/// Scrolls the scroll overlay by `delta` lines, clamped to the valid range. /// Scrolls the scroll overlay by `delta` lines, clamped to the valid range.
pub(crate) fn scroll_lines(&mut self, delta: i32) { pub(crate) fn scroll_lines(&mut self, delta: i32) {
self.overlay.offset = (self.overlay.offset as i32 + delta) self.overlay.offset =
.clamp(0, self.overlay.max_scroll as i32) as u16; (self.overlay.offset as i32 + delta).clamp(0, self.overlay.max_scroll as i32) as u16;
} }
/// Scrolls the menu overlay by `delta` lines, clamped to the valid range. /// Scrolls the menu overlay by `delta` lines, clamped to the valid range.
@@ -80,14 +80,21 @@ impl Ui {
/// Opens a menu: stores the items as [`MenuState`] and starts the open animation. /// Opens a menu: stores the items as [`MenuState`] and starts the open animation.
pub(crate) fn open_menu(&mut self, items: Vec<MenuItem>) { pub(crate) fn open_menu(&mut self, items: Vec<MenuItem>) {
self.active_animation = Some(Box::new(MenuOpenAnimation::new(items.clone()))); self.active_animation = Some(Box::new(MenuOpenAnimation::new(items.clone())));
self.menu = Some(MenuState { items, ..Default::default() }); self.menu = Some(MenuState {
items,
..Default::default()
});
} }
/// Starts the close animation using a snapshot of the current menu items. /// Starts the close animation using a snapshot of the current menu items.
/// ///
/// Called from within a menu item's action closure (e.g. `ui.begin_close_menu()`). /// Called from within a menu item's action closure (e.g. `ui.begin_close_menu()`).
pub(crate) fn begin_close_menu(&mut self) { pub(crate) fn begin_close_menu(&mut self) {
let items = self.menu.as_ref().map(|m| m.items.clone()).unwrap_or_default(); let items = self
.menu
.as_ref()
.map(|m| m.items.clone())
.unwrap_or_default();
self.active_animation = Some(Box::new(MenuCloseAnimation::new(items))); self.active_animation = Some(Box::new(MenuCloseAnimation::new(items)));
} }
@@ -111,7 +118,9 @@ impl Ui {
// Returning to Board mode: eagerly clear scroll and menu state so // Returning to Board mode: eagerly clear scroll and menu state so
// neither renders as open for one extra frame before the next tick. // neither renders as open for one extra frame before the next tick.
if matches!(after, crate::input::InputMode::Board) { if matches!(after, crate::input::InputMode::Board) {
if let Mode::Play(game) = mode { game.handle_scroll(); } if let Mode::Play(game) = mode {
game.handle_scroll();
}
self.menu = None; self.menu = None;
} }
} }
@@ -147,7 +156,9 @@ impl Ui {
if let Some(scroll) = game.active_scroll.as_mut() { if let Some(scroll) = game.active_scroll.as_mut() {
scroll.choice = choice; scroll.choice = choice;
} }
let lines = game.active_scroll.as_ref() let lines = game
.active_scroll
.as_ref()
.map(|s| s.lines.clone()) .map(|s| s.lines.clone())
.unwrap_or_default(); .unwrap_or_default();
self.active_animation = Some(Box::new(ScrollCloseAnimation::new(lines))); self.active_animation = Some(Box::new(ScrollCloseAnimation::new(lines)));
@@ -157,7 +168,12 @@ impl Ui {
/// ///
/// Called from `draw_board_area` when [`pending_transition`](Ui::pending_transition) /// Called from `draw_board_area` when [`pending_transition`](Ui::pending_transition)
/// is set, so the [`TransitionAnimation`] is sized to the exact inner board area. /// is set, so the [`TransitionAnimation`] is sized to the exact inner board area.
pub(crate) fn start_transition(&mut self, old: &Board, new: &Board, area: ratatui::layout::Rect) { pub(crate) fn start_transition(
&mut self,
old: &Board,
new: &Board,
area: ratatui::layout::Rect,
) {
self.active_animation = Some(Box::new(TransitionAnimation::new(old, new, area))); self.active_animation = Some(Box::new(TransitionAnimation::new(old, new, area)));
} }
} }
+1 -4
View File
@@ -12,8 +12,5 @@ pub fn rgba8_to_color(c: Rgba8) -> Color {
/// Returns true if two `Rect`s share at least one cell. /// Returns true if two `Rect`s share at least one cell.
pub fn rects_overlap(a: Rect, b: Rect) -> bool { pub fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x < b.x + b.width a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
&& b.x < a.x + a.width
&& a.y < b.y + b.height
&& b.y < a.y + a.height
} }
+4 -1
View File
@@ -28,7 +28,10 @@ impl Clipboard {
let system = arboard::Clipboard::new().ok(); let system = arboard::Clipboard::new().ok();
#[cfg(test)] #[cfg(test)]
let system = None; let system = None;
Self { system, internal: String::new() } Self {
system,
internal: String::new(),
}
} }
/// Stores `text` internally and (best-effort) on the system clipboard. /// Stores `text` internally and (best-effort) on the system clipboard.
+93 -26
View File
@@ -24,7 +24,9 @@ use syntect::parsing::syntax_definition::SyntaxDefinition;
use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder}; use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder};
use crate::clipboard::Clipboard; use crate::clipboard::Clipboard;
use crate::text::{TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl}; use crate::text::{
TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl,
};
/// The embedded Rhai Sublime-syntax definition used for highlighting. /// The embedded Rhai Sublime-syntax definition used for highlighting.
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax"); const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
@@ -178,17 +180,38 @@ impl CodeEditor {
KeyCode::Enter => self.insert_newline(), KeyCode::Enter => self.insert_newline(),
KeyCode::Backspace => self.backspace(), KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete_forward(), KeyCode::Delete => self.delete_forward(),
KeyCode::Left => { KeyCode::Left => self.moved(
self.moved(shift, if ctrl { Self::move_word_left } else { Self::move_left }) shift,
} if ctrl {
KeyCode::Right => { Self::move_word_left
self.moved(shift, if ctrl { Self::move_word_right } else { Self::move_right }) } else {
} Self::move_left
KeyCode::Up => { },
self.moved(shift, if ctrl { Self::move_paragraph_up } else { Self::move_up }) ),
} KeyCode::Right => self.moved(
KeyCode::Down => self shift,
.moved(shift, if ctrl { Self::move_paragraph_down } else { Self::move_down }), if ctrl {
Self::move_word_right
} else {
Self::move_right
},
),
KeyCode::Up => self.moved(
shift,
if ctrl {
Self::move_paragraph_up
} else {
Self::move_up
},
),
KeyCode::Down => self.moved(
shift,
if ctrl {
Self::move_paragraph_down
} else {
Self::move_down
},
),
KeyCode::Home => self.moved(shift, Self::move_home), KeyCode::Home => self.moved(shift, Self::move_home),
KeyCode::End => self.moved(shift, Self::move_end), KeyCode::End => self.moved(shift, Self::move_end),
KeyCode::PageUp => self.moved(shift, Self::move_page_up), KeyCode::PageUp => self.moved(shift, Self::move_page_up),
@@ -271,7 +294,9 @@ impl CodeEditor {
/// ctrl+Right: jump to the next word start (wrapping to the next line's start). /// ctrl+Right: jump to the next word start (wrapping to the next line's start).
fn move_word_right(&mut self) { fn move_word_right(&mut self) {
if self.cursor.col == self.line_len(self.cursor.row) && self.cursor.row + 1 < self.lines.len() { if self.cursor.col == self.line_len(self.cursor.row)
&& self.cursor.row + 1 < self.lines.len()
{
self.cursor.row += 1; self.cursor.row += 1;
self.cursor.col = 0; self.cursor.col = 0;
} else { } else {
@@ -282,7 +307,10 @@ impl CodeEditor {
/// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line. /// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line.
fn move_paragraph_up(&mut self) { fn move_paragraph_up(&mut self) {
self.cursor.row = (0..self.cursor.row).rev().find(|&r| self.is_blank_line(r)).unwrap_or(0); self.cursor.row = (0..self.cursor.row)
.rev()
.find(|&r| self.is_blank_line(r))
.unwrap_or(0);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row)); self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
} }
@@ -423,7 +451,9 @@ impl CodeEditor {
/// Deletes the current selection (if any) and places the cursor at its start. /// Deletes the current selection (if any) and places the cursor at its start.
fn replace_selection(&mut self) { fn replace_selection(&mut self) {
let Some((a, b)) = self.selection() else { return }; let Some((a, b)) = self.selection() else {
return;
};
self.begin_edit(EditKind::Other); self.begin_edit(EditKind::Other);
if a.row == b.row { if a.row == b.row {
let s = &mut self.lines[a.row]; let s = &mut self.lines[a.row];
@@ -462,7 +492,10 @@ impl CodeEditor {
self.break_group(); self.break_group();
let last = self.lines.len() - 1; let last = self.lines.len() - 1;
self.anchor = Some(Pos { row: 0, col: 0 }); self.anchor = Some(Pos { row: 0, col: 0 });
self.cursor = Pos { row: last, col: self.line_len(last) }; self.cursor = Pos {
row: last,
col: self.line_len(last),
};
} }
// ── Clipboard ────────────────────────────────────────────────────────────── // ── Clipboard ──────────────────────────────────────────────────────────────
@@ -508,7 +541,10 @@ impl CodeEditor {
/// Pushes the current state to the undo stack and clears redo. /// Pushes the current state to the undo stack and clears redo.
fn push_snapshot(&mut self) { fn push_snapshot(&mut self) {
self.undo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor }); self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
if self.undo.len() > UNDO_DEPTH { if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0); self.undo.remove(0);
} }
@@ -517,14 +553,20 @@ impl CodeEditor {
fn undo(&mut self) { fn undo(&mut self) {
if let Some(prev) = self.undo.pop() { if let Some(prev) = self.undo.pop() {
self.redo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor }); self.redo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(prev); self.restore(prev);
} }
} }
fn redo(&mut self) { fn redo(&mut self) {
if let Some(next) = self.redo.pop() { if let Some(next) = self.redo.pop() {
self.undo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor }); self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(next); self.restore(next);
} }
} }
@@ -577,10 +619,17 @@ impl CodeEditor {
let y = inner.y + i as u16; let y = inner.y + i as u16;
// Right-aligned line number in the gutter (temporary buffer borrow per call). // Right-aligned line number in the gutter (temporary buffer borrow per call).
let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1); let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1);
frame.buffer_mut().set_string(inner.x, y, num, Style::default().fg(GUTTER_FG)); frame
.buffer_mut()
.set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
// Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping. // Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping.
let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel); let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel);
let rect = Rect { x: text_x, y, width: text_w, height: 1 }; let rect = Rect {
x: text_x,
y,
width: text_w,
height: 1,
};
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect); frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
} }
@@ -620,7 +669,9 @@ impl CodeEditor {
for line in self.lines.iter().take(upto) { for line in self.lines.iter().take(upto) {
// Feed a trailing newline so multi-line constructs keep state, then drop it. // Feed a trailing newline so multi-line constructs keep state, then drop it.
let with_nl = format!("{line}\n"); let with_nl = format!("{line}\n");
let regions = h.highlight_line(&with_nl, &hl.syntax_set).unwrap_or_default(); let regions = h
.highlight_line(&with_nl, &hl.syntax_set)
.unwrap_or_default();
let mut colors = Vec::with_capacity(line.chars().count()); let mut colors = Vec::with_capacity(line.chars().count());
for (style, text) in regions { for (style, text) in regions {
let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b); let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
@@ -634,11 +685,19 @@ impl CodeEditor {
/// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and /// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and
/// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`. /// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`.
fn render_line(&self, row: usize, colors: Option<&[Color]>, sel: Option<(Pos, Pos)>) -> Line<'static> { fn render_line(
&self,
row: usize,
colors: Option<&[Color]>,
sel: Option<(Pos, Pos)>,
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new(); let mut spans: Vec<Span<'static>> = Vec::new();
let mut col_cells = 0; // running display column, for tab expansion let mut col_cells = 0; // running display column, for tab expansion
for (i, c) in self.lines[row].chars().enumerate() { for (i, c) in self.lines[row].chars().enumerate() {
let fg = colors.and_then(|cs| cs.get(i)).copied().unwrap_or(DEFAULT_FG); let fg = colors
.and_then(|cs| cs.get(i))
.copied()
.unwrap_or(DEFAULT_FG);
let mut style = Style::default().fg(fg); let mut style = Style::default().fg(fg);
if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) { if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) {
style = style.bg(SELECT_BG); style = style.bg(SELECT_BG);
@@ -672,7 +731,11 @@ fn build_highlight_parts() -> Option<HighlightParts> {
let syntax_set = builder.build(); let syntax_set = builder.build();
let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone(); let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone();
let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone(); let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone();
Some(HighlightParts { theme, syntax_ref, syntax_set: Arc::new(syntax_set) }) Some(HighlightParts {
theme,
syntax_ref,
syntax_set: Arc::new(syntax_set),
})
} }
#[cfg(test)] #[cfg(test)]
@@ -772,7 +835,11 @@ mod tests {
typ(&mut ed, "abc"); // coalesces to one undo step typ(&mut ed, "abc"); // coalesces to one undo step
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL); send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), ""); assert_eq!(ed.text(), "");
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL | KeyModifiers::SHIFT); send_mod(
&mut ed,
KeyCode::Char('z'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(ed.text(), "abc"); assert_eq!(ed.text(), "abc");
} }
+1 -3
View File
@@ -242,7 +242,6 @@ impl<Ctx> Dialog<Ctx> {
fn filtered_len(&self) -> usize { fn filtered_len(&self) -> usize {
filtered_count(&self.body) filtered_count(&self.body)
} }
} }
impl<Ctx> CursorOverlay for Dialog<Ctx> { impl<Ctx> CursorOverlay for Dialog<Ctx> {
@@ -482,8 +481,7 @@ mod tests {
fn text_dialog_reports_value_on_submit_and_none_on_cancel() { fn text_dialog_reports_value_on_submit_and_none_on_cancel() {
// Submit returns the edited value. // Submit returns the edited value.
let mut got: Option<Option<String>> = None; let mut got: Option<Option<String>> = None;
let mut d: Dialog<Option<Option<String>>> = let mut d: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
Dialog::text("t", "hi", |v, c| *c = Some(v));
d.handle_event(&key(KeyCode::Char('!'))); d.handle_event(&key(KeyCode::Char('!')));
let res = d.handle_event(&key(KeyCode::Enter)); let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut got); d.finish(res, &mut got);
+55 -11
View File
@@ -235,7 +235,11 @@ impl<Ctx> GlyphDialog<Ctx> {
} }
let i = stops.iter().position(|f| *f == self.focus).unwrap_or(0); let i = stops.iter().position(|f| *f == self.focus).unwrap_or(0);
let n = stops.len(); let n = stops.len();
let j = if forward { (i + 1) % n } else { (i + n - 1) % n }; let j = if forward {
(i + 1) % n
} else {
(i + n - 1) % n
};
self.focus = stops[j]; self.focus = stops[j];
DialogResult::Continue DialogResult::Continue
} }
@@ -248,7 +252,11 @@ impl<Ctx> GlyphDialog<Ctx> {
KeyCode::Left => picker.move_select(-1), KeyCode::Left => picker.move_select(-1),
KeyCode::Right => picker.move_select(1), KeyCode::Right => picker.move_select(1),
KeyCode::Down if picker.is_custom() => { KeyCode::Down if picker.is_custom() => {
self.focus = if is_fg { Focus::FgField } else { Focus::BgField }; self.focus = if is_fg {
Focus::FgField
} else {
Focus::BgField
};
} }
_ => {} _ => {}
} }
@@ -258,7 +266,11 @@ impl<Ctx> GlyphDialog<Ctx> {
/// edits the field. /// edits the field.
fn handle_field(&mut self, key: ratatui::crossterm::event::KeyEvent, is_fg: bool) { fn handle_field(&mut self, key: ratatui::crossterm::event::KeyEvent, is_fg: bool) {
if matches!(key.code, KeyCode::Up) { if matches!(key.code, KeyCode::Up) {
self.focus = if is_fg { Focus::FgStrip } else { Focus::BgStrip }; self.focus = if is_fg {
Focus::FgStrip
} else {
Focus::BgStrip
};
return; return;
} }
let picker = if is_fg { &mut self.fg } else { &mut self.bg }; let picker = if is_fg { &mut self.fg } else { &mut self.bg };
@@ -315,7 +327,14 @@ impl<Ctx> GlyphDialog<Ctx> {
/// Draws a color strip into `area`: a focusable label, then a solid swatch per named /// Draws a color strip into `area`: a focusable label, then a solid swatch per named
/// color plus a final custom slot. The selected slot is marked with `●`; the custom /// color plus a final custom slot. The selected slot is marked with `●`; the custom
/// slot is otherwise marked `+`. /// slot is otherwise marked `+`.
fn draw_strip(&self, buf: &mut Buffer, area: Rect, label: &str, picker: &ColorPicker, focused: bool) { fn draw_strip(
&self,
buf: &mut Buffer,
area: Rect,
label: &str,
picker: &ColorPicker,
focused: bool,
) {
draw_label(buf, area.x, area.y, label, focused); draw_label(buf, area.x, area.y, label, focused);
let sx = area.x + 3; let sx = area.x + 3;
let y = area.y; let y = area.y;
@@ -374,7 +393,12 @@ impl<Ctx> GlyphDialog<Ctx> {
Style::default().fg(Color::White).bg(INVALID_BG) Style::default().fg(Color::White).bg(INVALID_BG)
}; };
let box_x = x + 1; let box_x = x + 1;
buf.set_string(box_x, y, format!("{val:<width$}", width = w as usize), style); buf.set_string(
box_x,
y,
format!("{val:<width$}", width = w as usize),
style,
);
focused.then(|| { focused.then(|| {
let cur = box_x + (picker.field.display_cursor() as u16).min(w.saturating_sub(1)); let cur = box_x + (picker.field.display_cursor() as u16).min(w.saturating_sub(1));
Position::new(cur, y) Position::new(cur, y)
@@ -444,8 +468,16 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
block.render(rect, buf); block.render(rect, buf);
// Stack: grid, gap, fg strip + hex, bg strip + hex, filler, footer. // Stack: grid, gap, fg strip + hex, bg strip + hex, filler, footer.
let [grid_area, _gap, fg_strip, fg_field, bg_strip, bg_field, _filler, footer_area] = let [
Layout::vertical([ grid_area,
_gap,
fg_strip,
fg_field,
bg_strip,
bg_field,
_filler,
footer_area,
] = Layout::vertical([
Constraint::Length(GRID_ROWS as u16), Constraint::Length(GRID_ROWS as u16),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
@@ -462,9 +494,11 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
self.draw_grid(buf, grid_area, colors); self.draw_grid(buf, grid_area, colors);
self.draw_strip(buf, fg_strip, "fg ", &self.fg, self.focus == Focus::FgStrip); self.draw_strip(buf, fg_strip, "fg ", &self.fg, self.focus == Focus::FgStrip);
let fg_cursor = self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField); let fg_cursor =
self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField);
self.draw_strip(buf, bg_strip, "bg ", &self.bg, self.focus == Focus::BgStrip); self.draw_strip(buf, bg_strip, "bg ", &self.bg, self.focus == Focus::BgStrip);
let bg_cursor = self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField); let bg_cursor =
self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField);
self.draw_footer(buf, footer_area); self.draw_footer(buf, footer_area);
@@ -536,8 +570,18 @@ mod tests {
fn sample() -> Glyph { fn sample() -> Glyph {
Glyph { Glyph {
tile: 5, tile: 5,
fg: Rgba8 { r: 0xFF, g: 0x00, b: 0x00, a: 255 }, // not a named color fg: Rgba8 {
bg: Rgba8 { r: 0x00, g: 0x00, b: 0xFF, a: 255 }, // not a named color r: 0xFF,
g: 0x00,
b: 0x00,
a: 255,
}, // not a named color
bg: Rgba8 {
r: 0x00,
g: 0x00,
b: 0xFF,
a: 255,
}, // not a named color
} }
} }
+26 -6
View File
@@ -27,14 +27,24 @@ impl TextField {
pub fn new(initial: impl Into<String>) -> Self { pub fn new(initial: impl Into<String>) -> Self {
let text = initial.into(); let text = initial.into();
let cursor = text.chars().count(); let cursor = text.chars().count();
Self { text, cursor, clipboard: Clipboard::new(), max_length: None } Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: None,
}
} }
pub fn sized(initial: impl Into<String>, max_length: usize) -> Self { pub fn sized(initial: impl Into<String>, max_length: usize) -> Self {
let mut text = initial.into(); let mut text = initial.into();
text.truncate(max_length); text.truncate(max_length);
let cursor = text.chars().count(); let cursor = text.chars().count();
Self { text, cursor, clipboard: Clipboard::new(), max_length: Some(max_length) } Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: Some(max_length),
}
} }
/// The current text. /// The current text.
@@ -91,11 +101,17 @@ impl TextField {
fn insert(&mut self, c: char) { fn insert(&mut self, c: char) {
let b = byte_of(&self.text, self.cursor); let b = byte_of(&self.text, self.cursor);
if self.max_length.is_none_or(|max_length| max_length > self.text.len() ) { if self
.max_length
.is_none_or(|max_length| max_length > self.text.len())
{
self.text.insert(b, c); self.text.insert(b, c);
} }
if self.max_length.is_none_or(|max_length| max_length > self.cursor ) { if self
.max_length
.is_none_or(|max_length| max_length > self.cursor)
{
self.cursor += 1; self.cursor += 1;
} }
} }
@@ -121,12 +137,16 @@ impl TextField {
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line). /// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
fn paste(&mut self) { fn paste(&mut self) {
let clean: String = self.clipboard.get().chars().filter(|c| !matches!(c, '\n' | '\r')).collect(); let clean: String = self
.clipboard
.get()
.chars()
.filter(|c| !matches!(c, '\n' | '\r'))
.collect();
let b = byte_of(&self.text, self.cursor); let b = byte_of(&self.text, self.cursor);
self.text.insert_str(b, &clean); self.text.insert_str(b, &clean);
self.cursor += clean.chars().count(); self.cursor += clean.chars().count();
} }
} }
#[cfg(test)] #[cfg(test)]