/** * 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`; }