Merge agent branch agent/p2-4129b0f8 into main
This commit is contained in:
@@ -13,6 +13,7 @@ use crate::gameplay::galaxy::{
|
||||
SystemContents,
|
||||
};
|
||||
use crate::gameplay::in_system::DockedState;
|
||||
use crate::gameplay::physics::{BodyMass, ProximitySensor};
|
||||
|
||||
/// Tracks the currently active system for gameplay.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
@@ -45,6 +46,16 @@ pub struct DockingTarget {
|
||||
/// Offset from station where player ship spawns when docked.
|
||||
const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0);
|
||||
|
||||
/// World-space collision radius for the player ship. Matches its scaled
|
||||
/// visual extent (a ~0.12-scale cuboid) so it cannot overlap stations,
|
||||
/// planets, or the star.
|
||||
const PLAYER_COLLIDER_RADIUS: f32 = 0.15;
|
||||
|
||||
/// World-space radius of the player ship's proximity sensor — the range at
|
||||
/// which nearby bodies (stations, belts, the star) register as in-range via
|
||||
/// `ProximityEvent`s.
|
||||
const PLAYER_SCAN_RADIUS: f32 = 8.0;
|
||||
|
||||
/// Represents a docking target (either a station or a habitable planet).
|
||||
#[derive(Debug, Clone)]
|
||||
struct DockingTargetInfo {
|
||||
@@ -352,6 +363,15 @@ fn spawn_player_ship_docked(
|
||||
Docked {
|
||||
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
|
||||
},
|
||||
// Physical presence: a collider for the collision pipeline and a
|
||||
// proximity sensor for spatial queries. `BodyMass` weights
|
||||
// ship-to-ship separation; the ship has no `Velocity` while docked
|
||||
// so the resolver treats it as immovable until undock.
|
||||
BoundingVolume {
|
||||
radius: PLAYER_COLLIDER_RADIUS,
|
||||
},
|
||||
BodyMass(1.0),
|
||||
ProximitySensor::new(PLAYER_SCAN_RADIUS),
|
||||
Transform::from_translation(ship_position).with_scale(Vec3::splat(0.12)),
|
||||
Visibility::default(),
|
||||
InheritedVisibility::default(),
|
||||
|
||||
81
apps/game/src/gameplay/physics/components.rs
Normal file
81
apps/game/src/gameplay/physics/components.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! Component and event definitions for the kinematic physics/collision layer.
|
||||
//!
|
||||
//! Per ARCH-7 ("Movement & Collision — No Physics Engine") there is no
|
||||
//! rigid-body solver: movement is point-and-shoot and collision is
|
||||
//! distance-based. These components describe *who* participates in collision
|
||||
//! and proximity queries; the actual resolution is pure position/velocity math
|
||||
//! living in [`super::systems`] and [`super::geometry`].
|
||||
//!
|
||||
//! Collision radii come from [`crate::gameplay::galaxy::BoundingVolume`], which
|
||||
//! is the single source of truth for "how big is this thing" already attached
|
||||
//! to every spawned POI. [`BodyMass`] is the physics-owned counterpart for the
|
||||
//! *momentum* of movable bodies (ships) — it is deliberately separate from the
|
||||
//! galaxy [`crate::gameplay::galaxy::Massive`] component, which describes
|
||||
//! gravity-well scenery rather than ship inertia.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Mass of a movable body, used to weight collision separation.
|
||||
///
|
||||
/// Heavier bodies are displaced less than lighter ones when two movable bodies
|
||||
/// overlap. Static scenery (planets, stations, the star) has no
|
||||
/// [`crate::gameplay::movement::components::Velocity`] and is treated as
|
||||
/// immovable regardless of mass, so it does not need this component.
|
||||
///
|
||||
/// Defaults to `1.0`. Values are floored internally to avoid division by zero.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct BodyMass(pub f32);
|
||||
|
||||
impl Default for BodyMass {
|
||||
fn default() -> Self {
|
||||
Self(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A proximity scanner. Each frame the owner queries the world for physical
|
||||
/// bodies (entities with a [`crate::gameplay::galaxy::BoundingVolume`]) entering
|
||||
/// or leaving `radius` (world units) and emits [`ProximityEvent`]s for the
|
||||
/// transitions.
|
||||
///
|
||||
/// `occupants` is authoritative per-sensor state managed by
|
||||
/// [`super::systems::update_proximity_sensors`] — do not edit it manually.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProximitySensor {
|
||||
/// World-space scan radius.
|
||||
pub radius: f32,
|
||||
/// Bodies currently inside the radius, tracked frame to frame.
|
||||
pub occupants: Vec<Entity>,
|
||||
}
|
||||
|
||||
impl ProximitySensor {
|
||||
pub fn new(radius: f32) -> Self {
|
||||
Self {
|
||||
radius,
|
||||
occupants: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fired whenever two physical bodies overlap, after positional separation has
|
||||
/// been *planned* (but before it is applied). `normal` points from `a` toward
|
||||
/// `b`; `penetration` is the overlap depth in world units.
|
||||
///
|
||||
/// Future combat/damage systems read this to apply hit effects; today it is the
|
||||
/// observable signal that the collision pipeline is running.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CollisionEvent {
|
||||
pub a: Entity,
|
||||
pub b: Entity,
|
||||
pub normal: Vec3,
|
||||
pub penetration: f32,
|
||||
}
|
||||
|
||||
/// A proximity transition. Emitted by [`ProximitySensor`]s when a body enters or
|
||||
/// leaves the scan radius.
|
||||
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProximityEvent {
|
||||
/// `sensor` detected `other` entering its radius this frame.
|
||||
Enter { sensor: Entity, other: Entity },
|
||||
/// `other` left the sensor's radius this frame.
|
||||
Exit { sensor: Entity, other: Entity },
|
||||
}
|
||||
@@ -71,6 +71,49 @@ pub fn segment_vs_sphere(
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// Sphere-sphere overlap test. Returns `(penetration, normal)` where `normal`
|
||||
/// is the unit vector pointing from `a` toward `b`, or `None` if they do not
|
||||
/// overlap or their centers coincide (ambiguous direction).
|
||||
///
|
||||
/// `penetration` is always positive and equals the distance the two spheres
|
||||
/// must be separated along `normal` to just stop overlapping.
|
||||
pub fn sphere_overlap(
|
||||
a_pos: Vec3,
|
||||
a_radius: f32,
|
||||
b_pos: Vec3,
|
||||
b_radius: f32,
|
||||
) -> Option<(f32, Vec3)> {
|
||||
let delta = b_pos - a_pos;
|
||||
let dist_sq = delta.length_squared();
|
||||
let combined = a_radius + b_radius;
|
||||
if dist_sq >= combined * combined || dist_sq == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let dist = dist_sq.sqrt();
|
||||
Some((combined - dist, delta / dist))
|
||||
}
|
||||
|
||||
/// Split a positional correction between two bodies by inverse mass.
|
||||
///
|
||||
/// `normal` points from body `a` toward body `b`; `penetration` is the overlap
|
||||
/// depth. Returns `(push_a, push_b)` where `push_a` moves `a` away from `b`
|
||||
/// and `push_b` moves `b` away from `a`. `inv_a` / `inv_b` are inverse masses
|
||||
/// — pass `0.0` for an immovable body so it absorbs no correction.
|
||||
pub fn split_by_inverse_mass(
|
||||
normal: Vec3,
|
||||
penetration: f32,
|
||||
inv_a: f32,
|
||||
inv_b: f32,
|
||||
) -> (Vec3, Vec3) {
|
||||
let inv_sum = inv_a + inv_b;
|
||||
if inv_sum <= 0.0 {
|
||||
return (Vec3::ZERO, Vec3::ZERO);
|
||||
}
|
||||
let push_a = -normal * penetration * (inv_a / inv_sum);
|
||||
let push_b = normal * penetration * (inv_b / inv_sum);
|
||||
(push_a, push_b)
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -144,4 +187,44 @@ mod tests {
|
||||
);
|
||||
assert_eq!(t, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_overlap_reports_penetration_and_normal() {
|
||||
let (pen, normal) =
|
||||
sphere_overlap(Vec3::ZERO, 1.0, Vec3::new(1.0, 0.0, 0.0), 1.0).unwrap();
|
||||
assert!((pen - 1.0).abs() < 1e-5);
|
||||
assert!((normal - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_overlap_none_when_apart() {
|
||||
assert!(sphere_overlap(Vec3::ZERO, 1.0, Vec3::new(5.0, 0.0, 0.0), 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_overlap_none_when_coincident() {
|
||||
assert!(sphere_overlap(Vec3::ZERO, 1.0, Vec3::ZERO, 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_gives_full_correction_to_movable_body() {
|
||||
// normal a→b = +X, penetration 2.0, b immovable (inv_b = 0).
|
||||
let (push_a, push_b) = split_by_inverse_mass(Vec3::new(1.0, 0.0, 0.0), 2.0, 1.0, 0.0);
|
||||
assert!((push_a - Vec3::new(-2.0, 0.0, 0.0)).length() < 1e-5);
|
||||
assert!(push_b.length() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_halves_correction_for_equal_mass() {
|
||||
let (push_a, push_b) = split_by_inverse_mass(Vec3::new(1.0, 0.0, 0.0), 2.0, 1.0, 1.0);
|
||||
assert!((push_a - Vec3::new(-1.0, 0.0, 0.0)).length() < 1e-5);
|
||||
assert!((push_b - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_is_zero_when_both_immovable() {
|
||||
let (push_a, push_b) = split_by_inverse_mass(Vec3::new(1.0, 0.0, 0.0), 2.0, 0.0, 0.0);
|
||||
assert!(push_a.length() < 1e-5);
|
||||
assert!(push_b.length() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
//! Kinematic physics & collision layer.
|
||||
//!
|
||||
//! See ARCH-7 ("Movement & Collision — No Physics Engine"): there is no
|
||||
//! rigid-body solver. Collision is distance-based sphere overlap, separation is
|
||||
//! inverse-mass-weighted position correction, and proximity is a radius query.
|
||||
//! Geometry primitives live in [`geometry`]; components/events in
|
||||
//! [`components`]; the per-frame systems in [`systems`].
|
||||
//!
|
||||
//! The collision pipeline runs every frame during `AppState::InGame`, ordered
|
||||
//! after kinematic velocity integration so it resolves the latest positions.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::gameplay::movement;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod components;
|
||||
pub mod geometry;
|
||||
pub mod systems;
|
||||
|
||||
pub use components::{BodyMass, CollisionEvent, ProximityEvent, ProximitySensor};
|
||||
pub use systems::FrameCollisions;
|
||||
|
||||
pub struct PhysicsPlugin;
|
||||
|
||||
impl Plugin for PhysicsPlugin {
|
||||
fn build(&self, _app: &mut App) {
|
||||
// Geometry primitives in `geometry` are pure functions called directly by
|
||||
// gameplay systems. No global systems yet — wire them up when combat /
|
||||
// collision is implemented.
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_event::<CollisionEvent>()
|
||||
.add_event::<ProximityEvent>()
|
||||
.init_resource::<FrameCollisions>()
|
||||
// Collision pipeline: detect (read-only) → resolve (writes
|
||||
// Transform + Velocity), ordered after kinematic integration so
|
||||
// the positions being resolved are current.
|
||||
.add_systems(
|
||||
Update,
|
||||
(systems::detect_collisions, systems::resolve_collisions)
|
||||
.chain()
|
||||
.run_if(in_state(AppState::InGame))
|
||||
.after(movement::kinematic::integrate_velocity),
|
||||
)
|
||||
// Spatial queries: proximity sensors emit Enter/Exit events.
|
||||
.add_systems(
|
||||
Update,
|
||||
systems::update_proximity_sensors.run_if(in_state(AppState::InGame)),
|
||||
)
|
||||
// Debug observer for both event streams (reads the event fields so
|
||||
// they stay live until combat/docking systems consume them).
|
||||
.add_systems(
|
||||
Update,
|
||||
systems::log_physics_events.run_if(in_state(AppState::InGame)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,402 @@
|
||||
// Slice 1 has no collision systems yet — geometry primitives in `geometry.rs` are
|
||||
// defined and unit-tested but not yet wired into any Bevy systems.
|
||||
//
|
||||
// Future systems to add here:
|
||||
// - `projectile_hits`: query projectiles against damageable entities using `overlaps`
|
||||
// or `segment_vs_sphere` (for fast-moving projectiles that might tunnel).
|
||||
// - `ship_separation`: push ships apart when they get too close using `separate`.
|
||||
// - `proximity_triggers`: emit events when entities enter/exit a radius.
|
||||
//
|
||||
// When adding these, register them in `PhysicsPlugin::build()`. They should run on
|
||||
// `FixedUpdate` for determinism (see ARCH-9 in the docs).
|
||||
//! Kinematic collision resolution and proximity queries.
|
||||
//!
|
||||
//! Per ARCH-7 there is no physics engine: movement is point-and-shoot and
|
||||
//! collision is distance-based. This module wires the pure geometry in
|
||||
//! [`super::geometry`] into Bevy systems that run each frame during gameplay.
|
||||
//!
|
||||
//! ## Collision pipeline
|
||||
//!
|
||||
//! 1. [`detect_collisions`] snapshots every physical body (anything with a
|
||||
//! [`BoundingVolume`]) and runs an O(n²) broad+narrow phase, computing
|
||||
//! mass-weighted position corrections and velocity cancels into
|
||||
//! [`FrameCollisions`], and emitting [`CollisionEvent`]s.
|
||||
//! 2. [`resolve_collisions`] drains [`FrameCollisions`] and applies the
|
||||
//! corrections to `Transform` and `Velocity`.
|
||||
//!
|
||||
//! The split exists so detection (read-only) and resolution (write) never alias
|
||||
//! the same component in a single system. A body is "movable" iff it has a
|
||||
//! [`Velocity`]; everything else (planets, stations, the star, asteroid belts)
|
||||
//! is immovable scenery the ship bounces off. Ship-to-ship separation is
|
||||
//! weighted by inverse [`BodyMass`].
|
||||
//!
|
||||
//! ## Spatial queries
|
||||
//!
|
||||
//! [`update_proximity_sensors`] advances every [`ProximitySensor`], detecting
|
||||
//! bodies entering/leaving its radius and emitting [`ProximityEvent`]s — the
|
||||
//! building block for docking-range, mining-range, and mass-lock checks.
|
||||
//!
|
||||
//! ## Hierarchy assumption
|
||||
//!
|
||||
//! In-system entities are children of the scene root at the origin, so local
|
||||
//! `Transform` equals world space. World-space corrections are therefore
|
||||
//! applied directly to `Transform.translation`. This matches the existing
|
||||
//! convention in `movement::orbit` and `galaxy::orbits`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::gameplay::galaxy::BoundingVolume;
|
||||
use crate::gameplay::movement::components::Velocity;
|
||||
|
||||
use super::components::{BodyMass, CollisionEvent, ProximityEvent, ProximitySensor};
|
||||
use super::geometry::{sphere_overlap, split_by_inverse_mass};
|
||||
|
||||
/// Minimum mass floor so inverse-mass never divides by zero.
|
||||
const MASS_FLOOR: f32 = 1e-6;
|
||||
|
||||
/// Per-frame collision corrections: produced by [`detect_collisions`] and
|
||||
/// consumed by [`resolve_collisions`].
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct FrameCollisions {
|
||||
/// `(entity, world-space delta)` to add to each body's translation.
|
||||
pub pushes: HashMap<Entity, Vec3>,
|
||||
/// `(entity, world-space delta)` to subtract from each body's velocity.
|
||||
pub cancels: HashMap<Entity, Vec3>,
|
||||
}
|
||||
|
||||
/// Read-only snapshot of a body used by the pairwise resolver.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct BodySnapshot {
|
||||
entity: Entity,
|
||||
pos: Vec3,
|
||||
radius: f32,
|
||||
mass: f32,
|
||||
movable: bool,
|
||||
velocity: Vec3,
|
||||
}
|
||||
|
||||
/// A planned contact, kept around to emit [`CollisionEvent`]s.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct PlannedContact {
|
||||
a: Entity,
|
||||
b: Entity,
|
||||
normal: Vec3,
|
||||
penetration: f32,
|
||||
}
|
||||
|
||||
/// Pure output of [`plan_collisions`].
|
||||
#[derive(Default)]
|
||||
struct CollisionPlan {
|
||||
pushes: HashMap<Entity, Vec3>,
|
||||
cancels: HashMap<Entity, Vec3>,
|
||||
contacts: Vec<PlannedContact>,
|
||||
}
|
||||
|
||||
impl CollisionPlan {
|
||||
fn add_push(&mut self, entity: Entity, delta: Vec3) {
|
||||
self.pushes
|
||||
.entry(entity)
|
||||
.and_modify(|p| *p += delta)
|
||||
.or_insert(delta);
|
||||
}
|
||||
|
||||
fn add_cancel(&mut self, entity: Entity, delta: Vec3) {
|
||||
self.cancels
|
||||
.entry(entity)
|
||||
.and_modify(|c| *c += delta)
|
||||
.or_insert(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot physical bodies and compute the collision plan for this frame.
|
||||
///
|
||||
/// Pure (no ECS access) so it can be unit-tested directly.
|
||||
fn plan_collisions(bodies: &[BodySnapshot]) -> CollisionPlan {
|
||||
let mut plan = CollisionPlan::default();
|
||||
for (i, a) in bodies.iter().enumerate() {
|
||||
for b in &bodies[i + 1..] {
|
||||
if !a.movable && !b.movable {
|
||||
continue;
|
||||
}
|
||||
let Some((penetration, normal)) = sphere_overlap(a.pos, a.radius, b.pos, b.radius)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let inv_a = if a.movable { 1.0 / a.mass } else { 0.0 };
|
||||
let inv_b = if b.movable { 1.0 / b.mass } else { 0.0 };
|
||||
let (push_a, push_b) = split_by_inverse_mass(normal, penetration, inv_a, inv_b);
|
||||
// Only movable bodies accrue positional correction; immovable
|
||||
// scenery is never displaced.
|
||||
if a.movable {
|
||||
plan.add_push(a.entity, push_a);
|
||||
}
|
||||
if b.movable {
|
||||
plan.add_push(b.entity, push_b);
|
||||
}
|
||||
|
||||
// Cancel the velocity component driving each movable body into the
|
||||
// other, so ships slide along obstacles instead of vibrating.
|
||||
if a.movable {
|
||||
let inbound = a.velocity.dot(normal);
|
||||
if inbound > 0.0 {
|
||||
plan.add_cancel(a.entity, normal * inbound);
|
||||
}
|
||||
}
|
||||
if b.movable {
|
||||
let toward_a = -normal;
|
||||
let inbound = b.velocity.dot(toward_a);
|
||||
if inbound > 0.0 {
|
||||
plan.add_cancel(b.entity, toward_a * inbound);
|
||||
}
|
||||
}
|
||||
|
||||
plan.contacts.push(PlannedContact {
|
||||
a: a.entity,
|
||||
b: b.entity,
|
||||
normal,
|
||||
penetration,
|
||||
});
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// Snapshot physical bodies, compute the collision plan, stash it in
|
||||
/// [`FrameCollisions`], and emit [`CollisionEvent`]s for every contact.
|
||||
///
|
||||
/// Three narrow queries keep the system's component access simple and
|
||||
/// conflict-free (position/radius, mass, and velocity are disjoint
|
||||
/// components), looked up per-entity while building snapshots.
|
||||
pub fn detect_collisions(
|
||||
bodies_query: Query<(Entity, &GlobalTransform, &BoundingVolume)>,
|
||||
masses: Query<&BodyMass>,
|
||||
velocities: Query<&Velocity>,
|
||||
mut frame: ResMut<FrameCollisions>,
|
||||
mut events: EventWriter<CollisionEvent>,
|
||||
) {
|
||||
let bodies: Vec<BodySnapshot> = bodies_query
|
||||
.iter()
|
||||
.map(|(entity, gtf, volume)| {
|
||||
let movable = velocities.get(entity).is_ok();
|
||||
let mass = masses
|
||||
.get(entity)
|
||||
.map(|m| m.0)
|
||||
.unwrap_or(1.0)
|
||||
.max(MASS_FLOOR);
|
||||
let velocity = velocities.get(entity).map(|v| v.0).unwrap_or(Vec3::ZERO);
|
||||
BodySnapshot {
|
||||
entity,
|
||||
pos: gtf.translation(),
|
||||
radius: volume.radius,
|
||||
mass,
|
||||
movable,
|
||||
velocity,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let plan = plan_collisions(&bodies);
|
||||
for contact in &plan.contacts {
|
||||
events.write(CollisionEvent {
|
||||
a: contact.a,
|
||||
b: contact.b,
|
||||
normal: contact.normal,
|
||||
penetration: contact.penetration,
|
||||
});
|
||||
}
|
||||
|
||||
frame.pushes = plan.pushes;
|
||||
frame.cancels = plan.cancels;
|
||||
}
|
||||
|
||||
/// Apply the position and velocity corrections computed by
|
||||
/// [`detect_collisions`].
|
||||
pub fn resolve_collisions(
|
||||
mut frame: ResMut<FrameCollisions>,
|
||||
mut transforms: Query<&mut Transform>,
|
||||
mut velocities: Query<&mut Velocity>,
|
||||
) {
|
||||
let pushes = std::mem::take(&mut frame.pushes);
|
||||
for (entity, delta) in pushes {
|
||||
if let Ok(mut transform) = transforms.get_mut(entity) {
|
||||
transform.translation += delta;
|
||||
}
|
||||
}
|
||||
|
||||
let cancels = std::mem::take(&mut frame.cancels);
|
||||
for (entity, delta) in cancels {
|
||||
if let Ok(mut velocity) = velocities.get_mut(entity) {
|
||||
velocity.0 -= delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance each [`ProximitySensor`]: detect physical bodies entering or leaving
|
||||
/// its radius and emit [`ProximityEvent`]s. A body is "inside" when the gap
|
||||
/// between their centers is within `sensor.radius + body.radius`.
|
||||
pub fn update_proximity_sensors(
|
||||
mut sensors: Query<(Entity, &GlobalTransform, &mut ProximitySensor)>,
|
||||
bodies: Query<(Entity, &GlobalTransform, &BoundingVolume)>,
|
||||
mut events: EventWriter<ProximityEvent>,
|
||||
) {
|
||||
for (sensor_entity, sensor_transform, mut sensor) in sensors.iter_mut() {
|
||||
let center = sensor_transform.translation();
|
||||
let radius = sensor.radius;
|
||||
|
||||
let current: Vec<Entity> = bodies
|
||||
.iter()
|
||||
.filter_map(|(entity, gtf, volume)| {
|
||||
if entity == sensor_entity {
|
||||
return None;
|
||||
}
|
||||
let reach = radius + volume.radius;
|
||||
if center.distance(gtf.translation()) <= reach {
|
||||
Some(entity)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let previous = std::mem::replace(&mut sensor.occupants, current);
|
||||
|
||||
// Enter: present now, absent last frame.
|
||||
for &entity in &sensor.occupants {
|
||||
if !previous.contains(&entity) {
|
||||
events.write(ProximityEvent::Enter {
|
||||
sensor: sensor_entity,
|
||||
other: entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Exit: present last frame, absent now.
|
||||
for &entity in &previous {
|
||||
if !sensor.occupants.contains(&entity) {
|
||||
events.write(ProximityEvent::Exit {
|
||||
sensor: sensor_entity,
|
||||
other: entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug observer for the physics pipeline.
|
||||
///
|
||||
/// Reads [`CollisionEvent`] and [`ProximityEvent`] each frame and logs them,
|
||||
/// so the collision and spatial-query systems are observable before combat /
|
||||
/// docking systems are wired up to consume the same events. Collisions are
|
||||
/// logged at `debug` (they fire every frame an overlap persists); proximity
|
||||
/// transitions are logged at `info` (they fire only on Enter/Exit).
|
||||
pub fn log_physics_events(
|
||||
mut collisions: EventReader<CollisionEvent>,
|
||||
mut proximity: EventReader<ProximityEvent>,
|
||||
) {
|
||||
for event in collisions.read() {
|
||||
bevy::log::debug!(
|
||||
"collision: {:?} <-> {:?} penetration={:.3} normal={:?}",
|
||||
event.a, event.b, event.penetration, event.normal
|
||||
);
|
||||
}
|
||||
for event in proximity.read() {
|
||||
match *event {
|
||||
ProximityEvent::Enter { sensor, other } => {
|
||||
bevy::log::info!("proximity: {other:?} entered sensor {sensor:?}");
|
||||
}
|
||||
ProximityEvent::Exit { sensor, other } => {
|
||||
bevy::log::info!("proximity: {other:?} left sensor {sensor:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snap(index: u32, x: f32, radius: f32, movable: bool, vel_x: f32) -> BodySnapshot {
|
||||
BodySnapshot {
|
||||
entity: Entity::from_raw(index),
|
||||
pos: Vec3::new(x, 0.0, 0.0),
|
||||
radius,
|
||||
mass: 1.0,
|
||||
movable,
|
||||
velocity: Vec3::new(vel_x, 0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_mass_splits_correction_and_cancels_inbound_velocity() {
|
||||
// a at x=0 (r=1) moving +X; b at x=1 (r=1) moving -X. Overlap 1.0.
|
||||
let bodies = [snap(0, 0.0, 1.0, true, 1.0), snap(1, 1.0, 1.0, true, -1.0)];
|
||||
let plan = plan_collisions(&bodies);
|
||||
|
||||
assert!(
|
||||
(plan.pushes[&Entity::from_raw(0)] - Vec3::new(-0.5, 0.0, 0.0)).length() < 1e-5,
|
||||
"a should be pushed back half the overlap"
|
||||
);
|
||||
assert!(
|
||||
(plan.pushes[&Entity::from_raw(1)] - Vec3::new(0.5, 0.0, 0.0)).length() < 1e-5,
|
||||
"b should be pushed forward half the overlap"
|
||||
);
|
||||
assert!(
|
||||
(plan.cancels[&Entity::from_raw(0)] - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5,
|
||||
"a's inbound velocity should be canceled"
|
||||
);
|
||||
assert!(
|
||||
(plan.cancels[&Entity::from_raw(1)] - Vec3::new(-1.0, 0.0, 0.0)).length() < 1e-5,
|
||||
"b's inbound velocity should be canceled"
|
||||
);
|
||||
assert_eq!(plan.contacts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_body_takes_full_push_and_none_itself() {
|
||||
// Movable a vs immovable b: a absorbs the full correction.
|
||||
let bodies = [snap(0, 0.0, 1.0, true, 1.0), snap(1, 1.0, 1.0, false, 0.0)];
|
||||
let plan = plan_collisions(&bodies);
|
||||
|
||||
assert!(
|
||||
(plan.pushes[&Entity::from_raw(0)] - Vec3::new(-1.0, 0.0, 0.0)).length() < 1e-5
|
||||
);
|
||||
assert!(plan.pushes.get(&Entity::from_raw(1)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_overlap_produces_empty_plan() {
|
||||
let bodies = [snap(0, 0.0, 1.0, true, 1.0), snap(1, 5.0, 1.0, true, 0.0)];
|
||||
let plan = plan_collisions(&bodies);
|
||||
assert!(plan.pushes.is_empty());
|
||||
assert!(plan.cancels.is_empty());
|
||||
assert!(plan.contacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receding_body_keeps_its_velocity() {
|
||||
// a moving away from b (-X) should not have velocity canceled.
|
||||
let bodies = [snap(0, 0.0, 1.0, true, -1.0), snap(1, 1.0, 1.0, false, 0.0)];
|
||||
let plan = plan_collisions(&bodies);
|
||||
assert!(plan.cancels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_of_immovable_bodies_is_skipped() {
|
||||
let bodies = [snap(0, 0.0, 1.0, false, 0.0), snap(1, 0.5, 1.0, false, 0.0)];
|
||||
let plan = plan_collisions(&bodies);
|
||||
assert!(plan.pushes.is_empty());
|
||||
assert!(plan.contacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_body_moves_less() {
|
||||
// a heavy (mass 9), b light (mass 1), equal inverse split → a moves 1/10.
|
||||
let mut a = snap(0, 0.0, 1.0, true, 0.0);
|
||||
a.mass = 9.0;
|
||||
let mut b = snap(1, 1.0, 1.0, true, 0.0);
|
||||
b.mass = 1.0;
|
||||
let bodies = [a, b];
|
||||
let plan = plan_collisions(&bodies);
|
||||
|
||||
// overlap 1.0; a share = inv_a/(inv_a+inv_b) = (1/9)/(1/9+1) = 0.1
|
||||
assert!(
|
||||
(plan.pushes[&Entity::from_raw(0)] - Vec3::new(-0.1, 0.0, 0.0)).length() < 1e-5
|
||||
);
|
||||
assert!(
|
||||
(plan.pushes[&Entity::from_raw(1)] - Vec3::new(0.9, 0.0, 0.0)).length() < 1e-5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user