Let a tile report a touch instead of blocking it (#160)

A tile could be solid, one-way and scaled, but not report without
blocking. Water, lava, spikes or a goal zone needed a trigger object
of their own laid over the map. Now a tile has a switch Trigger, and
the collision events name the tile and the cell they met.

- A tile has the switch "Trigger (reports, never blocks)" under
  Solid. Its cells hold nothing up: no Move, no falling body, no
  ray. They raise On collision enter, stay and exit with the tilemap
  as other.
- The three events have the outputs tile, cell x and cell y. Without
  a tile, tile is empty and the cell is -1, -1.
- The three events have the input per cell. Off, the default, gives
  one event per area: cells of the same tile that touch side by
  side. On gives one event per cell.
- The scene view outlines trigger cells in yellow like a trigger
  collider, merged to rectangles but never with solid cells.
- Is touching with the map counts trigger tiles, and Set tile works
  while the game runs.
- Both systems find the touches with the same overlap check in
  runtime/systems/tileTriggers.ts, not through the physics engine.

Decided here:

- Trigger wins over Solid. A tile with both does not block, as with
  a collider.
- Water beside lava is two areas, and so are two ponds that touch
  only at a corner.
- An area names the cell nearest to the centre of the collider, and
  on exit the cell it named last.
- The solid cells of a map still do not name their tile.
- Normal, impulse and point are 0, as with a trigger collider.
- Collision scale and outlines shape a trigger cell like a solid
  one. One-way, friction and bounciness have no effect.

Tests:
- Gate on stein2: 205 files, 3104 tests (3067 + 37), exit 0
- e2e 39,40: 106 checks. e2e 1-8,39-43: 490 checks. Both on
  melektaus-atx. No full run, the sum of 1648 (1645 + 3) is
  reckoned and CI measures it.
- 37 unit tests in tests/core/tile-triggers.test.ts, nearly all
  against both systems
- Three e2e checks with a real mouse in block 40l-iv
- 24 unit counter-probes fail tests. Two did not at first, tests
  were added for them.
- Two browser counter-probes fail their checks
- Two screenshots looked at (yellow outline, the node with per cell)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 07:28:39 +02:00
1 parent 2ff3a78607
commit 275e50af01
20 files changed
+1161 -57

No files matched your search

+103
View File
@@ -96,6 +96,27 @@ const tileGraph = {
],
};
/** A behavior that logs the tile and the cell of every `On collision enter` (#160), in three lines. */
function triggerGraph(prefix) {
const id = (name) => `${prefix}-${name}`;
return {
nodes: [
node(id('enter'), 'OnCollisionEnter', 0, 0),
node(id('tile'), 'Log', 400, 0),
node(id('x'), 'Log', 700, 0),
node(id('y'), 'Log', 1000, 0),
],
edges: [
edge(id('e1'), id('enter'), 'exec', id('tile'), 'exec'),
edge(id('e2'), id('enter'), 'tile', id('tile'), 'message'),
edge(id('e3'), id('tile'), 'exec', id('x'), 'exec'),
edge(id('e4'), id('enter'), 'cellX', id('x'), 'message'),
edge(id('e5'), id('x'), 'exec', id('y'), 'exec'),
edge(id('e6'), id('enter'), 'cellY', id('y'), 'message'),
],
};
}
/** Whether a box lies inside another, give or take a pixel of rounding. */
function inside(inner, outer) {
return (
@@ -1229,6 +1250,88 @@ export async function tilemap(t) {
passed && passed.x > -170 + 20,
`world x=${passed?.x}`,
);
// 40l-iv. Trigger tiles (#160). The switch sits on the tile next to Solid; the map then outlines
// the cells in the trigger colour and blocks nothing there. Two behaviors under the crate
// listen as it walks across the block: one hears the area, one – "per cell" ticked on its
// node – every cell.
await select({ kind: 'asset', assetId: 'at-grass' });
await settle();
const triggerRow = await json("__gm.inspectorField('Trigger (reports, never blocks)')");
if (triggerRow) await click(triggerRow.x, triggerRow.y);
await settle();
const reporting = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`);
check(
'a tile has a Trigger switch; with it the map outlines its cells as triggers and none as solid',
Boolean(triggerRow) &&
(await t.asset('at-grass'))?.props.trigger === true &&
reporting?.visible === true &&
reporting.rects.length === 0 &&
JSON.stringify(reporting.triggers) ===
JSON.stringify([
{ x: 0, y: 0, w: 3, h: 3 },
{ x: 4, y: 0, w: 1, h: 1 },
]),
JSON.stringify({ triggerRow, props: (await t.asset('at-grass'))?.props, reporting }),
);
await shot('40-tilemap-trigger');
await mutate(`
const assets = __gm.state.project.assets;
const ear = (id, name, graph) => {
const made = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(made, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
assets.push(made);
};
ear('tt-area', 'Area ear', ${JSON.stringify(triggerGraph('ta'))});
ear('tt-cells', 'Cell ear', ${JSON.stringify(triggerGraph('tc'))});
const object = (id, assetId, name) => ({ id, assetId, name, position: { x: 0, y: 0, z: 0 },
editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: 'tm-crate', instanceEdits: {} });
__gm.state.activeScene.objects.push(
object('tm-crate-area', 'tt-area', 'Area ear'), object('tm-crate-cells', 'tt-cells', 'Cell ear'));`);
await select({ kind: 'node', assetId: 'tt-cells', nodeId: 'tc-enter' });
await settle();
const perCellField = await json("__gm.inspectorField('per cell', true)");
if (perCellField) await click(perCellField.x, perCellField.y);
await settle();
const enterParams = await json(
"__gm.state.project.assets.find((a) => a.id === 'tt-cells').graph.nodes.find((n) => n.id === 'tc-enter').params",
);
check(
'On collision enter has a "per cell" switch, ticked on the node with the mouse',
Boolean(perCellField) && enterParams.perCell === true,
JSON.stringify({ perCellField, enterParams }),
);
await shot('40-tilemap-trigger-node');
await evaluate('__gm.state.select(null)');
await openTab(t, 'scene');
const triggerLogFrom = t.consoleLog.length;
const crossed = await playAgainstMap();
const heardBy = (name) => {
const lines = t.consoleLog
.slice(triggerLogFrom)
.filter((line) => line.startsWith(`[behavior ${name}]`))
.map((line) => line.split('] ').pop());
const events = [];
for (let i = 0; i + 2 < lines.length; i += 3) events.push(lines.slice(i, i + 3).join(' '));
return events;
};
const areaHeard = heardBy('Area ear');
const cellsHeard = heardBy('Cell ear');
const names = (events) => events.every((event) => /^at-grass [0-4] [0-2]$/.test(event));
check(
'the crate walks across the trigger tiles: one enter for the block, one per cell with "per cell"',
crossed &&
crossed.x > -170 + 20 &&
areaHeard.length === 1 &&
cellsHeard.length > 1 &&
new Set(cellsHeard).size === cellsHeard.length &&
names(areaHeard) &&
names(cellsHeard),
JSON.stringify({ x: crossed?.x, areaHeard, cellsHeard }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('tt-'));
delete p.assets.find((a) => a.id === 'at-grass').props.trigger;`);
await mutate(`
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('tm-crate'));