The layer, frame and shape lists still had their add and delete buttons scattered below the list, each in its own layout. They now live in the shared SortableList, so every list works the same way. - SortableList gets an optional header with the title on the left and a + on the right, plus an optional x per row next to the eye. - The keepOne option hides the x on the last remaining row, so a pixel art image and a tilemap always keep one layer. - Pixel art layers and frames and tilemap layers use header, + and x. Only Merge down and Duplicate stay below the lists. - Vector art shapes get the x but no +. Add shape stays below the list and Delete shapes stays for a multi-selection. - The section headings in the pixel art column use the same style as the list titles and the inspector. Findings: - The wait loop added to check 47i for #196 only waited for the right half of the shape. If the texture landed between the two reads, the left half was still blue and the check failed. It now waits for both halves. Tests: - ./check.sh: 138 files, 2052 tests, exit 0. - GM_STEPS=39,40,47,48 (fresh): ALL PASSED, 156 checks (was 154). - GM_STEPS=47,48 (fresh, final): ALL PASSED, 86 checks. - Two new e2e checks: x on a shape (47) and x on a frame (48). The tilemap check in 40 now uses the x instead of Delete layer. - Counter-check: with the x not reporting to its owner, exactly the two new checks fail. - Check total 1266 in README and wiki is computed (1264 + 2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562 lines
22 KiB
JavaScript
562 lines
22 KiB
JavaScript
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();
|
||
|
||
// 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');
|
||
}
|