refactoring

This commit is contained in:
2026-06-07 00:19:53 -05:00
parent ea18fe8fc2
commit ea79dc20f9
13 changed files with 805 additions and 749 deletions
+38 -14
View File
@@ -47,18 +47,42 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
### kiln-core modules
**`kiln-core/src/game.rs`** — all core game types:
- `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette.
- `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used.
- `Behavior` — plain data struct of runtime behavioral properties: `solid: bool` (blocks/participates in movement — inverse of the old `passable`), `opaque: bool`, `pushable: Pushable` (an enum `No`/`Any`/`Horizontal`/`Vertical` with `allows(dir)`; only meaningful for `solid` things). Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere. (`ObjectDef.pushable` is still a plain `bool` = any direction.)
- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player` (the player; pushable any direction), `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time. The player is checked first, so its cell reports `Solid::Player` (and is impassable to movers).
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `Crate` is solid and pushable any direction (CP437 ■, char 254, light gray on black); `HCrate`/`VCrate` are crates pushable only east/west (↔, char 29) / north/south (↕, char 18) respectively, same colors. `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
- `ALL_ARCHETYPES: &[Archetype]`ordered list of valid editor choices (excludes `ErrorBlock`).
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (the visual floor layer — see `floor.rs`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>` (keyed by stable id; iterates in ascending-id = load order), `next_object_id: ObjectId` (counter starting at 1), `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells`, `floor`, and `floor_spec` are `pub(crate)`. `ObjectId` is a `pub type ObjectId = u32` (in `game.rs`). `add_object(obj) -> ObjectId` allocates the next id, inserts, and returns it; `object_id_at(x, y) -> Option<ObjectId>` and `object_at(x, y) -> Option<&ObjectDef>` look up by position. `display_glyph(x, y)` returns the static-layer glyph for a cell — the grid archetype's glyph, or the `floor[idx]` glyph when the cell is `Empty` (front-ends call this for the non-object/non-player layer; an `Empty` cell's *own* glyph is ignored, so a cell vacated by a pushed crate reveals the floor). `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec<LogLine>`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors".
- `Player``x: i32, y: i32`
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option<String>`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)`/`bump(id)` hooks are run by the scripting runtime (see `script.rs`).
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible) and fires `bump(-1)` on a solid object it walks into; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` advances each object's cooldown then runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue (`take_board_queue()`) and applies each `Action`: `Log``log`, `SetTile` → the source object's glyph, `Move(dir)` → the free fn `step_object` (gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates, and returns the `ObjectId` of any solid object it bumped). Resolution is two-phase — mutate the board collecting `(bumped, bumper)` pairs, then drop the board borrow and fire `run_bump` for each (a `bump` script reads `Board.*`, so no `board_mut` borrow may be held while it runs). A bumped object's own emitted actions wait for the next tick's pump (no same-tick cascade). `drain_errors()` moves the script error sink into `log`. This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
The core types that were formerly monolithic in `game.rs` are now split into focused modules. `game.rs` contains only `GameState`; the data types live in their own files.
**`kiln-core/src/glyph.rs`** — per-cell visual:
- `Glyph` (`Copy`, `Eq`, `Hash`) — `tile: u32` (tilesheet index), `fg/bg: Rgba8`. `Glyph::player()` is a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`.
**`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`)enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`.
- `ObjectId = u32` — stable identifier for board objects.
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Cell(Archetype)`, or `Object(ObjectId)`.
- `Player { x: i32, y: i32 }` — current player position.
- `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired.
**`kiln-core/src/font.rs`** — font override (`pub(crate)`):
- `FontSpec { path: String, tile_w: u32, tile_h: u32 }` — optional per-board bitmap font. When `None`, the app default is used.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`. `ObjectDef::new(x, y)` constructs with defaults. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/board.rs`** — the board data type:
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`, `zoom: u32` (integer tile scale factor), `scripts: HashMap<String, String>` (named Rhai source text; script name → source), `board_script_name: Option<String>` (name of a board-level script, if any), `load_errors: Vec<LogLine>` (`pub(crate)`).
- `get(x, y) -> &(Glyph, Archetype)` / `get_mut` — direct cell access (panics OOB).
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. Priority: player > solid object > non-solid object with nonzero tile > non-Empty grid archetype > floor. **Includes the player**`glyph_at` at the player's cell returns `Glyph::player()`. An `Empty` cell's own glyph is always ignored (floor supersedes it). Front-ends call this per-cell instead of managing a separate player overlay.
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then objects, then grid archetype).
- `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving `Empty` behind.
- `add_object(obj) -> ObjectId`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object queries.
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
**`kiln-core/src/game.rs`** — game-loop logic only:
- `GameState` — holds `board: Rc<RefCell<Board>>`, `log: Vec<LogLine>`, and `scripts: ScriptHost`. Front-ends reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns and runs `tick(dt)` hooks every frame. Both end by calling `apply_commands()`, which drains the board queue and applies each `Action` (`Log``log`, `SetTile` → source glyph, `Move(dir)``step_object`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`.
**`kiln-core/src/floor.rs`** — the visual floor layer:
- The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it.
@@ -105,7 +129,7 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
**`kiln-tui/src/render.rs`** — board → ratatui buffer:
- `rgba8_to_color(Rgba8) -> Color::Rgb` — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate.
- `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)`: a board smaller than the viewport is **centered** with screen padding, a larger one **scrolls** to keep the player on screen with no empty margins. Each cell resolves glyph priority player > object > grid floor, then writes char + fg/bg into the buffer.
- `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)`: a board smaller than the viewport is **centered** with screen padding, a larger one **scrolls** to keep the player on screen with no empty margins. Each cell calls `Board::glyph_at`, which already folds in the player glyph at the player's position — no separate player-overlay pass needed.
**`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup:
- `TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports.
@@ -262,7 +286,7 @@ Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coord
### Key design decisions
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
- **The floor layer is how `Empty` is drawn** — `Board::display_glyph` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
- **The floor layer is how `Empty` is drawn** — `Board::glyph_at` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant.
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example.
+152
View File
@@ -0,0 +1,152 @@
use color::Rgba8;
use crate::glyph::Glyph;
use crate::utils::{Behavior, Pushable};
/// 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.
#[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,
/// A solid, pushable box. Walking into it shoves it one cell in the same
/// direction (cascading through a chain of crates) if there is open space.
Crate,
/// A crate that can only be pushed east/west (horizontal axis). Glyph ↔.
HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate,
/// 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 {
Archetype::Empty => Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
},
Archetype::Wall => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
Archetype::Crate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Any,
},
Archetype::HCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Horizontal,
},
Archetype::VCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Vertical,
},
Archetype::ErrorBlock => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
}
}
/// Returns the canonical name used to reference this archetype in map files.
pub fn name(&self) -> &'static str {
match self {
Archetype::Empty => "empty",
Archetype::Wall => "wall",
Archetype::Crate => "crate",
Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate",
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.
#[rustfmt::skip]
pub fn default_glyph(&self) -> Glyph {
match self {
Archetype::Empty => Glyph {
tile: 32,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::Wall => Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
},
Archetype::Crate => Glyph {
tile: 254, // CP437 ■ (small filled square)
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // light gray on black
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::HCrate => Glyph {
tile: 29, // CP437 ↔ (left-right arrow) — pushable east/west
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::VCrate => Glyph {
tile: 18, // CP437 ↕ (up-down arrow) — pushable north/south
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph {
tile: 63,
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
},
}
}
}
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 {
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
"crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate),
// "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph.
_ => Err(format!("unknown archetype: {name}")),
}
}
}
+311
View File
@@ -0,0 +1,311 @@
use std::collections::{BTreeMap, HashMap};
use crate::archetype::Archetype;
use crate::archetype::Archetype::Empty;
use crate::font::FontSpec;
use crate::glyph::Glyph;
use crate::log::LogLine;
use crate::object_def::ObjectDef;
use crate::script::Direction;
use crate::utils::{ObjectId, Player, PortalDef, Solid};
/// 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 {
/// 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)>,
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
/// When a map declares no `[floor]`, every entry is black-on-black space (the
/// historical look of an empty cell). Read it via [`Board::glyph_at`].
pub(crate) floor: Vec<Glyph>,
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
/// round-trip it. `None` when the map declared no floor. (Generators
/// re-randomize on reload; literal glyph grids are preserved exactly.)
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
/// (not a `Vec`) so an object can be removed without invalidating other
/// objects' ids; iteration is in ascending-id order, which equals load order
/// (ids are assigned sequentially as the map loads).
pub objects: BTreeMap<ObjectId, ObjectDef>,
/// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing).
/// See [`Board::add_object`].
pub next_object_id: ObjectId,
/// Portals on this board. Parsed from the map file; not yet active.
pub portals: Vec<PortalDef>,
/// Optional font override for this board. When `None`, the app default is used.
pub font: Option<FontSpec>,
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
pub zoom: u32,
/// 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>,
/// Nonfatal problems collected while loading this map (e.g. unknown
/// archetypes, dropped objects, recovered placement chars), as red-on-black
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
/// the map file (purely a load diagnostic).
pub(crate) load_errors: Vec<LogLine>,
}
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.
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
}
/// Returns the glyph we should display for a given coordinate:
///
/// - If the coord contains a [`Solid`], use the appropriate glyph for that
/// - If it contains at least one non-solid object with a glyph other than 0, use one (arbitrarily)
/// - If it contains any non-Empty archetype, use that glyph
/// - Fall back to the [`floor`](Board::floor) glyph.
///
/// An `Empty` cell's *own* glyph is intentionally ignored — the floor layer
/// supersedes it (so a cell vacated by a pushed crate reveals the floor, not
/// black). Panics if out of bounds.
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
if let Some(solid) = self.solid_at(x, y) {
// Use the solid
return match solid {
Solid::Player => Glyph::player(),
Solid::Cell(_) => self.get(x, y).0,
Solid::Object(id) => self.objects[&id].glyph
}
}
let (glyph, arch) = *self.get(x, y);
let ids = self.object_ids_at(x, y); // IDs of non-solid (ethereal?) objects
// Objects with nonzero (nonblank) glyphs take precedence. This allows us to use invisible
// nonsolid objects on the board.
if let Some(glyph) = ids.iter().find(|id| self.objects[id].glyph.tile != 0 ).map(|id| self.objects[id].glyph) {
return glyph
}
// Is there a normal archetype?
if arch != Empty {
return glyph
}
self.floor[y * self.width + x]
}
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
///
/// Takes signed coords so callers can pass a raw `pos + delta` without first
/// checking for negatives.
pub fn in_bounds(&self, pos: (i32, i32)) -> bool {
let (x, y) = pos;
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
}
/// Records a nonfatal error: appends `message` as a red-on-black line to the
/// board's [`load_errors`](Board::load_errors). Used by the map loader (and
/// available at runtime) to surface recoverable problems.
pub fn report_error(&mut self, message: impl Into<String>) {
self.load_errors.push(LogLine::error(message));
}
/// Returns `true` if the map loaded with no nonfatal errors (the error list
/// is empty).
pub fn is_valid(&self) -> bool {
self.load_errors.is_empty()
}
/// Returns the single solid entity occupying `(x, y)`, if any.
///
/// Checks player first, then objects, 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.
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> {
// The player wins its cell (load-time invariant), so it is the solid there.
if self.player.x == x as i32 && self.player.y == y as i32 {
return Some(Solid::Player);
}
// A solid object shadows the grid cell it sits on.
if let Some(id) = self.solid_object_id_at(x, y)
{
return Some(Solid::Object(id));
}
// 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));
}
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()
}
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
///
/// Non-solid things are never pushable: `pushable` only matters for solids.
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
/// can be shoved in any direction.
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
match self.solid_at(x, y) {
Some(Solid::Player) => true, // the player is pushable in any direction
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
Some(Solid::Object(id)) => self.objects[&id].pushable,
None => false,
}
}
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
/// edge or a non-pushable solid.
///
/// Read-only (`&self`); pairs with [`push`](Board::push). Returns `false` when
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
/// ahead shovable?" half of a "can I move here?" query.
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
let (dx, dy): (i32, i32) = dir.into();
let (mut cx, mut cy) = (x, y);
loop {
// This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) {
return false;
}
let next = (cx as i32 + dx, cy as i32 + dy);
if !self.in_bounds(next) {
return false; // chain runs off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
if self.is_passable(nx, ny) {
return true; // open space at the end: the whole chain can move
}
// Next cell holds a solid too; continue (it must itself be pushable).
cx = nx;
cy = ny;
}
}
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
/// leaving `Empty` floor behind each moved cell.
///
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
/// so it is safe to call unconditionally.
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
if !self.can_push(x, y, dir) {
return;
}
let (dx, dy): (i32, i32) = dir.into();
// can_push guaranteed the chain ends at an in-bounds passable cell, so
// re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives.
let mut chain: Vec<(usize, usize)> = Vec::new();
let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) {
chain.push((cx, cy));
cx = (cx as i32 + dx) as usize;
cy = (cy as i32 + dy) as usize;
}
for &(px, py) in chain.iter().rev() {
self.shift_solid(px, py, dx, dy);
}
}
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
///
/// A solid object is relocated; otherwise the grid archetype (a crate) is
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
// The player owns its cell, so move it before considering objects/grid.
if self.player.x == x as i32 && self.player.y == y as i32 {
self.player.x = tx as i32;
self.player.y = ty as i32;
} else if let Some(id) = self.solid_object_id_at(x, y)
{
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
obj.x = tx;
obj.y = ty;
} else {
let moved = *self.get(x, y);
*self.get_mut(tx, ty) = moved;
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
}
}
/// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any.
pub fn object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
self.objects
.iter()
.filter(|(_, o)| o.x == x && o.y == y)
.map(|(&id, _)| id).collect()
}
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
self.objects
.iter()
.find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid {
Some(id)
} else {
None
}
})
}
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
///
/// Ids start at 1 and increase monotonically; an id is never reused, so it
/// stays a valid handle to this object for the board's lifetime.
pub fn add_object(&mut self, object: ObjectDef) -> ObjectId {
let id = self.next_object_id;
self.next_object_id += 1;
self.objects.insert(id, object);
id
}
}
+5 -4
View File
@@ -1,24 +1,25 @@
//! The floor layer: a per-cell visual background drawn wherever a board cell
//! would otherwise render as [`Archetype::Empty`](crate::game::Archetype::Empty).
//! would otherwise render as [`Archetype::Empty`](crate::archetype::Archetype::Empty).
//!
//! The floor is purely cosmetic — it carries no behavior. It exists to give
//! boards some non-distracting visual flavor (textured ground) with little
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
//!
//! A board's floor is computed once at load time ([`build_floor`]) and cached as
//! a `Vec<Glyph>` on the [`Board`](crate::game::Board); generators are run during
//! a `Vec<Glyph>` on the [`Board`](crate::board::Board); generators are run during
//! that single pass and their results stored, so there is no per-frame cost and
//! no tile flicker. The raw [`FloorSpec`] is also kept on the board so a save
//! round-trips the declaration (generators re-randomize on reload; literal glyph
//! grids are preserved exactly).
use crate::game::{Archetype, Glyph};
use crate::archetype::Archetype;
use crate::log::LogLine;
use crate::map_file::{TileIndex, parse_color};
use crate::map_file::{parse_color, TileIndex};
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tinyrand::{Probability, Rand, Seeded, StdRand};
use crate::glyph::Glyph;
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
/// for a given spec (stable across reloads within a run, and testable).
+13
View File
@@ -0,0 +1,13 @@
/// 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,
}
+27 -711
View File
@@ -1,708 +1,10 @@
use crate::log::LogLine;
use crate::script::{Action, Direction, ScriptHost};
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::cell::{Ref, RefCell, RefMut};
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use std::time::Duration;
/// The visual representation of a single board cell.
///
/// `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`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Glyph {
/// 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.
pub fg: Rgba8,
/// Background color, drawn as a filled rectangle behind the tile.
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 {
/// 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.
#[rustfmt::skip]
pub const fn player() -> Self {
Self {
tile: 64,
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
}
}
}
/// 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,
}
/// Which directions a solid may be pushed in.
///
/// Only meaningful for `solid` cells (a non-solid cell is passable, so nothing is
/// ever pushed into it). `Crate` is [`Pushable::Any`]; the directional crates
/// constrain pushes to one axis.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Pushable {
/// Cannot be pushed.
No,
/// Pushable in any direction.
Any,
/// Pushable east/west only.
Horizontal,
/// Pushable north/south only.
Vertical,
}
impl Pushable {
/// Whether a push in `dir` is allowed.
pub fn allows(self, dir: Direction) -> bool {
match self {
Pushable::No => false,
Pushable::Any => true,
Pushable::Horizontal => matches!(dir, Direction::East | Direction::West),
Pushable::Vertical => matches!(dir, Direction::North | Direction::South),
}
}
}
/// 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
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
/// be added here without changing call sites.
///
/// 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 {
/// 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,
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
pub pushable: Pushable,
}
/// 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.
#[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,
/// A solid, pushable box. Walking into it shoves it one cell in the same
/// direction (cascading through a chain of crates) if there is open space.
Crate,
/// A crate that can only be pushed east/west (horizontal axis). Glyph ↔.
HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate,
/// 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 {
Archetype::Empty => Behavior {
solid: false,
opaque: false,
pushable: Pushable::No,
},
Archetype::Wall => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
Archetype::Crate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Any,
},
Archetype::HCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Horizontal,
},
Archetype::VCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Vertical,
},
Archetype::ErrorBlock => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
}
}
/// Returns the canonical name used to reference this archetype in map files.
pub fn name(&self) -> &'static str {
match self {
Archetype::Empty => "empty",
Archetype::Wall => "wall",
Archetype::Crate => "crate",
Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate",
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.
#[rustfmt::skip]
pub fn default_glyph(&self) -> Glyph {
match self {
Archetype::Empty => Glyph {
tile: 32,
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::Wall => Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
},
Archetype::Crate => Glyph {
tile: 254, // CP437 ■ (small filled square)
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // light gray on black
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::HCrate => Glyph {
tile: 29, // CP437 ↔ (left-right arrow) — pushable east/west
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::VCrate => Glyph {
tile: 18, // CP437 ↕ (up-down arrow) — pushable north/south
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph {
tile: 63,
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
},
}
}
}
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 {
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
"crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate),
// "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph.
_ => 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.
pub const ALL_ARCHETYPES: &[Archetype] = &[
Archetype::Empty,
Archetype::Wall,
Archetype::Crate,
Archetype::HCrate,
Archetype::VCrate,
];
/// A stable, unique identifier for a board object.
///
/// Ids are handed out by [`Board::add_object`] from the per-board
/// [`Board::next_object_id`] counter (starting at 1) and never reused, so a
/// reference to an object stays valid even as other objects are added or removed.
/// This replaces the old "index into `Board::objects`" scheme, which shifted when
/// objects were reordered/destroyed.
pub type ObjectId = u32;
/// 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`].
///
/// 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.
///
/// 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
/// source text but run with independent Rhai scopes (see [`crate::script`]).
///
/// 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,
/// 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,
/// 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,
/// Whether the object blocks line of sight / FOV for the player
pub opaque: bool,
/// Whether the object can be shoved by a mover. Unused for now (no push
/// mechanic yet); defaults to `false`.
pub pushable: bool,
/// 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>,
}
impl ObjectDef {
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
#[rustfmt::skip]
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.
///
/// 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(),
solid: true,
opaque: true,
pushable: false,
script_name: None,
}
}
}
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
///
/// At most one solid — the player, 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 player occupies the cell. The player is solid (it blocks movers) and
/// pushable in any direction (see [`Board::is_pushable`]).
Player,
/// 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.
#[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 the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies.
#[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 {
/// 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)>,
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
/// When a map declares no `[floor]`, every entry is black-on-black space (the
/// historical look of an empty cell). Read it via [`Board::display_glyph`].
pub(crate) floor: Vec<Glyph>,
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
/// round-trip it. `None` when the map declared no floor. (Generators
/// re-randomize on reload; literal glyph grids are preserved exactly.)
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
/// (not a `Vec`) so an object can be removed without invalidating other
/// objects' ids; iteration is in ascending-id order, which equals load order
/// (ids are assigned sequentially as the map loads).
pub objects: BTreeMap<ObjectId, ObjectDef>,
/// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing).
/// See [`Board::add_object`].
pub next_object_id: ObjectId,
/// Portals on this board. Parsed from the map file; not yet active.
pub portals: Vec<PortalDef>,
/// Optional font override for this board. When `None`, the app default is used.
pub font: Option<FontSpec>,
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
pub zoom: u32,
/// 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>,
/// Nonfatal problems collected while loading this map (e.g. unknown
/// archetypes, dropped objects, recovered placement chars), as red-on-black
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
/// the map file (purely a load diagnostic).
pub(crate) load_errors: Vec<LogLine>,
}
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.
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
}
/// Returns the glyph to draw for the **static layer** of cell `(x, y)` — the
/// grid archetype, or the floor underneath an [`Archetype::Empty`] cell.
///
/// This is the single source of truth front-ends use for the non-object,
/// non-player layer: a wall/crate draws its own per-cell glyph, while an empty
/// cell draws its [`floor`](Board::floor) glyph. An `Empty` cell's *own* glyph
/// is intentionally ignored — the floor layer supersedes it (so a cell vacated
/// by a pushed crate reveals the floor, not black). Panics if out of bounds.
pub fn display_glyph(&self, x: usize, y: usize) -> Glyph {
let (glyph, arch) = self.get(x, y);
if *arch == Archetype::Empty {
self.floor[y * self.width + x]
} else {
*glyph
}
}
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
///
/// Takes signed coords so callers can pass a raw `pos + delta` without first
/// checking for negatives.
pub fn in_bounds(&self, pos: (i32, i32)) -> bool {
let (x, y) = pos;
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
}
/// Records a nonfatal error: appends `message` as a red-on-black line to the
/// board's [`load_errors`](Board::load_errors). Used by the map loader (and
/// available at runtime) to surface recoverable problems.
pub fn report_error(&mut self, message: impl Into<String>) {
self.load_errors.push(LogLine::error(message));
}
/// Returns `true` if the map loaded with no nonfatal errors (the error list
/// is empty).
pub fn is_valid(&self) -> bool {
self.load_errors.is_empty()
}
/// The nonfatal errors collected while loading this board, newest last.
pub fn load_errors(&self) -> &[LogLine] {
&self.load_errors
}
/// 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
}
/// Returns the single solid entity occupying `(x, y)`, if any.
///
/// 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.
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid<'_>> {
// The player wins its cell (load-time invariant), so it is the solid there.
if self.player.x == x as i32 && self.player.y == y as i32 {
return Some(Solid::Player);
}
// A solid object shadows the grid cell it sits on.
if let Some(obj) = self.solid_object_at(x, y)
{
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));
}
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()
}
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
///
/// Non-solid things are never pushable: `pushable` only matters for solids.
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
/// can be shoved in any direction.
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
match self.solid_at(x, y) {
Some(Solid::Player) => true, // the player is pushable in any direction
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
Some(Solid::Object(o)) => o.pushable,
None => false,
}
}
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
/// edge or a non-pushable solid.
///
/// Read-only (`&self`); pairs with [`push`](Board::push). Returns `false` when
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
/// ahead shovable?" half of a "can I move here?" query.
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
let (dx, dy): (i32, i32) = dir.into();
let (mut cx, mut cy) = (x, y);
loop {
// This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) {
return false;
}
let next = (cx as i32 + dx, cy as i32 + dy);
if !self.in_bounds(next) {
return false; // chain runs off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
if self.is_passable(nx, ny) {
return true; // open space at the end: the whole chain can move
}
// Next cell holds a solid too; continue (it must itself be pushable).
cx = nx;
cy = ny;
}
}
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
/// leaving `Empty` floor behind each moved cell.
///
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
/// so it is safe to call unconditionally.
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
if !self.can_push(x, y, dir) {
return;
}
let (dx, dy): (i32, i32) = dir.into();
// can_push guaranteed the chain ends at an in-bounds passable cell, so
// re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives.
let mut chain: Vec<(usize, usize)> = Vec::new();
let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) {
chain.push((cx, cy));
cx = (cx as i32 + dx) as usize;
cy = (cy as i32 + dy) as usize;
}
for &(px, py) in chain.iter().rev() {
self.shift_solid(px, py, dx, dy);
}
}
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
///
/// A solid object is relocated; otherwise the grid archetype (a crate) is
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
// The player owns its cell, so move it before considering objects/grid.
if self.player.x == x as i32 && self.player.y == y as i32 {
self.player.x = tx as i32;
self.player.y = ty as i32;
} else if let Some(id) = self.solid_object_id_at(x, y)
{
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
obj.x = tx;
obj.y = ty;
} else {
let moved = *self.get(x, y);
*self.get_mut(tx, ty) = moved;
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
}
}
/// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any.
pub fn object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
self.objects
.iter()
.filter(|(_, o)| o.x == x && o.y == y)
.map(|(&id, _)| id).collect()
}
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn solid_object_at(&self, x: usize, y: usize) -> Option<&ObjectDef> {
self.objects.values().find(|o| o.x == x && o.y == y && o.solid)
}
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
self.objects
.iter()
.find(|(id, o)| o.x == x && o.y == y && o.solid)
.map(|(&id, _)| id)
}
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
///
/// Ids start at 1 and increase monotonically; an id is never reused, so it
/// stays a valid handle to this object for the board's lifetime.
pub fn add_object(&mut self, object: ObjectDef) -> ObjectId {
let id = self.next_object_id;
self.next_object_id += 1;
self.objects.insert(id, object);
id
}
}
use crate::board::Board;
use crate::utils::{ObjectId, Solid};
/// Holds the active game world and provides game-logic operations.
///
@@ -887,6 +189,15 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
#[cfg(test)]
mod tests {
use color::Rgba8;
use std::collections::BTreeMap;
use std::time::Duration;
use crate::archetype::Archetype;
use crate::board::Board;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, Player, Pushable, Solid};
use super::*;
/// Builds a 1×1 board with a single object that optionally references a
@@ -996,7 +307,10 @@ mod tests {
// 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)),
Some(Solid::Object(id)) => {
let o = &board.objects[&id];
assert_eq!((o.x, o.y), (2, 0))
},
_ => panic!("expected Solid::Object"),
}
assert!(!board.is_passable(2, 0));
@@ -1216,7 +530,7 @@ mod tests {
assert!(board.is_valid());
board.report_error("something went wrong");
assert!(!board.is_valid());
assert_eq!(board.load_errors().len(), 1);
assert_eq!(board.load_errors.len(), 1);
}
#[test]
@@ -1603,10 +917,11 @@ mod tests {
}
#[test]
fn display_glyph_uses_floor_for_empty_and_grid_for_solid() {
fn glyph_at_uses_floor_for_empty_and_grid_for_solid() {
// A board with a distinctive floor glyph; empty cells show the floor, while
// a wall keeps drawing its own grid glyph (floor ignored under solids).
let mut board = open_board(2, 1, (1, 0), vec![], &[]);
// Player parked at (2,0) so it doesn't overlap either asserted cell.
let mut board = open_board(3, 1, (2, 0), vec![], &[]);
let floor_glyph = Glyph {
tile: '.' as u32,
fg: Rgba8 {
@@ -1622,15 +937,15 @@ mod tests {
a: 255,
},
};
board.floor = vec![floor_glyph; 2];
board.floor = vec![floor_glyph; 3];
wall_at(&mut board, 0, 0);
assert_eq!(board.display_glyph(0, 0), Archetype::Wall.default_glyph()); // solid: grid glyph
assert_eq!(board.display_glyph(1, 0), floor_glyph); // empty: floor glyph
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph()); // solid: grid glyph
assert_eq!(board.glyph_at(1, 0), floor_glyph); // empty: floor glyph
}
#[test]
fn pushing_a_crate_reveals_the_floor_underneath() {
// The cell a crate is pushed off of becomes Empty, so its `display_glyph`
// The cell a crate is pushed off of becomes Empty, so its `glyph_at`
// should show the floor glyph rather than black-on-black.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let floor_glyph = Glyph {
@@ -1654,9 +969,10 @@ mod tests {
game.try_move(Direction::East); // player at (0,0) pushes the crate east
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
// The vacated cell (1,0) is now Empty and reveals the floor.
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.display_glyph(1, 0), floor_glyph);
// After the push the player is at (1,0); check the cell the player vacated
// (0,0) — it is Empty and must reveal the floor glyph, not black-on-black.
assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate's old cell is empty
assert_eq!(b.glyph_at(0, 0), floor_glyph); // player's old cell shows floor
}
#[test]
+49
View File
@@ -0,0 +1,49 @@
use std::hash::{Hash, Hasher};
use color::Rgba8;
/// The visual representation of a single board cell.
///
/// `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`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Glyph {
/// 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.
pub fg: Rgba8,
/// Background color, drawn as a filled rectangle behind the tile.
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 {
/// 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.
#[rustfmt::skip]
pub const fn player() -> Self {
Self {
tile: 64,
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
}
}
}
+9 -1
View File
@@ -1,6 +1,6 @@
/// The optional per-cell visual floor layer ([`floor::FloorSpec`], [`floor::build_floor`]).
pub mod floor;
/// Core game types: [`game::Board`], [`game::Glyph`], [`game::Archetype`], etc.
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
pub mod game;
/// Styled log messages ([`log::LogLine`]) for the in-game message feed.
pub mod log;
@@ -8,3 +8,11 @@ pub mod log;
pub mod map_file;
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
pub mod script;
pub mod glyph;
mod font;
mod utils;
mod archetype;
mod object_def;
mod board;
pub use board::Board;
+11 -6
View File
@@ -1,11 +1,16 @@
use crate::floor::{FloorSpec, build_floor};
use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, ObjectId, Player, PortalDef};
use crate::floor::{build_floor, FloorSpec};
use crate::board::Board;
use crate::log::LogLine;
use color::Rgba8;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::convert::TryFrom;
use std::path::Path;
use crate::archetype::Archetype;
use crate::font::FontSpec;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::utils::{ObjectId, Player, PortalDef};
/// The top-level serialization/deserialization type for a `.toml` map file.
///
@@ -701,7 +706,7 @@ pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>
#[cfg(test)]
mod tests {
use super::*;
use crate::game::Archetype;
use crate::archetype::Archetype;
/// Parses `toml` and converts it to a [`Board`], panicking on any hard error.
/// (Object/placement validation failures are non-fatal `eprintln`s, so the
@@ -981,7 +986,7 @@ O.
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
let (_, arch) = board.get(0, 0);
assert_eq!(*arch, crate::game::Archetype::ErrorBlock);
assert_eq!(*arch, Archetype::ErrorBlock);
}
#[test]
@@ -1087,12 +1092,12 @@ g.
bg: parse_color("#040506"),
};
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
assert_eq!(board.display_glyph(1, 0), fixed);
assert_eq!(board.glyph_at(1, 0), fixed);
// Save back to TOML and reload; the floor declaration must be preserved.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
let board2 = load_board(&toml_out);
assert!(board2.floor_spec.is_some());
assert_eq!(board2.display_glyph(1, 0), fixed);
assert_eq!(board2.glyph_at(1, 0), fixed);
}
}
+72
View File
@@ -0,0 +1,72 @@
use color::Rgba8;
use crate::glyph::Glyph;
/// 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`].
///
/// 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.
///
/// 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
/// source text but run with independent Rhai scopes (see [`crate::script`]).
///
/// 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,
/// 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,
/// 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,
/// Whether the object blocks line of sight / FOV for the player
pub opaque: bool,
/// Whether the object can be shoved by a mover. Unused for now (no push
/// mechanic yet); defaults to `false`.
pub pushable: bool,
/// 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>,
}
impl ObjectDef {
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
#[rustfmt::skip]
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.
///
/// 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(),
solid: true,
opaque: true,
pushable: false,
script_name: None,
}
}
}
+3 -2
View File
@@ -31,12 +31,13 @@
//! [`take_board_queue`]: ScriptHost::take_board_queue
//! [`GameState`]: crate::game::GameState
use crate::game::{Board, ObjectId};
use crate::board::Board;
use crate::log::LogLine;
use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope};
use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
use crate::utils::ObjectId;
/// How long a move occupies an object before it can act again, in seconds.
pub const MOVE_COST: f64 = 0.25;
+113
View File
@@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
use crate::archetype::Archetype;
use crate::script::Direction;
/// Which directions a solid may be pushed in.
///
/// Only meaningful for `solid` cells (a non-solid cell is passable, so nothing is
/// ever pushed into it). `Crate` is [`Pushable::Any`]; the directional crates
/// constrain pushes to one axis.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Pushable {
/// Cannot be pushed.
No,
/// Pushable in any direction.
Any,
/// Pushable east/west only.
Horizontal,
/// Pushable north/south only.
Vertical,
}
impl Pushable {
/// Whether a push in `dir` is allowed.
pub fn allows(self, dir: Direction) -> bool {
match self {
Pushable::No => false,
Pushable::Any => true,
Pushable::Horizontal => matches!(dir, Direction::East | Direction::West),
Pushable::Vertical => matches!(dir, Direction::North | Direction::South),
}
}
}
/// 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
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
/// be added here without changing call sites.
///
/// 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 {
/// 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,
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
pub pushable: Pushable,
}
/// A stable, unique identifier for a board object.
///
/// Ids are handed out by [`Board::add_object`] from the per-board
/// [`Board::next_object_id`] counter (starting at 1) and never reused, so a
/// reference to an object stays valid even as other objects are added or removed.
/// This replaces the old "index into `Board::objects`" scheme, which shifted when
/// objects were reordered/destroyed.
pub type ObjectId = u32;
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
///
/// At most one solid — the player, 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 {
/// The player occupies the cell. The player is solid (it blocks movers) and
/// pushable in any direction (see [`Board::is_pushable`]).
Player,
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
Cell(Archetype),
/// A solid [`ObjectId`] occupies the cell.
Object(ObjectId),
}
/// 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.
#[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 the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies.
#[derive(Copy, Clone)]
pub struct Player {
/// Column position (0-indexed, increasing rightward).
pub x: i32,
/// Row position (0-indexed, increasing downward).
pub y: i32,
}
+2 -11
View File
@@ -7,7 +7,7 @@
use crate::cp437::tile_to_char;
use color::Rgba8;
use kiln_core::game::{Board, Glyph};
use kiln_core::Board;
use kiln_core::log::LogLine;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -100,16 +100,7 @@ impl Widget for BoardWidget<'_> {
let bx = off_x + col;
let sx = area.x + pad_x + col as u16;
// Resolve which glyph wins this cell: player > object > grid floor.
let glyph = if board.player.x == bx as i32 && board.player.y == by as i32 {
Glyph::player()
} else if let Some(id) = board.object_ids_at(bx, by).get(0) {
board.objects[id].glyph
} else {
// Static layer: the grid archetype's glyph, or the floor layer
// for an empty cell (see `Board::display_glyph`).
board.display_glyph(bx, by)
};
let glyph = board.glyph_at(bx, by);
// Paint the resolved glyph into the terminal cell.
if let Some(cell) = buf.cell_mut((sx, sy)) {