//! Field of view by symmetric shadowcasting, after Albert Ford's write-up at
//! .
//!
//! Symmetric means that if you can see a floor cell, someone standing there
//! can see you. Monsters wake when they come into view, so this keeps
//! ambushes fair: nothing notices you that you couldn't have seen first.
use super::{Map, Pos};
/// The cells visible from one spot.
pub struct Fov {
width: i32,
visible: Vec,
}
impl Fov {
pub fn contains(&self, p: Pos) -> bool {
p.x >= 0
&& p.y >= 0
&& p.x < self.width
&& self.visible.get((p.y * self.width + p.x) as usize).copied().unwrap_or(false)
}
}
/// Everything visible from `origin` within `radius` cells. Walls are visible
/// (that's how you see a room's shape); cells behind them aren't.
pub fn compute(map: &Map, origin: Pos, radius: i32) -> Fov {
let mut fov = Fov { width: map.width, visible: vec![false; (map.width * map.height) as usize] };
let mut reveal = |p: Pos| {
if map.contains(p) {
fov.visible[map.index(p)] = true;
}
};
reveal(origin);
for quadrant in Quadrant::ALL {
let mut rows = vec![Row { depth: 1, start: Slope::new(-1, 1), end: Slope::new(1, 1) }];
while let Some(mut row) = rows.pop() {
if row.depth > radius {
continue;
}
let mut prev_blocked = None;
for col in row.min_col()..=row.max_col() {
let p = quadrant.cell(origin, row.depth, col);
let blocked = map.tile(p).blocks_sight();
let in_radius = col * col + row.depth * row.depth <= radius * radius + radius;
if in_radius && (blocked || row.is_symmetric(col)) {
reveal(p);
}
if prev_blocked == Some(true) && !blocked {
row.start = Slope::left_edge(row.depth, col);
}
if prev_blocked == Some(false) && blocked {
let mut next = row.next();
next.end = Slope::left_edge(row.depth, col);
rows.push(next);
}
prev_blocked = Some(blocked);
}
if prev_blocked == Some(false) {
rows.push(row.next());
}
}
}
fov
}
#[derive(Clone, Copy)]
enum Quadrant {
North,
East,
South,
West,
}
impl Quadrant {
const ALL: [Quadrant; 4] = [Quadrant::North, Quadrant::East, Quadrant::South, Quadrant::West];
/// The cell `depth` rows out from `origin` and `col` columns across.
fn cell(self, origin: Pos, depth: i32, col: i32) -> Pos {
match self {
Quadrant::North => origin.offset(col, -depth),
Quadrant::South => origin.offset(col, depth),
Quadrant::East => origin.offset(depth, col),
Quadrant::West => origin.offset(-depth, col),
}
}
}
/// An exact fraction, so shadows land on the same cells every time.
#[derive(Clone, Copy)]
struct Slope {
num: i32,
/// Always positive.
den: i32,
}
impl Slope {
const fn new(num: i32, den: i32) -> Slope {
Slope { num, den }
}
/// The slope through the left edge of the cell at (`depth`, `col`).
const fn left_edge(depth: i32, col: i32) -> Slope {
Slope::new(2 * col - 1, 2 * depth)
}
}
#[derive(Clone, Copy)]
struct Row {
depth: i32,
start: Slope,
end: Slope,
}
impl Row {
/// `depth * start`, rounded with ties going up.
fn min_col(&self) -> i32 {
(2 * self.depth * self.start.num + self.start.den).div_euclid(2 * self.start.den)
}
/// `depth * end`, rounded with ties going down.
fn max_col(&self) -> i32 {
-(self.end.den - 2 * self.depth * self.end.num).div_euclid(2 * self.end.den)
}
/// Whether the centre of the cell at `col` is inside this row's slopes.
fn is_symmetric(&self, col: i32) -> bool {
col * self.start.den >= self.depth * self.start.num
&& col * self.end.den <= self.depth * self.end.num
}
fn next(&self) -> Row {
Row { depth: self.depth + 1, ..*self }
}
}
#[cfg(test)]
mod tests {
use rand::RngExt;
use super::*;
use crate::dungeon::Tile;
use crate::seed;
fn map_from(rows: &[&str]) -> (Map, Pos) {
let mut map = Map::new(rows[0].len() as i32, rows.len() as i32, Tile::Floor);
let mut at = Pos::new(0, 0);
for (y, row) in rows.iter().enumerate() {
for (x, c) in row.chars().enumerate() {
let p = Pos::new(x as i32, y as i32);
match c {
'#' => map.set(p, Tile::Wall),
'@' => at = p,
_ => {}
}
}
}
(map, at)
}
#[test]
fn walls_cast_shadows() {
let (map, at) = map_from(&[
"#########", //
"#.......#", "#...@...#", "#..###..#", "#.......#", "#########",
]);
let fov = compute(&map, at, 10);
assert!(fov.contains(Pos::new(1, 1)), "open floor is visible");
assert!(fov.contains(Pos::new(4, 3)), "the wall itself is visible");
assert!(!fov.contains(Pos::new(4, 4)), "the cell right behind the wall is hidden");
assert!(fov.contains(Pos::new(1, 3)), "cells beside the wall stay visible");
}
#[test]
fn radius_limits_sight() {
let (map, _) = map_from(&[&".".repeat(30)]);
let fov = compute(&map, Pos::new(0, 0), 5);
assert!(fov.contains(Pos::new(5, 0)));
assert!(!fov.contains(Pos::new(6, 0)));
}
/// The property the module promises: seeing is mutual between floor cells.
#[test]
fn sight_is_symmetric() {
let mut rng = seed::rng(4);
for _ in 0..20 {
let mut map = Map::new(24, 16, Tile::Floor);
for p in map.positions().collect::>() {
if rng.random_bool(0.25) {
map.set(p, Tile::Wall);
}
}
let floors: Vec =
map.positions().filter(|&p| map.tile(p) == Tile::Floor).collect();
let views: Vec = floors.iter().map(|&p| compute(&map, p, 8)).collect();
for (i, &a) in floors.iter().enumerate() {
for (j, &b) in floors.iter().enumerate() {
assert_eq!(
views[i].contains(b),
views[j].contains(a),
"{a:?} and {b:?} disagree about seeing each other"
);
}
}
}
}
}