//! Shadowcasting field-of-view algorithms //! //! This module implements two shadowcasting algorithms for FOV calculation: //! 1. Albert Ford's Symmetric Shadowcasting (production algorithm) //! 2. Traditional Recursive Shadowcasting (reference implementation) //! //! Coordinate system: Y-down (North = y-1, South = y+1) //! //! References: //! - Symmetric: https://www.albertford.com/shadowcasting/ //! - Traditional: RogueBasin recursive shadowcasting //! //! Note: HashSet is used here as a per-frame scratch accumulator for visible //! tile positions during the FOV sweep. Only `insert` and `contains` are used; //! iteration order never affects the output (results are handed to BTreeSet in //! query.rs). Not simulation state — exempt from the determinism constraint. #![allow(clippy::disallowed_types)] use std::collections::HashSet; /// Rational fraction for precise slope calculations without float drift #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Fraction { num: i32, den: i32, } impl Fraction { fn new(num: i32, den: i32) -> Self { Self { num, den } } /// Compare this fraction to another: returns true if self < other fn less_than(&self, other: &Fraction) -> bool { self.num * other.den < other.num * self.den } /// Compare this fraction to another: returns true if self > other fn greater_than(&self, other: &Fraction) -> bool { self.num * other.den > other.num * self.den } } /// Public production API: Visibility map for a single z-level #[derive(Debug, Clone)] pub struct VisibilityMap { visible: HashSet<(i32, i32)>, z_level: i32, } impl VisibilityMap { /// Check if a tile at (x, y) is visible pub fn is_visible(&self, x: i32, y: i32) -> bool { self.visible.contains(&(x, y)) } /// Iterate over all visible tiles pub fn visible_tiles(&self) -> impl Iterator + '_ { self.visible.iter().copied() } /// Count of visible tiles pub fn count(&self) -> usize { self.visible.len() } /// Get the z-level this visibility map represents pub fn z_level(&self) -> i32 { self.z_level } } /// Production FOV function - computes field of view using symmetric shadowcasting /// /// # Arguments /// * `is_opaque` - Function returning true if tile at (x, y) blocks vision /// * `origin_x`, `origin_y` - Observer position /// * `range` - Maximum vision distance (using Chebyshev distance) /// * `z_level` - Z-level for the visibility map /// /// # Returns /// A VisibilityMap containing all visible tiles (including the origin) pub fn compute_fov( is_opaque: impl Fn(i32, i32) -> bool, origin_x: i32, origin_y: i32, range: i32, z_level: i32, ) -> VisibilityMap { let visible = symmetric_shadowcast(&is_opaque, origin_x, origin_y, range); VisibilityMap { visible, z_level } } /// Albert Ford's Symmetric Shadowcasting algorithm /// /// Key property: if tile A sees tile B, then tile B sees tile A (symmetry) /// A tile is visible if its CENTER is within the unblocked cone /// /// Uses rational fractions to avoid floating-point drift pub fn symmetric_shadowcast( is_opaque: &impl Fn(i32, i32) -> bool, origin_x: i32, origin_y: i32, range: i32, ) -> HashSet<(i32, i32)> { let mut visible = HashSet::new(); visible.insert((origin_x, origin_y)); // Origin is always visible // Process 4 cardinal quadrants for &cardinal in &[ Cardinal::North, Cardinal::East, Cardinal::South, Cardinal::West, ] { scan_quadrant(&mut visible, is_opaque, origin_x, origin_y, range, cardinal); } visible } /// Cardinal directions for quadrant processing #[derive(Debug, Clone, Copy)] enum Cardinal { North, East, South, West, } impl Cardinal { /// Transform row/col in quadrant space to world (x, y) fn transform(&self, origin_x: i32, origin_y: i32, row: i32, col: i32) -> (i32, i32) { match self { Cardinal::North => (origin_x + col, origin_y - row), Cardinal::East => (origin_x + row, origin_y + col), Cardinal::South => (origin_x + col, origin_y + row), Cardinal::West => (origin_x - row, origin_y + col), } } } /// Scan a single quadrant using symmetric shadowcasting fn scan_quadrant( visible: &mut HashSet<(i32, i32)>, is_opaque: &impl Fn(i32, i32) -> bool, origin_x: i32, origin_y: i32, range: i32, cardinal: Cardinal, ) { let first_row = Row { depth: 1, start_slope: Fraction::new(-1, 1), end_slope: Fraction::new(1, 1), }; scan_row( visible, is_opaque, origin_x, origin_y, range, cardinal, first_row, ); } #[derive(Debug, Clone, Copy)] struct Row { depth: i32, start_slope: Fraction, end_slope: Fraction, } /// Recursively scan a row in the quadrant fn scan_row( visible: &mut HashSet<(i32, i32)>, is_opaque: &impl Fn(i32, i32) -> bool, origin_x: i32, origin_y: i32, range: i32, cardinal: Cardinal, mut row: Row, ) { if row.depth > range { return; } let mut prev_tile_opaque = None; let min_col = row.start_slope.num * row.depth / row.start_slope.den; let max_col = row.end_slope.num * row.depth / row.end_slope.den; for col in min_col..=max_col { let (x, y) = cardinal.transform(origin_x, origin_y, row.depth, col); // Check Chebyshev distance (max of absolute differences) let dx = (x - origin_x).abs(); let dy = (y - origin_y).abs(); if dx.max(dy) > range { continue; } // Check if tile center is within the view cone let tile_center_slope = Fraction::new(2 * col, 2 * row.depth); if is_visible_from_center(&row, &tile_center_slope) { visible.insert((x, y)); } let is_opaque_tile = is_opaque(x, y); // Handle wall-to-floor transition if prev_tile_opaque == Some(true) && !is_opaque_tile { // Exiting shadow - update start slope for this row row.start_slope = Fraction::new(2 * col - 1, 2 * row.depth); } // Handle floor-to-wall transition if prev_tile_opaque == Some(false) && is_opaque_tile { // Entering shadow - recursively scan next row with narrowed end slope let mut next_row = row; next_row.depth = row.depth + 1; next_row.end_slope = Fraction::new(2 * col - 1, 2 * row.depth); scan_row( visible, is_opaque, origin_x, origin_y, range, cardinal, next_row, ); } prev_tile_opaque = Some(is_opaque_tile); } // Continue to next row if the last tile wasn't opaque if prev_tile_opaque != Some(true) { let mut next_row = row; next_row.depth = row.depth + 1; scan_row( visible, is_opaque, origin_x, origin_y, range, cardinal, next_row, ); } } /// Check if a tile center is visible given the current row's slope bounds fn is_visible_from_center(row: &Row, tile_center_slope: &Fraction) -> bool { !tile_center_slope.less_than(&row.start_slope) && !tile_center_slope.greater_than(&row.end_slope) } /// Traditional recursive shadowcasting algorithm (8 octants, float slopes) /// /// This is a simpler reference implementation using iterative distance-based scanning pub fn recursive_shadowcast( is_opaque: &impl Fn(i32, i32) -> bool, origin_x: i32, origin_y: i32, range: i32, ) -> HashSet<(i32, i32)> { let mut visible = HashSet::new(); visible.insert((origin_x, origin_y)); // Simple approach: scan all tiles in range, use basic line-of-sight check for dx in -range..=range { for dy in -range..=range { let x = origin_x + dx; let y = origin_y + dy; // Skip origin (already added) if dx == 0 && dy == 0 { continue; } // Check Chebyshev distance (max of abs values) if dx.abs().max(dy.abs()) > range { continue; } // Check line of sight using simple raycast if has_line_of_sight(is_opaque, origin_x, origin_y, x, y) { visible.insert((x, y)); } } } visible } /// Simple line-of-sight check using DDA-style line traversal /// Returns true if target is visible (either no obstacles, or target itself is first obstacle) fn has_line_of_sight( is_opaque: &impl Fn(i32, i32) -> bool, x0: i32, y0: i32, x1: i32, y1: i32, ) -> bool { let dx = (x1 - x0).abs(); let dy = (y1 - y0).abs(); let sx = if x0 < x1 { 1 } else { -1 }; let sy = if y0 < y1 { 1 } else { -1 }; let mut err = dx - dy; let mut x = x0; let mut y = y0; loop { // Check if we hit a blocking tile BEFORE reaching target if (x != x0 || y != y0) && (x != x1 || y != y1) && is_opaque(x, y) { // Hit an obstacle before reaching target - blocked return false; } // If we reach the target, we can see it if x == x1 && y == y1 { return true; } let e2 = 2 * err; if e2 > -dy { err -= dy; x += sx; } if e2 < dx { err += dx; y += sy; } } } #[cfg(test)] mod tests { use super::*; /// Helper: create a simple wall map from a grid fn make_wall_fn(walls: HashSet<(i32, i32)>) -> impl Fn(i32, i32) -> bool { move |x, y| walls.contains(&(x, y)) } #[test] fn test_open_field_symmetric() { // Open field: all tiles within range should be visible let no_walls = HashSet::new(); let is_opaque = make_wall_fn(no_walls); let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5); // Should see at least the cross pattern + diagonals assert!(visible.contains(&(0, 0))); // origin assert!(visible.contains(&(1, 0))); // east assert!(visible.contains(&(0, 1))); // south assert!(visible.contains(&(-1, 0))); // west assert!(visible.contains(&(0, -1))); // north assert!(visible.contains(&(1, 1))); // SE diagonal assert!(visible.len() > 20); // Reasonable coverage } #[test] fn test_open_field_recursive() { let no_walls = HashSet::new(); let is_opaque = make_wall_fn(no_walls); let visible = recursive_shadowcast(&is_opaque, 0, 0, 5); assert!(visible.contains(&(0, 0))); assert!(visible.contains(&(1, 0))); assert!(visible.contains(&(0, 1))); assert!(visible.len() > 20); } #[test] fn test_single_wall_blocks_vision() { // Wall at (1, 0) should block vision beyond it let mut walls = HashSet::new(); walls.insert((1, 0)); let is_opaque = make_wall_fn(walls); let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5); // Should see the wall assert!(visible.contains(&(1, 0))); // Should NOT see directly behind it assert!(!visible.contains(&(2, 0))); } #[test] fn test_origin_always_visible() { let mut walls = HashSet::new(); // Even if origin is "opaque" it should be visible walls.insert((0, 0)); let is_opaque = make_wall_fn(walls); let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5); assert!(visible.contains(&(0, 0))); } #[test] fn test_range_cutoff() { let no_walls = HashSet::new(); let is_opaque = make_wall_fn(no_walls); let visible = symmetric_shadowcast(&is_opaque, 0, 0, 3); // Should see (3, 0) but not (4, 0) assert!(visible.contains(&(3, 0))); assert!(!visible.contains(&(4, 0))); } #[test] fn test_corner_peek() { // Wall at (1, 1), can we peek around corners? let mut walls = HashSet::new(); walls.insert((1, 1)); let is_opaque = make_wall_fn(walls); let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5); // Should see the wall assert!(visible.contains(&(1, 1))); // Should still see adjacent tiles like (2, 1) and (1, 2) assert!(visible.contains(&(2, 1))); assert!(visible.contains(&(1, 2))); } #[test] fn test_pillar_casts_shadow() { // Pillar at (2, 0) should cast shadow let mut walls = HashSet::new(); walls.insert((2, 0)); let is_opaque = make_wall_fn(walls); let visible = symmetric_shadowcast(&is_opaque, 0, 0, 10); // See the pillar assert!(visible.contains(&(2, 0))); // Should NOT see far behind it assert!(!visible.contains(&(8, 0))); } #[test] fn test_production_api() { let no_walls = HashSet::new(); let is_opaque = make_wall_fn(no_walls); let vis_map = compute_fov(is_opaque, 5, 5, 10, 0); assert_eq!(vis_map.z_level(), 0); assert!(vis_map.is_visible(5, 5)); assert!(vis_map.is_visible(6, 5)); assert!(vis_map.count() > 50); let tiles: Vec<_> = vis_map.visible_tiles().collect(); assert!(!tiles.is_empty()); } }