Skip to main content

mittens_engine/engine/ecs/system/
animation_system.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::engine::ecs::component::{
4    ActionComponent, AnimationComponent, AnimationState, KeyframeComponent, ResolveTargetsMode,
5};
6use crate::engine::ecs::system::System;
7use crate::engine::ecs::system::animation_keyframe_evaluator::AnimationKeyframeEvaluator;
8use crate::engine::ecs::system::animation_scheduler::AnimationScheduler;
9use crate::engine::ecs::{ComponentId, RxWorld, World};
10use crate::engine::graphics::VisualWorld;
11use crate::engine::user_input::InputState;
12
13#[derive(Debug, Default)]
14struct AnimationRuntime {
15    keyframes: Vec<ComponentId>,
16    fired_keyframes: BTreeSet<ComponentId>,
17    /// For audio lookahead scheduling, track the last loop-cycle index each keyframe was
18    /// scheduled for.
19    audio_scheduled_cycle_by_keyframe: BTreeMap<ComponentId, u64>,
20    /// Loop cycle index for audio scheduling. Increments whenever a looping animation wraps.
21    audio_cycle: u64,
22    start_beat: f64,
23    pending_state: Option<AnimationState>,
24    /// For `ResolveTargetsMode::OnAttach`: set once the first tick has
25    /// bulk-resolved every ActionComponent target under this animation's
26    /// keyframes. `OnPlay` mode ignores this and resolves per-action lazily
27    /// just before each push.
28    attach_resolved: bool,
29}
30
31#[derive(Debug, Default)]
32pub struct AnimationSystem {
33    /// Runtime state keyed by `AnimationComponent` id.
34    ///
35    /// BTree* gives deterministic iteration order (nice for debugging/logs).
36    animations: BTreeMap<ComponentId, AnimationRuntime>,
37    last_beat: f64,
38
39    scheduler: AnimationScheduler,
40    keyframe_evaluator: AnimationKeyframeEvaluator,
41}
42
43impl AnimationSystem {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn register_animation(&mut self, world: &mut World, component: ComponentId) {
49        if world
50            .get_component_by_id_as::<AnimationComponent>(component)
51            .is_none()
52        {
53            return;
54        }
55
56        self.animations
57            .entry(component)
58            .or_insert_with(AnimationRuntime::default);
59    }
60
61    pub fn set_animation_state(&mut self, animation: ComponentId, state: AnimationState) {
62        self.animations
63            .entry(animation)
64            .or_insert_with(AnimationRuntime::default)
65            .pending_state = Some(state);
66    }
67
68    pub fn register_keyframe(&mut self, world: &mut World, component: ComponentId) {
69        if world
70            .get_component_by_id_as::<KeyframeComponent>(component)
71            .is_none()
72        {
73            return;
74        }
75
76        // Find ancestor AnimationComponent.
77        let mut cursor = world.parent_of(component);
78        while let Some(node) = cursor {
79            if world
80                .get_component_by_id_as::<AnimationComponent>(node)
81                .is_some()
82            {
83                let runtime = self
84                    .animations
85                    .entry(node)
86                    .or_insert_with(AnimationRuntime::default);
87                let list = &mut runtime.keyframes;
88
89                if !list.contains(&component) {
90                    list.push(component);
91                }
92
93                // Keep deterministic order by beat.
94                list.sort_by(|a, b| {
95                    let ba = world
96                        .get_component_by_id_as::<KeyframeComponent>(*a)
97                        .map(|k| k.beat)
98                        .unwrap_or(0.0);
99                    let bb = world
100                        .get_component_by_id_as::<KeyframeComponent>(*b)
101                        .map(|k| k.beat)
102                        .unwrap_or(0.0);
103                    ba.partial_cmp(&bb).unwrap_or(std::cmp::Ordering::Equal)
104                });
105                return;
106            }
107            cursor = world.parent_of(node);
108        }
109    }
110
111    pub fn tick_with_beat(&mut self, world: &mut World, beat_now: f64, bpm: f64, rx: &mut RxWorld) {
112        // If time jumps backwards, reset fired state.
113        if beat_now + 1e-9 < self.last_beat {
114            for runtime in self.animations.values_mut() {
115                runtime.fired_keyframes.clear();
116                runtime.audio_scheduled_cycle_by_keyframe.clear();
117                runtime.audio_cycle = 0;
118            }
119        }
120
121        // Apply any requested state changes.
122        // Setting Playing/Looping is treated as a restart.
123        for (&anim, runtime) in self.animations.iter_mut() {
124            let Some(state) = runtime.pending_state.take() else {
125                continue;
126            };
127
128            let Some(anim_comp) = world.get_component_by_id_as_mut::<AnimationComponent>(anim)
129            else {
130                continue;
131            };
132
133            anim_comp.state = state;
134            runtime.start_beat = beat_now;
135            runtime.fired_keyframes.clear();
136            runtime.audio_scheduled_cycle_by_keyframe.clear();
137            runtime.audio_cycle = 0;
138        }
139
140        // Drive animations.
141        for (&anim, runtime) in self.animations.iter_mut() {
142            let (state, resolve_mode, length_override) =
143                match world.get_component_by_id_as::<AnimationComponent>(anim) {
144                    Some(c) => (c.state, c.resolve_targets, c.length_beats),
145                    None => continue,
146                };
147
148            if state == AnimationState::Paused {
149                continue;
150            }
151
152            if runtime.keyframes.is_empty() {
153                continue;
154            }
155
156            // OnAttach: on first tick, eagerly resolve every action under this
157            // animation's keyframes. Errors are logged but don't halt the
158            // animation — individual broken actions just skip in the push
159            // path below. OnPlay defers per-action to the push site.
160            if matches!(resolve_mode, ResolveTargetsMode::OnAttach) && !runtime.attach_resolved {
161                let action_ids: Vec<ComponentId> = runtime
162                    .keyframes
163                    .iter()
164                    .flat_map(|&kf| {
165                        world
166                            .children_of(kf)
167                            .iter()
168                            .copied()
169                            .filter(|&cid| {
170                                world
171                                    .get_component_by_id_as::<ActionComponent>(cid)
172                                    .is_some()
173                            })
174                            .collect::<Vec<_>>()
175                    })
176                    .collect();
177                for action_cid in action_ids {
178                    if let Err(e) = self
179                        .keyframe_evaluator
180                        .resolve_action_targets(world, action_cid)
181                    {
182                        eprintln!(
183                            "[AnimationSystem] OnAttach resolve failed for {action_cid:?}: {e}"
184                        );
185                    }
186                }
187                runtime.attach_resolved = true;
188            }
189
190            // Compute beat range for this animation.
191            let Some((min_beat, max_beat)) = runtime
192                .keyframes
193                .iter()
194                .filter_map(|&kf_id| {
195                    world
196                        .get_component_by_id_as::<KeyframeComponent>(kf_id)
197                        .map(|kf| kf.beat)
198                })
199                .fold(None, |acc: Option<(f64, f64)>, beat| match acc {
200                    None => Some((beat, beat)),
201                    Some((min_b, max_b)) => Some((min_b.min(beat), max_b.max(beat))),
202                })
203            else {
204                continue;
205            };
206
207            // Use per-animation local beat time so animations can restart/loop.
208            let mut local_beat = (beat_now - runtime.start_beat).max(0.0);
209            let span = (max_beat - min_beat).max(0.0);
210            // Explicit `Animation.length(n)` wins. Otherwise default:
211            // snap to the next whole beat after the last keyframe so
212            // common musical loops stay stable even with off-beat
213            // keyframes (e.g. max_beat=31.5 → 32.0, not 32.5).
214            let loop_len = match length_override {
215                Some(n) if n.is_finite() && n > 0.0 => n,
216                _ if span < 1e-6 => 1.0,
217                _ => span.floor() + 1.0,
218            };
219
220            if state == AnimationState::Looping {
221                // Wrap local beat into [0, loop_len).
222                // When we wrap, clear fired set so keyframes can fire again.
223                if local_beat + 1e-9 >= loop_len {
224                    let wraps = (local_beat / loop_len).floor();
225                    if wraps >= 1.0 {
226                        local_beat -= wraps * loop_len;
227                        runtime.start_beat = beat_now - local_beat;
228                        runtime.fired_keyframes.clear();
229
230                        // Audio scheduling de-dupe is tracked by loop cycle index, so we do
231                        // NOT clear it on wrap (lookahead may already have scheduled keyframes
232                        // for the next cycle). We just advance the cycle counter.
233                        runtime.audio_cycle = runtime.audio_cycle.saturating_add(wraps as u64);
234                    }
235                }
236            }
237
238            // Audio lookahead scheduling phase.
239            //
240            // Key detail: scheduled audio actions take a beat *offset* relative to the
241            // beat context passed into ActionSystem::execute. For lookahead, we want that
242            // context to be the keyframe's intended beat time (global), not "now".
243            let audio_due = self.scheduler.audio_due_keyframes(
244                world,
245                anim,
246                &runtime.keyframes,
247                &runtime.audio_scheduled_cycle_by_keyframe,
248                runtime.audio_cycle,
249                min_beat,
250                local_beat,
251                bpm,
252                loop_len,
253            );
254
255            if !audio_due.is_empty() {
256                for (kf_id, kf_local_beat, kf_cycle) in audio_due {
257                    let cycle_offset = kf_cycle.saturating_sub(runtime.audio_cycle) as f64;
258                    let kf_global_beat =
259                        runtime.start_beat + cycle_offset * loop_len + kf_local_beat;
260
261                    self.keyframe_evaluator.evaluate_audio_due_keyframe(
262                        world,
263                        rx,
264                        kf_id,
265                        kf_global_beat,
266                    );
267
268                    runtime
269                        .audio_scheduled_cycle_by_keyframe
270                        .insert(kf_id, kf_cycle);
271                }
272            }
273
274            let due_keyframes = self.scheduler.visual_due_keyframes(
275                world,
276                &runtime.keyframes,
277                &runtime.fired_keyframes,
278                min_beat,
279                local_beat,
280            );
281
282            for kf_id in due_keyframes {
283                let Some(kf) = world.get_component_by_id_as::<KeyframeComponent>(kf_id) else {
284                    continue;
285                };
286                let kf_local_beat = kf.beat - min_beat;
287
288                if kf_local_beat <= local_beat + 1e-9 {
289                    let already_scheduled = runtime
290                        .audio_scheduled_cycle_by_keyframe
291                        .get(&kf_id)
292                        .copied()
293                        == Some(runtime.audio_cycle);
294                    self.keyframe_evaluator.evaluate_visual_due_keyframe(
295                        world,
296                        rx,
297                        kf_id,
298                        beat_now,
299                        already_scheduled,
300                    );
301
302                    runtime.fired_keyframes.insert(kf_id);
303                }
304            }
305
306            // Completion: a one-shot animation becomes paused once it has passed its end.
307            if state == AnimationState::Playing {
308                let done = local_beat + 1e-9 >= loop_len;
309                if done {
310                    if let Some(anim_comp) =
311                        world.get_component_by_id_as_mut::<AnimationComponent>(anim)
312                    {
313                        anim_comp.state = AnimationState::Paused;
314                    }
315                }
316            }
317        }
318
319        self.last_beat = beat_now;
320    }
321}
322
323impl System for AnimationSystem {
324    fn tick(
325        &mut self,
326        _world: &mut World,
327        _visuals: &mut VisualWorld,
328        _input: &InputState,
329        _dt_sec: f32,
330    ) {
331        // Driven via `tick_with_beat` from SystemWorld.
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::engine::ecs::IntentValue;
339    use crate::engine::ecs::component::{
340        AudioOscillatorComponent, ComponentRef, TransformComponent,
341    };
342    use crate::engine::ecs::system::animation_keyframe_evaluator::AnimationKeyframeEvaluator;
343    use crate::scripting::ast::{
344        BinOpKind, BlockStatement, CallExpression, Expression, Ident, Statement,
345    };
346    use crate::scripting::object::{RuntimeClosure, Value};
347    use crate::scripting::world_evaluator::{RuntimeClosureExecMode, eval_runtime_closure};
348    use slotmap::Key;
349    use std::collections::HashMap;
350    use std::sync::Arc;
351
352    #[test]
353    fn resolve_action_targets_supports_relative_parent_prefixes() {
354        let mut world = World::default();
355        let root = world.add_component(TransformComponent::new());
356        let target = world.add_component_boxed_named("hero", Box::new(TransformComponent::new()));
357        world.add_child(root, target).unwrap();
358
359        let keyframe = world.add_component(KeyframeComponent::new(0.0));
360        world.add_child(root, keyframe).unwrap();
361
362        let action = world.add_component(ActionComponent::new_authored(
363            IntentValue::SetPosition {
364                component_ids: vec![ComponentId::null()],
365                position: [1.0, 2.0, 3.0],
366            },
367            vec![ComponentRef::Query("../../#hero".to_string())],
368        ));
369        world.add_child(keyframe, action).unwrap();
370
371        AnimationKeyframeEvaluator
372            .resolve_action_targets(&mut world, action)
373            .expect("action resolves");
374
375        let action = world
376            .get_component_by_id_as::<ActionComponent>(action)
377            .expect("action");
378        match &action.signal {
379            IntentValue::SetPosition { component_ids, .. } => {
380                assert_eq!(component_ids, &vec![target]);
381            }
382            other => panic!("unexpected signal: {other:?}"),
383        }
384        assert!(action.resolved);
385    }
386
387    #[test]
388    fn resolve_action_targets_uses_local_scope_for_bare_queries() {
389        let mut world = World::default();
390
391        let unrelated_root = world.add_component(TransformComponent::new());
392        let unrelated_target =
393            world.add_component_boxed_named("hero", Box::new(TransformComponent::new()));
394        world.add_child(unrelated_root, unrelated_target).unwrap();
395
396        let local_root = world.add_component(TransformComponent::new());
397        let keyframe = world.add_component(KeyframeComponent::new(0.0));
398        world.add_child(local_root, keyframe).unwrap();
399
400        let action = world.add_component(ActionComponent::new_authored(
401            IntentValue::SetPosition {
402                component_ids: vec![ComponentId::null()],
403                position: [1.0, 2.0, 3.0],
404            },
405            vec![ComponentRef::Query("#hero".to_string())],
406        ));
407        world.add_child(keyframe, action).unwrap();
408        let local_target =
409            world.add_component_boxed_named("hero", Box::new(TransformComponent::new()));
410        world.add_child(action, local_target).unwrap();
411
412        AnimationKeyframeEvaluator
413            .resolve_action_targets(&mut world, action)
414            .expect("action resolves");
415
416        let action = world
417            .get_component_by_id_as::<ActionComponent>(action)
418            .expect("action");
419        match &action.signal {
420            IntentValue::SetPosition { component_ids, .. } => {
421                assert_eq!(component_ids, &vec![local_target]);
422                assert_ne!(component_ids, &vec![unrelated_target]);
423            }
424            other => panic!("unexpected signal: {other:?}"),
425        }
426    }
427
428    #[test]
429    fn resolve_action_targets_use_animation_scope_for_bare_queries() {
430        let mut world = World::default();
431
432        let unrelated_root = world.add_component(TransformComponent::new());
433        let unrelated_target =
434            world.add_component_boxed_named("hero", Box::new(TransformComponent::new()));
435        world.add_child(unrelated_root, unrelated_target).unwrap();
436
437        let host = world.add_component(TransformComponent::new());
438        let scoped_root =
439            world.add_component_boxed_named("avatar_root", Box::new(TransformComponent::new()));
440        world.add_child(host, scoped_root).unwrap();
441        let scoped_target =
442            world.add_component_boxed_named("hero", Box::new(TransformComponent::new()));
443        world.add_child(scoped_root, scoped_target).unwrap();
444
445        let animation = world.add_component(
446            AnimationComponent::new()
447                .with_scope_source(ComponentRef::Query("../#avatar_root".to_string())),
448        );
449        world.add_child(host, animation).unwrap();
450
451        let keyframe = world.add_component(KeyframeComponent::new(0.0));
452        world.add_child(animation, keyframe).unwrap();
453
454        let action = world.add_component(ActionComponent::new_authored(
455            IntentValue::SetPosition {
456                component_ids: vec![ComponentId::null()],
457                position: [1.0, 2.0, 3.0],
458            },
459            vec![ComponentRef::Query("#hero".to_string())],
460        ));
461        world.add_child(keyframe, action).unwrap();
462
463        AnimationKeyframeEvaluator
464            .resolve_action_targets(&mut world, action)
465            .expect("action resolves");
466
467        let action = world
468            .get_component_by_id_as::<ActionComponent>(action)
469            .expect("action");
470        match &action.signal {
471            IntentValue::SetPosition { component_ids, .. } => {
472                assert_eq!(component_ids, &vec![scoped_target]);
473                assert_ne!(component_ids, &vec![unrelated_target]);
474            }
475            other => panic!("unexpected signal: {other:?}"),
476        }
477    }
478
479    #[test]
480    fn keyframe_callback_dispatches_live_component_intent_when_due() {
481        let mut world = World::default();
482        let animation =
483            world.add_component(AnimationComponent::new().with_state(AnimationState::Playing));
484        let target = world.add_component(TransformComponent::new());
485        let callback = RuntimeClosure {
486            body: BlockStatement {
487                statements: vec![Statement::Expression(Expression::Call(CallExpression {
488                    callee: Box::new(Expression::BinaryOp {
489                        op: BinOpKind::Dot,
490                        lhs: Box::new(Expression::Identifier(Ident("cube_t".to_string()))),
491                        rhs: Box::new(Expression::Identifier(Ident(
492                            "update_transform".to_string(),
493                        ))),
494                    }),
495                    args: vec![
496                        Expression::Array(vec![
497                            Expression::Number(1.0),
498                            Expression::Number(2.0),
499                            Expression::Number(3.0),
500                        ]),
501                        Expression::Array(vec![
502                            Expression::Number(0.0),
503                            Expression::Number(0.5),
504                            Expression::Number(0.0),
505                        ]),
506                        Expression::Array(vec![
507                            Expression::Number(2.0),
508                            Expression::Number(2.0),
509                            Expression::Number(2.0),
510                        ]),
511                    ],
512                }))],
513            },
514            captured_env: Arc::new(HashMap::from([(
515                "cube_t".to_string(),
516                Value::ComponentObject {
517                    id: target,
518                    component_type: "Transform".to_string(),
519                },
520            )])),
521            heap: crate::scripting::object::HeapHandle::new(),
522            analysis: None,
523        };
524        let keyframe = world.add_component(KeyframeComponent::new_with_callback(0.0, callback));
525        world.add_child(animation, keyframe).unwrap();
526
527        let mut system = AnimationSystem::new();
528        system.register_animation(&mut world, animation);
529        system.register_keyframe(&mut world, keyframe);
530
531        let mut rx = RxWorld::default();
532        system.tick_with_beat(&mut world, 0.0, 60.0, &mut rx);
533
534        let intents = rx.drain_ready_intents();
535        assert!(intents.iter().any(|signal| {
536            matches!(
537                signal.intent.as_ref().map(|intent| &intent.value),
538                Some(IntentValue::UpdateTransform {
539                    component_ids,
540                    translation,
541                    scale,
542                    ..
543                }) if component_ids == &vec![target]
544                    && *translation == [1.0, 2.0, 3.0]
545                    && *scale == [2.0, 2.0, 2.0]
546            )
547        }));
548
549        let transform = world
550            .get_component_by_id_as::<TransformComponent>(target)
551            .expect("target transform exists");
552        assert_eq!(transform.transform.translation, [1.0, 2.0, 3.0]);
553        assert_eq!(transform.transform.scale, [2.0, 2.0, 2.0]);
554    }
555
556    #[test]
557    fn keyframe_callback_emissive_set_intensity_emits_intensity_intent() {
558        let mut world = World::default();
559        let animation =
560            world.add_component(AnimationComponent::new().with_state(AnimationState::Playing));
561        let target = world.add_component(crate::engine::ecs::component::EmissiveComponent::off());
562        let callback = RuntimeClosure {
563            body: BlockStatement {
564                statements: vec![Statement::Expression(Expression::Call(CallExpression {
565                    callee: Box::new(Expression::BinaryOp {
566                        op: BinOpKind::Dot,
567                        lhs: Box::new(Expression::Identifier(Ident("glow".to_string()))),
568                        rhs: Box::new(Expression::Identifier(Ident("set_intensity".to_string()))),
569                    }),
570                    args: vec![Expression::Number(2.5)],
571                }))],
572            },
573            captured_env: Arc::new(HashMap::from([(
574                "glow".to_string(),
575                Value::ComponentObject {
576                    id: target,
577                    component_type: "EM".to_string(),
578                },
579            )])),
580            heap: crate::scripting::object::HeapHandle::new(),
581            analysis: None,
582        };
583        let keyframe = world.add_component(KeyframeComponent::new_with_callback(0.0, callback));
584        world.add_child(animation, keyframe).unwrap();
585
586        let mut system = AnimationSystem::new();
587        system.register_animation(&mut world, animation);
588        system.register_keyframe(&mut world, keyframe);
589
590        let mut rx = RxWorld::default();
591        system.tick_with_beat(&mut world, 0.0, 60.0, &mut rx);
592
593        let intents = rx.drain_ready_intents();
594        assert!(intents.iter().any(|signal| {
595            matches!(
596                signal.intent.as_ref().map(|intent| &intent.value),
597                Some(IntentValue::SetEmissiveIntensity {
598                    component_ids,
599                    intensity,
600                }) if component_ids == &vec![target] && (*intensity - 2.5).abs() < 1.0e-6
601            )
602        }));
603
604        let emissive = world
605            .get_component_by_id_as::<crate::engine::ecs::component::EmissiveComponent>(target)
606            .expect("target emissive exists");
607        assert!((emissive.intensity - 2.5).abs() < 1.0e-6);
608    }
609
610    #[test]
611    fn runtime_closure_audio_only_filters_visual_and_rewrites_beat_context() {
612        let mut world = World::default();
613        let glow = world.add_component(crate::engine::ecs::component::EmissiveComponent::off());
614        let lead = world.add_component(AudioOscillatorComponent::default());
615
616        let callback = RuntimeClosure {
617            body: BlockStatement {
618                statements: vec![
619                    Statement::Expression(Expression::Call(CallExpression {
620                        callee: Box::new(Expression::BinaryOp {
621                            op: BinOpKind::Dot,
622                            lhs: Box::new(Expression::Identifier(Ident("glow".to_string()))),
623                            rhs: Box::new(Expression::Identifier(Ident(
624                                "set_intensity".to_string(),
625                            ))),
626                        }),
627                        args: vec![Expression::Number(2.5)],
628                    })),
629                    Statement::Expression(Expression::Call(CallExpression {
630                        callee: Box::new(Expression::BinaryOp {
631                            op: BinOpKind::Dot,
632                            lhs: Box::new(Expression::Identifier(Ident("MusicNote".to_string()))),
633                            rhs: Box::new(Expression::Identifier(Ident("e".to_string()))),
634                        }),
635                        args: vec![
636                            Expression::Number(4.0),
637                            Expression::Number(0.25),
638                            Expression::Identifier(Ident("lead".to_string())),
639                        ],
640                    })),
641                ],
642            },
643            captured_env: Arc::new(HashMap::from([
644                (
645                    "glow".to_string(),
646                    Value::ComponentObject {
647                        id: glow,
648                        component_type: "EM".to_string(),
649                    },
650                ),
651                (
652                    "lead".to_string(),
653                    Value::ComponentObject {
654                        id: lead,
655                        component_type: "AudioOscillator".to_string(),
656                    },
657                ),
658            ])),
659            heap: crate::scripting::object::HeapHandle::new(),
660            analysis: None,
661        };
662
663        let mut rx = RxWorld::default();
664        eval_runtime_closure(
665            &callback,
666            None,
667            Some(&mut world),
668            Some(&mut rx),
669            None,
670            RuntimeClosureExecMode::KeyframeAudioOnly { beat_context: 12.5 },
671        )
672        .expect("audio-only runtime closure eval succeeds");
673
674        let intents = rx.drain_ready_intents();
675        assert_eq!(intents.len(), 1);
676        assert!(intents.iter().any(|signal| {
677            matches!(
678                signal.intent.as_ref().map(|intent| &intent.value),
679                Some(IntentValue::AudioSchedulePlay {
680                    component_ids,
681                    beat_context,
682                    ..
683                }) if component_ids == &vec![lead] && *beat_context == Some(12.5)
684            )
685        }));
686    }
687
688    #[test]
689    fn runtime_closure_visual_only_filters_audio() {
690        let mut world = World::default();
691        let glow = world.add_component(crate::engine::ecs::component::EmissiveComponent::off());
692        let lead = world.add_component(AudioOscillatorComponent::default());
693
694        let callback = RuntimeClosure {
695            body: BlockStatement {
696                statements: vec![
697                    Statement::Expression(Expression::Call(CallExpression {
698                        callee: Box::new(Expression::BinaryOp {
699                            op: BinOpKind::Dot,
700                            lhs: Box::new(Expression::Identifier(Ident("MusicNote".to_string()))),
701                            rhs: Box::new(Expression::Identifier(Ident("e".to_string()))),
702                        }),
703                        args: vec![
704                            Expression::Number(4.0),
705                            Expression::Number(0.25),
706                            Expression::Identifier(Ident("lead".to_string())),
707                        ],
708                    })),
709                    Statement::Expression(Expression::Call(CallExpression {
710                        callee: Box::new(Expression::BinaryOp {
711                            op: BinOpKind::Dot,
712                            lhs: Box::new(Expression::Identifier(Ident("glow".to_string()))),
713                            rhs: Box::new(Expression::Identifier(Ident(
714                                "set_intensity".to_string(),
715                            ))),
716                        }),
717                        args: vec![Expression::Number(2.5)],
718                    })),
719                ],
720            },
721            captured_env: Arc::new(HashMap::from([
722                (
723                    "glow".to_string(),
724                    Value::ComponentObject {
725                        id: glow,
726                        component_type: "EM".to_string(),
727                    },
728                ),
729                (
730                    "lead".to_string(),
731                    Value::ComponentObject {
732                        id: lead,
733                        component_type: "AudioOscillator".to_string(),
734                    },
735                ),
736            ])),
737            heap: crate::scripting::object::HeapHandle::new(),
738            analysis: None,
739        };
740
741        let mut rx = RxWorld::default();
742        eval_runtime_closure(
743            &callback,
744            None,
745            Some(&mut world),
746            Some(&mut rx),
747            None,
748            RuntimeClosureExecMode::KeyframeVisualOnly,
749        )
750        .expect("visual-only runtime closure eval succeeds");
751
752        let intents = rx.drain_ready_intents();
753        assert_eq!(intents.len(), 1);
754        assert!(intents.iter().any(|signal| {
755            matches!(
756                signal.intent.as_ref().map(|intent| &intent.value),
757                Some(IntentValue::SetEmissiveIntensity {
758                    component_ids,
759                    intensity,
760                }) if component_ids == &vec![glow] && (*intensity - 2.5).abs() < 1.0e-6
761            )
762        }));
763    }
764}