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/// Динамический объект карты (physicsDynamic из src/data/maps/*).
16#[derive(Clone, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct DynamicObjectConfig {
19    pub position: [f32; 2],
20    pub angle: f32,
21    pub width: f32,
22    pub height: f32,
23    pub density: f32,
24    #[serde(default)]
25    pub linear_damping: Option<f32>,
26    #[serde(default)]
27    pub angular_damping: Option<f32>,
28}
29
30/// JSON карты (src/data/maps/*.js, экспортированный в .json).
31/// Поля рендера (spriteSheet, layers) ядру не нужны и игнорируются.
32#[derive(Clone, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct MapConfig {
35    #[serde(default)]
36    pub set_id: Option<String>,
37    #[serde(default)]
38    pub scale: Option<f32>,
39    pub step: f32,
40    pub map: Vec<Vec<i32>>,
41    #[serde(default)]
42    pub physics_static: Vec<i32>,
43    #[serde(default)]
44    pub physics_dynamic: Vec<DynamicObjectConfig>,
45    #[serde(default)]
46    pub respawns: IndexMap<String, Vec<[f32; 3]>>,
47}
48
49/// Карта в мире: порт физической части src/server/parts/Map.js +
50/// масштабирование из RoundManager.createMap (scaleMapData).
51#[derive(Serialize, Deserialize)]
52pub struct GameMap {
53    pub set_id: String,
54    /// Размер тайла после масштабирования.
55    pub step: f32,
56    /// Сетка тайлов (немасштабируемая) — источник нав-сетки ботов.
57    pub grid: Vec<Vec<i32>>,
58    pub physics_static: Vec<i32>,
59    /// Респауны по командам (масштабированные) — [x, y, angle°].
60    pub respawns: IndexMap<String, Vec<[f32; 3]>>,
61    static_bodies: Vec<RigidBodyHandle>,
62    dynamic_bodies: Vec<RigidBodyHandle>,
63}
64
65impl GameMap {
66    /// Создаёт тела карты в мире. `default_scale`/`default_set_id` —
67    /// значения конфига, перекрываемые полями самой карты.
68    pub fn create(
69        world: &mut PhysicsWorld,
70        cfg: &MapConfig,
71        default_scale: f32,
72        default_set_id: &str,
73    ) -> Self {
74        let scale = cfg.scale.unwrap_or(default_scale);
75        let step = cfg.step * scale;
76
77        let mut map = Self {
78            set_id: cfg
79                .set_id
80                .clone()
81                .unwrap_or_else(|| default_set_id.to_string()),
82            step,
83            grid: cfg.map.clone(),
84            physics_static: cfg.physics_static.clone(),
85            respawns: cfg
86                .respawns
87                .iter()
88                .map(|(team, arr)| {
89                    (
90                        team.clone(),
91                        arr.iter()
92                            .map(|[x, y, angle]| [x * scale, y * scale, *angle])
93                            .collect(),
94                    )
95                })
96                .collect(),
97            static_bodies: Vec::new(),
98            dynamic_bodies: Vec::new(),
99        };
100
101        map.create_static(world);
102        map.create_dynamic(world, &cfg.physics_dynamic, scale);
103
104        map
105    }
106
107    /// Жадный поиск прямоугольного блока стен (Map.searchStaticBlock).
108    /// Обработанные ячейки затираются в рабочей копии сетки.
109    fn search_static_block(&self, work: &mut [Vec<Option<i32>>], y0: usize, x0: usize) -> (f32, f32) {
110        let mut x = x0;
111        let mut w_counter = 0;
112        let mut h_counter = 1;
113
114        // ширина блока
115        while x < work[y0].len()
116            && work[y0][x].is_some_and(|tile| self.physics_static.contains(&tile))
117        {
118            work[y0][x] = None;
119            x += 1;
120            w_counter += 1;
121        }
122
123        let len_x = x;
124        let len_y = work.len();
125
126        // высота блока
127        for y in (y0 + 1)..len_y {
128            let mut empty_tile = false;
129            let mut x = x0;
130
131            while x < len_x {
132                if x < work[y].len()
133                    && work[y][x].is_some_and(|tile| self.physics_static.contains(&tile))
134                {
135                    x += 1;
136                } else {
137                    empty_tile = true;
138                    break;
139                }
140            }
141
142            if empty_tile {
143                break;
144            }
145
146            h_counter += 1;
147
148            for cell in work[y][x0..len_x].iter_mut() {
149                *cell = None;
150            }
151        }
152
153        (w_counter as f32 * self.step, h_counter as f32 * self.step)
154    }
155
156    /// Статические стены (Map.createStatic).
157    fn create_static(&mut self, world: &mut PhysicsWorld) {
158        let mut work: Vec<Vec<Option<i32>>> = self
159            .grid
160            .iter()
161            .map(|row| row.iter().map(|&tile| Some(tile)).collect())
162            .collect();
163
164        for y in 0..work.len() {
165            for x in 0..work[y].len() {
166                let is_static = work[y][x].is_some_and(|tile| self.physics_static.contains(&tile));
167
168                if is_static {
169                    let (width, height) = self.search_static_block(&mut work, y, x);
170                    let pos_x = x as f32 * self.step + width / 2.0;
171                    let pos_y = y as f32 * self.step + height / 2.0;
172
173                    let body = world
174                        .insert_body(RigidBodyBuilder::fixed().translation(Vector::new(pos_x, pos_y)));
175
176                    world.insert_collider(
177                        ColliderBuilder::cuboid(width / 2.0, height / 2.0)
178                            .friction(DEFAULT_FRICTION)
179                            .restitution(DEFAULT_RESTITUTION),
180                        Some(body),
181                    );
182
183                    self.static_bodies.push(body);
184                }
185            }
186        }
187    }
188
189    /// Динамические элементы (Map.createDynamic).
190    fn create_dynamic(
191        &mut self,
192        world: &mut PhysicsWorld,
193        dynamics: &[DynamicObjectConfig],
194        scale: f32,
195    ) {
196        for data in dynamics {
197            let pos_x = data.position[0] * scale;
198            let pos_y = data.position[1] * scale;
199            let width = data.width * scale;
200            let height = data.height * scale;
201
202            let body = world.insert_body(
203                RigidBodyBuilder::dynamic()
204                    .translation(Vector::new(pos_x, pos_y))
205                    .rotation(deg_to_rad(data.angle))
206                    .linear_damping(data.linear_damping.unwrap_or(0.0))
207                    .angular_damping(data.angular_damping.unwrap_or(0.01))
208                    // предиктивные контакты на толщину ящика — та же причина,
209                    // что и у танка: дефолтная дистанция предсказания Rapier
210                    // рассчитана на метры и на порядки меньше пути тела за шаг
211                    .soft_ccd_prediction(width.min(height))
212                    .user_data(encode_map_object()),
213            );
214
215            // коллайдер со смещённым центром (позиция тела — угол объекта)
216            world.insert_collider(
217                ColliderBuilder::cuboid(width / 2.0, height / 2.0)
218                    .translation(Vector::new(width / 2.0, height / 2.0))
219                    .density(data.density)
220                    .friction(DEFAULT_FRICTION)
221                    .restitution(DEFAULT_RESTITUTION),
222                Some(body),
223            );
224
225            self.dynamic_bodies.push(body);
226        }
227    }
228
229    // ***** отладочный дамп (crate::debug) ***** //
230
231    pub fn static_body_count(&self) -> usize {
232        self.static_bodies.len()
233    }
234
235    pub fn dynamic_body_count(&self) -> usize {
236        self.dynamic_bodies.len()
237    }
238
239    /// Удаляет все тела карты из мира (Map.destroyMap).
240    pub fn destroy(&mut self, world: &mut PhysicsWorld) {
241        for handle in self.static_bodies.drain(..) {
242            world.remove_body(handle);
243        }
244
245        for handle in self.dynamic_bodies.drain(..) {
246            world.remove_body(handle);
247        }
248    }
249
250    /// Краткие данные динамических элементов (Map.getDynamicMapData):
251    /// индекс → [x, y, angle] (как поля строки блока), значения скруглены
252    /// до 2 знаков.
253    pub fn dynamic_map_data(&self, world: &PhysicsWorld) -> Vec<(u8, Vec<FieldValue>)> {
254        self.dynamic_bodies
255            .iter()
256            .enumerate()
257            .filter_map(|(index, &handle)| {
258                world.bodies.get(handle).map(|body| {
259                    let pos = body.translation();
260
261                    (
262                        index as u8,
263                        vec![
264                            FieldValue::F32(round2(pos.x)),
265                            FieldValue::F32(round2(pos.y)),
266                            FieldValue::F32(round2(body.rotation().angle())),
267                        ],
268                    )
269                })
270            })
271            .collect()
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    const TIME_STEP: f32 = 1.0 / 120.0;
280
281    // сплошная стена в верхнем ряду (5 тайлов по 20) и один ящик 20×20 под ней
282    fn map_config() -> MapConfig {
283        serde_json::from_value(serde_json::json!({
284            "step": 20.0,
285            "map": [[1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
286            "physicsStatic": [1],
287            "physicsDynamic": [{
288                "position": [40.0, 60.0],
289                "angle": 0.0,
290                "width": 20.0,
291                "height": 20.0,
292                "density": 1.0
293            }]
294        }))
295        .unwrap()
296    }
297
298    fn make_world() -> PhysicsWorld {
299        let mut world = PhysicsWorld::new();
300
301        world.gravity = Vector::ZERO;
302        world.integration_parameters.dt = TIME_STEP;
303
304        world
305    }
306
307    // глубина самого глубокого перекрытия за прогон: ящик разгоняется в стену
308    fn max_penetration(world: &mut PhysicsWorld, body: RigidBodyHandle) -> f32 {
309        let mut depth: f32 = 0.0;
310
311        // ~17 юнитов за шаг — на порядки больше дефолтных 0.002 и сравнимо
312        // с толщиной ящика: без предсказания контакт рождается уже внутри
313        world.bodies[body].set_linvel(Vector::new(0.0, -2000.0), true);
314
315        for _ in 0..120 {
316            world.step();
317
318            for pair in world.contact_pairs() {
319                for manifold in &pair.manifolds {
320                    for point in &manifold.points {
321                        depth = depth.max(-point.dist);
322                    }
323                }
324            }
325        }
326
327        depth
328    }
329
330    #[test]
331    fn dynamic_body_gets_soft_ccd_prediction_of_thickness() {
332        let mut world = make_world();
333        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
334
335        let prediction = world.bodies[map.dynamic_bodies[0]].soft_ccd_prediction();
336
337        // без предсказания контакт рождается уже по факту перекрытия: за шаг
338        // 1/120 тело проходит на порядки больше дефолтных 0.002 юнита
339        assert_eq!(prediction, 20.0);
340    }
341
342    #[test]
343    fn soft_ccd_prediction_keeps_penetration_shallow() {
344        let mut world = make_world();
345        let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
346        let handle = map.dynamic_bodies[0];
347
348        let predicted = max_penetration(&mut world, handle);
349
350        let mut plain_world = make_world();
351        let plain_map = GameMap::create(&mut plain_world, &map_config(), 1.0, "set");
352        let plain_handle = plain_map.dynamic_bodies[0];
353
354        plain_world.bodies[plain_handle].set_soft_ccd_prediction(0.0);
355
356        let plain = max_penetration(&mut plain_world, plain_handle);
357
358        assert!(
359            plain > 5.0,
360            "без предсказания ожидалось глубокое перекрытие, получено {plain}"
361        );
362        assert!(predicted < 2.0, "с предсказанием перекрытие {predicted}");
363    }
364}