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.
9.8 kB · 258 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259//! Canonical world-space desired headings and fractional movement, independent//! of camera orientation. 4096 clockwise bins: 0=north (-Y), 1024=east (+X),//! 2048=south (+Y), 3072=west (-X). No runtime floats or trigonometry.//!//! One substep is exactly 1/30 second (three per 100 ms tick). Unit vectors are//! rounded at scale 1e9. Every axis accumulates `speed_mm_s * unit_component`//! plus its signed remainder, rounding to nearest integer millimetre. Centered//! remainders bound vector quantization error by sqrt(0.5^2+0.5^2) < 1 mm;//! table approximation adds at most 1e-9 times distance in relative error.//!//! Wire v5 can store heading as u16 and remainder as two signed i64 numerators//! with the FIXED denominator 30_000_000_000. Decode via constructors, never//! unchecked fields. These are projection fractions, NOT velocity or inertia://! desired direction applies immediately, reversal is not delayed by velocity,//! and stopping emits zero while preserving fractions. No elapsed-time debt is//! stored. A collision caller must explicitly decide whether to commit a trial//! remainder or discard/reset blocked projections; this module does not sweep.
#[path = "heading_table.rs"]mod table;
pub const HEADING_BINS: u16 = 4096;pub const UNIT_SCALE: i64 = 1_000_000_000;pub const SUBSTEPS_PER_SECOND: i64 = 30;pub const REMAINDER_DENOMINATOR: i64 = UNIT_SCALE * SUBSTEPS_PER_SECOND;pub const PLAYER_SPEED_MM_S: u32 = 3000;pub const NPC_SPEED_MM_S: u32 = 1000;pub const INVESTIGATING_NPC_SPEED_MM_S: u32 = 2000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum HeadingError { InvalidHeading, InvalidRemainder, Overflow,}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]pub struct Heading(u16);impl Heading { pub const NORTH: Self = Self(0); pub const EAST: Self = Self(1024); pub const SOUTH: Self = Self(2048); pub const WEST: Self = Self(3072);
pub const fn new(bin: u16) -> Result<Self, HeadingError> { if bin < HEADING_BINS { Ok(Self(bin)) } else { Err(HeadingError::InvalidHeading) } } pub const fn bin(self) -> u16 { self.0 } pub fn unit_vector(self) -> [i64; 2] { table::UNIT_VECTORS[usize::from(self.0)] }}
/// Entire persistent movement accumulator, in world X/Y order. Both endpoints/// +/- denominator/2 are valid: exact ties round away from zero.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub struct MovementRemainder { numerators: [i64; 2],}impl MovementRemainder { pub const ZERO: Self = Self { numerators: [0; 2] };
pub fn new(numerators: [i64; 2]) -> Result<Self, HeadingError> { let half = REMAINDER_DENOMINATOR / 2; if numerators.iter().any(|&n| n < -half || n > half) { return Err(HeadingError::InvalidRemainder); } Ok(Self { numerators }) } pub const fn numerators(self) -> [i64; 2] { self.numerators }
/// Trial movement for exactly one substep. None or zero speed means stopped. /// Pure return value lets collision integration choose when to commit state. /// A zero desired axis never consumes its saved fraction (no lateral drift). pub fn project( self, desired: Option<Heading>, speed_mm_s: u32, ) -> Result<([i64; 2], Self), HeadingError> { let Some(heading) = desired.filter(|_| speed_mm_s != 0) else { return Ok(([0; 2], self)); }; let mut next = self; let mut delta = [0; 2]; let d = i128::from(REMAINDER_DENOMINATOR); for (axis, unit) in heading.unit_vector().into_iter().enumerate() { if unit == 0 { continue; } // u32*i64 table components and a validated i64 remainder fit i128. let n = i128::from(speed_mm_s) * i128::from(unit) + i128::from(self.numerators[axis]); let rounded = if n >= 0 { (n + d / 2) / d } else { -((-n + d / 2) / d) }; delta[axis] = i64::try_from(rounded).map_err(|_| HeadingError::Overflow)?; next.numerators[axis] = i64::try_from(n - rounded * d).map_err(|_| HeadingError::Overflow)?; } Ok((delta, next)) }
/// Unconstrained checked translation; not collision detection or world-bound /// validation. Failure changes neither position nor the accumulator. pub fn advance( &mut self, position_mm: &mut [i64; 2], desired: Option<Heading>, speed_mm_s: u32, ) -> Result<(), HeadingError> { let (delta, next) = self.project(desired, speed_mm_s)?; let position = [ position_mm[0] .checked_add(delta[0]) .ok_or(HeadingError::Overflow)?, position_mm[1] .checked_add(delta[1]) .ok_or(HeadingError::Overflow)?, ]; *position_mm = position; *self = next; Ok(()) }}
#[cfg(test)]mod tests { use super::*;
#[test] fn independent_compass_fixtures() { let fixtures = [ (0, [0, -1_000_000_000]), (512, [707_106_781, -707_106_781]), (1024, [1_000_000_000, 0]), (1536, [707_106_781, 707_106_781]), (2048, [0, 1_000_000_000]), (2560, [-707_106_781, 707_106_781]), (3072, [-1_000_000_000, 0]), (3584, [-707_106_781, -707_106_781]), (1, [1_533_980, -999_998_823]), ]; for (bin, vector) in fixtures { assert_eq!(Heading::new(bin).unwrap().unit_vector(), vector); } assert!(Heading::new(4095).is_ok()); assert_eq!(Heading::new(4096), Err(HeadingError::InvalidHeading)); assert!(Heading::new(u16::MAX).is_err()); }
#[test] fn table_norm_and_antipodes() { let scale2 = i128::from(UNIT_SCALE).pow(2); for bin in 0..HEADING_BINS { let [x, y] = Heading::new(bin).unwrap().unit_vector(); let norm2 = i128::from(x).pow(2) + i128::from(y).pow(2); assert!((norm2 - scale2).abs() <= 1_500_000_000); assert_eq!( Heading::new((bin + 2048) % 4096).unwrap().unit_vector(), [-x, -y] ); } }
#[test] fn thirty_ticks_distances_and_reversal() { for speed in [ NPC_SPEED_MM_S, INVESTIGATING_NPC_SPEED_MM_S, PLAYER_SPEED_MM_S, ] { for bin in [0, 512, 1024, 1536, 2048, 2560, 3072, 3584] { let mut remainder = MovementRemainder::ZERO; let mut position = [0; 2]; for _ in 0..90 { remainder .advance(&mut position, Some(Heading::new(bin).unwrap()), speed) .unwrap(); } // Float is test-only, an independent Euclidean-distance oracle. let distance = ((position[0] as f64).powi(2) + (position[1] as f64).powi(2)).sqrt(); assert!((distance - f64::from(speed) * 3.0).abs() < 1.0); for _ in 0..90 { remainder .advance( &mut position, Some(Heading::new((bin + 2048) % 4096).unwrap()), speed, ) .unwrap(); } assert_eq!(position, [0; 2]); assert_eq!(remainder, MovementRemainder::ZERO); } } }
#[test] fn tick_speeds_and_stop_are_exact() { for (speed, distance) in [(3000, 300), (1000, 100), (2000, 200)] { let mut remainder = MovementRemainder::ZERO; let mut position = [0; 2]; for _ in 0..3 { remainder .advance(&mut position, Some(Heading::EAST), speed) .unwrap(); } assert_eq!(position, [distance, 0]); assert_eq!(remainder, MovementRemainder::ZERO); } let (_, r) = MovementRemainder::ZERO .project(Some(Heading::new(512).unwrap()), 3000) .unwrap(); assert_eq!(r.project(None, 3000).unwrap(), ([0; 2], r)); assert_eq!(r.project(Some(Heading::EAST), 0).unwrap(), ([0; 2], r)); let (reverse, _) = r.project(Some(Heading::new(2560).unwrap()), 3000).unwrap(); assert!(reverse[0] < 0 && reverse[1] > 0); }
#[test] fn validated_wire_parts_and_transactional_overflow() { for bad in [i64::MIN, i64::MAX, REMAINDER_DENOMINATOR / 2 + 1] { assert!(MovementRemainder::new([bad, 0]).is_err()); assert!(MovementRemainder::new([0, bad]).is_err()); } for edge in [-REMAINDER_DENOMINATOR / 2, REMAINDER_DENOMINATOR / 2] { let r = MovementRemainder::new([edge, edge]).unwrap(); assert_eq!(MovementRemainder::new(r.numerators()).unwrap(), r); } let mut r = MovementRemainder::ZERO; let mut p = [i64::MAX, 0]; assert_eq!( r.advance(&mut p, Some(Heading::EAST), 3000), Err(HeadingError::Overflow) ); assert_eq!(p, [i64::MAX, 0]); assert_eq!(r, MovementRemainder::ZERO); p = [0, i64::MIN]; assert_eq!( r.advance(&mut p, Some(Heading::NORTH), 3000), Err(HeadingError::Overflow) ); assert_eq!(p, [0, i64::MIN]); assert_eq!(r, MovementRemainder::ZERO); assert!(r .project(Some(Heading::new(512).unwrap()), u32::MAX) .is_ok()); }}