Skip to main content

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