400 lines
15 KiB
Rust
400 lines
15 KiB
Rust
|
|
//! The floor layer: a per-cell visual background drawn wherever a board cell
|
||
|
|
//! would otherwise render as [`Archetype::Empty`](crate::game::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<Glyph>` on the [`Board`](crate::game::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::game::{Archetype, Glyph};
|
||
|
|
use crate::log::LogLine;
|
||
|
|
use crate::map_file::{TileIndex, parse_color};
|
||
|
|
use color::Rgba8;
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::collections::{HashMap, HashSet};
|
||
|
|
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
||
|
|
|
||
|
|
/// 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<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).
|
||
|
|
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<String, FloorTile>,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<Glyph>` (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<FloorSpec>,
|
||
|
|
width: usize,
|
||
|
|
height: usize,
|
||
|
|
errors: &mut Vec<LogLine>,
|
||
|
|
) -> Vec<Glyph> {
|
||
|
|
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<String, FloorTile>,
|
||
|
|
width: usize,
|
||
|
|
height: usize,
|
||
|
|
rng: &mut StdRand,
|
||
|
|
errors: &mut Vec<LogLine>,
|
||
|
|
) -> Vec<Glyph> {
|
||
|
|
// Key the palette by char for O(1) per-cell lookup.
|
||
|
|
let by_char: HashMap<char, &FloorTile> = 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<char> = 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<char> = 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::<Wrap>(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());
|
||
|
|
}
|
||
|
|
}
|