import { edge, node } from '../lib/builders.mjs'; import { colourDistance } from '../lib/harness.mjs'; import { LANES, MAKE_OBJECT, openTab, pickAssetType } from './lib/scaffold.mjs'; import { quadColour } from './39-tile.mjs'; /** * Tilemaps (#128): a placed grid painted with the tiles of step 39, which is where the sheet * and the `at-grass` tile come from – this step needs it and is run after it. * * The point of the checks below is that a painted cell really shows the quarters its neighbours * call for: they read pixels off the scene view, because everything between the model and the * screen (the uvs, the mesh, the transform of the placed object) is exactly what a unit test * cannot see through. */ const LANE = LANES.tilemap; const MAP_OBJECT = 'tm-object'; const COLUMNS = 6; const ROWS = 4; /** * A widget of the tilemap panel (#142), scrolled into view first. On a short window the layers sit * below the fold – and a click on a point outside the panel lands on whatever is drawn there instead. */ async function inPanel(t, hook) { for (let i = 0; i < 15; i++) { const rect = (await t.json('__gm.tilemap.panel()')).panel; if (!rect) return null; const point = await t.json(hook); if (point && point.y > rect.y + 8 && point.y < rect.y + rect.h - 8) return point; await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, point && point.y < rect.y ? -200 : 200); await t.settle(); } return null; } /** Clicks such a widget; returns false when it could not be brought into view. */ async function clickInPanel(t, hook) { const point = await inPanel(t, hook); if (point) await t.click(point.x, point.y); return Boolean(point); } /** * The − or + button of an edge row in the inspector's GRID EDGES block (#136), scrolled into view * first. Both glyphs are on screen many times over, so the button is the one on the label's line. */ async function edgeButton(t, label, glyph) { for (let i = 0; i < 15; i++) { const rect = await t.json('__gm.inspectorRect()'); const within = (p) => p.x > rect.x && p.x < rect.x + rect.w; const row = (await t.json(`__gm.locateAll(${JSON.stringify(label)})`)).find(within); if (row && row.y > rect.y + 8 && row.y < rect.y + rect.h - 8) { const buttons = (await t.json(`__gm.locateAll(${JSON.stringify(glyph)})`)).filter( (p) => within(p) && Math.abs(p.y - row.y) < 6 && p.x > row.x, ); return buttons[0] ?? null; } await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, row && row.y < rect.y ? -200 : 200); await t.settle(); } return null; } /** * Every 0.2 s: puts a still tile into cell 2,1 of its owner, a placed tilemap, logs what `Tile at` * reads there, then turns the cell's middle into world units and back and logs the cell it lands in. * A timer rather than On start, so the cell changes after the views have drawn the map once. */ const tileGraph = { nodes: [ node('tl-start', 'OnTimer', 0, 0, { interval: 0.2 }), node('tl-owner', 'Owner', 0, 200), node('tl-set', 'SetTile', 300, 0, { cellX: 2, cellY: 1, tile: 'tl-still' }), node('tl-at', 'TileAt', 300, 350, { cellX: 2, cellY: 1 }), node('tl-log-tile', 'Log', 650, 0), node('tl-middle', 'CellToWorld', 300, 550, { cellX: 2, cellY: 1 }), node('tl-cell', 'WorldToCell', 650, 550), node('tl-log-x', 'Log', 950, 0), node('tl-log-y', 'Log', 1250, 0), ], edges: [ edge('tl-e1', 'tl-start', 'exec', 'tl-set', 'exec'), edge('tl-e2', 'tl-owner', 'object', 'tl-set', 'tilemap'), edge('tl-e3', 'tl-owner', 'object', 'tl-at', 'tilemap'), edge('tl-e4', 'tl-owner', 'object', 'tl-middle', 'tilemap'), edge('tl-e5', 'tl-owner', 'object', 'tl-cell', 'tilemap'), edge('tl-e6', 'tl-set', 'exec', 'tl-log-tile', 'exec'), edge('tl-e7', 'tl-at', 'tile', 'tl-log-tile', 'message'), edge('tl-e8', 'tl-middle', 'x', 'tl-cell', 'x'), edge('tl-e9', 'tl-middle', 'y', 'tl-cell', 'y'), edge('tl-e10', 'tl-log-tile', 'exec', 'tl-log-x', 'exec'), edge('tl-e11', 'tl-cell', 'cellX', 'tl-log-x', 'message'), edge('tl-e12', 'tl-log-x', 'exec', 'tl-log-y', 'exec'), edge('tl-e13', 'tl-cell', 'cellY', 'tl-log-y', 'message'), ], }; /** A behavior that logs the tile and the cell of every `On collision enter` (#160), in three lines. */ function triggerGraph(prefix) { const id = (name) => `${prefix}-${name}`; return { nodes: [ node(id('enter'), 'OnCollisionEnter', 0, 0), node(id('tile'), 'Log', 400, 0), node(id('x'), 'Log', 700, 0), node(id('y'), 'Log', 1000, 0), ], edges: [ edge(id('e1'), id('enter'), 'exec', id('tile'), 'exec'), edge(id('e2'), id('enter'), 'tile', id('tile'), 'message'), edge(id('e3'), id('tile'), 'exec', id('x'), 'exec'), edge(id('e4'), id('enter'), 'cellX', id('x'), 'message'), edge(id('e5'), id('x'), 'exec', id('y'), 'exec'), edge(id('e6'), id('enter'), 'cellY', id('y'), 'message'), ], }; } /** Whether a box lies inside another, give or take a pixel of rounding. */ function inside(inner, outer) { return ( inner.x >= outer.x - 1 && inner.y >= outer.y - 1 && inner.x + inner.w <= outer.x + outer.w + 1 && inner.y + inner.h <= outer.y + outer.h + 1 ); } /** The four quarters of the cell, read in one call: a running animation must not step between them. */ async function cellQuarters(t, x, y) { const centre = await t.json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`); if (!centre) return null; // A quarter of a 48 px cell is 24 px, so these four points sit in the middle of each quarter. const points = [ [centre.x - 12, centre.y - 12], [centre.x + 12, centre.y - 12], [centre.x - 12, centre.y + 12], [centre.x + 12, centre.y + 12], ].map(([px, py]) => [Math.round(px), Math.round(py)]); return t.json(`${JSON.stringify(points)}.map(([x, y]) => __gm.pixelAt(x, y))`); } /** Whether a cell is drawn from these quadrants of the block, by the colours they were drawn in. */ async function cellShows(t, x, y, quadrants) { const read = await cellQuarters(t, x, y); if (!read) return { ok: false, detail: 'the cell is not on screen' }; const want = quadrants.map(([qx, qy]) => quadColour('floor', qx, qy)); const off = read.filter((colour, i) => colourDistance(colour, want[i]) > 12); return { ok: off.length === 0, detail: JSON.stringify({ read, want }) }; } /** `over` laid at `alpha` on `under`, both `#rrggbb` as `pixelAt` reads them. */ function blend(over, under, alpha) { const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); return `#${[1, 3, 5] .map((i) => Math.round(channel(over, i) * alpha + channel(under, i) * (1 - alpha))) .map((c) => c.toString(16).padStart(2, '0')) .join('')}`; } export async function tilemap(t) { const { check, json, evaluate, mutate, click, clickText, drag, press, typeText, shot, settle, undo, redo, select, } = t; const { saved, api, navigate, projectId } = t; await press('Escape'); await evaluate('__gm.state.select(null)'); await openTab(t, 'scene'); // 40a. The asset is made the way any asset is made: through the + menu of the asset panel. await clickText('+'); await pickAssetType(t, 'Tilemap'); await settle(); const made = await json( "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'tilemap').at(-1);" + ' return a ? { id: a.id, name: a.name, layers: a.layers.length } : null; })()', ); check( 'the + menu makes a tilemap, and it starts with one layer', made?.name === 'Tilemap' && made.layers === 1, JSON.stringify(made), ); if (!made) return; // A small grid in a lane of its own: the origin is crowded by the objects of earlier steps, and // a 20 × 15 map at 48 px would cover half of them. await mutate(` const project = __gm.state.project; const map = project.assets.find((a) => a.id === ${JSON.stringify(made.id)}); map.props.columns = ${COLUMNS}; map.props.rows = ${ROWS}; __gm.state.activeScene.objects.push({ id: ${JSON.stringify(MAP_OBJECT)}, assetId: ${JSON.stringify(made.id)}, name: 'Ground', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.lookAt(0, ${LANE});`); await settle(); const unselected = await json('__gm.tilemap.panel()'); const cellUnselected = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await settle(); // 40b. Painting has a panel of its own beside the scene view (#142): tools, tiles and layers. const panel = await json('__gm.tilemap.panel()'); const inspectorRect = await json('__gm.inspectorRect()'); check( 'selecting a placed tilemap opens the tilemap panel between the scene view and the inspector', !unselected.shown && panel.shown && panel.title === 'Tilemap · Tilemap' && Math.abs(panel.panel.w - inspectorRect.w) <= 1 && panel.viewport.x + panel.viewport.w <= panel.panel.x && panel.panel.x + panel.panel.w <= inspectorRect.x && Math.abs(panel.panel.y - panel.viewport.y) <= 1 && Math.abs(panel.panel.h - panel.viewport.h) <= 1 && panel.viewport.w < unselected.viewport.w, JSON.stringify({ unselected, panel, inspectorRect }), ); const section = await json( '(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.tileButtonAt("at-grass") !== null,' + ' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()', ); // The view keeps the world where it is on screen, rather than its centre, which shifted the map // by half the panel's width. const cellWithPanel = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); check( 'the map stays put on screen when the panel opens', Math.abs(cellWithPanel.x - cellUnselected.x) <= 1 && Math.abs(cellWithPanel.y - cellUnselected.y) <= 1, JSON.stringify({ cellUnselected, cellWithPanel }), ); // Staying put leaves the right of the map behind the panel; the steps below paint across it. await evaluate(`__gm.lookAt(0, ${LANE})`); await settle(); // Found by its title rather than a hook: whatever is left of the section in the inspector would // have no hook pointing at it any more. const paintingTitles = await json('__gm.locateAll("PAINTING")'); check( 'the panel offers tools, tiles and layers, and the inspector no longer does', section.tools && section.grass && Boolean(section.layers) && paintingTitles.length === 1 && inside({ ...paintingTitles[0], w: 0, h: 0 }, panel.panel) && Boolean(await json("__gm.inspectorField('Columns')")), JSON.stringify({ section, paintingTitles }), ); await shot('40-tilemap-panel'); // The tiles are a grid of pictures (#139), and each shows its block's lone tile: the four convex // corners, read off the palette in one call like a cell of the map. await inPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'); const swatch = await json(`(() => { const s = __gm.tilemap.tileSwatch("at-grass"); if (!s) return null; const q = s.picture.w / 4; const points = [[q, q], [3 * q, q], [q, 3 * q], [3 * q, 3 * q]] .map(([x, y]) => [Math.round(s.picture.x + x), Math.round(s.picture.y + y)]); return { columns: s.columns, read: points.map(([x, y]) => __gm.pixelAt(x, y)) }; })()`); const lone = [ [0, 2], [3, 2], [0, 5], [3, 5], ].map(([qx, qy]) => quadColour('floor', qx, qy)); check( 'the palette shows a tile as its picture, the lone tile of the block', swatch !== null && swatch.read.every((colour, i) => colourDistance(colour, lone[i]) <= 12), JSON.stringify({ swatch, want: lone }), ); check( 'and puts several tiles side by side in the panel', swatch !== null && swatch.columns >= 3, JSON.stringify(swatch), ); // Clicked through the hook rather than by its text: the tile's own row in the asset panel // reads "Grass" as well, and a click there would only select the tile. check( 'the tile can be picked in the panel', await clickInPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'), JSON.stringify(await json('__gm.tilemap.panel()')), ); await settle(); const paint = await json('__gm.tilemap.paint()'); check( 'picking a tile reaches for the brush by itself', paint.tileId === 'at-grass' && paint.tool === 'brush', JSON.stringify(paint), ); // 40c. Painting: a drag across the map paints every cell it passes, in one undo step. const scene = await json( `(() => ({ tab: __gm.tab(), rect: __gm.objectRect(${JSON.stringify(MAP_OBJECT)}),` + ' selection: __gm.state.selection, paint: __gm.tilemap.paint() }))()', ); check( 'the map is on screen in the scene tab, selected, with a brush in hand', scene.tab === 'scene' && Boolean(scene.rect) && scene.paint.tool === 'brush', JSON.stringify(scene), ); const from = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`); const to = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 3, 1)`); await drag(from.x, from.y, to.x, to.y); await settle(); const layerId = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[0].id; const painted = () => json(`__gm.tilemap.cells(${JSON.stringify(made.id)}, ${JSON.stringify(layerId)})`); let cells = await painted(); check( 'dragging the brush paints every cell along the way', Object.keys(cells).sort().join(' ') === '1,1 2,1 3,1', JSON.stringify(cells), ); await undo(); await settle(); check( 'and the whole stroke is one undo step', Object.keys(await painted()).length === 0, JSON.stringify(await painted()), ); // 40c-ii. The cells show while the stroke is still under way, before it is a command (#284): the // brush writes them in place and has to say so, or the view keeps drawing the old map. const drawnTiles = async () => (await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`))?.tiles; const drawnBefore = await drawnTiles(); await t.mouse('mouseMoved', from.x, from.y); await t.mouse('mousePressed', from.x, from.y); for (let i = 1; i <= 12; i++) { await t.mouse('mouseMoved', from.x + ((to.x - from.x) * i) / 12, from.y, 'left', { buttons: 1 }); await t.sleep(16); } await settle(); const drawnHeld = await drawnTiles(); await t.mouse('mouseReleased', to.x, to.y); await settle(); const drawnReleased = await drawnTiles(); await undo(); await settle(); check( 'the cells of a stroke are drawn while the button is still held', drawnBefore === 0 && drawnHeld === 3 && drawnReleased === 3 && Object.keys(await painted()).length === 0, JSON.stringify({ drawnBefore, drawnHeld, drawnReleased, left: await painted() }), ); // 40c-iii. However fast the pointer crosses cells, a stroke draws the scene at most once a frame, // and the panels keep their rows instead of drawing every label again (#347). const along = []; for (let x = 0; x < COLUMNS; x++) along.push(await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, 2)`)); await evaluate( 'window.__texts = []; window.__fillText = CanvasRenderingContext2D.prototype.fillText;' + ' CanvasRenderingContext2D.prototype.fillText = function (text, ...rest) {' + ' window.__texts.push(String(text)); return window.__fillText.call(this, text, ...rest); }', ); await t.mouse('mouseMoved', along[0].x, along[0].y); await t.mouse('mousePressed', along[0].x, along[0].y); await settle(); const counts = () => json('({ redraws: __gm.sceneRedraws(), drawn: __gm.renderStats().drawn })'); const countsBefore = await counts(); await evaluate('window.__texts = []'); for (let pass = 0; pass < 4; pass++) for (let i = 0; i < COLUMNS; i++) { const p = along[pass % 2 ? COLUMNS - 1 - i : i]; await t.mouse('mouseMoved', p.x, p.y, 'left', { buttons: 1 }); } await settle(); const countsAfter = await counts(); // The fps readout is the one text that changes on its own. const redrawnTexts = (await json('window.__texts')).filter((text) => !/fps$/.test(text)); await evaluate('CanvasRenderingContext2D.prototype.fillText = window.__fillText'); await t.mouse('mouseReleased', along[0].x, along[0].y); await settle(); const strokeCells = Object.keys(await painted()).length; await undo(); await settle(); const redraws = countsAfter.redraws - countsBefore.redraws; const frames = countsAfter.drawn - countsBefore.drawn; check( 'a fast stroke draws the scene at most once a frame and no list label again', strokeCells === COLUMNS && redraws <= frames && redrawnTexts.length === 0, JSON.stringify({ strokeCells, redraws, frames, redrawnTexts: redrawnTexts.slice(0, 12) }), ); // 40d. The rectangle tool fills the box a drag spans. await clickInPanel(t, '__gm.tilemap.toolAt("rect")'); await settle(); const corner = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); const far = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`); await drag(corner.x, corner.y, far.x, far.y); await settle(); cells = await painted(); check( 'the rectangle tool fills the box between the two corners', Object.keys(cells).length === 9 && cells['1,1'] === 'at-grass' && cells['2,2'] === 'at-grass', JSON.stringify(Object.keys(cells).sort()), ); // The same box again changes no cell, so it leaves no step behind (#212): the one undo takes // back the first rectangle, and redo brings it back for the blocks below. await drag(corner.x, corner.y, far.x, far.y); await settle(); await undo(); await settle(); const afterUndo = Object.keys(await painted()).length; await redo(); await settle(); check( 'a rectangle over cells that already hold its tile is no undo step', afterUndo === 0 && Object.keys(await painted()).length === 9, JSON.stringify({ afterUndo, redone: Object.keys(await painted()).length }), ); // 40e. The pixels. While the animation runs the block under the pointer changes between the two // steps, so it is cut down to one for the reading and put back afterwards (as step 39 does). await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); await settle(); // The rest of 40e: the middle of a 3 × 3 block is surrounded, so all four of its quarters come // from the inside of the block – taken turned by half a tile, as RPG Maker does it. The // corner cell has neighbours east and south only, so it shows the convex corner up left. const middle = await cellShows(t, 1, 1, [ [2, 4], [1, 4], [2, 3], [1, 3], ]); check('a cell surrounded by its own kind is drawn from the inside of the block', middle.ok, middle.detail); const corner00 = await cellShows(t, 0, 0, [ [0, 2], [1, 2], [0, 3], [1, 3], ]); check('and the top-left cell of the block shows the convex corner there', corner00.ok, corner00.detail); await shot('40-tilemap'); await mutate( "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", ); await settle(); // 40f. One mesh per tile, not one per cell – the whole point of drawing it as a mesh. const drawn = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); check( 'nine cells of one tile are one draw call', drawn.tiles === 9 && drawn.drawCalls === 1 && drawn.layers === 1, JSON.stringify(drawn), ); // 40g. Erasing takes cells out again. await clickInPanel(t, '__gm.tilemap.toolAt("erase")'); await settle(); const one = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`); await click(one.x, one.y); await settle(); cells = await painted(); check( 'the eraser clears the cell it is clicked on and leaves the rest', Object.keys(cells).length === 8 && cells['2,2'] === undefined, JSON.stringify(Object.keys(cells).sort()), ); await undo(); await settle(); // 40n. Fill and pick (#133). The 3 × 3 block sits in the corner of the 6 × 4 grid, so the empty // cells around it are one region of 15: a click fills exactly those, in one undo step. await clickInPanel(t, '__gm.tilemap.toolAt("fill")'); await settle(); const empty = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 5, 3)`); await click(empty.x, empty.y); await settle(); cells = await painted(); check( 'the fill tool fills the connected empty cells up to the edge of the grid', Object.keys(cells).length === COLUMNS * ROWS && Object.values(cells).every((id) => id === 'at-grass'), JSON.stringify(Object.keys(cells).sort()), ); await undo(); await settle(); check( 'and the whole fill is one undo step', Object.keys(await painted()).length === 9, JSON.stringify(Object.keys(await painted()).sort()), ); // With no tile chosen, whatever tile the paint state holds afterwards came from the map. await evaluate("__gm.state.setTilemapPaint({ tileId: '' })"); await clickInPanel(t, '__gm.tilemap.toolAt("pick")'); await settle(); const inBlock = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`); await click(inBlock.x, inBlock.y); await settle(); const picked = await json('__gm.tilemap.paint()'); check( 'the pick tool takes the tile of the cell and hands it back to the tool before it', picked.tileId === 'at-grass' && picked.tool === 'fill' && Object.keys(await painted()).length === 9, JSON.stringify(picked), ); // Held Alt: Pick for as long as it is down, the brush again once it is up. await clickInPanel(t, '__gm.tilemap.toolAt("brush")'); await evaluate("__gm.state.setTilemapPaint({ tileId: '' })"); await settle(); await t.keyDown('Alt'); await settle(); const holding = await json('__gm.tilemap.paint()'); await click(inBlock.x, inBlock.y, 'left', { alt: true }); const whileHeld = await json('__gm.tilemap.paint()'); await t.keyUp('Alt'); await settle(); const released = await json('__gm.tilemap.paint()'); check( 'holding Alt picks, and letting go of it brings the brush back', holding.tool === 'pick' && whileHeld.tool === 'pick' && whileHeld.tileId === 'at-grass' && released.tool === 'brush' && released.tileId === 'at-grass' && Object.keys(await painted()).length === 9, JSON.stringify({ holding, whileHeld, released }), ); // 40h. A second layer draws above the first, and hiding it leaves its cells alone. await clickInPanel(t, '__gm.tilemap.addLayerAt()'); await settle(); let layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); check( 'Add layer appends a second layer, on top of the first', layers.length === 2 && layers[1].cells === 0, JSON.stringify(layers), ); await clickInPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'); await settle(); const above = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 0)`); await click(above.x, above.y); await settle(); layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); check( 'painting goes onto the chosen layer, not the first one', layers[0].cells === 9 && layers[1].cells === 1, JSON.stringify(layers), ); // Hiding the upper layer (the list is drawn top-down, newest first) leaves its cell in the model // and takes it out of the drawing. const row = await inPanel(t, '__gm.tilemap.layerRowAt(0)'); if (row) await click(row.visible.x, row.visible.y); await settle(); const hidden = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); const drawnNow = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); check( 'switching a layer off keeps its cells and takes it out of the drawing', hidden[1].hidden === true && hidden[1].cells === 1 && drawnNow.layers === 1 && drawnNow.tiles === 9, JSON.stringify({ hidden, drawnNow }), ); await undo(); await settle(); // 40l-ii. The rows are dragged into another order: the upper layer dropped below the lower one is // drawn first. Undone again, so the blocks below find the layers as they were. const upperRow = await inPanel(t, '__gm.tilemap.layerRowAt(0)'); const lowerRow = await inPanel(t, '__gm.tilemap.layerRowAt(1)'); const orderBefore = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); if (upperRow && lowerRow) await drag(upperRow.name.x, upperRow.y, lowerRow.name.x, lowerRow.y + 20); await settle(); const orderAfter = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); const topRowNow = await json('(__gm.tilemap.layerRowAt(0) || {}).id'); check( 'dragging the upper layer row below the lower one puts that layer first in the drawing order', orderBefore.length === 2 && orderAfter.join() === [orderBefore[1], orderBefore[0]].join() && topRowNow === orderBefore[0], JSON.stringify({ orderBefore, orderAfter, topRowNow }), ); await undo(); await settle(); // With no layer chosen the topmost is painted on; the hook points at that row's cross. const chosenLayer = (await json('__gm.tilemap.paint()')).layerId; const paintedLayer = orderBefore.includes(chosenLayer) ? chosenLayer : orderBefore.at(-1); const removeAt = await inPanel(t, '__gm.tilemap.removeLayerAt()'); if (removeAt?.enabled) await click(removeAt.x, removeAt.y); await settle(); const leftOver = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); const lastOne = await json('__gm.tilemap.removeLayerAt()'); check( 'the cross takes out the layer painted on, and the last layer has none', leftOver.length === 1 && !leftOver.includes(paintedLayer) && lastOne?.enabled === false, JSON.stringify({ paintedLayer, leftOver, lastOne }), ); await undo(); await settle(); // 40m. A double-click on the painted layer's name edits it in its row, and Show singles it out in // the scene view (#138). It sits here, not at the end, because here both layers exist: the // upper one is painted on and holds the cell (4,0), the lower one the 3 × 3 block. const nameSpot = await inPanel(t, '__gm.tilemap.layerNameAt()'); if (nameSpot) await t.doubleClick(nameSpot.x, nameSpot.y); await settle(); const nameEditing = (await json('__gm.tilemap.layerNameAt()'))?.editing; await press('a', { ctrl: true }); await typeText('Roofs'); await press('Enter'); await settle(); layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); const renamedRow = await json('(__gm.tilemap.layerRowAt(0) || {}).id'); const nameNow = await json('__gm.tilemap.layerNameAt()'); check( 'a double-click on a layer name edits it in the row, and the list shows the new name', nameEditing?.value === 'Layer 2' && layers[1].name === 'Roofs' && layers[0].name === 'Layer 1' && renamedRow === layers[1].id && nameNow?.value === 'Roofs' && nameNow.editing === null, JSON.stringify({ nameEditing, layers, renamedRow, nameNow }), ); // 40p. The Z field in the row gives the painted layer a z of its own, added to the map's (#174): // at −5 the upper layer is sorted in below the map itself and below the lower layer. const zField = await inPanel(t, '__gm.tilemap.layerZAt()'); if (zField) { await click(zField.x, zField.y); await press('a', { ctrl: true }); await typeText('-5'); await press('Enter'); } await settle(); await shot('40-tilemap-layer-z'); const withZ = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); const slot = (order, id) => order.indexOf(id); const orderZ = await json('__gm.drawOrder()'); const upperSlot = `${MAP_OBJECT}#${withZ[1].id}`; const lowerSlot = `${MAP_OBJECT}#${withZ[0].id}`; await undo(); await settle(); const orderBack = await json('__gm.drawOrder()'); const zBack = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1].z; check( 'the Z field sorts the painted layer below the map and the other layer, and undo puts it back', withZ[1].z === -5 && withZ[0].z === 0 && slot(orderZ, upperSlot) >= 0 && slot(orderZ, upperSlot) < slot(orderZ, MAP_OBJECT) && slot(orderZ, upperSlot) < slot(orderZ, lowerSlot) && zBack === 0 && slot(orderBack, MAP_OBJECT) < slot(orderBack, lowerSlot) && slot(orderBack, lowerSlot) < slot(orderBack, upperSlot), JSON.stringify({ withZ, orderZ, orderBack, zBack }), ); // 40p-iii. Sort by Y (#228) draws the painted layer row by row: its one cell (4,0) becomes the // slot of row 0 in place of the whole layer's. Undone again for the blocks below. const sortBox = await inPanel(t, '__gm.tilemap.sortByYAt()'); if (sortBox) await click(sortBox.x, sortBox.y); await settle(); const sortedLayer = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1]; const orderSorted = await json('__gm.drawOrder()'); const boxSorted = await json('__gm.tilemap.sortByYAt()'); await undo(); await settle(); const orderUnsorted = await json('__gm.drawOrder()'); check( 'Sort by Y draws the painted layer as one slot per row, and undo makes it one piece again', sortBox?.checked === false && sortedLayer.sortByY === true && boxSorted?.checked === true && orderSorted.includes(`${upperSlot}#0`) && !orderSorted.includes(upperSlot) && orderUnsorted.includes(upperSlot) && !orderUnsorted.includes(`${upperSlot}#0`), JSON.stringify({ sortBox, sortedLayer, boxSorted, orderSorted, orderUnsorted }), ); // 40p-ii. The duplicate mark copies the painted layer right above it, cells and all, and paints // on the copy. Undone again: the blocks below count on two layers. const copyMark = await inPanel(t, '__gm.tilemap.duplicateLayerAt()'); if (copyMark) await click(copyMark.x, copyMark.y); await settle(); const withCopy = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); const paintedCopy = (await json('__gm.tilemap.paint()')).layerId; check( 'the duplicate mark on a layer row puts a copy with the same cells above it and paints on it', copyMark !== null && withCopy.length === 3 && withCopy[2].name === 'Roofs copy' && withCopy[2].cells === withCopy[1].cells && paintedCopy === withCopy[2].id, JSON.stringify({ copyMark, withCopy, paintedCopy }), ); await undo(); await settle(); // Pixels again, so the animation is cut down to one step for the reading, as in 40e. await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); await settle(); const quarter = async (x, y) => (await cellQuarters(t, x, y))?.[0] ?? null; const lowerWhole = await quarter(1, 1); const emptyCell = await quarter(5, 3); const upperWhole = await quarter(4, 0); /** Chooses an entry of the Show dropdown: the topmost hit, which is the open menu. */ const show = async (label) => { const button = await inPanel(t, '__gm.tilemap.focusAt()'); if (button) await click(button.x, button.y); await settle(); const entry = await t.locate(label, true); await click(entry.x, entry.y); await settle(); }; await show('Others faded'); const faded = await quarter(1, 1); // The lower layer is laid over the background at 30 %, so its colour lands in between. const mix = lowerWhole && emptyCell && blend(lowerWhole, emptyCell, 0.3); const focusLabel = await json('(__gm.tilemap.focusAt() || {}).label'); check( 'Others faded draws the other layers at 30 % and the painted one whole', Boolean(mix) && colourDistance(faded, mix) <= 16 && colourDistance(await quarter(4, 0), upperWhole) <= 12 && focusLabel === 'Others faded', JSON.stringify({ faded, mix, lowerWhole, emptyCell, focusLabel }), ); await show('Active layer only'); const solo = await quarter(1, 1); const soloInfo = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); check( 'Active layer only leaves the other layers out and keeps the painted one', colourDistance(solo, emptyCell) <= 12 && colourDistance(await quarter(4, 0), upperWhole) <= 12 && soloInfo.drawCalls === 1, JSON.stringify({ solo, emptyCell, soloInfo }), ); // Only the selected map is singled out; deselected it is drawn whole, and the model never changed. // The brush is taken up first, so the deselection has a tool to put down (#142). await evaluate("__gm.state.setTilemapPaint({ tool: 'brush' })"); const paintBefore = await json('__gm.tilemap.paint()'); const cellSelected = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); await evaluate('__gm.state.select(null)'); await settle(); const deselected = await quarter(1, 1); layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); check( 'a deselected map is drawn whole again, and Show hid nothing in the model', colourDistance(deselected, lowerWhole) <= 12 && layers.every((l) => !l.hidden), JSON.stringify({ deselected, lowerWhole, layers }), ); const gone = await json('__gm.tilemap.panel()'); const cellGone = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); const paintAfter = await json('__gm.tilemap.paint()'); check( 'deselecting closes the panel, gives the scene view its room back and puts the brush down', !gone.shown && gone.viewport.w > panel.viewport.w && Math.abs(cellGone.x - cellSelected.x) <= 1 && Math.abs(cellGone.y - cellSelected.y) <= 1 && paintAfter.tool === 'select' && paintAfter.tileId === paintBefore.tileId && paintAfter.layerId === paintBefore.layerId, JSON.stringify({ gone, cellSelected, cellGone, paintBefore, paintAfter }), ); // Two objects selected are no single map to paint, even when both are tilemaps; and the panel // belongs to the scene tab, so another tab takes it along while the map stays selected. const second = await json( `__gm.state.activeScene.objects.find((o) => o.id !== ${JSON.stringify(MAP_OBJECT)} && !o.parentId).id`, ); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT, second], objectId: MAP_OBJECT }); await settle(); const withTwo = (await json('__gm.tilemap.panel()')).shown; await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await settle(); await openTab(t, 'graph'); await settle(); const onOtherTab = (await json('__gm.tilemap.panel()')).shown; await openTab(t, 'scene'); await settle(); const back = await json('__gm.tilemap.panel()'); check( 'the panel stays away with two objects selected and on another tab, and comes back with the scene', withTwo === false && onOtherTab === false && back.shown && Math.abs(back.panel.w - panel.panel.w) <= 1, JSON.stringify({ second, withTwo, onOtherTab, back }), ); const cellBack = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); await drag(back.splitter.x, back.splitter.y, back.splitter.x - 60, back.splitter.y); await settle(); const wider = await json('__gm.tilemap.panel()'); const cellWider = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); check( 'the splitter widens the panel at the expense of the scene view, and the map stays put', Math.abs(wider.panel.w - back.panel.w - 60) <= 1 && Math.abs(back.viewport.w - wider.viewport.w - 60) <= 1 && Math.abs(cellWider.x - cellBack.x) <= 1, JSON.stringify({ back, wider, cellBack, cellWider }), ); await drag(wider.splitter.x, wider.splitter.y, wider.splitter.x + 60, wider.splitter.y); await settle(); await show('All layers'); await mutate( "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", ); await undo(); // the rename await settle(); // 40i. Back to Select: the map is moved again instead of painted on. await clickInPanel(t, '__gm.tilemap.toolAt("select")'); await settle(); const before = (await t.object(MAP_OBJECT)).position; const grab = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`); await drag(grab.x, grab.y, grab.x + 40, grab.y); await settle(); const after = (await t.object(MAP_OBJECT)).position; cells = await painted(); check( 'with Select the map is dragged like any other object and nothing is painted', Math.round(after.x - before.x) === 40 && Object.keys(cells).length === 9, JSON.stringify({ before, after, cells: Object.keys(cells).length }), ); await undo(); await settle(); // 40q. A click picks what is drawn under the pointer (#203). A crate of its own sits on the 3 × 3 // block at z −1, below the map's 0; with the block's layer sorted in at −5 the crate is drawn // in front, so it is what a click there takes, although the map's grid spans the spot. const CRATE = 'tm-pick-crate'; const blockLayer = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.find((l) => l.cells['1,1'])`; await mutate(` const scene = __gm.state.activeScene; ${MAKE_OBJECT} const crate = make(${JSON.stringify(CRATE)}, 'crate', '', ${LANE - 24}, 'Pick crate'); crate.position = { x: -72, y: ${LANE - 24}, z: -1 }; scene.objects.push(crate); ${blockLayer}.z = -5;`); await evaluate('__gm.state.select(null)'); await settle(); const cellPoint = (x, y) => json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`); const stackAt = (p) => json(`__gm.sceneStack(${p.x}, ${p.y})`); const selected = () => json('__gm.state.selectedObjectIds'); const onBlock = await cellPoint(1, 1); const stackBelow = await stackAt(onBlock); await click(onBlock.x, onBlock.y); await settle(); const pickedBelow = await selected(); check( 'a sprite drawn over a map layer sorted in below it is what a click there picks', JSON.stringify(stackBelow) === JSON.stringify([CRATE, MAP_OBJECT]) && JSON.stringify(pickedBelow) === JSON.stringify([CRATE]), JSON.stringify({ stackBelow, pickedBelow }), ); // An empty cell is empty space: the click there takes nothing and clears the selection. const onEmpty = await cellPoint(COLUMNS - 1, ROWS - 1); const stackEmpty = await stackAt(onEmpty); await click(onEmpty.x, onEmpty.y); await settle(); const pickedEmpty = await selected(); check( 'a click on an empty cell of the map selects nothing', stackEmpty.length === 0 && pickedEmpty.length === 0, JSON.stringify({ stackEmpty, pickedEmpty }), ); // Clicking what is selected passes on to the next one below, and from the bottom round to the top. const cycle = []; for (let i = 0; i < 3; i++) { const p = await cellPoint(1, 1); await click(p.x, p.y); await settle(); cycle.push((await selected()).join(',')); } check( 'clicking the selected object again selects the one below it, then the top one again', JSON.stringify(cycle) === JSON.stringify([CRATE, MAP_OBJECT, CRATE]), JSON.stringify(cycle), ); // With the layer sorted in above the crate the map is in front – and a drag on the selected crate // still moves the crate, not the map on top of it. await mutate(`${blockLayer}.z = 5;`); await select({ kind: 'object', holderId: 'scene1', objectIds: [CRATE], objectId: CRATE }); await settle(); const onTop = await cellPoint(1, 1); const stackAbove = await stackAt(onTop); const crateBefore = (await t.object(CRATE)).position; const mapUnder = (await t.object(MAP_OBJECT)).position; await drag(onTop.x, onTop.y, onTop.x + 40, onTop.y); await settle(); const crateAfter = (await t.object(CRATE)).position; const mapUnderAfter = (await t.object(MAP_OBJECT)).position; const pickedDrag = await selected(); check( 'a drag on a selected sprite hidden under the map moves the sprite and leaves the map', JSON.stringify(stackAbove) === JSON.stringify([MAP_OBJECT, CRATE]) && Math.round(crateAfter.x - crateBefore.x) === 40 && JSON.stringify(mapUnderAfter) === JSON.stringify(mapUnder) && JSON.stringify(pickedDrag) === JSON.stringify([CRATE]), JSON.stringify({ stackAbove, crateBefore, crateAfter, mapUnder, mapUnderAfter, pickedDrag }), ); await undo(); await settle(); // 40r. A right-click in the scene view opens the hierarchy's menu (#204), by the rule of a press: // the selected crate hidden under the map keeps the selection and gets the menu. const menuRows = async () => ((await json('__gm.menuRows()')) ?? []).filter(Boolean); const OBJECT_MENU = [ 'Select asset', 'Open asset', 'Copy', 'Paste', 'Add collider…', 'Add rigidbody…', 'Add joint…', 'Create container', 'Unpack container', 'Unparent (move to top level)', 'Group…', 'Remove from group', 'Remove from scene', ]; const onHidden = await cellPoint(1, 1); await click(onHidden.x, onHidden.y, 'right'); await settle(); const hiddenRows = await menuRows(); const hiddenPicked = await selected(); await clickText('Select asset'); await settle(); const afterPick = await json('__gm.state.selection'); check( 'a right-click on a selected sprite under the map opens the object menu for the sprite', JSON.stringify(hiddenRows) === JSON.stringify(OBJECT_MENU) && JSON.stringify(hiddenPicked) === JSON.stringify([CRATE]) && afterPick?.kind === 'asset' && afterPick.assetId === 'crate', JSON.stringify({ hiddenRows, hiddenPicked, afterPick }), ); // Nothing selected under the pointer: the topmost is selected first, the menu is for it. await evaluate('__gm.state.select(null)'); await settle(); await click(onHidden.x, onHidden.y, 'right'); await settle(); const topRows = await menuRows(); const topPicked = await selected(); await press('Escape'); await settle(); check( 'a right-click with nothing selected there selects the topmost object and opens its menu', JSON.stringify(topRows) === JSON.stringify(OBJECT_MENU) && JSON.stringify(topPicked) === JSON.stringify([MAP_OBJECT]), JSON.stringify({ topRows, topPicked }), ); // Empty space opens only the menu to paste into the scene (#240) and leaves the selection alone. const emptyPoint = await cellPoint(COLUMNS - 1, ROWS - 1); await click(emptyPoint.x, emptyPoint.y, 'right'); await settle(); const emptyMenu = await json('__gm.menuRows()'); const emptyPicked = await selected(); check( 'a right-click on an empty cell opens only Paste and keeps the selection', JSON.stringify(emptyMenu) === JSON.stringify(['Paste']) && JSON.stringify(emptyPicked) === JSON.stringify([MAP_OBJECT]), JSON.stringify({ emptyMenu, emptyPicked }), ); await press('Escape'); await evaluate('__gm.state.select(null)'); await mutate(` const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== ${JSON.stringify(CRATE)}); delete ${blockLayer}.z;`); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await settle(); // 40o. The grid grows at the left edge (#136): the cells of both layers move one column in, the // origin moves half a cell to the left, and on screen the painted cells stay where they were. const allCells = () => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => Object.keys(l.cells).sort().join(' '))`, ); const cellsBefore = await allCells(); const objectBefore = await t.object(MAP_OBJECT); const screenBefore = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`); const addLeft = await edgeButton(t, 'Left', '+'); if (addLeft) await click(addLeft.x, addLeft.y); await settle(); await shot('40-grid-edges'); const cellsGrown = await allCells(); const objectGrown = await t.object(MAP_OBJECT); const screenGrown = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`); const drawnGrown = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); const movedIn = (keys) => keys .split(' ') .filter(Boolean) .map((key) => key.split(',').map(Number)) .map(([x, y]) => `${x + 1},${y}`) .sort() .join(' '); check( 'the + of the left edge moves every layer one column in and the map stays where it is on screen', addLeft !== null && cellsGrown.length === 2 && cellsGrown.every((keys, i) => keys === movedIn(cellsBefore[i])) && objectGrown.overrides.columns === COLUMNS + 1 && Math.abs(objectGrown.position.x - (objectBefore.position.x - 24)) < 1e-6 && Math.abs(screenGrown.x - screenBefore.x) < 0.5 && Math.abs(screenGrown.y - screenBefore.y) < 0.5 && drawnGrown.tiles === 10, JSON.stringify({ addLeft, cellsBefore, cellsGrown, objectGrown, screenBefore, screenGrown, drawnGrown }), ); await undo(); await settle(); const objectUndone = await t.object(MAP_OBJECT); check( 'and one undo takes back the cells, the column and the position', JSON.stringify(await allCells()) === JSON.stringify(cellsBefore) && objectUndone.overrides.columns === undefined && JSON.stringify(objectUndone.position) === JSON.stringify(objectBefore.position), JSON.stringify({ cells: await allCells(), objectUndone }), ); // 40j. The running game draws the same map: it is scene content, not editor decoration. The map // is moved in front of the camera for this, because an object off screen is culled there. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}); o.position = { x: 0, y: 0, z: 0 };`); await settle(); await t.play(); await settle(); const game = await json(`__gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)})`); check( 'the map is drawn in the running game as well', game && game.tiles === 10 && game.layers === 2, JSON.stringify(game), ); // 40j-ii. A frame of the run draws the game view once and leaves a still map's meshes alone // (#284): the animated grass must not have the view drawn a second time each frame, nor // every geometry of the map built anew with each drawing. Counted in the page over the // same frames, so a slow machine changes neither ratio. const drawing = JSON.parse( await t.evaluate(`(async () => { const read = () => ({ frames: __gm.renderStats().drawn, redraws: __gm.gameInfo().redraws, builds: __gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)}).builds, }); const before = read(); await __gm.afterFrames(40, 4000); const after = read(); return { frames: after.frames - before.frames, redraws: after.redraws - before.redraws, builds: after.builds - before.builds, meshes: __gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)}).drawCalls, }; })().then(JSON.stringify)`), ); check( 'a frame of the run draws the game view once, not twice', drawing.frames >= 40 && drawing.redraws > 0 && drawing.redraws <= drawing.frames + 2, JSON.stringify(drawing), ); check( 'and builds a mesh again only when its animated tile steps on, not with every drawing', drawing.builds < drawing.redraws * drawing.meshes * 0.5, JSON.stringify(drawing), ); await t.stop(); await settle(); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}); o.position = { x: 0, y: ${LANE}, z: 0 };`); // 40k. The file holds the cells compactly (#134): per layer the tiles once and a string of runs // per row, no key per cell. A reload reads them back into exactly the same cells. const inMemory = await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells)`, ); // For 40k-ii: a picture of its own that nothing wears, so only loading ahead can bring it in. await evaluate(`(async () => { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 8; const g = canvas.getContext('2d'); g.fillStyle = 'rgb(201, 17, 99)'; g.fillRect(0, 0, 8, 8); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'unworn.png', { type: 'image/png' })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`); await t.sleep(2500); await saved(); const storedMap = (await api(`projects/${projectId}`)).project.assets.find((a) => a.id === made.id); const storedLayers = storedMap?.layers ?? []; check( 'the saved map stores each layer as its tiles and a run string per row, not a key per cell', storedLayers.length === 2 && storedLayers.every((l) => l.cells === undefined && Array.isArray(l.rows)) && storedLayers[0].tiles.join() === 'at-grass' && storedLayers[0].rows.every((r) => typeof r === 'string'), JSON.stringify(storedLayers.map(({ tiles, origin, rows }) => ({ tiles, origin, rows }))), ); await navigate(); const reloaded = await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells)`, ); const sorted = (layers) => JSON.stringify(layers.map((c) => Object.entries(c).sort())); check( 'a reloaded project has exactly the cells it was saved with', sorted(reloaded) === sorted(inMemory) && Object.keys(inMemory[0]).length === 9, JSON.stringify({ reloaded, inMemory }), ); // 40k-ii. Every picture the project names is loaded right after opening, not on its first draw: // a tile stepping to a picture fetched only then blinked out once. "unworn" was pasted // above and is placed nowhere. let pictures = await json('__gm.heldPictures()'); for (let i = 0; i < 20 && !pictures.every((p) => p.held); i++) { await t.sleep(150); pictures = await json('__gm.heldPictures()'); } check( 'every picture of the project is in memory after opening, drawn anywhere or not', pictures.some((p) => p.name === 'unworn') && pictures.every((p) => p.held), JSON.stringify(pictures), ); // 40l. Collision (#132). The switch sits on the tile, and the map outlines where it now blocks. // The map holds a 3 × 3 block and, on the second layer, one cell at (4, 0). await openTab(t, 'scene'); await select({ kind: 'asset', assetId: 'at-grass' }); const solidRow = await json("__gm.inspectorField('Solid')"); const surfaceRows = () => json("[__gm.inspectorField('Friction'), __gm.inspectorField('Bounciness')].map(Boolean)"); const surfaceBefore = await surfaceRows(); if (solidRow) await click(solidRow.x, solidRow.y); await settle(); check( 'a tile has a Solid switch in the inspector, and clicking it makes the tile solid', Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true, JSON.stringify({ solidRow, props: (await t.asset('at-grass'))?.props }), ); // Its surface (#193) is a solid tile's business: the rows come with the switch. const surfaceAfter = await surfaceRows(); check( 'Friction and Bounciness show on a tile only once it is solid', JSON.stringify(surfaceBefore) === '[false,false]' && JSON.stringify(surfaceAfter) === '[true,true]', JSON.stringify({ surfaceBefore, surfaceAfter }), ); await mutate(`__gm.lookAt(0, ${LANE})`); await settle(); const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); check( 'the scene view outlines the blocking cells, merged into rectangles', outlined?.visible === true && JSON.stringify(outlined.rects) === JSON.stringify([ { x: 0, y: 0, w: 3, h: 3 }, { x: 4, y: 0, w: 1, h: 1 }, ]), JSON.stringify(outlined), ); await shot('40-tilemap-solid'); // 40l-iii. The outline shows on every map, the tint only on the map being painted, and there only // where the layer picked in the panel blocks: the block on the lower, (4, 0) on the upper. const tinted = async () => (await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`))?.filled ?? null; const tintedByTile = await tinted(); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await settle(); const layerIds = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); const tintOf = async (row) => { const spot = await inPanel(t, `__gm.tilemap.layerRowAt(${row})`); if (spot) await click(spot.name.x, spot.y); await settle(); // No layer chosen means the topmost is painted on, as the panel has it. const chosen = (await json('__gm.tilemap.paint()')).layerId; return { layer: layerIds.includes(chosen) ? chosen : layerIds.at(-1), filled: await tinted() }; }; const tintRows = [await tintOf(0), await tintOf(1)]; const expectedTint = { [layerIds[0]]: [{ x: 0, y: 0, w: 3, h: 3 }], [layerIds[1]]: [{ x: 4, y: 0, w: 1, h: 1 }], }; const besideMap = await json( `__gm.state.activeScene.objects.find((o) => o.id !== ${JSON.stringify(MAP_OBJECT)} && !o.parentId).id`, ); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT, besideMap], objectId: MAP_OBJECT, }); await settle(); const tintedWithTwo = await tinted(); await select({ kind: 'asset', assetId: 'at-grass' }); await settle(); check( 'only the selected map is tinted, and only where the layer painted on blocks', tintedByTile === null && tintedWithTwo === null && layerIds.length === 2 && new Set(tintRows.map((r) => r.layer)).size === 2 && tintRows.every( (r) => JSON.stringify(r.filled?.rects) === JSON.stringify(expectedTint[r.layer]) && r.filled.outlines === 0 && r.filled.oneWay.length === 0, ), JSON.stringify({ tintedByTile, tintRows, tintedWithTwo }), ); // In the game a crate of its own walks into the block: the seeded Crate sprite with a 100 × 60 // collider and the Mover (ArrowRight moves its owner, colliding with solids) below it. Its own // rather than the seeded red crate, which an earlier step of a full run may have removed. It // starts at −200 with 30 units of free travel: the map sits so that the block's face is at −120. const mapBefore = (await t.object(MAP_OBJECT)).position; await mutate(` const scene = __gm.state.activeScene; const object = (id, assetId, parentId, position, overrides) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} }); scene.objects.push( object('tm-crate', 'crate', '', { x: -200, y: ${LANE}, z: 0 }, {}), object('tm-crate-collider', 'collider', 'tm-crate', { x: 0, y: 0, z: 0 }, { shape: 'box', width: 100, height: 60, isTrigger: false }), object('tm-crate-mover', 'mover', 'tm-crate', { x: 0, y: 0, z: 0 }, {})); scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = { x: 24, y: ${LANE + 24}, z: 0 }; __gm.state.select(null)`); await settle(); const playAgainstMap = async () => { await t.play(); await t.holdKey('ArrowRight', 700); const crate = await t.entity('tm-crate'); await t.stop(); await settle(); return crate; }; const stopped = await playAgainstMap(); check( 'Move with "Collide with solids" stops the crate flush against the solid cells', stopped && Math.abs(stopped.x - -170) < 0.01, `world x=${stopped?.x}`, ); // Collision scale (#140), typed into the tile's field: 0.5 draws the boxes in by a quarter cell on // their open sides only – the 3 × 3 block stays one rectangle – and lets the crate that much closer. await select({ kind: 'asset', assetId: 'at-grass' }); await settle(); const scaleRow = await json("__gm.inspectorField('Collision scale')"); if (scaleRow) { await click(scaleRow.x, scaleRow.y); await press('a', { ctrl: true }); await typeText('0.5'); await press('Enter'); } await settle(); const drawnIn = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); check( 'a tile has a Collision scale field; at 0.5 the outline is drawn in on the open sides, the block still one rectangle', (await t.asset('at-grass'))?.props.collisionScale === 0.5 && JSON.stringify(drawnIn?.rects) === JSON.stringify([ { x: 0.25, y: 0.25, w: 2.5, h: 2.5 }, { x: 4.25, y: 0.25, w: 0.5, h: 0.5 }, ]), JSON.stringify({ scaleRow, props: (await t.asset('at-grass'))?.props, drawnIn }), ); await evaluate('__gm.state.select(null)'); await settle(); const closer = await playAgainstMap(); check( 'with the smaller box the crate comes a quarter cell closer before it stops', closer && Math.abs(closer.x - -158) < 0.01, `world x=${closer?.x}`, ); await mutate("delete __gm.state.project.assets.find((a) => a.id === 'at-grass').props.collisionScale"); await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').props.solid = false"); const passed = await playAgainstMap(); check( 'a tile that is not solid lets the crate walk over it', passed && passed.x > -170 + 20, `world x=${passed?.x}`, ); // 40l-iv. Trigger tiles (#160). The switch sits on the tile next to Solid; the map then outlines // the cells in the trigger colour and blocks nothing there. Two behaviors under the crate // listen as it walks across the block: one hears the area, one – "per cell" ticked on its // node – every cell. await select({ kind: 'asset', assetId: 'at-grass' }); await settle(); const triggerRow = await json("__gm.inspectorField('Trigger (reports, never blocks)')"); if (triggerRow) await click(triggerRow.x, triggerRow.y); await settle(); const reporting = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); check( 'a tile has a Trigger switch; with it the map outlines its cells as triggers and none as solid', Boolean(triggerRow) && (await t.asset('at-grass'))?.props.trigger === true && reporting?.visible === true && reporting.rects.length === 0 && JSON.stringify(reporting.triggers) === JSON.stringify([ { x: 0, y: 0, w: 3, h: 3 }, { x: 4, y: 0, w: 1, h: 1 }, ]), JSON.stringify({ triggerRow, props: (await t.asset('at-grass'))?.props, reporting }), ); await shot('40-tilemap-trigger'); await mutate(` const assets = __gm.state.project.assets; const ear = (id, name, graph) => { const made = structuredClone(assets.find((a) => a.id === 'mover')); Object.assign(made, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph }); assets.push(made); }; ear('tt-area', 'Area ear', ${JSON.stringify(triggerGraph('ta'))}); ear('tt-cells', 'Cell ear', ${JSON.stringify(triggerGraph('tc'))}); const object = (id, assetId, name) => ({ id, assetId, name, position: { x: 0, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: 'tm-crate', instanceEdits: {} }); __gm.state.activeScene.objects.push( object('tm-crate-area', 'tt-area', 'Area ear'), object('tm-crate-cells', 'tt-cells', 'Cell ear'));`); await select({ kind: 'node', assetId: 'tt-cells', nodeId: 'tc-enter' }); await settle(); const perCellField = await json("__gm.inspectorField('per cell', true)"); if (perCellField) await click(perCellField.x, perCellField.y); await settle(); const enterParams = await json( "__gm.state.project.assets.find((a) => a.id === 'tt-cells').graph.nodes.find((n) => n.id === 'tc-enter').params", ); check( 'On collision enter has a "per cell" switch, ticked on the node with the mouse', Boolean(perCellField) && enterParams.perCell === true, JSON.stringify({ perCellField, enterParams }), ); await shot('40-tilemap-trigger-node'); await evaluate('__gm.state.select(null)'); await openTab(t, 'scene'); const triggerLogFrom = t.consoleLog.length; const crossed = await playAgainstMap(); const heardBy = (name) => { const lines = t.consoleLog .slice(triggerLogFrom) .filter((line) => line.startsWith(`[behavior ${name}]`)) .map((line) => line.split('] ').pop()); const events = []; for (let i = 0; i + 2 < lines.length; i += 3) events.push(lines.slice(i, i + 3).join(' ')); return events; }; const areaHeard = heardBy('Area ear'); const cellsHeard = heardBy('Cell ear'); const names = (events) => events.every((event) => /^at-grass [0-4] [0-2]$/.test(event)); check( 'the crate walks across the trigger tiles: one enter for the block, one per cell with "per cell"', crossed && crossed.x > -170 + 20 && areaHeard.length === 1 && cellsHeard.length > 1 && new Set(cellsHeard).size === cellsHeard.length && names(areaHeard) && names(cellsHeard), JSON.stringify({ x: crossed?.x, areaHeard, cellsHeard }), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !a.id.startsWith('tt-')); delete p.assets.find((a) => a.id === 'at-grass').props.trigger;`); await mutate(` const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('tm-crate')); scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = ${JSON.stringify(mapBefore)};`); await settle(); // 40s. A behavior reads and sets cells while the game runs (#135). One map asset is placed twice; // the behavior sits on the left placement and, on start, puts a tile into cell 2,1, reads it // back and finds the cell again from its middle in world units. Only that placement shows // it, and after Stop the asset holds what was painted and nothing more. await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; // A tile of one picture: an animated one redraws the view every frame and would hide whether a // changed cell asks for a redraw by itself. const still = structuredClone(assets.find((a) => a.id === 'at-grass')); Object.assign(still, { id: 'tl-still', name: 'Still', pictures: ['at-floor-1'] }); assets.push(still); const map = structuredClone(assets.find((a) => a.id === ${JSON.stringify(made.id)})); Object.assign(map, { id: 'tl-map', name: 'Live map', props: { ...map.props, columns: 4, rows: 2, tileSize: 48 }, layers: [{ id: 'tl-bridge', name: 'Bridge', cells: { '0,0': 'tl-still' } }] }); assets.push(map); const logic = structuredClone(assets.find((a) => a.id === 'mover')); Object.assign(logic, { id: 'tl-behavior', name: 'Tile logic', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(tileGraph)} }); assets.push(logic); const object = (id, assetId, parentId, x, name) => ({ id, assetId, name, position: { x, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} }); scene.objects.push(object('tl-a', 'tl-map', '', -150, 'Live left'), object('tl-b', 'tl-map', '', 150, 'Live right'), object('tl-logic', 'tl-behavior', 'tl-a', 0, 'Tiler')); __gm.state.select(null)`); await select({ kind: 'node', assetId: 'tl-behavior', nodeId: 'tl-set' }); await settle(); const layerField = await json("__gm.inspectorField('layer', true)"); if (layerField) await click(layerField.x, layerField.y); const layerChoices = await json('__gm.overlayTexts()'); if (layerChoices.includes('Bridge')) { const choice = await t.locate('Bridge', true); await click(choice.x, choice.y); } await settle(); const setParams = await json( "__gm.state.project.assets.find((a) => a.id === 'tl-behavior').graph.nodes.find((n) => n.id === 'tl-set').params", ); check( 'the layer of Set tile is picked by name from the layers of the project’s tilemaps', Boolean(layerField) && layerChoices.includes('First layer') && layerChoices.includes('Bridge') && setParams.layer === 'Bridge', JSON.stringify({ layerField, layerChoices, setParams }), ); await evaluate('__gm.state.select(null)'); await openTab(t, 'scene'); const tileLogFrom = t.consoleLog.length; await t.play(); await t.sleep(600); const leftInGame = await json("__gm.tilemap.gameInfo('tl-a')"); const rightInGame = await json("__gm.tilemap.gameInfo('tl-b')"); await t.stop(); await settle(); const tilerSaid = t.consoleLog .slice(tileLogFrom) .filter((line) => line.startsWith('[behavior Tiler]')) .map((line) => line.split('] ').pop()); check( 'Set tile puts the tile down, Tile at reads it back, and World to cell finds the cell from its middle', tilerSaid.length >= 3 && tilerSaid.length % 3 === 0 && tilerSaid.every((line, i) => line === ['tl-still', '2', '1'][i % 3]), JSON.stringify(tilerSaid), ); check( 'the running game draws the new tile on that placement only', leftInGame?.tiles === 2 && rightInGame?.tiles === 1, JSON.stringify({ leftInGame, rightInGame }), ); const liveCells = () => json("__gm.state.project.assets.find((a) => a.id === 'tl-map').layers[0].cells"); const afterPlay = await liveCells(); check( 'after Stop the map asset holds what was painted, not what the game set', JSON.stringify(afterPlay) === JSON.stringify({ '0,0': 'tl-still' }), JSON.stringify(afterPlay), ); // The editor run changes the same cell, and the scene view shows it while the run lasts. await mutate( "__gm.state.activeScene.objects.find((o) => o.id === 'tl-logic').overrides.runInEditor = true", ); await mutate('__gm.lookAt(0, 0)'); await t.sleep(500); const leftRunning = await json("__gm.tilemap.info('tl-a')"); const rightRunning = await json("__gm.tilemap.info('tl-b')"); await shot('40-tile-set-in-run'); await mutate( "__gm.state.activeScene.objects.find((o) => o.id === 'tl-logic').overrides.runInEditor = false", ); await settle(); const leftStopped = await json("__gm.tilemap.info('tl-a')"); const afterRun = await liveCells(); check( 'the editor run shows the set tile in the scene view, and ending it shows the painted map again', (await json('__gm.editorRun()')).running === false && leftRunning?.tiles === 2 && rightRunning?.tiles === 1 && leftStopped?.tiles === 1 && JSON.stringify(afterRun) === JSON.stringify({ '0,0': 'tl-still' }), JSON.stringify({ leftRunning, rightRunning, leftStopped, afterRun }), ); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('tl-')); p.assets = p.assets.filter((a) => !a.id.startsWith('tl-')); __gm.state.select(null)`); await settle(); // 40t. Deleting a tile that is painted (#268): the dialog counts its cells, the same step empties // them, a brush still holding the tile paints nothing, and an undo brings tile and cells back // together. A tile of its own on the empty bottom row, so the cells above stay as they are. await mutate(` const assets = __gm.state.project.assets; const doomed = structuredClone(assets.find((a) => a.id === 'at-grass')); Object.assign(doomed, { id: 'tm-doomed', name: 'Doomed' }); assets.push(doomed);`); const selectMap = async () => { await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await evaluate(`__gm.lookAt(0, ${LANE})`); await settle(); }; const doomedCells = () => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers` + ".flatMap((l) => Object.keys(l.cells).filter((key) => l.cells[key] === 'tm-doomed')).sort().join(' ')", ); await selectMap(); await clickInPanel(t, '__gm.tilemap.tileButtonAt("tm-doomed")'); await clickInPanel(t, '__gm.tilemap.toolAt("brush")'); await settle(); const rowStart = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 3)`); const rowEnd = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 3)`); await drag(rowStart.x, rowStart.y, rowEnd.x, rowEnd.y); await settle(); const paintedRow = await doomedCells(); const doomedRow = await json("__gm.assetRow('tm-doomed')"); await click(doomedRow.x, doomedRow.y, 'right'); await settle(); const deleteEntry = await t.locate('Delete…', true); await click(deleteEntry.x, deleteEntry.y); await settle(); const counted = await t.has('3 cell(s) on 1 tilemap(s) are painted with it and become empty.'); await shot('40-delete-painted-tile'); check( 'the delete dialog of a painted tile counts its cells and the maps they lie on', paintedRow === '0,3 1,3 2,3' && counted, JSON.stringify({ paintedRow, overlay: await json('__gm.overlayTexts()') }), ); const confirm = await t.locate('Delete', true); await click(confirm.x, confirm.y); await settle(); const afterDelete = { tile: await json("__gm.state.project.assets.some((a) => a.id === 'tm-doomed')"), cells: await doomedCells(), }; check( 'deleting the tile empties its cells in the same step', afterDelete.tile === false && afterDelete.cells === '', JSON.stringify(afterDelete), ); // The brush still holds the deleted tile; a click with it on a cell never painted must leave no // such cell behind. await selectMap(); await clickInPanel(t, '__gm.tilemap.toolAt("brush")'); await settle(); const inHand = await json('__gm.tilemap.paint()'); const untouched = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 3)`); await click(untouched.x, untouched.y); await settle(); const afterClick = { inHand, cell: await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells['4,3'] ?? '')`, ), notice: await json('__gm.state.notice'), }; check( 'a brush still holding the deleted tile paints nothing and asks for a tile', inHand.tool === 'brush' && inHand.tileId === 'tm-doomed' && afterClick.cell.every((id) => id === '') && afterClick.notice === 'Choose a tile to paint with.', JSON.stringify(afterClick), ); await undo(); await settle(); const restored = { tile: await json("__gm.state.project.assets.some((a) => a.id === 'tm-doomed')"), cells: await doomedCells(), }; check( 'an undo brings the tile and its cells back together', restored.tile === true && restored.cells === '0,3 1,3 2,3', JSON.stringify(restored), ); // The stroke goes the same way, and the tile the mutate brought in goes as it came. await undo(); await settle(); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'tm-doomed');`); await settle(); // Leave the editor as the rest of the suite expects to find it. await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })"); await evaluate('__gm.state.select(null)'); await mutate('__gm.lookAt(0, 0)'); }