Files
feuerwerk/tests/core/collision-layers.test.ts
adminandClaude Opus 5.5 b2326085ed
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m26s
Show each camera only its layers and lay overlay cameras on top (#302)
A camera could only show the whole scene, so a HUD drawn in the world
would run through a camera shader (#299) with everything else. Render
layers say which cameras see an object, and overlay cameras draw their
own picture over the main one.

- The project has a list of up to 16 render layers, edited in Project
  settings next to the collision layers. It is a list of its own, read
  by the same rules (`layerNamesOf`).
- Sprites, texts, tilemaps, particle sources and containers carry a row
  `Render layer` under their switches. Its default `Inherit` takes the
  parent's layer, at the top the first one.
- A camera has `Sees` (all layers by default) and `Overlay`. The main
  camera is the first one switched on without Overlay, so two plain
  cameras still draw through the first one only.
- Every view through a camera keeps only what that camera sees and lays
  one see-through view per overlay camera over itself. That view is a
  `WorldViewport` of its own, handed its view and frame from below.
- An overlay is fitted into the main camera's whole picture without
  distortion. One of the same size and zoom covers it exactly.
- `z` sorts within one camera only. The pictures lie on top of each
  other as a whole, which is what a shader per camera (#299) needs.
- The Game tab's preview now collects the scene once per redraw. Before
  it collected it twice, with the cameras it would have been five times.
- `SetCollisionLayersCommand` became `SetLayerNamesCommand`, which
  takes the list it sets.

Follow-ups: #304 (the mouse through a chosen camera) and #305 (change
the render layer at run time).

Tests:
- Gate: 226 files, 3487 tests (3480 + 7)
- e2e 63 (new): 4 checks. A green overlay sprite at z -10 covers a
  red main sprite at z 1000, in the preview and in the running game.
  A `UI` sprite in front of the main camera and a `Default` one in
  front of the overlay stay away. Without Overlay the second camera
  draws nothing. The settings dialog adds a render layer at the end.
- Counter-checks: inheriting from parents switched off fails 1 unit
  test. Overlays never drawn fails the two overlay checks in e2e 63.
- e2e total 1720 (1716 + 4), computed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:32:52 +02:00

378 lines
16 KiB
TypeScript
Raw Permalink 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 { SetLayerNamesCommand } 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<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 SetLayerNamesCommand('collisionLayers', ['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 SetLayerNamesCommand('collisionLayers', ['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);
});
});
/**
* 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<string, PropertyValue>) => {
const set = edit.properties(ports, { [OBJECT_TYPE_PARAM]: 'collider', ...filter });
edit.connect(child, 'child', set, 'object');
return set;
};
const press = (key: string, filter: Record<string, PropertyValue>) =>
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 SetLayerNamesCommand('collisionLayers', ['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);
});
});