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.
22 kB · 597 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598//! Production scene path; compatibility scene::render remains unchanged.use crate::{ camera::View, palette::Palette, projection::Projection, ray, scene, shade, Camera, Cell, FrameBuffer,};use hexwork_core::voxel::ChunkedGrid;#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum SemanticRole { Player, Actor, Threat, Npc, Item, Connector, Interaction,}/// Anchor is centered feet/access cell; eligibility uses role-defined samples.#[derive(Clone, Copy, Debug)]pub struct EntityDescriptor { pub id: u64, pub anchor: [f32; 3], pub height: f32, pub glyph: char, pub role: SemanticRole, pub local_light: u8,}#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct SceneBounds { pub min: [i32; 3], pub max: [i32; 3],}impl SceneBounds { pub fn contains(&self, cell: [i32; 3]) -> bool { (0..3).all(|i| self.min[i] <= cell[i] && cell[i] <= self.max[i]) } pub fn contains_point(&self, p: [f32; 3]) -> bool { p.iter().all(|v| v.is_finite()) && self.contains(p.map(|v| v.floor() as i32)) }}#[derive(Clone, Copy, Debug)]pub struct HalfSample { pub hit: Option<ray::RayHit>, pub world: [f32; 3], pub ray: [f32; 3], pub distance: f32, pub color: [u8; 3], pub role: Option<SemanticRole>, pub entity_id: Option<u64>, pub glyph: char,}impl Default for HalfSample { fn default() -> Self { Self { hit: None, world: [0.0; 3], ray: [0.0; 3], distance: f32::INFINITY, color: [0; 3], role: None, entity_id: None, glyph: ' ', } }}#[derive(Default, Debug)]pub struct RenderScratch { samples: Vec<HalfSample>, lights: Vec<(i32, i32, i32, u8)>, roles: Vec<Option<SemanticRole>>, brute_billboards: bool,}impl RenderScratch { /// Reference-validation hook: disable only billboard coarse bounds. #[doc(hidden)] pub fn set_brute_billboards_for_testing(&mut self, brute: bool) { self.brute_billboards = brute; } pub fn half_samples(&self) -> &[HalfSample] { &self.samples } pub fn cell_roles(&self) -> &[Option<SemanticRole>] { &self.roles } fn light(&self, p: [i32; 3]) -> u8 { self.lights .binary_search_by_key(&(p[0], p[1], p[2]), |v| (v.0, v.1, v.2)) .ok() .map_or(0, |i| self.lights[i].3) }}/// Role-defined eligibility points: actor torso/head clamped to body height,/// ground item +0.15m, connector/interaction access center +0.5m.pub fn eligibility_samples(e: &EntityDescriptor) -> [[f32; 3]; 2] { let offsets = match e.role { SemanticRole::Player | SemanticRole::Actor | SemanticRole::Threat | SemanticRole::Npc => [ (e.height * 0.5).min(0.9), (e.height - 0.1).max(0.0).min(1.6), ], SemanticRole::Item => [0.15; 2], _ => [0.5; 2], }; offsets.map(|z| [e.anchor[0], e.anchor[1], e.anchor[2] + z])}/// Exact single-sample LOS/range/bounds predicate, independent of camera./// Pair this with positive light at `point` before accepting the sample.pub fn sample_visible( grid: &ChunkedGrid, eye: [f32; 3], point: [f32; 3], bounds: &SceneBounds,) -> bool { if !bounds.contains_point(eye) || !bounds.contains_point(point) { return false; } let delta = std::array::from_fn(|i| point[i] - eye[i]); let distance = delta.iter().map(|v| v * v).sum::<f32>().sqrt(); distance.is_finite() && distance <= scene::MAX_DIST && (distance < 0.01 || ray::cast(grid, eye, delta, distance).is_none_or(|hit| hit.dist + 0.01 >= distance))}
/// Camera-independent eligibility. local_light is the positive local level/// supplied for the entity; callers needing per-sample light must test those/// sample levels before calling. Active location membership is caller-owned.pub fn entity_eligible( grid: &ChunkedGrid, player_eye: [f32; 3], entity: &EntityDescriptor, bounds: &SceneBounds,) -> bool { if !entity.height.is_finite() || entity.height <= 0.0 || entity.local_light == 0 || !bounds.contains_point(entity.anchor) || !bounds.contains_point(player_eye) { return false; } eligibility_samples(entity).into_iter().any(|p| { if !bounds.contains_point(p) { return false; } let delta = std::array::from_fn(|i| p[i] - player_eye[i]); let distance = delta.iter().map(|v| v * v).sum::<f32>().sqrt(); distance.is_finite() && distance <= scene::MAX_DIST && (distance < 0.01 || ray::cast(grid, player_eye, delta, distance) .is_none_or(|hit| hit.dist + 0.01 >= distance)) })}/// Shared semantic reflectance and local-light transfer for perspective/tactical./// Zero light stays black; only colorblind palettes substitute threat yellow.pub fn semantic_color(role: SemanticRole, local_light: u8, palette: &Palette) -> [u8; 3] { let colorblind = matches!(palette.name, "protan" | "deutan"); let base: [u8; 3] = match role { SemanticRole::Player => [255, 180, 32], SemanticRole::Threat if colorblind => [255, 220, 80], SemanticRole::Threat if palette.name == "tritan" => [255, 90, 140], SemanticRole::Npc if colorblind => [90, 150, 255], SemanticRole::Npc if palette.name == "tritan" => [80, 240, 220], SemanticRole::Threat => [255, 70, 45], SemanticRole::Npc => [100, 190, 255], SemanticRole::Item => [255, 220, 100], SemanticRole::Connector => [180, 255, 220], SemanticRole::Interaction => [220, 180, 255], SemanticRole::Actor => [230; 3], }; base.map(|c| crate::transfer::encode(u16::from(c) * u16::from(local_light.min(24))))}
/// ASCII twin of a semantic glyph. Printable ASCII remains authoritative;/// Unicode arrows preserve direction, otherwise the semantic role supplies a cue.pub fn semantic_ascii(role: SemanticRole, glyph: char) -> char { if glyph.is_ascii_graphic() { return glyph; } match glyph { '↑' | '⇧' | '▲' => '^', '↓' | '⇩' | '▼' => 'v', '←' | '◀' => '<', '→' | '▶' => '>', '⚠' => '!', '†' | '✝' => '+', _ => match role { SemanticRole::Player => '@', SemanticRole::Threat => '!', SemanticRole::Actor | SemanticRole::Npc => 'n', SemanticRole::Item => '*', SemanticRole::Connector => '>', SemanticRole::Interaction => '&', }, }}
/// ASCII conversion preserves typed entities (including punctuation glyphs).pub fn ascii_scene(fb: &mut FrameBuffer, scratch: &RenderScratch) { for (i, c) in fb.cells.iter_mut().enumerate() { if let Some(role) = scratch.roles.get(i).copied().flatten() { c.glyph = semantic_ascii(role, c.glyph); continue; } if matches!(c.glyph, '▀' | '▄' | '█' | '▓' | '▒' | '░') { let brightness = |rgb: [u8; 3]| { (u32::from(rgb[0]) * 3 + u32::from(rgb[1]) * 6 + u32::from(rgb[2])) / 10 }; let level = (brightness(c.fg) + brightness(c.bg)) / 2; const DENSITY: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; c.glyph = DENSITY[(level * 9 / 255) as usize]; // Preserve either half's strong illumination for no-color bold. c.fg = std::array::from_fn(|j| c.fg[j].max(c.bg[j])); c.bg = [0; 3]; continue; } c.glyph = match c.glyph { '│' => '|', '─' => '-', '┼' | '┌' | '┐' | '└' | '┘' | '├' | '┤' | '┬' | '┴' => '+', g if g.is_ascii() => g, _ => '.', }; }}// Boundary bits: north=1, east=2, south=4, west=8. Screen borders// are not implicit geometry boundaries. Compare face planes, not ray lengths:// ray length changes across an oblique flat surface are not silhouettes.fn boundary(a: HalfSample, b: HalfSample) -> bool { let Some(ah) = a.hit else { return false; }; let Some(bh) = b.hit else { return true; }; if ah.face != bh.face { return true; } let plane = |h: ray::RayHit| match h.face { ray::Face::XNeg | ray::Face::XPos => h.x, ray::Face::YNeg | ray::Face::YPos => h.y, ray::Face::ZNeg | ray::Face::ZPos => h.z, }; // Integer plane depth avoids infinity subtraction and fixed-point hit noise. plane(ah) != plane(bh)}fn boundary_mask(samples: &[HalfSample], w: usize, h2: usize, x: usize, y: usize) -> u8 { let s = samples[y * w + x]; let mut mask = 0; for (dx, dy, bit) in [(0, -1, 1), (1, 0, 2), (0, 1, 4), (-1, 0, 8)] { let nx = x as isize + dx; let ny = y as isize + dy; if nx >= 0 && ny >= 0 && nx < w as isize && ny < h2 as isize && boundary(s, samples[ny as usize * w + nx as usize]) { mask |= bit; } } mask}/// Full-cell boundary art, including all four corners and tee/cross joins./// Bits describe outward boundary sides: north=1,east=2,south=4,west=8.pub fn edge_join(mask: u8) -> char { match mask & 15 { 0 => '▀', 1 | 4 | 5 => '─', 2 | 8 | 10 => '│', 3 => '┐', 6 => '┘', 9 => '┌', 12 => '└', 7 => '┤', 11 => '┬', 13 => '├', 14 => '┴', 15 => '┼', _ => unreachable!(), }}
// Clip to the inclusive voxel AABB before DDA, so remote loaded geometry// between an outside chase eye and this location cannot shadow the location.fn bounded_cast( grid: &ChunkedGrid, eye: [f32; 3], dir: [f32; 3], bounds: &SceneBounds,) -> Option<ray::RayHit> { let mut enter = 0.0f32; let mut exit = scene::MAX_DIST; for j in 0..3 { let lo = bounds.min[j] as f32; let hi = bounds.max[j] as f32 + 1.0; if lo >= hi { return None; } if dir[j].abs() < 1e-8 { if eye[j] < lo || eye[j] >= hi { return None; } continue; } let a = (lo - eye[j]) / dir[j]; let b = (hi - eye[j]) / dir[j]; enter = enter.max(a.min(b)); exit = exit.min(a.max(b)); } if enter >= exit { return None; } // Step one fixed-point unit inside when entering from outside. let start = if enter > 0.0 { enter + 0.002 } else { 0.0 }; if start >= exit { return None; } let origin = std::array::from_fn(|j| eye[j] + dir[j] * start); let mut hit = ray::cast(grid, origin, dir, exit - start)?; if !bounds.contains([hit.x, hit.y, hit.z]) { return None; } hit.dist += start; Some(hit)}
// Conservative half-sample rectangle for a camera-facing billboard. Derive// screen slopes from shared Projection rays, not a second FOV/aspect formula.// All four corners have the center's forward depth because offsets are along// projection.right/up. Near-plane numerical ambiguity uses full scan; the// existing exact plane-depth predicate remains authoritative. Never use the// clipped project() result to infer that an offscreen corner means invisibility.fn billboard_rect( projection: &Projection, delta: [f32; 3], depth: f32, half_width: f32, half_height: f32, w: usize, h2: usize,) -> [usize; 4] { let full = [0, w, 0, h2]; if depth <= crate::projection::NEAR * 2.0 || w < 2 || h2 < 2 { return full; } let dot = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>(); let first = projection.ray(0, 0); let last = projection.ray(w - 1, h2 - 1); let slope = |ray: [f32; 3], axis: [f32; 3]| dot(ray, axis) / dot(ray, projection.forward); let left = slope(first, projection.right); let right = slope(last, projection.right); let top = slope(first, projection.up); let bottom = slope(last, projection.up); let center_x = dot(delta, projection.right); let center_y = dot(delta, projection.up); let x0 = ((center_x - half_width) / depth - left) / (right - left) * (w - 1) as f32; let x1 = ((center_x + half_width) / depth - left) / (right - left) * (w - 1) as f32; let y0 = ((center_y + half_height) / depth - top) / (bottom - top) * (h2 - 1) as f32; let y1 = ((center_y - half_height) / depth - top) / (bottom - top) * (h2 - 1) as f32; if ![x0, x1, y0, y1].iter().all(|v| v.is_finite()) { return full; } // Two-sample outward padding covers floating point projection/intersection // disagreement at edges; exact ray/rectangle tests reject extra candidates. let low = |v: f32, n: usize| (v.floor() - 2.0).clamp(0.0, n as f32) as usize; let high = |v: f32, n: usize| (v.ceil() + 3.0).clamp(0.0, n as f32) as usize; [ low(x0.min(x1), w), high(x0.max(x1), w), low(y0.min(y1), h2), high(y0.max(y1), h2), ]}
/// Bounded production perspective. Eligibility is separate and must be applied/// by the caller with entity_eligible (display camera is not the player eye).pub fn render_scene( grid: &ChunkedGrid, view: &View, palette: &Palette, framebuffer: &mut FrameBuffer, lights: &[(i32, i32, i32, u8)], entities: &[EntityDescriptor], bounds: &SceneBounds, scratch: &mut RenderScratch,) { let fb = framebuffer; fb.clear(); let n = fb.w.saturating_mul(fb.h); scratch.roles.resize(n, None); scratch.roles.fill(None); scratch.samples.clear(); let Some(projection) = Projection::new(view, fb.w, fb.h) else { return; }; if fb.cells.len() < n { return; } scratch.lights.clear(); scratch.lights.extend_from_slice(lights); scratch .lights .sort_unstable_by_key(|v| (v.0, v.1, v.2, std::cmp::Reverse(v.3))); scratch.lights.dedup_by_key(|v| (v.0, v.1, v.2)); scratch.samples.resize(n * 2, HalfSample::default()); for y in 0..fb.h * 2 { for x in 0..fb.w { let direction = projection.ray(x, y); let i = y * fb.w + x; let mut sample = HalfSample { ray: direction, ..HalfSample::default() }; if let Some(hit) = bounded_cast(grid, view.eye, direction, bounds) { let normal = match hit.face { ray::Face::XNeg => [-1, 0, 0], ray::Face::XPos => [1, 0, 0], ray::Face::YNeg => [0, -1, 0], ray::Face::YPos => [0, 1, 0], ray::Face::ZNeg => [0, 0, -1], ray::Face::ZPos => [0, 0, 1], }; let p = [hit.x, hit.y, hit.z]; let air = std::array::from_fn(|j| p[j] + normal[j]); let level = scratch.light(p).max(if bounds.contains(air) { scratch.light(air) } else { 0 }); let fog = hit.dist / scene::MAX_DIST; let (_, fg, bg) = shade::shade(&hit, level, fog, hit.face == ray::Face::ZPos); // Fog must not illuminate a completely unlit surface. let color = if level == 0 { [0; 3] } else { scene::apply_fog(fg, bg, scene::fog_for_z(palette, hit.z as f32), fog).0 }; sample.hit = Some(hit); sample.world = std::array::from_fn(|j| view.eye[j] + direction[j] * hit.dist); sample.distance = hit.dist; sample.color = color; } scratch.samples[i] = sample; } } for e in entities { if (view.mode == Camera::FirstPerson && e.role == SemanticRole::Player) || e.local_light == 0 || !e.height.is_finite() || e.height <= 0.0 || !bounds.contains_point(e.anchor) { continue; } // Camera-facing rectangle rasterized at the SAME center rays as terrain. // Width is a bounded visual footprint; anchor is feet and height is vertical // along camera up. This also provides coverage between former point samples. let center = [e.anchor[0], e.anchor[1], e.anchor[2] + e.height * 0.5]; let delta: [f32; 3] = std::array::from_fn(|j| center[j] - view.eye[j]); let dot = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>(); let plane_depth = dot(delta, projection.forward); if plane_depth < crate::projection::NEAR { continue; } let half_width = (e.height * 0.45).clamp(0.25, 0.9) * 0.5; let [x0, x1, y0, y1] = if scratch.brute_billboards { [0, fb.w, 0, fb.h * 2] } else { billboard_rect( &projection, delta, plane_depth, half_width, e.height * 0.5, fb.w, fb.h * 2, ) }; if x0 >= x1 || y0 >= y1 { continue; } let color = semantic_color(e.role, e.local_light, palette); for y in y0..y1 { for s in &mut scratch.samples[y * fb.w + x0..y * fb.w + x1] { let denominator = dot(s.ray, projection.forward); if denominator <= 0.0 { continue; } let distance = plane_depth / denominator; if !distance.is_finite() || distance > scene::MAX_DIST { continue; } let p = std::array::from_fn(|j| view.eye[j] + s.ray[j] * distance); let relative = std::array::from_fn(|j| p[j] - center[j]); if dot(relative, projection.right).abs() > half_width || dot(relative, projection.up).abs() > e.height * 0.5 || !bounds.contains_point(p) { continue; } if distance < s.distance - 0.001 || ((distance - s.distance).abs() <= 0.001 && s.entity_id.is_some_and(|id| e.id < id)) { s.distance = distance; s.world = p; s.role = Some(e.role); s.entity_id = Some(e.id); s.glyph = e.glyph; s.color = color; } } } } for y in 0..fb.h { for x in 0..fb.w { let a = scratch.samples[(2 * y) * fb.w + x]; let b = scratch.samples[(2 * y + 1) * fb.w + x]; let i = y * fb.w + x; if a.color == [0; 3] && b.color == [0; 3] && a.role.is_none() && b.role.is_none() { fb.cells[i] = Cell::default(); continue; } // Each semantic sample already won occlusion on its own center ray. // The full glyph is an anchor symbol for that defined visible sample, // NOT an assertion that its rectangle covers the partner terrain. // Choose lower stable ID, then upper sample on ties; never compare t // across different half rays. Keep all half depths/data unchanged. let entity = match (a.role, b.role) { (Some(_), Some(_)) => Some(if a.entity_id <= b.entity_id { (a, b) } else { (b, a) }), (Some(_), None) => Some((a, b)), (None, Some(_)) => Some((b, a)), _ => None, }; if let Some((e, partner)) = entity { scratch.roles[i] = e.role; fb.cells[i] = Cell { glyph: e.glyph, fg: e.color, // When clipped, fg=semantic and bg=partner terrain, including // nearer ledges. ASCII may collapse terrain but MUST keep role. bg: if partner.role.is_none() { partner.color } else { [0; 3] }, }; continue; } let cell_mask = |cx: usize, cy: usize| { boundary_mask(&scratch.samples, fb.w, fb.h * 2, cx, 2 * cy) | boundary_mask(&scratch.samples, fb.w, fb.h * 2, cx, 2 * cy + 1) }; let mut mask = cell_mask(x, y); // Concave joins sit on the solid diagonal of an opening: connect // neighboring horizontal/vertical runs using four-neighbor masks. if mask == 0 && a.hit.is_some() && b.hit.is_some() { let n = if y > 0 { cell_mask(x, y - 1) } else { 0 }; let s = if y + 1 < fb.h { cell_mask(x, y + 1) } else { 0 }; let w = if x > 0 { cell_mask(x - 1, y) } else { 0 }; let e = if x + 1 < fb.w { cell_mask(x + 1, y) } else { 0 }; if e & 4 != 0 && s & 2 != 0 { mask = 6; } else if w & 4 != 0 && s & 8 != 0 { mask = 12; } else if e & 1 != 0 && n & 2 != 0 { mask = 3; } else if w & 1 != 0 && n & 8 != 0 { mask = 9; } } // Full-cell edge policy; original half hit/depth stays in scratch. let glyph = edge_join(mask); fb.cells[i] = if mask != 0 { Cell { glyph, fg: if a.distance <= b.distance { a.color } else { b.color }, bg: [0; 3], } } else { Cell { glyph, fg: a.color, bg: b.color, } }; } }}