Skip to main content

vimp_engine_core/client/
collision.rs

1//! 2D-примитивы столкновений для клиентского предсказания — порт
2//! src/lib/collision.js (срез tank-battle 2026-08): «угол объекта» → центр,
3//! OBB против OBB (SAT), сбор контактов OBB с тайловой сеткой стен.
4//! Геометрия берётся тем же `Box2` и той же тайловой сеткой, что и
5//! `client::raycast`, — предсказание движения и предсказание выстрела обязаны
6//! видеть карту одинаково. Разрешение собранных контактов — в
7//! `client::rigid_body`.
8
9use super::raycast::Box2;
10
11/// Ширина контактного пятна как доля габарита тела. Одиночная опорная вершина
12/// при почти плоском контакте даёт автоколебание: импульс перекидывает корпус
13/// через положение «заподлицо», опорным становится противоположный угол грани,
14/// и так каждый шаг. Поэтому вершины грани смешиваются с непрерывным весом:
15/// при контакте заподлицо точка контакта — середина грани (плеча нет, тело
16/// успокаивается), при явно косом ударе вес второй вершины падает до нуля и
17/// остаётся честный угловой контакт с полным плечом разворота.
18const CONTACT_MANIFOLD_RATIO: f32 = 0.15;
19
20/// Контакт двух OBB: минимальный вектор выталкивания (нормаль направлена от
21/// центра `a` к центру `b`), глубина проникновения и мировая точка контакта.
22#[derive(Clone, Copy, Debug)]
23pub struct Contact {
24    pub nx: f32,
25    pub ny: f32,
26    pub depth: f32,
27    pub cx: f32,
28    pub cy: f32,
29}
30
31/// Контакт со сплошной клеткой сетки: нормаль направлена от OBB к клетке,
32/// `tile_x`/`tile_y` — центр задетой клетки (плечо статики).
33#[derive(Clone, Copy, Debug)]
34pub struct TileContact {
35    pub contact: Contact,
36    pub tile_x: f32,
37    pub tile_y: f32,
38}
39
40/// Переводит «угол объекта» (позиция тела Rapier для динамики карты —
41/// см. `map::GameMap::create_dynamic`) в центр бокса: коллайдер хоста смещён
42/// от тела на (half_w, half_h) в локальном (повёрнутом) фрейме объекта.
43pub fn box_center_from_origin(x: f32, y: f32, angle: f32, half_w: f32, half_h: f32) -> [f32; 2] {
44    let (sin, cos) = angle.sin_cos();
45
46    [
47        x + cos * half_w - sin * half_h,
48        y + sin * half_w + cos * half_h,
49    ]
50}
51
52// мировые углы OBB (порядок не важен — используются только для проекции)
53fn obb_corners(b: &Box2) -> [[f32; 2]; 4] {
54    let (sin, cos) = b.angle.sin_cos();
55    let mut corners = [[0.0f32; 2]; 4];
56    let mut i = 0;
57
58    for sx in [-1.0f32, 1.0] {
59        for sy in [-1.0f32, 1.0] {
60            let lx = sx * b.half_w;
61            let ly = sy * b.half_h;
62
63            corners[i] = [b.x + cos * lx - sin * ly, b.y + sin * lx + cos * ly];
64            i += 1;
65        }
66    }
67
68    corners
69}
70
71// точка контакта: вершины a вдоль нормали, смешанные по близости к самой
72// глубокой (см. CONTACT_MANIFOLD_RATIO)
73// tolerance строго положителен: тело с нулевыми полуразмерами проецируется
74// в точку, и obb_vs_obb отсеивает его как промах, не дойдя сюда
75fn contact_point(corners: &[[f32; 2]; 4], nx: f32, ny: f32, tolerance: f32) -> [f32; 2] {
76    let mut best = f32::NEG_INFINITY;
77
78    for p in corners {
79        best = best.max(p[0] * nx + p[1] * ny);
80    }
81
82    let mut sum_x = 0.0;
83    let mut sum_y = 0.0;
84    let mut sum_weight = 0.0;
85
86    for p in corners {
87        let weight = 1.0 - (best - (p[0] * nx + p[1] * ny)) / tolerance;
88
89        if weight > 0.0 {
90            sum_x += p[0] * weight;
91            sum_y += p[1] * weight;
92            sum_weight += weight;
93        }
94    }
95
96    [sum_x / sum_weight, sum_y / sum_weight]
97}
98
99/// SAT-тест двух повёрнутых прямоугольников (OBB).
100/// `None` — пересечения нет (касание вплотную тоже промах).
101pub fn obb_vs_obb(a: &Box2, b: &Box2) -> Option<Contact> {
102    let (a_sin, a_cos) = a.angle.sin_cos();
103    let (b_sin, b_cos) = b.angle.sin_cos();
104    let axes = [
105        [a_cos, a_sin],
106        [-a_sin, a_cos],
107        [b_cos, b_sin],
108        [-b_sin, b_cos],
109    ];
110
111    let corners_a = obb_corners(a);
112    let corners_b = obb_corners(b);
113
114    let mut min_overlap = f32::INFINITY;
115    let mut normal_x = 0.0;
116    let mut normal_y = 0.0;
117
118    for [ax, ay] in axes {
119        let mut min_a = f32::INFINITY;
120        let mut max_a = f32::NEG_INFINITY;
121        let mut min_b = f32::INFINITY;
122        let mut max_b = f32::NEG_INFINITY;
123
124        for p in &corners_a {
125            let proj = p[0] * ax + p[1] * ay;
126
127            min_a = min_a.min(proj);
128            max_a = max_a.max(proj);
129        }
130
131        for p in &corners_b {
132            let proj = p[0] * ax + p[1] * ay;
133
134            min_b = min_b.min(proj);
135            max_b = max_b.max(proj);
136        }
137
138        let overlap = max_a.min(max_b) - min_a.max(min_b);
139
140        if overlap <= 0.0 {
141            return None;
142        }
143
144        if overlap < min_overlap {
145            min_overlap = overlap;
146
147            // нормаль ориентируется от центра a к центру b
148            let cx = b.x - a.x;
149            let cy = b.y - a.y;
150            let sign = if cx * ax + cy * ay < 0.0 { -1.0 } else { 1.0 };
151
152            normal_x = ax * sign;
153            normal_y = ay * sign;
154        }
155    }
156
157    let contact = contact_point(
158        &corners_a,
159        normal_x,
160        normal_y,
161        CONTACT_MANIFOLD_RATIO * (a.half_w + a.half_h),
162    );
163
164    Some(Contact {
165        nx: normal_x,
166        ny: normal_y,
167        depth: min_overlap,
168        cx: contact[0],
169        cy: contact[1],
170    })
171}
172
173/// Список контактов OBB со сплошными клетками тайловой сетки.
174/// Один проход без внутренних итераций: многократное разрешение — задача
175/// импульсного решателя (`client::rigid_body`), он же естественно отрабатывает
176/// внутренний угол из нескольких тайлов.
177/// Сетка описывается той же тройкой, что и в `raycast::ray_vs_grid`.
178pub fn collect_tile_contacts(
179    obb: &Box2,
180    map: &[Vec<i32>],
181    solid_tiles: &[i32],
182    tile_size: f32,
183) -> Vec<TileContact> {
184    let rows = map.len();
185    let cols = map.first().map(|row| row.len()).unwrap_or(0);
186    let mut contacts = Vec::new();
187
188    if rows == 0 || cols == 0 || solid_tiles.is_empty() {
189        return contacts;
190    }
191
192    // консервативный AABB OBB (для отбора кандидатных клеток)
193    let (sin, cos) = obb.angle.sin_cos();
194    let (sin, cos) = (sin.abs(), cos.abs());
195    let extent_x = obb.half_w * cos + obb.half_h * sin;
196    let extent_y = obb.half_w * sin + obb.half_h * cos;
197
198    let min_cell_x = (((obb.x - extent_x) / tile_size).floor() as i64).max(0);
199    let max_cell_x = (((obb.x + extent_x) / tile_size).floor() as i64).min(cols as i64 - 1);
200    let min_cell_y = (((obb.y - extent_y) / tile_size).floor() as i64).max(0);
201    let max_cell_y = (((obb.y + extent_y) / tile_size).floor() as i64).min(rows as i64 - 1);
202
203    for cell_y in min_cell_y..=max_cell_y {
204        for cell_x in min_cell_x..=max_cell_x {
205            if !solid_tiles.contains(&map[cell_y as usize][cell_x as usize]) {
206                continue;
207            }
208
209            let tile_x = cell_x as f32 * tile_size + tile_size / 2.0;
210            let tile_y = cell_y as f32 * tile_size + tile_size / 2.0;
211            let tile = Box2 {
212                x: tile_x,
213                y: tile_y,
214                angle: 0.0,
215                half_w: tile_size / 2.0,
216                half_h: tile_size / 2.0,
217            };
218
219            if let Some(contact) = obb_vs_obb(obb, &tile) {
220                contacts.push(TileContact {
221                    contact,
222                    tile_x,
223                    tile_y,
224                });
225            }
226        }
227    }
228
229    contacts
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn box2(x: f32, y: f32, angle: f32, half_w: f32, half_h: f32) -> Box2 {
237        Box2 {
238            x,
239            y,
240            angle,
241            half_w,
242            half_h,
243        }
244    }
245
246    #[test]
247    fn origin_to_center_without_rotation_is_a_plain_offset() {
248        assert_eq!(
249            box_center_from_origin(10.0, 20.0, 0.0, 5.0, 3.0),
250            [15.0, 23.0]
251        );
252    }
253
254    #[test]
255    fn origin_to_center_follows_rotation() {
256        let center = box_center_from_origin(10.0, 20.0, core::f32::consts::FRAC_PI_2, 5.0, 3.0);
257
258        assert!((center[0] - 7.0).abs() < 1e-5);
259        assert!((center[1] - 25.0).abs() < 1e-5);
260    }
261
262    #[test]
263    fn separated_boxes_do_not_touch() {
264        let a = box2(0.0, 0.0, 0.0, 5.0, 5.0);
265        let b = box2(20.0, 0.0, 0.0, 5.0, 5.0);
266
267        assert!(obb_vs_obb(&a, &b).is_none());
268    }
269
270    #[test]
271    fn flush_touch_is_a_miss() {
272        let a = box2(0.0, 0.0, 0.0, 5.0, 5.0);
273        let b = box2(10.0, 0.0, 0.0, 5.0, 5.0);
274
275        assert!(obb_vs_obb(&a, &b).is_none());
276    }
277
278    #[test]
279    fn overlap_normal_points_from_a_to_b() {
280        let a = box2(0.0, 0.0, 0.0, 5.0, 5.0);
281        let b = box2(8.0, 0.0, 0.0, 5.0, 5.0);
282        let contact = obb_vs_obb(&a, &b).expect("контакт");
283
284        assert!((contact.nx - 1.0).abs() < 1e-5);
285        assert!(contact.ny.abs() < 1e-5);
286        assert!((contact.depth - 2.0).abs() < 1e-5);
287    }
288
289    #[test]
290    fn overlap_on_y_axis_keeps_direction() {
291        let a = box2(0.0, 0.0, 0.0, 5.0, 5.0);
292        let b = box2(0.0, -8.0, 0.0, 5.0, 5.0);
293        let contact = obb_vs_obb(&a, &b).expect("контакт");
294
295        assert!(contact.nx.abs() < 1e-5);
296        assert!((contact.ny + 1.0).abs() < 1e-5);
297        assert!((contact.depth - 2.0).abs() < 1e-5);
298    }
299
300    #[test]
301    fn face_contact_lands_in_the_middle_of_the_face() {
302        let a = box2(0.0, 0.0, 0.0, 5.0, 5.0);
303        let b = box2(8.0, 0.0, 0.0, 5.0, 5.0);
304        let contact = obb_vs_obb(&a, &b).expect("контакт");
305
306        assert!((contact.cx - 5.0).abs() < 1e-5);
307        assert!(contact.cy.abs() < 1e-5);
308    }
309
310    #[test]
311    fn corner_contact_keeps_the_lever() {
312        let a = box2(0.0, 0.0, core::f32::consts::FRAC_PI_4, 5.0, 5.0);
313        let b = box2(9.0, 0.0, 0.0, 3.0, 3.0);
314        let contact = obb_vs_obb(&a, &b).expect("контакт");
315
316        // повёрнутый на 45° квадрат касается вершиной на расстоянии half_w·√2
317        let distance = contact.cx.hypot(contact.cy);
318
319        assert!((distance - 5.0 * core::f32::consts::SQRT_2).abs() < 1e-4);
320    }
321
322    #[test]
323    fn returned_vector_actually_separates_rotated_boxes() {
324        // длинная узкая балка (a) и повёрнутая на 45° пластина (b) сверху
325        let a = box2(0.0, 0.0, 0.0, 10.0, 2.0);
326        let b = box2(0.0, 5.0, core::f32::consts::FRAC_PI_4, 6.0, 1.0);
327        let contact = obb_vs_obb(&a, &b).expect("контакт");
328
329        // небольшой запас страхует от остаточного пересечения на грани
330        // float-точности (SAT на самой границе даёт микроскопический depth)
331        let epsilon = 1e-4;
332        let separated = Box2 {
333            x: b.x + contact.nx * (contact.depth + epsilon),
334            y: b.y + contact.ny * (contact.depth + epsilon),
335            ..b
336        };
337
338        assert!(obb_vs_obb(&a, &separated).is_none());
339    }
340
341    // на этом держится ширина контактного пятна: тело с нулевым габаритом
342    // дало бы нулевой допуск и 0/0 в точке контакта, но сюда не доходит
343    #[test]
344    fn degenerate_box_is_a_miss() {
345        let degenerate = box2(0.0, 0.0, 0.0, 0.0, 0.0);
346        let b = box2(0.0, 0.0, 0.0, 5.0, 5.0);
347
348        assert!(obb_vs_obb(&degenerate, &b).is_none());
349        assert!(obb_vs_obb(&b, &degenerate).is_none());
350    }
351
352    #[test]
353    fn no_solid_tiles_means_no_contacts() {
354        let map = vec![vec![0, 0], vec![0, 0]];
355        let obb = box2(5.0, 5.0, 0.0, 3.0, 3.0);
356
357        assert!(collect_tile_contacts(&obb, &map, &[], 10.0).is_empty());
358    }
359
360    #[test]
361    fn box_away_from_the_wall_has_no_contacts() {
362        let map = vec![vec![0, 0], vec![0, 1]];
363        let obb = box2(100.0, 100.0, 0.0, 3.0, 3.0);
364
365        assert!(collect_tile_contacts(&obb, &map, &[1], 10.0).is_empty());
366    }
367
368    #[test]
369    fn single_wall_gives_one_contact_along_the_shortest_axis() {
370        // клетка (1,1) солид — мир [10,20]×[10,20], центр (15,15)
371        let map = vec![vec![0, 0], vec![0, 1]];
372        // бокс снизу, глубже въехал по Y (overlap 2), чем по X (overlap 6)
373        let obb = box2(15.0, 22.0, 0.0, 3.0, 4.0);
374        let contacts = collect_tile_contacts(&obb, &map, &[1], 10.0);
375
376        assert_eq!(contacts.len(), 1);
377
378        let hit = contacts[0];
379
380        // нормаль направлена от obb к клетке, то есть вверх (−Y)
381        assert!(hit.contact.nx.abs() < 1e-5);
382        assert!((hit.contact.ny + 1.0).abs() < 1e-5);
383        assert!((hit.contact.depth - 2.0).abs() < 1e-5);
384        assert_eq!(hit.tile_x, 15.0);
385        assert_eq!(hit.tile_y, 15.0);
386
387        // выталкивание против нормали разрешает пересечение
388        let resolved = Box2 {
389            x: obb.x - hit.contact.nx * hit.contact.depth,
390            y: obb.y - hit.contact.ny * hit.contact.depth,
391            ..obb
392        };
393        let tile = box2(15.0, 15.0, 0.0, 5.0, 5.0);
394
395        assert!(obb_vs_obb(&resolved, &tile).is_none());
396    }
397
398    #[test]
399    fn inner_corner_gives_a_contact_per_touched_tile() {
400        // L-форма: (0,0),(0,1),(1,0) солид; (1,1) — пустая клетка-«ниша»
401        let map = vec![vec![1, 1], vec![1, 0]];
402        // бокс прижат в угол ниши (10,10), пересекает все три стены сразу
403        let obb = box2(12.0, 12.0, 0.0, 4.0, 4.0);
404        let contacts = collect_tile_contacts(&obb, &map, &[1], 10.0);
405
406        assert_eq!(contacts.len(), 3);
407
408        let mut tiles: Vec<(i32, i32)> = contacts
409            .iter()
410            .map(|c| (c.tile_x as i32, c.tile_y as i32))
411            .collect();
412
413        tiles.sort();
414
415        assert_eq!(tiles, vec![(5, 5), (5, 15), (15, 5)]);
416        assert!(contacts.iter().all(|c| c.contact.depth > 0.0));
417    }
418
419    #[test]
420    fn rotated_box_picks_candidate_cells_by_conservative_aabb() {
421        let map = vec![vec![1, 0], vec![0, 0]];
422        // бокс повёрнут на 45°, углом заходит в клетку (0,0) — мир [0,10]×[0,10]
423        let obb = box2(13.0, 13.0, core::f32::consts::FRAC_PI_4, 5.0, 2.0);
424        let contacts = collect_tile_contacts(&obb, &map, &[1], 10.0);
425
426        assert_eq!(contacts.len(), 1);
427        assert!(contacts[0].contact.depth > 0.0);
428    }
429
430    // геометрия карты у луча и у контакта одна: стена, в которую упёрся OBB,
431    // и стена, которую нашёл луч в том же направлении, — одна и та же клетка
432    #[test]
433    fn ray_and_contact_agree_on_the_same_wall() {
434        let map = vec![vec![0, 0], vec![0, 1]];
435        let obb = box2(15.0, 22.0, 0.0, 3.0, 4.0);
436        let contacts = collect_tile_contacts(&obb, &map, &[1], 10.0);
437        let hit = super::super::raycast::ray_vs_grid(
438            [obb.x, obb.y],
439            [contacts[0].contact.nx, contacts[0].contact.ny],
440            50.0,
441            &map,
442            &[1],
443            10.0,
444        );
445
446        // луч вдоль нормали контакта упирается в ту же клетку (её грань y=20)
447        assert!(hit.is_some());
448        assert!((hit.unwrap() - 2.0).abs() < 1e-4);
449    }
450}