The scene view stopped at 24 device pixels per world pixel, which is too little to work on single texels of small pixel art. - FREE_ZOOM_STEPS gains the steps 32, 48 and 64. All of them are whole numbers, so every texel keeps the same width on screen. - The tile editor takes the same steps, so its fit can now land above 24 on a small board. - The unit test and the step list in e2e 39 follow the new top step. Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
486 lines
19 KiB
JavaScript
486 lines
19 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { openTab } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Tiles (#118, #131). 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, and in the corner left over a terrain block of 16 px tiles,
|
||
* 12 × 8 quarters of 8 px.
|
||
*/
|
||
const TERRAIN_QUAD = 8;
|
||
const TERRAIN_AT = { x: QUAD * 4, y: QUAD * 6 };
|
||
|
||
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});
|
||
}
|
||
for (let qy = 0; qy < 8; qy++)
|
||
for (let qx = 0; qx < 12; qx++) {
|
||
g.fillStyle = 'rgb(' + (20 + qx * 20) + ',' + (40 + qy * 25) + ',240)';
|
||
g.fillRect(${TERRAIN_AT.x} + qx * ${TERRAIN_QUAD}, ${TERRAIN_AT.y} + qy * ${TERRAIN_QUAD}, ${TERRAIN_QUAD}, ${TERRAIN_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. */
|
||
export function quadColour(kind, qx, qy, step = 0) {
|
||
const [r, g, b] =
|
||
kind === 'terrain'
|
||
? [20 + qx * 20, 40 + qy * 25, 240]
|
||
: 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.
|
||
* The step the animation stands on and the four pixels are read in **one** call into the page: read
|
||
* one at a time, a running animation can step between them, and that is a flake, not a finding.
|
||
*/
|
||
async function tileReads(t, index, expected, kind) {
|
||
const tile = await t.json(`__gm.tile.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 points = tile.quadrants.map((q) => [Math.round(q.at.x), Math.round(q.at.y)]);
|
||
const shot = await t.json(
|
||
`(() => ({ step: __gm.tile.board().picture, read: ${JSON.stringify(points)}` +
|
||
`.map(([x, y]) => __gm.pixelAt(x, y)) }))()`,
|
||
);
|
||
const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, shot.step));
|
||
const off = shot.read.filter((colour, i) => colourDistance(colour, want[i]) > 12);
|
||
return { ok: off.length === 0, step: shot.step, detail: JSON.stringify({ read: shot.read, want }) };
|
||
}
|
||
|
||
export async function tile(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 blocks cut out of it and the tile 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 tile 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 },
|
||
{ id: 'at-plain', name: 'Plain', x: 0, y: 0, w: 48, h: 48 },
|
||
{ id: 'at-terrain', name: 'Terrain', x: ${TERRAIN_AT.x}, y: ${TERRAIN_AT.y}, w: 96, h: 64 },
|
||
);
|
||
__gm.state.project.assets.push({
|
||
id: 'at-grass',
|
||
type: 'tile',
|
||
name: 'Grass',
|
||
props: { kind: 'floor', fps: 4, playback: 'loop' },
|
||
pictures: ['at-floor-1', 'at-floor-2'],
|
||
});`);
|
||
|
||
// 39a. Opening the tile 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.tile.board()');
|
||
check(
|
||
'opening a tile shows the Tile tab with the asset in it',
|
||
(await json('__gm.tab()')) === 'tile' && 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.tile.info()')).includes('2 pictures') &&
|
||
JSON.stringify((await json('__gm.tile.pictures()')).pictures) ===
|
||
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
|
||
JSON.stringify({
|
||
info: await json('__gm.tile.info()'),
|
||
strip: await json('__gm.tile.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 – taken turned by half a tile, the way RPG Maker does
|
||
// it, which is what makes the joint to the next tile seamless.
|
||
const surrounded = await json('__gm.tile.sampleTile(3, 2)');
|
||
const inside = await tileReads(
|
||
t,
|
||
surrounded,
|
||
[
|
||
[2, 4],
|
||
[1, 4],
|
||
[2, 3],
|
||
[1, 3],
|
||
],
|
||
'floor',
|
||
);
|
||
check('a surrounded tile of the sample map is drawn from its inside', inside.ok, inside.detail);
|
||
await shot('39-tile');
|
||
|
||
// 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.tile.board()')).picture);
|
||
await sleep(120);
|
||
}
|
||
check('the two pictures run as an animation', seen.has(0) && seen.has(1), JSON.stringify([...seen]));
|
||
const animated = await tileReads(
|
||
t,
|
||
0,
|
||
[
|
||
[0, 2],
|
||
[3, 2],
|
||
[0, 5],
|
||
[3, 5],
|
||
],
|
||
'floor',
|
||
);
|
||
check('and the step it stands on is the one drawn', animated.ok, `${animated.step}: ${animated.detail}`);
|
||
|
||
// 39d. Fit, pan and zoom.
|
||
await t.click(...Object.values(await json('__gm.tile.fitAt()')));
|
||
await settle();
|
||
board = await json('__gm.tile.board()');
|
||
const fitted = { zoom: board.zoom, pan: board.pan };
|
||
// Fit used to stop at 100 %, which left a board measured in 16 px tiles a stamp in the corner.
|
||
// It takes the scene view's steps (device pixels, `FREE_ZOOM_STEPS`): the largest one the board
|
||
// fits at, so the next one up would run past the margin.
|
||
const STEPS = [0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 16, 24, 32, 48, 64];
|
||
const fills = (b) => {
|
||
const at = STEPS.findIndex((step) => Math.abs(step - b.zoom * b.ratio) < 1e-9);
|
||
const fits = (step) =>
|
||
(b.size.w * step) / b.ratio <= b.rect.w - 32 + 1e-6 &&
|
||
(b.size.h * step) / b.ratio <= b.rect.h - 32 + 1e-6;
|
||
return at >= 0 && fits(STEPS[at]) && (at === STEPS.length - 1 || !fits(STEPS[at + 1]));
|
||
};
|
||
check(
|
||
'Fit fills the view with the board, at a step of the scene view',
|
||
fills(board),
|
||
JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }),
|
||
);
|
||
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.tile.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.tile.board()');
|
||
check(
|
||
'the wheel zooms it one step up',
|
||
STEPS.indexOf(board.zoom * board.ratio) === STEPS.indexOf(fitted.zoom * board.ratio) + 1,
|
||
JSON.stringify({ fitted, now: board.zoom }),
|
||
);
|
||
// The captions are text in screen space at one size for all: before, each was shrunk to its
|
||
// tile on its own inside the zoomed board, so `N` came out large and soft and `NESW-ne,se,sw,nw`
|
||
// tiny. Two lines for a notched tile, none of them running into another.
|
||
// Too small to read, they are left out, so the check zooms in until there is room for them.
|
||
let captions = await json('__gm.tile.captions()');
|
||
for (let i = 0; i < 6 && !captions.shown; i++) {
|
||
await wheel(centre.x, centre.y, -300);
|
||
await settle();
|
||
captions = await json('__gm.tile.captions()');
|
||
}
|
||
board = await json('__gm.tile.board()');
|
||
const overlap = (a, b) =>
|
||
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 1 &&
|
||
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 1;
|
||
const crowded = captions.boxes.some((a, i) => captions.boxes.slice(i + 1).some((b) => overlap(a, b)));
|
||
check(
|
||
'the captions are sharp text of one size, split in two lines and clear of each other',
|
||
captions.shown &&
|
||
JSON.stringify(captions.scales) === '[1]' &&
|
||
captions.fontSize >= 6 &&
|
||
captions.fontSize <= 10 &&
|
||
captions.texts.includes('NESW-ne,se,sw,nw') &&
|
||
captions.boxes.length > captions.texts.length &&
|
||
!crowded,
|
||
JSON.stringify({ ...captions, boxes: captions.boxes.length, crowded, zoom: board.zoom }),
|
||
);
|
||
await shot('39-captions');
|
||
await t.click(...Object.values(await json('__gm.tile.fitAt()')));
|
||
await settle();
|
||
board = await json('__gm.tile.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.tile.kindPickerAt()');
|
||
await t.click(kindAt.x, kindAt.y);
|
||
await clickText('Wall (2 × 2)');
|
||
await settle();
|
||
board = await json('__gm.tile.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.tile.pictureRowAt(0)');
|
||
await t.click(row.x, row.y);
|
||
const remove = await json('__gm.tile.removePictureAt()');
|
||
await t.click(remove.x, remove.y);
|
||
await settle();
|
||
}
|
||
board = await json('__gm.tile.board()');
|
||
check(
|
||
'a tile 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.tile.addPictureAt()');
|
||
await t.click(addAt.x, addAt.y);
|
||
await clickText('Autotiles / Wall');
|
||
await settle();
|
||
board = await json('__gm.tile.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 tile comes back.
|
||
for (let i = 0; i < 4; i++) await undo();
|
||
await settle();
|
||
board = await json('__gm.tile.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.tile.pictureRowAt(0)');
|
||
const second = await json('__gm.tile.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.tile.pictures()')).pictures) ===
|
||
JSON.stringify(['Autotiles / Floor 2', 'Autotiles / Floor 1']),
|
||
JSON.stringify(await json('__gm.tile.pictures()')),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
check(
|
||
'and undo puts the order back',
|
||
JSON.stringify((await json('__gm.tile.pictures()')).pictures) ===
|
||
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
|
||
JSON.stringify(await json('__gm.tile.pictures()')),
|
||
);
|
||
|
||
// 39i. A plain tile (#131): the floor blocks are no single square tile, and one square frame is –
|
||
// drawn as it is, the same four quarters wherever it lies, whatever lies around it.
|
||
const plainAt = await json('__gm.tile.kindPickerAt()');
|
||
await t.click(plainAt.x, plainAt.y);
|
||
await clickText('None (1 × 1)');
|
||
await settle();
|
||
board = await json('__gm.tile.board()');
|
||
check(
|
||
'switching the kind to None rejects the floor blocks as not square',
|
||
board.kind === 'none' && board.tiles === 0 && board.problem.includes('one square tile'),
|
||
JSON.stringify(board),
|
||
);
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-plain']");
|
||
await settle();
|
||
board = await json('__gm.tile.board()');
|
||
check(
|
||
'a square frame is one plain tile of 48 px',
|
||
board.combinations === 1 &&
|
||
board.tile === 48 &&
|
||
board.problem === '' &&
|
||
(await json('__gm.tile.info()')).endsWith(' · 48 px · 1 picture · 1 tile'),
|
||
JSON.stringify({ board, info: await json('__gm.tile.info()') }),
|
||
);
|
||
const plainQuarters = [
|
||
[0, 0],
|
||
[1, 0],
|
||
[0, 1],
|
||
[1, 1],
|
||
];
|
||
const plainLone = await tileReads(t, 0, plainQuarters, 'floor');
|
||
const plainInside = await tileReads(t, await json('__gm.tile.sampleTile(3, 2)'), plainQuarters, 'floor');
|
||
check(
|
||
'and it is drawn as it is, alone and surrounded alike',
|
||
plainLone.ok && plainInside.ok,
|
||
`${plainLone.detail} | ${plainInside.detail}`,
|
||
);
|
||
await mutate(
|
||
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
|
||
);
|
||
await undo();
|
||
await settle();
|
||
board = await json('__gm.tile.board()');
|
||
check(
|
||
'undo makes it a floor block again',
|
||
board.kind === 'floor' && board.combinations === 47 && board.problem === '',
|
||
JSON.stringify(board),
|
||
);
|
||
|
||
// 39j. A terrain block: whole tiles where the sheet draws them, composed where it does not.
|
||
const terrainAt = await json('__gm.tile.kindPickerAt()');
|
||
await t.click(terrainAt.x, terrainAt.y);
|
||
await clickText('Terrain (6 × 4)');
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-terrain']");
|
||
await settle();
|
||
board = await json('__gm.tile.board()');
|
||
check(
|
||
'a 6 × 4 terrain block gives 47 tiles at 16 px',
|
||
board.kind === 'terrain' && board.combinations === 47 && board.tile === 16 && board.problem === '',
|
||
JSON.stringify(board),
|
||
);
|
||
// A third of the tile size, and the view untouched since the last Fit: the board frames itself
|
||
// again, at 100 % or more, instead of shrinking to a third where it stood.
|
||
check(
|
||
'smaller tiles are framed again, at 100 % or more',
|
||
board.zoom * board.ratio >= 1 && fills(board),
|
||
JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }),
|
||
);
|
||
const wholeTile = (tx, ty) => [
|
||
[2 * tx, 2 * ty],
|
||
[2 * tx + 1, 2 * ty],
|
||
[2 * tx, 2 * ty + 1],
|
||
[2 * tx + 1, 2 * ty + 1],
|
||
];
|
||
const terrainLone = await tileReads(t, 0, wholeTile(0, 3), 'terrain');
|
||
const terrainInside = await tileReads(
|
||
t,
|
||
await json('__gm.tile.sampleTile(3, 2)'),
|
||
wholeTile(2, 1),
|
||
'terrain',
|
||
);
|
||
// Row 5, column 3 of the sample map has the hole in its north-east and a gap in its south-west.
|
||
const terrainPair = await tileReads(
|
||
t,
|
||
await json('__gm.tile.sampleTile(5, 3)'),
|
||
wholeTile(5, 2),
|
||
'terrain',
|
||
);
|
||
check(
|
||
'the lone, the surrounded and the two-notch tile are the whole tiles the sheet draws',
|
||
terrainLone.ok && terrainInside.ok && terrainPair.ok,
|
||
`${terrainLone.detail} | ${terrainInside.detail} | ${terrainPair.detail}`,
|
||
);
|
||
// An edge with a notch is not in the sheet: the left edge tile with the notch put in, and the
|
||
// quarter below the notch brought along from the same notch tile.
|
||
const composed = [
|
||
[2, 2],
|
||
[9, 2],
|
||
[2, 3],
|
||
[9, 3],
|
||
];
|
||
let composedIndex = -1;
|
||
for (let i = 0; i < board.combinations && composedIndex < 0; i++) {
|
||
const quadrants = (await json(`__gm.tile.tile(${i})`))?.quadrants ?? [];
|
||
if (JSON.stringify(quadrants.map((q) => [q.x, q.y])) === JSON.stringify(composed)) composedIndex = i;
|
||
}
|
||
const edgeNotch =
|
||
composedIndex < 0
|
||
? { ok: false, detail: 'no such tile' }
|
||
: await tileReads(t, composedIndex, composed, 'terrain');
|
||
check('an edge with a notch is composed from the edge and the notch tile', edgeNotch.ok, edgeNotch.detail);
|
||
await shot('39-terrain');
|
||
await mutate(
|
||
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
|
||
);
|
||
await undo();
|
||
await settle();
|
||
board = await json('__gm.tile.board()');
|
||
check(
|
||
'and undo makes it a floor block again',
|
||
board.kind === 'floor' && board.combinations === 47 && board.problem === '',
|
||
JSON.stringify(board),
|
||
);
|
||
|
||
// Leave the tab as the rest of the suite expects to find the editor.
|
||
await openTab(t, 'scene');
|
||
await evaluate('__gm.state.select(null)');
|
||
}
|