Keys carry an easing for the span to the next key: linear, step, ease in, ease out, ease in-out (#101)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c92a9478f
commit
b278605c53
7 files changed
+331
-27
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
RetargetAnimationTrackCommand,
|
||||
SetAnimationKeyCommand,
|
||||
SetAnimationRootCommand,
|
||||
SetKeyInterpolationCommand,
|
||||
SetTrackRelativeCommand,
|
||||
} from '../../src/core/commands/animationCommands';
|
||||
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
@@ -26,7 +27,13 @@ import {
|
||||
stopAnimation,
|
||||
trackValueAt,
|
||||
} from '../../src/core/model/animation';
|
||||
import type { AnimationAsset, AnimationKey, ContainerAsset } from '../../src/core/model/Asset';
|
||||
import { PREVIEW_ROOT_ID, previewHolder } from '../../src/core/model/animationPreview';
|
||||
import type {
|
||||
AnimationAsset,
|
||||
AnimationKey,
|
||||
ContainerAsset,
|
||||
KeyInterpolation,
|
||||
} from '../../src/core/model/Asset';
|
||||
import { instanceChildId } from '../../src/core/model/instance';
|
||||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||
import { animationsUsing } from '../../src/core/model/usages';
|
||||
@@ -152,6 +159,128 @@ describe('the value of a row', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('easing per key (#101)', () => {
|
||||
const eased = (interpolation: KeyInterpolation | undefined, frame: number, reverse = false) =>
|
||||
trackValueAt(
|
||||
[
|
||||
{ frame: 0, value: 0, interpolation },
|
||||
{ frame: 10, value: 100 },
|
||||
],
|
||||
'number',
|
||||
frame,
|
||||
reverse,
|
||||
);
|
||||
|
||||
it('bends the way to the next key: ease-in lags at half the time, ease-out leads, ease-in-out meets it', () => {
|
||||
expect(eased(undefined, 5)).toBe(50);
|
||||
expect(eased('linear', 5)).toBe(50);
|
||||
expect(eased('ease-in', 5)).toBeCloseTo(25);
|
||||
expect(eased('ease-out', 5)).toBeCloseTo(75);
|
||||
expect(eased('ease-in-out', 5)).toBeCloseTo(50);
|
||||
expect(eased('ease-in-out', 2.5)).toBeCloseTo(12.5);
|
||||
expect(eased('ease-in-out', 7.5)).toBeCloseTo(87.5);
|
||||
for (const interpolation of ['ease-in', 'ease-out', 'ease-in-out'] as const) {
|
||||
expect(eased(interpolation, 0)).toBe(0);
|
||||
expect(eased(interpolation, 10)).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds a step key until the next one, and the one above on the way back', () => {
|
||||
expect(eased('step', 9.9)).toBe(0);
|
||||
expect(eased('step', 10)).toBe(100);
|
||||
expect(eased('step', 5, true)).toBe(100);
|
||||
});
|
||||
|
||||
it("takes the earlier key's easing for each stretch, and eases a colour over its channels", () => {
|
||||
const row: AnimationKey[] = [
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 10, value: 100, interpolation: 'ease-in' },
|
||||
{ frame: 20, value: 200 },
|
||||
];
|
||||
expect(trackValueAt(row, 'number', 5)).toBe(50);
|
||||
expect(trackValueAt(row, 'number', 15)).toBeCloseTo(125);
|
||||
const colours: AnimationKey[] = [
|
||||
{ frame: 0, value: 0x000000, interpolation: 'ease-out' },
|
||||
{ frame: 10, value: 0xff0000 },
|
||||
];
|
||||
expect(trackValueAt(colours, 'color', 5)).toBe(0xbf0000);
|
||||
});
|
||||
|
||||
it('sets the easing of picked keys on several rows, skips a row that cannot blend, and undoes', () => {
|
||||
const f = makeSpriteScene();
|
||||
const walk = addAnimation(f, f.spriteId);
|
||||
const x = addRow(f, walk, 'x', [
|
||||
[0, 0],
|
||||
[10, 100],
|
||||
]);
|
||||
const tint = addRow(f, walk, 'color', [[0, 0]]);
|
||||
const shown = addRow(f, walk, 'enabled', [[0, true]]);
|
||||
f.stack.execute(
|
||||
new SetKeyInterpolationCommand(
|
||||
walk.id,
|
||||
[
|
||||
{ trackId: x, frame: 0 },
|
||||
{ trackId: tint, frame: 0 },
|
||||
{ trackId: shown, frame: 0 },
|
||||
],
|
||||
'ease-out',
|
||||
),
|
||||
);
|
||||
expect(rowOf(walk, x).keys[0]).toEqual({ frame: 0, value: 0, interpolation: 'ease-out' });
|
||||
expect(rowOf(walk, tint).keys[0]!.interpolation).toBe('ease-out');
|
||||
expect(rowOf(walk, shown).keys[0]).toEqual({ frame: 0, value: true });
|
||||
expect(() =>
|
||||
f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: shown, frame: 0 }], 'step')),
|
||||
).toThrow(CommandError);
|
||||
|
||||
// A new value and a move keep the easing; linear is stored as no field.
|
||||
f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0, 30));
|
||||
f.stack.execute(new MoveAnimationKeysCommand(walk.id, [{ trackId: x, frame: 0 }], 2));
|
||||
expect(rowOf(walk, x).keys[0]).toEqual({ frame: 2, value: 30, interpolation: 'ease-out' });
|
||||
f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 2 }], 'linear'));
|
||||
expect(rowOf(walk, x).keys[0]).not.toHaveProperty('interpolation');
|
||||
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [10, 100]));
|
||||
expect(rowOf(walk, tint).keys).toEqual(keys([0, 0]));
|
||||
});
|
||||
|
||||
it('keeps the easing through a round trip, and drops linear and what is no easing', () => {
|
||||
const f = makeSpriteScene();
|
||||
const walk = addAnimation(f, f.spriteId);
|
||||
const x = addRow(f, walk, 'x', [
|
||||
[0, 0],
|
||||
[10, 100],
|
||||
]);
|
||||
f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 0 }], 'ease-in-out'));
|
||||
expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk);
|
||||
|
||||
const raw = structuredClone(f.project) as unknown as { assets: Record<string, unknown>[] };
|
||||
const stored = raw.assets.find((a) => a.id === walk.id)!;
|
||||
stored.tracks = [
|
||||
{
|
||||
id: 'r1',
|
||||
target: '',
|
||||
property: 'x',
|
||||
keys: [
|
||||
{ frame: 0, value: 0, interpolation: 'linear' },
|
||||
{ frame: 1, value: 1, interpolation: 'bouncy' },
|
||||
{ frame: 2, value: 2, interpolation: 'step' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!;
|
||||
expect(coerced.tracks[0]!.keys).toEqual([
|
||||
{ frame: 0, value: 0 },
|
||||
{ frame: 1, value: 1 },
|
||||
{ frame: 2, value: 2, interpolation: 'step' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editing the timeline', () => {
|
||||
it('builds only over a sprite, text, container or scene', () => {
|
||||
const f = makeSpriteScene();
|
||||
@@ -317,6 +446,18 @@ describe('the AnimationSystem', () => {
|
||||
expect(sprite.x).toBeCloseTo(55);
|
||||
});
|
||||
|
||||
it('eases like the preview in the tab shows it (#101)', () => {
|
||||
const { f, walk, row, sprite, frame } = slide();
|
||||
f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: row, frame: 0 }], 'ease-in'));
|
||||
playAnimation(sprite, walk.id);
|
||||
frame(0.05);
|
||||
frame(0.45);
|
||||
const previewed = previewHolder(f.project, findAssetOf(f.project, walk.id, 'animation')!, 4.5)!;
|
||||
const root = previewed.objects.find((o) => o.id === PREVIEW_ROOT_ID)!;
|
||||
expect(sprite.x).toBeCloseTo(22.5);
|
||||
expect(root.position.x).toBeCloseTo(sprite.x);
|
||||
});
|
||||
|
||||
it('holds the last value when a once animation ends, and reports it exactly once', () => {
|
||||
const { walk, box, f } = slide({ playback: 'once' });
|
||||
const world = World.instantiate(f.project, f.sceneId);
|
||||
|
||||
Reference in new issue
Block a user