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 audio_scheduled_cycle_by_keyframe: BTreeMap<ComponentId, u64>,
20 audio_cycle: u64,
22 start_beat: f64,
23 pending_state: Option<AnimationState>,
24 attach_resolved: bool,
29}
30
31#[derive(Debug, Default)]
32pub struct AnimationSystem {
33 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 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 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 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 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 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 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 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 let mut local_beat = (beat_now - runtime.start_beat).max(0.0);
209 let span = (max_beat - min_beat).max(0.0);
210 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 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 runtime.audio_cycle = runtime.audio_cycle.saturating_add(wraps as u64);
234 }
235 }
236 }
237
238 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 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 }
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}