Files
kiln/kiln-core/src/floor.rs
T
2026-06-15 23:35:18 -05:00

173 lines
6.2 KiB
Rust

//! 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<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).
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<Glyph> {
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);
}
}
}