From 8e90084b53d05edba41c344c6a97a90e8bd0c15e Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 21 Jun 2026 01:32:47 -0500 Subject: [PATCH] spinners and fmt --- CLAUDE.md | 31 ++- kiln-core/src/action.rs | 17 ++ kiln-core/src/archetype.rs | 70 ++++- kiln-core/src/board.rs | 395 +++++++++++++++++++++++++++- kiln-core/src/builtin_scripts.rs | 5 + kiln-core/src/game.rs | 181 +++++++++++++ kiln-core/src/layer.rs | 26 +- kiln-core/src/lib.rs | 2 +- kiln-core/src/map_file.rs | 13 +- kiln-core/src/script.rs | 84 +++++- kiln-core/src/scripts/spinner.rhai | 98 +++++++ kiln-core/src/tests/map_file/mod.rs | 1 + kiln-core/src/world.rs | 5 +- kiln-tui/src/animation.rs | 6 +- kiln-tui/src/editor.rs | 6 + kiln-tui/src/editor_menu.rs | 47 +++- kiln-tui/src/log.rs | 13 +- kiln-tui/src/main.rs | 2 +- kiln-tui/src/menu.rs | 130 ++++++--- kiln-tui/src/mode.rs | 2 +- kiln-tui/src/render.rs | 11 +- kiln-tui/src/scroll_overlay.rs | 62 +++-- kiln-tui/src/speech.rs | 200 +++++++++----- kiln-tui/src/transition.rs | 27 +- kiln-tui/src/ui.rs | 42 ++- kiln-tui/src/utils.rs | 7 +- kiln-ui/src/clipboard.rs | 5 +- kiln-ui/src/code_editor.rs | 119 +++++++-- kiln-ui/src/dialog.rs | 4 +- kiln-ui/src/glyph_dialog.rs | 86 ++++-- kiln-ui/src/text_field.rs | 32 ++- 31 files changed, 1445 insertions(+), 284 deletions(-) create mode 100644 kiln-core/src/scripts/spinner.rhai diff --git a/CLAUDE.md b/CLAUDE.md index d5debc7..31357f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. **`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)`): -- `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_` 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` 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_` 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` — 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)`): @@ -89,9 +89,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `solid_at(x, y) -> Option` — 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`. - `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`). -- `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries. -- `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`). +- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option`, `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.) +- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec` — 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 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. - `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: - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). - `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it). -- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`. -- `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` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String)`, `Delay(f64)` (never reaches the board queue — consumed by `ScriptHost::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost). +- `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: - 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: - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects, a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: 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. -- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `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`). `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`): 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()`). +- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. +- **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`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). - The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`). - **Sender identity:** `source` (which object issued an action) rides the per-call **tag** — `run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From for (i32,i32)` gives the delta. - `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`): - `MapFile { map: MapHeader, layers: Vec }` — 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`. -- `impl TryFrom 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 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). - `pub fn load(path: &str) -> Result` — 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. @@ -190,8 +193,8 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln- **`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` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option>`, `code_editor: Option` (the open script editor; replaces the board view), `glyph_dialog: Option>` (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` (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` (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. -- 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). +- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`) — a sidebar menu level; `title()` (breadcrumb segment) and `entries() -> Vec` (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, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs). - Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`. - `editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel. @@ -324,9 +327,9 @@ content = """ "1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" } ``` -Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`). 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>` 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 - **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` 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` 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>`, `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). diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 705dbc5..f845aa4 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -68,6 +68,13 @@ pub(crate) enum Action { /// the source is solid and the destination already has a solid occupant. /// Zero time cost — multiple teleports may fire in one frame. Teleport { x: i32, y: i32 }, + /// 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 ────────────────────────────────────────────────────────── @@ -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("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 } diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index 6f1c29a..c8e3ecf 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -38,11 +38,27 @@ pub enum Archetype { /// [`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. 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_` + /// 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. /// Renders as a yellow `?` on red to make the error visible in-game. 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 { /// Returns the default [`Behavior`] for this archetype. /// @@ -79,6 +95,11 @@ impl Archetype { opaque: true, pushable: Pushable::No, }, + Archetype::Spinner(_) => Behavior { + solid: true, + opaque: true, + pushable: Pushable::No, + }, Archetype::ErrorBlock => Behavior { solid: true, opaque: true, @@ -99,6 +120,8 @@ impl Archetype { Archetype::Pusher(Direction::South) => "pusher_south", Archetype::Pusher(Direction::East) => "pusher_east", Archetype::Pusher(Direction::West) => "pusher_west", + Archetype::Spinner(SpinDirection::Clockwise) => "spinner_cw", + Archetype::Spinner(SpinDirection::CounterClockwise) => "spinner_ccw", Archetype::ErrorBlock => "error_block", } } @@ -148,6 +171,18 @@ impl Archetype { 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. Archetype::ErrorBlock => Glyph { tile: 63, @@ -178,6 +213,8 @@ impl TryFrom<&str> for Archetype { "pusher_south" => Ok(Archetype::Pusher(Direction::South)), "pusher_east" => Ok(Archetype::Pusher(Direction::East)), "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}")), } } @@ -185,11 +222,42 @@ impl TryFrom<&str> for Archetype { #[cfg(test)] mod tests { - use super::Archetype; + use super::{Archetype, SpinDirection}; #[test] fn directional_crate_default_glyph_tiles() { assert_eq!(Archetype::HCrate.default_glyph().tile, 29); 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 + ); + } } diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index fff24b9..134b2d3 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -5,7 +5,28 @@ use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::Direction; 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). /// @@ -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`, /// leaving `Empty` floor behind each moved cell. /// @@ -336,6 +382,18 @@ impl Board { 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 { + self.objects.remove(&id) + } + /// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at /// `(x, y)`, applying the editor's placement/removal rules. /// @@ -373,6 +431,194 @@ impl Board { *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 { + 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 = 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` /// (the cell's single terrain archetype, if any). By the one-solid-per-cell /// invariant there is at most one such layer. @@ -384,7 +630,7 @@ impl Board { #[cfg(test)] pub(crate) mod tests { use super::Board; - use crate::archetype::Archetype; + use crate::archetype::{Archetype, SpinDirection}; use crate::glyph::Glyph; use crate::layer::Layer; use crate::object_def::ObjectDef; @@ -538,6 +784,38 @@ pub(crate) mod tests { 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] fn push_into_player_pushes_player() { // 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_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 + } } diff --git a/kiln-core/src/builtin_scripts.rs b/kiln-core/src/builtin_scripts.rs index 5702969..cb43977 100644 --- a/kiln-core/src/builtin_scripts.rs +++ b/kiln-core/src/builtin_scripts.rs @@ -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. 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. /// `"BUILTIN_pusher_east"`. pub(crate) fn builtin_tag(arch: Archetype) -> String { @@ -41,6 +45,7 @@ pub(crate) fn archetype_from_builtin_tag(tag: &str) -> Option { pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> { match arch { Archetype::Pusher(_) => Some(PUSHER), + Archetype::Spinner(_) => Some(SPINNER), _ => None, } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index faf28ed..6ef5df3 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -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 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 = 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]); + } +} diff --git a/kiln-core/src/layer.rs b/kiln-core/src/layer.rs index f62aae5..eb7bd4e 100644 --- a/kiln-core/src/layer.rs +++ b/kiln-core/src/layer.rs @@ -144,7 +144,6 @@ pub(crate) struct ObjectTemplate { pub opaque: bool, pub pushable: bool, pub script_name: Option, - pub builtin_script: Option<&'static str>, pub tags: Vec, pub name: Option, } @@ -214,7 +213,6 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol opaque: e.opaque.unwrap_or(true), pushable: e.pushable.unwrap_or(false), script_name: e.script_name.clone(), - builtin_script: None, tags: e.tags.clone().unwrap_or_default(), name: e.name.clone(), }), @@ -234,26 +232,12 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec) -> Resol } }, "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`), so the expansion lives in one place. other => match Archetype::try_from(other) { - // Script-backed archetypes (e.g. pushers) expand into an object carrying - // the embedded script plus a `BUILTIN_` 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), - }, + Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a), Err(msg) => { errors.push(LogLine::error(format!( "palette '{ch}': {msg}; using error block" diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index a6dc9b1..a24413d 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -23,7 +23,7 @@ mod utils; /// World type: a named collection of boards in a single `.toml` file ([`world::World`], [`world::load`]). pub mod world; -pub use archetype::Archetype; +pub use archetype::{Archetype, SpinDirection}; pub use board::Board; pub use utils::Direction; diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 7755d83..74e9150 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -238,7 +238,9 @@ impl TryFrom for Board { opaque: t.opaque, pushable: t.pushable, 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(), name, }, @@ -267,7 +269,7 @@ impl TryFrom for Board { }); } - Ok(Board { + let mut board = Board { name: mf.map.name, width: w, height: h, @@ -282,7 +284,12 @@ impl TryFrom for Board { board_script_name: mf.map.board_script_name, load_errors, 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) } } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 216d4f9..458b58f 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -36,6 +36,13 @@ //! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit) //! - `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.*`) //! //! - `Me.id / name / x / y` — getters @@ -47,7 +54,8 @@ //! //! `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)`, -//! `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 //! @@ -672,6 +680,32 @@ fn register_read_api( 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] let b = board.clone(); 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> = elem.read_lock::().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 ───────────────────────────────────────────────────────────────── diff --git a/kiln-core/src/scripts/spinner.rhai b/kiln-core/src/scripts/spinner.rhai new file mode 100644 index 0000000..358e514 --- /dev/null +++ b/kiln-core/src/scripts/spinner.rhai @@ -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); +} diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs index 7483773..c198ac8 100644 --- a/kiln-core/src/tests/map_file/mod.rs +++ b/kiln-core/src/tests/map_file/mod.rs @@ -5,6 +5,7 @@ mod player_placement; mod portal_placement; mod pushers; mod round_trip; +mod spinners; use crate::board::Board; use crate::map_file::MapFile; diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs index ab97854..6aa4653 100644 --- a/kiln-core/src/world.rs +++ b/kiln-core/src/world.rs @@ -148,7 +148,10 @@ mod tests { ); // 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!( world.boards["start"].borrow().get(0, 1, 0).1, Archetype::Empty diff --git a/kiln-tui/src/animation.rs b/kiln-tui/src/animation.rs index 798d505..a8af69e 100644 --- a/kiln-tui/src/animation.rs +++ b/kiln-tui/src/animation.rs @@ -9,10 +9,10 @@ //! but open animations may advertise an interactive mode so the overlay is //! responsive before the animation completes. +use crate::input::InputMode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; -use crate::input::InputMode; /// Which region of the terminal an animation occupies. pub enum AnimationLayer { @@ -32,7 +32,9 @@ pub trait Animation { fn layer(&self) -> AnimationLayer; /// Which [`InputMode`] is active while this animation is running. /// 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. fn input_mode_after(&self) -> InputMode; } diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index 89d3c42..731c075 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -326,6 +326,12 @@ impl EditorState { let mut world = self.world.deep_clone(); // Start the playtest on the board open in the editor, not world.start. 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); game.log(LogLine::raw("[esc] to return to the editor")); game.run_init(); diff --git a/kiln-tui/src/editor_menu.rs b/kiln-tui/src/editor_menu.rs index 86217f8..efc2050 100644 --- a/kiln-tui/src/editor_menu.rs +++ b/kiln-tui/src/editor_menu.rs @@ -1,7 +1,7 @@ -use kiln_core::{Archetype, Direction}; use crate::editor::EditorState; use crate::menu::{MenuItem, MenuKey}; use crate::mode::PendingMode; +use kiln_core::{Archetype, Direction, SpinDirection}; /// 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('v', "↕ Crate", |ed| ed.set_current(Archetype::VCrate)), MenuEntry::item('h', "↔ Crate", |ed| ed.set_current(Archetype::HCrate)), - ], MenuLevel::Machines => vec![ 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![ - MenuEntry::item('n', "▲ Pusher", |ed| ed.set_current(Archetype::Pusher(Direction::North))), - 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))), + MenuEntry::item('n', "▲ Pusher", |ed| { + ed.set_current(Archetype::Pusher(Direction::North)) + }), + 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)) + }), ], } } @@ -91,7 +104,7 @@ impl MenuLevel { /// access to the editor session. Unmatched keys are ignored. pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) { let action = self.entries().into_iter().find_map(|entry| match entry { - MenuEntry::Item{ key, action, .. } if key == ch => Some(action), + MenuEntry::Item { key, action, .. } if key == ch => Some(action), _ => None, }); if let Some(action) = action { @@ -105,7 +118,7 @@ impl MenuLevel { /// [`Blank`](MenuEntry::Blank)/[`Separator`](MenuEntry::Separator) spacer. pub(crate) enum MenuEntry { /// A selectable, key-activated item. - Item{ + Item { /// The letter the user presses to activate this item. key: char, /// Display label shown after the `[key]` in the sidebar. @@ -119,15 +132,23 @@ pub(crate) enum MenuEntry { /// A horizontal rule spanning the sidebar width. Separator, /// The current value of some field - CurrentValue(Box String>) + CurrentValue(Box String>), } impl MenuEntry { - pub(crate) fn item(key: char, label: impl Into, action: F) -> Self { - MenuEntry::Item { key, label: label.into(), action: Box::new(action) } + pub(crate) fn item( + key: char, + label: impl Into, + action: F, + ) -> Self { + MenuEntry::Item { + key, + label: label.into(), + action: Box::new(action), + } } - pub(crate) fn value String + 'static>(action: F) -> Self { + pub(crate) fn value String + 'static>(action: F) -> Self { MenuEntry::CurrentValue(Box::new(action)) } } @@ -151,4 +172,4 @@ pub(crate) fn editor_menu_items() -> Vec { ui.begin_close_menu(); }), ] -} \ No newline at end of file +} diff --git a/kiln-tui/src/log.rs b/kiln-tui/src/log.rs index 2f02dc8..50aa917 100644 --- a/kiln-tui/src/log.rs +++ b/kiln-tui/src/log.rs @@ -3,6 +3,7 @@ //! [`LogWidget`] renders the scrollable log panel as a ratatui [`StatefulWidget`]; //! [`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 ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -10,7 +11,6 @@ use ratatui::style::{Color, Style}; use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget, Wrap}; -use crate::utils::rgba8_to_color; /// View state for the log panel. pub struct LogState { @@ -24,7 +24,11 @@ pub struct LogState { impl Default for LogState { 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) { let block = Block::bordered() .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 = self.logs.iter().rev().map(logline_to_line).collect(); Paragraph::new(lines) .block(block) diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 7cc82e9..a1f7a94 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -9,6 +9,7 @@ mod animation; mod editor; +mod editor_menu; mod input; mod log; mod menu; @@ -21,7 +22,6 @@ mod term; mod transition; mod ui; mod utils; -mod editor_menu; use crate::animation::{AnimWidget, AnimationLayer}; use crate::editor::{ diff --git a/kiln-tui/src/menu.rs b/kiln-tui/src/menu.rs index f32c991..231dd42 100644 --- a/kiln-tui/src/menu.rs +++ b/kiln-tui/src/menu.rs @@ -6,7 +6,11 @@ //! 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). -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::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; @@ -14,11 +18,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{ Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, }; -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 std::rc::Rc; /// Shared-ownership reference to a menu item action closure. /// @@ -65,12 +65,12 @@ pub struct MenuItem { impl MenuItem { /// Creates a menu item with the given key, label, and action closure. - pub fn new( - key: MenuKey, - label: impl Into, - action: impl Fn(&mut Ui) + 'static, - ) -> Self { - Self { key, label: label.into(), action: Rc::new(action) } + pub fn new(key: MenuKey, label: impl Into, action: impl Fn(&mut Ui) + 'static) -> Self { + Self { + key, + label: label.into(), + action: Rc::new(action), + } } /// 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; 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 { /// Creates a new open animation from a snapshot of the menu items. pub fn new(items: Vec) -> 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) { - 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. - 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. @@ -167,7 +190,10 @@ pub struct MenuCloseAnimation { impl MenuCloseAnimation { /// Creates a new close animation from a snapshot of the menu items. pub fn new(items: Vec) -> 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) { - 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. @@ -202,23 +239,30 @@ fn render_menu_at( let Some((content_area, scrollbar_area)) = render_overlay_frame(&MENU_STYLE, progress, area, buf) else { - if let Some(m) = out_max_scroll { *m = 0; } + if let Some(m) = out_max_scroll { + *m = 0; + } 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 lines: Vec = items.iter().map(|item| { - let key_text = match &item.key { - MenuKey::Char(c) => format!("[{c}] "), - MenuKey::Esc => "[esc] ".to_string(), - }; - Line::from(vec![ - Span::styled(key_text, key_style), - Span::styled(item.label.clone(), label_style), - ]) - }).collect(); + let lines: Vec = items + .iter() + .map(|item| { + let key_text = match &item.key { + MenuKey::Char(c) => format!("[{c}] "), + MenuKey::Esc => "[esc] ".to_string(), + }; + Line::from(vec![ + Span::styled(key_text, key_style), + Span::styled(item.label.clone(), label_style), + ]) + }) + .collect(); let content_lines = lines.len() as u16; let max_scroll = content_lines.saturating_sub(content_area.height); @@ -233,18 +277,24 @@ fn render_menu_at( Constraint::Length(pad), Constraint::Length(content_lines.min(content_area.height)), Constraint::Fill(1), - ]).areas(content_area); + ]) + .areas(content_area); use ratatui::widgets::Widget; para.render(content_rect, buf); if content_lines > content_area.height { - let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) - .position(clamped as usize); - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .render(scrollbar_area, buf, &mut sb_state); + let mut sb_state = + ScrollbarState::new((max_scroll + 1) as usize).position(clamped as usize); + Scrollbar::new(ScrollbarOrientation::VerticalRight).render( + 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. diff --git a/kiln-tui/src/mode.rs b/kiln-tui/src/mode.rs index 09b0a4c..dac6236 100644 --- a/kiln-tui/src/mode.rs +++ b/kiln-tui/src/mode.rs @@ -6,8 +6,8 @@ //! while editing: entering the editor drops the running game, and returning to //! play rebuilds it fresh from the world file. -use kiln_core::game::GameState; use crate::editor::EditorState; +use kiln_core::game::GameState; /// The top-level application state: exactly one variant is live at any moment. /// diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index d177234..59b7225 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -5,12 +5,12 @@ //! and background colors. Objects are drawn over their floor cell, and the //! 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::cp437::tile_to_char; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; -use crate::utils::rgba8_to_color; /// 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. @@ -60,7 +60,8 @@ impl<'a> BoardWidget<'a> { /// reports off-screen cells as `None` — needed for exact cursor placement.) pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); - let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + 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. if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows { 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). pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> { let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); - let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + 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. 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); @@ -140,4 +142,3 @@ impl Widget for BoardWidget<'_> { } } } - diff --git a/kiln-tui/src/scroll_overlay.rs b/kiln-tui/src/scroll_overlay.rs index 07edf62..50e54bb 100644 --- a/kiln-tui/src/scroll_overlay.rs +++ b/kiln-tui/src/scroll_overlay.rs @@ -5,6 +5,9 @@ //! and handle the cosmetic open/close transitions; while either plays, all input //! is suppressed. +use crate::animation::{Animation, AnimationLayer}; +use crate::input::InputMode; +use crate::overlay::{OverlayStyle, render_overlay_frame}; use kiln_core::game::ScrollLine; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Layout, Rect}; @@ -13,9 +16,6 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{ 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. const ANIM_SECS: f32 = 0.25; @@ -77,7 +77,10 @@ pub struct ScrollOpenAnimation { impl ScrollOpenAnimation { /// Creates a new open animation from a snapshot of the scroll's lines. pub fn new(lines: Vec) -> 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); } - 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. @@ -112,7 +119,10 @@ pub struct ScrollCloseAnimation { impl ScrollCloseAnimation { /// Creates a new close animation from a snapshot of the scroll's lines. pub fn new(lines: Vec) -> 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); } - 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 @@ -146,13 +160,17 @@ fn render_scroll_at( let Some((content_area, scrollbar_area)) = render_overlay_frame(&SCROLL_STYLE, progress, area, buf) else { - if let Some(s) = state { s.max_scroll = 0; } + if let Some(s) = state { + s.max_scroll = 0; + } return; }; - 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 choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).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 choice_style = Style::default() + .fg(Color::Rgb(200, 200, 100)) + .bg(SCROLL_STYLE.bg); // Build Lines — whitespace-leading lines are centered, others left-aligned. let mut ratatui_lines: Vec = Vec::new(); @@ -192,15 +210,21 @@ fn render_scroll_at( Constraint::Length(pad), Constraint::Length(content_lines.min(content_area.height)), Constraint::Fill(1), - ]).areas(content_area); + ]) + .areas(content_area); para.render(content_rect, buf); if content_lines > content_area.height { - let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) - .position(clamped as usize); - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .render(scrollbar_area, buf, &mut sb_state); + let mut sb_state = + ScrollbarState::new((max_scroll + 1) as usize).position(clamped as usize); + Scrollbar::new(ScrollbarOrientation::VerticalRight).render( + 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; + } } diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index a803481..daf50ad 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -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::layout::Rect; use ratatui::prelude::{Color, Line, Style, Widget}; 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. /// @@ -57,7 +57,12 @@ impl<'a> SpeechBubblesWidget<'a> { placed: &mut Vec, player: Option<(u16, u16)>, ) -> (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. // stable min_by_key preserves the first element on ties, so N > S > E > W wins. let best = dirs @@ -70,7 +75,17 @@ impl<'a> SpeechBubblesWidget<'a> { let (rect, dir) = best.map_or_else( // 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), ); @@ -81,9 +96,7 @@ impl<'a> SpeechBubblesWidget<'a> { impl Widget for SpeechBubblesWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { - let bubble_style = Style::default() - .fg(Color::White) - .bg(Color::Rgb(20, 20, 40)); + let bubble_style = Style::default().fg(Color::White).bg(Color::Rgb(20, 20, 40)); let border_style = Style::default() .fg(Color::Rgb(160, 160, 220)) .bg(Color::Rgb(20, 20, 40)); @@ -98,7 +111,8 @@ impl Widget for SpeechBubblesWidget<'_> { let player = player_vis.then_some((px, py)); // 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() .filter_map(|b| { 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 (rect, dir) = 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); @@ -137,32 +158,42 @@ impl Widget for SpeechBubblesWidget<'_> { /// Skipped for off-screen speakers. fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { for p in placements { - if !p.on_screen { continue; } + if !p.on_screen { + continue; + } let r = p.rect; match p.dir { Direction::North => { let col = tail_col(p.sx, r); 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 => { let col = tail_col(p.sx, r); 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 => { let row = join_row(p.sy, r); 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 => { // 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); 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. /// 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 { 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); block.render(box_rect, buf); Fill::new(" ").style(bubble_style).render(text_rect, buf); - let para_lines: Vec = p.lines.iter() + let para_lines: Vec = p + .lines + .iter() .map(|l| Line::styled(format!(" {l}"), bubble_style)) .collect(); 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. fn draw_join_chars(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { for p in placements { - if !p.on_screen { continue; } + if !p.on_screen { + continue; + } let r = p.rect; match p.dir { Direction::North => { @@ -249,32 +291,37 @@ fn try_place_direction( // Fixed (non-shifting) dimension: center on the speaker along the perpendicular axis. let fixed_left = center_left(obj_sx, box_w, area) as i32; - let fixed_top = center_top(obj_sy, box_h, area) as i32; + let fixed_top = center_top(obj_sy, box_h, area) as i32; // Starting position on the shifting axis and the per-iteration step. let (mut pos, delta): (i32, i32) = match dir { - Direction::North => (obj_sy as i32 - bh - 1, -1), - Direction::South => (obj_sy as i32 + 2, 1), - Direction::East => (obj_sx as i32 + 2, 1), - Direction::West => (obj_sx as i32 - bw - 1, -1), + Direction::North => (obj_sy as i32 - bh - 1, -1), + Direction::South => (obj_sy as i32 + 2, 1), + Direction::East => (obj_sx as i32 + 2, 1), + Direction::West => (obj_sx as i32 - bw - 1, -1), }; for _ in 0..=20 { let (left, top) = match dir { Direction::North | Direction::South => (fixed_left, pos), - Direction::East | Direction::West => (pos, fixed_top), + Direction::East | Direction::West => (pos, fixed_top), }; // Out-of-viewport: shifting further in this direction only makes it worse. if left < area.left() as i32 - || top < area.top() as i32 - || left + bw > area.right() as i32 - || top + bh > area.bottom() as i32 + || top < area.top() as i32 + || left + bw > area.right() as i32 + || top + bh > area.bottom() as i32 { 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 blocks_player = player.is_some_and(|(px, py)| { @@ -298,8 +345,8 @@ fn tail_length(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16) -> u32 { match dir { Direction::North => (obj_sy as i32 - r.y as i32 - r.height as i32).max(0) as u32, Direction::South => (r.y as i32 - obj_sy as i32 - 1).max(0) as u32, - Direction::East => (r.x as i32 - obj_sx as i32 - 1).max(0) as u32, - Direction::West => (obj_sx as i32 - (r.x as i32 + r.width as i32)).max(0) as u32, + Direction::East => (r.x as i32 - obj_sx as i32 - 1).max(0) as u32, + Direction::West => (obj_sx as i32 - (r.x as i32 + r.width as i32)).max(0) as u32, } } @@ -376,46 +423,47 @@ mod tests { use super::*; 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 ────────── #[test] fn north_places_box_above_with_one_tail_row() { - let (r, len) = try_place_direction( - Direction::North, big_area(), 50, 20, 10, 5, &[], None, - ).unwrap(); - assert_eq!(r.y, 14); // 20 - 5 - 1 - assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row + let (r, len) = + try_place_direction(Direction::North, big_area(), 50, 20, 10, 5, &[], None).unwrap(); + assert_eq!(r.y, 14); // 20 - 5 - 1 + assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row assert_eq!(len, 1); } #[test] fn south_places_box_below_with_one_tail_row() { - let (r, len) = try_place_direction( - Direction::South, big_area(), 50, 20, 10, 5, &[], None, - ).unwrap(); - assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row + let (r, len) = + try_place_direction(Direction::South, big_area(), 50, 20, 10, 5, &[], None).unwrap(); + assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row assert_eq!(len, 1); } #[test] fn east_places_box_right_with_one_tail_col() { - let (r, len) = try_place_direction( - Direction::East, big_area(), 50, 20, 10, 5, &[], None, - ).unwrap(); - assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col + let (r, len) = + try_place_direction(Direction::East, big_area(), 50, 20, 10, 5, &[], None).unwrap(); + assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col assert_eq!(len, 1); } #[test] fn west_places_box_left_with_one_tail_col() { - let (r, len) = try_place_direction( - Direction::West, big_area(), 50, 20, 10, 5, &[], None, - ).unwrap(); - 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 + let (r, len) = + try_place_direction(Direction::West, big_area(), 50, 20, 10, 5, &[], None).unwrap(); + 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!(len, 1); } @@ -425,12 +473,25 @@ mod tests { fn north_shifts_up_when_initial_position_blocked() { // Blocking rect touches only the bottom row of the initial box (rows 14–18), // 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( - Direction::North, big_area(), 50, 20, 10, 5, &[blocking], None, - ).unwrap(); - assert_eq!(r.y, 13); // shifted up one row from initial 14 - assert_eq!(len, 2); // tail is now 2 rows (rows 18–19) + Direction::North, + big_area(), + 50, + 20, + 10, + 5, + &[blocking], + None, + ) + .unwrap(); + assert_eq!(r.y, 13); // shifted up one row from initial 14 + assert_eq!(len, 2); // tail is now 2 rows (rows 18–19) } // ── Out of room ─────────────────────────────────────────────────────────── @@ -438,9 +499,7 @@ mod tests { #[test] 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. - let result = try_place_direction( - Direction::North, big_area(), 50, 4, 10, 5, &[], None, - ); + let result = try_place_direction(Direction::North, big_area(), 50, 4, 10, 5, &[], None); assert!(result.is_none()); } @@ -450,9 +509,8 @@ mod tests { fn place_bubble_prefers_north_when_all_equal() { // All four directions have tail_length = 1; North wins by preference order. let mut placed = vec![]; - let (_, dir) = SpeechBubblesWidget::place_bubble( - big_area(), 50, 20, 10, 5, &mut placed, None, - ); + let (_, dir) = + SpeechBubblesWidget::place_bubble(big_area(), 50, 20, 10, 5, &mut placed, None); assert_eq!(dir, Direction::North); } @@ -460,9 +518,8 @@ mod tests { 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). let mut placed = vec![]; - let (_, dir) = SpeechBubblesWidget::place_bubble( - big_area(), 50, 4, 10, 5, &mut placed, None, - ); + let (_, dir) = + SpeechBubblesWidget::place_bubble(big_area(), 50, 4, 10, 5, &mut placed, None); assert_eq!(dir, Direction::South); } @@ -471,10 +528,15 @@ mod tests { #[test] fn join_row_clamps_speaker_to_box_interior() { // Box: y=10, height=5 → top border row 10, interior rows 11–13, bottom border row 14. - 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(12, r), 12); // speaker inside box → unchanged - assert_eq!(join_row(20, r), 13); // speaker below box → last interior row + 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(12, r), 12); // speaker inside box → unchanged + assert_eq!(join_row(20, r), 13); // speaker below box → last interior row } // ── center_top centering ────────────────────────────────────────────────── diff --git a/kiln-tui/src/transition.rs b/kiln-tui/src/transition.rs index 37c4fd4..fdde28e 100644 --- a/kiln-tui/src/transition.rs +++ b/kiln-tui/src/transition.rs @@ -6,15 +6,11 @@ //! [`Animation::draw`] blends the two buffers — old cells for unrevealed positions, //! new cells for revealed ones. -use kiln_core::Board; -use ratatui::{ - buffer::Buffer, - layout::Rect, - widgets::Widget, -}; use crate::animation::{Animation, AnimationLayer}; use crate::input::InputMode; use crate::render::BoardWidget; +use kiln_core::Board; +use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; /// How long the wipe lasts in seconds. pub const TRANSITION_DURATION: f32 = 0.25; @@ -41,10 +37,14 @@ fn lfsr_sequence(total: usize) -> Vec { let idx = state as u32 - 1; if (idx as usize) < total { seq.push(idx); - if seq.len() == total { break; } + if seq.len() == total { + break; + } } 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 } @@ -99,8 +99,7 @@ impl Animation for TransitionAnimation { fn update(&mut self, dt: f32) -> bool { self.elapsed += dt; let total = self.revealed.len(); - let target = - ((self.elapsed / TRANSITION_DURATION) * total as f32) as usize; + let target = ((self.elapsed / TRANSITION_DURATION) * total as f32) as usize; let target = target.min(total); // 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 + } } diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 3b79c96..2a4f7d6 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -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::editor::EditorState; 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::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; 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. type PendingTransition = Option<(Rc>, Rc>)>; @@ -66,8 +66,8 @@ impl Default for Ui { impl Ui { /// Scrolls the scroll overlay by `delta` lines, clamped to the valid range. pub(crate) fn scroll_lines(&mut self, delta: i32) { - self.overlay.offset = (self.overlay.offset as i32 + delta) - .clamp(0, self.overlay.max_scroll as i32) as u16; + self.overlay.offset = + (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. @@ -80,14 +80,21 @@ impl Ui { /// Opens a menu: stores the items as [`MenuState`] and starts the open animation. pub(crate) fn open_menu(&mut self, items: Vec) { 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. /// /// Called from within a menu item's action closure (e.g. `ui.begin_close_menu()`). 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))); } @@ -111,7 +118,9 @@ impl Ui { // Returning to Board mode: eagerly clear scroll and menu state so // neither renders as open for one extra frame before the next tick. 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; } } @@ -147,7 +156,9 @@ impl Ui { if let Some(scroll) = game.active_scroll.as_mut() { scroll.choice = choice; } - let lines = game.active_scroll.as_ref() + let lines = game + .active_scroll + .as_ref() .map(|s| s.lines.clone()) .unwrap_or_default(); 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) /// 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))); } } diff --git a/kiln-tui/src/utils.rs b/kiln-tui/src/utils.rs index fbba75c..56b19a7 100644 --- a/kiln-tui/src/utils.rs +++ b/kiln-tui/src/utils.rs @@ -12,8 +12,5 @@ pub fn rgba8_to_color(c: Rgba8) -> Color { /// Returns true if two `Rect`s share at least one cell. pub fn rects_overlap(a: Rect, b: Rect) -> bool { - a.x < b.x + b.width - && b.x < a.x + a.width - && a.y < b.y + b.height - && b.y < a.y + a.height -} \ No newline at end of file + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height +} diff --git a/kiln-ui/src/clipboard.rs b/kiln-ui/src/clipboard.rs index 3f482f7..61f2623 100644 --- a/kiln-ui/src/clipboard.rs +++ b/kiln-ui/src/clipboard.rs @@ -28,7 +28,10 @@ impl Clipboard { let system = arboard::Clipboard::new().ok(); #[cfg(test)] let system = None; - Self { system, internal: String::new() } + Self { + system, + internal: String::new(), + } } /// Stores `text` internally and (best-effort) on the system clipboard. diff --git a/kiln-ui/src/code_editor.rs b/kiln-ui/src/code_editor.rs index db4bac6..2fbe811 100644 --- a/kiln-ui/src/code_editor.rs +++ b/kiln-ui/src/code_editor.rs @@ -24,7 +24,9 @@ use syntect::parsing::syntax_definition::SyntaxDefinition; use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder}; 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. const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax"); @@ -178,17 +180,38 @@ impl CodeEditor { KeyCode::Enter => self.insert_newline(), KeyCode::Backspace => self.backspace(), KeyCode::Delete => self.delete_forward(), - KeyCode::Left => { - self.moved(shift, if ctrl { Self::move_word_left } else { Self::move_left }) - } - KeyCode::Right => { - self.moved(shift, 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::Left => self.moved( + shift, + if ctrl { + Self::move_word_left + } else { + Self::move_left + }, + ), + KeyCode::Right => self.moved( + shift, + 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::End => self.moved(shift, Self::move_end), 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). 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.col = 0; } else { @@ -282,7 +307,10 @@ impl CodeEditor { /// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line. 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)); } @@ -423,7 +451,9 @@ impl CodeEditor { /// Deletes the current selection (if any) and places the cursor at its start. 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); if a.row == b.row { let s = &mut self.lines[a.row]; @@ -462,7 +492,10 @@ impl CodeEditor { self.break_group(); let last = self.lines.len() - 1; 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 ────────────────────────────────────────────────────────────── @@ -508,7 +541,10 @@ impl CodeEditor { /// Pushes the current state to the undo stack and clears redo. 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 { self.undo.remove(0); } @@ -517,14 +553,20 @@ impl CodeEditor { fn undo(&mut self) { 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); } } fn redo(&mut self) { 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); } } @@ -577,10 +619,17 @@ impl CodeEditor { let y = inner.y + i as u16; // Right-aligned line number in the gutter (temporary buffer borrow per call). 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. 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); } @@ -620,7 +669,9 @@ impl CodeEditor { for line in self.lines.iter().take(upto) { // Feed a trailing newline so multi-line constructs keep state, then drop it. 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()); for (style, text) in regions { 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 /// 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> = Vec::new(); let mut col_cells = 0; // running display column, for tab expansion 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); if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) { style = style.bg(SELECT_BG); @@ -672,7 +731,11 @@ fn build_highlight_parts() -> Option { let syntax_set = builder.build(); let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.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)] @@ -772,7 +835,11 @@ mod tests { typ(&mut ed, "abc"); // coalesces to one undo step send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL); 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"); } diff --git a/kiln-ui/src/dialog.rs b/kiln-ui/src/dialog.rs index 238417b..3168eba 100644 --- a/kiln-ui/src/dialog.rs +++ b/kiln-ui/src/dialog.rs @@ -242,7 +242,6 @@ impl Dialog { fn filtered_len(&self) -> usize { filtered_count(&self.body) } - } impl CursorOverlay for Dialog { @@ -482,8 +481,7 @@ mod tests { fn text_dialog_reports_value_on_submit_and_none_on_cancel() { // Submit returns the edited value. let mut got: Option> = None; - let mut d: Dialog>> = - Dialog::text("t", "hi", |v, c| *c = Some(v)); + let mut d: Dialog>> = Dialog::text("t", "hi", |v, c| *c = Some(v)); d.handle_event(&key(KeyCode::Char('!'))); let res = d.handle_event(&key(KeyCode::Enter)); d.finish(res, &mut got); diff --git a/kiln-ui/src/glyph_dialog.rs b/kiln-ui/src/glyph_dialog.rs index 553c877..6f3b9c7 100644 --- a/kiln-ui/src/glyph_dialog.rs +++ b/kiln-ui/src/glyph_dialog.rs @@ -235,7 +235,11 @@ impl GlyphDialog { } let i = stops.iter().position(|f| *f == self.focus).unwrap_or(0); 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]; DialogResult::Continue } @@ -248,7 +252,11 @@ impl GlyphDialog { KeyCode::Left => picker.move_select(-1), KeyCode::Right => picker.move_select(1), 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 GlyphDialog { /// edits the field. fn handle_field(&mut self, key: ratatui::crossterm::event::KeyEvent, is_fg: bool) { 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; } let picker = if is_fg { &mut self.fg } else { &mut self.bg }; @@ -315,7 +327,14 @@ impl GlyphDialog { /// 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 /// 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); let sx = area.x + 3; let y = area.y; @@ -374,7 +393,12 @@ impl GlyphDialog { Style::default().fg(Color::White).bg(INVALID_BG) }; let box_x = x + 1; - buf.set_string(box_x, y, format!("{val: CursorOverlay for GlyphDialog { block.render(rect, buf); // 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] = - Layout::vertical([ - Constraint::Length(GRID_ROWS as u16), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - ]) - .areas(inner); + let [ + grid_area, + _gap, + fg_strip, + fg_field, + bg_strip, + bg_field, + _filler, + footer_area, + ] = Layout::vertical([ + Constraint::Length(GRID_ROWS as u16), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .areas(inner); // Both colors must resolve for the grid to preview them; otherwise gray-on-black. let colors = self.fg.color().zip(self.bg.color()); self.draw_grid(buf, grid_area, colors); 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); - 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); @@ -536,8 +570,18 @@ mod tests { fn sample() -> Glyph { Glyph { tile: 5, - fg: Rgba8 { 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 + fg: Rgba8 { + 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 } } diff --git a/kiln-ui/src/text_field.rs b/kiln-ui/src/text_field.rs index 755aab9..3e6aa19 100644 --- a/kiln-ui/src/text_field.rs +++ b/kiln-ui/src/text_field.rs @@ -27,14 +27,24 @@ impl TextField { pub fn new(initial: impl Into) -> Self { let text = initial.into(); 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, max_length: usize) -> Self { let mut text = initial.into(); text.truncate(max_length); 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. @@ -91,11 +101,17 @@ impl TextField { fn insert(&mut self, c: char) { 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); } - 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; } } @@ -121,12 +137,16 @@ impl TextField { /// Inserts clipboard text at the cursor, with any newlines stripped (single-line). 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); self.text.insert_str(b, &clean); self.cursor += clean.chars().count(); } - } #[cfg(test)]