Remove the peripheral sector (100° half-angle, reduced range) and blind spot classification. The server now sends only tiles within the 120° forward cone; the client renders previously-explored tiles behind the player with a light fog overlay instead. This eliminates complexity in both the cone classifier and the snapshot protocol while preserving the core information asymmetry — you still can't see behind you, and the monologue system (D-016) still bridges the perceptual gap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
10 KiB
Rust
290 lines
10 KiB
Rust
//! Vision cone system (D-015)
|
|
//!
|
|
//! Modulates raw shadowcast output with a directional 120° forward cone
|
|
//! grounded in human binocular overlap (~60° per eye where both converge).
|
|
//! Tiles outside the cone are excluded from the visible set entirely —
|
|
//! the client renders previously-explored tiles behind the player with a
|
|
//! light fog overlay (art and information preserved, just "not fresh").
|
|
//!
|
|
//! Per-entity VisionConeConfig allows future augmentation (implants,
|
|
//! perception modes per D-017) to widen the cone beyond baseline human.
|
|
//!
|
|
//! 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.
|
|
///
|
|
/// Baseline: 120° forward arc (60° half-angle) grounded in human binocular
|
|
/// overlap physiology. Per-entity config allows augmentation via implants
|
|
/// or perception modes (D-017).
|
|
pub struct VisionConeConfig {
|
|
/// Maximum vision range (in tiles)
|
|
pub forward_range: i32,
|
|
/// Half-angle of vision cone in radians (60° = 120° arc)
|
|
pub forward_half_angle: f32,
|
|
}
|
|
|
|
impl Default for VisionConeConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
forward_range: 20,
|
|
forward_half_angle: std::f32::consts::FRAC_PI_3, // 60° = 120° 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 outside the forward cone.
|
|
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;
|
|
}
|
|
|
|
if diff.abs() <= config.forward_half_angle && dist <= config.forward_range {
|
|
Some(VisibilitySector::Forward)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Apply vision cone to a raw shadowcast VisibilityMap.
|
|
/// Returns only tiles within the forward cone, tagged as Forward.
|
|
/// Tiles outside the cone are excluded (client renders explored ones
|
|
/// with a light fog overlay).
|
|
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 side_tile_outside_cone() {
|
|
let config = default_config();
|
|
// Facing north, tile due east (90° off) is outside the 120° cone
|
|
let result = classify_tile(5, 5, 8, 5, FacingDirection::North, &config);
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[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 cone_boundary() {
|
|
let config = default_config();
|
|
// Tile at ~56° from North (within 60° half-angle) should be Forward
|
|
let result = classify_tile(0, 0, 3, -2, FacingDirection::North, &config);
|
|
assert_eq!(result, Some(VisibilitySector::Forward));
|
|
// Tile at ~63° from North (outside 60° half-angle) should be None
|
|
let result = classify_tile(0, 0, 2, -1, FacingDirection::North, &config);
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[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 outside cone
|
|
// 240° blind arc behind the 120° forward cone
|
|
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
|
|
);
|
|
}
|
|
}
|