Animations blend out on Stop and at the end of Once (reset), and Play can restart a running animation (#106)
Stop animation gets a blend (s) port that fades the animation back to what lies underneath. Play animation gets blend out (s) for Once (reset) and a restart port that starts a running, paused or fading animation from frame 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a377d9239
commit
195fc94e08
4 files changed
+369
-29
No files matched your search
@@ -703,6 +703,189 @@ describe('blending into an animation that takes over (#106)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fading an animation out (#106)', () => {
|
||||
it('blends a once-reset pass back to where it started, while another animation runs on', () => {
|
||||
const { f, walk, box } = slide({ playback: 'once-reset' });
|
||||
const bob = addAnimation(f, f.spriteId, { fps: 10, playback: 'pingpong' }, 'Bob');
|
||||
addRow(f, bob, 'y', [
|
||||
[0, 0],
|
||||
[4, 40],
|
||||
]);
|
||||
const world = World.instantiate(f.project, f.sceneId);
|
||||
const system = new AnimationSystem();
|
||||
world.addSystem(system);
|
||||
const sprite = world.findByObjectId(box)!;
|
||||
sprite.x = 10;
|
||||
playAnimation(sprite, bob.id);
|
||||
playAnimation(sprite, walk.id, 0, 0.4);
|
||||
const reports: number[] = [];
|
||||
const tick = () => {
|
||||
world.tick(0.1);
|
||||
reports.push(system.finished.length);
|
||||
};
|
||||
for (let i = 0; i < 11; i++) tick();
|
||||
expect(sprite.x).toBe(90);
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
tick();
|
||||
xs.push(sprite.x);
|
||||
ys.push(sprite.y);
|
||||
}
|
||||
// From the last key's 90 back to the starting 10 by 1/4 steps; the bob turns on at 40 meanwhile.
|
||||
[70, 50, 30, 10].forEach((x, i) => expect(xs[i]).toBeCloseTo(x));
|
||||
[30, 40, 30, 20].forEach((y, i) => expect(ys[i]).toBeCloseTo(y));
|
||||
expect(reports.filter((n) => n > 0)).toEqual([1]);
|
||||
expect(sprite.animations?.map((p) => p.animationId)).toEqual([bob.id]);
|
||||
});
|
||||
|
||||
it('fades a stopped animation back to what an earlier animation writes there, live', () => {
|
||||
const { f, walk, sprite, frame } = slide();
|
||||
const duck = addAnimation(f, f.spriteId, { fps: 10 }, 'Duck');
|
||||
addRow(f, duck, 'x', [[0, -100]]);
|
||||
playAnimation(sprite, walk.id);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, duck.id);
|
||||
frame(0.1);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBe(-100);
|
||||
stopAnimation(sprite, duck.id, 0.4);
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
frame(0.1);
|
||||
seen.push(sprite.x);
|
||||
}
|
||||
// The walk runs on at 30, 40, 50, 60, 70; the duck gives way by 1/4, 1/2, 3/4, all – then is gone.
|
||||
[-67.5, -30, 12.5, 60, 70].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||
expect(sprite.animations?.map((p) => p.animationId)).toEqual([walk.id]);
|
||||
});
|
||||
|
||||
it('fades back to the starting value with nothing below, switches what cannot blend at once, also paused', () => {
|
||||
const { f, walk, sprite, frame } = slide();
|
||||
addRow(f, walk, 'mirrorX', [[0, true]]);
|
||||
sprite.x = 100;
|
||||
playAnimation(sprite, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.2);
|
||||
expect(sprite.props.mirrorX).toBe(true);
|
||||
pauseAnimation(sprite, walk.id);
|
||||
stopAnimation(sprite, walk.id, 0.2);
|
||||
frame(0.1);
|
||||
// Held at 20 by the pause, half way back to 100.
|
||||
expect(sprite.x).toBeCloseTo(60);
|
||||
expect(sprite.props.mirrorX).toBe(false);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(100);
|
||||
expect(sprite.animations).toEqual([]);
|
||||
});
|
||||
|
||||
it('ends one that has not reached anything yet at once, blend or not', () => {
|
||||
const { walk, sprite } = slide();
|
||||
playAnimation(sprite, walk.id);
|
||||
stopAnimation(sprite, walk.id, 0.5);
|
||||
expect(sprite.animations).toEqual([]);
|
||||
});
|
||||
|
||||
it('turns a fade round without a jump when played again', () => {
|
||||
const { walk, sprite, frame } = slide();
|
||||
sprite.x = 100;
|
||||
playAnimation(sprite, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.2);
|
||||
stopAnimation(sprite, walk.id, 0.8);
|
||||
frame(0.1);
|
||||
// The walk at 30, 1/8 of the way back to 100.
|
||||
expect(sprite.x).toBeCloseTo(38.75);
|
||||
playAnimation(sprite, walk.id, 1.6);
|
||||
expect(sprite.animations?.[0]?.fade).toBeUndefined();
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
frame(0.1);
|
||||
seen.push(sprite.x);
|
||||
}
|
||||
// Blending in from 100 it stands at 7/8 – where the fade left it – and runs on from there: walk 40
|
||||
// at 15/16, walk 50 all in.
|
||||
[43.75, 50].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||
});
|
||||
|
||||
it('starts a once-reset pass over when played again while it fades back, from where it stands', () => {
|
||||
const { walk, sprite, frame } = slide({ playback: 'once-reset' });
|
||||
sprite.x = 10;
|
||||
playAnimation(sprite, walk.id, 0, 0.4);
|
||||
for (let i = 0; i < 13; i++) frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(50);
|
||||
playAnimation(sprite, walk.id, 0.2, 0.4);
|
||||
const player = sprite.animations?.[0];
|
||||
expect(player).toMatchObject({ time: 0, blendOut: 0.4 });
|
||||
expect(player?.fade).toBeUndefined();
|
||||
frame(0.1);
|
||||
// Frame 1 of the walk (10), blended in half way from the 50 the fade had reached.
|
||||
expect(sprite.x).toBeCloseTo(30);
|
||||
// Relative to the same start: it still goes back to 10 at the end.
|
||||
expect(player?.bases).toEqual({ [`${sprite.id} x`]: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('restarting an animation that already runs', () => {
|
||||
/** `walk` played for 0.4 s: frame 4, x 40. */
|
||||
function walked() {
|
||||
const s = slide();
|
||||
s.sprite.x = 5;
|
||||
playAnimation(s.sprite, s.walk.id);
|
||||
s.frame(0.1);
|
||||
s.frame(0.2);
|
||||
s.frame(0.2);
|
||||
expect(s.sprite.x).toBeCloseTo(40);
|
||||
return s;
|
||||
}
|
||||
|
||||
it('starts a running one over from its first frame, keeping its starting values', () => {
|
||||
const { walk, sprite, frame } = walked();
|
||||
const bases = { ...sprite.animations?.[0]?.bases };
|
||||
playAnimation(sprite, walk.id);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(50);
|
||||
playAnimation(sprite, walk.id, 0, 0, true);
|
||||
expect(sprite.animations?.[0]?.time).toBe(0);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(10);
|
||||
expect(sprite.animations?.[0]?.bases).toEqual(bases);
|
||||
});
|
||||
|
||||
it('starts a paused one over and lets it run', () => {
|
||||
const { walk, sprite, frame } = walked();
|
||||
pauseAnimation(sprite, walk.id);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, walk.id, 0, 0, true);
|
||||
frame(0.1);
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it('blends the new start in from where it stands', () => {
|
||||
const { walk, sprite, frame } = walked();
|
||||
playAnimation(sprite, walk.id, 0.2, 0, true);
|
||||
const seen: number[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
frame(0.1);
|
||||
seen.push(sprite.x);
|
||||
}
|
||||
// Walk 10 half blended in from 40, then walk 20 all in.
|
||||
[25, 20].forEach((x, i) => expect(seen[i]).toBeCloseTo(x));
|
||||
});
|
||||
|
||||
it('starts a fading one over instead of turning it round', () => {
|
||||
const { walk, sprite, frame } = walked();
|
||||
stopAnimation(sprite, walk.id, 0.8);
|
||||
frame(0.1);
|
||||
playAnimation(sprite, walk.id, 0, 0, true);
|
||||
expect(sprite.animations?.[0]).toMatchObject({ time: 0, playing: true });
|
||||
expect(sprite.animations?.[0]?.fade).toBeUndefined();
|
||||
frame(0.1);
|
||||
expect(sprite.x).toBeCloseTo(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reach of a played row (#109)', () => {
|
||||
/** A sprite packed into `Inner`, `Inner` packed into `Outer`, and `Outer` placed in the scene. */
|
||||
function nested() {
|
||||
@@ -942,13 +1125,76 @@ 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: 0.6 });
|
||||
const play = edit.addNode('PlayAnimation', { animation: walk.id, blend: 0.6, blendOut: 0.3 });
|
||||
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?.seconds).toBe(0.6);
|
||||
expect(world.findByObjectId(box)!.animations?.[0]).toMatchObject({
|
||||
blend: { seconds: 0.6 },
|
||||
blendOut: 0.3,
|
||||
});
|
||||
});
|
||||
|
||||
it('restarts the animation every time when Restart is on, and lets it run on when off', () => {
|
||||
const run = (restart: boolean) => {
|
||||
const f = makeSpriteScene();
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const walk = addAnimation(f, f.spriteId, { fps: 10 });
|
||||
addRow(f, walk, 'x', [
|
||||
[0, 0],
|
||||
[9, 90],
|
||||
]);
|
||||
const behavior = makeBehavior(f, 'Logic', []);
|
||||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const owner = edit.addNode('Owner');
|
||||
const play = edit.addNode('PlayAnimation', { animation: walk.id, restart });
|
||||
edit.wire([
|
||||
[update, 'exec', play, 'exec'],
|
||||
[owner, 'object', play, 'object'],
|
||||
]);
|
||||
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
|
||||
for (let i = 0; i < 5; i++) frame(0.1);
|
||||
return world.findByObjectId(box)!.animations?.[0]?.time;
|
||||
};
|
||||
expect(run(false)).toBeGreaterThan(0.3);
|
||||
expect(run(true)).toBeLessThan(0.15);
|
||||
});
|
||||
|
||||
it('passes Blend on to Stop animation, which fades the player out (#106)', () => {
|
||||
const f = makeSpriteScene();
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const walk = addAnimation(f, f.spriteId, { fps: 10 });
|
||||
addRow(f, walk, 'x', [
|
||||
[0, 0],
|
||||
[9, 90],
|
||||
]);
|
||||
const cue = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' }, 'Cue');
|
||||
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 playWalk = edit.addNode('PlayAnimation', { animation: walk.id });
|
||||
const playCue = edit.addNode('PlayAnimation', { animation: cue.id });
|
||||
const finished = edit.addNode('OnAnimationFinished', { animation: cue.id });
|
||||
const stop = edit.addNode('StopAnimation', { animation: walk.id, blend: 0.5 });
|
||||
edit.wire([
|
||||
[start, 'exec', playWalk, 'exec'],
|
||||
[playWalk, 'exec', playCue, 'exec'],
|
||||
[owner, 'object', playWalk, 'object'],
|
||||
[owner, 'object', playCue, 'object'],
|
||||
[finished, 'exec', stop, 'exec'],
|
||||
[owner, 'object', stop, 'object'],
|
||||
]);
|
||||
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
|
||||
for (let i = 0; i < 3; i++) frame(0.1);
|
||||
expect(world.findByObjectId(box)!.animations).toMatchObject([
|
||||
{ animationId: walk.id, fade: { seconds: 0.5 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user