flatland-pathfinding 0.2.26

Shared grid A* pathfinding for Flatland3 clients and sim
Documentation
//! Navigation world snapshot for pathfinding.

use flatland_protocol::{
    BuildingView, DoorView, LifeState, ResourceNodeState, TerrainKindView, TerrainZoneView,
    ZPlatformView, ZTransitionView,
};

pub const PLAYER_RADIUS_M: f32 = 0.45;
/// Extra clearance so paths don't hug walls.
pub const PATH_CLEARANCE_M: f32 = 0.35;
/// Sample spacing when testing straight segments against circle obstacles.
pub const SEGMENT_SAMPLE_M: f32 = 0.3;

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

/// Static + dynamic obstacles for one path query.
#[derive(Debug, Clone)]
pub struct NavWorld {
    pub world_width_m: f32,
    pub world_height_m: f32,
    pub terrain_zones: Vec<TerrainZoneView>,
    pub z_platforms: Vec<ZPlatformView>,
    pub z_transitions: Vec<ZTransitionView>,
    pub buildings: Vec<BuildingView>,
    pub doors: Vec<DoorView>,
    pub circles: Vec<NavBlockingCircle>,
}

impl NavWorld {
    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
        terrain_at(&self.terrain_zones, x, y)
            .map(|z| z.elevation)
            .unwrap_or(0.0)
    }

    pub fn terrain_kind_at(&self, x: f32, y: f32) -> TerrainKindView {
        terrain_at(&self.terrain_zones, x, y)
            .map(|z| z.kind)
            .unwrap_or(TerrainKindView::Grass)
    }
}

fn terrain_at(zones: &[TerrainZoneView], x: f32, y: f32) -> Option<&TerrainZoneView> {
    zones
        .iter()
        .find(|zone| x >= zone.x0 && x < zone.x1 && y >= zone.y0 && y < zone.y1)
}

pub(crate) fn terrain_cost(kind: TerrainKindView) -> u16 {
    match kind {
        TerrainKindView::Grass => 10,
        TerrainKindView::Dirt => 11,
        TerrainKindView::Tilled => 12,
        TerrainKindView::Desert => 13,
        TerrainKindView::Hill => 14,
        TerrainKindView::Bog => 25,
        TerrainKindView::ShallowWater => 40,
        TerrainKindView::DeepWater => u16::MAX,
        TerrainKindView::Trail => 8,
        TerrainKindView::Road => 5,
        TerrainKindView::Rock => u16::MAX,
    }
}

pub fn collides_player_at(x: f32, y: f32, world: &NavWorld) -> bool {
    if world.circles.iter().any(|o| {
        circle_overlap(x, y, PLAYER_RADIUS_M, o.x, o.y, o.radius_m)
    }) {
        return true;
    }
    let pad = PLAYER_RADIUS_M;
    world.buildings.iter().any(|b| {
        let hw = b.width_m / 2.0 + pad;
        let hd = b.depth_m / 2.0 + pad;
        x >= b.x - hw && x <= b.x + hw && y >= b.y - hd && y <= b.y + hd
    })
}

fn circle_overlap(ax: f32, ay: f32, ar: f32, bx: f32, by: f32, br: f32) -> bool {
    let dx = ax - bx;
    let dy = ay - by;
    let min_dist = ar + br;
    dx * dx + dy * dy < min_dist * min_dist
}

pub(crate) fn block_circle(blocked: &mut [bool], width: i16, height: i16, cx: f32, cy: f32, radius_m: f32) {
    let block_r = radius_m + PLAYER_RADIUS_M + PATH_CLEARANCE_M;
    let r = block_r.ceil() as i16;
    let ix = cx.floor() as i16;
    let iy = cy.floor() as i16;
    for dy in -r..=r {
        for dx in -r..=r {
            let cell_x = ix + dx;
            let cell_y = iy + dy;
            if cell_x < 0 || cell_y < 0 || cell_x >= width || cell_y >= height {
                continue;
            }
            let cell_cx = cell_x as f32 + 0.5;
            let cell_cy = cell_y as f32 + 0.5;
            if (cell_cx - cx).hypot(cell_cy - cy) <= block_r {
                let idx = (cell_y as usize) * (width as usize) + (cell_x as usize);
                blocked[idx] = true;
            }
        }
    }
}

pub(crate) fn mark_building_footprint(blocked: &mut [bool], width: i16, height: i16, building: &BuildingView) {
    let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
    let hw = building.width_m / 2.0;
    let hd = building.depth_m / 2.0;
    let x0 = (building.x - hw - pad).floor() as i16;
    let y0 = (building.y - hd - pad).floor() as i16;
    let x1 = (building.x + hw + pad).ceil() as i16 - 1;
    let y1 = (building.y + hd + pad).ceil() as i16 - 1;
    if x1 < x0 || y1 < y0 {
        return;
    }
    for x in x0..=x1 {
        for y in y0..=y1 {
            if x >= 0 && y >= 0 && x < width && y < height {
                let idx = (y as usize) * (width as usize) + (x as usize);
                blocked[idx] = true;
            }
        }
    }
}

pub(crate) fn clear_door_cells(blocked: &mut [bool], width: i16, height: i16, doors: &[DoorView]) {
    for door in doors {
        if door.open {
            let (x, y) = world_to_cell(door.x, door.y);
            for dx in -1i16..=1 {
                for dy in -1i16..=1 {
                    if dx.abs() + dy.abs() <= 1 {
                        let cx = x + dx;
                        let cy = y + dy;
                        if cx >= 0 && cy >= 0 && cx < width && cy < height {
                            let idx = (cy as usize) * (width as usize) + (cx as usize);
                            blocked[idx] = false;
                        }
                    }
                }
            }
        }
    }
}

pub(crate) fn world_to_cell(x: f32, y: f32) -> (i16, i16) {
    (x.floor() as i16, y.floor() as i16)
}

pub(crate) fn cell_center(x: i16, y: i16) -> (f32, f32) {
    (x as f32 + 0.5, y as f32 + 0.5)
}

/// Build blocking circles from protocol views (resource nodes + living NPCs).
pub fn circles_from_views(
    resource_nodes: &[flatland_protocol::ResourceNodeView],
    npcs: &[flatland_protocol::NpcView],
) -> Vec<NavBlockingCircle> {
    let mut circles = Vec::new();
    for node in resource_nodes {
        if node.blocking
            && matches!(
                node.state,
                ResourceNodeState::Available | ResourceNodeState::Harvesting
            )
        {
            let radius = if node.blocking_radius_m > 0.0 {
                node.blocking_radius_m
            } else {
                0.8
            };
            circles.push(NavBlockingCircle {
                x: node.x,
                y: node.y,
                radius_m: radius,
            });
        }
    }
    for npc in npcs {
        let alive =
            npc.life_state != Some(LifeState::Dead) && npc.hp_pct.is_none_or(|h| h > 0.0);
        if alive {
            circles.push(NavBlockingCircle {
                x: npc.x,
                y: npc.y,
                radius_m: 0.55,
            });
        }
    }
    circles
}