flatland-client-lib 0.2.115

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Client-side LoS probe (mirrors `flatland_sim::los` elevation + obstacle rules).
//!
//! Used by gfx F7 debug overlay. Outdoor AOI: terrain zones, building footprints,
//! blocking resource nodes. Interior wall segments are not modeled yet.

use flatland_protocol::{BuildingView, ResourceNodeState, ResourceNodeView};

/// Slack when comparing ray height to terrain (meters).
const GROUND_CLIP_SLACK_M: f32 = 0.25;
/// Max elevation difference for character LoS across a shelf.
const MAX_VISIBLE_ELEV_DELTA_M: f32 = 1.0;
const SAMPLES: usize = 12;
const LOS_XY_STEP_M: f32 = 0.5;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BlockingRect {
    pub x0: f32,
    pub y0: f32,
    pub x1: f32,
    pub y1: f32,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BlockingCircle {
    pub x: f32,
    pub y: f32,
    pub radius_m: f32,
}

/// Outdoor LoS blockers derivable from AOI snapshot data.
#[derive(Debug, Clone, Default)]
pub struct LosObstacles {
    pub rects: Vec<BlockingRect>,
    pub circles: Vec<BlockingCircle>,
}

/// Why a ray stopped (for debug tinting).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LosBlockKind {
    ElevationGap,
    ElevationTaper,
    Rect,
    Circle,
    Terrain,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LosBlock {
    pub x: f32,
    pub y: f32,
    pub z: f32,
    pub kind: LosBlockKind,
}

impl LosObstacles {
    /// Build outdoor obstacle set from streamed views (matches sim `movement_blocking_rects` +
    /// outdoor `blocking_obstacles` for buildings + resource nodes).
    pub fn from_outdoor_views(
        buildings: &[BuildingView],
        resource_nodes: &[ResourceNodeView],
        inside_building: Option<&str>,
    ) -> Self {
        let mut rects = Vec::new();
        for b in buildings {
            if inside_building == Some(b.id.as_str()) {
                continue;
            }
            let hw = b.width_m / 2.0;
            let hd = b.depth_m / 2.0;
            rects.push(BlockingRect {
                x0: b.x - hw,
                y0: b.y - hd,
                x1: b.x + hw,
                y1: b.y + hd,
            });
        }
        let mut circles = Vec::new();
        for n in resource_nodes {
            if !n.blocking {
                continue;
            }
            if !matches!(
                n.state,
                ResourceNodeState::Available | ResourceNodeState::Harvesting { .. }
            ) {
                continue;
            }
            circles.push(BlockingCircle {
                x: n.x,
                y: n.y,
                radius_m: n.blocking_radius_m.max(0.1),
            });
        }
        Self { rects, circles }
    }
}

/// First occlusion along the 3D ray, if any.
pub fn first_los_block(
    from_x: f32,
    from_y: f32,
    from_z: f32,
    to_x: f32,
    to_y: f32,
    to_z: f32,
    obstacles: &LosObstacles,
    ground_z: impl Fn(f32, f32) -> f32,
) -> Option<LosBlock> {
    let elev_delta = (from_z - to_z).abs();
    if elev_delta > MAX_VISIBLE_ELEV_DELTA_M + GROUND_CLIP_SLACK_M {
        let t = 0.5;
        return Some(LosBlock {
            x: from_x + (to_x - from_x) * t,
            y: from_y + (to_y - from_y) * t,
            z: from_z + (to_z - from_z) * t,
            kind: LosBlockKind::ElevationGap,
        });
    }
    if elev_delta > GROUND_CLIP_SLACK_M {
        let t = (elev_delta / MAX_VISIBLE_ELEV_DELTA_M).clamp(0.0, 1.0);
        let max_xy = 24.0 + (10.0 - 24.0) * t;
        let dist_xy = (to_x - from_x).hypot(to_y - from_y);
        if dist_xy > max_xy {
            let t = (max_xy / dist_xy.max(0.001)).clamp(0.0, 1.0);
            return Some(LosBlock {
                x: from_x + (to_x - from_x) * t,
                y: from_y + (to_y - from_y) * t,
                z: from_z + (to_z - from_z) * t,
                kind: LosBlockKind::ElevationTaper,
            });
        }
    }
    let high_z = from_z.max(to_z);
    let adjacent_shelf = elev_delta > GROUND_CLIP_SLACK_M;
    let dist_xy = ((to_x - from_x).hypot(to_y - from_y)).max(0.001);
    let sample_count = SAMPLES
        .max((dist_xy / LOS_XY_STEP_M).ceil() as usize)
        .max(2);

    for i in 1..sample_count {
        let t = i as f32 / sample_count as f32;
        let x = from_x + (to_x - from_x) * t;
        let y = from_y + (to_y - from_y) * t;
        let z = from_z + (to_z - from_z) * t;
        if point_in_rects(x, y, &obstacles.rects) {
            return Some(LosBlock {
                x,
                y,
                z,
                kind: LosBlockKind::Rect,
            });
        }
        if point_in_circles(x, y, &obstacles.circles) {
            return Some(LosBlock {
                x,
                y,
                z,
                kind: LosBlockKind::Circle,
            });
        }
        let ground = ground_z(x, y);
        if terrain_undercut_blocks(ground, z, high_z, elev_delta, adjacent_shelf) {
            return Some(LosBlock {
                x,
                y,
                z,
                kind: LosBlockKind::Terrain,
            });
        }
    }
    None
}

pub fn has_los(
    from_x: f32,
    from_y: f32,
    from_z: f32,
    to_x: f32,
    to_y: f32,
    to_z: f32,
    obstacles: &LosObstacles,
    ground_z: impl Fn(f32, f32) -> f32,
) -> bool {
    first_los_block(
        from_x, from_y, from_z, to_x, to_y, to_z, obstacles, ground_z,
    )
    .is_none()
}

fn point_in_circles(x: f32, y: f32, circles: &[BlockingCircle]) -> bool {
    circles.iter().any(|o| {
        let dx = x - o.x;
        let dy = y - o.y;
        dx * dx + dy * dy < o.radius_m * o.radius_m
    })
}

fn point_in_rects(x: f32, y: f32, rects: &[BlockingRect]) -> bool {
    rects
        .iter()
        .any(|r| x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1)
}

fn terrain_undercut_blocks(
    ground: f32,
    sample_z: f32,
    high_z: f32,
    elev_delta: f32,
    adjacent_shelf: bool,
) -> bool {
    let under = ground - sample_z;
    if under <= GROUND_CLIP_SLACK_M {
        return false;
    }
    if !adjacent_shelf {
        return true;
    }
    if ground > high_z + GROUND_CLIP_SLACK_M {
        return true;
    }
    under > elev_delta + GROUND_CLIP_SLACK_M + 0.15
}

#[cfg(test)]
mod tests {
    use super::*;
    use flatland_protocol::BuildingView;

    #[test]
    fn intervening_peak_blocks() {
        let ground = |x: f32, _y: f32| {
            if (10.0..11.0).contains(&x) {
                3.0
            } else if x < 10.0 {
                1.0
            } else {
                0.0
            }
        };
        let obs = LosObstacles::default();
        assert_eq!(
            first_los_block(9.5, 20.0, 1.0, 11.5, 20.0, 0.0, &obs, ground).map(|b| b.kind),
            Some(LosBlockKind::Terrain)
        );
        assert!(has_los(9.5, 20.0, 1.0, 9.8, 20.0, 1.0, &obs, ground));
    }

    #[test]
    fn building_footprint_blocks_ray() {
        let buildings = vec![BuildingView {
            id: "shop".into(),
            label: "Shop".into(),
            x: 10.0,
            y: 10.0,
            width_m: 4.0,
            depth_m: 4.0,
            interior_blueprint: None,
            tags: vec![],
            market_boundary_zone_ids: vec![],
            market_max_volume: None,
            wall_set: None,
            roof_set: None,
        }];
        let obs = LosObstacles::from_outdoor_views(&buildings, &[], None);
        let ground = |_x: f32, _y: f32| 0.0;
        let block = first_los_block(5.0, 10.0, 0.0, 15.0, 10.0, 0.0, &obs, ground);
        assert_eq!(block.map(|b| b.kind), Some(LosBlockKind::Rect));
        assert!((block.unwrap().x - 8.0).abs() < 0.5);
    }
}