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>
120 lines
5.1 KiB
TypeScript
120 lines
5.1 KiB
TypeScript
/**
|
||
* Collision layers (#152): which colliders may meet at all. Every collider sits on exactly one
|
||
* named layer and carries a mask of the layers it collides with; a pair meets only when *both*
|
||
* sides allow the other. That is symmetric on purpose – it is how Rapier's interaction groups
|
||
* work, and it leaves no pair that collides only one way.
|
||
*
|
||
* The rule has to answer the same in three places (the physics engine, `Solids` and the event pair
|
||
* search), which is why it lives here as `layersMeet` rather than in any of them.
|
||
*/
|
||
import type { PropertyBag } from './Properties';
|
||
|
||
/** As many as fit the 16 membership bits of a Rapier interaction group. */
|
||
export const MAX_COLLISION_LAYERS = 16;
|
||
|
||
/** Layer 0, which every collider starts on and which a project always has. */
|
||
export const DEFAULT_LAYER_NAME = 'Default';
|
||
|
||
/** The mask of every layer – what a new collider collides with, so nothing changes by default. */
|
||
export const ALL_LAYERS = (1 << MAX_COLLISION_LAYERS) - 1;
|
||
|
||
export const COLLISION_LAYER_KEY = 'layer';
|
||
export const COLLIDES_WITH_KEY = 'collidesWith';
|
||
|
||
/** A collider's side of the rule: the layer it is on, and the layers it collides with. */
|
||
export interface CollisionFilter {
|
||
layer: number;
|
||
mask: number;
|
||
}
|
||
|
||
/**
|
||
* The names as stored, read off untrusted JSON: the first one is layer 0 and is always there, a
|
||
* name that is not a text (or is empty) falls back to its number, and anything past the sixteenth
|
||
* is dropped. A project written before there were layers has exactly one, "Default".
|
||
*/
|
||
export function collisionLayersOf(raw: unknown): string[] {
|
||
return layerNamesOf((raw as { collisionLayers?: unknown } | null)?.collisionLayers);
|
||
}
|
||
|
||
/** A list of layer names read off untrusted JSON, by the rules of `collisionLayersOf`. */
|
||
export function layerNamesOf(list: unknown): string[] {
|
||
const names = (Array.isArray(list) ? list : []).slice(0, MAX_COLLISION_LAYERS);
|
||
const read = names.map((name, i) => (typeof name === 'string' && name !== '' ? name : layerFallback(i)));
|
||
return read.length > 0 ? read : [DEFAULT_LAYER_NAME];
|
||
}
|
||
|
||
/** What an unnamed layer is called – its number, so a mask never points at a nameless row. */
|
||
export function layerFallback(index: number): string {
|
||
return index === 0 ? DEFAULT_LAYER_NAME : `Layer ${index}`;
|
||
}
|
||
|
||
/** The name of one layer, for a number outside the project's list its fallback. */
|
||
export function layerName(names: readonly string[], layer: number): string {
|
||
return names[layer] ?? layerFallback(layer);
|
||
}
|
||
|
||
/** A layer number held to the range that exists; anything unreadable is layer 0. */
|
||
export function layerIndexOf(value: unknown): number {
|
||
const n = Math.trunc(Number(value));
|
||
return Number.isFinite(n) && n > 0 && n < MAX_COLLISION_LAYERS ? n : 0;
|
||
}
|
||
|
||
/**
|
||
* A mask held to the 16 bits there are; anything unreadable collides with everything. Only a real
|
||
* number counts, because `Number(null)` is 0 and JSON writes `null` for a value it cannot hold –
|
||
* that would silently mean "collides with nothing", the one answer nobody asked for.
|
||
*/
|
||
export function layerMaskOf(value: unknown): number {
|
||
if (typeof value !== 'number' && !(typeof value === 'string' && value.trim() !== '')) return ALL_LAYERS;
|
||
const n = Math.trunc(Number(value));
|
||
return Number.isFinite(n) && n >= 0 ? n & ALL_LAYERS : ALL_LAYERS;
|
||
}
|
||
|
||
function layerBit(layer: number): number {
|
||
return 1 << layerIndexOf(layer);
|
||
}
|
||
|
||
/** Whether the mask lets that layer through. */
|
||
export function maskHas(mask: number, layer: number): boolean {
|
||
return (layerMaskOf(mask) & layerBit(layer)) !== 0;
|
||
}
|
||
|
||
/** The mask with one layer switched on or off. */
|
||
export function maskWith(mask: number, layer: number, on: boolean): number {
|
||
const bit = layerBit(layer);
|
||
return on ? (layerMaskOf(mask) | bit) & ALL_LAYERS : layerMaskOf(mask) & ~bit & ALL_LAYERS;
|
||
}
|
||
|
||
/**
|
||
* The filter of a collider or tilemap. A bag without the two keys – every one written before this
|
||
* existed – reads as layer 0 colliding with everything, so no old project changes behaviour.
|
||
*/
|
||
export function collisionFilterOf(props: PropertyBag): CollisionFilter {
|
||
return {
|
||
layer: layerIndexOf(props[COLLISION_LAYER_KEY]),
|
||
mask: layerMaskOf(props[COLLIDES_WITH_KEY]),
|
||
};
|
||
}
|
||
|
||
/** Whether two colliders may meet: each one's mask has to name the other's layer. */
|
||
export function layersMeet(a: CollisionFilter, b: CollisionFilter): boolean {
|
||
return maskHas(a.mask, b.layer) && maskHas(b.mask, a.layer);
|
||
}
|
||
|
||
/**
|
||
* The filter as one Rapier interaction group: membership in the high 16 bits, filter in the low
|
||
* ones. Rapier applies it the same way to both sides, which is exactly `layersMeet`.
|
||
*/
|
||
export function interactionGroups(filter: CollisionFilter): number {
|
||
return ((layerBit(filter.layer) << 16) | layerMaskOf(filter.mask)) >>> 0;
|
||
}
|
||
|
||
/** How a mask reads on the button that opens its picker. */
|
||
export function maskLabel(mask: number, names: readonly string[]): string {
|
||
const on = names.map((_, i) => i).filter((i) => maskHas(mask, i));
|
||
if (on.length === names.length) return 'All layers';
|
||
if (on.length === 0) return 'Nothing';
|
||
if (on.length <= 2) return on.map((i) => layerName(names, i)).join(', ');
|
||
return `${on.length} layers`;
|
||
}
|