Files
feuerwerk/src/core/model/collisionLayers.ts
T
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

120 lines
5.1 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.
/**
* 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`;
}