import { beforeAll, describe, expect, it } from 'vitest'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { SetCollisionLayersCommand } from '../../src/core/commands/projectCommands'; import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { DeclaredPort, GraphNode } from '../../src/core/graph'; import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts'; import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY, collisionFilterOf, collisionLayersOf, interactionGroups, layerMaskOf, layerName, layersMeet, maskHas, maskLabel, maskWith, MAX_COLLISION_LAYERS, } from '../../src/core/model/collisionLayers'; import { findAsset } from '../../src/core/model/Project'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld, } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); /** A collider on layer `layer` that collides only with the layers in `meets`. */ const on = (layer: number, meets: number[]): Record => ({ [COLLISION_LAYER_KEY]: layer, [COLLIDES_WITH_KEY]: meets.reduce((mask, l) => maskWith(mask, l, true), 0), }); describe('collision layers: the rule itself (#152)', () => { it('a bag without the two keys is layer 0 against every layer – no old project changes', () => { const old = collisionFilterOf({ width: 100 }); expect(old).toEqual({ layer: 0, mask: ALL_LAYERS }); expect(layersMeet(old, old)).toBe(true); expect(layersMeet(old, collisionFilterOf(on(3, [0, 3])))).toBe(true); }); it('is symmetric: one side alone allowing the other is not enough', () => { const player = collisionFilterOf(on(1, [0, 1, 2])); const shot = collisionFilterOf(on(2, [0])); expect(maskHas(player.mask, shot.layer)).toBe(true); expect(maskHas(shot.mask, player.layer)).toBe(false); expect(layersMeet(player, shot)).toBe(false); expect(layersMeet(shot, player)).toBe(false); }); it('a layer collides with itself only while its own mask names it', () => { const enemy = collisionFilterOf(on(2, [0, 2])); expect(layersMeet(enemy, enemy)).toBe(true); const loner = collisionFilterOf(on(2, [0])); expect(layersMeet(loner, loner)).toBe(false); }); it('an unreadable layer is 0 and an unreadable mask is every layer', () => { expect(collisionFilterOf({ [COLLISION_LAYER_KEY]: 'nope' })).toEqual({ layer: 0, mask: ALL_LAYERS }); // `null` is what JSON writes for a value it cannot hold, and `Number(null)` is 0 – which as a // mask would mean "collides with nothing". It has to read as "not set" instead. expect(layerMaskOf(null)).toBe(ALL_LAYERS); expect(layerMaskOf(0)).toBe(0); // Out of range in either direction folds back onto layer 0 rather than onto a bit that is not there. expect(collisionFilterOf({ [COLLISION_LAYER_KEY]: MAX_COLLISION_LAYERS }).layer).toBe(0); expect(collisionFilterOf({ [COLLISION_LAYER_KEY]: -3 }).layer).toBe(0); }); it('the Rapier group is membership in the high bits and the mask in the low ones', () => { expect(interactionGroups({ layer: 0, mask: ALL_LAYERS })).toBe(0x0001ffff); expect(interactionGroups({ layer: 3, mask: 0b101 })).toBe(0x00080005); // Rapier meets a pair exactly when both groups pass, which is what `layersMeet` says. const a = { layer: 1, mask: maskWith(0, 2, true) }; const b = { layer: 2, mask: maskWith(0, 1, true) }; const passes = (x: number, y: number) => ((x >>> 16) & (y & 0xffff)) !== 0; const ga = interactionGroups(a); const gb = interactionGroups(b); expect(passes(ga, gb) && passes(gb, ga)).toBe(layersMeet(a, b)); }); it('reads the project list off crude input and always has layer 0', () => { expect(collisionLayersOf(null)).toEqual(['Default']); expect(collisionLayersOf({ collisionLayers: [] })).toEqual(['Default']); expect(collisionLayersOf({ collisionLayers: 'Player' })).toEqual(['Default']); expect(collisionLayersOf({ collisionLayers: ['Ground', 7, ''] })).toEqual([ 'Ground', 'Layer 1', 'Layer 2', ]); expect(collisionLayersOf({ collisionLayers: Array(30).fill('x') })).toHaveLength(MAX_COLLISION_LAYERS); }); it('names a layer the project does not have by its number', () => { expect(layerName(['Ground', 'Player'], 1)).toBe('Player'); expect(layerName(['Ground'], 4)).toBe('Layer 4'); }); it('says on the button what the mask holds', () => { const names = ['Default', 'Player', 'Enemy', 'Shots']; expect(maskLabel(ALL_LAYERS, names)).toBe('All layers'); expect(maskLabel(0, names)).toBe('Nothing'); expect(maskLabel(maskWith(0, 1, true), names)).toBe('Player'); expect(maskLabel(maskWith(maskWith(0, 0, true), 2, true), names)).toBe('Default, Enemy'); expect(maskLabel(0b0111, names)).toBe('3 layers'); }); }); describe('the project owns the list of layers (#152)', () => { it('a new project has one layer, and setting them is one undo step', () => { const f = makeSpriteScene('Thing'); expect(f.project.collisionLayers).toEqual(['Default']); f.stack.execute(new SetCollisionLayersCommand(['Ground', 'Player'])); expect(f.project.collisionLayers).toEqual(['Ground', 'Player']); f.stack.undo(); expect(f.project.collisionLayers).toEqual(['Default']); f.stack.redo(); expect(f.project.collisionLayers).toEqual(['Ground', 'Player']); }); it('a nameless layer is stored under its number rather than blank', () => { const f = makeSpriteScene('Thing'); f.stack.execute(new SetCollisionLayersCommand(['Ground', ''])); expect(f.project.collisionLayers).toEqual(['Ground', 'Layer 1']); }); }); /** A scene of objects with one collider each; `add` sets the collider's (and rigidbody's) properties. */ function scene() { const f = makeSpriteScene('Thing'); const add = (at: Vec2, collider: Record, body?: Record) => { const id = place(f, f.spriteId, at); const command = new AddColliderCommand(f.sceneId, id); f.stack.execute(command); for (const [key, value] of Object.entries(collider)) f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value)); if (body) { const rigidbody = new AddRigidbodyCommand(f.sceneId, id); f.stack.execute(rigidbody); for (const [key, value] of Object.entries(body)) f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value)); } return id; }; return { ...f, add }; } function run(f: SceneFixture, physics: boolean) { const started = startWorld(f, { physics, collisions: true }); const entity = (id: string) => started.world.findByObjectId(id)!; const move = (id: string, dx: number, dy: number) => started.collisions.moveAndSlide(started.world, entity(id), dx, dy); return { ...started, entity, move }; } /** A wall 8 wide and 200 tall at x = 0; the mover starts left of it and walks right. */ const WALL = { width: 8, height: 200 }; describe.each(COLLISION_SYSTEMS)('$system: layers filter both systems alike (#152)', ({ physics }) => { it('a mover whose mask leaves out the wall walks straight through it', () => { const s = scene(); s.add({ x: 0, y: 0 }, { ...WALL, ...on(1, [1]) }); const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20, ...on(2, [2]) }); const r = run(s, physics); for (let i = 0; i < 20; i++) expect(r.move(walker, 10, 0).hit).toBe(false); expect(r.entity(walker).x).toBeCloseTo(100, 9); }); it('the same pair on layers that meet stops flush at the wall', () => { const s = scene(); s.add({ x: 0, y: 0 }, { ...WALL, ...on(1, [1, 2]) }); const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20, ...on(2, [1, 2]) }); const r = run(s, physics); for (let i = 0; i < 20; i++) r.move(walker, 10, 0); expect(r.entity(walker).x).toBeCloseTo(-14, 9); }); it('one side alone allowing the other is not enough to be stopped', () => { const s = scene(); // The wall would meet the walker, the walker does not meet the wall: symmetric, so it passes. s.add({ x: 0, y: 0 }, { ...WALL, ...on(1, [1, 2]) }); const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20, ...on(2, [2]) }); const r = run(s, physics); for (let i = 0; i < 20; i++) r.move(walker, 10, 0); expect(r.entity(walker).x).toBeCloseTo(100, 9); }); it('two overlapping colliders on layers that do not meet report no collision', () => { const s = scene(); s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(1, [1]) }); s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(2, [2]) }); const r = run(s, physics); r.frame(); r.frame(); expect(r.collisions.enters).toHaveLength(0); expect(r.collisions.stays).toHaveLength(0); }); it('the same two, once their masks name each other, do report it', () => { const s = scene(); s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(1, [2]) }); s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(2, [1]) }); const r = run(s, physics); r.frame(); expect(r.collisions.enters).toHaveLength(1); }); }); /** * A wall and a walker left of it, both on layer 0 against every layer. The walker's behavior moves * its collider to layer 3, colliding only with layer 3, on `g`, and back on `b` – with a Properties * node of type Collider, the way a graph changes a layer (#211). */ function ghostScene() { const s = scene(); s.add({ x: 0, y: 0 }, WALL); const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20 }); const collider = s.viewOf().objects.find((o) => o.parentId === walker)!; const behavior = makeBehavior(s, 'Ghost'); const edit = graphEditor(s, behavior); const child = edit.addNode('ChildByName', { name: findAsset(s.project, collider.assetId)!.name }); edit.connect(edit.addNode('Owner'), 'object', child, 'object'); const ports = [COLLISION_LAYER_KEY, COLLIDES_WITH_KEY].map((key): DeclaredPort => ({ key, kind: 'number', write: true, })); const setter = (filter: Record) => { const set = edit.properties(ports, { [OBJECT_TYPE_PARAM]: 'collider', ...filter }); edit.connect(child, 'child', set, 'object'); return set; }; const press = (key: string, filter: Record) => edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', setter(filter), 'exec'); press('g', on(3, [3])); press('b', on(0, [0])); // `Layer of` (#211): asked of the walker on `l`, on `p` before and after moving it in one pulse, // and on `c` of the collider itself and of the behavior, which has no collider. const layerOf = (source: GraphNode, port: string) => { const node = edit.addNode('LayerOf'); edit.connect(source, port, node, 'object'); return node; }; const log = (from: GraphNode, source: GraphNode, port: string) => { const node = edit.addNode('Log'); edit.connect(source, port, node, 'message'); edit.connect(from, 'exec', node, 'exec'); return node; }; const ofWalker = layerOf(edit.addNode('Owner'), 'object'); const ofCollider = layerOf(child, 'child'); const ofBehavior = layerOf(edit.addNode('Self'), 'object'); log(log(edit.addNode('OnKeyDown', { key: 'l' }), ofWalker, 'name'), ofWalker, 'number'); const moved = setter(on(3, [3])); edit.connect(log(edit.addNode('OnKeyDown', { key: 'p' }), ofWalker, 'name'), 'exec', moved, 'exec'); log(moved, ofWalker, 'name'); const fromC = log(edit.addNode('OnKeyDown', { key: 'c' }), ofCollider, 'number'); log(log(fromC, ofBehavior, 'name'), ofBehavior, 'number'); s.stack.execute(new PlaceAssetCommand(s.sceneId, behavior.id, { x: 0, y: 0 }, walker)); return { ...s, walker, colliderId: collider.id }; } describe.each(COLLISION_SYSTEMS)( '$system: a graph changes the layer during the run (#211)', ({ physics }) => { const start = () => { const s = ghostScene(); const started = startWorld(s, { physics, collisions: true, behaviors: true }); const walker = started.world.findByObjectId(s.walker)!; const walk = () => { walker.x = -100; for (let i = 0; i < 20; i++) started.collisions.moveAndSlide(started.world, walker, 10, 0); return walker.x; }; const press = (key: string) => { started.input.press(key); started.frame(); started.input.release(key); }; return { ...s, ...started, walk, press }; }; it('a blocked mover walks through once its collider left the layer, and is blocked again once back', () => { const r = start(); r.frame(); expect(r.walk()).toBeCloseTo(-14, 9); r.press('g'); expect(collisionFilterOf(r.world.findByObjectId(r.colliderId)!.props)).toEqual({ layer: 3, mask: 8 }); expect(r.walk()).toBeCloseTo(100, 9); r.press('b'); expect(r.walk()).toBeCloseTo(-14, 9); }); it('a ray filtered by layers finds the collider on its new layer only', () => { const r = start(); r.frame(); const seen = (layer: number) => r.collisions.raycast(r.world, { x: -100, y: -50 }, { x: 0, y: 1 }, 200, { ignore: null, triggers: false, layers: maskWith(0, layer, true), }) !== null; expect([seen(0), seen(3)]).toEqual([true, false]); r.press('g'); expect([seen(0), seen(3)]).toEqual([false, true]); }); it('Layer of names the layer of the object’s collider, already in the pulse that changed it', () => { const r = start(); r.stack.execute(new SetCollisionLayersCommand(['Ground', 'Layer 1', 'Layer 2', 'Ghost'])); r.frame(); r.press('l'); expect(r.world.log).toEqual(['Ground', '0']); r.press('p'); expect(r.world.log.slice(2)).toEqual(['Ground', 'Ghost']); r.press('l'); expect(r.world.log.slice(4)).toEqual(['Ghost', '3']); }); it('Layer of answers for a collider itself, and with no name for an object that has none', () => { const r = start(); r.frame(); r.press('g'); r.press('c'); expect(r.world.log).toEqual(['3', '', '-1']); }); it('Layer of still answers while the collider is switched off', () => { const r = start(); r.frame(); r.press('g'); r.world.findByObjectId(r.colliderId)!.props.enabled = false; r.press('l'); expect(r.world.log).toEqual(['Layer 3', '3']); }); }, ); describe('a dynamic body falls through a layer it does not meet (#152)', () => { it('drops through the ground from the step after a graph took it off the layer (#211)', () => { const s = scene(); s.add({ x: 0, y: 104 }, { width: 400, height: 8 }); const box = s.add({ x: 0, y: 60 }, { width: 20, height: 20 }, { bodyType: 'dynamic' }); const r = run(s, true); for (let i = 0; i < 90; i++) r.frame(1 / 60); expect(r.entity(box).y).toBeCloseTo(90, 0); const collider = r.world.entities.find((e) => e.type === 'collider' && e.parent === r.entity(box))!; Object.assign(collider.props, on(3, [3])); for (let i = 0; i < 60; i++) r.frame(1 / 60); expect(r.entity(box).y).toBeGreaterThan(300); }); it('lands on ground that meets it and keeps falling through ground that does not', () => { const fall = (groundLayers: number[]) => { const s = scene(); s.add({ x: 0, y: 104 }, { width: 400, height: 8, ...on(1, groundLayers) }); const box = s.add({ x: 0, y: 0 }, { width: 20, height: 20, ...on(2, [1, 2]) }, { bodyType: 'dynamic' }); const r = run(s, true); for (let i = 0; i < 90; i++) r.frame(1 / 60); return r.entity(box).y; }; // Ground that names the box's layer holds it just above its top face at y = 100. expect(fall([1, 2])).toBeCloseTo(90, 0); // Ground that does not is not there for this box at all: after 1.5 s it is far below. expect(fall([1])).toBeGreaterThan(400); }); });