Files
feuerwerk/tools/e2e/53-curves.mjs
T
adminandClaude Opus 5.5 a5ae21481b
game-maker / gate (push) Successful in 3m23s
game-maker / suite (push) Successful in 10m9s
Type point values, snap and scrub the preview in the curve tab (#248)
Building a curve by mouse alone was fiddly: there was no way to put a
point on an exact value, no grid, and the running preview could not be
stopped at a chosen moment. The hand cursor over points also looked
like a link instead of a drawing tool.

- Number fields t and v in the header show the selected point. Typing
  or dragging on them goes through the same rules as a mouse drag, so
  points never overtake and handles stay clamped. One undo step each.
  t is greyed out on the two end points.
- The magnet button from Vector art snaps points and handles to a
  0.05 grid in t and v, when placing on free space and when dragging.
- Points and handles keep the default arrow in the curve tab and in
  Vector art. The pixel art palette dropped pointer as well, which is
  beyond the issue. Collider corners never had it.
- The preview now starts off. A vertical time line with a knob under
  the t axis sets t when dragged sideways. Dragging the dot on the
  rail jumps to the first t where the curve reaches that value, or to
  the closest one if it never does.
- The rules are pure functions in curveView.ts (withCurvePointSet,
  snappedToGrid, shareAtValue, previewSecondsAt), the canvas only
  calls them.

Tests:
- ./check.sh: 180 files, 2616 tests, CHECK EXIT 0
- GM_STEPS=1,47,53: ALL PASSED, 87 checks
- 4 new unit tests in curve-view.test.ts, 4 new e2e checks in 53
  (53h-53k) and 1 in 47, all with real mouse and keyboard
- Counter-probes bite: without "first t" 1 unit test fails, with
  pointer, no snap, no field commit, no scrub or preview on 5 browser
  checks fail, pointer in Vector art fails the new 47 check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:45:53 +02:00

477 lines
20 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 stands still until its button runs it; pressed again, it holds.
// Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass.
const sample = async () => {
const seen = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
seen.push((await json('__gm.curve.preview()'))?.t);
}
return seen;
};
const previewButton = await json('__gm.curve.previewButton()');
const idle = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const runningButton = await json('__gm.curve.previewButton()');
const running = await sample();
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 stands still at first, the Preview button runs it, and pressed again holds it',
!previewButton.toggled &&
new Set(idle).size === 1 &&
runningButton.toggled &&
new Set(running).size > 1 &&
!(await json('__gm.curve.previewButton()')).toggled &&
held[0] === held[1],
JSON.stringify({ previewButton, idle, 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 headerAndScrub(t, curveId, bounced);
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');
}
/**
* 53h–53k on the Bounce out curve the preset left: the picked point's `t` and `v` fields, the plain
* arrow over points, the magnet, and the preview dragged by its knob and by its dot beside the graph.
*/
async function headerAndScrub(t, curveId, bounced) {
const { check, json, evaluate, click, drag, settle, shot, undo } = t;
const pointsOf = () => json(`__gm.curve.points(${JSON.stringify(curveId)})`);
const at = (tt, v) => json(`__gm.curve.at(${tt}, ${v})`);
const onGrid = (n) => Math.abs(n / 0.05 - Math.round(n / 0.05)) < 1e-6;
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
// Somewhere left of t 0: no point goes there, so a click only drops the pick.
const aside = await at(-0.08, 0.5);
// 53h. A picked point shows its t and v in the header; typed in, it goes there, one undo step each.
const target = bounced[2];
const spot = await at(target.x, target.y);
await click(spot.x, spot.y);
await settle();
const shownT = await json("__gm.curve.placeField('t')");
const shownV = await json("__gm.curve.placeField('v')");
const typeInto = async (axis, text) => {
const field = await json(`__gm.curve.placeField(${JSON.stringify(axis)})`);
await click(field.x, field.y);
await t.typeText(text);
await t.press('Enter');
await settle();
};
const middle = ((bounced[1].x + bounced[3].x) / 2).toFixed(3);
await typeInto('t', middle);
await typeInto('v', '0.42');
const typed = await pointsOf();
await click(aside.x, aside.y);
await undo();
const oneBack = await pointsOf();
await undo();
const twoBack = await pointsOf();
check(
'the t and v fields show the picked point, move it when typed into, and each is one undo step',
(await json('__gm.curve.picked()')) === -1 &&
shownT.enabled &&
Math.abs(Number(shownT.text) - target.x) < 0.001 &&
Math.abs(Number(shownV.text) - target.y) < 0.001 &&
typed?.length === bounced.length &&
typed[2].x === Number(middle) &&
typed[2].y === 0.42 &&
oneBack[2].x === Number(middle) &&
oneBack[2].y === target.y &&
JSON.stringify(twoBack) === JSON.stringify(bounced),
JSON.stringify({
shownT,
shownV,
middle,
typed: typed?.[2],
oneBack: oneBack?.[2],
twoBack: twoBack?.[2],
}),
);
// 53i. An end keeps its t: the field is off, its v is not. Over a point the arrow stays plain.
const start = await at(0, bounced[0].y);
await click(start.x, start.y);
await settle();
const endT = await json("__gm.curve.placeField('t')");
const endV = await json("__gm.curve.placeField('v')");
await t.mouse('mouseMoved', start.x, start.y);
await settle();
const overPoint = await json('__gm.curve.cursor()');
const free = await at(0.5, 0.2);
await t.mouse('mouseMoved', free.x, free.y);
await settle();
const overSpace = await json('__gm.curve.cursor()');
check(
"an end's t field is off and its v field on; over a point the cursor stays the plain arrow",
!endT.enabled && endV.enabled && overPoint === 'default' && overSpace === 'crosshair',
JSON.stringify({ endT, endV, overPoint, overSpace }),
);
// 53j. With the magnet on, a point put down and then dragged lands on the 0.05 grid in t and v.
const magnet = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await settle();
const put = await at(0.337, 0.412);
await click(put.x, put.y);
await settle();
const index = await json('__gm.curve.picked()');
const dropped = (await pointsOf())?.[index];
await drag(put.x, put.y, put.x + 3, put.y - 37, { steps: 8 });
const dragged = (await pointsOf())?.[index];
const magnetOn = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await click(aside.x, aside.y);
await undo();
await undo();
const restored = await pointsOf();
check(
'with the magnet on a point is put and dragged onto the 0.05 grid of t and v',
!magnet.toggled &&
magnetOn.toggled &&
!(await json('__gm.curve.snapButton()')).toggled &&
Math.abs(dropped?.x - 0.35) < 1e-6 &&
Math.abs(dropped?.y - 0.4) < 1e-6 &&
onGrid(dragged?.x) &&
onGrid(dragged?.y) &&
dragged.y > dropped.y + 0.04 &&
JSON.stringify(restored) === JSON.stringify(bounced),
JSON.stringify({ magnet, dropped, dragged, restored: restored?.length }),
);
// 53k. Held, the preview moves only when dragged: its knob sideways sets t, its dot beside the
// graph up or down jumps to the first t the curve reaches that height. The curve stays.
const before = await json('__gm.curve.preview()');
const seventy = await at(0.7, 0);
await drag(before.knob.x, before.knob.y, seventy.x, before.knob.y, { steps: 10 });
const scrubbed = await json('__gm.curve.preview()');
const half = await at(0, 0.5);
await drag(scrubbed.bar.x, scrubbed.bar.y, scrubbed.bar.x, half.y, { steps: 10 });
const jumped = await json('__gm.curve.preview()');
await shot('53-curve-scrub');
const earlier = expectedAt(bounced, Math.max(jumped.t - 0.02, 0));
check(
'dragging the knob puts the preview at that t, dragging its twin jumps to where the curve first reaches that value',
!(await json('__gm.curve.previewButton()')).toggled &&
Math.abs(scrubbed.t - 0.7) < 0.01 &&
Math.abs(scrubbed.v - expectedAt(bounced, scrubbed.t)) < 0.002 &&
Math.abs(jumped.v - 0.5) < 0.01 &&
earlier < 0.5 &&
jumped.t < scrubbed.t &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({ before, scrubbed, jumped, earlier }),
);
}