Files
feuerwerk/src/runtime/systems/CollisionSystem.ts
T
adminandClaude Opus 5.5 275e50af01 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>
2026-10-02 07:28:39 +02:00

124 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}