Files
feuerwerk/tests/core/collider.test.ts
T
rootandClaude Fable 5.1 6ecb20cf02 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>
2026-09-07 15:38:14 +02:00

277 lines
15 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 { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
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 { 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';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
describe('collider geometry', () => {
it('box vs box: overlapping yes, touching edges no', () => {
expect(shapesOverlap(box(0, 0, 100, 100), box(90, 0, 100, 100))).toBe(true);
expect(shapesOverlap(box(0, 0, 100, 100), box(100, 0, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
});
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 100), circle(60, 0, 20))).toBe(true);
expect(shapesOverlap(box(0, 0, 100, 100), circle(80, 0, 20))).toBe(false);
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
});
it('builds shapes from properties with defaults', () => {
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. */
function setup() {
const project = createEmptyProject('p', 'Colliders');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Crate');
const behavior = new CreateAssetCommand('behavior', 'Hit');
for (const c of [scene, sprite, behavior]) stack.execute(c);
sprite.asset.props.height = 60;
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
stack.execute(a);
stack.execute(b);
const colA = new AddColliderCommand(scene.asset.id, a.objectId);
const colB = new AddColliderCommand(scene.asset.id, b.objectId);
stack.execute(colA);
stack.execute(colB);
const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }, a.objectId);
stack.execute(place);
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, behavior: behavior.asset as BehaviorAsset, a: a.objectId, b: b.objectId, colA, colB };
}
describe('AddColliderCommand', () => {
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
const { project, stack, sceneId, a, colA } = setup();
const asset = project.assets.find((x) => x.id === colA.assetId)!;
expect(asset.type).toBe('collider');
expect(asset.name).toBe('Crate collider');
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
const object = findScene(project, sceneId)!.objects.find((o) => o.id === colA.objectId)!;
expect(object.parentId).toBe(a);
expect(object.position).toEqual({ x: 0, y: 0, z: 0 });
// Undo of the last three commands: behavior placement, collider B, collider A.
stack.undo();
stack.undo();
stack.undo();
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(false);
expect(findScene(project, sceneId)!.objects.some((o) => o.id === colA.objectId)).toBe(false);
stack.redo();
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(true);
});
it('refuses a collider on a collider and survives a round trip', () => {
const { project, stack, sceneId, colA } = setup();
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/);
const loaded = deserializeProject(serializeProject(project));
expect(loaded.assets.find((x) => x.id === colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 });
});
});
describe('CollisionSystem', () => {
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
const { project, sceneId, a, b } = setup();
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
const crateA = world.findByObjectId(a)!;
const crateB = world.findByObjectId(b)!;
world.tick(0.1);
expect(collisions.enters).toHaveLength(0);
crateB.x = 90; // boxes of width 100 overlap once the centres are closer than 100
world.tick(0.1);
expect(collisions.enters).toHaveLength(1);
expect(collisions.stays).toHaveLength(0);
world.tick(0.1);
expect(collisions.enters).toHaveLength(0);
expect(collisions.stays).toHaveLength(1);
expect(collisions.touching(world, crateA, crateB)).toBe(true);
crateB.x = 300;
world.tick(0.1);
expect(collisions.exits).toHaveLength(1);
expect(collisions.stays).toHaveLength(0);
expect(collisions.touching(world, crateA, crateB)).toBe(false);
});
it('respects overrides and circles; two colliders of the same body never collide', () => {
const { project, stack, sceneId, a, b, colA, colB } = setup();
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'shape', 'circle'));
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'radius', 30));
const second = new AddColliderCommand(sceneId, a);
stack.execute(second); // a second collider on crate A, overlapping colA completely
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
world.tick(0.1);
expect(collisions.enters).toHaveLength(0); // colA and the second collider share a body
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
world.tick(0.1);
expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A
expect(collisions.enters.every((p) => p.a.objectId === colA.objectId || p.b.objectId === colA.objectId || p.a.objectId === second.objectId || p.b.objectId === second.objectId)).toBe(true);
});
});
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();
// Graph: On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
const enter = new AddNodeCommand(behavior.id, 'OnCollisionEnter', 0, 0);
const logEnter = new AddNodeCommand(behavior.id, 'Log', 0, 0);
const exit = new AddNodeCommand(behavior.id, 'OnCollisionExit', 0, 0);
const logExit = new AddNodeCommand(behavior.id, 'Log', 0, 0, { message: 'bye' });
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
const byName = new AddNodeCommand(behavior.id, 'ObjectByName', 0, 0, { name: 'B' });
const touching = new AddNodeCommand(behavior.id, 'IsTouching', 0, 0);
const logTouch = new AddNodeCommand(behavior.id, 'Log', 0, 0);
for (const c of [enter, logEnter, exit, logExit, update, owner, byName, touching, logTouch]) 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(enter, 'exec', logEnter, 'exec');
connect(enter, 'other', logEnter, 'message');
connect(exit, 'exec', logExit, 'exec');
connect(update, 'exec', logTouch, 'exec');
connect(owner, 'object', touching, 'a');
connect(byName, 'object', touching, 'b');
connect(touching, 'result', logTouch, 'message');
findScene(project, sceneId)!.objects.find((o) => o.id === b)!.name = 'B';
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
const crateB = world.findByObjectId(b)!;
world.tick(0.1);
expect(world.log).toEqual(['false']);
crateB.x = 50;
world.tick(0.1);
expect(world.log).toEqual(['false', 'B', 'true']);
crateB.x = 500;
world.tick(0.1);
expect(world.log).toEqual(['false', 'B', 'true', 'bye', 'false']);
});
});