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    /// Whether the behavior runs against individual entities rather than the
387    /// world as a whole.
388    pub fn is_scoped(&self) -> bool {
389        !self.scope.is_empty()
390    }
391
392    // True when any node in the body, at any nesting depth, satisfies `pred`.
393    fn visit(&self, pred: &mut impl FnMut(&BehaviorNode) -> bool) -> bool {
394        fn walk(nodes: &[BehaviorNode], pred: &mut impl FnMut(&BehaviorNode) -> bool) -> bool {
395            nodes.iter().any(|n| {
396                pred(n)
397                    || match n {
398                        BehaviorNode::If {
399                            then, otherwise, ..
400                        } => walk(then, pred) || walk(otherwise, pred),
401                        BehaviorNode::ForEach { body, .. } => walk(body, pred),
402                        _ => false,
403                    }
404            })
405        }
406        walk(&self.body, pred)
407    }
408}
409
410fn unit_scale() -> [f32; 3] {
411    [1.0, 1.0, 1.0]
412}
413
414fn unit_volume() -> f32 {
415    1.0
416}
417
418fn default_transition() -> String {
419    String::from("FadeBlack")
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    fn parse(json: &str) -> Behavior {
427        serde_json::from_str(json).expect("behavior parses")
428    }
429
430    // Destructuring helpers, each `None` for any other kind. The parse tests
431    // read as assertions on the parts rather than nested pattern matches.
432    fn as_if(node: &BehaviorNode) -> Option<(&BehaviorExpr, &[BehaviorNode], &[BehaviorNode])> {
433        match node {
434            BehaviorNode::If {
435                cond,
436                then,
437                otherwise,
438            } => Some((cond, then, otherwise)),
439            _ => None,
440        }
441    }
442
443    fn as_spawn(node: &BehaviorNode) -> Option<(Option<&str>, [f32; 3])> {
444        match node {
445            BehaviorNode::Spawn { bind, scale, .. } => Some((bind.as_deref(), *scale)),
446            _ => None,
447        }
448    }
449
450    fn as_set(node: &BehaviorNode) -> Option<(&str, &BehaviorExpr, bool)> {
451        match node {
452            BehaviorNode::Set { var, value, add } => Some((var, value, *add)),
453            _ => None,
454        }
455    }
456
457    fn as_despawn(node: &BehaviorNode) -> Option<&BehaviorExpr> {
458        match node {
459            BehaviorNode::Despawn { target } => Some(target),
460            _ => None,
461        }
462    }
463
464    fn as_lt(expr: &BehaviorExpr) -> Option<(&BehaviorExpr, &BehaviorExpr)> {
465        match expr {
466            BehaviorExpr::Lt(lhs, rhs) => Some((lhs, rhs)),
467            _ => None,
468        }
469    }
470
471    fn as_distance(expr: &BehaviorExpr) -> Option<(&BehaviorExpr, &BehaviorExpr)> {
472        match expr {
473            BehaviorExpr::Distance(a, b) => Some((a, b)),
474            _ => None,
475        }
476    }
477
478    #[test]
479    fn the_destructuring_helpers_only_match_their_own_kind() {
480        let b = parse(r#"{"do":[{"save":null}]}"#);
481        let save = &b.body[0];
482        assert!(as_if(save).is_none());
483        assert!(as_spawn(save).is_none());
484        assert!(as_set(save).is_none());
485        assert!(as_despawn(save).is_none());
486        assert!(as_lt(&BehaviorExpr::Bool(true)).is_none());
487        assert!(as_distance(&BehaviorExpr::Bool(true)).is_none());
488    }
489
490    #[test]
491    fn defaults_are_world_scoped_and_empty() {
492        let b = Behavior::default();
493        assert!(!b.is_scoped());
494        assert!(b.body.is_empty());
495        assert_eq!(b.on, BehaviorSource::Start);
496        assert!(!b.plays_sound());
497        assert!(!b.saves_state());
498    }
499
500    #[test]
501    fn tick_source_parses() {
502        let b = parse(r#"{"on":"tick"}"#);
503        assert_eq!(b.on, BehaviorSource::Tick);
504    }
505
506    #[test]
507    fn spawned_source_parses() {
508        let b = parse(r#"{"on":"spawned"}"#);
509        assert_eq!(b.on, BehaviorSource::Spawned);
510    }
511
512    #[test]
513    fn scope_and_locals_parse() {
514        let b = parse(
515            r#"{"on":"tick","scope":["Prop"],"locals":[{"name":"speed","value":{"float":3.0}}]}"#,
516        );
517        assert!(b.is_scoped());
518        assert_eq!(b.scope, ["Prop"]);
519        assert_eq!(b.locals.len(), 1);
520        assert_eq!(b.locals[0].name, "speed");
521        assert_eq!(b.locals[0].value, BehaviorLiteral::Float(3.0));
522    }
523
524    #[test]
525    fn query_parses() {
526        let b = parse(r#"{"queries":[{"name":"player","has":["Camera3D","Prop"]}]}"#);
527        assert_eq!(b.queries.len(), 1);
528        assert_eq!(b.queries[0].name, "player");
529        assert_eq!(b.queries[0].has, ["Camera3D", "Prop"]);
530    }
531
532    #[test]
533    fn comparison_expression_parses_as_pair() {
534        let b = parse(
535            r#"{"do":[{"if":{"cond":{"lt":[{"distance":["self",{"bind":"t"}]},{"float":20.0}]}}}]}"#,
536        );
537        let (cond, then, otherwise) = as_if(&b.body[0]).expect("an if node");
538        assert!(then.is_empty());
539        assert!(otherwise.is_empty());
540        let (lhs, rhs) = as_lt(cond).expect("a less-than comparison");
541        assert_eq!(*rhs, BehaviorExpr::Float(20.0));
542        let (a, b) = as_distance(lhs).expect("a distance expression");
543        assert_eq!(*a, BehaviorExpr::SelfEntity);
544        assert_eq!(*b, BehaviorExpr::Bind(String::from("t")));
545    }
546
547    #[test]
548    fn spawn_binds_the_new_entity() {
549        let b = parse(r#"{"do":[{"spawn":{"bind":"made"}}]}"#);
550        let (bind, scale) = as_spawn(&b.body[0]).expect("a spawn node");
551        assert_eq!(bind, Some("made"));
552        assert_eq!(scale, [1.0, 1.0, 1.0]);
553    }
554
555    #[test]
556    fn nested_nodes_are_visited_for_sound_and_save() {
557        let b = parse(
558            r#"{"do":[{"for_each":{"query":"q","bind":"e","do":[{"if":{"cond":{"bool":true},"then":[{"sound":{}}],"else":[{"save":null}]}}]}}]}"#,
559        );
560        assert!(b.plays_sound());
561        assert!(b.saves_state());
562    }
563
564    #[test]
565    fn round_trips_through_json() {
566        let b = parse(
567            r#"{"on":{"timer":{"interval":5.0,"repeat":true}},"do":[{"set":{"var":"visits","value":{"int":1},"add":true}}]}"#,
568        );
569        let encoded = serde_json::to_string(&b).expect("behavior encodes");
570        let again: Behavior = serde_json::from_str(&encoded).expect("behavior re-parses");
571        assert_eq!(again.on, b.on);
572        let (var, value, add) = as_set(&again.body[0]).expect("a set node");
573        assert_eq!(var, "visits");
574        assert_eq!(*value, BehaviorExpr::Int(1));
575        assert!(add);
576    }
577
578    #[test]
579    fn a_blank_expression_is_false() {
580        // Lets a node's expression field carry `#[serde(default)]` without the
581        // omission reading as "fires".
582        assert_eq!(BehaviorExpr::default(), BehaviorExpr::Bool(false));
583    }
584
585    #[test]
586    fn a_scene_node_fades_unless_told_to_cut() {
587        crate::test_support::install_resolvers();
588        let b = parse(r#"{"do":[{"scene":{"scene":"hub"}},{"scene":{"transition":"Cut"}}]}"#);
589        assert!(matches!(
590            (&b.body[0], &b.body[1]),
591            (
592                BehaviorNode::Scene { transition: a, .. },
593                BehaviorNode::Scene { transition: c, .. },
594            ) if a == "FadeBlack" && c == "Cut"
595        ));
596    }
597
598    #[test]
599    fn a_sound_node_plays_at_unit_gain_unless_told_otherwise() {
600        let b = parse(r#"{"do":[{"sound":{}}]}"#);
601        assert!(matches!(b.body[0], BehaviorNode::Sound { volume, .. } if volume == 1.0));
602        assert!(b.plays_sound());
603    }
604
605    #[test]
606    fn round_trips_through_postcard() {
607        let b = parse(r#"{"on":"tick","scope":["Prop"],"do":[{"despawn":{"target":"self"}}]}"#);
608        let bytes = postcard::to_allocvec(&b).expect("behavior encodes");
609        let again: Behavior = postcard::from_bytes(&bytes).expect("behavior decodes");
610        assert_eq!(again.on, BehaviorSource::Tick);
611        let target = as_despawn(&again.body[0]).expect("a despawn node");
612        assert_eq!(*target, BehaviorExpr::SelfEntity);
613    }
614}