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>
378 lines
16 KiB
TypeScript
378 lines
16 KiB
TypeScript
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);
|
||
});
|
||
});
|