speech bubbles and delays

This commit is contained in:
2026-06-08 22:15:44 -05:00
parent f5ed8f2edf
commit e5c406e42d
12 changed files with 354 additions and 190 deletions
+2 -2
View File
@@ -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;
+37 -3
View File
@@ -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<LogLine>,
/// 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<SpeechBubble>,
}
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<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = 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);
}
+1
View File
@@ -16,6 +16,7 @@ mod object_def;
mod board;
pub use board::Board;
pub use utils::Direction;
#[cfg(test)]
mod tests;
+123 -170
View File
@@ -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<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
/// 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<A: FuncArgs + Copy>(
&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>("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) {
+4 -4
View File
@@ -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));
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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]
+48 -2
View File
@@ -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<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),
}
}
}