1use 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;
11pub const PATH_CLEARANCE_M: f32 = 0.35;
13pub const WORKER_NAV_BUILDING_CLEARANCE_M: f32 = 0.20;
22pub const SEGMENT_SAMPLE_M: f32 = 0.3;
24pub 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#[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#[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 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
107pub 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#[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 pub blocked_geometry: Vec<bool>,
139}
140
141#[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 pub kind_nav: TerrainNavTable,
154 pub worker_building_clearance_m: f32,
159 static_paint: std::sync::Arc<std::sync::OnceLock<StaticNavPaint>>,
161}
162
163impl NavWorld {
164 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 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 pub fn adopt_static_paint_from(&mut self, other: &NavWorld) {
213 self.static_paint = std::sync::Arc::clone(&other.static_paint);
214 }
215
216 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 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
240pub 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
255pub 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 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
390pub const DEFAULT_PLACED_PROP_BLOCK_RADIUS_M: f32 = 0.7;
392
393pub 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
415pub 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
458pub 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}