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

+103 -1
View File
@@ -6,7 +6,7 @@ import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCom
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { colliderShape, shapesOverlap } from '../../src/core/properties/collider';
import { axisCorrection, axisSeparation, colliderShape, shapesOverlap } from '../../src/core/properties/collider';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem';
@@ -37,6 +37,23 @@ describe('collider geometry', () => {
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
});
it('axis separation: the centre distance at which shapes touch along one axis', () => {
expect(axisSeparation(box(0, 0, 100, 60), box(0, 10, 40, 60), 'x')).toBe(70);
expect(axisSeparation(box(0, 0, 100, 60), box(0, 100, 40, 60), 'x')).toBe(0); // apart on y
expect(axisSeparation(circle(0, 0, 10), circle(0, 12, 10), 'x')).toBeCloseTo(16);
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 20, 10), 'x')).toBe(60); // flat side
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 56, 10), 'x')).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 70, 10), 'x')).toBe(0);
});
it('axis correction: how far a mover must back off to just touch', () => {
// Mover moved right into a box: centres 80 apart, need 100 → push back 20.
expect(axisCorrection(box(0, 0, 100, 100), box(80, 0, 100, 100), 'x', 1)).toBe(20);
expect(axisCorrection(box(80, 0, 100, 100), box(0, 0, 100, 100), 'x', -1)).toBe(20);
expect(axisCorrection(box(0, 0, 100, 100), box(120, 0, 100, 100), 'x', 1)).toBe(0);
expect(axisCorrection(box(0, 0, 100, 100), box(0, 90, 100, 100), 'y', 1)).toBe(10);
});
});
/** A scene with two crates (100×60) 300 apart, each with a collider child; a behavior on crate A. */
@@ -132,6 +149,91 @@ describe('CollisionSystem', () => {
});
});
describe('move and slide', () => {
function runtime() {
const s = setup();
const world = World.instantiate(s.project, s.sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
}
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime();
// Crates are 100 wide, 300 apart: 250 of travel is possible before they touch (centres 100 apart).
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200); // touching edges do not overlap, so the wall keeps holding
expect(collisions.moveAndSlide(world, crateA, -30, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(170);
});
it('slides along the wall: a diagonal move keeps its y component', () => {
const { world, collisions, crateA } = runtime();
expect(collisions.moveAndSlide(world, crateA, 250, 40)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(crateA.y).toBe(40);
});
it('triggers report but never block; own and descendant colliders are ignored', () => {
const { project, stack, sceneId, a, b, colB } = setup();
// Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap.
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true));
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
const crateA = world.findByObjectId(a)!;
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(250);
world.tick(0.1);
expect(collisions.enters).toHaveLength(1);
// A collider parented under the mover travels with it and cannot block it.
const rider = world.entities.find((e) => e.type === 'collider' && e.parent === world.findByObjectId(b))!;
rider.props.isTrigger = false;
rider.parent = crateA;
rider.x = 150;
expect(collisions.moveAndSlide(world, crateA, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(260);
});
it('pairs that already overlap are not resolved (no jolt), leaving is always possible', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 60; // start overlapped
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(5);
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-95);
});
it('the Move and slide node moves the owner and outputs the blocked axes', () => {
const { project, stack, sceneId, behavior, a } = setup();
// On update → Move and slide(Owner, dx = 1000, dt) → Log(blocked x)
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
const move = new AddNodeCommand(behavior.id, 'MoveAndSlide', 0, 0, { dx: 1000 });
const log = new AddNodeCommand(behavior.id, 'Log', 0, 0);
for (const c of [update, owner, move, log]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(update, 'exec', move, 'exec');
connect(update, 'dt', move, 'dt');
connect(owner, 'object', move, 'object');
connect(move, 'exec', log, 'exec');
connect(move, 'blockedX', log, 'message');
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
const crateA = world.findByObjectId(a)!;
world.tick(0.1); // 100 units: free
expect(crateA.x).toBe(100);
world.tick(0.2); // 200 more would reach 300; the wall stops it at 200
expect(crateA.x).toBe(200);
expect(world.log).toEqual(['false', 'true']);
});
});
describe('collision events in behaviors', () => {
it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => {
const { project, stack, sceneId, behavior, b } = setup();
+5
View File
@@ -26,6 +26,11 @@ function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: st
findByName: () => null,
isKeyDown: () => false,
isTouching: () => false,
moveAndSlide: (target: EntityHandle, dx: number, dy: number) => {
target.x += dx;
target.y += dy;
return { blockedX: false, blockedY: false };
},
log: (m: string) => logs.push(m),
destroy: () => {},
stop: () => { ctx.stopped = true; },
+1 -1
View File
@@ -156,7 +156,7 @@ describe('runtime hierarchy', () => {
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
] };
const logs: string[] = [];
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, isTouching: () => false, log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(logs).toEqual(['105', '5', '100']);
});