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>
124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
import { isHandleEnabled } from '../../core/graph';
|
||
import { shapesOverlap } from '../../core/properties/collider';
|
||
import type { Entity } from '../Entity';
|
||
import type { World } from '../World';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
import {
|
||
bodyOf,
|
||
boundsAround,
|
||
type Contact,
|
||
type CollisionPair,
|
||
type CollisionSource,
|
||
collidersMeet,
|
||
isCollisionBody,
|
||
type MoveResult,
|
||
partsOf,
|
||
type RayFilter,
|
||
type RayResult,
|
||
Solids,
|
||
} from './solids';
|
||
import type { System } from './System';
|
||
import { TileTriggers } from './tileTriggers';
|
||
|
||
/**
|
||
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
|
||
* collide. Runs before the BehaviorSystem so graphs see this frame's events. This is the editor
|
||
* run's collision; a played game has the physics engine instead (`PhysicsSystem`, #146).
|
||
*
|
||
* A placed tilemap is a solid body of its own: its blocking cells, merged into rectangles, stop a
|
||
* move and report a collision with the tilemap as the other side. Two tilemaps never meet – they
|
||
* are ground, not things that walk into each other. Its trigger tiles block nothing and report
|
||
* the cells overlapped (`TileTriggers`, #160).
|
||
*/
|
||
export class CollisionSystem implements System, CollisionSource {
|
||
enters: CollisionPair[] = [];
|
||
stays: CollisionPair[] = [];
|
||
exits: CollisionPair[] = [];
|
||
private previous = new Map<string, CollisionPair>();
|
||
private readonly solids = new Solids();
|
||
private readonly triggers = new TileTriggers();
|
||
|
||
update(world: World): void {
|
||
this.solids.reset();
|
||
// Switched-off bodies (or ones below something switched off) take no part at all.
|
||
const bodies = world.entities.filter((e) => isCollisionBody(e) && isHandleEnabled(e));
|
||
const current = new Map<string, CollisionPair>();
|
||
for (let i = 0; i < bodies.length; i++) {
|
||
for (let j = i + 1; j < bodies.length; j++) {
|
||
const a = bodies[i]!;
|
||
const b = bodies[j]!;
|
||
if (bodyOf(a) === bodyOf(b)) continue;
|
||
if (a.type === 'tilemap' && b.type === 'tilemap') continue;
|
||
if (!collidersMeet(a, b)) continue;
|
||
if (this.overlaps(world, a, b)) current.set(`${a.id}|${b.id}`, { a, b });
|
||
}
|
||
}
|
||
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;
|
||
// A map's trigger tiles (#160) are pairs of their own, told after the others.
|
||
this.triggers.update(world);
|
||
this.enters.push(...this.triggers.enters);
|
||
this.stays.push(...this.triggers.stays);
|
||
this.exits.push(...this.triggers.exits);
|
||
}
|
||
|
||
stop(): void {
|
||
this.previous = new Map();
|
||
this.solids.reset();
|
||
this.triggers.stop();
|
||
this.enters = [];
|
||
this.stays = [];
|
||
this.exits = [];
|
||
}
|
||
|
||
/** Live test of two bodies (world positions applied): any shape of one over any of the other. */
|
||
private overlaps(world: World, a: Entity, b: Entity): boolean {
|
||
// A map takes part with a shape per cell, so only the cells within reach of the other side are
|
||
// worked out at all (#184); between two maps neither narrows the other down.
|
||
if (a.type === 'tilemap' && b.type !== 'tilemap') return this.overlaps(world, b, a);
|
||
const mine = this.solids.shapesOf(world, a);
|
||
const others = this.solids.shapesOf(world, b, a.type === 'tilemap' ? undefined : boundsAround(mine));
|
||
return mine.some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
|
||
}
|
||
|
||
/** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */
|
||
touching(world: World, a: Entity, b: Entity): boolean {
|
||
for (const ca of partsOf(world, a)) {
|
||
for (const cb of partsOf(world, b)) {
|
||
if (ca === cb) continue;
|
||
if (collidersMeet(ca, cb) && this.overlaps(world, ca, cb)) return true;
|
||
if (this.triggers.touching(world, ca, cb)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult {
|
||
return this.solids.moveAndSlide(world, entity, dx, dy);
|
||
}
|
||
|
||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||
return this.solids.raycast(world, from, direction, length, filter);
|
||
}
|
||
|
||
raycastAll(
|
||
world: World,
|
||
from: Vec2,
|
||
direction: Vec2,
|
||
length: number,
|
||
filter: RayFilter,
|
||
maxHits?: number,
|
||
): RayResult[] {
|
||
return this.solids.raycastAll(world, from, direction, length, filter, maxHits);
|
||
}
|
||
|
||
/** Nothing is simulated here, so a contact has no normal or impulse to tell. */
|
||
contactOf(): Contact | null {
|
||
return null;
|
||
}
|
||
}
|