Files
feuerwerk/tools/e2e/40-tilemap.mjs
T
2026-09-18 06:41:47 +02:00

300 lines
13 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-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)');
}