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 receives one carrying these values at start so the
12/// settings are a component rather than a fallback. 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}