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