speech bubbles and delays
This commit is contained in:
@@ -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.
|
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
|
||||||
|
|
||||||
**`kiln-core/src/game.rs`** — game-loop logic only:
|
**`kiln-core/src/game.rs`** — game-loop logic only:
|
||||||
- `GameState` — holds `board: Rc<RefCell<Board>>`, `log: Vec<LogLine>`, and `scripts: ScriptHost`. 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 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<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`.
|
||||||
|
|
||||||
**`kiln-core/src/floor.rs`** — the visual floor layer:
|
**`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.
|
- 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<RefCell<Board>>)` (registers the API, compiles scripts, reports compile/unknown-script failures onto the error sink; runs nothing).
|
- `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.
|
- 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`.
|
- **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)` 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<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)` 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()`).
|
||||||
- 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`).
|
- 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.
|
- **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.
|
- `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:
|
**`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.
|
- `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:
|
**`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.
|
- `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,
|
# optional, fires when something steps into this object; id = bumper's object id,
|
||||||
# or -1 for the player.
|
# 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.
|
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
|
### Key design decisions
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::font::FontSpec;
|
|||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::script::Direction;
|
use crate::utils::Direction;
|
||||||
use crate::utils::{ObjectId, Player, PortalDef, Solid};
|
use crate::utils::{ObjectId, Player, PortalDef, Solid};
|
||||||
|
|
||||||
/// The complete state of one game board (a single room or screen).
|
/// 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::archetype::Archetype;
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::script::Direction;
|
use crate::utils::Direction;
|
||||||
use crate::utils::{ObjectId, Player, Solid};
|
use crate::utils::{ObjectId, Player, Solid};
|
||||||
use super::Board;
|
use super::Board;
|
||||||
|
|
||||||
|
|||||||
+37
-3
@@ -1,10 +1,23 @@
|
|||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use crate::script::{Action, Direction, ScriptHost};
|
use crate::script::ScriptHost;
|
||||||
use std::cell::{Ref, RefCell, RefMut};
|
use std::cell::{Ref, RefCell, RefMut};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use crate::board::Board;
|
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.
|
/// Holds the active game world and provides game-logic operations.
|
||||||
///
|
///
|
||||||
@@ -22,6 +35,8 @@ pub struct GameState {
|
|||||||
pub log: Vec<LogLine>,
|
pub log: Vec<LogLine>,
|
||||||
/// The Rhai scripting runtime driving this board's scripted objects.
|
/// The Rhai scripting runtime driving this board's scripted objects.
|
||||||
scripts: ScriptHost,
|
scripts: ScriptHost,
|
||||||
|
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
|
||||||
|
pub speech_bubbles: Vec<SpeechBubble>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
@@ -37,6 +52,7 @@ impl GameState {
|
|||||||
board,
|
board,
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
scripts,
|
scripts,
|
||||||
|
speech_bubbles: Vec::new(),
|
||||||
};
|
};
|
||||||
// Surface any compile-time script errors collected during ScriptHost::new.
|
// Surface any compile-time script errors collected during ScriptHost::new.
|
||||||
state.drain_errors();
|
state.drain_errors();
|
||||||
@@ -73,8 +89,10 @@ impl GameState {
|
|||||||
/// then resolves the actions that were promoted onto the board queue.
|
/// then resolves the actions that were promoted onto the board queue.
|
||||||
pub fn tick(&mut self, dt: Duration) {
|
pub fn tick(&mut self, dt: Duration) {
|
||||||
let secs = dt.as_secs_f64();
|
let secs = dt.as_secs_f64();
|
||||||
self.scripts.advance_timers(secs);
|
|
||||||
self.scripts.run_tick(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();
|
self.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +116,7 @@ impl GameState {
|
|||||||
// below also borrows `self`.
|
// below also borrows `self`.
|
||||||
let mut logs: Vec<LogLine> = Vec::new();
|
let mut logs: Vec<LogLine> = Vec::new();
|
||||||
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
|
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
|
||||||
|
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut board = self.board_mut();
|
let mut board = self.board_mut();
|
||||||
for ba in actions {
|
for ba in actions {
|
||||||
@@ -118,10 +137,25 @@ impl GameState {
|
|||||||
if present { obj.tags.insert(tag); } else { obj.tags.remove(&tag); }
|
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);
|
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 {
|
for (bumped, bumper) in bumps {
|
||||||
self.scripts.run_bump(bumped, bumper);
|
self.scripts.run_bump(bumped, bumper);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod object_def;
|
|||||||
mod board;
|
mod board;
|
||||||
|
|
||||||
pub use board::Board;
|
pub use board::Board;
|
||||||
|
pub use utils::Direction;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
+123
-170
@@ -12,22 +12,28 @@
|
|||||||
//! ## Actions, queues, and rate limiting
|
//! ## Actions, queues, and rate limiting
|
||||||
//!
|
//!
|
||||||
//! Scripts don't mutate the world directly. Each write host function (`move`,
|
//! Scripts don't mutate the world directly. Each write host function (`move`,
|
||||||
//! `set_tile`, `log`) appends an [`Action`] to the issuing object's **output
|
//! `set_tile`, `log`, `say`) appends an [`Action`] to the issuing object's **output
|
||||||
//! queue** (a per-object FIFO). Actions leave that queue one batch at a time via
|
//! queue** (a per-object FIFO).
|
||||||
//! [`ScriptHost::pump`], which promotes them onto the shared **board queue**:
|
|
||||||
//!
|
//!
|
||||||
//! - A [`pump`](ScriptHost::pump) pulls the leading run of zero-cost actions plus at
|
//! Timing is encoded **inline** in the queue as [`Action::Delay`] entries rather than
|
||||||
//! most one action with a time cost (currently only [`Action::Move`], 250 ms).
|
//! as an external per-object timer. `move(dir)` appends both a `Move` and a
|
||||||
//! - Pulling a timed action arms the object's **ready timer**; while it is running no
|
//! `Delay(MOVE_COST)` so the object pauses before its next action. Adjacent delays are
|
||||||
//! further actions are pulled (this is what caps an object's speed). [`tick`] ticks
|
//! merged at push time so the queue never contains two consecutive `Delay`s.
|
||||||
//! the timer down via [`advance_timers`](ScriptHost::advance_timers).
|
//!
|
||||||
|
//! 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
|
//! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies
|
||||||
//! each action *after* the script batch, preserving the borrow discipline (scripts only
|
//! 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
|
//! 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`]).
|
//! per-call **tag** (set to the object index in [`ScriptHost::run`]).
|
||||||
//!
|
//!
|
||||||
//! [`tick`]: ScriptHost::run_tick
|
|
||||||
//! [`take_board_queue`]: ScriptHost::take_board_queue
|
//! [`take_board_queue`]: ScriptHost::take_board_queue
|
||||||
//! [`GameState`]: crate::game::GameState
|
//! [`GameState`]: crate::game::GameState
|
||||||
|
|
||||||
@@ -37,37 +43,7 @@ use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext,
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use crate::utils::ObjectId;
|
use crate::utils::{Action, Direction, ObjectId, MOVE_COST};
|
||||||
|
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An action promoted from an object's output queue onto the board queue, tagged
|
/// An action promoted from an object's output queue onto the board queue, tagged
|
||||||
/// with the object that issued it.
|
/// with the object that issued it.
|
||||||
@@ -78,27 +54,6 @@ pub struct BoardAction {
|
|||||||
pub action: Action,
|
pub action: Action,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cardinal movement direction.
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub enum Direction {
|
|
||||||
North,
|
|
||||||
South,
|
|
||||||
East,
|
|
||||||
West,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Direction> 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
|
/// 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
|
/// reference to the [`Board`]; only getters are registered, so it is
|
||||||
/// read-only by construction.
|
/// read-only by construction.
|
||||||
@@ -133,7 +88,7 @@ struct CompiledScript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The runtime state of one scripted object: which script it runs, its persistent
|
/// 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 {
|
struct ObjectRuntime {
|
||||||
/// The stable [`ObjectId`] of this object; passed to scripts as the per-call
|
/// The stable [`ObjectId`] of this object; passed to scripts as the per-call
|
||||||
/// tag so host functions know which object issued an action.
|
/// 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.
|
/// can hold object-local state in the future). Persists across calls.
|
||||||
scope: Scope<'static>,
|
scope: Scope<'static>,
|
||||||
/// This object's output queue (shared with its scope's `Queue` object).
|
/// 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,
|
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.
|
/// Owns the Rhai engine and per-object script state for a board.
|
||||||
@@ -243,7 +196,6 @@ impl ScriptHost {
|
|||||||
script_name: name.clone(),
|
script_name: name.clone(),
|
||||||
scope: new_object_scope(board_cell, &queue, id),
|
scope: new_object_scope(board_cell, &queue, id),
|
||||||
output_queue: queue,
|
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
|
/// Calls `init()` on every scripted object that defines it and drains each queue
|
||||||
/// queue afterward.
|
/// with `dt = 0` (no time advance — just flush any zero-cost front actions).
|
||||||
pub fn run_init(&mut self) {
|
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
|
/// Calls `tick(dt)` on every scripted object that defines it, then drains each
|
||||||
/// seconds, then pumps each object's queue.
|
/// queue, advancing inline [`Action::Delay`]s by `dt`.
|
||||||
pub fn run_tick(&mut self, dt: f64) {
|
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.
|
/// 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.
|
/// `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
|
/// After the hook, drains the object's queue with `dt = 0` so any zero-cost actions
|
||||||
/// a bump can't cascade within the same resolution pass.
|
/// the hook enqueued (e.g. `say(); now()`) take effect in the same frame.
|
||||||
pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) {
|
pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) {
|
||||||
let Self {
|
// Find the index first so we can drain after the split-borrow block.
|
||||||
engine,
|
let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else {
|
||||||
scripts,
|
|
||||||
objects,
|
|
||||||
errors,
|
|
||||||
..
|
|
||||||
} = self;
|
|
||||||
let Some(obj) = objects.iter_mut().find(|o| o.object_id == object_id) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(compiled) = scripts.get(&obj.script_name) else {
|
{
|
||||||
return;
|
let Self { engine, scripts, objects, errors, .. } = self;
|
||||||
};
|
let obj = &mut objects[i];
|
||||||
if !compiled.has_bump {
|
let Some(compiled) = scripts.get(&obj.script_name) else { return; };
|
||||||
return;
|
if !compiled.has_bump { return; }
|
||||||
}
|
let options = CallFnOptions::default().with_tag(object_id as i64);
|
||||||
let options = CallFnOptions::default().with_tag(object_id as i64);
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||||
if let Err(err) = engine.call_fn_with_options::<()>(
|
options, &mut obj.scope, &compiled.ast, "bump", (bumper,),
|
||||||
options,
|
) {
|
||||||
&mut obj.scope,
|
errors.borrow_mut().push(LogLine::raw(format!(
|
||||||
&compiled.ast,
|
"script '{}' bump error: {err}", obj.script_name
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
// 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.
|
/// Removes and returns the actions promoted onto the board queue.
|
||||||
@@ -325,94 +260,71 @@ impl ScriptHost {
|
|||||||
std::mem::take(&mut self.errors.borrow_mut())
|
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
|
/// Non-delay actions are promoted immediately. When a `Delay` is encountered it is
|
||||||
/// action for this object is already in the board queue, nothing is pulled (a
|
/// decremented by the remaining `dt` (which is then consumed so subsequent delays
|
||||||
/// second timed action would race it). Otherwise the leading run of zero-cost
|
/// in the same call are not also decremented). An expired delay is removed and
|
||||||
/// actions is promoted, followed by at most one timed action (which arms the timer
|
/// draining continues; a live delay stops the loop for this call.
|
||||||
/// and ends the pump). All queued free actions therefore land in the board queue
|
fn drain(&mut self, i: usize, mut dt: f64) {
|
||||||
/// in the same pump call as the next timed action — they apply atomically in the
|
|
||||||
/// same `resolve()`.
|
|
||||||
fn pump(&mut self, i: usize) {
|
|
||||||
let oid = self.objects[i].object_id;
|
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 {
|
loop {
|
||||||
let queue = self.objects[i].output_queue.clone();
|
let queue = self.objects[i].output_queue.clone();
|
||||||
let cost = match queue.borrow().front() {
|
// Peek at the front to decide what to do without holding the borrow.
|
||||||
Some(action) => action.time_cost(),
|
let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_)));
|
||||||
None => break, // queue drained
|
if is_delay {
|
||||||
};
|
// Decrement the delay; pop it only if it expires.
|
||||||
let action = queue.borrow_mut().pop_front().expect("front checked above");
|
let expired = {
|
||||||
if cost > 0.0 {
|
let mut q = queue.borrow_mut();
|
||||||
self.objects[i].ready_timer = cost;
|
let Some(Action::Delay(r)) = q.front_mut() else { break };
|
||||||
}
|
*r -= dt;
|
||||||
self.board_queue.borrow_mut().push(BoardAction {
|
dt = 0.0; // consume so later delays in this call aren't also decremented
|
||||||
source: oid,
|
*r <= 0.0
|
||||||
action,
|
};
|
||||||
});
|
if expired {
|
||||||
// A timed action ends the pump; zero-cost ones keep draining.
|
queue.borrow_mut().pop_front();
|
||||||
if cost > 0.0 {
|
// Continue — drain actions after the expired delay.
|
||||||
break;
|
} 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
|
/// 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
|
/// defines it (tagging the call with the object's id so host functions know the
|
||||||
/// source), then pump that object so backlogged actions drain and later objects'
|
/// source), then drain that object's queue so actions reach the board queue before
|
||||||
/// `blocked` checks can see earlier movers. Runtime errors are captured rather than
|
/// the next object's `blocked` check runs.
|
||||||
/// aborting the batch.
|
|
||||||
fn run<A: FuncArgs + Copy>(
|
fn run<A: FuncArgs + Copy>(
|
||||||
&mut self,
|
&mut self,
|
||||||
hook: &str,
|
hook: &str,
|
||||||
defined: fn(&CompiledScript) -> bool,
|
defined: fn(&CompiledScript) -> bool,
|
||||||
args: A,
|
args: A,
|
||||||
|
drain_dt: f64,
|
||||||
) {
|
) {
|
||||||
for i in 0..self.objects.len() {
|
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 {
|
let Self { engine, scripts, objects, errors, .. } = self;
|
||||||
engine,
|
|
||||||
scripts,
|
|
||||||
objects,
|
|
||||||
errors,
|
|
||||||
..
|
|
||||||
} = self;
|
|
||||||
let obj = &mut objects[i];
|
let obj = &mut objects[i];
|
||||||
if let Some(compiled) = scripts.get(&obj.script_name)
|
if let Some(compiled) = scripts.get(&obj.script_name)
|
||||||
&& defined(compiled)
|
&& defined(compiled)
|
||||||
{
|
{
|
||||||
// The tag carries this object's id to the host functions.
|
|
||||||
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
|
||||||
if let Err(err) = engine.call_fn_with_options::<()>(
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||||
options,
|
options, &mut obj.scope, &compiled.ast, hook, args,
|
||||||
&mut obj.scope,
|
|
||||||
&compiled.ast,
|
|
||||||
hook,
|
|
||||||
args,
|
|
||||||
) {
|
) {
|
||||||
errors.borrow_mut().push(LogLine::raw(format!(
|
errors.borrow_mut().push(LogLine::raw(format!(
|
||||||
"script '{}' {hook} error: {err}",
|
"script '{}' {hook} error: {err}", obj.script_name
|
||||||
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
|
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`/
|
||||||
/// functions, each appending an [`Action`] to the issuing object's output queue.
|
/// `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) {
|
fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
||||||
engine.register_type_with_name::<Direction>("Direction");
|
engine.register_type_with_name::<Direction>("Direction");
|
||||||
|
|
||||||
|
// move(dir): enqueue a Move followed by a rate-limiting Delay.
|
||||||
let q = queues.clone();
|
let q = queues.clone();
|
||||||
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
|
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();
|
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.
|
// 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.
|
// Use MY_ID as target_id to mutate the calling object's own tags.
|
||||||
let q = queues.clone();
|
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());
|
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`.
|
/// Appends `action` to the output queue of the object identified by `source`.
|
||||||
fn emit(queues: &QueueMap, source: ObjectId, action: Action) {
|
fn emit(queues: &QueueMap, source: ObjectId, action: Action) {
|
||||||
if let Some(queue) = queues.borrow().get(&source) {
|
if let Some(queue) = queues.borrow().get(&source) {
|
||||||
|
|||||||
@@ -128,9 +128,9 @@ fn move_cost_rate_limits_repeated_moves() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocked_move_still_costs_cooldown() {
|
fn inline_delay_paces_subsequent_moves() {
|
||||||
// A move into a wall fails but still charges the 250 ms cooldown, so a second
|
// move() always appends a Delay(250 ms) to the queue, so a second queued move
|
||||||
// queued move (into open space) is delayed just as a successful move would be.
|
// is held back by that delay even if the first move was blocked by a wall.
|
||||||
let mut board = open_board(
|
let mut board = open_board(
|
||||||
3,
|
3,
|
||||||
3,
|
3,
|
||||||
@@ -144,7 +144,7 @@ fn blocked_move_still_costs_cooldown() {
|
|||||||
// First (eastward) move is blocked by the wall: object hasn't moved.
|
// 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));
|
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));
|
||||||
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));
|
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::board::tests::{crate_at, open_board, stamp, wall_at};
|
|||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
use crate::object_def::ObjectDef;
|
use crate::object_def::ObjectDef;
|
||||||
use crate::script::Direction;
|
use crate::utils::Direction;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pushing_a_crate_reveals_the_floor_underneath() {
|
fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use crate::board::tests::open_board;
|
use crate::board::tests::open_board;
|
||||||
use crate::game::GameState;
|
use crate::game::GameState;
|
||||||
use crate::script::Direction;
|
use crate::utils::Direction;
|
||||||
use super::{board_with_object, scripted_object, log_texts};
|
use super::{board_with_object, scripted_object, log_texts};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+48
-2
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::archetype::Archetype;
|
use crate::archetype::Archetype;
|
||||||
use crate::script::Direction;
|
use crate::log::LogLine;
|
||||||
|
|
||||||
/// Which directions a solid may be pushed in.
|
/// Which directions a solid may be pushed in.
|
||||||
///
|
///
|
||||||
@@ -114,7 +114,7 @@ pub struct Player {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::script::Direction;
|
use crate::utils::Direction;
|
||||||
use super::Pushable;
|
use super::Pushable;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -132,4 +132,50 @@ mod tests {
|
|||||||
assert!(!Pushable::Vertical.allows(Direction::East));
|
assert!(!Pushable::Vertical.allows(Direction::East));
|
||||||
assert!(!Pushable::Vertical.allows(Direction::West));
|
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<Direction> 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ mod term;
|
|||||||
use kiln_core::game::GameState;
|
use kiln_core::game::GameState;
|
||||||
use kiln_core::log::LogLine;
|
use kiln_core::log::LogLine;
|
||||||
use kiln_core::map_file;
|
use kiln_core::map_file;
|
||||||
use kiln_core::script::Direction;
|
use kiln_core::Direction;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::crossterm::event::{
|
use ratatui::crossterm::event::{
|
||||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
|
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);
|
let inner = block.inner(board_area);
|
||||||
frame.render_widget(block, board_area);
|
frame.render_widget(block, board_area);
|
||||||
frame.render_widget(BoardWidget::new(board), inner);
|
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.
|
// Newest message on top: reverse the log into ratatui lines.
|
||||||
let log_block = Block::bordered()
|
let log_block = Block::bordered()
|
||||||
@@ -236,6 +237,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
|
|||||||
let inner = block.inner(frame.area());
|
let inner = block.inner(frame.area());
|
||||||
frame.render_widget(block, frame.area());
|
frame.render_widget(block, frame.area());
|
||||||
frame.render_widget(BoardWidget::new(board), inner);
|
frame.render_widget(BoardWidget::new(board), inner);
|
||||||
|
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
-1
@@ -8,6 +8,7 @@
|
|||||||
use crate::cp437::tile_to_char;
|
use crate::cp437::tile_to_char;
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
use kiln_core::Board;
|
use kiln_core::Board;
|
||||||
|
use kiln_core::game::SpeechBubble;
|
||||||
use kiln_core::log::LogLine;
|
use kiln_core::log::LogLine;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
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
|
/// - 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
|
/// 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.
|
/// 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 {
|
if board_len <= view {
|
||||||
let pad = (view - board_len) / 2;
|
let pad = (view - board_len) / 2;
|
||||||
(0, pad as u16, board_len)
|
(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<Rect> = 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!(" {:<width$} ", bubble.text, width = inner_w - 2);
|
||||||
|
write_cell(buf, left, top + 1, '│', border_style);
|
||||||
|
for (i, ch) in padded.chars().enumerate() {
|
||||||
|
write_cell(buf, left + 1 + i as u16, top + 1, ch, bubble_style);
|
||||||
|
}
|
||||||
|
write_cell(buf, left + box_w - 1, top + 1, '│', border_style);
|
||||||
|
|
||||||
|
// Row 2: ╰──...─╮─...──╯ with the tail join at obj_sx (clamped inside the box)
|
||||||
|
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
|
||||||
|
write_cell(buf, left, top + 2, '╰', border_style);
|
||||||
|
for cx in (left + 1)..(left + box_w - 1) {
|
||||||
|
let ch = if cx == tail_col { '╮' } else { '─' };
|
||||||
|
write_cell(buf, cx, top + 2, ch, border_style);
|
||||||
|
}
|
||||||
|
write_cell(buf, left + box_w - 1, top + 2, '╯', border_style);
|
||||||
|
|
||||||
|
// Row 3: vertical tail descending to the object
|
||||||
|
write_cell(buf, tail_col, top + 3, '│', border_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if two `Rect`s share at least one cell.
|
||||||
|
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||||
|
a.x < b.x + b.width
|
||||||
|
&& b.x < a.x + a.width
|
||||||
|
&& a.y < b.y + b.height
|
||||||
|
&& b.y < a.y + a.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ greeter = """
|
|||||||
fn init() {
|
fn init() {
|
||||||
log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`);
|
log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`);
|
||||||
set_tile(2); // change my glyph to ☻ — proves the write path
|
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.
|
// or -1 for the player.
|
||||||
fn bump(id) {
|
fn bump(id) {
|
||||||
log(`mover bumped by ${id}`);
|
log(`mover bumped by ${id}`);
|
||||||
|
say("Ow!");
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
Reference in New Issue
Block a user