Files
feuerwerk/tests/core/easing-curves.test.ts
T
adminandClaude Opus 5.5 297fca3e94 Show Direction and Curve of a node only where they matter
An animation key shows Direction only for one of the ten curves and
the Curve row only for Curve. Lerp and Set sound FX parameters hid
them on the node body, but the inspector still offered both rows for
every easing, so a linear Lerp had a Direction to pick that did
nothing.

- A node definition can name `idleParams`: settings that do nothing
  at the current values. The node body and the inspector both leave
  them out.
- `hiddenParams` stays as it was. It only trims the node body, the
  inspector keeps those rows (the Order of an event, the mode of
  Find), so it could not take this job.
- Lerp and Set sound FX parameters use `idleEasing` (formerly
  `hiddenEasing`) for it.
- The idle settings are part of the node signature of the inspector.
  Picking an easing that needs Direction or Curve rebuilds the
  section, and the row appears.

Tests:
- easing-curves: 14 tests (+1), node-catalog: 381, green
- e2e 06 (149 checks, green) checks that a new linear Lerp has no
  Direction and no Curve row in the inspector. Picking Cubic brings
  the Direction row, and Out is picked there as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:15:15 +02:00

237 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { requireNodeDef } from '../../src/core/commands/require';
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 { LERP } from '../../src/core/model/curve';
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';
// A Curve asset (#247) is the last entry: drawn by hand, not one of the ten, and without directions.
const CURVES = EASING_SHAPES.filter((s) => !['linear', 'step', 'curve'].includes(s.value)).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);
});
});
// Like a key, a node shows Direction and Curve only where they ease something – on its body and in
// the inspector alike.
describe('the easing settings of a node', () => {
for (const type of [LERP, 'SetSoundFxParameters']) {
it(`${type} leaves out what the easing does not use`, () => {
const idle = requireNodeDef(type).idleParams!;
expect(idle({})).toEqual(['direction', 'curve']);
expect(idle({ ease: 'linear', direction: 'in', curve: 'c' })).toEqual(['direction', 'curve']);
expect(idle({ ease: 'step' })).toEqual(['direction', 'curve']);
expect(idle({ ease: 'quad', direction: 'out' })).toEqual(['curve']);
expect(idle({ ease: 'elastic' })).toEqual(['curve']);
expect(idle({ ease: 'curve', direction: 'in' })).toEqual(['direction']);
expect(requireNodeDef(type).hiddenParams).toBeUndefined();
});
}
});