Files
settled-reach/server/src/perception/vision_cone.rs
T
jpmschweitzerandClaude Opus 4.6 f760624967 feat(simulation): add vision cone with forward/peripheral/blind sectors (#111)
Implement direction-dependent visibility modulation per D-015.
Forward cone (~120 deg) at full range, peripheral (~180 deg each side)
at reduced range, blind spot (~60 deg behind) excluded. Facing component
updated on player movement via facing_from_delta.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:21:01 +01:00

295 lines
10 KiB
Rust

//! Vision cone system (D-015)
//!
//! Modulates raw shadowcast output with direction-dependent sectors:
//! - Forward: full LOS range, full detail (~120 degree arc)
//! - Peripheral: reduced range, dimmer (~90 degrees each side)
//! - Behind: blind (excluded from output)
//!
//! Y-down convention: North = (0, -1)
use crate::bridge::types::{FacingDirection, VisibilitySector};
use crate::perception::shadowcast::VisibilityMap;
use bevy_ecs::prelude::*;
/// Component tracking which direction an entity faces.
/// Updated by the input system when an entity moves.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Facing(pub FacingDirection);
impl Default for Facing {
fn default() -> Self {
Facing(FacingDirection::North)
}
}
/// Vision cone configuration per D-015
pub struct VisionConeConfig {
/// Maximum vision range for forward sector (in tiles)
pub forward_range: i32,
/// Maximum vision range for peripheral sector (shorter than forward)
pub peripheral_range: i32,
/// Half-angle of forward cone in radians (~60 degrees = 120 degree arc)
pub forward_half_angle: f32,
/// Half-angle of total visible cone in radians (~150 degrees = 300 degree arc)
/// Tiles beyond this are in the blind spot
pub visible_half_angle: f32,
}
impl Default for VisionConeConfig {
fn default() -> Self {
Self {
forward_range: 20,
peripheral_range: 12,
forward_half_angle: std::f32::consts::FRAC_PI_3, // 60 degrees = 120 degree arc
visible_half_angle: 5.0 * std::f32::consts::FRAC_PI_6, // 150 degrees = 300 degree arc
}
}
}
/// Convert FacingDirection to a unit direction angle in radians (y-down coords).
/// East = 0, South = PI/2, West = PI/-PI, North = -PI/2
fn facing_to_angle(facing: FacingDirection) -> f32 {
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
match facing {
FacingDirection::East => 0.0,
FacingDirection::Southeast => FRAC_PI_4,
FacingDirection::South => FRAC_PI_2,
FacingDirection::Southwest => 3.0 * FRAC_PI_4,
FacingDirection::West => PI,
FacingDirection::Northwest => -3.0 * FRAC_PI_4,
FacingDirection::North => -FRAC_PI_2,
FacingDirection::Northeast => -FRAC_PI_4,
}
}
/// Derive FacingDirection from a movement delta (dx, dy) in y-down coords
pub fn facing_from_delta(dx: i32, dy: i32) -> FacingDirection {
match (dx, dy) {
(0, -1) => FacingDirection::North,
(0, 1) => FacingDirection::South,
(1, 0) => FacingDirection::East,
(-1, 0) => FacingDirection::West,
(1, -1) => FacingDirection::Northeast,
(-1, -1) => FacingDirection::Northwest,
(1, 1) => FacingDirection::Southeast,
(-1, 1) => FacingDirection::Southwest,
_ => FacingDirection::North, // default for no movement
}
}
/// Classify a visible tile into a vision sector based on facing direction.
/// Returns None if the tile falls in the blind spot (behind).
fn classify_tile(
observer_x: i32,
observer_y: i32,
tile_x: i32,
tile_y: i32,
facing: FacingDirection,
config: &VisionConeConfig,
) -> Option<VisibilitySector> {
// Tile at observer position is always Forward
if tile_x == observer_x && tile_y == observer_y {
return Some(VisibilitySector::Forward);
}
let dx = (tile_x - observer_x) as f32;
let dy = (tile_y - observer_y) as f32;
// Chebyshev distance for range check
let dist = dx.abs().max(dy.abs()) as i32;
// Angle from observer to tile (y-down: atan2(dy, dx))
let tile_angle = dy.atan2(dx);
let facing_angle = facing_to_angle(facing);
// Angular difference (wrapped to [-PI, PI])
let mut diff = tile_angle - facing_angle;
if diff > std::f32::consts::PI {
diff -= 2.0 * std::f32::consts::PI;
}
if diff < -std::f32::consts::PI {
diff += 2.0 * std::f32::consts::PI;
}
let abs_diff = diff.abs();
// Check sectors from innermost to outermost
if abs_diff <= config.forward_half_angle && dist <= config.forward_range {
Some(VisibilitySector::Forward)
} else if abs_diff <= config.visible_half_angle && dist <= config.peripheral_range {
Some(VisibilitySector::Peripheral)
} else if abs_diff <= config.visible_half_angle && dist <= config.forward_range {
// Beyond peripheral range but within visible angle and forward range:
// still visible at reduced quality
Some(VisibilitySector::Peripheral)
} else {
None // Blind spot
}
}
/// Apply vision cone to a raw shadowcast VisibilityMap.
/// Returns only tiles in Forward or Peripheral sectors, with sector tags.
/// Tiles in the blind spot (behind) are excluded.
pub fn apply_vision_cone(
fov: &VisibilityMap,
observer_x: i32,
observer_y: i32,
facing: FacingDirection,
config: &VisionConeConfig,
) -> Vec<(i32, i32, VisibilitySector)> {
fov.visible_tiles()
.filter_map(|(x, y)| {
classify_tile(observer_x, observer_y, x, y, facing, config)
.map(|sector| (x, y, sector))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> VisionConeConfig {
VisionConeConfig::default()
}
#[test]
fn observer_position_always_forward() {
let config = default_config();
for dir in [
FacingDirection::North,
FacingDirection::South,
FacingDirection::East,
FacingDirection::West,
] {
let result = classify_tile(5, 5, 5, 5, dir, &config);
assert_eq!(result, Some(VisibilitySector::Forward));
}
}
#[test]
fn forward_sector_north() {
let config = default_config();
// Facing north, tile directly north should be Forward
let result = classify_tile(5, 5, 5, 3, FacingDirection::North, &config);
assert_eq!(result, Some(VisibilitySector::Forward));
}
#[test]
fn peripheral_sector_sides() {
let config = default_config();
// Facing north, tile to the east should be Peripheral
let result = classify_tile(5, 5, 8, 5, FacingDirection::North, &config);
assert_eq!(result, Some(VisibilitySector::Peripheral));
}
#[test]
fn behind_is_blind() {
let config = default_config();
// Facing north, tile directly south should be blind (None)
let result = classify_tile(5, 5, 5, 10, FacingDirection::North, &config);
assert_eq!(result, None);
}
#[test]
fn forward_range_limit() {
let config = default_config();
// Tile at forward range should be visible
let result = classify_tile(0, 0, 0, -20, FacingDirection::North, &config);
assert_eq!(result, Some(VisibilitySector::Forward));
// Tile beyond forward range should not be (but this would not be in FOV anyway)
}
#[test]
fn peripheral_range_limit() {
let config = default_config();
// Tile at distance > peripheral_range but in peripheral angle:
// should be Peripheral (within forward_range)
let result = classify_tile(0, 0, 15, 0, FacingDirection::North, &config);
assert_eq!(result, Some(VisibilitySector::Peripheral));
}
#[test]
fn all_facing_directions_produce_forward() {
let config = default_config();
// For each facing direction, the tile directly ahead should be Forward
let cases = [
(FacingDirection::North, (0, -3)),
(FacingDirection::South, (0, 3)),
(FacingDirection::East, (3, 0)),
(FacingDirection::West, (-3, 0)),
(FacingDirection::Northeast, (3, -3)),
(FacingDirection::Southeast, (3, 3)),
(FacingDirection::Southwest, (-3, 3)),
(FacingDirection::Northwest, (-3, -3)),
];
for (dir, (dx, dy)) in cases {
let result = classify_tile(5, 5, 5 + dx, 5 + dy, dir, &config);
assert_eq!(
result,
Some(VisibilitySector::Forward),
"Facing {:?}, tile ({}, {}) should be Forward",
dir,
5 + dx,
5 + dy
);
}
}
#[test]
fn facing_from_delta_all_directions() {
assert_eq!(facing_from_delta(0, -1), FacingDirection::North);
assert_eq!(facing_from_delta(0, 1), FacingDirection::South);
assert_eq!(facing_from_delta(1, 0), FacingDirection::East);
assert_eq!(facing_from_delta(-1, 0), FacingDirection::West);
assert_eq!(facing_from_delta(1, -1), FacingDirection::Northeast);
assert_eq!(facing_from_delta(-1, -1), FacingDirection::Northwest);
assert_eq!(facing_from_delta(1, 1), FacingDirection::Southeast);
assert_eq!(facing_from_delta(-1, 1), FacingDirection::Southwest);
}
#[test]
fn apply_vision_cone_filters_behind() {
use crate::perception::shadowcast::compute_fov;
let fov = compute_fov(|_, _| false, 5, 5, 10, 0);
let config = default_config();
let cone = apply_vision_cone(&fov, 5, 5, FacingDirection::North, &config);
// Should have some tiles
assert!(!cone.is_empty());
// Tile directly south (same x, far behind) should be in blind spot
// The blind spot is the 60 degrees directly behind
let has_direct_south_far = cone.iter().any(|&(x, y, _)| x == 5 && y >= 10);
assert!(
!has_direct_south_far,
"tiles directly behind (same column, far south) should be blind"
);
// Origin should be present
let has_origin = cone.iter().any(|&(x, y, _)| x == 5 && y == 5);
assert!(has_origin, "observer position should be in cone");
// Tiles directly north should be Forward
let north_tiles: Vec<_> = cone
.iter()
.filter(|&&(x, _, _)| x == 5)
.filter(|&&(_, y, _)| y < 5)
.collect();
assert!(!north_tiles.is_empty());
for &&(_, _, sector) in &north_tiles {
assert_eq!(sector, VisibilitySector::Forward);
}
// Fewer tiles behind than in front (asymmetric cone)
let tiles_north = cone.iter().filter(|&&(_, y, _)| y < 5).count();
let tiles_south = cone.iter().filter(|&&(_, y, _)| y > 5).count();
assert!(
tiles_north > tiles_south,
"should see more tiles forward (north={}) than behind (south={})",
tiles_north,
tiles_south
);
}
}