Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
//! The [`RigidBody`] component and its [`RigidBodyKind`].
|
||||
//!
|
||||
//! A rigid body is the dynamics half of a physics object: it says *how* an
|
||||
//! entity moves under the simulation (or that it does not). The *shape* it
|
||||
//! collides with is a separate [`Collider`](crate::Collider) component, so the
|
||||
//! two concerns compose — a body with no collider is a point mass, a collider
|
||||
//! with no body is static world geometry.
|
||||
//!
|
||||
//! Like every Oxide component these are plain serializable data with
|
||||
//! `#[derive(Reflect)]`, so they are editable from the inspector and from
|
||||
//! scripts with no per-type editor code. The simulation reads them when it
|
||||
//! builds the rapier world; the
|
||||
//! authored fields here are inputs, never live simulation state (velocities and
|
||||
//! poses live on the [`Transform`](oxide_engine::math::Transform) and inside
|
||||
//! rapier).
|
||||
|
||||
use oxide_engine::reflect::{Reflect, ReflectEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a [`RigidBody`] participates in the simulation.
|
||||
///
|
||||
/// Mirrors rapier's body types, named for engine users:
|
||||
/// - [`Dynamic`](Self::Dynamic) — fully simulated; moved by gravity, forces,
|
||||
/// and contacts.
|
||||
/// - [`Kinematic`](Self::Kinematic) — moved only by the game (its
|
||||
/// [`Transform`](oxide_engine::math::Transform)); pushes dynamic bodies but is
|
||||
/// never pushed back. The basis for the character controller (Stage 9 later
|
||||
/// piece) and for moving platforms.
|
||||
/// - [`Static`](Self::Static) — never moves; immovable world geometry (floors,
|
||||
/// walls). The cheapest body — no integration cost.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ReflectEnum)]
|
||||
pub enum RigidBodyKind {
|
||||
/// Fully simulated — gravity, forces, and contacts move it.
|
||||
#[default]
|
||||
Dynamic,
|
||||
/// Moved only by the game; affects dynamics but is not affected by them.
|
||||
Kinematic,
|
||||
/// Immovable world geometry.
|
||||
Static,
|
||||
}
|
||||
|
||||
impl RigidBodyKind {
|
||||
/// Every kind, for editor menus.
|
||||
pub const ALL: [RigidBodyKind; 3] = [
|
||||
RigidBodyKind::Dynamic,
|
||||
RigidBodyKind::Kinematic,
|
||||
RigidBodyKind::Static,
|
||||
];
|
||||
|
||||
/// A human-readable label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
RigidBodyKind::Dynamic => "Dynamic",
|
||||
RigidBodyKind::Kinematic => "Kinematic",
|
||||
RigidBodyKind::Static => "Static",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Component: the dynamics of a physics object.
|
||||
///
|
||||
/// Attach alongside a [`Collider`](crate::Collider) to make an entity take part
|
||||
/// in the simulation. The fields are authoring inputs consumed when the rapier
|
||||
/// body is created; runtime velocity/pose are not stored here (the
|
||||
/// [`Transform`](oxide_engine::math::Transform) is the authoritative pose, which
|
||||
/// the simulation writes back each step).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
|
||||
pub struct RigidBody {
|
||||
/// How the body participates in the simulation.
|
||||
pub kind: RigidBodyKind,
|
||||
/// Mass in kilograms. Ignored for non-dynamic bodies. `0` means "derive the
|
||||
/// mass from the collider's [`density`](crate::Collider::density)".
|
||||
pub mass: f32,
|
||||
/// Linear velocity damping (drag), `0` = none. Slows translation over time.
|
||||
pub linear_damping: f32,
|
||||
/// Angular velocity damping, `0` = none. Slows rotation over time.
|
||||
pub angular_damping: f32,
|
||||
/// Per-body multiplier on global gravity (`1` = normal, `0` = floats,
|
||||
/// negative = anti-gravity). Lets some bodies ignore gravity individually.
|
||||
pub gravity_scale: f32,
|
||||
/// Enable continuous collision detection — prevents fast bodies tunnelling
|
||||
/// through thin geometry, at extra cost. Off by default.
|
||||
pub ccd: bool,
|
||||
}
|
||||
|
||||
impl Default for RigidBody {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: RigidBodyKind::Dynamic,
|
||||
// 0 = derive mass from collider density (rapier's default behaviour).
|
||||
mass: 0.0,
|
||||
linear_damping: 0.0,
|
||||
angular_damping: 0.0,
|
||||
gravity_scale: 1.0,
|
||||
ccd: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RigidBody {
|
||||
/// A static (immovable) body — convenience for world geometry.
|
||||
pub fn static_body() -> Self {
|
||||
Self {
|
||||
kind: RigidBodyKind::Static,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A kinematic body — moved by the game, not by the simulation.
|
||||
pub fn kinematic() -> Self {
|
||||
Self {
|
||||
kind: RigidBodyKind::Kinematic,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_a_dynamic_body() {
|
||||
let b = RigidBody::default();
|
||||
assert_eq!(b.kind, RigidBodyKind::Dynamic);
|
||||
assert_eq!(b.gravity_scale, 1.0);
|
||||
assert!(!b.ccd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructors_set_the_kind() {
|
||||
assert_eq!(RigidBody::static_body().kind, RigidBodyKind::Static);
|
||||
assert_eq!(RigidBody::kinematic().kind, RigidBodyKind::Kinematic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let b = RigidBody {
|
||||
kind: RigidBodyKind::Kinematic,
|
||||
mass: 2.5,
|
||||
linear_damping: 0.1,
|
||||
angular_damping: 0.2,
|
||||
gravity_scale: 0.0,
|
||||
ccd: true,
|
||||
};
|
||||
let ron = ron::to_string(&b).unwrap();
|
||||
let back: RigidBody = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(b, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_variants_are_reflected_for_a_combo() {
|
||||
// The editor lists these in a dropdown via ReflectEnum.
|
||||
assert_eq!(
|
||||
RigidBodyKind::variants(),
|
||||
&["Dynamic", "Kinematic", "Static"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The [`CharacterController`] component — a kinematic capsule character.
|
||||
//!
|
||||
//! A character controller is *not* a rigid body: it never reacts to forces.
|
||||
//! Instead the game asks it to move by some desired translation each frame and
|
||||
//! the controller resolves that against the world's colliders — sliding along
|
||||
//! walls, stepping up small ledges, sticking to the ground on slopes, and
|
||||
//! refusing to climb anything too steep — then reports whether the character
|
||||
//! ended up grounded. It is the basis for player/NPC movement and is reused by
|
||||
//! the Stage-15 prototyping kit.
|
||||
//!
|
||||
//! The character is a **capsule** (radius + cylindrical half-height along `+Y`).
|
||||
//! An entity with this component is moved through
|
||||
//! [`PhysicsWorld::move_character`](crate::PhysicsWorld::move_character); it does
|
||||
//! **not** carry a [`RigidBody`](crate::RigidBody)/[`Collider`](crate::Collider)
|
||||
//! (it isn't simulated), so it never appears in the body set and never
|
||||
//! self-collides.
|
||||
|
||||
use oxide_engine::math::Vec3;
|
||||
use oxide_engine::reflect::Reflect;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Component: a kinematic capsule character moved by move-and-slide.
|
||||
///
|
||||
/// Tune the capsule shape (`radius`/`half_height`) and the movement rules
|
||||
/// (`max_slope_degrees`, `step_offset`, `snap_to_ground`, `skin_width`). Pass an
|
||||
/// entity carrying this to
|
||||
/// [`PhysicsWorld::move_character`](crate::PhysicsWorld::move_character).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
|
||||
pub struct CharacterController {
|
||||
/// Capsule radius.
|
||||
pub radius: f32,
|
||||
/// Capsule cylindrical half-height (the straight segment between the two
|
||||
/// hemispherical caps), along local `+Y`.
|
||||
pub half_height: f32,
|
||||
/// Steepest ground slope (degrees from horizontal) the character will walk
|
||||
/// up; steeper surfaces are treated as walls.
|
||||
pub max_slope_degrees: f32,
|
||||
/// Tallest step/ledge the character will automatically climb. `0` disables
|
||||
/// auto-stepping.
|
||||
pub step_offset: f32,
|
||||
/// Distance below the feet within which the character snaps down to stay
|
||||
/// grounded (prevents bouncing down stairs/slopes). `0` disables snapping.
|
||||
pub snap_to_ground: f32,
|
||||
/// A small collision-detection margin kept around the capsule.
|
||||
pub skin_width: f32,
|
||||
}
|
||||
|
||||
impl Default for CharacterController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radius: 0.3,
|
||||
half_height: 0.6,
|
||||
max_slope_degrees: 45.0,
|
||||
step_offset: 0.3,
|
||||
snap_to_ground: 0.2,
|
||||
skin_width: 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CharacterController {
|
||||
/// The total half-height of the capsule (cylinder half-height + one radius),
|
||||
/// i.e. the distance from the capsule center to either tip.
|
||||
pub fn total_half_height(&self) -> f32 {
|
||||
self.half_height + self.radius
|
||||
}
|
||||
|
||||
/// The local-space offset from the capsule center down to the feet (the
|
||||
/// bottom tip), useful for placing/grounding the character.
|
||||
pub fn foot_offset(&self) -> Vec3 {
|
||||
Vec3::new(0.0, -self.total_half_height(), 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_a_reasonable_humanoid_capsule() {
|
||||
let c = CharacterController::default();
|
||||
assert_eq!(c.radius, 0.3);
|
||||
assert!((c.total_half_height() - 0.9).abs() < 1e-6);
|
||||
assert!((c.foot_offset() - Vec3::new(0.0, -0.9, 0.0)).length() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let c = CharacterController {
|
||||
radius: 0.4,
|
||||
half_height: 1.0,
|
||||
max_slope_degrees: 50.0,
|
||||
step_offset: 0.0,
|
||||
snap_to_ground: 0.0,
|
||||
skin_width: 0.02,
|
||||
};
|
||||
let ron = ron::to_string(&c).unwrap();
|
||||
let back: CharacterController = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(c, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! The [`Collider`] component and its [`ColliderShape`].
|
||||
//!
|
||||
//! A collider is the *shape* half of a physics object: the geometry the
|
||||
//! simulation tests for contact, plus the material (friction/restitution) and
|
||||
//! the [`LayerMask`] filtering that decides *what it collides with*. Paired with
|
||||
//! a [`RigidBody`](crate::RigidBody) it becomes a moving body; on its own it is
|
||||
//! static world geometry.
|
||||
//!
|
||||
//! The shape is modelled as a flat selector + dimension fields rather than a
|
||||
//! data-carrying enum, mirroring [`MeshRenderer`]'s `PrimitiveShape` — that
|
||||
//! keeps every field an individually-reflectable scalar so the inspector renders
|
||||
//! a clean combo + drag-values with no per-type editor code. Convex-hull and
|
||||
//! triangle-mesh colliders (which need mesh data) are a later Stage-9 piece.
|
||||
//!
|
||||
//! [`MeshRenderer`]: oxide_engine::render::MeshRenderer
|
||||
|
||||
use oxide_engine::layer::LayerMask;
|
||||
use oxide_engine::math::Vec3;
|
||||
use oxide_engine::reflect::{Reflect, ReflectEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The primitive shape of a [`Collider`].
|
||||
///
|
||||
/// Which dimension fields on the [`Collider`] are read depends on the shape:
|
||||
/// - [`Box`](Self::Box) — `half_extents` (per-axis half sizes).
|
||||
/// - [`Sphere`](Self::Sphere) — `radius`.
|
||||
/// - [`Capsule`](Self::Capsule) — `radius` + `half_height` (the cylindrical
|
||||
/// segment half-length, capped by hemispheres; axis is local `+Y`).
|
||||
/// - [`Cylinder`](Self::Cylinder) — `radius` + `half_height` (axis is local
|
||||
/// `+Y`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ReflectEnum)]
|
||||
pub enum ColliderShape {
|
||||
/// Axis-aligned box; uses `half_extents`.
|
||||
#[default]
|
||||
Box,
|
||||
/// Sphere; uses `radius`.
|
||||
Sphere,
|
||||
/// Capsule along local `+Y`; uses `radius` + `half_height`.
|
||||
Capsule,
|
||||
/// Cylinder along local `+Y`; uses `radius` + `half_height`.
|
||||
Cylinder,
|
||||
}
|
||||
|
||||
impl ColliderShape {
|
||||
/// Every shape, for editor menus.
|
||||
pub const ALL: [ColliderShape; 4] = [
|
||||
ColliderShape::Box,
|
||||
ColliderShape::Sphere,
|
||||
ColliderShape::Capsule,
|
||||
ColliderShape::Cylinder,
|
||||
];
|
||||
|
||||
/// A human-readable label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColliderShape::Box => "Box",
|
||||
ColliderShape::Sphere => "Sphere",
|
||||
ColliderShape::Capsule => "Capsule",
|
||||
ColliderShape::Cylinder => "Cylinder",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Component: the collision shape and material of a physics object.
|
||||
///
|
||||
/// Attach on its own for static world geometry, or alongside a
|
||||
/// [`RigidBody`](crate::RigidBody) for a moving body. `membership`/`filter` are
|
||||
/// the engine's shared [`LayerMask`] primitive (Stage 5), so collision groups,
|
||||
/// triggers, and scene queries all filter the same way.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
|
||||
pub struct Collider {
|
||||
/// The primitive shape; selects which dimension fields below are used.
|
||||
pub shape: ColliderShape,
|
||||
/// Half-extents for [`ColliderShape::Box`] (per-axis half sizes).
|
||||
pub half_extents: Vec3,
|
||||
/// Radius for sphere / capsule / cylinder shapes.
|
||||
pub radius: f32,
|
||||
/// Half-height for capsule / cylinder shapes (along local `+Y`).
|
||||
pub half_height: f32,
|
||||
/// Coulomb friction coefficient (`0` = frictionless, ~`0.5` typical).
|
||||
pub friction: f32,
|
||||
/// Bounciness, `0` = inelastic, `1` = fully elastic.
|
||||
pub restitution: f32,
|
||||
/// Mass density (kg/m³-ish); used to derive a dynamic body's mass when its
|
||||
/// [`mass`](crate::RigidBody::mass) is `0`.
|
||||
pub density: f32,
|
||||
/// When `true` the collider is a **sensor (trigger)**: it reports overlap
|
||||
/// (enter/stay/exit events) without resolving contact, so things pass
|
||||
/// through it.
|
||||
pub sensor: bool,
|
||||
/// The layers this collider **belongs to** — what it *is*, for the other
|
||||
/// side's filter to select.
|
||||
pub membership: LayerMask,
|
||||
/// The layers this collider **collides with** — what it *cares about*. Two
|
||||
/// colliders interact only when each one's membership intersects the
|
||||
/// other's filter.
|
||||
pub filter: LayerMask,
|
||||
}
|
||||
|
||||
impl Default for Collider {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shape: ColliderShape::Box,
|
||||
// A unit cube (matches the default MeshRenderer primitive).
|
||||
half_extents: Vec3::splat(0.5),
|
||||
radius: 0.5,
|
||||
half_height: 0.5,
|
||||
friction: 0.5,
|
||||
restitution: 0.0,
|
||||
density: 1.0,
|
||||
sensor: false,
|
||||
// Belong to layer 0 ("Default") and collide with everything, so a
|
||||
// freshly-added collider interacts out of the box.
|
||||
membership: LayerMask::layer(0),
|
||||
filter: LayerMask::ALL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Collider {
|
||||
/// A box collider with the given per-axis half-extents.
|
||||
pub fn cuboid(half_extents: Vec3) -> Self {
|
||||
Self {
|
||||
shape: ColliderShape::Box,
|
||||
half_extents,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A sphere collider of the given radius.
|
||||
pub fn ball(radius: f32) -> Self {
|
||||
Self {
|
||||
shape: ColliderShape::Sphere,
|
||||
radius,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A capsule collider (radius + cylindrical half-height along `+Y`).
|
||||
pub fn capsule(radius: f32, half_height: f32) -> Self {
|
||||
Self {
|
||||
shape: ColliderShape::Capsule,
|
||||
radius,
|
||||
half_height,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// This collider made a sensor (trigger).
|
||||
pub fn as_sensor(mut self) -> Self {
|
||||
self.sensor = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// This collider on the given membership/filter masks.
|
||||
pub fn with_layers(mut self, membership: LayerMask, filter: LayerMask) -> Self {
|
||||
self.membership = membership;
|
||||
self.filter = filter;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_a_unit_box_that_collides_with_everything() {
|
||||
let c = Collider::default();
|
||||
assert_eq!(c.shape, ColliderShape::Box);
|
||||
assert_eq!(c.half_extents, Vec3::splat(0.5));
|
||||
assert!(!c.sensor);
|
||||
assert_eq!(c.membership, LayerMask::layer(0));
|
||||
assert_eq!(c.filter, LayerMask::ALL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructors_pick_the_shape() {
|
||||
assert_eq!(Collider::ball(2.0).shape, ColliderShape::Sphere);
|
||||
assert_eq!(Collider::ball(2.0).radius, 2.0);
|
||||
assert_eq!(Collider::capsule(0.3, 0.8).shape, ColliderShape::Capsule);
|
||||
assert!(Collider::default().as_sensor().sensor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_layers_sets_masks() {
|
||||
let c = Collider::default().with_layers(LayerMask::layer(2), LayerMask::layer(3));
|
||||
assert_eq!(c.membership, LayerMask::layer(2));
|
||||
assert_eq!(c.filter, LayerMask::layer(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_ron() {
|
||||
let c = Collider::capsule(0.4, 1.0).as_sensor().with_layers(
|
||||
LayerMask::layer(1),
|
||||
LayerMask::layer(2).union(LayerMask::layer(5)),
|
||||
);
|
||||
let ron = ron::to_string(&c).unwrap();
|
||||
let back: Collider = ron::from_str(&ron).unwrap();
|
||||
assert_eq!(c, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_variants_are_reflected_for_a_combo() {
|
||||
assert_eq!(
|
||||
ColliderShape::variants(),
|
||||
&["Box", "Sphere", "Capsule", "Cylinder"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Oxide Engine — physics module (`oxide-physics`).
|
||||
//!
|
||||
//! Stage 9 adds rigid-body physics — collision, queries, constraints, and a
|
||||
//! character controller — as a **feature-gated module** built on
|
||||
//! [`rapier3d`](https://rapier.rs), not a thin wrapper. It plugs into the engine
|
||||
//! through the Stage-5 module system: add [`PhysicsModule`] to an
|
||||
//! [`App`](oxide_engine::app::App) and physics components become live.
|
||||
//!
|
||||
//! The design keeps the **ECS as the source of truth**: an entity describes its
|
||||
//! physics with two serializable, reflected components —
|
||||
//! - [`RigidBody`] — *how* it moves (dynamic / kinematic / static, mass,
|
||||
//! damping, gravity scale, CCD), and
|
||||
//! - [`Collider`] — *what shape* it is, its material, and the [`LayerMask`]
|
||||
//! filtering of what it collides with.
|
||||
//!
|
||||
//! The rapier simulation world is a transient resource rebuilt from these
|
||||
//! components, so play-mode snapshot/restore (Stage 8.7) works for free: Stop
|
||||
//! reverts the authored components and the next Play rebuilds the world fresh.
|
||||
//!
|
||||
//! [`LayerMask`]: oxide_engine::layer::LayerMask
|
||||
//!
|
||||
//! This first piece lands the component data model and the module wiring; the
|
||||
//! rapier-backed simulation, scene queries, joints, and the character controller
|
||||
//! follow in subsequent pieces.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
mod body;
|
||||
mod character;
|
||||
mod collider;
|
||||
mod module;
|
||||
mod world;
|
||||
|
||||
pub use body::{RigidBody, RigidBodyKind};
|
||||
pub use character::CharacterController;
|
||||
pub use collider::{Collider, ColliderShape};
|
||||
pub use module::{PhysicsModule, PhysicsSettings, DEFAULT_GRAVITY};
|
||||
pub use world::{CharacterMovement, CollisionEvent, JointId, JointKind, PhysicsWorld, RayHit};
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The [`PhysicsModule`] — the Stage-9 entry point that wires physics into an
|
||||
//! [`App`].
|
||||
//!
|
||||
//! Following the engine's module convention, everything physics contributes is
|
||||
//! registered here so it can be enabled, disabled, or removed as a unit (and so
|
||||
//! an exported game that never touches physics never compiles it in). This piece
|
||||
//! registers the component *types* for reflection (making [`RigidBody`] and
|
||||
//! [`Collider`] dual-editable and captured by the play-mode snapshot) and
|
||||
//! installs the global [`PhysicsSettings`]. The simulation system itself is
|
||||
//! added in a later piece.
|
||||
|
||||
use oxide_engine::app::{App, Module, Schedule};
|
||||
use oxide_engine::math::Vec3;
|
||||
|
||||
use crate::world::step_physics;
|
||||
use crate::{CharacterController, Collider, PhysicsWorld, RigidBody};
|
||||
|
||||
/// Earth-like gravity (m/s²) along `-Y` — the default for a new physics world.
|
||||
pub const DEFAULT_GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
|
||||
|
||||
/// Project-wide physics tuning, stored as an [`App`] resource.
|
||||
///
|
||||
/// Kept tiny and serializable so it can grow into a settings page; for now it
|
||||
/// carries the global gravity vector the simulation integrates dynamic bodies
|
||||
/// against (each body can still scale it via
|
||||
/// [`RigidBody::gravity_scale`](crate::RigidBody::gravity_scale)).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PhysicsSettings {
|
||||
/// Global gravity acceleration applied to dynamic bodies.
|
||||
pub gravity: Vec3,
|
||||
}
|
||||
|
||||
impl Default for PhysicsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gravity: DEFAULT_GRAVITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The physics module. Add it after [`DefaultModules`] to give an app a
|
||||
/// rapier-backed simulation.
|
||||
///
|
||||
/// [`DefaultModules`]: oxide_engine::app::DefaultModules
|
||||
///
|
||||
/// ```
|
||||
/// use oxide_engine::app::App;
|
||||
/// use oxide_physics::PhysicsModule;
|
||||
///
|
||||
/// let mut app = App::new();
|
||||
/// app.add_module(PhysicsModule);
|
||||
/// assert!(app.has_module("physics"));
|
||||
/// assert!(app.types.is_registered("RigidBody"));
|
||||
/// ```
|
||||
pub struct PhysicsModule;
|
||||
|
||||
impl Module for PhysicsModule {
|
||||
fn name(&self) -> &'static str {
|
||||
"physics"
|
||||
}
|
||||
|
||||
fn build(&self, app: &mut App) {
|
||||
// Register the component types for reflection so they round-trip through
|
||||
// RON (scripts/AI) and are captured by the play-mode scene snapshot.
|
||||
app.register_type::<RigidBody>("RigidBody");
|
||||
app.register_type::<Collider>("Collider");
|
||||
app.register_type::<CharacterController>("CharacterController");
|
||||
|
||||
// The global gravity setting; the simulation reads it each fixed step.
|
||||
app.insert_resource(PhysicsSettings::default());
|
||||
|
||||
// The transient rapier world (rebuilt from components) and the
|
||||
// fixed-timestep system that syncs, steps, and writes poses back.
|
||||
app.insert_resource(PhysicsWorld::new());
|
||||
app.add_system(Schedule::FixedUpdate, step_physics);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oxide_engine::app::DefaultModules;
|
||||
|
||||
#[test]
|
||||
fn module_registers_component_types() {
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
app.add_module(PhysicsModule);
|
||||
|
||||
assert!(app.has_module("physics"));
|
||||
assert!(app.types.is_registered("RigidBody"));
|
||||
assert!(app.types.is_registered("Collider"));
|
||||
assert_eq!(
|
||||
app.get_resource::<PhysicsSettings>().map(|s| s.gravity),
|
||||
Some(DEFAULT_GRAVITY)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_module_drops_its_types() {
|
||||
let mut app = App::new();
|
||||
app.add_module(PhysicsModule);
|
||||
assert!(app.types.is_registered("Collider"));
|
||||
|
||||
assert!(app.remove_module("physics"));
|
||||
assert!(!app.has_module("physics"));
|
||||
assert!(!app.types.is_registered("RigidBody"));
|
||||
assert!(!app.types.is_registered("Collider"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user