From f73c02437dfe8a945ad335a8cadaec2835d7b77e Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Wed, 10 Jun 2026 01:42:33 -0500 Subject: [PATCH] Added scroll() --- CLAUDE.md | 16 ++-- kiln-core/src/action.rs | 21 +++++ kiln-core/src/game.rs | 42 +++++++++ kiln-core/src/script.rs | 25 +++++- kiln-tui/src/main.rs | 187 +++++++++++++++++++++++++++++++++------- kiln-tui/src/render.rs | 97 ++++++++++++++++++++- maps/start.toml | 30 ++++++- 7 files changed, 375 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e019873..c4fc16a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,8 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`kiln-core/src/game.rs`** — game-loop logic only: - `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one). - `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds). -- `GameState` — holds `board: Rc>`, `log: Vec`, `scripts: ScriptHost`, and `pub speech_bubbles: Vec`. Front-ends reach the board through `board() -> Ref` / `board_mut() -> RefMut`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `apply_commands()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. +- `Scroll { source: ObjectId, lines: Vec }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`. +- `GameState` — holds `board: Rc>`, `log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, and `pub active_scroll: Option`. Front-ends reach the board through `board() -> Ref` / `board_mut() -> RefMut`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. **`kiln-core/src/floor.rs`** — the visual floor layer: - The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it. @@ -99,7 +100,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>)` (registers the API, compiles scripts, reports compile/unknown-script failures onto the error sink; runs nothing). - Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal. - **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. -- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`; `Action::time_cost()` is `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`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). +- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`; `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). - The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()` and `Queue.clear()`. Safe to mutate from script because the host only touches output queues between calls (during `pump`). - **Sender identity:** `source` (which object issued an action) rides the per-call **tag** — `run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From for (i32,i32)` gives the delta. - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. @@ -121,9 +122,10 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint **`kiln-tui/src/main.rs`** — entry point and play loop: - Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load` *before* touching the terminal so load errors print to a normal screen. - `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`. -- `run` is a ~30 FPS real-time loop: it `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick`. It acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via `GameState::try_move`; `l` toggles the log panel; `q`/`Esc` quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). `game.run_init()` runs object `init()` hooks after the board is loaded and before the loop. -- `draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`). -- `Ui { log_open, log_height, scroll }` — view-only panel state kept in the front-end (not on `GameState`). +- `run` is a ~30 FPS real-time loop: it `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick`. It acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via `GameState::try_move`; `l` toggles the log panel; `q`/`Esc` quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). `game.run_init()` runs object `init()` hooks after the board is loaded and before the loop. When `game.active_scroll` is `Some`, all input is redirected to scroll navigation (Up/Down to scroll, letter keys for choices, Esc to dismiss if no choices); `game.tick()` is skipped until the overlay is closed. +- `draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`). The scroll overlay is drawn last (above everything) by `render::draw_scroll_overlay`. +- `Ui { log_open, log_height, scroll, scroll_offset, scroll_anim }` — view-only panel state kept in the front-end (not on `GameState`). +- `ScrollAnimState { Opening(f32), Open, Closing { progress, choice } }` — tracks the 0.5 s open/close animation for the scroll overlay. `begin_close(ui, choice)` starts closing. When `Closing` reaches 0, `game.close_scroll(choice)` is called and the animation resets. **`kiln-tui/src/cp437.rs`** — CP437 → Unicode mapping: - `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.). @@ -134,6 +136,8 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint - `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)` (pub): a board smaller than the viewport is **centered** with screen padding, a larger one **scrolls** to keep the player on screen with no empty margins. Each cell calls `Board::glyph_at`, which already folds in the player glyph at the player's position — no separate player-overlay pass needed. - `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if the cell is scrolled off-screen. Used by the speech-bubble overlay. - `draw_speech_bubbles(buf, area, board, bubbles)` — draws box-drawing speech bubbles over the board for all active `SpeechBubble`s. Each bubble shows a `╭─╮` / `│ text │` / `╰─╮─╯` / `│` frame with the tail at the object's column. Bubbles that would overlap are shifted upward one row at a time until no collision. +- `draw_scroll_overlay(buf, area, scroll, offset, anim)` — draws the full-screen scroll overlay. Dims the entire `area` (fg=Rgb(60,60,60), bg=Black), then renders a centered box-drawing window whose height is scaled by `anim` (0.0→1.0 = opening; 1.0→0.0 = closing). Text lines are word-wrapped via `wrap_text`; choice lines show `[a]`/`[b]` key prefixes in yellow with amber label text. `offset` scrolls the content vertically. +- `wrap_text(text, max_width) -> Vec` — word-wraps a string to lines no longer than `max_width` characters. **`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup: - `TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports. @@ -286,7 +290,7 @@ fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); } The optional `[floor]` section declares the visual floor layer (drawn under every empty cell, and revealed when a crate/object is pushed away); see `floor.rs`. It takes one of four forms: omitted (black-on-black space, the default), a generator name (`floor = "grass"`), a single fixed glyph (`[floor]` with `tile`/`fg`/`bg`), or a grid (`[floor] content` + `[floor.palette]`, whose entries are generator names or fixed glyphs). The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: an unknown generator/char or a grid-dimension mismatch is recorded on `Board::load_errors` and those cells fall back to the default empty glyph. -Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); 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)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, `log(s)`, and `say(s)` (displays a speech bubble above the object for 3 s, replacing any existing bubble from that object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **lowest id wins** (= load order; the earlier-loaded object lands, the later is pushed or blocked) and the object that was moved into receives `bump(id)`. +Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); 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)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, `log(s)`, `say(s)` (displays a speech bubble above the object for 3 s, replacing any existing bubble from that object), and `scroll(lines)` (opens a full-screen scrollable overlay; pauses ticks until dismissed; if the lines include choice entries the player must select one, which fires a named function on the source object via `send`). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **lowest id wins** (= load order; the earlier-loaded object lands, the later is pushed or blocked) and the object that was moved into receives `bump(id)`. ### Key design decisions diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index e8ac909..d6792ce 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -29,6 +29,19 @@ use rhai::{Dynamic, ImmutableString, Module}; /// How long a move occupies an object before it can act again, in seconds. pub(crate) const MOVE_COST: f64 = 0.25; +/// One line of content in a [`Action::Scroll`] overlay. +/// +/// Each element of the array passed to the `scroll()` Rhai function becomes a +/// `ScrollLine`: a plain string becomes [`Text`](ScrollLine::Text); a two-element +/// array `[choice, display]` becomes a [`Choice`](ScrollLine::Choice). +pub enum ScrollLine { + /// Plain text, word-wrapped to the scroll window width. + Text(String), + /// A selectable option: `choice` is the event name sent back to the source + /// object via `send`, and `display` is the label shown to the player. + Choice { choice: String, display: String }, +} + /// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] /// after it is promoted onto the board queue. /// @@ -52,6 +65,9 @@ pub(crate) enum Action { SetColor { fg: Option, bg: Option }, /// Call `fn_name` on `target` object, optionally passing `arg`. Send { target: ObjectId, fn_name: String, arg: Option }, + /// Open a scrollable text overlay. Pauses game ticks while shown; a choice + /// selection dispatches [`Send`](Action::Send) to the source object. + Scroll(Vec), } // ── Direction helpers ───────────────────────────────────────────────────────── @@ -128,6 +144,10 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { m.insert("arg".into(), dyn_arg); } } + // Scroll lines are not round-trippable via the queue map API; emit type only. + Action::Scroll(_) => { + ins_str(&mut m, "type", "Scroll"); + } } m } @@ -202,6 +222,7 @@ impl TryFrom for Action { }; Ok(Action::Send { target: target as ObjectId, fn_name, arg }) } + "Scroll" => Err("Scroll actions cannot be constructed from maps".to_string()), other => Err(format!("unknown action type: '{other}'")), } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 4de04c0..9461b1a 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -10,6 +10,21 @@ use crate::utils::{Direction, ObjectId, ScriptArg, Solid}; /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; +// Re-export ScrollLine so kiln-tui can pattern-match scroll content without +// accessing the private `action` module directly. +pub use crate::action::ScrollLine; + +/// An active scroll overlay opened by a scripted object via `scroll()`. +/// +/// While a `Scroll` is present on [`GameState`], the front-end should pause ticks +/// and display the overlay. Closed via [`GameState::close_scroll`]. +pub struct Scroll { + /// The object whose `scroll()` call opened this overlay. + pub source: ObjectId, + /// The lines of content to display. + pub lines: Vec, +} + /// An active speech bubble emitted by a scripted object via `say()`. pub struct SpeechBubble { /// The object whose `say()` call created this bubble. @@ -38,6 +53,9 @@ pub struct GameState { scripts: ScriptHost, /// Active speech bubbles from `say()` calls, displayed by the front-end over the board. pub speech_bubbles: Vec, + /// An active scroll overlay opened by `scroll()`. `Some` while the overlay is + /// visible; the front-end pauses ticks and closes it via [`close_scroll`](GameState::close_scroll). + pub active_scroll: Option, } impl GameState { @@ -54,6 +72,7 @@ impl GameState { log: Vec::new(), scripts, speech_bubbles: Vec::new(), + active_scroll: None, }; // Surface any compile-time script errors collected during ScriptHost::new. state.drain_errors(); @@ -120,6 +139,8 @@ impl GameState { let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); let mut sends: Vec<(ObjectId, String, Option)> = Vec::new(); let mut new_bubbles: Vec = Vec::new(); + // Collected outside the board borrow so we can assign to self.active_scroll. + let mut new_scroll: Option = None; { let mut board = self.board_mut(); for ba in actions { @@ -159,6 +180,10 @@ impl GameState { Action::Send { target, fn_name, arg } => { sends.push((target, fn_name, arg)); } + // Later scrolls overwrite earlier ones from the same tick. + Action::Scroll(lines) => { + new_scroll = Some(Scroll { source: ba.source, lines }); + } } } } @@ -168,6 +193,9 @@ impl GameState { self.speech_bubbles.retain(|b| b.object_id != bubble.object_id); self.speech_bubbles.push(bubble); } + if let Some(scroll) = new_scroll { + self.active_scroll = Some(scroll); + } for (bumped, bumper) in bumps { self.scripts.run_bump(bumped, bumper); } @@ -177,6 +205,20 @@ impl GameState { self.drain_errors(); } + /// Closes the active scroll overlay, optionally dispatching a named choice + /// back to the object that opened it via `send`. + /// + /// Call from the front-end when the player dismisses the scroll or selects a + /// choice. If `choice` is `Some`, the source object receives a `send` call with + /// that string as the function name (e.g. `"eat"` triggers `fn eat()`). + pub fn close_scroll(&mut self, choice: Option<&str>) { + let source = self.active_scroll.take().map(|s| s.source); + if let (Some(src), Some(ch)) = (source, choice) { + self.scripts.run_send(src, ch, None); + self.drain_errors(); + } + } + /// Attempts to move the player one cell in `dir`. /// /// The move is ignored if the target cell is out of bounds, or it is neither diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 108f1b1..183e7ea 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -45,12 +45,12 @@ //! //! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`, `Queue.push(map)` -use crate::action::{action_to_map, rhai_action_module, Action, MOVE_COST}; +use crate::action::{action_to_map, rhai_action_module, Action, ScrollLine, MOVE_COST}; use crate::board::Board; use crate::log::LogLine; use crate::map_file::{color_to_hex, parse_color}; use crate::utils::{Direction, ObjectId, ScriptArg}; -use rhai::{CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST}; +use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; @@ -613,6 +613,27 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { emit(&q, source_of(&ctx), Action::Say(msg.to_string())); }); + // scroll(lines): open a full-screen scrollable overlay. Each element of `lines` + // is either a plain string (text) or a 2-element array [choice_key, display_text]. + let q = queues.clone(); + engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| { + let lines = arr.into_iter().filter_map(|item| { + if let Ok(s) = item.clone().into_string() { + Some(ScrollLine::Text(s)) + } else { + let pair: Vec = item.into_array().ok()?; + if pair.len() == 2 { + let choice = pair[0].clone().into_string().ok()?; + let display = pair[1].clone().into_string().ok()?; + Some(ScrollLine::Choice { choice, display }) + } else { + None + } + } + }).collect(); + emit(&q, source_of(&ctx), Action::Scroll(lines)); + }); + let q = queues.clone(); engine.register_fn( "set_tag", diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 833d46b..e29a784 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -11,7 +11,7 @@ mod cp437; mod render; mod term; -use kiln_core::game::GameState; +use kiln_core::game::{GameState, ScrollLine}; use kiln_core::log::LogLine; use kiln_core::map_file; use kiln_core::Direction; @@ -31,8 +31,19 @@ use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; -/// View-only UI state for the log panel. This is presentation state, so it lives -/// in the front-end rather than on the engine's [`GameState`]. +/// Animation state for the scroll overlay window. +enum ScrollAnimState { + /// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s. + Opening(f32), + /// Window is fully open. + Open, + /// Window is closing: progress from 1.0 → 0.0 over 0.5 s. + /// When it reaches 0 the `choice` (if any) is dispatched and the overlay clears. + Closing { progress: f32, choice: Option }, +} + +/// View-only UI state for the log panel and scroll overlay. This is presentation +/// state, so it lives in the front-end rather than on the engine's [`GameState`]. struct Ui { /// Whether the bottom log panel is open. log_open: bool, @@ -40,6 +51,10 @@ struct Ui { log_height: u16, /// Vertical scroll offset of the log panel, in lines from the top (newest). scroll: u16, + /// Vertical scroll offset within an active scroll overlay. + scroll_offset: u16, + /// Animation state for the scroll overlay; `None` when no overlay is active. + scroll_anim: Option, } impl Default for Ui { @@ -48,10 +63,37 @@ impl Default for Ui { log_open: false, log_height: 10, scroll: 0, + scroll_offset: 0, + scroll_anim: None, } } } +/// Starts the scroll-close animation with the optional player choice. +/// Uses the current opening progress if the overlay was still animating in. +fn begin_close(ui: &mut Ui, choice: Option) { + let progress = match &ui.scroll_anim { + Some(ScrollAnimState::Opening(p)) => p.min(1.0), + _ => 1.0, + }; + ui.scroll_anim = Some(ScrollAnimState::Closing { progress, choice }); +} + +/// Returns the choice string for the given key character, or `None` if it doesn't +/// match any choice in the scroll. Choices are assigned letters a, b, c… in order. +fn resolve_choice(lines: &[ScrollLine], c: char) -> Option { + let mut letter = b'a'; + for line in lines { + if let ScrollLine::Choice { choice, .. } = line { + if c == letter as char { + return Some(choice.clone()); + } + letter += 1; + } + } + None +} + /// Entry point: parse the map path, load the board, then run the play loop with /// the terminal in raw/alternate-screen mode (restored on every exit path). fn main() -> ExitCode { @@ -142,36 +184,74 @@ fn run( // continuously (the Kitty protocol emits Repeat events while a key // is held). Ignore Release, which that protocol also emits and // which would otherwise fire a second, spurious move per keypress. - Event::Key(key) if key.kind != KeyEventKind::Release => match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), - KeyCode::Up => game.try_move(Direction::North), - KeyCode::Down => game.try_move(Direction::South), - KeyCode::Left => game.try_move(Direction::West), - // Right arrow moves right; 'l' is the log panel. - KeyCode::Right => game.try_move(Direction::East), - // 'l' toggles the log panel; reset scroll to newest. - KeyCode::Char('l') => { - ui.log_open = !ui.log_open; - ui.scroll = 0; + Event::Key(key) if key.kind != KeyEventKind::Release => { + // When a scroll overlay is active (and not already animating + // closed), intercept all keys for scroll navigation/choices. + let scroll_active = game.active_scroll.is_some() + && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); + if scroll_active { + let scroll = game.active_scroll.as_ref().unwrap(); + let has_choices = scroll.lines.iter() + .any(|l| matches!(l, ScrollLine::Choice { .. })); + match key.code { + // Esc dismisses the scroll only when there are no choices; + // with choices present the player must select one. + KeyCode::Esc if !has_choices => begin_close(ui, None), + KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1), + KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1), + KeyCode::Char(c) => { + if let Some(ch) = resolve_choice(&scroll.lines, c) { + begin_close(ui, Some(ch)); + } + } + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Up => game.try_move(Direction::North), + KeyCode::Down => game.try_move(Direction::South), + KeyCode::Left => game.try_move(Direction::West), + // Right arrow moves right; 'l' is the log panel. + KeyCode::Right => game.try_move(Direction::East), + // 'l' toggles the log panel; reset scroll to newest. + KeyCode::Char('l') => { + ui.log_open = !ui.log_open; + ui.scroll = 0; + } + // PageUp/PageDown scroll the log when it's open. + KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5), + KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5), + _ => {} + } } - // PageUp/PageDown scroll the log when it's open. - KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5), - KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5), - _ => {} - }, - // Wheel scrolls the open log; dragging the divider resizes it. - Event::Mouse(m) if ui.log_open => match m.kind { - MouseEventKind::ScrollUp => scroll_log(ui, game, -1), - MouseEventKind::ScrollDown => scroll_log(ui, game, 1), - MouseEventKind::Drag(_) => { - // The divider sits at row `total - log_height`; dragging - // it up (smaller row) grows the panel. Keep both usable. - let total = terminal.size()?.height; - let target = total.saturating_sub(m.row); - ui.log_height = target.clamp(3, total.saturating_sub(3).max(3)); + } + // Mouse: scroll overlay captures wheel events when active; otherwise + // wheel scrolls the log and drag resizes it. + Event::Mouse(m) => { + let scroll_active = game.active_scroll.is_some() + && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); + if scroll_active { + match m.kind { + MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1), + MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1), + _ => {} + } + } else if ui.log_open { + match m.kind { + MouseEventKind::ScrollUp => scroll_log(ui, game, -1), + MouseEventKind::ScrollDown => scroll_log(ui, game, 1), + MouseEventKind::Drag(_) => { + // The divider sits at row `total - log_height`; dragging + // it up (smaller row) grows the panel. Keep both usable. + let total = terminal.size()?.height; + let target = total.saturating_sub(m.row); + ui.log_height = target.clamp(3, total.saturating_sub(3).max(3)); + } + _ => {} + } } - _ => {} - }, + } _ => {} } } @@ -182,7 +262,35 @@ fn run( if last_tick.elapsed() >= FRAME { let dt = last_tick.elapsed(); last_tick = Instant::now(); - game.tick(dt); + + // Advance scroll animation regardless of game pause state. + let dt_s = dt.as_secs_f32(); + match &mut ui.scroll_anim { + Some(ScrollAnimState::Opening(p)) => { + *p += dt_s / 0.5; + if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); } + } + Some(ScrollAnimState::Closing { progress, choice }) => { + *progress -= dt_s / 0.5; + if *progress <= 0.0 { + // Animation done — dispatch choice and clear the scroll. + let ch = choice.take(); + game.close_scroll(ch.as_deref()); + ui.scroll_anim = None; + ui.scroll_offset = 0; + } + } + _ => {} + } + + // Tick the game only when no scroll overlay is blocking. + if game.active_scroll.is_none() { + game.tick(dt); + // Detect a scroll just opened by the tick; start its open animation. + if game.active_scroll.is_some() && ui.scroll_anim.is_none() { + ui.scroll_anim = Some(ScrollAnimState::Opening(0.0)); + } + } } } } @@ -197,6 +305,7 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) { /// Render a single frame: the board in a bordered block, and — when open — a log /// panel across the bottom. When the panel is closed the latest log message is /// previewed in the board's bottom-left border after a `[l]og:` label. +/// A scroll overlay is drawn on top of everything when active. fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { // Borrow the board for the duration of the frame (no script runs during draw). let board = game.board(); @@ -239,6 +348,20 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { frame.render_widget(BoardWidget::new(board), inner); render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); } + + // Scroll overlay: drawn last so it sits above all other content. + if let Some(scroll) = &game.active_scroll { + let anim = match &ui.scroll_anim { + Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0), + Some(ScrollAnimState::Open) => 1.0, + Some(ScrollAnimState::Closing { progress, .. }) => progress.clamp(0.0, 1.0), + // Fallback: overlay exists but animation not set yet — show fully open. + None => 1.0, + }; + // Capture area before the mutable buffer borrow to satisfy the borrow checker. + let area = frame.area(); + render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim); + } } /// Builds the `[l]og: ` preview shown in the board's bottom-left diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 3f36ac0..9dcde31 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -8,13 +8,13 @@ use crate::cp437::tile_to_char; use color::Rgba8; use kiln_core::Board; -use kiln_core::game::SpeechBubble; +use kiln_core::game::{Scroll, ScrollLine, SpeechBubble}; use kiln_core::log::LogLine; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::Widget; +use ratatui::widgets::{Block, Paragraph, Widget}; /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. /// @@ -234,3 +234,96 @@ fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) { cell.set_char(ch).set_style(style); } } + +/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls. +/// +/// The overlay dims the entire background, then renders a `Block`-bordered window +/// whose height animates from 0 → full (opening) or full → 0 (closing) based on +/// `anim` (0.0 = invisible, 1.0 = fully open). Text is pre-wrapped so the exact +/// line count is known for the animation; a `Paragraph` renders the content. +/// Choice lines show an `[a]` / `[b]` key prefix in yellow. `offset` scrolls the +/// content vertically. +pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) { + if anim <= 0.0 { return; } + + let bg = Color::Rgb(30, 25, 10); + let text_style = Style::default().fg(Color::White).bg(bg); + let key_style = Style::default().fg(Color::Yellow).bg(bg); + let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg); + let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg); + + // Pre-wrap text so we know the total line count for the animation height. + // max_text_w is the usable text width inside the block (border + 1 pad each side). + let max_text_w = (area.width as usize).saturating_sub(6).clamp(10, 60); + let mut lines: Vec = Vec::new(); + let mut choice_letter = b'a'; + for item in &scroll.lines { + match item { + ScrollLine::Text(s) => { + for wrapped in wrap_text(s, max_text_w) { + // Leading space provides left-padding inside the border. + lines.push(Line::styled(format!(" {wrapped}"), text_style)); + } + } + ScrollLine::Choice { display, .. } => { + let key = choice_letter as char; + lines.push(Line::from(vec![ + Span::styled(format!(" [{key}] "), key_style), + Span::styled(display.clone(), choice_style), + ])); + choice_letter += 1; + } + } + } + + // Derive overlay dimensions. Width fits the longest line; height animates. + let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(0).min(max_text_w); + let outer_w = (content_w + 4) as u16; // border (1) + pad (1) each side = +4 total + let full_outer_h = (lines.len() + 2) as u16; // +2 for top/bottom border rows + let max_outer_h = ((area.height as f32 * 0.85) as u16).max(4); + let actual_outer_h = ((full_outer_h as f32 * anim).ceil() as u16) + .min(max_outer_h) + .max(1); + + let left = area.x + area.width.saturating_sub(outer_w) / 2; + let top = area.y + area.height.saturating_sub(actual_outer_h) / 2; + let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_outer_h }; + + // Dim the background — no ratatui widget does this, so it stays manual. + let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(dim_style); + } + } + } + + // Render the bordered box, then the scrollable content inside it. + let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg)); + let inner = block.inner(overlay_rect); + block.render(overlay_rect, buf); + Paragraph::new(lines).scroll((offset, 0)).render(inner, buf); +} + +/// Wraps `text` at word boundaries so no line exceeds `max_width` characters. +/// Used to pre-compute line count for the scroll overlay's animated height. +fn wrap_text(text: &str, max_width: usize) -> Vec { + if max_width == 0 { return vec![text.to_string()]; } + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current.push_str(word); + } else if current.len() + 1 + word.len() <= max_width { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current.push_str(word); + } + } + if !current.is_empty() { lines.push(current); } + if lines.is_empty() { lines.push(String::new()); } + lines +} diff --git a/maps/start.toml b/maps/start.toml index 1d5ae9d..a154cc4 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -66,7 +66,7 @@ content = """ # # # # | # # # -# oo G - @ # +# oo G - M @ # # # # # # # @@ -102,6 +102,15 @@ bg = "#000000" tile = 1 script_name = "mover" +[[objects]] +palette = "M" +tile = 15 +fg = "#ffdd88" +bg = "#000000" +solid = true +name = "muffin" +script_name = "muffin" + [scripts] greeter = """ fn init() { @@ -134,4 +143,23 @@ fn bump(id) { log(`mover bumped by ${id}`); say("Ow!"); } +""" + +muffin = """ +fn bump(id) { + scroll([ + "You find a small muffin on the ground, your favorite kind.", + "It smells of cinnamon and warm mornings.", + ["eat", "Pick up the muffin and eat it."], + ["ignore", "This is obviously a trap — ignore it."] + ]); +} +fn eat() { + log("You ate the muffin. Delicious."); + say("Mmm!"); +} +fn ignore() { + log("You walk away from the muffin. Wise."); + say("Suspicious..."); +} """ \ No newline at end of file