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:
1 parent
0a59513b74
commit
6ecb20cf02
14 files changed
+283
-12
No files matched your search
+103
-1
@@ -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();
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user