diff --git a/server/src/main.rs b/server/src/main.rs index 887c8a367..bb94311b9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,6 +6,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; +use settled_reach_server::perception::vision_cone::Facing; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::SimulationPlugin; @@ -40,7 +41,7 @@ fn main() { app.insert_resource(BridgeResource::new(bridge)); app.insert_resource(WalkabilityMap::new(32, 32, 1)); app.world_mut() - .spawn((PlayerCharacter, TilePosition::new(16, 16, 0))); + .spawn((PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default())); tracing::info!("Simulation initialized, entering game loop"); diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index d31253691..70dd3fb6d 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -4,6 +4,10 @@ use bevy_app::prelude::*; +pub mod observer; +pub mod shadowcast; +pub mod vision_cone; + /// Perception system plugin /// Manages information boundaries and observer snapshots pub struct PerceptionPlugin; diff --git a/server/src/perception/vision_cone.rs b/server/src/perception/vision_cone.rs new file mode 100644 index 000000000..982709a39 --- /dev/null +++ b/server/src/perception/vision_cone.rs @@ -0,0 +1,294 @@ +//! 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 + ); + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index cc711459c..f5e9a2ffc 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -3,6 +3,7 @@ // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode) use crate::bridge::types::{PlayerAction, PlayerInput}; +use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; use bevy_ecs::prelude::*; @@ -101,6 +102,10 @@ fn apply_move( commands.entity(entity).insert(MoveIntent { target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), }); + // Update facing direction based on movement (D-015 vision cone) + commands + .entity(entity) + .insert(Facing(facing_from_delta(dx, dy))); } else { tracing::warn!("No player entity found for movement input"); }