Added scroll()
This commit is contained in:
@@ -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 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<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
|
||||
|
||||
|
||||
@@ -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}'")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+155
-32
@@ -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,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: <latest message>` preview shown in the board's bottom-left
|
||||
|
||||
+95
-2
@@ -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
@@ -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...");
|
||||
}
|
||||
"""
|
||||
Reference in New Issue
Block a user