Added scroll()

This commit is contained in:
2026-06-10 01:42:33 -05:00
parent c2168b992d
commit f73c02437d
7 changed files with 375 additions and 43 deletions
+21
View File
@@ -29,6 +29,19 @@ use rhai::{Dynamic, ImmutableString, Module};
/// How long a move occupies an object before it can act again, in seconds.
pub(crate) const MOVE_COST: f64 = 0.25;
/// One line of content in a [`Action::Scroll`] overlay.
///
/// Each element of the array passed to the `scroll()` Rhai function becomes a
/// `ScrollLine`: a plain string becomes [`Text`](ScrollLine::Text); a two-element
/// array `[choice, display]` becomes a [`Choice`](ScrollLine::Choice).
pub enum ScrollLine {
/// Plain text, word-wrapped to the scroll window width.
Text(String),
/// A selectable option: `choice` is the event name sent back to the source
/// object via `send`, and `display` is the label shown to the player.
Choice { choice: String, display: String },
}
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue.
///
@@ -52,6 +65,9 @@ pub(crate) enum Action {
SetColor { fg: Option<Rgba8>, bg: Option<Rgba8> },
/// Call `fn_name` on `target` object, optionally passing `arg`.
Send { target: ObjectId, fn_name: String, arg: Option<ScriptArg> },
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>),
}
// ── Direction helpers ─────────────────────────────────────────────────────────
@@ -128,6 +144,10 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
m.insert("arg".into(), dyn_arg);
}
}
// Scroll lines are not round-trippable via the queue map API; emit type only.
Action::Scroll(_) => {
ins_str(&mut m, "type", "Scroll");
}
}
m
}
@@ -202,6 +222,7 @@ impl TryFrom<rhai::Map> for Action {
};
Ok(Action::Send { target: target as ObjectId, fn_name, arg })
}
"Scroll" => Err("Scroll actions cannot be constructed from maps".to_string()),
other => Err(format!("unknown action type: '{other}'")),
}
}
+42
View File
@@ -10,6 +10,21 @@ use crate::utils::{Direction, ObjectId, ScriptArg, Solid};
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly.
pub use crate::action::ScrollLine;
/// An active scroll overlay opened by a scripted object via `scroll()`.
///
/// While a `Scroll` is present on [`GameState`], the front-end should pause ticks
/// and display the overlay. Closed via [`GameState::close_scroll`].
pub struct Scroll {
/// The object whose `scroll()` call opened this overlay.
pub source: ObjectId,
/// The lines of content to display.
pub lines: Vec<ScrollLine>,
}
/// An active speech bubble emitted by a scripted object via `say()`.
pub struct SpeechBubble {
/// The object whose `say()` call created this bubble.
@@ -38,6 +53,9 @@ pub struct GameState {
scripts: ScriptHost,
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
pub speech_bubbles: Vec<SpeechBubble>,
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
/// visible; the front-end pauses ticks and closes it via [`close_scroll`](GameState::close_scroll).
pub active_scroll: Option<Scroll>,
}
impl GameState {
@@ -54,6 +72,7 @@ impl GameState {
log: Vec::new(),
scripts,
speech_bubbles: Vec::new(),
active_scroll: None,
};
// Surface any compile-time script errors collected during ScriptHost::new.
state.drain_errors();
@@ -120,6 +139,8 @@ impl GameState {
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
{
let mut board = self.board_mut();
for ba in actions {
@@ -159,6 +180,10 @@ impl GameState {
Action::Send { target, fn_name, arg } => {
sends.push((target, fn_name, arg));
}
// Later scrolls overwrite earlier ones from the same tick.
Action::Scroll(lines) => {
new_scroll = Some(Scroll { source: ba.source, lines });
}
}
}
}
@@ -168,6 +193,9 @@ impl GameState {
self.speech_bubbles.retain(|b| b.object_id != bubble.object_id);
self.speech_bubbles.push(bubble);
}
if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll);
}
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
}
@@ -177,6 +205,20 @@ impl GameState {
self.drain_errors();
}
/// Closes the active scroll overlay, optionally dispatching a named choice
/// back to the object that opened it via `send`.
///
/// Call from the front-end when the player dismisses the scroll or selects a
/// choice. If `choice` is `Some`, the source object receives a `send` call with
/// that string as the function name (e.g. `"eat"` triggers `fn eat()`).
pub fn close_scroll(&mut self, choice: Option<&str>) {
let source = self.active_scroll.take().map(|s| s.source);
if let (Some(src), Some(ch)) = (source, choice) {
self.scripts.run_send(src, ch, None);
self.drain_errors();
}
}
/// Attempts to move the player one cell in `dir`.
///
/// The move is ignored if the target cell is out of bounds, or it is neither
+23 -2
View File
@@ -45,12 +45,12 @@
//!
//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`, `Queue.push(map)`
use crate::action::{action_to_map, rhai_action_module, Action, MOVE_COST};
use crate::action::{action_to_map, rhai_action_module, Action, ScrollLine, MOVE_COST};
use crate::board::Board;
use crate::log::LogLine;
use crate::map_file::{color_to_hex, parse_color};
use crate::utils::{Direction, ObjectId, ScriptArg};
use rhai::{CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
use rhai::{Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
@@ -613,6 +613,27 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
emit(&q, source_of(&ctx), Action::Say(msg.to_string()));
});
// scroll(lines): open a full-screen scrollable overlay. Each element of `lines`
// is either a plain string (text) or a 2-element array [choice_key, display_text].
let q = queues.clone();
engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| {
let lines = arr.into_iter().filter_map(|item| {
if let Ok(s) = item.clone().into_string() {
Some(ScrollLine::Text(s))
} else {
let pair: Vec<Dynamic> = item.into_array().ok()?;
if pair.len() == 2 {
let choice = pair[0].clone().into_string().ok()?;
let display = pair[1].clone().into_string().ok()?;
Some(ScrollLine::Choice { choice, display })
} else {
None
}
}
}).collect();
emit(&q, source_of(&ctx), Action::Scroll(lines));
});
let q = queues.clone();
engine.register_fn(
"set_tag",