use std::collections::{BTreeMap, BTreeSet};
use crate::engine::ecs::component::{AnimationComponent, AnimationState, KeyframeComponent};
use crate::engine::ecs::system::System;
use crate::engine::ecs::system::animation_keyframe_evaluator::AnimationKeyframeEvaluator;
use crate::engine::ecs::system::animation_scheduler::AnimationScheduler;
use crate::engine::ecs::{ComponentId, RxWorld, World};
use crate::engine::graphics::VisualWorld;
use crate::engine::user_input::InputState;
#[derive(Debug, Default)]
struct AnimationRuntime {
keyframes: Vec<ComponentId>,
fired_keyframes: BTreeSet<ComponentId>,
audio_scheduled_cycle_by_keyframe: BTreeMap<ComponentId, u64>,
audio_cycle: u64,
start_beat: f64,
pending_state: Option<AnimationState>,
}
#[derive(Debug, Default)]
pub struct AnimationSystem {
animations: BTreeMap<ComponentId, AnimationRuntime>,
last_beat: f64,
scheduler: AnimationScheduler,
keyframe_evaluator: AnimationKeyframeEvaluator,
}
impl AnimationSystem {
pub fn new() -> Self {
Self::default()
}
pub fn register_animation(&mut self, world: &mut World, component: ComponentId) {
if world
.get_component_by_id_as::<AnimationComponent>(component)
.is_none()
{
return;
}
self.animations
.entry(component)
.or_insert_with(AnimationRuntime::default);
}
pub fn set_animation_state(&mut self, animation: ComponentId, state: AnimationState) {
self.animations
.entry(animation)
.or_insert_with(AnimationRuntime::default)
.pending_state = Some(state);
}
pub fn register_keyframe(&mut self, world: &mut World, component: ComponentId) {
if world
.get_component_by_id_as::<KeyframeComponent>(component)
.is_none()
{
return;
}
let mut cursor = world.parent_of(component);
while let Some(node) = cursor {
if world
.get_component_by_id_as::<AnimationComponent>(node)
.is_some()
{
let runtime = self
.animations
.entry(node)
.or_insert_with(AnimationRuntime::default);
let list = &mut runtime.keyframes;
if !list.contains(&component) {
list.push(component);
}
list.sort_by(|a, b| {
let ba = world
.get_component_by_id_as::<KeyframeComponent>(*a)
.map(|k| k.beat)
.unwrap_or(0.0);
let bb = world
.get_component_by_id_as::<KeyframeComponent>(*b)
.map(|k| k.beat)
.unwrap_or(0.0);
ba.partial_cmp(&bb).unwrap_or(std::cmp::Ordering::Equal)
});
return;
}
cursor = world.parent_of(node);
}
}
pub fn tick_with_beat(&mut self, world: &mut World, beat_now: f64, bpm: f64, rx: &mut RxWorld) {
if beat_now + 1e-9 < self.last_beat {
for runtime in self.animations.values_mut() {
runtime.fired_keyframes.clear();
runtime.audio_scheduled_cycle_by_keyframe.clear();
runtime.audio_cycle = 0;
}
}
for (&anim, runtime) in self.animations.iter_mut() {
let Some(state) = runtime.pending_state.take() else {
continue;
};
let Some(anim_comp) = world.get_component_by_id_as_mut::<AnimationComponent>(anim)
else {
continue;
};
anim_comp.state = state;
runtime.start_beat = beat_now;
runtime.fired_keyframes.clear();
runtime.audio_scheduled_cycle_by_keyframe.clear();
runtime.audio_cycle = 0;
}
for (&anim, runtime) in self.animations.iter_mut() {
let (state, length_override) =
match world.get_component_by_id_as::<AnimationComponent>(anim) {
Some(c) => (c.state, c.length_beats),
None => continue,
};
if state == AnimationState::Paused {
continue;
}
if runtime.keyframes.is_empty() {
continue;
}
let Some((min_beat, max_beat)) = runtime
.keyframes
.iter()
.filter_map(|&kf_id| {
world
.get_component_by_id_as::<KeyframeComponent>(kf_id)
.map(|kf| kf.beat)
})
.fold(None, |acc: Option<(f64, f64)>, beat| match acc {
None => Some((beat, beat)),
Some((min_b, max_b)) => Some((min_b.min(beat), max_b.max(beat))),
})
else {
continue;
};
let mut local_beat = (beat_now - runtime.start_beat).max(0.0);
let span = (max_beat - min_beat).max(0.0);
let loop_len = match length_override {
Some(n) if n.is_finite() && n > 0.0 => n,
_ if span < 1e-6 => 1.0,
_ => span.floor() + 1.0,
};
if state == AnimationState::Looping {
if local_beat + 1e-9 >= loop_len {
let wraps = (local_beat / loop_len).floor();
if wraps >= 1.0 {
local_beat -= wraps * loop_len;
runtime.start_beat = beat_now - local_beat;
runtime.fired_keyframes.clear();
runtime.audio_cycle = runtime.audio_cycle.saturating_add(wraps as u64);
}
}
}
let audio_due = self.scheduler.audio_due_keyframes(
world,
anim,
&runtime.keyframes,
&runtime.audio_scheduled_cycle_by_keyframe,
runtime.audio_cycle,
min_beat,
local_beat,
bpm,
loop_len,
);
if !audio_due.is_empty() {
for (kf_id, kf_local_beat, kf_cycle) in audio_due {
let cycle_offset = kf_cycle.saturating_sub(runtime.audio_cycle) as f64;
let kf_global_beat =
runtime.start_beat + cycle_offset * loop_len + kf_local_beat;
self.keyframe_evaluator.evaluate_audio_due_keyframe(
world,
rx,
kf_id,
kf_global_beat,
);
runtime
.audio_scheduled_cycle_by_keyframe
.insert(kf_id, kf_cycle);
}
}
let due_keyframes = self.scheduler.visual_due_keyframes(
world,
&runtime.keyframes,
&runtime.fired_keyframes,
min_beat,
local_beat,
);
for kf_id in due_keyframes {
let Some(kf) = world.get_component_by_id_as::<KeyframeComponent>(kf_id) else {
continue;
};
let kf_local_beat = kf.beat - min_beat;
if kf_local_beat <= local_beat + 1e-9 {
let already_scheduled = runtime
.audio_scheduled_cycle_by_keyframe
.get(&kf_id)
.copied()
== Some(runtime.audio_cycle);
self.keyframe_evaluator.evaluate_visual_due_keyframe(
world,
rx,
kf_id,
beat_now,
already_scheduled,
);
runtime.fired_keyframes.insert(kf_id);
}
}
if state == AnimationState::Playing {
let done = local_beat + 1e-9 >= loop_len;
if done {
if let Some(anim_comp) =
world.get_component_by_id_as_mut::<AnimationComponent>(anim)
{
anim_comp.state = AnimationState::Paused;
}
}
}
}
self.last_beat = beat_now;
}
}
impl System for AnimationSystem {
fn tick(
&mut self,
_world: &mut World,
_visuals: &mut VisualWorld,
_input: &InputState,
_dt_sec: f32,
) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::ecs::IntentValue;
use crate::engine::ecs::component::{AudioOscillatorComponent, TransformComponent};
use crate::scripting::ast::{
BinOpKind, BlockStatement, CallExpression, Expression, Ident, Statement,
};
use crate::scripting::object::{RuntimeClosure, Value};
use crate::scripting::world_evaluator::{RuntimeClosureExecMode, eval_runtime_closure};
use std::collections::HashMap;
use std::sync::Arc;
#[test]
fn keyframe_callback_dispatches_live_component_intent_when_due() {
let mut world = World::default();
let animation =
world.add_component(AnimationComponent::new().with_state(AnimationState::Playing));
let target = world.add_component(TransformComponent::new());
let callback = RuntimeClosure {
body: BlockStatement {
statements: vec![Statement::Expression(Expression::Call(CallExpression {
callee: Box::new(Expression::BinaryOp {
op: BinOpKind::Dot,
lhs: Box::new(Expression::Identifier(Ident("cube_t".to_string()))),
rhs: Box::new(Expression::Identifier(Ident(
"update_transform".to_string(),
))),
}),
args: vec![
Expression::Array(vec![
Expression::Number(1.0),
Expression::Number(2.0),
Expression::Number(3.0),
]),
Expression::Array(vec![
Expression::Number(0.0),
Expression::Number(0.5),
Expression::Number(0.0),
]),
Expression::Array(vec![
Expression::Number(2.0),
Expression::Number(2.0),
Expression::Number(2.0),
]),
],
}))],
},
captured_env: Arc::new(HashMap::from([(
"cube_t".to_string(),
Value::ComponentObject {
id: target,
component_type: "Transform".to_string(),
},
)])),
heap: crate::scripting::object::HeapHandle::new(),
analysis: None,
};
let keyframe = world.add_component(KeyframeComponent::new_with_callback(0.0, callback));
world.add_child(animation, keyframe).unwrap();
let mut system = AnimationSystem::new();
system.register_animation(&mut world, animation);
system.register_keyframe(&mut world, keyframe);
let mut rx = RxWorld::default();
system.tick_with_beat(&mut world, 0.0, 60.0, &mut rx);
let intents = rx.drain_ready_intents();
assert!(intents.iter().any(|signal| {
matches!(
signal.intent.as_ref().map(|intent| &intent.value),
Some(IntentValue::UpdateTransform {
component_id,
translation,
scale,
..
}) if component_id == &target
&& *translation == [1.0, 2.0, 3.0]
&& *scale == [2.0, 2.0, 2.0]
)
}));
}
#[test]
fn keyframe_callback_emissive_set_intensity_emits_intensity_intent() {
let mut world = World::default();
let animation =
world.add_component(AnimationComponent::new().with_state(AnimationState::Playing));
let target = world.add_component(crate::engine::ecs::component::EmissiveComponent::off());
let callback = RuntimeClosure {
body: BlockStatement {
statements: vec![Statement::Expression(Expression::Call(CallExpression {
callee: Box::new(Expression::BinaryOp {
op: BinOpKind::Dot,
lhs: Box::new(Expression::Identifier(Ident("glow".to_string()))),
rhs: Box::new(Expression::Identifier(Ident("set_intensity".to_string()))),
}),
args: vec![Expression::Number(2.5)],
}))],
},
captured_env: Arc::new(HashMap::from([(
"glow".to_string(),
Value::ComponentObject {
id: target,
component_type: "EM".to_string(),
},
)])),
heap: crate::scripting::object::HeapHandle::new(),
analysis: None,
};
let keyframe = world.add_component(KeyframeComponent::new_with_callback(0.0, callback));
world.add_child(animation, keyframe).unwrap();
let mut system = AnimationSystem::new();
system.register_animation(&mut world, animation);
system.register_keyframe(&mut world, keyframe);
let mut rx = RxWorld::default();
system.tick_with_beat(&mut world, 0.0, 60.0, &mut rx);
let intents = rx.drain_ready_intents();
assert!(intents.iter().any(|signal| {
matches!(
signal.intent.as_ref().map(|intent| &intent.value),
Some(IntentValue::SetEmissiveIntensity {
component_id,
intensity,
}) if component_id == &target && (*intensity - 2.5).abs() < 1.0e-6
)
}));
let emissive = world
.get_component_by_id_as::<crate::engine::ecs::component::EmissiveComponent>(target)
.expect("target emissive exists");
assert!((emissive.intensity - 2.5).abs() < 1.0e-6);
}
#[test]
fn runtime_closure_audio_only_filters_visual_and_rewrites_beat_context() {
let mut world = World::default();
let glow = world.add_component(crate::engine::ecs::component::EmissiveComponent::off());
let lead = world.add_component(AudioOscillatorComponent::default());
let callback = RuntimeClosure {
body: BlockStatement {
statements: vec![
Statement::Expression(Expression::Call(CallExpression {
callee: Box::new(Expression::BinaryOp {
op: BinOpKind::Dot,
lhs: Box::new(Expression::Identifier(Ident("glow".to_string()))),
rhs: Box::new(Expression::Identifier(Ident(
"set_intensity".to_string(),
))),
}),
args: vec![Expression::Number(2.5)],
})),
Statement::Expression(Expression::Call(CallExpression {
callee: Box::new(Expression::BinaryOp {
op: BinOpKind::Dot,
lhs: Box::new(Expression::Identifier(Ident("MusicNote".to_string()))),
rhs: Box::new(Expression::Identifier(Ident("e".to_string()))),
}),
args: vec![
Expression::Number(4.0),
Expression::Number(0.25),
Expression::Identifier(Ident("lead".to_string())),
],
})),
],
},
captured_env: Arc::new(HashMap::from([
(
"glow".to_string(),
Value::ComponentObject {
id: glow,
component_type: "EM".to_string(),
},
),
(
"lead".to_string(),
Value::ComponentObject {
id: lead,
component_type: "AudioOscillator".to_string(),
},
),
])),
heap: crate::scripting::object::HeapHandle::new(),
analysis: None,
};
let mut rx = RxWorld::default();
eval_runtime_closure(
&callback,
None,
Some(&mut world),
Some(&mut rx),
None,
RuntimeClosureExecMode::KeyframeAudioOnly { beat_context: 12.5 },
)
.expect("audio-only runtime closure eval succeeds");
let intents = rx.drain_ready_intents();
assert_eq!(intents.len(), 1);
assert!(intents.iter().any(|signal| {
matches!(
signal.intent.as_ref().map(|intent| &intent.value),
Some(IntentValue::AudioSchedulePlay {
component_id,
beat_context,
..
}) if component_id == &lead && *beat_context == Some(12.5)
)
}));
}
#[test]
fn runtime_closure_visual_only_filters_audio() {
let mut world = World::default();
let glow = world.add_component(crate::engine::ecs::component::EmissiveComponent::off());
let lead = world.add_component(AudioOscillatorComponent::default());
let callback = RuntimeClosure {
body: BlockStatement {
statements: vec![
Statement::Expression(Expression::Call(CallExpression {
callee: Box::new(Expression::BinaryOp {
op: BinOpKind::Dot,
lhs: Box::new(Expression::Identifier(Ident("MusicNote".to_string()))),
rhs: Box::new(Expression::Identifier(Ident("e".to_string()))),
}),
args: vec![
Expression::Number(4.0),
Expression::Number(0.25),
Expression::Identifier(Ident("lead".to_string())),
],
})),
Statement::Expression(Expression::Call(CallExpression {
callee: Box::new(Expression::BinaryOp {
op: BinOpKind::Dot,
lhs: Box::new(Expression::Identifier(Ident("glow".to_string()))),
rhs: Box::new(Expression::Identifier(Ident(
"set_intensity".to_string(),
))),
}),
args: vec![Expression::Number(2.5)],
})),
],
},
captured_env: Arc::new(HashMap::from([
(
"glow".to_string(),
Value::ComponentObject {
id: glow,
component_type: "EM".to_string(),
},
),
(
"lead".to_string(),
Value::ComponentObject {
id: lead,
component_type: "AudioOscillator".to_string(),
},
),
])),
heap: crate::scripting::object::HeapHandle::new(),
analysis: None,
};
let mut rx = RxWorld::default();
eval_runtime_closure(
&callback,
None,
Some(&mut world),
Some(&mut rx),
None,
RuntimeClosureExecMode::KeyframeVisualOnly,
)
.expect("visual-only runtime closure eval succeeds");
let intents = rx.drain_ready_intents();
assert_eq!(intents.len(), 1);
assert!(intents.iter().any(|signal| {
matches!(
signal.intent.as_ref().map(|intent| &intent.value),
Some(IntentValue::SetEmissiveIntensity {
component_id,
intensity,
}) if component_id == &glow && (*intensity - 2.5).abs() < 1.0e-6
)
}));
}
}