Let behaviors read and set tilemap cells while the game runs (#135)
game-maker / gate (push) Successful in 2m59s
game-maker / suite (push) Successful in 9m22s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 20:45:29 +02:00
1 parent cb32b4bc23
commit d444b27fae
22 files changed
+848 -20

No files matched your search

+147
View File
@@ -1,3 +1,4 @@
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';
@@ -61,6 +62,40 @@ async function edgeButton(t, label, glyph) {
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 (
@@ -1040,6 +1075,118 @@ export async function tilemap(t) {
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)');