Skip to main content

basalt_api/world/
collision.rs

1//! Collision utilities for physics simulation.
2//!
3//! Provides AABB-vs-block collision detection and ray casting against
4//! the block grid. These are the building blocks for the physics system
5//! and future gameplay mechanics (line-of-sight, block targeting).
6
7use crate::world::block::is_solid;
8use crate::world::handle::WorldHandle;
9
10/// An axis-aligned bounding box in world coordinates.
11#[derive(Debug, Clone, Copy)]
12pub struct Aabb {
13    /// Minimum corner (lowest X, Y, Z).
14    pub min_x: f64,
15    /// Minimum Y.
16    pub min_y: f64,
17    /// Minimum Z.
18    pub min_z: f64,
19    /// Maximum corner (highest X, Y, Z).
20    pub max_x: f64,
21    /// Maximum Y.
22    pub max_y: f64,
23    /// Maximum Z.
24    pub max_z: f64,
25}
26
27impl Aabb {
28    /// Creates an AABB centered at `(x, y, z)` with the given dimensions.
29    ///
30    /// The box extends `width/2` in X and Z from center, and `height`
31    /// upward from `y` (entity position is at feet level).
32    pub fn from_entity(x: f64, y: f64, z: f64, width: f32, height: f32) -> Self {
33        let hw = f64::from(width) / 2.0;
34        let h = f64::from(height);
35        Self {
36            min_x: x - hw,
37            min_y: y,
38            min_z: z - hw,
39            max_x: x + hw,
40            max_y: y + h,
41            max_z: z + hw,
42        }
43    }
44
45    /// Returns this AABB offset by the given delta.
46    pub fn offset(&self, dx: f64, dy: f64, dz: f64) -> Self {
47        Self {
48            min_x: self.min_x + dx,
49            min_y: self.min_y + dy,
50            min_z: self.min_z + dz,
51            max_x: self.max_x + dx,
52            max_y: self.max_y + dy,
53            max_z: self.max_z + dz,
54        }
55    }
56
57    /// Returns whether this AABB overlaps a unit block at (bx, by, bz).
58    fn overlaps_block(&self, bx: i32, by: i32, bz: i32) -> bool {
59        let bx = bx as f64;
60        let by = by as f64;
61        let bz = bz as f64;
62        self.max_x > bx
63            && self.min_x < bx + 1.0
64            && self.max_y > by
65            && self.min_y < by + 1.0
66            && self.max_z > bz
67            && self.min_z < bz + 1.0
68    }
69}
70
71/// Checks if an AABB overlaps any solid block in the world.
72///
73/// Iterates all block positions that the AABB spans and returns
74/// `true` if any of them are solid. Used for ground detection and
75/// simple collision checks.
76pub fn check_overlap(world: &dyn WorldHandle, aabb: &Aabb) -> bool {
77    let min_bx = aabb.min_x.floor() as i32;
78    let min_by = aabb.min_y.floor() as i32;
79    let min_bz = aabb.min_z.floor() as i32;
80    let max_bx = aabb.max_x.ceil() as i32;
81    let max_by = aabb.max_y.ceil() as i32;
82    let max_bz = aabb.max_z.ceil() as i32;
83
84    for bx in min_bx..max_bx {
85        for by in min_by..max_by {
86            for bz in min_bz..max_bz {
87                if is_solid(world.get_block(bx, by, bz)) && aabb.overlaps_block(bx, by, bz) {
88                    return true;
89                }
90            }
91        }
92    }
93    false
94}
95
96/// Result of a ray cast against the block grid.
97#[derive(Debug, Clone)]
98pub struct RayHit {
99    /// The block position that was hit.
100    pub block_x: i32,
101    /// Block Y.
102    pub block_y: i32,
103    /// Block Z.
104    pub block_z: i32,
105    /// Distance from origin to hit point.
106    pub distance: f64,
107}
108
109/// Casts a ray through the world and returns the first solid block hit.
110///
111/// Uses a simple stepping algorithm along the ray direction.
112/// Returns `None` if no solid block is found within `max_distance`.
113pub fn ray_cast(
114    world: &dyn WorldHandle,
115    origin: (f64, f64, f64),
116    direction: (f64, f64, f64),
117    max_distance: f64,
118) -> Option<RayHit> {
119    let (origin_x, origin_y, origin_z) = origin;
120    let (dir_x, dir_y, dir_z) = direction;
121    let step = 0.1;
122    let steps = (max_distance / step) as usize;
123    let len = (dir_x * dir_x + dir_y * dir_y + dir_z * dir_z).sqrt();
124    if len < 1e-10 {
125        return None;
126    }
127    let (nx, ny, nz) = (dir_x / len, dir_y / len, dir_z / len);
128
129    for i in 0..=steps {
130        let d = i as f64 * step;
131        let x = origin_x + nx * d;
132        let y = origin_y + ny * d;
133        let z = origin_z + nz * d;
134        let bx = x.floor() as i32;
135        let by = y.floor() as i32;
136        let bz = z.floor() as i32;
137
138        if is_solid(world.get_block(bx, by, bz)) {
139            return Some(RayHit {
140                block_x: bx,
141                block_y: by,
142                block_z: bz,
143                distance: d,
144            });
145        }
146    }
147    None
148}
149
150/// Resolves movement of an AABB against solid blocks.
151///
152/// Takes the entity's AABB and desired velocity, returns the actual
153/// velocity after clamping against solid blocks. Each axis is resolved
154/// independently (Y first for gravity, then X, then Z).
155pub fn resolve_movement(
156    world: &dyn WorldHandle,
157    aabb: &Aabb,
158    dx: f64,
159    dy: f64,
160    dz: f64,
161) -> (f64, f64, f64) {
162    let mut resolved_dy = dy;
163    let mut resolved_dx = dx;
164    let mut resolved_dz = dz;
165
166    // Resolve Y axis first (gravity is most important)
167    if resolved_dy != 0.0 {
168        let test = aabb.offset(0.0, resolved_dy, 0.0);
169        if check_overlap(world, &test) {
170            // Clamp to the nearest block boundary
171            if resolved_dy < 0.0 {
172                // Falling: snap to top of block below
173                resolved_dy = (aabb.min_y.floor() - aabb.min_y).max(resolved_dy);
174                // If still overlapping, zero out
175                if check_overlap(world, &aabb.offset(0.0, resolved_dy, 0.0)) {
176                    resolved_dy = 0.0;
177                }
178            } else {
179                // Rising: snap to bottom of block above
180                resolved_dy = (aabb.max_y.ceil() - aabb.max_y).min(resolved_dy);
181                if check_overlap(world, &aabb.offset(0.0, resolved_dy, 0.0)) {
182                    resolved_dy = 0.0;
183                }
184            }
185        }
186    }
187
188    let aabb_after_y = aabb.offset(0.0, resolved_dy, 0.0);
189
190    // Resolve X axis
191    if resolved_dx != 0.0 {
192        let test = aabb_after_y.offset(resolved_dx, 0.0, 0.0);
193        if check_overlap(world, &test) {
194            resolved_dx = 0.0;
195        }
196    }
197
198    // Resolve Z axis
199    if resolved_dz != 0.0 {
200        let test = aabb_after_y.offset(resolved_dx, 0.0, resolved_dz);
201        if check_overlap(world, &test) {
202            resolved_dz = 0.0;
203        }
204    }
205
206    (resolved_dx, resolved_dy, resolved_dz)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::world::block;
213    use crate::world::block_entity::BlockEntity;
214
215    /// Minimal flat-world mock for collision tests.
216    ///
217    /// Returns solid blocks (bedrock/dirt/grass) at y <= -61 and air
218    /// above, matching the standard superflat layout.
219    struct FlatMock;
220
221    impl WorldHandle for FlatMock {
222        fn get_block(&self, _x: i32, y: i32, _z: i32) -> u16 {
223            match y {
224                -64 => block::BEDROCK,
225                -63 | -62 => block::DIRT,
226                -61 => block::GRASS_BLOCK,
227                _ => block::AIR,
228            }
229        }
230        fn set_block(&self, _x: i32, _y: i32, _z: i32, _state: u16) {}
231        fn get_block_entity(&self, _x: i32, _y: i32, _z: i32) -> Option<BlockEntity> {
232            None
233        }
234        fn set_block_entity(&self, _x: i32, _y: i32, _z: i32, _entity: BlockEntity) {}
235        fn mark_chunk_dirty(&self, _cx: i32, _cz: i32) {}
236        fn persist_chunk(&self, _cx: i32, _cz: i32) {}
237        fn dirty_chunks(&self) -> Vec<(i32, i32)> {
238            Vec::new()
239        }
240        fn check_overlap(&self, aabb: &Aabb) -> bool {
241            check_overlap(self, aabb)
242        }
243        fn ray_cast(
244            &self,
245            origin: (f64, f64, f64),
246            direction: (f64, f64, f64),
247            max_distance: f64,
248        ) -> Option<RayHit> {
249            ray_cast(self, origin, direction, max_distance)
250        }
251        fn resolve_movement(&self, aabb: &Aabb, dx: f64, dy: f64, dz: f64) -> (f64, f64, f64) {
252            resolve_movement(self, aabb, dx, dy, dz)
253        }
254    }
255
256    fn test_world() -> FlatMock {
257        FlatMock
258    }
259
260    #[test]
261    fn aabb_from_entity() {
262        let aabb = Aabb::from_entity(0.0, -60.0, 0.0, 0.6, 1.8);
263        // f32->f64 conversion causes small precision loss, use relaxed tolerance
264        assert!(aabb.min_x < 0.0, "min_x should be negative");
265        assert!((aabb.min_y - (-60.0)).abs() < 1e-6);
266        assert!((aabb.max_y - (-58.2)).abs() < 1e-4);
267    }
268
269    #[test]
270    fn check_overlap_detects_solid() {
271        let world = test_world();
272        // AABB at y=-62 overlaps dirt block at y=-62
273        let aabb = Aabb::from_entity(0.0, -62.0, 0.0, 0.6, 1.8);
274        assert!(check_overlap(&world, &aabb));
275    }
276
277    #[test]
278    fn check_overlap_no_collision_in_air() {
279        let world = test_world();
280        // AABB at y=-60 (above grass at -61) -- all air
281        let aabb = Aabb::from_entity(0.0, -60.0, 0.0, 0.6, 1.8);
282        assert!(!check_overlap(&world, &aabb));
283    }
284
285    #[test]
286    fn ray_cast_finds_ground() {
287        let world = test_world();
288        // Cast straight down from y=-50
289        let hit = ray_cast(&world, (0.5, -50.0, 0.5), (0.0, -1.0, 0.0), 20.0);
290        assert!(hit.is_some());
291        let hit = hit.unwrap();
292        assert_eq!(hit.block_y, -61); // Grass layer
293    }
294
295    #[test]
296    fn ray_cast_misses_in_air() {
297        let world = test_world();
298        // Cast horizontally at y=-50 -- all air
299        let hit = ray_cast(&world, (0.5, -50.0, 0.5), (1.0, 0.0, 0.0), 5.0);
300        assert!(hit.is_none());
301    }
302
303    #[test]
304    fn resolve_movement_stops_at_ground() {
305        let world = test_world();
306        // Entity at y=-60 (just above grass), falling
307        let aabb = Aabb::from_entity(0.0, -60.0, 0.0, 0.6, 1.8);
308        let (dx, dy, dz) = resolve_movement(&world, &aabb, 0.0, -1.0, 0.0);
309        assert_eq!(dx, 0.0);
310        assert_eq!(dy, 0.0); // Stopped by ground
311        assert_eq!(dz, 0.0);
312    }
313
314    #[test]
315    fn resolve_movement_allows_free_fall() {
316        let world = test_world();
317        // Entity at y=-50 (high in air), falling
318        let aabb = Aabb::from_entity(0.0, -50.0, 0.0, 0.6, 1.8);
319        let (_, dy, _) = resolve_movement(&world, &aabb, 0.0, -0.5, 0.0);
320        assert!((dy - (-0.5)).abs() < f64::EPSILON);
321    }
322
323    #[test]
324    fn resolve_movement_stops_horizontal() {
325        let world = test_world();
326        // Entity overlapping ground at y=-62, horizontal blocked
327        let aabb = Aabb::from_entity(0.0, -62.0, 0.0, 0.6, 1.8);
328        let (dx, _, _) = resolve_movement(&world, &aabb, 1.0, 0.0, 0.0);
329        assert_eq!(dx, 0.0);
330    }
331}