2026-06-15 23:35:18 -05:00
|
|
|
|
//! Procedural floor generators.
|
2026-06-06 18:49:45 -05:00
|
|
|
|
//!
|
2026-06-15 23:35:18 -05:00
|
|
|
|
//! 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
|
2026-06-06 18:49:45 -05:00
|
|
|
|
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
|
|
|
|
|
|
//!
|
2026-06-15 23:35:18 -05:00
|
|
|
|
//! 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.
|
2026-06-06 18:49:45 -05:00
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::glyph::Glyph;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use color::Rgba8;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
|
|
|
|
|
|
|
|
|
|
|
/// 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 [`FloorGenerator`] 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: FloorGenerator,
|
|
|
|
|
|
/// 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: FloorGenerator, 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(&self, x: usize, y: usize, width: usize) -> Option<Glyph> {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
Floor::Blank => None,
|
|
|
|
|
|
Floor::Fixed(g) => Some(*g),
|
|
|
|
|
|
Floor::Biome { glyphs, .. } => glyphs.get(y * width + x).copied(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-06 18:49:45 -05:00
|
|
|
|
|
|
|
|
|
|
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// 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;
|
2026-06-06 18:49:45 -05:00
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// The generator's map-file name (inverse of [`from_name`](FloorGenerator::from_name)),
|
|
|
|
|
|
/// re-emitted on save so a biome floor round-trips.
|
|
|
|
|
|
pub fn name(&self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
FloorGenerator::Grass => "grass",
|
|
|
|
|
|
FloorGenerator::Dirt => "dirt",
|
|
|
|
|
|
FloorGenerator::Stone => "stone",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:49:45 -05:00
|
|
|
|
/// 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).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
pub(crate) fn generate(&self, rng: &mut StdRand) -> Glyph {
|
2026-06-06 18:49:45 -05:00
|
|
|
|
// (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::*;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
use tinyrand::Seeded;
|
2026-06-06 18:49:45 -05:00
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// 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()
|
2026-06-06 18:49:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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);
|
2026-06-06 18:49:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn grass_generator_stays_in_scheme_and_is_deterministic() {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let a = roll(FloorGenerator::Grass, 64);
|
|
|
|
|
|
let b = roll(FloorGenerator::Grass, 64);
|
2026-06-06 18:49:45 -05:00
|
|
|
|
assert_eq!(a.len(), 64);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
// Same seed → identical rolls across builds.
|
2026-06-06 18:49:45 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|