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:
1 parent
2ff3a78607
commit
275e50af01
20 files changed
+1161
-57
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -120,6 +120,17 @@ export class GraphInterpreter {
|
|||||||
this.follow(node, port, ctx, 0);
|
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
|
* 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
|
* input carries a running value rather than a pick has no state outputs at all, so it is never
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cellToWorld, worldToCell } from '../../../model/liveTilemap';
|
|||||||
import { worldMatrixOf } from '../../NodeContext';
|
import { worldMatrixOf } from '../../NodeContext';
|
||||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||||
import { asEntity, asNumber, asString, type Value } from '../../values';
|
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. */
|
/** The layer by name, picked from the layers of every map in the project; none = the first. */
|
||||||
const layerPort = (): PortDef => ({
|
const layerPort = (): PortDef => ({
|
||||||
@@ -17,14 +17,6 @@ const layerPort = (): PortDef => ({
|
|||||||
field: { key: 'layer', label: 'Layer', kind: 'tileLayer', default: '' },
|
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')];
|
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. */
|
/** 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 type { NodeDef } from '../../NodeRegistry';
|
||||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||||
import { collisionOutputs, contactOutputs } from '../shared';
|
import { collisionOutputs, contactOutputs, perCellPort, tileOutputs } from '../shared';
|
||||||
|
|
||||||
export const OnCollisionEnter: NodeDef = {
|
export const OnCollisionEnter: NodeDef = {
|
||||||
type: 'OnCollisionEnter',
|
type: 'OnCollisionEnter',
|
||||||
@@ -9,9 +9,11 @@ export const OnCollisionEnter: NodeDef = {
|
|||||||
description:
|
description:
|
||||||
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
|
'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 ' +
|
'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).',
|
'point x/y where (all 0 for a trigger). Walking onto a trigger tile of a tilemap runs it too, with ' +
|
||||||
inputs: [],
|
'the tile and its cell: once for a whole stretch of touching cells of that tile, or with "per cell" ' +
|
||||||
outputs: [...collisionOutputs(), ...contactOutputs()],
|
'for every cell.',
|
||||||
|
inputs: [perCellPort()],
|
||||||
|
outputs: [...collisionOutputs(), ...contactOutputs(), ...tileOutputs()],
|
||||||
params: [orderParam],
|
params: [orderParam],
|
||||||
hiddenParams: hiddenOrder,
|
hiddenParams: hiddenOrder,
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||||
import { collisionOutputs } from '../shared';
|
import { collisionOutputs, perCellPort, tileOutputs } from '../shared';
|
||||||
|
|
||||||
export const OnCollisionExit: NodeDef = {
|
export const OnCollisionExit: NodeDef = {
|
||||||
type: 'OnCollisionExit',
|
type: 'OnCollisionExit',
|
||||||
label: 'On collision exit',
|
label: 'On collision exit',
|
||||||
category: 'event',
|
category: 'event',
|
||||||
description: 'Runs once when a collider of the owner stops overlapping another collider.',
|
description:
|
||||||
inputs: [],
|
'Runs once when a collider of the owner stops overlapping another collider – or leaves a trigger tile ' +
|
||||||
outputs: collisionOutputs(),
|
'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],
|
params: [orderParam],
|
||||||
hiddenParams: hiddenOrder,
|
hiddenParams: hiddenOrder,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||||
import { collisionOutputs, contactOutputs } from '../shared';
|
import { collisionOutputs, contactOutputs, perCellPort, tileOutputs } from '../shared';
|
||||||
|
|
||||||
export const OnCollisionStay: NodeDef = {
|
export const OnCollisionStay: NodeDef = {
|
||||||
type: 'OnCollisionStay',
|
type: 'OnCollisionStay',
|
||||||
@@ -9,9 +9,11 @@ export const OnCollisionStay: NodeDef = {
|
|||||||
description:
|
description:
|
||||||
'Runs while a collider of the owner overlaps another collider: every frame, or with an interval once ' +
|
'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 ' +
|
'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).',
|
'how they touch right now (all 0 for a trigger and in the editor run). On a trigger tile of a ' +
|
||||||
inputs: [],
|
'tilemap, tile and cell x/y say which: the cell nearest to the own collider, or with "per cell" each ' +
|
||||||
outputs: [...collisionOutputs(), ...contactOutputs()],
|
'cell it lies on.',
|
||||||
|
inputs: [perCellPort()],
|
||||||
|
outputs: [...collisionOutputs(), ...contactOutputs(), ...tileOutputs()],
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
key: 'interval',
|
key: 'interval',
|
||||||
|
|||||||
@@ -44,6 +44,34 @@ export const contactOutputs = (): PortDef[] => [
|
|||||||
num('pointY', 'point y'),
|
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' };
|
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||||
|
|
||||||
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const TILE_FPS_KEY = 'fps';
|
|||||||
export const TILE_PLAYBACK_KEY = 'playback';
|
export const TILE_PLAYBACK_KEY = 'playback';
|
||||||
/** Whether a cell of this tile blocks movement in the running game (#132). */
|
/** Whether a cell of this tile blocks movement in the running game (#132). */
|
||||||
export const TILE_SOLID_KEY = 'solid';
|
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). */
|
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
|
||||||
export const TILE_ONE_WAY_KEY = 'oneWay';
|
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). */
|
/** 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';
|
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 {
|
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 {
|
export function isTileOneWay(asset: TileAsset): boolean {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with
|
* 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 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 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 type { TileAsset, TilemapAsset } from './Asset';
|
||||||
import { tileQuadrants } from './autotile';
|
import { tileQuadrants } from './autotile';
|
||||||
@@ -33,6 +36,7 @@ import { findFrame } from './texture';
|
|||||||
import {
|
import {
|
||||||
isTileOneWay,
|
isTileOneWay,
|
||||||
isTileSolid,
|
isTileSolid,
|
||||||
|
isTileTrigger,
|
||||||
readTile,
|
readTile,
|
||||||
TILE_ONE_WAY_ANGLE_KEY,
|
TILE_ONE_WAY_ANGLE_KEY,
|
||||||
tileCollisionScale,
|
tileCollisionScale,
|
||||||
@@ -244,6 +248,17 @@ function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMate
|
|||||||
return oneWay === true || tileOneWayAngle(tile) === oneWay;
|
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. */
|
/** The solid tiles painted on the map, each once, in the order the layers first name them. */
|
||||||
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||||||
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||||||
@@ -286,8 +301,7 @@ function blockingCells(
|
|||||||
project: Project,
|
project: Project,
|
||||||
props: PropertyBag,
|
props: PropertyBag,
|
||||||
asset: TilemapAsset,
|
asset: TilemapAsset,
|
||||||
oneWay: OneWayFilter,
|
pick: TilePick,
|
||||||
material?: TileMaterial,
|
|
||||||
): readonly CellBlock[] {
|
): readonly CellBlock[] {
|
||||||
const tiles = new Map<string, BlockingTile>();
|
const tiles = new Map<string, BlockingTile>();
|
||||||
for (const layer of asset.layers)
|
for (const layer of asset.layers)
|
||||||
@@ -296,7 +310,7 @@ function blockingCells(
|
|||||||
const tile = findAssetOf(project, id, 'tile');
|
const tile = findAssetOf(project, id, 'tile');
|
||||||
tiles.set(
|
tiles.set(
|
||||||
id,
|
id,
|
||||||
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
|
tile && picks(tile, pick)
|
||||||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
@@ -304,8 +318,9 @@ function blockingCells(
|
|||||||
const key = [
|
const key = [
|
||||||
tilemapColumns(props),
|
tilemapColumns(props),
|
||||||
tilemapRows(props),
|
tilemapRows(props),
|
||||||
oneWay,
|
'trigger' in pick
|
||||||
material ? `${material.friction}/${material.bounciness}` : '',
|
? `trigger:${pick.trigger ?? ''}`
|
||||||
|
: `${pick.oneWay}|${pick.material ? `${pick.material.friction}/${pick.material.bounciness}` : ''}`,
|
||||||
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
|
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
|
||||||
[...tiles]
|
[...tiles]
|
||||||
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
|
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
|
||||||
@@ -373,7 +388,7 @@ export function solidCells(
|
|||||||
oneWay: OneWayFilter = false,
|
oneWay: OneWayFilter = false,
|
||||||
material?: TileMaterial,
|
material?: TileMaterial,
|
||||||
): Required<SolidCell>[] {
|
): 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>[] {
|
function boxCells(cells: readonly CellBlock[]): Required<SolidCell>[] {
|
||||||
@@ -393,7 +408,11 @@ export function solidPolygons(
|
|||||||
oneWay: OneWayFilter = false,
|
oneWay: OneWayFilter = false,
|
||||||
material?: TileMaterial,
|
material?: TileMaterial,
|
||||||
): readonly CellPolygon[] {
|
): 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[] = [];
|
const polygons: CellPolygon[] = [];
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
if (cell.box !== null) continue;
|
if (cell.box !== null) continue;
|
||||||
@@ -527,9 +546,126 @@ export function solidRects(
|
|||||||
oneWay: OneWayFilter = false,
|
oneWay: OneWayFilter = false,
|
||||||
material?: TileMaterial,
|
material?: TileMaterial,
|
||||||
): readonly CellRect[] {
|
): readonly CellRect[] {
|
||||||
return derived(rectsOf, blockingCells(project, props, asset, oneWay, material), (cells) =>
|
return rectCells(blockingCells(project, props, asset, { oneWay, material }));
|
||||||
mergeCells(boxCells(cells)),
|
}
|
||||||
);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
TILE_SORT_OFFSET_KEY,
|
TILE_SORT_OFFSET_KEY,
|
||||||
TILE_SORT_OFFSET_LIMIT,
|
TILE_SORT_OFFSET_LIMIT,
|
||||||
TILE_SOLID_KEY,
|
TILE_SOLID_KEY,
|
||||||
|
TILE_TRIGGER_KEY,
|
||||||
} from '../model/tile';
|
} from '../model/tile';
|
||||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||||
import { PALETTE_KEY } from '../model/pixelart';
|
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).
|
// 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 },
|
{ 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).
|
// 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 },
|
{ 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 },
|
{ ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY },
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
|
|||||||
sameRects(a.solids, b.solids) &&
|
sameRects(a.solids, b.solids) &&
|
||||||
samePolygons(a.outlines, b.outlines) &&
|
samePolygons(a.outlines, b.outlines) &&
|
||||||
sameRects(a.oneWay.solids, b.oneWay.solids) &&
|
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<
|
private readonly boundaries = new Map<
|
||||||
string,
|
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. */
|
/** 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>();
|
private readonly tinted = new Map<string, TilemapSolids>();
|
||||||
@@ -64,7 +66,9 @@ export class TilemapSolidOutlines {
|
|||||||
oneWay: CellRect[];
|
oneWay: CellRect[];
|
||||||
oneWayOutlines: Vec2[][];
|
oneWayOutlines: Vec2[][];
|
||||||
boundary: BoundaryLine[];
|
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 {
|
} | null {
|
||||||
const node = item && this.nodes.get(item.id);
|
const node = item && this.nodes.get(item.id);
|
||||||
if (!node || !item.tilemap) return null;
|
if (!node || !item.tilemap) return null;
|
||||||
@@ -75,6 +79,7 @@ export class TilemapSolidOutlines {
|
|||||||
rects: tinted.solids.map((rect) => ({ ...rect })),
|
rects: tinted.solids.map((rect) => ({ ...rect })),
|
||||||
outlines: tinted.outlines.length,
|
outlines: tinted.outlines.length,
|
||||||
oneWay: tinted.oneWay.solids.map((rect) => ({ ...rect })),
|
oneWay: tinted.oneWay.solids.map((rect) => ({ ...rect })),
|
||||||
|
triggers: tinted.triggers.solids.map((rect) => ({ ...rect })),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
visible: node.visible,
|
visible: node.visible,
|
||||||
@@ -82,6 +87,10 @@ export class TilemapSolidOutlines {
|
|||||||
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
||||||
oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })),
|
oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })),
|
||||||
oneWayOutlines: item.tilemap.oneWay.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
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) => ({
|
boundary: this.boundaryOf(item).solid.map((line) => ({
|
||||||
points: line.points.map((p) => ({ ...p })),
|
points: line.points.map((p) => ({ ...p })),
|
||||||
closed: line.closed,
|
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!;
|
const from: TilemapSolids = item.tilemap!;
|
||||||
let boundary = this.boundaries.get(item.id);
|
let boundary = this.boundaries.get(item.id);
|
||||||
if (!boundary || !sameSolids(boundary.from, from)) {
|
if (!boundary || !sameSolids(boundary.from, from)) {
|
||||||
@@ -97,6 +110,7 @@ export class TilemapSolidOutlines {
|
|||||||
from,
|
from,
|
||||||
solid: solidBoundary(from.solids, from.outlines),
|
solid: solidBoundary(from.solids, from.outlines),
|
||||||
oneWay: solidBoundary(from.oneWay.solids, from.oneWay.outlines),
|
oneWay: solidBoundary(from.oneWay.solids, from.oneWay.outlines),
|
||||||
|
triggers: solidBoundary(from.triggers.solids, from.triggers.outlines),
|
||||||
};
|
};
|
||||||
this.boundaries.set(item.id, boundary);
|
this.boundaries.set(item.id, boundary);
|
||||||
}
|
}
|
||||||
@@ -113,8 +127,10 @@ export class TilemapSolidOutlines {
|
|||||||
const rects = item.tilemap?.solids ?? [];
|
const rects = item.tilemap?.solids ?? [];
|
||||||
const outlines = item.tilemap?.outlines ?? [];
|
const outlines = item.tilemap?.outlines ?? [];
|
||||||
const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] };
|
const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] };
|
||||||
|
const triggers = item.tilemap?.triggers ?? { solids: [], outlines: [] };
|
||||||
this.tinted.delete(item.id);
|
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;
|
if (node) node.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,7 +148,12 @@ export class TilemapSolidOutlines {
|
|||||||
const color = Theme.colors.collider;
|
const color = Theme.colors.collider;
|
||||||
const boundary = this.boundaryOf(item);
|
const boundary = this.boundaryOf(item);
|
||||||
// Filled piece by piece, but stroked only along the outer edges, so no seam shows (#173).
|
// 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;
|
if (rects.length + outlines.length === 0) return;
|
||||||
for (const rect of rects) {
|
for (const rect of rects) {
|
||||||
const corner = cellOrigin(item.props, rect.x, rect.y);
|
const corner = cellOrigin(item.props, rect.x, rect.y);
|
||||||
@@ -145,7 +166,11 @@ export class TilemapSolidOutlines {
|
|||||||
);
|
);
|
||||||
node.fill({ color, alpha });
|
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;
|
if (lines.length === 0) return;
|
||||||
for (const line of lines)
|
for (const line of lines)
|
||||||
node.poly(
|
node.poly(
|
||||||
@@ -158,9 +183,12 @@ export class TilemapSolidOutlines {
|
|||||||
this.tinted.set(item.id, filled);
|
this.tinted.set(item.id, filled);
|
||||||
fill(filled.solids, filled.outlines, COLLIDER_FILL_ALPHA);
|
fill(filled.solids, filled.outlines, COLLIDER_FILL_ALPHA);
|
||||||
fill(filled.oneWay.solids, filled.oneWay.outlines, COLLIDER_FILL_ALPHA / 2);
|
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.solid, width);
|
||||||
stroke(boundary.oneWay, width / 2);
|
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.
|
// 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));
|
const length = CONE_LENGTH / (scale * scaleOf(item.m));
|
||||||
for (const rect of oneWay.solids) {
|
for (const rect of oneWay.solids) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
surfaceOf,
|
surfaceOf,
|
||||||
} from '../systems/solids';
|
} from '../systems/solids';
|
||||||
import type { System } from '../systems/System';
|
import type { System } from '../systems/System';
|
||||||
|
import { TileTriggers } from '../systems/tileTriggers';
|
||||||
import {
|
import {
|
||||||
angleBetween,
|
angleBetween,
|
||||||
blend,
|
blend,
|
||||||
@@ -148,6 +149,8 @@ export class PhysicsSystem implements System, CollisionSource {
|
|||||||
private passes = new Map<string, boolean>();
|
private passes = new Map<string, boolean>();
|
||||||
private hooks: PhysicsHooks | null = null;
|
private hooks: PhysicsHooks | null = null;
|
||||||
private readonly solids = new Solids();
|
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;
|
private backlog = 0;
|
||||||
/** The length of the frame being played – the time a move of the graph's is spread over. */
|
/** The length of the frame being played – the time a move of the graph's is spread over. */
|
||||||
private frameDt = PHYSICS_STEP;
|
private frameDt = PHYSICS_STEP;
|
||||||
@@ -188,6 +191,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
|||||||
this.enters = [];
|
this.enters = [];
|
||||||
this.stays = [];
|
this.stays = [];
|
||||||
this.exits = [];
|
this.exits = [];
|
||||||
|
this.triggers.stop();
|
||||||
this.backlog = 0;
|
this.backlog = 0;
|
||||||
this.frameDt = PHYSICS_STEP;
|
this.frameDt = PHYSICS_STEP;
|
||||||
this.hinted.clear();
|
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.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog);
|
||||||
this.writeBack(this.backlog / PHYSICS_STEP);
|
this.writeBack(this.backlog / PHYSICS_STEP);
|
||||||
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
|
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. */
|
/** 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 {
|
touching(world: World, a: Entity, b: Entity): boolean {
|
||||||
for (const ca of partsOf(world, a))
|
for (const ca of partsOf(world, a))
|
||||||
for (const cb of partsOf(world, b))
|
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;
|
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. */
|
/** How a current pair touches as of the last step: read from the engine's contact. */
|
||||||
contactOf(pair: CollisionPair): Contact | null {
|
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}`);
|
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;
|
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]));
|
return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1]));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
adoptDeclarations,
|
adoptDeclarations,
|
||||||
|
asBoolean,
|
||||||
buildSharedState,
|
buildSharedState,
|
||||||
carriedForward,
|
carriedForward,
|
||||||
declaredVariables,
|
declaredVariables,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
type Value,
|
type Value,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import { GraphCache } from '../../core/graph/graphIndex';
|
import { GraphCache } from '../../core/graph/graphIndex';
|
||||||
|
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
|
||||||
import { keyName } from '../../core/keys';
|
import { keyName } from '../../core/keys';
|
||||||
import { graphLookup } from '../../core/model/graphLookup';
|
import { graphLookup } from '../../core/model/graphLookup';
|
||||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
collisionStayPulses,
|
collisionStayPulses,
|
||||||
type Instance,
|
type Instance,
|
||||||
keyDownPulses,
|
keyDownPulses,
|
||||||
|
type PerCell,
|
||||||
type Produced,
|
type Produced,
|
||||||
type Pulse,
|
type Pulse,
|
||||||
pulsesOf,
|
pulsesOf,
|
||||||
@@ -537,6 +540,8 @@ export class BehaviorSystem implements System {
|
|||||||
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
||||||
};
|
};
|
||||||
const { input, collisions, animations } = this;
|
const { input, collisions, animations } = this;
|
||||||
|
const perCell: PerCell = (i, node) =>
|
||||||
|
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
|
||||||
const kinds: (() => Pulse[])[] = [
|
const kinds: (() => Pulse[])[] = [
|
||||||
() =>
|
() =>
|
||||||
ticking.flatMap((i) =>
|
ticking.flatMap((i) =>
|
||||||
@@ -546,9 +551,9 @@ export class BehaviorSystem implements System {
|
|||||||
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
|
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
|
||||||
() => ticking.flatMap((i) => timerPulses(i, dt)),
|
() => ticking.flatMap((i) => timerPulses(i, dt)),
|
||||||
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
// 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) => collisionEnterPulses(i, collisions, perCell))),
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt))),
|
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions))),
|
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
|
||||||
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
||||||
() =>
|
() =>
|
||||||
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Solids,
|
Solids,
|
||||||
} from './solids';
|
} from './solids';
|
||||||
import type { System } from './System';
|
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
|
* 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
|
* 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
|
* 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 {
|
export class CollisionSystem implements System, CollisionSource {
|
||||||
enters: CollisionPair[] = [];
|
enters: CollisionPair[] = [];
|
||||||
@@ -34,6 +36,7 @@ export class CollisionSystem implements System, CollisionSource {
|
|||||||
exits: CollisionPair[] = [];
|
exits: CollisionPair[] = [];
|
||||||
private previous = new Map<string, CollisionPair>();
|
private previous = new Map<string, CollisionPair>();
|
||||||
private readonly solids = new Solids();
|
private readonly solids = new Solids();
|
||||||
|
private readonly triggers = new TileTriggers();
|
||||||
|
|
||||||
update(world: World): void {
|
update(world: World): void {
|
||||||
this.solids.reset();
|
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 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);
|
for (const [key, pair] of this.previous) if (!current.has(key)) this.exits.push(pair);
|
||||||
this.previous = current;
|
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 {
|
stop(): void {
|
||||||
this.previous = new Map();
|
this.previous = new Map();
|
||||||
this.solids.reset();
|
this.solids.reset();
|
||||||
|
this.triggers.stop();
|
||||||
this.enters = [];
|
this.enters = [];
|
||||||
this.stays = [];
|
this.stays = [];
|
||||||
this.exits = [];
|
this.exits = [];
|
||||||
@@ -80,7 +89,9 @@ export class CollisionSystem implements System, CollisionSource {
|
|||||||
touching(world: World, a: Entity, b: Entity): boolean {
|
touching(world: World, a: Entity, b: Entity): boolean {
|
||||||
for (const ca of partsOf(world, a)) {
|
for (const ca of partsOf(world, a)) {
|
||||||
for (const cb of partsOf(world, b)) {
|
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;
|
return false;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { parentChain } from '../../core/model/walk';
|
|||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import type { AnimationSystem } from './AnimationSystem';
|
import type { AnimationSystem } from './AnimationSystem';
|
||||||
import type { Input } from './Input';
|
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. */
|
/** What one node last handed on, and when – the graph tab shows the newest of every instance. */
|
||||||
export interface Produced {
|
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 ?? []);
|
const entered = sides(instance, collisions?.enters ?? []);
|
||||||
return instance.interpreter
|
return instance.interpreter.eventNodes('OnCollisionEnter').flatMap((node) =>
|
||||||
.eventNodes('OnCollisionEnter')
|
heardBy(instance, node, entered, perCell).map((side) => ({
|
||||||
.flatMap((node) => entered.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) })));
|
instance,
|
||||||
|
node,
|
||||||
|
outputs: sideOutputs(side, side.pair),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,6 +153,7 @@ export function collisionStayPulses(
|
|||||||
instance: Instance,
|
instance: Instance,
|
||||||
collisions: CollisionSource | null,
|
collisions: CollisionSource | null,
|
||||||
dt: number,
|
dt: number,
|
||||||
|
perCell: PerCell,
|
||||||
): Pulse[] {
|
): Pulse[] {
|
||||||
if (!collisions) return [];
|
if (!collisions) return [];
|
||||||
const interpreter = instance.interpreter;
|
const interpreter = instance.interpreter;
|
||||||
@@ -146,10 +168,10 @@ export function collisionStayPulses(
|
|||||||
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
||||||
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
||||||
const running: Record<string, number> = {};
|
const running: Record<string, number> = {};
|
||||||
for (const side of stays) {
|
for (const side of heardBy(instance, node, stays, perCell)) {
|
||||||
let fires = 1;
|
let fires = 1;
|
||||||
if (interval > 0) {
|
if (interval > 0) {
|
||||||
const key = `${side.pair.a.id}|${side.pair.b.id}`;
|
const key = pairKey(side.pair);
|
||||||
let elapsed = (clocks[key] ?? 0) + dt;
|
let elapsed = (clocks[key] ?? 0) + dt;
|
||||||
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
|
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
|
||||||
running[key] = elapsed;
|
running[key] = elapsed;
|
||||||
@@ -163,11 +185,17 @@ export function collisionStayPulses(
|
|||||||
return pulses;
|
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 ?? []);
|
const left = sides(instance, collisions?.exits ?? []);
|
||||||
return instance.interpreter
|
return instance.interpreter
|
||||||
.eventNodes('OnCollisionExit')
|
.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. */
|
/** 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> {
|
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) {
|
if (contact !== undefined) {
|
||||||
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
||||||
const sign = own === pair.a ? 1 : -1;
|
const sign = own === pair.a ? 1 : -1;
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ export interface CollisionPair {
|
|||||||
normal?: Vec2;
|
normal?: Vec2;
|
||||||
impulse?: number;
|
impulse?: number;
|
||||||
point?: Vec2;
|
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}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
oneWayAngles,
|
oneWayAngles,
|
||||||
solidPolygons,
|
solidPolygons,
|
||||||
solidRects,
|
solidRects,
|
||||||
|
triggerPolygons,
|
||||||
|
triggerRects,
|
||||||
} from '../core/model/tilemapCollision';
|
} from '../core/model/tilemapCollision';
|
||||||
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
|
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
|
||||||
import { objectName } from '../core/model/SceneObject';
|
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.
|
* its tiles land things within (#159), since tiles of two angles never merge.
|
||||||
*/
|
*/
|
||||||
oneWay: { solids: (CellRect & { angle: number })[]; outlines: CellPolygon[] };
|
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
|
* 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.
|
* 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. */
|
/** 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 {
|
function solidsOf(project: Project, props: PropertyBag, asset: TilemapAsset): TilemapSolids {
|
||||||
const angles = oneWayAngles(project, asset);
|
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)),
|
outlines: angles.flatMap((angle) => solidPolygons(project, props, asset, angle)),
|
||||||
},
|
},
|
||||||
|
triggers: {
|
||||||
|
solids: triggerRects(project, props, asset),
|
||||||
|
outlines: triggerPolygons(project, props, asset),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof setup>;
|
||||||
|
|
||||||
|
const cellsOf = (s: Setup) => triggerCells(s.project, s.assetOf().props, s.assetOf());
|
||||||
|
const flat = (cells: ReadonlyMap<string, readonly TriggerCell[]>) => [...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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. */
|
/** Whether a box lies inside another, give or take a pixel of rounding. */
|
||||||
function inside(inner, outer) {
|
function inside(inner, outer) {
|
||||||
return (
|
return (
|
||||||
@@ -1229,6 +1250,88 @@ export async function tilemap(t) {
|
|||||||
passed && passed.x > -170 + 20,
|
passed && passed.x > -170 + 20,
|
||||||
`world x=${passed?.x}`,
|
`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(`
|
await mutate(`
|
||||||
const scene = __gm.state.activeScene;
|
const scene = __gm.state.activeScene;
|
||||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('tm-crate'));
|
scene.objects = scene.objects.filter((o) => !o.id.startsWith('tm-crate'));
|
||||||
|
|||||||
Reference in new issue
Block a user