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