diff --git a/CLAUDE.md b/CLAUDE.md index 8a2fa78..88471e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` + `floor_spec: Option` (the visual floor layer — see `floor.rs`), `player: Player`, `objects: BTreeMap` (keyed by stable id; iterates in ascending-id = load order), `next_object_id: ObjectId` (counter starting at 1), `portals: Vec`, `font: Option`. `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` 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` 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`); `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`. `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>`, the message `log: Vec`, and a `ScriptHost`. The board sits behind `Rc>` 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_mut() -> RefMut` (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`. `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` + `floor_spec: Option` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap`, `next_object_id: ObjectId`, `portals: Vec`, `font: Option`, `zoom: u32` (integer tile scale factor), `scripts: HashMap` (named Rhai source text; script name → source), `board_script_name: Option` (name of a board-level script, if any), `load_errors: Vec` (`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` — 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>`, `log: Vec`, and `scripts: ScriptHost`. Front-ends reach the board through `board() -> Ref` / `board_mut() -> RefMut`. `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. diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs new file mode 100644 index 0000000..1938cb2 --- /dev/null +++ b/kiln-core/src/archetype.rs @@ -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 { + 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}")), + } + } +} diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs new file mode 100644 index 0000000..87127af --- /dev/null +++ b/kiln-core/src/board.rs @@ -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, + /// 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, + /// 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, + /// 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, + /// Optional font override for this board. When `None`, the app default is used. + pub font: Option, + /// 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, + /// 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, + /// 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, +} + +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) { + 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 { + // 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 { + 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 { + 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 + } +} \ No newline at end of file diff --git a/kiln-core/src/floor.rs b/kiln-core/src/floor.rs index 716021d..4f1af9b 100644 --- a/kiln-core/src/floor.rs +++ b/kiln-core/src/floor.rs @@ -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` on the [`Board`](crate::game::Board); generators are run during +//! a `Vec` 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). diff --git a/kiln-core/src/font.rs b/kiln-core/src/font.rs new file mode 100644 index 0000000..6a76891 --- /dev/null +++ b/kiln-core/src/font.rs @@ -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, +} \ No newline at end of file diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 98e1f3f..563f467 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -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(&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 { - 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, -} - -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, - /// 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, - /// 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, - /// 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, - /// Optional font override for this board. When `None`, the app default is used. - pub font: Option, - /// 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, - /// 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, - /// 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, -} - -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) { - 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> { - // 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 { - 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 { - 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 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] diff --git a/kiln-core/src/glyph.rs b/kiln-core/src/glyph.rs new file mode 100644 index 0000000..798c890 --- /dev/null +++ b/kiln-core/src/glyph.rs @@ -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(&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 + } + } +} \ No newline at end of file diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 8ce6552..cd5cb5a 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -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; \ No newline at end of file diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 06fe1c6..96a3b9a 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -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 #[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); } } diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs new file mode 100644 index 0000000..37368f8 --- /dev/null +++ b/kiln-core/src/object_def.rs @@ -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, +} + +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, + } + } +} \ No newline at end of file diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 384c427..8d997cd 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -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; diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs new file mode 100644 index 0000000..d8fa314 --- /dev/null +++ b/kiln-core/src/utils.rs @@ -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, +} \ No newline at end of file diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 36b0045..bca3331 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -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)) {