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']); }); });