spinners and fmt
This commit is contained in:
@@ -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_<archetype>` tag instead of a terrain cell.
|
||||
- `archetype_script(arch) -> Option<&'static str>` — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (`include_str!`); currently `Pusher(_) -> scripts/pusher.rhai` and `Spinner(_) -> scripts/spinner.rhai`. Such an archetype loads as a plain terrain cell (`layer::resolve_entry`); `Board::expand_builtin_archetypes` (called from `TryFrom<MapFile>` at load and again before a playtest) is the **single** place that turns it into an `ObjectDef` carrying that source in `builtin_script` plus a `BUILTIN_<archetype>` tag. The spinner reads its `BUILTIN_spinner_cw`/`_ccw` tag for spin direction (defaulting clockwise) and stores per-instance animation frame state in the board `Registry`.
|
||||
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since `Me` isn't visible inside Rhai helper functions); `map_file::save` uses it to collapse the object back into its archetype keyword so maps round-trip. `scripts/pusher.rhai` self-propels via `move(dir)` (which already shoves chains via `step_object`/`push`), pacing itself with `delay` to the old ~0.5 s cadence.
|
||||
|
||||
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
|
||||
@@ -89,9 +89,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
||||
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on *any* layer via `solid_cell_layer`).
|
||||
- `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<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries. (`remove_object` only edits the `objects` map; a live `ScriptHost` keeps a stale `ObjectRuntime` whose later host-fn calls resolve to a missing id and no-op — benign.)
|
||||
- `apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine>` — applies a batch of one-way solid moves **simultaneously** (reads every source's solid occupant — player/object/terrain — into a private `SolidSnapshot` before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via `remove_object`). The **player is never destroyed** — a write that would overwrite it without relocating it is skipped + logged. Out-of-bounds entries are skipped + logged. Backs the script `swap()` fn.
|
||||
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the existing terrain layer (`terrain_layer_at`, the single non-`Empty` cell) or else the top layer; `Empty` erases — removes every object plus the terrain cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
|
||||
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every script-backed terrain cell (e.g. `Spinner`/`Pusher`, those with an `archetype_script`) with the scripted object it expands to (embedded script + `BUILTIN_*` tag + behavior, copying the cell's glyph so palette overrides survive). Idempotent (cells already loaded as objects are skipped). Called by `TryFrom<MapFile> for Board` after cross-layer validation (so disk loads get working machines) and again by the editor's `playtest()` on the `World::deep_clone` (so editor-stamped machines, which `place_archetype` writes as inert terrain, also run — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still layer-then-reading order among themselves).
|
||||
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
|
||||
- `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<ScrollLine>)`.
|
||||
- `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<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Swap(Vec<(i32,i32,i32,i32)>)` (batch of simultaneous one-way solid moves; zero time cost).
|
||||
- `action_to_map(action) -> rhai::Map` — converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. The map always has a `"type"` key; other keys carry the payload. `Scroll`/`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<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a `(key, source)` compiled once per key: a named script keys by its pool name; an object's embedded `builtin_script` keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing.
|
||||
- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal.
|
||||
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
|
||||
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`; `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
|
||||
- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc<RefCell<Board>>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `can_push(x, y, dir) -> bool` is a read fn mirroring `Board::can_push`: true if `(x, y)` holds a pushable whose chain can be shoved in `dir` (false off-board). `can_shift(x, y, dir) -> bool` mirrors `Board::can_shift`: like `can_push` but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via `swap`. `passable(x, y) -> bool` mirrors `Board::is_passable`: true if `(x, y)` is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which `can_shift`/`can_push` alone cannot). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`.
|
||||
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)`, `push(x, y, dir)`, `swap(pairs)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`/`Push`/`Swap`; `MOVE_COST = 0.25` s for `Move`, else 0 — `push`/`swap` add no delay) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap<usize, ObjQueue>`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `push(x, y, dir)` shoves the pushable chain at arbitrary coords `(x, y)`. `swap(pairs)` takes an array of four-int `[src_x, src_y, dst_x, dst_y]` arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see `Board::apply_swap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
|
||||
- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()`, `Queue.clear()`, `Queue.peek()` (front action as a Rhai map, or `()` if empty), and `Queue.pop()` (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during `pump`).
|
||||
- **Sender identity:** `source` (which object issued an action) rides the per-call **tag** — `run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From<Direction> for (i32,i32)` gives the delta.
|
||||
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
|
||||
@@ -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<LayerData> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`).
|
||||
- `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`.
|
||||
- `impl TryFrom<MapFile> for Board` — the load conversion: builds each layer via `layer::build_layer` (collecting object/portal/player placements with their layer `z`), then runs the cross-layer validations. **Best-effort/nonfatal**: only a layer grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order.
|
||||
- `impl TryFrom<MapFile> for Board` — the load conversion: builds each layer via `layer::build_layer` (collecting object/portal/player placements with their layer `z`), then runs the cross-layer validations. **Best-effort/nonfatal**: only a layer grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells (pushers/spinners) into their scripted objects (these get ids after the hand-placed objects).
|
||||
- `impl From<&Board> for MapFile` — save: emits one `[[layers]]` per board layer (deduping each unique `(Glyph, Archetype)` to a palette char), with objects/portals grouped by `z` and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed `floor` glyphs (the generator name is not recovered).
|
||||
- `pub fn load(path: &str) -> Result<Board, …>` — reads a single-board `.toml` file. Production code uses `world::load` instead.
|
||||
- `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file.
|
||||
@@ -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<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
|
||||
- `MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`) — a sidebar menu level; `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main` → `World`/`Floor`/`Terrain`/`Machines`; `Machines` → `Pushers`), **sets the drawing tool** (`Terrain`/`Pushers` → `set_current(arch)`), or **launches a playtest** (`Main` → `[p] Play board` → `playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
|
||||
- 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<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main` → `World`/`Floor`/`Terrain`/`Machines`; `Machines` → `Pushers`), **sets the drawing tool** (`Terrain`/`Pushers` → `set_current(arch)`, plus `Machines` → `[s]` CW / `[c]` CCW spinner), or **launches a playtest** (`Main` → `[p] Play board` → `playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
|
||||
- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **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<RefCell<Board>>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(Me.id, "visited", true)`).
|
||||
|
||||
@@ -354,6 +357,6 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
|
||||
### Other not-yet-implemented threads
|
||||
|
||||
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
|
||||
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates), so references survive reordering. What's still missing: a `remove_object`, and wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime until the host is rebuilt).
|
||||
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates, `remove_object` deletes — used by `apply_swap` to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — runtime until the host is rebuilt).
|
||||
- **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.)
|
||||
- **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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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_<dir>`
|
||||
/// tag (see [`crate::builtin_scripts`]). The script reads the tag for its spin
|
||||
/// direction, rotates the 8 neighbouring cells one step each 0.5 s, and animates
|
||||
/// its own glyph through a spinning line `/ ─ \ │` (slash-swapped for counter-clockwise).
|
||||
Spinner(SpinDirection),
|
||||
/// Sentinel for map files that reference an unknown archetype name.
|
||||
/// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+393
-2
@@ -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<ObjectDef> {
|
||||
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<LogLine> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// 1. Validate: keep only entries whose source and destination are in bounds.
|
||||
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
|
||||
for &(sx, sy, dx, dy) in pairs {
|
||||
if !self.in_bounds((sx, sy)) || !self.in_bounds((dx, dy)) {
|
||||
errors.push(LogLine::error(format!(
|
||||
"swap: out-of-bounds entry ({sx},{sy})->({dx},{dy})"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
valid.push(((sx, sy), (dx, dy)));
|
||||
}
|
||||
|
||||
// 2. Snapshot the solid at each unique source (read phase). Reading every
|
||||
// source before any write is what lets cycles/swaps resolve.
|
||||
let mut snapshots: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
|
||||
for &(src, _) in &valid {
|
||||
snapshots
|
||||
.entry(src)
|
||||
.or_insert_with(|| self.snapshot_solid(src));
|
||||
}
|
||||
|
||||
// 3. Compute the final occupant of every affected cell. A source that is not
|
||||
// anyone's destination is vacated (Empty); each entry writes its source's
|
||||
// snapshot into its destination (a later entry wins a repeated destination).
|
||||
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
|
||||
let mut final_state: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
|
||||
for &(src, _) in &valid {
|
||||
if !dsts.contains(&src) {
|
||||
final_state.insert(src, SolidSnapshot::Empty);
|
||||
}
|
||||
}
|
||||
for &(src, dst) in &valid {
|
||||
final_state.insert(dst, snapshots[&src].clone());
|
||||
}
|
||||
|
||||
// The player's final cell: where a Player snapshot is installed, else its
|
||||
// current cell (it stays put). Used to protect the player from being
|
||||
// overwritten — computed up front so it's stable across the write loop.
|
||||
let player_final = final_state
|
||||
.iter()
|
||||
.find(|(_, s)| matches!(s, SolidSnapshot::Player))
|
||||
.map(|(&c, _)| c)
|
||||
.unwrap_or((self.player.x, self.player.y));
|
||||
|
||||
// 4a. Clear each affected cell's current occupant, despawning any object that
|
||||
// doesn't survive (isn't reused in final_state). The player and surviving
|
||||
// objects keep their entity and are repositioned by the install step.
|
||||
let survivors: HashSet<ObjectId> = final_state
|
||||
.values()
|
||||
.filter_map(|s| match s {
|
||||
SolidSnapshot::Object(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let affected: HashSet<(i32, i32)> = snapshots
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(final_state.keys().copied())
|
||||
.collect();
|
||||
for &(cx, cy) in &affected {
|
||||
let (ux, uy) = (cx as usize, cy as usize);
|
||||
match self.solid_at(ux, uy) {
|
||||
Some(Solid::Object(id)) if !survivors.contains(&id) => {
|
||||
self.remove_object(id);
|
||||
}
|
||||
Some(Solid::Cell(_)) => {
|
||||
if let Some(z) = self.solid_cell_layer(ux, uy) {
|
||||
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. Install each cell's computed occupant.
|
||||
for (&(cx, cy), snap) in &final_state {
|
||||
let (ux, uy) = (cx as usize, cy as usize);
|
||||
// The player wins its cell: never overwrite player_final with anything
|
||||
// other than the player itself.
|
||||
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
|
||||
errors.push(LogLine::error(format!(
|
||||
"swap: cannot overwrite the player at ({cx},{cy})"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
match snap {
|
||||
SolidSnapshot::Empty => {} // already cleared
|
||||
SolidSnapshot::Terrain { z, glyph, arch } => {
|
||||
*self.get_mut(*z, ux, uy) = (*glyph, *arch);
|
||||
}
|
||||
SolidSnapshot::Object(id) => {
|
||||
if let Some(obj) = self.objects.get_mut(id) {
|
||||
obj.x = ux;
|
||||
obj.y = uy;
|
||||
}
|
||||
}
|
||||
SolidSnapshot::Player => {
|
||||
self.player.x = cx;
|
||||
self.player.y = cy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
|
||||
/// [`apply_swap`](Board::apply_swap)). Coordinates must be in bounds.
|
||||
fn snapshot_solid(&self, (x, y): (i32, i32)) -> SolidSnapshot {
|
||||
let (ux, uy) = (x as usize, y as usize);
|
||||
match self.solid_at(ux, uy) {
|
||||
Some(Solid::Player) => SolidSnapshot::Player,
|
||||
Some(Solid::Object(id)) => SolidSnapshot::Object(id),
|
||||
Some(Solid::Cell(arch)) => {
|
||||
let z = self
|
||||
.solid_cell_layer(ux, uy)
|
||||
.expect("a solid terrain cell has a layer");
|
||||
SolidSnapshot::Terrain {
|
||||
z,
|
||||
glyph: self.get(z, ux, uy).0,
|
||||
arch,
|
||||
}
|
||||
}
|
||||
None => SolidSnapshot::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
|
||||
/// (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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Archetype> {
|
||||
pub(crate) fn archetype_script(arch: Archetype) -> Option<&'static str> {
|
||||
match arch {
|
||||
Archetype::Pusher(_) => Some(PUSHER),
|
||||
Archetype::Spinner(_) => Some(SPINNER),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,17 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
}
|
||||
// push() self-checks can_push, so an in-bounds guard is all we add.
|
||||
Action::Push { x, y, dir } => {
|
||||
if board.in_bounds((x, y)) {
|
||||
board.push(x as usize, y as usize, dir);
|
||||
} else {
|
||||
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
|
||||
}
|
||||
}
|
||||
// apply_swap reads all sources then writes all destinations, so a
|
||||
// whole batch of moves resolves simultaneously (cycles included).
|
||||
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,3 +462,173 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
|
||||
}
|
||||
bumped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GameState;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, wall_at};
|
||||
use crate::object_def::ObjectDef;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a `GameState` with one non-solid object running `src` as its script.
|
||||
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
|
||||
let mut obj = ObjectDef::new(0, 0);
|
||||
obj.solid = false;
|
||||
obj.script_name = Some("s".to_string());
|
||||
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_push_shoves_a_crate() {
|
||||
// The object pushes the crate at (2,0) one step east on its first tick.
|
||||
let mut game = game_with_object_script(
|
||||
5,
|
||||
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
|
||||
);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_swap_moves_crates_simultaneously() {
|
||||
// Swap the crate at (2,0) with the empty cell at (3,0): one ends up empty,
|
||||
// the other holds the crate, applied in a single batch.
|
||||
let mut game = game_with_object_script(
|
||||
5,
|
||||
"fn tick(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }",
|
||||
);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_passable_distinguishes_empty_solid_and_offboard() {
|
||||
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
|
||||
// object sits at (0,0); the player at (3,0). passable should report only the
|
||||
// genuinely empty in-bounds cell.
|
||||
let mut obj = ObjectDef::new(0, 0);
|
||||
obj.solid = false;
|
||||
obj.script_name = Some("s".to_string());
|
||||
let mut board = open_board(4, 1, (3, 0), vec![obj]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let src = "fn init() { \
|
||||
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
|
||||
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
|
||||
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
|
||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
|
||||
let lines: Vec<String> = game
|
||||
.log
|
||||
.iter()
|
||||
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
|
||||
}
|
||||
|
||||
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
|
||||
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
|
||||
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
|
||||
spinner_board_dir(crates, walls, false)
|
||||
}
|
||||
|
||||
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
|
||||
/// script spins counter-clockwise (clockwise is the default when no tag present).
|
||||
fn spinner_board_dir(
|
||||
crates: &[(usize, usize)],
|
||||
walls: &[(usize, usize)],
|
||||
ccw: bool,
|
||||
) -> GameState {
|
||||
let mut obj = ObjectDef::new(1, 1);
|
||||
obj.solid = false;
|
||||
obj.script_name = Some("spinner".to_string());
|
||||
if ccw {
|
||||
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
|
||||
}
|
||||
let mut board = open_board(3, 3, (1, 1), vec![obj]);
|
||||
for &(x, y) in crates {
|
||||
crate_at(&mut board, x, y);
|
||||
}
|
||||
for &(x, y) in walls {
|
||||
wall_at(&mut board, x, y);
|
||||
}
|
||||
let scripts = HashMap::from([(
|
||||
"spinner".to_string(),
|
||||
include_str!("scripts/spinner.rhai").to_string(),
|
||||
)]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_does_not_destroy_a_blocked_neighbour() {
|
||||
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
|
||||
// wall, so N must not rotate onto NE — the old (can_shift-only) script
|
||||
// overwrote and destroyed NE's crate. The cascade leaves everything put.
|
||||
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
|
||||
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_rotates_an_arc_into_a_hole() {
|
||||
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
|
||||
// Clockwise, every crate advances one slot and the hole ends up at W.
|
||||
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
|
||||
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_ccw_rotates_the_other_way() {
|
||||
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
|
||||
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
|
||||
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_animates_its_glyph_without_recoloring() {
|
||||
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
|
||||
// per 0.5s rotation, while the colours stay put.
|
||||
let mut game = spinner_board(&[], &[]);
|
||||
let id = *game.board().objects.keys().next().unwrap();
|
||||
let (fg0, bg0) = {
|
||||
let b = game.board();
|
||||
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
|
||||
};
|
||||
let mut seq = Vec::new();
|
||||
for _ in 0..5 {
|
||||
game.tick(Duration::from_secs_f64(0.5));
|
||||
let b = game.board();
|
||||
seq.push(b.objects[&id].glyph.tile);
|
||||
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
|
||||
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
|
||||
}
|
||||
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-21
@@ -144,7 +144,6 @@ pub(crate) struct ObjectTemplate {
|
||||
pub opaque: bool,
|
||||
pub pushable: bool,
|
||||
pub script_name: Option<String>,
|
||||
pub builtin_script: Option<&'static str>,
|
||||
pub tags: Vec<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
@@ -214,7 +213,6 @@ fn resolve_entry(ch: char, e: &PaletteEntry, errors: &mut Vec<LogLine>) -> Resol
|
||||
opaque: e.opaque.unwrap_or(true),
|
||||
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<LogLine>) -> 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<MapFile>`), 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_<archetype>` tag the script reads.
|
||||
Ok(a) => match crate::builtin_scripts::archetype_script(a) {
|
||||
Some(src) => {
|
||||
let b = a.behavior();
|
||||
Resolved::Object(ObjectTemplate {
|
||||
glyph: glyph_with_default(a.default_glyph()),
|
||||
solid: b.solid,
|
||||
opaque: b.opaque,
|
||||
pushable: false,
|
||||
script_name: None,
|
||||
builtin_script: Some(src),
|
||||
tags: vec![crate::builtin_scripts::builtin_tag(a)],
|
||||
name: None,
|
||||
})
|
||||
}
|
||||
None => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
|
||||
},
|
||||
Ok(a) => Resolved::Cell(glyph_with_default(a.default_glyph()), a),
|
||||
Err(msg) => {
|
||||
errors.push(LogLine::error(format!(
|
||||
"palette '{ch}': {msg}; using error block"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -238,7 +238,9 @@ impl TryFrom<MapFile> 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<MapFile> for Board {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Board {
|
||||
let mut board = Board {
|
||||
name: mf.map.name,
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -282,7 +284,12 @@ impl TryFrom<MapFile> 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+83
-1
@@ -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<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| {
|
||||
if inner.len() == 4 {
|
||||
inner.iter().map(|v| v.as_int().ok()).collect()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
match coords {
|
||||
Some(c) => pairs.push((c[0] as i32, c[1] as i32, c[2] as i32, c[3] as i32)),
|
||||
None => emit(
|
||||
&q,
|
||||
src,
|
||||
Action::Log(LogLine::error(
|
||||
"swap: each entry must be [src_x, src_y, dst_x, dst_y]".to_string(),
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
emit(&q, src, Action::Swap(pairs));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Queue API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+33
-12
@@ -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<dyn FnOnce(& EditorState) -> String>)
|
||||
CurrentValue(Box<dyn FnOnce(&EditorState) -> String>),
|
||||
}
|
||||
|
||||
impl MenuEntry {
|
||||
pub(crate) fn item<F: FnOnce(&mut EditorState) + 'static>(key: char, label: impl Into<String>, action: F) -> Self {
|
||||
MenuEntry::Item { key, label: label.into(), action: Box::new(action) }
|
||||
pub(crate) fn item<F: FnOnce(&mut EditorState) + 'static>(
|
||||
key: char,
|
||||
label: impl Into<String>,
|
||||
action: F,
|
||||
) -> Self {
|
||||
MenuEntry::Item {
|
||||
key,
|
||||
label: label.into(),
|
||||
action: Box::new(action),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value<F: FnOnce(& EditorState) -> String + 'static>(action: F) -> Self {
|
||||
pub(crate) fn value<F: FnOnce(&EditorState) -> String + 'static>(action: F) -> Self {
|
||||
MenuEntry::CurrentValue(Box::new(action))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -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<Line> = self.logs.iter().rev().map(logline_to_line).collect();
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
|
||||
@@ -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::{
|
||||
|
||||
+82
-32
@@ -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<String>,
|
||||
action: impl Fn(&mut Ui) + 'static,
|
||||
) -> Self {
|
||||
Self { key, label: label.into(), action: Rc::new(action) }
|
||||
pub fn new(key: MenuKey, label: impl Into<String>, 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<MenuItem>) -> Self {
|
||||
Self { items, progress: 0.0 }
|
||||
Self {
|
||||
items,
|
||||
progress: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,15 +154,28 @@ impl Animation for MenuOpenAnimation {
|
||||
}
|
||||
|
||||
fn draw(&self, area: Rect, buf: &mut Buffer) {
|
||||
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<MenuItem>) -> Self {
|
||||
Self { items, progress: 1.0 }
|
||||
Self {
|
||||
items,
|
||||
progress: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,12 +204,23 @@ impl Animation for MenuCloseAnimation {
|
||||
}
|
||||
|
||||
fn draw(&self, area: Rect, buf: &mut Buffer) {
|
||||
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,14 +239,20 @@ 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<Line> = items.iter().map(|item| {
|
||||
let lines: Vec<Line> = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let key_text = match &item.key {
|
||||
MenuKey::Char(c) => format!("[{c}] "),
|
||||
MenuKey::Esc => "[esc] ".to_string(),
|
||||
@@ -218,7 +261,8 @@ fn render_menu_at(
|
||||
Span::styled(key_text, key_style),
|
||||
Span::styled(item.label.clone(), label_style),
|
||||
])
|
||||
}).collect();
|
||||
})
|
||||
.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.
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ScrollLine>) -> Self {
|
||||
Self { lines, progress: 0.0 }
|
||||
Self {
|
||||
lines,
|
||||
progress: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +94,13 @@ impl Animation for ScrollOpenAnimation {
|
||||
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
|
||||
}
|
||||
|
||||
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<ScrollLine>) -> Self {
|
||||
Self { lines, progress: 1.0 }
|
||||
Self {
|
||||
lines,
|
||||
progress: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +136,13 @@ impl Animation for ScrollCloseAnimation {
|
||||
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
|
||||
}
|
||||
|
||||
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 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<Line> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
+109
-47
@@ -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<Rect>,
|
||||
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<Line> = p.lines.iter()
|
||||
let para_lines: Vec<Line> = 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 => {
|
||||
@@ -274,7 +316,12 @@ fn try_place_direction(
|
||||
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)| {
|
||||
@@ -376,16 +423,20 @@ 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();
|
||||
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);
|
||||
@@ -393,27 +444,24 @@ mod tests {
|
||||
|
||||
#[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();
|
||||
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();
|
||||
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();
|
||||
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,10 +473,23 @@ 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();
|
||||
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)
|
||||
}
|
||||
@@ -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,7 +528,12 @@ 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 };
|
||||
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
|
||||
|
||||
+15
-12
@@ -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<u32> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+29
-13
@@ -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<RefCell<Board>>, Rc<RefCell<Board>>)>;
|
||||
@@ -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<MenuItem>) {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+93
-26
@@ -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<Span<'static>> = 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<HighlightParts> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,6 @@ impl<Ctx> Dialog<Ctx> {
|
||||
fn filtered_len(&self) -> usize {
|
||||
filtered_count(&self.body)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<Ctx> CursorOverlay for Dialog<Ctx> {
|
||||
@@ -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<Option<String>> = None;
|
||||
let mut d: Dialog<Option<Option<String>>> =
|
||||
Dialog::text("t", "hi", |v, c| *c = Some(v));
|
||||
let mut d: Dialog<Option<Option<String>>> = 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);
|
||||
|
||||
+55
-11
@@ -235,7 +235,11 @@ impl<Ctx> GlyphDialog<Ctx> {
|
||||
}
|
||||
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<Ctx> GlyphDialog<Ctx> {
|
||||
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<Ctx> GlyphDialog<Ctx> {
|
||||
/// 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<Ctx> GlyphDialog<Ctx> {
|
||||
/// 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<Ctx> GlyphDialog<Ctx> {
|
||||
Style::default().fg(Color::White).bg(INVALID_BG)
|
||||
};
|
||||
let box_x = x + 1;
|
||||
buf.set_string(box_x, y, format!("{val:<width$}", width = w as usize), style);
|
||||
buf.set_string(
|
||||
box_x,
|
||||
y,
|
||||
format!("{val:<width$}", width = w as usize),
|
||||
style,
|
||||
);
|
||||
focused.then(|| {
|
||||
let cur = box_x + (picker.field.display_cursor() as u16).min(w.saturating_sub(1));
|
||||
Position::new(cur, y)
|
||||
@@ -444,8 +468,16 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
|
||||
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([
|
||||
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),
|
||||
@@ -462,9 +494,11 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,24 @@ impl TextField {
|
||||
pub fn new(initial: impl Into<String>) -> 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<String>, 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)]
|
||||
|
||||
Reference in New Issue
Block a user