Files
feuerwerk/tools/e2e/40-tilemap.mjs
T

822 lines
35 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 { colourDistance } from '../lib/harness.mjs';
import { LANES, 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;
}
/** 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,
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()),
);
// 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();
// 40m. The painted layer is named in the field below the list, 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 nameField = await inPanel(t, '__gm.tilemap.layerNameAt()');
if (nameField) {
await click(nameField.x, nameField.y);
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() || {}).value');
check(
'the name field renames the layer painted on, and the list shows the new name',
layers[1].name === 'Roofs' &&
layers[0].name === 'Layer 1' &&
renamedRow === layers[1].id &&
nameNow === 'Roofs',
JSON.stringify({ layers, renamedRow, nameNow }),
);
// 40p. The Z field below the name 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 }),
);
// 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();
// 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')");
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 }),
);
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();
// 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)');
}