//! Procedural floor generators. //! //! A "floor" is a purely cosmetic, non-solid visual placed on a board layer (a //! palette entry with `kind = "floor"`). 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". //! //! The actual placement / per-cell expansion happens in [`crate::layer`] during //! map load: a floor palette entry either names one of these generators (a fresh //! glyph is rolled per grid cell) or gives a fixed glyph. This module only owns //! the generators themselves. use crate::glyph::Glyph; use color::Rgba8; use tinyrand::{Probability, Rand, StdRand}; /// Fixed seed for the floor PRNG, so a board's generated floor is deterministic /// for a given map (stable across reloads within a run, and testable). The layer /// builder seeds one [`StdRand`] with this and threads it through every /// generator call so the result depends only on the map content. pub(crate) 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). pub(crate) 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, } } #[cfg(test)] mod tests { use super::*; use tinyrand::Seeded; /// Rolls `count` glyphs from `generator` against a freshly-seeded RNG, the /// same way the layer builder does. fn roll(generator: FloorGenerator, count: usize) -> Vec { let mut rng = StdRand::seed(FLOOR_SEED); (0..count).map(|_| generator.generate(&mut rng)).collect() } #[test] fn from_name_parses_known_generators() { assert_eq!( FloorGenerator::from_name("grass"), Some(FloorGenerator::Grass) ); assert_eq!( FloorGenerator::from_name("dirt"), Some(FloorGenerator::Dirt) ); assert_eq!( FloorGenerator::from_name("stone"), Some(FloorGenerator::Stone) ); assert_eq!(FloorGenerator::from_name("lava"), None); } #[test] fn grass_generator_stays_in_scheme_and_is_deterministic() { let a = roll(FloorGenerator::Grass, 64); let b = roll(FloorGenerator::Grass, 64); assert_eq!(a.len(), 64); // Same seed → identical rolls 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); } } }