diff --git a/apps/game/src/gameplay/in_system/scene.rs b/apps/game/src/gameplay/in_system/scene.rs index 76168ff..e9f2ccc 100644 --- a/apps/game/src/gameplay/in_system/scene.rs +++ b/apps/game/src/gameplay/in_system/scene.rs @@ -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(), diff --git a/apps/game/src/gameplay/physics/components.rs b/apps/game/src/gameplay/physics/components.rs new file mode 100644 index 0000000..7932007 --- /dev/null +++ b/apps/game/src/gameplay/physics/components.rs @@ -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, +} + +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 }, +} diff --git a/apps/game/src/gameplay/physics/geometry.rs b/apps/game/src/gameplay/physics/geometry.rs index 35fd4c9..d55bc47 100644 --- a/apps/game/src/gameplay/physics/geometry.rs +++ b/apps/game/src/gameplay/physics/geometry.rs @@ -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); + } } diff --git a/apps/game/src/gameplay/physics/mod.rs b/apps/game/src/gameplay/physics/mod.rs index 054f853..d298974 100644 --- a/apps/game/src/gameplay/physics/mod.rs +++ b/apps/game/src/gameplay/physics/mod.rs @@ -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::() + .add_event::() + .init_resource::() + // 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)), + ); } } diff --git a/apps/game/src/gameplay/physics/systems.rs b/apps/game/src/gameplay/physics/systems.rs index a6e78d7..01f9068 100644 --- a/apps/game/src/gameplay/physics/systems.rs +++ b/apps/game/src/gameplay/physics/systems.rs @@ -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, world-space delta)` to subtract from each body's velocity. + pub cancels: HashMap, +} + +/// 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, + cancels: HashMap, + contacts: Vec, +} + +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, + mut events: EventWriter, +) { + let bodies: Vec = 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, + 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, +) { + for (sensor_entity, sensor_transform, mut sensor) in sensors.iter_mut() { + let center = sensor_transform.translation(); + let radius = sensor.radius; + + let current: Vec = 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, + mut proximity: EventReader, +) { + 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 + ); + } +}