Skip to main content

concinnity_core/components/
behavior.rs

1// Behavior schema: declarative logic over typed state, world reads, and a
2// per-frame tick.
3
4use alloc::boxed::Box;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8use crate::components::StoryPlayback;
9use crate::ecs::AudioClipHandle;
10use crate::ecs::asset_id::AssetId;
11use crate::ecs::asset_id::de_opt_asset_ref;
12use crate::ecs::de_opt_audio_clip_handle;
13
14/// A unit of world logic: an event source, the entities it runs against, its
15/// state, the world it reads, and the nodes it runs.
16///
17/// A behavior with an empty `scope` runs once per firing, world-scoped. A
18/// behavior with a `scope` runs once per matching entity, each with its own
19/// copy of `locals`, and `"self"` resolves to that entity.
20///
21/// `locals` and `queries` are declared here and resolved to dense slots once,
22/// when the world starts, so nothing is looked up by name while it runs. A
23/// query naming an unknown component is a build error.
24///
25/// `once` limits a behavior to a single firing; `cooldown` enforces a minimum
26/// number of seconds between firings; `delay` postpones the nodes after the
27/// firing decision, which is made at fire time rather than after the delay.
28/// Timers, delays, and cooldowns freeze while a menu is open, like the rest of
29/// the world clock.
30#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
31#[serde(default)]
32pub struct Behavior {
33    /// Asset identity; injected via `inject_name`. Not part of `args`.
34    #[serde(skip)]
35    pub asset_id: AssetId,
36    /// The event that fires this behavior.
37    pub on: BehaviorSource,
38    /// Component names selecting the entities this behavior runs against. An
39    /// empty list runs it once, world-scoped, with no `"self"`.
40    ///
41    /// The names are matched against the components entities carry while the
42    /// world runs, which are not always the ones a world declares: the build
43    /// expands some types away, compiles others into the resource stream, and a
44    /// load-time pass decomposes the rest. `"Prop"` is the common case and
45    /// works, resolving to the marker decomposition leaves on every prop's
46    /// entity, model- and mesh-backed alike. A name with no runtime counterpart
47    /// is a build error rather than a scope that silently matches nothing.
48    pub scope: Vec<String>,
49    /// Per-entity state. Each matching entity gets its own copy, reset to the
50    /// declared value when the world starts. Locals are never persisted.
51    pub locals: Vec<BehaviorLocal>,
52    /// World reads, resolved once per tick into a stable-ordered entity list.
53    pub queries: Vec<BehaviorQuery>,
54    /// The nodes run, in order, each time the behavior fires.
55    #[serde(rename = "do")]
56    pub body: Vec<BehaviorNode>,
57    /// Fire at most once per run.
58    pub once: bool,
59    /// Seconds between the firing decision and the nodes running (`0` runs
60    /// them immediately).
61    pub delay: f32,
62    /// Minimum seconds between firings (`0` allows every firing).
63    pub cooldown: f32,
64}
65
66/// The event that fires a [Behavior](#behavior).
67#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum BehaviorSource {
70    /// Fires once when the world starts.
71    #[default]
72    Start,
73    /// Fires every tick.
74    Tick,
75    /// Fires when `interval` seconds have elapsed; with `repeat`, every
76    /// `interval` seconds.
77    Timer {
78        /// Seconds before the behavior fires.
79        #[serde(default)]
80        interval: f32,
81        /// `true` fires every `interval` seconds; `false` fires once.
82        #[serde(default)]
83        repeat: bool,
84    },
85    /// Fires whenever the named world variable changes value.
86    Variable(String),
87    /// Fires when something enters the named [TriggerVolume](#triggervolume).
88    Enter(#[serde(deserialize_with = "de_opt_asset_ref")] Option<AssetId>),
89    /// Fires when something leaves the named [TriggerVolume](#triggervolume).
90    Exit(#[serde(deserialize_with = "de_opt_asset_ref")] Option<AssetId>),
91    /// Fires when the interact key is pressed on the named entity (a
92    /// [Prop](#prop) declared `interactable`).
93    Interact(#[serde(deserialize_with = "de_opt_asset_ref")] Option<AssetId>),
94    /// Fires on an entity the tick after it spawns.
95    Spawned,
96}
97
98/// A per-entity state slot declared by a [Behavior](#behavior). The declared
99/// value fixes both the slot's type and its starting value.
100#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
101#[serde(default)]
102pub struct BehaviorLocal {
103    /// The name nodes read the slot by.
104    pub name: String,
105    /// The slot's type and starting value.
106    pub value: BehaviorLiteral,
107}
108
109/// A world read declared by a [Behavior](#behavior), resolved once per tick
110/// into the entities carrying every named component.
111#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
112#[serde(default)]
113pub struct BehaviorQuery {
114    /// The name expressions read the result by.
115    pub name: String,
116    /// Component names an entity must all carry to match. Resolved the same
117    /// way as a behavior's [`scope`](Behavior::scope).
118    pub has: Vec<String>,
119}
120
121/// A typed literal in a [Behavior](#behavior).
122#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum BehaviorLiteral {
125    /// A boolean.
126    Bool(bool),
127    /// A signed integer.
128    Int(i32),
129    /// A 32-bit float.
130    Float(f32),
131    /// A 3-component vector.
132    Vec3([f32; 3]),
133}
134
135impl Default for BehaviorLiteral {
136    fn default() -> Self {
137        BehaviorLiteral::Int(0)
138    }
139}
140
141/// A value expression in a [Behavior](#behavior).
142///
143/// Expressions are pure: they read state, entities, and the clock, and never
144/// change the world. Arithmetic works on both scalars and vectors, and mixing
145/// the two is a build error.
146#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum BehaviorExpr {
149    /// A boolean literal.
150    Bool(bool),
151    /// An integer literal.
152    Int(i32),
153    /// A float literal.
154    Float(f32),
155    /// A vector literal.
156    Vec3([f32; 3]),
157    /// Reads a world variable.
158    Var(String),
159    /// Reads one of this behavior's per-entity locals.
160    Local(String),
161    /// Reads a name bound earlier by a `let` or `for_each` node.
162    Bind(String),
163    /// An entity declared in the world, addressed by asset name.
164    Named(#[serde(deserialize_with = "de_opt_asset_ref")] Option<AssetId>),
165    /// The entity this behavior instance runs for. Only valid with a `scope`.
166    #[serde(rename = "self")]
167    SelfEntity,
168    /// Seconds elapsed since the previous tick.
169    Dt,
170    /// Seconds elapsed since the world started.
171    Elapsed,
172    /// World-space position of an entity.
173    Position(Box<BehaviorExpr>),
174    /// Distance between two entities.
175    Distance(Box<BehaviorExpr>, Box<BehaviorExpr>),
176    /// The first entity of a declared query, or none when it is empty.
177    First(String),
178    /// How many entities a declared query matched.
179    Count(String),
180    /// Whether an entity is still alive.
181    Alive(Box<BehaviorExpr>),
182    /// Sum of two values.
183    Add(Box<BehaviorExpr>, Box<BehaviorExpr>),
184    /// Difference of two values.
185    Sub(Box<BehaviorExpr>, Box<BehaviorExpr>),
186    /// Product of two values; a vector may be scaled by a scalar.
187    Mul(Box<BehaviorExpr>, Box<BehaviorExpr>),
188    /// Quotient of two values; dividing by zero yields zero.
189    Div(Box<BehaviorExpr>, Box<BehaviorExpr>),
190    /// A vector rescaled to unit length; a zero vector stays zero.
191    Normalize(Box<BehaviorExpr>),
192    /// Equality test.
193    Eq(Box<BehaviorExpr>, Box<BehaviorExpr>),
194    /// Inequality test.
195    Ne(Box<BehaviorExpr>, Box<BehaviorExpr>),
196    /// Less-than test.
197    Lt(Box<BehaviorExpr>, Box<BehaviorExpr>),
198    /// Less-than-or-equal test.
199    Le(Box<BehaviorExpr>, Box<BehaviorExpr>),
200    /// Greater-than test.
201    Gt(Box<BehaviorExpr>, Box<BehaviorExpr>),
202    /// Greater-than-or-equal test.
203    Ge(Box<BehaviorExpr>, Box<BehaviorExpr>),
204    /// True when every operand is true.
205    All(Vec<BehaviorExpr>),
206    /// True when any operand is true.
207    Any(Vec<BehaviorExpr>),
208    /// Logical negation.
209    Not(Box<BehaviorExpr>),
210}
211
212impl Default for BehaviorExpr {
213    fn default() -> Self {
214        BehaviorExpr::Bool(false)
215    }
216}
217
218/// One node run by a firing [Behavior](#behavior).
219///
220/// Nodes are the only way a behavior changes the world. There is no unbounded
221/// loop and no recursion, so a behavior body always terminates.
222#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum BehaviorNode {
225    /// Runs `then` when `cond` holds, `else` otherwise.
226    If {
227        /// The tested expression.
228        cond: BehaviorExpr,
229        /// Nodes run when `cond` holds.
230        #[serde(default)]
231        then: Vec<BehaviorNode>,
232        /// Nodes run when `cond` does not hold.
233        #[serde(default, rename = "else")]
234        otherwise: Vec<BehaviorNode>,
235    },
236    /// Runs `do` once per entity a declared query matched, binding each to
237    /// `bind`.
238    ForEach {
239        /// The declared query iterated.
240        query: String,
241        /// The name each entity is bound to.
242        bind: String,
243        /// Nodes run per entity.
244        #[serde(default, rename = "do")]
245        body: Vec<BehaviorNode>,
246    },
247    /// Binds an expression to a name for the rest of the body.
248    Let {
249        /// The bound name.
250        name: String,
251        /// The bound expression.
252        value: BehaviorExpr,
253    },
254    /// Writes a world variable: assign `value`, or add it to the current value
255    /// when `add` is `true`.
256    Set {
257        /// The variable written.
258        var: String,
259        /// The value assigned (or added).
260        value: BehaviorExpr,
261        /// `false` assigns `value`; `true` adds it to the current value.
262        #[serde(default)]
263        add: bool,
264    },
265    /// Writes one of this behavior's per-entity locals.
266    SetLocal {
267        /// The local written.
268        local: String,
269        /// The value assigned (or added).
270        value: BehaviorExpr,
271        /// `false` assigns `value`; `true` adds it to the current value.
272        #[serde(default)]
273        add: bool,
274    },
275    /// Writes an entity's transform. An omitted field is left unchanged.
276    SetTransform {
277        /// The entity moved.
278        entity: BehaviorExpr,
279        /// New world-space position.
280        #[serde(default)]
281        position: Option<BehaviorExpr>,
282        /// New Euler rotation in degrees.
283        #[serde(default)]
284        rotation_deg: Option<BehaviorExpr>,
285        /// New scale.
286        #[serde(default)]
287        scale: Option<BehaviorExpr>,
288    },
289    /// Creates a copy of an existing placement at a world position. Binding
290    /// `bind` makes the copy addressable for the rest of the body.
291    Spawn {
292        /// The placement copied (e.g. a [Prop](#prop)).
293        #[serde(default, deserialize_with = "de_opt_asset_ref")]
294        template: Option<AssetId>,
295        /// World-space position of the copy.
296        #[serde(default)]
297        position: [f32; 3],
298        /// Euler rotation of the copy in degrees.
299        #[serde(default)]
300        rotation_deg: [f32; 3],
301        /// Scale of the copy (`[1, 1, 1]` keeps the template's size).
302        #[serde(default = "unit_scale")]
303        scale: [f32; 3],
304        /// Seconds the copy lives before auto-despawning (`0` lives forever).
305        #[serde(default)]
306        lifetime: f32,
307        /// Name the new entity is bound to for the rest of the body.
308        #[serde(default)]
309        bind: Option<String>,
310    },
311    /// Removes an entity and its children from the world.
312    Despawn {
313        /// The entity removed.
314        target: BehaviorExpr,
315    },
316    /// Re-points an entity's parent edge.
317    Reparent {
318        /// The entity moved.
319        child: BehaviorExpr,
320        /// The new parent, or unset to detach to a root.
321        #[serde(default)]
322        parent: Option<BehaviorExpr>,
323    },
324    /// Makes a hidden entity (and its children) visible again.
325    Show {
326        /// The entity revealed.
327        target: BehaviorExpr,
328    },
329    /// Makes an entity (and its children) invisible without removing it. A
330    /// hidden entity keeps simulating; `show` reverses this.
331    Hide {
332        /// The entity hidden.
333        target: BehaviorExpr,
334    },
335    /// Plays an [AudioClip](#audioclip) flat on the main mix (no 3D position).
336    Sound {
337        /// The clip played.
338        #[serde(default, deserialize_with = "de_opt_audio_clip_handle")]
339        clip: Option<AudioClipHandle>,
340        /// Playback behavior: a looping `music` track or a one-shot `sound`.
341        #[serde(default)]
342        kind: crate::components::CueKind,
343        /// Linear gain applied to the clip (`1.0` leaves it unchanged).
344        #[serde(default = "unit_volume")]
345        volume: f32,
346    },
347    /// Jumps the world to a named [Scene](#scene).
348    Scene {
349        /// The scene jumped to.
350        #[serde(default, deserialize_with = "de_opt_asset_ref")]
351        scene: Option<AssetId>,
352        /// The transition: `"Cut"` or `"FadeBlack"`.
353        #[serde(default = "default_transition")]
354        transition: String,
355    },
356    /// Shows a [Screen](#screen), replacing the top of the screen stack.
357    Screen {
358        /// The screen shown.
359        #[serde(default, deserialize_with = "de_opt_asset_ref")]
360        screen: Option<AssetId>,
361    },
362    /// Controls the world's [Story](#story) playback.
363    Story(StoryPlayback),
364    /// Writes the world's logic state to disk: every world variable, plus
365    /// which `once` behaviors have fired. Per-entity locals are not saved. The
366    /// state is restored the next time the world starts, so a behavior gated
367    /// on a saved variable carries across runs. Timers, delays, and cooldowns
368    /// restart fresh; entities are not saved. A world with no `save` node
369    /// never reads or writes state.
370    Save,
371}
372
373impl Behavior {
374    /// Whether any node plays an audio clip, so the runtime knows this
375    /// behavior needs the audio system and its clip payloads cached.
376    pub fn plays_sound(&self) -> bool {
377        self.visit(&mut |n| matches!(n, BehaviorNode::Sound { .. }))
378    }
379
380    /// Whether any node saves the world's logic state, so the runtime knows to
381    /// restore persisted state when the world starts.
382    pub fn saves_state(&self) -> bool {
383        self.visit(&mut |n| matches!(n, BehaviorNode::Save))
384    }
385
386    // True when any node in the body, at any nesting depth, satisfies `pred`.
387    fn visit(&self, pred: &mut impl FnMut(&BehaviorNode) -> bool) -> bool {
388        fn walk(nodes: &[BehaviorNode], pred: &mut impl FnMut(&BehaviorNode) -> bool) -> bool {
389            nodes.iter().any(|n| {
390                pred(n)
391                    || match n {
392                        BehaviorNode::If {
393                            then, otherwise, ..
394                        } => walk(then, pred) || walk(otherwise, pred),
395                        BehaviorNode::ForEach { body, .. } => walk(body, pred),
396                        _ => false,
397                    }
398            })
399        }
400        walk(&self.body, pred)
401    }
402}
403
404fn unit_scale() -> [f32; 3] {
405    [1.0, 1.0, 1.0]
406}
407
408fn unit_volume() -> f32 {
409    1.0
410}
411
412fn default_transition() -> String {
413    String::from("FadeBlack")
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn parse(json: &str) -> Behavior {
421        serde_json::from_str(json).expect("behavior parses")
422    }
423
424    // Destructuring helpers, each `None` for any other kind. The parse tests
425    // read as assertions on the parts rather than nested pattern matches.
426    fn as_if(node: &BehaviorNode) -> Option<(&BehaviorExpr, &[BehaviorNode], &[BehaviorNode])> {
427        match node {
428            BehaviorNode::If {
429                cond,
430                then,
431                otherwise,
432            } => Some((cond, then, otherwise)),
433            _ => None,
434        }
435    }
436
437    fn as_spawn(node: &BehaviorNode) -> Option<(Option<&str>, [f32; 3])> {
438        match node {
439            BehaviorNode::Spawn { bind, scale, .. } => Some((bind.as_deref(), *scale)),
440            _ => None,
441        }
442    }
443
444    fn as_set(node: &BehaviorNode) -> Option<(&str, &BehaviorExpr, bool)> {
445        match node {
446            BehaviorNode::Set { var, value, add } => Some((var, value, *add)),
447            _ => None,
448        }
449    }
450
451    fn as_despawn(node: &BehaviorNode) -> Option<&BehaviorExpr> {
452        match node {
453            BehaviorNode::Despawn { target } => Some(target),
454            _ => None,
455        }
456    }
457
458    fn as_lt(expr: &BehaviorExpr) -> Option<(&BehaviorExpr, &BehaviorExpr)> {
459        match expr {
460            BehaviorExpr::Lt(lhs, rhs) => Some((lhs, rhs)),
461            _ => None,
462        }
463    }
464
465    fn as_distance(expr: &BehaviorExpr) -> Option<(&BehaviorExpr, &BehaviorExpr)> {
466        match expr {
467            BehaviorExpr::Distance(a, b) => Some((a, b)),
468            _ => None,
469        }
470    }
471
472    #[test]
473    fn the_destructuring_helpers_only_match_their_own_kind() {
474        let b = parse(r#"{"do":[{"save":null}]}"#);
475        let save = &b.body[0];
476        assert!(as_if(save).is_none());
477        assert!(as_spawn(save).is_none());
478        assert!(as_set(save).is_none());
479        assert!(as_despawn(save).is_none());
480        assert!(as_lt(&BehaviorExpr::Bool(true)).is_none());
481        assert!(as_distance(&BehaviorExpr::Bool(true)).is_none());
482    }
483
484    #[test]
485    fn defaults_are_world_scoped_and_empty() {
486        let b = Behavior::default();
487        assert!(b.scope.is_empty());
488        assert!(b.body.is_empty());
489        assert_eq!(b.on, BehaviorSource::Start);
490        assert!(!b.plays_sound());
491        assert!(!b.saves_state());
492    }
493
494    #[test]
495    fn tick_source_parses() {
496        let b = parse(r#"{"on":"tick"}"#);
497        assert_eq!(b.on, BehaviorSource::Tick);
498    }
499
500    #[test]
501    fn spawned_source_parses() {
502        let b = parse(r#"{"on":"spawned"}"#);
503        assert_eq!(b.on, BehaviorSource::Spawned);
504    }
505
506    #[test]
507    fn scope_and_locals_parse() {
508        let b = parse(
509            r#"{"on":"tick","scope":["Prop"],"locals":[{"name":"speed","value":{"float":3.0}}]}"#,
510        );
511        assert_eq!(b.scope, ["Prop"]);
512        assert_eq!(b.locals.len(), 1);
513        assert_eq!(b.locals[0].name, "speed");
514        assert_eq!(b.locals[0].value, BehaviorLiteral::Float(3.0));
515    }
516
517    #[test]
518    fn query_parses() {
519        let b = parse(r#"{"queries":[{"name":"player","has":["Camera3D","Prop"]}]}"#);
520        assert_eq!(b.queries.len(), 1);
521        assert_eq!(b.queries[0].name, "player");
522        assert_eq!(b.queries[0].has, ["Camera3D", "Prop"]);
523    }
524
525    #[test]
526    fn comparison_expression_parses_as_pair() {
527        let b = parse(
528            r#"{"do":[{"if":{"cond":{"lt":[{"distance":["self",{"bind":"t"}]},{"float":20.0}]}}}]}"#,
529        );
530        let (cond, then, otherwise) = as_if(&b.body[0]).expect("an if node");
531        assert!(then.is_empty());
532        assert!(otherwise.is_empty());
533        let (lhs, rhs) = as_lt(cond).expect("a less-than comparison");
534        assert_eq!(*rhs, BehaviorExpr::Float(20.0));
535        let (a, b) = as_distance(lhs).expect("a distance expression");
536        assert_eq!(*a, BehaviorExpr::SelfEntity);
537        assert_eq!(*b, BehaviorExpr::Bind(String::from("t")));
538    }
539
540    #[test]
541    fn spawn_binds_the_new_entity() {
542        let b = parse(r#"{"do":[{"spawn":{"bind":"made"}}]}"#);
543        let (bind, scale) = as_spawn(&b.body[0]).expect("a spawn node");
544        assert_eq!(bind, Some("made"));
545        assert_eq!(scale, [1.0, 1.0, 1.0]);
546    }
547
548    #[test]
549    fn nested_nodes_are_visited_for_sound_and_save() {
550        let b = parse(
551            r#"{"do":[{"for_each":{"query":"q","bind":"e","do":[{"if":{"cond":{"bool":true},"then":[{"sound":{}}],"else":[{"save":null}]}}]}}]}"#,
552        );
553        assert!(b.plays_sound());
554        assert!(b.saves_state());
555    }
556
557    #[test]
558    fn round_trips_through_json() {
559        let b = parse(
560            r#"{"on":{"timer":{"interval":5.0,"repeat":true}},"do":[{"set":{"var":"visits","value":{"int":1},"add":true}}]}"#,
561        );
562        let encoded = serde_json::to_string(&b).expect("behavior encodes");
563        let again: Behavior = serde_json::from_str(&encoded).expect("behavior re-parses");
564        assert_eq!(again.on, b.on);
565        let (var, value, add) = as_set(&again.body[0]).expect("a set node");
566        assert_eq!(var, "visits");
567        assert_eq!(*value, BehaviorExpr::Int(1));
568        assert!(add);
569    }
570
571    #[test]
572    fn a_blank_expression_is_false() {
573        // Lets a node's expression field carry `#[serde(default)]` without the
574        // omission reading as "fires".
575        assert_eq!(BehaviorExpr::default(), BehaviorExpr::Bool(false));
576    }
577
578    #[test]
579    fn a_scene_node_fades_unless_told_to_cut() {
580        crate::test_support::install_resolvers();
581        let b = parse(r#"{"do":[{"scene":{"scene":"hub"}},{"scene":{"transition":"Cut"}}]}"#);
582        assert!(matches!(
583            (&b.body[0], &b.body[1]),
584            (
585                BehaviorNode::Scene { transition: a, .. },
586                BehaviorNode::Scene { transition: c, .. },
587            ) if a == "FadeBlack" && c == "Cut"
588        ));
589    }
590
591    #[test]
592    fn a_sound_node_plays_at_unit_gain_unless_told_otherwise() {
593        let b = parse(r#"{"do":[{"sound":{}}]}"#);
594        assert!(matches!(b.body[0], BehaviorNode::Sound { volume, .. } if volume == 1.0));
595        assert!(b.plays_sound());
596    }
597
598    #[test]
599    fn round_trips_through_postcard() {
600        let b = parse(r#"{"on":"tick","scope":["Prop"],"do":[{"despawn":{"target":"self"}}]}"#);
601        let bytes = postcard::to_allocvec(&b).expect("behavior encodes");
602        let again: Behavior = postcard::from_bytes(&bytes).expect("behavior decodes");
603        assert_eq!(again.on, BehaviorSource::Tick);
604        let target = as_despawn(&again.body[0]).expect("a despawn node");
605        assert_eq!(*target, BehaviorExpr::SelfEntity);
606    }
607}