Trigger colliders and Move and slide against solid colliders

- Collider property isTrigger (no blocking, still reports enter/stay/exit)
- Node 'Move and slide' with blocked x/y outputs, axis-wise resolution
- axisSeparation/axisCorrection geometry for box/box, circle/circle, box/circle
- Amber outline for trigger colliders in the viewport
- Demo mover uses Move and slide; E2E step 15b, 7 new unit tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 15:38:14 +02:00
1 parent 0a59513b74
commit 6ecb20cf02
14 files changed
+283 -12

No files matched your search

+8
View File
@@ -138,6 +138,14 @@ export class BehaviorSystem implements System {
const eb = world.entities.find((e) => e === b || e.id === b.id);
return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb));
},
moveAndSlide: (target, dx, dy) => {
const found = world.entities.find((e) => e === target || e.id === target.id);
if (!found) return { blockedX: false, blockedY: false };
if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy);
found.x += dx;
found.y += dy;
return { blockedX: false, blockedY: false };
},
log: (message) => {
world.log.push(message);
console.log(`[behavior ${entity.name}]`, message);
+38 -1
View File
@@ -1,4 +1,4 @@
import { colliderShape, shapesOverlap } from '../../core/properties/collider';
import { type Axis, axisCorrection, colliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
@@ -61,6 +61,43 @@ export class CollisionSystem implements System {
}
return false;
}
/**
* Moves `entity` by (dx, dy) in its local units, one axis at a time (x, then y): after
* each axis step its solid colliders are pushed back to just touch whatever solid collider
* they ran into, so the remaining axis still moves – the object slides along walls.
* Pairs that already overlapped before the step are ignored (an object that starts inside
* a wall may leave it, and stacked objects never get jolted apart). Triggers, the
* entity's own colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && !this.travelsWith(c, entity));
return { blockedX: this.slideAxis(world, entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(world, entity, own, obstacles, 'y', dy) };
}
private slideAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean {
if (delta === 0) return false;
const dir: 1 | -1 = delta > 0 ? 1 : -1;
// Which pairs are free to collide: only those that were apart before this step.
const candidates: [Entity, Entity][] = [];
for (const a of own) for (const b of obstacles) if (!this.overlapping(world, a, b)) candidates.push([a, b]);
entity[axis] += delta;
let correction = 0;
for (const [a, b] of candidates) {
const t = axisCorrection(colliderShape(a.props, world.worldPosition(a)), colliderShape(b.props, world.worldPosition(b)), axis, dir);
if (t > correction) correction = t;
}
if (correction === 0) return false;
entity[axis] -= dir * Math.min(correction, Math.abs(delta));
return true;
}
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
private travelsWith(collider: Entity, entity: Entity): boolean {
for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true;
return false;
}
}
/** The object a collider belongs to: its parent, or the collider itself when top-level. */