Let animation keys ease with Sine to Bounce in, out or in-out
A key could only ease quadratically, which makes quick moves like a sword swing feel soft. Animators know the Penner curves from CSS, GSAP and Unity, so a key now offers all of them. - Ten curves (Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce), each in, out and in-out, next to Linear and Step. - The inspector shows two fields instead of one long list: Easing, and Direction only when a curve is picked. Switching the curve keeps the direction. Coming from Linear or Step starts at in-out. - Old projects keep working. ease-in/out/in-out now read as Quad and are still saved under that name. New curves save as cubic-out, back-in-out and so on. - Back and Elastic overshoot on purpose. A number property with min or max is clamped in play and preview, so a Back in from scale 0 does not flip the sprite. trackWriteAt now takes the property definition, and animatedKind* is renamed to animatedDef*. - The timeline marker still shows only the direction. - In-out is built for every curve from its in-curve and its mirror. easings.net uses own constants for Back and Elastic in-out, so our Back in-out winds up about 5 % instead of 10 %. Tests: - ./check.sh: 165 files, 2413 tests, CHECK EXIT 0. - 12 new unit tests in tests/core/easing-curves.test.ts: round trip of all 30 values, start/end/midpoint of every curve, reference values, only Back and Elastic leave 0..1, clamping in play and preview, unknown values are rejected. - e2e 25: ALL PASSED, 28 checks. New 25b-ii (3 checks) clicks both fields and undoes the four choices. Check sum 1393 -> 1396. - Counter-checks, each fails its test: no clamping (2), Quad saved as quad-* (2), mirrored in-out wrong (2), curve lookup with `in` instead of Object.hasOwn (1), no Direction field (e2e 25). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94749b4f3e
commit
147f8ffbea
9 files changed
+500
-74
No files matched your search
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The easing curves of animation keys: ten curves, each in, out and in-out. Quad keeps the stored
|
||||
* name it had as the only ease (`ease-in`), so older projects play as before.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AddAnimationTrackCommand,
|
||||
SetAnimationKeyCommand,
|
||||
SetAnimationRootCommand,
|
||||
SetKeyInterpolationCommand,
|
||||
} from '../../src/core/commands/animationCommands';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import {
|
||||
EASING_DIRECTIONS,
|
||||
EASING_SHAPES,
|
||||
easedShare,
|
||||
easingOf,
|
||||
easingValue,
|
||||
isKeyInterpolation,
|
||||
PLACED_ROOT_ID,
|
||||
playAnimation,
|
||||
trackValueAt,
|
||||
trackWriteAt,
|
||||
} from '../../src/core/model/animation';
|
||||
import { previewHolder } from '../../src/core/model/animationPreview';
|
||||
import type { AnimationTrack, KeyInterpolation } from '../../src/core/model/Asset';
|
||||
import { findAssetOf } from '../../src/core/model/Project';
|
||||
import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem';
|
||||
import { World } from '../../src/runtime/World';
|
||||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
const CURVES = EASING_SHAPES.filter((s) => s.value !== 'linear' && s.value !== 'step').map((s) => s.value);
|
||||
const ALL = CURVES.flatMap((c) => EASING_DIRECTIONS.map((d) => easingValue(c, d.value)));
|
||||
|
||||
describe('easing curves', () => {
|
||||
it('offers ten curves in three directions, and quad keeps its old stored names', () => {
|
||||
expect(CURVES).toEqual([
|
||||
'sine',
|
||||
'quad',
|
||||
'cubic',
|
||||
'quart',
|
||||
'quint',
|
||||
'expo',
|
||||
'circ',
|
||||
'back',
|
||||
'elastic',
|
||||
'bounce',
|
||||
]);
|
||||
expect(ALL).toHaveLength(30);
|
||||
expect(new Set(ALL).size).toBe(30);
|
||||
expect(easingValue('quad', 'in')).toBe('ease-in');
|
||||
expect(easingValue('quad', 'in-out')).toBe('ease-in-out');
|
||||
expect(easingValue('cubic', 'in-out')).toBe('cubic-in-out');
|
||||
expect(easingValue('step', 'out')).toBe('step');
|
||||
expect(easingValue('back')).toBe('back-in-out');
|
||||
});
|
||||
|
||||
it('splits every stored name back into its two fields', () => {
|
||||
for (const value of ALL) {
|
||||
const { shape, direction } = easingOf(value);
|
||||
expect(easingValue(shape, direction)).toBe(value);
|
||||
expect(isKeyInterpolation(value)).toBe(true);
|
||||
}
|
||||
expect(easingOf('ease-out')).toEqual({ shape: 'quad', direction: 'out' });
|
||||
expect(easingOf('linear')).toEqual({ shape: 'linear' });
|
||||
expect(easingOf('step')).toEqual({ shape: 'step' });
|
||||
});
|
||||
|
||||
it('rejects what is no easing', () => {
|
||||
for (const value of [
|
||||
'quad-in',
|
||||
'bouncy',
|
||||
'cubic-sideways',
|
||||
'toString-in',
|
||||
'constructor-in',
|
||||
'ease',
|
||||
'cubic-in-',
|
||||
'',
|
||||
3,
|
||||
])
|
||||
expect(isKeyInterpolation(value)).toBe(false);
|
||||
});
|
||||
|
||||
it('starts every curve at 0 and ends it at 1', () => {
|
||||
for (const value of ALL) {
|
||||
expect(easedShare(value, 0)).toBeCloseTo(0, 9);
|
||||
expect(easedShare(value, 1)).toBeCloseTo(1, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('meets half way at half the time going both ways', () => {
|
||||
for (const curve of CURVES) expect(easedShare(easingValue(curve, 'in-out'), 0.5)).toBeCloseTo(0.5, 9);
|
||||
});
|
||||
|
||||
it('bends each curve the way its formula says', () => {
|
||||
const at = (value: KeyInterpolation, t = 0.5) => easedShare(value, t);
|
||||
expect(at('sine-in')).toBeCloseTo(1 - Math.SQRT1_2);
|
||||
expect(at('ease-in')).toBeCloseTo(0.25);
|
||||
expect(at('cubic-in')).toBeCloseTo(0.125);
|
||||
expect(at('quart-in')).toBeCloseTo(0.0625);
|
||||
expect(at('quint-in')).toBeCloseTo(0.03125);
|
||||
expect(at('expo-out')).toBeCloseTo(1 - 2 ** -5);
|
||||
expect(at('circ-in')).toBeCloseTo(1 - Math.sqrt(0.75));
|
||||
expect(at('bounce-out')).toBeCloseTo(0.765625);
|
||||
expect(at('bounce-in')).toBeCloseTo(1 - 0.765625);
|
||||
expect(at('cubic-out', 0.25)).toBeCloseTo(1 - 0.75 ** 3);
|
||||
expect(at('cubic-in-out', 0.25)).toBeCloseTo(0.0625);
|
||||
expect(at('cubic-in-out', 0.75)).toBeCloseTo(0.9375);
|
||||
});
|
||||
|
||||
it('lets back and elastic leave 0…1, and keeps the others inside', () => {
|
||||
const samples = (value: KeyInterpolation) =>
|
||||
Array.from({ length: 101 }, (_, i) => easedShare(value, i / 100));
|
||||
expect(Math.min(...samples('back-in'))).toBeCloseTo(-0.1002, 3);
|
||||
expect(Math.max(...samples('back-out'))).toBeCloseTo(1.1002, 3);
|
||||
expect(Math.max(...samples('elastic-out'))).toBeGreaterThan(1.3);
|
||||
expect(Math.min(...samples('elastic-in'))).toBeLessThan(-0.3);
|
||||
for (const curve of ['sine', 'quad', 'cubic', 'quart', 'quint', 'expo', 'circ', 'bounce'] as const)
|
||||
for (const d of EASING_DIRECTIONS) {
|
||||
const values = samples(easingValue(curve, d.value));
|
||||
expect(Math.min(...values)).toBeGreaterThanOrEqual(-1e-12);
|
||||
expect(Math.max(...values)).toBeLessThanOrEqual(1 + 1e-12);
|
||||
}
|
||||
});
|
||||
|
||||
it('eases a row between two keys', () => {
|
||||
const row = (interpolation: KeyInterpolation) => [
|
||||
{ frame: 0, value: 0, interpolation },
|
||||
{ frame: 10, value: 100 },
|
||||
];
|
||||
expect(trackValueAt(row('cubic-in'), 'number', 5)).toBeCloseTo(12.5);
|
||||
expect(trackValueAt(row('back-in'), 'number', 2)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an overshooting curve and the limits of a property', () => {
|
||||
const track = (interpolation: KeyInterpolation, relative = false): AnimationTrack => ({
|
||||
id: 'r',
|
||||
target: '',
|
||||
property: 'scale',
|
||||
relative,
|
||||
keys: [
|
||||
{ frame: 0, value: 0, interpolation },
|
||||
{ frame: 10, value: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
it('holds a number to min and max, and leaves a property without them free', () => {
|
||||
const limited = { kind: 'number', min: 0, max: 1 };
|
||||
expect(trackWriteAt(track('back-in'), limited, 2, undefined)).toBe(0);
|
||||
expect(trackWriteAt(track('back-out'), limited, 8, undefined)).toBe(1);
|
||||
expect(trackWriteAt(track('back-in'), { kind: 'number' }, 2, undefined)).toBeLessThan(0);
|
||||
expect(trackWriteAt(track('back-out'), { kind: 'number' }, 8, undefined)).toBeGreaterThan(1);
|
||||
expect(trackWriteAt(track('back-out', true), limited, 8, 0.5)).toBe(1);
|
||||
});
|
||||
|
||||
it('holds a colour to its channels', () => {
|
||||
const colours = [
|
||||
{ frame: 0, value: 0x000000, interpolation: 'back-out' as const },
|
||||
{ frame: 10, value: 0xff0000 },
|
||||
];
|
||||
expect(trackValueAt(colours, 'color', 8)).toBe(0xff0000);
|
||||
});
|
||||
|
||||
it('never gives a sprite a negative scale, in play and in the preview', () => {
|
||||
const f = makeSpriteScene();
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const create = new CreateAssetCommand('animation', 'Pop', { fps: 10, playback: 'once' });
|
||||
f.stack.execute(create);
|
||||
f.stack.execute(new SetAnimationRootCommand(create.asset.id, f.spriteId));
|
||||
const row = new AddAnimationTrackCommand(create.asset.id, '', 'scale');
|
||||
f.stack.execute(row);
|
||||
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0));
|
||||
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 10, 1));
|
||||
f.stack.execute(
|
||||
new SetKeyInterpolationCommand(create.asset.id, [{ trackId: row.trackId, frame: 0 }], 'back-in'),
|
||||
);
|
||||
|
||||
const world = World.instantiate(f.project, f.sceneId);
|
||||
world.addSystem(new AnimationSystem());
|
||||
const sprite = world.findByObjectId(box)!;
|
||||
playAnimation(sprite, create.asset.id);
|
||||
const scales: number[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
world.tick(0.1);
|
||||
scales.push(Number(sprite.props.scale));
|
||||
}
|
||||
expect(Math.min(...scales)).toBe(0);
|
||||
expect(scales.at(-1)).toBe(1);
|
||||
|
||||
const walk = findAssetOf(f.project, create.asset.id, 'animation')!;
|
||||
const root = previewHolder(f.project, walk, 2)!.objects.find((o) => o.id === PLACED_ROOT_ID)!;
|
||||
expect(root.overrides.scale).toBe(0);
|
||||
});
|
||||
|
||||
it('takes a new curve as a key easing, keeps it through a round trip, and refuses a made-up one', () => {
|
||||
const f = makeSpriteScene();
|
||||
const create = new CreateAssetCommand('animation', 'Walk', {});
|
||||
f.stack.execute(create);
|
||||
f.stack.execute(new SetAnimationRootCommand(create.asset.id, f.spriteId));
|
||||
const row = new AddAnimationTrackCommand(create.asset.id, '', 'x');
|
||||
f.stack.execute(row);
|
||||
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0));
|
||||
const at = [{ trackId: row.trackId, frame: 0 }];
|
||||
f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, 'elastic-in-out'));
|
||||
const walk = findAssetOf(f.project, create.asset.id, 'animation')!;
|
||||
expect(walk.tracks[0]!.keys[0]!.interpolation).toBe('elastic-in-out');
|
||||
expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk);
|
||||
expect(() =>
|
||||
f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, 'quad-in' as KeyInterpolation)),
|
||||
).toThrow(CommandError);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user