use bevy_ecs::component::Component;
use bevy_ecs::system::{Query, Res};
use dola::runtime::{DolaRuntime, UpdateResult};
use super::graphics::FrameTime;
#[derive(Component)]
pub struct DolaAnimator {
runtime: DolaRuntime,
}
unsafe impl Send for DolaAnimator {}
unsafe impl Sync for DolaAnimator {}
impl DolaAnimator {
pub fn new() -> Self {
Self {
runtime: DolaRuntime::new(),
}
}
pub fn with_runtime(runtime: DolaRuntime) -> Self {
Self { runtime }
}
pub fn tick(&mut self, current_time: f64) {
self.runtime.tick(current_time);
}
pub fn last_result(&self) -> &UpdateResult {
self.runtime.last_result()
}
pub fn runtime(&self) -> &DolaRuntime {
&self.runtime
}
}
impl Default for DolaAnimator {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for DolaAnimator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DolaAnimator")
.field("has_runtime", &true)
.finish()
}
}
pub fn tick_dola_animators(mut query: Query<&mut DolaAnimator>, frame_time: Res<FrameTime>) {
let mut active = false;
for mut animator in query.iter_mut() {
animator.tick(frame_time.0);
let result = animator.last_result();
active |= !result.changes.is_empty() || !result.triggered.is_empty();
}
if active {
crate::ecs::world::tick_wake::mark(crate::ecs::world::tick_wake::ANIM);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::schedule::Schedule;
use bevy_ecs::world::World;
use dola::runtime::{DolaRuntime, EvaluatedValue};
use dola::{
AnimationVariableDef, DolaDocumentBuilder, StoryboardBuilder, StoryboardEntry,
TransitionDef, TransitionRef, TransitionValue,
};
fn loaded_runtime() -> DolaRuntime {
let doc = DolaDocumentBuilder::new("1.0")
.variable(
"opacity",
AnimationVariableDef::Float {
initial: 0.0,
min: None,
max: None,
},
)
.transition(
"fade_t",
TransitionDef {
from: Some(TransitionValue::Scalar(0.0)),
to: Some(TransitionValue::Scalar(1.0)),
duration: Some(1.0),
..Default::default()
},
)
.storyboard(
"fade_in",
StoryboardBuilder::new()
.entry(StoryboardEntry {
variable: Some("opacity".into()),
transition: Some(TransitionRef::Named("fade_t".into())),
..Default::default()
})
.build(),
)
.build()
.unwrap();
let mut rt = DolaRuntime::new();
rt.subscribe("opacity");
rt.load_document(doc).unwrap();
rt
}
fn started_animator() -> DolaAnimator {
let mut rt = loaded_runtime();
rt.start("fade_in", 0.0).unwrap();
DolaAnimator::with_runtime(rt)
}
#[test]
fn new_animator_has_empty_last_result() {
let animator = DolaAnimator::new();
let result = animator.last_result();
assert!(
result.changes.is_empty(),
"new() must start with no changes"
);
assert!(
result.triggered.is_empty(),
"new() must start with no triggered results"
);
}
#[test]
fn default_animator_matches_new_empty_result() {
let animator = DolaAnimator::default();
let result = animator.last_result();
assert!(
result.changes.is_empty() && result.triggered.is_empty(),
"default() must delegate to new() and have an empty result"
);
}
#[test]
fn with_runtime_preserves_supplied_runtime_state() {
let animator = started_animator();
let mut animator = animator;
animator.tick(0.5);
assert!(
!animator.last_result().changes.is_empty(),
"with_runtime must retain the loaded/started runtime (opacity should change at t=0.5)"
);
}
#[test]
fn tick_updates_last_result_from_empty_to_changes() {
let mut animator = started_animator();
assert!(
animator.last_result().changes.is_empty(),
"before tick(): last_result must be empty"
);
animator.tick(0.5);
assert!(
!animator.last_result().changes.is_empty(),
"after tick(0.5): opacity change must be present"
);
}
#[test]
fn last_result_is_idempotent_across_repeated_reads() {
let mut animator = started_animator();
animator.tick(0.5);
let first_len = animator.last_result().changes.len();
let second_len = animator.last_result().changes.len();
let third_len = animator.last_result().changes.len();
assert_eq!(
first_len, second_len,
"last_result() must be stable across repeated reads"
);
assert_eq!(
second_len, third_len,
"last_result() must remain idempotent"
);
assert!(
first_len > 0,
"midpoint tick must produce at least one change"
);
}
#[test]
fn tick_advancing_time_replaces_previous_result() {
let mut animator = started_animator();
animator.tick(1.0);
assert_eq!(
animator.last_result().changes.len(),
1,
"tick(1.0) must deliver the opacity end value once"
);
animator.tick(2.0);
assert!(
animator.last_result().changes.is_empty(),
"tick(2.0) settles at the same value: last_result is replaced by the latest (empty-diff) tick, not accumulated"
);
}
#[test]
fn repeated_tick_at_same_value_yields_empty_diff_after_first_delivery() {
let mut rt = loaded_runtime();
rt.start("fade_in", 1.0).unwrap(); let mut animator = DolaAnimator::with_runtime(rt);
animator.tick(0.5);
assert_eq!(
animator.last_result().changes.len(),
1,
"first tick delivers the initial value (0.0) once, even before start_time"
);
animator.tick(0.5); assert!(
animator.last_result().changes.is_empty(),
"re-ticking the same time yields no diff (value unchanged from last delivery)"
);
}
#[test]
fn runtime_accessor_returns_inner_runtime_reference() {
let animator = started_animator();
let inner = animator.runtime();
assert!(
std::ptr::eq(inner.last_result(), animator.last_result()),
"runtime() must expose the same inner runtime backing last_result()"
);
}
#[test]
fn debug_format_reports_has_runtime_true() {
let animator = DolaAnimator::new();
let debug = format!("{animator:?}");
assert!(
debug.contains("DolaAnimator"),
"Debug must include the type name: {debug}"
);
assert!(
debug.contains("has_runtime") && debug.contains("true"),
"Debug must report has_runtime: true: {debug}"
);
}
fn tick_schedule() -> Schedule {
let mut s = Schedule::default();
s.add_systems(tick_dola_animators);
s
}
#[test]
fn tick_system_on_empty_world_is_noop() {
let mut world = World::new();
world.insert_resource(FrameTime(0.5));
let mut schedule = tick_schedule();
schedule.run(&mut world); assert_eq!(
world.query::<&DolaAnimator>().iter(&world).count(),
0,
"empty world must remain empty after the tick system"
);
}
#[test]
fn tick_system_drives_all_animators_from_frame_time() {
let mut world = World::new();
world.insert_resource(FrameTime(0.5));
let e1 = world.spawn(started_animator()).id();
let e2 = world.spawn(started_animator()).id();
let mut schedule = tick_schedule();
schedule.run(&mut world);
for (label, e) in [("e1", e1), ("e2", e2)] {
let animator = world.get::<DolaAnimator>(e).unwrap();
assert!(
!animator.last_result().changes.is_empty(),
"{label} must be ticked by the system at FrameTime(0.5)"
);
}
}
#[test]
fn tick_system_uses_injected_frame_time_value() {
let mut world = World::new();
world.insert_resource(FrameTime(0.5));
let e = world.spawn(started_animator()).id();
let mut schedule = tick_schedule();
schedule.run(&mut world);
let changes = &world.get::<DolaAnimator>(e).unwrap().last_result().changes;
assert_eq!(changes.len(), 1, "one opacity change expected at t=0.5");
let (var_id, value) = &changes[0];
assert_eq!(*var_id, 0, "subscribed opacity has variable_id 0");
match value {
EvaluatedValue::Float(v) => assert!(
(*v - 0.5).abs() < 1e-9,
"FrameTime(0.5) must interpolate opacity to 0.5 (system forwards injected time), got {v}"
),
other => panic!("expected Float opacity value, got {other:?}"),
}
}
}