Files
feuerwerk/tools/e2e/25-animation.mjs
T
rootandClaude Opus 5 4feb4c2cf6 A step opens a tab the way a person has to, through the menu when it is hidden
CI run 35 was red: the suite died after step 15 with `widget with text
"Container" not found`. Nothing in the editor broke — the tenth tab (#73) pushed
Container behind the `»` button at the test window's 1280 px, exactly the case
#84 was built for, and three steps still clicked the tab name directly.

openTab(t, tab) in tools/e2e/lib/scaffold.mjs asks tabRow() whether the tab is
hidden and goes through the menu when it is, so the fix lives in one place rather
than in every step that opens a tab. The LABELS record moves there as TAB_LABELS,
since both the helper and 14-overflow need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:03:40 +02:00

533 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 } = 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)');
await sleep(200);
// 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);
`);
await sleep(400);
// 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 sleep(300);
await clickText('Create animation from texture');
await sleep(400);
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();
await sleep(300);
const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length");
await redo();
await sleep(300);
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');
await sleep(300);
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');
// 25d. Pausing holds the frame on screen, and the preview shows that frame's picture.
await click(...Object.values(await json('__gm.animation.playButton()')));
await sleep(200);
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);
await sleep(250);
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' });
await sleep(200);
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');
await sleep(250);
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);
await sleep(300);
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();
await sleep(250);
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 sleep(200);
await press('Delete');
await sleep(250);
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();
await sleep(250);
// 25h. + Frames offers a sheet's frames at once and inserts them after the selection.
await clickText('+ Frames');
await sleep(300);
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.some((s) => s.startsWith('strip 2')),
JSON.stringify(offered),
);
// In the full suite a 17-frame sheet sorts first and pushes this row out of the list's view.
await typeText('strip');
await sleep(150);
await clickText('strip – all 4 frames');
await sleep(300);
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();
await sleep(250);
// 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);
await sleep(300);
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)');
await sleep(300);
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 evaluate("__gm.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();
await sleep(300);
const first = await edgesOf();
await stop();
await sleep(200);
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();
await sleep(300);
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();
await sleep(200);
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)}`,
);
await sleep(300);
// 25k. Play runs it: the pixels the game draws change colour over time.
await t.play();
await sleep(150);
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();
await sleep(300);
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 sleep(400);
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();
await sleep(300);
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 sleep(200);
await typeText(name);
await sleep(150);
await press('Enter');
await sleep(300);
};
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 sleep(400);
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 sleep(400);
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 sleep(300);
await clickText('Delete…');
await sleep(300);
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 sleep(300);
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();
`);
await sleep(300);
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));
}