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>
90 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
});
|