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
136impl MapConfig {
137    /// Валидация слоёных полей. Любая из этих ошибок в рантайме молчит:
138    /// карта с рассинхроном размерностей гридов даёт танк, проваливающийся
139    /// в пустоту, и ни одной строки в консоли.
140    pub fn validate(&self) -> Result<(), String> {
141        let rows = self.map.len();
142        let level_count = self.levels.len() + 1;
143
144        if !self.levels.is_empty() {
145            if level_count > MAX_LEVELS {
146                return Err(format!(
147                    "map levels: {level_count} levels, at most {MAX_LEVELS} supported"
148                ));
149            }
150
151            let mut keys: Vec<u8> = Vec::with_capacity(self.levels.len());
152
153            for key in self.levels.keys() {
154                let level: u8 = key
155                    .parse()
156                    .map_err(|_| format!("map levels: key '{key}' is not a level number"))?;
157
158                if level == 0 {
159                    return Err(
160                        "map levels: level 0 lives in map/physicsStatic, not in levels".to_string(),
161                    );
162                }
163
164                keys.push(level);
165            }
166
167            keys.sort_unstable();
168
169            for (index, &level) in keys.iter().enumerate() {
170                if level as usize != index + 1 {
171                    return Err(format!(
172                        "map levels: levels must run from 1 without gaps, got {level} at position {}",
173                        index + 1
174                    ));
175                }
176            }
177        }
178
179        for (key, level) in &self.levels {
180            if level.map.len() != rows {
181                return Err(format!(
182                    "map levels: level {key} grid has {} rows, map has {rows}",
183                    level.map.len()
184                ));
185            }
186
187            for (y, row) in level.map.iter().enumerate() {
188                let expected = self.map[y].len();
189
190                if row.len() != expected {
191                    return Err(format!(
192                        "map levels: level {key} row {y} has {} cells, map has {expected}",
193                        row.len()
194                    ));
195                }
196            }
197
198            for tile in &level.walls {
199                if !level.floor.contains(tile) {
200                    return Err(format!(
201                        "map levels: level {key} wall tile {tile} is not part of floor"
202                    ));
203                }
204            }
205        }
206
207        for (index, ramp) in self.ramps.iter().enumerate() {
208            if ramp.from == ramp.to {
209                return Err(format!("map ramps: ramp {index} goes from level {} to itself", ramp.from));
210            }
211
212            if (ramp.from as usize) >= level_count || (ramp.to as usize) >= level_count {
213                return Err(format!(
214                    "map ramps: ramp {index} references level out of range (levels: {level_count})"
215                ));
216            }
217
218            let grid = if ramp.from == 0 {
219                Some(&self.map)
220            } else {
221                self.levels.get(&ramp.from.to_string()).map(|level| &level.map)
222            };
223
224            let found = grid.is_some_and(|grid| {
225                grid.iter().any(|row| row.contains(&ramp.tile))
226            });
227
228            if !found {
229                return Err(format!(
230                    "map ramps: ramp {index} tile {} is missing from level {} grid",
231                    ramp.tile, ramp.from
232                ));
233            }
234        }
235
236        for (team, points) in &self.respawns {
237            for (index, point) in points.iter().enumerate() {
238                if point.len() != 3 && point.len() != 4 {
239                    return Err(format!(
240                        "map respawns: {team}[{index}] has {} numbers, expected 3 or 4",
241                        point.len()
242                    ));
243                }
244
245                if point.len() == 4 && (point[3] as usize) >= level_count {
246                    return Err(format!(
247                        "map respawns: {team}[{index}] level {} is out of range (levels: {level_count})",
248                        point[3]
249                    ));
250                }
251            }
252        }
253
254        for (index, object) in self.physics_dynamic.iter().enumerate() {
255            if (object.level as usize) >= level_count {
256                return Err(format!(
257                    "map physicsDynamic: object {index} level {} is out of range (levels: {level_count})",
258                    object.level
259                ));
260            }
261        }
262
263        Ok(())
264    }
265}
266
267/// Битовая маска слоя для InteractionGroups. Уровень 0 — GROUP_1,
268/// уровень 1 — GROUP_2. Тела за пределами реестра (не выставившие группы)
269/// остаются в `Group::ALL` и поэтому продолжают взаимодействовать с
270/// уровнем 0 — старые карты и старые игры не замечают появления слоёв.
271pub fn level_group(level: u8) -> Group {
272    match level {
273        0 => Group::GROUP_1,
274        _ => Group::GROUP_2,
275    }
276}
277
278/// Маска «я на уровне `level` и вижу только его».
279pub fn level_interaction(level: u8) -> InteractionGroups {
280    let group = level_group(level);
281
282    InteractionGroups::new(group, group, InteractionTestMode::And)
283}
284
285/// Маска «я вижу все уровни из `mask`» (танк на рампе).
286pub fn levels_interaction(mask: Group) -> InteractionGroups {
287    InteractionGroups::new(mask, mask, InteractionTestMode::And)
288}
289
290/// Результат попадания точки на рампу.
291#[derive(Clone, Copy, Debug, PartialEq)]
292pub struct RampSample {
293    /// 0.0 у подножия прогона, 1.0 на его верхней кромке.
294    pub progress: f32,
295    pub from: u8,
296    pub to: u8,
297}
298
299/// Прогон рампы: максимальная непрерывная линия тайлов одной рампы вдоль её
300/// оси в одной строке (ось x) или колонке (ось y).
301#[derive(Clone, Serialize, Deserialize)]
302pub struct RampRun {
303    /// 0 = x, 1 = y.
304    pub axis: u8,
305    /// +1 — подъём в сторону роста координаты.
306    pub sign: i8,
307    pub from: u8,
308    pub to: u8,
309    /// Границы прогона по своей оси в МИРОВЫХ (масштабированных) единицах.
310    pub min: f32,
311    pub max: f32,
312    /// Границы прогона поперёк своей оси (нужны нав-графу, чтобы поставить
313    /// узлы у подножия и на вершине по центру прогона).
314    pub cross_min: f32,
315    pub cross_max: f32,
316}
317
318/// Слоистая геометрия карты без физического мира: гриды уровней, списки
319/// сплошных тайлов, прогоны рамп. Хост держит её внутри `GameMap`,
320/// клиентская реплика игры строит из полей MAP_DATA — правила уровней у
321/// обеих сторон читаются отсюда и только отсюда.
322#[derive(Clone, Default, Serialize, Deserialize)]
323pub struct MapLevels {
324    /// Гриды по индексу уровня: [0] — земля, [1] — эстакада.
325    grids: Vec<Vec<Vec<i32>>>,
326    /// Тайлы-стены по уровням (для 0 — `physicsStatic`, для N — `walls`).
327    solid: Vec<Vec<i32>>,
328    /// Тайлы-пол по уровням (для 0 — пусто: земля есть везде внутри карты).
329    floor: Vec<Vec<i32>>,
330    runs: Vec<RampRun>,
331    /// Параллелен `grids[0]`: индекс прогона рампы в клетке либо -1.
332    run_cells: Vec<Vec<i16>>,
333    /// Размер тайла в МИРОВЫХ единицах (step * scale).
334    tile_size: f32,
335}
336
337impl MapLevels {
338    /// `grid0`/`solid0` — грид и стены уровня 0; `levels` — конфиги
339    /// надземных уровней (ключ — номер строкой); `ramps` — конфиги рамп;
340    /// `tile_size` — УЖЕ масштабированный размер тайла.
341    pub fn build(
342        grid0: &[Vec<i32>],
343        solid0: &[i32],
344        levels: &IndexMap<String, MapLevelConfig>,
345        ramps: &[RampConfig],
346        tile_size: f32,
347    ) -> Self {
348        let mut out = Self {
349            grids: vec![grid0.to_vec()],
350            solid: vec![solid0.to_vec()],
351            floor: vec![Vec::new()],
352            runs: Vec::new(),
353            run_cells: grid0.iter().map(|row| vec![-1i16; row.len()]).collect(),
354            tile_size,
355        };
356
357        // порядок уровней — по числовому ключу, а не по порядку в JSON:
358        // индекс в `grids` обязан совпадать с номером уровня
359        let mut ordered: Vec<(u8, &MapLevelConfig)> = levels
360            .iter()
361            .filter_map(|(key, level)| key.parse::<u8>().ok().map(|index| (index, level)))
362            .collect();
363
364        ordered.sort_by_key(|(index, _)| *index);
365
366        for (index, level) in ordered {
367            if index as usize != out.grids.len() {
368                continue;
369            }
370
371            out.grids.push(level.map.clone());
372            out.solid.push(level.walls.clone());
373            out.floor.push(level.floor.clone());
374        }
375
376        out.build_runs(ramps);
377
378        out
379    }
380
381    // прогоны рамп: непрерывные линии одинаковых тайлов вдоль оси рампы
382    fn build_runs(&mut self, ramps: &[RampConfig]) {
383        for ramp in ramps {
384            let (axis, sign) = ramp.dir.axis_sign();
385            let Some(grid) = self.grid(ramp.from) else {
386                continue;
387            };
388            let grid = grid.clone();
389            let rows = grid.len();
390
391            if axis == 1 {
392                let cols = grid.iter().map(|row| row.len()).max().unwrap_or(0);
393
394                for x in 0..cols {
395                    let mut y = 0;
396
397                    while y < rows {
398                        if grid[y].get(x) != Some(&ramp.tile) {
399                            y += 1;
400                            continue;
401                        }
402
403                        let y0 = y;
404
405                        while y < rows && grid[y].get(x) == Some(&ramp.tile) {
406                            y += 1;
407                        }
408
409                        self.push_run(ramp, axis, sign, y0, y, x, x + 1);
410                    }
411                }
412            } else {
413                for (y, row) in grid.iter().enumerate() {
414                    let mut x = 0;
415
416                    while x < row.len() {
417                        if row[x] != ramp.tile {
418                            x += 1;
419                            continue;
420                        }
421
422                        let x0 = x;
423
424                        while x < row.len() && row[x] == ramp.tile {
425                            x += 1;
426                        }
427
428                        self.push_run(ramp, axis, sign, x0, x, y, y + 1);
429                    }
430                }
431            }
432        }
433    }
434
435    // `from`..`to` — границы прогона по своей оси в клетках,
436    // `cross0`..`cross1` — поперёк; клетки, уже занятые прогоном,
437    // пропускаются (первая объявленная рампа выигрывает — детерминированно)
438    #[allow(clippy::too_many_arguments)]
439    fn push_run(
440        &mut self,
441        ramp: &RampConfig,
442        axis: u8,
443        sign: i8,
444        from: usize,
445        to: usize,
446        cross0: usize,
447        cross1: usize,
448    ) {
449        if self.runs.len() >= i16::MAX as usize {
450            return;
451        }
452
453        let index = self.runs.len() as i16;
454        let mut claimed = false;
455
456        for main in from..to {
457            for cross in cross0..cross1 {
458                let (x, y) = if axis == 1 { (cross, main) } else { (main, cross) };
459
460                let Some(cell) = self.run_cells.get_mut(y).and_then(|row| row.get_mut(x)) else {
461                    continue;
462                };
463
464                if *cell < 0 {
465                    *cell = index;
466                    claimed = true;
467                }
468            }
469        }
470
471        if !claimed {
472            return;
473        }
474
475        let size = self.tile_size;
476
477        self.runs.push(RampRun {
478            axis,
479            sign,
480            from: ramp.from,
481            to: ramp.to,
482            min: from as f32 * size,
483            max: to as f32 * size,
484            cross_min: cross0 as f32 * size,
485            cross_max: cross1 as f32 * size,
486        });
487    }
488
489    /// Есть ли надземные уровни (2.5D-режим).
490    pub fn is_layered(&self) -> bool {
491        self.grids.len() > 1
492    }
493
494    /// Число уровней, включая землю (1 у обычной карты).
495    pub fn level_count(&self) -> usize {
496        self.grids.len().max(1)
497    }
498
499    pub fn tile_size(&self) -> f32 {
500        self.tile_size
501    }
502
503    pub fn grid(&self, level: u8) -> Option<&Vec<Vec<i32>>> {
504        self.grids.get(level as usize)
505    }
506
507    pub fn solid(&self, level: u8) -> &[i32] {
508        self.solid.get(level as usize).map_or(&[], |list| list)
509    }
510
511    pub fn floor(&self, level: u8) -> &[i32] {
512        self.floor.get(level as usize).map_or(&[], |list| list)
513    }
514
515    pub fn runs(&self) -> &[RampRun] {
516        &self.runs
517    }
518
519    /// (колонка, строка) по мировой точке; None вне карты.
520    pub fn cell_at(&self, x: f32, y: f32) -> Option<(usize, usize)> {
521        if self.tile_size <= 0.0 || x < 0.0 || y < 0.0 {
522            return None;
523        }
524
525        let cx = (x / self.tile_size).floor() as usize;
526        let cy = (y / self.tile_size).floor() as usize;
527        let row = self.grids.first()?.get(cy)?;
528
529        if cx >= row.len() { None } else { Some((cx, cy)) }
530    }
531
532    /// Есть ли пол уровня `level` в точке. Для уровня 0 — true везде внутри
533    /// карты (под мостом земля никуда не девается).
534    pub fn has_floor(&self, level: u8, x: f32, y: f32) -> bool {
535        let Some((cx, cy)) = self.cell_at(x, y) else {
536            return false;
537        };
538
539        if level == 0 {
540            return true;
541        }
542
543        let Some(grid) = self.grid(level) else {
544            return false;
545        };
546        let Some(&tile) = grid.get(cy).and_then(|row| row.get(cx)) else {
547            return false;
548        };
549
550        self.floor(level).contains(&tile)
551    }
552
553    /// Стена уровня `level` в точке.
554    pub fn is_solid(&self, level: u8, x: f32, y: f32) -> bool {
555        let Some((cx, cy)) = self.cell_at(x, y) else {
556            return false;
557        };
558        let Some(grid) = self.grid(level) else {
559            return false;
560        };
561        let Some(&tile) = grid.get(cy).and_then(|row| row.get(cx)) else {
562            return false;
563        };
564
565        self.solid(level).contains(&tile)
566    }
567
568    /// Наивысший уровень, у которого есть пол в точке. Спавн без явного
569    /// уровня приземляется сюда.
570    pub fn level_at(&self, x: f32, y: f32) -> u8 {
571        let mut level = 0;
572
573        for candidate in (1..self.level_count() as u8).rev() {
574            if self.has_floor(candidate, x, y) {
575                level = candidate;
576                break;
577            }
578        }
579
580        level
581    }
582
583    /// Рампа под точкой.
584    pub fn ramp_at(&self, x: f32, y: f32) -> Option<RampSample> {
585        let (cx, cy) = self.cell_at(x, y)?;
586        let index = *self.run_cells.get(cy)?.get(cx)?;
587
588        if index < 0 {
589            return None;
590        }
591
592        let run = &self.runs[index as usize];
593        let value = if run.axis == 0 { x } else { y };
594        let span = run.max - run.min;
595        let raw = if span <= 0.0 { 0.0 } else { (value - run.min) / span };
596        let progress = if run.sign > 0 { raw } else { 1.0 - raw };
597
598        Some(RampSample {
599            progress: progress.clamp(0.0, 1.0),
600            from: run.from,
601            to: run.to,
602        })
603    }
604}
605
606/// Карта в мире: порт физической части src/server/parts/Map.js +
607/// масштабирование из RoundManager.createMap (scaleMapData).
608#[derive(Serialize, Deserialize)]
609pub struct GameMap {
610    pub set_id: String,
611    /// Размер тайла после масштабирования.
612    pub step: f32,
613    /// Сетка тайлов (немасштабируемая) — источник нав-сетки ботов.
614    pub grid: Vec<Vec<i32>>,
615    pub physics_static: Vec<i32>,
616    /// Респауны по командам (масштабированные) — [x, y, angle°] либо
617    /// [x, y, angle°, level].
618    pub respawns: IndexMap<String, Vec<Vec<f32>>>,
619    /// Слоистая геометрия (одноуровневая у карты без `levels`).
620    levels: MapLevels,
621    static_bodies: Vec<RigidBodyHandle>,
622    /// Уровень каждого статического тела, параллелен `static_bodies`.
623    static_levels: Vec<u8>,
624    dynamic_bodies: Vec<RigidBodyHandle>,
625    /// Уровень каждого динамического тела, параллелен `dynamic_bodies`.
626    dynamic_levels: Vec<u8>,
627}
628
629impl GameMap {
630    /// Создаёт тела карты в мире. `default_scale`/`default_set_id` —
631    /// значения конфига, перекрываемые полями самой карты.
632    pub fn create(
633        world: &mut PhysicsWorld,
634        cfg: &MapConfig,
635        default_scale: f32,
636        default_set_id: &str,
637    ) -> Self {
638        let scale = cfg.scale.unwrap_or(default_scale);
639        let step = cfg.step * scale;
640        let levels = MapLevels::build(
641            &cfg.map,
642            &cfg.physics_static,
643            &cfg.levels,
644            &cfg.ramps,
645            step,
646        );
647
648        let mut map = Self {
649            set_id: cfg
650                .set_id
651                .clone()
652                .unwrap_or_else(|| default_set_id.to_string()),
653            step,
654            grid: cfg.map.clone(),
655            physics_static: cfg.physics_static.clone(),
656            respawns: cfg
657                .respawns
658                .iter()
659                .map(|(team, arr)| {
660                    (
661                        team.clone(),
662                        arr.iter()
663                            .map(|point| {
664                                let mut out = point.clone();
665
666                                if out.len() >= 2 {
667                                    out[0] *= scale;
668                                    out[1] *= scale;
669                                }
670
671                                out
672                            })
673                            .collect(),
674                    )
675                })
676                .collect(),
677            levels,
678            static_bodies: Vec::new(),
679            static_levels: Vec::new(),
680            dynamic_bodies: Vec::new(),
681            dynamic_levels: Vec::new(),
682        };
683
684        map.create_static(world);
685        map.create_dynamic(world, &cfg.physics_dynamic, scale);
686
687        map
688    }
689
690    /// Статические стены (Map.createStatic) — по уровням, в порядке 0, 1, …
691    /// Внутри уровня обход прежний (строки сверху вниз, колонки слева
692    /// направо), поэтому у одноуровневой карты порядок вставки тел
693    /// не меняется вовсе.
694    fn create_static(&mut self, world: &mut PhysicsWorld) {
695        for level in 0..self.levels.level_count() as u8 {
696            let Some(grid) = self.levels.grid(level) else {
697                continue;
698            };
699            let solid = self.levels.solid(level).to_vec();
700
701            let mut work: Vec<Vec<Option<i32>>> = grid
702                .iter()
703                .map(|row| row.iter().map(|&tile| Some(tile)).collect())
704                .collect();
705
706            for y in 0..work.len() {
707                for x in 0..work[y].len() {
708                    let is_static = work[y][x].is_some_and(|tile| solid.contains(&tile));
709
710                    if is_static {
711                        let (width, height) =
712                            search_static_block(&mut work, &solid, self.step, y, x);
713                        let pos_x = x as f32 * self.step + width / 2.0;
714                        let pos_y = y as f32 * self.step + height / 2.0;
715
716                        let body = world.insert_body(
717                            RigidBodyBuilder::fixed().translation(Vector::new(pos_x, pos_y)),
718                        );
719
720                        world.insert_collider(
721                            ColliderBuilder::cuboid(width / 2.0, height / 2.0)
722                                .friction(DEFAULT_FRICTION)
723                                .restitution(DEFAULT_RESTITUTION)
724                                .collision_groups(level_interaction(level)),
725                            Some(body),
726                        );
727
728                        self.static_bodies.push(body);
729                        self.static_levels.push(level);
730                    }
731                }
732            }
733        }
734    }
735
736    /// Динамические элементы (Map.createDynamic).
737    fn create_dynamic(
738        &mut self,
739        world: &mut PhysicsWorld,
740        dynamics: &[DynamicObjectConfig],
741        scale: f32,
742    ) {
743        for data in dynamics {
744            let pos_x = data.position[0] * scale;
745            let pos_y = data.position[1] * scale;
746            let width = data.width * scale;
747            let height = data.height * scale;
748
749            let body = world.insert_body(
750                RigidBodyBuilder::dynamic()
751                    .translation(Vector::new(pos_x, pos_y))
752                    .rotation(deg_to_rad(data.angle))
753                    .linear_damping(data.linear_damping.unwrap_or(0.0))
754                    .angular_damping(data.angular_damping.unwrap_or(0.01))
755                    // предиктивные контакты на толщину ящика — та же причина,
756                    // что и у танка: дефолтная дистанция предсказания Rapier
757                    // рассчитана на метры и на порядки меньше пути тела за шаг
758                    .soft_ccd_prediction(width.min(height))
759                    .user_data(encode_map_object()),
760            );
761
762            // коллайдер со смещённым центром (позиция тела — угол объекта)
763            world.insert_collider(
764                ColliderBuilder::cuboid(width / 2.0, height / 2.0)
765                    .translation(Vector::new(width / 2.0, height / 2.0))
766                    .density(data.density)
767                    .friction(DEFAULT_FRICTION)
768                    .restitution(DEFAULT_RESTITUTION)
769                    .collision_groups(level_interaction(data.level)),
770                Some(body),
771            );
772
773            self.dynamic_bodies.push(body);
774            self.dynamic_levels.push(data.level);
775        }
776    }
777
778    // ***** слоистая геометрия ***** //
779
780    pub fn levels(&self) -> &MapLevels {
781        &self.levels
782    }
783
784    pub fn is_layered(&self) -> bool {
785        self.levels.is_layered()
786    }
787
788    pub fn level_count(&self) -> usize {
789        self.levels.level_count()
790    }
791
792    pub fn level_at(&self, x: f32, y: f32) -> u8 {
793        self.levels.level_at(x, y)
794    }
795
796    pub fn has_floor(&self, level: u8, x: f32, y: f32) -> bool {
797        self.levels.has_floor(level, x, y)
798    }
799
800    pub fn ramp_at(&self, x: f32, y: f32) -> Option<RampSample> {
801        self.levels.ramp_at(x, y)
802    }
803
804    /// Уровень динамического тела по его индексу в блоке снапшота.
805    pub fn dynamic_level(&self, index: usize) -> u8 {
806        self.dynamic_levels.get(index).copied().unwrap_or(0)
807    }
808
809    /// Уровни статических тел (отладочный дамп).
810    pub fn static_levels(&self) -> &[u8] {
811        &self.static_levels
812    }
813
814    /// Уровни динамических тел (отладочный дамп).
815    pub fn dynamic_levels(&self) -> &[u8] {
816        &self.dynamic_levels
817    }
818
819    // ***** отладочный дамп (crate::debug) ***** //
820
821    pub fn static_body_count(&self) -> usize {
822        self.static_bodies.len()
823    }
824
825    pub fn dynamic_body_count(&self) -> usize {
826        self.dynamic_bodies.len()
827    }
828
829    /// Удаляет все тела карты из мира (Map.destroyMap).
830    pub fn destroy(&mut self, world: &mut PhysicsWorld) {
831        for handle in self.static_bodies.drain(..) {
832            world.remove_body(handle);
833        }
834
835        for handle in self.dynamic_bodies.drain(..) {
836            world.remove_body(handle);
837        }
838
839        self.static_levels.clear();
840        self.dynamic_levels.clear();
841    }
842
843    /// Краткие данные динамических элементов (Map.getDynamicMapData):
844    /// индекс → [x, y, angle] (как поля строки блока), значения скруглены
845    /// до 2 знаков.
846    ///
847    /// При `with_velocities` движущееся тело отдаёт ещё и хвост
848    /// [vx, vy, angvel] (кадр v4, `optionalFrom` в схеме блока): клиент
849    /// предсказывает динамику карты рядом со своим танком, а оценка скорости
850    /// конечной разностью между кадрами 30 Гц сильнее всего врёт именно в
851    /// момент удара. Покоящееся тело хвост не шлёт.
852    pub fn dynamic_map_data(
853        &self,
854        world: &PhysicsWorld,
855        with_velocities: bool,
856    ) -> Vec<(u8, Vec<FieldValue>)> {
857        self.dynamic_bodies
858            .iter()
859            .enumerate()
860            .filter_map(|(index, &handle)| {
861                world.bodies.get(handle).map(|body| {
862                    let pos = body.translation();
863
864                    let mut fields = vec![
865                        FieldValue::F32(round2(pos.x)),
866                        FieldValue::F32(round2(pos.y)),
867                        FieldValue::F32(round2(body.rotation().angle())),
868                    ];
869
870                    if with_velocities {
871                        let linvel = body.linvel();
872                        let angvel = body.angvel();
873                        let resting = body.is_sleeping()
874                            || (linvel.x.hypot(linvel.y) < REST_VELOCITY_EPSILON
875                                && angvel.abs() < REST_VELOCITY_EPSILON);
876
877                        if !resting {
878                            fields.push(FieldValue::F32(round2(linvel.x)));
879                            fields.push(FieldValue::F32(round2(linvel.y)));
880                            fields.push(FieldValue::F32(round2(angvel)));
881                        }
882                    }
883
884                    (index as u8, fields)
885                })
886            })
887            .collect()
888    }
889}
890
891/// Жадный поиск прямоугольного блока стен (Map.searchStaticBlock).
892/// Обработанные ячейки затираются в рабочей копии сетки. Список сплошных
893/// тайлов свой у каждого уровня, поэтому функция свободная, а не метод.
894fn search_static_block(
895    work: &mut [Vec<Option<i32>>],
896    solid: &[i32],
897    step: f32,
898    y0: usize,
899    x0: usize,
900) -> (f32, f32) {
901    let mut x = x0;
902    let mut w_counter = 0;
903    let mut h_counter = 1;
904
905    // ширина блока
906    while x < work[y0].len() && work[y0][x].is_some_and(|tile| solid.contains(&tile)) {
907        work[y0][x] = None;
908        x += 1;
909        w_counter += 1;
910    }
911
912    let len_x = x;
913    let len_y = work.len();
914
915    // высота блока
916    for y in (y0 + 1)..len_y {
917        let mut empty_tile = false;
918        let mut x = x0;
919
920        while x < len_x {
921            if x < work[y].len() && work[y][x].is_some_and(|tile| solid.contains(&tile)) {
922                x += 1;
923            } else {
924                empty_tile = true;
925                break;
926            }
927        }
928
929        if empty_tile {
930            break;
931        }
932
933        h_counter += 1;
934
935        for cell in work[y][x0..len_x].iter_mut() {
936            *cell = None;
937        }
938    }
939
940    (w_counter as f32 * step, h_counter as f32 * step)
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    const TIME_STEP: f32 = 1.0 / 120.0;
948
949    // сплошная стена в верхнем ряду (5 тайлов по 20) и один ящик 20×20 под ней
950    fn map_config() -> MapConfig {
951        serde_json::from_value(serde_json::json!({
952            "step": 20.0,
953            "map": [[1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
954            "physicsStatic": [1],
955            "physicsDynamic": [{
956                "position": [40.0, 60.0],
957                "angle": 0.0,
958                "width": 20.0,
959                "height": 20.0,
960                "density": 1.0
961            }]
962        }))
963        .unwrap()
964    }
965
966    fn make_world() -> PhysicsWorld {
967        let mut world = PhysicsWorld::new();
968
969        world.gravity = Vector::ZERO;
970        world.integration_parameters.dt = TIME_STEP;
971
972        world
973    }
974
975    // глубина самого глубокого перекрытия за прогон: ящик разгоняется в стену
976    fn max_penetration(world: &mut PhysicsWorld, body: RigidBodyHandle) -> f32 {
977        let mut depth: f32 = 0.0;
978
979        // ~17 юнитов за шаг — на порядки больше дефолтных 0.002 и сравнимо
980        // с толщиной ящика: без предсказания контакт рождается уже внутри
981        world.bodies[body].set_linvel(Vector::new(0.0, -2000.0), true);
982
983        for _ in 0..120 {
984            world.step();
985
986            for pair in world.contact_pairs() {
987                for manifold in &pair.manifolds {
988                    for point in &manifold.points {
989                        depth = depth.max(-point.dist);
990                    }
991                }
992            }
993        }
994
995        depth
996    }
997
998    // карта 3×3: стена уровня 0 в углу, плита уровня 1 в центре строки 1,
999    // перила — правый тайл плиты
1000    fn layered_config() -> MapConfig {
1001        serde_json::from_value(serde_json::json!({
1002            "step": 20.0,
1003            "map": [[1, 0, 0], [0, 0, 0], [0, 0, 0]],
1004            "physicsStatic": [1],
1005            "levels": {
1006                "1": {
1007                    "map": [[0, 0, 0], [0, 5, 6], [0, 0, 0]],
1008                    "floor": [5, 6],
1009                    "walls": [6]
1010                }
1011            }
1012        }))
1013        .unwrap()
1014    }
1015
1016    // вертикальная рампа 1×3 в колонке 0
1017    fn ramp_config(dir: &str) -> MapConfig {
1018        serde_json::from_value(serde_json::json!({
1019            "step": 20.0,
1020            "map": [[7, 0, 0], [7, 0, 0], [7, 0, 0]],
1021            "physicsStatic": [],
1022            "levels": {
1023                "1": { "map": [[0, 0, 0], [0, 0, 0], [0, 0, 0]], "floor": [] }
1024            },
1025            "ramps": [{ "tile": 7, "dir": dir }]
1026        }))
1027        .unwrap()
1028    }
1029
1030    fn collision_groups(world: &PhysicsWorld, body: RigidBodyHandle) -> InteractionGroups {
1031        let handle = world.bodies[body].colliders()[0];
1032
1033        world.colliders[handle].collision_groups()
1034    }
1035
1036    #[test]
1037    fn legacy_map_has_no_levels() {
1038        let mut world = make_world();
1039        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
1040
1041        assert!(!map.is_layered());
1042        assert_eq!(map.level_count(), 1);
1043        // стена 5×1 схлопывается в одно тело, как и до появления слоёв
1044        assert_eq!(map.static_body_count(), 1);
1045        assert_eq!(
1046            world.bodies[map.static_bodies[0]].translation(),
1047            Vector::new(50.0, 10.0)
1048        );
1049    }
1050
1051    #[test]
1052    fn layered_map_builds_static_per_level() {
1053        let mut world = make_world();
1054        let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
1055
1056        assert!(map.is_layered());
1057        assert_eq!(map.static_body_count(), 2);
1058        assert_eq!(map.static_levels(), &[0, 1]);
1059        assert_eq!(
1060            collision_groups(&world, map.static_bodies[0]).memberships,
1061            Group::GROUP_1
1062        );
1063        assert_eq!(
1064            collision_groups(&world, map.static_bodies[1]).memberships,
1065            Group::GROUP_2
1066        );
1067    }
1068
1069    #[test]
1070    fn dynamic_body_carries_level_group() {
1071        let mut cfg = layered_config();
1072
1073        cfg.physics_dynamic = vec![DynamicObjectConfig {
1074            position: [20.0, 20.0],
1075            angle: 0.0,
1076            width: 20.0,
1077            height: 20.0,
1078            density: 1.0,
1079            linear_damping: None,
1080            angular_damping: None,
1081            level: 1,
1082        }];
1083
1084        let mut world = make_world();
1085        let map = GameMap::create(&mut world, &cfg, 1.0, "set");
1086
1087        assert_eq!(map.dynamic_level(0), 1);
1088        assert_eq!(
1089            collision_groups(&world, map.dynamic_bodies[0]).memberships,
1090            Group::GROUP_2
1091        );
1092    }
1093
1094    #[test]
1095    fn has_floor_reports_slab_and_ground() {
1096        let mut world = make_world();
1097        let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
1098
1099        assert!(map.has_floor(0, 10.0, 10.0));
1100        assert!(!map.has_floor(0, -5.0, 10.0));
1101        assert!(!map.has_floor(0, 70.0, 10.0));
1102
1103        assert!(map.has_floor(1, 30.0, 30.0));
1104        assert!(map.has_floor(1, 50.0, 30.0));
1105        assert!(!map.has_floor(1, 10.0, 10.0));
1106    }
1107
1108    #[test]
1109    fn level_at_picks_highest_floor() {
1110        let mut world = make_world();
1111        let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
1112
1113        assert_eq!(map.level_at(30.0, 30.0), 1);
1114        assert_eq!(map.level_at(10.0, 30.0), 0);
1115    }
1116
1117    #[test]
1118    fn ramp_progress_runs_from_bottom_to_top() {
1119        let mut world = make_world();
1120        let north = GameMap::create(&mut world, &ramp_config("north"), 1.0, "set");
1121
1122        // подъём на север: подножие внизу (y = 60), вершина вверху (y = 0)
1123        assert!(north.ramp_at(10.0, 59.0).unwrap().progress < 0.05);
1124        assert!(north.ramp_at(10.0, 1.0).unwrap().progress > 0.95);
1125        assert!((north.ramp_at(10.0, 30.0).unwrap().progress - 0.5).abs() < 0.01);
1126        assert_eq!(north.ramp_at(10.0, 30.0).unwrap().to, 1);
1127        assert!(north.ramp_at(30.0, 30.0).is_none());
1128
1129        let mut world = make_world();
1130        let south = GameMap::create(&mut world, &ramp_config("south"), 1.0, "set");
1131
1132        assert!(south.ramp_at(10.0, 1.0).unwrap().progress < 0.05);
1133        assert!(south.ramp_at(10.0, 59.0).unwrap().progress > 0.95);
1134    }
1135
1136    #[test]
1137    fn ramp_runs_are_split_per_line() {
1138        let mut cfg = ramp_config("north");
1139
1140        // тот же тайл рампы в двух соседних колонках
1141        for row in &mut cfg.map {
1142            row[1] = 7;
1143        }
1144
1145        let mut world = make_world();
1146        let map = GameMap::create(&mut world, &cfg, 1.0, "set");
1147        let runs = map.levels().runs();
1148
1149        assert_eq!(runs.len(), 2);
1150        assert_eq!((runs[0].min, runs[0].max), (0.0, 60.0));
1151        assert_eq!((runs[1].min, runs[1].max), (0.0, 60.0));
1152        assert_ne!(runs[0].cross_min, runs[1].cross_min);
1153    }
1154
1155    #[test]
1156    fn respawn_accepts_three_and_four_numbers() {
1157        let cfg: MapConfig = serde_json::from_value(serde_json::json!({
1158            "step": 20.0,
1159            "map": [[0, 0], [0, 0]],
1160            "levels": { "1": { "map": [[0, 0], [0, 0]], "floor": [] } },
1161            "respawns": { "team1": [[10.0, 20.0, 0.0], [30.0, 40.0, 90.0, 1.0]] }
1162        }))
1163        .unwrap();
1164
1165        cfg.validate().unwrap();
1166
1167        let mut world = make_world();
1168        let map = GameMap::create(&mut world, &cfg, 2.0, "set");
1169        let points = &map.respawns["team1"];
1170
1171        assert_eq!(points[0], vec![20.0, 40.0, 0.0]);
1172        assert_eq!(points[1], vec![60.0, 80.0, 90.0, 1.0]);
1173    }
1174
1175    #[test]
1176    fn validate_rejects_mismatched_level_grid() {
1177        let mut cfg = layered_config();
1178
1179        cfg.levels["1"].map.pop();
1180
1181        let error = cfg.validate().unwrap_err();
1182
1183        assert!(error.contains("rows"), "{error}");
1184    }
1185
1186    #[test]
1187    fn validate_rejects_railing_outside_floor() {
1188        let mut cfg = layered_config();
1189
1190        cfg.levels["1"].floor = vec![5];
1191
1192        let error = cfg.validate().unwrap_err();
1193
1194        assert!(error.contains("floor"), "{error}");
1195    }
1196
1197    #[test]
1198    fn validate_rejects_unknown_ramp_tile() {
1199        let mut cfg = ramp_config("north");
1200
1201        cfg.ramps[0].tile = 99;
1202
1203        let error = cfg.validate().unwrap_err();
1204
1205        assert!(error.contains("missing"), "{error}");
1206    }
1207
1208    #[test]
1209    fn validate_rejects_level_out_of_range() {
1210        let cfg: MapConfig = serde_json::from_value(serde_json::json!({
1211            "step": 20.0,
1212            "map": [[0, 0], [0, 0]],
1213            "respawns": { "team1": [[10.0, 20.0, 0.0, 5.0]] }
1214        }))
1215        .unwrap();
1216
1217        let error = cfg.validate().unwrap_err();
1218
1219        assert!(error.contains("out of range"), "{error}");
1220    }
1221
1222    #[test]
1223    fn dynamic_body_gets_soft_ccd_prediction_of_thickness() {
1224        let mut world = make_world();
1225        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
1226
1227        let prediction = world.bodies[map.dynamic_bodies[0]].soft_ccd_prediction();
1228
1229        // без предсказания контакт рождается уже по факту перекрытия: за шаг
1230        // 1/120 тело проходит на порядки больше дефолтных 0.002 юнита
1231        assert_eq!(prediction, 20.0);
1232    }
1233
1234    #[test]
1235    fn soft_ccd_prediction_keeps_penetration_shallow() {
1236        let mut world = make_world();
1237        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
1238        let handle = map.dynamic_bodies[0];
1239
1240        let predicted = max_penetration(&mut world, handle);
1241
1242        let mut plain_world = make_world();
1243        let plain_map = GameMap::create(&mut plain_world, &map_config(), 1.0, "set");
1244        let plain_handle = plain_map.dynamic_bodies[0];
1245
1246        plain_world.bodies[plain_handle].set_soft_ccd_prediction(0.0);
1247
1248        let plain = max_penetration(&mut plain_world, plain_handle);
1249
1250        assert!(
1251            plain > 5.0,
1252            "без предсказания ожидалось глубокое перекрытие, получено {plain}"
1253        );
1254        assert!(predicted < 2.0, "с предсказанием перекрытие {predicted}");
1255    }
1256}