Files
feuerwerk/tools/e2e/48-pixelart.mjs
T
adminandClaude Opus 5 5b9b57fd19 Layer, frame and shape lists all reorder by drag and drop
Reordering shapes by dragging worked well in the vector tab. Pixel
art layers and frames and tilemap layers still used up/down buttons.
All three lists now share one widget built from the shape list.

- SortableList in the UI toolkit, with the drag rules next to it in
  src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint
  on the right and an eye toggle. Every drop is one undo step.
- The shape list is a thin wrapper around it. shapeRows.ts is gone,
  its tests moved to the new rules.
- Pixel art layers show the layer alone on the current frame, frames
  show the whole frame. Up/Down, Hide, Earlier and Later are gone.
- Tilemap layers get the eye instead of the checkbox. The per-row
  up, down and delete buttons are replaced by Delete layer next to
  Add layer.
- A row is selected on release instead of press. Selecting rebuilt
  the inspector or tilemap panel and cancelled a drag that started
  on a row not yet selected.

Tests:
- ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051).
- GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks.
- 5 new e2e checks. Check total 1264 in the README is computed
  (1259 + 5), CI never measured 1259.
- Counter-checks bite: dropMove ignoring reversed fails a unit test,
  a tilemap drop not applied fails the drag check, a layer thumbnail
  showing all layers fails the thumbnail check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 18:48:50 +02:00

549 lines
22 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 { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Pixel art (#130): a picture painted pixel by pixel, in layers and frames, with a palette. Every
* block goes the way a user goes – the asset comes out of the + menu, the tools are picked in the
* side panel and dragged across the canvas – and reads back what only a running editor can show:
* the indices in the cels, the colours on screen, and that the picture reaches a sprite and survives
* a reload. Runs last: the reload at its end starts the page afresh.
*/
/** Colours of the default palette (PICO-8), by index. */
const RED = 9; // #ff004d
const GREEN = 12; // #00e436
const BLUE = 13; // #29adff
const HEX = { [RED]: '#ff004d', [GREEN]: '#00e436', [BLUE]: '#29adff' };
export async function pixelArt(t) {
const {
api,
check,
click,
drag,
evaluate,
json,
mutate,
navigate,
press,
projectId,
saved,
settle,
shot,
showTab,
sleep,
typeText,
undo,
} = t;
const Y = LANES.pixelArt;
const control = async (name) => {
const found = await json(`__gm.pixelArt.control(${JSON.stringify(name)})`);
await settle();
// Revealing may have scrolled the panel; ask again for where it is now.
return (await json(`__gm.pixelArt.control(${JSON.stringify(name)})`)) ?? found;
};
const press_ = async (name) => {
const at = await control(name);
await click(at.x, at.y);
await settle();
};
const swatch = async (index) => {
await json(`__gm.pixelArt.swatch(${index})`);
await settle();
const at = await json(`__gm.pixelArt.swatch(${index})`);
await click(at.x, at.y);
await settle();
};
/** A row of the layer or frame list, scrolled into view: its centre, and for a layer its eye. */
const listRow = async (list, rowId) => {
const hook = `__gm.pixelArt.${list}Row(${JSON.stringify(rowId)})`;
await json(hook);
await settle();
return json(hook);
};
const at = (x, y) => json(`__gm.pixelArt.pixelAt(${x}, ${y})`);
const paint = async (from, to, modifiers = {}) => {
const a = await at(from[0], from[1]);
const b = await at(to[0], to[1]);
await drag(a.x, a.y, b.x, b.y, { steps: 8, modifiers });
await settle();
};
const asset = (id) => json(`__gm.pixelArt.asset(${JSON.stringify(id)})`);
/** The indices of a layer on a frame, as a flat array; all zero for an empty cel. */
const cel = async (id, frameIndex, layerIndex) => {
const a = await asset(id);
const file = a.frames[frameIndex]?.cels[a.layers[layerIndex]?.id] ?? '';
const read = file ? await json(`__gm.pixelArt.cel(${JSON.stringify(file)})`) : null;
return read && read !== 'loading' ? read.data : new Array(a.width * a.height).fill(0);
};
const count = (data, index) => data.filter((i) => i === index).length;
const screen = async (x, y) => {
const p = await at(x, y);
return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`);
};
// 48a. The + menu makes the asset, and a palette with it, and opens it in its tab.
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 palettesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'palette').length");
await showTab('scene');
await t.clickText('+');
const entry = await inOverlay('Pixel art');
check('the + menu offers Pixel art', Boolean(entry), JSON.stringify(await json('__gm.menuRows()')));
await click(entry.x, entry.y);
await settle();
const id = (
await json("__gm.state.project.assets.filter((a) => a.type === 'pixelart').map((a) => a.id)")
).at(-1);
const made = await asset(id);
const palettes = await json(
"__gm.state.project.assets.filter((a) => a.type === 'palette').map((a) => a.id)",
);
check(
'it makes a 32 x 32 pixel art asset with one layer and one frame, painted in a new palette',
made?.width === 32 &&
made.height === 32 &&
made.layers.length === 1 &&
made.frames.length === 1 &&
palettes.length === palettesBefore + 1 &&
made.props.palette === palettes.at(-1),
JSON.stringify({ made, palettes, palettesBefore }),
);
check(
'and opens it in the Pixel art tab',
(await json('__gm.pixelArt.current()')) === id &&
(await json('__gm.pixelArt.info()')) ===
`32 × 32 px · 1 frame · ${await json('__gm.pixelArt.zoom()')}x` &&
(await json('__gm.pixelArt.zoom()')) > 1,
JSON.stringify({
current: await json('__gm.pixelArt.current()'),
info: await json('__gm.pixelArt.info()'),
}),
);
// 48b. The pencil: a swatch picks the colour, a drag paints a row, and one undo takes the stroke back.
await swatch(RED);
await press_('pencil');
await paint([2, 2], [9, 2]);
let data = await cel(id, 0, 0);
const row = data.slice(2 * 32, 3 * 32);
check(
'a drag with the pencil paints the pixels it crosses in the picked colour',
(await json('__gm.pixelArt.color()')) === RED &&
row.slice(2, 10).every((i) => i === RED) &&
count(data, RED) === 8,
JSON.stringify({ color: await json('__gm.pixelArt.color()'), row, painted: count(data, RED) }),
);
const onScreen = await screen(5, 2);
check('the canvas shows them in the palette colour', colourDistance(onScreen, HEX[RED]) < 10, onScreen);
await undo();
data = await cel(id, 0, 0);
const undone = count(data, RED);
await t.redo();
data = await cel(id, 0, 0);
check(
'the stroke is one undo step',
undone === 0 && count(data, RED) === 8,
JSON.stringify({ afterUndo: undone, afterRedo: count(data, RED) }),
);
// 48c. Fill pours into the area it is poured into; the painted row keeps its colour.
await swatch(GREEN);
await press_('fill');
await paint([0, 20], [0, 20]);
data = await cel(id, 0, 0);
check(
'fill turns every transparent pixel joined to the one clicked green, and leaves the row',
count(data, GREEN) === 32 * 32 - 8 && count(data, RED) === 8,
JSON.stringify({ green: count(data, GREEN), red: count(data, RED) }),
);
// 48d. The picker, and Alt with any tool.
await press_('pencil');
const redAt = await at(5, 2);
await click(redAt.x, redAt.y, 'left', { alt: true });
await settle();
const altPicked = await json('__gm.pixelArt.color()');
await press_('picker');
const greenAt = await at(20, 20);
await click(greenAt.x, greenAt.y);
await settle();
check(
'Alt+click picks the colour under the pointer, and so does the picker',
altPicked === RED && (await json('__gm.pixelArt.color()')) === GREEN,
JSON.stringify({ altPicked, picked: await json('__gm.pixelArt.color()') }),
);
// 48e. A second layer, and the shape tools on it.
const layersBefore = (await asset(id)).layers.length;
await press_('layer:add');
let art = await asset(id);
const top = art.layers.at(-1).id;
check(
'Add puts a new layer on top and paints on it',
art.layers.length === layersBefore + 1 && (await json('__gm.pixelArt.layerId()')) === top,
JSON.stringify({ layers: art.layers.map((l) => l.name), current: await json('__gm.pixelArt.layerId()') }),
);
await swatch(BLUE);
await press_('rect');
await paint([10, 10], [15, 14]);
data = await cel(id, 0, 1);
const rectCount = count(data, BLUE);
await press_('line');
await paint([0, 31], [31, 0]);
data = await cel(id, 0, 1);
const lineCount = count(data, BLUE) - rectCount;
check(
'a rectangle outline is its border, and a diagonal line one pixel per column',
rectCount === 2 * (6 + 5) - 4 && lineCount >= 31 && lineCount <= 32 && data[31 * 32 + 0] === BLUE,
JSON.stringify({ rectCount, lineCount }),
);
await undo();
const filledBox = await control('filled');
await click(filledBox.x, filledBox.y);
await press_('ellipse');
await paint([20, 20], [27, 27]);
data = await cel(id, 0, 1);
check(
'a filled ellipse covers its middle and leaves the corners of its box',
data[23 * 32 + 23] === BLUE &&
data[20 * 32 + 20] === 0 &&
data[27 * 32 + 27] === 0 &&
count(data, BLUE) - rectCount > 30,
JSON.stringify({
middle: data[23 * 32 + 23],
corner: data[20 * 32 + 20],
ellipse: count(data, BLUE) - rectCount,
}),
);
await click(filledBox.x, filledBox.y);
// 48f. The eye in the layer's row and the opacity change what the canvas shows, not the pixels.
const blueShown = await screen(10, 10);
const eyeAt = (await listRow('layer', top)).eye;
await click(eyeAt.x, eyeAt.y);
await settle();
const hidden = await screen(10, 10);
const hiddenFlag = (await asset(id)).layers.at(-1).hidden;
await click(eyeAt.x, eyeAt.y);
await settle();
const opacity = await control('layerOpacity');
await click(opacity.x, opacity.y);
await press('a', { ctrl: true });
await typeText('0.5');
await press('Enter');
await settle();
const half = await screen(10, 10);
art = await asset(id);
// Half blue over green, as the composer mixes straight colours.
const mixed =
'#' +
[
[0x29, 0x00],
[0xad, 0xe4],
[0xff, 0x36],
]
.map(([a, b]) =>
Math.round(a * 0.5 + b * 0.5)
.toString(16)
.padStart(2, '0'),
)
.join('');
check(
'the eye hides a layer, which is then not drawn, and a layer at half opacity is mixed with the one below',
colourDistance(blueShown, HEX[BLUE]) < 10 &&
hiddenFlag === true &&
colourDistance(hidden, HEX[GREEN]) < 10 &&
!art.layers.at(-1).hidden &&
art.layers.at(-1).opacity === 0.5 &&
colourDistance(half, mixed) < 12,
JSON.stringify({ blueShown, hiddenFlag, hidden, half, mixed, opacity: art.layers.at(-1).opacity }),
);
// 48f-ii. Each layer row shows that layer alone, on the frame painted, and the list is dragged into
// another order. The picture's centre is canvas pixel (16, 16): green below, empty above.
const bottom = art.layers[0].id;
const topRow = await listRow('layer', top);
const bottomRow = await listRow('layer', bottom);
const topThumb = await json(`__gm.pixelAt(${topRow.thumb.x}, ${topRow.thumb.y})`);
const bottomThumb = await json(`__gm.pixelAt(${bottomRow.thumb.x}, ${bottomRow.thumb.y})`);
check(
'a layer row pictures that layer alone: green below, nothing painted there above',
colourDistance(bottomThumb, HEX[GREEN]) < 12 &&
colourDistance(topThumb, HEX[GREEN]) > 60 &&
colourDistance(topThumb, HEX[BLUE]) > 60,
JSON.stringify({ topThumb, bottomThumb }),
);
const rowsBefore = await json('__gm.pixelArt.layerRows()');
await drag(topRow.x, topRow.y, bottomRow.x, bottomRow.y + 20, { steps: 8 });
await settle();
const movedLayers = (await asset(id)).layers.map((l) => l.id);
const rowsAfter = await json('__gm.pixelArt.layerRows()');
check(
'dragging the top layer row below the other puts that layer first in the drawing order',
rowsBefore.join() === [top, bottom].join() &&
movedLayers.join() === [top, bottom].join() &&
rowsAfter.join() === [bottom, top].join(),
JSON.stringify({ rowsBefore, movedLayers, rowsAfter }),
);
await undo();
await settle();
// 48g. Merge down: one layer left, holding both; the upper layer's opacity is not kept.
await press_('layer:merge');
art = await asset(id);
data = await cel(id, 0, 0);
check(
'merging lays the upper layer onto the lower one and takes it out',
art.layers.length === 1 && data[10 * 32 + 10] === BLUE && data[2 * 32 + 5] === RED && data[0] === GREEN,
JSON.stringify({
layers: art.layers.length,
rect: data[10 * 32 + 10],
row: data[2 * 32 + 5],
ground: data[0],
}),
);
// 48h. Frames: a copy, a painted difference, onion skin and the playing preview.
await press_('frame:duplicate');
art = await asset(id);
const second = await json('__gm.pixelArt.frameId()');
check(
'Duplicate adds a copy of the frame after it and paints on the copy',
art.frames.length === 2 &&
art.frames[1].id === second &&
JSON.stringify(art.frames[1].cels) === JSON.stringify(art.frames[0].cels) &&
/· 2 frames ·/.test(await json('__gm.pixelArt.info()')),
JSON.stringify({ frames: art.frames, info: await json('__gm.pixelArt.info()') }),
);
await press_('frame:add');
await swatch(RED);
await press_('pencil');
await paint([16, 16], [16, 16]);
art = await asset(id);
data = await cel(id, 2, 0);
const emptyThird = count(data, 0);
const onionOff = await screen(10, 10);
const onion = await control('onion');
await click(onion.x, onion.y);
await settle();
const onionOn = await screen(10, 10);
await click(onion.x, onion.y);
check(
'an empty frame shows the one before it faintly while onion skin is on',
art.frames.length === 3 &&
emptyThird === 32 * 32 - 1 &&
colourDistance(onionOn, onionOff) > 20 &&
colourDistance(onionOn, HEX[BLUE]) > 60,
JSON.stringify({ frames: art.frames.length, emptyThird, onionOff, onionOn }),
);
const seen = new Set();
for (let i = 0; i < 8; i++) {
seen.add(await json('__gm.pixelArt.previewIndex()'));
await sleep(70);
}
check('the preview plays through the frames', seen.size >= 2, JSON.stringify([...seen]));
// 48h-ii. The frame rows picture their frames and are dragged into another order, first on top.
const framesBefore = (await asset(id)).frames.map((f) => f.id);
const firstFrameRow = await listRow('frame', framesBefore[0]);
const lastFrameRow = await listRow('frame', framesBefore[2]);
const firstThumb = await json(`__gm.pixelAt(${firstFrameRow.thumb.x}, ${firstFrameRow.thumb.y})`);
const lastThumb = await json(`__gm.pixelAt(${lastFrameRow.thumb.x}, ${lastFrameRow.thumb.y})`);
await drag(firstFrameRow.x, firstFrameRow.y, lastFrameRow.x, lastFrameRow.y + 20, { steps: 8 });
await settle();
const framesAfter = (await asset(id)).frames.map((f) => f.id);
const frameRowsAfter = await json('__gm.pixelArt.frameRows()');
check(
'a frame row pictures its frame, and dragging the first row to the bottom makes it the last frame',
colourDistance(firstThumb, HEX[GREEN]) < 12 &&
colourDistance(lastThumb, HEX[GREEN]) > 60 &&
framesAfter.join() === [framesBefore[1], framesBefore[2], framesBefore[0]].join() &&
frameRowsAfter.join() === framesAfter.join(),
JSON.stringify({ firstThumb, lastThumb, framesBefore, framesAfter, frameRowsAfter }),
);
await undo();
await settle();
// 48i. Tile preview: the picture repeats around the canvas. Shown on the first frame, which is painted all over.
const first = (await asset(id)).frames[0].id;
const firstRowAt = await json(`__gm.pixelArt.frameRow(${JSON.stringify(first)})`);
await click(firstRowAt.x, firstRowAt.y);
await settle();
const tiles = await control('tiles');
await click(tiles.x, tiles.y);
await settle();
// Two pixels left of the canvas: the frame's pixel 30 in the tile beside it, painted green.
const left = await screen(-2, 2);
await shot('48-pixel-art');
await click(tiles.x, tiles.y);
await settle();
const leftOff = await screen(-2, 2);
check(
'the tile preview repeats the frame beside the canvas',
colourDistance(left, HEX[GREEN]) < 10 && colourDistance(leftOff, HEX[GREEN]) > 60,
JSON.stringify({ left, leftOff }),
);
// 48j. Resize with the anchor in the middle: the picture keeps its place relative to it.
await press_('resize');
let dialog = await json('__gm.pixelArt.resizeDialog()');
await click(dialog.width.x, dialog.width.y);
await press('a', { ctrl: true });
await typeText('40');
await press('Enter');
const centre = dialog.anchors['0.5,0.5'];
await click(centre.x, centre.y);
dialog = await json('__gm.pixelArt.resizeDialog()');
await click(dialog.confirm.x, dialog.confirm.y);
await settle();
art = await asset(id);
data = await cel(id, 0, 0);
const wide = data.slice(2 * 40, 3 * 40);
check(
'resizing to 40 wide from the middle moves every pixel 4 to the right and keeps the frames',
art.width === 40 &&
art.height === 32 &&
art.frames[0].id === first &&
wide[6] === RED &&
wide[13] === RED &&
wide[14] !== RED &&
wide[0] === 0,
JSON.stringify({ width: art.width, row: wide }),
);
await undo();
art = await asset(id);
check(
'one undo gives the canvas its old size back',
art.width === 32,
JSON.stringify({ width: art.width }),
);
// 48k. The palette: a colour changed there recolours the picture, as one undo step.
await swatch(RED);
// The field's middle is its hex text; a click there puts a caret in it, so the old value goes first.
const colorField = await control('color');
await click(colorField.x, colorField.y);
await press('a', { ctrl: true });
await typeText('123456');
await press('Enter');
await settle();
const paletteColor = () =>
json(
`(() => { const p = __gm.state.project; const a = p.assets.find((x) => x.id === ${JSON.stringify(id)});` +
` return p.assets.find((x) => x.id === a.props.palette).colors[${RED - 1}]; })()`,
);
const recolouredColor = await paletteColor();
const recoloured = await screen(5, 2);
await undo();
const restored = await paletteColor();
check(
'changing a palette colour recolours every pixel painted with it, and undo takes it back',
recolouredColor === 0x123456 && colourDistance(recoloured, '#123456') < 10 && restored === 0xff004d,
JSON.stringify({ recolouredColor, recoloured, restored }),
);
// 48l. A sprite wears the second frame; the picker offers every frame by name.
await mutate(`
const project = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
sprite.id = 'pa-sprite';
sprite.name = 'Pixel sprite';
sprite.props = { ...sprite.props, width: 128, height: 128, color: 0xffffff, texture: ${JSON.stringify(second)}, shader: '' };
project.assets.push(sprite);
scene.objects.push(make('pa-object', 'pa-sprite', '', ${Y}, 'Pixel object'));
__gm.state.select({ kind: 'asset', assetId: 'pa-sprite' })`);
await showTab('scene');
await evaluate(`__gm.lookAt(0, ${Y})`);
await settle();
await sleep(300);
const box = await json("__gm.objectRect('pa-object')");
const spriteAt = (px, py) =>
json(
`__gm.pixelAt(${Math.round(box.x - box.w / 2 + ((px + 0.5) / 32) * box.w)}, ${Math.round(box.y - box.h / 2 + ((py + 0.5) / 32) * box.h)})`,
);
const spriteRed = await spriteAt(5, 2);
const spriteBlue = await spriteAt(10, 10);
check(
'a sprite wearing the second frame shows that frame in the scene',
colourDistance(spriteRed, HEX[RED]) < 12 && colourDistance(spriteBlue, HEX[BLUE]) < 12,
JSON.stringify({ spriteRed, spriteBlue }),
);
const textureRow = await json("__gm.inspectorField('Texture', true)");
await click(textureRow.x, textureRow.y);
await settle();
const offers = (await json('__gm.menuRows()')) ?? [];
await press('Escape');
const name =
(await asset(id)) &&
(await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`));
check(
'the Texture row offers the asset and each of its frames',
[name, `${name} / Frame 1`, `${name} / Frame 2`, `${name} / Frame 3`].every((label) =>
offers.some((row) => row.endsWith(label)),
),
JSON.stringify({ name, offers }),
);
// The asset's menu makes a sprite and an animation that keys every frame in order.
const artRow = await json(`__gm.assetRow(${JSON.stringify(id)})`);
await click(artRow.x, artRow.y, 'right');
const animationEntry = await json("__gm.locate('Create animation from frames', false)");
if (animationEntry) await click(animationEntry.x, animationEntry.y);
await settle();
const keyed = await json(`(() => {
const p = __gm.state.project;
const s = __gm.state.selection;
const anim = s && s.kind === 'asset' ? p.assets.find((a) => a.id === s.assetId) : null;
const root = anim && p.assets.find((a) => a.id === anim.root);
return anim && anim.type === 'animation'
? { keys: anim.tracks[0].keys.map((k) => [k.frame, k.value]), rootTexture: root && root.props.texture }
: null;
})()`);
const frameIds = (await asset(id)).frames.map((f) => f.id);
check(
'Create animation from frames keys every frame in order on a sprite wearing the first',
animationEntry !== null &&
JSON.stringify(keyed?.keys) === JSON.stringify(frameIds.map((f, i) => [i, f])) &&
keyed.rootTexture === frameIds[0],
JSON.stringify({ entry: animationEntry !== null, keyed, frameIds }),
);
// 48m. Saved: the cels reach the server before the project does, and a reload paints them again.
await saved();
const pending = await json('__gm.pixelArt.pending()');
const doc = await api(`projects/${projectId}`);
const stored = doc.project.assets.find((a) => a.id === id);
const files = [...new Set(stored.frames.flatMap((f) => Object.values(f.cels)))];
const statuses = JSON.parse(
await evaluate(
`Promise.all(${JSON.stringify(files)}.map((f) => fetch('/game-maker/data/pixels/' + f).then((r) => r.status))).then((s) => JSON.stringify(s))`,
),
);
check(
'saving uploads every cel the project names, and the file names them',
pending === 0 && files.length >= 2 && statuses.every((s) => s === 200),
JSON.stringify({ pending, files: files.length, statuses }),
);
await navigate();
await sleep(500);
await evaluate(`__gm.openDocument('asset:' + ${JSON.stringify(id)})`);
await settle();
await sleep(500);
const back = await screen(5, 2);
check(
'after a reload the tab paints the saved pixels again',
(await json('__gm.pixelArt.current()')) === id && colourDistance(back, HEX[RED]) < 10,
JSON.stringify({ current: await json('__gm.pixelArt.current()'), back }),
);
await showTab('scene');
}