Play animation can blend into the new animation over a number of frames (#106)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beeea355d9
commit
04dc254b3d
4 files changed
+177
-14
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import { pauseAnimation, playAnimation, stopAnimation } from '../../../model/animation';
|
import { pauseAnimation, playAnimation, stopAnimation } from '../../../model/animation';
|
||||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||||
import { asEntity, asString } from '../../values';
|
import { asEntity, asNumber, asString } from '../../values';
|
||||||
import { exec, obj } from '../shared';
|
import { exec, num, obj, withDefault } from '../shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `animation` input of the three animation nodes; unconnected it shows the animation picker, the
|
* The `animation` input of the three animation nodes; unconnected it shows the animation picker, the
|
||||||
@@ -21,14 +21,15 @@ export const PlayAnimation: NodeDef = {
|
|||||||
description:
|
description:
|
||||||
'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' +
|
'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 ' +
|
'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.',
|
'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' +
|
||||||
inputs: [exec(), obj('object'), animationPort()],
|
'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)],
|
||||||
outputs: [exec()],
|
outputs: [exec()],
|
||||||
params: [],
|
params: [],
|
||||||
run: (_ctx, inputs) => {
|
run: (_ctx, inputs) => {
|
||||||
const target = asEntity(inputs.object);
|
const target = asEntity(inputs.object);
|
||||||
const animation = asString(inputs.animation);
|
const animation = asString(inputs.animation);
|
||||||
if (target && animation) playAnimation(target, animation);
|
if (target && animation) playAnimation(target, animation, Math.max(0, asNumber(inputs.blend)));
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -388,6 +388,24 @@ export interface AnimationPlayer {
|
|||||||
/** Start order across the whole run: of two players setting one property, the later start wins. */
|
/** Start order across the whole run: of two players setting one property, the later start wins. */
|
||||||
order: number;
|
order: number;
|
||||||
bases?: Record<string, PropertyValue>;
|
bases?: Record<string, PropertyValue>;
|
||||||
|
blend?: AnimationBlend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface AnimationBlend {
|
||||||
|
frames: 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayerHolder {
|
interface PlayerHolder {
|
||||||
@@ -400,16 +418,21 @@ let startCount = 0;
|
|||||||
* Starts the animation on the object, or lets a paused one run on. One already running keeps its
|
* 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
|
* 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.
|
* 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
|
||||||
|
* 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): void {
|
export function playAnimation(holder: PlayerHolder, animationId: string, blendFrames = 0): void {
|
||||||
const players = (holder.animations ??= []);
|
const players = (holder.animations ??= []);
|
||||||
const known = players.find((p) => p.animationId === animationId);
|
const known = players.find((p) => p.animationId === animationId);
|
||||||
|
const blend = blendFrames > 0 ? { frames: blendFrames, elapsed: 0 } : undefined;
|
||||||
if (known) {
|
if (known) {
|
||||||
|
const leads = players.every((p) => p.order <= known.order);
|
||||||
known.playing = true;
|
known.playing = true;
|
||||||
known.order = ++startCount;
|
known.order = ++startCount;
|
||||||
|
if (!leads) known.blend = blend;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
players.push({ animationId, time: 0, playing: true, order: ++startCount });
|
players.push({ animationId, time: 0, playing: true, order: ++startCount, ...(blend && { blend }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends it where it stands – the values stay; `''` ends every animation on the object. */
|
/** Ends it where it stands – the values stay; `''` ends every animation on the object. */
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
animatableDefsOf,
|
animatableDefsOf,
|
||||||
animatedKind,
|
animatedKind,
|
||||||
animationPlayheadAt,
|
animationPlayheadAt,
|
||||||
|
blendShare,
|
||||||
|
blends,
|
||||||
|
blendValue,
|
||||||
|
fpsOf,
|
||||||
isPositionRow,
|
isPositionRow,
|
||||||
playbackOf,
|
playbackOf,
|
||||||
trackWriteAt,
|
trackWriteAt,
|
||||||
@@ -34,8 +38,8 @@ interface Binding {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays what `Play animation` started. Players run in start order, so of two setting one property
|
* Plays what `Play animation` started. Players run in start order, so of two setting one property
|
||||||
* the later start wins. Runs before the BehaviorSystem: a behavior writing the same property writes
|
* the later start wins – or, while it blends in, mixes over what was there. Runs before the
|
||||||
* last, and hears this frame's `finished`.
|
* BehaviorSystem: a behavior writing the same property writes last, and hears this frame's `finished`.
|
||||||
*/
|
*/
|
||||||
export class AnimationSystem implements System {
|
export class AnimationSystem implements System {
|
||||||
finished: AnimationFinish[] = [];
|
finished: AnimationFinish[] = [];
|
||||||
@@ -46,6 +50,8 @@ export class AnimationSystem implements System {
|
|||||||
for (const entity of world.entities)
|
for (const entity of world.entities)
|
||||||
for (const player of entity.animations ?? []) running.push({ entity, player });
|
for (const player of entity.animations ?? []) running.push({ entity, player });
|
||||||
running.sort((a, b) => a.player.order - b.player.order);
|
running.sort((a, b) => a.player.order - b.player.order);
|
||||||
|
// What the players before this one wrote this tick – a blend mixes from that value, live.
|
||||||
|
const written = new Set<string>();
|
||||||
for (const { entity, player } of running) {
|
for (const { entity, player } of running) {
|
||||||
const asset = findAssetOf(world.project, player.animationId, 'animation');
|
const asset = findAssetOf(world.project, player.animationId, 'animation');
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -53,16 +59,32 @@ export class AnimationSystem implements System {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const bindings = bind(world, asset, entity);
|
const bindings = bind(world, asset, entity);
|
||||||
|
const blend = player.blend;
|
||||||
|
if (blend && !blend.from) {
|
||||||
|
blend.from = {};
|
||||||
|
for (const b of bindings) blend.from[b.base] = b.read();
|
||||||
|
}
|
||||||
if (!player.bases) {
|
if (!player.bases) {
|
||||||
// The first tick only takes the starting values and shows frame 0; the clock runs from the next.
|
// The first tick only takes the starting values and shows frame 0; the clock runs from the next.
|
||||||
player.bases = {};
|
player.bases = {};
|
||||||
for (const b of bindings) player.bases[b.base] = b.read();
|
for (const b of bindings) player.bases[b.base] = b.read();
|
||||||
} else if (player.playing && isHandleEnabled(entity)) player.time += dt;
|
} else if (player.playing && isHandleEnabled(entity)) {
|
||||||
const head = animationPlayheadAt(asset, player.time);
|
player.time += dt;
|
||||||
for (const b of bindings) {
|
if (blend) blend.elapsed += dt;
|
||||||
const value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base], head.reverse);
|
|
||||||
if (value !== undefined) b.write(value);
|
|
||||||
}
|
}
|
||||||
|
const head = animationPlayheadAt(asset, player.time);
|
||||||
|
const share = blend ? blendShare(blend, fpsOf(asset)) : 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;
|
||||||
|
if (share < 1 && blends(b.kind)) {
|
||||||
|
const from = written.has(b.base) ? b.read() : blend?.from?.[b.base];
|
||||||
|
if (from !== undefined) value = blendValue(b.kind, from, value, share);
|
||||||
|
}
|
||||||
|
b.write(value);
|
||||||
|
written.add(b.base);
|
||||||
|
}
|
||||||
|
if (blend && share >= 1) delete player.blend;
|
||||||
if (!head.finished) continue;
|
if (!head.finished) continue;
|
||||||
if (playbackOf(asset) === 'once-reset')
|
if (playbackOf(asset) === 'once-reset')
|
||||||
for (const b of bindings) {
|
for (const b of bindings) {
|
||||||
|
|||||||
@@ -592,6 +592,104 @@ describe('the AnimationSystem', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('blending into an animation that takes over (#106)', () => {
|
||||||
|
/** `slide` plus a `Stand` holding `x` at `at`, at 10 fps. */
|
||||||
|
function standing(at: number, name = 'Stand') {
|
||||||
|
const s = slide();
|
||||||
|
const stand = addAnimation(s.f, s.f.spriteId, { fps: 10 }, name);
|
||||||
|
addRow(s.f, stand, 'x', [[0, at]]);
|
||||||
|
return { ...s, stand };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('mixes from the animation it takes over, linearly over its blend frames', () => {
|
||||||
|
const { walk, stand, sprite, frame } = standing(100);
|
||||||
|
playAnimation(sprite, stand.id);
|
||||||
|
frame(0.1);
|
||||||
|
playAnimation(sprite, walk.id, 4);
|
||||||
|
const seen: number[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
frame(0.1);
|
||||||
|
seen.push(sprite.x);
|
||||||
|
}
|
||||||
|
// The walk at frames 0…4 is 0, 10, 20, 30, 40; mixed in by 0, 1/4, 1/2, 3/4, all.
|
||||||
|
[100, 77.5, 60, 47.5, 40].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||||
|
expect(sprite.animations?.find((p) => p.animationId === walk.id)?.blend).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
frame(0.1);
|
||||||
|
frame(0.1);
|
||||||
|
expect(sprite.x).toBeCloseTo(77.5);
|
||||||
|
// Mixed from last tick's 77.5 it would read 48.75: the blend runs from the value at its start.
|
||||||
|
frame(0.1);
|
||||||
|
expect(sprite.x).toBeCloseTo(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
frame(0.1);
|
||||||
|
frame(0.1);
|
||||||
|
expect(sprite.props.mirrorX).toBe(true);
|
||||||
|
expect(sprite.x).toBeCloseTo(77.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its blend when a graph asks for it every frame', () => {
|
||||||
|
const { walk, sprite, frame } = slide();
|
||||||
|
sprite.x = 100;
|
||||||
|
const seen: number[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
playAnimation(sprite, walk.id, 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', () => {
|
||||||
|
const { walk, stand, sprite, frame } = standing(100);
|
||||||
|
playAnimation(sprite, walk.id);
|
||||||
|
frame(0.1);
|
||||||
|
frame(0.1);
|
||||||
|
playAnimation(sprite, stand.id);
|
||||||
|
frame(0.1);
|
||||||
|
expect(sprite.x).toBe(100);
|
||||||
|
playAnimation(sprite, walk.id, 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);
|
||||||
|
playAnimation(sprite, stand.id, 0);
|
||||||
|
frame(0.1);
|
||||||
|
expect(sprite.x).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a third start during a blend mix from the value already mixed, without a jump', () => {
|
||||||
|
const { f, walk, stand, sprite, frame } = standing(100);
|
||||||
|
const duck = addAnimation(f, f.spriteId, { fps: 10 }, 'Duck');
|
||||||
|
addRow(f, duck, 'x', [[0, -100]]);
|
||||||
|
playAnimation(sprite, stand.id);
|
||||||
|
frame(0.1);
|
||||||
|
playAnimation(sprite, walk.id, 4);
|
||||||
|
frame(0.1);
|
||||||
|
frame(0.1);
|
||||||
|
playAnimation(sprite, duck.id, 2);
|
||||||
|
const seen: number[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
frame(0.1);
|
||||||
|
seen.push(sprite.x);
|
||||||
|
}
|
||||||
|
// Walk over stand gives 60, 47.5, 40; the duck takes each in by 0, 1/2, all.
|
||||||
|
[60, -26.25, -100].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the reach of a played row (#109)', () => {
|
describe('the reach of a played row (#109)', () => {
|
||||||
/** A sprite packed into `Inner`, `Inner` packed into `Outer`, and `Outer` placed in the scene. */
|
/** A sprite packed into `Inner`, `Inner` packed into `Outer`, and `Outer` placed in the scene. */
|
||||||
function nested() {
|
function nested() {
|
||||||
@@ -821,4 +919,23 @@ describe('the animation nodes', () => {
|
|||||||
it('is not heard by an On animation finished naming another animation', () => {
|
it('is not heard by an On animation finished naming another animation', () => {
|
||||||
expect(listening('other').done).toBe(0);
|
expect(listening('other').done).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes Blend on to the player it starts (#106)', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||||
|
const walk = addAnimation(f, f.spriteId, { fps: 10 });
|
||||||
|
const behavior = makeBehavior(f, 'Logic', []);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||||
|
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 });
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user