Files
feuerwerk/tests/core/collision-layers.test.ts
T
adminandClaude Opus 5 4f55f85ad4
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 8m21s
Collision layers and masks: a collider carries a layer and a mask of the layers it meets, and the pair only meets when both masks name the other's layer - the rule is one pure module (src/core/model/collisionLayers.ts) with layerBit, layersMeet and the names a project starts with, so the three places that have to agree read the same function: the editor run through CollisionSystem, the Solids sweep per pair and Rapier, where the mask becomes the collider's interaction groups; the project file gained collisionLayers: ["Default"] (default one layer, no formatVersion bump), the collider schema two new kinds - layer as a dropdown of the project's layers and layerMask as a menu of tickboxes that stays open while it is ticked (Menu.ts learned rows that do not close on a pick) - and the names are edited through "Collision layers..." in the toolbar's Project menu, not a Project tab, which is the decision I made myself because the question ran into the MCP timeout: the dialog (src/editor/CollisionLayersDialog.ts) holds up to 16 names, Add layer appends "Layer <n>" and Apply writes all of them as one undo step (SetCollisionLayersCommand); a mask read from a file goes through a check that refuses anything that is not a finite number, because Number(null) is 0 and a collidesWith: null would silently have meant "meets nothing"; 9 new e2e checks in module 8 cover the menu entry, the dialog, Add layer, Apply and its undo, both inspector rows, the tickbox menu, and the mover walking straight through the crate that 8c blocked on once their layers no longer meet, and 21 new unit tests cover the rule, the schema and the command (#152)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:31:22 +02:00

224 lines
9.8 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 { 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);
});
});