import { colourDistance } from '../lib/harness.mjs'; import { LANES, openTab, pickAssetType } from './lib/scaffold.mjs'; import { quadColour } from './39-autotile.mjs'; /** * Tilemaps (#128): a placed grid painted with the autotiles of step 39, which is where the sheet * and the `at-grass` autotile 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 painting section, scrolled into the inspector first. The section sits below the * rows of the object, so on this window it starts out below the panel – and a click on a point * outside the panel lands on whatever is drawn there instead. */ async function inInspector(t, hook) { const anchor = await t.json("__gm.inspectorField('Columns')"); for (let i = 0; i < 15; i++) { const rect = await t.json('__gm.inspectorRect()'); 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(anchor.x, anchor.y, 200); await t.settle(); } return null; } /** Clicks such a widget; returns false when it could not be brought into view. */ async function clickInInspector(t, hook) { const point = await inInspector(t, hook); if (point) await t.click(point.x, point.y); return Boolean(point); } /** 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 }) }; } export async function tilemap(t) { const { check, json, evaluate, mutate, click, clickText, drag, press, shot, settle, undo, select } = 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(); await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); await settle(); // 40b. The inspector paints: a tool, the autotiles of the project, the layers. const section = await json( '(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.autotileAt("at-grass") !== null,' + ' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()', ); check( 'a selected tilemap offers tools, autotiles and layers in the inspector', section.tools && section.grass && Boolean(section.layers), JSON.stringify(section), ); // Clicked through the hook rather than by its text: the autotile's own row in the asset panel // reads "Grass" as well, and a click there would only select the autotile. check( 'the autotile can be picked in the section', await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")'), JSON.stringify(await json('__gm.inspectorRect()')), ); await settle(); const paint = await json('__gm.tilemap.paint()'); check( 'picking an autotile reaches for the brush by itself', paint.autotileId === '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()), ); // 40d. The rectangle tool fills the box a drag spans. await clickInInspector(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()), ); // 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 autotile, 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 autotile are one draw call', drawn.tiles === 9 && drawn.drawCalls === 1 && drawn.layers === 1, JSON.stringify(drawn), ); // 40g. Erasing takes cells out again. await clickInInspector(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(); // 40h. A second layer draws above the first, and hiding it leaves its cells alone. await clickInInspector(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 clickInInspector(t, '__gm.tilemap.autotileAt("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 inInspector(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(); // 40i. Back to Select: the map is moved again instead of painted on. await clickInInspector(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(); // 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), ); 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 };`); // 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)'); }