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 }), ); }