Files
feuerwerk/tools/e2e/48-pixelart.mjs
T
adminandClaude Opus 5 bbbf34109b
game-maker / gate (push) Successful in 2m20s
game-maker / suite (push) Successful in 8m25s
Lists duplicate, rename and edit values right in their rows
Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.

- SortableList gets three optional callbacks. onDuplicate shows a
  copy button between the eye and the delete button. onRename opens
  a text field on double click, Enter or a click outside commits,
  Esc cancels, an empty name is ignored. value puts a NumberField
  into the row.
- Dragging on the number changes the value and never moves the row.
  A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
  Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
  frames, rename, opacity in percent. The Name and Opacity fields
  below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
  the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
  DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
  ("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
  because the model never imports from commands/.

Findings:

- Double clicking a shape that is not selected yet only selects it.
  The first click moves the list up, so the second click misses the
  name. Module 47 selects the row first.

Tests:

- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
  check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
  layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:02:47 +02:00

626 lines
25 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();
// 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 }),
);
// 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');
}