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
@@ -21,6 +21,8 @@ export const TILE_FPS_KEY = 'fps';
|
||||
export const TILE_PLAYBACK_KEY = 'playback';
|
||||
/** Whether a cell of this tile blocks movement in the running game (#132). */
|
||||
export const TILE_SOLID_KEY = 'solid';
|
||||
/** Whether a cell of this tile reports what overlaps it instead of blocking it: water, lava, a goal (#160). */
|
||||
export const TILE_TRIGGER_KEY = 'trigger';
|
||||
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
|
||||
export const TILE_ONE_WAY_KEY = 'oneWay';
|
||||
/** How far a contact may lean from straight up and still land on a one-way tile, in degrees (#159). */
|
||||
@@ -59,8 +61,13 @@ export function tilePlayback(asset: TileAsset): TilePlayback {
|
||||
return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop';
|
||||
}
|
||||
|
||||
/** A trigger never blocks, whatever Solid says: it is the collider's rule, where Trigger wins too. */
|
||||
export function isTileSolid(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_SOLID_KEY] === true;
|
||||
return asset.props[TILE_SOLID_KEY] === true && !isTileTrigger(asset);
|
||||
}
|
||||
|
||||
export function isTileTrigger(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_TRIGGER_KEY] === true;
|
||||
}
|
||||
|
||||
export function isTileOneWay(asset: TileAsset): boolean {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with
|
||||
* a platform of another angle (#159). The same holds for a tile's surface (#193): with a `material`,
|
||||
* a function looks only at the tiles of that friction and bounciness.
|
||||
*
|
||||
* A trigger tile (#160) blocks nothing. Its cells are worked out by the same rules, apart from the
|
||||
* solid ones, and handed on cell by cell: the collision events tell which one was walked into.
|
||||
*/
|
||||
import type { TileAsset, TilemapAsset } from './Asset';
|
||||
import { tileQuadrants } from './autotile';
|
||||
@@ -33,6 +36,7 @@ import { findFrame } from './texture';
|
||||
import {
|
||||
isTileOneWay,
|
||||
isTileSolid,
|
||||
isTileTrigger,
|
||||
readTile,
|
||||
TILE_ONE_WAY_ANGLE_KEY,
|
||||
tileCollisionScale,
|
||||
@@ -244,6 +248,17 @@ function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMate
|
||||
return oneWay === true || tileOneWayAngle(tile) === oneWay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose cells a function works out: the solid tiles passing `oneWay` and `material`, or the trigger
|
||||
* tiles (#160) – all of them, or the one named. A trigger tile is never among the solid ones.
|
||||
*/
|
||||
type TilePick = { oneWay: OneWayFilter; material?: TileMaterial } | { trigger: string | null };
|
||||
|
||||
function picks(tile: TileAsset, pick: TilePick): boolean {
|
||||
if ('trigger' in pick) return isTileTrigger(tile) && (pick.trigger === null || pick.trigger === tile.id);
|
||||
return isTileSolid(tile) && passesFilter(tile, pick.oneWay, pick.material);
|
||||
}
|
||||
|
||||
/** The solid tiles painted on the map, each once, in the order the layers first name them. */
|
||||
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||||
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||||
@@ -286,8 +301,7 @@ function blockingCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay: OneWayFilter,
|
||||
material?: TileMaterial,
|
||||
pick: TilePick,
|
||||
): readonly CellBlock[] {
|
||||
const tiles = new Map<string, BlockingTile>();
|
||||
for (const layer of asset.layers)
|
||||
@@ -296,7 +310,7 @@ function blockingCells(
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
tiles.set(
|
||||
id,
|
||||
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
|
||||
tile && picks(tile, pick)
|
||||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||||
: null,
|
||||
);
|
||||
@@ -304,8 +318,9 @@ function blockingCells(
|
||||
const key = [
|
||||
tilemapColumns(props),
|
||||
tilemapRows(props),
|
||||
oneWay,
|
||||
material ? `${material.friction}/${material.bounciness}` : '',
|
||||
'trigger' in pick
|
||||
? `trigger:${pick.trigger ?? ''}`
|
||||
: `${pick.oneWay}|${pick.material ? `${pick.material.friction}/${pick.material.bounciness}` : ''}`,
|
||||
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
|
||||
[...tiles]
|
||||
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
|
||||
@@ -373,7 +388,7 @@ export function solidCells(
|
||||
oneWay: OneWayFilter = false,
|
||||
material?: TileMaterial,
|
||||
): Required<SolidCell>[] {
|
||||
return boxCells(blockingCells(project, props, asset, oneWay, material));
|
||||
return boxCells(blockingCells(project, props, asset, { oneWay, material }));
|
||||
}
|
||||
|
||||
function boxCells(cells: readonly CellBlock[]): Required<SolidCell>[] {
|
||||
@@ -393,7 +408,11 @@ export function solidPolygons(
|
||||
oneWay: OneWayFilter = false,
|
||||
material?: TileMaterial,
|
||||
): readonly CellPolygon[] {
|
||||
return derived(polygonsOf, blockingCells(project, props, asset, oneWay, material), (cells) => {
|
||||
return polygonCells(blockingCells(project, props, asset, { oneWay, material }));
|
||||
}
|
||||
|
||||
function polygonCells(blocks: readonly CellBlock[]): readonly CellPolygon[] {
|
||||
return derived(polygonsOf, blocks, (cells) => {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of cells) {
|
||||
if (cell.box !== null) continue;
|
||||
@@ -527,9 +546,126 @@ export function solidRects(
|
||||
oneWay: OneWayFilter = false,
|
||||
material?: TileMaterial,
|
||||
): readonly CellRect[] {
|
||||
return derived(rectsOf, blockingCells(project, props, asset, oneWay, material), (cells) =>
|
||||
mergeCells(boxCells(cells)),
|
||||
);
|
||||
return rectCells(blockingCells(project, props, asset, { oneWay, material }));
|
||||
}
|
||||
|
||||
function rectCells(blocks: readonly CellBlock[]): readonly CellRect[] {
|
||||
return derived(rectsOf, blocks, (cells) => mergeCells(boxCells(cells)));
|
||||
}
|
||||
|
||||
/** The trigger tiles painted on the map (#160), each once, in the order the layers first name them. */
|
||||
function triggerTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||||
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||||
return [...ids].flatMap((id) => {
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
return tile && isTileTrigger(tile) ? [tile] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the map's trigger tiles lie (#160), as the scene view outlines it: merged into rectangles by
|
||||
* the rules of the solid cells, but on their own – a pond never merges with its shore.
|
||||
*/
|
||||
export function triggerRects(project: Project, props: PropertyBag, asset: TilemapAsset): readonly CellRect[] {
|
||||
return rectCells(blockingCells(project, props, asset, { trigger: null }));
|
||||
}
|
||||
|
||||
/** The trigger cells that report with an outline, like `solidPolygons`. */
|
||||
export function triggerPolygons(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
): readonly CellPolygon[] {
|
||||
return polygonCells(blockingCells(project, props, asset, { trigger: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of a trigger tile as the running game meets it (#160): what of the cell reports, in
|
||||
* cells, and the area it is part of – the stretch of cells of the same tile touching side to side,
|
||||
* named after its top-left one. Walking through a pond is one area, however many cells it crosses.
|
||||
*/
|
||||
export interface TriggerCell {
|
||||
x: number;
|
||||
y: number;
|
||||
tileId: string;
|
||||
area: string;
|
||||
rects: readonly CellRect[];
|
||||
polygons: readonly CellPolygon[];
|
||||
}
|
||||
|
||||
const triggers = new KeyedMemo<ReadonlyMap<string, readonly TriggerCell[]>>();
|
||||
const NO_TRIGGERS: ReadonlyMap<string, readonly TriggerCell[]> = new Map();
|
||||
|
||||
/**
|
||||
* The trigger cells of a map by `x,y`. Each tile is worked out on its own, so a cell holding two
|
||||
* trigger tiles on two layers reports both, and water next to lava is two areas.
|
||||
*/
|
||||
export function triggerCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
): ReadonlyMap<string, readonly TriggerCell[]> {
|
||||
const tiles = triggerTiles(project, asset);
|
||||
if (tiles.length === 0) return NO_TRIGGERS;
|
||||
const blocks = tiles.map((tile) => blockingCells(project, props, asset, { trigger: tile.id }));
|
||||
const key = tiles.map((tile, i) => `${tile.id}:${identityToken(blocks[i]!)}`).join(',');
|
||||
return triggers.get(key, () => {
|
||||
const byCell = new Map<string, TriggerCell[]>();
|
||||
for (const [i, tile] of tiles.entries())
|
||||
for (const cell of cellsOfTile(tile.id, blocks[i]!)) {
|
||||
const at = `${cell.x},${cell.y}`;
|
||||
const held = byCell.get(at);
|
||||
if (held) held.push(cell);
|
||||
else byCell.set(at, [cell]);
|
||||
}
|
||||
return byCell;
|
||||
});
|
||||
}
|
||||
|
||||
function cellsOfTile(tileId: string, blocks: readonly CellBlock[]): TriggerCell[] {
|
||||
const boxes = new Map(boxCells(blocks).map((cell) => [`${cell.x},${cell.y}`, cell]));
|
||||
const isBox = (x: number, y: number) => boxes.has(`${x},${y}`);
|
||||
const areas = areasOf(blocks);
|
||||
return blocks.map((block) => {
|
||||
const at = `${block.x},${block.y}`;
|
||||
const box = boxes.get(at);
|
||||
return {
|
||||
x: block.x,
|
||||
y: block.y,
|
||||
tileId,
|
||||
area: areas.get(at)!,
|
||||
rects: box
|
||||
? cellPieces(box, isBox).map((p) => ({ x: p.x0, y: p.y0, w: p.x1 - p.x0, h: p.y1 - p.y0 }))
|
||||
: [],
|
||||
polygons: box
|
||||
? []
|
||||
: block.polygons.map(({ outline, pieces }) => ({ x: block.x, y: block.y, outline, pieces })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Which area each cell is part of: cells joined side to side, the area named after its top-left cell. */
|
||||
function areasOf(cells: readonly { x: number; y: number }[]): Map<string, string> {
|
||||
const open = new Set(cells.map((cell) => `${cell.x},${cell.y}`));
|
||||
const areas = new Map<string, string>();
|
||||
for (const first of [...cells].sort((a, b) => a.y - b.y || a.x - b.x)) {
|
||||
const name = `${first.x},${first.y}`;
|
||||
if (!open.delete(name)) continue;
|
||||
const queue = [first];
|
||||
for (let cell = queue.pop(); cell; cell = queue.pop()) {
|
||||
areas.set(`${cell.x},${cell.y}`, name);
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
] as const) {
|
||||
const next = { x: cell.x + dx, y: cell.y + dy };
|
||||
if (open.delete(`${next.x},${next.y}`)) queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return areas;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user