Skip to main content

concinnity_core/components/
prop.rs

1// Scene-object prop schema.
2
3use crate::ecs::MaterialHandle;
4use crate::ecs::MeshHandle;
5use crate::ecs::TextureHandle;
6use crate::ecs::asset_id::AssetId;
7use crate::ecs::asset_id::de_opt_asset_ref;
8use crate::ecs::de_opt_material_handle;
9use crate::ecs::de_opt_mesh_handle;
10use crate::ecs::de_opt_texture_handle;
11use alloc::string::{String, ToString};
12
13/// Collision volume attached to a [Prop](#prop).
14///
15/// The shape dimensions are in the prop's local space and are scaled by the
16/// prop's `scale`. `ball` and `capsule` use the X scale component (they assume
17/// uniform scaling).
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19#[serde(default)]
20pub struct PropCollider {
21    /// Collision shape: "aabb" (alias "cuboid"), "ball", or "capsule".
22    pub shape: String,
23    /// Box half-extents in local space [x, y, z]. Used by cuboid shapes.
24    pub half_extents: [f32; 3],
25    /// Radius in local space. Used by ball and capsule shapes.
26    pub radius: f32,
27    /// Half the cylinder height in local space. Used by capsule shapes.
28    pub half_height: f32,
29    /// Collision layer name. Built-in layers are `world`, `prop`, `character`,
30    /// and `trigger`; extra names come from [PhysicsConfig](#physicsconfig)
31    /// `layers`. Empty derives the layer from the body kind: `world` for a
32    /// static prop, `prop` when a [PropBody](#propbody) makes it dynamic.
33    pub layer: String,
34}
35
36impl Default for PropCollider {
37    fn default() -> Self {
38        Self {
39            shape: "cuboid".to_string(),
40            half_extents: [0.5, 0.5, 0.5],
41            radius: 0.5,
42            half_height: 0.5,
43            layer: String::new(),
44        }
45    }
46}
47
48/// A scene object: places geometry at a world-space transform.
49///
50/// Reference either a [Model](#model) (multi-mesh) or a single
51/// [Mesh](#mesh)/[ProceduralMesh](#proceduralmesh). `model` takes precedence
52/// when both are set.
53///
54/// Rotation notes:
55/// - `rotation_deg[0]` = pitch (tilt forward/back)
56/// - `rotation_deg[1]` = yaw (spin on vertical axis), most common
57/// - `rotation_deg[2]` = roll (tilt side-to-side)
58///
59/// ```rust
60/// # use concinnity_core::components::Prop;
61/// Prop {
62///     position: [4.0, 0.4, -8.0],
63///     ..Default::default()
64/// };
65/// ```
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67#[serde(default)]
68pub struct Prop {
69    /// Asset identity; injected via `inject_name`. Not part of `args`.
70    #[serde(skip)]
71    pub asset_id: AssetId,
72    /// A [Model](#model) asset. When set, the prop renders all sub-meshes of
73    /// that model (each with its own material) sharing this prop's transform.
74    /// Takes precedence over `mesh` and `material`.
75    #[serde(deserialize_with = "de_opt_asset_ref")]
76    pub model: Option<AssetId>,
77    /// A [Mesh](#mesh) or [ProceduralMesh](#proceduralmesh) asset this prop
78    /// renders. Used when `model` is unset.
79    #[serde(deserialize_with = "de_opt_mesh_handle")]
80    pub mesh: Option<MeshHandle>,
81    /// A [Material](#material) to use for this prop. When set it takes
82    /// precedence over `texture` and provides the albedo texture plus the
83    /// lighting parameters (roughness, metallic, tint, emissive). Used when
84    /// `model` is unset.
85    #[serde(deserialize_with = "de_opt_material_handle")]
86    pub material: Option<MaterialHandle>,
87    /// A [Texture](#texture) to use for this prop. Older field: ignored when
88    /// `material` is set. Unset uses the first declared texture (or a white
89    /// fallback).
90    #[serde(deserialize_with = "de_opt_texture_handle")]
91    pub texture: Option<TextureHandle>,
92    /// World-space position [x, y, z].
93    pub position: [f32; 3],
94    /// Euler rotation in degrees [pitch, yaw, roll], applied in YXZ order
95    /// (yaw first so that rotating around the vertical axis is intuitive).
96    pub rotation_deg: [f32; 3],
97    /// Non-uniform scale [x, y, z]. Defaults to [1, 1, 1].
98    pub scale: [f32; 3],
99    /// Optional collision volume. When present, the prop blocks the player; when
100    /// absent the prop is non-solid.
101    pub collider: Option<PropCollider>,
102    /// When true, the player can interact with this prop: pressing the interact
103    /// key (E) while close and facing it triggers its rotation behaviour.
104    pub interactable: bool,
105    /// When true, the player can pick up and carry this prop with the interact
106    /// key (E). A companion [PropBody](#propbody) must also be declared so the
107    /// prop falls correctly after being dropped.
108    pub pickup: bool,
109    /// Another [Prop](#prop) whose world transform this prop inherits. When set,
110    /// `position`, `rotation_deg`, and `scale` are relative to the parent's
111    /// world transform. The parent must be declared in the same world; circular
112    /// chains are treated as an error.
113    #[serde(deserialize_with = "de_opt_asset_ref")]
114    pub parent: Option<AssetId>,
115    /// [Scene](#scene) this prop belongs to. Resolved automatically from the
116    /// naming convention (a prop named `<scene>_*` belongs to scene `<scene>`);
117    /// you don't set this directly. `None` means the prop is visible in every
118    /// scene. Used by scene switches for per-scene visibility.
119    #[serde(default, deserialize_with = "de_opt_asset_ref")]
120    pub scene: Option<AssetId>,
121    /// Name of a [Prefab](#prefab) to instantiate at this prop's transform. When
122    /// set, it expands into concrete child props and lights, replacing this
123    /// prop. Cannot be combined with `model` or `mesh`.
124    pub prefab: String,
125    /// Optional view-distance cutoff in world units. When > 0 the prop is hidden
126    /// once the camera is further than this from it. 0 (default) keeps the prop
127    /// visible at any distance.
128    pub cull_distance: f32,
129    /// Set at runtime while the prop is being carried. Not serialised.
130    /// While true, PhysicsSystem drives the prop as a kinematic body that
131    /// follows the camera instead of simulating it dynamically.
132    #[serde(skip)]
133    pub is_held: bool,
134}
135
136impl Default for Prop {
137    fn default() -> Self {
138        Self {
139            asset_id: AssetId::default(),
140            model: None,
141            mesh: None,
142            material: None,
143            texture: None,
144            position: [0.0, 0.0, 0.0],
145            rotation_deg: [0.0, 0.0, 0.0],
146            scale: [1.0, 1.0, 1.0],
147            collider: None,
148            interactable: false,
149            pickup: false,
150            parent: None,
151            scene: None,
152            prefab: String::new(),
153            cull_distance: 0.0,
154            is_held: false,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn a_blank_collider_is_a_unit_cuboid() {
165        let c = PropCollider::default();
166        assert_eq!(c.shape, "cuboid");
167        assert_eq!(c.half_extents, [0.5, 0.5, 0.5]);
168        assert_eq!(c.radius, 0.5);
169        assert_eq!(c.half_height, 0.5);
170    }
171
172    #[test]
173    fn a_blank_prop_is_an_unscaled_non_interactive_placement() {
174        let p = Prop::default();
175        assert_eq!(p.position, [0.0, 0.0, 0.0]);
176        assert_eq!(p.rotation_deg, [0.0, 0.0, 0.0]);
177        assert_eq!(p.scale, [1.0, 1.0, 1.0]);
178        // No collider means the prop is decoration: physics ignores it.
179        assert!(p.collider.is_none());
180        assert!(!p.interactable);
181        assert!(!p.pickup);
182        assert!(!p.is_held);
183        assert_eq!(p.cull_distance, 0.0);
184        assert!(p.prefab.is_empty());
185        assert!(p.model.is_none());
186        assert!(p.mesh.is_none());
187        assert!(p.material.is_none());
188        assert!(p.texture.is_none());
189        assert!(p.parent.is_none());
190        assert!(p.scene.is_none());
191    }
192
193    #[test]
194    fn every_reference_resolves_through_its_own_seam() {
195        crate::test_support::install_resolvers();
196        let p: Prop = serde_json::from_str(
197            r#"{"model":"crate_model","mesh":"crate_mesh","material":"wood","texture":"tex_wood",
198                "parent":"shelf","scene":"vault"}"#,
199        )
200        .unwrap();
201        // A Model is still an interned name; the resource kinds are handles.
202        assert_eq!(p.model, Some(AssetId(11)));
203        assert_eq!(p.mesh, Some(MeshHandle(10)));
204        assert_eq!(p.material, Some(MaterialHandle(4)));
205        assert_eq!(p.texture, Some(TextureHandle(8)));
206        assert_eq!(p.parent, Some(AssetId(5)));
207        assert_eq!(p.scene, Some(AssetId(5)));
208    }
209
210    #[test]
211    fn a_pickup_with_a_ball_collider_round_trips_through_postcard() {
212        let p: Prop = serde_json::from_str(
213            r#"{"position":[1,2,3],"rotation_deg":[0,90,0],"scale":[2,2,2],
214                "collider":{"shape":"ball","radius":0.25},
215                "interactable":true,"pickup":true,"prefab":"lantern","cull_distance":60}"#,
216        )
217        .unwrap();
218        let collider = p.collider.as_ref().expect("collider");
219        assert_eq!(collider.shape, "ball");
220        assert_eq!(collider.radius, 0.25);
221        // Unmentioned collider dimensions keep the schema defaults.
222        assert_eq!(collider.half_height, 0.5);
223
224        let bytes = postcard::to_allocvec(&p).unwrap();
225        let back: Prop = postcard::from_bytes(&bytes).unwrap();
226        assert_eq!(back.position, [1.0, 2.0, 3.0]);
227        assert_eq!(back.rotation_deg, [0.0, 90.0, 0.0]);
228        assert_eq!(back.scale, [2.0, 2.0, 2.0]);
229        assert_eq!(back.collider.expect("collider").shape, "ball");
230        assert!(back.interactable);
231        assert!(back.pickup);
232        assert_eq!(back.prefab, "lantern");
233        assert_eq!(back.cull_distance, 60.0);
234        // Held state is runtime-only, so it never rides the wire.
235        assert!(!back.is_held);
236        assert_eq!(back.asset_id, AssetId::default());
237    }
238}