Files
kiln/kiln-core/src/floor.rs
T
2026-08-11 01:02:38 -05:00

244 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::board_spec`] 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 serde::{Deserialize, Serialize};
use tinyrand::{Probability, Rand, Seeded, StdRand};
use crate::utils::Point;
/// A board's floor: the cosmetic backdrop drawn beneath everything, replacing the
/// old dedicated floor *layer*. A board has exactly one [`Floor`] (see
/// [`Board::floor`](crate::board::Board)), given in the map file's `[map]` header
/// as an optional `floor = { … }` attribute.
///
/// Three forms: [`Blank`](Floor::Blank) (the canonical empty/black cell shows
/// through), [`Fixed`](Floor::Fixed) (one glyph tiled across the whole board), or
/// [`Biome`](Floor::Biome) (a procedural [`FloorBiome`] texture). A biome keeps
/// its generator (so save re-emits the generator name) alongside a per-cell glyph
/// buffer pre-rolled once at load from [`FLOOR_SEED`] — deterministic, and the
/// direct replacement for the old per-cell floor-layer rolling.
#[derive(Clone)]
pub enum Floor {
/// No floor: the canonical black empty cell shows.
Blank,
/// A single fixed glyph tiled across the whole board.
Fixed(Glyph),
/// A procedural biome floor: the `generator` (retained for save) plus a
/// `glyphs` buffer holding one pre-rolled glyph per cell (row-major).
Biome {
/// The generator this biome was built from; re-emitted on save.
generator: FloorBiome,
/// One pre-rolled glyph per cell (`width * height`, row-major).
glyphs: Vec<Glyph>,
},
}
impl Default for Floor {
/// A board with no declared floor is [`Floor::Blank`].
fn default() -> Self {
Floor::Blank
}
}
impl Floor {
/// Builds a [`Floor::Biome`] for a `width × height` board, pre-rolling one glyph
/// per cell from a [`FLOOR_SEED`]-seeded PRNG (so the result is deterministic and
/// depends only on the board dimensions + generator).
pub(crate) fn biome(generator: FloorBiome, width: usize, height: usize) -> Floor {
let mut rng = StdRand::seed(FLOOR_SEED);
let glyphs = (0..width * height).map(|_| generator.generate(&mut rng)).collect();
Floor::Biome { generator, glyphs }
}
/// The floor glyph to draw at `(x, y)`, or `None` for [`Floor::Blank`].
///
/// `width` is the board width, needed to index a biome's row-major buffer.
pub(crate) fn glyph_at<P: Into<Point>>(&self, p: P, width: usize) -> Option<Glyph> {
let p = p.into();
match self {
Floor::Blank => None,
Floor::Fixed(g) => Some(*g),
Floor::Biome { glyphs, .. } => glyphs.get(p.uy() * width + p.ux()).copied(),
}
}
}
/// 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, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FloorBiome {
/// 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 FloorBiome {
/// Parses a generator from its map-file name, or `None` if unrecognized.
pub fn from_name(name: &str) -> Option<FloorBiome> {
match name {
"grass" => Some(FloorBiome::Grass),
"dirt" => Some(FloorBiome::Dirt),
"stone" => Some(FloorBiome::Stone),
_ => None,
}
}
/// The generator's map-file name (inverse of [`from_name`](FloorBiome::from_name)),
/// re-emitted on save so a biome floor round-trips.
pub fn name(&self) -> &'static str {
match self {
FloorBiome::Grass => "grass",
FloorBiome::Dirt => "dirt",
FloorBiome::Stone => "stone",
}
}
/// 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.
FloorBiome::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.
FloorBiome::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.
FloorBiome::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,
fg: lighten(bg, 35), // a lighter shade of the same ground
bg,
}
} else {
// Bare ground: a literal space, which paints over whatever is
// beneath rather than revealing it (unlike the transparent sentinel).
Glyph {
tile: ' ',
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: FloorBiome, 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!(
FloorBiome::from_name("grass"),
Some(FloorBiome::Grass)
);
assert_eq!(
FloorBiome::from_name("dirt"),
Some(FloorBiome::Dirt)
);
assert_eq!(
FloorBiome::from_name("stone"),
Some(FloorBiome::Stone)
);
assert_eq!(FloorBiome::from_name("lava"), None);
}
#[test]
fn grass_generator_stays_in_scheme_and_is_deterministic() {
let a = roll(FloorBiome::Grass, 64);
let b = roll(FloorBiome::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);
}
}
}