Files
feuerwerk/tests/editor/curve-view.test.ts
T
adminandClaude Opus 5.5 393b8ec7f9
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s
Build your own easing curve and use it on keys and Lerp (#247)
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>
2026-09-27 15:58:05 +02:00

90 lines
3.5 KiB
TypeScript

/**
* The Curve tab's rules (#247): the curve is laid onto a picture for the vector art gestures and read
* back, a moved point stays between its neighbours, the ends move only up and down, and a press on
* free space puts a point where it falls in `t`.
*/
import { describe, expect, it } from 'vitest';
import type { VectorPoint } from '../../src/core/model/Asset';
import {
CURVE_SCALE,
curveFrame,
curveToImage,
imageToCurve,
isCurveEnd,
previewShare,
withCurvePointAt,
withCurvePointMoved,
} from '../../src/editor/curve/curveView';
const CURVE: VectorPoint[] = [
{ x: 0, y: 0, out: { x: 0.1, y: 0.2 } },
{ x: 0.5, y: 1.25, in: { x: -0.2, y: 0 }, out: { x: 0.2, y: 0 }, mode: 'smooth' },
{ x: 1, y: 1 },
];
describe('the curve on the picture', () => {
it('lays t to the right and v upwards, and reads it back unchanged', () => {
const ring = curveToImage(CURVE);
expect(ring.closed).toBe(false);
expect(ring.points[0]).toMatchObject({
x: 0,
y: CURVE_SCALE,
out: { x: 0.1 * CURVE_SCALE, y: -0.2 * CURVE_SCALE },
});
expect(ring.points[1]).toMatchObject({ x: CURVE_SCALE / 2, y: -0.25 * CURVE_SCALE, mode: 'smooth' });
expect(imageToCurve(ring)).toEqual(CURVE);
});
it('frames 0…1 in t and everything the curve reaches in v', () => {
const frame = curveFrame(CURVE);
expect(frame.x).toBeLessThan(0);
expect(frame.x + frame.w).toBeGreaterThan(CURVE_SCALE);
// The top of the frame lies above v = 1.25, the bottom below v = 0.
expect(frame.y).toBeLessThan(-0.25 * CURVE_SCALE);
expect(frame.y + frame.h).toBeGreaterThan(CURVE_SCALE);
});
});
describe('moving and putting down points', () => {
const ring = curveToImage(CURVE);
it('keeps a moved point between its neighbours in t', () => {
const right = withCurvePointMoved(ring, 1, { x: 900, y: 10 });
expect(right.points[1]).toMatchObject({ x: CURVE_SCALE, y: 10 });
const left = withCurvePointMoved(ring, 1, { x: -50, y: 10 });
expect(left.points[1]!.x).toBe(0);
expect(withCurvePointMoved(ring, 1, { x: 64, y: 20 }).points[1]).toMatchObject({ x: 64, y: 20 });
// Handles ride along: they are offsets.
expect(right.points[1]!.out).toEqual(ring.points[1]!.out);
});
it('moves the two ends only up and down', () => {
expect(withCurvePointMoved(ring, 0, { x: 40, y: 30 }).points[0]).toMatchObject({ x: 0, y: 30 });
expect(withCurvePointMoved(ring, 2, { x: 10, y: 30 }).points[2]).toMatchObject({ x: CURVE_SCALE, y: 30 });
});
it('puts a point on free space where it falls in t, and none outside the curve', () => {
const put = withCurvePointAt(ring, { x: 200, y: 40 })!;
expect(put.index).toBe(2);
expect(put.ring.points.map((p) => p.x)).toEqual([0, CURVE_SCALE / 2, 200, CURVE_SCALE]);
expect(put.ring.points[2]).toEqual({ x: 200, y: 40 });
expect(withCurvePointAt(ring, { x: 20, y: 0 })!.index).toBe(1);
expect(withCurvePointAt(ring, { x: -1, y: 0 })).toBeNull();
expect(withCurvePointAt(ring, { x: CURVE_SCALE + 1, y: 0 })).toBeNull();
expect(withCurvePointAt(ring, { x: 0, y: 0 })).toBeNull();
});
it('knows the ends', () => {
expect([0, 1, 2].map((i) => isCurveEnd(ring, i))).toEqual([true, false, true]);
});
});
describe('the preview', () => {
it('climbs through t for a second, rests at the end, then starts over', () => {
expect(previewShare(0)).toBe(0);
expect(previewShare(0.25)).toBeCloseTo(0.25, 12);
expect(previewShare(1.2)).toBe(1);
expect(previewShare(1.75)).toBeCloseTo(0.25, 12);
});
});