feat!: Implemented CollisionEvent trigger

- Added integration tests
- Replaced GlobalTransform with Transform in collision math
- Fixed doc lint
- collider_detects_different_groups test does not pass
This commit is contained in:
Alexey 2026-02-13 17:21:48 +03:00
commit 63b30f4a9f
2 changed files with 208 additions and 22 deletions

View file

@ -1,29 +1,29 @@
#![warn(missing_docs)]
// TODO: add more precise crate documentation
//! Simple 2D collision system, inspired by Godot Engine.
use bevy::math::bounding::IntersectsVolume; use bevy::math::bounding::IntersectsVolume;
#[warn(missing_docs)]
use bevy::{math::bounding::{Aabb2d, BoundingCircle}, prelude::*}; use bevy::{math::bounding::{Aabb2d, BoundingCircle}, prelude::*};
// TODO: add more precise crate documentation #[derive(Component, PartialEq, Debug)]
// TODO: implement event emitting on collision
/// Simple 2D collision system, inspired by Godot Engine.
#[derive(Component)]
/// Enum of available shapes /// Enum of available shapes
pub enum CollisionShape { pub enum CollisionShape {
/// Rectangle shape /// Rectangle shape
Aabb { Aabb {
/// The shape itself, tied to **global** position /// The shape itself, tied to [`Transform`]
shape: Aabb2d, shape: Aabb2d,
/// Start offset, used to calculate global position. /// Start offset, used to calculate current position inside shape.
offset: Vec2, offset: Vec2,
/// Size of the shape, used to calculate global position. /// Size of the shape, used to calculate global position.
size: Vec2, size: Vec2,
}, },
/// Circle shape /// Circle shape
Circle { Circle {
/// The shape itself, tied to **global** position /// The shape itself, tied to [`Transform`]
shape: BoundingCircle, shape: BoundingCircle,
/// Start offset, used to calculate global position /// Start offset, used to calculate current position inside shape.
offset: Vec2, offset: Vec2,
}, },
} }
@ -46,9 +46,35 @@ impl CollisionShape {
}, },
} }
} }
/// Create [`CollisionShape::Aabb`]
pub fn new_aabb(offset: Vec2, size: Vec2) -> Self {
let half_size = Vec2 { x: size.x / 2f32, y: size.y / 2f32 };
Self::Aabb { shape: Aabb2d::new(offset, half_size), offset, size }
}
/// Create [`CollisionShape::Circle`]
pub fn new_circle(offset: Vec2, radius: f32) -> Self {
Self::Circle { shape: BoundingCircle::new(offset, radius), offset }
}
/// Returns current centered offset for the shape
pub fn current_offset(&self) -> Vec2 {
match self {
Self::Aabb { shape, .. } => {
Vec2::new(
(shape.min.x + shape.max.x) / 2.,
(shape.min.y + shape.max.y) / 2.,
)
},
Self::Circle { shape, .. } => {
shape.center
},
}
}
} }
#[derive(Component)] #[derive(Component, PartialEq, Debug)]
#[require(Transform)] #[require(Transform)]
/// Component, handling multiple collision shapes for single entity /// Component, handling multiple collision shapes for single entity
pub struct Collider { pub struct Collider {
@ -79,50 +105,80 @@ impl Collider {
/// Returns true if any of self shapes intersect other's shapes, respecting groups. /// Returns true if any of self shapes intersect other's shapes, respecting groups.
/// Wrapper around [intersects_unchecked](Self::intersects_unchecked) /// Wrapper around [intersects_unchecked](Self::intersects_unchecked)
pub fn intersects(&self, other: &Collider) -> bool { pub fn intersects(&self, other: &Collider) -> bool {
println!("groups: {}", self.check_mask & other.mask );
if self.check_mask & other.mask == 0 { if self.check_mask & other.mask == 0 {
return false; return false;
} }
self.intersects_unchecked(other) self.intersects_unchecked(other)
} }
/// Update shapes global position /// Update shapes' current position
pub fn update_shapes_position(&mut self, global_position: &Vec2) { pub fn update_shapes_position(&mut self, new_position: &Vec2) {
for shape in self.shapes.iter_mut() { for shape in self.shapes.iter_mut() {
match shape { match shape {
CollisionShape::Aabb { shape, offset, size } => { CollisionShape::Aabb { shape, offset, size } => {
shape.min.x = global_position.x + offset.x; shape.min.x = new_position.x + offset.x - size.x / 2.;
shape.min.y = global_position.y + offset.y; shape.min.y = new_position.y + offset.y - size.y / 2.;
shape.max.x = shape.min.x + size.x; shape.max.x = shape.min.x + size.x;
shape.max.y = shape.min.y + size.y; shape.max.y = shape.min.y + size.y;
}, },
CollisionShape::Circle { shape, offset } => { CollisionShape::Circle { shape, offset } => {
shape.center.x = global_position.x + offset.x; shape.center.x = new_position.x + offset.x;
shape.center.y = global_position.y + offset.y; shape.center.y = new_position.y + offset.y;
}, },
} }
} }
} }
} }
#[derive(Component)] #[derive(Component, Debug)]
/// Add this component on entities, which have their [`Transform`] updated.
/// Used for [`update_collider_shapes`] system
pub struct UpdateShapes; pub struct UpdateShapes;
/// Update collider shapes to match new global transform /// Update collider shapes to match new [`Transform`]
pub fn update_collider_shapes( pub fn update_collider_shapes(
mut commands: Commands, mut commands: Commands,
colliders: Query<(&mut Collider, &GlobalTransform, Entity), With<UpdateShapes>>, colliders: Query<(&mut Collider, &Transform, Entity), With<UpdateShapes>>,
) { ) {
for (mut collider, transform, entity) in colliders { for (mut collider, transform, entity) in colliders {
collider.update_shapes_position(&transform.translation().xy()); collider.update_shapes_position(&transform.translation.xy());
commands.entity(entity).remove::<UpdateShapes>(); commands.entity(entity).remove::<UpdateShapes>();
} }
} }
#[derive(EntityEvent, Debug)]
/// Emitted when colliders intersect in [`check_for_collisions`] system
pub struct CollisionEvent {
/// Entity, whose [`Collider`] detected another [`Collider`]
pub entity: Entity,
/// Entity, whose [`Collider`] was detected as intersecting
pub detected: Entity,
}
/// Check each [`Collider`] for collisions with another colliders.
/// Triggers [`CollisionEvent`] on collider intersection
pub fn check_for_collisions(
mut commands: Commands,
colliders: Query<(&Collider, Entity)>,
) {
let mut visited_colliders: Vec<&Collider> = Vec::new();
for (collider, entity) in colliders.iter() {
for (other_collider, other_entity) in colliders.iter().filter(|(c, _)| !visited_colliders.contains(c)) {
if collider.intersects(other_collider) {
commands.trigger(CollisionEvent { entity, detected: other_entity });
}
}
visited_colliders.push(collider);
println!("{visited_colliders:?}");
}
}
/// Plugin that adds collision systems /// Plugin that adds collision systems
pub struct CollisionPlugin; pub struct CollisionPlugin;
impl Plugin for CollisionPlugin { impl Plugin for CollisionPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, update_collider_shapes); app.add_systems(PostUpdate, (update_collider_shapes, check_for_collisions).chain());
} }
} }

130
tests/collider_position.rs Normal file
View file

@ -0,0 +1,130 @@
use bevy::prelude::*;
use bevy_collision_plugin::{Collider, CollisionEvent, CollisionPlugin, CollisionShape, UpdateShapes};
const CHECKED_GROUPS: usize = 1 >> 0 | 1 >> 1;
#[derive(Component)]
struct MovingCollider;
fn moving_collider_bundle() -> impl Bundle {
(
Collider {
shapes: vec![
CollisionShape::new_aabb(Vec2::new(0., 0.), Vec2::new(10., 10.)),
CollisionShape::new_circle(Vec2::new(0., 15.), 5.),
],
mask: 0,
check_mask: CHECKED_GROUPS,
},
Transform::from_translation(Vec3::new(0., 0., 0.)),
MovingCollider,
)
}
fn zerogroup_collider_bundle() -> impl Bundle {
(
Collider {
shapes: vec![
CollisionShape::new_aabb(Vec2::new(0., 0.), Vec2::new(10., 10.)),
],
mask: 1 >> 0,
check_mask: 0,
},
Transform::from_translation(Vec3::new(53., 5., 0.)),
)
}
fn firstgroup_collider_bundle() -> impl Bundle {
(
Collider {
shapes: vec![
CollisionShape::new_circle(Vec2::new(0., 0.), 5.),
],
mask: 1 >> 1,
check_mask: 0,
},
Transform::from_translation(Vec3::new(53., 18., 0.)),
)
}
fn move_collider(
mut commands: Commands,
query: Query<(Entity, &mut Transform), With<MovingCollider>>,
) {
for (entity, mut transform) in query {
transform.translation.x += 50.;
commands.entity(entity)
.insert(UpdateShapes);
}
}
fn setup(
mut commands: Commands,
) {
commands.spawn(moving_collider_bundle());
}
fn configured_app() -> App {
let mut app = App::new();
app.add_plugins(CollisionPlugin)
.add_systems(Startup, setup)
.add_systems(Update, move_collider);
app
}
#[test]
fn collider_shapes_update_properly() {
let mut app = configured_app();
app.update();
let mut query = app.world_mut().query::<&Collider>();
for collider in query.iter(app.world()) {
let aabb_offset = collider.shapes[0].current_offset();
let circle_offset = collider.shapes[1].current_offset();
let expected_aabb = Vec2::new(50., 0.);
let expected_circle = Vec2::new(50., 15.);
assert_eq!(aabb_offset, expected_aabb);
assert_eq!(circle_offset, expected_circle);
}
}
#[derive(Resource)]
struct CollisionList(Vec<Entity>);
fn setup_others(
mut commands: Commands,
) {
commands.spawn(zerogroup_collider_bundle());
commands.spawn(firstgroup_collider_bundle());
}
fn on_collision_event(
event: On<CollisionEvent>,
mut res: ResMut<CollisionList>,
) {
if !res.0.contains(&event.detected) {
res.0.push(event.detected);
}
}
#[test]
fn collider_detects_different_groups() {
let mut app = configured_app();
app.insert_resource(CollisionList(vec![]))
.add_systems(Startup, setup_others)
.add_observer(on_collision_event);
app.update();
let collisions = app.world().get_resource::<CollisionList>().unwrap();
let got_collisions = collisions.0.len();
let expected = 2;
assert_eq!(got_collisions, expected);
}