From 275e50af011bafb317ff2b415b0bf829d0b63085 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 2 Oct 2026 07:28:39 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/graph/GraphInterpreter.ts | 11 + src/core/graph/nodes/action/tileNodes.ts | 10 +- .../graph/nodes/events/OnCollisionEnter.ts | 10 +- .../graph/nodes/events/OnCollisionExit.ts | 10 +- .../graph/nodes/events/OnCollisionStay.ts | 10 +- src/core/graph/nodes/shared.ts | 28 + src/core/model/tile.ts | 9 +- src/core/model/tilemapCollision.ts | 156 ++++- src/core/properties/PropertySchema.ts | 3 + src/editor/viewport/tilemapSolids.ts | 42 +- src/runtime/physics/PhysicsSystem.ts | 17 +- src/runtime/systems/BehaviorSystem.ts | 11 +- src/runtime/systems/CollisionSystem.ts | 15 +- src/runtime/systems/behaviorPulses.ts | 55 +- src/runtime/systems/solids.ts | 20 + src/runtime/systems/tileTriggers.ts | 128 ++++ src/viewport/items.ts | 10 +- tests/core/tile-triggers.test.ts | 568 ++++++++++++++++++ tools/e2e/40-tilemap.mjs | 103 ++++ 20 files changed, 1161 insertions(+), 57 deletions(-) create mode 100644 src/runtime/systems/tileTriggers.ts create mode 100644 tests/core/tile-triggers.test.ts diff --git a/README.md b/README.md index 6e72dfee..02c95693 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1645 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1648 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 547adf84..d7d4a3fa 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -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 diff --git a/src/core/graph/nodes/action/tileNodes.ts b/src/core/graph/nodes/action/tileNodes.ts index f72a702b..2dae1a1d 100644 --- a/src/core/graph/nodes/action/tileNodes.ts +++ b/src/core/graph/nodes/action/tileNodes.ts @@ -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. */ diff --git a/src/core/graph/nodes/events/OnCollisionEnter.ts b/src/core/graph/nodes/events/OnCollisionEnter.ts index 3187980f..e4f22e5c 100644 --- a/src/core/graph/nodes/events/OnCollisionEnter.ts +++ b/src/core/graph/nodes/events/OnCollisionEnter.ts @@ -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, }; diff --git a/src/core/graph/nodes/events/OnCollisionExit.ts b/src/core/graph/nodes/events/OnCollisionExit.ts index a99ef8b3..ec9552cc 100644 --- a/src/core/graph/nodes/events/OnCollisionExit.ts +++ b/src/core/graph/nodes/events/OnCollisionExit.ts @@ -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, }; diff --git a/src/core/graph/nodes/events/OnCollisionStay.ts b/src/core/graph/nodes/events/OnCollisionStay.ts index 403642a9..d00b10c1 100644 --- a/src/core/graph/nodes/events/OnCollisionStay.ts +++ b/src/core/graph/nodes/events/OnCollisionStay.ts @@ -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', diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 95b8bf7f..971a97dc 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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. */ diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index da2451e2..e44fce39 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -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 { diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 76fa9c2a..c81089bf 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -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(); 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[] { - return boxCells(blockingCells(project, props, asset, oneWay, material)); + return boxCells(blockingCells(project, props, asset, { oneWay, material })); } function boxCells(cells: readonly CellBlock[]): Required[] { @@ -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>(); +const NO_TRIGGERS: ReadonlyMap = 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 { + 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(); + 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 { + const open = new Set(cells.map((cell) => `${cell.x},${cell.y}`)); + const areas = new Map(); + 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; } /** diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index e9359963..a8286e4d 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { }, // 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 }, diff --git a/src/editor/viewport/tilemapSolids.ts b/src/editor/viewport/tilemapSolids.ts index b86fa9e4..2634c28f 100644 --- a/src/editor/viewport/tilemapSolids.ts +++ b/src/editor/viewport/tilemapSolids.ts @@ -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(); @@ -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) { diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 955bbe81..ef734af2 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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(); 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])); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 7312e3d5..9dd8485f 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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 }) : [], diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index ec4d1d4f..631a41a3 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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(); 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; diff --git a/src/runtime/systems/behaviorPulses.ts b/src/runtime/systems/behaviorPulses.ts index 9611fc82..79f77d75 100644 --- a/src/runtime/systems/behaviorPulses.ts +++ b/src/runtime/systems/behaviorPulses.ts @@ -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 = {}; - 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 | null): Record { - const outputs: Record = { other: bodyOf(other), otherCollider: other, collider: own }; + const outputs: Record = { + 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; diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index e380ef5b..64bcb714 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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}`}`; } /** diff --git a/src/runtime/systems/tileTriggers.ts b/src/runtime/systems/tileTriggers.ts new file mode 100644 index 00000000..11823be0 --- /dev/null +++ b/src/runtime/systems/tileTriggers.ts @@ -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(); + + update(world: World): void { + const current = new Map(); + 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 { + 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, +): 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, +): 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(); + 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)), + ]; +} diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 774be42b..2e93e18d 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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; +export type TilemapSolids = Pick; 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), + }, }; } diff --git a/tests/core/tile-triggers.test.ts b/tests/core/tile-triggers.test.ts new file mode 100644 index 00000000..4dbe7438 --- /dev/null +++ b/tests/core/tile-triggers.test.ts @@ -0,0 +1,568 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; +import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddTilePictureCommand } from '../../src/core/commands/tileCommands'; +import { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; +import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset'; +import { ALL_LAYERS, COLLIDES_WITH_KEY } from '../../src/core/model/collisionLayers'; +import { findAsset } from '../../src/core/model/Project'; +import { + isTileSolid, + isTileTrigger, + TILE_COLLISION_SCALE_KEY, + TILE_SOLID_KEY, + TILE_TRIGGER_KEY, +} from '../../src/core/model/tile'; +import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; +import { + solidRects, + type TriggerCell, + triggerCells, + triggerPolygons, + triggerRects, +} from '../../src/core/model/tilemapCollision'; +import { schemaFor } from '../../src/core/properties/PropertySchema'; +import { loadPhysics } from '../../src/runtime/physics/engine'; +import type { CollisionPair, CollisionSource } from '../../src/runtime/systems/solids'; +import type { System } from '../../src/runtime/systems/System'; +import { + COLLISION_SYSTEMS, + graphEditor, + makeBehavior, + makeSpriteScene, + place, + roundTrip, + startWorld, +} from '../helpers'; + +/** + * A scene with a 10 × 10 map of 48 px cells placed at the origin – cell (x, y) spans + * x·48 − 240 … x·48 − 192 – and a 40 × 40 hero with a collider, standing in row 4 left of a pond: + * water in the cells 6, 7 and 8 of that row and lava in cell 9, both trigger tiles. + */ +function setup() { + const f = makeSpriteScene('Hero'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40)); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40)); + const water = new CreateAssetCommand('tile', 'Water'); + const lava = new CreateAssetCommand('tile', 'Lava'); + const wall = new CreateAssetCommand('tile', 'Wall'); + const map = new CreateAssetCommand('tilemap', 'Ground'); + for (const command of [water, lava, wall, map]) f.stack.execute(command); + f.stack.execute(new SetAssetPropertyCommand(water.asset.id, TILE_TRIGGER_KEY, true)); + f.stack.execute(new SetAssetPropertyCommand(lava.asset.id, TILE_TRIGGER_KEY, true)); + f.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10)); + const mapObject = place(f, map.asset.id, { x: 0, y: 0 }); + const hero = place(f, f.spriteId, { x: 0, y: -24 }); + const collider = new AddColliderCommand(f.sceneId, hero); + f.stack.execute(collider); + const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset; + const paint = (at: [number, number][], tileId: string, layerId = assetOf().layers[0]!.id) => + f.stack.execute( + new PaintTilesCommand( + map.asset.id, + layerId, + at.map(([x, y]) => ({ x, y, tileId })), + ), + ); + const s = { + ...f, + waterId: water.asset.id, + lavaId: lava.asset.id, + wallId: wall.asset.id, + mapId: map.asset.id, + mapObject, + hero, + heroCollider: collider.objectId, + assetOf, + paint, + }; + paint( + [ + [6, 4], + [7, 4], + [8, 4], + ], + s.waterId, + ); + paint([[9, 4]], s.lavaId); + return s; +} + +type Setup = ReturnType; + +const cellsOf = (s: Setup) => triggerCells(s.project, s.assetOf().props, s.assetOf()); +const flat = (cells: ReadonlyMap) => [...cells.values()].flat(); + +/** A pair as a word: `area` or `cell`, the tile's name and the cell. */ +function told(s: Setup, pairs: readonly CollisionPair[]): string[] { + const name = (id: string) => (id === s.waterId ? 'water' : id === s.lavaId ? 'lava' : id); + return pairs + .filter((pair) => pair.tile) + .map( + ({ tile }) => + `${tile!.perCell ? 'cell' : 'area'} ${name(tile!.tileId)} ${tile!.cell.x},${tile!.cell.y}`, + ) + .sort(); +} + +describe('trigger tiles: which cells report (#160)', () => { + it('a tile reports once Trigger is on, the switch survives a round trip, and it wins over Solid', () => { + const s = setup(); + expect(schemaFor('tile').find((d) => d.key === TILE_TRIGGER_KEY)).toMatchObject({ + kind: 'boolean', + default: false, + }); + const tile = (id: string, project = s.project) => { + const found = findAsset(project, id); + if (found?.type !== 'tile') throw new Error('no tile'); + return found; + }; + expect(isTileTrigger(tile(s.wallId))).toBe(false); + expect(isTileTrigger(tile(s.waterId, roundTrip(s.project)))).toBe(true); + s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_SOLID_KEY, true)); + expect(isTileTrigger(tile(s.waterId))).toBe(true); + expect(isTileSolid(tile(s.waterId))).toBe(false); + expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toEqual([]); + }); + + it('merges into rectangles like solid cells, but never with them', () => { + const s = setup(); + s.paint([[5, 4]], s.wallId); + const props = s.assetOf().props; + // Water and lava are both triggers, so the outline runs round the two together. + expect(triggerRects(s.project, props, s.assetOf())).toEqual([{ x: 6, y: 4, w: 4, h: 1 }]); + expect(solidRects(s.project, props, s.assetOf())).toEqual([{ x: 5, y: 4, w: 1, h: 1 }]); + }); + + it('an area is the cells of one tile touching side to side, named after its top-left cell', () => { + const s = setup(); + s.paint( + [ + [7, 5], + [9, 5], + [2, 2], + [3, 3], + ], + s.waterId, + ); + const areas = Object.fromEntries( + flat(cellsOf(s)).map((cell) => [ + `${cell.tileId === s.waterId ? 'w' : 'l'} ${cell.x},${cell.y}`, + cell.area, + ]), + ); + expect(areas).toEqual({ + 'w 6,4': '6,4', + 'w 7,4': '6,4', + 'w 8,4': '6,4', + 'w 7,5': '6,4', + // Lava lies between this one and the pond, and lava is not water. + 'w 9,5': '9,5', + 'l 9,4': '9,4', + // Corner to corner is not side to side. + 'w 2,2': '2,2', + 'w 3,3': '3,3', + }); + }); + + it('a cell holding two trigger tiles on two layers reports both; outside the grid nothing does', () => { + const s = setup(); + const add = new AddTilemapLayerCommand(s.mapId); + s.stack.execute(add); + s.paint( + [ + [6, 4], + [12, 4], + ], + s.lavaId, + add.layerId, + ); + const cells = cellsOf(s); + expect( + cells + .get('6,4')! + .map((cell) => cell.tileId) + .sort(), + ).toEqual([s.lavaId, s.waterId].sort()); + expect(cells.has('12,4')).toBe(false); + }); + + it('the collision scale draws a cell in on its open sides only, as for a solid tile', () => { + const s = setup(); + s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_COLLISION_SCALE_KEY, 0.5)); + const pieces = (x: number) => + cellsOf(s) + .get(`${x},4`)! + .find((cell) => cell.tileId === s.waterId)!.rects; + const span = (x: number) => { + const rects = pieces(x); + return [Math.min(...rects.map((r) => r.x)), Math.max(...rects.map((r) => r.x + r.w))]; + }; + // The left end is drawn in, the side towards the next water cell is flush; lava is no water. + expect(span(6)).toEqual([6.25, 7]); + expect(span(7)).toEqual([7, 8]); + expect(span(8)).toEqual([8, 8.75]); + expect(pieces(7).every((r) => r.y === 4.25 && r.h === 0.5)).toBe(true); + }); + + it('a trigger tile with an outline reports with that polygon', () => { + const s = setup(); + const texture = new CreateAssetCommand('texture', 'Sheet'); + s.stack.execute(texture); + const sheet = findAsset(s.project, texture.asset.id) as TextureAsset; + sheet.file = `${'a'.repeat(64)}.png`; + sheet.width = 32; + sheet.height = 32; + const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 }); + s.stack.execute(frame); + s.stack.execute(new AddTilePictureCommand(s.lavaId, frame.frameId)); + // The lower half of the tile. + const half = [ + { x: 0, y: 16 }, + { x: 32, y: 16 }, + { x: 32, y: 32 }, + { x: 0, y: 32 }, + ]; + s.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [half])); + const lava = cellsOf(s) + .get('9,4')! + .find((cell) => cell.tileId === s.lavaId)!; + expect(lava.rects).toEqual([]); + expect(lava.polygons.map((p) => p.outline)).toEqual([ + [ + { x: 9, y: 4.5 }, + { x: 10, y: 4.5 }, + { x: 10, y: 5 }, + { x: 9, y: 5 }, + ], + ]); + const props = s.assetOf().props; + expect(triggerPolygons(s.project, props, s.assetOf())).toHaveLength(1); + expect(triggerRects(s.project, props, s.assetOf())).toEqual([{ x: 6, y: 4, w: 3, h: 1 }]); + }); +}); + +describe.each(COLLISION_SYSTEMS)('$system: trigger tiles report and never block (#160)', ({ physics }) => { + beforeAll(async () => { + await loadPhysics(); + }); + + const run = (s: Setup) => { + const started = startWorld(s, { physics, collisions: true }); + const hero = started.world.findByObjectId(s.hero)!; + const map = started.world.findByObjectId(s.mapObject)!; + const goTo = (x: number, y = -24) => { + hero.x = x; + hero.y = y; + started.world.tick(1 / 60); + }; + return { ...started, hero, map, goTo }; + }; + + it('walking through the pond: each cell and each area enters, stays and exits on its own', () => { + const s = setup(); + const { collisions, goTo } = run(s); + goTo(0); + expect(collisions.enters).toEqual([]); + // 40 … 80: over the first water cell (48 … 96). + goTo(60); + expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']); + expect(told(s, collisions.stays)).toEqual([]); + // 80 … 120: over cells 6 and 7, nearer to 7. The area is the one entered before. + goTo(100); + expect(told(s, collisions.enters)).toEqual(['cell water 7,4']); + expect(told(s, collisions.stays)).toEqual(['area water 7,4', 'cell water 6,4']); + expect(told(s, collisions.exits)).toEqual([]); + // 160 … 200: over the last water cell and the lava next to it. + goTo(180); + expect(told(s, collisions.enters)).toEqual(['area lava 9,4', 'cell lava 9,4', 'cell water 8,4']); + expect(told(s, collisions.stays)).toEqual(['area water 8,4']); + expect(told(s, collisions.exits)).toEqual(['cell water 6,4', 'cell water 7,4']); + goTo(0); + expect(told(s, collisions.exits)).toEqual([ + 'area lava 9,4', + 'area water 8,4', + 'cell lava 9,4', + 'cell water 8,4', + ]); + goTo(0); + expect(collisions.enters.length + collisions.stays.length + collisions.exits.length).toBe(0); + }); + + it('the pair is the collider and the map, apart from the pair of the map’s solid cells', () => { + const s = setup(); + const add = new AddTilemapLayerCommand(s.mapId); + s.stack.execute(add); + s.paint([[6, 4]], s.wallId, add.layerId); + // The same map once more, placed after the hero: the order in the scene has no say in the pair. + const later = place(s, s.mapId, { x: 0, y: 0 }); + const { collisions, world, goTo, map } = run(s); + goTo(60); + const collider = world.findByObjectId(s.heroCollider)!; + const maps = [map, world.findByObjectId(later)!]; + const tiles = collisions.enters.filter((pair) => pair.tile); + expect(tiles).toHaveLength(4); + expect(tiles.every((pair) => pair.a === collider && maps.includes(pair.b))).toBe(true); + // The wall under the water is each map's own pair, and it names no tile. + expect(collisions.enters.filter((pair) => !pair.tile)).toHaveLength(2); + // Nothing presses on a trigger tile, whatever the map's solid cells say of the same collider. + for (const pair of tiles) expect(collisions.contactOf(pair)).toBeNull(); + }); + + it('touching says so on a trigger tile, and no more off it', () => { + const s = setup(); + const { collisions, world, hero, map, goTo } = run(s); + goTo(0); + expect(collisions.touching(world, hero, map)).toBe(false); + goTo(100); + expect(collisions.touching(world, hero, map)).toBe(true); + expect(collisions.touching(world, map, hero)).toBe(true); + goTo(100, 100); + expect(collisions.touching(world, hero, map)).toBe(false); + }); + + it('a move goes through it and a ray does not meet it, even with Solid ticked', () => { + const s = setup(); + s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_SOLID_KEY, true)); + const { collisions, world, hero, goTo } = run(s); + goTo(0); + expect(collisions.moveAndSlide(world, hero, 150, 0)).toMatchObject({ blockedX: false, hit: false }); + expect(hero.x).toBe(150); + const ray = { ignore: hero, triggers: true, layers: ALL_LAYERS }; + expect(collisions.raycast(world, { x: -200, y: -24 }, { x: 1, y: 0 }, 500, ray)).toBeNull(); + }); + + it('nothing is told for a map switched off, a collider switched off, or layers that do not meet', () => { + const s = setup(); + const { collisions, world, map, goTo } = run(s); + const collider = world.findByObjectId(s.heroCollider)!; + goTo(100); + expect(told(s, collisions.enters)).toHaveLength(3); + map.props.enabled = false; + goTo(100); + expect(told(s, collisions.exits)).toHaveLength(3); + map.props.enabled = true; + collider.props.enabled = false; + goTo(100); + expect(told(s, collisions.enters)).toEqual([]); + collider.props.enabled = true; + map.props[COLLIDES_WITH_KEY] = 0; + goTo(100); + expect(told(s, collisions.enters)).toEqual([]); + expect(collisions.touching(world, collider, map)).toBe(false); + map.props[COLLIDES_WITH_KEY] = ALL_LAYERS; + goTo(100); + expect(told(s, collisions.enters)).toHaveLength(3); + }); + + it('follows the placed map, and a tile set during the run', () => { + const s = setup(); + const { collisions, world, map, goTo } = run(s); + goTo(0); + // Cell 4,4 spans -48 … 0: the hero stands on it and on 5,4. + expect(world.setTile(map, '', { x: 4, y: 4 }, s.lavaId)).toBe(true); + goTo(0); + expect(told(s, collisions.enters)).toEqual(['area lava 4,4', 'cell lava 4,4']); + world.setTile(map, '', { x: 4, y: 4 }, ''); + goTo(0); + expect(told(s, collisions.exits)).toEqual(['area lava 4,4', 'cell lava 4,4']); + // The map moved 48 to the left: the pond now begins at the hero's cell. + map.x = -48; + goTo(20); + expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']); + }); + + it('a cell grown by its collision scale is met from the cell next to it', () => { + const s = setup(); + s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_COLLISION_SCALE_KEY, 2)); + const { collisions, goTo } = run(s); + // Cell 6 begins at 48 and reaches half a cell further out, to 24; the hero ends at 30, in cell 5. + goTo(10); + expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']); + }); + + it('a system stopped and started again forgets what it told: the pond is entered anew', () => { + const s = setup(); + const { collisions, world, goTo } = run(s); + goTo(100); + expect(told(s, collisions.enters)).toHaveLength(3); + const system = collisions as CollisionSource & System; + system.stop?.(world); + expect(collisions.enters.length + collisions.stays.length).toBe(0); + system.start?.(world); + goTo(100); + expect(told(s, collisions.enters)).toHaveLength(3); + expect(told(s, collisions.stays)).toEqual([]); + }); + + it('a collider under the map itself is told nothing', () => { + const s = setup(); + const own = new AddColliderCommand(s.sceneId, s.mapObject); + s.stack.execute(own); + s.stack.execute(new SetOverrideCommand(s.sceneId, own.objectId, 'width', 400)); + const { collisions, world, goTo } = run(s); + goTo(0); + const collider = world.findByObjectId(own.objectId)!; + expect(collisions.enters.filter((pair) => pair.tile && pair.a === collider)).toEqual([]); + }); +}); + +describe.each(COLLISION_SYSTEMS)('$system: the collision events tell the tile (#160)', ({ physics }) => { + beforeAll(async () => { + await loadPhysics(); + }); + + /** A behavior on the hero logging five lines per event: the label, the tile, its cell and the other body. */ + function logging(s: Setup, type: string, label: string, params = {}) { + const behavior = makeBehavior(s, `${label} listener`); + place(s, behavior.id, { x: 0, y: 0 }, s.hero); + const edit = graphEditor(s, behavior); + const event = edit.addNode(type, params); + const logs = ['tile', 'cellX', 'cellY', 'other'].map(() => edit.addNode('Log')); + const mark = edit.addNode('Log', { message: label }); + edit.wire([ + [event, 'exec', mark, 'exec'], + [mark, 'exec', logs[0]!, 'exec'], + [logs[0]!, 'exec', logs[1]!, 'exec'], + [logs[1]!, 'exec', logs[2]!, 'exec'], + [logs[2]!, 'exec', logs[3]!, 'exec'], + [event, 'tile', logs[0]!, 'message'], + [event, 'cellX', logs[1]!, 'message'], + [event, 'cellY', logs[2]!, 'message'], + [event, 'other', logs[3]!, 'message'], + ]); + return { edit, event }; + } + + const walk = (s: Setup) => { + const started = startWorld(s, { physics, collisions: true, behaviors: true }); + const hero = started.world.findByObjectId(s.hero)!; + const goTo = (x: number) => { + hero.x = x; + started.world.tick(1 / 60); + const said = started.world.log.splice(0); + // Five lines per event: the label, the tile, the cell and the other body. + const events: string[] = []; + for (let i = 0; i < said.length; i += 5) events.push(said.slice(i, i + 5).join(' ')); + return events; + }; + return { ...started, hero, goTo }; + }; + + it('enter and exit run once for the pond by default, and once per cell with "per cell"', () => { + const s = setup(); + logging(s, 'OnCollisionEnter', 'in'); + logging(s, 'OnCollisionExit', 'out'); + logging(s, 'OnCollisionEnter', 'in-cell', { perCell: true }); + logging(s, 'OnCollisionExit', 'out-cell', { perCell: true }); + const { goTo } = walk(s); + const w = s.waterId; + expect(goTo(0)).toEqual([]); + expect(goTo(60)).toEqual([`in ${w} 6 4 Ground`, `in-cell ${w} 6 4 Ground`]); + expect(goTo(100)).toEqual([`in-cell ${w} 7 4 Ground`]); + expect(goTo(140)).toEqual([`in-cell ${w} 8 4 Ground`, `out-cell ${w} 6 4 Ground`]); + // At 140 the hero was nearer to cell 7 than to 8, and that is the area's last word. + expect(goTo(0)).toEqual([`out ${w} 7 4 Ground`, `out-cell ${w} 7 4 Ground`, `out-cell ${w} 8 4 Ground`]); + }); + + it('stay tells the nearest cell of the area, or every cell lain on', () => { + const s = setup(); + logging(s, 'OnCollisionStay', 'on'); + logging(s, 'OnCollisionStay', 'on-cell', { perCell: true }); + const { goTo } = walk(s); + const w = s.waterId; + expect(goTo(100)).toEqual([]); + expect(goTo(90)).toEqual([`on ${w} 6 4 Ground`, `on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]); + expect(goTo(110)).toEqual([`on ${w} 7 4 Ground`, `on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]); + }); + + it('"per cell" is read from what feeds it, each time the event comes', () => { + const s = setup(); + const { edit, event } = logging(s, 'OnCollisionEnter', 'in'); + const held = edit.addNode('KeyIsDown', { key: 'c' }); + edit.connect(held, 'down', event, 'perCell'); + const { goTo, input } = walk(s); + const w = s.waterId; + expect(goTo(60)).toEqual([`in ${w} 6 4 Ground`]); + input.press('c'); + // With the key held the node hears cells: the area it stands in is not news to it, cell 7 is. + expect(goTo(100)).toEqual([`in ${w} 7 4 Ground`]); + }); + + it('a stay with an interval keeps one clock per cell', () => { + const s = setup(); + logging(s, 'OnCollisionStay', 'on-cell', { perCell: true, interval: 0.04 }); + const { goTo } = walk(s); + const w = s.waterId; + // Cell 6 is entered two frames before cell 7, so its 0.04 s are up while cell 7 still waits. + expect(goTo(60)).toEqual([]); + expect(goTo(60)).toEqual([]); + expect(goTo(100)).toEqual([]); + expect(goTo(100)).toEqual([`on-cell ${w} 6 4 Ground`]); + expect(goTo(100)).toEqual([]); + // Three frames on, cell 7 is due for the first time and cell 6, with 0.01 s left over, again. + expect(goTo(100)).toEqual([`on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]); + }); + + it('another collider and the solid cells of a map tell no tile: empty and -1, -1', () => { + const s = setup(); + logging(s, 'OnCollisionEnter', 'in'); + s.paint([[5, 4]], s.wallId); + const crate = place(s, s.spriteId, { x: -200, y: -24 }); + s.stack.execute(new AddColliderCommand(s.sceneId, crate)); + const { goTo } = walk(s); + // 0 … 40: over the wall in cell 5 (0 … 48), short of the water. + expect(goTo(20)).toEqual(['in -1 -1 Ground']); + expect(goTo(-170)).toEqual(['in -1 -1 Hero']); + }); + + it('a behavior on the map hears who walks onto its tiles', () => { + const s = setup(); + const behavior = makeBehavior(s, 'Pond'); + place(s, behavior.id, { x: 0, y: 0 }, s.mapObject); + const edit = graphEditor(s, behavior); + const enter = edit.addNode('OnCollisionEnter'); + const who = edit.addNode('Log'); + const where = edit.addNode('Log'); + edit.wire([ + [enter, 'exec', who, 'exec'], + [who, 'exec', where, 'exec'], + [enter, 'other', who, 'message'], + [enter, 'cellX', where, 'message'], + ]); + const { world, hero } = walk(s); + hero.x = 180; + world.tick(1 / 60); + // Water and lava are two areas, so two events. + expect(world.log).toEqual(['Hero', '8', 'Hero', '9']); + }); +}); + +describe('a dynamic body falls through trigger tiles and is told (#160)', () => { + beforeAll(async () => { + await loadPhysics(); + }); + + it('keeps falling, and enters and leaves the pond on the way', () => { + const s = setup(); + s.stack.execute(new AddRigidbodyCommand(s.sceneId, s.hero)); + const { world, collisions } = startWorld(s, { physics: true, collisions: true }); + const hero = world.findByObjectId(s.hero)!; + // Above the pond's middle cell (96 … 144), which lies at -48 … 0. + hero.x = 120; + hero.y = -150; + const seen: string[] = []; + for (let i = 0; i < 90; i++) { + world.tick(1 / 60); + for (const pair of collisions.enters) if (pair.tile && !pair.tile.perCell) seen.push('enter'); + for (const pair of collisions.exits) if (pair.tile && !pair.tile.perCell) seen.push('exit'); + } + expect(seen).toEqual(['enter', 'exit']); + expect(hero.y).toBeGreaterThan(100); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 05d32e61..72ad0034 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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'));