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>
329 lines
14 KiB
JavaScript
329 lines
14 KiB
JavaScript
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');
|
||
}
|