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

+11
View File
@@ -120,6 +120,17 @@ export class GraphInterpreter {
this.follow(node, port, ctx, 0);
}
/**
* A data input of an event node, read ahead of its pulse: what decides whether the event concerns
* the node at all (#160). Null for an input the node does not have.
*/
eventInput(node: GraphNode, key: string, ctx: NodeContext): Value {
this.frame = this.emptyFrame(this.graph);
this.steps = 0;
const port = this.def(node)?.inputs.find((p) => p.key === key);
return port ? this.inputValue(node, port, ctx) : null;
}
/**
* The `On state entered` / `On state exited` nodes watching one machine. A node whose machine
* input carries a running value rather than a pick has no state outputs at all, so it is never
+1 -9
View File
@@ -7,7 +7,7 @@ import { cellToWorld, worldToCell } from '../../../model/liveTilemap';
import { worldMatrixOf } from '../../NodeContext';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asEntity, asNumber, asString, type Value } from '../../values';
import { bool, exec, int, num, obj } from '../shared';
import { bool, exec, int, num, obj, tilePort } from '../shared';
/** The layer by name, picked from the layers of every map in the project; none = the first. */
const layerPort = (): PortDef => ({
@@ -17,14 +17,6 @@ const layerPort = (): PortDef => ({
field: { key: 'layer', label: 'Layer', kind: 'tileLayer', default: '' },
});
/** A tile asset; unconnected, it shows the tile picker, and none chosen empties the cell. */
const tilePort = (key = 'tile', label = key): PortDef => ({
key,
label,
type: 'string',
field: { key, label: 'Tile', kind: 'asset', default: '', assetTypes: ['tile'] },
});
const CELL_INPUTS: readonly PortDef[] = [int('cellX', 0, 'cell x'), int('cellY', 0, 'cell y')];
/** A cell counts in whole columns and rows, so a fraction arriving over an edge is floored. */
@@ -1,6 +1,6 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs, contactOutputs } from '../shared';
import { collisionOutputs, contactOutputs, perCellPort, tileOutputs } from '../shared';
export const OnCollisionEnter: NodeDef = {
type: 'OnCollisionEnter',
@@ -9,9 +9,11 @@ export const OnCollisionEnter: NodeDef = {
description:
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
'normal x/y point from the other collider towards the own one, impulse says how hard they met and ' +
'point x/y where (all 0 for a trigger).',
inputs: [],
outputs: [...collisionOutputs(), ...contactOutputs()],
'point x/y where (all 0 for a trigger). Walking onto a trigger tile of a tilemap runs it too, with ' +
'the tile and its cell: once for a whole stretch of touching cells of that tile, or with "per cell" ' +
'for every cell.',
inputs: [perCellPort()],
outputs: [...collisionOutputs(), ...contactOutputs(), ...tileOutputs()],
params: [orderParam],
hiddenParams: hiddenOrder,
};
@@ -1,14 +1,16 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs } from '../shared';
import { collisionOutputs, perCellPort, tileOutputs } from '../shared';
export const OnCollisionExit: NodeDef = {
type: 'OnCollisionExit',
label: 'On collision exit',
category: 'event',
description: 'Runs once when a collider of the owner stops overlapping another collider.',
inputs: [],
outputs: collisionOutputs(),
description:
'Runs once when a collider of the owner stops overlapping another collider – or leaves a trigger tile ' +
'of a tilemap: the whole stretch of touching cells of that tile, or with "per cell" each cell.',
inputs: [perCellPort()],
outputs: [...collisionOutputs(), ...tileOutputs()],
params: [orderParam],
hiddenParams: hiddenOrder,
};
@@ -1,6 +1,6 @@
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs, contactOutputs } from '../shared';
import { collisionOutputs, contactOutputs, perCellPort, tileOutputs } from '../shared';
export const OnCollisionStay: NodeDef = {
type: 'OnCollisionStay',
@@ -9,9 +9,11 @@ export const OnCollisionStay: NodeDef = {
description:
'Runs while a collider of the owner overlaps another collider: every frame, or with an interval once ' +
'each time that long has passed since they met. While playing, normal x/y, impulse and point x/y tell ' +
'how they touch right now (all 0 for a trigger and in the editor run).',
inputs: [],
outputs: [...collisionOutputs(), ...contactOutputs()],
'how they touch right now (all 0 for a trigger and in the editor run). On a trigger tile of a ' +
'tilemap, tile and cell x/y say which: the cell nearest to the own collider, or with "per cell" each ' +
'cell it lies on.',
inputs: [perCellPort()],
outputs: [...collisionOutputs(), ...contactOutputs(), ...tileOutputs()],
params: [
{
key: 'interval',
+28
View File
@@ -44,6 +44,34 @@ export const contactOutputs = (): PortDef[] => [
num('pointY', 'point y'),
];
/** A tile asset; unconnected as an input, it shows the tile picker. */
export const tilePort = (key = 'tile', label = key): PortDef => ({
key,
label,
type: 'string',
field: { key, label: 'Tile', kind: 'asset', default: '', assetTypes: ['tile'] },
});
export const PER_CELL_INPUT = 'perCell';
/**
* Which pairs a collision event hears of a map's trigger tiles (#160): off, one per area of a tile –
* a pond is entered once –, on, one per cell.
*/
export const perCellPort = (): PortDef => ({
key: PER_CELL_INPUT,
label: 'per cell',
type: 'boolean',
default: false,
field: { key: PER_CELL_INPUT, label: 'Per cell (trigger tiles)', kind: 'boolean', default: false },
});
/**
* The trigger tile a collider lies on (#160) and its cell in the map, which is then `other`. No
* tile and cell -1, -1 for anything else: another collider, or a map's solid cells.
*/
export const tileOutputs = (): PortDef[] => [tilePort(), num('cellX', 'cell x'), num('cellY', 'cell y')];
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** No `assetTypes`: any placeable asset is a valid answer here. */
+8 -1
View File
@@ -21,6 +21,8 @@ export const TILE_FPS_KEY = 'fps';
export const TILE_PLAYBACK_KEY = 'playback';
/** Whether a cell of this tile blocks movement in the running game (#132). */
export const TILE_SOLID_KEY = 'solid';
/** Whether a cell of this tile reports what overlaps it instead of blocking it: water, lava, a goal (#160). */
export const TILE_TRIGGER_KEY = 'trigger';
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
export const TILE_ONE_WAY_KEY = 'oneWay';
/** How far a contact may lean from straight up and still land on a one-way tile, in degrees (#159). */
@@ -59,8 +61,13 @@ export function tilePlayback(asset: TileAsset): TilePlayback {
return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop';
}
/** A trigger never blocks, whatever Solid says: it is the collider's rule, where Trigger wins too. */
export function isTileSolid(asset: TileAsset): boolean {
return asset.props[TILE_SOLID_KEY] === true;
return asset.props[TILE_SOLID_KEY] === true && !isTileTrigger(asset);
}
export function isTileTrigger(asset: TileAsset): boolean {
return asset.props[TILE_TRIGGER_KEY] === true;
}
export function isTileOneWay(asset: TileAsset): boolean {
+146 -10
View File
@@ -22,6 +22,9 @@
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with
* a platform of another angle (#159). The same holds for a tile's surface (#193): with a `material`,
* a function looks only at the tiles of that friction and bounciness.
*
* A trigger tile (#160) blocks nothing. Its cells are worked out by the same rules, apart from the
* solid ones, and handed on cell by cell: the collision events tell which one was walked into.
*/
import type { TileAsset, TilemapAsset } from './Asset';
import { tileQuadrants } from './autotile';
@@ -33,6 +36,7 @@ import { findFrame } from './texture';
import {
isTileOneWay,
isTileSolid,
isTileTrigger,
readTile,
TILE_ONE_WAY_ANGLE_KEY,
tileCollisionScale,
@@ -244,6 +248,17 @@ function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMate
return oneWay === true || tileOneWayAngle(tile) === oneWay;
}
/**
* Whose cells a function works out: the solid tiles passing `oneWay` and `material`, or the trigger
* tiles (#160) – all of them, or the one named. A trigger tile is never among the solid ones.
*/
type TilePick = { oneWay: OneWayFilter; material?: TileMaterial } | { trigger: string | null };
function picks(tile: TileAsset, pick: TilePick): boolean {
if ('trigger' in pick) return isTileTrigger(tile) && (pick.trigger === null || pick.trigger === tile.id);
return isTileSolid(tile) && passesFilter(tile, pick.oneWay, pick.material);
}
/** The solid tiles painted on the map, each once, in the order the layers first name them. */
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
@@ -286,8 +301,7 @@ function blockingCells(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
oneWay: OneWayFilter,
material?: TileMaterial,
pick: TilePick,
): readonly CellBlock[] {
const tiles = new Map<string, BlockingTile>();
for (const layer of asset.layers)
@@ -296,7 +310,7 @@ function blockingCells(
const tile = findAssetOf(project, id, 'tile');
tiles.set(
id,
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
tile && picks(tile, pick)
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
: null,
);
@@ -304,8 +318,9 @@ function blockingCells(
const key = [
tilemapColumns(props),
tilemapRows(props),
oneWay,
material ? `${material.friction}/${material.bounciness}` : '',
'trigger' in pick
? `trigger:${pick.trigger ?? ''}`
: `${pick.oneWay}|${pick.material ? `${pick.material.friction}/${pick.material.bounciness}` : ''}`,
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
[...tiles]
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
@@ -373,7 +388,7 @@ export function solidCells(
oneWay: OneWayFilter = false,
material?: TileMaterial,
): Required<SolidCell>[] {
return boxCells(blockingCells(project, props, asset, oneWay, material));
return boxCells(blockingCells(project, props, asset, { oneWay, material }));
}
function boxCells(cells: readonly CellBlock[]): Required<SolidCell>[] {
@@ -393,7 +408,11 @@ export function solidPolygons(
oneWay: OneWayFilter = false,
material?: TileMaterial,
): readonly CellPolygon[] {
return derived(polygonsOf, blockingCells(project, props, asset, oneWay, material), (cells) => {
return polygonCells(blockingCells(project, props, asset, { oneWay, material }));
}
function polygonCells(blocks: readonly CellBlock[]): readonly CellPolygon[] {
return derived(polygonsOf, blocks, (cells) => {
const polygons: CellPolygon[] = [];
for (const cell of cells) {
if (cell.box !== null) continue;
@@ -527,9 +546,126 @@ export function solidRects(
oneWay: OneWayFilter = false,
material?: TileMaterial,
): readonly CellRect[] {
return derived(rectsOf, blockingCells(project, props, asset, oneWay, material), (cells) =>
mergeCells(boxCells(cells)),
);
return rectCells(blockingCells(project, props, asset, { oneWay, material }));
}
function rectCells(blocks: readonly CellBlock[]): readonly CellRect[] {
return derived(rectsOf, blocks, (cells) => mergeCells(boxCells(cells)));
}
/** The trigger tiles painted on the map (#160), each once, in the order the layers first name them. */
function triggerTiles(project: Project, asset: TilemapAsset): TileAsset[] {
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
return [...ids].flatMap((id) => {
const tile = findAssetOf(project, id, 'tile');
return tile && isTileTrigger(tile) ? [tile] : [];
});
}
/**
* Where the map's trigger tiles lie (#160), as the scene view outlines it: merged into rectangles by
* the rules of the solid cells, but on their own – a pond never merges with its shore.
*/
export function triggerRects(project: Project, props: PropertyBag, asset: TilemapAsset): readonly CellRect[] {
return rectCells(blockingCells(project, props, asset, { trigger: null }));
}
/** The trigger cells that report with an outline, like `solidPolygons`. */
export function triggerPolygons(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
): readonly CellPolygon[] {
return polygonCells(blockingCells(project, props, asset, { trigger: null }));
}
/**
* One cell of a trigger tile as the running game meets it (#160): what of the cell reports, in
* cells, and the area it is part of – the stretch of cells of the same tile touching side to side,
* named after its top-left one. Walking through a pond is one area, however many cells it crosses.
*/
export interface TriggerCell {
x: number;
y: number;
tileId: string;
area: string;
rects: readonly CellRect[];
polygons: readonly CellPolygon[];
}
const triggers = new KeyedMemo<ReadonlyMap<string, readonly TriggerCell[]>>();
const NO_TRIGGERS: ReadonlyMap<string, readonly TriggerCell[]> = new Map();
/**
* The trigger cells of a map by `x,y`. Each tile is worked out on its own, so a cell holding two
* trigger tiles on two layers reports both, and water next to lava is two areas.
*/
export function triggerCells(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
): ReadonlyMap<string, readonly TriggerCell[]> {
const tiles = triggerTiles(project, asset);
if (tiles.length === 0) return NO_TRIGGERS;
const blocks = tiles.map((tile) => blockingCells(project, props, asset, { trigger: tile.id }));
const key = tiles.map((tile, i) => `${tile.id}:${identityToken(blocks[i]!)}`).join(',');
return triggers.get(key, () => {
const byCell = new Map<string, TriggerCell[]>();
for (const [i, tile] of tiles.entries())
for (const cell of cellsOfTile(tile.id, blocks[i]!)) {
const at = `${cell.x},${cell.y}`;
const held = byCell.get(at);
if (held) held.push(cell);
else byCell.set(at, [cell]);
}
return byCell;
});
}
function cellsOfTile(tileId: string, blocks: readonly CellBlock[]): TriggerCell[] {
const boxes = new Map(boxCells(blocks).map((cell) => [`${cell.x},${cell.y}`, cell]));
const isBox = (x: number, y: number) => boxes.has(`${x},${y}`);
const areas = areasOf(blocks);
return blocks.map((block) => {
const at = `${block.x},${block.y}`;
const box = boxes.get(at);
return {
x: block.x,
y: block.y,
tileId,
area: areas.get(at)!,
rects: box
? cellPieces(box, isBox).map((p) => ({ x: p.x0, y: p.y0, w: p.x1 - p.x0, h: p.y1 - p.y0 }))
: [],
polygons: box
? []
: block.polygons.map(({ outline, pieces }) => ({ x: block.x, y: block.y, outline, pieces })),
};
});
}
/** Which area each cell is part of: cells joined side to side, the area named after its top-left cell. */
function areasOf(cells: readonly { x: number; y: number }[]): Map<string, string> {
const open = new Set(cells.map((cell) => `${cell.x},${cell.y}`));
const areas = new Map<string, string>();
for (const first of [...cells].sort((a, b) => a.y - b.y || a.x - b.x)) {
const name = `${first.x},${first.y}`;
if (!open.delete(name)) continue;
const queue = [first];
for (let cell = queue.pop(); cell; cell = queue.pop()) {
areas.set(`${cell.x},${cell.y}`, name);
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
] as const) {
const next = { x: cell.x + dx, y: cell.y + dy };
if (open.delete(`${next.x},${next.y}`)) queue.push(next);
}
}
}
return areas;
}
/**
+3
View File
@@ -37,6 +37,7 @@ import {
TILE_SORT_OFFSET_KEY,
TILE_SORT_OFFSET_LIMIT,
TILE_SOLID_KEY,
TILE_TRIGGER_KEY,
} from '../model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import { PALETTE_KEY } from '../model/pixelart';
@@ -454,6 +455,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
},
// Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132).
{ key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false },
// A trigger tile blocks nothing and tells the collision events what lies on it (#160).
{ key: TILE_TRIGGER_KEY, label: 'Trigger (reports, never blocks)', kind: 'boolean', default: false },
// A solid tile that only blocks from above, like a platform one jumps through (#155).
{ key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false },
{ ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY },
+35 -7
View File
@@ -23,7 +23,9 @@ function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
sameRects(a.solids, b.solids) &&
samePolygons(a.outlines, b.outlines) &&
sameRects(a.oneWay.solids, b.oneWay.solids) &&
samePolygons(a.oneWay.outlines, b.oneWay.outlines)
samePolygons(a.oneWay.outlines, b.oneWay.outlines) &&
sameRects(a.triggers.solids, b.triggers.solids) &&
samePolygons(a.triggers.outlines, b.triggers.outlines)
);
}
@@ -39,7 +41,7 @@ export class TilemapSolidOutlines {
*/
private readonly boundaries = new Map<
string,
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] }
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[]; triggers: BoundaryLine[] }
>();
/** What each map's last draw tinted, or nothing: the painted layer's part, while it is selected. */
private readonly tinted = new Map<string, TilemapSolids>();
@@ -64,7 +66,9 @@ export class TilemapSolidOutlines {
oneWay: CellRect[];
oneWayOutlines: Vec2[][];
boundary: BoundaryLine[];
filled: { rects: CellRect[]; outlines: number; oneWay: CellRect[] } | null;
triggers: CellRect[];
triggerOutlines: Vec2[][];
filled: { rects: CellRect[]; outlines: number; oneWay: CellRect[]; triggers: CellRect[] } | null;
} | null {
const node = item && this.nodes.get(item.id);
if (!node || !item.tilemap) return null;
@@ -75,6 +79,7 @@ export class TilemapSolidOutlines {
rects: tinted.solids.map((rect) => ({ ...rect })),
outlines: tinted.outlines.length,
oneWay: tinted.oneWay.solids.map((rect) => ({ ...rect })),
triggers: tinted.triggers.solids.map((rect) => ({ ...rect })),
}
: null,
visible: node.visible,
@@ -82,6 +87,10 @@ export class TilemapSolidOutlines {
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })),
oneWayOutlines: item.tilemap.oneWay.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
triggers: item.tilemap.triggers.solids.map((rect) => ({ ...rect })),
triggerOutlines: item.tilemap.triggers.outlines.map((polygon) =>
polygon.outline.map((p) => ({ ...p })),
),
boundary: this.boundaryOf(item).solid.map((line) => ({
points: line.points.map((p) => ({ ...p })),
closed: line.closed,
@@ -89,7 +98,11 @@ export class TilemapSolidOutlines {
};
}
private boundaryOf(item: DrawItem): { solid: BoundaryLine[]; oneWay: BoundaryLine[] } {
private boundaryOf(item: DrawItem): {
solid: BoundaryLine[];
oneWay: BoundaryLine[];
triggers: BoundaryLine[];
} {
const from: TilemapSolids = item.tilemap!;
let boundary = this.boundaries.get(item.id);
if (!boundary || !sameSolids(boundary.from, from)) {
@@ -97,6 +110,7 @@ export class TilemapSolidOutlines {
from,
solid: solidBoundary(from.solids, from.outlines),
oneWay: solidBoundary(from.oneWay.solids, from.oneWay.outlines),
triggers: solidBoundary(from.triggers.solids, from.triggers.outlines),
};
this.boundaries.set(item.id, boundary);
}
@@ -113,8 +127,10 @@ export class TilemapSolidOutlines {
const rects = item.tilemap?.solids ?? [];
const outlines = item.tilemap?.outlines ?? [];
const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] };
const triggers = item.tilemap?.triggers ?? { solids: [], outlines: [] };
this.tinted.delete(item.id);
if (!show || rects.length + outlines.length + oneWay.solids.length + oneWay.outlines.length === 0) {
const shapes = [rects, outlines, oneWay.solids, oneWay.outlines, triggers.solids, triggers.outlines];
if (!show || shapes.every((list) => list.length === 0)) {
if (node) node.visible = false;
return;
}
@@ -132,7 +148,12 @@ export class TilemapSolidOutlines {
const color = Theme.colors.collider;
const boundary = this.boundaryOf(item);
// Filled piece by piece, but stroked only along the outer edges, so no seam shows (#173).
const fill = (rects: readonly CellRect[], outlines: readonly CellPolygon[], alpha: number) => {
const fill = (
rects: readonly CellRect[],
outlines: readonly CellPolygon[],
alpha: number,
color: number = Theme.colors.collider,
) => {
if (rects.length + outlines.length === 0) return;
for (const rect of rects) {
const corner = cellOrigin(item.props, rect.x, rect.y);
@@ -145,7 +166,11 @@ export class TilemapSolidOutlines {
);
node.fill({ color, alpha });
};
const stroke = (lines: readonly BoundaryLine[], lineWidth: number) => {
const stroke = (
lines: readonly BoundaryLine[],
lineWidth: number,
color: number = Theme.colors.collider,
) => {
if (lines.length === 0) return;
for (const line of lines)
node.poly(
@@ -158,9 +183,12 @@ export class TilemapSolidOutlines {
this.tinted.set(item.id, filled);
fill(filled.solids, filled.outlines, COLLIDER_FILL_ALPHA);
fill(filled.oneWay.solids, filled.oneWay.outlines, COLLIDER_FILL_ALPHA / 2);
fill(filled.triggers.solids, filled.triggers.outlines, COLLIDER_FILL_ALPHA, Theme.colors.trigger);
}
stroke(boundary.solid, width);
stroke(boundary.oneWay, width / 2);
// Trigger tiles in the trigger collider's colour (#160): they report, the others block.
stroke(boundary.triggers, width, Theme.colors.trigger);
// A platform's top is what blocks, so that is what stands out, with the cone it lands within.
const length = CONE_LENGTH / (scale * scaleOf(item.m));
for (const rect of oneWay.solids) {
+16 -1
View File
@@ -48,6 +48,7 @@ import {
surfaceOf,
} from '../systems/solids';
import type { System } from '../systems/System';
import { TileTriggers } from '../systems/tileTriggers';
import {
angleBetween,
blend,
@@ -148,6 +149,8 @@ export class PhysicsSystem implements System, CollisionSource {
private passes = new Map<string, boolean>();
private hooks: PhysicsHooks | null = null;
private readonly solids = new Solids();
/** The maps' trigger tiles (#160): found by overlap where the objects are drawn, not by the engine. */
private readonly triggers = new TileTriggers();
private backlog = 0;
/** The length of the frame being played – the time a move of the graph's is spread over. */
private frameDt = PHYSICS_STEP;
@@ -188,6 +191,7 @@ export class PhysicsSystem implements System, CollisionSource {
this.enters = [];
this.stays = [];
this.exits = [];
this.triggers.stop();
this.backlog = 0;
this.frameDt = PHYSICS_STEP;
this.hinted.clear();
@@ -218,13 +222,22 @@ export class PhysicsSystem implements System, CollisionSource {
this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog);
this.writeBack(this.backlog / PHYSICS_STEP);
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
this.triggers.update(world);
this.enters.push(...this.triggers.enters);
this.stays.push(...this.triggers.stays);
this.exits.push(...this.triggers.exits);
}
/** Whether any collider of `a` is in contact with any collider of `b` as of the last step. */
touching(world: World, a: Entity, b: Entity): boolean {
for (const ca of partsOf(world, a))
for (const cb of partsOf(world, b))
if (this.current.has(`${ca.id}|${cb.id}`) || this.current.has(`${cb.id}|${ca.id}`)) return true;
if (
this.current.has(`${ca.id}|${cb.id}`) ||
this.current.has(`${cb.id}|${ca.id}`) ||
this.triggers.touching(world, ca, cb)
)
return true;
return false;
}
@@ -253,6 +266,8 @@ export class PhysicsSystem implements System, CollisionSource {
/** How a current pair touches as of the last step: read from the engine's contact. */
contactOf(pair: CollisionPair): Contact | null {
// A trigger tile is no engine collider: nothing presses there.
if (pair.tile) return null;
const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`);
if (!handles || !this.parts.has(handles[0]) || !this.parts.has(handles[1])) return null;
return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1]));
+8 -3
View File
@@ -1,5 +1,6 @@
import {
adoptDeclarations,
asBoolean,
buildSharedState,
carriedForward,
declaredVariables,
@@ -20,6 +21,7 @@ import {
type Value,
} from '../../core/graph';
import { GraphCache } from '../../core/graph/graphIndex';
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
import { keyName } from '../../core/keys';
import { graphLookup } from '../../core/model/graphLookup';
import { type PropertyBag, textOf } from '../../core/model/Properties';
@@ -39,6 +41,7 @@ import {
collisionStayPulses,
type Instance,
keyDownPulses,
type PerCell,
type Produced,
type Pulse,
pulsesOf,
@@ -537,6 +540,8 @@ export class BehaviorSystem implements System {
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
};
const { input, collisions, animations } = this;
const perCell: PerCell = (i, node) =>
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
const kinds: (() => Pulse[])[] = [
() =>
ticking.flatMap((i) =>
@@ -546,9 +551,9 @@ export class BehaviorSystem implements System {
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
() => ticking.flatMap((i) => timerPulses(i, dt)),
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions))),
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt))),
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions))),
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
() =>
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
+13 -2
View File
@@ -18,6 +18,7 @@ import {
Solids,
} from './solids';
import type { System } from './System';
import { TileTriggers } from './tileTriggers';
/**
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
@@ -26,7 +27,8 @@ import type { System } from './System';
*
* A placed tilemap is a solid body of its own: its blocking cells, merged into rectangles, stop a
* move and report a collision with the tilemap as the other side. Two tilemaps never meet – they
* are ground, not things that walk into each other.
* are ground, not things that walk into each other. Its trigger tiles block nothing and report
* the cells overlapped (`TileTriggers`, #160).
*/
export class CollisionSystem implements System, CollisionSource {
enters: CollisionPair[] = [];
@@ -34,6 +36,7 @@ export class CollisionSystem implements System, CollisionSource {
exits: CollisionPair[] = [];
private previous = new Map<string, CollisionPair>();
private readonly solids = new Solids();
private readonly triggers = new TileTriggers();
update(world: World): void {
this.solids.reset();
@@ -56,11 +59,17 @@ export class CollisionSystem implements System, CollisionSource {
for (const [key, pair] of current) (this.previous.has(key) ? this.stays : this.enters).push(pair);
for (const [key, pair] of this.previous) if (!current.has(key)) this.exits.push(pair);
this.previous = current;
// A map's trigger tiles (#160) are pairs of their own, told after the others.
this.triggers.update(world);
this.enters.push(...this.triggers.enters);
this.stays.push(...this.triggers.stays);
this.exits.push(...this.triggers.exits);
}
stop(): void {
this.previous = new Map();
this.solids.reset();
this.triggers.stop();
this.enters = [];
this.stays = [];
this.exits = [];
@@ -80,7 +89,9 @@ export class CollisionSystem implements System, CollisionSource {
touching(world: World, a: Entity, b: Entity): boolean {
for (const ca of partsOf(world, a)) {
for (const cb of partsOf(world, b)) {
if (ca !== cb && collidersMeet(ca, cb) && this.overlaps(world, ca, cb)) return true;
if (ca === cb) continue;
if (collidersMeet(ca, cb) && this.overlaps(world, ca, cb)) return true;
if (this.triggers.touching(world, ca, cb)) return true;
}
}
return false;
+45 -10
View File
@@ -11,7 +11,7 @@ import { parentChain } from '../../core/model/walk';
import type { Entity } from '../Entity';
import type { AnimationSystem } from './AnimationSystem';
import type { Input } from './Input';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact } from './solids';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
/** What one node last handed on, and when – the graph tab shows the newest of every instance. */
export interface Produced {
@@ -116,11 +116,32 @@ function sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
});
}
export function collisionEnterPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] {
/** Reads an event node's "per cell" input for its instance (#160). */
export type PerCell = (instance: Instance, node: GraphNode) => boolean;
/**
* What of the pairs one node hears. A trigger tile is told twice, per cell and per area (#160), and
* the node's "per cell" input picks one; it is only read where a tile is among the pairs at all.
*/
function heardBy(instance: Instance, node: GraphNode, all: Side[], perCell: PerCell): Side[] {
if (!all.some((side) => side.pair.tile)) return all;
const cells = perCell(instance, node);
return all.filter((side) => !side.pair.tile || side.pair.tile.perCell === cells);
}
export function collisionEnterPulses(
instance: Instance,
collisions: CollisionSource | null,
perCell: PerCell,
): Pulse[] {
const entered = sides(instance, collisions?.enters ?? []);
return instance.interpreter
.eventNodes('OnCollisionEnter')
.flatMap((node) => entered.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) })));
return instance.interpreter.eventNodes('OnCollisionEnter').flatMap((node) =>
heardBy(instance, node, entered, perCell).map((side) => ({
instance,
node,
outputs: sideOutputs(side, side.pair),
})),
);
}
/**
@@ -132,6 +153,7 @@ export function collisionStayPulses(
instance: Instance,
collisions: CollisionSource | null,
dt: number,
perCell: PerCell,
): Pulse[] {
if (!collisions) return [];
const interpreter = instance.interpreter;
@@ -146,10 +168,10 @@ export function collisionStayPulses(
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
const clocks = interpreter.nodeState.get(node.id) ?? {};
const running: Record<string, number> = {};
for (const side of stays) {
for (const side of heardBy(instance, node, stays, perCell)) {
let fires = 1;
if (interval > 0) {
const key = `${side.pair.a.id}|${side.pair.b.id}`;
const key = pairKey(side.pair);
let elapsed = (clocks[key] ?? 0) + dt;
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
running[key] = elapsed;
@@ -163,11 +185,17 @@ export function collisionStayPulses(
return pulses;
}
export function collisionExitPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] {
export function collisionExitPulses(
instance: Instance,
collisions: CollisionSource | null,
perCell: PerCell,
): Pulse[] {
const left = sides(instance, collisions?.exits ?? []);
return instance.interpreter
.eventNodes('OnCollisionExit')
.flatMap((node) => left.map((side) => ({ instance, node, outputs: sideOutputs(side) })));
.flatMap((node) =>
heardBy(instance, node, left, perCell).map((side) => ({ instance, node, outputs: sideOutputs(side) })),
);
}
/**
@@ -189,7 +217,14 @@ export function animationFinishedPulses(instance: Instance, animations: Animatio
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
const outputs: Record<string, Value> = {
other: bodyOf(other),
otherCollider: other,
collider: own,
tile: pair.tile?.tileId ?? '',
cellX: pair.tile?.cell.x ?? -1,
cellY: pair.tile?.cell.y ?? -1,
};
if (contact !== undefined) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === pair.a ? 1 : -1;
+20
View File
@@ -72,6 +72,26 @@ export interface CollisionPair {
normal?: Vec2;
impulse?: number;
point?: Vec2;
/** Set where `b` is a map and `a` overlaps a trigger tile of it (#160) – a pair of its own, next to the map's. */
tile?: TileContact;
}
/**
* Which trigger tile of a map a collider overlaps (#160). A pair stands either for one cell or for a
* whole area of the tile; an area names the cell of it nearest to the collider.
*/
export interface TileContact {
tileId: string;
cell: { x: number; y: number };
area: string;
perCell: boolean;
}
/** What a pair is known by from one tick to the next. */
export function pairKey({ a, b, tile }: CollisionPair): string {
const key = `${a.id}|${b.id}`;
if (!tile) return key;
return `${key}|${tile.tileId}|${tile.perCell ? `cell ${tile.cell.x},${tile.cell.y}` : `area ${tile.area}`}`;
}
/**
+128
View File
@@ -0,0 +1,128 @@
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { liveTilemap } from '../../core/model/liveTilemap';
import { apply } from '../../core/model/matrix';
import { findAssetOf } from '../../core/model/Project';
import { cellOrigin, tilemapColumns, tilemapRows } from '../../core/model/tilemap';
import {
cellPolygonShape,
cellRectShape,
cellsWithin,
type TriggerCell,
triggerCells,
} from '../../core/model/tilemapCollision';
import { shapeBounds, shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bodyOf, collidersMeet, type CollisionPair, pairKey, shapeOf } from './solids';
/** A collision scale above 1 lets a cell reach this far into its neighbours, in cells. */
const REACH = 1;
/**
* The trigger tiles of the placed maps (#160): which colliders overlap which of their cells, sorted
* into enter / stay / exit like any other pair. Both collision systems ask this one, with the
* editor's own overlap test, so a run in the editor and a played game tell the same.
*
* Every overlap is told twice, as two pairs: once per cell and once per area of the tile. An event
* node hears the one it asks for.
*/
export class TileTriggers {
enters: CollisionPair[] = [];
stays: CollisionPair[] = [];
exits: CollisionPair[] = [];
private previous = new Map<string, CollisionPair>();
update(world: World): void {
const current = new Map<string, CollisionPair>();
const maps = world.entities.filter((e) => e.type === 'tilemap' && isHandleEnabled(e));
const colliders = maps.length > 0 ? world.entities.filter((e) => e.type === 'collider') : [];
for (const map of maps) {
const cells = cellsOf(world, map);
if (cells.size === 0) continue;
for (const collider of colliders)
for (const pair of pairsOf(collider, map, cells)) current.set(pairKey(pair), pair);
}
this.enters = [];
this.stays = [];
this.exits = [];
for (const [key, pair] of current) (this.previous.has(key) ? this.stays : this.enters).push(pair);
for (const [key, pair] of this.previous) if (!current.has(key)) this.exits.push(pair);
this.previous = current;
}
stop(): void {
this.previous = new Map();
this.enters = [];
this.stays = [];
this.exits = [];
}
/** Whether a collider lies on any trigger tile of a map right now, whichever of the two is `a`. */
touching(world: World, a: Entity, b: Entity): boolean {
if (a.type === 'tilemap') return b.type !== 'tilemap' && this.touching(world, b, a);
if (b.type !== 'tilemap' || !isHandleEnabled(b)) return false;
return touched(a, b, cellsOf(world, b)).length > 0;
}
}
function cellsOf(world: World, map: Entity): ReadonlyMap<string, readonly TriggerCell[]> {
const stored = findAssetOf(world.project, map.assetId, 'tilemap');
return stored ? triggerCells(world.project, map.props, liveTilemap(stored, map.tileEdits)) : new Map();
}
/** The trigger cells of the map the collider overlaps – none for one switched off, or on layers that do not meet. */
function touched(
collider: Entity,
map: Entity,
cells: ReadonlyMap<string, readonly TriggerCell[]>,
): TriggerCell[] {
if (cells.size === 0 || !isHandleEnabled(collider)) return [];
if (bodyOf(collider) === map || !collidersMeet(collider, map)) return [];
const m = worldMatrixOf(map);
const shape = shapeOf(collider);
// Only the cells the collider can reach are looked at, as for a move or a ray (#184).
const range = cellsWithin(map.props, m, shapeBounds(shape));
const from = (edge: number) => Math.max(0, Math.floor(edge) - REACH);
const to = (edge: number, count: number) => Math.min(count - 1, Math.floor(edge) + REACH);
const found: TriggerCell[] = [];
for (let y = from(range.top); y <= to(range.bottom, tilemapRows(map.props)); y++)
for (let x = from(range.left); x <= to(range.right, tilemapColumns(map.props)); x++)
for (const cell of cells.get(`${x},${y}`) ?? []) {
const over =
cell.rects.some((rect) => shapesOverlap(shape, cellRectShape(map.props, rect, m))) ||
cell.polygons.some((polygon) => shapesOverlap(shape, cellPolygonShape(map.props, polygon, m)));
if (over) found.push(cell);
}
return found;
}
/** A pair per cell overlapped, and one per area: that one with the cell nearest to the collider's middle. */
function pairsOf(
collider: Entity,
map: Entity,
cells: ReadonlyMap<string, readonly TriggerCell[]>,
): CollisionPair[] {
const found = touched(collider, map, cells);
if (found.length === 0) return [];
const m = worldMatrixOf(map);
const middle = shapeOf(collider);
const distance = (cell: TriggerCell) => {
const at = apply(m, cellOrigin(map.props, cell.x + 0.5, cell.y + 0.5));
return Math.hypot(at.x - middle.x, at.y - middle.y);
};
const pair = (cell: TriggerCell, perCell: boolean): CollisionPair => ({
a: collider,
b: map,
tile: { tileId: cell.tileId, cell: { x: cell.x, y: cell.y }, area: cell.area, perCell },
});
const nearest = new Map<string, TriggerCell>();
for (const cell of found) {
const key = `${cell.tileId}|${cell.area}`;
const known = nearest.get(key);
if (!known || distance(cell) < distance(known)) nearest.set(key, cell);
}
return [
...found.map((cell) => pair(cell, true)),
...[...nearest.values()].map((cell) => pair(cell, false)),
];
}
+9 -1
View File
@@ -20,6 +20,8 @@ import {
oneWayAngles,
solidPolygons,
solidRects,
triggerPolygons,
triggerRects,
} from '../core/model/tilemapCollision';
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
import { objectName } from '../core/model/SceneObject';
@@ -113,6 +115,8 @@ export interface TilemapDraw {
* its tiles land things within (#159), since tiles of two angles never merge.
*/
oneWay: { solids: (CellRect & { angle: number })[]; outlines: CellPolygon[] };
/** Where its trigger tiles lie (#160): they block nothing and are outlined like a trigger collider. */
triggers: { solids: readonly CellRect[]; outlines: readonly CellPolygon[] };
/**
* Where one layer alone blocks, the same way – the scene view fills only the painted layer's
* part. Worked out when asked for, since only the selected map ever asks.
@@ -121,7 +125,7 @@ export interface TilemapDraw {
}
/** Where a map, or one of its layers, blocks. */
export type TilemapSolids = Pick<TilemapDraw, 'solids' | 'outlines' | 'oneWay'>;
export type TilemapSolids = Pick<TilemapDraw, 'solids' | 'outlines' | 'oneWay' | 'triggers'>;
function solidsOf(project: Project, props: PropertyBag, asset: TilemapAsset): TilemapSolids {
const angles = oneWayAngles(project, asset);
@@ -134,6 +138,10 @@ function solidsOf(project: Project, props: PropertyBag, asset: TilemapAsset): Ti
),
outlines: angles.flatMap((angle) => solidPolygons(project, props, asset, angle)),
},
triggers: {
solids: triggerRects(project, props, asset),
outlines: triggerPolygons(project, props, asset),
},
};
}