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, } /// 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>`), /// 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>, /// The in-game message log, oldest first (newest pushed at the end). pub log: Vec, /// 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, /// 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, } 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 = Vec::new(); let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); let mut sends: Vec<(ObjectId, String, Option)> = Vec::new(); let mut new_bubbles: Vec = Vec::new(); // Collected outside the board borrow so we can assign to self.active_scroll. let mut new_scroll: Option = 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 { 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 }