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 node with "Collide with solids" moves the owner and outputs the blocked axes', () => { const { project, stack, sceneId, behavior, a } = setup(); // On update → Move(Owner, dx = 1000, dt, collide) → 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, 'Move', 0, 0, { dx: 1000, collide: true }); 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']); }); });