Collision layers and masks: a collider carries a layer and a mask of the layers it meets, and the pair only meets when both masks name the other's layer - the rule is one pure module (src/core/model/collisionLayers.ts) with layerBit, layersMeet and the names a project starts with, so the three places that have to agree read the same function: the editor run through CollisionSystem, the Solids sweep per pair and Rapier, where the mask becomes the collider's interaction groups; the project file gained collisionLayers: ["Default"] (default one layer, no formatVersion bump), the collider schema two new kinds - layer as a dropdown of the project's layers and layerMask as a menu of tickboxes that stays open while it is ticked (Menu.ts learned rows that do not close on a pick) - and the names are edited through "Collision layers..." in the toolbar's Project menu, not a Project tab, which is the decision I made myself because the question ran into the MCP timeout: the dialog (src/editor/CollisionLayersDialog.ts) holds up to 16 names, Add layer appends "Layer <n>" and Apply writes all of them as one undo step (SetCollisionLayersCommand); a mask read from a file goes through a check that refuses anything that is not a finite number, because Number(null) is 0 and a collidesWith: null would silently have meant "meets nothing"; 9 new e2e checks in module 8 cover the menu entry, the dialog, Add layer, Apply and its undo, both inspector rows, the tickbox menu, and the mover walking straight through the crate that 8c blocked on once their layers no longer meet, and 21 new unit tests cover the rule, the schema and the command (#152)
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 8m21s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 15:31:22 +02:00
1 parent 5ba796a2b6
commit 4f55f85ad4
19 files changed
+721 -18

No files matched your search

+22
View File
@@ -1,3 +1,4 @@
import { collisionLayersOf } from '../model/collisionLayers';
import type { Project } from '../model/Project';
import type { Command } from './Command';
@@ -16,3 +17,24 @@ export class RenameProjectCommand implements Command {
project.name = this.previous;
}
}
/**
* The named collision layers of the project (#152). Colliders name a layer by number, so removing
* one shifts what every collider below it points at – which is why the dialog only ever hands over
* a whole list, and why the list is read through `collisionLayersOf` here as well.
*/
export class SetCollisionLayersCommand implements Command {
readonly label = 'Collision layers';
private previous: string[] = [];
constructor(private readonly names: readonly string[]) {}
execute(project: Project): void {
this.previous = project.collisionLayers;
project.collisionLayers = collisionLayersOf({ collisionLayers: this.names });
}
undo(project: Project): void {
project.collisionLayers = this.previous;
}
}
+8
View File
@@ -6,6 +6,7 @@ import {
isSceneAsset,
type SceneAsset,
} from './Asset';
import { DEFAULT_LAYER_NAME } from './collisionLayers';
/** 2: a placed container carries no copy of its template. */
export const PROJECT_FORMAT_VERSION = 2;
@@ -29,6 +30,12 @@ export interface Project {
* written before there was such a field names none, which is also what a new one does.
*/
plugins: string[];
/**
* The named collision layers (#152), the first one being layer 0. A collider names one of them
* and the ones it collides with; a project written before there were any has just "Default",
* which is also what every collider starts on, so nothing collides differently than it did.
*/
collisionLayers: string[];
}
export function createEmptyProject(id: string, name: string): Project {
@@ -40,6 +47,7 @@ export function createEmptyProject(id: string, name: string): Project {
activeSceneId: '',
startSceneId: '',
plugins: [],
collisionLayers: [DEFAULT_LAYER_NAME],
};
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 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;
}
/** What a collider carries before anything is set: layer 0, colliding with everything. */
export const DEFAULT_FILTER: CollisionFilter = Object.freeze({ layer: 0, mask: ALL_LAYERS });
/**
* 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[] {
const list = (raw as { collisionLayers?: unknown } | null)?.collisionLayers;
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;
}
export 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`;
}
+13
View File
@@ -20,6 +20,7 @@ import {
type VariableKind,
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
import {
TILE_COLLISION_SCALE_KEY,
TILE_COLLISION_SCALE_MAX,
@@ -98,6 +99,16 @@ const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
shownWith: 'oneWay',
};
/**
* Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be
* ground that only some things walk on. The defaults are layer 0 against every layer, which is
* what a project without any named layer does – nothing collides differently than before.
*/
const COLLISION_LAYER_ROWS: readonly FlatDef[] = [
{ key: COLLISION_LAYER_KEY, label: 'Layer', kind: 'layer', default: 0 },
{ key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS },
];
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
| FlatDef
@@ -326,6 +337,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
step: 1,
},
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
...COLLISION_LAYER_ROWS,
],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
@@ -419,6 +431,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
},
// How far a contact may lean from that side and still land (#159).
ONE_WAY_ANGLE_ROW,
...COLLISION_LAYER_ROWS,
],
rigidbody: [
{
+4
View File
@@ -157,6 +157,10 @@ export const KINDS = [
{ kind: 'key', label: 'Key', travels: 'string', empty: '', coerce: text },
/** An asset id, `''` = none. Ids are not validated on load, so a reference can dangle. */
{ kind: 'asset', label: 'Asset', travels: 'string', empty: '', coerce: text, reference: true },
/** One collision layer by number (#152); the names come from the project, not from the row. */
{ kind: 'layer', label: 'Layer', travels: 'number', empty: 0, coerce: numberOf },
/** A set of collision layers as the 16 bits of a mask (#152). */
{ kind: 'layers', label: 'Layers', travels: 'number', empty: 0, coerce: numberOf },
] as const satisfies readonly KindRow[];
type Row = (typeof KINDS)[number];
+2
View File
@@ -29,6 +29,7 @@ import type {
} from '../model/Asset';
import { parseCellKey } from '../model/tilemap';
import { pluginsOf } from './projectPlugins';
import { collisionLayersOf } from '../model/collisionLayers';
import { decodeCells, encodeCells } from './tilemapCells';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
@@ -222,6 +223,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
// The same reading the editor did before it loaded the plugins (#119), so the model says what
// is actually running.
plugins: pluginsOf(r),
collisionLayers: collisionLayersOf(r),
};
return project;
}