AnimControls
ui.fxContrôle d'animation : yoyo, timelines, keyframes et ressorts.
BEHAVIOR CONTRACT
Lecture, inversion, réinitialisation et positionnement agissent sur une seule machine à états d'animation.
RECORDED INTERACTION
Le harness joue le yoyo et la timeline.
pub fn buildAnimCtl(scope: *ui.Scope, cx: *ui.Cx) anyerror!*ui.Node {
const a = cx.allocator;
const c = try col(cx, 12);
try c.appendChild(a, try label(cx, "Animation controls — yoyo replay / timeline reverse / keyframes catch-up / degenerate spring"));
// yoyo tween:20 → 120 → 20(loops=2 + yoyo),完成后停在 from。
const yoyo_ctrl = try a.create(ui.fx.AnimationController);
yoyo_ctrl.* = ui.fx.AnimationController.initTween(.{ .from = 20, .to = 120, .duration = 0.7, .easing = .linear });
yoyo_ctrl.yoyo = true;
yoyo_ctrl.loops = 2;
try scope.registerResource(@ptrCast(yoyo_ctrl), destroyAnimController);
// timeline:单条 0→100 线性 2s。
const tl_ctrl = try a.create(ui.fx.AnimationController);
tl_ctrl.* = ui.fx.AnimationController.initTween(.{ .from = 0, .to = 100, .duration = 2.0, .easing = .linear });
try scope.registerResource(@ptrCast(tl_ctrl), destroyAnimController);
const tl = try a.create(ui.fx.Timeline);
tl.* = ui.fx.Timeline.init(a);
try scope.registerResource(@ptrCast(tl), destroyAnimTimeline);
try tl.add(tl_ctrl, ui.fx.Position.start);
// 退化 spring:负 stiffness(修复后 precompute 三参全钳,不产 NaN)。
const spring_ctrl = try a.create(ui.fx.AnimationController);
spring_ctrl.* = ui.fx.AnimationController.initSpring(.{ .from = 0, .to = 100, .stiffness = -50, .damping = 10, .mass = 1 });
try scope.registerResource(@ptrCast(spring_ctrl), destroyAnimController);
// 可视化目标(值断言走 label;箱体只是肉眼观察辅助)
const arena = try ui.box(cx, .{
.width = .{ .px = 560 },
.height = .{ .px = 60 },
.background = light.color.bg_secondary,
}, .{});
const yoyo_box = try ui.box(cx, .{ .width = .{ .px = 28 }, .height = .{ .px = 28 }, .background = light.color.accent }, .{});
yoyo_box.meta.ownership.meta.test_id = "story.animctl.yoyobox";
try arena.appendChild(a, yoyo_box);
const spring_box = try ui.box(cx, .{ .width = .{ .px = 28 }, .height = .{ .px = 28 }, .background = light.color.border }, .{});
spring_box.meta.ownership.meta.test_id = "story.animctl.springbox";
try arena.appendChild(a, spring_box);
try c.appendChild(a, arena);
const yoyo_label = try ui.text(cx, "yoyo: state=idle value=20.0 replay_first=-1.0", .{ .font_size = 13, .color = light.color.fg_primary });
yoyo_label.meta.ownership.meta.test_id = "story.animctl.yoyo";
const tl_label = try ui.text(cx, "tl: state=idle t=0.00 v=0.0", .{ .font_size = 13, .color = light.color.fg_primary });
tl_label.meta.ownership.meta.test_id = "story.animctl.tl";
const kf_label = try ui.text(cx, "kf: loops=0 value=0.0 completed=false", .{ .font_size = 13, .color = light.color.fg_primary });
kf_label.meta.ownership.meta.test_id = "story.animctl.kf";
const spring_label = try ui.text(cx, "spring: ticks=0 value=0.0", .{ .font_size = 13, .color = light.color.fg_primary });
spring_label.meta.ownership.meta.test_id = "story.animctl.spring";
try c.appendChild(a, yoyo_label);
try c.appendChild(a, tl_label);
try c.appendChild(a, kf_label);
try c.appendChild(a, spring_label);
const st = try cx.bindState(AnimCtlStory, .{
.cx = cx,
.yoyo_ctrl = yoyo_ctrl,
.tl = tl,
.tl_ctrl = tl_ctrl,
.spring_ctrl = spring_ctrl,
.kf = ui.fx.KeyframeAnimation.init(&KF_FRAMES, 400, 4),
.yoyo_box = yoyo_box,
.spring_box = spring_box,
.yoyo_label = yoyo_label,
.tl_label = tl_label,
.kf_label = kf_label,
.spring_label = spring_label,
});
const btn_row = try row(cx, 8);
const yoyo_btn = try W.Button(.{ .label = "Play yoyo", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.playYoyo) }).mount(scope, cx);
yoyo_btn.meta.ownership.meta.test_id = "story.animctl.yoyo_play";
try btn_row.appendChild(a, yoyo_btn);
const tl_play_btn = try W.Button(.{ .label = "Play timeline", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.playTimeline) }).mount(scope, cx);
tl_play_btn.meta.ownership.meta.test_id = "story.animctl.tl_play";
try btn_row.appendChild(a, tl_play_btn);
const tl_rev_btn = try W.Button(.{ .label = "Reverse timeline", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.reverseTimeline) }).mount(scope, cx);
tl_rev_btn.meta.ownership.meta.test_id = "story.animctl.tl_reverse";
try btn_row.appendChild(a, tl_rev_btn);
const kf_reset_btn = try W.Button(.{ .label = "KF reset", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.kfReset) }).mount(scope, cx);
kf_reset_btn.meta.ownership.meta.test_id = "story.animctl.kf_reset";
try btn_row.appendChild(a, kf_reset_btn);
const kf_step_btn = try W.Button(.{ .label = "KF +1s", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.kfAdvance) }).mount(scope, cx);
kf_step_btn.meta.ownership.meta.test_id = "story.animctl.kf_step";
try btn_row.appendChild(a, kf_step_btn);
try c.appendChild(a, btn_row);
c.meta.per_frame.hooks.slots.anim_state = @ptrCast(st);
c.meta.per_frame.hooks.before_render.main = AnimCtlStory.tickHook;
// spring 自动起播(mount 即驱动,e2e 只读回值)
spring_ctrl.play();
cx.needs_redraw = true;
return c;
}Le code source tel quel de la story dans le Storybook (zenit 5f9add5+wip 2026-09-30). W désigne ui.widgets ; col / row / label sont de petits helpers de mise en page du Storybook.