Scripts currently have two ways to give text to the player: log() (status messages appended to the log panel) and say() (a short speech bubble above the object, auto-expires after 3 s). Neither is suitable for longer messages or interactive menus. The new scroll(lines[]) action opens a full-screen overlay with scrollable text and optional menu choices. Choosing an option sends a named message back to the object that opened the scroll (via the existing Action::Send / run_send pipeline), letting scripts branch on player decisions.
Add two types:
pub enum ScrollLine {
Text(String),
Choice { choice: String, display: String },
}
// New variant on the existing Action enum:
Action::Scroll(Vec<ScrollLine>),Action::Scroll has no time_cost (same as Say — zero cost, fires immediately from the queue).
Parsing from Rhai happens in script.rs when the host function converts the Rhai Array arg — not here.
pub struct ActiveScroll {
pub source: ObjectId,
pub lines: Vec<ScrollLine>,
}Add to GameState:
pub active_scroll: Option<ActiveScroll>,In resolve(), handle the new action variant:
Action::Scroll(lines) => {
self.active_scroll = Some(ActiveScroll { source: ba.source, lines });
}Add a method for the frontend to close the scroll and optionally dispatch a choice:
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();
}
}(run_send is already pub(crate) — make it pub or add this wrapper in game.rs which is within kiln-core.)
Register alongside the existing say / log fns:
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 if let Ok(pair) = item.into_array() {
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 }
} else { None }
}).collect();
emit(&q, source_of(&ctx), Action::Scroll(lines));
});Add to Ui:
scroll_offset: u16,
scroll_anim: Option<ScrollAnimState>,where:
enum ScrollAnimState {
Opening(f32), // 0.0 → 1.0 over 0.5 s
Open,
Closing { progress: f32, choice: Option<String> }, // 1.0 → 0.0, then dispatch
}Animation advance (in the per-frame tick block, before/instead of game.tick()):
let dt_s = dt.as_secs_f32();
match &mut ui.scroll_anim {
Some(ScrollAnimState::Opening(p)) => {
*p += dt_s / 0.5;
if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); }
}
Some(ScrollAnimState::Closing { progress, choice }) => {
*progress -= dt_s / 0.5;
if *progress <= 0.0 {
let ch = choice.take();
game.close_scroll(ch.as_deref());
ui.scroll_anim = None;
ui.scroll_offset = 0;
}
}
_ => {}
}Detect scroll opening: After game.tick(dt), if game.active_scroll.is_some() and ui.scroll_anim.is_none(), set ui.scroll_anim = Some(ScrollAnimState::Opening(0.0)).
Pause ticks while scroll is active: wrap the game.tick(dt) call:
if game.active_scroll.is_none() { game.tick(dt); }Input interception — add an early branch in the key-event handler:
if game.active_scroll.is_some() && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing{..})) {
match key.code {
KeyCode::Esc => begin_close(&mut ui, None),
KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
KeyCode::Down => ui.scroll_offset += 1,
KeyCode::Char(c) => {
// check if `c` matches a choice letter (assigned in render order a/b/c…)
if let Some(ch) = resolve_choice(game.active_scroll.as_ref(), c) {
begin_close(&mut ui, Some(ch));
}
}
_ => {}
}
return Ok(false); // consume event, no player movement
}begin_close sets ui.scroll_anim = Some(ScrollAnimState::Closing { progress: 1.0, choice }).
Mouse wheel up/down also routes to scroll_offset adjustment when scroll is active.
New function called from draw() after all board/log rendering:
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &ActiveScroll,
offset: u16, anim: f32)Walk every cell in area, set fg = Rgb(60,60,60), bg = Color::Black. Do this before drawing the overlay so the scroll sits on top.
content_width: min(60, area.width * 3/4)text_lines: eachScrollLine::Textword-wrapped tocontent_width - 4, counted;Choicelines count as 1 eachcontent_height: min(text_lines + 2, area.height * 4/5) (+ 2 for borders)- Animated:
actual_h = (content_height as f32 * anim).round() as u16(grow from 0 → full height)
Center the overlay rect (x = (area.width - content_width)/2, y = (area.height - actual_h)/2). Use Block::bordered() rendered into the buffer via ratatui's render-to-buffer path, then write text lines cell-by-cell (following the draw_speech_bubbles pattern for direct buffer writes). Style:
- Border:
Rgb(180, 160, 100)fg,Rgb(30, 25, 10)bg - Plain text: white fg, dark bg
- Choice lines:
[a]prefix in yellow, text inRgb(200, 200, 100), dark bg
Choice letters are assigned a…z in order of appearance among ScrollLine::Choice variants; this order must match the input resolver in main.rs.
cargo build— no errors across the workspace.cargo test— existing tests still pass.- Write/update
maps/start.tomlto include a test object with abumphandler that callsscroll(["Some text.", ["eat", "Eat the muffin"], ["ignore", "Ignore it"]]). Walk into the object; confirm:- Screen dims, scroll opens with animation.
- Up/Down arrows scroll content.
- Pressing
aorbcloses the scroll with animation and dispatchesfn eat()/fn ignore()on the object (log the choice in those handlers to verify). - Pressing Esc closes without dispatch.
- Ticks (object movement, bubble timers) resume after scroll closes.
cargo clippy— no warnings.