Files
kiln/kiln-core/src/game.rs
T
2026-06-10 01:42:33 -05:00

286 lines
12 KiB
Rust

use crate::action::Action;
use crate::log::LogLine;
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::{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.
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 the board (behind a shared `Rc<RefCell<Board>>`),
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
/// internal logic reach the board through [`board`](GameState::board) /
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
/// after each script batch.
pub struct GameState {
/// The scriptable world, shared with the script host's read getters.
board: Rc<RefCell<Board>>,
/// The in-game message log, oldest first (newest pushed at the end).
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>,
/// 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 {
/// Creates a `GameState` from a pre-loaded [`Board`].
///
/// Compiles the board's object scripts but does **not** run any of them;
/// call [`GameState::run_init`] once the game is ready to start. Any script
/// compile errors are surfaced into the log here.
pub fn new(board: Board) -> Self {
let board = Rc::new(RefCell::new(board));
let scripts = ScriptHost::new(&board);
let mut state = Self {
board,
log: Vec::new(),
scripts,
speech_bubbles: Vec::new(),
active_scroll: None,
};
// Surface any compile-time script errors collected during ScriptHost::new.
state.drain_errors();
state
}
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
self.board.borrow()
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
self.board.borrow_mut()
}
/// 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. Counts down object
/// cooldowns, drives every scripted object's `tick(dt)` hook (pumping each queue),
/// 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.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) => 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(_) => {}
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 });
}
}
}
}
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();
}
/// 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
/// 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 (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;
}
}
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
}