Play animation's blend is given in seconds, not in frames of the new animation (#106)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
04dc254b3d
commit
8a377d9239
4 files changed
+39
-26
No files matched your search
@@ -22,8 +22,8 @@ export const PlayAnimation: NodeDef = {
|
||||
'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' +
|
||||
'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' +
|
||||
'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' +
|
||||
'of its frames from what they held when it took over; everything else switches at once.',
|
||||
inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (frames)'), 0)],
|
||||
'seconds from what they held when it took over; everything else switches at once.',
|
||||
inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (s)'), 0)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
+12
-11
@@ -392,20 +392,21 @@ export interface AnimationPlayer {
|
||||
}
|
||||
|
||||
/**
|
||||
* A crossfade into a player that just took over (#106): over `frames` of its own, a row that blends
|
||||
* mixes from what the property held into the player's value. `from` is taken on the first tick of the
|
||||
* blend; a property an earlier player writes in the same tick is mixed from that value instead, live.
|
||||
* A crossfade into a player that just took over (#106): over `seconds`, whatever the animation's frame
|
||||
* rate, a row that blends mixes from what the property held into the player's value. `from` is taken
|
||||
* on the first tick of the blend; a property an earlier player writes in the same tick is mixed from
|
||||
* that value instead, live.
|
||||
*/
|
||||
export interface AnimationBlend {
|
||||
frames: number;
|
||||
seconds: number;
|
||||
elapsed: number;
|
||||
from?: Record<string, PropertyValue>;
|
||||
}
|
||||
|
||||
/** How far a blend has come, 0…1; an animation without a frame rate has nothing to blend over. */
|
||||
export function blendShare(blend: AnimationBlend, fps: number): number {
|
||||
if (!(blend.frames > 0) || !(fps > 0)) return 1;
|
||||
return Math.min(1, (blend.elapsed * fps) / blend.frames);
|
||||
/** How far a blend has come, 0…1. */
|
||||
export function blendShare(blend: AnimationBlend): number {
|
||||
if (!(blend.seconds > 0)) return 1;
|
||||
return Math.min(1, blend.elapsed / blend.seconds);
|
||||
}
|
||||
|
||||
interface PlayerHolder {
|
||||
@@ -418,13 +419,13 @@ let startCount = 0;
|
||||
* Starts the animation on the object, or lets a paused one run on. One already running keeps its
|
||||
* frame, so a graph may ask for it every frame without holding it at its first frame – but it counts
|
||||
* as started last again: turning back to a walk that still runs has to show that walk.
|
||||
* `blendFrames` crossfades into it – but only when it takes over: asked for again while it already
|
||||
* `blendSeconds` crossfades into it – but only when it takes over: asked for again while it already
|
||||
* leads on the object, it keeps the blend it has, so a graph calling every frame does not restart it.
|
||||
*/
|
||||
export function playAnimation(holder: PlayerHolder, animationId: string, blendFrames = 0): void {
|
||||
export function playAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void {
|
||||
const players = (holder.animations ??= []);
|
||||
const known = players.find((p) => p.animationId === animationId);
|
||||
const blend = blendFrames > 0 ? { frames: blendFrames, elapsed: 0 } : undefined;
|
||||
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
|
||||
if (known) {
|
||||
const leads = players.every((p) => p.order <= known.order);
|
||||
known.playing = true;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
blendShare,
|
||||
blends,
|
||||
blendValue,
|
||||
fpsOf,
|
||||
isPositionRow,
|
||||
playbackOf,
|
||||
trackWriteAt,
|
||||
@@ -73,7 +72,7 @@ export class AnimationSystem implements System {
|
||||
if (blend) blend.elapsed += dt;
|
||||
}
|
||||
const head = animationPlayheadAt(asset, player.time);
|
||||
const share = blend ? blendShare(blend, fpsOf(asset)) : 1;
|
||||
const share = blend ? blendShare(blend) : 1;
|
||||
for (const b of bindings) {
|
||||
let value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base], head.reverse);
|
||||
if (value === undefined) continue;
|
||||
|
||||
@@ -601,11 +601,11 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
return { ...s, stand };
|
||||
}
|
||||
|
||||
it('mixes from the animation it takes over, linearly over its blend frames', () => {
|
||||
it('mixes from the animation it takes over, linearly over its blend time', () => {
|
||||
const { walk, stand, sprite, frame } = standing(100);
|
||||
playAnimation(sprite, stand.id);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, walk.id, 4);
|
||||
playAnimation(sprite, walk.id, 0.4);
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
frame(0.1);
|
||||
@@ -619,7 +619,7 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
it('mixes from what the property held when no animation before it writes there – without easing off', () => {
|
||||
const { walk, sprite, frame } = slide();
|
||||
sprite.x = 100;
|
||||
playAnimation(sprite, walk.id, 4);
|
||||
playAnimation(sprite, walk.id, 0.4);
|
||||
frame(0.1);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(77.5);
|
||||
@@ -628,13 +628,26 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
expect(sprite.x).toBeCloseTo(60);
|
||||
});
|
||||
|
||||
it('takes its blend time in seconds, whatever the frame rate of the animation', () => {
|
||||
const { walk, sprite, frame } = slide({ fps: 30 });
|
||||
sprite.x = 100;
|
||||
playAnimation(sprite, walk.id, 0.4);
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
frame(0.1);
|
||||
seen.push(sprite.x);
|
||||
}
|
||||
// The walk at 0, 0.1, 0.2 s is 0, 30, 60; mixed in by 0, 1/4, 1/2 – not by 3/4, 3/2 as frames would.
|
||||
[100, 82.5, 80].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||
});
|
||||
|
||||
it('switches a value that cannot blend at once', () => {
|
||||
const { f, walk, stand, sprite, frame } = standing(100);
|
||||
addRow(f, stand, 'mirrorX', [[0, false]]);
|
||||
addRow(f, walk, 'mirrorX', [[0, true]]);
|
||||
playAnimation(sprite, stand.id);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, walk.id, 4);
|
||||
playAnimation(sprite, walk.id, 0.4);
|
||||
frame(0.1);
|
||||
frame(0.1);
|
||||
expect(sprite.props.mirrorX).toBe(true);
|
||||
@@ -646,14 +659,14 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
sprite.x = 100;
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
playAnimation(sprite, walk.id, 4);
|
||||
playAnimation(sprite, walk.id, 0.4);
|
||||
frame(0.1);
|
||||
seen.push(sprite.x);
|
||||
}
|
||||
[100, 77.5, 60].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||
});
|
||||
|
||||
it('blends again when a running animation wins back, and not at all without blend frames', () => {
|
||||
it('blends again when a running animation wins back, and not at all without a blend time', () => {
|
||||
const { walk, stand, sprite, frame } = standing(100);
|
||||
playAnimation(sprite, walk.id);
|
||||
frame(0.1);
|
||||
@@ -661,7 +674,7 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
playAnimation(sprite, stand.id);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBe(100);
|
||||
playAnimation(sprite, walk.id, 2);
|
||||
playAnimation(sprite, walk.id, 0.2);
|
||||
frame(0.1);
|
||||
// The walk runs on at frame 3 (30), half blended in from the stand's 100.
|
||||
expect(sprite.x).toBeCloseTo(65);
|
||||
@@ -676,10 +689,10 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
addRow(f, duck, 'x', [[0, -100]]);
|
||||
playAnimation(sprite, stand.id);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, walk.id, 4);
|
||||
playAnimation(sprite, walk.id, 0.4);
|
||||
frame(0.1);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, duck.id, 2);
|
||||
playAnimation(sprite, duck.id, 0.2);
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
frame(0.1);
|
||||
@@ -929,13 +942,13 @@ describe('the animation nodes', () => {
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const owner = edit.addNode('Owner');
|
||||
const play = edit.addNode('PlayAnimation', { animation: walk.id, blend: 6 });
|
||||
const play = edit.addNode('PlayAnimation', { animation: walk.id, blend: 0.6 });
|
||||
edit.wire([
|
||||
[start, 'exec', play, 'exec'],
|
||||
[owner, 'object', play, 'object'],
|
||||
]);
|
||||
const { world, frame } = startWorld(f, { behaviors: true });
|
||||
frame(0.1);
|
||||
expect(world.findByObjectId(box)!.animations?.[0]?.blend?.frames).toBe(6);
|
||||
expect(world.findByObjectId(box)!.animations?.[0]?.blend?.seconds).toBe(0.6);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user