//! The floor layer: a per-cell visual background drawn wherever a board cell //! 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::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::archetype::Archetype; use crate::log::LogLine; 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). 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()); } }