diff --git a/CLAUDE.md b/CLAUDE.md index a6cf908..875cdad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,12 +54,18 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. - `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), `player: Player`, `objects: Vec`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `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". +- `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: Vec`, `portals: Vec`, `font: Option`. `cells`, `floor`, and `floor_spec` are `pub(crate)`. `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 index 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. +**`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. +- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name; `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe) seeded with a fixed `FLOOR_SEED` for deterministic, testable output. +- `FloorSpec` — `#[serde(untagged)]` enum for the `[floor]` declaration: `Generator(String)` (whole board), `Single(FloorGlyph)` (one fixed glyph everywhere), or `Grid { content, palette: HashMap }` (a grid with its own palette). Untagged ordering matters: a string → `Generator`; a table with `tile` → `Single`; a table with `content` → `Grid`. `FloorTile` (untagged) is a grid-palette entry: a generator name (string) or a `FloorGlyph` (table). `FloorGlyph { tile: TileIndex, fg, bg }` reuses `map_file::TileIndex` (now `pub`) and `parse_color`. +- `build_floor(&Option, w, h, &mut Vec) -> Vec` — best-effort/nonfatal (like the map loader): unknown generators/chars and grid-dimension mismatches are recorded on the error vec and that cell falls back to the canonical black-on-black space; `None` → an all-empty floor (the historical look). + **`kiln-core/src/log.rs`** — styled log messages: - `LogSpan { text, fg: Option, bg: Option }` and `LogLine { spans: Vec }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time. @@ -184,6 +190,26 @@ tile_h = 16 "-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # crate, pushable east/west only "|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # crate, pushable north/south only +# Optional visual floor layer (drawn under every empty cell). Four forms: +# floor = "grass" # 1. one generator for the whole board +# [floor] # 2. one fixed glyph everywhere +# tile = "." fg = "#1c3a1c" bg = "#0a1a0a" +# [floor] # 3. a grid with its own palette +# content = """...""" # (must match width/height) +# [floor.palette] # entries are generator names OR glyphs +# "g" = "grass" # generator: "grass" | "dirt" | "stone" +# "." = { tile = ".", fg = "#222", bg = "#000" } +# (Omitting [floor] entirely = black-on-black space, the default.) +[floor] +content = """ +gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg +gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg +gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg +""" +[floor.palette] +"g" = "grass" +"d" = "dirt" + [grid] content = """ ############################################################ @@ -229,11 +255,14 @@ fn bump(id) { log(`bumped by ${id}`); } """ ``` +The optional `[floor]` section declares the visual floor layer (drawn under every empty cell, and revealed when a crate/object is pushed away); see `floor.rs`. It takes one of four forms: omitted (black-on-black space, the default), a generator name (`floor = "grass"`), a single fixed glyph (`[floor]` with `tile`/`fg`/`bg`), or a grid (`[floor] content` + `[floor.palette]`, whose entries are generator names or fixed glyphs). The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: an unknown generator/char or a grid-dimension mismatch is recorded on `Board::load_errors` and those cells fall back to the default empty glyph. + Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **array order wins** (the earlier object lands; the later is pushed or blocked) and the object that was moved into receives `bump(id)`. ### 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`. - **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/floor.rs b/kiln-core/src/floor.rs new file mode 100644 index 0000000..716021d --- /dev/null +++ b/kiln-core/src/floor.rs @@ -0,0 +1,399 @@ +//! The floor layer: a per-cell visual background drawn wherever a board cell +//! would otherwise render as [`Archetype::Empty`](crate::game::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 +//! 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::log::LogLine; +use crate::map_file::{TileIndex, parse_color}; +use color::Rgba8; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use tinyrand::{Probability, Rand, Seeded, StdRand}; + +/// 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). +const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001; + +/// A procedural floor texture. Each generator differs only in its color scheme +/// and the probability/character set of its scattered "texture" glyphs; the +/// colors are deliberately dark and low-saturation so foreground objects stay +/// readable against them. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FloorGenerator { + /// Random green-to-greenish-yellow ground with a fairly high chance of grassy + /// characters (comma, period, backquote, apostrophe). + Grass, + /// Random brown ground with a lower chance of `.`, `:`, `,`, `;`. + Dirt, + /// Random gray ground with a lower chance of `.` or `,`. + Stone, +} + +impl FloorGenerator { + /// Parses a generator from its map-file name, or `None` if unrecognized. + pub fn from_name(name: &str) -> Option { + match name { + "grass" => Some(FloorGenerator::Grass), + "dirt" => Some(FloorGenerator::Dirt), + "stone" => Some(FloorGenerator::Stone), + _ => None, + } + } + + /// Generates one random floor [`Glyph`] for this generator. + /// + /// Picks a background ground color within the generator's scheme, then with + /// the generator's probability scatters a lighter "texture" char on top + /// (otherwise a blank space, whose fg is irrelevant). + fn generate(&self, rng: &mut StdRand) -> Glyph { + // (background color ranges, texture probability, texture chars). + let (bg, chars, prob) = match self { + // Green → greenish-yellow: g dominant, a touch of r for the yellow tilt. + FloorGenerator::Grass => ( + Rgba8 { + r: shade(rng, 20, 55), + g: shade(rng, 45, 85), + b: shade(rng, 20, 35), + a: 255, + }, + [',', '.', '`', '\''].as_slice(), + 0.35, + ), + // Brown: r highest, g mid, b low. + FloorGenerator::Dirt => ( + Rgba8 { + r: shade(rng, 45, 75), + g: shade(rng, 30, 50), + b: shade(rng, 18, 32), + a: 255, + }, + ['.', ':', ',', ';'].as_slice(), + 0.20, + ), + // Gray: all channels share one shade. + FloorGenerator::Stone => { + let v = shade(rng, 40, 70); + ( + Rgba8 { + r: v, + g: v, + b: v, + a: 255, + }, + ['.', ','].as_slice(), + 0.20, + ) + } + }; + // Decide whether this cell shows a texture char or just bare ground. + if rng.next_bool(Probability::new(prob)) { + let ch = chars[rng.next_lim_usize(chars.len())]; + Glyph { + tile: ch as u32, + fg: lighten(bg, 35), // a lighter shade of the same ground + bg, + } + } else { + // Bare ground: a space (its fg never shows). + Glyph { + tile: 32, + fg: bg, + bg, + } + } + } +} + +/// Returns a random channel value in `[lo, hi]` (inclusive). +fn shade(rng: &mut StdRand, lo: u8, hi: u8) -> u8 { + lo + rng.next_lim_usize((hi - lo) as usize + 1) as u8 +} + +/// Brightens each color channel by `amt`, saturating at 255 — used to draw a +/// texture char as a lighter version of its ground color. +fn lighten(c: Rgba8, amt: u8) -> Rgba8 { + Rgba8 { + r: c.r.saturating_add(amt), + g: c.g.saturating_add(amt), + b: c.b.saturating_add(amt), + a: c.a, + } +} + +/// A fixed floor glyph declared in a map file: visual only, no archetype. +/// +/// Reuses [`TileIndex`] (so `tile` accepts a char or an int) and `"#RRGGBB"` +/// color strings, exactly like a `[palette]` entry minus the archetype. +#[derive(Deserialize, Serialize, Clone)] +pub struct FloorGlyph { + /// Tile index into the board's font. Accepts an integer or single-char string. + pub tile: TileIndex, + /// Foreground color as an `"#RRGGBB"` hex string. + pub fg: String, + /// Background color as an `"#RRGGBB"` hex string. + pub bg: String, +} + +impl FloorGlyph { + /// Converts this declaration into a concrete [`Glyph`]. + fn to_glyph(&self) -> Glyph { + Glyph { + tile: self.tile.into_u32(), + fg: parse_color(&self.fg), + bg: parse_color(&self.bg), + } + } +} + +/// One entry in a floor grid's palette: either a generator name or a fixed glyph. +/// +/// `#[serde(untagged)]`: a TOML string parses as [`FloorTile::Generator`]; a TOML +/// table parses as [`FloorTile::Glyph`]. +#[derive(Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum FloorTile { + /// A generator name, e.g. `"grass"`. + Generator(String), + /// A fixed glyph definition. + Glyph(FloorGlyph), +} + +/// The optional `[floor]` declaration in a map file, in one of four shapes. +/// +/// `#[serde(untagged)]`, so the variants are distinguished by TOML shape — order +/// matters: a string hits [`FloorSpec::Generator`]; a table with a `tile` key hits +/// [`FloorSpec::Single`]; a table with a `content` key hits [`FloorSpec::Grid`]. +/// (Absence of a `[floor]` section is represented by `Option::None` on the map +/// file, not a variant here, and yields a default black-on-black floor.) +#[derive(Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum FloorSpec { + /// A single generator name covering the whole board, e.g. `floor = "grass"`. + Generator(String), + /// A single fixed glyph filling every floor cell. + Single(FloorGlyph), + /// A grid (like `[grid]`) with its own palette of generators and/or glyphs. + Grid { + /// Multi-line grid string; one char per cell, looked up in `palette`. + content: String, + /// Char → [`FloorTile`] lookup for the grid. + palette: HashMap, + }, +} + +/// The canonical "empty" floor glyph: a black-on-black space. This is the default +/// floor (when a map declares no `[floor]`) and the fallback for any cell that +/// can't be resolved. +fn empty_glyph() -> Glyph { + Archetype::Empty.default_glyph() +} + +/// Builds the cached per-cell floor `Vec` (row-major, `width * height`) +/// from an optional [`FloorSpec`]. +/// +/// Best-effort and nonfatal: any problem (unknown generator/char, grid-dimension +/// mismatch) is recorded on `errors` and that cell falls back to [`empty_glyph`], +/// so the board still loads. With no spec, the floor is all black-on-black space +/// (the previous behavior, where `Empty` cells rendered as nothing). +pub fn build_floor( + spec: &Option, + width: usize, + height: usize, + errors: &mut Vec, +) -> Vec { + let mut rng = StdRand::seed(FLOOR_SEED); + let count = width * height; + match spec { + // No declaration: the historical default of black-on-black whitespace. + None => vec![empty_glyph(); count], + // One generator for the whole board: run it once per cell. + Some(FloorSpec::Generator(name)) => match FloorGenerator::from_name(name) { + Some(generator) => (0..count).map(|_| generator.generate(&mut rng)).collect(), + None => { + errors.push(LogLine::error(format!("unknown floor generator '{name}'"))); + vec![empty_glyph(); count] + } + }, + // One fixed glyph: parse once, fill every cell. + Some(FloorSpec::Single(glyph)) => vec![glyph.to_glyph(); count], + // A grid with its own palette of generators and/or fixed glyphs. + Some(FloorSpec::Grid { content, palette }) => { + build_grid_floor(content, palette, width, height, &mut rng, errors) + } + } +} + +/// Builds the floor for a [`FloorSpec::Grid`]: each grid character is looked up in +/// `palette` and resolved to a glyph (generators run per-cell). Dimension +/// mismatches and unknown chars/generators are reported once and fall back to the +/// empty glyph. +fn build_grid_floor( + content: &str, + palette: &HashMap, + width: usize, + height: usize, + rng: &mut StdRand, + errors: &mut Vec, +) -> Vec { + // Key the palette by char for O(1) per-cell lookup. + let by_char: HashMap = palette + .iter() + .filter_map(|(k, v)| k.chars().next().map(|c| (c, v))) + .collect(); + + let rows: Vec<&str> = content.lines().collect(); + if rows.len() != height { + errors.push(LogLine::error(format!( + "floor grid has {} rows but the board is {height} tall; missing cells use empty floor", + rows.len() + ))); + } + + // Report each unknown char / bad generator only once, not per occurrence. + let mut reported: HashSet = HashSet::new(); + let mut floor = Vec::with_capacity(width * height); + for y in 0..height { + // `chars().nth(x)` is O(x) but floors are small; this keeps the lookup + // correct for multi-byte chars (a byte index would not). + let row: Vec = rows.get(y).map(|r| r.chars().collect()).unwrap_or_default(); + for x in 0..width { + let glyph = match row.get(x) { + Some(&ch) => match by_char.get(&ch) { + Some(FloorTile::Glyph(g)) => g.to_glyph(), + Some(FloorTile::Generator(name)) => match FloorGenerator::from_name(name) { + Some(generator) => generator.generate(rng), + None => { + if reported.insert(ch) { + errors.push(LogLine::error(format!( + "floor palette '{ch}' names unknown generator '{name}'; using empty floor" + ))); + } + empty_glyph() + } + }, + None => { + if reported.insert(ch) { + errors.push(LogLine::error(format!( + "unknown floor grid character '{ch}'; using empty floor" + ))); + } + empty_glyph() + } + }, + // Row missing or too short: padded with empty floor (already reported above). + None => empty_glyph(), + }; + floor.push(glyph); + } + } + floor +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses a `FloorSpec` from a standalone TOML snippet of the form + /// `floor = ...` / `[floor]\n...`, returning the parsed spec. + fn parse_spec(toml: &str) -> FloorSpec { + #[derive(Deserialize)] + struct Wrap { + floor: FloorSpec, + } + toml::from_str::(toml) + .expect("parse floor spec") + .floor + } + + #[test] + fn no_spec_yields_all_empty_floor() { + let mut errs = Vec::new(); + let floor = build_floor(&None, 3, 2, &mut errs); + assert_eq!(floor.len(), 6); + assert!(floor.iter().all(|g| *g == empty_glyph())); + assert!(errs.is_empty()); + } + + #[test] + fn single_glyph_fills_every_cell() { + let spec = parse_spec("[floor]\ntile = \".\"\nfg = \"#112233\"\nbg = \"#445566\"\n"); + let mut errs = Vec::new(); + let floor = build_floor(&Some(spec), 2, 2, &mut errs); + let expected = Glyph { + tile: '.' as u32, + fg: parse_color("#112233"), + bg: parse_color("#445566"), + }; + assert_eq!(floor.len(), 4); + assert!(floor.iter().all(|g| *g == expected)); + } + + #[test] + fn grass_generator_stays_in_scheme_and_is_deterministic() { + let mut errs = Vec::new(); + let a = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs); + let b = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs); + assert_eq!(a.len(), 64); + // Same seed → identical floors across builds. + assert!(a.iter().zip(&b).all(|(x, y)| x == y)); + // Every cell's ground (bg) sits inside grass's channel ranges. + for g in &a { + assert!((20..=55).contains(&g.bg.r), "r out of range: {}", g.bg.r); + assert!((45..=85).contains(&g.bg.g), "g out of range: {}", g.bg.g); + assert!((20..=35).contains(&g.bg.b), "b out of range: {}", g.bg.b); + } + assert!(errs.is_empty()); + } + + #[test] + fn unknown_generator_falls_back_and_reports() { + let mut errs = Vec::new(); + let floor = build_floor(&Some(FloorSpec::Generator("lava".into())), 2, 1, &mut errs); + assert!(floor.iter().all(|g| *g == empty_glyph())); + assert_eq!(errs.len(), 1); + } + + #[test] + fn grid_resolves_glyph_and_generator_entries() { + let spec = parse_spec( + "[floor]\ncontent = \"\"\"\ng.\n.g\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n\".\" = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n", + ); + let mut errs = Vec::new(); + let floor = build_floor(&Some(spec), 2, 2, &mut errs); + assert!(errs.is_empty()); + let fixed = Glyph { + tile: '#' as u32, + fg: parse_color("#010203"), + bg: parse_color("#040506"), + }; + // (0,0) and (1,1) are grass; (1,0) and (0,1) are the fixed glyph. + assert_eq!(floor[1], fixed); + assert_eq!(floor[2], fixed); + assert!((20..=55).contains(&floor[0].bg.r)); // grass ground + assert!((20..=55).contains(&floor[3].bg.r)); + } + + #[test] + fn grid_dimension_mismatch_is_nonfatal() { + // Declares 1 row for a 2-tall board: missing row is padded with empty floor. + let spec = parse_spec( + "[floor]\ncontent = \"\"\"\ngg\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n", + ); + let mut errs = Vec::new(); + let floor = build_floor(&Some(spec), 2, 2, &mut errs); + assert_eq!(floor.len(), 4); + assert_eq!(errs.len(), 1); // the row-count mismatch was reported + assert_eq!(floor[2], empty_glyph()); // padded second row + assert_eq!(floor[3], empty_glyph()); + } +} diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 29c200c..0e68e96 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -20,7 +20,7 @@ use std::time::Duration; /// `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)] +#[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, @@ -428,6 +428,16 @@ pub struct Board { /// 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. Parsed from the map file; not yet active. @@ -472,6 +482,23 @@ impl Board { &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 @@ -836,6 +863,8 @@ mod tests { width: 1, height: 1, cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)], + floor: vec![Archetype::Empty.default_glyph()], + floor_spec: None, player: Player { x: 0, y: 0 }, objects: vec![object], portals: Vec::new(), @@ -872,6 +901,8 @@ mod tests { width: w, height: h, cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h], + floor: vec![Archetype::Empty.default_glyph(); w * h], + floor_spec: None, player: Player { x: player.0, y: player.1, @@ -1352,16 +1383,25 @@ mod tests { let mut game = GameState::new(board); game.run_init(); // First (eastward) move is blocked by the wall: object hasn't moved. - assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1)); + assert_eq!( + (game.board().objects[0].x, game.board().objects[0].y), + (1, 1) + ); // The blocked move charged the cooldown, so South is still pending here. game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100)); - assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1)); + assert_eq!( + (game.board().objects[0].x, game.board().objects[0].y), + (1, 1) + ); // Past 250 ms the queued South move resolves. game.tick(Duration::from_millis(100)); - assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 2)); + assert_eq!( + (game.board().objects[0].x, game.board().objects[0].y), + (1, 2) + ); } #[test] @@ -1373,7 +1413,10 @@ mod tests { 1, (0, 0), vec![scripted_object(1, 0, "q")], - &[("q", "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }")], + &[( + "q", + "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", + )], ); let mut game = GameState::new(board); game.run_init(); @@ -1405,7 +1448,10 @@ mod tests { 1, (0, 0), vec![scripted_object(1, 0, "b")], - &[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")], + &[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )], ); wall_at(&mut board, 2, 0); let mut game = GameState::new(board); @@ -1418,7 +1464,10 @@ mod tests { 1, (0, 0), vec![scripted_object(1, 0, "b")], - &[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")], + &[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )], ); let mut game = GameState::new(board); game.run_init(); @@ -1507,6 +1556,63 @@ mod tests { assert!(log_texts(&game).iter().any(|t| t == "bumped by -1")); } + #[test] + fn display_glyph_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![], &[]); + let floor_glyph = Glyph { + tile: '.' as u32, + fg: Rgba8 { + r: 10, + g: 20, + b: 30, + a: 255, + }, + bg: Rgba8 { + r: 1, + g: 2, + b: 3, + a: 255, + }, + }; + board.floor = vec![floor_glyph; 2]; + 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 + } + + #[test] + fn pushing_a_crate_reveals_the_floor_underneath() { + // The cell a crate is pushed off of becomes Empty, so its `display_glyph` + // should show the floor glyph rather than black-on-black. + let mut board = open_board(4, 1, (0, 0), vec![], &[]); + let floor_glyph = Glyph { + tile: ',' as u32, + fg: Rgba8 { + r: 40, + g: 60, + b: 40, + a: 255, + }, + bg: Rgba8 { + r: 5, + g: 10, + b: 5, + a: 255, + }, + }; + board.floor = vec![floor_glyph; 4]; + crate_at(&mut board, 1, 0); + let mut game = GameState::new(board); + 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); + } + #[test] fn solid_at_reports_player() { // The player's own cell is solid (and thus impassable to movers). diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index eff07f6..8ce6552 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -1,3 +1,5 @@ +/// 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. pub mod game; /// Styled log messages ([`log::LogLine`]) for the in-game message feed. diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 2dd10ba..614d0d4 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -1,3 +1,4 @@ +use crate::floor::{FloorSpec, build_floor}; use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef}; use crate::log::LogLine; use color::Rgba8; @@ -25,6 +26,10 @@ pub struct MapFile { /// Optional `[font]` section specifying a bitmap font for this board. #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, + /// Optional `[floor]` declaration: the visual floor layer (a generator name, + /// a single glyph, or a grid with its own palette). See [`FloorSpec`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub floor: Option, /// Any `[[objects]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) objects: Vec, @@ -76,9 +81,9 @@ pub struct FontHeader { /// `tile = " "` for convenience (the char is converted to its Unicode scalar). /// This means existing maps that used `ch = "#"` can be migrated by renaming /// the key to `tile`; the char form keeps working. -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone, Copy)] #[serde(untagged)] -pub(crate) enum TileIndex { +pub enum TileIndex { /// A direct tile index (e.g. `tile = 35`). Num(u32), /// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar. @@ -195,7 +200,7 @@ fn is_zoom_default(z: &u32) -> bool { /// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`]. /// Returns opaque black on any parse failure. -fn parse_color(hex: &str) -> Rgba8 { +pub(crate) fn parse_color(hex: &str) -> Rgba8 { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { return Rgba8 { @@ -212,7 +217,7 @@ fn parse_color(hex: &str) -> Rgba8 { } /// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored). -fn color_to_hex(color: Rgba8) -> String { +pub(crate) fn color_to_hex(color: Rgba8) -> String { format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b) } @@ -409,6 +414,12 @@ impl TryFrom for Board { tile_h: f.tile_h, }); + // Build the cached floor layer from the (optional) `[floor]` declaration. + // Best-effort: any problem is recorded on `load_errors`, not fatal. The + // raw spec is kept on the board so a save can round-trip it. + let floor_spec = mf.floor; + let floor = build_floor(&floor_spec, w, h, &mut load_errors); + // Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell. // `solid_occupied` is seeded from the grid (solid archetypes); each solid // object then claims its cell, and a second solid on a cell is dropped. @@ -513,6 +524,8 @@ impl TryFrom for Board { width: w, height: h, cells, + floor, + floor_spec, player: Player { x: px as i32, y: py as i32, @@ -648,6 +661,9 @@ impl From<&Board> for MapFile { palette, grid: GridData { content }, font, + // Round-trip the original floor declaration verbatim (generators + // re-randomize on the next load; literal glyph grids are exact). + floor: board.floor_spec.clone(), objects, portals, scripts: board.scripts.clone(), @@ -1026,4 +1042,52 @@ bg = "#000000" assert_eq!(obj2.glyph.fg, obj.glyph.fg); assert_eq!(obj2.glyph.bg, obj.glyph.bg); } + + #[test] + fn floor_spec_round_trips_through_toml() { + // A 2×2 board with a grid floor (a generator and a fixed glyph) must survive + // save→load with its `[floor]` declaration intact, and the reloaded board's + // computed floor must place the fixed glyph back at its declared cell. + let toml = r##" +[map] +name = "Test" +width = 2 +height = 2 +player_start = [0, 0] + +[palette] +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +.. +.. +""" + +[floor] +content = """ +g. +.g +""" + +[floor.palette] +"g" = "grass" +"." = { tile = "#", fg = "#010203", bg = "#040506" } +"##; + let board = load_board(toml); + assert!(board.floor_spec.is_some()); + let fixed = Glyph { + tile: '#' as u32, + fg: parse_color("#010203"), + bg: parse_color("#040506"), + }; + // (1,0) is the fixed `.` floor glyph (the cell archetype is Empty). + assert_eq!(board.display_glyph(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); + } } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index e3390a7..0000264 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -212,16 +212,16 @@ impl ScriptHost { } Err(err) => { failed.insert(name.clone()); - errors - .borrow_mut() - .push(LogLine::raw(format!("script '{name}' failed to compile: {err}"))); + errors.borrow_mut().push(LogLine::raw(format!( + "script '{name}' failed to compile: {err}" + ))); } }, None => { failed.insert(name.clone()); - errors - .borrow_mut() - .push(LogLine::raw(format!("object references unknown script '{name}'"))); + errors.borrow_mut().push(LogLine::raw(format!( + "object references unknown script '{name}'" + ))); } } } @@ -294,12 +294,17 @@ impl ScriptHost { return; } let options = CallFnOptions::default().with_tag(object_index as i64); - if let Err(err) = - engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, "bump", (bumper,)) - { - errors - .borrow_mut() - .push(LogLine::raw(format!("script '{}' bump error: {err}", obj.script_name))); + if let Err(err) = engine.call_fn_with_options::<()>( + options, + &mut obj.scope, + &compiled.ast, + "bump", + (bumper,), + ) { + errors.borrow_mut().push(LogLine::raw(format!( + "script '{}' bump error: {err}", + obj.script_name + ))); } } @@ -347,7 +352,10 @@ impl ScriptHost { if cost > 0.0 { self.objects[i].ready_timer = cost; } - self.board_queue.borrow_mut().push(BoardAction { source: oid, action }); + self.board_queue.borrow_mut().push(BoardAction { + source: oid, + action, + }); // A timed action ends the pump; zero-cost ones keep draining. if cost > 0.0 { break; @@ -360,7 +368,12 @@ impl ScriptHost { /// source), then pump that object so backlogged actions drain and later objects' /// `blocked` checks can see earlier movers. Runtime errors are captured rather than /// aborting the batch. - fn run(&mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A) { + fn run( + &mut self, + hook: &str, + defined: fn(&CompiledScript) -> bool, + args: A, + ) { for i in 0..self.objects.len() { // Scope the split borrow so it ends before the `pump` reborrow below. { @@ -377,9 +390,13 @@ impl ScriptHost { { // The tag carries this object's index to the host functions. let options = CallFnOptions::default().with_tag(obj.object_index as i64); - if let Err(err) = - engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, hook, args) - { + if let Err(err) = engine.call_fn_with_options::<()>( + options, + &mut obj.scope, + &compiled.ast, + hook, + args, + ) { errors.borrow_mut().push(LogLine::raw(format!( "script '{}' {hook} error: {err}", obj.script_name @@ -461,9 +478,16 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { }); let q = queues.clone(); - engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| { - emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string()))); - }); + engine.register_fn( + "log", + move |ctx: NativeCallContext, msg: ImmutableString| { + emit( + &q, + source_of(&ctx), + Action::Log(LogLine::raw(msg.to_string())), + ); + }, + ); } /// Registers the `Queue` object: a handle to an object's output queue with `length()` diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index c20b1f5..0c1d114 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -106,7 +106,9 @@ impl Widget for BoardWidget<'_> { } else if let Some(obj) = board.object_at(bx, by) { obj.glyph } else { - board.get(bx, by).0 + // Static layer: the grid archetype's glyph, or the floor layer + // for an empty cell (see `Board::display_glyph`). + board.display_glyph(bx, by) }; // Paint the resolved glyph into the terminal cell. diff --git a/maps/start.toml b/maps/start.toml index 832e663..e1b62d9 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -12,6 +12,46 @@ player_start = "@" # placed by the '@' grid char (convention) "-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } "|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } +# Optional visual floor layer, drawn under every empty cell (and revealed when a +# crate is pushed away). This is a grid form with its own palette: `g`/`d`/`s` +# name the built-in generators (random dark, low-saturation texture cached at +# load); a palette entry could also be a fixed glyph like +# `{ tile = ".", fg = "#222", bg = "#000" }`. +[floor] +content = """ +gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg +gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg +gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg +gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg +gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg +gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg +gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg +gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg +""" + +[floor.palette] +"g" = "grass" +"d" = "dirt" +"s" = "stone" +" " = { tile = "~", fg = "#6666ff", bg = "#000066" } + [grid] content = """ ############################################################