Skip to main content

flatland_pathfinding/
grid.rs

1//! Navigation world snapshot for pathfinding.
2
3use flatland_protocol::{
4    BuildingView, DoorView, LifeState, ResourceNodeState, TerrainKindView, TerrainZoneView,
5    ZPlatformView, ZTransitionView,
6};
7
8use crate::mode::PathMode;
9
10pub const PLAYER_RADIUS_M: f32 = 0.45;
11/// Extra clearance so paths don't hug walls.
12pub const PATH_CLEARANCE_M: f32 = 0.35;
13/// Sample spacing when testing straight segments against circle obstacles.
14pub const SEGMENT_SAMPLE_M: f32 = 0.3;
15/// Grass baseline A* cost (and Direct walkable cost). Fastest uses `BASE / speed`.
16pub const DEFAULT_COST: u16 = 10;
17
18#[derive(Debug, Clone, Copy)]
19pub struct NavBlockingCircle {
20    pub x: f32,
21    pub y: f32,
22    pub radius_m: f32,
23}
24
25/// Per-kind move speed + impassable flag from content (`terrain-kinds.yaml`).
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct TerrainKindNavParams {
28    pub move_speed_mult: f32,
29    pub impassable: bool,
30}
31
32impl Default for TerrainKindNavParams {
33    fn default() -> Self {
34        Self {
35            move_speed_mult: 1.0,
36            impassable: false,
37        }
38    }
39}
40
41/// Lookup table for path costs — populated from the terrain kind catalog (no hardcoded speeds).
42#[derive(Debug, Clone, Default)]
43pub struct TerrainNavTable {
44    params: Vec<(TerrainKindView, TerrainKindNavParams)>,
45}
46
47impl TerrainNavTable {
48    pub fn set(&mut self, kind: TerrainKindView, params: TerrainKindNavParams) {
49        if let Some(slot) = self.params.iter_mut().find(|(k, _)| *k == kind) {
50            slot.1 = params;
51        } else {
52            self.params.push((kind, params));
53        }
54    }
55
56    pub fn get(&self, kind: TerrainKindView) -> TerrainKindNavParams {
57        self.params
58            .iter()
59            .find(|(k, _)| *k == kind)
60            .map(|(_, p)| *p)
61            .unwrap_or_default()
62    }
63
64    pub fn iter(&self) -> impl Iterator<Item = (TerrainKindView, TerrainKindNavParams)> + '_ {
65        self.params.iter().copied()
66    }
67
68    /// Test / fixture helper: former baked-in speeds so unit tests stay self-contained.
69    pub fn unit_test_defaults() -> Self {
70        let mut t = Self::default();
71        let rows: &[(TerrainKindView, f32, bool)] = &[
72            (TerrainKindView::Grass, 1.0, false),
73            (TerrainKindView::Dirt, 0.98, false),
74            (TerrainKindView::Tilled, 0.95, false),
75            (TerrainKindView::Desert, 0.9, false),
76            (TerrainKindView::Hill, 0.92, false),
77            (TerrainKindView::Trail, 1.10, false),
78            (TerrainKindView::Road, 1.50, false),
79            (TerrainKindView::Rock, 0.85, true),
80            (TerrainKindView::Bog, 0.65, false),
81            (TerrainKindView::Beach, 0.94, false),
82            (TerrainKindView::ShallowWater, 0.55, false),
83            (TerrainKindView::DeepWater, 0.4, true),
84        ];
85        for &(kind, speed, impassable) in rows {
86            t.set(
87                kind,
88                TerrainKindNavParams {
89                    move_speed_mult: speed,
90                    impassable,
91                },
92            );
93        }
94        t
95    }
96}
97
98/// Map catalog id (`road`, `shallow_water`, …) to protocol view.
99pub fn terrain_kind_view_from_id(id: &str) -> Option<TerrainKindView> {
100    Some(match id {
101        "grass" => TerrainKindView::Grass,
102        "dirt" => TerrainKindView::Dirt,
103        "tilled" => TerrainKindView::Tilled,
104        "desert" => TerrainKindView::Desert,
105        "hill" => TerrainKindView::Hill,
106        "bog" => TerrainKindView::Bog,
107        "beach" => TerrainKindView::Beach,
108        "shallow_water" => TerrainKindView::ShallowWater,
109        "deep_water" => TerrainKindView::DeepWater,
110        "trail" => TerrainKindView::Trail,
111        "road" => TerrainKindView::Road,
112        "rock" => TerrainKindView::Rock,
113        _ => return None,
114    })
115}
116
117/// Terrain + building raster shared across path queries on the same [`NavWorld`].
118///
119/// Painting thousands of authored zones onto a full outdoor grid dominated tick cost
120/// when every A* rebuilt from scratch. Dynamic circles / z-band filters still apply
121/// per query on top of this layer.
122#[derive(Debug, Clone)]
123pub struct StaticNavPaint {
124    pub width: i16,
125    pub height: i16,
126    pub kind_at: Vec<TerrainKindView>,
127    pub elev: Vec<f32>,
128    /// Terrain impassable + building footprints with doors cleared.
129    pub blocked_geometry: Vec<bool>,
130}
131
132/// Static + dynamic obstacles for one path query.
133#[derive(Debug, Clone)]
134pub struct NavWorld {
135    pub world_width_m: f32,
136    pub world_height_m: f32,
137    pub terrain_zones: Vec<TerrainZoneView>,
138    pub z_platforms: Vec<ZPlatformView>,
139    pub z_transitions: Vec<ZTransitionView>,
140    pub buildings: Vec<BuildingView>,
141    pub doors: Vec<DoorView>,
142    pub circles: Vec<NavBlockingCircle>,
143    /// Kind → speed / impassable from content catalog.
144    pub kind_nav: TerrainNavTable,
145    /// Lazily painted zone/building layer; clones of this world share the Arc.
146    static_paint: std::sync::Arc<std::sync::OnceLock<StaticNavPaint>>,
147}
148
149impl NavWorld {
150    /// Fresh world with an empty static-paint cache (call after mutating geometry).
151    pub fn new(
152        world_width_m: f32,
153        world_height_m: f32,
154        terrain_zones: Vec<TerrainZoneView>,
155        z_platforms: Vec<ZPlatformView>,
156        z_transitions: Vec<ZTransitionView>,
157        buildings: Vec<BuildingView>,
158        doors: Vec<DoorView>,
159        circles: Vec<NavBlockingCircle>,
160        kind_nav: TerrainNavTable,
161    ) -> Self {
162        Self {
163            world_width_m,
164            world_height_m,
165            terrain_zones,
166            z_platforms,
167            z_transitions,
168            buildings,
169            doors,
170            circles,
171            kind_nav,
172            static_paint: std::sync::Arc::new(std::sync::OnceLock::new()),
173        }
174    }
175
176    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
177        terrain_at(&self.terrain_zones, x, y)
178            .map(|z| z.elevation)
179            .unwrap_or(0.0)
180    }
181
182    pub fn terrain_kind_at(&self, x: f32, y: f32) -> TerrainKindView {
183        terrain_at(&self.terrain_zones, x, y)
184            .map(|z| z.kind)
185            .unwrap_or(TerrainKindView::Grass)
186    }
187
188    /// Lazily paint / return the static terrain+building raster for this world.
189    pub(crate) fn ensure_static_paint(
190        &self,
191        init: impl FnOnce() -> StaticNavPaint,
192    ) -> &StaticNavPaint {
193        self.static_paint.get_or_init(init)
194    }
195
196    /// Share another world's static paint cache (same terrain/buildings; circles may differ).
197    pub fn adopt_static_paint_from(&mut self, other: &NavWorld) {
198        self.static_paint = std::sync::Arc::clone(&other.static_paint);
199    }
200
201    /// True when the zone/building raster has already been painted.
202    pub fn static_paint_ready(&self) -> bool {
203        self.static_paint.get().is_some()
204    }
205}
206
207fn terrain_at(zones: &[TerrainZoneView], x: f32, y: f32) -> Option<&TerrainZoneView> {
208    // Match sim `WorldSegment::terrain_zone_at`: highest z_order wins; tie → later list index.
209    zones
210        .iter()
211        .enumerate()
212        .filter(|(_, zone)| x >= zone.x0 && x < zone.x1 && y >= zone.y0 && y < zone.y1)
213        .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
214        .map(|(_, z)| z)
215}
216
217pub fn terrain_move_speed_mult(kind: TerrainKindView, table: &TerrainNavTable) -> f32 {
218    table.get(kind).move_speed_mult.max(0.01)
219}
220
221pub fn terrain_is_impassable(kind: TerrainKindView, table: &TerrainNavTable) -> bool {
222    table.get(kind).impassable
223}
224
225/// A* cell cost for `mode`. Impassable → `u16::MAX` (blocked).
226pub fn terrain_cost(kind: TerrainKindView, mode: PathMode, table: &TerrainNavTable) -> u16 {
227    if terrain_is_impassable(kind, table) {
228        return u16::MAX;
229    }
230    match mode {
231        PathMode::Direct => DEFAULT_COST,
232        PathMode::Fastest => {
233            let speed = terrain_move_speed_mult(kind, table);
234            let c = (DEFAULT_COST as f32 / speed).round();
235            c.clamp(1.0, (u16::MAX - 1) as f32) as u16
236        }
237    }
238}
239
240/// Minimum walkable cost for `mode` given zones present (admissible A* scale).
241pub fn min_walkable_cost(
242    mode: PathMode,
243    zones: &[TerrainZoneView],
244    table: &TerrainNavTable,
245) -> u16 {
246    match mode {
247        PathMode::Direct => DEFAULT_COST,
248        PathMode::Fastest => {
249            let mut min_c = DEFAULT_COST;
250            for z in zones {
251                let c = terrain_cost(z.kind, PathMode::Fastest, table);
252                if c != u16::MAX && c < min_c {
253                    min_c = c;
254                }
255            }
256            min_c
257        }
258    }
259}
260
261pub fn collides_player_at(x: f32, y: f32, world: &NavWorld) -> bool {
262    if world
263        .circles
264        .iter()
265        .any(|o| circle_overlap(x, y, PLAYER_RADIUS_M, o.x, o.y, o.radius_m))
266    {
267        return true;
268    }
269    let pad = PLAYER_RADIUS_M;
270    world.buildings.iter().any(|b| {
271        let hw = b.width_m / 2.0 + pad;
272        let hd = b.depth_m / 2.0 + pad;
273        x >= b.x - hw && x <= b.x + hw && y >= b.y - hd && y <= b.y + hd
274    })
275}
276
277fn circle_overlap(ax: f32, ay: f32, ar: f32, bx: f32, by: f32, br: f32) -> bool {
278    let dx = ax - bx;
279    let dy = ay - by;
280    let min_dist = ar + br;
281    dx * dx + dy * dy < min_dist * min_dist
282}
283
284pub(crate) fn block_circle(
285    blocked: &mut [bool],
286    width: i16,
287    height: i16,
288    cx: f32,
289    cy: f32,
290    radius_m: f32,
291) {
292    let block_r = radius_m + PLAYER_RADIUS_M + PATH_CLEARANCE_M;
293    let r = block_r.ceil() as i16;
294    let ix = cx.floor() as i16;
295    let iy = cy.floor() as i16;
296    for dy in -r..=r {
297        for dx in -r..=r {
298            let cell_x = ix + dx;
299            let cell_y = iy + dy;
300            if cell_x < 0 || cell_y < 0 || cell_x >= width || cell_y >= height {
301                continue;
302            }
303            let cell_cx = cell_x as f32 + 0.5;
304            let cell_cy = cell_y as f32 + 0.5;
305            if (cell_cx - cx).hypot(cell_cy - cy) <= block_r {
306                let idx = (cell_y as usize) * (width as usize) + (cell_x as usize);
307                blocked[idx] = true;
308            }
309        }
310    }
311}
312
313pub(crate) fn mark_building_footprint(
314    blocked: &mut [bool],
315    width: i16,
316    height: i16,
317    building: &BuildingView,
318) {
319    // Match `collides_player_at`: player radius only. Extra PATH_CLEARANCE here used to
320    // seal road cells beside buildings (e.g. West Storage eating the y=106 road) while
321    // the player could still walk that pavement — A* then detoured through grass.
322    let pad = PLAYER_RADIUS_M;
323    let hw = building.width_m / 2.0;
324    let hd = building.depth_m / 2.0;
325    let x0 = (building.x - hw - pad).floor() as i16;
326    let y0 = (building.y - hd - pad).floor() as i16;
327    let x1 = (building.x + hw + pad).ceil() as i16 - 1;
328    let y1 = (building.y + hd + pad).ceil() as i16 - 1;
329    if x1 < x0 || y1 < y0 {
330        return;
331    }
332    for x in x0..=x1 {
333        for y in y0..=y1 {
334            if x >= 0 && y >= 0 && x < width && y < height {
335                let idx = (y as usize) * (width as usize) + (x as usize);
336                blocked[idx] = true;
337            }
338        }
339    }
340}
341
342pub(crate) fn clear_door_cells(blocked: &mut [bool], width: i16, height: i16, doors: &[DoorView]) {
343    for door in doors {
344        if door.open {
345            let (x, y) = world_to_cell(door.x, door.y);
346            for dx in -1i16..=1 {
347                for dy in -1i16..=1 {
348                    if dx.abs() + dy.abs() <= 1 {
349                        let cx = x + dx;
350                        let cy = y + dy;
351                        if cx >= 0 && cy >= 0 && cx < width && cy < height {
352                            let idx = (cy as usize) * (width as usize) + (cx as usize);
353                            blocked[idx] = false;
354                        }
355                    }
356                }
357            }
358        }
359    }
360}
361
362pub(crate) fn world_to_cell(x: f32, y: f32) -> (i16, i16) {
363    (x.floor() as i16, y.floor() as i16)
364}
365
366pub(crate) fn cell_center(x: i16, y: i16) -> (f32, f32) {
367    (x as f32 + 0.5, y as f32 + 0.5)
368}
369
370/// Default nav radius when a placed prop has `blocking: true` but radius 0 in data.
371pub const DEFAULT_PLACED_PROP_BLOCK_RADIUS_M: f32 = 0.7;
372
373/// Blocking circles for placed props that have `blocking` on the item template (AOI view).
374pub fn circles_from_placed_containers(
375    containers: &[flatland_protocol::PlacedContainerView],
376) -> Vec<NavBlockingCircle> {
377    containers
378        .iter()
379        .filter(|c| c.blocking)
380        .map(|c| {
381            let radius_m = if c.blocking_radius_m > 0.0 {
382                c.blocking_radius_m
383            } else {
384                DEFAULT_PLACED_PROP_BLOCK_RADIUS_M
385            };
386            NavBlockingCircle {
387                x: c.x,
388                y: c.y,
389                radius_m,
390            }
391        })
392        .collect()
393}
394
395/// Outdoor travel goal snapped clear of buildings, trees, NPCs, and placed props.
396pub fn snap_nav_goal(world: &NavWorld, gx: f32, gy: f32) -> (f32, f32) {
397    if !nav_position_blocked(world, gx, gy, PATH_CLEARANCE_M) {
398        return (gx, gy);
399    }
400    for step in 1..=40 {
401        let d = step as f32 * 0.35;
402        for (dx, dy) in [
403            (0.0, -d),
404            (0.0, d),
405            (d, 0.0),
406            (-d, 0.0),
407            (d, -d),
408            (d, d),
409            (-d, -d),
410            (-d, d),
411        ] {
412            let nx = gx + dx;
413            let ny = gy + dy;
414            if !nav_position_blocked(world, nx, ny, PATH_CLEARANCE_M) {
415                return (nx, ny);
416            }
417        }
418    }
419    (gx, gy)
420}
421
422fn nav_position_blocked(world: &NavWorld, x: f32, y: f32, extra_pad_m: f32) -> bool {
423    let body = PLAYER_RADIUS_M + extra_pad_m.max(0.0);
424    if world
425        .circles
426        .iter()
427        .any(|o| circle_overlap(x, y, body, o.x, o.y, o.radius_m + extra_pad_m.max(0.0)))
428    {
429        return true;
430    }
431    world.buildings.iter().any(|b| {
432        let hw = b.width_m / 2.0 + body;
433        let hd = b.depth_m / 2.0 + body;
434        x >= b.x - hw && x <= b.x + hw && y >= b.y - hd && y <= b.y + hd
435    })
436}
437
438/// Build blocking circles from protocol views (resource nodes + living NPCs).
439pub fn circles_from_views(
440    resource_nodes: &[flatland_protocol::ResourceNodeView],
441    npcs: &[flatland_protocol::NpcView],
442) -> Vec<NavBlockingCircle> {
443    let mut circles = Vec::new();
444    for node in resource_nodes {
445        if node.blocking
446            && matches!(
447                node.state,
448                ResourceNodeState::Available | ResourceNodeState::Harvesting
449            )
450        {
451            let radius = if node.blocking_radius_m > 0.0 {
452                node.blocking_radius_m
453            } else {
454                0.8
455            };
456            circles.push(NavBlockingCircle {
457                x: node.x,
458                y: node.y,
459                radius_m: radius,
460            });
461        }
462    }
463    for npc in npcs {
464        let alive = npc.life_state != Some(LifeState::Dead) && npc.hp_pct.is_none_or(|h| h > 0.0);
465        if alive {
466            circles.push(NavBlockingCircle {
467                x: npc.x,
468                y: npc.y,
469                radius_m: 0.55,
470            });
471        }
472    }
473    circles
474}