Type point values, snap and scrub the preview in the curve tab (#248)
game-maker / gate (push) Successful in 3m23s
game-maker / suite (push) Successful in 10m9s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 16:45:53 +02:00
1 parent 393b8ec7f9
commit a5ae21481b
11 files changed
+461 -43

No files matched your search

+13
View File
@@ -228,6 +228,19 @@ export async function vectorArt(t) {
near(moved.x, 180) && near(moved.y, 40),
JSON.stringify(moved),
);
const cursorOver = async (x, y) => {
const at = await json(`__gm.vectorArt.pointAt(${x}, ${y})`);
await t.mouse('mouseMoved', at.x, at.y);
await settle();
return json('__gm.vectorArt.cursor()');
};
const overPoint = await cursorOver(180, 40);
const overShape = await cursorOver(110, 60);
check(
'over a point the cursor stays the plain arrow, inside the shape it shows the move cross',
overPoint === 'default' && overShape === 'move',
JSON.stringify({ overPoint, overShape }),
);
// The magnet is the graph's (#194): the tab has no Grid button of its own, and with it
// on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it.
+157 -9
View File
@@ -136,26 +136,36 @@ export async function curves(t) {
);
await shot('53-curve-tab');
// The preview runs by itself until its button holds it; held, it stands still.
// 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 running = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
running.push((await json('__gm.curve.preview()'))?.t);
}
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 runs by itself, and the Preview button holds it',
previewButton.toggled &&
'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, running, held }),
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.
@@ -318,6 +328,8 @@ export async function curves(t) {
);
await shot('53-curve-preset');
await headerAndScrub(t, curveId, bounced);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
@@ -326,3 +338,139 @@ export async function curves(t) {
__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 }),
);
}