Skip to main content

pite_physics/
world.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::collections::{HashMap, HashSet};
3use std::sync::Mutex;
4
5use pite_core::NodeId;
6use rapier2d::control::{CharacterLength, KinematicCharacterController};
7use rapier2d::dynamics::CCDSolver;
8use rapier2d::prelude::*;
9
10use crate::character::CharacterMoveResult;
11use crate::query::RaycastHit;
12use crate::shape::ShapeConfig;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PhysicsEvent {
16    BodyEntered {
17        area_id: NodeId,
18        body_id: NodeId,
19    },
20    BodyExited {
21        area_id: NodeId,
22        body_id: NodeId,
23    },
24    AreaEntered {
25        area_id: NodeId,
26        other_area_id: NodeId,
27    },
28    AreaExited {
29        area_id: NodeId,
30        other_area_id: NodeId,
31    },
32}
33
34#[derive(Default)]
35struct PhysicsEventCollector {
36    events: Mutex<Vec<CollisionEvent>>,
37}
38
39impl EventHandler for PhysicsEventCollector {
40    fn handle_collision_event(
41        &self,
42        _bodies: &RigidBodySet,
43        _colliders: &ColliderSet,
44        event: CollisionEvent,
45        _: Option<&ContactPair>,
46    ) {
47        if let Ok(mut lock) = self.events.lock() {
48            lock.push(event);
49        }
50    }
51
52    fn handle_contact_force_event(
53        &self,
54        _dt: Real,
55        _bodies: &RigidBodySet,
56        _colliders: &ColliderSet,
57        _contact_pair: &ContactPair,
58        _total_force_magnitude: Real,
59    ) {
60    }
61}
62
63pub struct PhysicsWorld {
64    pub gravity: Vector<Real>,
65    integration_parameters: IntegrationParameters,
66    physics_pipeline: PhysicsPipeline,
67    island_manager: IslandManager,
68    broad_phase: DefaultBroadPhase,
69    narrow_phase: NarrowPhase,
70    rigid_body_set: RigidBodySet,
71    collider_set: ColliderSet,
72    impulse_joint_set: ImpulseJointSet,
73    multibody_joint_set: MultibodyJointSet,
74    ccd_solver: CCDSolver,
75    query_pipeline: QueryPipeline,
76    character_controller: KinematicCharacterController,
77    event_collector: PhysicsEventCollector,
78
79    // Node <-> Handle mappings
80    node_to_body: HashMap<NodeId, RigidBodyHandle>,
81    body_to_node: HashMap<RigidBodyHandle, NodeId>,
82    node_to_collider: HashMap<NodeId, ColliderHandle>,
83    collider_to_node: HashMap<ColliderHandle, NodeId>,
84    collider_to_body: HashMap<ColliderHandle, NodeId>,
85
86    // Shape configs and offsets
87    shape_configs: HashMap<NodeId, (ShapeConfig, Vector<Real>)>,
88
89    // Body kinds & tags
90    area_nodes: HashSet<NodeId>,
91    character_nodes: HashSet<NodeId>,
92    rigid_nodes: HashSet<NodeId>,
93    static_nodes: HashSet<NodeId>,
94}
95
96impl Default for PhysicsWorld {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl PhysicsWorld {
103    pub fn new() -> Self {
104        let mut character_controller = KinematicCharacterController::default();
105        character_controller.up = -Vector::y_axis(); // Standard 2D coordinate system: -Y is up
106        character_controller.offset = CharacterLength::Absolute(0.01);
107        character_controller.slide = true;
108
109        let mut integration_parameters = IntegrationParameters::default();
110        integration_parameters.dt = 1.0 / 60.0;
111
112        Self {
113            gravity: Vector::new(0.0, 980.0), // 980 pixels/sec^2
114            integration_parameters,
115            physics_pipeline: PhysicsPipeline::new(),
116            island_manager: IslandManager::new(),
117            broad_phase: DefaultBroadPhase::new(),
118            narrow_phase: NarrowPhase::new(),
119            rigid_body_set: RigidBodySet::new(),
120            collider_set: ColliderSet::new(),
121            impulse_joint_set: ImpulseJointSet::new(),
122            multibody_joint_set: MultibodyJointSet::new(),
123            ccd_solver: CCDSolver::new(),
124            query_pipeline: QueryPipeline::new(),
125            character_controller,
126            event_collector: PhysicsEventCollector::default(),
127
128            node_to_body: HashMap::new(),
129            body_to_node: HashMap::new(),
130            node_to_collider: HashMap::new(),
131            collider_to_node: HashMap::new(),
132            collider_to_body: HashMap::new(),
133
134            shape_configs: HashMap::new(),
135
136            area_nodes: HashSet::new(),
137            character_nodes: HashSet::new(),
138            rigid_nodes: HashSet::new(),
139            static_nodes: HashSet::new(),
140        }
141    }
142
143    /// Add a StaticBody2D to the physics world.
144    pub fn add_static_body(&mut self, id: NodeId, position: (f32, f32), rotation: f32) {
145        self.remove_node(&id);
146        let rb = RigidBodyBuilder::fixed()
147            .translation(Vector::new(position.0, position.1))
148            .rotation(rotation)
149            .build();
150        let handle = self.rigid_body_set.insert(rb);
151        self.node_to_body.insert(id.clone(), handle);
152        self.body_to_node.insert(handle, id.clone());
153        self.static_nodes.insert(id);
154    }
155
156    /// Add a CharacterBody2D (kinematic position-based) to the physics world.
157    pub fn add_character_body(&mut self, id: NodeId, position: (f32, f32), rotation: f32) {
158        self.remove_node(&id);
159        let rb = RigidBodyBuilder::kinematic_position_based()
160            .translation(Vector::new(position.0, position.1))
161            .rotation(rotation)
162            .build();
163        let handle = self.rigid_body_set.insert(rb);
164        self.node_to_body.insert(id.clone(), handle);
165        self.body_to_node.insert(handle, id.clone());
166        self.character_nodes.insert(id);
167    }
168
169    /// Add a RigidBody2D (dynamic) to the physics world.
170    pub fn add_rigid_body(
171        &mut self,
172        id: NodeId,
173        position: (f32, f32),
174        rotation: f32,
175        mass: f32,
176        gravity_scale: f32,
177        lock_rotation: bool,
178    ) {
179        self.remove_node(&id);
180        let mut builder = RigidBodyBuilder::dynamic()
181            .translation(Vector::new(position.0, position.1))
182            .rotation(rotation)
183            .gravity_scale(gravity_scale)
184            .additional_mass(mass.max(0.001));
185        if lock_rotation {
186            builder = builder.lock_rotations();
187        }
188        let handle = self.rigid_body_set.insert(builder.build());
189        self.node_to_body.insert(id.clone(), handle);
190        self.body_to_node.insert(handle, id.clone());
191        self.rigid_nodes.insert(id);
192    }
193
194    /// Add an Area2D to the physics world.
195    pub fn add_area(&mut self, id: NodeId, position: (f32, f32), rotation: f32) {
196        self.remove_node(&id);
197        let rb = RigidBodyBuilder::fixed()
198            .translation(Vector::new(position.0, position.1))
199            .rotation(rotation)
200            .build();
201        let handle = self.rigid_body_set.insert(rb);
202        self.node_to_body.insert(id.clone(), handle);
203        self.body_to_node.insert(handle, id.clone());
204        self.area_nodes.insert(id);
205    }
206
207    /// Attach a CollisionShape2D collider to a parent body or area.
208    pub fn attach_shape(
209        &mut self,
210        body_id: &NodeId,
211        shape_node_id: NodeId,
212        config: ShapeConfig,
213        offset: (f32, f32),
214        rotation: f32,
215        is_sensor: bool,
216        collision_layer: u32,
217        collision_mask: u32,
218        friction: f32,
219        restitution: f32,
220    ) {
221        if let Some(&body_handle) = self.node_to_body.get(body_id) {
222            let shared_shape = config.to_shared_shape();
223            let mut collider_builder = ColliderBuilder::new(shared_shape)
224                .translation(Vector::new(offset.0, offset.1))
225                .rotation(rotation)
226                .friction(friction)
227                .restitution(restitution)
228                .active_events(ActiveEvents::COLLISION_EVENTS)
229                .active_collision_types(ActiveCollisionTypes::all());
230
231            if is_sensor || self.area_nodes.contains(body_id) {
232                collider_builder = collider_builder.sensor(true);
233            }
234
235            // Set collision filter groups
236            let memberships = Group::from_bits_truncate(collision_layer);
237            let filter = Group::from_bits_truncate(collision_mask);
238            collider_builder =
239                collider_builder.collision_groups(InteractionGroups::new(memberships, filter));
240
241            let collider = collider_builder.build();
242            let collider_handle = self.collider_set.insert_with_parent(
243                collider,
244                body_handle,
245                &mut self.rigid_body_set,
246            );
247
248            self.node_to_collider
249                .insert(shape_node_id.clone(), collider_handle);
250            self.collider_to_node
251                .insert(collider_handle, shape_node_id.clone());
252            self.collider_to_body
253                .insert(collider_handle, body_id.clone());
254            self.shape_configs
255                .insert(shape_node_id, (config, Vector::new(offset.0, offset.1)));
256        }
257    }
258
259    /// Remove a node (body or shape) from the physics world.
260    pub fn remove_node(&mut self, id: &NodeId) {
261        if let Some(body_handle) = self.node_to_body.remove(id) {
262            self.body_to_node.remove(&body_handle);
263            self.area_nodes.remove(id);
264            self.character_nodes.remove(id);
265            self.rigid_nodes.remove(id);
266            self.static_nodes.remove(id);
267            self.rigid_body_set.remove(
268                body_handle,
269                &mut self.island_manager,
270                &mut self.collider_set,
271                &mut self.impulse_joint_set,
272                &mut self.multibody_joint_set,
273                true,
274            );
275        }
276        if let Some(collider_handle) = self.node_to_collider.remove(id) {
277            self.collider_to_node.remove(&collider_handle);
278            self.collider_to_body.remove(&collider_handle);
279            self.shape_configs.remove(id);
280            self.collider_set.remove(
281                collider_handle,
282                &mut self.island_manager,
283                &mut self.rigid_body_set,
284                true,
285            );
286        }
287    }
288
289    /// Step the simulation by the fixed timestep.
290    pub fn step(&mut self) {
291        let physics_hooks = ();
292        self.physics_pipeline.step(
293            &self.gravity,
294            &self.integration_parameters,
295            &mut self.island_manager,
296            &mut self.broad_phase,
297            &mut self.narrow_phase,
298            &mut self.rigid_body_set,
299            &mut self.collider_set,
300            &mut self.impulse_joint_set,
301            &mut self.multibody_joint_set,
302            &mut self.ccd_solver,
303            Some(&mut self.query_pipeline),
304            &physics_hooks,
305            &self.event_collector,
306        );
307    }
308
309    /// Execute `move_and_slide` for a `CharacterBody2D`.
310    pub fn move_and_slide(
311        &mut self,
312        character_id: &NodeId,
313        desired_velocity: (f32, f32),
314        dt: f32,
315        up_direction: (f32, f32),
316    ) -> CharacterMoveResult {
317        let Some(&body_handle) = self.node_to_body.get(character_id) else {
318            return CharacterMoveResult::default();
319        };
320
321        let (rb_pos, first_collider) = match self.rigid_body_set.get(body_handle) {
322            Some(b) => {
323                let t = *b.translation();
324                let c = b.colliders().first().copied();
325                (t, c)
326            }
327            None => return CharacterMoveResult::default(),
328        };
329
330        let Some(collider_handle) = first_collider else {
331            // No colliders attached: simply move without collision
332            let movement = (desired_velocity.0 * dt, desired_velocity.1 * dt);
333            if let Some(b) = self.rigid_body_set.get_mut(body_handle) {
334                b.set_translation(
335                    Vector::new(rb_pos.x + movement.0, rb_pos.y + movement.1),
336                    true,
337                );
338            }
339            return CharacterMoveResult {
340                movement,
341                velocity: desired_velocity,
342                is_on_floor: false,
343                is_on_wall: false,
344                is_on_ceiling: false,
345            };
346        };
347
348        let collider = match self.collider_set.get(collider_handle) {
349            Some(c) => c,
350            None => return CharacterMoveResult::default(),
351        };
352
353        let shape = collider.shape();
354        let current_pos = collider.position();
355        let desired_translation = Vector::new(desired_velocity.0 * dt, desired_velocity.1 * dt);
356
357        let up_vec = Vector::new(up_direction.0, up_direction.1);
358        let up = UnitVector::try_new(up_vec, 0.0001).unwrap_or_else(|| -Vector::y_axis());
359        self.character_controller.up = up;
360
361        self.query_pipeline.update(&self.collider_set);
362
363        let filter = QueryFilter::default()
364            .exclude_rigid_body(body_handle)
365            .exclude_sensors();
366
367        let mut is_on_floor = false;
368        let mut is_on_wall = false;
369        let mut is_on_ceiling = false;
370
371        let movement = self.character_controller.move_shape(
372            dt,
373            &self.rigid_body_set,
374            &self.collider_set,
375            &self.query_pipeline,
376            shape,
377            current_pos,
378            desired_translation,
379            filter,
380            |collision| {
381                let normal = collision.hit.normal2;
382                let dot = normal.dot(up.as_ref());
383                if dot > 0.7 {
384                    is_on_floor = true;
385                } else if dot < -0.7 {
386                    is_on_ceiling = true;
387                } else {
388                    is_on_wall = true;
389                }
390            },
391        );
392
393        if movement.grounded {
394            is_on_floor = true;
395        }
396
397        // Apply effective translation to character rigid body
398        let actual_translation = movement.translation;
399        let new_pos = Vector::new(
400            rb_pos.x + actual_translation.x,
401            rb_pos.y + actual_translation.y,
402        );
403        if let Some(b) = self.rigid_body_set.get_mut(body_handle) {
404            b.set_translation(new_pos, true);
405        }
406
407        let effective_vel = if dt > 0.0 {
408            (actual_translation.x / dt, actual_translation.y / dt)
409        } else {
410            desired_velocity
411        };
412
413        CharacterMoveResult {
414            movement: (actual_translation.x, actual_translation.y),
415            velocity: effective_vel,
416            is_on_floor,
417            is_on_wall,
418            is_on_ceiling,
419        }
420    }
421
422    /// Apply an impulse to a RigidBody2D.
423    pub fn apply_impulse(&mut self, id: &NodeId, impulse: (f32, f32)) {
424        if let Some(&body_handle) = self.node_to_body.get(id) {
425            if let Some(body) = self.rigid_body_set.get_mut(body_handle) {
426                body.apply_impulse(Vector::new(impulse.0, impulse.1), true);
427            }
428        }
429    }
430
431    /// Apply a continuous force to a RigidBody2D.
432    pub fn apply_force(&mut self, id: &NodeId, force: (f32, f32)) {
433        if let Some(&body_handle) = self.node_to_body.get(id) {
434            if let Some(body) = self.rigid_body_set.get_mut(body_handle) {
435                body.add_force(Vector::new(force.0, force.1), true);
436            }
437        }
438    }
439
440    pub fn is_rigid_body(&self, id: &NodeId) -> bool {
441        self.rigid_nodes.contains(id)
442    }
443
444    /// Retrieve the current global position of a body.
445    pub fn get_body_position(&self, id: &NodeId) -> Option<(f64, f64)> {
446        let handle = self.node_to_body.get(id)?;
447        let body = self.rigid_body_set.get(*handle)?;
448        let t = body.translation();
449        Some((t.x as f64, t.y as f64))
450    }
451
452    /// Set the position of a body.
453    pub fn set_body_position(&mut self, id: &NodeId, pos: (f64, f64)) {
454        if let Some(&handle) = self.node_to_body.get(id) {
455            if let Some(body) = self.rigid_body_set.get_mut(handle) {
456                body.set_translation(Vector::new(pos.0 as f32, pos.1 as f32), true);
457            }
458        }
459    }
460
461    /// Retrieve the current rotation of a body.
462    pub fn get_body_rotation(&self, id: &NodeId) -> Option<f64> {
463        let handle = self.node_to_body.get(id)?;
464        let body = self.rigid_body_set.get(*handle)?;
465        Some(body.rotation().angle() as f64)
466    }
467
468    /// Perform a 2D raycast query.
469    pub fn raycast(&self, from: (f32, f32), to: (f32, f32), mask: u32) -> Option<RaycastHit> {
470        let ray_origin = Point::new(from.0, from.1);
471        let dir = Vector::new(to.0 - from.0, to.1 - from.1);
472        let max_toi = dir.norm();
473        if max_toi <= 0.0001 {
474            return None;
475        }
476        let ray_dir = dir / max_toi;
477        let ray = Ray::new(ray_origin, ray_dir);
478
479        let groups = InteractionGroups::new(Group::all(), Group::from_bits_truncate(mask));
480        let filter = QueryFilter::default().groups(groups);
481
482        let (handle, intersection) = self.query_pipeline.cast_ray_and_get_normal(
483            &self.rigid_body_set,
484            &self.collider_set,
485            &ray,
486            max_toi,
487            true,
488            filter,
489        )?;
490
491        let collider_node = self.collider_to_node.get(&handle)?.clone();
492        let body_node = self.collider_to_body.get(&handle).cloned();
493        let hit_point = ray.point_at(intersection.time_of_impact);
494        let normal = intersection.normal;
495
496        Some(RaycastHit {
497            point: (hit_point.x, hit_point.y),
498            normal: (normal.x, normal.y),
499            distance: intersection.time_of_impact,
500            collider_node,
501            body_node,
502        })
503    }
504
505    /// Drain queued collision events (body/area enter and exit).
506    pub fn drain_events(&mut self) -> Vec<PhysicsEvent> {
507        let mut raw_events = Vec::new();
508        if let Ok(mut lock) = self.event_collector.events.lock() {
509            raw_events.append(&mut *lock);
510        }
511
512        let mut events = Vec::new();
513        for ev in raw_events {
514            match ev {
515                CollisionEvent::Started(h1, h2, _flags) => {
516                    self.resolve_collision_pair(h1, h2, true, &mut events);
517                }
518                CollisionEvent::Stopped(h1, h2, _flags) => {
519                    self.resolve_collision_pair(h1, h2, false, &mut events);
520                }
521            }
522        }
523        events
524    }
525
526    fn resolve_collision_pair(
527        &self,
528        h1: ColliderHandle,
529        h2: ColliderHandle,
530        started: bool,
531        out: &mut Vec<PhysicsEvent>,
532    ) {
533        let b1 = self.collider_to_body.get(&h1);
534        let b2 = self.collider_to_body.get(&h2);
535        let Some(node1) = b1 else { return };
536        let Some(node2) = b2 else { return };
537
538        let is_area1 = self.area_nodes.contains(node1);
539        let is_area2 = self.area_nodes.contains(node2);
540
541        if is_area1 && is_area2 {
542            if started {
543                out.push(PhysicsEvent::AreaEntered {
544                    area_id: node1.clone(),
545                    other_area_id: node2.clone(),
546                });
547                out.push(PhysicsEvent::AreaEntered {
548                    area_id: node2.clone(),
549                    other_area_id: node1.clone(),
550                });
551            } else {
552                out.push(PhysicsEvent::AreaExited {
553                    area_id: node1.clone(),
554                    other_area_id: node2.clone(),
555                });
556                out.push(PhysicsEvent::AreaExited {
557                    area_id: node2.clone(),
558                    other_area_id: node1.clone(),
559                });
560            }
561        } else if is_area1 && !is_area2 {
562            if started {
563                out.push(PhysicsEvent::BodyEntered {
564                    area_id: node1.clone(),
565                    body_id: node2.clone(),
566                });
567            } else {
568                out.push(PhysicsEvent::BodyExited {
569                    area_id: node1.clone(),
570                    body_id: node2.clone(),
571                });
572            }
573        } else if !is_area1 && is_area2 {
574            if started {
575                out.push(PhysicsEvent::BodyEntered {
576                    area_id: node2.clone(),
577                    body_id: node1.clone(),
578                });
579            } else {
580                out.push(PhysicsEvent::BodyExited {
581                    area_id: node2.clone(),
582                    body_id: node1.clone(),
583                });
584            }
585        }
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn test_character_moves_and_hits_floor() {
595        let mut world = PhysicsWorld::new();
596
597        // Floor at y = 100, box 200 x 20
598        let floor_id = NodeId::from("Floor");
599        world.add_static_body(floor_id.clone(), (0.0, 100.0), 0.0);
600        world.attach_shape(
601            &floor_id,
602            NodeId::from("FloorShape"),
603            ShapeConfig::Box {
604                width: 200.0,
605                height: 20.0,
606            },
607            (0.0, 0.0),
608            0.0,
609            false,
610            1,
611            1,
612            0.5,
613            0.0,
614        );
615
616        // Character starting at y = 70 with a box 20 x 20 (bottom at y = 80)
617        let player_id = NodeId::from("Player");
618        world.add_character_body(player_id.clone(), (0.0, 70.0), 0.0);
619        world.attach_shape(
620            &player_id,
621            NodeId::from("PlayerShape"),
622            ShapeConfig::Box {
623                width: 20.0,
624                height: 20.0,
625            },
626            (0.0, 0.0),
627            0.0,
628            false,
629            1,
630            1,
631            0.0,
632            0.0,
633        );
634
635        // Move downward by 50 units (trying to reach y = 120, but floor top is at 90)
636        let res = world.move_and_slide(&player_id, (0.0, 50.0), 1.0, (0.0, -1.0));
637        assert!(res.is_on_floor);
638        let pos = world.get_body_position(&player_id).unwrap();
639        // Should have stopped around y = 80 (since player half-height = 10, floor top = 90)
640        assert!((pos.1 - 80.0).abs() < 1.0, "Expected y ~ 80, got {}", pos.1);
641    }
642
643    #[test]
644    fn test_rigidbody_falls_under_gravity() {
645        let mut world = PhysicsWorld::new();
646        let crate_id = NodeId::from("Crate");
647        world.add_rigid_body(crate_id.clone(), (0.0, 0.0), 0.0, 1.0, 1.0, false);
648        world.attach_shape(
649            &crate_id,
650            NodeId::from("CrateShape"),
651            ShapeConfig::Box {
652                width: 10.0,
653                height: 10.0,
654            },
655            (0.0, 0.0),
656            0.0,
657            false,
658            1,
659            1,
660            0.5,
661            0.0,
662        );
663
664        // Step for 10 frames (1/60s each)
665        for _ in 0..10 {
666            world.step();
667        }
668
669        let pos = world.get_body_position(&crate_id).unwrap();
670        assert!(pos.1 > 0.0, "RigidBody should have fallen under gravity");
671    }
672
673    #[test]
674    fn test_area2d_signals_on_overlap() {
675        let mut world = PhysicsWorld::new();
676
677        let area_id = NodeId::from("Coin");
678        world.add_area(area_id.clone(), (0.0, 0.0), 0.0);
679        world.attach_shape(
680            &area_id,
681            NodeId::from("CoinShape"),
682            ShapeConfig::Circle { radius: 20.0 },
683            (0.0, 0.0),
684            0.0,
685            true,
686            1,
687            1,
688            0.0,
689            0.0,
690        );
691
692        let body_id = NodeId::from("Ball");
693        world.add_rigid_body(body_id.clone(), (0.0, 0.0), 0.0, 1.0, 0.0, false);
694        world.attach_shape(
695            &body_id,
696            NodeId::from("BallShape"),
697            ShapeConfig::Circle { radius: 10.0 },
698            (0.0, 0.0),
699            0.0,
700            false,
701            1,
702            1,
703            0.0,
704            0.0,
705        );
706
707        world.step();
708        let events = world.drain_events();
709        assert!(
710            events.contains(&PhysicsEvent::BodyEntered {
711                area_id: area_id.clone(),
712                body_id: body_id.clone()
713            }),
714            "Expected BodyEntered event for area and body"
715        );
716    }
717
718    #[test]
719    fn test_character_triggers_area() {
720        let mut world = PhysicsWorld::new();
721        let area_id = NodeId::from("Area");
722        world.add_area(area_id.clone(), (0.0, 20.0), 0.0);
723        world.attach_shape(
724            &area_id,
725            NodeId::from("AreaShape"),
726            ShapeConfig::Box {
727                width: 50.0,
728                height: 50.0,
729            },
730            (0.0, 0.0),
731            0.0,
732            true,
733            1,
734            1,
735            0.0,
736            0.0,
737        );
738
739        let char_id = NodeId::from("Char");
740        world.add_character_body(char_id.clone(), (0.0, 0.0), 0.0);
741        world.attach_shape(
742            &char_id,
743            NodeId::from("CharShape"),
744            ShapeConfig::Box {
745                width: 20.0,
746                height: 20.0,
747            },
748            (0.0, 0.0),
749            0.0,
750            false,
751            1,
752            1,
753            0.0,
754            0.0,
755        );
756
757        world.move_and_slide(&char_id, (0.0, 200.0), 1.0 / 60.0, (0.0, -1.0));
758        world.step();
759        let events = world.drain_events();
760        println!("Events: {events:?}");
761        assert!(!events.is_empty());
762    }
763
764    #[test]
765    fn test_raycast_detects_solid() {
766        let mut world = PhysicsWorld::new();
767
768        let wall_id = NodeId::from("Wall");
769        world.add_static_body(wall_id.clone(), (100.0, 0.0), 0.0);
770        world.attach_shape(
771            &wall_id,
772            NodeId::from("WallShape"),
773            ShapeConfig::Box {
774                width: 20.0,
775                height: 100.0,
776            },
777            (0.0, 0.0),
778            0.0,
779            false,
780            1,
781            1,
782            0.0,
783            0.0,
784        );
785
786        world.step(); // update query pipeline
787
788        // Cast ray from (0, 0) to (200, 0)
789        let hit = world
790            .raycast((0.0, 0.0), (200.0, 0.0), 1)
791            .expect("ray should hit wall");
792        assert_eq!(hit.collider_node, NodeId::from("WallShape"));
793        assert_eq!(hit.body_node, Some(wall_id));
794        // Wall left face is at x = 90
795        assert!((hit.point.0 - 90.0).abs() < 1.0);
796        assert_eq!(hit.normal, (-1.0, 0.0));
797
798        // Cast ray with non-matching mask
799        let miss = world.raycast((0.0, 0.0), (200.0, 0.0), 2);
800        assert!(miss.is_none());
801    }
802}