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.
15 kB · 381 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382//! Scene assembly: snapshot + camera + raycast + shade → frame buffer//! (docs/05 §5.2 steps 1–5, wiring-plan §3).//!//! The single render entry point is [`render`]: for each cell column-half it//! casts a primary ray ([`ray`](crate::ray)), shades the hit//! ([`shade`](crate::shade)), snaps depth discontinuities to edge-art,//! composites snapshot billboards brightest-first, and applies stratum fog//! as a POST-shade lerp ([`apply_fog`]). Output is a//! [`FrameBuffer`](crate::FrameBuffer); ANSI encoding is [`ansi`](crate::ansi).//!//! # Ownership map (reviewer MINOR-2 arbitration)//! Stratum fog is a post-lerp on `shade()`'s fg/bg toward the active//! [`Palette`](crate::palette) fog color at the fog-density step — NOT a//! pre-lerp of material bases (unimplementable via `shade()`'s signature,//! whose base lookup is internal). Bases stay canonical so screenshot hashes//! pin one table; fog varies per stratum without touching it.//!//! # Camera routing (reviewer MINOR-4)//! Only [`Camera::FirstPerson`] / [`Camera::ChaseOrbit`] route through//! [`ray::ray_dir`] (perspective). [`Camera::TopTactical`] is orthographic//! and renders via [`render_tactical`] (plan-view glyphs, no rays) —//! tactical-ortho raycasting is a tracked follow-up, not this function.
use crate::camera::View;use crate::palette::Palette;pub use crate::production::{ ascii_scene, edge_join, eligibility_samples, entity_eligible, render_scene, sample_visible, semantic_ascii, semantic_color, EntityDescriptor, HalfSample, RenderScratch, SceneBounds, SemanticRole,};use crate::ray::{self, Frustum};use crate::shade::{self, actor_glyph};use crate::{Camera, Cell, FrameBuffer};
/// Max ray distance per quality tier (docs/05 §5.2 step 2).pub const MAX_DIST: f32 = 48.0;
/// Lerp one channel toward fog by `t` (0 = full color, 1 = full fog).fn lerp_ch(c: u8, f: u8, t: f32) -> u8 { let t = t.clamp(0.0, 1.0); (f32::from(c) + (f32::from(f) - f32::from(c)) * t).round() as u8}
/// Post-shade stratum fog: lerp fg/bg toward the palette fog color for the/// hit's stratum at the fog-density step (docs/05 §5.1 fog strata)./// Pure function — the same inputs always yield the same outputs.pub fn apply_fog(fg: [u8; 3], bg: [u8; 3], fog: [u8; 3], fog_frac: f32) -> ([u8; 3], [u8; 3]) { let t = fog_frac.clamp(0.0, 1.0); ( [ lerp_ch(fg[0], fog[0], t), lerp_ch(fg[1], fog[1], t), lerp_ch(fg[2], fog[2], t), ], [ lerp_ch(bg[0], fog[0], t), lerp_ch(bg[1], fog[1], t), lerp_ch(bg[2], fog[2], t), ], )}
/// Fog color for a world z against the palette (Shallows above −30,/// Works −30..−90, Deep below — provisional bands until worldgen strata/// volumes land in Phase 10; documented here so the hash rig pins it).pub fn fog_for_z(pal: &Palette, z: f32) -> [u8; 3] { if z < -90.0 { pal.fog_deep } else if z < -30.0 { pal.fog_works } else { pal.fog_shallows }}
/// One shaded half-block sample plus its display depth (INFINITY = miss).struct Sample { cell: Cell, dist: f32, face: Option<ray::Face>,}
fn miss_sample() -> Sample { Sample { cell: Cell { glyph: ' ', fg: [0, 0, 0], bg: [0, 0, 0], }, dist: f32::INFINITY, face: None, }}
/// Render one frame: perspective raycast + shade + edge-art + fog into `fb`/// (docs/05 §5.2 steps 2–5). `lights` maps world cells to 24-step levels;/// pass `&[(x, y, z, level)]` sampled from the sim light plane./// `actors` are `(species, pos, stance, lit, phase)` billboards composited/// brightest-first after opaques (docs/05 §5.2 step 4).////// Two passes, both deterministic: pass 1 shades every half-block sample;/// pass 2 snaps horizontal depth discontinuities to edge-art via/// [`shade::edge_glyph`] (right-neighbor diff) and packs bright-over-dim/// into cells.pub fn render( grid: &hexwork_core::voxel::ChunkedGrid, view: &View, pal: &Palette, fb: &mut FrameBuffer, lights: &[(i32, i32, i32, u8)], actors: &[(&str, [f32; 3], hexwork_core::actor::Stance, bool, u8)],) { assert!( !view.is_orthographic(), "perspective render only; use render_tactical for TopTactical (MINOR-4)" ); // View → Frustum: View clamps FOV (except NaN); Frustum::new asserts. // NaN FOV is a CLI-boundary error — reject here, never panic deep. assert!( view.fov_deg.is_finite(), "NaN FOV must be validated at the CLI boundary (MINOR-3)" ); if fb.w == 0 || fb.h == 0 { return; } let frustum = Frustum::new(fb.w, fb.h, view.fov_deg, view.yaw, view.pitch); // Pass 1: shade every half-block sample. let mut samples = Vec::with_capacity(fb.w * fb.h * 2); for y in 0..fb.h { for x in 0..fb.w { for half in 0..2 { let dir = ray::ray_dir(&frustum, x, y * 2 + half); match ray::cast(grid, view.eye, dir, MAX_DIST) { None => samples.push(miss_sample()), Some(hit) => { let fog_frac = (hit.dist / MAX_DIST).clamp(0.0, 1.0); let warm = hit.face == ray::Face::ZPos; // Solid voxels stop the light flood: their visible face // receives light from the adjacent air cell, not the rock. let (dx, dy, dz) = match hit.face { ray::Face::XPos => (1, 0, 0), ray::Face::XNeg => (-1, 0, 0), ray::Face::YPos => (0, 1, 0), ray::Face::YNeg => (0, -1, 0), ray::Face::ZPos => (0, 0, 1), ray::Face::ZNeg => (0, 0, -1), }; let lvl = light_at(lights, hit.x, hit.y, hit.z).max(light_at( lights, hit.x + dx, hit.y + dy, hit.z + dz, )); let (glyph, fg, bg) = shade::shade(&hit, lvl, fog_frac, warm); let (fg, bg) = apply_fog(fg, bg, fog_for_z(pal, hit.z as f32), fog_frac); samples.push(Sample { cell: Cell { glyph, fg, bg }, dist: hit.dist, face: Some(hit.face), }); } } } } } let at = |x: usize, y: usize, half: usize, w: usize| (y * w + x) * 2 + half; // Pass 2: edge-art snap (right-neighbor depth diff per half-row) + pack. let mut depths = vec![f32::INFINITY; fb.w * fb.h]; for y in 0..fb.h { for x in 0..fb.w { for half in 0..2 { let i = at(x, y, half, fb.w); if samples[i].face.is_none() { continue; } // Right-neighbor diff; at the right edge compare the two // halves of this cell instead (never out of bounds). let ndist = if x + 1 < fb.w { samples[at(x + 1, y, half, fb.w)].dist } else { samples[at(x, y, 1 - half, fb.w)].dist }; let diff = (samples[i].dist - ndist).abs(); if let Some(face) = samples[i].face { if let Some(edge) = shade::edge_glyph(face, diff) { samples[i].cell.glyph = edge; } } } // Brightest half-block wins the cell (simple, deterministic). let top = &samples[at(x, y, 0, fb.w)]; let bottom = &samples[at(x, y, 1, fb.w)]; let bright = if brightness(top.cell) >= brightness(bottom.cell) { top } else { bottom }; let i = y * fb.w + x; fb.cells[i] = Cell { glyph: bright.cell.glyph, fg: bright.cell.fg, // Density and line-art glyphs need their shaded, dark partner. // The other sample's foreground is only meaningful with an // actual oriented half-block glyph; using it here erases flat // surfaces and edges (fg ~= bg). bg: bright.cell.bg, }; depths[i] = bright.dist; } } // Billboards brightest-first: sort far→near so near overwrites far. // Deterministic by (dist desc, index asc). let mut order: Vec<(f32, usize)> = actors .iter() .enumerate() .map(|(i, a)| { let dx = a.1[0] - view.eye[0]; let dy = a.1[1] - view.eye[1]; let dz = a.1[2] - view.eye[2]; (dx * dx + dy * dy + dz * dz, i) }) .collect(); order.sort_by(|a, b| { b.0.partial_cmp(&a.0) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.1.cmp(&b.1)) }); for (_, i) in order { let (species, pos, stance, lit, phase) = &actors[i]; // Project: crude pinhole onto the cell grid (deterministic; the // full billboard projection lands with the threaded renderer). let dx = pos[0] - view.eye[0]; let dy = pos[1] - view.eye[1]; if dx <= 0.1 || fb.w == 0 { continue; } let col = ((dy / dx * fb.w as f32 * 0.5 + fb.w as f32 * 0.5) as usize).min(fb.w - 1); let row = (fb.h / 2).min(fb.h - 1); let idx = row * fb.w + col; let dist_sq = dx * dx + dy * dy + (pos[2] - view.eye[2]).powi(2); if dist_sq.sqrt() < depths[idx] { fb.cells[idx].glyph = actor_glyph(species, *stance, *lit, *phase); } }}
/// Immutable active-location bounds (inclusive) and authoritative player cell.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct TacticalMapContext { pub min: [i32; 3], pub max: [i32; 3], pub player_cell: [i32; 3],}
/// Terrain-only orthographic map at the explicit selected body plane./// Shares the bounded map classifier, without location bounds or a marker.pub fn render_tactical(grid: &hexwork_core::voxel::ChunkedGrid, view: &View, fb: &mut FrameBuffer) { render_tactical_inner(grid, view, None, fb);}
/// Bounded selected-body-plane map: `#` body/head obstruction, `~` water,/// `:` gas, `.` occupied support, and space for unsupported air/outside bounds./// Support is not a complete movement-legality claim. Only the player's own/// on-slice position is overlaid; no other entity or reveal policy is inferred.pub fn render_tactical_map( grid: &hexwork_core::voxel::ChunkedGrid, view: &View, context: &TacticalMapContext, fb: &mut FrameBuffer,) { render_tactical_inner(grid, view, Some(context), fb);}
fn tactical_contains(context: Option<&TacticalMapContext>, cell: [i32; 3]) -> bool { context.map_or(true, |c| { (0..3).all(|i| c.min[i] <= cell[i] && cell[i] <= c.max[i]) })}
fn tactical_center(value: f32) -> Option<i32> { let value = f64::from(value); (value.is_finite() && value >= f64::from(i32::MIN) && value <= f64::from(i32::MAX)) .then_some(value as i32)}
fn tactical_terrain( grid: &hexwork_core::voxel::ChunkedGrid, context: Option<&TacticalMapContext>, cell: [i32; 3],) -> char { if !tactical_contains(context, cell) { return ' '; } let [x, y, z] = cell; let body = grid.get_world(x, y, z); let occupied = |z| tactical_contains(context, [x, y, z]) && grid.get_world(x, y, z).occ != 0; if body.occ != 0 || z.checked_add(1).is_some_and(occupied) { '#' } else if body.fluid_kind == 1 && body.fluid_amt > 0 { '~' } else if body.gas_kind != 0 && body.gas_amt > 0 { ':' } else if z.checked_sub(1).is_some_and(occupied) { '.' } else { ' ' }}
fn render_tactical_inner( grid: &hexwork_core::voxel::ChunkedGrid, view: &View, context: Option<&TacticalMapContext>, fb: &mut FrameBuffer,) { assert!( view.is_orthographic(), "render_tactical needs a TopTactical view" ); let slice_z = view.tactical_slice_z.expect( "TopTactical view must carry explicit tactical_slice_z metadata; construct it with View::new or top_tactical", ); fb.cells.fill(Cell { glyph: ' ', fg: [200, 200, 200], bg: [0, 0, 0], }); if fb.w == 0 || fb.h == 0 { return; } let (Some(cx), Some(cy)) = (tactical_center(view.eye[0]), tactical_center(view.eye[1])) else { return; }; if context.is_some_and(|c| { (0..3).any(|i| c.min[i] > c.max[i]) || slice_z < c.min[2] || slice_z > c.max[2] }) { return; } // i128 accommodates usize framebuffer offsets plus any i32 world center. let left = i128::from(cx) - (fb.w / 2) as i128; let top = i128::from(cy) - (fb.h / 2) as i128; for y in 0..fb.h { for x in 0..fb.w { let (Ok(wx), Ok(wy)) = ( i32::try_from(left + x as i128), i32::try_from(top + y as i128), ) else { continue; }; fb.cells[y * fb.w + x].glyph = tactical_terrain(grid, context, [wx, wy, slice_z]); } } // Constant-time overlay, on precisely the same projection as terrain. if let Some(c) = context { if c.player_cell[2] == slice_z && tactical_contains(context, c.player_cell) { let x = i128::from(c.player_cell[0]) - left; let y = i128::from(c.player_cell[1]) - top; if x >= 0 && x < fb.w as i128 && y >= 0 && y < fb.h as i128 { fb.cells[y as usize * fb.w + x as usize].glyph = '@'; } } }}
fn brightness(c: Cell) -> u16 { u16::from(c.fg[0]) + u16::from(c.fg[1]) + u16::from(c.fg[2])}
fn light_at(lights: &[(i32, i32, i32, u8)], x: i32, y: i32, z: i32) -> u8 { lights .iter() .find(|l| l.0 == x && l.1 == y && l.2 == z) .map(|l| l.3) .unwrap_or(0)}
/// Silence the unused [`Camera`] import warning when docs link it.#[allow(dead_code)]fn _camera_doc_link() { let _ = Camera::FirstPerson;}