Skip to main content

vimp_engine_core/nav/
navigation.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::map::MapLevels;
6use crate::nav::pathfinder::{self, Edge};
7use crate::rng::Rng;
8
9// коэффициент шага сетки
10const COEF_GRID_STEP: f32 = 2.0;
11
12/// Штраф ребра «спрыгнуть с обрыва» в единицах длины: бот выбирает
13/// прыжок, только если он экономит больше этого. Прыжок стоит здоровья
14/// (fallDamage игры), поэтому дешёвым он быть не должен.
15const LEDGE_PENALTY: f32 = 1500.0;
16
17/// Точка пути с уровнем: смена уровня между соседними точками означает
18/// проезд по рампе или прыжок с обрыва.
19#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
20pub struct PathPoint {
21    pub pos: [f32; 2],
22    pub level: u8,
23}
24
25/// Навигация ботов: сетка проходимости + граф с A*
26/// (порт src/server/modules/bots/NavigationSystem.js).
27#[derive(Clone, Default, Serialize, Deserialize)]
28pub struct NavigationSystem {
29    nav_grid: Vec<Vec<u8>>,
30    grid_step: f32,
31    nodes: Vec<[f32; 2]>,
32    edges: Vec<Vec<Edge>>,
33    node_grid: HashMap<(i32, i32), Vec<usize>>,
34    node_grid_cell_size: f32,
35    /// Уровень каждого узла, параллелен `nodes`. Пусто у одноуровневого
36    /// графа — тогда все узлы считаются уровнем 0.
37    #[serde(default)]
38    node_levels: Vec<u8>,
39    /// Сетки проходимости надземных уровней (индекс 0 = уровень 1).
40    /// `nav_grid` остаётся сеткой уровня 0.
41    #[serde(default)]
42    upper_grids: Vec<Vec<Vec<u8>>>,
43    /// Число рёбер рамп и обрывов (отладочный дамп).
44    #[serde(default)]
45    ramp_edges: usize,
46    #[serde(default)]
47    ledge_edges: usize,
48}
49
50impl NavigationSystem {
51    /// Строит сетку проходимости и навигационный граф из данных карты
52    /// (сетка тайлов + масштабированный step + список статичных тайлов).
53    pub fn generate(grid: &[Vec<i32>], physics_static: &[i32], step: f32) -> Self {
54        let mut nav = Self::default();
55
56        if grid.is_empty() || step <= 0.0 {
57            return nav;
58        }
59
60        nav.grid_step = step;
61        nav.nav_grid = grid
62            .iter()
63            .map(|row| {
64                row.iter()
65                    .map(|tile| u8::from(physics_static.contains(tile)))
66                    .collect()
67            })
68            .collect();
69
70        let node_placement_step = step * COEF_GRID_STEP;
71        let map_width = nav.nav_grid[0].len() as f32 * step;
72        let map_height = nav.nav_grid.len() as f32 * step;
73
74        // расстановка узлов в свободных местах
75        let mut x = node_placement_step / 2.0;
76
77        while x < map_width {
78            let mut y = node_placement_step / 2.0;
79
80            while y < map_height {
81                if nav.is_walkable(x, y) {
82                    nav.nodes.push([x, y]);
83                }
84
85                y += node_placement_step;
86            }
87
88            x += node_placement_step;
89        }
90
91        // соединение ближайших видимых узлов рёбрами
92        let max_connection_dist_sq =
93            node_placement_step * 1.5 * (node_placement_step * 1.5);
94
95        nav.edges = vec![Vec::new(); nav.nodes.len()];
96
97        for i in 0..nav.nodes.len() {
98            for j in (i + 1)..nav.nodes.len() {
99                let dx = nav.nodes[i][0] - nav.nodes[j][0];
100                let dy = nav.nodes[i][1] - nav.nodes[j][1];
101                let dist_sq = dx * dx + dy * dy;
102
103                if dist_sq <= max_connection_dist_sq
104                    && !nav.has_obstacle_between(nav.nodes[i], nav.nodes[j])
105                {
106                    let distance = dist_sq.sqrt();
107
108                    nav.edges[i].push(Edge {
109                        node: j,
110                        weight: distance,
111                    });
112                    nav.edges[j].push(Edge {
113                        node: i,
114                        weight: distance,
115                    });
116                }
117            }
118        }
119
120        // сетка для быстрого поиска ближайших узлов
121        nav.node_grid_cell_size = node_placement_step;
122
123        for (index, node) in nav.nodes.iter().enumerate() {
124            let cx = (node[0] / nav.node_grid_cell_size).floor() as i32;
125            let cy = (node[1] / nav.node_grid_cell_size).floor() as i32;
126
127            nav.node_grid.entry((cx, cy)).or_default().push(index);
128        }
129
130        nav
131    }
132
133    /// Граф со слоями: узлы каждого уровня + рёбра переходов (рампы —
134    /// двусторонние, обрывы — только сверху вниз).
135    pub fn generate_layered(levels: &MapLevels, step: f32) -> Self {
136        let mut nav = Self::default();
137        let Some(grid0) = levels.grid(0) else {
138            return nav;
139        };
140
141        if grid0.is_empty() || step <= 0.0 {
142            return nav;
143        }
144
145        nav.grid_step = step;
146        nav.nav_grid = grid0
147            .iter()
148            .map(|row| {
149                row.iter()
150                    .map(|tile| u8::from(levels.solid(0).contains(tile)))
151                    .collect()
152            })
153            .collect();
154
155        // надземный уровень проходим только по плите и только вне перил
156        for level in 1..levels.level_count() as u8 {
157            let Some(grid) = levels.grid(level) else {
158                continue;
159            };
160
161            nav.upper_grids.push(
162                grid.iter()
163                    .map(|row| {
164                        row.iter()
165                            .map(|tile| {
166                                u8::from(
167                                    !levels.floor(level).contains(tile)
168                                        || levels.solid(level).contains(tile),
169                                )
170                            })
171                            .collect()
172                    })
173                    .collect(),
174            );
175        }
176
177        let node_placement_step = step * COEF_GRID_STEP;
178        let map_width = nav.nav_grid[0].len() as f32 * step;
179        let map_height = nav.nav_grid.len() as f32 * step;
180
181        // расстановка узлов: уровни по очереди, внутри уровня — прежний
182        // порядок обхода (x внешний, y внутренний)
183        for level in 0..nav.level_count() as u8 {
184            let mut x = node_placement_step / 2.0;
185
186            while x < map_width {
187                let mut y = node_placement_step / 2.0;
188
189                while y < map_height {
190                    if nav.is_walkable_on(level, x, y) {
191                        nav.nodes.push([x, y]);
192                        nav.node_levels.push(level);
193                    }
194
195                    y += node_placement_step;
196                }
197
198                x += node_placement_step;
199            }
200        }
201
202        // рёбра внутри уровня
203        let max_connection_dist_sq = node_placement_step * 1.5 * (node_placement_step * 1.5);
204
205        nav.edges = vec![Vec::new(); nav.nodes.len()];
206
207        for i in 0..nav.nodes.len() {
208            for j in (i + 1)..nav.nodes.len() {
209                if nav.node_levels[i] != nav.node_levels[j] {
210                    continue;
211                }
212
213                let dx = nav.nodes[i][0] - nav.nodes[j][0];
214                let dy = nav.nodes[i][1] - nav.nodes[j][1];
215                let dist_sq = dx * dx + dy * dy;
216
217                if dist_sq <= max_connection_dist_sq
218                    && !nav.has_obstacle_between_on(nav.node_levels[i], nav.nodes[i], nav.nodes[j])
219                {
220                    let distance = dist_sq.sqrt();
221
222                    nav.edges[i].push(Edge {
223                        node: j,
224                        weight: distance,
225                    });
226                    nav.edges[j].push(Edge {
227                        node: i,
228                        weight: distance,
229                    });
230                }
231            }
232        }
233
234        // сетка для быстрого поиска ближайших узлов
235        nav.node_grid_cell_size = node_placement_step;
236
237        for (index, node) in nav.nodes.iter().enumerate() {
238            let cx = (node[0] / nav.node_grid_cell_size).floor() as i32;
239            let cy = (node[1] / nav.node_grid_cell_size).floor() as i32;
240
241            nav.node_grid.entry((cx, cy)).or_default().push(index);
242        }
243
244        nav.connect_ramps(levels);
245        nav.connect_ledges(levels);
246
247        nav
248    }
249
250    // рёбра рамп: подножие прогона на уровне `from` ↔ вершина на `to`
251    fn connect_ramps(&mut self, levels: &MapLevels) {
252        let half = levels.tile_size() / 2.0;
253
254        for run in levels.runs() {
255            let cross = (run.cross_min + run.cross_max) / 2.0;
256            // точки подключения берутся ЗА кромками прогона: подножие — на
257            // земле перед рампой, вершина — уже на плите за ней; внутри
258            // самого прогона плиты уровня `to` ещё нет, и узел там не виден
259            let (bottom_along, top_along) = if run.sign > 0 {
260                (run.min - half, run.max + half)
261            } else {
262                (run.max + half, run.min - half)
263            };
264
265            let point = |along: f32| {
266                if run.axis == 0 {
267                    [along, cross]
268                } else {
269                    [cross, along]
270                }
271            };
272
273            let (Some(bottom), Some(top)) = (
274                self.closest_visible_node_on(run.from, point(bottom_along)),
275                self.closest_visible_node_on(run.to, point(top_along)),
276            ) else {
277                continue;
278            };
279
280            let weight = distance(self.nodes[bottom], self.nodes[top]);
281
282            self.edges[bottom].push(Edge {
283                node: top,
284                weight,
285            });
286            self.edges[top].push(Edge {
287                node: bottom,
288                weight,
289            });
290            self.ramp_edges += 2;
291        }
292    }
293
294    // рёбра обрывов: односторонние, только сверху вниз
295    fn connect_ledges(&mut self, levels: &MapLevels) {
296        let size = levels.tile_size();
297        let half = size / 2.0;
298        let rows = self.nav_grid.len();
299        let cols = self.nav_grid.first().map(|row| row.len()).unwrap_or(0);
300        let mut seen: HashSet<(usize, usize)> = HashSet::new();
301
302        for level in 1..self.level_count() as u8 {
303            for cy in 0..rows {
304                for cx in 0..cols {
305                    let x = cx as f32 * size + half;
306                    let y = cy as f32 * size + half;
307
308                    if !self.is_walkable_on(level, x, y) {
309                        continue;
310                    }
311
312                    for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {
313                        let nx = cx as i64 + dx;
314                        let ny = cy as i64 + dy;
315
316                        if nx < 0 || ny < 0 || nx >= cols as i64 || ny >= rows as i64 {
317                            continue;
318                        }
319
320                        let wx = nx as f32 * size + half;
321                        let wy = ny as f32 * size + half;
322
323                        if levels.has_floor(level, wx, wy) {
324                            continue;
325                        }
326
327                        let (Some(top), Some(bottom)) = (
328                            self.closest_visible_node_on(level, [x, y]),
329                            self.closest_visible_node_on(0, [wx, wy]),
330                        ) else {
331                            continue;
332                        };
333
334                        if !seen.insert((top, bottom)) {
335                            continue;
336                        }
337
338                        self.edges[top].push(Edge {
339                            node: bottom,
340                            weight: distance(self.nodes[top], self.nodes[bottom]) + LEDGE_PENALTY,
341                        });
342                        self.ledge_edges += 1;
343                    }
344                }
345            }
346        }
347    }
348
349    pub fn has_nodes(&self) -> bool {
350        !self.nodes.is_empty()
351    }
352
353    // ***** отладочный дамп (crate::debug) ***** //
354
355    pub fn node_count(&self) -> usize {
356        self.nodes.len()
357    }
358
359    pub fn edge_count(&self) -> usize {
360        self.edges.iter().map(|edges| edges.len()).sum()
361    }
362
363    pub fn grid_step(&self) -> f32 {
364        self.grid_step
365    }
366
367    /// Случайный узел графа (цель патрулирования).
368    pub fn random_node(&self, rng: &mut Rng) -> Option<[f32; 2]> {
369        if self.nodes.is_empty() {
370            return None;
371        }
372
373        let index = (rng.next_f32() * self.nodes.len() as f32).floor() as usize;
374
375        self.nodes.get(index).copied()
376    }
377
378    /// Сетка проходимости уровня (0 — земля, N — надземный уровень).
379    fn grid_of(&self, level: u8) -> Option<&Vec<Vec<u8>>> {
380        if level == 0 {
381            Some(&self.nav_grid)
382        } else {
383            self.upper_grids.get(level as usize - 1)
384        }
385    }
386
387    /// Проходима ли точка в мировых координатах (уровень 0).
388    pub fn is_walkable(&self, x: f32, y: f32) -> bool {
389        self.is_walkable_on(0, x, y)
390    }
391
392    /// Проходима ли точка на конкретном уровне.
393    pub fn is_walkable_on(&self, level: u8, x: f32, y: f32) -> bool {
394        let Some(grid) = self.grid_of(level) else {
395            return false;
396        };
397
398        if grid.is_empty() || self.grid_step == 0.0 {
399            return false;
400        }
401
402        let grid_x = (x / self.grid_step).floor();
403        let grid_y = (y / self.grid_step).floor();
404
405        if grid_x < 0.0 || grid_y < 0.0 {
406            return false;
407        }
408
409        grid.get(grid_y as usize)
410            .and_then(|row| row.get(grid_x as usize))
411            .is_some_and(|&cell| cell == 0)
412    }
413
414    /// Быстрая линия видимости по сетке (алгоритм Брезенхэма):
415    /// true — на пути есть препятствие.
416    pub fn has_obstacle_between(&self, start: [f32; 2], end: [f32; 2]) -> bool {
417        self.has_obstacle_between_on(0, start, end)
418    }
419
420    /// Линия видимости по сетке конкретного уровня.
421    pub fn has_obstacle_between_on(&self, level: u8, start: [f32; 2], end: [f32; 2]) -> bool {
422        let Some(grid) = self.grid_of(level) else {
423            return true;
424        };
425
426        let mut x0 = (start[0] / self.grid_step).floor() as i64;
427        let mut y0 = (start[1] / self.grid_step).floor() as i64;
428        let x1 = (end[0] / self.grid_step).floor() as i64;
429        let y1 = (end[1] / self.grid_step).floor() as i64;
430
431        let dx = (x1 - x0).abs();
432        let dy = -(y1 - y0).abs();
433        let sx = if x0 < x1 { 1 } else { -1 };
434        let sy = if y0 < y1 { 1 } else { -1 };
435        let mut err = dx + dy;
436
437        loop {
438            let is_wall = y0 >= 0
439                && x0 >= 0
440                && grid
441                    .get(y0 as usize)
442                    .and_then(|row| row.get(x0 as usize))
443                    .is_some_and(|&cell| cell == 1);
444
445            if is_wall {
446                return true;
447            }
448
449            if x0 == x1 && y0 == y1 {
450                break;
451            }
452
453            let e2 = 2 * err;
454
455            if e2 >= dy {
456                err += dy;
457                x0 += sx;
458            }
459
460            if e2 <= dx {
461                err += dx;
462                y0 += sy;
463            }
464        }
465
466        false
467    }
468
469    /// Путь из точки в точку (мировые координаты) или None.
470    pub fn find_path(&self, start: [f32; 2], end: [f32; 2]) -> Option<Vec<[f32; 2]>> {
471        if self.nodes.is_empty() {
472            return None;
473        }
474
475        if !self.has_obstacle_between(start, end) {
476            return Some(vec![end]);
477        }
478
479        let start_node = self.closest_visible_node(start)?;
480        let end_node = self.closest_visible_node(end)?;
481
482        if start_node == end_node {
483            return None;
484        }
485
486        let path_indexes = pathfinder::find_path(start_node, end_node, &self.nodes, &self.edges)?;
487
488        let mut path: Vec<[f32; 2]> = path_indexes
489            .into_iter()
490            .map(|index| self.nodes[index])
491            .collect();
492
493        path.push(end);
494
495        Some(path)
496    }
497
498    /// Путь между точками с уровнями (мировые координаты) или None.
499    pub fn find_path_on(&self, start: PathPoint, end: PathPoint) -> Option<Vec<PathPoint>> {
500        if self.nodes.is_empty() {
501            return None;
502        }
503
504        // прямая видимость возможна только внутри одного уровня: смена
505        // уровня всегда едет по ребру рампы или обрыва
506        if start.level == end.level
507            && !self.has_obstacle_between_on(start.level, start.pos, end.pos)
508        {
509            return Some(vec![end]);
510        }
511
512        let start_node = self.closest_visible_node_on(start.level, start.pos)?;
513        let end_node = self.closest_visible_node_on(end.level, end.pos)?;
514
515        if start_node == end_node {
516            return None;
517        }
518
519        let path_indexes = pathfinder::find_path(start_node, end_node, &self.nodes, &self.edges)?;
520
521        let mut path: Vec<PathPoint> = path_indexes
522            .into_iter()
523            .map(|index| PathPoint {
524                pos: self.nodes[index],
525                level: self.node_level(index),
526            })
527            .collect();
528
529        path.push(end);
530
531        Some(path)
532    }
533
534    /// Случайный узел графа вместе с его уровнем (цель патрулирования).
535    pub fn random_point(&self, rng: &mut Rng) -> Option<PathPoint> {
536        if self.nodes.is_empty() {
537            return None;
538        }
539
540        let index = (rng.next_f32() * self.nodes.len() as f32).floor() as usize;
541
542        self.nodes.get(index).map(|&pos| PathPoint {
543            pos,
544            level: self.node_level(index),
545        })
546    }
547
548    /// Уровень узла: у одноуровневого графа `node_levels` пуст — все узлы
549    /// на земле.
550    pub fn node_level(&self, index: usize) -> u8 {
551        self.node_levels.get(index).copied().unwrap_or(0)
552    }
553
554    /// Число уровней графа, включая землю.
555    pub fn level_count(&self) -> usize {
556        self.upper_grids.len() + 1
557    }
558
559    /// Число узлов по уровням (отладочный дамп).
560    pub fn nodes_by_level(&self) -> Vec<usize> {
561        let mut counts = vec![0usize; self.level_count()];
562
563        for index in 0..self.nodes.len() {
564            let level = self.node_level(index) as usize;
565
566            if let Some(count) = counts.get_mut(level) {
567                *count += 1;
568            }
569        }
570
571        counts
572    }
573
574    pub fn ramp_edge_count(&self) -> usize {
575        self.ramp_edges
576    }
577
578    pub fn ledge_edge_count(&self) -> usize {
579        self.ledge_edges
580    }
581
582    /// Ближайший видимый узел к мировой позиции (поиск по 9 ячейкам).
583    fn closest_visible_node(&self, position: [f32; 2]) -> Option<usize> {
584        self.closest_visible_node_on(0, position)
585    }
586
587    /// Ближайший видимый узел НУЖНОГО уровня.
588    fn closest_visible_node_on(&self, level: u8, position: [f32; 2]) -> Option<usize> {
589        if self.nodes.is_empty() || self.node_grid_cell_size == 0.0 {
590            return None;
591        }
592
593        let center_cx = (position[0] / self.node_grid_cell_size).floor() as i32;
594        let center_cy = (position[1] / self.node_grid_cell_size).floor() as i32;
595        let mut candidates: Vec<usize> = Vec::new();
596
597        for cy in (center_cy - 1)..=(center_cy + 1) {
598            for cx in (center_cx - 1)..=(center_cx + 1) {
599                if let Some(cell) = self.node_grid.get(&(cx, cy)) {
600                    candidates.extend_from_slice(cell);
601                }
602            }
603        }
604
605        let mut closest: Option<usize> = None;
606        let mut min_distance_sq = f32::INFINITY;
607
608        for index in candidates {
609            if self.node_level(index) != level {
610                continue;
611            }
612
613            let node = self.nodes[index];
614
615            if !self.has_obstacle_between_on(level, position, node) {
616                let dx = position[0] - node[0];
617                let dy = position[1] - node[1];
618                let distance_sq = dx * dx + dy * dy;
619
620                if distance_sq < min_distance_sq {
621                    min_distance_sq = distance_sq;
622                    closest = Some(index);
623                }
624            }
625        }
626
627        closest
628    }
629}
630
631// евклидова дистанция между узлами (вес ребра перехода)
632fn distance(a: [f32; 2], b: [f32; 2]) -> f32 {
633    (a[0] - b[0]).hypot(a[1] - b[1])
634}
635
636#[cfg(test)]
637mod tests {
638    use indexmap::IndexMap;
639
640    use super::*;
641
642    use crate::map::MapLevels;
643
644    // карта 6×6: стены по периметру
645    fn walled_grid() -> Vec<Vec<i32>> {
646        vec![
647            vec![1, 1, 1, 1, 1, 1],
648            vec![1, 0, 0, 0, 0, 1],
649            vec![1, 0, 0, 0, 0, 1],
650            vec![1, 0, 0, 0, 0, 1],
651            vec![1, 0, 0, 0, 0, 1],
652            vec![1, 1, 1, 1, 1, 1],
653        ]
654    }
655
656    // карта 8×8, тайл 10: земля свободна, плита уровня 1 — правая половина
657    // (колонки 4..8), одна клетка рампы на земле ведёт под плиту
658    fn layered(with_ramp: bool) -> MapLevels {
659        use crate::map::{MapLevelConfig, RampConfig, RampDir};
660
661        let mut grid0 = vec![vec![0; 8]; 8];
662
663        if with_ramp {
664            grid0[4][3] = 3;
665        }
666
667        let grid1: Vec<Vec<i32>> = (0..8)
668            .map(|_| (0..8).map(|x| if x >= 4 { 9 } else { 0 }).collect())
669            .collect();
670
671        let mut levels = IndexMap::new();
672
673        levels.insert(
674            "1".to_string(),
675            MapLevelConfig {
676                map: grid1,
677                floor: vec![9],
678                walls: vec![],
679                layers: IndexMap::new(),
680            },
681        );
682
683        let ramps = if with_ramp {
684            vec![RampConfig {
685                tile: 3,
686                dir: RampDir::East,
687                from: 0,
688                to: 1,
689            }]
690        } else {
691            vec![]
692        };
693
694        MapLevels::build(&grid0, &[], &levels, &ramps, 10.0)
695    }
696
697    #[test]
698    fn layered_graph_places_nodes_on_both_levels() {
699        let nav = NavigationSystem::generate_layered(&layered(true), 10.0);
700        let counts = nav.nodes_by_level();
701
702        assert_eq!(nav.level_count(), 2);
703        assert!(counts[0] > 0 && counts[1] > 0, "{counts:?}");
704        assert_eq!(nav.node_levels.len(), nav.node_count());
705    }
706
707    #[test]
708    fn upper_level_nodes_only_on_floor() {
709        let nav = NavigationSystem::generate_layered(&layered(true), 10.0);
710
711        for index in 0..nav.node_count() {
712            if nav.node_level(index) == 1 {
713                assert!(nav.nodes[index][0] >= 40.0, "{:?}", nav.nodes[index]);
714            }
715        }
716    }
717
718    #[test]
719    fn ramp_edge_connects_levels() {
720        let nav = NavigationSystem::generate_layered(&layered(true), 10.0);
721
722        assert!(nav.ramp_edge_count() > 0);
723
724        let path = nav
725            .find_path_on(
726                PathPoint {
727                    pos: [15.0, 15.0],
728                    level: 0,
729                },
730                PathPoint {
731                    pos: [75.0, 45.0],
732                    level: 1,
733                },
734            )
735            .expect("путь через рампу не найден");
736
737        assert!(path.iter().any(|point| point.level == 1));
738        assert!(path.iter().any(|point| point.level == 0));
739    }
740
741    #[test]
742    fn no_path_between_levels_without_ramp() {
743        let nav = NavigationSystem::generate_layered(&layered(false), 10.0);
744
745        assert_eq!(nav.ramp_edge_count(), 0);
746        assert!(
747            nav.find_path_on(
748                PathPoint {
749                    pos: [15.0, 15.0],
750                    level: 0,
751                },
752                PathPoint {
753                    pos: [75.0, 45.0],
754                    level: 1,
755                },
756            )
757            .is_none()
758        );
759    }
760
761    #[test]
762    fn ledge_edge_is_one_way() {
763        let nav = NavigationSystem::generate_layered(&layered(false), 10.0);
764
765        assert!(nav.ledge_edge_count() > 0);
766        // сверху вниз — по ребру обрыва
767        assert!(
768            nav.find_path_on(
769                PathPoint {
770                    pos: [75.0, 45.0],
771                    level: 1,
772                },
773                PathPoint {
774                    pos: [15.0, 15.0],
775                    level: 0,
776                },
777            )
778            .is_some()
779        );
780        // снизу вверх через тот же обрыв — нет
781        assert!(
782            nav.find_path_on(
783                PathPoint {
784                    pos: [15.0, 15.0],
785                    level: 0,
786                },
787                PathPoint {
788                    pos: [75.0, 45.0],
789                    level: 1,
790                },
791            )
792            .is_none()
793        );
794    }
795
796    #[test]
797    fn legacy_generate_unchanged() {
798        let nav = NavigationSystem::generate(&walled_grid(), &[1], 10.0);
799
800        // одноуровневый граф не заводит слоёв и совпадает с прежним выводом
801        assert_eq!(nav.level_count(), 1);
802        assert!(nav.node_levels.is_empty());
803        assert_eq!(nav.node_count(), 4);
804        assert_eq!(nav.edge_count(), 12);
805        assert_eq!(nav.ramp_edge_count(), 0);
806        assert_eq!(nav.ledge_edge_count(), 0);
807    }
808
809    #[test]
810    fn walkable_inside_not_on_walls() {
811        let nav = NavigationSystem::generate(&walled_grid(), &[1], 10.0);
812
813        assert!(nav.is_walkable(25.0, 25.0));
814        assert!(!nav.is_walkable(5.0, 5.0)); // стена
815        assert!(!nav.is_walkable(-5.0, 25.0)); // за пределами
816    }
817
818    #[test]
819    fn line_of_sight_blocked_by_wall() {
820        let grid = vec![
821            vec![0, 0, 0],
822            vec![0, 1, 0],
823            vec![0, 0, 0],
824        ];
825        let nav = NavigationSystem::generate(&grid, &[1], 10.0);
826
827        // через центр (стена)
828        assert!(nav.has_obstacle_between([5.0, 5.0], [25.0, 25.0]));
829        // вдоль свободного края
830        assert!(!nav.has_obstacle_between([5.0, 5.0], [25.0, 5.0]));
831    }
832
833    #[test]
834    fn direct_path_when_visible() {
835        let nav = NavigationSystem::generate(&walled_grid(), &[1], 10.0);
836        let path = nav.find_path([15.0, 15.0], [45.0, 45.0]).unwrap();
837
838        assert_eq!(path, vec![[45.0, 45.0]]);
839    }
840}