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.
18 kB · 471 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472//! Cameras: three views over one sim (docs/05 §5.1 "Three cameras, one sim").//!//! - [`first_person`] — default view; eye at actor head height, FOV capped//! at 75° by the motion-sickness law (docs/05 §5.1 camera 1, §5.4 law 4).//! - [`chase_orbit`] — hold-Tab orbit 3 m behind/up with parent-side//! collision pull-in via a raycast hook (docs/05 §5.1 camera 2).//! - [`top_tactical`] — orthographic plan view with Z-slider slice `z`//! (docs/05 §5.1 camera 3); chalk marks and trajectory overlays are//! parent-side.//!//! [`interpolate`] glides actor positions from the 10 Hz sim to 60 fps//! render (docs/05 §5.1 "Motion language", docs/04 §4.2 tick contract).//!//! Parent-unification notes (parallel workstream `ray.rs` / `shade.rs`)://! - `View.eye` / `yaw` / `pitch` feed the primary-ray generator; `View.mode`//! selects perspective (`FirstPerson` / `ChaseOrbit`) vs orthographic//! (`TopTactical`) projection in `ray.rs`.//! - Head-bob is intentionally OFF here: the parent interpolates a small bob//! offset onto [`first_person`] output when bob is enabled (docs/05 §5.1://! toggleable, off by default in no-shake / reduced-motion mode).//! - Chase collision pull-in is parent-side: raycast eye→actor and shorten//! the orbit distance instead of clipping (docs/05 §5.1 camera 2).
use crate::Camera;use std::f32::consts::PI;
/// Hard FOV cap in degrees (docs/05 §5.1 camera 1, §5.4 law 4).pub const MAX_FOV_DEG: f32 = 75.0;/// Minimum sane FOV; avoids degenerate projection matrices parent-side.pub const MIN_FOV_DEG: f32 = 1.0;/// Actor head height above the feet in metres (docs/05 §5.1 camera 1).pub const EYE_HEIGHT_M: f32 = 1.6;/// Default chase-orbit distance in metres (docs/05 §5.1 camera 2).pub const CHASE_DIST_M: f32 = 3.0;/// Plan-view height above the tactical slice at zoom 1.0, in metres.pub const TACTICAL_HEIGHT_M: f32 = 24.0;
/// A camera placement over the sim (docs/05 §5.1).////// `mode` reuses [`crate::Camera`] so all three views stay in one enum;/// orthographic projection is implied by `mode == Camera::TopTactical`/// (see [`View::is_orthographic`]).////// This is a pre-release public type. The explicit [`View::tactical_slice_z`]/// projection field is part of its evolving API and must be preserved when a/// view is copied or adjusted.#[derive(Clone, Copy, Debug, PartialEq)]pub struct View { /// Camera position in world metres. pub eye: [f32; 3], /// Heading in radians; `0` looks along `+x`. pub yaw: f32, /// Look angle in radians; `0` is level, `+` looks up. pub pitch: f32, /// Vertical field of view in degrees, clamped to /// `[MIN_FOV_DEG, MAX_FOV_DEG]`. Ignored by the orthographic plan view. pub fov_deg: f32, /// Which of the three cameras this is. pub mode: Camera, /// Exact world-Z plane selected by a top-tactical view. /// /// Perspective views carry `None`. For direct [`View::new`] construction /// in [`Camera::TopTactical`] mode, this is `floor(eye[2])`; callers that /// float the eye above another plane should set that plane explicitly, as /// [`top_tactical`] does. pub tactical_slice_z: Option<i32>,}
impl View { /// Build a view, clamping FOV into `[MIN_FOV_DEG, MAX_FOV_DEG]` /// (docs/05 §5.4 law 4) and pitch into `[-90°, +90°]`. /// /// Direct construction in [`Camera::TopTactical`] mode selects the plane /// at `floor(eye[2])`; perspective modes have no tactical plane metadata. /// Use [`top_tactical`] when the eye should remain elevated above a /// separately selected slice. /// /// Non-finite eye components and angles become zero; non-finite FOV /// becomes `MAX_FOV_DEG`. This policy is identical in debug and release. pub fn new(eye: [f32; 3], yaw: f32, pitch: f32, fov_deg: f32, mode: Camera) -> Self { let eye = eye.map(finite_or_zero); let yaw = finite_or_zero(yaw); let pitch = finite_or_zero(pitch); let fov = if fov_deg.is_finite() { fov_deg.clamp(MIN_FOV_DEG, MAX_FOV_DEG) } else { MAX_FOV_DEG }; let half_pi = PI / 2.0; let tactical_slice_z = (mode == Camera::TopTactical).then(|| eye[2].floor() as i32); Self { eye, yaw, pitch: pitch.clamp(-half_pi, half_pi), fov_deg: fov, mode, tactical_slice_z, } }
/// True for the orthographic plan view (docs/05 §5.1 camera 3). pub fn is_orthographic(&self) -> bool { self.mode == Camera::TopTactical }}
/// First-person view: eye at actor head height (`+1.6 m`, docs/05 §5.1).////// Subtle head-bob is documented as parent-side interpolation and defaults/// to OFF (no-shake / reduced-motion mode, docs/05 §5.1, §5.4 law 4).pub fn first_person(actor_pos: [f32; 3], yaw: f32, pitch: f32) -> View { View::new( [actor_pos[0], actor_pos[1], actor_pos[2] + EYE_HEIGHT_M], yaw, pitch, MAX_FOV_DEG, Camera::FirstPerson, )}
/// Chase-orbit view: camera behind and above the actor (docs/05 §5.1).////// `dist` is the desired orbit distance in metres (`CHASE_DIST_M` = 3 m/// default; non-positive values fall back to the default). The camera sits/// `dist` behind the actor's heading and `1.0 + dist * 0.5` m above the/// actor's feet. Collision pull-in is parent-side: raycast eye→actor and/// shorten `dist` rather than clipping (docs/05 §5.1 camera 2).pub fn chase_orbit(actor_pos: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> View { let d = if dist > 0.0 { dist } else { CHASE_DIST_M }; View::new( [ actor_pos[0] - yaw.cos() * d, actor_pos[1] - yaw.sin() * d, actor_pos[2] + 1.0 + d * 0.5, ], yaw, pitch, MAX_FOV_DEG, Camera::ChaseOrbit, )}
/// Top-tactical view: orthographic plan view over the Z-slider slice `z`/// (docs/05 §5.1 camera 3).////// The eye sits directly above `center` at slice height `z` (metres) plus/// `TACTICAL_HEIGHT_M / zoom`; `zoom` is clamped to `[0.25, 4.0]`/// (non-finite falls back to `1.0`). Pitch is straight down. Chalk marks,/// rope-length arcs, and thrown-trajectory dots are parent-side overlays;/// pause-step auto-engage (Story) vs `LIVE` tag (Classic) is parent-side UI.pub fn top_tactical(center: [f32; 3], z: i32, zoom: f32) -> View { let zf = if zoom.is_finite() { zoom.clamp(0.25, 4.0) } else { 1.0 }; let mut view = View::new( [center[0], center[1], z as f32 + TACTICAL_HEIGHT_M / zf], 0.0, -PI / 2.0, MAX_FOV_DEG, Camera::TopTactical, ); view.tactical_slice_z = Some(z); view}
/// Center authoritative integer occupancy coordinates; Z remains the feet plane.pub fn voxel_center(pos: [f32; 3]) -> [f32; 3] { [pos[0] + 0.5, pos[1] + 0.5, pos[2]]}
/// Spherical chase orbit about centered feet + 1m. Inputs are render coordinates.pub fn chase_orbit_elevated(actor_pos: [f32; 3], yaw: f32, elevation: f32) -> View { let elevation = if elevation.is_finite() { elevation.clamp(5f32.to_radians(), 75f32.to_radians()) } else { 1.5f32.atan2(3.0) }; let radius = 11.25f32.sqrt(); let horizontal = radius * elevation.cos(); View::new( [ actor_pos[0] - yaw.cos() * horizontal, actor_pos[1] - yaw.sin() * horizontal, actor_pos[2] + 1.0 + radius * elevation.sin(), ], yaw, -elevation, MAX_FOV_DEG, Camera::ChaseOrbit, )}
fn finite_or_zero(value: f32) -> f32 { if value.is_finite() { value } else { 0.0 }}
/// Read-only recovery within the actor's own vertical body/head column./// Try the normal head first, then midpoints of every free vertical interval./// Keeping X/Y fixed prevents recovery on the far side of a wall. Interval/// endpoints come from voxel faces, not a sampling step that could miss gaps.fn safe_first_person(grid: &hexwork_core::voxel::ChunkedGrid, actor: [f32; 3], yaw: f32) -> View { let normal = first_person(actor, yaw, 0.0); const SKIN: f64 = 0.15; let p = actor.map(f64::from); let bottom = p[2]; let top = bottom + 2.0; let low = [ (p[0] - SKIN).floor(), (p[1] - SKIN).floor(), (bottom - SKIN).floor(), ]; let high = [ (p[0] + SKIN).floor(), (p[1] + SKIN).floor(), (top + SKIN).floor(), ]; if (0..3) .any(|i| low[i] < i32::MIN as f64 || high[i] > i32::MAX as f64 || high[i] - low[i] > 4.0) { return normal; } // At most 2*2*4 nearby voxels; no world-wide search or mutation. let mut blocked = Vec::new(); for x in low[0] as i32..=high[0] as i32 { for y in low[1] as i32..=high[1] as i32 { if p[0] <= x as f64 - SKIN || p[0] >= x as f64 + 1.0 + SKIN || p[1] <= y as f64 - SKIN || p[1] >= y as f64 + 1.0 + SKIN { continue; } for z in low[2] as i32..=high[2] as i32 { if grid.get_world(x, y, z).material != hexwork_core::voxel::material::AIR { blocked.push((z as f64 - SKIN, z as f64 + 1.0 + SKIN)); } } } } blocked.sort_by(|a, b| a.0.total_cmp(&b.0)); let clear = |z: f32| { let z = f64::from(z); z >= bottom && z <= top && blocked.iter().all(|&(a, b)| z <= a || z >= b) }; if clear(normal.eye[2]) { return normal; } // Build the complete complement of the blocked intervals, then validate // again after f32 conversion (large-coordinate rounding can erase gaps). let mut cursor = bottom; for &(a, b) in &blocked { if a >= cursor { let z = ((cursor + a.min(top)) * 0.5) as f32; if clear(z) { return View { eye: [actor[0], actor[1], z], ..normal }; } } cursor = cursor.max(b); if cursor > top { break; } } if cursor <= top { let z = ((cursor + top) * 0.5) as f32; if clear(z) { return View { eye: [actor[0], actor[1], z], ..normal }; } } // Impossible invalid geometry: no representable safe point in this // column. Deterministically retain the finite normal eye, possibly blocked, // rather than mutate terrain/player state or teleport through a wall. normal}
/// Sweep target to eye against solid voxel AABBs expanded by 0.15m on/// every axis (conservative even at corners). Only the small orbit bounding/// box is visited; unloaded voxels have the grid's usual AIR semantics./// Initial contact or less than 0.3m of usable orbit falls back to first/// person: normal eye if clear, otherwise a clear point at the same X/Y in/// feet..feet+2m. Clearance requires 0.15m skin, including nearby side solids./// Preconditions: a representable safe point must exist in that body/head/// column for collision-safe fallback. If invalid geometry fills the column/// (or coordinates are outside the grid's representable range), the deterministic/// policy is the finite normal FP eye, possibly blocked. No safe point can be/// fabricated in this case without moving outside the actor's column./// This helper never changes actor or world geometry.pub fn collision_safe_chase( grid: &hexwork_core::voxel::ChunkedGrid, actor_pos: [f32; 3], yaw: f32, elevation: f32,) -> View { let actor_pos = actor_pos.map(finite_or_zero); let yaw = finite_or_zero(yaw); let fallback = safe_first_person(grid, actor_pos, yaw); let mut view = chase_orbit_elevated(actor_pos, yaw, elevation); let target = [actor_pos[0], actor_pos[1], actor_pos[2] + 1.0]; let start = target.map(f64::from); let end = view.eye.map(f64::from); let delta: [f64; 3] = std::array::from_fn(|i| end[i] - start[i]); let length = delta.iter().map(|v| v * v).sum::<f64>().sqrt(); const SKIN: f64 = 0.15; // f64 arithmetic avoids overflow and preserves the voxel bounds at i32 // limits. Reject unrepresentable worlds rather than scanning huge ranges. let low: [f64; 3] = std::array::from_fn(|i| (start[i].min(end[i]) - SKIN).floor()); let high: [f64; 3] = std::array::from_fn(|i| (start[i].max(end[i]) + SKIN).floor()); if !length.is_finite() || length < 0.3 || (0..3).any(|i| { low[i] < i32::MIN as f64 || high[i] > i32::MAX as f64 || high[i] - low[i] > 10.0 }) { return fallback; } let mut first = 1.0f64; for x in low[0] as i32..=high[0] as i32 { for y in low[1] as i32..=high[1] as i32 { for z in low[2] as i32..=high[2] as i32 { if grid.get_world(x, y, z).material == hexwork_core::voxel::material::AIR { continue; } let voxel = [x, y, z].map(f64::from); let mut enter = 0.0f64; let mut leave = 1.0f64; for i in 0..3 { let min = voxel[i] - SKIN; let max = voxel[i] + 1.0 + SKIN; if delta[i] == 0.0 { if start[i] < min || start[i] > max { leave = -1.0; break; } } else { let a = (min - start[i]) / delta[i]; let b = (max - start[i]) / delta[i]; enter = enter.max(a.min(b)); leave = leave.min(a.max(b)); } } if enter <= leave { first = first.min(enter); } } } } if first < 1.0 { // Back off very slightly so conversion to f32 does not put the eye // just inside the expanded face at ordinary world coordinates. let distance = first * length - 0.0001; if distance < 0.3 { return fallback; } view.eye = std::array::from_fn(|i| (start[i] + delta[i] * distance / length) as f32); } let look: [f32; 3] = std::array::from_fn(|i| target[i] - view.eye[i]); view.yaw = look[1].atan2(look[0]); view.pitch = look[2].atan2(look[0].hypot(look[1])); view}
/// Glide an actor position from the 10 Hz sim to 60 fps render (docs/05 §5.1/// "Motion language", docs/04 §4.2): pure linear interpolation with `alpha`/// clamped to `[0, 1]`. `alpha = 0` yields `prev`, `alpha = 1` yields `next`.pub fn interpolate(prev: [f32; 3], next: [f32; 3], alpha: f32) -> [f32; 3] { let a = alpha.clamp(0.0, 1.0); [ prev[0] + (next[0] - prev[0]) * a, prev[1] + (next[1] - prev[1]) * a, prev[2] + (next[2] - prev[2]) * a, ]}
#[cfg(test)]mod tests { use super::*;
#[test] fn fov_clamped_to_75() { // docs/05 §5.4 law 4: FOV must never exceed 75°. let v = View::new([0.0, 0.0, 0.0], 0.0, 0.0, 120.0, Camera::FirstPerson); assert_eq!(v.fov_deg, MAX_FOV_DEG); }
#[test] fn fov_floor() { let v = View::new([0.0, 0.0, 0.0], 0.0, 0.0, -10.0, Camera::FirstPerson); assert_eq!(v.fov_deg, MIN_FOV_DEG); }
#[test] fn fp_eye_at_head_height() { let v = first_person([1.0, 2.0, 3.0], 0.5, 0.1); assert_eq!(v.mode, Camera::FirstPerson); assert!((v.eye[0] - 1.0).abs() < 1e-6); assert!((v.eye[1] - 2.0).abs() < 1e-6); assert!((v.eye[2] - (3.0 + EYE_HEIGHT_M)).abs() < 1e-6); }
#[test] fn orbit_sits_behind_and_above() { // yaw = 0 looks along +x, so the camera must sit at -x and above. let v = chase_orbit([0.0, 0.0, 5.0], 0.0, 0.0, CHASE_DIST_M); assert_eq!(v.mode, Camera::ChaseOrbit); assert!(v.eye[0] < 0.0, "camera must sit behind the actor"); assert!(v.eye[2] > 5.0, "camera must sit above the actor"); }
#[test] fn orbit_bad_dist_falls_back_to_default() { let a = chase_orbit([0.0, 0.0, 0.0], 0.0, 0.0, -2.0); let b = chase_orbit([0.0, 0.0, 0.0], 0.0, 0.0, CHASE_DIST_M); assert_eq!(a.eye, b.eye); }
#[test] fn tactical_is_orthographic_plan_view() { let v = top_tactical([4.0, 5.0, 0.0], 2, 1.0); assert_eq!(v.mode, Camera::TopTactical); assert!(v.is_orthographic()); assert!((v.eye[0] - 4.0).abs() < 1e-6); assert!((v.eye[1] - 5.0).abs() < 1e-6); assert!((v.pitch + PI / 2.0).abs() < 1e-6); assert!(!first_person([0.0, 0.0, 0.0], 0.0, 0.0).is_orthographic()); assert!(!chase_orbit([0.0, 0.0, 0.0], 0.0, 0.0, 3.0).is_orthographic()); }
#[test] fn interpolate_endpoints_and_midpoint() { let a = [0.0, 0.0, 0.0]; let b = [10.0, -4.0, 2.0]; assert_eq!(interpolate(a, b, 0.0), a); assert_eq!(interpolate(a, b, 1.0), b); assert_eq!(interpolate(a, b, 0.5), [5.0, -2.0, 1.0]); }
#[test] fn interpolate_alpha_clamped() { let a = [1.0, 1.0, 1.0]; let b = [9.0, 9.0, 9.0]; assert_eq!(interpolate(a, b, -3.0), a); assert_eq!(interpolate(a, b, 42.0), b); }
#[test] fn all_three_modes_constructible() { assert_eq!( first_person([0.0, 0.0, 0.0], 0.0, 0.0).mode, Camera::FirstPerson ); assert_eq!( chase_orbit([0.0, 0.0, 0.0], 0.0, 0.0, 3.0).mode, Camera::ChaseOrbit ); assert_eq!( top_tactical([0.0, 0.0, 0.0], 0, 1.0).mode, Camera::TopTactical ); }}