The tile tab zoomed freely, so the pixels of a tile could end up
unevenly wide at odd factors. Scene view, container tab and animation
preview already snap to FREE_ZOOM_STEPS. The tile tab now does the
same, so every pixel editor zooms alike.
- The wheel steps through FREE_ZOOM_STEPS, counted in device pixels.
The board position is rounded to whole device pixels.
- New wheelSteps() in src/viewport/view.ts turns wheel events into
steps: one notch is one step, a trackpad gathers 50 px per step.
FreeViewport uses it too, so the rule exists only once.
- Fit takes the largest step at which the whole board fits. Refitting
on a new tab or board size stays as before.
- panAndZoom takes an optional zoom rule instead of limits. Graph and
node board keep zooming freely.
- A saved view from before the steps snaps to the nearest step.
- The cap parameter of frameBoxes from 4027afc has no user anymore.
It is removed together with its unit test.
Fit rounds down, and below 100 percent the steps are far apart. In the
small e2e window the 48 px board lands at 25 instead of 41 percent,
the 16 px board at 100 instead of 124 percent.
Tests:
- check.sh: 161 files, 2370 tests, exit 0 (-1 cap test,
+1 wheelSteps test)
- e2e 1-5 and 39: all passed, 169 checks. Three checks in 39 now
expect steps: Fit on a step with the next one too large, one notch
goes 0.25 to 0.5, 16 px tiles refit to at least 100 percent.
- Counter check: a free Fit factor fails exactly those three checks
(zoom 0.413 and 1.2396).
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];
|
||
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)');
|
||
}
|