Files
kiln/kiln-core/src/game.rs
T
2026-06-21 01:32:47 -05:00

635 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::action::Action;
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::time::Duration;
/// 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 display
/// the overlay and pause ticks. When the player selects a choice (or dismisses),
/// the front-end sets [`choice`](Scroll::choice); [`GameState::tick`] calls
/// [`GameState::handle_scroll`] at the start of each tick to dispatch and clear it.
pub struct Scroll {
/// The object whose `scroll()` call opened this overlay.
pub source: ObjectId,
/// The lines of content to display.
pub lines: Vec<ScrollLine>,
/// The choice key the player selected, or `None` if dismissed without a choice.
/// Set by the front-end; consumed by [`GameState::handle_scroll`].
pub choice: Option<String>,
}
/// 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.
///
/// `GameState` is the boundary between the engine (rendering, input) and the
/// game data. It owns a [`World`] (all boards + world scripts) and tracks which
/// board is currently active. Front-ends reach the active board through
/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts
/// read the board and queue mutations via commands applied by `resolve` after
/// each script batch.
pub struct GameState {
/// All boards and world-level scripts.
world: World,
/// Key of the currently active board in [`world.boards`](World::boards).
current_board_name: String,
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
/// The Rhai scripting runtime for the active board's objects.
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 sets [`Scroll::choice`] before resuming.
pub active_scroll: Option<Scroll>,
/// Remaining seconds for the board-entry transition animation, set to `1.0`
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>,
}
impl GameState {
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
///
/// The starting board's objects are compiled and registered with the
/// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here;
/// call [`run_init`](GameState::run_init) once the game is ready to start.
/// Compile errors are surfaced into the log immediately.
pub fn from_world(world: World) -> Self {
let name = world.start.clone();
let host = ScriptHost::new(
world
.boards
.get(&name)
.expect("world::load guarantees start board exists"),
&world.scripts,
);
let mut state = Self {
world,
current_board_name: name,
log: Vec::new(),
scripts: host,
speech_bubbles: Vec::new(),
active_scroll: None,
board_transition: None,
};
state.drain_errors();
state
}
/// Creates a `GameState` from a single [`Board`] with no scripts.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn new(board: Board) -> Self {
Self::with_scripts(board, std::collections::HashMap::new())
}
/// Creates a `GameState` from a single [`Board`] and an explicit script pool.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn with_scripts(board: Board, scripts: std::collections::HashMap<String, String>) -> Self {
// Wrap the bare board in Rc<RefCell<Board>> to match World::boards storage.
let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board));
let world = World {
name: String::new(),
start: "board".to_string(),
scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
};
Self::from_world(world)
}
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
self.world.boards[&self.current_board_name].borrow()
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
self.world.boards[&self.current_board_name].borrow_mut()
}
/// The name (key) of the currently active board within the world.
pub fn current_board_name(&self) -> &str {
&self.current_board_name
}
/// Returns a clone of the `Rc` for the active board.
///
/// Lets a front-end hold a reference to the current board across a board
/// transition — the old board stays alive in `world.boards`, so the clone
/// keeps it reachable even after `current_board_name` changes.
pub fn board_rc(&self) -> std::rc::Rc<std::cell::RefCell<Board>> {
self.world.boards[&self.current_board_name].clone()
}
/// Appends a styled message to the log.
pub fn log(&mut self, line: LogLine) {
self.log.push(line);
}
/// Runs the `init()` hook of every scripted object, pumps their queues, and
/// resolves the resulting actions. Call once, after the whole map is loaded and
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
pub fn run_init(&mut self) {
self.scripts.run_init();
self.resolve();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
/// Called once per frame by the front-end's game loop. First processes any active
/// scroll (dispatching the player's choice if set), then drives object tick hooks
/// and resolves queued actions.
pub fn tick(&mut self, dt: Duration) {
// Process any pending scroll choice before running scripts; ticks are
// suppressed by the front-end while a scroll overlay is visible, so
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
let secs = dt.as_secs_f64();
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();
}
/// Drains errors collected by the script host into the game log.
fn drain_errors(&mut self) {
// TODO: errors are only logged for now. This is the place to halt execution /
// set an error state when a script faults.
let errors = self.scripts.take_errors();
self.log.extend(errors);
}
/// Resolves the board queue: applies each promoted action to the board, then fires
/// the `bump` and `send` hooks the moves triggered.
///
/// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the
/// hooks after the borrow drops.
fn resolve(&mut self) {
let actions = self.scripts.take_board_queue();
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
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 {
match ba.action {
Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
bumps.push((bumped, ba.source as i64));
}
}
Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.glyph.tile = tile;
}
}
Action::Log(line) => logs.push(line),
Action::SetTag {
target,
tag,
present,
} => {
if let Some(obj) = board.objects.get_mut(&target) {
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, duration) => new_bubbles.push(SpeechBubble {
object_id: ba.source,
text,
remaining: duration,
}),
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
}
// Collected and fired after the board borrow drops, like bumps.
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,
choice: None,
});
}
Action::Teleport { x, y } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
} else {
let (ux, uy) = (x as usize, y as usize);
let source_solid = board
.objects
.get(&ba.source)
.map(|o| o.solid)
.unwrap_or(false);
if source_solid && board.solid_at(ux, uy).is_some() {
logs.push(LogLine::error(format!(
"teleport({x},{y}): destination is solid"
)));
} else if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.x = ux;
obj.y = uy;
}
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if board.in_bounds((x, y)) {
board.push(x as usize, y as usize, dir);
} else {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
}
}
// apply_swap reads all sources then writes all destinations, so a
// whole batch of moves resolves simultaneously (cycles included).
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
}
}
}
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);
}
if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll);
}
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
}
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
}
self.drain_errors();
}
/// Consumes the active scroll, dispatching the player's choice (if any) back
/// to the source object via `send`.
///
/// Called automatically by [`tick`](GameState::tick) as its first step. The
/// front-end sets [`Scroll::choice`] before resuming ticks; if no choice was
/// made (player dismissed), `choice` stays `None` and the scroll is cleared
/// without dispatching.
pub fn handle_scroll(&mut self) {
if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice
{
self.scripts.run_send(scroll.source, &choice, None);
self.drain_errors();
}
}
/// Switches the active board, placing the player at the named arrival portal,
/// rebuilding the script host, and running `init()` hooks on the new board's objects.
///
/// Called automatically by [`try_move`](GameState::try_move) when the player
/// steps onto a portal. Front-ends may check [`board_transition`](GameState::board_transition)
/// to play a visual effect during the switch.
pub fn enter_board(&mut self, target_map: &str, target_entry: &str) {
if !self.world.boards.contains_key(target_map) {
self.log.push(LogLine::error(format!(
"portal target board {target_map:?} not found"
)));
return;
}
// Find the named arrival portal on the target board (borrow then release).
let arrival = self.world.boards[target_map]
.borrow()
.portals
.iter()
.find(|p| p.name == target_entry)
.map(|p| (p.x, p.y));
let (ax, ay) = match arrival {
Some(pos) => pos,
None => {
self.log.push(LogLine::error(format!(
"portal entry {target_entry:?} not found on board {target_map:?}"
)));
return;
}
};
// Clear per-board transient state.
self.speech_bubbles.clear();
self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string();
self.board_mut().player = Player {
x: ax as i32,
y: ay as i32,
};
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
&self.world.boards[&self.current_board_name],
&self.world.scripts,
);
// Stub hook for the front-end transition animation (1 second).
self.board_transition = Some(1.0);
// Run init hooks and resolve their queued actions.
self.run_init();
}
/// 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
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If the target cell holds a solid object, that
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
pub fn try_move(&mut self, dir: Direction) {
let bumped;
let portal_target;
{
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// A solid object in the way is bumped by the player (id -1).
bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
_ => None,
};
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
board.player.x = nx as i32;
board.player.y = ny as i32;
// Check for a portal at the new position; clone strings to release the borrow.
portal_target = board
.portals
.iter()
.find(|p| p.x == nx && p.y == ny)
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
} else {
portal_target = None;
}
}
// A portal takes priority: board transitions skip the bump hook.
if let Some((target_map, target_entry)) = portal_target {
self.enter_board(&target_map, &target_entry);
return;
}
if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1);
self.drain_errors();
}
}
}
/// Moves object `id` one cell in `dir` on `board`, returning the [`ObjectId`] of a
/// solid object it bumped (its target cell was occupied by that object), if any.
///
/// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded whenever a solid object occupies the target — whether it gets
/// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump.
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i32, i32) = dir.into();
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) {
return None;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its id is stable).
let bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
_ => None,
};
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
let obj = board.objects.get_mut(&id).expect("id checked above");
obj.x = nx;
obj.y = ny;
}
bumped
}
#[cfg(test)]
mod tests {
use super::GameState;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
/// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn script_push_shoves_a_crate() {
// The object pushes the crate at (2,0) one step east on its first tick.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn script_swap_moves_crates_simultaneously() {
// Swap the crate at (2,0) with the empty cell at (3,0): one ends up empty,
// the other holds the crate, applied in a single batch.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn script_passable_distinguishes_empty_solid_and_offboard() {
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
// object sits at (0,0); the player at (3,0). passable should report only the
// genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init() { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let lines: Vec<String> = game
.log
.iter()
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
.collect();
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
spinner_board_dir(crates, walls, false)
}
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
/// script spins counter-clockwise (clockwise is the default when no tag present).
fn spinner_board_dir(
crates: &[(usize, usize)],
walls: &[(usize, usize)],
ccw: bool,
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.solid = false;
obj.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
}
let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates {
crate_at(&mut board, x, y);
}
for &(x, y) in walls {
wall_at(&mut board, x, y);
}
let scripts = HashMap::from([(
"spinner".to_string(),
include_str!("scripts/spinner.rhai").to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn spinner_does_not_destroy_a_blocked_neighbour() {
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
// wall, so N must not rotate onto NE — the old (can_shift-only) script
// overwrote and destroyed NE's crate. The cascade leaves everything put.
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
}
#[test]
fn spinner_rotates_an_arc_into_a_hole() {
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
// Clockwise, every crate advances one slot and the hole ends up at W.
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
fn spinner_ccw_rotates_the_other_way() {
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
}
#[test]
fn spinner_animates_its_glyph_without_recoloring() {
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
// per 0.5s rotation, while the colours stay put.
let mut game = spinner_board(&[], &[]);
let id = *game.board().objects.keys().next().unwrap();
let (fg0, bg0) = {
let b = game.board();
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
};
let mut seq = Vec::new();
for _ in 0..5 {
game.tick(Duration::from_secs_f64(0.5));
let b = game.board();
seq.push(b.objects[&id].glyph.tile);
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
}
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
}
}