Added scroll()

This commit is contained in:
2026-06-10 01:42:33 -05:00
parent c2168b992d
commit f73c02437d
7 changed files with 375 additions and 43 deletions
+10 -6
View File
@@ -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<RefCell<Board>>`, `log: Vec<LogLine>`, `scripts: ScriptHost`, and `pub speech_bubbles: Vec<SpeechBubble>`. Front-ends reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>`. `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<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
- `GameState` — holds `board: Rc<RefCell<Board>>`, `log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, and `pub active_scroll: Option<Scroll>`. Front-ends reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>`. `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<RefCell<Board>>)` (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<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)` 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<usize, ObjQueue>`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec<BoardAction { source, action }>`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`).
- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)` 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()`).
- 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<Direction> 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 0x000x1F, box-drawing for 0xB00xDF, 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<String>` — 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
+21
View File
@@ -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<Rgba8>, bg: Option<Rgba8> },
/// Call `fn_name` on `target` object, optionally passing `arg`.
Send { target: ObjectId, fn_name: String, arg: Option<ScriptArg> },
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>),
}
// ── 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<rhai::Map> 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}'")),
}
}
+42
View File
@@ -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<ScrollLine>,
}
/// 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<SpeechBubble>,
/// 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<Scroll>,
}
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<ScriptArg>)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{
let mut board = self.board_mut();
for ba in actions {
@@ -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
+23 -2
View File
@@ -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<Dynamic> = 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",
+131 -8
View File
@@ -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<String> },
}
/// 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<ScrollAnimState>,
}
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<String>) {
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<String> {
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,7 +184,30 @@ 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 {
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),
@@ -158,9 +223,22 @@ fn run(
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 {
}
}
}
// 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(_) => {
@@ -171,7 +249,9 @@ fn run(
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();
// 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: <latest message>` preview shown in the board's bottom-left
+95 -2
View File
@@ -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<Line> = 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<String> {
if max_width == 0 { return vec![text.to_string()]; }
let mut lines: Vec<String> = 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
}
+29 -1
View File
@@ -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() {
@@ -135,3 +144,22 @@ fn bump(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...");
}
"""