Tilemaps are a new placeable asset type that is painted with autotiles in the scene view, with layers, brush, eraser and rectangle, and is drawn at runtime and in the export (#128)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m51s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 06:41:47 +02:00
1 parent 0607221423
commit 33a7b1b63e
30 files changed
+2060 -20

No files matched your search

+2 -2
View File
@@ -74,7 +74,7 @@ export async function assets(t) {
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
// what can be placed in a scene, a line, then what only lives in the library – and it is
// tall enough to show all sixteen types without scrolling.
// tall enough to show all seventeen types without scrolling.
await clickText('+');
const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null);
@@ -87,7 +87,7 @@ export async function assets(t) {
);
check(
'every asset type is offered once, each group sorted by label',
placeable.length + library.length === 16 &&
placeable.length + library.length === 17 &&
String(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }),
+1 -1
View File
@@ -39,7 +39,7 @@ const PASTE_SHEET = `(async () => {
})()`;
/** The colour a quarter of a block was drawn in – the same formula as the paste above. */
function quadColour(kind, qx, qy, step = 0) {
export function quadColour(kind, qx, qy, step = 0) {
const [r, g, b] =
kind === 'wall' ? [30 + qy * 60, 40 + qx * 40, 20] : [40 + qx * 60, 30 + qy * 40, step ? 200 : 90];
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
+299
View File
@@ -0,0 +1,299 @@
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)');
}
+2
View File
@@ -42,6 +42,7 @@ import { graphGroups } from './36-graph-groups.mjs';
import { nineSlice } from './37-nine-slice.mjs';
import { plugins } from './38-plugins.mjs';
import { autotile } from './39-autotile.mjs';
import { tilemap } from './40-tilemap.mjs';
export const STEPS = [
hierarchy,
@@ -83,4 +84,5 @@ export const STEPS = [
nineSlice,
plugins,
autotile,
tilemap,
];
+15 -1
View File
@@ -20,6 +20,7 @@ export const LANES = {
font: -1900,
particles: -2300,
sound: -2700,
tilemap: -3100,
};
/**
@@ -124,11 +125,24 @@ export const TAB_LABELS = {
* nothing and took the whole suite down with it.
*/
export async function openTab(t, tab) {
const label = TAB_LABELS[tab];
const row = await t.json('__gm.tabRow()');
if (row.hidden.includes(tab)) {
await t.clickText('»');
await t.clickText(label);
return;
}
await t.clickText(TAB_LABELS[tab]);
// The label alone is not the tab: the toolbar labels its scene selector "Scene" too, and being
// drawn first that is what `clickText` had been hitting – so the tab stayed where it was
// (measured 2026-09-17: step 39 ended on the Autotile tab although it asks for the Scene tab).
// The hit is therefore picked by the box the tab row reports for that tab.
const entry = row.shown.find((e) => e.tab === tab);
const hits = entry ? await t.json(`__gm.locateAll(${JSON.stringify(label)})`) : [];
const hit = hits.find(
(p) => p.x >= entry.x && p.x <= entry.right && p.y >= entry.y && p.y <= entry.y + entry.h,
);
if (hit) await t.click(hit.x, hit.y);
else await t.clickText(label);
}
/**