diff --git a/crates/city/src/walker/fov.rs b/crates/city/src/walker/fov.rs new file mode 100644 index 0000000..db3e753 --- /dev/null +++ b/crates/city/src/walker/fov.rs @@ -0,0 +1,155 @@ +//! Symmetric shadowcasting field of view. +//! +//! Albert Ford's algorithm: each quadrant is scanned row by row between two rational +//! slopes; a floor tile is visible exactly when its centre lies inside the view +//! segment, which makes visibility symmetric between any two floor tiles. + +use citygen::Pos; + +/// A rational slope `num / den` with `den > 0`. +#[derive(Clone, Copy, Debug)] +struct Frac { + num: i64, + den: i64, +} + +impl Frac { + const fn new(num: i64, den: i64) -> Self { + Frac { num, den } + } +} + +#[derive(Clone, Copy)] +enum Quadrant { + N, + E, + S, + W, +} + +impl Quadrant { + fn transform(self, origin: Pos, depth: i64, col: i64) -> Pos { + let (d, c) = (depth as i32, col as i32); + match self { + Quadrant::N => Pos::new(origin.x + c, origin.y - d), + Quadrant::S => Pos::new(origin.x + c, origin.y + d), + Quadrant::E => Pos::new(origin.x + d, origin.y + c), + Quadrant::W => Pos::new(origin.x - d, origin.y + c), + } + } +} + +/// `floor(depth * f + 1/2)`. +fn round_ties_up(depth: i64, f: Frac) -> i64 { + (2 * depth * f.num + f.den).div_euclid(2 * f.den) +} + +/// `ceil(depth * f - 1/2)`. +fn round_ties_down(depth: i64, f: Frac) -> i64 { + let n = 2 * depth * f.num - f.den; + let d = 2 * f.den; + -((-n).div_euclid(d)) +} + +fn slope(depth: i64, col: i64) -> Frac { + Frac::new(2 * col - 1, 2 * depth) +} + +/// `col >= depth * start && col <= depth * end`. +fn is_symmetric(depth: i64, col: i64, start: Frac, end: Frac) -> bool { + col * start.den >= depth * start.num && col * end.den <= depth * end.num +} + +/// Marks every tile visible from `origin` within `radius`. `blocks` says whether a +/// tile stops sight; out-of-bounds tiles (per `in_bounds`) count as walls and are +/// never marked. +pub fn compute( + origin: Pos, + radius: i32, + blocks: impl Fn(Pos) -> bool, + in_bounds: impl Fn(Pos) -> bool, + mut mark: impl FnMut(Pos), +) { + mark(origin); + let r2 = (radius as i64) * (radius as i64); + for q in [Quadrant::N, Quadrant::E, Quadrant::S, Quadrant::W] { + let mut stack: Vec<(i64, Frac, Frac)> = vec![(1, Frac::new(-1, 1), Frac::new(1, 1))]; + while let Some((depth, mut start, end)) = stack.pop() { + if depth > radius as i64 { + continue; + } + let min_col = round_ties_up(depth, start); + let max_col = round_ties_down(depth, end); + let mut prev: Option = None; // Some(true) = wall, Some(false) = floor + for col in min_col..=max_col { + let p = q.transform(origin, depth, col); + let inside = in_bounds(p); + let wall = !inside || blocks(p); + let within = depth * depth + col * col <= r2; + if inside && within && (wall || is_symmetric(depth, col, start, end)) { + mark(p); + } + if prev == Some(true) && !wall { + start = slope(depth, col); + } + if prev == Some(false) && wall { + stack.push((depth + 1, start, slope(depth, col))); + } + prev = Some(wall); + } + if prev == Some(false) { + stack.push((depth + 1, start, end)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use citygen::Pos; + use std::collections::HashSet; + + fn see(from: Pos, radius: i32) -> HashSet { + // 9x9 room, wall column at x=4 rows 0..=6, opening at (4,7),(4,8). + let wall = |p: Pos| p.x == 4 && p.y <= 6; + let bounds = |p: Pos| (0..9).contains(&p.x) && (0..9).contains(&p.y); + let mut v = HashSet::new(); + compute(from, radius, wall, bounds, |p| { + v.insert(p); + }); + v + } + + #[test] + fn fov_symmetric_and_walls_block() { + let a = Pos::new(1, 1); + let b = Pos::new(7, 1); + assert_eq!(see(a, 12).contains(&b), see(b, 12).contains(&a)); + assert!(!see(a, 12).contains(&b)); + assert!(see(a, 12).contains(&Pos::new(4, 1))); + assert!(see(a, 12).contains(&a)); + assert!(see(Pos::new(1, 8), 12).contains(&Pos::new(7, 8))); + assert!(!see(a, 12).contains(&Pos::new(1, 14))); + assert!(!see(a, 2).contains(&Pos::new(1, 5))); + assert!(see(a, 2).contains(&Pos::new(1, 3))); + // Every floor tile on the open side is visible from the middle of the room. + let c = Pos::new(2, 4); + for y in 0..9 { + for x in 0..4 { + assert!(see(c, 12).contains(&Pos::new(x, y)), "({x},{y}) hidden"); + } + } + // Symmetry holds for every pair of floor tiles. + let floors: Vec = (0..9) + .flat_map(|y| (0..9).map(move |x| Pos::new(x, y))) + .filter(|p| !(p.x == 4 && p.y <= 6)) + .collect(); + for p in &floors { + let sp = see(*p, 20); + for q in &floors { + assert_eq!(sp.contains(q), see(*q, 20).contains(p), "{p:?} {q:?}"); + } + } + } +} diff --git a/crates/city/src/walker/mod.rs b/crates/city/src/walker/mod.rs index ae645e1..64306b5 100644 --- a/crates/city/src/walker/mod.rs +++ b/crates/city/src/walker/mod.rs @@ -1,5 +1,181 @@ //! The walker: the player's `@`, movement, connectors and field of view. -/// The player. Filled in by the walker task; declared here so app state can hold one. +pub mod fov; + +pub use crate::render::Visibility; +use citygen::rng::hash64; +use citygen::{City, Dir, Grid, Pos, StratumKind, Theme}; + +pub const RADIUS_STREET: i32 = 14; +pub const RADIUS_UNDER: i32 = 8; + #[derive(Clone, Debug)] -pub struct Player; +pub struct Player { + pub pos: Pos, + pub stratum: StratumKind, + /// What has been seen on each physical stratum. + memory: Vec>, +} + +impl Player { + /// Drops the player on a public connector at street level, outside the corp core. + pub fn spawn(city: &City, seed: u64) -> Player { + let street = StratumKind::Street; + let is_core = |p: Pos| { + city.tile(street, p) + .and_then(|t| t.district) + .is_some_and(|d| city.district(d).theme == Theme::CorpCore) + }; + let public: Vec = city + .connectors + .iter() + .filter(|c| c.public && c.serves.contains(&street) && city.walkable(street, c.pos)) + .map(|c| c.pos) + .collect(); + let preferred: Vec = public.iter().copied().filter(|p| !is_core(*p)).collect(); + let pool = if preferred.is_empty() { + &public + } else { + &preferred + }; + let pos = if pool.is_empty() { + city.stratum(street) + .and_then(|st| { + st.tiles + .iter() + .find(|(_, t)| t.is_walkable()) + .map(|(p, _)| p) + }) + .unwrap_or(city.size.center()) + } else { + pool[(hash64(seed, 0x5A17) % pool.len() as u64) as usize] + }; + let mut player = Player { + pos, + stratum: street, + memory: StratumKind::PHYSICAL + .iter() + .map(|_| Grid::filled(city.size, Visibility::Unseen)) + .collect(), + }; + player.update_fov(city); + player + } + + pub fn radius(&self) -> i32 { + if self.stratum == StratumKind::Undercity { + RADIUS_UNDER + } else { + RADIUS_STREET + } + } + + /// Steps one tile if the target is walkable. + pub fn try_move(&mut self, city: &City, dir: Dir) -> bool { + let target = self.pos.step(dir); + if city.walkable(self.stratum, target) { + self.pos = target; + true + } else { + false + } + } + + /// Takes the connector under the player one stratum up or down, if it serves that way. + pub fn use_connector(&mut self, city: &City, up: bool) -> Option { + let dest = city + .connectors_at(self.pos) + .filter_map(|c| c.serves_from(self.stratum)) + .find_map(|(below, above)| if up { above } else { below })?; + if !city.walkable(dest, self.pos) { + return None; + } + self.stratum = dest; + Some(dest) + } + + /// Recomputes what the player sees on the current stratum, demoting old sight to memory. + pub fn update_fov(&mut self, city: &City) { + let radius = self.radius(); + let Some(st) = city.stratum(self.stratum) else { + return; + }; + let grid = &mut self.memory[self.stratum.index()]; + for v in grid.as_mut_slice().iter_mut() { + if *v == Visibility::Visible { + *v = Visibility::Remembered; + } + } + let size = city.size; + fov::compute( + self.pos, + radius, + |p| st.tiles.get(p).is_none_or(|t| t.blocks_sight()), + |p| size.contains(p), + |p| { + grid.set(p, Visibility::Visible); + }, + ); + } + + /// Visibility grid for the current stratum. + pub fn vis(&self) -> &Grid { + &self.memory[self.stratum.index()] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use citygen::{Dir, GenParams, Generator, Pos, SizePreset, StratumKind}; + + #[test] + fn player_moves_and_uses_connectors() { + let c = Generator::run(GenParams { + seed: 6, + size: SizePreset::Custom { w: 120, h: 60 }, + districts: 5, + ..Default::default() + }) + .unwrap(); + let mut p = Player::spawn(&c, 1); + assert_eq!(p.stratum, StratumKind::Street); + assert!(c.walkable(p.stratum, p.pos)); + let conn = c + .connectors + .iter() + .find(|k| k.public && k.serves.contains(&StratumKind::Undercity)) + .unwrap(); + p.pos = conn.pos; + p.stratum = StratumKind::Street; + assert_eq!(p.use_connector(&c, false), Some(StratumKind::Undercity)); + assert_eq!(p.use_connector(&c, false), None); + assert_eq!(p.use_connector(&c, true), Some(StratumKind::Street)); + p.update_fov(&c); + assert_eq!(p.vis()[p.pos], Visibility::Visible); + assert!(p.vis().count(|v| *v == Visibility::Visible) > 10); + let moved = Dir::ALL.iter().any(|d| { + let mut q = p.clone(); + q.try_move(&c, *d) + }); + assert!(moved); + let before = p.pos; + for d in Dir::ALL { + if p.try_move(&c, d) { + break; + } + } + assert_ne!(p.pos, before); + p.update_fov(&c); + assert!( + p.vis().count(|v| *v == Visibility::Remembered) + <= p.vis().count(|v| *v != Visibility::Unseen) + ); + let mut q = p.clone(); + q.pos = Pos::new(-1, -1); + assert!(!q.try_move(&c, Dir::N) || c.walkable(q.stratum, q.pos)); + assert_eq!(p.radius(), 14); + p.stratum = StratumKind::Undercity; + assert_eq!(p.radius(), 8); + } +}