feat(physics): collision resolution + proximity queries (P2)
Implement the kinematic physics & collision layer per ARCH-7 (no physics engine; distance-based collision + spatial queries). physics/ - components.rs (new): BodyMass, ProximitySensor, CollisionEvent, ProximityEvent - geometry.rs: add sphere_overlap() + split_by_inverse_mass() with unit tests - systems.rs: collision pipeline (detect_collisions -> resolve_collisions) with mass-weighted separation and inbound-velocity cancel, plus update_proximity_sensors spatial query and a log_physics_events observer. Core math factored into pure plan_collisions() with unit tests. - mod.rs: PhysicsPlugin registers events, FrameCollisions resource, and the InGame systems ordered after kinematic integration. in_system/scene.rs: give the player ship a BoundingVolume collider, BodyMass, and a ProximitySensor so it participates in collision and spatial queries. Bodies with a Velocity are movable; scenery is immovable. 19 unit tests pass; no new compiler/clippy warnings introduced.
This commit is contained in:
@@ -13,6 +13,7 @@ use crate::gameplay::galaxy::{
|
|||||||
SystemContents,
|
SystemContents,
|
||||||
};
|
};
|
||||||
use crate::gameplay::in_system::DockedState;
|
use crate::gameplay::in_system::DockedState;
|
||||||
|
use crate::gameplay::physics::{BodyMass, ProximitySensor};
|
||||||
|
|
||||||
/// Tracks the currently active system for gameplay.
|
/// Tracks the currently active system for gameplay.
|
||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
@@ -45,6 +46,16 @@ pub struct DockingTarget {
|
|||||||
/// Offset from station where player ship spawns when docked.
|
/// Offset from station where player ship spawns when docked.
|
||||||
const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0);
|
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).
|
/// Represents a docking target (either a station or a habitable planet).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct DockingTargetInfo {
|
struct DockingTargetInfo {
|
||||||
@@ -352,6 +363,15 @@ fn spawn_player_ship_docked(
|
|||||||
Docked {
|
Docked {
|
||||||
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
|
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)),
|
Transform::from_translation(ship_position).with_scale(Vec3::splat(0.12)),
|
||||||
Visibility::default(),
|
Visibility::default(),
|
||||||
InheritedVisibility::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)
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -144,4 +187,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(t, None);
|
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 bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::gameplay::movement;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub mod components;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod systems;
|
pub mod systems;
|
||||||
|
|
||||||
|
pub use components::{BodyMass, CollisionEvent, ProximityEvent, ProximitySensor};
|
||||||
|
pub use systems::FrameCollisions;
|
||||||
|
|
||||||
pub struct PhysicsPlugin;
|
pub struct PhysicsPlugin;
|
||||||
|
|
||||||
impl Plugin for PhysicsPlugin {
|
impl Plugin for PhysicsPlugin {
|
||||||
fn build(&self, _app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Geometry primitives in `geometry` are pure functions called directly by
|
app.add_event::<CollisionEvent>()
|
||||||
// gameplay systems. No global systems yet — wire them up when combat /
|
.add_event::<ProximityEvent>()
|
||||||
// collision is implemented.
|
.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
|
//! Kinematic collision resolution and proximity queries.
|
||||||
// defined and unit-tested but not yet wired into any Bevy systems.
|
//!
|
||||||
//
|
//! Per ARCH-7 there is no physics engine: movement is point-and-shoot and
|
||||||
// Future systems to add here:
|
//! collision is distance-based. This module wires the pure geometry in
|
||||||
// - `projectile_hits`: query projectiles against damageable entities using `overlaps`
|
//! [`super::geometry`] into Bevy systems that run each frame during gameplay.
|
||||||
// or `segment_vs_sphere` (for fast-moving projectiles that might tunnel).
|
//!
|
||||||
// - `ship_separation`: push ships apart when they get too close using `separate`.
|
//! ## Collision pipeline
|
||||||
// - `proximity_triggers`: emit events when entities enter/exit a radius.
|
//!
|
||||||
//
|
//! 1. [`detect_collisions`] snapshots every physical body (anything with a
|
||||||
// When adding these, register them in `PhysicsPlugin::build()`. They should run on
|
//! [`BoundingVolume`]) and runs an O(n²) broad+narrow phase, computing
|
||||||
// `FixedUpdate` for determinism (see ARCH-9 in the docs).
|
//! 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