229 lines
9.6 KiB
Rust
229 lines
9.6 KiB
Rust
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::{Action, Direction, ObjectId, Solid};
|
|
|
|
/// How long a `say()` speech bubble stays on screen, in seconds.
|
|
pub const SAY_DURATION: f64 = 3.0;
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
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(),
|
|
};
|
|
// 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` hooks the moves triggered.
|
|
///
|
|
/// Done in two phases so no `board_mut` borrow is held while `bump` scripts run
|
|
/// (they read the board through its getters): phase A mutates the board and records
|
|
/// `(bumped_object, bumper)` pairs; phase B fires the bumps 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 new_bubbles: Vec<SpeechBubble> = Vec::new();
|
|
{
|
|
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(_) => {}
|
|
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
for (bumped, bumper) in bumps {
|
|
self.scripts.run_bump(bumped, bumper);
|
|
}
|
|
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
|
|
}
|
|
|