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>
This commit is contained in:
1 parent
320d3e27e1
commit
393b8ec7f9
41 files changed
+1981
-67
No files matched your search
@@ -81,7 +81,7 @@ export async function assets(t) {
|
||||
|
||||
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
|
||||
// what can be placed in a scene, a line, then what only lives in the library – and it is
|
||||
// tall enough to show all twenty-one types without scrolling.
|
||||
// tall enough to show all twenty-two types without scrolling.
|
||||
await clickText('+');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
const cut = rows.indexOf(null);
|
||||
@@ -94,7 +94,7 @@ export async function assets(t) {
|
||||
);
|
||||
check(
|
||||
'every asset type is offered once, each group sorted by label',
|
||||
placeable.length + library.length === 21 &&
|
||||
placeable.length + library.length === 22 &&
|
||||
String(placeable) === String([...placeable].sort()) &&
|
||||
String(library) === String([...library].sort()),
|
||||
JSON.stringify({ placeable, library }),
|
||||
@@ -453,7 +453,7 @@ export async function assets(t) {
|
||||
async function addMenuOnRightClick(t) {
|
||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||
const addRows = (rows) =>
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 21 && rows.includes('Sprite');
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 22 && rows.includes('Sprite');
|
||||
const heading = await json("__gm.assetRowShown('group:scene')");
|
||||
const sceneFolded = async () =>
|
||||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||||
|
||||
@@ -159,6 +159,7 @@ export async function animation(t) {
|
||||
'Back',
|
||||
'Elastic',
|
||||
'Bounce',
|
||||
'Curve',
|
||||
]) &&
|
||||
(await easingOf()) === 'cubic-in-out' &&
|
||||
Boolean(cubicDirection),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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');
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { users } from './49-users.mjs';
|
||||
import { snapshots } from './50-snapshots.mjs';
|
||||
import { clipboard } from './51-clipboard.mjs';
|
||||
import { hierarchyGroups } from './52-hierarchy-groups.mjs';
|
||||
import { curves } from './53-curves.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -109,4 +110,5 @@ export const STEPS = [
|
||||
snapshots,
|
||||
clipboard,
|
||||
hierarchyGroups,
|
||||
curves,
|
||||
];
|
||||
@@ -118,6 +118,7 @@ export const TAB_LABELS = {
|
||||
vectorart: 'Vector art',
|
||||
pixelart: 'Pixel art',
|
||||
animation: 'Animation',
|
||||
curve: 'Curve',
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
@@ -166,6 +167,7 @@ const DOCUMENT_KINDS = [
|
||||
'vectorart',
|
||||
'pixelart',
|
||||
'animation',
|
||||
'curve',
|
||||
'tile',
|
||||
'container',
|
||||
'list',
|
||||
|
||||
Reference in new issue
Block a user