1use alloc::boxed::Box;
7use alloc::collections::{BTreeMap, BTreeSet};
8use alloc::vec::Vec;
9
10use crate::physics::{
11 BodyHandle, CharacterCapsule, CharacterMoveInput, ColliderShape, ContactHit, GRAVITY,
12 PhysicsBudget, SensorCrossing, SimConfig, Simulation,
13};
14
15use crate::components::{
16 BodyDynamics, Camera3D, Collider, ContactEvent, Held, PhysicsConfig, PhysicsJoint, Pickup,
17 RigidBody, Transform, TriggerFilter, TriggerVolume, VolumeEvent,
18};
19use crate::ecs::asset_id::AssetId;
20use crate::ecs::{
21 Entity, EntityByName, EventCursor, MenuActive, PipelineContext, ScheduleMode, SimTiming,
22 StepResult, System, WorldPhysicsBudget,
23};
24use crate::math::{cos, sin, sqrt};
25
26use super::budget::DriverCapacities;
27use super::contacts::{ContactBatch, ContactGate};
28use super::convert::{collider_shape, joint_spec};
29use super::fanout::{PhysicsFanout, SerialFanout};
30use super::index::SortedMap;
31use super::interp::PointInterp;
32use super::layers::{LAYER_CHARACTER, LAYER_PROP, LAYER_TRIGGER, LAYER_WORLD, LayerTable};
33use super::props::{PropBodies, PropCollSnap, STATIC_FRICTION};
34use super::terrain::{TerrainParams, build_heightfield, build_heightfield_collider};
35
36const PICKUP_REACH: f32 = 3.0;
38const PICKUP_MIN_DOT: f32 = 0.5;
40const HOLD_DISTANCE: f32 = 1.8;
42const HOLD_DROP: f32 = 0.35;
44const THROW_SPEED: f32 = 6.0;
46
47#[derive(Debug)]
53pub struct PhysicsSystem {
54 floor_y: f32,
57 terrain: Option<TerrainParams>,
59 terrain_mesh: Option<AssetId>,
63 terrain_offset_y: f32,
67 world: Option<Simulation>,
69 player: Option<PlayerPhysics>,
71 rigs: Vec<super::rig::RigPhysics>,
73 props: PropBodies,
75 root_cursor: EventCursor,
77 new_props: Vec<(Entity, PropCollSnap)>,
80 motion_scratch: Vec<crate::components::RootMotionEvent>,
82 contact_scratch: Vec<ContactHit>,
83 sensor_scratch: Vec<SensorCrossing>,
84 held: Option<usize>,
86 sensor_filters: SortedMap<u64, (AssetId, TriggerFilter)>,
89 layers: LayerTable,
91 contact_min_impulse: f32,
93 contact_batch: ContactBatch,
95 contact_gate: ContactGate,
97 spawn_headroom: u32,
99 body_cap: u32,
103 fanout: Box<dyn PhysicsFanout>,
106}
107
108#[derive(Debug)]
110struct PlayerPhysics {
111 handle: BodyHandle,
112 shape: CharacterCapsule,
115 eye_offset: f32,
117 has_gravity: bool,
119 gravity_scale: f32,
120 jump_height: f32,
121 vy: f32,
123 grounded: bool,
125 center: PointInterp,
127 written_eye: Option<[f32; 3]>,
131}
132
133impl PhysicsSystem {
134 #[cfg(test)]
137 fn physics_body_count(&self) -> usize {
138 self.world.as_ref().map_or(0, |w| w.body_count())
139 }
140
141 #[cfg(test)]
144 fn physics_collider_count(&self) -> usize {
145 self.world.as_ref().map_or(0, |w| w.collider_count())
146 }
147
148 pub fn new(config: PhysicsConfig) -> Self {
151 let terrain = if config.terrain_subdivisions > 0 {
152 Some(TerrainParams {
153 half_width: config.terrain_half_width,
154 half_depth: config.terrain_half_depth,
155 subdivisions: config.terrain_subdivisions,
156 amplitude: config.terrain_amplitude,
157 offset_y: config.terrain_offset_y,
158 })
159 } else {
160 None
161 };
162 Self {
163 floor_y: config.floor_y,
164 terrain,
165 terrain_mesh: config.terrain_mesh,
166 terrain_offset_y: config.terrain_offset_y,
167 world: None,
168 player: None,
169 rigs: Vec::new(),
170 props: PropBodies::default(),
171 root_cursor: EventCursor::default(),
172 new_props: Vec::new(),
173 motion_scratch: Vec::new(),
174 contact_scratch: Vec::new(),
175 sensor_scratch: Vec::new(),
176 held: None,
177 sensor_filters: SortedMap::default(),
178 layers: LayerTable::new(&config),
179 contact_min_impulse: config.contact_min_impulse.max(0.0),
180 contact_batch: ContactBatch::default(),
181 contact_gate: ContactGate::default(),
182 spawn_headroom: config.spawn_headroom,
183 body_cap: 0,
184 fanout: Box::new(SerialFanout),
185 }
186 }
187
188 pub fn with_fanout(mut self, fanout: Box<dyn PhysicsFanout>) -> Self {
193 self.fanout = fanout;
194 self
195 }
196
197 fn resolve_budget(&self, ctx: &PipelineContext) -> PhysicsBudget {
211 let scan = super::budget::scan_counts(ctx);
212 let Some(record) = ctx.resource::<WorldPhysicsBudget>().map(|b| b.0) else {
213 return PhysicsBudget::derive(&scan, self.spawn_headroom);
214 };
215 let shipped = super::budget::budget_of(&record);
216 debug_assert_eq!(
217 shipped,
218 PhysicsBudget::derive(&scan, record.spawn_headroom),
219 "the shipped physics budget does not match the loaded world"
220 );
221 shipped
222 }
223
224 fn reserve(&mut self, budget: &PhysicsBudget) {
227 let caps = DriverCapacities::derive(budget);
228 self.props = PropBodies::with_capacity(&caps);
229 self.rigs = Vec::with_capacity(caps.rigs);
230 self.new_props = Vec::with_capacity(caps.new_props);
231 self.motion_scratch = Vec::with_capacity(caps.root_motions);
232 self.contact_scratch = Vec::with_capacity(caps.contacts);
233 self.sensor_scratch = Vec::with_capacity(caps.sensor_crossings);
234 self.contact_batch = ContactBatch::with_capacity(caps.contact_pairs);
235 self.contact_gate = ContactGate::with_capacity(caps.contact_pairs);
236 self.sensor_filters = SortedMap::with_capacity(caps.sensor_filters);
237 }
238
239 fn build_prop_bodies(
244 &mut self,
245 ctx: &PipelineContext,
246 world: &mut Simulation,
247 body_handles: &mut BTreeMap<AssetId, BodyHandle>,
248 ) {
249 let entity_name: BTreeMap<Entity, AssetId> = ctx
250 .resource::<EntityByName>()
251 .map(|n| n.0.iter().map(|(&id, &e)| (e, id)).collect())
252 .unwrap_or_default();
253 let pickup: BTreeSet<Entity> = ctx.query_with_entity::<Pickup>().map(|(e, _)| e).collect();
254 let dynamics: BTreeMap<Entity, BodyDynamics> = ctx
255 .query_with_entity::<BodyDynamics>()
256 .map(|(e, b)| (e, *b))
257 .collect();
258 let snaps: Vec<(Entity, PropCollSnap)> = ctx
259 .join2::<Collider, Transform>()
260 .map(|(entity, collider, transform)| {
261 (
262 entity,
263 PropCollSnap {
264 shape: collider_shape(&collider.0, transform.scale),
265 layer: collider.0.layer.clone(),
266 position: transform.position,
267 rotation_deg: transform.rotation_deg,
268 pickup: pickup.contains(&entity),
269 dynamics: dynamics.get(&entity).copied(),
270 },
271 )
272 })
273 .collect();
274
275 for (entity, snap) in snaps {
276 let Some(handle) = self.props.add(&self.layers, world, entity, snap) else {
277 continue;
278 };
279 if let Some(&id) = entity_name.get(&entity) {
280 body_handles.insert(id, handle);
281 }
282 }
283 }
284}
285
286impl System for PhysicsSystem {
287 fn init(&mut self, ctx: &mut PipelineContext) {
288 let budget = self.resolve_budget(ctx);
291 self.body_cap = budget.body_cap();
292 self.reserve(&budget);
293
294 let mut world = Simulation::new(
298 SimConfig {
299 gravity: GRAVITY,
300 ..SimConfig::default()
301 },
302 budget.body_cap() as usize,
303 );
304 world.set_contact_min_impulse(self.contact_min_impulse, SimTiming::TICK_DT);
305 world.reserve_workers(
309 self.fanout
310 .worker_count(ScheduleMode::current(ctx.resources)),
311 );
312 let world_mask = self.layers.mask(LAYER_WORLD);
313
314 let mut floor_built = false;
316 if let Some(mesh_id) = self.terrain_mesh {
317 let mesh_snap = ctx
318 .query::<crate::components::ProceduralMesh>()
319 .find(|m| m.asset_id == mesh_id)
320 .cloned();
321 if let Some(m) = mesh_snap
325 && m.generator == "heightfield"
326 && build_heightfield_collider(
327 &mut world,
328 &m,
329 self.terrain_offset_y,
330 world_mask,
331 ctx,
332 )
333 .is_ok()
334 {
335 floor_built = true;
336 }
337 }
338 if !floor_built {
339 if let Some(terrain) = self.terrain.clone() {
340 build_heightfield(&mut world, &terrain, world_mask);
341 } else {
342 world.add_fixed(
344 &ColliderShape::Cuboid {
345 half_extents: [500.0, 5.0, 500.0],
346 },
347 [0.0, -5.0, 0.0],
348 [0.0; 3],
349 STATIC_FRICTION,
350 world_mask,
351 );
352 }
353 }
354
355 let trigger_mask = self.layers.mask(LAYER_TRIGGER);
358 let volumes: Vec<TriggerVolume> = ctx.query::<TriggerVolume>().cloned().collect();
359 for volume in &volumes {
360 let shape = collider_shape(&volume.collider, [1.0; 3]);
361 let tag = u64::from(volume.asset_id.0);
362 if world
363 .add_sensor(
364 &shape,
365 volume.position,
366 volume.rotation_deg,
367 tag,
368 trigger_mask,
369 )
370 .is_none()
371 {
372 continue;
373 }
374 self.sensor_filters
375 .insert(tag, (volume.asset_id, volume.detects));
376 }
377
378 let mut body_handles: BTreeMap<AssetId, BodyHandle> = BTreeMap::new();
381 self.build_prop_bodies(ctx, &mut world, &mut body_handles);
382
383 let joints: Vec<PhysicsJoint> = ctx.drain::<PhysicsJoint>();
390 for joint in joints {
391 let Some(body_a_id) = joint.body_a else {
392 continue;
393 };
394 let Some(handle_a) = body_handles.get(&body_a_id).copied() else {
395 continue;
396 };
397 let handle_b = if let Some(body_b_id) = joint.body_b {
398 match body_handles.get(&body_b_id).copied() {
399 Some(h) => h,
400 None => {
401 continue;
402 }
403 }
404 } else {
405 let anchor = world.add_fixed(
408 &ColliderShape::Ball { radius: 0.001 },
409 joint.anchor_b,
410 [0.0; 3],
411 0.0,
412 world_mask,
413 );
414 match anchor {
415 Some(handle) => handle,
416 None => continue,
417 }
418 };
419 let anchor_b = if joint.body_b.is_some() {
422 joint.anchor_b
423 } else {
424 [0.0, 0.0, 0.0]
425 };
426 if !world.add_joint(
427 handle_a,
428 handle_b,
429 joint.anchor_a,
430 anchor_b,
431 joint_spec(&joint),
432 ) {
433 continue;
434 }
435 }
436 let camera_pos = ctx
443 .query::<Camera3D>()
444 .next()
445 .filter(|c| {
446 c.controller
447 .as_ref()
448 .is_none_or(|ctrl| ctrl.follow.is_none())
449 })
450 .map(|c| c.position);
451 if let Some(cam_pos) = camera_pos {
452 let rb_opt = ctx.query::<RigidBody>().next().cloned();
453 let has_gravity = rb_opt.is_some();
454 let rb = rb_opt.unwrap_or_default();
455 if self.floor_y == 0.0 {
456 self.floor_y = cam_pos[1];
457 }
458 let radius = rb.capsule_radius.max(0.05);
459 let half_height = ((rb.capsule_height * 0.5) - radius).max(0.05);
460 let eye_offset = if has_gravity {
463 (rb.capsule_height * 0.5).max(radius + 0.05)
464 } else {
465 0.0
466 };
467 let center = [cam_pos[0], cam_pos[1] - eye_offset, cam_pos[2]];
468 world.configure_character(rb.max_slope_deg, rb.step_height, has_gravity);
469 let handle = world.add_character(
470 half_height,
471 radius,
472 center,
473 self.layers.mask(LAYER_CHARACTER),
474 );
475 self.player = handle.map(|handle| PlayerPhysics {
476 handle,
477 shape: CharacterCapsule::new(half_height, radius),
478 eye_offset,
479 has_gravity,
480 gravity_scale: rb.gravity_scale.max(0.0),
481 jump_height: rb.jump_height.max(0.0),
482 vy: 0.0,
483 grounded: true,
484 center: PointInterp::new(center),
485 written_eye: None,
486 });
487 }
488
489 super::rig::init_rigs(
492 &mut world,
493 ctx,
494 self.layers.mask(LAYER_CHARACTER),
495 &mut self.rigs,
496 );
497
498 super::budget::publish_reservation(crate::memory::ledger(), &budget, &world);
501 self.world = Some(world);
502 }
503
504 fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
505 if ctx.resource::<MenuActive>().is_some_and(|m| m.0) {
512 return StepResult::Continue;
513 }
514
515 if self.world.is_none() {
516 return StepResult::Continue;
517 }
518
519 let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
523
524 let (cam_pos, cam_yaw, cam_pitch, desired_move, jump_req, interact_req) = ctx
526 .query::<Camera3D>()
527 .next()
528 .map(|c| {
529 (
530 c.position,
531 c.yaw,
532 c.pitch,
533 c.desired_move,
534 c.jump_requested,
535 c.interact_requested,
536 )
537 })
538 .unwrap_or(([0.0, self.floor_y, 0.0], 0.0, 0.0, [0.0; 3], false, false));
539
540 let fwd_flat = [-sin(cam_yaw), 0.0, -cos(cam_yaw)];
542 let fwd_full = [
543 -(sin(cam_yaw) * cos(cam_pitch)),
544 -sin(cam_pitch),
545 -(cos(cam_yaw) * cos(cam_pitch)),
546 ];
547
548 let mode = ScheduleMode::current(ctx.resources);
551
552 let world = self.world.as_mut().expect("world checked above");
553
554 self.held = self
557 .props
558 .reap(world, self.held, |entity| ctx.is_alive(entity));
559
560 self.new_props.clear();
566 self.new_props.extend(
567 ctx.join2::<Collider, Transform>()
568 .filter(|(entity, _, _)| {
569 !self.props.is_tracked(*entity) && !self.props.is_refused(*entity)
570 })
571 .map(|(entity, collider, transform)| {
572 (
573 entity,
574 PropCollSnap {
575 shape: collider_shape(&collider.0, transform.scale),
576 layer: collider.0.layer.clone(),
577 position: transform.position,
578 rotation_deg: transform.rotation_deg,
579 pickup: false,
580 dynamics: None,
581 },
582 )
583 }),
584 );
585 for (entity, mut snap) in self.new_props.drain(..) {
586 snap.pickup = ctx.get::<Pickup>(entity).is_some();
587 snap.dynamics = ctx.get::<BodyDynamics>(entity).copied();
588 self.props
589 .adopt(&self.layers, world, entity, snap, self.body_cap);
590 }
591
592 let mut held_changed: Option<(Entity, bool)> = None;
595 if interact_req {
596 if let Some(held_idx) = self.held.take() {
597 let pp = self.props.get(held_idx).expect("held index is valid");
599 let throw = [
600 fwd_full[0] * THROW_SPEED,
601 fwd_full[1] * THROW_SPEED + 1.0,
602 fwd_full[2] * THROW_SPEED,
603 ];
604 world.make_dynamic(pp.handle, throw);
605 held_changed = Some((pp.entity, false));
606 } else {
607 let entity_positions: BTreeMap<Entity, [f32; 3]> = ctx
611 .query_with_entity::<Transform>()
612 .map(|(e, t)| (e, t.position))
613 .collect();
614 let mut best: Option<(f32, usize)> = None;
615 for (idx, pp) in self.props.iter().enumerate() {
616 if !pp.pickup {
617 continue;
618 }
619 let pos = entity_positions.get(&pp.entity).copied().unwrap_or(cam_pos);
620 let dx = pos[0] - cam_pos[0];
621 let dz = pos[2] - cam_pos[2];
622 let dist = sqrt(dx * dx + dz * dz);
623 if dist >= PICKUP_REACH || dist <= 0.0 {
624 continue;
625 }
626 let dot = (fwd_flat[0] * dx + fwd_flat[2] * dz) / dist;
627 if dot > PICKUP_MIN_DOT && best.is_none_or(|(d, _)| dist < d) {
628 best = Some((dist, idx));
629 }
630 }
631 if let Some((_, idx)) = best {
632 let pp = self.props.get(idx).expect("scanned index is valid");
633 world.make_kinematic(pp.handle);
634 held_changed = Some((pp.entity, true));
635 self.held = Some(idx);
636 }
637 }
638 }
639
640 if let Some(player) = self.player.as_mut()
644 && player.written_eye != Some(cam_pos)
645 {
646 player
647 .center
648 .snap([cam_pos[0], cam_pos[1] - player.eye_offset, cam_pos[2]]);
649 }
650
651 let hold_pos = [
654 cam_pos[0] + fwd_full[0] * HOLD_DISTANCE,
655 cam_pos[1] + fwd_full[1] * HOLD_DISTANCE - HOLD_DROP,
656 cam_pos[2] + fwd_full[2] * HOLD_DISTANCE,
657 ];
658
659 super::rig::drain_motions_into(ctx, &mut self.root_cursor, &mut self.motion_scratch);
663 super::rig::sync_rigs(ctx, &mut self.rigs);
664
665 for tick in 0..timing.ticks {
666 let dt = timing.tick_dt;
667
668 if let Some(prop) = self.held.and_then(|idx| self.props.get(idx)) {
670 world.set_kinematic_translation(prop.handle, hold_pos);
671 }
672
673 if let Some(player) = self.player.as_mut() {
675 if player.has_gravity {
676 if tick == 0 && jump_req && player.grounded && player.jump_height > 0.0 {
677 player.vy = sqrt(2.0 * GRAVITY * player.gravity_scale * player.jump_height);
678 }
679 player.vy -= GRAVITY * player.gravity_scale * dt;
680 }
681
682 let center = player.center.current();
683 let desired = [desired_move[0] * dt, player.vy * dt, desired_move[2] * dt];
684 let moved = world.move_character(
685 &player.shape,
686 &CharacterMoveInput {
687 center,
688 desired,
689 dt,
690 exclude: player.handle,
691 mask: self.layers.mask(LAYER_CHARACTER),
692 },
693 );
694 let new_center = [
695 center[0] + moved.translation[0],
696 center[1] + moved.translation[1],
697 center[2] + moved.translation[2],
698 ];
699 world.set_kinematic_translation(player.handle, new_center);
700
701 player.grounded = moved.grounded;
702 if moved.grounded && player.vy < 0.0 {
703 player.vy = 0.0;
704 }
705 player.center.push(new_center);
706 }
707
708 super::rig::tick_rigs(
710 world,
711 ctx,
712 &mut self.rigs,
713 if tick == 0 { &self.motion_scratch } else { &[] },
714 dt,
715 GRAVITY,
716 self.layers.mask(LAYER_CHARACTER),
717 );
718
719 self.fanout.step(world, dt, mode);
721
722 self.contact_gate.advance_tick();
724 world.drain_contact_hits_into(&mut self.contact_scratch);
725 for hit in self.contact_scratch.drain(..) {
726 self.contact_batch.add(hit);
727 }
728
729 self.props.record_tick_poses(world);
731 }
732
733 super::probes::step_probes(
735 world,
736 ctx,
737 &self.rigs,
738 self.layers
739 .query_mask(LAYER_CHARACTER, &[LAYER_WORLD, LAYER_PROP]),
740 );
741
742 for hit in self.contact_batch.drain() {
747 let event = match (self.props.entity_of(hit.a), self.props.entity_of(hit.b)) {
748 (Some(a), b) => ContactEvent {
749 a,
750 b,
751 point: hit.point,
752 normal: hit.normal,
753 impulse: hit.impulse,
754 },
755 (None, Some(b)) => ContactEvent {
756 a: b,
757 b: None,
758 point: hit.point,
759 normal: [-hit.normal[0], -hit.normal[1], -hit.normal[2]],
760 impulse: hit.impulse,
761 },
762 (None, None) => continue,
763 };
764 if self.contact_gate.admit(&hit) {
765 ctx.events_mut::<ContactEvent>().send(event);
766 }
767 }
768
769 world.drain_sensor_crossings_into(&mut self.sensor_scratch);
773 for crossing in self.sensor_scratch.drain(..) {
774 let Some(&(volume, filter)) = self.sensor_filters.get(&crossing.tag) else {
775 continue;
776 };
777 let passes = match filter {
778 TriggerFilter::Player => crossing.other.is_some_and(|h| {
779 self.player.as_ref().is_some_and(|p| p.handle == h)
780 || self.rigs.iter().any(|r| r.handle == h)
781 }),
782 TriggerFilter::Props => crossing
783 .other
784 .is_some_and(|h| self.props.entity_of(h).is_some()),
785 TriggerFilter::Any => true,
786 };
787 if passes {
788 ctx.events_mut::<VolumeEvent>().send(VolumeEvent {
789 volume,
790 entered: crossing.entered,
791 });
792 }
793 }
794
795 let alpha = timing.alpha;
799 for &(entity, pos, rot) in self.props.sample_poses(alpha) {
800 if let Some(t) = ctx.get_mut::<Transform>(entity) {
801 t.position = pos;
802 t.rotation_deg = rot;
803 }
804 }
805 if let Some((entity, is_held)) = held_changed {
806 if is_held {
807 if ctx.get::<Held>(entity).is_none() {
808 ctx.insert(entity, Held);
809 }
810 } else {
811 ctx.remove::<Held>(entity);
812 }
813 }
814
815 let mut grounded = true;
817 if let Some(player) = self.player.as_mut() {
818 let center = player.center.sample(alpha);
819 let eye = [center[0], center[1] + player.eye_offset, center[2]];
820 player.written_eye = Some(eye);
821 grounded = player.grounded;
822 for camera in ctx.query_mut::<Camera3D>() {
823 camera.position = eye;
824 camera.view_matrix =
825 crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
826 }
827 }
828
829 for body in ctx.query_mut::<RigidBody>() {
831 body.is_grounded = grounded;
832 }
833
834 super::rig::publish_rigs(ctx, &mut self.rigs, alpha);
836
837 StepResult::Continue
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use alloc::string::ToString;
845 use alloc::vec;
846
847 use crate::components::{
848 CameraController, CharacterRig, FollowController, ProceduralMesh, PropCollider,
849 };
850 use crate::ecs::SkinnedMeshHandle;
851 use crate::physics::LayerMask;
852 use crate::physics::budget::{record_of, scan_counts};
853 use crate::physics::test_world::TestWorld;
854
855 fn make_dynamic(world: &mut TestWorld, entity: Entity) {
858 world.components.insert_typed(entity, ball_dynamics());
859 }
860
861 fn controlled_camera() -> Camera3D {
862 Camera3D {
863 fov_y_degrees: 75.0,
864 near: 0.05,
865 far: 200.0,
866 view_matrix: [[0.0; 4]; 4],
867 position: [0.0, 1.0, 0.0],
868 yaw: 0.0,
869 pitch: 0.0,
870 desired_move: [0.0; 3],
871 jump_requested: false,
872 interact_requested: false,
873 controller: Some(CameraController::default()),
874 }
875 }
876
877 #[test]
883 fn third_person_camera_gets_no_player_capsule() {
884 let mut world = TestWorld::new();
886 let mut camera = controlled_camera();
887 camera.controller = Some(CameraController {
888 follow: Some(FollowController {
889 target: Some(SkinnedMeshHandle(1)),
890 ..FollowController::default()
891 }),
892 ..CameraController::default()
893 });
894 world.components.push_typed(camera);
895 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
896 physics.init(&mut world.ctx());
897 assert!(physics.player.is_none(), "no capsule for the orbit camera");
898
899 let mut world = TestWorld::new();
901 world.components.push_typed(controlled_camera());
902 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
903 physics.init(&mut world.ctx());
904 assert!(
905 physics.player.is_some(),
906 "first-person camera keeps its capsule"
907 );
908 }
909
910 fn ball_dynamics() -> BodyDynamics {
911 BodyDynamics {
912 mass: 1.0,
913 friction: 0.5,
914 linear_damping: 0.0,
915 ..Default::default()
916 }
917 }
918
919 fn interacting_camera(position: [f32; 3]) -> Camera3D {
922 Camera3D {
923 interact_requested: true,
924 controller: None,
925 position,
926 ..controlled_camera()
927 }
928 }
929
930 #[test]
933 fn dynamic_prop_writes_transform() {
934 let id = AssetId(1);
935 let mut world = TestWorld::new();
936 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
937 make_dynamic(&mut world, entity);
938
939 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
940 physics.init(&mut world.ctx());
941 for _ in 0..10 {
942 physics.step(&mut world.ctx());
943 }
944
945 let transform_y = world.components.get::<Transform>(entity).unwrap().position[1];
946 assert!(
947 transform_y < 5.0,
948 "the simulated pose falls the Transform (y={transform_y})"
949 );
950 }
951
952 #[test]
957 fn menu_active_freezes_then_resumes_physics() {
958 let id = AssetId(1);
959 let mut world = TestWorld::new();
960 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
961 make_dynamic(&mut world, entity);
962
963 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
964 physics.init(&mut world.ctx());
965
966 world.resources.insert(MenuActive(true));
968 for _ in 0..5 {
969 physics.step(&mut world.ctx());
970 }
971 let y_paused = world.components.get::<Transform>(entity).unwrap().position[1];
972 assert!(
973 (y_paused - 5.0).abs() < 1e-3,
974 "the body must not fall while a menu is active (y={y_paused})"
975 );
976
977 world.resources.insert(MenuActive(false));
979 for _ in 0..5 {
980 physics.step(&mut world.ctx());
981 }
982 let y_resumed = world.components.get::<Transform>(entity).unwrap().position[1];
983 assert!(
984 y_resumed < y_paused - 1e-3,
985 "the body must fall once the menu closes (y={y_resumed})"
986 );
987 }
988
989 #[test]
993 fn zero_tick_frames_blend_between_the_last_two_ticks() {
994 let id = AssetId(1);
995 let mut world = TestWorld::new();
996 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
997 make_dynamic(&mut world, entity);
998
999 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1000 physics.init(&mut world.ctx());
1001
1002 let tick = |ticks, alpha| SimTiming {
1003 ticks,
1004 tick_dt: SimTiming::TICK_DT,
1005 alpha,
1006 };
1007 world.resources.insert(tick(1, 1.0));
1008 physics.step(&mut world.ctx());
1009 let y_prev = world.components.get::<Transform>(entity).unwrap().position[1];
1010 physics.step(&mut world.ctx());
1011 let y_curr = world.components.get::<Transform>(entity).unwrap().position[1];
1012 assert!(y_curr < y_prev, "the body falls tick over tick");
1013
1014 world.resources.insert(tick(0, 0.0));
1016 physics.step(&mut world.ctx());
1017 let y_alpha0 = world.components.get::<Transform>(entity).unwrap().position[1];
1018 assert!(
1019 (y_alpha0 - y_prev).abs() < 1e-6,
1020 "alpha 0 samples the previous tick"
1021 );
1022
1023 world.resources.insert(tick(0, 0.5));
1025 physics.step(&mut world.ctx());
1026 let y_mid = world.components.get::<Transform>(entity).unwrap().position[1];
1027 let expected = (y_prev + y_curr) * 0.5;
1028 assert!(
1029 (y_mid - expected).abs() < 1e-6,
1030 "alpha 0.5 blends the tick poses (y={y_mid}, expected {expected})"
1031 );
1032 }
1033
1034 #[test]
1038 fn tick_grouping_does_not_change_the_outcome() {
1039 let run = |frames: &[u32]| -> ([f32; 3], [f32; 3]) {
1040 let id = AssetId(1);
1041 let mut world = TestWorld::new();
1042 let entity = world.spawn_prop(id, [0.3, 5.0, 0.1], false);
1043 make_dynamic(&mut world, entity);
1044
1045 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1046 physics.init(&mut world.ctx());
1047 for &ticks in frames {
1048 world.resources.insert(SimTiming {
1049 ticks,
1050 tick_dt: SimTiming::TICK_DT,
1051 alpha: 1.0,
1052 });
1053 physics.step(&mut world.ctx());
1054 }
1055 let t = world.components.get::<Transform>(entity).unwrap();
1056 (t.position, t.rotation_deg)
1057 };
1058
1059 let thirty: Vec<u32> = core::iter::repeat_n(2, 30).collect();
1062 let one_twenty: Vec<u32> = (0..120).map(|i| i % 2).collect();
1063 assert_eq!(
1064 run(&thirty),
1065 run(&one_twenty),
1066 "the fixed-tick outcome must not depend on frame grouping"
1067 );
1068 }
1069
1070 #[test]
1073 fn despawning_a_prop_reaps_its_physics_body() {
1074 let id = AssetId(1);
1075 let mut world = TestWorld::new();
1076 let ball = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1077 make_dynamic(&mut world, ball);
1078
1079 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1080 physics.init(&mut world.ctx());
1081
1082 for _ in 0..2 {
1084 physics.step(&mut world.ctx());
1085 }
1086 let before = physics.physics_body_count();
1087
1088 world.components.despawn(ball);
1091 physics.step(&mut world.ctx());
1092 let after = physics.physics_body_count();
1093 assert_eq!(after, before - 1, "the despawned prop's body was removed");
1094
1095 physics.step(&mut world.ctx());
1097 assert_eq!(
1098 physics.physics_body_count(),
1099 after,
1100 "no further bodies removed"
1101 );
1102 }
1103
1104 #[test]
1106 fn pickup_sets_held_tag() {
1107 let id = AssetId(1);
1108 let mut world = TestWorld::new();
1109 let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1110 make_dynamic(&mut world, carriable);
1111 world
1112 .components
1113 .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1114
1115 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1116 physics.init(&mut world.ctx());
1117
1118 physics.step(&mut world.ctx());
1119
1120 assert_eq!(
1121 world.ctx().query::<Held>().count(),
1122 1,
1123 "pickup inserts the Held tag on the entity"
1124 );
1125 }
1126
1127 #[test]
1132 fn runtime_spawned_prop_gets_a_body_and_falls() {
1133 let mut world = TestWorld::new();
1134 let mut physics = PhysicsSystem::new(PhysicsConfig {
1135 spawn_headroom: 1,
1136 ..PhysicsConfig::default()
1137 });
1138 physics.init(&mut world.ctx());
1139 let baseline = physics.physics_body_count();
1140
1141 let spawned = world.spawn_prop(AssetId(7), [0.0, 5.0, 0.0], false);
1142 make_dynamic(&mut world, spawned);
1143 physics.step(&mut world.ctx());
1144 assert_eq!(
1145 physics.physics_body_count(),
1146 baseline + 1,
1147 "the spawned entity got a body on its first step"
1148 );
1149 for _ in 0..30 {
1150 physics.step(&mut world.ctx());
1151 }
1152 let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1153 assert!(y < 4.5, "the spawned body falls (y = {y})");
1154 for _ in 0..300 {
1155 physics.step(&mut world.ctx());
1156 }
1157 let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1158 assert!(
1159 (y - 0.5).abs() < 0.1,
1160 "the spawned ball rests on the flat floor (y = {y})"
1161 );
1162 }
1163
1164 #[test]
1168 fn spawn_despawn_respawn_cycle_is_leak_free() {
1169 let mut world = TestWorld::new();
1170 let mut physics = PhysicsSystem::new(PhysicsConfig {
1171 spawn_headroom: 1,
1172 ..PhysicsConfig::default()
1173 });
1174 physics.init(&mut world.ctx());
1175 let bodies = physics.physics_body_count();
1176 let colliders = physics.physics_collider_count();
1177
1178 for round in 0..3 {
1179 let spawned = world.spawn_prop(AssetId(100 + round), [0.0, 3.0, 0.0], false);
1180 make_dynamic(&mut world, spawned);
1181 physics.step(&mut world.ctx());
1182 assert_eq!(physics.physics_body_count(), bodies + 1, "round {round}");
1183 world.components.despawn(spawned);
1184 physics.step(&mut world.ctx());
1185 assert_eq!(
1186 physics.physics_body_count(),
1187 bodies,
1188 "round {round} reaped the body"
1189 );
1190 assert_eq!(
1191 physics.physics_collider_count(),
1192 colliders,
1193 "round {round} reaped the collider"
1194 );
1195 }
1196 }
1197
1198 #[test]
1201 fn a_shipped_budget_matching_the_world_is_adopted() {
1202 let mut world = TestWorld::new();
1203 let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1204 make_dynamic(&mut world, entity);
1205 world.components.push_typed(controlled_camera());
1206
1207 let counts = scan_counts(&world.ctx());
1210 let budget = PhysicsBudget::derive(&counts, 2);
1211 assert_eq!(budget.dynamic, 1);
1212 assert_eq!(budget.kinematic, 1, "the first-person camera capsule");
1213 world
1214 .resources
1215 .insert(WorldPhysicsBudget(record_of(&budget)));
1216
1217 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1218 physics.init(&mut world.ctx());
1219 assert_eq!(physics.body_cap, budget.body_cap());
1220 assert_eq!(
1221 physics.physics_body_count(),
1222 budget.body_total() as usize,
1223 "init built exactly the bodies the budget reserved"
1224 );
1225 }
1226
1227 #[test]
1235 fn a_world_without_a_shipped_budget_reserves_from_what_it_holds() {
1236 let mut world = TestWorld::new();
1237 let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1238 make_dynamic(&mut world, entity);
1239
1240 let mut physics = PhysicsSystem::new(PhysicsConfig {
1241 spawn_headroom: 4,
1242 ..PhysicsConfig::default()
1243 });
1244 physics.init(&mut world.ctx());
1245
1246 assert_eq!(
1247 physics.physics_body_count(),
1248 2,
1249 "the floor and the one authored prop"
1250 );
1251 assert_eq!(physics.body_cap, 2 + 4, "plus the authored headroom");
1252 }
1253
1254 #[test]
1258 fn a_spawn_past_the_shipped_budget_is_refused() {
1259 let mut world = TestWorld::new();
1260 let authored = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1261 make_dynamic(&mut world, authored);
1262
1263 let counts = scan_counts(&world.ctx());
1265 let budget = PhysicsBudget::derive(&counts, 0);
1266 world
1267 .resources
1268 .insert(WorldPhysicsBudget(record_of(&budget)));
1269
1270 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1271 physics.init(&mut world.ctx());
1272 let full = physics.physics_body_count();
1273 assert_eq!(full, budget.body_cap() as usize, "the budget is spent");
1274
1275 let spawned = world.spawn_prop(AssetId(2), [0.0, 6.0, 0.0], false);
1276 make_dynamic(&mut world, spawned);
1277 physics.step(&mut world.ctx());
1278 assert_eq!(physics.physics_body_count(), full, "no body was built");
1279 assert!(physics.props.is_refused(spawned));
1280
1281 for _ in 0..10 {
1284 physics.step(&mut world.ctx());
1285 }
1286 assert_eq!(physics.physics_body_count(), full);
1287 let refused_y = world.components.get::<Transform>(spawned).unwrap().position[1];
1288 assert_eq!(refused_y, 6.0, "a refused prop is not simulated at all");
1289 let live_y = world
1290 .components
1291 .get::<Transform>(authored)
1292 .unwrap()
1293 .position[1];
1294 assert!(live_y < 3.0, "the authored prop still falls");
1295 }
1296
1297 #[test]
1305 fn a_rig_capsule_spawned_on_the_floor_neither_sinks_nor_rises() {
1306 let identity = [
1307 [1.0, 0.0, 0.0, 0.0],
1308 [0.0, 1.0, 0.0, 0.0],
1309 [0.0, 0.0, 1.0, 0.0],
1310 [0.0, 0.0, 0.0, 1.0],
1311 ];
1312 let mut world = TestWorld::new();
1313 world.components.push_typed(CharacterRig::new(
1314 SkinnedMeshHandle(1),
1315 0,
1316 identity,
1317 0.6,
1318 0.3,
1319 ));
1320
1321 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1322 physics.init(&mut world.ctx());
1323 let rig_y = |world: &mut TestWorld| {
1324 world
1325 .ctx()
1326 .query::<CharacterRig>()
1327 .next()
1328 .expect("the rig is there")
1329 .position[1]
1330 };
1331 assert_eq!(
1332 rig_y(&mut world),
1333 0.0,
1334 "the capsule spawns at its authored position, unlifted"
1335 );
1336
1337 for _ in 0..30 {
1338 physics.step(&mut world.ctx());
1339 }
1340 let settled = rig_y(&mut world);
1341 for _ in 0..300 {
1342 physics.step(&mut world.ctx());
1343 }
1344 let held = rig_y(&mut world);
1345
1346 assert!(
1347 settled.abs() < 0.01,
1348 "the capsule stayed on the floor (y = {settled})"
1349 );
1350 assert!(
1351 (held - settled).abs() < 1.0e-4,
1352 "and stopped moving ({settled} -> {held})"
1353 );
1354 }
1355
1356 #[test]
1359 fn contact_event_fires_on_impact_and_not_at_rest() {
1360 let id = AssetId(1);
1361 let mut world = TestWorld::new();
1362 let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1363 make_dynamic(&mut world, entity);
1364
1365 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1366 physics.init(&mut world.ctx());
1367
1368 let mut cursor = EventCursor::default();
1369 let mut impacts: Vec<ContactEvent> = Vec::new();
1370 for _ in 0..120 {
1371 physics.step(&mut world.ctx());
1372 let ctx = world.ctx();
1373 if let Some(events) = ctx.events::<ContactEvent>() {
1374 impacts.extend(events.read(&mut cursor).copied());
1375 }
1376 }
1377 assert_eq!(impacts.len(), 1, "one landing, one event: {impacts:?}");
1378 let impact = impacts[0];
1379 assert_eq!(impact.a, entity);
1380 assert_eq!(impact.b, None, "the floor slab has no entity");
1381 assert!(
1382 impact.impulse > 3.0 && impact.impulse < 50.0,
1383 "impulse {} out of the plausible landing range",
1384 impact.impulse
1385 );
1386
1387 for _ in 0..300 {
1389 physics.step(&mut world.ctx());
1390 let ctx = world.ctx();
1391 if let Some(events) = ctx.events::<ContactEvent>() {
1392 assert_eq!(
1393 events.read(&mut cursor).count(),
1394 0,
1395 "resting contact must not publish events"
1396 );
1397 }
1398 }
1399 }
1400
1401 #[test]
1404 fn a_trigger_volume_reports_a_prop_crossing_it() {
1405 let volume_id = AssetId(9);
1406 let mut world = TestWorld::new();
1407 world.components.push_typed(TriggerVolume {
1408 asset_id: volume_id,
1409 position: [0.0, 3.0, 0.0],
1410 rotation_deg: [0.0; 3],
1411 collider: PropCollider {
1412 shape: "cuboid".to_string(),
1413 half_extents: [1.0, 0.5, 1.0],
1414 ..Default::default()
1415 },
1416 detects: TriggerFilter::Props,
1417 });
1418 let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1419 make_dynamic(&mut world, ball);
1420
1421 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1422 physics.init(&mut world.ctx());
1423
1424 let mut cursor = EventCursor::default();
1425 let mut crossings: Vec<VolumeEvent> = Vec::new();
1426 for _ in 0..180 {
1427 physics.step(&mut world.ctx());
1428 let ctx = world.ctx();
1429 if let Some(events) = ctx.events::<VolumeEvent>() {
1430 crossings.extend(events.read(&mut cursor).copied());
1431 }
1432 }
1433 assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1434 assert!(crossings.iter().all(|c| c.volume == volume_id));
1435 assert!(crossings[0].entered, "the ball entered first");
1436 assert!(!crossings[1].entered, "and left afterwards");
1437 }
1438
1439 #[test]
1443 fn a_world_anchored_joint_holds_its_prop_up() {
1444 let bob_id = AssetId(1);
1445 let mut world = TestWorld::new();
1446 let bob = world.spawn_prop(bob_id, [1.0, 4.0, 0.0], false);
1447 make_dynamic(&mut world, bob);
1448 world.components.push_typed(PhysicsJoint {
1449 asset_id: AssetId(2),
1450 kind: "spherical".to_string(),
1451 body_a: Some(bob_id),
1452 body_b: None,
1453 anchor_a: [-1.0, 0.0, 0.0],
1455 anchor_b: [0.0, 4.0, 0.0],
1456 ..Default::default()
1457 });
1458
1459 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1460 physics.init(&mut world.ctx());
1461 for _ in 0..240 {
1462 physics.step(&mut world.ctx());
1463 }
1464
1465 let position = world.components.get::<Transform>(bob).unwrap().position;
1466 let reach =
1467 ((position[0]).powi(2) + (position[1] - 4.0).powi(2) + (position[2]).powi(2)).sqrt();
1468 assert!(
1469 (reach - 1.0).abs() < 0.05,
1470 "the bob hangs one unit from the anchor, at {position:?} ({reach})"
1471 );
1472 assert!(
1473 position[1] > 2.5,
1474 "and is held up rather than falling ({position:?})"
1475 );
1476 }
1477
1478 #[test]
1481 fn no_collide_config_lets_a_layered_prop_fall_through_the_floor() {
1482 let config = PhysicsConfig {
1483 layers: vec!["ghost".to_string()],
1484 no_collide: vec![["ghost".to_string(), "world".to_string()]],
1485 ..PhysicsConfig::default()
1486 };
1487
1488 let mut world = TestWorld::new();
1489 let entity = world.spawn_prop(AssetId(1), [0.0, 2.0, 0.0], false);
1490 make_dynamic(&mut world, entity);
1491 world
1492 .components
1493 .get_mut::<Collider>(entity)
1494 .unwrap()
1495 .0
1496 .layer = "ghost".to_string();
1497
1498 let mut physics = PhysicsSystem::new(config);
1499 physics.init(&mut world.ctx());
1500 for _ in 0..240 {
1501 physics.step(&mut world.ctx());
1502 }
1503 let y = world.components.get::<Transform>(entity).unwrap().position[1];
1504 assert!(
1505 y < -10.0,
1506 "the ghost-layer prop fell through the floor (y = {y})"
1507 );
1508 }
1509
1510 fn floor_height(physics: &PhysicsSystem, x: f32, z: f32) -> Option<f32> {
1512 physics
1513 .world
1514 .as_ref()?
1515 .raycast([x, 100.0, z], [0.0, -1.0, 0.0], 200.0, None, LayerMask::ALL)
1516 .map(|hit| hit.point[1])
1517 }
1518
1519 fn terrain_config() -> PhysicsConfig {
1520 PhysicsConfig {
1521 terrain_half_width: 32.0,
1522 terrain_half_depth: 32.0,
1523 terrain_subdivisions: 32,
1524 terrain_amplitude: 4.0,
1525 ..PhysicsConfig::default()
1526 }
1527 }
1528
1529 #[test]
1532 fn authored_subdivisions_build_a_noise_floor_instead_of_a_slab() {
1533 let mut world = TestWorld::new();
1534 let mut physics = PhysicsSystem::new(terrain_config());
1535 assert!(physics.terrain.is_some(), "the config authored a terrain");
1536 physics.init(&mut world.ctx());
1537
1538 let a = floor_height(&physics, 0.0, 0.0).expect("the ray meets the floor");
1539 let b = floor_height(&physics, 12.0, -7.0).expect("the ray meets the floor");
1540 assert_ne!(a, b, "a noise floor is not level");
1541 }
1542
1543 #[test]
1544 fn no_subdivisions_leaves_a_level_slab() {
1545 let mut world = TestWorld::new();
1546 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1547 assert!(physics.terrain.is_none());
1548 physics.init(&mut world.ctx());
1549
1550 let a = floor_height(&physics, 0.0, 0.0).expect("the ray meets the floor");
1551 let b = floor_height(&physics, 12.0, -7.0).expect("the ray meets the floor");
1552 assert_eq!(a, b, "a slab is level");
1553 }
1554
1555 #[test]
1561 fn an_unusable_terrain_mesh_falls_through_to_the_fallback_floor() {
1562 let cases: [(&str, Option<ProceduralMesh>); 3] = [
1563 ("no such asset", None),
1564 (
1565 "wrong generator",
1566 Some(ProceduralMesh {
1567 asset_id: AssetId(7),
1568 generator: "box".to_string(),
1569 ..ProceduralMesh::default()
1570 }),
1571 ),
1572 (
1573 "unreadable payload",
1574 Some(ProceduralMesh {
1575 asset_id: AssetId(7),
1576 generator: "heightfield".to_string(),
1577 ..ProceduralMesh::default()
1578 }),
1579 ),
1580 ];
1581
1582 for (what, mesh) in cases {
1583 let mut world = TestWorld::new();
1584 if let Some(mesh) = mesh {
1585 world.components.push_typed(mesh);
1586 }
1587 let mut config = terrain_config();
1588 config.terrain_mesh = Some(AssetId(7));
1589 let mut physics = PhysicsSystem::new(config);
1590 physics.init(&mut world.ctx());
1591 assert!(
1592 floor_height(&physics, 0.0, 0.0).is_some(),
1593 "{what}: the world was left with no floor at all"
1594 );
1595 }
1596 }
1597
1598 #[test]
1604 fn a_grounded_player_jumps_and_lands() {
1605 let mut world = TestWorld::new();
1606 world.components.push_typed(RigidBody {
1607 gravity_scale: 1.0,
1608 capsule_radius: 0.3,
1609 capsule_height: 1.8,
1610 jump_height: 1.0,
1611 ..RigidBody::default()
1612 });
1613 let mut camera = controlled_camera();
1614 camera.jump_requested = true;
1615 world.components.push_typed(camera);
1616
1617 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1618 physics.init(&mut world.ctx());
1619 assert!(
1620 physics.player.as_ref().expect("a capsule").has_gravity,
1621 "a RigidBody in the world is what makes the capsule fall"
1622 );
1623
1624 physics.step(&mut world.ctx());
1625 let launched = physics.player.as_ref().expect("a capsule").vy;
1626 assert!(
1627 launched > 0.0,
1628 "the jump did not lift the capsule: {launched}"
1629 );
1630
1631 for camera in world.ctx().query_mut::<Camera3D>() {
1633 camera.jump_requested = false;
1634 }
1635 for _ in 0..240 {
1636 physics.step(&mut world.ctx());
1637 }
1638
1639 let player = physics.player.as_ref().expect("a capsule");
1640 assert!(player.grounded, "the capsule never landed");
1641 assert_eq!(player.vy, 0.0, "landing clears the downward velocity");
1642 assert!(
1643 world
1644 .ctx()
1645 .query::<RigidBody>()
1646 .all(|body| body.is_grounded),
1647 "the grounded state that gates the next jump was not published"
1648 );
1649 }
1650
1651 #[test]
1655 fn interacting_twice_picks_up_then_throws_the_prop() {
1656 let id = AssetId(1);
1657 let mut world = TestWorld::new();
1658 let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1659 make_dynamic(&mut world, carriable);
1660 world
1661 .components
1662 .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1663
1664 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1665 physics.init(&mut world.ctx());
1666
1667 physics.step(&mut world.ctx());
1668 assert_eq!(world.ctx().query::<Held>().count(), 1, "picked up");
1669 assert!(physics.held.is_some());
1670
1671 physics.step(&mut world.ctx());
1673 assert!(physics.held.is_none(), "the prop was not released");
1674 assert_eq!(
1675 world.ctx().query::<Held>().count(),
1676 0,
1677 "the Held tag outlived the throw"
1678 );
1679 }
1680
1681 fn crossings_through(detects: TriggerFilter, steps: usize) -> Vec<VolumeEvent> {
1684 let volume_id = AssetId(9);
1685 let mut world = TestWorld::new();
1686 world.components.push_typed(TriggerVolume {
1687 asset_id: volume_id,
1688 position: [0.0, 3.0, 0.0],
1689 rotation_deg: [0.0; 3],
1690 collider: PropCollider {
1691 shape: "cuboid".to_string(),
1692 half_extents: [1.0, 0.5, 1.0],
1693 ..Default::default()
1694 },
1695 detects,
1696 });
1697 let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1698 make_dynamic(&mut world, ball);
1699
1700 let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1701 physics.init(&mut world.ctx());
1702
1703 let mut cursor = EventCursor::default();
1704 let mut crossings = Vec::new();
1705 for _ in 0..steps {
1706 physics.step(&mut world.ctx());
1707 let ctx = world.ctx();
1708 if let Some(events) = ctx.events::<VolumeEvent>() {
1709 crossings.extend(events.read(&mut cursor).copied());
1710 }
1711 }
1712 crossings
1713 }
1714
1715 #[test]
1718 fn an_any_volume_reports_a_prop_crossing_it() {
1719 let crossings = crossings_through(TriggerFilter::Any, 180);
1720 assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1721 }
1722
1723 #[test]
1726 fn a_player_volume_ignores_a_prop_crossing_it() {
1727 let crossings = crossings_through(TriggerFilter::Player, 180);
1728 assert!(
1729 crossings.is_empty(),
1730 "a prop is not the player: {crossings:?}"
1731 );
1732 }
1733}