Skip to main content

nightshade/ecs/physics/
components.rs

1//! Physics component definitions.
2
3use nalgebra_glm::{Quat, Vec3};
4use serde::{Deserialize, Serialize};
5
6use super::types::{ColliderHandle, InteractionGroups, LockedAxes, RigidBodyHandle, RigidBodyType};
7
8/// Rigid body component for physics simulation.
9///
10/// Defines a physics body that can be dynamic (affected by forces), kinematic
11/// (animated but affecting others), or static (fixed in place). Attach alongside
12/// a [`ColliderComponent`] to participate in collision detection and response.
13#[derive(Debug, Clone, Serialize, Deserialize, enum2schema::Schema)]
14pub struct RigidBodyComponent {
15    /// Internal handle assigned by the physics engine (managed automatically).
16    #[serde(skip)]
17    #[schema(skip)]
18    pub handle: Option<RigidBodyHandle>,
19    /// Body type: Dynamic, KinematicPositionBased, KinematicVelocityBased, or Fixed.
20    pub body_type: RigidBodyType,
21    /// Initial position in world space.
22    pub translation: [f32; 3],
23    /// Initial orientation as quaternion [x, y, z, w].
24    pub rotation: [f32; 4],
25    /// Linear velocity in world units per second.
26    pub linvel: [f32; 3],
27    /// Angular velocity in radians per second.
28    pub angvel: [f32; 3],
29    /// Mass in kilograms (only affects dynamic bodies).
30    pub mass: f32,
31    /// Linear velocity decay rate. 0.0 leaves motion to drag-free space, higher
32    /// values bleed off translation over time. Default: 0.0.
33    #[serde(default)]
34    pub linear_damping: f32,
35    /// Angular velocity decay rate. 0.0 spins forever, higher values settle
36    /// rotation over time. Default: 0.0.
37    #[serde(default)]
38    pub angular_damping: f32,
39    /// Per-body multiplier on world gravity. 1.0 is normal, 0.0 floats, negative
40    /// rises, 2.0 falls twice as hard. Default: 1.0.
41    #[serde(default = "default_gravity_scale")]
42    pub gravity_scale: f32,
43    /// Constraints on which axes can translate or rotate.
44    pub locked_axes: LockedAxes,
45    /// Enable continuous collision detection. Costs CPU but prevents fast-moving bodies from
46    /// tunneling through thin geometry. Default: false.
47    #[serde(default)]
48    pub ccd_enabled: bool,
49}
50
51fn default_gravity_scale() -> f32 {
52    1.0
53}
54
55impl Default for RigidBodyComponent {
56    fn default() -> Self {
57        Self {
58            handle: None,
59            body_type: RigidBodyType::default(),
60            translation: [0.0, 0.0, 0.0],
61            rotation: [0.0, 0.0, 0.0, 1.0],
62            linvel: [0.0, 0.0, 0.0],
63            angvel: [0.0, 0.0, 0.0],
64            mass: 1.0,
65            linear_damping: 0.0,
66            angular_damping: 0.0,
67            gravity_scale: 1.0,
68            locked_axes: LockedAxes::default(),
69            ccd_enabled: false,
70        }
71    }
72}
73
74impl RigidBodyComponent {
75    /// Creates a dynamic rigid body affected by forces and gravity.
76    pub fn new_dynamic() -> Self {
77        Self {
78            body_type: RigidBodyType::Dynamic,
79            ..Default::default()
80        }
81    }
82
83    /// Creates a kinematic rigid body driven by setting its translation each frame.
84    pub fn new_kinematic() -> Self {
85        Self {
86            body_type: RigidBodyType::KinematicPositionBased,
87            ..Default::default()
88        }
89    }
90
91    /// Creates a kinematic rigid body driven by setting its linear velocity. Unlike
92    /// the position-based kind, it responds to `set_linvel`, which is what scripts
93    /// use to move it, and dynamic bodies cannot push it.
94    pub fn new_kinematic_velocity() -> Self {
95        Self {
96            body_type: RigidBodyType::KinematicVelocityBased,
97            ..Default::default()
98        }
99    }
100
101    /// Creates a static (fixed) rigid body that never moves.
102    pub fn new_static() -> Self {
103        Self {
104            body_type: RigidBodyType::Fixed,
105            ..Default::default()
106        }
107    }
108
109    /// Sets the initial translation.
110    pub fn with_translation(mut self, x: f32, y: f32, z: f32) -> Self {
111        self.translation = [x, y, z];
112        self
113    }
114
115    /// Sets the mass in kilograms.
116    pub fn with_mass(mut self, mass: f32) -> Self {
117        self.mass = mass;
118        self
119    }
120
121    /// Sets the linear damping (translation decay rate).
122    pub fn with_linear_damping(mut self, damping: f32) -> Self {
123        self.linear_damping = damping;
124        self
125    }
126
127    /// Sets the angular damping (rotation decay rate).
128    pub fn with_angular_damping(mut self, damping: f32) -> Self {
129        self.angular_damping = damping;
130        self
131    }
132
133    /// Sets the per-body gravity multiplier.
134    pub fn with_gravity_scale(mut self, scale: f32) -> Self {
135        self.gravity_scale = scale;
136        self
137    }
138
139    /// Sets the initial rotation as a quaternion.
140    pub fn with_rotation(mut self, x: f32, y: f32, z: f32, w: f32) -> Self {
141        self.rotation = [x, y, z, w];
142        self
143    }
144
145    /// Enables continuous collision detection for this body. Use for fast-moving bodies
146    /// that would otherwise tunnel through thin geometry (bullets, fast props).
147    pub fn with_ccd(mut self, enabled: bool) -> Self {
148        self.ccd_enabled = enabled;
149        self
150    }
151
152    /// Converts to a Rapier rigid body for simulation.
153    #[cfg(feature = "physics")]
154    pub(crate) fn to_rapier_rigid_body(&self) -> rapier3d::prelude::RigidBody {
155        use rapier3d::prelude::*;
156
157        let translation = vector![
158            self.translation[0],
159            self.translation[1],
160            self.translation[2]
161        ];
162        let rotation =
163            rapier3d::na::UnitQuaternion::from_quaternion(rapier3d::na::Quaternion::new(
164                self.rotation[3],
165                self.rotation[0],
166                self.rotation[1],
167                self.rotation[2],
168            ));
169        let position = rapier3d::na::Isometry3::from_parts(translation.into(), rotation);
170
171        let rapier_body_type = super::types::rigid_body_type_to_rapier(self.body_type);
172        let rapier_locked_axes = super::types::locked_axes_to_rapier(self.locked_axes);
173
174        let mut rb = RigidBodyBuilder::new(rapier_body_type)
175            .pose(position)
176            .linvel(vector![self.linvel[0], self.linvel[1], self.linvel[2]])
177            .angvel(vector![self.angvel[0], self.angvel[1], self.angvel[2]])
178            .linear_damping(self.linear_damping)
179            .angular_damping(self.angular_damping)
180            .gravity_scale(self.gravity_scale)
181            .locked_axes(rapier_locked_axes)
182            .ccd_enabled(self.ccd_enabled)
183            .build();
184
185        if self.body_type == super::types::RigidBodyType::Dynamic {
186            rb.set_additional_mass(self.mass, true);
187        }
188
189        rb
190    }
191}
192
193use super::types::CharacterControllerConfig;
194
195/// First-person character controller with kinematic movement.
196///
197/// Provides walking, jumping, crouching, and sprinting using Rapier's
198/// kinematic character controller. Handles ground detection, slope limits,
199/// step climbing, and collision response.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, enum2schema::Schema)]
201pub struct CharacterControllerComponent {
202    /// Configuration for step height, slope limits, and autostep behavior.
203    pub config: CharacterControllerConfig,
204    /// Whether the character is currently touching the ground.
205    pub grounded: bool,
206    /// Current movement velocity.
207    pub velocity: Vec3,
208    /// Collision shape (typically a capsule).
209    pub shape: ColliderShape,
210    /// Maximum horizontal movement speed.
211    pub max_speed: f32,
212    /// Acceleration rate when moving.
213    pub acceleration: f32,
214    /// Upward velocity applied when jumping.
215    pub jump_impulse: f32,
216    /// Whether the character can currently jump (grounded and not recently jumped).
217    pub can_jump: bool,
218    /// Whether the character is currently crouching.
219    pub is_crouching: bool,
220    /// Whether the character is currently sprinting.
221    pub is_sprinting: bool,
222    /// Whether crouching is enabled.
223    pub crouch_enabled: bool,
224    /// Speed multiplier when crouching (typically < 1.0).
225    pub crouch_speed_multiplier: f32,
226    /// Speed multiplier when sprinting (typically > 1.0).
227    pub sprint_speed_multiplier: f32,
228    /// Capsule half-height when standing.
229    pub standing_half_height: f32,
230    /// Capsule half-height when crouching.
231    pub crouching_half_height: f32,
232    /// Internal state for crouch toggle detection.
233    pub crouch_input_was_pressed: bool,
234    /// Internal state for sprint toggle detection.
235    pub sprint_input_was_pressed: bool,
236    /// Internal state for jump detection.
237    pub jump_input_was_pressed: bool,
238    /// Overall character scale factor.
239    pub scale: f32,
240    /// Friction rate applied when grounded and at or below max_speed.
241    /// Higher values = faster deceleration. Applied as `1.0 - (rate * dt)`.
242    pub friction_rate: f32,
243    /// Friction rate applied when grounded and above max_speed.
244    /// Applied as exponential decay `exp(-rate * dt)`.
245    pub above_max_friction_rate: f32,
246    /// When false, the engine skips sprint/crouch/jump input for this entity.
247    /// The game is responsible for setting is_crouching, is_sprinting, and velocity.y.
248    /// The engine still handles WASD movement, acceleration, friction, gravity, and collision.
249    pub engine_input_enabled: bool,
250    /// When true, the engine skips gravity integration and the rapier
251    /// character solver for this entity, letting game code teleport
252    /// the player's transform without physics fighting it. Used by
253    /// the noclip fly path.
254    pub disable_gravity: bool,
255    /// Camera-relative analog movement injected by game code, treated like a
256    /// gamepad left stick: `x` steers right, `y` drives forward, magnitude
257    /// clamped to one. Non-zero values override keyboard movement for the
258    /// frame, which is how an on-screen joystick moves the player.
259    pub external_movement: nalgebra_glm::Vec2,
260    /// A one-shot jump request from game code, consumed by the input system on
261    /// the next step. Lets an on-screen button jump without a Space key.
262    pub external_jump: bool,
263}
264
265impl Default for CharacterControllerComponent {
266    fn default() -> Self {
267        Self {
268            config: CharacterControllerConfig::default(),
269            grounded: false,
270            velocity: Vec3::zeros(),
271            shape: ColliderShape::Capsule {
272                half_height: 0.9,
273                radius: 0.3,
274            },
275            max_speed: 5.0,
276            acceleration: 50.0,
277            jump_impulse: 8.0,
278            can_jump: false,
279            is_crouching: false,
280            is_sprinting: false,
281            crouch_enabled: true,
282            crouch_speed_multiplier: 0.5,
283            sprint_speed_multiplier: 1.6,
284            standing_half_height: 0.9,
285            crouching_half_height: 0.45,
286            crouch_input_was_pressed: false,
287            sprint_input_was_pressed: false,
288            jump_input_was_pressed: false,
289            scale: 1.0,
290            friction_rate: 8.0,
291            above_max_friction_rate: 1.5,
292            engine_input_enabled: true,
293            disable_gravity: false,
294            external_movement: nalgebra_glm::Vec2::zeros(),
295            external_jump: false,
296        }
297    }
298}
299
300impl CharacterControllerComponent {
301    /// Creates a character controller with a capsule collision shape.
302    pub fn new_capsule(half_height: f32, radius: f32) -> Self {
303        let character_height = 2.0 * half_height + 2.0 * radius;
304        let scale = character_height / 2.4;
305
306        Self {
307            config: CharacterControllerConfig::default(),
308            grounded: false,
309            velocity: Vec3::zeros(),
310            shape: ColliderShape::Capsule {
311                half_height,
312                radius,
313            },
314            max_speed: 5.0,
315            acceleration: 50.0,
316            jump_impulse: 8.0,
317            can_jump: false,
318            is_crouching: false,
319            is_sprinting: false,
320            crouch_enabled: true,
321            crouch_speed_multiplier: 0.5,
322            sprint_speed_multiplier: 1.6,
323            standing_half_height: half_height,
324            crouching_half_height: half_height * 0.5,
325            crouch_input_was_pressed: false,
326            sprint_input_was_pressed: false,
327            jump_input_was_pressed: false,
328            scale,
329            friction_rate: 8.0,
330            above_max_friction_rate: 1.5,
331            engine_input_enabled: true,
332            disable_gravity: false,
333            external_movement: nalgebra_glm::Vec2::zeros(),
334            external_jump: false,
335        }
336    }
337}
338
339/// Collision shape component with physical material properties.
340///
341/// Defines the collision geometry and surface properties for an entity.
342/// Can be attached to a rigid body for physics response, or used standalone
343/// for trigger volumes (sensors).
344#[derive(Debug, Clone, Serialize, Deserialize, enum2schema::Schema)]
345pub struct ColliderComponent {
346    /// Internal handle assigned by the physics engine (managed automatically).
347    #[serde(skip)]
348    #[schema(skip)]
349    pub handle: Option<ColliderHandle>,
350    /// Collision geometry shape.
351    pub shape: ColliderShape,
352    /// Friction coefficient (0.0 = frictionless, 1.0 = high friction).
353    pub friction: f32,
354    /// Restitution (bounciness) coefficient (0.0 = no bounce, 1.0 = perfectly elastic).
355    pub restitution: f32,
356    /// Density used to compute mass from volume (for dynamic bodies).
357    pub density: f32,
358    /// When true, detects overlaps but doesn't generate collision response.
359    pub is_sensor: bool,
360    /// Bitmask for filtering which colliders can collide.
361    pub collision_groups: InteractionGroups,
362    /// Bitmask for filtering which colliders participate in constraint solving.
363    pub solver_groups: InteractionGroups,
364}
365
366impl Default for ColliderComponent {
367    fn default() -> Self {
368        Self {
369            handle: None,
370            shape: ColliderShape::Cuboid {
371                hx: 0.5,
372                hy: 0.5,
373                hz: 0.5,
374            },
375            friction: 0.5,
376            restitution: 0.0,
377            density: 1.0,
378            is_sensor: false,
379            collision_groups: InteractionGroups::all(),
380            solver_groups: InteractionGroups::all(),
381        }
382    }
383}
384
385/// Collision shape geometry.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, enum2schema::Schema)]
387pub enum ColliderShape {
388    /// Sphere defined by radius.
389    Ball { radius: f32 },
390    /// Box defined by half-extents along each axis.
391    Cuboid { hx: f32, hy: f32, hz: f32 },
392    /// Capsule (cylinder with hemispherical caps) aligned to Y axis.
393    Capsule { half_height: f32, radius: f32 },
394    /// Cylinder aligned to Y axis.
395    Cylinder { half_height: f32, radius: f32 },
396    /// Cone pointing up along Y axis.
397    Cone { half_height: f32, radius: f32 },
398    /// Convex hull computed from a set of vertices.
399    ConvexMesh { vertices: Vec<[f32; 3]> },
400    /// Triangle mesh for static geometry.
401    TriMesh {
402        vertices: Vec<[f32; 3]>,
403        indices: Vec<[u32; 3]>,
404    },
405    /// Height field terrain.
406    HeightField {
407        nrows: usize,
408        ncols: usize,
409        heights: Vec<f32>,
410        scale: [f32; 3],
411    },
412}
413
414impl ColliderComponent {
415    /// Creates a sphere collider.
416    pub fn new_ball(radius: f32) -> Self {
417        Self {
418            shape: ColliderShape::Ball { radius },
419            ..Default::default()
420        }
421    }
422
423    /// Creates a box collider with the given half-extents.
424    pub fn new_cuboid(hx: f32, hy: f32, hz: f32) -> Self {
425        Self {
426            shape: ColliderShape::Cuboid { hx, hy, hz },
427            ..Default::default()
428        }
429    }
430
431    /// Creates a capsule collider aligned to the Y axis.
432    pub fn new_capsule(half_height: f32, radius: f32) -> Self {
433        Self {
434            shape: ColliderShape::Capsule {
435                half_height,
436                radius,
437            },
438            ..Default::default()
439        }
440    }
441
442    /// Creates a cylinder collider aligned to the Y axis.
443    pub fn new_cylinder(half_height: f32, radius: f32) -> Self {
444        Self {
445            shape: ColliderShape::Cylinder {
446                half_height,
447                radius,
448            },
449            ..Default::default()
450        }
451    }
452
453    /// Creates a cone collider pointing up along the Y axis.
454    pub fn new_cone(half_height: f32, radius: f32) -> Self {
455        Self {
456            shape: ColliderShape::Cone {
457                half_height,
458                radius,
459            },
460            ..Default::default()
461        }
462    }
463
464    /// Sets the friction coefficient.
465    pub fn with_friction(mut self, friction: f32) -> Self {
466        self.friction = friction;
467        self
468    }
469
470    /// Sets the restitution (bounciness) coefficient.
471    pub fn with_restitution(mut self, restitution: f32) -> Self {
472        self.restitution = restitution;
473        self
474    }
475
476    /// Sets the density for mass computation.
477    pub fn with_density(mut self, density: f32) -> Self {
478        self.density = density;
479        self
480    }
481
482    /// Marks this collider as a sensor (trigger volume).
483    pub fn as_sensor(mut self) -> Self {
484        self.is_sensor = true;
485        self
486    }
487
488    /// Converts to a Rapier collider for simulation.
489    #[cfg(feature = "physics")]
490    pub(crate) fn to_rapier_collider(&self) -> rapier3d::prelude::Collider {
491        use rapier3d::prelude::{ColliderBuilder, DMatrix, Point, Real, SharedShape, vector};
492
493        let shape: SharedShape = match &self.shape {
494            ColliderShape::Ball { radius } => SharedShape::ball(*radius),
495            ColliderShape::Cuboid { hx, hy, hz } => SharedShape::cuboid(*hx, *hy, *hz),
496            ColliderShape::Capsule {
497                half_height,
498                radius,
499            } => SharedShape::capsule_y(*half_height, *radius),
500            ColliderShape::Cylinder {
501                half_height,
502                radius,
503            } => SharedShape::cylinder(*half_height, *radius),
504            ColliderShape::Cone {
505                half_height,
506                radius,
507            } => SharedShape::cone(*half_height, *radius),
508            ColliderShape::ConvexMesh { vertices } => {
509                let points: Vec<Point<Real>> = vertices
510                    .iter()
511                    .map(|v| Point::new(v[0], v[1], v[2]))
512                    .collect();
513                SharedShape::convex_hull(&points).unwrap_or_else(|| SharedShape::ball(0.1))
514            }
515            ColliderShape::TriMesh { vertices, indices } => {
516                let points: Vec<Point<Real>> = vertices
517                    .iter()
518                    .map(|v| Point::new(v[0], v[1], v[2]))
519                    .collect();
520                SharedShape::trimesh(points, indices.clone())
521                    .unwrap_or_else(|_| SharedShape::ball(0.1))
522            }
523            ColliderShape::HeightField {
524                nrows,
525                ncols,
526                heights,
527                scale,
528            } => SharedShape::heightfield(
529                DMatrix::from_vec(*nrows, *ncols, heights.clone()),
530                vector![scale[0], scale[1], scale[2]],
531            ),
532        };
533
534        let rapier_collision_groups =
535            super::types::interaction_groups_to_rapier(self.collision_groups);
536        let rapier_solver_groups = super::types::interaction_groups_to_rapier(self.solver_groups);
537
538        ColliderBuilder::new(shape)
539            .friction(self.friction)
540            .restitution(self.restitution)
541            .density(self.density)
542            .sensor(self.is_sensor)
543            .collision_groups(rapier_collision_groups)
544            .solver_groups(rapier_solver_groups)
545            .build()
546    }
547}
548
549#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
550pub struct CollisionListener;
551
552/// Interpolation state for smooth rendering between physics updates.
553///
554/// Physics runs at a fixed timestep, but rendering may occur at different rates.
555/// This component stores the previous and current physics state to allow smooth
556/// interpolation based on the accumulated time since the last physics step.
557#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
558pub struct PhysicsInterpolation {
559    /// Position at the previous physics step.
560    pub previous_translation: Vec3,
561    /// Rotation at the previous physics step.
562    pub previous_rotation: Quat,
563    /// Position at the current physics step.
564    pub current_translation: Vec3,
565    /// Rotation at the current physics step.
566    pub current_rotation: Quat,
567    /// Whether interpolation is active.
568    pub enabled: bool,
569}
570
571impl PhysicsInterpolation {
572    /// Creates a new interpolation state with interpolation enabled.
573    pub fn new() -> Self {
574        Self {
575            previous_translation: Vec3::zeros(),
576            previous_rotation: Quat::identity(),
577            current_translation: Vec3::zeros(),
578            current_rotation: Quat::identity(),
579            enabled: true,
580        }
581    }
582
583    /// Computes the interpolated transform at the given alpha (0.0 = previous, 1.0 = current).
584    pub fn interpolate(&self, alpha: f32) -> (Vec3, Quat) {
585        if !self.enabled {
586            return (self.current_translation, self.current_rotation);
587        }
588
589        let translation =
590            nalgebra_glm::lerp(&self.previous_translation, &self.current_translation, alpha);
591        let rotation =
592            nalgebra_glm::quat_slerp(&self.previous_rotation, &self.current_rotation, alpha);
593        (translation, rotation)
594    }
595
596    /// Copies current state to previous (called before each physics step).
597    pub fn update_previous(&mut self) {
598        self.previous_translation = self.current_translation;
599        self.previous_rotation = self.current_rotation;
600    }
601
602    /// Updates the current physics state (called after each physics step).
603    pub fn update_current(&mut self, translation: Vec3, rotation: Quat) {
604        self.current_translation = translation;
605        self.current_rotation = rotation;
606    }
607}