Skip to main content

concinnity_core/components/
physics_config.rs

1// World-level physics configuration schema.
2
3use crate::ecs::asset_id::AssetId;
4use crate::ecs::asset_id::de_opt_asset_ref;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8/// Configures the world's physics floor / terrain.
9///
10/// Optional: a world with physics bodies but no `PhysicsConfig` simulates over a
11/// flat floor at Y = 0, and the build injects one carrying these values so the
12/// settings are visible in `world-lock.json`. Physics runs whenever the world
13/// declares a `PhysicsConfig`, a [RigidBody](#rigidbody), a
14/// [PropBody](#propbody), a [TriggerVolume](#triggervolume), or a
15/// [SkinnedMesh](#skinnedmesh) with a `capsule`. Declare a `PhysicsConfig` to
16/// put bodies on terrain or a non-zero floor.
17///
18/// For terrain-based outdoor scenes the terrain parameters must match the
19/// terrain mesh exactly.
20///
21/// ```rust
22/// # use concinnity_core::components::PhysicsConfig;
23/// PhysicsConfig {
24///     terrain_offset_y: -0.5,
25///     ..Default::default()
26/// };
27/// ```
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29#[serde(default)]
30pub struct PhysicsConfig {
31    /// Y coordinate of the floor. When left at 0.0 it is auto-detected from the
32    /// camera; set it explicitly to override.
33    pub floor_y: f32,
34    /// Half-width of the terrain mesh along X. Must match the terrain mesh.
35    /// Leave at 0.0 (with `terrain_subdivisions` = 0) for flat-floor scenes.
36    pub terrain_half_width: f32,
37    /// Half-depth of the terrain mesh along Z. Must match the terrain mesh.
38    pub terrain_half_depth: f32,
39    /// Subdivision count of the terrain mesh. When 0, a flat floor at Y = 0 is
40    /// used instead of a heightfield.
41    pub terrain_subdivisions: u32,
42    /// Height variation of the terrain mesh. Must match the terrain mesh.
43    pub terrain_amplitude: f32,
44    /// World-space Y offset of the terrain: the height of the prop that renders
45    /// the terrain mesh. Leave at 0.0 when the terrain sits at the origin.
46    pub terrain_offset_y: f32,
47    /// Name of a [ProceduralMesh](#proceduralmesh) with `generator:
48    /// "heightfield"`. When set, the physics surface is built from that mesh's
49    /// source image so props rest on the visible terrain. Takes precedence over
50    /// the `terrain_*` values above.
51    #[serde(default, deserialize_with = "de_opt_asset_ref")]
52    pub terrain_mesh: Option<AssetId>,
53    /// Extra collision layer names beyond the built-ins (`world`, `prop`,
54    /// `character`, `trigger`). At most 28; referenced by collider `layer`
55    /// fields and `no_collide` pairs.
56    pub layers: Vec<String>,
57    /// Unordered layer-name pairs that do not collide. Everything collides by
58    /// default; each pair here disables collision (and contact solving) between
59    /// its two layers symmetrically. Pairs naming `character` also filter the
60    /// character controller's movement.
61    pub no_collide: Vec<[String; 2]>,
62    /// Minimum contact impulse (mass times velocity change) for a collision to
63    /// publish a contact event. Resting contact stays below it; raise to hear
64    /// only hard impacts.
65    pub contact_min_impulse: f32,
66    /// Extra physics bodies reserved for props created while the world runs
67    /// (by a [Spawner](#spawner), a [Behavior](#behavior) `spawn` node, or the
68    /// host). Physics reserves every body it will ever need when the world
69    /// loads and never grows: once the declared bodies plus this many are
70    /// live, a further spawn gets no physics body and is reported as an error.
71    ///
72    /// This is a floor beneath what the build reserves on its own, not the
73    /// whole reservation. Every [Spawner](#spawner) whose `interval` and
74    /// `lifetime` bound how many copies can be alive at once is already
75    /// reserved for, and the larger of the two numbers wins. Set a value here
76    /// for the sources the build cannot count: a `Spawner` with `lifetime: 0`
77    /// (its copies live forever), a `spawn` node in a behavior, and spawns the
78    /// host drives itself.
79    pub spawn_headroom: u32,
80}
81
82impl Default for PhysicsConfig {
83    fn default() -> Self {
84        Self {
85            floor_y: 0.0,
86            terrain_half_width: 0.0,
87            terrain_half_depth: 0.0,
88            terrain_subdivisions: 0,
89            terrain_amplitude: 0.0,
90            terrain_offset_y: 0.0,
91            terrain_mesh: None,
92            layers: Vec::new(),
93            no_collide: Vec::new(),
94            contact_min_impulse: 1.0,
95            spawn_headroom: 0,
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use alloc::string::ToString;
104    use alloc::vec;
105
106    #[test]
107    fn a_blank_config_is_a_flat_floor_at_the_origin() {
108        let p = PhysicsConfig::default();
109        assert_eq!(p.floor_y, 0.0);
110        assert_eq!(p.terrain_amplitude, 0.0);
111        assert_eq!(p.terrain_subdivisions, 0);
112        assert_eq!(p.terrain_offset_y, 0.0);
113        // No mesh named means the generated terrain values are what is used.
114        assert!(p.terrain_mesh.is_none());
115        // Everything collides by default; light impacts stay silent.
116        assert!(p.layers.is_empty());
117        assert!(p.no_collide.is_empty());
118        assert_eq!(p.contact_min_impulse, 1.0);
119        // Nothing is held back for runtime spawns unless a world asks for it.
120        assert_eq!(p.spawn_headroom, 0);
121    }
122
123    #[test]
124    fn authored_spawn_headroom_round_trips_through_postcard() {
125        let p: PhysicsConfig = serde_json::from_str(r#"{"spawn_headroom":64}"#).unwrap();
126        assert_eq!(p.spawn_headroom, 64);
127
128        // The runtime reads the headroom off the baked component, so it has to
129        // survive the wire, not just the JSON parse.
130        let bytes = postcard::to_allocvec(&p).unwrap();
131        let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
132        assert_eq!(back.spawn_headroom, 64);
133    }
134
135    #[test]
136    fn layers_and_no_collide_parse_and_round_trip_through_postcard() {
137        let p: PhysicsConfig = serde_json::from_str(
138            r#"{"layers":["debris"],"no_collide":[["debris","character"]],
139                "contact_min_impulse":2.5}"#,
140        )
141        .unwrap();
142        assert_eq!(p.layers, vec!["debris".to_string()]);
143        assert_eq!(
144            p.no_collide,
145            vec![["debris".to_string(), "character".to_string()]]
146        );
147
148        let bytes = postcard::to_allocvec(&p).unwrap();
149        let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
150        assert_eq!(back.layers, p.layers);
151        assert_eq!(back.no_collide, p.no_collide);
152        assert_eq!(back.contact_min_impulse, 2.5);
153    }
154
155    #[test]
156    fn a_named_terrain_mesh_parses_and_round_trips_through_postcard() {
157        crate::test_support::install_resolvers();
158        let p: PhysicsConfig = serde_json::from_str(
159            r#"{"floor_y":-1.5,"terrain_half_width":128,"terrain_half_depth":128,
160                "terrain_subdivisions":64,"terrain_amplitude":12,"terrain_offset_y":2,
161                "terrain_mesh":"ground"}"#,
162        )
163        .unwrap();
164        assert_eq!(p.terrain_mesh, Some(AssetId(6)));
165
166        let bytes = postcard::to_allocvec(&p).unwrap();
167        let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
168        assert_eq!(back.floor_y, -1.5);
169        assert_eq!(back.terrain_half_width, 128.0);
170        assert_eq!(back.terrain_half_depth, 128.0);
171        assert_eq!(back.terrain_subdivisions, 64);
172        assert_eq!(back.terrain_amplitude, 12.0);
173        assert_eq!(back.terrain_offset_y, 2.0);
174        assert_eq!(back.terrain_mesh, Some(AssetId(6)));
175    }
176}