The built-in eases cover the usual shapes, but a game often wants its own: a coin that bounces twice, a door that overshoots and settles. A new asset type holds such a curve, and keys and the Lerp node can pick it instead of a built-in ease. - New asset type "curve": points like in Vector art, x is t and y is the value. A new curve is the straight line from (0, 0) to (1, 1). - One pure evaluation in src/core/model/curve.ts serves the timeline preview, the running game and the Lerp node. - The curve always has one value per t. The end points stay at t 0 and 1, points cannot pass each other, and a handle that reaches past its neighbour is shortened along its direction. Loading a hand-edited file applies the same rules. - Curve tab: click on the line to insert a point that keeps the shape, click on free space to put one there, drag points and handles, double-click or Delete removes, Alt-click toggles round and sharp. Every step is one undo step. - Presets: all 30 built-in eases plus Linear, fitted with adaptive subdivision. Each stays within 0.4 % of the built-in ease. - Preview: a dot runs along the curve once a second, a second dot shows only the value. The Preview button stops it. - Animation keys get "Curve" under Easing with a curve picker. Lerp gets "Curve" under Ease, and its result may overshoot b. - A missing or unpicked curve runs linear. The inspector says so, the node shows it as a problem, and deleting a used curve warns with the number of keys and Lerp nodes. Tests: - ./check.sh: 180 files, 2612 tests, CHECK EXIT 0 - GM_STEPS=1-6,25,53: ALL PASSED, 309 checks - New e2e module 53 with 13 checks, all with the real mouse. Expected values come from its own Bezier evaluation. - Counter-checks bite: without Bezier evaluation 4 unit tests fail, without handle clamping 2. In the browser, a key that cannot find its curve, a click that sets no point and a handle drag that moves the point all turn checks red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/**
|
|
* Curve assets (#247): an easing drawn by hand. One function evaluates it for the tab, the animation
|
|
* player and the `Lerp` node; every stored curve is held to the rules first, so it has exactly one
|
|
* value for every `t`.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
AddAnimationTrackCommand,
|
|
SetAnimationKeyCommand,
|
|
SetAnimationRootCommand,
|
|
SetKeyInterpolationCommand,
|
|
} from '../../src/core/commands/animationCommands';
|
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
|
import { SetCurvePointsCommand } from '../../src/core/commands/curveCommands';
|
|
import {
|
|
easedShare,
|
|
easingOf,
|
|
easingValue,
|
|
isKeyInterpolation,
|
|
playAnimation,
|
|
trackValueAt,
|
|
} from '../../src/core/model/animation';
|
|
import type { VectorPoint } from '../../src/core/model/Asset';
|
|
import {
|
|
curveLookup,
|
|
curveUsages,
|
|
curveValue,
|
|
linearCurve,
|
|
sanitizedCurve,
|
|
} from '../../src/core/model/curve';
|
|
import { curvePresets, easeCurve } from '../../src/core/model/curvePresets';
|
|
import { findAssetOf } from '../../src/core/model/Project';
|
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
|
import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem';
|
|
import { World } from '../../src/runtime/World';
|
|
import { makeProject, makeSpriteScene, place, roundTrip } from '../helpers';
|
|
|
|
/** Quad in, exactly: a cubic whose `t` runs evenly and whose value is s². */
|
|
const QUAD_IN: VectorPoint[] = [
|
|
{ x: 0, y: 0, out: { x: 1 / 3, y: 0 } },
|
|
{ x: 1, y: 1, in: { x: -1 / 3, y: -2 / 3 } },
|
|
];
|
|
|
|
const steps = (count: number) => Array.from({ length: count + 1 }, (_, i) => i / count);
|
|
|
|
describe('evaluating a curve', () => {
|
|
it('reads a new curve as linear', () => {
|
|
for (const t of steps(20)) expect(curveValue(linearCurve(), t)).toBeCloseTo(t, 12);
|
|
});
|
|
|
|
it('solves a Bezier segment for t', () => {
|
|
for (const t of steps(16)) expect(curveValue(QUAD_IN, t)).toBeCloseTo(t * t, 9);
|
|
// CSS `ease-in-out`: symmetric about the middle, and rising all the way.
|
|
const cssEaseInOut: VectorPoint[] = [
|
|
{ x: 0, y: 0, out: { x: 0.42, y: 0 } },
|
|
{ x: 1, y: 1, in: { x: -0.42, y: 0 } },
|
|
];
|
|
expect(curveValue(cssEaseInOut, 0.5)).toBeCloseTo(0.5, 9);
|
|
expect(curveValue(cssEaseInOut, 0.25) + curveValue(cssEaseInOut, 0.75)).toBeCloseTo(1, 9);
|
|
const values = steps(100).map((t) => curveValue(cssEaseInOut, t));
|
|
for (let i = 1; i < values.length; i++) expect(values[i]!).toBeGreaterThanOrEqual(values[i - 1]!);
|
|
});
|
|
|
|
it('hits the ends exactly and holds t to 0…1', () => {
|
|
const back = easeCurve('back', 'out');
|
|
expect(curveValue(back, 0)).toBe(0);
|
|
expect(curveValue(back, 1)).toBe(1);
|
|
expect(curveValue(back, -3)).toBe(0);
|
|
expect(curveValue(back, 7)).toBe(1);
|
|
});
|
|
|
|
it('overshoots where the points go past 1', () => {
|
|
const overshoot: VectorPoint[] = [
|
|
{ x: 0, y: 0 },
|
|
{ x: 0.5, y: 1.5 },
|
|
{ x: 1, y: 1 },
|
|
];
|
|
expect(curveValue(overshoot, 0.25)).toBeCloseTo(0.75, 12);
|
|
expect(curveValue(overshoot, 0.5)).toBeCloseTo(1.5, 12);
|
|
expect(curveValue(overshoot, 0.75)).toBeCloseTo(1.25, 12);
|
|
expect(Math.max(...steps(100).map((t) => curveValue(easeCurve('back', 'out'), t)))).toBeGreaterThan(1.09);
|
|
});
|
|
|
|
it('keeps one value for every t even when the stored handles reach past a neighbour', () => {
|
|
const wild: VectorPoint[] = [
|
|
{ x: 0, y: 0, out: { x: 5, y: 1 } },
|
|
{ x: 1, y: 1, in: { x: -5, y: -1 } },
|
|
];
|
|
const values = steps(200).map((t) => curveValue(wild, t));
|
|
for (let i = 1; i < values.length; i++) expect(values[i]!).toBeGreaterThanOrEqual(values[i - 1]! - 1e-12);
|
|
});
|
|
});
|
|
|
|
describe('the rules a stored curve is held to', () => {
|
|
it('sorts the points, pins the ends to t 0 and 1 and keeps t in 0…1', () => {
|
|
const held = sanitizedCurve([
|
|
{ x: 0.9, y: 0.5 },
|
|
{ x: 0.2, y: 2 },
|
|
{ x: 1.4, y: 1 },
|
|
{ x: -0.3, y: 0.1 },
|
|
]);
|
|
expect(held.map((p) => [p.x, p.y])).toEqual([
|
|
[0, 0.1],
|
|
[0.2, 2],
|
|
[0.9, 0.5],
|
|
[1, 1],
|
|
]);
|
|
});
|
|
|
|
it('drops what is no number, and falls back to linear below two points', () => {
|
|
expect(
|
|
sanitizedCurve([
|
|
{ x: Number.NaN, y: 0 },
|
|
{ x: 0.5, y: 0.5 },
|
|
]),
|
|
).toEqual(linearCurve());
|
|
expect(sanitizedCurve([])).toEqual(linearCurve());
|
|
});
|
|
|
|
it('shortens a handle reaching past its neighbour along its own direction', () => {
|
|
const [first, last] = sanitizedCurve([
|
|
{ x: 0, y: 0, out: { x: 2, y: 1 } },
|
|
{ x: 1, y: 1, in: { x: -4, y: -2 } },
|
|
]);
|
|
expect(first!.out).toEqual({ x: 1, y: 0.5 });
|
|
expect(last!.in).toEqual({ x: -1, y: -0.5 });
|
|
});
|
|
|
|
it('turns a handle pointing backwards in t upright, and drops the ends outer handles', () => {
|
|
const [first, middle, last] = sanitizedCurve([
|
|
{ x: 0, y: 0, in: { x: -0.1, y: 0 }, out: { x: 0.1, y: 0.2 } },
|
|
{ x: 0.5, y: 0.5, in: { x: 0.2, y: 0.3 }, out: { x: -0.1, y: 0.4 }, mode: 'smooth' },
|
|
{ x: 1, y: 1, out: { x: 0.2, y: 0 } },
|
|
]);
|
|
expect(first!.in).toBeUndefined();
|
|
expect(middle!.in).toEqual({ x: 0, y: 0.3 });
|
|
expect(middle!.out).toEqual({ x: 0, y: 0.4 });
|
|
expect(middle!.mode).toBe('smooth');
|
|
expect(last!.out).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('a key and a Lerp that ease along a curve', () => {
|
|
it('stores the curve on the key and splits it back', () => {
|
|
expect(easingValue('curve', undefined, 'abc')).toBe('curve:abc');
|
|
expect(easingOf('curve:abc')).toEqual({ shape: 'curve', curve: 'abc' });
|
|
expect(isKeyInterpolation('curve:abc')).toBe(true);
|
|
expect(isKeyInterpolation('curve:')).toBe(true);
|
|
expect(easingOf('curve:')).toEqual({ shape: 'curve', curve: '' });
|
|
});
|
|
|
|
it('blends a row along the curve, and linearly once it is gone', () => {
|
|
const { project, stack } = makeProject();
|
|
const create = new CreateAssetCommand('curve', 'Quad');
|
|
stack.execute(create);
|
|
stack.execute(new SetCurvePointsCommand(create.asset.id, QUAD_IN));
|
|
const row = [
|
|
{ frame: 0, value: 0, interpolation: easingValue('curve', undefined, create.asset.id) },
|
|
{ frame: 10, value: 100 },
|
|
];
|
|
expect(trackValueAt(row, 'number', 5, false, curveLookup(project))).toBeCloseTo(25, 6);
|
|
expect(trackValueAt(row, 'number', 5)).toBeCloseTo(50, 9);
|
|
expect(easedShare('curve:gone', 0.3, curveLookup(project))).toBe(0.3);
|
|
expect(easedShare('curve:', 0.3, curveLookup(project))).toBe(0.3);
|
|
});
|
|
|
|
it('plays a key along the curve in the running game', () => {
|
|
const f = makeSpriteScene();
|
|
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
|
const curve = new CreateAssetCommand('curve', 'Quad');
|
|
f.stack.execute(curve);
|
|
f.stack.execute(new SetCurvePointsCommand(curve.asset.id, QUAD_IN));
|
|
const create = new CreateAssetCommand('animation', 'Slide', { 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, '', 'x');
|
|
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, 100));
|
|
const at = [{ trackId: row.trackId, frame: 0 }];
|
|
f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, `curve:${curve.asset.id}`));
|
|
|
|
const world = World.instantiate(f.project, f.sceneId);
|
|
world.addSystem(new AnimationSystem());
|
|
const sprite = world.findByObjectId(box)!;
|
|
playAnimation(sprite, create.asset.id);
|
|
// The first tick takes the starting values and shows frame 0; five more reach frame 5, t = 0.5.
|
|
world.tick(0.1);
|
|
for (let i = 0; i < 5; i++) world.tick(0.1);
|
|
expect(sprite.x).toBeCloseTo(25, 4);
|
|
});
|
|
|
|
it('counts the keys and Lerp nodes using a curve, for the warning before deleting it', () => {
|
|
const f = makeSpriteScene();
|
|
const curve = new CreateAssetCommand('curve', 'Pop');
|
|
f.stack.execute(curve);
|
|
const create = new CreateAssetCommand('animation', 'Slide', {});
|
|
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);
|
|
for (const frame of [0, 5, 10])
|
|
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, 0));
|
|
const at = [0, 5].map((frame) => ({ trackId: row.trackId, frame }));
|
|
f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, `curve:${curve.asset.id}`));
|
|
const behavior = new CreateAssetCommand('behavior', 'Mover');
|
|
f.stack.execute(behavior);
|
|
findAssetOf(f.project, behavior.asset.id, 'behavior')!.graph.nodes.push(
|
|
{ id: 'l1', type: 'Lerp', x: 0, y: 0, params: { ease: 'curve', curve: curve.asset.id } },
|
|
{ id: 'l2', type: 'Lerp', x: 0, y: 0, params: { ease: 'linear' } },
|
|
);
|
|
expect(curveUsages(f.project, curve.asset.id)).toEqual({ keys: 2, nodes: 1 });
|
|
});
|
|
});
|
|
|
|
describe('storing and editing a curve', () => {
|
|
it('starts as a straight line and keeps its points through a round trip', () => {
|
|
const { project, stack } = makeProject();
|
|
const create = new CreateAssetCommand('curve', 'Pop');
|
|
stack.execute(create);
|
|
expect(findAssetOf(project, create.asset.id, 'curve')!.points).toEqual(linearCurve());
|
|
stack.execute(new SetCurvePointsCommand(create.asset.id, easeCurve('elastic', 'out')));
|
|
const curve = findAssetOf(project, create.asset.id, 'curve')!;
|
|
expect(findAssetOf(roundTrip(project), create.asset.id, 'curve')).toEqual(curve);
|
|
});
|
|
|
|
it('holds a hand-written file to the rules on loading', () => {
|
|
const { project, stack } = makeProject();
|
|
const create = new CreateAssetCommand('curve', 'Pop');
|
|
stack.execute(create);
|
|
const raw = serializeProject(project) as { assets: { id: string; points?: unknown }[] };
|
|
raw.assets.find((a) => a.id === create.asset.id)!.points = [
|
|
{ x: 1, y: 1 },
|
|
{ x: 'far', y: 0 },
|
|
{ x: 0.4, y: 0.8, out: { x: 3, y: 0 } },
|
|
{ x: 0.2, y: 0 },
|
|
];
|
|
const loaded = findAssetOf(deserializeProject(raw), create.asset.id, 'curve')!;
|
|
expect(loaded.points).toEqual([
|
|
{ x: 0, y: 0 },
|
|
{ x: 0.4, y: 0.8, out: { x: 0.6, y: 0 } },
|
|
{ x: 1, y: 1 },
|
|
]);
|
|
});
|
|
|
|
it('writes the curve in one undo step, held to the rules, and takes it back', () => {
|
|
const { project, stack } = makeProject();
|
|
const create = new CreateAssetCommand('curve', 'Pop');
|
|
stack.execute(create);
|
|
const curve = () => findAssetOf(project, create.asset.id, 'curve')!;
|
|
stack.execute(
|
|
new SetCurvePointsCommand(create.asset.id, [
|
|
{ x: 0.3, y: 0 },
|
|
{ x: 0.6, y: 1.2 },
|
|
{ x: 0.8, y: 1 },
|
|
]),
|
|
);
|
|
expect(curve().points.map((p) => p.x)).toEqual([0, 0.6, 1]);
|
|
expect(SetCurvePointsCommand.changes(curve(), curve().points)).toBe(false);
|
|
stack.undo();
|
|
expect(curve().points).toEqual(linearCurve());
|
|
});
|
|
});
|
|
|
|
describe('the built-in eases as presets', () => {
|
|
it('traces every ease to within half a percent, and each is a legal curve as it is', () => {
|
|
const presets = curvePresets();
|
|
expect(presets).toHaveLength(31);
|
|
expect(presets[0]!.build()).toEqual(linearCurve());
|
|
for (const preset of presets.slice(1)) {
|
|
const points = preset.build();
|
|
expect(sanitizedCurve(points), preset.label).toEqual(points);
|
|
const [shape, direction] = preset.label.toLowerCase().split(' ') as [string, string];
|
|
const interpolation = easingValue(
|
|
shape === 'quad' ? 'quad' : (shape as never),
|
|
direction as 'in' | 'out' | 'in-out',
|
|
);
|
|
for (const t of steps(97))
|
|
expect(
|
|
Math.abs(curveValue(points, t) - easedShare(interpolation, t)),
|
|
`${preset.label} at ${t}`,
|
|
).toBeLessThan(0.005);
|
|
}
|
|
});
|
|
|
|
it('needs one segment for a quad, and more for what bounces', () => {
|
|
expect(easeCurve('quad', 'in')).toHaveLength(2);
|
|
expect(easeCurve('bounce', 'out').length).toBeGreaterThan(8);
|
|
});
|
|
});
|