Files
feuerwerk/tools/e2e/48-pixelart.mjs
T
adminandClaude Opus 5 66d6aafbb4
game-maker / gate (push) Successful in 2m24s
game-maker / suite (push) Failing after 7m40s
Pixel art can be drawn in its own tab with layers and frames (#130)
Textures are uploaded files named by their content, so they cannot
be edited in place. Pixel art is therefore its own asset type, like
vector art. Its pixels live in the project and the tab rasters them
into a texture that sprites, tiles and animations can use.

- New asset types Pixel art and Palette. The first pixel art of a
  project gets a palette with the 16 PICO-8 colours in the same undo
  step. Every pixel is a palette index, 0 is transparent.
- Each layer on each frame is its own file data/pixels/<sha256>.px.
  The project only holds the names, so undo swaps names. The browser
  computes the hash and uploads new cels before the project is saved.
- New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201
  and 422 for broken data. data/pixels/ is git-ignored.
- Tools: pencil, eraser, fill (contiguous or all pixels of a colour),
  eyedropper, line, rectangle and ellipse (outline or filled), brush
  size 1 to 8.
- Layers can be added, deleted, renamed, reordered, hidden, faded and
  merged down. Frames can be added, duplicated, deleted and reordered,
  with onion skin and a small playing preview.
- Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and
  Resize... with a 3x3 anchor.
- All frames sit side by side in one image. Each frame is a region
  like a cut texture frame, so texture rows offer "Asset / Frame 2"
  and the context menu has Create animation from frames.

Decisions:
- Merging down drops the opacity of the upper layer. Indexed colours
  know no blends.
- Only the last palette colour can be removed. Otherwise the indices
  of every image using the palette would shift.
- Undo stores the whole layer per stroke instead of pixel diffs.
- Alt+click picks a colour. It is a mouse gesture, not a shortcut.

Follow-up issues: #197 import from texture, #198 selection and
flipping, #199 PNG export, #200 more tools.

Tests:
- ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in
  tests/core/pixel-art.test.ts, 4 from the icon tables.
- e2e module 48 fresh: ALL PASSED, 29 checks, console empty.
- Counter-probes bite: five on the unit tests, upload order (2 tests
  fail) and cels not uploaded in the browser (1 check fails).
- Check total 1259 in README is computed (1232 measured in CI + 27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:33:44 +02:00

486 lines
19 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();
};
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. Hide and opacity change what the canvas shows, not the pixels.
const blueShown = await screen(10, 10);
await press_('layer:hide');
const hidden = await screen(10, 10);
await press_('layer:hide');
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(
'a hidden layer is not drawn, and a layer at half opacity is mixed with the one below',
colourDistance(blueShown, HEX[BLUE]) < 10 &&
colourDistance(hidden, HEX[GREEN]) < 10 &&
art.layers.at(-1).opacity === 0.5 &&
colourDistance(half, mixed) < 12,
JSON.stringify({ blueShown, hidden, half, mixed, opacity: art.layers.at(-1).opacity }),
);
// 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]));
// 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');
}