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 }), ); 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'); }