224 lines
9.8 KiB
TypeScript
224 lines
9.8 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
|
||
import { SetCollisionLayersCommand } from '../../src/core/commands/projectCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
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 type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||
import { COLLISION_SYSTEMS, 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<string, PropertyValue> => ({
|
||
[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<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('a dynamic body falls through a layer it does not meet (#152)', () => {
|
||
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);
|
||
});
|
||
});
|