Files
feuerwerk/tools/e2e/40-tilemap.mjs
T
adminandClaude Opus 5.5 d444b27fae
game-maker / gate (push) Successful in 2m59s
game-maker / suite (push) Successful in 9m22s
Let behaviors read and set tilemap cells while the game runs (#135)
A behavior could move sprites around but had no way to look at or
change the tilemap it was standing on. Doors, breakable blocks and
"is there ground ahead?" checks all need that.

- New nodes Tile at, Set tile, World to cell and Cell to world. The
  conversion honours position, rotation, scale and mirroring of the
  placed map. A small Tile literal node lets a graph compare what it
  read against a fixed tile.
- The layer is picked by name from all tilemap layers in the project
  through a new field kind. "First layer" takes the map's first one.
  Because layer is an input, a text can also be wired in.
- Each placement keeps only its edited cells in liveTilemap.ts,
  layered over the asset's cells. A second placement of the same map
  stays untouched, and the asset itself is never written.
- A read sees a Set tile from earlier in the same flow. Drawing picks
  up the edit at once and neighbouring autotile edges adapt.
- Solids see the edits straight away, so a Move in the same frame
  already hits the new cell. Rapier rebuilds its shapes on the next
  step.
- After Stop the map is back to what was painted. The editor run shows
  the edit in the scene view while it lasts and does not touch the
  project.
- An unwired tilemap input means no map, like Get position. Cells
  outside the grid are refused and done reports false.

Tests:
- ./check.sh: 155 files, 2271 tests, CHECK EXIT 0 (before 2245).
- tests/core/tile-access.test.ts (14), one physics test for a floor
  set at runtime, catalog entries for the five nodes.
- e2e 39,40: all passed, 86 checks (before 81), five new in 40s.
- Counter-checks: collision ignoring edits fails 3 unit tests, the
  solids cache missing same-tick edits fails only the "at once" test,
  drawing ignoring edits fails the 2 drawing checks, the editor run
  not reporting a new tile fails only its check.
- 40s uses a still tile. The animated fixture tile redraws every frame
  and hid the last counter-check at first.
- Check total 1351 + 5 = 1356, computed. CI confirmed 1351 at cb32b4b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:45:29 +02:00

1195 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'),
],
};
/** 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()),
);
// 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-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',
'Add collider…',
'Add rigidbody…',
'Create container',
'Unpack container',
'Unparent (move to top level)',
'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 no menu 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 no menu and keeps the selection',
emptyMenu === null && 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),
);
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)`,
);
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 }),
);
// 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');
// 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}`,
);
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();
// 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)');
}