concinnity_core/components/spawner.rs
1// src/components/spawner.rs
2//
3// The `Spawner` asset: the authored args a world declares, and the runtime
4// component (with its spawn accumulator) they bake into.
5
6use crate::ecs::Component;
7use crate::ecs::asset_id::AssetId;
8
9/// Authored fields of a `Spawner`; the runtime accumulator is not declared.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11#[serde(default)]
12pub struct SpawnerArgs {
13 /// Name of the placement to copy on each spawn.
14 pub template: AssetId,
15 /// Seconds between spawns.
16 pub interval: f32,
17 /// Seconds each spawned copy lives before auto-removal; 0 keeps it forever.
18 pub lifetime: f32,
19}
20
21impl Default for SpawnerArgs {
22 fn default() -> Self {
23 Self {
24 template: AssetId::default(),
25 interval: 1.0,
26 lifetime: 0.0,
27 }
28 }
29}
30
31#[cfg(test)]
32mod tests {
33 use super::*;
34
35 #[test]
36 fn a_blank_spawner_ticks_once_a_second_and_never_expires_its_copies() {
37 let s = SpawnerArgs::default();
38 assert_eq!(s.interval, 1.0);
39 // Zero lifetime means the copy lives until something despawns it.
40 assert_eq!(s.lifetime, 0.0);
41 assert_eq!(s.template, AssetId::default());
42 }
43
44 #[test]
45 fn an_authored_spawner_parses_and_round_trips_through_postcard() {
46 crate::test_support::install_resolvers();
47 let s: SpawnerArgs =
48 serde_json::from_str(r#"{"template":"spark","interval":0.25,"lifetime":3}"#).unwrap();
49 assert_eq!(s.template, AssetId(5));
50 assert_eq!(s.interval, 0.25);
51
52 let bytes = postcard::to_allocvec(&s).unwrap();
53 let back: SpawnerArgs = postcard::from_bytes(&bytes).unwrap();
54 assert_eq!(back.template, AssetId(5));
55 assert_eq!(back.lifetime, 3.0);
56 }
57}
58
59/// Periodically instantiates copies of an existing placement at this entity's
60/// position.
61///
62/// A spawner clones `template` (the name of another placement in the world)
63/// every `interval` seconds, giving each copy a `lifetime` after which it is
64/// automatically removed. Pairing a short lifetime with a short interval keeps a
65/// bounded population churning (an enemy wave, a particle of debris, a fountain
66/// of props) and is what exercises GPU draw-slot recycling: each expiry frees a
67/// slot the next spawn reuses.
68///
69/// The spawner's own `Transform` (its position) is where copies appear, so place
70/// the spawner where you want the stream to originate.
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct Spawner {
73 /// Name of the placement to copy on each spawn.
74 pub template: AssetId,
75 /// Seconds between spawns.
76 pub interval: f32,
77 /// Seconds each spawned copy lives before auto-removal; 0 keeps it forever.
78 pub lifetime: f32,
79 /// Runtime: seconds accumulated toward the next spawn.
80 pub elapsed: f32,
81 /// Runtime: number of copies spawned so far.
82 pub count: u32,
83}
84
85impl Spawner {
86 /// Translate the authored args into the runtime spawner: clamp the timing
87 /// knobs and zero the runtime counters. Run by cook at build time (the
88 /// baked blob record carries the result).
89 pub fn bake(args: SpawnerArgs) -> Self {
90 Self {
91 template: args.template,
92 interval: args.interval.max(0.0),
93 lifetime: args.lifetime.max(0.0),
94 elapsed: 0.0,
95 count: 0,
96 }
97 }
98}
99
100impl Component for Spawner {
101 const NAME: &'static str = "Spawner";
102
103 fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
104 Ok(crate::blob::decode_exact(bytes)?)
105 }
106}