Skip to main content

concinnity_core/components/
prop_body.rs

1// Dynamic physics body schema for a companion Prop.
2
3use crate::ecs::AudioClipHandle;
4use crate::ecs::asset_id::AssetId;
5use crate::ecs::asset_id::de_opt_asset_ref;
6use crate::ecs::de_opt_audio_clip_handle;
7
8/// Makes a companion [Prop](#prop) a dynamic physics body.
9///
10/// Attach a PropBody to give a [Prop](#prop) real physics: it falls, collides,
11/// stacks, tumbles, and (with `pickup: true` on the prop) can be carried and
12/// thrown. A Prop with a `collider` but no PropBody is a static, immovable
13/// obstacle.
14///
15/// ```json
16/// {
17///   "name": "crate_a_body",
18///   "type": "PropBody",
19///   "args": { "prop_name": "crate_a", "mass": 4.0, "friction": 0.6 }
20/// }
21/// ```
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23#[serde(default)]
24pub struct PropBody {
25    /// The [Prop](#prop) this body drives. Must match a Prop declared in the
26    /// same world.
27    #[serde(deserialize_with = "de_opt_asset_ref")]
28    pub prop_name: Option<AssetId>,
29    /// Mass in kilograms. 0 lets the simulation derive mass from the collider
30    /// shape and a default density.
31    pub mass: f32,
32    /// Friction coefficient used for contacts with this body.
33    pub friction: f32,
34    /// Bounciness in [0, 1]. 0 is fully inelastic.
35    pub restitution: f32,
36    /// Multiplier applied to world gravity for this body. 1.0 is normal.
37    pub gravity_scale: f32,
38    /// Linear velocity damping, modelling air drag.
39    pub linear_damping: f32,
40    /// Optional [AudioClip](#audioclip) played at the contact point when this
41    /// body collides hard enough to pass the world's `contact_min_impulse`
42    /// (see [PhysicsConfig](#physicsconfig)). Louder impacts play louder.
43    #[serde(deserialize_with = "de_opt_audio_clip_handle")]
44    pub impact_clip: Option<AudioClipHandle>,
45    /// Linear gain applied to the impact clip at full impulse.
46    pub impact_volume: f32,
47}
48
49impl Default for PropBody {
50    fn default() -> Self {
51        Self {
52            prop_name: None,
53            mass: 0.0,
54            friction: 0.5,
55            restitution: 0.0,
56            gravity_scale: 1.0,
57            linear_damping: 0.05,
58            impact_clip: None,
59            impact_volume: 1.0,
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn a_blank_body_falls_under_full_gravity_without_bouncing() {
70        let b = PropBody::default();
71        assert_eq!(b.gravity_scale, 1.0);
72        assert_eq!(b.friction, 0.5);
73        assert_eq!(b.restitution, 0.0);
74        assert_eq!(b.linear_damping, 0.05);
75        // Zero mass means "derive it from the collider", not "massless".
76        assert_eq!(b.mass, 0.0);
77        assert!(b.prop_name.is_none());
78    }
79
80    #[test]
81    fn a_bouncy_floating_body_parses_and_round_trips_through_postcard() {
82        crate::test_support::install_resolvers();
83        let b: PropBody = serde_json::from_str(
84            r#"{"prop_name":"ball","mass":2.5,"friction":0.1,"restitution":0.9,
85                "gravity_scale":0,"linear_damping":0.2}"#,
86        )
87        .unwrap();
88        assert_eq!(b.prop_name, Some(AssetId(4)));
89        assert_eq!(b.gravity_scale, 0.0);
90
91        let bytes = postcard::to_allocvec(&b).unwrap();
92        let back: PropBody = postcard::from_bytes(&bytes).unwrap();
93        assert_eq!(back.prop_name, Some(AssetId(4)));
94        assert_eq!(back.mass, 2.5);
95        assert_eq!(back.friction, 0.1);
96        assert_eq!(back.restitution, 0.9);
97        assert_eq!(back.linear_damping, 0.2);
98    }
99}