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

+5
View File
@@ -56,6 +56,11 @@ export interface NodeContext {
* stands for itself; any other entity stands for its direct collider children.
*/
isTouching(a: EntityHandle, b: EntityHandle): boolean;
/**
* Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid
* colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit.
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
log(message: string): void;
destroy(target: EntityHandle): void;
stop(): void;
+19
View File
@@ -307,6 +307,25 @@ registerNode({
return { next: 'exec' };
},
});
registerNode({
type: 'MoveAndSlide',
label: 'Move and slide',
category: 'action',
description: 'Like Move, but solid colliders of the object stop at other solid colliders and slide along them. Outputs which axes were blocked.',
inputs: [exec(), obj('object'), num('dx'), num('dy'), { ...num('scale'), default: 1 }, { ...num('dt'), default: 1 }],
outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')],
params: [],
run: (ctx, i) => {
const target = asEntity(i.object);
let blockedX = false;
let blockedY = false;
if (target) {
const factor = asNumber(i.scale) * asNumber(i.dt);
({ blockedX, blockedY } = ctx.moveAndSlide(target, asNumber(i.dx) * factor, asNumber(i.dy) * factor));
}
return { next: 'exec', outputs: { blockedX, blockedY } };
},
});
registerNode({
type: 'SetPosition',
label: 'Set position',
+2
View File
@@ -44,6 +44,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
// Triggers report collisions but never block "Move and slide"; solid colliders do both.
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
],
};
+47
View File
@@ -16,6 +16,11 @@ export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? d.width)), h: Math.max(0, Number(props.height ?? d.height)) };
}
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
export function isTrigger(props: PropertyBag): boolean {
return Boolean(props.isTrigger ?? defaultsFor('collider').isTrigger);
}
/** Footprint of a collider shape (used for culling and the selection gizmo). */
export function colliderSize(props: PropertyBag): { w: number; h: number } {
const shape = colliderShape(props, { x: 0, y: 0 });
@@ -38,3 +43,45 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
const cy = Math.max(box.y - box.h / 2, Math.min(circle.y, box.y + box.h / 2));
return (circle.x - cx) ** 2 + (circle.y - cy) ** 2 < circle.r * circle.r;
}
export type Axis = 'x' | 'y';
/**
* The centre distance along `axis` at which the two shapes exactly touch, given their
* current offset on the other axis. Only meaningful when the shapes overlap on that other
* axis (otherwise they cannot collide by moving along `axis`); returns 0 when they do not.
*/
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: Axis): number {
const other: Axis = axis === 'x' ? 'y' : 'x';
const offset = Math.abs(a[other] - b[other]);
if (a.kind === 'box' && b.kind === 'box') {
return offset * 2 < extent(a, other) + extent(b, other) ? (extent(a, axis) + extent(b, axis)) / 2 : 0;
}
if (a.kind === 'circle' && b.kind === 'circle') {
const r = a.r + b.r;
return offset < r ? Math.sqrt(r * r - offset * offset) : 0;
}
const box = a.kind === 'box' ? a : (b as Extract<ColliderShape, { kind: 'box' }>);
const circle = a.kind === 'circle' ? a : (b as Extract<ColliderShape, { kind: 'circle' }>);
const halfOther = extent(box, other) / 2;
const halfAxis = extent(box, axis) / 2;
// Circle centre within the box's span on the other axis: the flat side is hit.
if (offset <= halfOther) return halfAxis + circle.r;
// Otherwise the corner is the closest point: a circle-vs-point solution.
const corner = offset - halfOther;
return corner < circle.r ? halfAxis + Math.sqrt(circle.r * circle.r - corner * corner) : 0;
}
/**
* How far `mover` has to be pushed back against its travel direction `dir` (±1) along
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
* `mover` approached from the far side, i.e. it did not overlap before its move.
*/
export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: Axis, dir: 1 | -1): number {
if (!shapesOverlap(mover, obstacle)) return 0;
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
}
function extent(box: Extract<ColliderShape, { kind: 'box' }>, axis: Axis): number {
return axis === 'x' ? box.w : box.h;
}
+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. */
+2 -1
View File
@@ -32,8 +32,9 @@ export const Theme = {
gizmo: 0x4c8dff,
/** Behavior symbols and owner links. */
behavior: 0xb066ff,
/** Collider outlines. */
/** Collider outlines: solid (blocking) and trigger (events only). */
collider: 0x4fd18b,
trigger: 0xf2c84b,
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
graphBackground: 0x15171b,
node: 0x262a32,
+3 -2
View File
@@ -5,7 +5,7 @@ import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/mod
import { treeOrder, worldPosition } from '../core/model/hierarchy';
import { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { colliderShape, colliderSize } from '../core/properties/collider';
import { colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
@@ -305,7 +305,8 @@ export class Viewport extends Widget {
const shape = colliderShape(item.props, { x: 0, y: 0 });
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
node.fill({ color: Theme.colors.collider, alpha: 0.12 }).stroke({ width: 2 / view.scale, color: Theme.colors.collider });
const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider;
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / view.scale, color });
}
}
this.drawParentLinks(items, editing, view.scale);