Keys carry an easing for the span to the next key: linear, step, ease in, ease out, ease in-out (#101)
game-maker / gate (push) Successful in 1m32s
game-maker / suite (push) Successful in 5m36s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 07:27:44 +02:00
1 parent 4c92a9478f
commit b278605c53
7 files changed
+331 -27

No files matched your search

+142 -1
View File
@@ -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);