Files
feuerwerk/tools/e2e/39-autotile.mjs
T
adminandClaude Opus 5 1a9f07c4ac
game-maker / gate (push) Successful in 1m50s
game-maker / suite (push) Successful in 5m59s
Autotiles are a new asset type with a tab that shows every tile of the floor (2x3) or wall (2x2) block and an animated sample pattern (#118)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:35:29 +02:00

296 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { colourDistance } from '../lib/harness.mjs';
import { openTab } from './lib/scaffold.mjs';
/**
* Autotiles (#118). The sheet is drawn here and pasted in like a real picture: two floor blocks
* (the two steps of an animation) beside each other and a wall block below them, every quarter tile
* in a colour of its own. That is what lets the checks below read a pixel off the board and say
* which quarter the editor put into which corner of a tile – the rule the whole feature is, and one
* no unit test can see through the renderer.
*
* Drawn here rather than seeded into the fixture: a texture asset in the fixture is a texture the
* texture steps would find first, and they are written for a project that starts without one.
*/
const QUAD = 24;
/**
* The sheet as a paste: 8 × 10 quarter tiles. Floor blocks at x = 0 and x = 96 (told apart by their
* blue), the wall block below the first, the rest transparent.
*/
const PASTE_SHEET = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = ${QUAD * 8};
canvas.height = ${QUAD * 10};
const g = canvas.getContext('2d');
for (let qy = 0; qy < 10; qy++)
for (let qx = 0; qx < 8; qx++) {
if (qy >= 6 && qx >= 4) continue;
const [r, gr, b] = qy < 6
? [40 + (qx % 4) * 60, 30 + qy * 40, qx < 4 ? 90 : 200]
: [30 + (qy - 6) * 60, 40 + qx * 40, 20];
g.fillStyle = 'rgb(' + r + ',' + gr + ',' + b + ')';
g.fillRect(qx * ${QUAD}, qy * ${QUAD}, ${QUAD}, ${QUAD});
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'autotiles.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/** The colour a quarter of a block was drawn in – the same formula as the paste above. */
function quadColour(kind, qx, qy, step = 0) {
const [r, g, b] =
kind === 'wall' ? [30 + qy * 60, 40 + qx * 40, 20] : [40 + qx * 60, 30 + qy * 40, step ? 200 : 90];
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
}
/** Whether the tile's quarters were taken from these block positions and drawn in these colours. */
async function tileReads(t, index, expected, kind, step = 0) {
const tile = await t.json(`__gm.autotile.tile(${index})`);
if (!tile) return { ok: false, detail: 'no such tile on the board' };
const positions = tile.quadrants.map((q) => [q.x, q.y]);
if (JSON.stringify(positions) !== JSON.stringify(expected))
return { ok: false, detail: JSON.stringify({ positions, expected }) };
const read = [];
for (const quadrant of tile.quadrants)
read.push(await t.json(`__gm.pixelAt(${Math.round(quadrant.at.x)}, ${Math.round(quadrant.at.y)})`));
const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, step));
const off = read.filter((colour, i) => colourDistance(colour, want[i]) > 12);
return { ok: off.length === 0, detail: JSON.stringify({ read, want }) };
}
export async function autotile(t) {
const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, settle, undo } = t;
await press('Escape');
await evaluate('__gm.state.select(null)');
// 39z. The sheet, the three blocks cut out of it and the autotile drawn from the two floor ones.
// Only the picture goes in the normal way (a paste, as in the nine-slice step); the frames
// and the asset are written into the model, which is what the unit tests cover.
await evaluate(PASTE_SHEET);
await sleep(2500);
const sheet = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
check(
'the autotile sheet is imported at its own size',
sheet?.w === 192 && sheet?.h === 240,
JSON.stringify(sheet),
);
await mutate(`
const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet.id)});
sheet.name = 'Autotiles';
sheet.props.filter = 'nearest';
sheet.frames.push(
{ id: 'at-floor-1', name: 'Floor 1', x: 0, y: 0, w: 96, h: 144 },
{ id: 'at-floor-2', name: 'Floor 2', x: 96, y: 0, w: 96, h: 144 },
{ id: 'at-wall', name: 'Wall', x: 0, y: 144, w: 96, h: 96 },
);
__gm.state.project.assets.push({
id: 'at-grass',
type: 'autotile',
name: 'Grass',
props: { kind: 'floor', fps: 4, playback: 'loop' },
pictures: ['at-floor-1', 'at-floor-2'],
});`);
// 39a. Opening the autotile asset opens its tab, with the board built for a floor block.
await evaluate("__gm.state.openAsset('at-grass')");
await settle();
let board = await json('__gm.autotile.board()');
check(
'opening an autotile shows the Autotile tab with the asset in it',
(await json('__gm.tab()')) === 'autotile' && board.assetId === 'at-grass',
JSON.stringify({ tab: await json('__gm.tab()'), board }),
);
check(
'a floor block gives 47 tiles at 48 px and nothing to complain about',
board.kind === 'floor' && board.combinations === 47 && board.tile === 48 && board.problem === '',
JSON.stringify(board),
);
check(
'the readout and the picture list name the two steps',
(await json('__gm.autotile.info()')).includes('2 pictures') &&
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
JSON.stringify({
info: await json('__gm.autotile.info()'),
strip: await json('__gm.autotile.pictures()'),
}),
);
// 39b. The pixels. While the animation runs the picture under the pointer changes, so it is cut
// down to one step for the reading and put back afterwards.
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
await settle();
const lone = await tileReads(
t,
0,
[
[0, 2],
[3, 2],
[0, 5],
[3, 5],
],
'floor',
);
check('a lone tile is drawn from the four convex corners of the block', lone.ok, lone.detail);
// Row 3, column 2 of the sample map is surrounded on all eight sides, so all four quarters of
// that tile come from the block's inside.
const surrounded = await json('__gm.autotile.sampleTile(3, 2)');
const inside = await tileReads(
t,
surrounded,
[
[1, 3],
[2, 3],
[1, 4],
[2, 4],
],
'floor',
);
check('a surrounded tile of the sample map is drawn from its inside', inside.ok, inside.detail);
await shot('39-autotile');
// 39c. The animation: two pictures at 4 fps step through on their own, and the picture decides
// the colours on screen.
await mutate(
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
);
await settle();
const seen = new Set();
for (let i = 0; i < 20 && seen.size < 2; i++) {
seen.add((await json('__gm.autotile.board()')).picture);
await sleep(120);
}
check('the two pictures run as an animation', seen.has(0) && seen.has(1), JSON.stringify([...seen]));
const step = (await json('__gm.autotile.board()')).picture;
const animated = await tileReads(
t,
0,
[
[0, 2],
[3, 2],
[0, 5],
[3, 5],
],
'floor',
step,
);
check('and the step it stands on is the one drawn', animated.ok, `${step}: ${animated.detail}`);
// 39d. Fit, pan and zoom.
await t.click(...Object.values(await json('__gm.autotile.fitAt()')));
await settle();
board = await json('__gm.autotile.board()');
const fitted = { zoom: board.zoom, pan: board.pan };
const centre = { x: board.rect.x + board.rect.w / 2, y: board.rect.y + board.rect.h - 40 };
await drag(centre.x, centre.y, centre.x + 60, centre.y - 40);
board = await json('__gm.autotile.board()');
check(
'dragging on the board pans it',
Math.round(board.pan.x - fitted.pan.x) === 60 && Math.round(board.pan.y - fitted.pan.y) === -40,
JSON.stringify({ fitted, now: board.pan }),
);
await wheel(centre.x, centre.y, -300);
await settle();
board = await json('__gm.autotile.board()');
check('the wheel zooms it', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom }));
await t.click(...Object.values(await json('__gm.autotile.fitAt()')));
await settle();
board = await json('__gm.autotile.board()');
check(
'Fit frames the board as before',
Math.abs(board.zoom - fitted.zoom) < 1e-6 && Math.abs(board.pan.x - fitted.pan.x) < 1e-6,
JSON.stringify({ fitted, now: { zoom: board.zoom, pan: board.pan } }),
);
// 39e. The kind: the same pictures are no wall block, and the tab says so instead of drawing
// something wrong.
const kindAt = await json('__gm.autotile.kindPickerAt()');
await t.click(kindAt.x, kindAt.y);
await clickText('Wall (2 × 2)');
await settle();
board = await json('__gm.autotile.board()');
check(
'switching the kind to Wall rejects the floor blocks with a reason',
board.kind === 'wall' && board.tiles === 0 && board.problem.includes('wall block'),
JSON.stringify(board),
);
// 39f. Its own block, though, is one: the two floor pictures out, the wall picture in.
for (let i = 0; i < 2; i++) {
const row = await json('__gm.autotile.pictureRowAt(0)');
await t.click(row.x, row.y);
const remove = await json('__gm.autotile.removePictureAt()');
await t.click(remove.x, remove.y);
await settle();
}
board = await json('__gm.autotile.board()');
check(
'an autotile without a picture says what it is missing',
board.pictures === 0 && board.problem.includes('No picture yet'),
JSON.stringify(board),
);
const addAt = await json('__gm.autotile.addPictureAt()');
await t.click(addAt.x, addAt.y);
await clickText('Autotiles / Wall');
await settle();
board = await json('__gm.autotile.board()');
check(
'a wall block gives the 16 tiles a wall has',
board.combinations === 16 && board.tile === 48 && board.problem === '',
JSON.stringify(board),
);
const wall = await tileReads(
t,
0,
[
[0, 0],
[3, 0],
[0, 3],
[3, 3],
],
'wall',
);
check('and its lone tile is drawn from its own four corners', wall.ok, wall.detail);
// 39g. Everything since the kind switch is one undo step each, and the floor autotile comes back.
for (let i = 0; i < 4; i++) await undo();
await settle();
board = await json('__gm.autotile.board()');
check(
'undo puts the pictures and the kind back',
board.kind === 'floor' && board.combinations === 47 && board.pictures === 2 && board.problem === '',
JSON.stringify(board),
);
// 39h. Dragging a row of the picture list reorders the animation.
const first = await json('__gm.autotile.pictureRowAt(0)');
const second = await json('__gm.autotile.pictureRowAt(1)');
await drag(first.x, first.y, second.x, second.y + 6);
await settle();
check(
'dragging a picture below the other reorders the steps',
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
JSON.stringify(['Autotiles / Floor 2', 'Autotiles / Floor 1']),
JSON.stringify(await json('__gm.autotile.pictures()')),
);
await undo();
await settle();
check(
'and undo puts the order back',
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
JSON.stringify(await json('__gm.autotile.pictures()')),
);
// Leave the tab as the rest of the suite expects to find the editor.
await openTab(t, 'scene');
await evaluate('__gm.state.select(null)');
}