Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
16 kB · 385 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386//! Glyph + color selection per ray hit (docs/05 §5.1, §5.2 step 3).//!//! Render-purity law (docs/05 §5.4.3): [`shade`], [`edge_glyph`], and//! [`actor_glyph`] are pure functions of hit + light + camera. No sim//! access, no RNG, no hidden state — the same snapshot + camera always//! yields the same cell, which is what makes screenshot-hash tests//! meaningful. Facing and background arithmetic is integer; lit foreground//! intensity is sRGB-encoded for terminal display. Fog/depth thresholds//! quantize through the 10-step density ramp.
use hexwork_core::actor::Stance;use hexwork_core::voxel::material;
use crate::density_glyph;use crate::ray::{Face, RayHit};
/// Depth discontinuity (in voxels) above which a neighbor pair snaps to/// line-art instead of the density ramp (docs/05 §5.1 "Edges as/// line-art").////// Pinned value: **0.25 voxels** — an order of magnitude above DDA/// boundary-graze jitter (~1/1024 voxel) and well below a genuine/// one-voxel step, so flat walls never sprout edges while stair lips,/// doorframes, and occlusion boundaries always do. The parent's/// screenshot-hash rig pins this constant; change it only with a hash/// re-baseline.pub const EDGE_DEPTH_EPS: f32 = 0.25;
/// Hard-occlusion depth (in voxels) at which an edge snaps to `'+'`./// Derived: `8 × EDGE_DEPTH_EPS` = **2.0 voxels** (docs/05 §5.1: `+`/// marks corners/occlusions, not mere creases).pub const EDGE_CORNER_DEPTH: f32 = 2.0;
/// Linear material reflectance coefficients, encoded to terminal truecolor/// after illumination (docs/05 §5.2 step 3, terminal exposure convention).////// Shallows-default palette: warm browns dominate; cool materials (iron,/// glass, bone) read by brightness + glyph redundancy, never hue alone/// (docs/05 §5.5 colorblind path). Stratum fog is parent-side POST-shade:/// the parent lerps [`shade`]'s fg/bg toward the active [`crate::palette`]/// fog color at the fog-density step (see `scene::apply_fog`); bases stay/// canonical so screenshot hashes pin one table.////// Pinned values (parent's screenshot-hash rig pins this table):////// | id | material | RGB |/// |----|------------|----------------|/// | 0 | AIR | [0, 0, 0] (void; misses never shade) |/// | 1 | WOOD | [139, 90, 43] |/// | 2 | IRON | [112, 112, 120]|/// | 3 | COPPER | [184, 115, 51] |/// | 4 | STONE | [125, 105, 85] |/// | 5 | BRASS | [181, 137, 66] |/// | 6 | FLESH | [178, 118, 96] |/// | 7 | SAINT_BONE | [226, 214, 188]|/// | 8 | GLASS | [170, 205, 215]|/// | 9 | ROPE | [150, 120, 70] |/// | 10 | PAPER | [210, 195, 160]|/// | _ | unknown | [255, 0, 255] magenta tell — unknown materials must/// scream, never blend in ("no silent rules", docs/04 §4.10 rule 5).pub fn material_base(material: u8) -> [u8; 3] { match material { material::AIR => [0, 0, 0], material::WOOD => [139, 90, 43], material::IRON => [112, 112, 120], material::COPPER => [184, 115, 51], material::STONE => [125, 105, 85], material::BRASS => [181, 137, 66], material::FLESH => [178, 118, 96], material::SAINT_BONE => [226, 214, 188], material::GLASS => [170, 205, 215], material::ROPE => [150, 120, 70], material::PAPER => [210, 195, 160], _ => [255, 0, 255], }}
/// Encode a linear material × illumination intensity for an sRGB terminal./// Sending the linear product directly as an RGB byte applies the display's/// transfer curve a second time: medium illumination becomes almost black./// Material bases are linear reflectance coefficients (0..255); the sim's/// 24 light steps remain unchanged. Zero intensity remains exactly black.fn light_scale(channel: u8, light: u16) -> u8 { crate::transfer::encode(u16::from(channel) * light.min(24))}
/// Shade a hit into `(glyph, fg, bg)` (docs/05 §5.2 step 3).////// - Glyph: 10-step density ramp selected by `fog_frac` (0 = near,/// 1 = far), reusing [`crate::density_glyph`] (docs/05 §5.1 "Depth as/// density")./// - Brightness: material base × 24-step light level/// (`hexwork_core::light::MAX_LIGHT`), integer multiply — every cell's/// brightness is computed light, quantized (docs/05 §5.1 "Light as/// truth"). Levels above 24 clamp./// - Facing tint (docs/05 §5.2 step 3): pass `facing_warm = true` for/// lantern-warmed upward faces (`Face::ZPos`): R × 110%, B × 95%./// Downward faces (`Face::ZNeg`, `facing_warm = false`) go cold:/// R × 85%, B × 105%. Side faces are neutral. Integer math, clamped./// - Darkness crush (docs/05 §5.2 step 5): `light == 0` snaps to/// near-black blue fg `[3, 3, 8]` on black — darkness feels total and/// **never invents detail** (darkness-is-honest, docs/05 §5.4.3:/// underexposed cells show nothing the sim does not vouch for)./// - `bg` is the quarter-bright partner for half-block gradient cells/// (docs/05 §5.1 "Faces as half-block gradients" hook).pub fn shade( hit: &RayHit, light: u8, fog_frac: f32, facing_warm: bool,) -> (char, [u8; 3], [u8; 3]) { let glyph = density_glyph(fog_frac); let light = light.min(24); if light == 0 { return (glyph, [3, 3, 8], [0, 0, 0]); } let [r, g, b] = material_base(hit.material); let (r, g, b) = if facing_warm { ( (u16::from(r) * 110 / 100).min(255) as u8, g, (u16::from(b) * 95 / 100) as u8, ) } else if hit.face == Face::ZNeg { ( (u16::from(r) * 85 / 100) as u8, g, (u16::from(b) * 105 / 100).min(255) as u8, ) } else { (r, g, b) }; let l = u16::from(light); let fg = [light_scale(r, l), light_scale(g, l), light_scale(b, l)]; let bg = [ (u16::from(r) * l / 96) as u8, (u16::from(g) * l / 96) as u8, (u16::from(b) * l / 96) as u8, ]; (glyph, fg, bg)}
/// Snap a depth discontinuity to line-art (docs/05 §5.1 "Edges as/// line-art", §5.2 step 3).////// Returns `Some(glyph)` when the ray grazed a voxel boundary OR the/// neighbor depth discontinuity exceeds [`EDGE_DEPTH_EPS`]; `None`/// means "take the density path" (caller falls back to [`shade`])./// `neighbor_depth_diff` is in voxels (absolute value is used; NaN and/// non-finite inputs yield `None` — never a panic).////// Glyph selection (per-hit information only):/// - vertical faces (`X`/`Y`) → `'│'` (doorframes, wall edges, cables);/// - horizontal faces (`Z`) → `'─'` (stair lips, floor seams);/// - discontinuity ≥ [`EDGE_CORNER_DEPTH`] (2.0 voxels, hard occlusion)/// → `'+'` (corner/occlusion junction)./// - Slope (`/`, `\`) and box-join (`┌┐└┘├┤`) selection needs a 2-D/// screen-space neighborhood (adjacent faces + edge orientation) and/// is therefore parent-side; this function is the per-hit gate, not/// the join resolver.pub fn edge_glyph(face: Face, neighbor_depth_diff: f32) -> Option<char> { if !neighbor_depth_diff.is_finite() { return None; } let d = neighbor_depth_diff.abs(); if d <= EDGE_DEPTH_EPS { return None; } if d >= EDGE_CORNER_DEPTH { return Some('+'); } match face { Face::XNeg | Face::XPos | Face::YNeg | Face::YPos => Some('│'), Face::ZNeg | Face::ZPos => Some('─'), }}
/// Actor billboard glyph by species + posture (docs/05 §5.1 "Actors as/// glyphs-with-posture").////// Posture overrides species (posture reads at distance; docs/05 §5.1):/// climb → `'≋'`, swim → `'≈'`, rope → `'|'`, prone → `'–'` (U+2013)./// Rope's two-cell `|o` composition (docs/05 §5.1) is caller-side: this/// returns the `'|'` rope cell and the caller draws the `'o'` head cell/// from the actor's head offset. Crouch keeps the species glyph (the/// smaller hitbox is sim-side; callers may dim it).////// Species table (canonical lowercase; caller normalizes). This table IS/// the no-camouflage-kill enforcement point (docs/05 §5.4.2): the lethal/// set (`'r'` cable-rat, `'V'` Vendric incl. the `"mimic"` alias) shares/// no glyph with the harmless set (`'@'`, `'&'`, `'%'`, `'§'`, `'O'`,/// `'✦'`). Unknown species fall back to `'?'`, which is in neither set/// and must be rendered threat-bright by the caller — unknown reads as/// threat until identified, so a new lethal can never camouflage as/// furniture.////// `lit` and `anim_phase` are accepted (and currently ignored for the/// glyph itself) to keep the pure-function signature stable for the/// render-purity law: player gold-lit vs dim-blue-dark, rat charge/// sparks, bell-clapper `~` swing, burning `*` flicker, blessed `.`/// halo, and cursed `:` drip are all caller-side color/overlay/// compositions, never glyph swaps — so the glyph stream stays a pure/// function of species + stance while animation lives in color.pub fn actor_glyph(species: &str, stance: Stance, lit: bool, anim_phase: u8) -> char { let _ = (lit, anim_phase); match stance { Stance::Climb => return '≋', Stance::Swim => return '≈', Stance::Rope => return '|', Stance::Prone => return '–', Stance::Stand | Stance::Crouch => {} } match species { "player" | "human" | "@" => '@', "rat" | "cable-rat" | "cable_rat" => 'r', "vendric" | "mimic" => 'V', "bell" | "drowned" | "drowned-bell" | "drowned_bell" => 'O', "cherub" => '✦', "townsfolk" | "townsperson" | "villager" => '&', "warden" => '§', "tinker" => '%', _ => '?', }}
#[cfg(test)]mod tests { use super::*; use hexwork_core::voxel::material;
fn hit_with(face: Face, mat: u8) -> RayHit { RayHit { dist: 3.0, x: 5, y: 0, z: 0, face, material: mat, } }
#[test] fn shade_ramp_endpoints() { let hit = hit_with(Face::XNeg, material::STONE); let (near, _, _) = shade(&hit, 24, 0.0, false); let (far, _, _) = shade(&hit, 24, 1.0, false); assert_eq!(near, '█'); assert_eq!(far, ' '); }
#[test] fn shade_near_lit_vs_far_dark_differ() { let hit = hit_with(Face::XNeg, material::STONE); let (g_near, fg_near, _) = shade(&hit, 24, 0.0, false); let (g_far, fg_far, _) = shade(&hit, 3, 0.9, false); assert_ne!(g_near, g_far); let bright = |c: [u8; 3]| u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]); assert!(bright(fg_near) > bright(fg_far)); }
#[test] fn light_zero_is_near_black() { // Darkness-is-honest (docs/05 §5.4.3): underexposed never invents. let hit = hit_with(Face::XNeg, material::SAINT_BONE); let (_, fg, bg) = shade(&hit, 0, 0.0, true); assert_eq!(fg, [3, 3, 8]); assert_eq!(bg, [0, 0, 0]); assert!(fg.iter().all(|&c| c <= 8)); }
#[test] fn facing_tint_ordering_top_warmer_than_bottom() { let top = hit_with(Face::ZPos, material::STONE); let side = hit_with(Face::XNeg, material::STONE); let bottom = hit_with(Face::ZNeg, material::STONE); let (_, warm, _) = shade(&top, 24, 0.0, true); let (_, neutral, _) = shade(&side, 24, 0.0, false); let (_, cold, _) = shade(&bottom, 24, 0.0, false); assert!(warm[0] > neutral[0], "{warm:?} vs {neutral:?}"); assert!(neutral[0] > cold[0], "{neutral:?} vs {cold:?}"); // Exact pinned values for STONE [125, 105, 85]: R×110% = 137, // neutral 125, R×85% = 106. assert_eq!(warm[0], 194); assert_eq!(neutral[0], 186); assert_eq!(cold[0], 173); }
#[test] fn light_scale_monotonic_and_srgb_at_full() { let hit = hit_with(Face::XNeg, material::COPPER); let (_, full, _) = shade(&hit, 24, 0.0, false); // Linear copper reflectance encoded for the terminal, not raw bytes. assert_eq!(full, [221, 179, 124]); let (_, half, _) = shade(&hit, 12, 0.0, false); let sum = |c: [u8; 3]| u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]); assert!(sum(full) > sum(half)); assert!(sum(half) > 0); // Over-range light clamps to full (24-step law). let (_, over, _) = shade(&hit, 255, 0.0, false); assert_eq!(over, full); }
#[test] fn edge_threshold_boundary() { assert_eq!(EDGE_DEPTH_EPS, 0.25); assert_eq!(edge_glyph(Face::XNeg, EDGE_DEPTH_EPS), None); assert_eq!(edge_glyph(Face::XNeg, 0.0), None); assert_eq!(edge_glyph(Face::XNeg, -0.2), None); assert_eq!(edge_glyph(Face::XNeg, f32::NAN), None); assert_eq!(edge_glyph(Face::XNeg, f32::INFINITY), None); assert!(edge_glyph(Face::XNeg, EDGE_DEPTH_EPS + 0.01).is_some()); }
#[test] fn edge_face_mapping() { assert_eq!(edge_glyph(Face::XNeg, 0.5), Some('│')); assert_eq!(edge_glyph(Face::YPos, 0.5), Some('│')); assert_eq!(edge_glyph(Face::ZNeg, 0.5), Some('─')); assert_eq!(edge_glyph(Face::ZPos, 0.5), Some('─')); // Hard occlusion (≥ 2.0 voxels) snaps to the corner junction. assert_eq!(edge_glyph(Face::XNeg, 2.0), Some('+')); assert_eq!(edge_glyph(Face::ZPos, 5.0), Some('+')); }
#[test] fn actor_species_table() { let lit = true; assert_eq!(actor_glyph("player", Stance::Stand, lit, 0), '@'); assert_eq!(actor_glyph("rat", Stance::Stand, lit, 0), 'r'); assert_eq!(actor_glyph("cable-rat", Stance::Stand, lit, 0), 'r'); assert_eq!(actor_glyph("vendric", Stance::Stand, lit, 0), 'V'); assert_eq!(actor_glyph("bell", Stance::Stand, lit, 0), 'O'); assert_eq!(actor_glyph("cherub", Stance::Stand, lit, 0), '✦'); assert_eq!(actor_glyph("townsfolk", Stance::Stand, lit, 0), '&'); assert_eq!(actor_glyph("warden", Stance::Stand, lit, 0), '§'); assert_eq!(actor_glyph("tinker", Stance::Stand, lit, 0), '%'); }
#[test] fn actor_posture_overrides_species() { assert_eq!(actor_glyph("player", Stance::Climb, true, 0), '≋'); assert_eq!(actor_glyph("rat", Stance::Swim, true, 0), '≈'); assert_eq!(actor_glyph("player", Stance::Rope, true, 0), '|'); assert_eq!(actor_glyph("vendric", Stance::Prone, true, 0), '–'); // Crouch keeps the species glyph. assert_eq!(actor_glyph("tinker", Stance::Crouch, true, 0), '%'); }
#[test] fn actor_unknown_fallback_is_distinct() { // No-camouflage-kill (docs/05 §5.4.2): '?' is in neither the // lethal set {'r', 'V'} nor any harmless glyph. let q = actor_glyph("dragon", Stance::Stand, true, 0); assert_eq!(q, '?'); for known in ['@', 'r', 'V', 'O', '✦', '&', '§', '%'] { assert_ne!(q, known); } }
#[test] fn material_palette_covers_all_eleven() { let mut seen = [[0u8; 3]; 11]; for m in 0..=10u8 { seen[usize::from(m)] = material_base(m); } for (i, a) in seen.iter().enumerate() { for (j, b) in seen.iter().enumerate() { if i != j { assert_ne!(a, b, "materials {i} and {j} share {a:?}"); } } } assert_eq!(material_base(99), [255, 0, 255]); }
#[test] fn shade_is_pure_same_inputs_same_outputs() { let hit = hit_with(Face::YPos, material::BRASS); assert_eq!(shade(&hit, 17, 0.4, true), shade(&hit, 17, 0.4, true)); assert_eq!( actor_glyph("bell", Stance::Stand, false, 3), actor_glyph("bell", Stance::Stand, false, 7) ); }}