Skip to main content

vimp_engine_core/client/
raycast.rs

1//! 2D raycast-примитивы для клиентского предсказания выстрелов — порт
2//! src/lib/raycast.js (срез 2.6): приближённая реплика авторитетного
3//! world.cast_ray по данным, которые уже есть у клиента (тайловая сетка
4//! стен, прямоугольники динамики карты и танков).
5
6/// Повёрнутый прямоугольник (OBB) — общее представление геометрии для
7/// slab-теста луча и SAT-контактов (`client::collision`).
8#[derive(Clone, Copy, Debug)]
9pub struct Box2 {
10    pub x: f32,
11    pub y: f32,
12    pub angle: f32,
13    pub half_w: f32,
14    pub half_h: f32,
15}
16
17/// Обход клеток сетки вдоль луча (DDA). Колбэк получает клетку и дистанцию
18/// ВХОДА в неё вдоль луча (0.0 для стартовой клетки) и возвращает `false`,
19/// чтобы остановить обход. Обход прекращается сам, когда пройденная
20/// дистанция превысила `range` или луч вышел за пределы сетки по обеим осям.
21///
22/// Вынесено из `ray_vs_grid`, чтобы игра могла принять СВОЁ решение на
23/// каждой клетке (2.5D: смена уровня луча на кромке плиты) вместо
24/// единственного зашитого «стена — стоп».
25pub fn walk_ray_cells(
26    origin: [f32; 2],
27    dir: [f32; 2],
28    range: f32,
29    rows: usize,
30    cols: usize,
31    tile_size: f32,
32    mut visit: impl FnMut(i64, i64, f32) -> bool,
33) {
34    if rows == 0 || cols == 0 {
35        return;
36    }
37
38    let mut cell_x = (origin[0] / tile_size).floor() as i64;
39    let mut cell_y = (origin[1] / tile_size).floor() as i64;
40
41    if !visit(cell_x, cell_y, 0.0) {
42        return;
43    }
44
45    let step_x: i64 = if dir[0] > 0.0 { 1 } else { -1 };
46    let step_y: i64 = if dir[1] > 0.0 { 1 } else { -1 };
47
48    // дистанция вдоль луча на пересечение одной клетки по каждой оси
49    let delta_x = if dir[0] != 0.0 {
50        (tile_size / dir[0]).abs()
51    } else {
52        f32::INFINITY
53    };
54    let delta_y = if dir[1] != 0.0 {
55        (tile_size / dir[1]).abs()
56    } else {
57        f32::INFINITY
58    };
59
60    // дистанция вдоль луча до первой границы клетки по каждой оси
61    let mut max_x = if dir[0] != 0.0 {
62        let edge = if dir[0] > 0.0 {
63            (cell_x + 1) as f32 * tile_size - origin[0]
64        } else {
65            origin[0] - cell_x as f32 * tile_size
66        };
67
68        edge / dir[0].abs()
69    } else {
70        f32::INFINITY
71    };
72    let mut max_y = if dir[1] != 0.0 {
73        let edge = if dir[1] > 0.0 {
74            (cell_y + 1) as f32 * tile_size - origin[1]
75        } else {
76            origin[1] - cell_y as f32 * tile_size
77        };
78
79        edge / dir[1].abs()
80    } else {
81        f32::INFINITY
82    };
83
84    let mut traveled = 0.0f32;
85
86    while traveled <= range {
87        if max_x < max_y {
88            traveled = max_x;
89            max_x += delta_x;
90            cell_x += step_x;
91        } else {
92            traveled = max_y;
93            max_y += delta_y;
94            cell_y += step_y;
95        }
96
97        if traveled > range {
98            return;
99        }
100
101        // луч монотонен по каждой оси: уйдя за край в сторону движения,
102        // он в сетку уже не вернётся
103        let gone = (step_x < 0 && cell_x < 0)
104            || (step_x > 0 && cell_x >= cols as i64)
105            || (step_y < 0 && cell_y < 0)
106            || (step_y > 0 && cell_y >= rows as i64);
107
108        if gone {
109            return;
110        }
111
112        if !visit(cell_x, cell_y, traveled) {
113            return;
114        }
115    }
116}
117
118/// Луч против тайловой сетки стен (DDA-обход клеток).
119/// Возвращает дистанцию до стены или None (промах в пределах range).
120pub fn ray_vs_grid(
121    origin: [f32; 2],
122    dir: [f32; 2],
123    range: f32,
124    map: &[Vec<i32>],
125    solid_tiles: &[i32],
126    tile_size: f32,
127) -> Option<f32> {
128    let rows = map.len();
129    let cols = map.first().map(|row| row.len()).unwrap_or(0);
130
131    if rows == 0 || cols == 0 || solid_tiles.is_empty() {
132        return None;
133    }
134
135    let mut hit = None;
136
137    walk_ray_cells(origin, dir, range, rows, cols, tile_size, |cx, cy, t| {
138        let solid = cy >= 0
139            && (cy as usize) < rows
140            && cx >= 0
141            && (cx as usize) < cols
142            && solid_tiles.contains(&map[cy as usize][cx as usize]);
143
144        if solid {
145            hit = Some(t);
146
147            return false;
148        }
149
150        true
151    });
152
153    hit
154}
155
156/// Луч против повёрнутого прямоугольника (slab-тест в локальном фрейме OBB).
157/// Возвращает дистанцию до ближней грани или None (промах).
158pub fn ray_vs_box(origin: [f32; 2], dir: [f32; 2], range: f32, b: &Box2) -> Option<f32> {
159    // перевод луча в локальный фрейм бокса (поворот на −angle)
160    let (sin, cos) = (-b.angle).sin_cos();
161    let rel_x = origin[0] - b.x;
162    let rel_y = origin[1] - b.y;
163
164    let local_origin = [cos * rel_x - sin * rel_y, sin * rel_x + cos * rel_y];
165    let local_dir = [cos * dir[0] - sin * dir[1], sin * dir[0] + cos * dir[1]];
166
167    let mut t_min = 0.0f32;
168    let mut t_max = range;
169
170    // slab-тест по каждой оси AABB [-half, +half]
171    let slabs = [
172        (local_origin[0], local_dir[0], b.half_w),
173        (local_origin[1], local_dir[1], b.half_h),
174    ];
175
176    for (o, d, half) in slabs {
177        if d == 0.0 {
178            if o < -half || o > half {
179                return None;
180            }
181
182            continue;
183        }
184
185        let mut t1 = (-half - o) / d;
186        let mut t2 = (half - o) / d;
187
188        if t1 > t2 {
189            std::mem::swap(&mut t1, &mut t2);
190        }
191
192        t_min = t_min.max(t1);
193        t_max = t_max.min(t2);
194
195        if t_min > t_max {
196            return None;
197        }
198    }
199
200    Some(t_min)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    // сетка 5×5 со стеной (1) в колонке 3; тайл 10 юнитов
208    fn wall_grid() -> Vec<Vec<i32>> {
209        let mut grid = vec![vec![0; 5]; 5];
210
211        for row in &mut grid {
212            row[3] = 1;
213        }
214
215        grid
216    }
217
218    #[test]
219    fn grid_ray_hits_wall_to_the_right() {
220        let grid = wall_grid();
221        // старт в центре тайла (1,1), луч вправо: стена начинается на x=30
222        let hit = ray_vs_grid([15.0, 15.0], [1.0, 0.0], 100.0, &grid, &[1], 10.0);
223
224        assert_eq!(hit, Some(15.0));
225    }
226
227    #[test]
228    fn grid_ray_misses_within_range() {
229        let grid = wall_grid();
230        let hit = ray_vs_grid([15.0, 15.0], [1.0, 0.0], 10.0, &grid, &[1], 10.0);
231
232        assert_eq!(hit, None);
233    }
234
235    #[test]
236    fn grid_ray_away_from_wall_misses() {
237        let grid = wall_grid();
238        let hit = ray_vs_grid([15.0, 15.0], [-1.0, 0.0], 100.0, &grid, &[1], 10.0);
239
240        assert_eq!(hit, None);
241    }
242
243    #[test]
244    fn grid_start_inside_wall_hits_at_zero() {
245        let grid = wall_grid();
246        let hit = ray_vs_grid([35.0, 15.0], [1.0, 0.0], 100.0, &grid, &[1], 10.0);
247
248        assert_eq!(hit, Some(0.0));
249    }
250
251    #[test]
252    fn grid_diagonal_and_vertical_rays() {
253        let mut grid = vec![vec![0; 5]; 5];
254
255        grid[3] = vec![1; 5]; // стена — строка 3 (y от 30)
256
257        let dir = [std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2];
258        let diagonal = ray_vs_grid([5.0, 5.0], dir, 100.0, &grid, &[1], 10.0).unwrap();
259
260        // до y=30 по диагонали: 25/sin(45°) ≈ 35.36
261        assert!((diagonal - 25.0 * std::f32::consts::SQRT_2).abs() < 0.01);
262
263        let vertical = ray_vs_grid([5.0, 5.0], [0.0, 1.0], 100.0, &grid, &[1], 10.0);
264
265        assert_eq!(vertical, Some(25.0));
266    }
267
268    #[test]
269    fn grid_without_solid_tiles_never_hits() {
270        let grid = wall_grid();
271        let hit = ray_vs_grid([15.0, 15.0], [1.0, 0.0], 100.0, &grid, &[], 10.0);
272
273        assert_eq!(hit, None);
274    }
275
276    #[test]
277    fn walk_visits_start_cell_at_zero() {
278        let mut visited: Vec<(i64, i64, f32)> = Vec::new();
279
280        walk_ray_cells([15.0, 15.0], [1.0, 0.0], 25.0, 5, 5, 10.0, |cx, cy, t| {
281            visited.push((cx, cy, t));
282
283            true
284        });
285
286        assert_eq!(visited[0], (1, 1, 0.0));
287        assert_eq!(visited[1], (2, 1, 5.0));
288        // клетки за range не посещаются вовсе
289        assert!(visited.iter().all(|(_, _, t)| *t <= 25.0));
290    }
291
292    #[test]
293    fn walk_stops_on_false() {
294        let mut count = 0;
295
296        walk_ray_cells([15.0, 15.0], [1.0, 0.0], 100.0, 5, 5, 10.0, |_, _, _| {
297            count += 1;
298
299            count < 3
300        });
301
302        assert_eq!(count, 3);
303    }
304
305    #[test]
306    fn walk_matches_ray_vs_grid() {
307        let grid = wall_grid();
308        let dirs = [
309            [1.0, 0.0],
310            [-1.0, 0.0],
311            [0.0, 1.0],
312            [0.0, -1.0],
313            [
314                std::f32::consts::FRAC_1_SQRT_2,
315                std::f32::consts::FRAC_1_SQRT_2,
316            ],
317            [0.6, -0.8],
318        ];
319
320        for dir in dirs {
321            let expected = ray_vs_grid([15.0, 15.0], dir, 100.0, &grid, &[1], 10.0);
322            let mut manual = None;
323
324            walk_ray_cells([15.0, 15.0], dir, 100.0, 5, 5, 10.0, |cx, cy, t| {
325                let solid = (0..5).contains(&cy)
326                    && (0..5).contains(&cx)
327                    && grid[cy as usize][cx as usize] == 1;
328
329                if solid {
330                    manual = Some(t);
331
332                    return false;
333                }
334
335                true
336            });
337
338            assert_eq!(expected, manual, "dir {dir:?}");
339        }
340    }
341
342    #[test]
343    fn box_head_on_hit() {
344        let b = Box2 {
345            x: 50.0,
346            y: 0.0,
347            angle: 0.0,
348            half_w: 10.0,
349            half_h: 5.0,
350        };
351        let hit = ray_vs_box([0.0, 0.0], [1.0, 0.0], 100.0, &b);
352
353        assert_eq!(hit, Some(40.0));
354    }
355
356    #[test]
357    fn box_miss_and_out_of_range() {
358        let b = Box2 {
359            x: 50.0,
360            y: 20.0,
361            angle: 0.0,
362            half_w: 10.0,
363            half_h: 5.0,
364        };
365
366        assert_eq!(ray_vs_box([0.0, 0.0], [1.0, 0.0], 100.0, &b), None);
367
368        let near = Box2 { y: 0.0, ..b };
369
370        assert_eq!(ray_vs_box([0.0, 0.0], [1.0, 0.0], 30.0, &near), None);
371    }
372
373    #[test]
374    fn box_start_inside_hits_at_zero() {
375        let b = Box2 {
376            x: 0.0,
377            y: 0.0,
378            angle: 0.0,
379            half_w: 10.0,
380            half_h: 10.0,
381        };
382
383        assert_eq!(ray_vs_box([0.0, 0.0], [1.0, 0.0], 100.0, &b), Some(0.0));
384    }
385
386    #[test]
387    fn box_rotation_changes_hit_distance() {
388        // бокс 20×2, повёрнутый на 90°: по лучу видна узкая грань
389        let b = Box2 {
390            x: 50.0,
391            y: 0.0,
392            angle: std::f32::consts::FRAC_PI_2,
393            half_w: 10.0,
394            half_h: 1.0,
395        };
396        let hit = ray_vs_box([0.0, 0.0], [1.0, 0.0], 100.0, &b).unwrap();
397
398        assert!((hit - 49.0).abs() < 0.001);
399    }
400
401    #[test]
402    fn box_behind_ray_misses() {
403        let b = Box2 {
404            x: -50.0,
405            y: 0.0,
406            angle: 0.0,
407            half_w: 10.0,
408            half_h: 5.0,
409        };
410
411        assert_eq!(ray_vs_box([0.0, 0.0], [1.0, 0.0], 100.0, &b), None);
412    }
413}