refactor(simulation): simplify vision cone to forward-only 120° arc
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>
This commit is contained in:
@@ -590,10 +590,10 @@ fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
|
||||
));
|
||||
// NPC 1: behind wall (should be hidden)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
|
||||
// NPC 2: to the side, no wall (should be visible)
|
||||
// NPC 2: NW diagonal, in forward cone (should be visible)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
// NPC 3: also visible
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
|
||||
// NPC 3: NE diagonal, in forward cone (should be visible)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
@@ -1911,7 +1911,7 @@ fn equidistant_npcs_produce_stable_snapshot_ordering() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Three NPCs equidistant from observer at (16,16) — all 2 tiles away.
|
||||
// Three NPCs equidistant from observer at (16,16) — all within forward cone.
|
||||
// Spawn order: npc_a, npc_b, npc_c → ascending stable_ids.
|
||||
let npc_a = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
@@ -1919,12 +1919,12 @@ fn equidistant_npcs_produce_stable_snapshot_ordering() {
|
||||
let npc_a_sid = registry.register(npc_a);
|
||||
|
||||
let npc_b = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(14, 16, 0)))
|
||||
.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)))
|
||||
.id();
|
||||
let npc_b_sid = registry.register(npc_b);
|
||||
|
||||
let npc_c = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(18, 16, 0)))
|
||||
.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)))
|
||||
.id();
|
||||
let npc_c_sid = registry.register(npc_c);
|
||||
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
//! Vision cone system (D-015)
|
||||
//!
|
||||
//! Modulates raw shadowcast output with direction-dependent sectors
|
||||
//! grounded in human sensory physiology:
|
||||
//! 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").
|
||||
//!
|
||||
//! - Forward: 120° arc (60° half-angle) — binocular overlap zone where
|
||||
//! both eyes converge. Stereoscopic depth, high acuity. Full LOS range.
|
||||
//! - Peripheral: extends to 200° arc (100° half-angle) — combined sensory
|
||||
//! awareness envelope. Beyond pure visual acuity (~120°), this includes
|
||||
//! subconscious tracking of sound cues, movement in the corner of the
|
||||
//! eye, and ambient sensory input. Reduced range, dimmer rendering.
|
||||
//! The fog shader gradient (D-059) is aligned to this outer edge.
|
||||
//! - Behind: 160° blind arc — no sensory awareness without explicit cues.
|
||||
//! You can be snuck up on. Monologue system (D-016) bridges this gap
|
||||
//! when the character's other senses fire.
|
||||
//!
|
||||
//! Reference: binocular field ~200° horizontal, overlap ~120°,
|
||||
//! far peripheral limit ~100-105° per eye (Wikipedia, NCBI NBK220).
|
||||
//! 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)
|
||||
|
||||
@@ -36,31 +28,21 @@ impl Default for Facing {
|
||||
|
||||
/// Vision cone configuration per D-015.
|
||||
///
|
||||
/// Angles grounded in human visual field physiology:
|
||||
/// - forward_half_angle (60°): binocular overlap — where both eyes converge,
|
||||
/// providing stereoscopic depth and high acuity.
|
||||
/// - visible_half_angle (100°): average human binocular field limit — the
|
||||
/// outermost boundary of peripheral vision. The fog shader's transparency
|
||||
/// gradient is aligned to this edge.
|
||||
/// 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 for forward sector (in tiles)
|
||||
/// Maximum vision range (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° = 120° arc, binocular overlap)
|
||||
/// Half-angle of vision cone in radians (60° = 120° arc)
|
||||
pub forward_half_angle: f32,
|
||||
/// Half-angle of total visible cone in radians (100° = 200° arc, peripheral limit)
|
||||
/// Tiles beyond this are in the blind spot (~160° behind)
|
||||
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° = 120° arc (binocular overlap)
|
||||
visible_half_angle: 100.0_f32.to_radians(), // 100° = 200° arc (peripheral limit)
|
||||
forward_half_angle: std::f32::consts::FRAC_PI_3, // 60° = 120° arc
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +79,7 @@ pub fn facing_from_delta(dx: i32, dy: i32) -> FacingDirection {
|
||||
}
|
||||
|
||||
/// Classify a visible tile into a vision sector based on facing direction.
|
||||
/// Returns None if the tile falls in the blind spot (behind).
|
||||
/// Returns None if the tile falls outside the forward cone.
|
||||
fn classify_tile(
|
||||
observer_x: i32,
|
||||
observer_y: i32,
|
||||
@@ -129,25 +111,18 @@ fn classify_tile(
|
||||
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 {
|
||||
if diff.abs() <= 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
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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,
|
||||
@@ -193,11 +168,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_sector_sides() {
|
||||
fn side_tile_outside_cone() {
|
||||
let config = default_config();
|
||||
// Facing north, tile to the east should be Peripheral
|
||||
// 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, Some(VisibilitySector::Peripheral));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -218,12 +193,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_range_limit() {
|
||||
fn cone_boundary() {
|
||||
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));
|
||||
// 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]
|
||||
@@ -276,8 +253,8 @@ mod tests {
|
||||
// Should have some tiles
|
||||
assert!(!cone.is_empty());
|
||||
|
||||
// Tile directly south (same x, far behind) should be in blind spot
|
||||
// The blind spot is ~160 degrees behind (beyond 100° peripheral limit)
|
||||
// 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,11 +122,11 @@ fn snapshot_excludes_entities_outside_los() {
|
||||
"IB-2 sanity: observer's own position must always be in the FOV set"
|
||||
);
|
||||
|
||||
// --- Additional sanity: an immediately adjacent tile (1 step) is visible ---
|
||||
let adjacent_pos = TilePosition::new(6, 5, 0);
|
||||
// --- Additional sanity: tile directly ahead (1 step north) is visible ---
|
||||
let adjacent_pos = TilePosition::new(5, 4, 0);
|
||||
assert!(
|
||||
geometry.visible_positions.contains(&(adjacent_pos.x, adjacent_pos.y)),
|
||||
"IB-2 sanity: tile immediately adjacent to observer must be visible"
|
||||
"IB-2 sanity: tile directly ahead of observer must be visible"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user