Skip to main content

engine/
physics.rs

1/**------------------------------------------------------------
2*!  Basic 2D physics primitives for collision detection.
3*?  Usage: Use AABB for player, enemies, platforms,
4*?  and any rectangular object needing collision checks.
5*------------------------------------------------------------**/
6use glam::Vec2;
7
8//? Axis-Aligned Bounding Box for 2D collision detection.
9//* AABBs are simple and efficient for collision detection
10//* in 2D games, since their sides are always parallel to the axes.
11#[derive(Debug, Clone, Copy)]
12pub struct AABB {
13    pub center: Vec2,
14    pub size: Vec2,
15}
16
17impl AABB {
18    //? Create a new AABB from center position and size.
19    pub fn new(center: Vec2, size: Vec2) -> Self {
20        Self { center, size }
21    }
22
23    //? Create AABB from top-left position and size (common for sprite rendering).
24    pub fn from_top_left(top_left: Vec2, size: Vec2) -> Self {
25        let center = top_left + size * 0.5;
26        Self { center, size }
27    }
28
29    //* Different coordinate conventions (center vs. top-left) are used
30    //* in rendering and physics, so both constructors make integration easy.
31
32    //? Get the minimum corner (top-left in screen space).
33    pub fn min(&self) -> Vec2 {
34        self.center - self.size * 0.5
35    }
36
37    //? Get the maximum corner (bottom-right in screen space).
38    pub fn max(&self) -> Vec2 {
39        self.center + self.size * 0.5
40    }
41
42    //? Alias - Get the top-left position (useful for sprite rendering).
43    pub fn top_left(&self) -> Vec2 {
44        self.min()
45    }
46
47    //? Check if this AABB overlaps with another.
48    //* If the min of one is less than the max of the other,
49    //* and vice versa, on both axes, they overlap.
50    pub fn check_collision(&self, other: &AABB) -> bool {
51        let self_min = self.min();
52        let self_max = self.max();
53        let other_min = other.min();
54        let other_max = other.max();
55
56        self_min.x < other_max.x
57            && self_max.x > other_min.x
58            && self_min.y < other_max.y
59            && self_max.y > other_min.y
60    }
61
62    //? Get the overlap amount on each axis (positive means collision).
63    //* Returns (x_overlap, y_overlap).
64    pub fn get_overlap(&self, other: &AABB) -> Vec2 {
65        let self_min = self.min();
66        let self_max = self.max();
67        let other_min = other.min();
68        let other_max = other.max();
69
70        let x_overlap = (self_max.x - other_min.x).min(other_max.x - self_min.x);
71        let y_overlap = (self_max.y - other_min.y).min(other_max.y - self_min.y);
72
73        Vec2::new(x_overlap, y_overlap)
74    }
75
76    //? Compute the minimum translation vector (MTV) to push `mover` out of `obstacle`.
77    //? Returns `None` if they don't overlap. Pushes along the smallest overlap axis.
78    //* When both axes have equal overlap, the Y axis wins (the `else` branch).
79    //* The default push direction for Y is upward (`sign = -1.0`) because in a
80    //* Y-down coordinate system this places entities on top of platforms rather
81    //* than pushing them through. This intentional directional bias is correct
82    //* for a platformer where landing on surfaces is the dominant collision case.
83    pub fn resolve_collision(mover: &AABB, obstacle: &AABB) -> Option<Vec2> {
84        let overlap = mover.get_overlap(obstacle);
85        if overlap.x <= 0.0 || overlap.y <= 0.0 {
86            return None;
87        }
88        if overlap.x < overlap.y {
89            let sign = (mover.center.x - obstacle.center.x).signum();
90            let sign = if sign == 0.0 { 1.0 } else { sign };
91            Some(Vec2::new(overlap.x * sign, 0.0))
92        } else {
93            let sign = (mover.center.y - obstacle.center.y).signum();
94            let sign = if sign == 0.0 { -1.0 } else { sign };
95            Some(Vec2::new(0.0, overlap.y * sign))
96        }
97    }
98}
99
100//? Collision layer tag for multi-layer AABB system.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum CollisionLayer {
103    Pushbox,
104    Hurtbox,
105    Hitbox,
106    Parrybox,
107}
108
109//? A positioned collision volume relative to an entity center.
110#[derive(Debug, Clone, Copy)]
111pub struct BoxVolume {
112    pub layer: CollisionLayer,
113    pub local_offset: Vec2,
114    pub size: Vec2,
115    pub active: bool,
116}
117
118impl BoxVolume {
119    pub fn new(layer: CollisionLayer, offset: Vec2, size: Vec2) -> Self {
120        Self {
121            layer,
122            local_offset: offset,
123            size,
124            active: true,
125        }
126    }
127
128    //? Generate the world-space AABB, flipping X offset based on facing direction.
129    pub fn world_aabb(&self, entity_pos: Vec2, facing_right: bool) -> AABB {
130        let flip = if facing_right { 1.0 } else { -1.0 };
131        let center = entity_pos + Vec2::new(self.local_offset.x * flip, self.local_offset.y);
132        AABB::new(center, self.size)
133    }
134}
135
136#[derive(Debug, Clone, Copy)]
137pub struct SweepResult {
138    pub time: f32,
139    pub normal: Vec2,
140}
141
142impl AABB {
143    //? Swept AABB: move `self` along `displacement` and find the earliest
144    //? collision with `obstacle`. Uses Minkowski-expanded ray cast.
145    pub fn swept_collision(&self, displacement: Vec2, obstacle: &AABB) -> Option<SweepResult> {
146        if displacement.x == 0.0 && displacement.y == 0.0 {
147            return None;
148        }
149
150        //* Minkowski expansion: grow obstacle by mover's half-extents
151        let expanded_half = (obstacle.size + self.size) * 0.5;
152        let exp_min = obstacle.center - expanded_half;
153        let exp_max = obstacle.center + expanded_half;
154        let origin = self.center;
155
156        let (t_near_x, t_far_x) = if displacement.x.abs() > f32::EPSILON {
157            let t1 = (exp_min.x - origin.x) / displacement.x;
158            let t2 = (exp_max.x - origin.x) / displacement.x;
159            (t1.min(t2), t1.max(t2))
160        } else if origin.x >= exp_min.x && origin.x <= exp_max.x {
161            (f32::NEG_INFINITY, f32::INFINITY)
162        } else {
163            return None;
164        };
165
166        let (t_near_y, t_far_y) = if displacement.y.abs() > f32::EPSILON {
167            let t1 = (exp_min.y - origin.y) / displacement.y;
168            let t2 = (exp_max.y - origin.y) / displacement.y;
169            (t1.min(t2), t1.max(t2))
170        } else if origin.y >= exp_min.y && origin.y <= exp_max.y {
171            (f32::NEG_INFINITY, f32::INFINITY)
172        } else {
173            return None;
174        };
175
176        let t_entry = t_near_x.max(t_near_y);
177        let t_exit = t_far_x.min(t_far_y);
178
179        if t_entry > t_exit || t_entry >= 1.0 || t_exit <= 0.0 {
180            return None;
181        }
182
183        let time = t_entry.clamp(0.0, 1.0);
184
185        let normal = if t_near_x > t_near_y {
186            Vec2::new(-displacement.x.signum(), 0.0)
187        } else {
188            Vec2::new(0.0, -displacement.y.signum())
189        };
190
191        Some(SweepResult { time, normal })
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn swept_detects_head_on_collision() {
201        let mover = AABB::new(Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0));
202        let wall = AABB::new(Vec2::new(30.0, 0.0), Vec2::new(10.0, 20.0));
203        let displacement = Vec2::new(50.0, 0.0);
204
205        let result = mover.swept_collision(displacement, &wall);
206        assert!(result.is_some());
207        let r = result.unwrap();
208        assert!(r.time > 0.0 && r.time < 1.0);
209        assert_eq!(r.normal, Vec2::new(-1.0, 0.0));
210    }
211
212    #[test]
213    fn swept_misses_when_parallel() {
214        let mover = AABB::new(Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0));
215        let wall = AABB::new(Vec2::new(0.0, 50.0), Vec2::new(10.0, 10.0));
216        let displacement = Vec2::new(100.0, 0.0);
217
218        assert!(mover.swept_collision(displacement, &wall).is_none());
219    }
220
221    #[test]
222    fn swept_returns_none_for_zero_displacement() {
223        let mover = AABB::new(Vec2::ZERO, Vec2::new(10.0, 10.0));
224        let wall = AABB::new(Vec2::new(20.0, 0.0), Vec2::new(10.0, 10.0));
225        assert!(mover.swept_collision(Vec2::ZERO, &wall).is_none());
226    }
227
228    #[test]
229    fn swept_detects_downward_landing() {
230        let mover = AABB::new(Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0));
231        let floor = AABB::new(Vec2::new(0.0, 40.0), Vec2::new(100.0, 10.0));
232        let displacement = Vec2::new(0.0, 60.0);
233
234        let result = mover.swept_collision(displacement, &floor);
235        assert!(result.is_some());
236        let r = result.unwrap();
237        assert!(r.time < 1.0);
238        assert_eq!(r.normal, Vec2::new(0.0, -1.0));
239    }
240
241    #[test]
242    fn resolve_collision_pushes_out() {
243        let mover = AABB::new(Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0));
244        let wall = AABB::new(Vec2::new(18.0, 0.0), Vec2::new(10.0, 10.0));
245        let mtv = AABB::resolve_collision(&mover, &wall);
246        assert!(mtv.is_some());
247        let mtv = mtv.unwrap();
248        assert!(mtv.x < 0.0, "should push mover left, away from wall");
249    }
250}