use concinnity_core::animation::skeleton;
use concinnity_core::components::{
Animation, AnimationGraph, AnimationParams, CharacterRig, PhysicsConfig, Prop, PropCollider,
RootMotionEvent, SkeletonPose,
};
use concinnity_core::ecs::ComponentSlot;
use concinnity_core::ecs::EventCursor;
use concinnity_core::ecs::FrameTime;
use concinnity_core::ecs::MenuActive;
use concinnity_core::ecs::SkinnedMeshHandle;
use concinnity_core::ecs::World;
use concinnity_core::transform;
use concinnity_host::thread::asset_id;
use concinnity_host::thread::asset_id::intern;
use crate::ecs::SYSTEMS;
fn frame_dt(dt: f32) -> FrameTime {
FrameTime { dt, elapsed: 0.0 }
}
#[test]
fn animation_component_spawns_internal_system() {
let mut world = World::new();
world.add_component(Animation::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["AnimationSystem"]);
}
#[test]
fn no_animation_no_internal_system() {
let mut world = World::new();
world.start(SYSTEMS).unwrap();
assert!(world.systems().is_empty());
}
#[test]
fn anim_graph_component_spawns_internal_system() {
let mut world = World::new();
world.add_component(AnimationGraph::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["AnimationSystem"]);
}
fn clip(duration: f32) -> Animation {
asset_id::ensure_name_resolver();
serde_json::from_value(serde_json::json!({
"target": "hero",
"duration": duration,
"looping": true,
}))
.unwrap()
}
fn add_named<C: ComponentSlot>(world: &mut World, name: &str, c: C) {
world.push_identified(intern(name), c);
}
fn hero_graph() -> AnimationGraph {
serde_json::from_value(serde_json::json!({
"target": "hero",
"parameters": [{"name": "speed", "default": 0.0}],
"initial": "idle",
"states": [
{"name": "idle", "clip": "idle_clip"},
{"name": "run", "clip": "run_clip"}
],
"transitions": [
{"from": "idle", "to": "run",
"conditions": [{"parameter": "speed", "op": "gt", "value": 0.5}]},
{"from": "run", "to": "idle",
"conditions": [{"parameter": "speed", "op": "le", "value": 0.5}]}
]
}))
.unwrap()
}
fn graph_world() -> World {
let mut world = World::new();
add_named(&mut world, "idle_clip", clip(1.0));
add_named(&mut world, "run_clip", clip(0.8));
add_named(&mut world, "hero_graph", hero_graph());
world.start(SYSTEMS).unwrap();
world
}
fn hero() -> SkinnedMeshHandle {
SkinnedMeshHandle(intern("hero").0)
}
fn with_anim<R>(world: &mut World, f: impl FnOnce(&mut super::AnimationSystem) -> R) -> R {
for system in world.systems_mut() {
if let Some(anim) = system.downcast_mut::<super::AnimationSystem>() {
return f(anim);
}
}
panic!("AnimationSystem not constructed");
}
#[test]
fn graph_init_seeds_params_and_initial_state() {
let mut world = graph_world();
world.step();
let params: Vec<&AnimationParams> = world.query::<AnimationParams>().collect();
assert_eq!(params.len(), 1);
assert_eq!(params[0].target, hero());
assert_eq!(params[0].values, vec![0.0]);
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "idle");
assert_eq!(report.params, vec![("speed".to_string(), 0.0)]);
assert!(
report.blend_weights.is_none(),
"single-clip states report no blend weights"
);
}
#[test]
fn blendspace_weights_follow_the_parameter() {
let target = SkinnedMeshHandle(intern("hero_blend").0);
let mut world = World::new();
for (name, duration) in [("bl_idle", 1.0), ("bl_walk", 0.8), ("bl_run", 0.6)] {
let mut a = clip(duration);
a.target = Some(SkinnedMeshHandle(target.0));
add_named(&mut world, name, a);
}
let g: AnimationGraph = serde_json::from_value(serde_json::json!({
"target": "hero_blend",
"parameters": [{"name": "speed", "default": 0.0}],
"states": [
{"name": "locomotion", "blend": {"kind": "blend1d", "parameter": "speed",
"sync": true,
"points": [
{"value": 0.0, "clip": "bl_idle"},
{"value": 1.6, "clip": "bl_walk"},
{"value": 5.0, "clip": "bl_run"}
]}}
]
}))
.unwrap();
add_named(&mut world, "hero_blend_graph", g);
world.start(SYSTEMS).unwrap();
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(target).unwrap());
assert_eq!(report.state, "locomotion");
assert_eq!(report.blend_weights, Some(vec![1.0, 0.0, 0.0]));
with_anim(&mut world, |anim| {
anim.queue_param(target, "speed", 3.3).unwrap();
});
world.step();
let w = with_anim(&mut world, |anim| anim.graph_report(target).unwrap())
.blend_weights
.unwrap();
assert_eq!(w[0], 0.0);
assert!(
(w[1] - 0.5).abs() < 1e-4 && (w[2] - 0.5).abs() < 1e-4,
"{w:?}"
);
}
#[test]
fn graph_transitions_on_component_write() {
let mut world = graph_world();
world.step();
for p in world.query_mut::<AnimationParams>() {
p.set(0, 2.0);
}
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "run");
for p in world.query_mut::<AnimationParams>() {
p.set(0, 0.0);
}
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "idle");
}
#[test]
fn queued_param_writes_component_and_drives_graph() {
let mut world = graph_world();
world.step();
with_anim(&mut world, |anim| {
anim.queue_param(hero(), "speed", 3.0).unwrap();
assert!(
anim.queue_param(hero(), "nope", 1.0)
.unwrap_err()
.contains("no parameter"),
);
});
world.step();
let params: Vec<&AnimationParams> = world.query::<AnimationParams>().collect();
assert_eq!(params[0].values, vec![3.0], "write landed in the component");
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "run");
}
#[test]
fn mode_mismatched_commands_are_rejected() {
let mut world = graph_world();
world.step();
with_anim(&mut world, |anim| {
let err = anim
.apply_crossfade(hero(), vec![1.0, 0.0], 0.0, 0.0)
.unwrap_err();
assert!(err.contains("graph-driven"));
assert!(err.contains("anim-param"));
});
let mut flat_world = World::new();
let mut a = clip(1.0);
a.target = Some(SkinnedMeshHandle(intern("flat_hero").0));
flat_world.add_component(a);
flat_world.start(SYSTEMS).unwrap();
flat_world.step();
with_anim(&mut flat_world, |anim| {
let target = SkinnedMeshHandle(intern("flat_hero").0);
anim.apply_crossfade(target, vec![0.5], 0.0, 0.0).unwrap();
let err = anim.queue_param(target, "speed", 1.0).unwrap_err();
assert!(err.contains("anim-crossfade"));
let err = anim.graph_report(target).unwrap_err();
assert!(err.contains("no AnimationGraph"));
});
}
#[test]
fn root_motion_clip_publishes_displacement_events() {
let target = SkinnedMeshHandle(intern("hero_rm").0);
let mut world = World::new();
let a: Animation = serde_json::from_value(serde_json::json!({
"target": "hero_rm",
"duration": 1.0,
"looping": true,
"root_motion": true,
"root_track": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 1.0, "translation": [2.0, 0.0, 0.0]}
],
}))
.unwrap();
add_named(&mut world, "hero_rm_walk", a);
world.start(SYSTEMS).unwrap();
world.insert_resource(frame_dt(0.005));
world.step();
world.step();
let events = world
.events::<RootMotionEvent>()
.expect("RootMotionEvent queue exists");
let mut cursor = EventCursor::default();
let motions: Vec<_> = events.read(&mut cursor).collect();
assert!(!motions.is_empty(), "expected displacement events");
let total: f32 = motions
.iter()
.filter(|m| m.target == target)
.map(|m| m.delta[0])
.sum();
assert!(total > 0.0, "walk moves +X: {total}");
assert!(
motions
.iter()
.all(|m| m.delta[1] == 0.0 && m.delta[2] == 0.0)
);
}
#[test]
fn root_motion_events_emit_in_handle_order() {
let mut world = World::new();
let mut handles = Vec::new();
for name in ["rm_ord_c", "rm_ord_a", "rm_ord_b"] {
handles.push(SkinnedMeshHandle(intern(name).0));
let a: Animation = serde_json::from_value(serde_json::json!({
"target": name,
"duration": 1.0,
"looping": true,
"root_motion": true,
"root_track": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 1.0, "translation": [2.0, 0.0, 0.0]}
],
}))
.unwrap();
add_named(&mut world, &format!("{name}_clip"), a);
}
world.start(SYSTEMS).unwrap();
world.insert_resource(frame_dt(0.0));
world.step();
world.insert_resource(frame_dt(0.005));
world.step();
let events = world
.events::<RootMotionEvent>()
.expect("RootMotionEvent queue exists");
let mut cursor = EventCursor::default();
let order: Vec<_> = events.read(&mut cursor).map(|m| m.target).collect();
assert_eq!(order.len(), handles.len(), "one event per moving target");
let mut sorted = order.clone();
sorted.sort();
assert_eq!(order, sorted, "events are published in handle order");
}
#[test]
fn ik_pins_the_foot_to_a_raised_ledge() {
use concinnity_core::animation::skeleton::{Joint, JointPose, Skeleton};
let target = SkinnedMeshHandle(intern("hero_ik").0);
let mut world = World::new();
let joint = |name: &str, parent: Option<usize>, t: [f32; 3]| Joint {
name: name.to_string(),
parent,
bind: JointPose {
translation: t,
..JointPose::default()
},
};
let skeleton = Skeleton::new(vec![
joint("hip", None, [0.6, 2.0, 0.0]),
joint("knee", Some(0), [0.0, -1.0, 0.0]),
joint("foot", Some(1), [0.0, -1.0, 0.0]),
]);
world.add_component(SkeletonPose::new(target, 0, skeleton));
world.add_component(CharacterRig::new(target, 0, transform::IDENTITY, 0.5, 0.3));
let stand: Animation = serde_json::from_value(serde_json::json!({
"target": "hero_ik",
"duration": 1.0,
"looping": true,
"tracks": [{"joint": 0, "keyframes": [
{"time": 0.0, "translation": [0.6, 2.0, 0.0]},
{"time": 1.0, "translation": [0.6, 2.0, 0.0]}
]}],
}))
.unwrap();
add_named(&mut world, "hero_ik_stand", stand);
let graph: AnimationGraph = serde_json::from_value(serde_json::json!({
"target": "hero_ik",
"states": [{"name": "stand", "clip": "hero_ik_stand"}],
"ik_chains": [{"joints": ["hip", "knee", "foot"], "pole": [0.0, 0.0, 1.0]}],
}))
.unwrap();
add_named(&mut world, "hero_ik_graph", graph);
world.add_component(PhysicsConfig::default());
world.push_identified(
intern("ledge"),
Prop {
position: [0.75, 0.1, 0.0],
collider: Some(PropCollider {
shape: concinnity_core::components::PropColliderShape::Cuboid,
half_extents: [0.3, 0.15, 0.3],
radius: 0.0,
half_height: 0.0,
layer: String::new(),
}),
..Default::default()
},
);
world.start(SYSTEMS).unwrap();
world.insert_resource(frame_dt(0.005));
for _ in 0..8 {
world.step();
}
let pose = world.query::<SkeletonPose>().next().expect("pose survives");
let foot_mesh = {
let m = pose.joint_matrices[2];
let b = pose.skeleton.bind_position(2);
[
m[0][0] * b[0] + m[1][0] * b[1] + m[2][0] * b[2] + m[3][0],
m[0][1] * b[0] + m[1][1] * b[1] + m[2][1] * b[2] + m[3][1],
m[0][2] * b[0] + m[1][2] * b[1] + m[2][2] * b[2] + m[3][2],
]
};
let rig_y = world.query::<CharacterRig>().next().unwrap().position[1];
let foot_world_y = foot_mesh[1] + rig_y;
assert!(
(foot_world_y - 0.25).abs() < 0.03,
"foot pinned to the ledge top: world y = {foot_world_y}"
);
assert!((foot_mesh[0] - 0.6).abs() < 0.02, "{foot_mesh:?}");
}
#[test]
fn rig_capsule_follows_root_motion() {
let target = SkinnedMeshHandle(intern("hero_rig").0);
let mut world = World::new();
let a: Animation = serde_json::from_value(serde_json::json!({
"target": "hero_rig",
"duration": 1.0,
"looping": true,
"root_motion": true,
"root_track": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 1.0, "translation": [2.0, 0.0, 0.0]}
],
}))
.unwrap();
add_named(&mut world, "hero_rig_walk", a);
world.add_component(PhysicsConfig::default());
world.add_component(CharacterRig::new(target, 0, transform::IDENTITY, 0.5, 0.3));
world.start(SYSTEMS).unwrap();
world.insert_resource(frame_dt(0.005));
for _ in 0..4 {
world.step();
}
let rig = world.query::<CharacterRig>().next().expect("rig survives");
assert!(
rig.position[0] > 0.0,
"capsule advanced along the walk: {:?}",
rig.position
);
assert!(
rig.moved,
"render follow flag set (no GraphicsSystem to clear it)"
);
assert!(
rig.position[1] > -0.2,
"flat floor holds the capsule up: {:?}",
rig.position
);
}
#[test]
fn graph_freezes_while_menu_open() {
let mut world = graph_world();
world.step();
world.insert_resource(MenuActive(true));
for p in world.query_mut::<AnimationParams>() {
p.set(0, 2.0);
}
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(
report.state, "idle",
"paused step must not take transitions"
);
world.insert_resource(MenuActive(false));
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "run", "resumed step sees the parameter");
}
#[test]
fn a_paused_frame_does_not_advance_clip_time() {
let target = SkinnedMeshHandle(intern("pause_skip").0);
let slide = || -> Animation {
serde_json::from_value(serde_json::json!({
"target": "pause_skip",
"duration": 20.0,
"looping": true,
"tracks": [{"joint": 0, "keyframes": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 20.0, "translation": [20.0, 0.0, 0.0]}
]}],
}))
.unwrap()
};
let root_x =
|world: &World| world.query::<SkeletonPose>().next().unwrap().joint_matrices[0][3][0];
let mut world = World::new();
world.add_component(slide());
world.add_component(single_joint_pose(target));
world.start(SYSTEMS).unwrap();
world.insert_resource(frame_dt(0.25));
world.step();
let before = root_x(&world);
assert!((before - 0.25).abs() < 1e-4, "the clip plays: {before}");
world.insert_resource(MenuActive(true));
world.insert_resource(frame_dt(10.0));
world.step();
assert_eq!(root_x(&world), before, "the paused frame leaves the pose");
let clip_secs = with_anim(&mut world, |anim| anim.clip_secs);
assert_eq!(clip_secs, 0.25, "and the clip clock");
world.insert_resource(MenuActive(false));
world.insert_resource(frame_dt(0.25));
world.step();
let after = root_x(&world);
assert!(
(after - 0.5).abs() < 1e-4,
"resumed without a jump: {after}"
);
}
fn runtime_clip(duration: f32) -> skeleton::AnimationClip {
skeleton::AnimationClip {
morph_keys: Vec::new(),
duration,
looping: true,
tracks: Vec::new(),
root: None,
}
}
fn flat_clip(target: SkinnedMeshHandle) -> Animation {
let mut a = clip(1.0);
a.target = Some(SkinnedMeshHandle(target.0));
a
}
#[test]
fn apply_reloaded_clip_reseats_a_flat_slot_and_rejects_bad_targets() {
let target = SkinnedMeshHandle(intern("flat_reload").0);
let mut world = World::new();
world.add_component(flat_clip(target));
world.start(SYSTEMS).unwrap();
world.step();
with_anim(&mut world, |anim| {
assert!(
anim.apply_reloaded_clip(target, 0, runtime_clip(3.0), 0.5),
"a valid slot accepts the reload"
);
assert!(
!anim.apply_reloaded_clip(
SkinnedMeshHandle(intern("nobody").0),
0,
runtime_clip(1.0),
1.0
),
"an unknown target is refused"
);
assert!(
!anim.apply_reloaded_clip(target, 9, runtime_clip(1.0), 1.0),
"an out-of-range slot is refused"
);
});
}
#[test]
fn apply_reloaded_clip_refreshes_graph_clip_duration() {
let mut world = graph_world();
world.step();
let applied = with_anim(&mut world, |anim| {
anim.apply_reloaded_clip(hero(), 0, runtime_clip(2.5), 1.0)
});
assert!(applied, "the graph bucket's idle slot reloads");
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "idle");
}
#[test]
fn reload_entries_is_empty_without_captured_sources() {
let mut world = graph_world();
world.step();
let count = with_anim(&mut world, |anim| anim.reload_entries().len());
assert_eq!(count, 0);
}
#[test]
fn debug_impl_summarizes_target_and_reload_counts() {
let mut world = graph_world();
world.step();
let text = with_anim(&mut world, |anim| format!("{anim:?}"));
assert!(text.contains("AnimationSystem"), "{text}");
assert!(text.contains("targets: 1"), "{text}");
assert!(text.contains("reload_entries: 0"), "{text}");
}
fn single_joint_pose(target: SkinnedMeshHandle) -> SkeletonPose {
use concinnity_core::animation::skeleton::{Joint, JointPose, Skeleton};
let skeleton = Skeleton::new(vec![Joint {
name: "root".to_string(),
parent: None,
bind: JointPose::default(),
}]);
SkeletonPose::new(target, 0, skeleton)
}
#[test]
fn flat_single_clip_samples_the_pose() {
let target = SkinnedMeshHandle(intern("flat_single_pose").0);
let mut world = World::new();
world.add_component(flat_clip(target));
world.add_component(single_joint_pose(target));
world.start(SYSTEMS).unwrap();
world.step();
let matrices = world
.query::<SkeletonPose>()
.next()
.map(|p| p.joint_matrices.len())
.unwrap();
assert_eq!(matrices, 1, "one skinning matrix for the one joint");
}
#[test]
fn flat_fade_in_blends_multiple_clips_into_the_pose() {
let target = SkinnedMeshHandle(intern("flat_blend_pose").0);
let mut world = World::new();
let mut faded = flat_clip(target);
faded.fade_in_secs = 0.5;
world.add_component(faded);
world.add_component(flat_clip(target));
world.add_component(single_joint_pose(target));
world.start(SYSTEMS).unwrap();
world.step();
world.step();
let matrices = world
.query::<SkeletonPose>()
.next()
.map(|p| p.joint_matrices.len())
.unwrap();
assert_eq!(matrices, 1, "the weighted blend produced a pose");
}
#[test]
fn morph_base_layer_composes_with_clip_morph_tracks() {
use concinnity_core::animation::proportions::ProportionLayer;
use concinnity_core::components::MorphKey;
let target = SkinnedMeshHandle(intern("morph_base_pose").0);
let mut world = World::new();
let mut a = flat_clip(target);
a.morph_track = vec![
MorphKey {
time: 0.0,
weights: vec![0.3, 0.9],
},
MorphKey {
time: 1.0,
weights: vec![0.3, 0.9],
},
];
world.add_component(a);
world.add_component(
single_joint_pose(target).with_shape(vec![0.5, 0.5], ProportionLayer::default()),
);
world.start(SYSTEMS).unwrap();
world.step();
let weights = world
.query::<SkeletonPose>()
.next()
.map(|p| p.morph_weights.clone())
.unwrap();
assert!((weights[0] - 0.8).abs() < 1e-5, "{weights:?}");
assert_eq!(weights[1], 1.0, "base + clip clamps at 1");
let target = SkinnedMeshHandle(intern("morph_base_only").0);
let mut world = World::new();
world.add_component(flat_clip(target));
world.add_component(
single_joint_pose(target).with_shape(vec![0.25], ProportionLayer::default()),
);
world.start(SYSTEMS).unwrap();
world.step();
let pose = world.query::<SkeletonPose>().next().unwrap();
assert_eq!(pose.morph_weights, [0.25]);
assert!(pose.updated);
}
#[test]
fn proportions_apply_to_the_sampled_pose() {
use concinnity_core::animation::proportions::ProportionLayer;
use concinnity_core::components::JointProportion;
let target = SkinnedMeshHandle(intern("proportioned_pose").0);
let mut world = World::new();
world.add_component(flat_clip(target));
let pose = single_joint_pose(target);
let layer = ProportionLayer::resolve(
&pose.skeleton,
&[JointProportion {
joint: "root".into(),
scale: 3.0,
length: 0.0,
}],
);
world.add_component(pose.with_shape(Vec::new(), layer));
world.start(SYSTEMS).unwrap();
world.step();
let pose = world.query::<SkeletonPose>().next().unwrap();
assert_eq!(pose.joint_matrices[0][0][0], 3.0);
}