Files
kiln/kiln-core/src/game.rs
T

840 lines
32 KiB
Rust
Raw Normal View History

2026-06-03 22:46:54 -05:00
use crate::log::LogLine;
2026-06-04 23:52:33 -05:00
use crate::script::{Direction, GameCommand, ScriptHost};
2026-05-30 20:20:09 -05:00
use color::Rgba8;
2026-05-30 18:48:41 -05:00
use serde::{Deserialize, Serialize};
2026-06-04 23:52:33 -05:00
use std::cell::{Ref, RefCell, RefMut};
2026-05-30 18:48:41 -05:00
use std::collections::HashMap;
2026-05-30 20:20:09 -05:00
use std::hash::{Hash, Hasher};
2026-06-04 23:52:33 -05:00
use std::rc::Rc;
2026-06-04 20:11:55 -05:00
use std::time::Duration;
/// The visual representation of a single board cell.
///
2026-05-19 00:07:04 -05:00
/// `Glyph` holds everything needed to draw one cell on screen: which tile
/// index to display and what colors to use. It is stored per-cell (not per
/// archetype), so individual cells can vary their appearance independently.
///
/// `tile` is a left-to-right, top-to-bottom index into the board's bitmap
/// font. For the default CP437 font this matches the ASCII/CP437 code point.
///
/// `Glyph` values come from the map file palette and are set at load time.
/// The player is the only entity whose glyph is hardcoded at runtime
/// (see [`Glyph::player`]).
2026-05-30 20:20:09 -05:00
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Glyph {
2026-05-19 00:07:04 -05:00
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
pub tile: u32,
/// Foreground color, applied to non-background pixels of the tile.
2026-05-30 20:20:09 -05:00
pub fg: Rgba8,
2026-05-19 00:07:04 -05:00
/// Background color, drawn as a filled rectangle behind the tile.
2026-05-30 20:20:09 -05:00
pub bg: Rgba8,
}
impl Hash for Glyph {
/// Hash via packed u32 representations so the impl stays in sync with Eq.
fn hash<H: Hasher>(&self, state: &mut H) {
self.tile.hash(state);
self.fg.to_u32().hash(state);
self.bg.to_u32().hash(state);
}
}
impl Glyph {
2026-06-03 22:46:54 -05:00
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
///
/// This is the only hardcoded glyph; all other glyphs come from the map
/// file palette. It will be removed once the player becomes a scripted
/// object with its own palette entry.
2026-06-04 23:52:33 -05:00
#[rustfmt::skip]
2026-05-19 00:07:04 -05:00
pub const fn player() -> Self {
Self {
tile: 64,
2026-06-03 22:46:54 -05:00
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
2026-05-19 00:07:04 -05:00
}
}
}
2026-05-19 00:07:04 -05:00
/// Specifies a bitmap font for a board.
///
/// Each board can optionally specify a font image and tile dimensions.
/// When absent, the app's default embedded CP437 font is used.
#[derive(Clone, PartialEq, Eq)]
pub struct FontSpec {
/// Path to the PNG font image, relative to the working directory.
pub path: String,
/// Width of each tile in the font image, in pixels.
pub tile_w: u32,
/// Height of each tile in the font image, in pixels.
pub tile_h: u32,
}
/// The behavioral properties of a board cell at runtime.
///
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
/// contains the properties the engine needs to simulate a cell — currently
2026-06-06 01:37:19 -05:00
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
/// be added here without changing call sites.
///
2026-06-06 01:37:19 -05:00
/// For scripted objects, solidity and opacity are stored directly on
/// [`ObjectDef`] and will eventually be overridable by Rhai scripts at runtime.
#[derive(Copy, Clone, Debug)]
pub struct Behavior {
2026-06-06 01:37:19 -05:00
/// Whether this cell blocks / participates in movement. A solid cell stops a
/// mover (and is the only kind of cell that can later be pushed or receive a
/// collision event). This is the inverse of the old `passable` flag.
pub solid: bool,
/// Whether this cell blocks line of sight (reserved for future rendering).
pub opaque: bool,
2026-06-06 01:37:19 -05:00
/// Whether a mover can shove this cell. Unused for now — every archetype is
/// `false`; the push mechanic is future work.
pub pushable: bool,
}
/// A class of board cell, encoding its default behavior and appearance.
///
/// `Archetype` is an enum of the element types the engine knows about. Each
/// variant provides a default [`Behavior`] (via [`Archetype::behavior`]) and a
/// default [`Glyph`] (via [`Archetype::default_glyph`]) used when the editor
/// stamps a cell.
///
/// Map files reference archetypes by [`name`](Archetype::name) (e.g. `"wall"`),
/// so the list of variants can be reordered without breaking saved games.
///
/// ## Object special case
///
/// `Archetype::Object` currently returns a static default `Behavior`.
/// TODO: In the future, Object passability and opacity will come from the cell's Rhai script
/// rather than from this enum. [`Board::is_passable`] will need a special branch
/// at that point. `ErrorBlock` is used as a sentinel for unrecognized archetype
/// names in map files — it should never appear in a valid board.
2026-05-30 18:48:41 -05:00
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum Archetype {
/// An open cell; the player and other entities can pass through it.
Empty,
/// A solid wall; impassable and opaque.
Wall,
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
}
impl Archetype {
/// Returns the default [`Behavior`] for this archetype.
///
/// For `Object`, this is a placeholder until Rhai scripts drive the behavior.
pub fn behavior(&self) -> Behavior {
match self {
2026-05-19 00:07:04 -05:00
Archetype::Empty => Behavior {
2026-06-06 01:37:19 -05:00
solid: false,
2026-05-19 00:07:04 -05:00
opaque: false,
2026-06-06 01:37:19 -05:00
pushable: false,
2026-05-19 00:07:04 -05:00
},
Archetype::Wall => Behavior {
2026-06-06 01:37:19 -05:00
solid: true,
2026-05-19 00:07:04 -05:00
opaque: true,
2026-06-06 01:37:19 -05:00
pushable: false,
2026-05-19 00:07:04 -05:00
},
Archetype::ErrorBlock => Behavior {
2026-06-06 01:37:19 -05:00
solid: true,
2026-05-19 00:07:04 -05:00
opaque: true,
2026-06-06 01:37:19 -05:00
pushable: false,
2026-05-19 00:07:04 -05:00
},
}
}
/// Returns the canonical name used to reference this archetype in map files.
pub fn name(&self) -> &'static str {
match self {
2026-05-19 00:07:04 -05:00
Archetype::Empty => "empty",
Archetype::Wall => "wall",
Archetype::ErrorBlock => "error_block",
}
}
/// Returns the default glyph painted when the editor stamps this archetype.
///
/// This glyph is used only for new cells created in the editor; existing
/// cells retain their own per-cell glyph.
2026-06-04 23:52:33 -05:00
#[rustfmt::skip]
pub fn default_glyph(&self) -> Glyph {
match self {
2026-05-19 00:07:04 -05:00
Archetype::Empty => Glyph {
tile: 32,
2026-05-30 20:20:09 -05:00
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
2026-05-19 00:07:04 -05:00
},
Archetype::Wall => Glyph {
tile: 35,
2026-05-30 20:20:09 -05:00
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
2026-05-19 00:07:04 -05:00
},
// Visually distinct so malformed map files are immediately obvious.
2026-05-19 00:07:04 -05:00
Archetype::ErrorBlock => Glyph {
tile: 63,
2026-05-30 20:20:09 -05:00
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
2026-05-19 00:07:04 -05:00
},
}
}
}
impl TryFrom<&str> for Archetype {
type Error = String;
/// Parses an archetype by its map-file name.
///
/// Returns an error for unrecognized names; the caller should substitute
/// [`Archetype::ErrorBlock`] and log the error so the problem is visible.
fn try_from(name: &str) -> Result<Self, Self::Error> {
match name {
2026-05-19 00:07:04 -05:00
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
2026-05-30 19:25:10 -05:00
// "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph.
2026-05-19 00:07:04 -05:00
_ => Err(format!("unknown archetype: {name}")),
}
}
}
/// The archetypes available for placement in the editor, in display order.
///
/// `ErrorBlock` is excluded — it is a sentinel for load errors, not a valid
/// editing choice.
2026-05-30 21:22:36 -05:00
pub const ALL_ARCHETYPES: &[Archetype] = &[Archetype::Empty, Archetype::Wall];
/// A scripted object placed on the board, loaded from a map file.
///
/// `ObjectDef` represents a tile that has Rhai script attached to it.
/// Scripts can respond to events like the player touching or shooting the
/// tile. Objects are parsed from `[[objects]]` entries in `.toml` map files
/// and stored on [`Board`].
///
2026-05-30 19:25:10 -05:00
/// Objects are rendered as an overlay on top of the board grid — the grid
/// cell at `(x, y)` holds the background (floor) that is revealed if the
/// object moves away. `glyph` is owned by the object itself and may be
/// mutated by its Rhai script at runtime.
///
2026-05-30 18:48:41 -05:00
/// Script text lives in [`Board::scripts`]; this struct holds only the name
/// used to look it up. Two `ObjectDef`s with the same `script_name` share
2026-06-04 20:11:55 -05:00
/// source text but run with independent Rhai scopes (see [`crate::script`]).
2026-05-30 18:48:41 -05:00
///
2026-06-04 20:11:55 -05:00
/// Scripts are executed by [`crate::script::ScriptHost`]: an object's optional
/// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
pub struct ObjectDef {
/// Column of this object on the board (0-indexed).
pub x: usize,
/// Row of this object on the board (0-indexed).
pub y: usize,
2026-05-30 19:25:10 -05:00
/// Visual representation of this object. Owned by the object (not derived
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph,
2026-06-06 01:37:19 -05:00
/// Whether the object blocks / participates in movement. A solid object stops
/// a mover walking into it; at most one solid (object or grid archetype) may
/// occupy a cell. Inverse of the old `passable` flag.
pub solid: bool,
2026-05-30 21:22:36 -05:00
/// Whether the object blocks line of sight / FOV for the player
pub opaque: bool,
2026-06-06 01:37:19 -05:00
/// Whether the object can be shoved by a mover. Unused for now (no push
/// mechanic yet); defaults to `false`.
pub pushable: bool,
2026-05-30 18:48:41 -05:00
/// Name of the Rhai script in [`Board::scripts`] that drives this object.
/// `None` means this object has no script yet.
pub script_name: Option<String>,
}
2026-05-30 21:22:36 -05:00
impl ObjectDef {
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
2026-06-04 23:52:33 -05:00
#[rustfmt::skip]
2026-05-30 21:22:36 -05:00
pub fn default_glyph() -> Glyph {
Glyph {
tile: 63,
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
/// Creates a new object at `(x, y)` with default glyph and blocking behavior.
///
2026-06-06 01:37:19 -05:00
/// Defaults: `solid = true`, `opaque = true`, `pushable = false`, no script.
/// These match the serde defaults in the map file format so new objects
/// round-trip correctly.
pub fn new(x: usize, y: usize) -> Self {
Self {
x,
y,
glyph: Self::default_glyph(),
2026-06-06 01:37:19 -05:00
solid: true,
opaque: true,
2026-06-06 01:37:19 -05:00
pushable: false,
script_name: None,
}
}
2026-05-30 21:22:36 -05:00
}
2026-06-06 01:37:19 -05:00
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
///
/// At most one solid — a grid [`Archetype`] *or* an [`ObjectDef`] — may occupy a
/// cell (the invariant enforced at load time), so this represents the one thing a
/// mover would collide with there.
pub enum Solid<'a> {
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
Cell(Archetype),
/// A solid [`ObjectDef`] occupies the cell.
Object(&'a ObjectDef),
}
/// A portal that teleports the player to a named entry point on another board.
///
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and
/// stored on [`Board`]. When the player steps onto a portal's cell, they
/// should be moved to `target_entry` on the board named by `target_map`.
///
/// **Not yet runtime-wired.** Portal navigation (multi-board loading and
/// switching) is a future feature.
2026-05-30 18:48:41 -05:00
#[derive(Deserialize, Serialize)]
pub struct PortalDef {
/// Column of this portal on the board (0-indexed).
pub x: usize,
/// Row of this portal on the board (0-indexed).
pub y: usize,
/// File name (without extension) of the target board, e.g. `"cave"`.
pub target_map: String,
/// Named entry point on the target board where the player arrives.
pub target_entry: String,
}
/// The player's current position on the board.
///
/// The player is currently a special entity rendered on top of the board
/// rather than being stored as a board cell. This is expected to change:
/// the player will eventually become a scripted object that responds to
/// input events, at which point this struct may be removed or made optional.
/// See `ARCHITECTURE.md` for details.
#[derive(Copy, Clone)]
pub struct Player {
/// Column position (0-indexed, increasing rightward).
pub x: i32,
/// Row position (0-indexed, increasing downward).
pub y: i32,
}
/// The complete state of one game board (a single room or screen).
///
/// `Board` is the central data structure of the engine, equivalent to a
/// "board" in ZZT. It contains everything needed to represent and run one
/// self-contained area of the game world:
///
/// - A grid of cells, each with a visual representation ([`Glyph`]) and an [`Archetype`]
/// - The current player position
/// - Scripted objects and portals (loaded but not yet active)
///
/// ## Cell storage
///
/// Cells are stored as `(Glyph, Archetype)` tuples in a row-major `Vec`.
/// Each cell directly owns its visual and behavioral class — there is no
/// separate element palette or index indirection. Access cells with
/// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates.
/// Use [`Board::is_passable`] for collision checks.
pub struct Board {
2026-05-30 18:48:41 -05:00
/// Human-readable name for this board, loaded from the map file and round-tripped on save.
pub name: String,
/// Width of the board in cells.
pub width: usize,
/// Height of the board in cells.
pub height: usize,
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
/// access by `(x, y)` coordinates.
pub(crate) cells: Vec<(Glyph, Archetype)>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board. Parsed from the map file; not yet active.
pub objects: Vec<ObjectDef>,
/// Portals on this board. Parsed from the map file; not yet active.
pub portals: Vec<PortalDef>,
2026-05-19 00:07:04 -05:00
/// Optional font override for this board. When `None`, the app default is used.
pub font: Option<FontSpec>,
2026-05-31 23:51:51 -05:00
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
pub zoom: u32,
2026-05-30 18:48:41 -05:00
/// Named Rhai scripts available on this board: script name → source text.
///
/// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`]
/// reference entries by name. This means multiple objects can share the same
/// source while each running instance gets its own Rhai scope at runtime.
pub scripts: HashMap<String, String>,
/// Name of the board-level script in [`Board::scripts`], if any.
///
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
/// rather than being tied to a specific object cell.
pub board_script_name: Option<String>,
}
impl Board {
/// Returns a reference to the cell at `(x, y)`.
///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are
/// out of bounds.
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.cells[y * self.width + x]
}
/// Returns a mutable reference to the cell at `(x, y)`.
///
/// Panics if `x` or `y` are out of bounds.
#[allow(dead_code)]
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
}
2026-05-30 20:20:09 -05:00
/// Returns a slice of all cells in row-major order.
///
/// Useful for iterating over the full board (e.g. to collect unique glyphs).
pub fn cells(&self) -> &[(Glyph, Archetype)] {
&self.cells
}
2026-06-06 01:37:19 -05:00
/// Returns the single solid entity occupying `(x, y)`, if any.
///
2026-06-06 01:37:19 -05:00
/// Checks objects first, then the grid archetype. Because at most one solid
/// may occupy a cell (an invariant enforced when the board is loaded — see
/// [`crate::map_file`]), this returns that one occupant or `None`.
/// Panics if `x` or `y` are out of bounds.
2026-06-06 01:37:19 -05:00
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid<'_>> {
// A solid object shadows the grid cell it sits on.
2026-06-04 23:52:33 -05:00
if let Some(obj) = self.object_at(x, y)
2026-06-06 01:37:19 -05:00
&& obj.solid
2026-06-04 23:52:33 -05:00
{
2026-06-06 01:37:19 -05:00
return Some(Solid::Object(obj));
}
// Otherwise the grid archetype itself may be solid (e.g. a wall).
let arch = self.get(x, y).1;
if arch.behavior().solid {
return Some(Solid::Cell(arch));
2026-05-30 19:25:10 -05:00
}
2026-06-06 01:37:19 -05:00
None
}
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
///
/// Convenience inverse of [`solid_at`](Board::solid_at).
/// Panics if `x` or `y` are out of bounds.
pub fn is_passable(&self, x: usize, y: usize) -> bool {
self.solid_at(x, y).is_none()
2026-05-30 19:25:10 -05:00
}
/// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any.
2026-05-30 21:22:36 -05:00
pub fn object_index_at(&self, x: usize, y: usize) -> Option<usize> {
2026-05-30 19:25:10 -05:00
self.objects.iter().position(|o| o.x == x && o.y == y)
}
2026-05-30 21:22:36 -05:00
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn object_at(&self, x: usize, y: usize) -> Option<&ObjectDef> {
self.object_index_at(x, y).map(|idx| &self.objects[idx])
}
}
2026-06-04 23:52:33 -05:00
/// Holds the active game world and provides game-logic operations.
///
/// `GameState` is the boundary between the engine (rendering, input) and the
2026-06-05 00:12:57 -05:00
/// game data. It owns the board (behind a shared `Rc<RefCell<Board>>`),
2026-06-04 23:52:33 -05:00
/// 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 {
2026-06-04 23:52:33 -05:00
/// The scriptable world, shared with the script host's read getters.
2026-06-05 00:12:57 -05:00
board: Rc<RefCell<Board>>,
2026-06-03 22:46:54 -05:00
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
2026-06-04 20:11:55 -05:00
/// The Rhai scripting runtime driving this board's scripted objects.
scripts: ScriptHost,
}
impl GameState {
/// Creates a `GameState` from a pre-loaded [`Board`].
2026-06-04 20:11:55 -05:00
///
/// 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 {
2026-06-05 00:12:57 -05:00
let board = Rc::new(RefCell::new(board));
let scripts = ScriptHost::new(&board);
2026-06-04 20:11:55 -05:00
let mut state = Self {
2026-06-05 00:12:57 -05:00
board,
2026-06-03 22:46:54 -05:00
log: Vec::new(),
2026-06-04 20:11:55 -05:00
scripts,
};
// Surface any compile-time script errors collected during ScriptHost::new.
2026-06-04 23:52:33 -05:00
state.apply_commands();
2026-06-04 20:11:55 -05:00
state
2026-06-03 22:46:54 -05:00
}
2026-06-04 23:52:33 -05:00
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
2026-06-05 00:12:57 -05:00
self.board.borrow()
2026-06-04 23:52:33 -05:00
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
2026-06-05 00:12:57 -05:00
self.board.borrow_mut()
2026-06-04 23:52:33 -05:00
}
2026-06-03 22:46:54 -05:00
/// Appends a styled message to the log.
pub fn log(&mut self, line: LogLine) {
self.log.push(line);
}
2026-06-04 23:52:33 -05:00
/// Runs the `init()` hook of every scripted object, then applies the commands
/// they queued. 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.
2026-06-04 20:11:55 -05:00
pub fn run_init(&mut self) {
self.scripts.run_init();
2026-06-04 23:52:33 -05:00
self.apply_commands();
2026-06-04 20:11:55 -05:00
}
2026-06-03 23:21:05 -05:00
/// Advances real-time game state by `dt` (the elapsed time since the last
2026-06-04 20:11:55 -05:00
/// tick). Called once per frame by the front-end's game loop; drives every
2026-06-04 23:52:33 -05:00
/// scripted object's `tick(dt)` hook, then applies the commands they queued.
2026-06-03 23:21:05 -05:00
pub fn tick(&mut self, dt: Duration) {
2026-06-04 20:11:55 -05:00
self.scripts.run_tick(dt.as_secs_f64());
2026-06-04 23:52:33 -05:00
self.apply_commands();
2026-06-03 23:21:05 -05:00
}
2026-06-04 23:52:33 -05:00
/// Drains the script command queue and applies each command. Runs *after* a
/// script batch, when nothing holds a borrow of the board — so the mutations
/// here can't conflict with the read getters scripts use during execution.
fn apply_commands(&mut self) {
for cmd in self.scripts.take_commands() {
match cmd.kind {
GameCommand::Log(line) => self.log.push(line),
// TODO: errors are only logged for now. This is the place to halt
// execution / set an error state when a script faults.
GameCommand::Error(msg) => self.log.push(LogLine::raw(msg)),
GameCommand::SetTile(tile) => {
if let Some(obj) = self.board_mut().objects.get_mut(cmd.source) {
obj.glyph.tile = tile;
}
}
GameCommand::Move(dir) => self.move_object(cmd.source, dir),
}
}
}
/// Moves object `idx` one cell in `dir`, if the target is in bounds and
/// passable. This is where movement rules live (a no-op when blocked).
fn move_object(&mut self, idx: usize, dir: Direction) {
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let Some((ox, oy)) = board.objects.get(idx).map(|o| (o.x, o.y)) else {
return;
};
let nx = ox as i32 + dx;
let ny = oy as i32 + dy;
if nx < 0 || ny < 0 {
return;
}
let (nx, ny) = (nx as usize, ny as usize);
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
let obj = &mut board.objects[idx];
obj.x = nx;
obj.y = ny;
}
2026-06-03 23:21:05 -05:00
}
/// Attempts to move the player by `(dx, dy)` cells.
///
/// The move is ignored if the target cell is out of bounds or its behavior
/// is not passable. No-ops silently (the caller does not need to check).
pub fn try_move(&mut self, dx: i32, dy: i32) {
2026-06-04 23:52:33 -05:00
let mut board = self.board_mut();
let nx = board.player.x + dx;
let ny = board.player.y + dy;
if nx >= 0 && ny >= 0 {
let nx = nx as usize;
let ny = ny as usize;
2026-06-04 23:52:33 -05:00
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
board.player.x = nx as i32;
board.player.y = ny as i32;
}
}
}
2026-05-19 00:07:04 -05:00
}
2026-06-03 23:21:05 -05:00
#[cfg(test)]
mod tests {
use super::*;
2026-06-04 20:11:55 -05:00
/// Builds a 1×1 board with a single object that optionally references a
/// script, plus the given `(name, source)` script table entries.
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
let mut object = ObjectDef::new(0, 0);
object.script_name = object_script.map(str::to_string);
2026-06-03 23:21:05 -05:00
Board {
name: "test".into(),
width: 1,
height: 1,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
player: Player { x: 0, y: 0 },
2026-06-04 20:11:55 -05:00
objects: vec![object],
2026-06-03 23:21:05 -05:00
portals: Vec::new(),
font: None,
zoom: 1,
2026-06-04 20:11:55 -05:00
scripts: scripts
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
2026-06-03 23:21:05 -05:00
board_script_name: None,
}
}
2026-06-04 23:52:33 -05:00
/// An `ObjectDef` at `(x, y)` bound to the named script.
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = ObjectDef::new(x, y);
o.script_name = Some(script.to_string());
o
}
/// Builds an all-empty (passable) `w×h` board with the given player position,
/// objects, and `(name, source)` scripts — room for movement, unlike the 1×1
/// `board_with_object`.
fn open_board(
w: usize,
h: usize,
player: (i32, i32),
objects: Vec<ObjectDef>,
scripts: &[(&str, &str)],
) -> Board {
Board {
name: "test".into(),
width: w,
height: h,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
player: Player {
x: player.0,
y: player.1,
},
objects,
portals: Vec::new(),
font: None,
zoom: 1,
scripts: scripts
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
board_script_name: None,
}
}
2026-06-04 20:11:55 -05:00
/// Flattens each log line into a single string for easy assertions.
fn log_texts(game: &GameState) -> Vec<String> {
game.log
.iter()
.map(|line| line.spans.iter().map(|s| s.text.as_str()).collect())
.collect()
}
2026-06-06 01:37:19 -05:00
#[test]
fn solid_at_reports_wall_object_and_empty() {
// A 3×1 board: empty floor, a wall, and an empty cell holding one object.
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
// A solid object on the otherwise-empty cell (2, 0).
board.objects.push(ObjectDef::new(2, 0)); // solid by default
// Empty floor: nothing solid.
assert!(board.solid_at(0, 0).is_none());
assert!(board.is_passable(0, 0));
// Wall cell: the grid archetype is the solid.
match board.solid_at(1, 0) {
Some(Solid::Cell(Archetype::Wall)) => {}
other => panic!("expected Solid::Cell(Wall), got {:?}", other.is_some()),
}
assert!(!board.is_passable(1, 0));
// Object cell: the solid object shadows the empty floor under it.
match board.solid_at(2, 0) {
Some(Solid::Object(o)) => assert_eq!((o.x, o.y), (2, 0)),
_ => panic!("expected Solid::Object"),
}
assert!(!board.is_passable(2, 0));
}
#[test]
fn non_solid_object_does_not_block() {
// A non-solid object sits on empty floor: the cell stays passable.
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
let board = open_board(3, 1, (0, 0), vec![obj], &[]);
assert!(board.solid_at(1, 0).is_none());
assert!(board.is_passable(1, 0));
}
2026-06-04 20:11:55 -05:00
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
2026-06-04 23:52:33 -05:00
let board = board_with_object(
Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)],
);
2026-06-04 20:11:55 -05:00
let mut game = GameState::new(board);
// init must not fire during construction / deserialization.
assert!(game.log.is_empty());
game.run_init();
assert_eq!(log_texts(&game), vec!["hello"]);
}
2026-06-03 23:21:05 -05:00
#[test]
2026-06-04 20:11:55 -05:00
fn tick_calls_script_tick_with_elapsed_seconds() {
let board = board_with_object(
Some("t"),
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
);
let mut game = GameState::new(board);
// No tick hook runs until tick() is called.
game.run_init();
assert!(game.log.is_empty());
2026-06-03 23:21:05 -05:00
game.tick(Duration::from_millis(500));
assert_eq!(game.log.len(), 1);
2026-06-04 20:11:55 -05:00
// The dt argument reached the script as ~0.5 seconds.
let logged: f64 = log_texts(&game)[0].parse().unwrap();
assert!((logged - 0.5).abs() < 1e-9);
}
2026-06-03 23:21:05 -05:00
2026-06-04 20:11:55 -05:00
#[test]
fn missing_hooks_and_no_script_are_noops() {
// Object with no script: nothing happens.
let mut game = GameState::new(board_with_object(None, &[]));
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
// Script defines neither init nor tick: also a no-op.
2026-06-04 23:52:33 -05:00
let mut game = GameState::new(board_with_object(
Some("e"),
&[("e", "fn other() { log(\"x\"); }")],
));
2026-06-04 20:11:55 -05:00
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
}
#[test]
fn compile_and_unknown_script_errors_are_logged() {
// A reference to a script name that isn't in the table.
let game = GameState::new(board_with_object(Some("ghost"), &[]));
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
// A script that fails to compile is reported at construction time.
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
assert!(log_texts(&game)[0].contains("failed to compile"));
2026-06-03 23:21:05 -05:00
}
2026-06-04 23:52:33 -05:00
#[test]
fn script_reads_board_through_view() {
let board = open_board(
5,
3,
(3, 1),
vec![scripted_object(2, 1, "r")],
2026-06-05 01:09:50 -05:00
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
2026-06-04 23:52:33 -05:00
);
let mut game = GameState::new(board);
game.run_init();
// The view reported the player's x (3).
assert_eq!(log_texts(&game), vec!["3"]);
}
#[test]
fn move_command_relocates_the_source_object() {
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(2, 1, "m")],
2026-06-05 01:09:50 -05:00
&[("m", "fn init() { move(East); }")],
2026-06-04 23:52:33 -05:00
);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
// East increments x by one; the object started at (2, 1).
assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1));
}
#[test]
fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored.
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "m")],
2026-06-05 01:09:50 -05:00
&[("m", "fn init() { move(West); }")],
2026-06-04 23:52:33 -05:00
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].x, 0);
}
#[test]
fn set_tile_command_changes_the_source_glyph() {
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(2, 1, "s")],
&[("s", "fn init() { set_tile(7); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 7);
}
#[test]
fn start_map_greeter_runs_init() {
// End-to-end against the shipped example map: load it, run init, and
// confirm the greeter read the board (interpolated message) and wrote to
// itself (set_tile). Also guards the example from drifting out of sync.
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
let board = crate::map_file::load(path).expect("load start.toml");
let mut game = GameState::new(board);
game.run_init();
assert!(
log_texts(&game)
.iter()
.any(|t| t.contains("hello from object")),
"greeter init should log a greeting"
);
assert!(
game.board().objects.iter().any(|o| o.glyph.tile == 2),
"greeter set_tile(2) should change its glyph"
);
}
#[test]
fn commands_are_routed_to_their_own_source_object() {
// Two objects with different scripts move in opposite directions; each
// must affect only itself (the per-call tag routes the command source).
// Targets are kept apart so the impassable objects don't block each other.
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
&[
2026-06-05 01:09:50 -05:00
("e", "fn init() { move(East); }"),
("w", "fn init() { move(West); }"),
2026-06-04 23:52:33 -05:00
],
);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!(b.objects[0].x, 1); // moved east from 0
assert_eq!(b.objects[1].x, 3); // moved west from 4
}
2026-06-03 23:21:05 -05:00
}