Skip to main content

vimp_engine_core/
map.rs

1use indexmap::IndexMap;
2use rapier2d::prelude::*;
3use serde::{Deserialize, Serialize};
4
5use crate::config::FieldValue;
6use crate::physics::{deg_to_rad, encode_map_object, round2};
7
8// параметры поверхности по умолчанию (дефолты planck/Box2D,
9// с которыми сбалансировано ощущение управления). Публичные: клиентская
10// реплика контактов (client::rigid_body::MAP_SURFACE) обязана брать те же
11// значения, иначе предсказание тихо разъедется с хостом
12pub const DEFAULT_FRICTION: f32 = 0.2;
13pub const DEFAULT_RESTITUTION: f32 = 0.0;
14
15// порог «тело покоится» для хвоста скоростей в снапшоте: ниже него
16// скорость не стоит 12 байт на строку (Map.js REST_VELOCITY_EPSILON)
17const REST_VELOCITY_EPSILON: f32 = 0.01;
18
19/// Максимум поддерживаемых уровней: земля + одна эстакада. Больше двух
20/// упирается в 2.5D-модель (у луча/танка ровно один «текущий» уровень) и
21/// в бюджет масок Rapier, отведённый под уровни.
22pub const MAX_LEVELS: usize = 2;
23
24/// Динамический объект карты (physicsDynamic из src/data/maps/*).
25#[derive(Clone, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct DynamicObjectConfig {
28    pub position: [f32; 2],
29    pub angle: f32,
30    pub width: f32,
31    pub height: f32,
32    pub density: f32,
33    #[serde(default)]
34    pub linear_damping: Option<f32>,
35    #[serde(default)]
36    pub angular_damping: Option<f32>,
37    /// Уровень, на котором стоит тело (0 — земля).
38    #[serde(default)]
39    pub level: u8,
40}
41
42/// Описание НАДЗЕМНОГО уровня карты (level >= 1). Уровень 0 остаётся в
43/// полях `map`/`physicsStatic`/`layers` — так карта старого формата
44/// продолжает грузиться без единой правки.
45#[derive(Clone, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct MapLevelConfig {
48    /// Грид тайлов этого уровня; размерность обязана совпадать с `map`.
49    /// Значение `0` — пустота (уровня здесь нет, видно нижний).
50    pub map: Vec<Vec<i32>>,
51    /// Тайлы, по которым МОЖНО ездить на этом уровне (плита моста).
52    #[serde(default)]
53    pub floor: Vec<i32>,
54    /// Тайлы-стены этого уровня (перила): блокируют движение и луч.
55    /// Тайл перил обычно входит и в `floor` — по нему нельзя ехать сквозь,
56    /// но он часть плиты и экранирует луч снизу.
57    #[serde(default)]
58    pub walls: Vec<i32>,
59    /// Рендер-слои этого грида (zIndex -> список тайлов). Клиентское поле,
60    /// ядро его игнорирует.
61    #[serde(default)]
62    pub layers: IndexMap<String, Vec<i32>>,
63}
64
65/// Направление ПОДЪЁМА рампы (куда ехать, чтобы подняться).
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
67#[serde(rename_all = "lowercase")]
68pub enum RampDir {
69    /// -y
70    North,
71    /// +y
72    South,
73    /// -x
74    West,
75    /// +x
76    East,
77}
78
79impl RampDir {
80    /// (ось: 0 = x, 1 = y; знак: +1 — подъём в сторону роста координаты).
81    pub fn axis_sign(self) -> (u8, i8) {
82        match self {
83            RampDir::North => (1, -1),
84            RampDir::South => (1, 1),
85            RampDir::West => (0, -1),
86            RampDir::East => (0, 1),
87        }
88    }
89}
90
91/// Рампа: тайл-переход между уровнями.
92#[derive(Clone, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct RampConfig {
95    /// Индекс тайла в гриде уровня `from`.
96    pub tile: i32,
97    pub dir: RampDir,
98    #[serde(default)]
99    pub from: u8,
100    #[serde(default = "default_ramp_to")]
101    pub to: u8,
102}
103
104fn default_ramp_to() -> u8 {
105    1
106}
107
108/// JSON карты (src/data/maps/*.js, экспортированный в .json).
109/// Поля рендера (spriteSheet, layers) ядру не нужны и игнорируются.
110#[derive(Clone, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct MapConfig {
113    #[serde(default)]
114    pub set_id: Option<String>,
115    #[serde(default)]
116    pub scale: Option<f32>,
117    pub step: f32,
118    pub map: Vec<Vec<i32>>,
119    #[serde(default)]
120    pub physics_static: Vec<i32>,
121    #[serde(default)]
122    pub physics_dynamic: Vec<DynamicObjectConfig>,
123    /// [x, y, angleDeg] либо [x, y, angleDeg, level] — 4-й элемент
124    /// необязателен: без него уровень выводится из геометрии
125    /// (`GameMap::level_at`).
126    #[serde(default)]
127    pub respawns: IndexMap<String, Vec<Vec<f32>>>,
128    /// Надземные уровни: ключ — номер уровня строкой ("1"). Отсутствие поля
129    /// = одноуровневая карта.
130    #[serde(default)]
131    pub levels: IndexMap<String, MapLevelConfig>,
132    #[serde(default)]
133    pub ramps: Vec<RampConfig>,
134}
135
136/// Проверки формы слоёных полей карты (`levels`/`ramps`). Вынесены из
137/// `MapConfig::validate` отдельной функцией, потому что выполнять их обязаны
138/// обе стороны: хост при загрузке карты и клиентская реплика, которой те же
139/// поля приходят по сети в MAP_DATA. Косой грид уровня на клиенте не паникует
140/// — он молча даёт геометрию, отличную от хостовой, и предсказание расходится
141/// без единой строки в консоли: ровно та категория отказа, ради которой
142/// валидатор и написан.
143pub fn validate_levels(
144    map: &[Vec<i32>],
145    physics_static: &[i32],
146    levels: &IndexMap<String, MapLevelConfig>,
147    ramps: &[RampConfig],
148) -> Result<(), String> {
149    let rows = map.len();
150    let level_count = levels.len() + 1;
151
152    if !levels.is_empty() {
153        if level_count > MAX_LEVELS {
154            return Err(format!(
155                "map levels: {level_count} levels, at most {MAX_LEVELS} supported"
156            ));
157        }
158
159        let mut keys: Vec<u8> = Vec::with_capacity(levels.len());
160
161        for key in levels.keys() {
162            let level: u8 = key
163                .parse()
164                .map_err(|_| format!("map levels: key '{key}' is not a level number"))?;
165
166            if level == 0 {
167                return Err(
168                    "map levels: level 0 lives in map/physicsStatic, not in levels".to_string(),
169                );
170            }
171
172            keys.push(level);
173        }
174
175        keys.sort_unstable();
176
177        for (index, &level) in keys.iter().enumerate() {
178            if level as usize != index + 1 {
179                return Err(format!(
180                    "map levels: levels must run from 1 without gaps, got {level} at position {}",
181                    index + 1
182                ));
183            }
184        }
185    }
186
187    for (key, level) in levels {
188        if level.map.len() != rows {
189            return Err(format!(
190                "map levels: level {key} grid has {} rows, map has {rows}",
191                level.map.len()
192            ));
193        }
194
195        for (y, row) in level.map.iter().enumerate() {
196            let expected = map[y].len();
197
198            if row.len() != expected {
199                return Err(format!(
200                    "map levels: level {key} row {y} has {} cells, map has {expected}",
201                    row.len()
202                ));
203            }
204        }
205
206        for tile in &level.walls {
207            if !level.floor.contains(tile) {
208                return Err(format!(
209                    "map levels: level {key} wall tile {tile} is not part of floor"
210                ));
211            }
212        }
213    }
214
215    for (index, ramp) in ramps.iter().enumerate() {
216        if ramp.from == ramp.to {
217            return Err(format!("map ramps: ramp {index} goes from level {} to itself", ramp.from));
218        }
219
220        if (ramp.from as usize) >= level_count || (ramp.to as usize) >= level_count {
221            return Err(format!(
222                "map ramps: ramp {index} references level out of range (levels: {level_count})"
223            ));
224        }
225
226        let grid = if ramp.from == 0 {
227            Some(map)
228        } else {
229            levels.get(&ramp.from.to_string()).map(|level| level.map.as_slice())
230        };
231
232        let found = grid.is_some_and(|grid| {
233            grid.iter().any(|row| row.contains(&ramp.tile))
234        });
235
236        if !found {
237            return Err(format!(
238                "map ramps: ramp {index} tile {} is missing from level {} grid",
239                ramp.tile, ramp.from
240            ));
241        }
242
243        // рампа, ведущая в пустоту: за верхним концом прогона обязана быть
244        // поверхность уровня `to`. Иначе танк доезжает до вершины и в тот же
245        // шаг срывается вниз — молча, потому что и подъём, и падение
246        // штатные правила
247        let grid = grid.unwrap_or(map);
248
249        for (x, y) in ramp_run_exits(grid, ramp.tile, ramp.dir) {
250            if !walkable(map, physics_static, levels, ramp.to, x, y) {
251                return Err(format!(
252                    "map ramps: ramp {index} run ends at ({x}, {y}), which is not \
253                     walkable ground of level {}",
254                    ramp.to
255                ));
256            }
257        }
258    }
259
260    validate_level_edges(map, physics_static, levels)?;
261
262    Ok(())
263}
264
265/// Край плиты: клетка `floor` уровня N, у которой сосед — не плита и не
266/// перила, это обрыв. Обрыв разрешён (падение — правило игры), но приземлиться
267/// с него нужно на проходимую землю: за краем карты или в стене уровня 0 танк
268/// либо уезжает за карту (стены уровня 0 плите не преграда), либо
269/// приземляется внутри стены, откуда его выталкивает рывком.
270fn validate_level_edges(
271    map: &[Vec<i32>],
272    physics_static: &[i32],
273    levels: &IndexMap<String, MapLevelConfig>,
274) -> Result<(), String> {
275    for (key, level) in levels {
276        for (y, row) in level.map.iter().enumerate() {
277            for (x, tile) in row.iter().enumerate() {
278                // перила закрывают край сами; клетка вне плиты краем не бывает
279                if !level.floor.contains(tile) || level.walls.contains(tile) {
280                    continue;
281                }
282
283                for (dx, dy) in [(1_i64, 0_i64), (-1, 0), (0, 1), (0, -1)] {
284                    let nx = x as i64 + dx;
285                    let ny = y as i64 + dy;
286
287                    let neighbour = cell_at(&level.map, nx, ny);
288
289                    if neighbour.is_some_and(|tile| level.floor.contains(&tile)) {
290                        continue;
291                    }
292
293                    // сосед вне плиты — обрыв: под ним обязана быть земля
294                    if ground_walkable(map, physics_static, nx, ny) {
295                        continue;
296                    }
297
298                    return Err(format!(
299                        "map levels: level {key} floor cell ({x}, {y}) has an open \
300                         edge at ({nx}, {ny}) with no walkable ground below — close \
301                         it with a wall tile"
302                    ));
303                }
304            }
305        }
306    }
307
308    Ok(())
309}
310
311fn cell_at(grid: &[Vec<i32>], x: i64, y: i64) -> Option<i32> {
312    if x < 0 || y < 0 {
313        return None;
314    }
315
316    grid.get(y as usize)
317        .and_then(|row| row.get(x as usize))
318        .copied()
319}
320
321/// Земля уровня 0 проходима, если клетка есть в гриде и её тайл не объявлен
322/// стеной (`physicsStatic`).
323fn ground_walkable(map: &[Vec<i32>], physics_static: &[i32], x: i64, y: i64) -> bool {
324    cell_at(map, x, y).is_some_and(|tile| !physics_static.contains(&tile))
325}
326
327/// Проходима ли клетка на уровне `level`: для земли — не стена, для
328/// надземного уровня — плита без перил.
329fn walkable(
330    map: &[Vec<i32>],
331    physics_static: &[i32],
332    levels: &IndexMap<String, MapLevelConfig>,
333    level: u8,
334    x: i64,
335    y: i64,
336) -> bool {
337    if level == 0 {
338        return ground_walkable(map, physics_static, x, y);
339    }
340
341    let Some(cfg) = levels.get(&level.to_string()) else {
342        return false;
343    };
344
345    cell_at(&cfg.map, x, y)
346        .is_some_and(|tile| cfg.floor.contains(&tile) && !cfg.walls.contains(&tile))
347}
348
349/// Клетки, следующие за верхними концами прогонов рампы (прогон — непрерывная
350/// линия одинаковых тайлов вдоль оси рампы, как в `MapLevels::build_runs`).
351fn ramp_run_exits(grid: &[Vec<i32>], tile: i32, dir: RampDir) -> Vec<(i64, i64)> {
352    let (axis, sign) = dir.axis_sign();
353    let mut out = Vec::new();
354
355    if axis == 1 {
356        let cols = grid.iter().map(|row| row.len()).max().unwrap_or(0);
357
358        for x in 0..cols {
359            let mut y = 0;
360
361            while y < grid.len() {
362                if grid[y].get(x) != Some(&tile) {
363                    y += 1;
364                    continue;
365                }
366
367                let y0 = y;
368
369                while y < grid.len() && grid[y].get(x) == Some(&tile) {
370                    y += 1;
371                }
372
373                let exit = if sign > 0 { y as i64 } else { y0 as i64 - 1 };
374
375                out.push((x as i64, exit));
376            }
377        }
378    } else {
379        for (y, row) in grid.iter().enumerate() {
380            let mut x = 0;
381
382            while x < row.len() {
383                if row[x] != tile {
384                    x += 1;
385                    continue;
386                }
387
388                let x0 = x;
389
390                while x < row.len() && row[x] == tile {
391                    x += 1;
392                }
393
394                let exit = if sign > 0 { x as i64 } else { x0 as i64 - 1 };
395
396                out.push((exit, y as i64));
397            }
398        }
399    }
400
401    out
402}
403
404impl MapConfig {
405    /// Валидация слоёных полей. Любая из этих ошибок в рантайме молчит:
406    /// карта с рассинхроном размерностей гридов даёт танк, проваливающийся
407    /// в пустоту, и ни одной строки в консоли.
408    pub fn validate(&self) -> Result<(), String> {
409        let level_count = self.levels.len() + 1;
410
411        validate_levels(&self.map, &self.physics_static, &self.levels, &self.ramps)?;
412
413        for (team, points) in &self.respawns {
414            for (index, point) in points.iter().enumerate() {
415                if point.len() != 3 && point.len() != 4 {
416                    return Err(format!(
417                        "map respawns: {team}[{index}] has {} numbers, expected 3 or 4",
418                        point.len()
419                    ));
420                }
421
422                if point.len() == 4 && (point[3] as usize) >= level_count {
423                    return Err(format!(
424                        "map respawns: {team}[{index}] level {} is out of range (levels: {level_count})",
425                        point[3]
426                    ));
427                }
428            }
429        }
430
431        for (index, object) in self.physics_dynamic.iter().enumerate() {
432            if (object.level as usize) >= level_count {
433                return Err(format!(
434                    "map physicsDynamic: object {index} level {} is out of range (levels: {level_count})",
435                    object.level
436                ));
437            }
438        }
439
440        Ok(())
441    }
442}
443
444/// Битовая маска слоя для InteractionGroups. Уровень 0 — GROUP_1,
445/// уровень 1 — GROUP_2. Тела за пределами реестра (не выставившие группы)
446/// остаются в `Group::ALL` и поэтому продолжают взаимодействовать с
447/// уровнем 0 — старые карты и старые игры не замечают появления слоёв.
448pub fn level_group(level: u8) -> Group {
449    // уровень вне `MAX_LEVELS` схлопнулся бы в уровень 1 молча: карта с
450    // тремя слоями не проходит `validate`, поэтому сюда он попасть может
451    // только из кода, и ловить это должен разработчик, а не игрок
452    debug_assert!(
453        (level as usize) < MAX_LEVELS,
454        "level_group: level {level} is out of range (MAX_LEVELS: {MAX_LEVELS})"
455    );
456
457    match level {
458        0 => Group::GROUP_1,
459        _ => Group::GROUP_2,
460    }
461}
462
463/// Группа статики карты: её несут стены ВСЕХ уровней в дополнение к своей
464/// `level_group`. Нужна телам, которым по правилам игры полагаются только
465/// стены и ни одного динамического тела (падающий с обрыва танк): маска
466/// `STATIC_LEVEL_GROUP` в одиночку видит стену любого уровня и не видит ни
467/// танков, ни ящиков. Уровни занимают GROUP_1..GROUP_2 (см. `MAX_LEVELS`),
468/// поэтому статика берёт заведомо свободный бит.
469pub const STATIC_LEVEL_GROUP: Group = Group::GROUP_9;
470
471/// Маска «я на уровне `level` и вижу только его».
472pub fn level_interaction(level: u8) -> InteractionGroups {
473    let group = level_group(level);
474
475    InteractionGroups::new(group, group, InteractionTestMode::And)
476}
477
478/// Маска стены уровня `level`: своя группа уровня плюс `STATIC_LEVEL_GROUP`.
479pub fn static_level_interaction(level: u8) -> InteractionGroups {
480    let group = level_group(level) | STATIC_LEVEL_GROUP;
481
482    InteractionGroups::new(group, group, InteractionTestMode::And)
483}
484
485/// Маска «я вижу все уровни из `mask`» (танк на рампе).
486pub fn levels_interaction(mask: Group) -> InteractionGroups {
487    InteractionGroups::new(mask, mask, InteractionTestMode::And)
488}
489
490/// Результат попадания точки на рампу.
491#[derive(Clone, Copy, Debug, PartialEq)]
492pub struct RampSample {
493    /// 0.0 у подножия прогона, 1.0 на его верхней кромке.
494    pub progress: f32,
495    pub from: u8,
496    pub to: u8,
497}
498
499/// Прогон рампы: максимальная непрерывная линия тайлов одной рампы вдоль её
500/// оси в одной строке (ось x) или колонке (ось y).
501#[derive(Clone, Serialize, Deserialize)]
502pub struct RampRun {
503    /// 0 = x, 1 = y.
504    pub axis: u8,
505    /// +1 — подъём в сторону роста координаты.
506    pub sign: i8,
507    pub from: u8,
508    pub to: u8,
509    /// Границы прогона по своей оси в МИРОВЫХ (масштабированных) единицах.
510    pub min: f32,
511    pub max: f32,
512    /// Границы прогона поперёк своей оси (нужны нав-графу, чтобы поставить
513    /// узлы у подножия и на вершине по центру прогона).
514    pub cross_min: f32,
515    pub cross_max: f32,
516}
517
518/// Слоистая геометрия карты без физического мира: гриды уровней, списки
519/// сплошных тайлов, прогоны рамп. Хост держит её внутри `GameMap`,
520/// клиентская реплика игры строит из полей MAP_DATA — правила уровней у
521/// обеих сторон читаются отсюда и только отсюда.
522#[derive(Clone, Default, Serialize, Deserialize)]
523pub struct MapLevels {
524    /// Гриды по индексу уровня: [0] — земля, [1] — эстакада.
525    grids: Vec<Vec<Vec<i32>>>,
526    /// Тайлы-стены по уровням (для 0 — `physicsStatic`, для N — `walls`).
527    solid: Vec<Vec<i32>>,
528    /// Тайлы-пол по уровням (для 0 — пусто: земля есть везде внутри карты).
529    floor: Vec<Vec<i32>>,
530    runs: Vec<RampRun>,
531    /// Параллелен `grids[0]`: индекс прогона рампы в клетке либо -1.
532    run_cells: Vec<Vec<i16>>,
533    /// Размер тайла в МИРОВЫХ единицах (step * scale).
534    tile_size: f32,
535}
536
537/// Один прогон рампы: `from`..`to` — границы по своей оси в клетках,
538/// `cross0`..`cross1` — поперёк; клетки, уже занятые прогоном, пропускаются
539/// (первая объявленная рампа выигрывает — детерминированно). Свободная
540/// функция, а не метод: `build_runs` держит грид уровня взаймы у того же
541/// `MapLevels`, и метод по `&mut self` заставил бы клонировать грид.
542#[allow(clippy::too_many_arguments)]
543fn push_run(
544    runs: &mut Vec<RampRun>,
545    run_cells: &mut [Vec<i16>],
546    tile_size: f32,
547    ramp: &RampConfig,
548    axis: u8,
549    sign: i8,
550    from: usize,
551    to: usize,
552    cross0: usize,
553    cross1: usize,
554) {
555    if runs.len() >= i16::MAX as usize {
556        return;
557    }
558
559    let index = runs.len() as i16;
560    let mut claimed = false;
561
562    for main in from..to {
563        for cross in cross0..cross1 {
564            let (x, y) = if axis == 1 { (cross, main) } else { (main, cross) };
565
566            let Some(cell) = run_cells.get_mut(y).and_then(|row| row.get_mut(x)) else {
567                continue;
568            };
569
570            if *cell < 0 {
571                *cell = index;
572                claimed = true;
573            }
574        }
575    }
576
577    if !claimed {
578        return;
579    }
580
581    let size = tile_size;
582
583    runs.push(RampRun {
584        axis,
585        sign,
586        from: ramp.from,
587        to: ramp.to,
588        min: from as f32 * size,
589        max: to as f32 * size,
590        cross_min: cross0 as f32 * size,
591        cross_max: cross1 as f32 * size,
592    });
593}
594
595impl MapLevels {
596    /// `grid0`/`solid0` — грид и стены уровня 0; `levels` — конфиги
597    /// надземных уровней (ключ — номер строкой); `ramps` — конфиги рамп;
598    /// `tile_size` — УЖЕ масштабированный размер тайла.
599    pub fn build(
600        grid0: &[Vec<i32>],
601        solid0: &[i32],
602        levels: &IndexMap<String, MapLevelConfig>,
603        ramps: &[RampConfig],
604        tile_size: f32,
605    ) -> Self {
606        let mut out = Self {
607            grids: vec![grid0.to_vec()],
608            solid: vec![solid0.to_vec()],
609            floor: vec![Vec::new()],
610            runs: Vec::new(),
611            run_cells: grid0.iter().map(|row| vec![-1i16; row.len()]).collect(),
612            tile_size,
613        };
614
615        // порядок уровней — по числовому ключу, а не по порядку в JSON:
616        // индекс в `grids` обязан совпадать с номером уровня
617        let mut ordered: Vec<(u8, &MapLevelConfig)> = levels
618            .iter()
619            .filter_map(|(key, level)| key.parse::<u8>().ok().map(|index| (index, level)))
620            .collect();
621
622        ordered.sort_by_key(|(index, _)| *index);
623
624        for (index, level) in ordered {
625            if index as usize != out.grids.len() {
626                continue;
627            }
628
629            out.grids.push(level.map.clone());
630            out.solid.push(level.walls.clone());
631            out.floor.push(level.floor.clone());
632        }
633
634        out.build_runs(ramps);
635
636        out
637    }
638
639    // прогоны рамп: непрерывные линии одинаковых тайлов вдоль оси рампы
640    fn build_runs(&mut self, ramps: &[RampConfig]) {
641        // заимствования разведены по полям: грид уровня читается на месте,
642        // прогоны пишутся в свои поля — иначе пришлось бы клонировать грид
643        // на каждую рампу
644        let Self {
645            grids,
646            runs,
647            run_cells,
648            tile_size,
649            ..
650        } = self;
651
652        for ramp in ramps {
653            let (axis, sign) = ramp.dir.axis_sign();
654            let Some(grid) = grids.get(ramp.from as usize) else {
655                continue;
656            };
657            let rows = grid.len();
658
659            if axis == 1 {
660                let cols = grid.iter().map(|row| row.len()).max().unwrap_or(0);
661
662                for x in 0..cols {
663                    let mut y = 0;
664
665                    while y < rows {
666                        if grid[y].get(x) != Some(&ramp.tile) {
667                            y += 1;
668                            continue;
669                        }
670
671                        let y0 = y;
672
673                        while y < rows && grid[y].get(x) == Some(&ramp.tile) {
674                            y += 1;
675                        }
676
677                        push_run(
678                            runs,
679                            run_cells,
680                            *tile_size,
681                            ramp,
682                            axis,
683                            sign,
684                            y0,
685                            y,
686                            x,
687                            x + 1,
688                        );
689                    }
690                }
691            } else {
692                for (y, row) in grid.iter().enumerate() {
693                    let mut x = 0;
694
695                    while x < row.len() {
696                        if row[x] != ramp.tile {
697                            x += 1;
698                            continue;
699                        }
700
701                        let x0 = x;
702
703                        while x < row.len() && row[x] == ramp.tile {
704                            x += 1;
705                        }
706
707                        push_run(
708                            runs,
709                            run_cells,
710                            *tile_size,
711                            ramp,
712                            axis,
713                            sign,
714                            x0,
715                            x,
716                            y,
717                            y + 1,
718                        );
719                    }
720                }
721            }
722        }
723    }
724
725    /// Есть ли надземные уровни (2.5D-режим).
726    pub fn is_layered(&self) -> bool {
727        self.grids.len() > 1
728    }
729
730    /// Число уровней, включая землю (1 у обычной карты).
731    pub fn level_count(&self) -> usize {
732        self.grids.len().max(1)
733    }
734
735    pub fn tile_size(&self) -> f32 {
736        self.tile_size
737    }
738
739    pub fn grid(&self, level: u8) -> Option<&Vec<Vec<i32>>> {
740        self.grids.get(level as usize)
741    }
742
743    pub fn solid(&self, level: u8) -> &[i32] {
744        self.solid.get(level as usize).map_or(&[], |list| list)
745    }
746
747    pub fn floor(&self, level: u8) -> &[i32] {
748        self.floor.get(level as usize).map_or(&[], |list| list)
749    }
750
751    pub fn runs(&self) -> &[RampRun] {
752        &self.runs
753    }
754
755    /// (колонка, строка) по мировой точке; None вне карты.
756    pub fn cell_at(&self, x: f32, y: f32) -> Option<(usize, usize)> {
757        if self.tile_size <= 0.0 || x < 0.0 || y < 0.0 {
758            return None;
759        }
760
761        let cx = (x / self.tile_size).floor() as usize;
762        let cy = (y / self.tile_size).floor() as usize;
763        let row = self.grids.first()?.get(cy)?;
764
765        if cx >= row.len() { None } else { Some((cx, cy)) }
766    }
767
768    /// Есть ли пол уровня `level` в точке. Для уровня 0 — true везде внутри
769    /// карты (под мостом земля никуда не девается).
770    pub fn has_floor(&self, level: u8, x: f32, y: f32) -> bool {
771        let Some((cx, cy)) = self.cell_at(x, y) else {
772            return false;
773        };
774
775        if level == 0 {
776            return true;
777        }
778
779        let Some(grid) = self.grid(level) else {
780            return false;
781        };
782        let Some(&tile) = grid.get(cy).and_then(|row| row.get(cx)) else {
783            return false;
784        };
785
786        self.floor(level).contains(&tile)
787    }
788
789    /// Стена уровня `level` в точке.
790    pub fn is_solid(&self, level: u8, x: f32, y: f32) -> bool {
791        let Some((cx, cy)) = self.cell_at(x, y) else {
792            return false;
793        };
794        let Some(grid) = self.grid(level) else {
795            return false;
796        };
797        let Some(&tile) = grid.get(cy).and_then(|row| row.get(cx)) else {
798            return false;
799        };
800
801        self.solid(level).contains(&tile)
802    }
803
804    /// Наивысший уровень, у которого есть пол в точке. Спавн без явного
805    /// уровня приземляется сюда.
806    pub fn level_at(&self, x: f32, y: f32) -> u8 {
807        let mut level = 0;
808
809        for candidate in (1..self.level_count() as u8).rev() {
810            if self.has_floor(candidate, x, y) {
811                level = candidate;
812                break;
813            }
814        }
815
816        level
817    }
818
819    /// Рампа под точкой.
820    pub fn ramp_at(&self, x: f32, y: f32) -> Option<RampSample> {
821        let (cx, cy) = self.cell_at(x, y)?;
822        let index = *self.run_cells.get(cy)?.get(cx)?;
823
824        if index < 0 {
825            return None;
826        }
827
828        let run = &self.runs[index as usize];
829        let value = if run.axis == 0 { x } else { y };
830        let span = run.max - run.min;
831        let raw = if span <= 0.0 { 0.0 } else { (value - run.min) / span };
832        let progress = if run.sign > 0 { raw } else { 1.0 - raw };
833
834        Some(RampSample {
835            progress: progress.clamp(0.0, 1.0),
836            from: run.from,
837            to: run.to,
838        })
839    }
840}
841
842/// Карта в мире: порт физической части src/server/parts/Map.js +
843/// масштабирование из RoundManager.createMap (scaleMapData).
844#[derive(Serialize, Deserialize)]
845pub struct GameMap {
846    pub set_id: String,
847    /// Размер тайла после масштабирования.
848    pub step: f32,
849    /// Сетка тайлов (немасштабируемая) — источник нав-сетки ботов.
850    pub grid: Vec<Vec<i32>>,
851    pub physics_static: Vec<i32>,
852    /// Респауны по командам (масштабированные) — [x, y, angle°] либо
853    /// [x, y, angle°, level].
854    pub respawns: IndexMap<String, Vec<Vec<f32>>>,
855    /// Слоистая геометрия (одноуровневая у карты без `levels`).
856    levels: MapLevels,
857    static_bodies: Vec<RigidBodyHandle>,
858    /// Уровень каждого статического тела, параллелен `static_bodies`.
859    static_levels: Vec<u8>,
860    dynamic_bodies: Vec<RigidBodyHandle>,
861    /// Уровень каждого динамического тела, параллелен `dynamic_bodies`.
862    dynamic_levels: Vec<u8>,
863}
864
865impl GameMap {
866    /// Создаёт тела карты в мире. `default_scale`/`default_set_id` —
867    /// значения конфига, перекрываемые полями самой карты.
868    pub fn create(
869        world: &mut PhysicsWorld,
870        cfg: &MapConfig,
871        default_scale: f32,
872        default_set_id: &str,
873    ) -> Self {
874        let scale = cfg.scale.unwrap_or(default_scale);
875        let step = cfg.step * scale;
876        let levels = MapLevels::build(
877            &cfg.map,
878            &cfg.physics_static,
879            &cfg.levels,
880            &cfg.ramps,
881            step,
882        );
883
884        let mut map = Self {
885            set_id: cfg
886                .set_id
887                .clone()
888                .unwrap_or_else(|| default_set_id.to_string()),
889            step,
890            grid: cfg.map.clone(),
891            physics_static: cfg.physics_static.clone(),
892            respawns: cfg
893                .respawns
894                .iter()
895                .map(|(team, arr)| {
896                    (
897                        team.clone(),
898                        arr.iter()
899                            .map(|point| {
900                                let mut out = point.clone();
901
902                                if out.len() >= 2 {
903                                    out[0] *= scale;
904                                    out[1] *= scale;
905                                }
906
907                                out
908                            })
909                            .collect(),
910                    )
911                })
912                .collect(),
913            levels,
914            static_bodies: Vec::new(),
915            static_levels: Vec::new(),
916            dynamic_bodies: Vec::new(),
917            dynamic_levels: Vec::new(),
918        };
919
920        map.create_static(world);
921        map.create_dynamic(world, &cfg.physics_dynamic, scale);
922
923        map
924    }
925
926    /// Статические стены (Map.createStatic) — по уровням, в порядке 0, 1, …
927    /// Внутри уровня обход прежний (строки сверху вниз, колонки слева
928    /// направо), поэтому у одноуровневой карты порядок вставки тел
929    /// не меняется вовсе.
930    fn create_static(&mut self, world: &mut PhysicsWorld) {
931        for level in 0..self.levels.level_count() as u8 {
932            let Some(grid) = self.levels.grid(level) else {
933                continue;
934            };
935            let solid = self.levels.solid(level).to_vec();
936
937            let mut work: Vec<Vec<Option<i32>>> = grid
938                .iter()
939                .map(|row| row.iter().map(|&tile| Some(tile)).collect())
940                .collect();
941
942            for y in 0..work.len() {
943                for x in 0..work[y].len() {
944                    let is_static = work[y][x].is_some_and(|tile| solid.contains(&tile));
945
946                    if is_static {
947                        let (width, height) =
948                            search_static_block(&mut work, &solid, self.step, y, x);
949                        let pos_x = x as f32 * self.step + width / 2.0;
950                        let pos_y = y as f32 * self.step + height / 2.0;
951
952                        let body = world.insert_body(
953                            RigidBodyBuilder::fixed().translation(Vector::new(pos_x, pos_y)),
954                        );
955
956                        world.insert_collider(
957                            ColliderBuilder::cuboid(width / 2.0, height / 2.0)
958                                .friction(DEFAULT_FRICTION)
959                                .restitution(DEFAULT_RESTITUTION)
960                                .collision_groups(static_level_interaction(level)),
961                            Some(body),
962                        );
963
964                        self.static_bodies.push(body);
965                        self.static_levels.push(level);
966                    }
967                }
968            }
969        }
970    }
971
972    /// Динамические элементы (Map.createDynamic).
973    fn create_dynamic(
974        &mut self,
975        world: &mut PhysicsWorld,
976        dynamics: &[DynamicObjectConfig],
977        scale: f32,
978    ) {
979        for data in dynamics {
980            let pos_x = data.position[0] * scale;
981            let pos_y = data.position[1] * scale;
982            let width = data.width * scale;
983            let height = data.height * scale;
984
985            let body = world.insert_body(
986                RigidBodyBuilder::dynamic()
987                    .translation(Vector::new(pos_x, pos_y))
988                    .rotation(deg_to_rad(data.angle))
989                    .linear_damping(data.linear_damping.unwrap_or(0.0))
990                    .angular_damping(data.angular_damping.unwrap_or(0.01))
991                    // предиктивные контакты на толщину ящика — та же причина,
992                    // что и у танка: дефолтная дистанция предсказания Rapier
993                    // рассчитана на метры и на порядки меньше пути тела за шаг
994                    .soft_ccd_prediction(width.min(height))
995                    .user_data(encode_map_object()),
996            );
997
998            // коллайдер со смещённым центром (позиция тела — угол объекта)
999            world.insert_collider(
1000                ColliderBuilder::cuboid(width / 2.0, height / 2.0)
1001                    .translation(Vector::new(width / 2.0, height / 2.0))
1002                    .density(data.density)
1003                    .friction(DEFAULT_FRICTION)
1004                    .restitution(DEFAULT_RESTITUTION)
1005                    .collision_groups(level_interaction(data.level)),
1006                Some(body),
1007            );
1008
1009            self.dynamic_bodies.push(body);
1010            self.dynamic_levels.push(data.level);
1011        }
1012    }
1013
1014    // ***** слоистая геометрия ***** //
1015
1016    pub fn levels(&self) -> &MapLevels {
1017        &self.levels
1018    }
1019
1020    pub fn is_layered(&self) -> bool {
1021        self.levels.is_layered()
1022    }
1023
1024    pub fn level_count(&self) -> usize {
1025        self.levels.level_count()
1026    }
1027
1028    pub fn level_at(&self, x: f32, y: f32) -> u8 {
1029        self.levels.level_at(x, y)
1030    }
1031
1032    pub fn has_floor(&self, level: u8, x: f32, y: f32) -> bool {
1033        self.levels.has_floor(level, x, y)
1034    }
1035
1036    pub fn ramp_at(&self, x: f32, y: f32) -> Option<RampSample> {
1037        self.levels.ramp_at(x, y)
1038    }
1039
1040    /// Уровень динамического тела по его индексу в блоке снапшота.
1041    pub fn dynamic_level(&self, index: usize) -> u8 {
1042        self.dynamic_levels.get(index).copied().unwrap_or(0)
1043    }
1044
1045    /// Уровни статических тел (отладочный дамп).
1046    pub fn static_levels(&self) -> &[u8] {
1047        &self.static_levels
1048    }
1049
1050    /// Уровни динамических тел (отладочный дамп).
1051    pub fn dynamic_levels(&self) -> &[u8] {
1052        &self.dynamic_levels
1053    }
1054
1055    // ***** отладочный дамп (crate::debug) ***** //
1056
1057    pub fn static_body_count(&self) -> usize {
1058        self.static_bodies.len()
1059    }
1060
1061    pub fn dynamic_body_count(&self) -> usize {
1062        self.dynamic_bodies.len()
1063    }
1064
1065    /// Удаляет все тела карты из мира (Map.destroyMap).
1066    pub fn destroy(&mut self, world: &mut PhysicsWorld) {
1067        for handle in self.static_bodies.drain(..) {
1068            world.remove_body(handle);
1069        }
1070
1071        for handle in self.dynamic_bodies.drain(..) {
1072            world.remove_body(handle);
1073        }
1074
1075        self.static_levels.clear();
1076        self.dynamic_levels.clear();
1077    }
1078
1079    /// Краткие данные динамических элементов (Map.getDynamicMapData):
1080    /// индекс → [x, y, angle] (как поля строки блока), значения скруглены
1081    /// до 2 знаков.
1082    ///
1083    /// При `with_velocities` движущееся тело отдаёт ещё и хвост
1084    /// [vx, vy, angvel] (кадр v4, `optionalFrom` в схеме блока): клиент
1085    /// предсказывает динамику карты рядом со своим танком, а оценка скорости
1086    /// конечной разностью между кадрами 30 Гц сильнее всего врёт именно в
1087    /// момент удара. Покоящееся тело хвост не шлёт.
1088    pub fn dynamic_map_data(
1089        &self,
1090        world: &PhysicsWorld,
1091        with_velocities: bool,
1092    ) -> Vec<(u8, Vec<FieldValue>)> {
1093        self.dynamic_bodies
1094            .iter()
1095            .enumerate()
1096            .filter_map(|(index, &handle)| {
1097                world.bodies.get(handle).map(|body| {
1098                    let pos = body.translation();
1099
1100                    let mut fields = vec![
1101                        FieldValue::F32(round2(pos.x)),
1102                        FieldValue::F32(round2(pos.y)),
1103                        FieldValue::F32(round2(body.rotation().angle())),
1104                    ];
1105
1106                    if with_velocities {
1107                        let linvel = body.linvel();
1108                        let angvel = body.angvel();
1109                        let resting = body.is_sleeping()
1110                            || (linvel.x.hypot(linvel.y) < REST_VELOCITY_EPSILON
1111                                && angvel.abs() < REST_VELOCITY_EPSILON);
1112
1113                        if !resting {
1114                            fields.push(FieldValue::F32(round2(linvel.x)));
1115                            fields.push(FieldValue::F32(round2(linvel.y)));
1116                            fields.push(FieldValue::F32(round2(angvel)));
1117                        }
1118                    }
1119
1120                    (index as u8, fields)
1121                })
1122            })
1123            .collect()
1124    }
1125}
1126
1127/// Жадный поиск прямоугольного блока стен (Map.searchStaticBlock).
1128/// Обработанные ячейки затираются в рабочей копии сетки. Список сплошных
1129/// тайлов свой у каждого уровня, поэтому функция свободная, а не метод.
1130fn search_static_block(
1131    work: &mut [Vec<Option<i32>>],
1132    solid: &[i32],
1133    step: f32,
1134    y0: usize,
1135    x0: usize,
1136) -> (f32, f32) {
1137    let mut x = x0;
1138    let mut w_counter = 0;
1139    let mut h_counter = 1;
1140
1141    // ширина блока
1142    while x < work[y0].len() && work[y0][x].is_some_and(|tile| solid.contains(&tile)) {
1143        work[y0][x] = None;
1144        x += 1;
1145        w_counter += 1;
1146    }
1147
1148    let len_x = x;
1149    let len_y = work.len();
1150
1151    // высота блока
1152    for y in (y0 + 1)..len_y {
1153        let mut empty_tile = false;
1154        let mut x = x0;
1155
1156        while x < len_x {
1157            if x < work[y].len() && work[y][x].is_some_and(|tile| solid.contains(&tile)) {
1158                x += 1;
1159            } else {
1160                empty_tile = true;
1161                break;
1162            }
1163        }
1164
1165        if empty_tile {
1166            break;
1167        }
1168
1169        h_counter += 1;
1170
1171        for cell in work[y][x0..len_x].iter_mut() {
1172            *cell = None;
1173        }
1174    }
1175
1176    (w_counter as f32 * step, h_counter as f32 * step)
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181    use super::*;
1182
1183    const TIME_STEP: f32 = 1.0 / 120.0;
1184
1185    // сплошная стена в верхнем ряду (5 тайлов по 20) и один ящик 20×20 под ней
1186    fn map_config() -> MapConfig {
1187        serde_json::from_value(serde_json::json!({
1188            "step": 20.0,
1189            "map": [[1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
1190            "physicsStatic": [1],
1191            "physicsDynamic": [{
1192                "position": [40.0, 60.0],
1193                "angle": 0.0,
1194                "width": 20.0,
1195                "height": 20.0,
1196                "density": 1.0
1197            }]
1198        }))
1199        .unwrap()
1200    }
1201
1202    fn make_world() -> PhysicsWorld {
1203        let mut world = PhysicsWorld::new();
1204
1205        world.gravity = Vector::ZERO;
1206        world.integration_parameters.dt = TIME_STEP;
1207
1208        world
1209    }
1210
1211    // глубина самого глубокого перекрытия за прогон: ящик разгоняется в стену
1212    fn max_penetration(world: &mut PhysicsWorld, body: RigidBodyHandle) -> f32 {
1213        let mut depth: f32 = 0.0;
1214
1215        // ~17 юнитов за шаг — на порядки больше дефолтных 0.002 и сравнимо
1216        // с толщиной ящика: без предсказания контакт рождается уже внутри
1217        world.bodies[body].set_linvel(Vector::new(0.0, -2000.0), true);
1218
1219        for _ in 0..120 {
1220            world.step();
1221
1222            for pair in world.contact_pairs() {
1223                for manifold in &pair.manifolds {
1224                    for point in &manifold.points {
1225                        depth = depth.max(-point.dist);
1226                    }
1227                }
1228            }
1229        }
1230
1231        depth
1232    }
1233
1234    // карта 3×3: стена уровня 0 в углу, плита уровня 1 в центре строки 1,
1235    // перила — правый тайл плиты
1236    fn layered_config() -> MapConfig {
1237        serde_json::from_value(serde_json::json!({
1238            "step": 20.0,
1239            "map": [[1, 0, 0], [0, 0, 0], [0, 0, 0]],
1240            "physicsStatic": [1],
1241            "levels": {
1242                "1": {
1243                    "map": [[0, 0, 0], [0, 5, 6], [0, 0, 0]],
1244                    "floor": [5, 6],
1245                    "walls": [6]
1246                }
1247            }
1248        }))
1249        .unwrap()
1250    }
1251
1252    // вертикальная рампа 1×3 в колонке 0
1253    fn ramp_config(dir: &str) -> MapConfig {
1254        serde_json::from_value(serde_json::json!({
1255            "step": 20.0,
1256            "map": [[7, 0, 0], [7, 0, 0], [7, 0, 0]],
1257            "physicsStatic": [],
1258            "levels": {
1259                "1": { "map": [[0, 0, 0], [0, 0, 0], [0, 0, 0]], "floor": [] }
1260            },
1261            "ramps": [{ "tile": 7, "dir": dir }]
1262        }))
1263        .unwrap()
1264    }
1265
1266    fn collision_groups(world: &PhysicsWorld, body: RigidBodyHandle) -> InteractionGroups {
1267        let handle = world.bodies[body].colliders()[0];
1268
1269        world.colliders[handle].collision_groups()
1270    }
1271
1272    #[test]
1273    fn legacy_map_has_no_levels() {
1274        let mut world = make_world();
1275        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
1276
1277        assert!(!map.is_layered());
1278        assert_eq!(map.level_count(), 1);
1279        // стена 5×1 схлопывается в одно тело, как и до появления слоёв
1280        assert_eq!(map.static_body_count(), 1);
1281        assert_eq!(
1282            world.bodies[map.static_bodies[0]].translation(),
1283            Vector::new(50.0, 10.0)
1284        );
1285    }
1286
1287    #[test]
1288    fn layered_map_builds_static_per_level() {
1289        let mut world = make_world();
1290        let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
1291
1292        assert!(map.is_layered());
1293        assert_eq!(map.static_body_count(), 2);
1294        assert_eq!(map.static_levels(), &[0, 1]);
1295        // стена несёт свою группу уровня И группу статики: падающий танк
1296        // (маска STATIC_LEVEL_GROUP) обязан задевать стены обоих уровней
1297        assert_eq!(
1298            collision_groups(&world, map.static_bodies[0]).memberships,
1299            Group::GROUP_1 | STATIC_LEVEL_GROUP
1300        );
1301        assert_eq!(
1302            collision_groups(&world, map.static_bodies[1]).memberships,
1303            Group::GROUP_2 | STATIC_LEVEL_GROUP
1304        );
1305    }
1306
1307    #[test]
1308    fn static_group_sees_walls_but_no_bodies() {
1309        let falling = InteractionGroups::new(
1310            STATIC_LEVEL_GROUP,
1311            STATIC_LEVEL_GROUP,
1312            InteractionTestMode::And,
1313        );
1314
1315        // стены обоих уровней падающий задевает
1316        assert!(falling.test(static_level_interaction(0)));
1317        assert!(falling.test(static_level_interaction(1)));
1318        // тела (танки, ящики) — нет
1319        assert!(!falling.test(level_interaction(0)));
1320        assert!(!falling.test(level_interaction(1)));
1321    }
1322
1323    #[test]
1324    fn dynamic_body_carries_level_group() {
1325        let mut cfg = layered_config();
1326
1327        cfg.physics_dynamic = vec![DynamicObjectConfig {
1328            position: [20.0, 20.0],
1329            angle: 0.0,
1330            width: 20.0,
1331            height: 20.0,
1332            density: 1.0,
1333            linear_damping: None,
1334            angular_damping: None,
1335            level: 1,
1336        }];
1337
1338        let mut world = make_world();
1339        let map = GameMap::create(&mut world, &cfg, 1.0, "set");
1340
1341        assert_eq!(map.dynamic_level(0), 1);
1342        assert_eq!(
1343            collision_groups(&world, map.dynamic_bodies[0]).memberships,
1344            Group::GROUP_2
1345        );
1346    }
1347
1348    #[test]
1349    fn has_floor_reports_slab_and_ground() {
1350        let mut world = make_world();
1351        let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
1352
1353        assert!(map.has_floor(0, 10.0, 10.0));
1354        assert!(!map.has_floor(0, -5.0, 10.0));
1355        assert!(!map.has_floor(0, 70.0, 10.0));
1356
1357        assert!(map.has_floor(1, 30.0, 30.0));
1358        assert!(map.has_floor(1, 50.0, 30.0));
1359        assert!(!map.has_floor(1, 10.0, 10.0));
1360    }
1361
1362    #[test]
1363    fn level_at_picks_highest_floor() {
1364        let mut world = make_world();
1365        let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
1366
1367        assert_eq!(map.level_at(30.0, 30.0), 1);
1368        assert_eq!(map.level_at(10.0, 30.0), 0);
1369    }
1370
1371    #[test]
1372    fn ramp_progress_runs_from_bottom_to_top() {
1373        let mut world = make_world();
1374        let north = GameMap::create(&mut world, &ramp_config("north"), 1.0, "set");
1375
1376        // подъём на север: подножие внизу (y = 60), вершина вверху (y = 0)
1377        assert!(north.ramp_at(10.0, 59.0).unwrap().progress < 0.05);
1378        assert!(north.ramp_at(10.0, 1.0).unwrap().progress > 0.95);
1379        assert!((north.ramp_at(10.0, 30.0).unwrap().progress - 0.5).abs() < 0.01);
1380        assert_eq!(north.ramp_at(10.0, 30.0).unwrap().to, 1);
1381        assert!(north.ramp_at(30.0, 30.0).is_none());
1382
1383        let mut world = make_world();
1384        let south = GameMap::create(&mut world, &ramp_config("south"), 1.0, "set");
1385
1386        assert!(south.ramp_at(10.0, 1.0).unwrap().progress < 0.05);
1387        assert!(south.ramp_at(10.0, 59.0).unwrap().progress > 0.95);
1388    }
1389
1390    #[test]
1391    fn ramp_runs_are_split_per_line() {
1392        let mut cfg = ramp_config("north");
1393
1394        // тот же тайл рампы в двух соседних колонках
1395        for row in &mut cfg.map {
1396            row[1] = 7;
1397        }
1398
1399        let mut world = make_world();
1400        let map = GameMap::create(&mut world, &cfg, 1.0, "set");
1401        let runs = map.levels().runs();
1402
1403        assert_eq!(runs.len(), 2);
1404        assert_eq!((runs[0].min, runs[0].max), (0.0, 60.0));
1405        assert_eq!((runs[1].min, runs[1].max), (0.0, 60.0));
1406        assert_ne!(runs[0].cross_min, runs[1].cross_min);
1407    }
1408
1409    #[test]
1410    fn respawn_accepts_three_and_four_numbers() {
1411        let cfg: MapConfig = serde_json::from_value(serde_json::json!({
1412            "step": 20.0,
1413            "map": [[0, 0], [0, 0]],
1414            "levels": { "1": { "map": [[0, 0], [0, 0]], "floor": [] } },
1415            "respawns": { "team1": [[10.0, 20.0, 0.0], [30.0, 40.0, 90.0, 1.0]] }
1416        }))
1417        .unwrap();
1418
1419        cfg.validate().unwrap();
1420
1421        let mut world = make_world();
1422        let map = GameMap::create(&mut world, &cfg, 2.0, "set");
1423        let points = &map.respawns["team1"];
1424
1425        assert_eq!(points[0], vec![20.0, 40.0, 0.0]);
1426        assert_eq!(points[1], vec![60.0, 80.0, 90.0, 1.0]);
1427    }
1428
1429    #[test]
1430    fn validate_rejects_mismatched_level_grid() {
1431        let mut cfg = layered_config();
1432
1433        cfg.levels["1"].map.pop();
1434
1435        let error = cfg.validate().unwrap_err();
1436
1437        assert!(error.contains("rows"), "{error}");
1438    }
1439
1440    #[test]
1441    fn validate_rejects_railing_outside_floor() {
1442        let mut cfg = layered_config();
1443
1444        cfg.levels["1"].floor = vec![5];
1445
1446        let error = cfg.validate().unwrap_err();
1447
1448        assert!(error.contains("floor"), "{error}");
1449    }
1450
1451    #[test]
1452    fn validate_rejects_unknown_ramp_tile() {
1453        let mut cfg = ramp_config("north");
1454
1455        cfg.ramps[0].tile = 99;
1456
1457        let error = cfg.validate().unwrap_err();
1458
1459        assert!(error.contains("missing"), "{error}");
1460    }
1461
1462    // Общий с JS-правилом E4 корпус кейсов: одна и та же карта и один и тот
1463    // же ожидаемый фрагмент сообщения. Тексты у двух реализаций разные,
1464    // фрагмент — то, о чём они договорились; разойдётся одна из них —
1465    // покраснеет здесь, а не молча на живой карте.
1466    #[test]
1467    fn shared_layered_fixtures() {
1468        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1469            .join("../contract/fixtures/layered");
1470
1471        let mut checked = 0;
1472
1473        for entry in std::fs::read_dir(&dir).expect("fixtures dir") {
1474            let path = entry.unwrap().path();
1475
1476            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1477                continue;
1478            }
1479
1480            let name = path.file_stem().unwrap().to_string_lossy().to_string();
1481            let doc: serde_json::Value =
1482                serde_json::from_str(&std::fs::read_to_string(&path).unwrap())
1483                    .unwrap_or_else(|e| panic!("{name}: {e}"));
1484
1485            let cfg: MapConfig = serde_json::from_value(doc["map"].clone())
1486                .unwrap_or_else(|e| panic!("{name}: {e}"));
1487
1488            let result = cfg.validate();
1489
1490            match doc.get("expect").and_then(|v| v.as_str()) {
1491                None => assert!(result.is_ok(), "{name}: {result:?}"),
1492                Some(fragment) => {
1493                    let error = result.expect_err(&format!("{name}: expected an error"));
1494
1495                    assert!(error.contains(fragment), "{name}: got {error}");
1496                }
1497            }
1498
1499            checked += 1;
1500        }
1501
1502        assert!(checked > 1, "корпус фикстур не прочитан: {checked}");
1503    }
1504
1505    #[test]
1506    fn validate_rejects_level_out_of_range() {
1507        let cfg: MapConfig = serde_json::from_value(serde_json::json!({
1508            "step": 20.0,
1509            "map": [[0, 0], [0, 0]],
1510            "respawns": { "team1": [[10.0, 20.0, 0.0, 5.0]] }
1511        }))
1512        .unwrap();
1513
1514        let error = cfg.validate().unwrap_err();
1515
1516        assert!(error.contains("out of range"), "{error}");
1517    }
1518
1519    #[test]
1520    fn dynamic_body_gets_soft_ccd_prediction_of_thickness() {
1521        let mut world = make_world();
1522        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
1523
1524        let prediction = world.bodies[map.dynamic_bodies[0]].soft_ccd_prediction();
1525
1526        // без предсказания контакт рождается уже по факту перекрытия: за шаг
1527        // 1/120 тело проходит на порядки больше дефолтных 0.002 юнита
1528        assert_eq!(prediction, 20.0);
1529    }
1530
1531    #[test]
1532    fn soft_ccd_prediction_keeps_penetration_shallow() {
1533        let mut world = make_world();
1534        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
1535        let handle = map.dynamic_bodies[0];
1536
1537        let predicted = max_penetration(&mut world, handle);
1538
1539        let mut plain_world = make_world();
1540        let plain_map = GameMap::create(&mut plain_world, &map_config(), 1.0, "set");
1541        let plain_handle = plain_map.dynamic_bodies[0];
1542
1543        plain_world.bodies[plain_handle].set_soft_ccd_prediction(0.0);
1544
1545        let plain = max_penetration(&mut plain_world, plain_handle);
1546
1547        assert!(
1548            plain > 5.0,
1549            "без предсказания ожидалось глубокое перекрытие, получено {plain}"
1550        );
1551        assert!(predicted < 2.0, "с предсказанием перекрытие {predicted}");
1552    }
1553}