speech bubbles and delays
This commit is contained in:
+123
-170
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user