From e5c406e42db31588fcbbd4647e396d49d58988b1 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Mon, 8 Jun 2026 22:15:44 -0500 Subject: [PATCH] speech bubbles and delays --- CLAUDE.md | 14 +- kiln-core/src/board.rs | 4 +- kiln-core/src/game.rs | 40 ++++- kiln-core/src/lib.rs | 1 + kiln-core/src/script.rs | 293 +++++++++++++------------------ kiln-core/src/tests/actions.rs | 8 +- kiln-core/src/tests/movement.rs | 2 +- kiln-core/src/tests/scripting.rs | 2 +- kiln-core/src/utils.rs | 50 +++++- kiln-tui/src/main.rs | 4 +- kiln-tui/src/render.rs | 124 ++++++++++++- maps/start.toml | 2 + 12 files changed, 354 insertions(+), 190 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e867c94..e019873 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,9 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface. **`kiln-core/src/game.rs`** — game-loop logic only: -- `GameState` — holds `board: Rc>`, `log: Vec`, and `scripts: ScriptHost`. 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 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`). 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`. +- `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`. **`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. @@ -97,7 +99,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)` append an `Action` (`Move`/`SetTile`/`Log`; `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)` 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()`). - 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. @@ -129,7 +131,9 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint **`kiln-tui/src/render.rs`** — board → ratatui buffer: - `rgba8_to_color(Rgba8) -> Color::Rgb` — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate. -- `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)`: 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. +- `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. **`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. @@ -276,13 +280,13 @@ fn tick(dt) { } # optional, fires when something steps into this object; id = bumper's object id, # or -1 for the player. -fn bump(id) { log(`bumped by ${id}`); } +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)`, and `log(s)`. 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)`, 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)`. ### Key design decisions diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 1ed4e6f..725ca5d 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -5,7 +5,7 @@ use crate::font::FontSpec; use crate::glyph::Glyph; use crate::log::LogLine; use crate::object_def::ObjectDef; -use crate::script::Direction; +use crate::utils::Direction; use crate::utils::{ObjectId, Player, PortalDef, Solid}; /// The complete state of one game board (a single room or screen). @@ -318,7 +318,7 @@ pub(crate) mod tests { use crate::archetype::Archetype; use crate::glyph::Glyph; use crate::object_def::ObjectDef; - use crate::script::Direction; + use crate::utils::Direction; use crate::utils::{ObjectId, Player, Solid}; use super::Board; diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index b137360..a065017 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,10 +1,23 @@ use crate::log::LogLine; -use crate::script::{Action, Direction, ScriptHost}; +use crate::script::ScriptHost; use std::cell::{Ref, RefCell, RefMut}; use std::rc::Rc; use std::time::Duration; use crate::board::Board; -use crate::utils::{ObjectId, Solid}; +use crate::utils::{Action, Direction, ObjectId, Solid}; + +/// How long a `say()` speech bubble stays on screen, in seconds. +pub const SAY_DURATION: f64 = 3.0; + +/// An active speech bubble emitted by a scripted object via `say()`. +pub struct SpeechBubble { + /// The object whose `say()` call created this bubble. + pub object_id: ObjectId, + /// The text to display. + pub text: String, + /// Seconds remaining before the bubble disappears. + pub remaining: f64, +} /// Holds the active game world and provides game-logic operations. /// @@ -22,6 +35,8 @@ pub struct GameState { pub log: Vec, /// The Rhai scripting runtime driving this board's scripted objects. scripts: ScriptHost, + /// Active speech bubbles from `say()` calls, displayed by the front-end over the board. + pub speech_bubbles: Vec, } impl GameState { @@ -37,6 +52,7 @@ impl GameState { board, log: Vec::new(), scripts, + speech_bubbles: Vec::new(), }; // Surface any compile-time script errors collected during ScriptHost::new. state.drain_errors(); @@ -73,8 +89,10 @@ impl GameState { /// then resolves the actions that were promoted onto the board queue. pub fn tick(&mut self, dt: Duration) { let secs = dt.as_secs_f64(); - self.scripts.advance_timers(secs); self.scripts.run_tick(secs); + // Expire speech bubbles before resolving new actions so a fresh say() + // this frame isn't immediately culled. + self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 }); self.resolve(); } @@ -98,6 +116,7 @@ impl GameState { // below also borrows `self`. let mut logs: Vec = Vec::new(); let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); + let mut new_bubbles: Vec = Vec::new(); { let mut board = self.board_mut(); for ba in actions { @@ -118,10 +137,25 @@ impl GameState { if present { obj.tags.insert(tag); } else { obj.tags.remove(&tag); } } } + // Replace any existing bubble from this object so repeated say() calls + // don't stack visually — the new text resets the timer. + Action::Say(text) => new_bubbles.push(SpeechBubble { + object_id: ba.source, + text, + remaining: SAY_DURATION, + }), + // Delays are consumed by ScriptHost::drain and never reach the board queue. + Action::Delay(_) => {} + } } } self.log.extend(logs); + for bubble in new_bubbles { + // One bubble per object: replace the existing one if present. + self.speech_bubbles.retain(|b| b.object_id != bubble.object_id); + self.speech_bubbles.push(bubble); + } for (bumped, bumper) in bumps { self.scripts.run_bump(bumped, bumper); } diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index dd9fd25..2804af2 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -16,6 +16,7 @@ mod object_def; mod board; pub use board::Board; +pub use utils::Direction; #[cfg(test)] mod tests; \ No newline at end of file diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 1cdb13a..1b4ff6e 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -12,22 +12,28 @@ //! ## Actions, queues, and rate limiting //! //! Scripts don't mutate the world directly. Each write host function (`move`, -//! `set_tile`, `log`) appends an [`Action`] to the issuing object's **output -//! queue** (a per-object FIFO). Actions leave that queue one batch at a time via -//! [`ScriptHost::pump`], which promotes them onto the shared **board queue**: +//! `set_tile`, `log`, `say`) appends an [`Action`] to the issuing object's **output +//! queue** (a per-object FIFO). //! -//! - A [`pump`](ScriptHost::pump) pulls the leading run of zero-cost actions plus at -//! most one action with a time cost (currently only [`Action::Move`], 250 ms). -//! - Pulling a timed action arms the object's **ready timer**; while it is running no -//! further actions are pulled (this is what caps an object's speed). [`tick`] ticks -//! the timer down via [`advance_timers`](ScriptHost::advance_timers). +//! Timing is encoded **inline** in the queue as [`Action::Delay`] entries rather than +//! as an external per-object timer. `move(dir)` appends both a `Move` and a +//! `Delay(MOVE_COST)` so the object pauses before its next action. Adjacent delays are +//! merged at push time so the queue never contains two consecutive `Delay`s. +//! +//! Each tick, [`ScriptHost::drain`] advances the queue for each object: +//! it promotes non-delay actions onto the shared **board queue** until it hits a live +//! `Delay`, which it decrements (and removes when expired). `dt` is consumed on the +//! first `Delay` encountered so multiple delays in one tick are not double-decremented. +//! +//! The `now()` host function pops the most recently enqueued action to the front of +//! the queue, letting scripts prioritize a zero-cost action (e.g. `say`) over a +//! pending delay. //! //! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies //! each action *after* the script batch, preserving the borrow discipline (scripts only //! ever read the board during execution). Which object issued an action is read from the //! per-call **tag** (set to the object index in [`ScriptHost::run`]). //! -//! [`tick`]: ScriptHost::run_tick //! [`take_board_queue`]: ScriptHost::take_board_queue //! [`GameState`]: crate::game::GameState @@ -37,37 +43,7 @@ use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; -use crate::utils::ObjectId; - -/// How long a move occupies an object before it can act again, in seconds. -pub const MOVE_COST: f64 = 0.25; - -/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] -/// after it is promoted onto the board queue. -pub enum Action { - /// Move the source object one cell in a direction (subject to passability). - Move(Direction), - /// Set the source object's glyph tile index. - SetTile(u32), - /// Append a plain (uncolored) line to the game log. - Log(LogLine), - /// Add (`present = true`) or remove (`present = false`) `tag` on `target`. - /// Zero-cost; applied by [`crate::game::GameState`] after the action batch. - SetTag { target: ObjectId, tag: String, present: bool }, -} - -impl Action { - /// How long performing this action keeps the object busy, in seconds. - /// - /// Only [`Action::Move`] has a cost ([`MOVE_COST`]); zero-cost actions can be - /// drained several-per-pump and never arm the ready timer. - pub fn time_cost(&self) -> f64 { - match self { - Action::Move(_) => MOVE_COST, - Action::SetTile(_) | Action::Log(_) | Action::SetTag { .. } => 0.0, - } - } -} +use crate::utils::{Action, Direction, ObjectId, MOVE_COST}; /// An action promoted from an object's output queue onto the board queue, tagged /// with the object that issued it. @@ -78,27 +54,6 @@ pub struct BoardAction { pub action: Action, } -/// A cardinal movement direction. -#[derive(Clone, Copy, Debug)] -pub enum Direction { - North, - South, - East, - West, -} - -impl From for (i32, i32) { - /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). - fn from(d: Direction) -> Self { - match d { - Direction::North => (0, -1), - Direction::South => (0, 1), - Direction::East => (1, 0), - Direction::West => (-1, 0), - } - } -} - /// A read-only handle to the world, exposed to scripts as `Board`. Holds a shared /// reference to the [`Board`]; only getters are registered, so it is /// read-only by construction. @@ -133,7 +88,7 @@ struct CompiledScript { } /// The runtime state of one scripted object: which script it runs, its persistent -/// Rhai scope, its pending actions, and its cooldown timer. +/// Rhai scope, and its pending action queue. struct ObjectRuntime { /// The stable [`ObjectId`] of this object; passed to scripts as the per-call /// tag so host functions know which object issued an action. @@ -144,10 +99,8 @@ struct ObjectRuntime { /// can hold object-local state in the future). Persists across calls. scope: Scope<'static>, /// This object's output queue (shared with its scope's `Queue` object). + /// May contain [`Action::Delay`] entries that pace the release of other actions. output_queue: ObjQueue, - /// Seconds remaining before this object may pull another action. While `> 0` - /// nothing is pulled from [`output_queue`](Self::output_queue). - ready_timer: f64, } /// Owns the Rhai engine and per-object script state for a board. @@ -243,7 +196,6 @@ impl ScriptHost { script_name: name.clone(), scope: new_object_scope(board_cell, &queue, id), output_queue: queue, - ready_timer: 0.0, }); } @@ -258,61 +210,44 @@ impl ScriptHost { } } - /// Calls `init()` on every scripted object that defines it, pumping each object's - /// queue afterward. + /// Calls `init()` on every scripted object that defines it and drains each queue + /// with `dt = 0` (no time advance — just flush any zero-cost front actions). pub fn run_init(&mut self) { - self.run("init", |c| c.has_init, ()); + self.run("init", |c| c.has_init, (), 0.0); } - /// Calls `tick(dt)` on every scripted object that defines it, passing the elapsed - /// seconds, then pumps each object's queue. + /// Calls `tick(dt)` on every scripted object that defines it, then drains each + /// queue, advancing inline [`Action::Delay`]s by `dt`. pub fn run_tick(&mut self, dt: f64) { - self.run("tick", |c| c.has_tick, (dt,)); + self.run("tick", |c| c.has_tick, (dt,), dt); } /// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines the hook. /// /// `bumper` is the id of the object that moved into it, or `-1` for the player. - /// Does **not** pump: any actions the hook emits wait for the next tick's pump, so - /// a bump can't cascade within the same resolution pass. + /// After the hook, drains the object's queue with `dt = 0` so any zero-cost actions + /// the hook enqueued (e.g. `say(); now()`) take effect in the same frame. pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) { - let Self { - engine, - scripts, - objects, - errors, - .. - } = self; - let Some(obj) = objects.iter_mut().find(|o| o.object_id == object_id) else { + // Find the index first so we can drain after the split-borrow block. + let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else { return; }; - let Some(compiled) = scripts.get(&obj.script_name) else { - return; - }; - if !compiled.has_bump { - return; - } - let options = CallFnOptions::default().with_tag(object_id as i64); - if let Err(err) = engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, - "bump", - (bumper,), - ) { - errors.borrow_mut().push(LogLine::raw(format!( - "script '{}' bump error: {err}", - obj.script_name - ))); - } - } - - /// Counts every object's ready timer down by `dt` (floored at zero). Call once per - /// frame before [`run_tick`](Self::run_tick). - pub fn advance_timers(&mut self, dt: f64) { - for obj in &mut self.objects { - obj.ready_timer = (obj.ready_timer - dt).max(0.0); + { + let Self { engine, scripts, objects, errors, .. } = self; + let obj = &mut objects[i]; + let Some(compiled) = scripts.get(&obj.script_name) else { return; }; + if !compiled.has_bump { return; } + let options = CallFnOptions::default().with_tag(object_id as i64); + if let Err(err) = engine.call_fn_with_options::<()>( + options, &mut obj.scope, &compiled.ast, "bump", (bumper,), + ) { + errors.borrow_mut().push(LogLine::raw(format!( + "script '{}' bump error: {err}", obj.script_name + ))); + } } + // dt=0: flush zero-cost front actions without advancing any delays. + self.drain(i, 0.0); } /// Removes and returns the actions promoted onto the board queue. @@ -325,94 +260,71 @@ impl ScriptHost { std::mem::take(&mut self.errors.borrow_mut()) } - /// Promotes ready actions from object `i`'s output queue onto the board queue. + /// Drains object `i`'s output queue onto the board queue, advancing inline + /// [`Action::Delay`]s by `dt`. /// - /// While the object's ready timer is running, nothing is pulled. While a timed - /// action for this object is already in the board queue, nothing is pulled (a - /// second timed action would race it). Otherwise the leading run of zero-cost - /// actions is promoted, followed by at most one timed action (which arms the timer - /// and ends the pump). All queued free actions therefore land in the board queue - /// in the same pump call as the next timed action — they apply atomically in the - /// same `resolve()`. - fn pump(&mut self, i: usize) { + /// Non-delay actions are promoted immediately. When a `Delay` is encountered it is + /// decremented by the remaining `dt` (which is then consumed so subsequent delays + /// in the same call are not also decremented). An expired delay is removed and + /// draining continues; a live delay stops the loop for this call. + fn drain(&mut self, i: usize, mut dt: f64) { let oid = self.objects[i].object_id; - - // A running cooldown blocks all further pulls (timed or not). - if self.objects[i].ready_timer > 0.0 { - return; - } - - // Don't promote while a timed action for this object is already in the board - // queue and awaiting resolution — a second timed action would race it. - if self.board_queue.borrow().iter() - .any(|ba| ba.source == oid && ba.action.time_cost() > 0.0) - { - return; - } - loop { let queue = self.objects[i].output_queue.clone(); - let cost = match queue.borrow().front() { - Some(action) => action.time_cost(), - None => break, // queue drained - }; - let action = queue.borrow_mut().pop_front().expect("front checked above"); - if cost > 0.0 { - self.objects[i].ready_timer = cost; - } - self.board_queue.borrow_mut().push(BoardAction { - source: oid, - action, - }); - // A timed action ends the pump; zero-cost ones keep draining. - if cost > 0.0 { - break; + // Peek at the front to decide what to do without holding the borrow. + let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_))); + if is_delay { + // Decrement the delay; pop it only if it expires. + let expired = { + let mut q = queue.borrow_mut(); + let Some(Action::Delay(r)) = q.front_mut() else { break }; + *r -= dt; + dt = 0.0; // consume so later delays in this call aren't also decremented + *r <= 0.0 + }; + if expired { + queue.borrow_mut().pop_front(); + // Continue — drain actions after the expired delay. + } else { + break; // Live delay: stop draining. + } + } else { + let Some(action) = queue.borrow_mut().pop_front() else { break }; + self.board_queue.borrow_mut().push(BoardAction { source: oid, action }); } } } /// Shared driver for `init`/`tick`: for each object, call the hook if its script - /// `defined` it (tagging the call with the object index so host functions know the - /// source), then pump that object so backlogged actions drain and later objects' - /// `blocked` checks can see earlier movers. Runtime errors are captured rather than - /// aborting the batch. + /// defines it (tagging the call with the object's id so host functions know the + /// source), then drain that object's queue so actions reach the board queue before + /// the next object's `blocked` check runs. fn run( &mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A, + drain_dt: f64, ) { for i in 0..self.objects.len() { - // Scope the split borrow so it ends before the `pump` reborrow below. + // Scope the split borrow so it ends before the drain reborrow below. { - let Self { - engine, - scripts, - objects, - errors, - .. - } = self; + let Self { engine, scripts, objects, errors, .. } = self; let obj = &mut objects[i]; if let Some(compiled) = scripts.get(&obj.script_name) && defined(compiled) { - // The tag carries this object's id to the host functions. let options = CallFnOptions::default().with_tag(obj.object_id as i64); if let Err(err) = engine.call_fn_with_options::<()>( - options, - &mut obj.scope, - &compiled.ast, - hook, - args, + options, &mut obj.scope, &compiled.ast, hook, args, ) { errors.borrow_mut().push(LogLine::raw(format!( - "script '{}' {hook} error: {err}", - obj.script_name + "script '{}' {hook} error: {err}", obj.script_name ))); } } } - self.pump(i); + self.drain(i, drain_dt); } } } @@ -533,14 +445,36 @@ fn is_blocked( }) } -/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` host -/// functions, each appending an [`Action`] to the issuing object's output queue. +/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`/ +/// `say`/`now` host functions. +/// +/// `move(dir)` pushes both a `Move` and a `Delay(MOVE_COST)` so the object's queue +/// paces itself automatically. `now()` promotes the most recently enqueued action to +/// the front of the queue, letting a zero-cost action bypass a pending delay. fn register_write_api(engine: &mut Engine, queues: &QueueMap) { engine.register_type_with_name::("Direction"); + // move(dir): enqueue a Move followed by a rate-limiting Delay. let q = queues.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { - emit(&q, source_of(&ctx), Action::Move(dir)); + let src = source_of(&ctx); + if let Some(queue) = q.borrow().get(&src) { + queue.borrow_mut().push_back(Action::Move(dir)); + push_delay(queue, MOVE_COST); + } + }); + + // now(): pop the back of the queue and push it to the front. + // Use after a zero-cost action (say, log) to bypass any pending Delay. + let q = queues.clone(); + engine.register_fn("now", move |ctx: NativeCallContext| { + let src = source_of(&ctx); + if let Some(queue) = q.borrow().get(&src) { + let mut q = queue.borrow_mut(); + if let Some(back) = q.pop_back() { + q.push_front(back); + } + } }); let q = queues.clone(); @@ -560,6 +494,14 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { }, ); + let q = queues.clone(); + engine.register_fn( + "say", + move |ctx: NativeCallContext, msg: ImmutableString| { + emit(&q, source_of(&ctx), Action::Say(msg.to_string())); + }, + ); + // set_tag(target_id, tag, present): add (true) or remove (false) a tag on any object. // Use MY_ID as target_id to mutate the calling object's own tags. let q = queues.clone(); @@ -588,6 +530,17 @@ fn register_queue_api(engine: &mut Engine) { engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear()); } +/// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing +/// trailing delay to prevent adjacent delays from accumulating. +fn push_delay(queue: &ObjQueue, secs: f64) { + let mut q = queue.borrow_mut(); + if let Some(Action::Delay(r)) = q.back_mut() { + *r += secs; + } else { + q.push_back(Action::Delay(secs)); + } +} + /// Appends `action` to the output queue of the object identified by `source`. fn emit(queues: &QueueMap, source: ObjectId, action: Action) { if let Some(queue) = queues.borrow().get(&source) { diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 539153f..33c8fd5 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -128,9 +128,9 @@ fn move_cost_rate_limits_repeated_moves() { } #[test] -fn blocked_move_still_costs_cooldown() { - // A move into a wall fails but still charges the 250 ms cooldown, so a second - // queued move (into open space) is delayed just as a successful move would be. +fn inline_delay_paces_subsequent_moves() { + // move() always appends a Delay(250 ms) to the queue, so a second queued move + // is held back by that delay even if the first move was blocked by a wall. let mut board = open_board( 3, 3, @@ -144,7 +144,7 @@ fn blocked_move_still_costs_cooldown() { // First (eastward) move is blocked by the wall: object hasn't moved. assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1)); - // The blocked move charged the cooldown, so South is still pending here. + // The Delay(250 ms) appended by move(East) is still live, so South is pending. game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100)); assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1)); diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index 16df94f..9489fe1 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -4,7 +4,7 @@ use crate::board::tests::{crate_at, open_board, stamp, wall_at}; use crate::game::GameState; use crate::glyph::Glyph; use crate::object_def::ObjectDef; -use crate::script::Direction; +use crate::utils::Direction; #[test] fn pushing_a_crate_reveals_the_floor_underneath() { diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 6b23722..170343f 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -1,7 +1,7 @@ use std::time::Duration; use crate::board::tests::open_board; use crate::game::GameState; -use crate::script::Direction; +use crate::utils::Direction; use super::{board_with_object, scripted_object, log_texts}; #[test] diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 80581dc..c003649 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use crate::archetype::Archetype; -use crate::script::Direction; +use crate::log::LogLine; /// Which directions a solid may be pushed in. /// @@ -114,7 +114,7 @@ pub struct Player { #[cfg(test)] mod tests { - use crate::script::Direction; + use crate::utils::Direction; use super::Pushable; #[test] @@ -132,4 +132,50 @@ mod tests { assert!(!Pushable::Vertical.allows(Direction::East)); assert!(!Pushable::Vertical.allows(Direction::West)); } +} + +/// How long a move occupies an object before it can act again, in seconds. +pub const MOVE_COST: f64 = 0.25; + +/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] +/// after it is promoted onto the board queue. +/// +/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue. +/// It lives only in the per-object output queue and is consumed by [`ScriptHost::drain`] +/// to pace how quickly other actions are released. +pub enum Action { + /// Move the source object one cell in a direction (subject to passability). + Move(Direction), + /// Set the source object's glyph tile index. + SetTile(u32), + /// Append a plain (uncolored) line to the game log. + Log(LogLine), + /// Add (`present = true`) or remove (`present = false`) `tag` on `target`. + SetTag { target: ObjectId, tag: String, present: bool }, + /// Display a speech bubble above the source object for [`crate::game::SAY_DURATION`] seconds. + Say(String), + /// Pause draining this object's queue for `secs` seconds. Never reaches the board queue. + /// Adjacent delays are merged at push time (see [`script::push_delay`]). + Delay(f64), +} + +/// A cardinal movement direction. +#[derive(Clone, Copy, Debug)] +pub enum Direction { + North, + South, + East, + West, +} + +impl From for (i32, i32) { + /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). + fn from(d: Direction) -> Self { + match d { + Direction::North => (0, -1), + Direction::South => (0, 1), + Direction::East => (1, 0), + Direction::West => (-1, 0), + } + } } \ No newline at end of file diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 5bdcf9d..833d46b 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -14,7 +14,7 @@ mod term; use kiln_core::game::GameState; use kiln_core::log::LogLine; use kiln_core::map_file; -use kiln_core::script::Direction; +use kiln_core::Direction; use ratatui::Frame; use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, @@ -215,6 +215,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { let inner = block.inner(board_area); frame.render_widget(block, board_area); frame.render_widget(BoardWidget::new(board), inner); + render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); // Newest message on top: reverse the log into ratatui lines. let log_block = Block::bordered() @@ -236,6 +237,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); frame.render_widget(BoardWidget::new(board), inner); + render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); } } diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index bca3331..3f36ac0 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -8,6 +8,7 @@ use crate::cp437::tile_to_char; use color::Rgba8; use kiln_core::Board; +use kiln_core::game::SpeechBubble; use kiln_core::log::LogLine; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -70,7 +71,7 @@ impl<'a> BoardWidget<'a> { /// - When the board is larger it **scrolls** to follow the player: `pad` is /// 0, `off` centers on the player clamped into `[0, board_len - view]`, and /// exactly `view` cells are shown — never any empty space past the edge. - fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) { + pub fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) { if board_len <= view { let pad = (view - board_len) / 2; (0, pad as u16, board_len) @@ -112,3 +113,124 @@ impl Widget for BoardWidget<'_> { } } } + +/// Converts a board cell coordinate to terminal screen coordinates within `area`, +/// accounting for centering/scrolling. Returns `None` if the cell is off-screen. +pub fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { + let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); + let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + if bx < off_x || bx >= off_x + cols { return None; } + if by < off_y || by >= off_y + rows { return None; } + let sx = area.x + pad_x + (bx - off_x) as u16; + let sy = area.y + pad_y + (by - off_y) as u16; + Some((sx, sy)) +} + +/// Draws speech bubbles for all active [`SpeechBubble`]s over the board area. +/// +/// Each bubble appears as a box-drawing frame with a tail pointing down at the +/// speaker's cell. Bubbles that would collide (same horizontal range, adjacent +/// vertically) are shifted upward one at a time until there is no overlap. +pub fn draw_speech_bubbles(buf: &mut Buffer, area: Rect, board: &Board, bubbles: &[SpeechBubble]) { + // Determine the screen position of each visible bubble's speaker. + let mut items: Vec<(u16, u16, &SpeechBubble)> = bubbles + .iter() + .filter_map(|b| { + let obj = board.objects.get(&b.object_id)?; + let (sx, sy) = board_screen_pos(area, board, obj.x, obj.y)?; + Some((sx, sy, b)) + }) + .collect(); + + // Process bubbles lowest on screen first so upward-shift doesn't interfere + // with already-placed higher bubbles. + items.sort_by_key(|&(_, sy, _)| std::cmp::Reverse(sy)); + + // Track the rects of already-placed bubbles to detect collisions. + let mut placed: Vec = Vec::new(); + + let bubble_style = Style::default() + .fg(Color::White) + .bg(Color::Rgb(20, 20, 40)); + let border_style = Style::default() + .fg(Color::Rgb(160, 160, 220)) + .bg(Color::Rgb(20, 20, 40)); + + for (obj_sx, obj_sy, bubble) in items { + let text_w = bubble.text.chars().count() as u16; + // Box: ╭ space text space ╮ → width = text + 4 + let box_w = text_w + 4; + // 4 rows: top border, text, bottom border, tail + let box_h: u16 = 4; + + // Center the box over the object; clamp to area bounds. + let raw_left = obj_sx.saturating_sub(box_w / 2); + let left = raw_left.min(area.right().saturating_sub(box_w)); + let left = left.max(area.left()); + + // Desired top: 4 rows above the object (rows: top, text, bottom, tail → speaker). + let desired_top = obj_sy.saturating_sub(box_h); + + // Shift upward until no overlap with already-placed bubbles. + let mut top = desired_top; + let mut iterations = 0u16; + 'place: loop { + if iterations > 20 || top < area.top() { break; } + let candidate = Rect { x: left, y: top, width: box_w, height: box_h }; + for p in &placed { + if rects_overlap(candidate, *p) { + top = top.saturating_sub(1); + iterations += 1; + continue 'place; + } + } + break; + } + if top < area.top() { continue; } // no room; skip this bubble + + placed.push(Rect { x: left, y: top, width: box_w, height: box_h }); + + // Row 0: ╭──...──╮ + write_cell(buf, left, top, '╭', border_style); + for cx in (left + 1)..(left + box_w - 1) { + write_cell(buf, cx, top, '─', border_style); + } + write_cell(buf, left + box_w - 1, top, '╮', border_style); + + // Row 1: │ text │ (pad text to box_w - 2 inner width) + let inner_w = (box_w - 2) as usize; + let padded: String = format!(" {: bool { + a.x < b.x + b.width + && b.x < a.x + a.width + && a.y < b.y + b.height + && b.y < a.y + a.height +} + +/// Writes a single styled character into the buffer at `(x, y)`. +fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_char(ch).set_style(style); + } +} diff --git a/maps/start.toml b/maps/start.toml index bc61a26..d7f07d1 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -107,6 +107,7 @@ greeter = """ fn init() { log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`); set_tile(2); // change my glyph to ☻ — proves the write path + say("Hello there, traveller!"); } """ @@ -123,5 +124,6 @@ fn tick(dt) { // or -1 for the player. fn bump(id) { log(`mover bumped by ${id}`); + say("Ow!"); } """ \ No newline at end of file