The groups of the inspector had a small grey label as their title, in the same grid as a property row, so with many rows one group hardly stood apart from the next. Now every group has a band as its head, from edge to edge of the panel, and a click on it folds the group. - A band runs across the padding of its column in the header colour, with the title and a mark that says whether the group is open. The titles stay as they are, as answered in the issue. - A click on a band folds its group: the rows go, the band stays and the next band moves up against it. A control in the band (Open, + Variable, + Property) does its own job and folds nothing. - A placed object has a group SWITCHES under its name, asset and parent: Enabled, Keep on scene load and the switches of a behavior. An asset has SWITCHES and PROPERTIES, and a group without rows is left out. - The same bands head the tilemap panel, the Variables tab, the side of the Pixel art tab and the shape list of vector art. A list with a title (Layers, Frames, Shapes) folds with a click on its header, clear of its plus. - ui/sections.ts holds the rules as pure functions, SectionStack is the column and SectionHeader the band. Every title of the inspector goes through addTitle or sectionHeader in inspector/host.ts. Decided here: - A fold is kept for good, in localStorage of this browser. The issue asked whether for the session or for good and the answer left it open. - A fold is remembered by title, per panel. PROPERTIES folded once is folded on every object, asset and node. A number in a title does not count. - A behavior asset has all its rows under SWITCHES. A Variables asset and a state machine get a group SCOPE. - A greyed out control in a band takes the click too. Without that the click fell through to the band and folded the group. - The Curve tab has no column of this kind and is unchanged. Found on the way: - The inspector of a sprite is taller than the 800 pixel window of the checks now. The hooks that hand out a row scroll it into view first, and module 28 scrolls before it looks for a row by its text. Tests: - Gate: 202 files, 3008 tests (2982 + 26), exit 0 - e2e: no complete run for this state. Modules 28 to 61 passed alone (749 checks). A full run was cut off in the accounts module at 1492 passes without a failure, the modules after it and the smokes are missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it. - 26 unit tests in tests/editor/sections.test.ts - Four e2e checks with a real mouse, three in module 02 and one in module 48 - 25 unit counter-probes all fail tests, run on stein2 - Screenshots of the bands looked at before the last build, none after it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
776 lines
32 KiB
JavaScript
776 lines
32 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()'),
|
||
}),
|
||
);
|
||
|
||
// 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 }),
|
||
);
|
||
// 48n. The header of a list is a band like every group head (#290): a click on it, clear of the
|
||
// plus, folds the rows and what stands under them away; another click brings them back.
|
||
const layerHead = await control('layer:head');
|
||
const mergeBefore = await control('layer:merge');
|
||
await click(layerHead.x, layerHead.y);
|
||
await settle();
|
||
const foldedHead = await control('layer:head');
|
||
const foldedList = {
|
||
folded: foldedHead.folded,
|
||
h: foldedHead.h,
|
||
merge: await control('layer:merge'),
|
||
stored: await json(`localStorage.getItem('game-maker.folded-sections')`),
|
||
};
|
||
await shot('48-list-folded');
|
||
await click(foldedHead.x, foldedHead.y);
|
||
await settle();
|
||
const openHead = await control('layer:head');
|
||
check(
|
||
'a click on a list header folds its rows and the button under them away, another brings them back',
|
||
Boolean(mergeBefore) &&
|
||
layerHead.folded === false &&
|
||
foldedList.folded === true &&
|
||
foldedList.h <= layerHead.h - 30 &&
|
||
foldedList.merge === null &&
|
||
String(foldedList.stored).includes('pixelart:LAYERS') &&
|
||
openHead.folded === false &&
|
||
openHead.h === layerHead.h &&
|
||
(await control('layer:merge')) !== null,
|
||
JSON.stringify({ foldedList, open: openHead.folded, h: [layerHead.h, openHead.h] }),
|
||
);
|
||
await showTab('scene');
|
||
}
|