//! 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 { // 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 ); } }