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:
2026-02-28 12:19:38 +01:00
co-authored by Claude Opus 4.6
parent 0bb38dd3d9
commit 2016e9a725
4 changed files with 44 additions and 3266 deletions
+6 -6
View File
@@ -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);
+31 -54
View File
@@ -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 10peripheral limit)
// Tile directly south (same x, far behind) should be outside cone
// 240° blind arc behind the 12forward 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
+3 -3
View File
@@ -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"
);
}