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 }; 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', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom })); 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), ); 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)'); }