Files
feuerwerk/tools/e2e/25-animation.mjs
T
adminandClaude Opus 5.5 393b8ec7f9
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s
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>
2026-09-27 15:58:05 +02:00

441 lines
18 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 { colourDistance } from '../lib/harness.mjs';
import { openTab } from './lib/scaffold.mjs';
const RED = '#ff0000';
const SIZE = 40;
/**
* Animations (#97, #98, #99, #110, #102): a row is added through the dialog, keys are set, moved,
* banded, deleted, copied, pasted and duplicated on the timeline with real gestures, frames are
* inserted, and the preview above it is read at the playhead – as a model value, as a screen rectangle and as pixels. The subject is a container whose child
* is animated, since a row aiming inside the root is the case a single sprite cannot show. It
* removes everything it made.
*/
export async function animation(t) {
const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText } = t;
const { undo, settle, sleep, waitUntil, find } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const sprite = structuredClone(p.assets.find((a) => a.id === 'crate'));
Object.assign(sprite, { id: 'an-sprite', name: 'Arm sprite' });
sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xff0000, texture: '', shader: '' };
const arm = structuredClone(scene.objects[0]);
Object.assign(arm, {
id: 'an-arm', assetId: 'an-sprite', name: 'Arm', parentId: '',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {},
});
p.assets.push(
sprite,
{ id: 'an-box', type: 'container', name: 'Dancer', props: { enabled: true }, objects: [arm] },
{ id: 'an-walk', type: 'animation', name: 'Walk', props: { fps: 10, playback: 'loop' }, root: 'an-box', tracks: [] },
);
__gm.state.select({ kind: 'asset', assetId: 'an-walk' });
`);
await openTab(t, 'animation');
await sleep(300);
check('the animation tab shows the animation', (await json('__gm.animation.current()')) === 'an-walk');
const walk = () => json("__gm.state.project.assets.find((a) => a.id === 'an-walk')");
const template = () =>
json("JSON.stringify(__gm.state.project.assets.find((a) => a.id === 'an-box').objects)");
const keysOf = async () => (await walk()).tracks[0]?.keys ?? [];
const templateBefore = await template();
// 25a. The dialog offers the root and what is placed inside it; a row aims at the child.
const add = await json('__gm.animation.addRowAt()');
await click(add.x, add.y);
const offered = await json('__gm.animation.rowDialog()');
check(
'the row dialog offers the root itself and the object placed inside it',
JSON.stringify(offered?.targets) === JSON.stringify(['Dancer (itself)', 'Arm']),
JSON.stringify(offered),
);
await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')");
await settle();
const armOffered = (await json('__gm.animation.rowDialog()')).properties;
const armProperties = armOffered.length;
check(
'the position rows come first, Z (the draw order) after X and Y',
JSON.stringify(armOffered.slice(0, 3)) === JSON.stringify(['X', 'Y', 'Z']),
JSON.stringify(armOffered),
);
const addButton = await find('Add row', true);
await click(addButton.x, addButton.y);
const row = (await walk()).tracks;
check(
'confirming adds one row aimed at the child, with no keys yet',
row.length === 1 && row[0].target === 'an-arm' && row[0].property === 'x' && row[0].keys.length === 0,
JSON.stringify(row),
);
const trackId = row[0]?.id;
await click(add.x, add.y);
await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')");
await settle();
const again = await json('__gm.animation.rowDialog()');
check(
'the same object and property is not offered a second time and cannot be confirmed',
again.properties.length === armProperties - 1 && again.ok === false,
JSON.stringify({ armProperties, again }),
);
const cancel = await find('Cancel', true);
await click(cancel.x, cancel.y);
check('cancelling adds nothing', (await walk()).tracks.length === 1);
// 25b. A double click sets a key with the value the row has there; the inspector edits the key.
const cell = (frame) => json(`__gm.animation.cellAt(${JSON.stringify(trackId)}, ${frame})`);
const c0 = await cell(0);
await doubleClick(c0.x, c0.y);
const c10 = await cell(10);
await doubleClick(c10.x, c10.y);
const doubled = await keysOf();
check(
'a double click sets a key holding the value in force there – the child stands at 0',
JSON.stringify(doubled) ===
JSON.stringify([
{ frame: 0, value: 0 },
{ frame: 10, value: 0 },
]),
JSON.stringify(doubled),
);
const selection = (await t.state()).selection;
const valueField = await json("__gm.inspectorField('Value')");
check(
'the key just set is picked and the inspector shows its value',
selection?.kind === 'track' && selection.keys?.[0]?.frame === 10 && Boolean(valueField),
JSON.stringify({ selection, valueField }),
);
await click(valueField.x, valueField.y);
await press('a', { ctrl: true });
await typeText('100');
await press('Enter');
await settle();
const edited = await keysOf();
check(
'typing a value writes the key, and the container template stays as it was',
edited.find((k) => k.frame === 10)?.value === 100 && (await template()) === templateBefore,
JSON.stringify(edited),
);
const info = await json('__gm.animation.info()');
check(
'the readout counts to the last key: 11 frames, 1.1 s',
info === '1 row · 11 frames · 10 fps · 1.1 s',
info,
);
// 25b-ii. Easing is two fields: the curve, and for a curve its direction. Each pick is a step,
// all three are undone here so 25h counts only its own.
const easingOf = async () => (await keysOf()).find((k) => k.frame === 10)?.interpolation ?? 'linear';
const pick = async (field, label) => {
const row = await json(`__gm.inspectorField(${JSON.stringify(field)})`);
await click(row.x, row.y);
await sleep(100);
const rows = await json('__gm.menuRows()');
const entry = await t.locate(label, true);
await click(entry.x, entry.y);
await settle();
return rows;
};
const shapes = await pick('Easing', 'Cubic');
const cubicDirection = await json("__gm.inspectorField('Direction')");
check(
'the Easing field lists every curve, and a curve brings the Direction field, eased both ways',
JSON.stringify(shapes) ===
JSON.stringify([
'Linear',
'Step',
'Sine',
'Quad',
'Cubic',
'Quart',
'Quint',
'Expo',
'Circ',
'Back',
'Elastic',
'Bounce',
'Curve',
]) &&
(await easingOf()) === 'cubic-in-out' &&
Boolean(cubicDirection),
JSON.stringify({ shapes, easing: await easingOf(), cubicDirection }),
);
const directions = await pick('Direction', 'Out');
await shot('25-animation-easing');
const outward = await easingOf();
await pick('Easing', 'Back');
const back = await easingOf();
check(
'Direction offers In, Out and In-out, and a new curve keeps the direction picked',
JSON.stringify(directions) === JSON.stringify(['In', 'Out', 'In-out']) &&
outward === 'cubic-out' &&
back === 'back-out',
JSON.stringify({ directions, outward, back }),
);
await pick('Easing', 'Linear');
const linearDirection = await json("__gm.inspectorField('Direction')");
const linear = await easingOf();
await focusTimeline();
await undo();
const backAgain = await easingOf();
for (let i = 0; i < 3; i++) await undo();
const easingUndone = await easingOf();
const keptValue = (await keysOf()).find((k) => k.frame === 10)?.value;
check(
'linear drops the Direction field and the stored easing, and undo takes the four picks back one by one',
!linearDirection &&
linear === 'linear' &&
backAgain === 'back-out' &&
easingUndone === 'linear' &&
keptValue === 100,
JSON.stringify({ linearDirection, linear, backAgain, easingUndone, keptValue }),
);
// 25c. The ruler scrubs, and the preview draws the child at the playhead. Framed at the far end,
// so both ends and the middle are on screen together.
const scrubTo = async (frame) => {
const p = await json(`__gm.animation.rulerAt(${frame})`);
await click(p.x, p.y);
await sleep(100);
};
const armId = await json("__gm.animation.preview.objectId('an-arm')");
const rectAt = async (frame) => {
await scrubTo(frame);
return json(`__gm.animation.preview.rect(${JSON.stringify(armId)})`);
};
await scrubTo(10);
const fit = await json('__gm.animation.preview.fitAt()');
await click(fit.x, fit.y);
await sleep(200);
const start = await rectAt(0);
const end = await rectAt(10);
const middle = await rectAt(5);
const playhead = (await json('__gm.animation.view()')).playhead;
const drawn = await json(`__gm.animation.preview.item(${JSON.stringify(armId)})`);
const expectedX = start && end ? (start.x + end.x) / 2 : NaN;
check(
'clicking the ruler at 5 puts the playhead there, and the child is drawn half way at x 50',
playhead === 5 && drawn?.x === 50 && Math.abs(middle?.x - expectedX) <= 1 && end?.x - start?.x > SIZE,
JSON.stringify({ playhead, drawn: drawn?.x, start, middle, end }),
);
await shot('25-animation-preview');
if (!start || !middle || !end) return;
// Pixels: red all over the child where it is drawn, and nothing red where it stood at frame 0.
const grid = [];
for (const fx of [-0.3, 0, 0.3]) for (const fy of [-0.3, 0, 0.3]) grid.push([fx, fy]);
const samples = await json(
`[${grid.map(([fx, fy]) => `__gm.pixelAt(${Math.round(middle.x + fx * middle.w)}, ${Math.round(middle.y + fy * middle.h)})`).join(',')}]`,
);
const left = await json(`__gm.pixelAt(${Math.round(start.x)}, ${Math.round(start.y)})`);
check(
'the preview paints the child red at frame 5, and the spot it held at frame 0 is empty',
samples.every((c) => colourDistance(c, RED) < 30) && colourDistance(left, RED) > 150,
JSON.stringify({ samples, left }),
);
// 25d. Drag moves a key, landing on another replaces it; undo brings the replaced one back.
const c5 = await cell(5);
await doubleClick(c5.x, c5.y);
const withMiddle = await keysOf();
const from = await cell(5);
const to = await cell(10);
await drag(from.x, from.y, to.x, to.y);
const landed = await keysOf();
check(
'a key dragged onto another replaces it and keeps its own value',
withMiddle.find((k) => k.frame === 5)?.value === 50 &&
JSON.stringify(landed) ===
JSON.stringify([
{ frame: 0, value: 0 },
{ frame: 10, value: 50 },
]),
JSON.stringify({ withMiddle, landed }),
);
await undo();
check(
'undo takes the drag back, the replaced key included',
JSON.stringify(await keysOf()) === JSON.stringify(withMiddle),
JSON.stringify(await keysOf()),
);
// 25e. A band from the empty space below the row over all three keys, then Delete.
const bandFrom = { x: (await cell(14)).x, y: c0.y + 40 };
const bandTo = { x: c0.x - 12, y: c0.y - 11 };
await drag(bandFrom.x, bandFrom.y, bandTo.x, bandTo.y);
const banded = (await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame);
await press('Delete');
const deleted = await keysOf();
check(
'the band picks every key it spans, and Delete removes them all but keeps the row',
banded.join() === '0,5,10' && deleted.length === 0 && (await walk()).tracks.length === 1,
JSON.stringify({ banded, deleted }),
);
await undo();
check(
'one undo brings all three keys back',
JSON.stringify(await keysOf()) === JSON.stringify(withMiddle),
JSON.stringify(await keysOf()),
);
// 25f. Once (hold) keeps the last value; once (reset) puts the starting value back at the end.
const playThrough = async (playback) => {
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = '${playback}'`);
await scrubTo(0);
const play = await json('__gm.animation.playAt()');
await click(play.x, play.y);
await waitUntil('!__gm.animation.view().playing', { timeout: 5000, label: `the ${playback} pass ended` });
await sleep(100);
return {
playhead: (await json('__gm.animation.view()')).playhead,
x: (await json(`__gm.animation.preview.item(${JSON.stringify(armId)})`))?.x,
button: (await json('__gm.animation.playAt()')).text,
};
};
const held = await playThrough('once');
const reset = await playThrough('once-reset');
check(
'a once pass ends holding the last key, a once-reset pass ends showing the starting value',
held.x === 100 && reset.x === 0 && reset.playhead >= 10 && reset.button === 'Play',
JSON.stringify({ held, reset }),
);
// 25g. Copy and paste (#102): two keys banded, Ctrl+C, the playhead to 12, Ctrl+V – they land
// relative to the playhead, keeping their distance, and come back picked. Each step undone.
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = 'loop'`);
const three = JSON.stringify(withMiddle);
const pickedFrames = async () =>
(await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame);
const c3 = await cell(3);
await drag((await cell(14)).x, c0.y + 40, c3.x, c0.y - 11);
const copied = await pickedFrames();
await press('c', { ctrl: true });
const clip = await json('__gm.animation.clipboard()');
await scrubTo(12);
await press('v', { ctrl: true });
const pasted = await keysOf();
const pastedPick = await pickedFrames();
check(
'Ctrl+C and Ctrl+V paste the banded keys relative to the playhead and pick them',
copied.join() === '5,10' &&
JSON.stringify(clip?.rows?.[0]?.keys?.map((k) => k.offset)) === '[0,5]' &&
JSON.stringify(pasted) ===
JSON.stringify([...withMiddle, { frame: 12, value: 50 }, { frame: 17, value: 100 }]) &&
pastedPick.join() === '12,17',
JSON.stringify({ copied, clip, pasted, pastedPick }),
);
await undo();
check(
'one undo takes the paste back',
JSON.stringify(await keysOf()) === three,
JSON.stringify(await keysOf()),
);
// Ctrl+drag duplicates: the key at 5 stays, a copy lands on 7.
const k5 = await cell(5);
const k7 = await cell(7);
await click(k5.x, k5.y);
await drag(k5.x, k5.y, k7.x, k7.y, { modifiers: { ctrl: true } });
const duplicated = await keysOf();
check(
'Ctrl+drag leaves the key where it was and drops a copy where the pointer lets go',
JSON.stringify(duplicated) ===
JSON.stringify([withMiddle[0], withMiddle[1], { frame: 7, value: 50 }, withMiddle[2]]),
JSON.stringify(duplicated),
);
await undo();
// Right-click → Insert frames… at the playhead: every key from there on moves by the count.
await scrubTo(5);
const inCells = await cell(2);
await click(inCells.x, inCells.y + 40, 'right');
const insertItem = await find('Insert frames at 5…', true);
await click(insertItem.x, insertItem.y);
await evaluate('__gm.animation.framesDialogCount(3)');
await settle();
const dialog = await json('__gm.animation.framesDialog()');
const confirm = await json('__gm.animation.framesDialogConfirm()');
await click(confirm.x, confirm.y);
const inserted = await keysOf();
check(
'the menu inserts 3 frames at the playhead: the keys on 5 and 10 move to 8 and 13, the one on 0 stays',
dialog?.summary === 'Moves 2 keys 3 frames later.' &&
JSON.stringify(inserted.map((k) => k.frame)) === '[0,8,13]' &&
(await json('__gm.animation.framesDialog()')) === null,
JSON.stringify({ dialog, inserted }),
);
await undo();
check(
'undo takes the inserted frames back',
JSON.stringify(await keysOf()) === three,
JSON.stringify(await keysOf()),
);
// Right-click on the row's header → Delete row: the row goes, the animation stays picked; undo.
const rowHeader = await json(`__gm.animation.headerAt(${JSON.stringify(trackId)})`);
await click(rowHeader.x, rowHeader.y, 'right');
const deleteItem = await find('Delete row', true);
// A destructive entry is red text on the plain menu, not a filled red row.
const menuRows = await json('__gm.menuRows()');
const looks = await json('__gm.menuLooks()');
const danger = await json(`__gm.themeColor('danger')`);
const plain = await json(`__gm.themeColor('text')`);
const deleteLook = looks[menuRows.indexOf('Delete row')];
const insertLook = looks[menuRows.findIndex((row) => row?.startsWith('Insert frames'))];
check(
'Delete row is drawn in red text without a red fill, a plain entry in the normal text colour',
deleteLook?.text === danger && deleteLook.fill !== danger && insertLook?.text === plain,
JSON.stringify({ deleteLook, insertLook, danger, plain }),
);
await click(deleteItem.x, deleteItem.y);
const rowsGone = (await walk()).tracks.length;
const afterDelete = await json('__gm.state.selection');
await undo();
check(
'the menu on a row header deletes that row, and undo brings it back with its keys',
rowsGone === 0 &&
afterDelete?.kind === 'asset' &&
(await walk()).tracks.length === 1 &&
JSON.stringify(await keysOf()) === three,
JSON.stringify({ rowsGone, afterDelete, keys: await keysOf() }),
);
// 25h. Undo all the way: the key at 5, the value edit, the keys at 10 and 0, then the row. Exactly
// that many – one more would take back a command of an earlier step, so the count is checked
// on the way: after four the row is still there, empty.
await focusTimeline();
for (let i = 0; i < 4; i++) await undo();
const emptied = await walk();
await undo();
const undone = await walk();
check(
'undo takes back the keys and the value edit one by one, and the row last',
emptied.tracks.length === 1 &&
emptied.tracks[0].keys.length === 0 &&
undone.tracks.length === 0 &&
(await template()) === templateBefore,
JSON.stringify({ emptied: emptied.tracks, undone: undone.tracks }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));
__gm.state.select(null);
`);
const leftOver = await json(
"__gm.state.project.assets.filter((a) => ['an-walk', 'an-box', 'an-sprite'].includes(a.id)).length",
);
check('the step leaves the fixture as it found it', leftOver === 0, String(leftOver));
/** Puts the keyboard on the timeline, so Ctrl+Z is not taken by a text field. */
async function focusTimeline() {
const header = await json(`__gm.animation.headerAt(${JSON.stringify(trackId)})`);
await click(header.x, header.y);
}
}