bevy_2d_box_physics 0.1.1

A 2D box-collision physics engine for use with the bevy engine
Documentation
use crate::physics::*;
use bevy::prelude::*;
use bevy_prototype_debug_lines::*;

/// CollisionBoxes are defined as tuples, with the first reperesenting the width
/// and second the height of the collision box.
#[derive(Component, Default)]
pub struct CollisionBox(pub f32, pub f32);

/// A component that defines which collisions the attached object should collide
/// with
#[derive(Component, Default)]
pub struct CollisionLayers {
    pub layer_id: usize,
    pub collides_with_ids: Vec<usize>,
}

impl CollisionLayers {
    fn can_collide(&self, col_candidate: &Self) -> bool {
        self.collides_with_ids.contains(&col_candidate.layer_id)
    }
}

/// Contains information about the state of the collision(s)
#[derive(Component, Default, Debug)]
pub struct CollisionData {
    pub allowed_left: Option<f32>,
    pub allowed_right: Option<f32>,
    pub allowed_top: Option<f32>,
    pub allowed_bottom: Option<f32>,
    pub entity_left: Option<Entity>,
    pub entity_right: Option<Entity>,
    pub entity_top: Option<Entity>,
    pub entity_bottom: Option<Entity>,
}

fn is_collision(allowed_value: Option<f32>) -> bool {
    match allowed_value {
        Some(dist) => dist <= 0.0,
        None => false,
    }
}

impl CollisionData {
    /// Check if the (box)collider is grounded (it collides at bottom)
    pub fn is_grounded(&self) -> bool {
        is_collision(self.allowed_bottom)
    }

    /// Check if the (box)collider collides to the left side
    pub fn collides_left(&self) -> bool {
        is_collision(self.allowed_left)
    }

    /// Check if the (box)collider collides to the right side
    pub fn collides_right(&self) -> bool {
        is_collision(self.allowed_right)
    }

    /// Check if the (box)collider collides to the top
    pub fn collides_top(&self) -> bool {
        is_collision(self.allowed_top)
    }

    fn update_data(&mut self, data: Option<(f32, Entity)>, direction: &str) {
        let (allowed, closest_entity) = match direction {
            "top" => (&mut self.allowed_top, &mut self.entity_top),
            "bottom" => (&mut self.allowed_bottom, &mut self.entity_bottom),
            "left" => (&mut self.allowed_left, &mut self.entity_left),
            "right" => (&mut self.allowed_right, &mut self.entity_right),
            dir => panic!("Invalid direction {}", dir),
        };

        match data {
            Some((dist, entity)) => {
                *allowed = Some(dist);
                *closest_entity = Some(entity);
            }
            None => {
                *allowed = None;
                *closest_entity = None
            }
        }
    }
}

/// A bundle for adding a collision
#[derive(Bundle, Default)]
pub struct CollisionBundle {
    pub collision_box: CollisionBox,
    pub collision_data: CollisionData,
    pub collision_layers: CollisionLayers,
}

/// Checks for an overlap (in one dimension)
pub fn overlap(x1: f32, x2: f32, y1: f32, y2: f32) -> bool {
    if x1 < x2 {
        x1 + y1 > x2 - y2
    } else {
        x2 + y2 > x1 - y1
    }
}

/// Helper function for updating the spacing in the space hashmap if the current
/// spacing is less than the previously registered spacing.
fn update_space(
    entity: &Entity,
    space: &mut std::collections::HashMap<Entity, Option<(f32, Entity)>>,
    current_space: f32,
    colliding_entity: &Entity,
) {
    match space.get(entity) {
        Some(Some((min_space, _))) => {
            if *min_space > current_space {
                space.insert(*entity, Some((current_space, *colliding_entity)));
            }
        }
        Some(None) | None => {
            space.insert(*entity, Some((current_space, *colliding_entity)));
        }
    }
}

/// A bevy system to set the attributes of the CollisionData components
pub fn update_collision_data(
    mut query: Query<(
        Entity,
        &Transform,
        &CollisionBox,
        &CollisionLayers,
        &RigidBody,
    )>,
    mut query_collision_data: Query<&mut CollisionData>,
) {
    let mut closest_top: std::collections::HashMap<Entity, Option<(f32, Entity)>> =
        std::collections::HashMap::new();
    let mut closest_bottom: std::collections::HashMap<Entity, Option<(f32, Entity)>> =
        std::collections::HashMap::new();
    let mut closest_left: std::collections::HashMap<Entity, Option<(f32, Entity)>> =
        std::collections::HashMap::new();
    let mut closest_right: std::collections::HashMap<Entity, Option<(f32, Entity)>> =
        std::collections::HashMap::new();

    for (entity, _, _, _, _) in query.iter() {
        closest_top.insert(entity, None);
        closest_bottom.insert(entity, None);
        closest_left.insert(entity, None);
        closest_right.insert(entity, None);
    }

    let mut iter = query.iter_combinations_mut();
    while let Some(
        [(entity1, transform1, collision_box1, collision_layers1, rigidbody1), (entity2, transform2, collision_box2, collision_layers2, rigidbody2)],
    ) = iter.fetch_next()
    {
        // We don't need to check for collision on itself, or if the layers of
        // one of the collisions does not include the other.
        if !collision_layers1.can_collide(collision_layers2)
            || !collision_layers2.can_collide(collision_layers1)
        {
            continue;
        }

        // We don't need to save any collision info on gameobjects
        // with static rigidbodies. If collisiondata should be
        // registered it should use kinematic rigidbody.
        if let (RigidBody::Static, RigidBody::Static) = (rigidbody1, rigidbody2) {
            continue;
        }

        let (col_width1, col_height1) = (collision_box1.0, collision_box1.1);
        let (col_width2, col_height2) = (collision_box2.0, collision_box2.1);

        // If there's overlap along the x-axis we check for collision along the
        // y-axis

        if overlap(
            transform1.translation.x,
            transform2.translation.x,
            col_width1,
            col_width2,
        ) {
            if transform1.translation.y < transform2.translation.y {
                // transform1 is below transform2
                let current_space_top = (transform2.translation.y - col_height2)
                    - (transform1.translation.y + col_height1);
                update_space(&entity1, &mut closest_top, current_space_top, &entity2);
                update_space(&entity2, &mut closest_bottom, current_space_top, &entity1);
            } else {
                // transform1 is above transform2
                let current_space_bottom = (transform1.translation.y - col_height1)
                    - (transform2.translation.y + col_height2);
                update_space(
                    &entity1,
                    &mut closest_bottom,
                    current_space_bottom,
                    &entity2,
                );
                update_space(&entity2, &mut closest_top, current_space_bottom, &entity1);
            }
        }

        // If there's overlap along the y-axis we check for collision along the
        // x-axis
        if overlap(
            transform1.translation.y,
            transform2.translation.y,
            col_height1,
            col_height2,
        ) {
            if transform1.translation.x < transform2.translation.x {
                // transform1 is left of transform2
                let current_space_right = (transform2.translation.x - col_width2)
                    - (transform1.translation.x + col_width1);
                update_space(&entity1, &mut closest_right, current_space_right, &entity2);
                update_space(&entity2, &mut closest_left, current_space_right, &entity1);
            } else {
                // transform1 is right of transform2
                let current_space_left = (transform1.translation.x - col_width1)
                    - (transform2.translation.x + col_width2);
                update_space(&entity1, &mut closest_left, current_space_left, &entity2);
                update_space(&entity2, &mut closest_right, current_space_left, &entity1);
            }
        }
    }

    for (entity, data) in closest_top {
        if let Ok(mut collision_data) = query_collision_data.get_mut(entity) {
            collision_data.update_data(data, "top")
        }
    }
    for (entity, data) in closest_bottom {
        if let Ok(mut collision_data) = query_collision_data.get_mut(entity) {
            collision_data.update_data(data, "bottom")
        }
    }
    for (entity, data) in closest_left {
        if let Ok(mut collision_data) = query_collision_data.get_mut(entity) {
            collision_data.update_data(data, "left")
        }
    }
    for (entity, data) in closest_right {
        if let Ok(mut collision_data) = query_collision_data.get_mut(entity) {
            collision_data.update_data(data, "right")
        }
    }
}

/// Calculate how much the entity should move in the next frame based on the
/// velocity and collision data
#[allow(clippy::type_complexity)]
pub fn calculate_movement(
    mut query: Query<(&mut Velocity, &CollisionData), (With<CollisionBox>, With<RigidBody>)>,
    time: Res<Time>,
    physics_config: Res<PhysicsConfiguration>,
) {
    if !physics_config.paused {
        let dt = time.delta_seconds();
        for (mut v, collision_data) in query.iter_mut() {
            let to_move_x = v.x() * dt;
            let to_move_y = v.y() * dt;
            // Limits in x direction according to collision
            if let Some(allowed_right) = collision_data.allowed_right {
                if to_move_x > 0.0 && to_move_x > allowed_right {
                    v.set_x(allowed_right / dt);
                }
            }
            if let Some(allowed_left) = collision_data.allowed_left {
                if to_move_x < 0.0 && -to_move_x > allowed_left {
                    v.set_x(-allowed_left / dt);
                }
            }

            // Limits in y direction according to collision
            if let Some(allowed_top) = collision_data.allowed_top {
                if to_move_y > 0.0 && to_move_y > allowed_top {
                    v.set_y(allowed_top / dt);
                }
            }
            if let Some(allowed_bottom) = collision_data.allowed_bottom {
                if to_move_y < 0.0 && -to_move_y > allowed_bottom {
                    v.set_y(-allowed_bottom / dt);
                }
            }
        }
    }
}

/// A bevy system to draw the collision boxes (if the show_collision_boxes
/// attribute of the PhysicsConfiguration Resource is set to true)
pub fn draw_collision_boxes(
    query: Query<(&Transform, &CollisionBox)>,
    mut lines: ResMut<DebugLines>,
    physics_configuration: Res<PhysicsConfiguration>,
) {
    if physics_configuration.show_collision_boxes {
        for (transform, collision_box) in query.iter() {
            let duration = 0.0;
            let top_left_corner =
                transform.translation + Vec3::new(-collision_box.0, collision_box.1, 0.0);
            let bottom_left_corner = top_left_corner + Vec3::new(0.0, -collision_box.1 * 2.0, 0.0);
            let bottom_right_corner =
                bottom_left_corner + Vec3::new(collision_box.0 * 2.0, 0.0, 0.0);
            let top_right_corner = bottom_right_corner + Vec3::new(0.0, collision_box.1 * 2.0, 0.0);
            lines.line(bottom_left_corner, bottom_right_corner, duration);
            lines.line(bottom_right_corner, top_right_corner, duration);
            lines.line(top_right_corner, top_left_corner, duration);
            lines.line(top_left_corner, bottom_left_corner, duration);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{tests::create_physics_app, PhysicsBundle};

    #[test]
    fn overlap_works() {
        // One side out, one side in
        assert!(overlap(10.0, 12.0, 4.0, 10.0));
        assert!(overlap(0.0, 12.0, 14.0, 10.0));
        // Inside
        assert!(overlap(-1.0, 6.0, 12.0, 2.0));
        // Directly over (we want this to overlap)
        assert!(overlap(0.0, 0.0, 16.0, 16.0));
        assert!(overlap(0.0, 4.0, 3.0, 3.0));
        assert!(overlap(16.0, 10.0, 4.0, 3.0));
    }

    #[test]
    fn updates_space_correctly() {
        let entity1 = Entity::from_raw(1);
        let entity2 = Entity::from_raw(2);

        let dist_to_closest = 10.0;
        let mut data: std::collections::HashMap<Entity, Option<(f32, Entity)>> =
            std::collections::HashMap::new();

        update_space(&entity1, &mut data, dist_to_closest, &entity2);
        assert_eq!(data[&entity1], Some((dist_to_closest, entity2)));

        // Seeing as this is further away from the current closest the
        // closest should remain 10.0
        let new_dist_to_closest = dist_to_closest + 10.0;

        update_space(&entity1, &mut data, new_dist_to_closest, &entity2);
        assert_eq!(data[&entity1], Some((dist_to_closest, entity2)));

        // Since the distance now is at record low the closest distance should be the same.
        let new_dist_to_closest = dist_to_closest / 2.0;

        update_space(&entity1, &mut data, new_dist_to_closest, &entity2);
        assert_eq!(data[&entity1], Some((new_dist_to_closest, entity2)));
    }

    #[test]
    fn collision_works_simple() {
        let mut app = create_physics_app();

        let collision_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 0,
                    collides_with_ids: vec![0, 1],
                },
                ..default()
            })
            .insert(Transform::from_xyz(0.0, 0.0, 0.0))
            .id();

        let to_collide_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 0,
                    collides_with_ids: vec![0, 1],
                },
                ..default()
            })
            .insert(Transform::from_xyz(20.0, 0.0, 0.0))
            .id();

        app.update();

        let col_data1 = app.world.get::<CollisionData>(collision_id).unwrap();
        let col_data2 = app.world.get::<CollisionData>(to_collide_id).unwrap();

        assert_eq!(col_data1.allowed_right, Some(4.0));
        assert_eq!(col_data2.allowed_left, Some(4.0));
        assert_eq!(col_data1.allowed_top, None);
        assert_eq!(col_data1.allowed_bottom, None);
        assert_eq!(col_data1.allowed_left, None);

        assert_eq!(col_data1.entity_right, Some(to_collide_id));
        assert_eq!(col_data2.entity_left, Some(collision_id));
        assert_eq!(col_data1.entity_top, None);
        assert_eq!(col_data1.entity_bottom, None);
        assert_eq!(col_data1.entity_left, None);
    }

    #[test]
    fn collision_works_advanced() {
        let mut app = create_physics_app();
        let collision_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 0,
                    collides_with_ids: vec![0, 1, 2],
                },
                ..default()
            })
            .insert(Transform::from_xyz(0.0, 0.0, 0.0))
            .id();

        let to_collide_1_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 2,
                    collides_with_ids: vec![0, 2],
                },
                ..default()
            })
            .insert(Transform::from_xyz(-10.0, -24.0, 0.0))
            .id();

        let to_collide_2_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 1,
                    collides_with_ids: vec![0, 1, 2],
                },
                ..default()
            })
            .insert(Transform::from_xyz(10.0, -24.0, 0.0))
            .id();

        app.update();

        // Since object 1 is directly over object 2 and 3, with a margin of 8px
        // allowed bottom of 1 (and allowed top of 2 and 3) should be 8. Since
        // the layers of 2 and 3 aren't compatible no sideways
        // collisions/margins should be present.
        let col_data1 = app.world.get::<CollisionData>(collision_id).unwrap();
        let col_data2 = app.world.get::<CollisionData>(to_collide_1_id).unwrap();
        let col_data3 = app.world.get::<CollisionData>(to_collide_2_id).unwrap();

        assert_eq!(col_data1.allowed_bottom, Some(8.0));
        assert_eq!(col_data1.allowed_top, None);
        assert_eq!(col_data2.allowed_top, Some(8.0));
        assert_eq!(col_data3.allowed_top, Some(8.0));
        assert_eq!(col_data2.allowed_right, None);
        assert_eq!(col_data3.allowed_left, None);
    }

    #[test]
    fn perfect_overlap_collision_collides() {
        let mut app = create_physics_app();
        // Checks that a collision box (16x16 px) which is directly
        // over/under/besides another (16x16 px) collision box collides
        let collision_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 0,
                    collides_with_ids: vec![0, 1],
                },
                ..default()
            })
            .insert(Transform::from_xyz(0.0, 16.0, 0.0))
            .id();

        let to_collide_id = app
            .world
            .spawn_empty()
            .insert(PhysicsBundle {
                rigidbody: RigidBody::Dynamic,
                ..default()
            })
            .insert(CollisionBundle {
                collision_box: CollisionBox(8.0, 8.0),
                collision_layers: CollisionLayers {
                    layer_id: 1,
                    collides_with_ids: vec![0, 1, 2],
                },
                ..default()
            })
            .insert(Transform::from_xyz(0.0, 0.0, 0.0))
            .id();

        app.update();

        // This should only collide with to_collide_1 because the layers
        let col_data1 = app.world.get::<CollisionData>(collision_id).unwrap();
        // This should likewise should only collide with collision because layers
        let col_data2 = app.world.get::<CollisionData>(to_collide_id).unwrap();

        assert_eq!(col_data1.allowed_bottom, Some(0.0));
        assert_eq!(col_data2.allowed_top, Some(0.0));
    }
}