Files
feuerwerk/tools/e2e/25-animation.mjs
T
admin 7e5003b590
game-maker / suite (push) Successful in 6m48s
A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
2026-09-16 08:08:10 +02:00

519 lines
21 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 { edge, node, propertiesOf, variable, written } from '../lib/builders.mjs';
import { MAKE_OBJECT, openTab } from './lib/scaffold.mjs';
/**
* Animations: made from a texture, edited in their own tab, worn by a sprite through `texture`,
* run in play only, and heard by `On animation finished`. The step pastes a strip of four colour
* cells, so which frame is on screen is a `pixelAt` question. It removes everything it made.
*/
const CELLS = ['#e63c3c', '#3cc85a', '#466ef0', '#f5f5f5'];
const NAMES = ['red', 'green', 'blue', 'white'];
const PASTE_STRIP = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 16;
const g = canvas.getContext('2d');
${JSON.stringify(CELLS)}.forEach((colour, i) => { g.fillStyle = colour; g.fillRect(i * 16, 0, 16, 16); });
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'strip.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
const FRAME_IDS = ['an-f0', 'an-f1', 'an-f2', 'an-f3'];
function nearestCell(sample) {
let best = -1;
let bestDistance = Infinity;
CELLS.forEach((hex, i) => {
const distance = colourDistance(sample, hex);
if (distance < bestDistance) {
bestDistance = distance;
best = i;
}
});
return bestDistance < 60 ? NAMES[best] : `other ${sample}`;
}
/** `On animation finished (walk) → done += 1, and the owner wears Run`. */
const logicGraph = (walkId) => ({
nodes: [
node('an-done', 'OnAnimationFinished', 0, 0, { animation: walkId }),
node('an-count', 'Variables', 300, 0, {}, [written('done', 'number')]),
node('an-add', 'Add', 300, 200, { b: 1 }),
node('an-owner', 'Owner', 300, 360),
node('an-wear', 'Properties', 600, 0, { ...propertiesOf('sprite'), texture: 'an-run' }, [
written('texture', 'texture'),
]),
],
edges: [
edge('an-e1', 'an-done', 'exec', 'an-count', 'exec'),
edge('an-e2', 'an-count', 'done', 'an-add', 'a'),
edge('an-e3', 'an-add', 'result', 'an-count', 'done'),
edge('an-e4', 'an-count', 'exec', 'an-wear', 'exec'),
edge('an-e5', 'an-owner', 'object', 'an-wear', 'object'),
],
});
export async function animation(t) {
const { check, sleep, json, evaluate, shot, mutate, press, click, clickText, drag, undo, redo, showTab } =
t;
const { mouse, typeText } = t;
const { entity, stop } = t;
const info = () => json('__gm.animation.current()');
const frames = async (id) =>
await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})?.frames ?? null`);
const previewColour = async () => {
const rect = (await info()).preview;
return rect
? nearestCell(
await json(`__gm.pixelAt(${Math.round(rect.x + rect.w / 2)}, ${Math.round(rect.y + rect.h / 2)})`),
)
: 'none';
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
// 25a. A strip of four cells, cut into four frames and moved to the head of the asset list.
await evaluate(PASTE_STRIP);
await sleep(2500);
const strip = await json(
"(__gm.state.project.assets.find((a) => a.type === 'texture' && a.name === 'strip') ?? {}).id ?? ''",
);
check('the strip is imported as a texture', strip !== '', strip);
await mutate(`
const p = __gm.state.project;
const index = p.assets.findIndex((a) => a.id === ${JSON.stringify(strip)});
const texture = p.assets.splice(index, 1)[0];
texture.props.filter = 'nearest';
texture.frames = ${JSON.stringify(FRAME_IDS)}.map((id, i) => ({ id, name: 'Cell ' + (i + 1), x: i * 16, y: 0, w: 16, h: 16 }));
p.assets.unshift(texture);
`);
// 25b. The texture's context menu makes an animation of its frames, as one undo step.
const row = await json(`__gm.assetRow(${JSON.stringify(strip)})`);
await click(row.x, row.y, 'right');
await clickText('Create animation from texture');
const made = await json(
"__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, name: a.name, frames: a.frames, props: a.props }))",
);
const walk = made[0] ?? {};
check(
'Create animation from texture plays its frames in order',
made.length === 1 && JSON.stringify(walk.frames) === JSON.stringify(FRAME_IDS),
JSON.stringify(made),
);
check(
'and is selected, named after the texture, at 10 fps looping',
(await json('__gm.state.selection?.assetId')) === walk.id &&
walk.name === 'strip 2' &&
walk.props?.fps === 10 &&
walk.props?.playback === 'loop',
JSON.stringify({ selection: await json('__gm.state.selection'), walk }),
);
await undo();
const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length");
await redo();
check(
'undo takes asset and frames back in one step, redo brings both',
undone === 0 && JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS),
JSON.stringify({ undone, after: await frames(walk.id) }),
);
// 25c. The Animation tab shows it and plays it; the inspector keeps its two properties.
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(walk.id)} })`);
await openTab(t, 'animation');
check(
'the inspector edits frames per second and playback',
(await t.has('Frames per second')) && (await t.has('Playback')) && (await t.has('Loop')),
);
const opened = await info();
check(
'the tab button has room for its bold label',
(await json("__gm.shownLabel('Animation')")) === 'Animation',
String(await json("__gm.shownLabel('Animation')")),
);
check(
'the Animation tab shows the selected animation with its four frames, playing',
(await json('__gm.tab()')) === 'animation' &&
opened.assetId === walk.id &&
opened.frames === 4 &&
opened.playing,
JSON.stringify(opened),
);
const shown = new Set();
for (let i = 0; i < 8; i++) {
shown.add((await info()).shown);
await sleep(70);
}
check('the preview runs through the frames', shown.size >= 3, JSON.stringify([...shown]));
await shot('01-playing');
// 25c-ii. (#90) The preview runs on the global clock like everything else that moves by itself,
// so a scale of 0 holds the frame on screen while the preview is still *playing*.
await evaluate('__gm.setTimeScale(0)');
await sleep(300);
const atZero = await info();
await sleep(500);
const stillZero = await info();
await evaluate('__gm.setTimeScale(1)');
const moved = new Set();
for (let i = 0; i < 8; i++) {
moved.add((await info()).shown);
await sleep(70);
}
check(
'a global time scale of 0 holds the frame without stopping the play, and 1 runs it on',
atZero.playing === true &&
stillZero.playing === true &&
stillZero.shown === atZero.shown &&
moved.size >= 3,
JSON.stringify({ atZero: atZero.shown, stillZero: stillZero.shown, afterwards: [...moved] }),
);
// 25d. Pausing holds the frame on screen, and the preview shows that frame's picture.
await click(...Object.values(await json('__gm.animation.playButton()')));
const paused = await info();
await sleep(350);
check(
'the play button pauses, and the frame on screen stays',
!paused.playing &&
(await json('__gm.animation.playLabel()')) === '▶' &&
(await info()).shown === paused.shown,
JSON.stringify({ paused, later: await info() }),
);
check(
'the preview draws the frame it says it shows',
(await previewColour()) === NAMES[paused.shown],
JSON.stringify({ shown: paused.shown, colour: await previewColour() }),
);
// 25e. A click on a tile selects it and shows it.
const tile = async (i) => json(`__gm.animation.tileCentre(${i})`);
const third = await tile(2);
await click(third.x, third.y);
const picked = await info();
check(
'clicking a tile selects it and the preview shows it',
picked.selected === 2 && picked.shown === 2 && (await previewColour()) === 'blue',
JSON.stringify({ picked, colour: await previewColour() }),
);
await shot('02-tile-picked');
// 25e'. The wheel zooms the preview about the pointer, the middle button pans, Fit undoes both.
const fitted = picked.preview;
const pointer = { x: Math.round(fitted.x + fitted.w * 0.25), y: Math.round(fitted.y + fitted.h * 0.5) };
const wheel = async (x, y, deltaY) => {
await mouse('mouseMoved', x, y);
await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY });
await sleep(250);
};
await wheel(pointer.x, pointer.y, -240);
const zoomed = (await info()).preview;
const share = (rect) => ((pointer.x - rect.x) / rect.w).toFixed(3);
check(
'the wheel zooms the preview in about the pointer',
zoomed.w > fitted.w * 1.3 && share(zoomed) === share(fitted),
JSON.stringify({ fitted, zoomed }),
);
check(
'the pixel under the pointer is still the frame shown',
nearestCell(await json(`__gm.pixelAt(${pointer.x}, ${pointer.y})`)) === 'blue',
);
await drag(pointer.x, pointer.y, pointer.x + 40, pointer.y - 30, { button: 'middle' });
const panned = (await info()).preview;
check(
'the middle button pans it',
Math.round(panned.x - zoomed.x) === 40 &&
Math.round(panned.y - zoomed.y) === -30 &&
panned.w === zoomed.w,
JSON.stringify({ zoomed, panned }),
);
await wheel(third.x, third.y, -240);
check(
'the wheel over the strip leaves the zoom alone',
JSON.stringify((await info()).preview) === JSON.stringify(panned),
JSON.stringify((await info()).preview),
);
await shot('02b-zoomed');
await clickText('Fit');
check(
'Fit puts the preview back',
JSON.stringify((await info()).preview) === JSON.stringify(fitted),
JSON.stringify((await info()).preview),
);
// 25f. Dragging a tile into another gap reorders the frames, as one undo step.
const first = await tile(0);
await drag(first.x, first.y, third.x + 39, third.y, 10);
const reordered = await frames(walk.id);
check(
'dragging the first tile behind the third moves that frame there',
JSON.stringify(reordered) === JSON.stringify(['an-f1', 'an-f2', 'an-f0', 'an-f3']) &&
(await info()).selected === 2,
JSON.stringify({ reordered, info: await info() }),
);
await undo();
check('undo puts it back', JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS));
// 25g. Delete removes the selected tile.
const second = await tile(1);
await click(second.x, second.y);
await press('Delete');
const deleted = await frames(walk.id);
check(
'Delete removes the selected frame and nothing else',
JSON.stringify(deleted) === JSON.stringify(['an-f0', 'an-f2', 'an-f3']) &&
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(walk.id)})`)),
JSON.stringify(deleted),
);
await undo();
// 25h. + Frames offers a sheet's frames at once and inserts them after the selection.
await clickText('+ Frames');
// Search first: in the full suite a 17-frame sheet sorts ahead of this one and pushes the later
// cells out of the list's view, and a row outside its list is not on the screen to be read.
// The filter also sharpens the check, because the animation is called "strip 2" and would match.
await typeText('strip');
const offered = await json('__gm.overlayTexts()');
check(
'+ Frames offers the whole strip and each cell, but no animation',
offered.includes('strip – all 4 frames') &&
offered.includes('strip / Cell 3') &&
offered.includes('strip / Cell 4') &&
!offered.some((s) => s.startsWith('strip 2')),
JSON.stringify(offered),
);
await clickText('strip – all 4 frames');
const grown = await frames(walk.id);
check(
'all four go in right after the selected frame',
JSON.stringify(grown) === JSON.stringify(['an-f0', 'an-f1', ...FRAME_IDS, 'an-f2', 'an-f3']),
JSON.stringify(grown),
);
await undo();
// 25i. A sprite wears the animation through its Texture picker.
await mutate(`
const p = __gm.state.project;
const sprite = structuredClone(p.assets.find((a) => a.type === 'sprite'));
Object.assign(sprite, { id: 'an-sprite', name: 'Animated' });
sprite.props = { ...sprite.props, width: 80, height: 80, color: 0xffffff, texture: '' };
p.assets.unshift(sprite);
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const box = make('an-box', 'an-sprite', '', 120, 'Animated');
box.position.z = 950;
scene.objects.push(box);
__gm.state.select({ kind: 'asset', assetId: 'an-sprite' });
`);
await sleep(500);
const picker = await json("__gm.inspectorField('Texture', true)");
await click(picker.x, picker.y);
const choices = await json('__gm.overlayTexts()');
check(
'the Texture picker offers the animation first, marked as one',
choices.indexOf('strip 2 (animation)') === choices.indexOf('● None') + 1,
JSON.stringify(choices),
);
await clickText('strip 2 (animation)');
check(
'picking it makes the sprite wear the animation',
(await json("__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture")) === walk.id,
);
// 25j. The scene shows the first frame and does not play.
await showTab('scene');
await sleep(700);
const box = await json("__gm.objectRect('an-box')");
const sceneColour = async () =>
nearestCell(await json(`__gm.pixelAt(${Math.round(box.x)}, ${Math.round(box.y)})`));
const before = await sceneColour();
await sleep(450);
check(
'the Scene tab shows the first frame and stays on it',
before === 'red' && (await sceneColour()) === 'red',
JSON.stringify({ box, before, after: await sceneColour() }),
);
await shot('03-scene-first-frame');
// 25j'. A frame is a texture of its own. Drawn from the sheet, an edge that landed inside a screen
// pixel showed the neighbouring cell: the partly covered pixel is evaluated at its centre,
// past the edge. Measured in play, where the background is flat.
await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = 'an-f1'`);
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const fraction = (v) => v - Math.floor(v);
const edgesOf = async () => {
const r = await json("__gm.gameRect('an-box')");
return { y: Math.round(r.y), left: r.x - r.w / 2, right: r.x + r.w / 2, scale: r.w / 80 };
};
// Moves the box so the edge's fraction lands where the pixel is partly covered but its centre
// lies outside; `outward` says which way the neighbouring cell would be.
const edgePixels = async (side, target, outward) => {
await t.play();
const first = await edgesOf();
await stop();
const shift = (target - fraction(first[side]) + 1) % 1;
await mutate(
`__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`,
);
await t.play();
const e = await edgesOf();
const x = Math.floor(e[side]);
const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`));
const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`));
await stop();
return { fraction: fraction(e[side]).toFixed(2), edge, outside };
};
// The edge pixel is the cell's green over the background and nothing else: red and blue rise
// above the background only in the cell's own proportion to the rise in green (plus rounding),
// and green rises enough to prove it is the partly covered pixel, not plain background.
const cell = rgb(CELLS[1]);
const onlyOwnColour = ({ edge, outside }) => {
const green = edge[1] - outside[1];
return (
green > 10 &&
[0, 2].every(
(c) => edge[c] - outside[c] <= (green * (cell[c] - outside[c])) / (cell[1] - outside[1]) + 3,
)
);
};
const leftEdge = await edgePixels('left', 0.75, -1);
check(
'the left edge of a frame shows nothing of the cell to its left',
onlyOwnColour(leftEdge),
JSON.stringify(leftEdge),
);
const rightEdge = await edgePixels('right', 0.25, 1);
check(
'nor the right edge anything of the cell to its right',
onlyOwnColour(rightEdge),
JSON.stringify(rightEdge),
);
await mutate(
`__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = ${JSON.stringify(walk.id)}`,
);
// 25k. Play runs it: the pixels the game draws change colour over time.
await t.play();
const colours = new Set();
for (let i = 0; i < 10; i++) {
const at = await json("__gm.gameScreen('an-box')");
if (at) colours.add(nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`)));
await sleep(60);
}
const clock = (await entity('an-box'))?.animation;
await shot('04-playing');
await stop();
check(
'in play the sprite runs through the frames',
[...colours].filter((c) => NAMES.includes(c)).length >= 3 && clock?.id === walk.id && clock.time > 0,
JSON.stringify({ colours: [...colours], clock }),
);
// 25l. Once: the end is an event, and a Properties node puts another animation on.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(walk.id)}).props.playback = 'once';
p.assets.unshift({ id: 'an-run', type: 'animation', name: 'Run', props: { fps: 10, playback: 'loop' }, frames: ['an-f3'] });
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(logic, {
id: 'an-logic',
name: 'Animation logic',
props: { enabled: true, runInEditor: false, done: 0 },
variables: ${JSON.stringify([variable('done', 'number', 0)])},
graph: ${JSON.stringify(logicGraph(walk.id))},
});
p.assets.unshift(logic);
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
scene.objects.push(make('an-brain', 'an-logic', 'an-box', 0, 'Animation logic'));
__gm.state.select(null);
`);
await t.play();
await sleep(900);
const brain = (await entity('an-brain'))?.props;
const worn = await entity('an-box');
const at = await json("__gm.gameScreen('an-box')");
const after = at
? nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`))
: 'none';
await stop();
check(
'On animation finished fires once when a once animation ends',
brain?.done === 1,
JSON.stringify({ brain, log: await json('__gm.state.notice') }),
);
check(
'and the Properties node it runs puts the other animation on',
worn?.props.texture === 'an-run' && worn?.animation?.id === 'an-run' && after === 'white',
JSON.stringify({ texture: worn?.props.texture, clock: worn?.animation, after }),
);
// 25l'. A picture is a kind of its own: the Texture node hands one into the graph, and a
// variable may be one. Both are picked, never typed.
const pickPicture = async (label, name) => {
const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`);
await click(field.x, field.y);
await typeText(name);
await press('Enter');
};
await mutate(`
const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic');
logic.graph.nodes.push(${JSON.stringify(node('an-pic', 'Texture', 600, 300))});
logic.variables.push(${JSON.stringify(variable('pic', 'texture', ''))});
logic.props.pic = '';
__gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] });
`);
await pickPicture('Value', walk.name);
const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic');
const picRows = (await json("__gm.graph.nodeSettings('an-pic')")) ?? [];
check(
'the Texture node picks its picture and shows it by name',
picNode?.params.value === walk.id && picRows.some((r) => r.includes(walk.name)),
JSON.stringify({ params: picNode?.params, rows: picRows }),
);
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })");
await pickPicture('pic', walk.name);
check(
'a texture variable is picked with the same picker',
(await t.asset('an-logic')).props.pic === walk.id,
JSON.stringify((await t.asset('an-logic')).props),
);
await shot('04b-texture-kind');
// 25m. Deleting the texture counts the animations that play its frames.
const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`);
await click(again.x, again.y, 'right');
await clickText('Delete…');
const dialog = await json('__gm.overlayTexts()');
await shot('05-delete-dialog');
check(
'deleting the texture names the animations that use it',
dialog.some((s) => s.includes('2 animation(s)')),
JSON.stringify(dialog),
);
check('and its bold Delete button is not cut short', dialog.includes('Delete'), JSON.stringify(dialog));
const cancel = await t.locate('Cancel', true);
await click(cancel.x, cancel.y);
await mutate(`
const p = __gm.state.project;
const mine = new Set(['an-sprite', 'an-run', 'an-logic', ${JSON.stringify(walk.id)}, ${JSON.stringify(strip)}]);
p.assets = p.assets.filter((a) => !mine.has(a.id));
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'an-box' && o.id !== 'an-brain');
__gm.state.select(null);
__gm.state.preview();
`);
const left = await json(
"__gm.state.project.assets.filter((a) => a.type === 'animation' || a.id === 'an-sprite' || a.id === 'an-logic').length",
);
check('the step leaves the fixture as it found it', left === 0, String(left));
}