Files
feuerwerk/tests/core/collider.test.ts
T
rootandClaude Fable 5.1 016e83c9b5
game-maker / suite (push) Successful in 9m5s
Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
Four new rows on sprite, text, collider and container (block TRANSFORM):
scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they
get override/reset/apply, loading of old projects, write-back after play and
the Properties node for free; camera and behavior have none.

The hierarchy composes T*R*S per level (composeAlongChain replaces
sumAlongChain), so children rotate and scale with their parent. Reparenting
and grouping keep the look (carriedTransform). Drawing goes through
placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags
are computed in the parent's system.

Colliders are oriented boxes: SAT for box/box, box-circle in the box frame;
exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along
the object's own axes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 17:25:06 +02:00

336 lines
16 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 { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import { transformMatrix, translation } from '../../src/core/model/matrix';
import {
colliderShape,
exitDistance,
shapeContains,
shapesOverlap,
} from '../../src/core/properties/collider';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
kind: 'box' as const,
x,
y,
w,
h,
angle: (degrees * Math.PI) / 180,
});
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
const LEFT = { x: -1, y: 0 };
const UP = { x: 0, y: -1 };
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('a turned box reaches where its corners are, not where its upright frame would be', () => {
// A 100×20 bar turned by 45° reaches ≈ 42.4 along x; upright it would reach 50.
expect(shapesOverlap(box(0, 0, 100, 20, 45), box(95, 0, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 20), box(95, 0, 100, 100))).toBe(true);
// A diamond (square turned by 45°, corners at ±70.7) and a square at (110, 110): their upright
// frames overlap (120.7 > 110 on both axes) but the square's corner (60, 60) lies outside the
// diamond's edge x + y = 70.7.
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(110, 110, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(95, 0, 100, 100))).toBe(true);
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 0, y: 45 })).toBe(true);
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 45, y: 0 })).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);
// The same corner turned to (0, 70.7): the circle at (0, 80) with r=10 just misses it.
expect(shapesOverlap(circle(0, 80, 10), box(0, 0, 100, 100, 45))).toBe(true);
expect(shapesOverlap(circle(0, 81, 10), box(0, 0, 100, 100, 45))).toBe(false);
});
it('builds shapes from properties with defaults, sized and turned by the transform', () => {
expect(colliderShape({}, translation(1, 2))).toEqual(box(1, 2, 100, 100));
expect(colliderShape({ shape: 'circle', radius: 7 }, translation(0, 0))).toEqual(circle(0, 0, 7));
const turned = colliderShape({ width: 40, height: 20 }, transformMatrix(5, 6, 30, 2, false, false));
expect(turned).toMatchObject({ kind: 'box', x: 5, y: 6, w: 80, h: 40 });
expect(turned.kind === 'box' && (turned.angle * 180) / Math.PI).toBeCloseTo(30);
// A mirror keeps a box a box: the same shape, whichever way its x axis points.
const mirrored = colliderShape({ width: 40, height: 20 }, transformMatrix(0, 0, 0, 1, true, false));
expect(shapeContains(mirrored, { x: 19, y: 9 })).toBe(true);
expect(shapeContains(mirrored, { x: 21, y: 9 })).toBe(false);
expect(colliderShape({ shape: 'circle', radius: 7 }, transformMatrix(0, 0, 0, 3, false, false))).toEqual(
circle(0, 0, 21),
);
});
it('exit distance: how far a mover must travel to just clear an obstacle', () => {
// Mover moved right into a box: centres 80 apart, need 100 → 20 back to the left.
expect(exitDistance(box(80, 0, 100, 100), box(0, 0, 100, 100), { x: 1, y: 0 })).toBe(20);
expect(exitDistance(box(0, 0, 100, 100), box(80, 0, 100, 100), LEFT)).toBe(20);
expect(exitDistance(box(0, 0, 100, 100), box(120, 0, 100, 100), LEFT)).toBe(0);
expect(exitDistance(box(0, 0, 100, 100), box(0, 90, 100, 100), UP)).toBe(10);
expect(exitDistance(box(0, 10, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(70);
expect(exitDistance(box(0, 100, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(0); // apart on y
expect(exitDistance(circle(0, 12, 10), circle(0, 0, 10), LEFT)).toBeCloseTo(16);
expect(exitDistance(circle(0, 20, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(60); // flat side
expect(exitDistance(circle(0, 56, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
expect(exitDistance(circle(0, 70, 10), box(0, 0, 100, 100), LEFT)).toBe(0);
// Moving away diagonally clears a corner sooner than along one axis would.
expect(exitDistance(box(80, 80, 100, 100), box(0, 0, 100, 100), { x: 1, y: 1 })).toBeCloseTo(
20 * Math.SQRT2,
);
// A wall turned by 45°, a box moving left into its slanted face: level with the corner it must
// back off to the corner (70.7 + 10 − 60), higher up the face is only half a unit deep along
// its normal – an upright frame would still demand the 20.7.
expect(exitDistance(box(-60, 0, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
50 * Math.SQRT2 + 10 - 60,
);
expect(exitDistance(box(-60, 30, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
(50 + 10 * Math.SQRT2 - 90 * Math.SQRT1_2) * Math.SQRT2,
);
});
});
/** Two crates (100×60) 300 apart, each with a collider child; a behavior "Hit" placed below crate a. */
function setup() {
const f = makeSpriteScene('Crate');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Hit');
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
const colA = new AddColliderCommand(f.sceneId, a);
const colB = new AddColliderCommand(f.sceneId, b);
f.stack.execute(colA);
f.stack.execute(colB);
place(f, behavior.id, { x: 0, y: 0 }, a);
return { ...f, behavior, a, b, colA, colB };
}
/** The crates in a running world with collision detection. */
function runtime() {
const s = setup();
const { world, collisions } = startWorld(s, { collisions: true });
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
}
describe('AddColliderCommand', () => {
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
const { project, stack, sceneOf, object, a, colA } = setup();
const asset = findAsset(project, colA.assetId)!;
expect(asset.type).toBe('collider');
expect(asset.name).toBe('Crate collider');
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
expect(object(colA.objectId).parentId).toBe(a);
expect(object(colA.objectId).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(sceneOf().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/,
);
expect(findAsset(roundTrip(project), 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 { world, collisions, crateA, crateB } = runtime();
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 s = setup();
const { stack, sceneId, a, b, colA, colB } = s;
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, collisions } = startWorld(s, { collisions: true });
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', () => {
const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime();
// Crates are 100 wide, 300 apart: 200 of travel 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 s = setup();
const { stack, sceneId, a, b, colB } = s;
// 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, collisions } = startWorld(s, { collisions: true });
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('an existing overlap is neither resolved (no jolt) nor deepened; leaving is always possible', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 60; // start overlapped by 40
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(0); // no jolt out, but no burrowing deeper either
expect(collisions.moveAndSlide(world, crateA, -20, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-20); // moving away is always free
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-120);
});
it('keeps blocking while pressed against a wall, however long and at any speed', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 0;
crateB.y = 300; // wall straight above, colliders are 100×60 → contact at y = 240
for (let i = 0; i < 500; i++) {
// Uneven steps, so contact is never hit at a round number.
const { blockedY } = collisions.moveAndSlide(world, crateA, 0, 3.7 + (i % 7) * 0.13);
expect(crateA.y).toBeLessThanOrEqual(240 + SKIN);
if (i > 60) expect(blockedY).toBe(true);
}
expect(crateA.y).toBeGreaterThan(240 - SKIN);
// A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back.
crateA.y = 0;
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
expect(crateA.y).toBeCloseTo(240, 6);
});
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
const s = setup();
const { behavior, a } = s;
// On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x)
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 1000, collide: true });
const log = edit.addNode('Log');
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
[move, 'exec', log, 'exec'],
[move, 'blockedX', log, 'message'],
]);
const { world } = startWorld(s, { collisions: true, behaviors: true });
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 s = setup();
const { behavior, b, object } = s;
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
const edit = graphEditor(s, behavior);
const enter = edit.addNode('OnCollisionEnter');
const logEnter = edit.addNode('Log');
const exit = edit.addNode('OnCollisionExit');
const logExit = edit.addNode('Log', { message: 'bye' });
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const byName = edit.addNode('ObjectByName', { name: 'B' });
const touching = edit.addNode('IsTouching');
const logTouch = edit.addNode('Log');
edit.wire([
[enter, 'exec', logEnter, 'exec'],
[enter, 'other', logEnter, 'message'],
[exit, 'exec', logExit, 'exec'],
[update, 'exec', logTouch, 'exec'],
[owner, 'object', touching, 'a'],
[byName, 'object', touching, 'b'],
[touching, 'result', logTouch, 'message'],
]);
object(b).name = 'B';
const { world } = startWorld(s, { collisions: true, behaviors: true });
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']);
});
});