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

+7
View File
@@ -11,6 +11,7 @@ import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model
import type { Vec2 } from '../../core/model/Properties';
import { signedArea } from '../../core/model/polygon';
import { parentChain } from '../../core/model/walk';
import { collisionFilterOf, interactionGroups } from '../../core/model/collisionLayers';
import {
type ColliderShape,
contactBetween,
@@ -500,6 +501,8 @@ export class PhysicsSystem implements System, CollisionSource {
pieces.map(pieceKey),
facing && [round(facing.x), round(facing.y), facing.cone],
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
// The layers sit on the engine collider, so changing one has to rebuild it (#152).
interactionGroups(collisionFilterOf(entity.props)),
]),
) + JSON.stringify(want.settings?.mass ?? 0);
if (shapeKey !== body.shapeKey) {
@@ -593,6 +596,10 @@ export class PhysicsSystem implements System, CollisionSource {
else desc.setDensity(material.density);
desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS);
desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
// The layers (#152). Rapier applies one group to detecting and to blocking alike, which is the
// single setting the issue asked for: what does not collide reports nothing either.
const groups = interactionGroups(collisionFilterOf(entity.props));
desc.setCollisionGroups(groups).setSolverGroups(groups);
if (oneWay && isSolid(entity)) desc.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS);
return desc;
}
+3 -1
View File
@@ -8,6 +8,7 @@ import {
type Contact,
type CollisionPair,
type CollisionSource,
collidersMeet,
isCollisionBody,
type MoveResult,
partsOf,
@@ -44,6 +45,7 @@ export class CollisionSystem implements System, CollisionSource {
const b = bodies[j]!;
if (bodyOf(a) === bodyOf(b)) continue;
if (a.type === 'tilemap' && b.type === 'tilemap') continue;
if (!collidersMeet(a, b)) continue;
if (this.overlaps(world, a, b)) current.set(`${a.id}|${b.id}`, { a, b });
}
}
@@ -73,7 +75,7 @@ export class CollisionSystem implements System, CollisionSource {
touching(world: World, a: Entity, b: Entity): boolean {
for (const ca of partsOf(world, a)) {
for (const cb of partsOf(world, b)) {
if (ca !== cb && this.overlaps(world, ca, cb)) return true;
if (ca !== cb && collidersMeet(ca, cb) && this.overlaps(world, ca, cb)) return true;
}
}
return false;
+42 -10
View File
@@ -12,6 +12,7 @@ import {
solidRects,
} from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk';
import { type CollisionFilter, collisionFilterOf, layersMeet } from '../../core/model/collisionLayers';
import {
type ColliderShape,
colliderShape,
@@ -96,6 +97,14 @@ export interface SolidShape {
/** A shape in the mover's way and the collider or tilemap it belongs to. */
interface Obstacle extends SolidShape {
body: Entity;
/** Which layers it is on and meets (#152) – the same for every shape of one tilemap. */
filter: CollisionFilter;
}
/** One of the mover's own colliders, with the layers it may meet. */
interface Mover {
collider: Entity;
filter: CollisionFilter;
}
/** What a ray met (#153): the collider or tilemap, where, the face's normal and how far along the ray. */
@@ -250,7 +259,10 @@ export class Solids {
dy: number,
onBlocked?: (blocked: Blocked) => void,
): MoveResult {
const own = blockingCollidersOf(world, entity);
const own: Mover[] = blockingCollidersOf(world, entity).map((collider) => ({
collider,
filter: collisionFilterOf(collider.props),
}));
const frame = parentFrame(entity);
const wanted = applyVector(frame, { x: dx, y: dy });
if (own.length === 0) {
@@ -261,12 +273,19 @@ export class Solids {
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
const obstacles: Obstacle[] = world.entities
.filter((c) => blocksMover(c, entity))
.flatMap((body) => this.solidShapesOf(world, body).map((solid) => ({ ...solid, body })));
.flatMap((body) => {
const filter = collisionFilterOf(body.props);
return this.solidShapesOf(world, body).map((solid) => ({ ...solid, body, filter }));
});
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
// whole move: it is on its way through, from below or from the side.
const ignoring = world.ignoresOneWay(entity);
const passing = new Set(
obstacles.filter((o) => o.facing && (ignoring || own.some((a) => shapesOverlap(shapeOf(a), o.shape)))),
obstacles.filter(
(o) =>
o.facing &&
(ignoring || own.some((a) => meets(a, o) && shapesOverlap(shapeOf(a.collider), o.shape))),
),
);
const contacts: SweepContact[] = [];
let rest = wanted;
@@ -313,7 +332,7 @@ function parentFrame(entity: Entity): Mat {
function sweep(
entity: Entity,
frame: Mat,
own: Entity[],
own: Mover[],
obstacles: Obstacle[],
passing: Set<Obstacle>,
move: Vec2,
@@ -332,16 +351,16 @@ function sweep(
// How deep each pair already overlaps. A step may never deepen that, but resolving it is not
// our job (no jolt, and leaving a wall is always possible).
const before = own.map((a) => {
const mover = shapeOf(a);
const mover = shapeOf(a.collider);
return obstacles.map(({ shape }) => overlapDepth(contactBetween(mover, shape, forward).separation));
});
shift(step);
let correction = 0;
let found: { own: Entity; obstacle: Obstacle } | null = null;
for (const [k, a] of own.entries()) {
const mover = shapeOf(a);
const mover = shapeOf(a.collider);
for (const [j, obstacle] of obstacles.entries()) {
if (passing.has(obstacle)) continue;
if (passing.has(obstacle) || !meets(a, obstacle)) continue;
const was = before[k]![j]!;
if (overlapDepth(contactBetween(mover, obstacle.shape, forward).separation) <= was + DEEPER) continue;
// Coming from outside, the step goes back exactly to where the two met; from inside, all
@@ -359,7 +378,7 @@ function sweep(
}
if (back > correction || !found) {
correction = back;
found = { own: a, obstacle };
found = { own: a.collider, obstacle };
}
}
}
@@ -446,13 +465,26 @@ function travelsWith(collider: Entity, entity: Entity): boolean {
* Longest safe step in world units: half of the thinnest collider involved, measured along the
* travel as the colliders sit in the world (never below 1).
*/
function stepLimit(own: Entity[], obstacles: Obstacle[], along: Vec2): number {
function stepLimit(own: Mover[], obstacles: Obstacle[], along: Vec2): number {
let min = Infinity;
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
for (const { collider } of own) min = Math.min(min, shapeExtent(shapeOf(collider), along));
for (const { shape } of obstacles) min = Math.min(min, shapeExtent(shape, along));
return Math.max(1, min / 2);
}
/** Whether the mover's collider and that obstacle are on layers that meet (#152). */
function meets(mover: Mover, obstacle: Obstacle): boolean {
return layersMeet(mover.filter, obstacle.filter);
}
/**
* Whether two colliders (or a collider and a tilemap) may meet at all, by their layers – the one
* rule the physics engine, `Solids` and the event pair search all answer by.
*/
export function collidersMeet(a: Entity, b: Entity): boolean {
return layersMeet(collisionFilterOf(a.props), collisionFilterOf(b.props));
}
/** What takes part in collisions: colliders, and placed tilemaps with their solid cells. */
export function isCollisionBody(entity: Entity): boolean {
return entity.type === 'collider' || entity.type === 'tilemap';