Files
feuerwerk/tools/e2e/53-curves.mjs
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

329 lines
14 KiB
JavaScript
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.
import { edge, node } from '../lib/builders.mjs';
/**
* Curve assets (#247): made from the + menu and opened by its row, bent in the Curve tab with the
* real mouse – a point put down on free space, turned smooth through the header, a handle pulled –
* and then picked where a built-in ease is picked, on an animation key and on a `Lerp` node. The
* values those two produce are compared with the curve worked out here, by walking its Bezier
* segments, not with the editor's own solver.
*
* Everything it adds carries a `cv-` id or is the curve it made, and goes again at the end.
*/
/** The curve's value at `t`, from its segments walked finely and read off in between. */
function expectedAt(points, t) {
for (let i = 1; i < points.length; i++) {
const from = points[i - 1];
const to = points[i];
if (t > to.x) continue;
const c1 = { x: from.x + (from.out?.x ?? 0), y: from.y + (from.out?.y ?? 0) };
const c2 = { x: to.x + (to.in?.x ?? 0), y: to.y + (to.in?.y ?? 0) };
const at = (s, a, b, c, d) =>
(1 - s) ** 3 * a + 3 * (1 - s) ** 2 * s * b + 3 * (1 - s) * s * s * c + s ** 3 * d;
let previous = { x: from.x, y: from.y };
for (let step = 1; step <= 20000; step++) {
const s = step / 20000;
const here = { x: at(s, from.x, c1.x, c2.x, to.x), y: at(s, from.y, c1.y, c2.y, to.y) };
if (here.x >= t) {
const share = here.x > previous.x ? (t - previous.x) / (here.x - previous.x) : 1;
return previous.y + (here.y - previous.y) * share;
}
previous = here;
}
return to.y;
}
return points.at(-1).y;
}
export async function curves(t) {
const { check, json, evaluate, mutate, click, doubleClick, drag, settle, sleep, shot, showTab, undo } = t;
const inOverlay = async (text) => {
const box = await json('__gm.overlayRect()');
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
return (
hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null
);
};
const menuRow = async (label) => {
const rows = (await json('__gm.menuRows()')) ?? [];
const row = rows.find((r) => typeof r === 'string' && (r === label || r.endsWith(` ${label}`)));
return row ? inOverlay(row) : null;
};
const pointsOf = (id) => json(`__gm.curve.points(${JSON.stringify(id)})`);
// 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points.
await showTab('scene');
await t.clickText('+');
const offered = (await json('__gm.menuRows()')) ?? [];
const entry = await inOverlay('Curve');
check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered));
if (!entry) return;
await click(entry.x, entry.y);
await settle();
const curveId = (
await json("__gm.state.project.assets.filter((a) => a.type === 'curve').map((a) => a.id)")
).at(-1);
// "Curve" is the new asset's name, the Easing value and the row label at once – a name of its own
// keeps `inspectorField('Curve')` on the row.
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name = 'Swoop 53'`,
);
const row = await json(`__gm.assetRow(${JSON.stringify(curveId)})`);
await doubleClick(row.x, row.y);
await settle();
const fresh = await pointsOf(curveId);
check(
'a double-click on the new curve opens the Curve tab on a straight line from (0, 0) to (1, 1)',
(await json('__gm.curve.current()')) === curveId &&
JSON.stringify(fresh) ===
JSON.stringify([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
]) &&
(await json('__gm.curve.info()')) === '2 points',
JSON.stringify({ current: await json('__gm.curve.current()'), fresh }),
);
// 53b. A click on free space puts a point there, and it is the picked one.
const spot = await json('__gm.curve.at(0.5, 0.9)');
await click(spot.x, spot.y);
await settle();
const put = await pointsOf(curveId);
const middle = put?.[1];
check(
'a click on free space puts a point where it was clicked, between the ends, and picks it',
put?.length === 3 &&
Math.abs(middle.x - 0.5) < 0.01 &&
Math.abs(middle.y - 0.9) < 0.01 &&
!middle.in &&
!middle.out &&
(await json('__gm.curve.picked()')) === 1,
JSON.stringify({ put, picked: await json('__gm.curve.picked()') }),
);
// 53c. Point: Smooth grows its handles, and pulling the right one up bends the curve – the left
// handle turns with it, the other way round.
const pointButton = await json('__gm.curve.pointButton()');
await click(pointButton.x, pointButton.y);
await sleep(100);
const smooth = await menuRow('Smooth');
if (smooth) await click(smooth.x, smooth.y);
await settle();
const rounded = await pointsOf(curveId);
const grown = rounded?.[1];
check(
'Point: Smooth grows both handles of the picked point, in line',
grown?.mode === 'smooth' &&
Boolean(grown.in && grown.out) &&
Math.abs(grown.in.x * grown.out.y - grown.in.y * grown.out.x) < 1e-9,
JSON.stringify({ grown, button: pointButton }),
);
if (!grown?.out) return;
// The handle reaches above what the straight line was fitted to; Fit frames it again.
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
const tip = await json(`__gm.curve.at(${grown.x + grown.out.x}, ${grown.y + grown.out.y})`);
await drag(tip.x, tip.y, tip.x, tip.y - 60, { steps: 8 });
await settle();
const bent = await pointsOf(curveId);
const pulled = bent?.[1];
const across = pulled && pulled.in.x * pulled.out.y - pulled.in.y * pulled.out.x;
check(
'dragging the right handle up raises it, and the smooth point turns its left handle to match',
pulled?.out.y > grown.out.y + 0.1 && Math.abs(across) < 1e-6 && pulled.in.x < 0 && pulled.in.y < 0,
JSON.stringify({ before: grown, after: pulled }),
);
await shot('53-curve-tab');
// The preview runs by itself until its button holds it; held, it stands still.
// Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass.
const running = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
running.push((await json('__gm.curve.preview()'))?.t);
}
const previewButton = await json('__gm.curve.previewButton()');
await click(previewButton.x, previewButton.y);
await settle();
const held = [(await json('__gm.curve.preview()'))?.t];
await sleep(250);
held.push((await json('__gm.curve.preview()'))?.t);
check(
'the preview runs by itself, and the Preview button holds it',
previewButton.toggled &&
new Set(running).size > 1 &&
!(await json('__gm.curve.previewButton()')).toggled &&
held[0] === held[1],
JSON.stringify({ previewButton, running, held }),
);
// The preview's dot sits on the line at its t, and its twin beside the graph at the same height.
await evaluate('__gm.curve.setPreviewTime(0.35)');
const preview = await json('__gm.curve.preview()');
const onLine = await json(`__gm.curve.at(${preview?.t}, ${expectedAt(bent, preview?.t ?? 0)})`);
check(
'the preview dot runs along the drawn curve, and its twin beside the graph at the same height',
Math.abs(preview?.t - 0.35) < 1e-9 &&
Math.abs(preview.v - expectedAt(bent, 0.35)) < 0.002 &&
Math.abs(preview.dot.x - onLine.x) < 1 &&
Math.abs(preview.dot.y - onLine.y) < 1 &&
Math.abs(preview.bar.y - preview.dot.y) < 1e-6 &&
preview.bar.x > preview.dot.x,
JSON.stringify({ preview, onLine }),
);
// 53d. An animation key picks the curve in the inspector, and the preview draws the value it gives.
await mutate(`
const p = __gm.state.project;
const sprite = p.assets.find((a) => a.type === 'sprite');
p.assets.push({ id: 'cv-anim', type: 'animation', name: 'Curve slide', props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'cv-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }] });`);
await evaluate("__gm.openDocument('asset:cv-anim')");
await settle();
const cell = await json("__gm.animation.cellAt('cv-row', 0)");
await click(cell.x, cell.y);
await settle();
const easing = await json("__gm.inspectorField('Easing')");
if (easing) {
await click(easing.x, easing.y);
await sleep(100);
const curveEntry = await menuRow('Curve');
if (curveEntry) await click(curveEntry.x, curveEntry.y);
await settle();
}
const key = async () =>
(await json("__gm.state.project.assets.find((a) => a.id === 'cv-anim').tracks[0].keys[0]"))
?.interpolation;
const curveRow = await json("__gm.inspectorField('Curve', true)");
check(
'Easing: Curve on the key takes the one curve there is and shows it in a Curve row',
(await key()) === `curve:${curveId}` && Boolean(curveRow),
JSON.stringify({ interpolation: await key(), curveRow, easing }),
);
const rootId = await json("__gm.animation.preview.objectId('')");
const drawnAt = async (frame) => {
await evaluate(`__gm.animation.scrub(${frame})`);
await sleep(100);
return (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.x;
};
const drawn = [await drawnAt(3), await drawnAt(5), await drawnAt(8)];
const wanted = [0.3, 0.5, 0.8].map((share) => 100 * expectedAt(bent, share));
check(
'the preview puts the sprite where the curve says, not where a straight line would',
drawn.every((x, i) => Math.abs(x - wanted[i]) < 0.05) && Math.abs(drawn[1] - 50) > 5,
JSON.stringify({ drawn, wanted }),
);
await shot('53-curve-key');
// 53e. A Lerp picks it too – Ease: Curve, then the curve in its row – and the game logs its value.
await mutate(`
const p = __gm.state.project;
p.assets.push({ id: 'cv-lerpy', type: 'behavior', name: 'Curve lerp', props: { enabled: true, runInEditor: false },
variables: [], graph: ${JSON.stringify({
nodes: [
node('cv-start', 'OnStart', 0, 0),
node('cv-lerp', 'Lerp', 0, 160, { a: 0, b: 100, t: 0.3 }),
node('cv-log', 'Log', 300, 0),
],
edges: [
edge('cv-e1', 'cv-start', 'exec', 'cv-log', 'exec'),
edge('cv-e2', 'cv-lerp', 'result', 'cv-log', 'message'),
],
})} });
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'cv-holder', assetId: 'cv-lerpy', parentId: '', name: 'Curve holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
__gm.state.select({ kind: 'node', assetId: 'cv-lerpy', nodeId: 'cv-lerp', nodeIds: ['cv-lerp'], annotationIds: [] });`);
await showTab('graph');
await settle();
const pick = async (field, label) => {
const at = await json(`__gm.inspectorField(${JSON.stringify(field)}, true)`);
if (!at) return null;
await click(at.x, at.y);
await sleep(100);
const option = await menuRow(label);
if (option) await click(option.x, option.y);
else await t.press('Escape');
await settle();
return option;
};
await pick('Ease', 'Curve');
const unpicked = await json("__gm.graph.nodeSettings('cv-lerp')");
const name = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name`);
const picked = await pick('Curve', `${name} (curve)`);
const params = await json(
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
);
const settings = await json("__gm.graph.nodeSettings('cv-lerp')");
check(
'Ease: Curve brings the Curve row onto the node, and picking the curve there sets it',
params?.ease === 'curve' &&
params.curve === curveId &&
Boolean(picked) &&
unpicked?.length === 2 &&
settings?.some((s) => s.includes(name)),
JSON.stringify({ params, unpicked, settings, picked }),
);
await shot('53-curve-lerp');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await t.stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Curve holder]'))
.map((line) => Number(line.split(']').pop()));
const lerpWanted = 100 * expectedAt(bent, 0.3);
check(
'the running Lerp gives what the curve says at t 0.3',
logged.length === 1 && Math.abs(logged[0] - lerpWanted) < 0.05 && Math.abs(logged[0] - 30) > 1,
JSON.stringify({ logged, lerpWanted }),
);
// 53f. Undo takes the pick back to the plain Lerp; then everything made here goes.
await showTab('graph');
await undo();
await settle();
const undone = await json(
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
);
check('undo takes the picked curve back off the Lerp', !undone?.curve, JSON.stringify(undone));
// 53g. A preset starts the curve over from a built-in ease, and the key follows it at once.
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const presetButton = await json('__gm.curve.presetButton()');
await click(presetButton.x, presetButton.y);
await sleep(100);
const presets = (await json('__gm.menuRows()')) ?? [];
// Thirty-one entries run past the bottom of the popup; its search box is the way to the last ones.
await t.typeText('bounce out');
await t.press('Enter');
await settle();
const bounced = await pointsOf(curveId);
await evaluate("__gm.openDocument('asset:cv-anim')");
await settle();
const bouncedAt = await drawnAt(5);
check(
'Preset → Bounce out replaces the curve with the traced ease, and the key follows it (76.6 at the middle)',
presets.filter((r) => typeof r === 'string').length === 31 &&
bounced?.length > 8 &&
Math.abs(bouncedAt - 76.5625) < 0.4,
JSON.stringify({ presets: presets.length, points: bounced?.length, bouncedAt }),
);
await shot('53-curve-preset');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cv-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('cv-') && a.id !== ${JSON.stringify(curveId)});
__gm.state.select(null)`);
await showTab('scene');
}