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