Files
feuerwerk/tools/e2e/48-pixelart.mjs
T
adminandClaude Opus 5.5 3f57cf731d Keep a pixel frame worn through a shader alive while it is drawn
Changing a palette colour of pixel art flooded the console with Pixi
warnings: "[BindGroup] a 'textureSource' was destroyed while still
bound to a shader", and the same for its sampler. #293 had stopped
this for a drawing, but a frame of pixel art is worn as a cut of the
sheet. The cut has a source of its own, so the sheet looked unbound,
was freed at once and took the cut with it under the shader.

- The check whether a replaced picture is still in use gets its key
  too. TextureLibrary then asks the picture and every cut taken from
  it.
- A sheet with a bound cut waits like a bound picture did since #293,
  and both go together once the shader has taken the new frame.

Found on the way:

- The project the report came from has a sprite wearing a frame of
  an eleven-frame pixel art through a shader asset.
- A nine-sliced sprite wearing the frame showed no warning, even
  without the change. The first try of the e2e check used it.

Tests:
- Gate on melektaus-atx: 236 files, 3577 tests (+1)
- e2e 48: 45 checks (+1, sum 1741). 48l-iii puts a shader on the
  object wearing the second frame, changes a palette colour three
  times and counts the warnings.
- Counter-checks bite: without the cuts in the check the unit test
  fails, and 48l-iii counts 6 warnings for 3 changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:30:00 +02:00

868 lines
36 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()'),
}),
);
// A middle drag pans by what the pointer moved; the way back leaves the view as the steps below expect it.
const origin = await json('__gm.pixelArt.pixelAt(0, 0)');
const middle = await json('__gm.pixelArt.pixelAt(16, 16)');
await drag(middle.x, middle.y, middle.x + 30, middle.y + 20, { steps: 6, button: 'middle' });
const panned = await json('__gm.pixelArt.pixelAt(0, 0)');
await drag(middle.x + 30, middle.y + 20, middle.x, middle.y, { steps: 6, button: 'middle' });
check(
'a middle drag pans the canvas by as much as the pointer moved, and back',
panned.x - origin.x === 30 &&
panned.y - origin.y === 20 &&
JSON.stringify(await json('__gm.pixelArt.pixelAt(0, 0)')) === JSON.stringify(origin),
JSON.stringify({ origin, panned, back: await json('__gm.pixelArt.pixelAt(0, 0)') }),
);
// 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();
// Filled shapes stands beside the shape tools only.
await press_('ellipse');
const filledBox = await control('filled');
await click(filledBox.x, filledBox.y);
await settle();
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();
// The opacity stands in the layer's row, in percent.
const opacity = (await listRow('layer', top)).opacity;
await click(opacity.x, opacity.y);
await press('a', { ctrl: true });
await typeText('50');
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 50 % in its row 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-i. Scrubbing the opacity drags the number, not the row: the order stays, the canvas follows
// while the pointer moves, and the whole scrub is one undo step back to 50 %.
const orderBeforeScrub = await json('__gm.pixelArt.layerRows()');
const scrubFrom = (await listRow('layer', top)).opacity;
await drag(scrubFrom.x, scrubFrom.y, scrubFrom.x + 30, scrubFrom.y + 12, { steps: 10 });
await settle();
const scrubbed = (await asset(id)).layers.at(-1).opacity;
const orderAfterScrub = await json('__gm.pixelArt.layerRows()');
const scrubText = (await listRow('layer', top)).opacity?.value;
await undo();
await settle();
const scrubUndone = (await asset(id)).layers.at(-1).opacity;
check(
'scrubbing the opacity in a row changes it without moving the row, and one undo takes it back',
scrubbed === 0.8 &&
scrubText === '80' &&
orderAfterScrub.join() === orderBeforeScrub.join() &&
scrubUndone === 0.5,
JSON.stringify({ scrubbed, scrubText, orderBeforeScrub, orderAfterScrub, scrubUndone }),
);
// 48f-ii-a. The duplicate mark copies the layer right above it and paints on the copy; a
// double-click on its name edits it in the row. Both are undone again.
const layersBeforeCopy = (await asset(id)).layers;
const copyMark = (await listRow('layer', top)).duplicate;
if (copyMark) await click(copyMark.x, copyMark.y);
await settle();
const withLayerCopy = await asset(id);
const layerCopy = withLayerCopy.layers.at(-1);
check(
'the duplicate mark on a layer row puts a copy above it, with the same pixels, and paints on it',
copyMark != null &&
withLayerCopy.layers.length === layersBeforeCopy.length + 1 &&
layerCopy.name === `${layersBeforeCopy.at(-1).name} copy` &&
layerCopy.opacity === 0.5 &&
withLayerCopy.frames[0].cels[layerCopy.id] === withLayerCopy.frames[0].cels[top] &&
(await json('__gm.pixelArt.layerId()')) === layerCopy.id,
JSON.stringify({ copyMark, layers: withLayerCopy.layers, cels: withLayerCopy.frames[0].cels }),
);
await undo();
await settle();
const nameSpot = (await listRow('layer', top)).name;
await t.doubleClick(nameSpot.x, nameSpot.y);
await settle();
const editing = (await listRow('layer', top)).renaming;
await press('a', { ctrl: true });
await typeText('Ink');
await press('Enter');
await settle();
const renamedLayer = (await asset(id)).layers.find((l) => l.id === top);
check(
'a double-click on a layer name edits it in the row, and Enter keeps the new name',
editing?.value === layersBeforeCopy.at(-1).name &&
renamedLayer?.name === 'Ink' &&
(await listRow('layer', top)).renaming === null,
JSON.stringify({ editing, renamed: renamedLayer?.name }),
);
await undo();
await settle();
// 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.
const frameMark = (await listRow('frame', await json('__gm.pixelArt.frameId()'))).duplicate;
await click(frameMark.x, frameMark.y);
await settle();
art = await asset(id);
const second = await json('__gm.pixelArt.frameId()');
check(
'the duplicate mark on a frame row 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();
// 48h-iii. The cross on a frame row takes that frame out; undo brings it back.
const lastFrameAgain = await listRow('frame', framesBefore[2]);
if (lastFrameAgain?.remove) await click(lastFrameAgain.remove.x, lastFrameAgain.remove.y);
await settle();
const framesCrossed = (await asset(id)).frames.map((f) => f.id);
check(
'the cross on a frame row removes that frame',
lastFrameAgain?.remove != null && framesCrossed.join() === framesBefore.slice(0, 2).join(),
JSON.stringify({ remove: lastFrameAgain?.remove, framesBefore, framesCrossed }),
);
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 }),
);
// 48k-ii. Remove takes the picked colour out of the middle (#201): its pixels turn transparent,
// the later colours move down one index, and every other pixel keeps the colour it had.
const paletteOf = () =>
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; })()`,
);
const colorsBefore = await paletteOf();
const allCels = async () => {
const a = await asset(id);
const out = [];
for (let f = 0; f < a.frames.length; f++)
for (let l = 0; l < a.layers.length; l++) out.push(await cel(id, f, l));
return out;
};
const celsBefore = await allCels();
await swatch(GREEN);
const removeButton = await control('removeColor');
await click(removeButton.x, removeButton.y);
await settle();
const colorsAfter = await paletteOf();
const celsAfter = await allCels();
const shifted = celsBefore.every((before, c) =>
before.every((i, p) => celsAfter[c][p] === (i === GREEN ? 0 : i > GREEN ? i - 1 : i)),
);
// The colour after it moves into its index, so an index that moved down still names its colour.
check(
'Remove takes the picked colour out: its pixels go transparent, every other pixel keeps its colour',
removeButton.text === 'Remove' &&
colorsAfter.length === colorsBefore.length - 1 &&
colorsAfter[GREEN - 1] === colorsBefore[GREEN] &&
celsBefore.some((before) => before.includes(GREEN)) &&
shifted,
JSON.stringify({
text: removeButton.text,
before: colorsBefore.length,
after: colorsAfter.length,
shifted,
}),
);
await undo();
const celsUndone = await allCels();
check(
'and one undo gives back the colour and every pixel',
JSON.stringify(await paletteOf()) === JSON.stringify(colorsBefore) &&
JSON.stringify(celsUndone) === JSON.stringify(celsBefore),
JSON.stringify({ colors: (await paletteOf()).length }),
);
// 48k-iii. The palette runs across the whole width of the panel (#201).
await control('palette');
const span = await json('__gm.pixelArt.sideSpan()');
const firstSwatch = await json(`__gm.pixelArt.swatch(0)`);
const cell = (await json(`__gm.pixelArt.swatch(1)`)).x - firstSwatch.x - 2;
let perRow = 1;
while (perRow < 32 && (await json(`__gm.pixelArt.swatch(${perRow})`)).y === firstSwatch.y) perRow++;
const lastInRow = await json(`__gm.pixelArt.swatch(${perRow - 1})`);
// Swatches are whole pixels wide, so the row may stop up to a pixel per swatch short.
const reach = { left: firstSwatch.x - cell / 2, right: lastInRow.x + cell / 2 };
const removeRight = removeButton.x + removeButton.w / 2;
check(
'the palette fills the width of the panel, more than eight swatches to a row, and Remove ends inside it',
perRow > 8 &&
Math.abs(reach.left - span.left) <= 1 &&
span.right - reach.right <= perRow &&
removeRight <= span.right + 0.5,
JSON.stringify({ span, reach, perRow, cell, removeRight }),
);
// 48k-iv. Each tool shows only the options that change what it paints (#201).
const options = async () => ({
brush: (await json(`__gm.pixelArt.control('brush')`)) !== null,
filled: (await json(`__gm.pixelArt.control('filled')`)) !== null,
contiguous: (await json(`__gm.pixelArt.control('contiguous')`)) !== null,
});
const shownFor = {};
for (const tool of ['pencil', 'fill', 'picker', 'rect']) {
await press_(tool);
shownFor[tool] = await options();
}
const filledToggle = await control('filled');
await click(filledToggle.x, filledToggle.y);
await settle();
shownFor.filledRect = await options();
// The box stays where it was while the brush beside it goes, so the same spot switches it off.
await click(filledToggle.x, filledToggle.y);
await settle();
shownFor.outlinedAgain = (await json(`__gm.pixelArt.control('brush')`)) !== null;
const want = {
pencil: { brush: true, filled: false, contiguous: false },
fill: { brush: false, filled: false, contiguous: true },
picker: { brush: false, filled: false, contiguous: false },
rect: { brush: true, filled: true, contiguous: false },
filledRect: { brush: false, filled: true, contiguous: false },
outlinedAgain: true,
};
check(
'brush, Filled shapes and Contiguous show only with the tools they change',
JSON.stringify(shownFor) === JSON.stringify(want),
JSON.stringify(shownFor),
);
await press_('pencil');
await shot('48-pixel-tools');
// 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 }),
);
// 48l-ii. Nine-slice on pixel art (#308): 12-pixel borders at scale 2 draw the corner at 2 units a
// pixel instead of 4, so red (5, 2) and blue (10, 10) move to units (11, 5) and (21, 21). The same
// spots are read before the borders are set, so a sprite that ignored them could not pass.
const unitAt = (ux, uy) =>
json(
`__gm.pixelAt(${Math.round(box.x - box.w / 2 + (ux / 128) * box.w)}, ${Math.round(box.y - box.h / 2 + (uy / 128) * box.h)})`,
);
const slicedSpots = async () => ({ red: await unitAt(11, 5), blue: await unitAt(21, 21) });
const unsliced = await slicedSpots();
await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
art.border = { left: 12, top: 12, right: 12, bottom: 12 };
art.props.sliceScale = 2;`);
await settle();
await sleep(300);
const sliced = await slicedSpots();
const isSliced = (spots) =>
colourDistance(spots.red, HEX[RED]) < 12 && colourDistance(spots.blue, HEX[BLUE]) < 12;
check(
'a sprite wearing a pixel frame with borders draws its corners at the nine-slice scale',
isSliced(sliced) && !isSliced(unsliced),
JSON.stringify({ sliced, unsliced }),
);
await shot('48-pixel-nine-slice');
await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
delete art.border;
delete art.props.sliceScale;`);
await settle();
// 48l-iii. The object wears the frame through a shader asset: its mesh names a cut of the sheet,
// which has a source of its own. A palette colour changed under it freed the sheet at
// once and the cut with it, and Pixi warned for the source and its sampler every time.
await mutate(`
Object.assign(__gm.state.activeScene.objects.find((o) => o.id === 'pa-object').overrides,
{ shader: 'adjust-colors', saturation: 0 })`);
await settle();
await sleep(300);
const boundLogFrom = t.consoleLog.length;
const waiting = [];
for (const color of [0x3050a0, 0x70b030, 0xff004d]) {
await mutate(`
const p = __gm.state.project;
const art = p.assets.find((a) => a.id === ${JSON.stringify(id)});
p.assets.find((a) => a.id === art.props.palette).colors[${RED - 1}] = ${color};`);
await settle();
await sleep(200);
waiting.push((await json('__gm.picturesWaiting()')).length);
}
const boundWarnings = t.consoleLog.slice(boundLogFrom).filter((line) => line.includes('BindGroup'));
check(
'a palette colour changed under a frame worn through a shader warns of no destroyed texture',
boundWarnings.length === 0 && waiting.every((n) => n === 0),
JSON.stringify({ warnings: boundWarnings.length, waiting }),
);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === 'pa-object');
delete o.overrides.shader;
delete o.overrides.saturation;`);
await settle();
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 }),
);
// 48m-ii. A divider stays where it was left: its place is kept in the browser, not in the project,
// so the reload brings the wider panel back. Dragged back after, for the modules to come.
const keptSizes = () => json(`JSON.parse(localStorage.getItem('game-maker.panel-sizes') ?? '{}')`);
const columnBefore = await json('__gm.leftColumn()');
const bars = await json('__gm.panelSplitters()');
await drag(bars.left.x, bars.left.y, bars.left.x + 40, bars.left.y, { steps: 6 });
await settle();
const columnWide = await json('__gm.leftColumn()');
const sizesWide = await keptSizes();
await navigate();
await sleep(500);
const columnKept = await json('__gm.leftColumn()');
const barsKept = await json('__gm.panelSplitters()');
await drag(barsKept.left.x, barsKept.left.y, barsKept.left.x - 40, barsKept.left.y, { steps: 6 });
await settle();
const columnBack = await json('__gm.leftColumn()');
check(
'a panel dragged wider is as wide after a reload, and the browser keeps its size',
columnWide.assets.w === columnBefore.assets.w + 40 &&
sizesWide.left === columnWide.assets.w &&
columnKept.assets.w === columnWide.assets.w &&
columnBack.assets.w === columnBefore.assets.w &&
(await keptSizes()).left === columnBefore.assets.w,
JSON.stringify({
before: columnBefore.assets.w,
wide: columnWide.assets.w,
kept: columnKept.assets.w,
back: columnBack.assets.w,
sizesWide,
sizes: await keptSizes(),
}),
);
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 }),
);
// 48n. The header of a list is a band like every group head (#290): a click on it, clear of the
// plus, folds the rows and what stands under them away; another click brings them back.
const layerHead = await control('layer:head');
const mergeBefore = await control('layer:merge');
await click(layerHead.x, layerHead.y);
await settle();
const foldedHead = await control('layer:head');
const foldedList = {
folded: foldedHead.folded,
h: foldedHead.h,
merge: await control('layer:merge'),
stored: await json(`localStorage.getItem('game-maker.folded-sections')`),
};
await shot('48-list-folded');
await click(foldedHead.x, foldedHead.y);
await settle();
const openHead = await control('layer:head');
check(
'a click on a list header folds its rows and the button under them away, another brings them back',
Boolean(mergeBefore) &&
layerHead.folded === false &&
foldedList.folded === true &&
foldedList.h <= layerHead.h - 30 &&
foldedList.merge === null &&
String(foldedList.stored).includes('pixelart:LAYERS') &&
openHead.folded === false &&
openHead.h === layerHead.h &&
(await control('layer:merge')) !== null,
JSON.stringify({ foldedList, open: openHead.folded, h: [layerHead.h, openHead.h] }),
);
await showTab('scene');
}