Floor layer

This commit is contained in:
2026-06-06 18:49:45 -05:00
parent a5f60e22e1
commit 374a69f0ca
8 changed files with 699 additions and 33 deletions
+30 -1
View File
@@ -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<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec<LogLine>`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors".
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (the visual floor layer — see `floor.rs`), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `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<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec<LogLine>`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors".
- `Player``x: i32, y: i32`
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option<String>`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)`/`bump(id)` hooks are run by the scripting runtime (see `script.rs`).
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible) and fires `bump(-1)` on a solid object it walks into; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` advances each object's cooldown then runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue (`take_board_queue()`) and applies each `Action`: `Log``log`, `SetTile` → the source object's glyph, `Move(dir)` → the free fn `step_object` (gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates, and returns the 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<String, FloorTile> }` (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<FloorSpec>, w, h, &mut Vec<LogLine>) -> Vec<Glyph>` — 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<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — 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.
+399
View File
@@ -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<Glyph>` 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<FloorGenerator> {
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<String, FloorTile>,
},
}
/// 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<Glyph>` (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<FloorSpec>,
width: usize,
height: usize,
errors: &mut Vec<LogLine>,
) -> Vec<Glyph> {
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<String, FloorTile>,
width: usize,
height: usize,
rng: &mut StdRand,
errors: &mut Vec<LogLine>,
) -> Vec<Glyph> {
// Key the palette by char for O(1) per-cell lookup.
let by_char: HashMap<char, &FloorTile> = 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<char> = 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<char> = 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::<Wrap>(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());
}
}
+113 -7
View File
@@ -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<Glyph>,
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
/// round-trip it. `None` when the map declared no floor. (Generators
/// re-randomize on reload; literal glyph grids are preserved exactly.)
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board. 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).
+2
View File
@@ -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.
+68 -4
View File
@@ -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<FontHeader>,
/// 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<FloorSpec>,
/// Any `[[objects]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) objects: Vec<MapFileObjectEntry>,
@@ -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<MapFile> 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<MapFile> 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);
}
}
+44 -20
View File
@@ -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<A: FuncArgs + Copy>(&mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A) {
fn run<A: FuncArgs + Copy>(
&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()`
+3 -1
View File
@@ -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.
+40
View File
@@ -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 = """
############################################################