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

+1 -1
View File
@@ -40,7 +40,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1129 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1138 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+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;
}
+77
View File
@@ -0,0 +1,77 @@
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
import { Button, HStack, Label, TextField, Theme, VStack } from '../ui';
import { FormDialog } from './FormDialog';
/**
* The project's collision layers (#152). Colliders name a layer by its *number*, so a layer taken
* out of the middle would silently move every collider below it onto another one; only the last
* layer can be removed, and the list is otherwise grown at the end.
*/
export class CollisionLayersDialog extends FormDialog {
private names: string[];
private readonly rows = new VStack({ gap: Theme.gap });
private readonly addButton: Button;
constructor(names: readonly string[], onApply: (names: string[]) => void) {
super(320, 'Collision layers');
this.names = [...names];
this.content.add(
new Label('Two colliders meet only when each one names the layer of the other.', {
style: 'muted',
}),
);
this.content.add(this.rows);
this.addButton = new Button('Add layer', { onClick: () => this.addLayer() });
this.content.add(this.formRow(this.addButton));
this.addButtons('Apply', 'primary', () => onApply(this.names));
this.build();
}
/** The names as the fields stand (test hook). */
layerNames(): string[] {
return [...this.names];
}
private addLayer(): void {
if (this.names.length >= MAX_COLLISION_LAYERS) return;
this.names.push(layerFallback(this.names.length));
this.build();
}
private build(): void {
this.rows.removeChildren();
for (const [i, name] of this.names.entries()) {
const field = new TextField(name, {
onChange: (value) => {
// An empty name would leave the layer nameless, which `collisionLayersOf` reads back as
// its number – so the field shows that number rather than staying blank.
const name = String(value).trim() || layerFallback(i);
this.names[i] = name;
if (name !== String(value)) field.setValue(name);
},
});
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.fieldLabel(String(i)));
row.add(field, 1);
// Only the last one: removing any other would move every collider below it onto a new layer.
if (i > 0 && i === this.names.length - 1)
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => {
this.names.pop();
this.build();
},
}),
);
this.rows.add(row);
}
this.addButton.setEnabled(this.names.length < MAX_COLLISION_LAYERS);
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
// which is exactly what happened here.
const size = this.measure();
this.resize(size.w, size.h);
}
}
+6
View File
@@ -30,6 +30,7 @@ import { SceneViewport } from './viewport/SceneViewport';
import { TAB_OF_ASSET } from './assetTabs';
import { attachAutosave } from './autosave';
import { PasswordDialog } from './auth/PasswordDialog';
import type { CollisionLayersDialog } from './CollisionLayersDialog';
import { dialogSlot } from './FormDialog';
import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
@@ -460,6 +461,11 @@ export class Editor implements PlayHost {
return this.passwordDialog.current();
}
/** The open "Collision layers" dialog (#152), for the check scripts. */
collisionLayersDialog(): CollisionLayersDialog | null {
return this.projects.collisionLayersDialog();
}
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
hasTab(id: string): boolean {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
+24 -2
View File
@@ -2,12 +2,17 @@
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger it.
*/
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
import { SetCollisionLayersCommand } from '../core/commands/projectCommands';
import { listScenes } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import { Dialog, Menu, type MenuItem, type Widget } from '../ui';
import { Dialog, Menu, type MenuEntry, type MenuItem, type Widget } from '../ui';
import { CollisionLayersDialog } from './CollisionLayersDialog';
import { dialogSlot } from './FormDialog';
import type { EditorState } from './state';
export class ProjectSession {
private readonly layersDialog = dialogSlot<CollisionLayersDialog>();
/**
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
* run in progress holds a copy of the project being replaced and has to end first.
@@ -70,13 +75,16 @@ export class ProjectSession {
openProjectMenu(anchor: Widget): void {
const open = async () => {
const list = await this.store.list();
const items: MenuItem[] = [
const items: MenuEntry[] = [
{ label: 'New project', onSelect: () => void this.create(`Project ${list.length + 1}`) },
...list.map((p) => ({
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
onSelect: () => void this.open(p.id),
disabled: p.id === this.state.project.id,
})),
{ separator: true },
{ label: 'Collision layers…', onSelect: () => this.openCollisionLayers() },
{ separator: true },
{
label: 'Delete this project…',
danger: true,
@@ -96,6 +104,20 @@ export class ProjectSession {
void open().catch((error) => this.state.setNotice(String(error), 'error'));
}
/** The project's named collision layers (#152); one dialog at a time, like the other forms. */
openCollisionLayers(): CollisionLayersDialog {
return this.layersDialog.open(
new CollisionLayersDialog(this.state.project.collisionLayers, (names) =>
this.state.execute(new SetCollisionLayersCommand(names)),
),
);
}
/** The open layers dialog, for the check scripts. */
collisionLayersDialog(): CollisionLayersDialog | null {
return this.layersDialog.current();
}
openSceneMenu(anchor: Widget): void {
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
+5
View File
@@ -206,6 +206,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
tabRow: () => editor.tabRow(),
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
passwordDialog: () => editor.accountDialog()?.info() ?? null,
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
collisionLayers: () => ({
project: [...state.project.collisionLayers],
dialog: editor.collisionLayersDialog()?.layerNames() ?? null,
}),
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
/** The left column: asset panel on top, scene objects below, and the divider between them. */
+48
View File
@@ -1,5 +1,13 @@
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
layerIndexOf,
layerMaskOf,
layerName,
maskHas,
maskLabel,
maskWith,
} from '../../core/model/collisionLayers';
import {
assetChoiceLabel,
assetRefChoices,
@@ -370,6 +378,46 @@ export function createField(
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'layer': {
// The names live in the project, not in the row: renaming a layer renames it everywhere.
const names = () => ctx.project().collisionLayers;
const labelOf = (v: PropertyValue) => layerName(names(), layerIndexOf(v));
const button = new Dropdown(labelOf(value));
button.onClick = () => {
new Menu(names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }))).openBelow(
button,
);
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'layers': {
const names = () => ctx.project().collisionLayers;
const labelOf = (v: PropertyValue) => maskLabel(layerMaskOf(v), names());
let mask = layerMaskOf(value);
const button = new Dropdown(labelOf(value));
// A tick per layer, and the menu stays open: picking several is the normal case here.
button.onClick = () => {
new Menu(
names().map((name, i) => ({
label: name,
checked: () => maskHas(mask, i),
keepOpen: true,
onSelect: () => {
mask = maskWith(mask, i, !maskHas(mask, i));
handlers.onChange(mask);
},
})),
).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
mask = layerMaskOf(v);
button.setText(labelOf(v));
},
};
}
case 'key': {
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
+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';
+24 -3
View File
@@ -17,8 +17,15 @@ export interface MenuItem {
disabled?: boolean;
/** SVG file in front of the label (see `ButtonOptions.icon`). */
icon?: string;
/** Draws a tick box in front of the label and asks again after every pick. */
checked?: () => boolean;
/** Picking this does not close the menu – for a set you tick several entries in. */
keepOpen?: true;
}
const TICKED = '☑';
const UNTICKED = '☐';
/** A hairline between two groups of entries; it is not selectable and matches no search. */
export interface MenuSeparator {
separator: true;
@@ -36,6 +43,8 @@ interface Row {
/** Null for a separator: it is decoration between groups, not an entry. */
button: Button | null;
label: string;
/** The text as it stands, tick box and all – asked again whenever a ticked entry was picked. */
text?: () => string;
}
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
@@ -65,20 +74,32 @@ export class Menu extends Popup {
this.rows.push({ widget: new Separator(), button: null, label: '' });
continue;
}
const button = new Button(item.label, {
const { checked } = item;
const text = () => (checked ? `${checked() ? TICKED : UNTICKED} ${item.label}` : item.label);
const button = new Button(text(), {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
icon: item.icon,
});
button.setEnabled(!item.disabled);
button.onClick = this.closeThen(item.onSelect);
this.rows.push({ widget: button, button, label: item.label.toLowerCase() });
button.onClick = item.keepOpen
? () => {
item.onSelect();
this.retick();
}
: this.closeThen(item.onSelect);
this.rows.push({ widget: button, button, label: item.label.toLowerCase(), text });
}
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.filter();
}
/** Re-reads every tick box: one entry may switch off another, so all of them are asked. */
private retick(): void {
for (const row of this.rows) if (row.button && row.text) row.button.setText(row.text());
}
override open(globalX: number, globalY: number): void {
super.open(globalX, globalY);
setCurrent(this);
+223
View File
@@ -0,0 +1,223 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { SetCollisionLayersCommand } from '../../src/core/commands/projectCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
ALL_LAYERS,
COLLIDES_WITH_KEY,
COLLISION_LAYER_KEY,
collisionFilterOf,
collisionLayersOf,
interactionGroups,
layerMaskOf,
layerName,
layersMeet,
maskHas,
maskLabel,
maskWith,
MAX_COLLISION_LAYERS,
} from '../../src/core/model/collisionLayers';
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { COLLISION_SYSTEMS, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
/** A collider on layer `layer` that collides only with the layers in `meets`. */
const on = (layer: number, meets: number[]): Record<string, PropertyValue> => ({
[COLLISION_LAYER_KEY]: layer,
[COLLIDES_WITH_KEY]: meets.reduce((mask, l) => maskWith(mask, l, true), 0),
});
describe('collision layers: the rule itself (#152)', () => {
it('a bag without the two keys is layer 0 against every layer – no old project changes', () => {
const old = collisionFilterOf({ width: 100 });
expect(old).toEqual({ layer: 0, mask: ALL_LAYERS });
expect(layersMeet(old, old)).toBe(true);
expect(layersMeet(old, collisionFilterOf(on(3, [0, 3])))).toBe(true);
});
it('is symmetric: one side alone allowing the other is not enough', () => {
const player = collisionFilterOf(on(1, [0, 1, 2]));
const shot = collisionFilterOf(on(2, [0]));
expect(maskHas(player.mask, shot.layer)).toBe(true);
expect(maskHas(shot.mask, player.layer)).toBe(false);
expect(layersMeet(player, shot)).toBe(false);
expect(layersMeet(shot, player)).toBe(false);
});
it('a layer collides with itself only while its own mask names it', () => {
const enemy = collisionFilterOf(on(2, [0, 2]));
expect(layersMeet(enemy, enemy)).toBe(true);
const loner = collisionFilterOf(on(2, [0]));
expect(layersMeet(loner, loner)).toBe(false);
});
it('an unreadable layer is 0 and an unreadable mask is every layer', () => {
expect(collisionFilterOf({ [COLLISION_LAYER_KEY]: 'nope' })).toEqual({ layer: 0, mask: ALL_LAYERS });
// `null` is what JSON writes for a value it cannot hold, and `Number(null)` is 0 – which as a
// mask would mean "collides with nothing". It has to read as "not set" instead.
expect(layerMaskOf(null)).toBe(ALL_LAYERS);
expect(layerMaskOf(0)).toBe(0);
// Out of range in either direction folds back onto layer 0 rather than onto a bit that is not there.
expect(collisionFilterOf({ [COLLISION_LAYER_KEY]: MAX_COLLISION_LAYERS }).layer).toBe(0);
expect(collisionFilterOf({ [COLLISION_LAYER_KEY]: -3 }).layer).toBe(0);
});
it('the Rapier group is membership in the high bits and the mask in the low ones', () => {
expect(interactionGroups({ layer: 0, mask: ALL_LAYERS })).toBe(0x0001ffff);
expect(interactionGroups({ layer: 3, mask: 0b101 })).toBe(0x00080005);
// Rapier meets a pair exactly when both groups pass, which is what `layersMeet` says.
const a = { layer: 1, mask: maskWith(0, 2, true) };
const b = { layer: 2, mask: maskWith(0, 1, true) };
const passes = (x: number, y: number) => ((x >>> 16) & (y & 0xffff)) !== 0;
const ga = interactionGroups(a);
const gb = interactionGroups(b);
expect(passes(ga, gb) && passes(gb, ga)).toBe(layersMeet(a, b));
});
it('reads the project list off crude input and always has layer 0', () => {
expect(collisionLayersOf(null)).toEqual(['Default']);
expect(collisionLayersOf({ collisionLayers: [] })).toEqual(['Default']);
expect(collisionLayersOf({ collisionLayers: 'Player' })).toEqual(['Default']);
expect(collisionLayersOf({ collisionLayers: ['Ground', 7, ''] })).toEqual([
'Ground',
'Layer 1',
'Layer 2',
]);
expect(collisionLayersOf({ collisionLayers: Array(30).fill('x') })).toHaveLength(MAX_COLLISION_LAYERS);
});
it('names a layer the project does not have by its number', () => {
expect(layerName(['Ground', 'Player'], 1)).toBe('Player');
expect(layerName(['Ground'], 4)).toBe('Layer 4');
});
it('says on the button what the mask holds', () => {
const names = ['Default', 'Player', 'Enemy', 'Shots'];
expect(maskLabel(ALL_LAYERS, names)).toBe('All layers');
expect(maskLabel(0, names)).toBe('Nothing');
expect(maskLabel(maskWith(0, 1, true), names)).toBe('Player');
expect(maskLabel(maskWith(maskWith(0, 0, true), 2, true), names)).toBe('Default, Enemy');
expect(maskLabel(0b0111, names)).toBe('3 layers');
});
});
describe('the project owns the list of layers (#152)', () => {
it('a new project has one layer, and setting them is one undo step', () => {
const f = makeSpriteScene('Thing');
expect(f.project.collisionLayers).toEqual(['Default']);
f.stack.execute(new SetCollisionLayersCommand(['Ground', 'Player']));
expect(f.project.collisionLayers).toEqual(['Ground', 'Player']);
f.stack.undo();
expect(f.project.collisionLayers).toEqual(['Default']);
f.stack.redo();
expect(f.project.collisionLayers).toEqual(['Ground', 'Player']);
});
it('a nameless layer is stored under its number rather than blank', () => {
const f = makeSpriteScene('Thing');
f.stack.execute(new SetCollisionLayersCommand(['Ground', '']));
expect(f.project.collisionLayers).toEqual(['Ground', 'Layer 1']);
});
});
/** A scene of objects with one collider each; `add` sets the collider's (and rigidbody's) properties. */
function scene() {
const f = makeSpriteScene('Thing');
const add = (at: Vec2, collider: Record<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
const id = place(f, f.spriteId, at);
const command = new AddColliderCommand(f.sceneId, id);
f.stack.execute(command);
for (const [key, value] of Object.entries(collider))
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
if (body) {
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
f.stack.execute(rigidbody);
for (const [key, value] of Object.entries(body))
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value));
}
return id;
};
return { ...f, add };
}
function run(f: SceneFixture, physics: boolean) {
const started = startWorld(f, { physics, collisions: true });
const entity = (id: string) => started.world.findByObjectId(id)!;
const move = (id: string, dx: number, dy: number) =>
started.collisions.moveAndSlide(started.world, entity(id), dx, dy);
return { ...started, entity, move };
}
/** A wall 8 wide and 200 tall at x = 0; the mover starts left of it and walks right. */
const WALL = { width: 8, height: 200 };
describe.each(COLLISION_SYSTEMS)('$system: layers filter both systems alike (#152)', ({ physics }) => {
it('a mover whose mask leaves out the wall walks straight through it', () => {
const s = scene();
s.add({ x: 0, y: 0 }, { ...WALL, ...on(1, [1]) });
const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20, ...on(2, [2]) });
const r = run(s, physics);
for (let i = 0; i < 20; i++) expect(r.move(walker, 10, 0).hit).toBe(false);
expect(r.entity(walker).x).toBeCloseTo(100, 9);
});
it('the same pair on layers that meet stops flush at the wall', () => {
const s = scene();
s.add({ x: 0, y: 0 }, { ...WALL, ...on(1, [1, 2]) });
const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20, ...on(2, [1, 2]) });
const r = run(s, physics);
for (let i = 0; i < 20; i++) r.move(walker, 10, 0);
expect(r.entity(walker).x).toBeCloseTo(-14, 9);
});
it('one side alone allowing the other is not enough to be stopped', () => {
const s = scene();
// The wall would meet the walker, the walker does not meet the wall: symmetric, so it passes.
s.add({ x: 0, y: 0 }, { ...WALL, ...on(1, [1, 2]) });
const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20, ...on(2, [2]) });
const r = run(s, physics);
for (let i = 0; i < 20; i++) r.move(walker, 10, 0);
expect(r.entity(walker).x).toBeCloseTo(100, 9);
});
it('two overlapping colliders on layers that do not meet report no collision', () => {
const s = scene();
s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(1, [1]) });
s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(2, [2]) });
const r = run(s, physics);
r.frame();
r.frame();
expect(r.collisions.enters).toHaveLength(0);
expect(r.collisions.stays).toHaveLength(0);
});
it('the same two, once their masks name each other, do report it', () => {
const s = scene();
s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(1, [2]) });
s.add({ x: 0, y: 0 }, { width: 40, height: 40, ...on(2, [1]) });
const r = run(s, physics);
r.frame();
expect(r.collisions.enters).toHaveLength(1);
});
});
describe('a dynamic body falls through a layer it does not meet (#152)', () => {
it('lands on ground that meets it and keeps falling through ground that does not', () => {
const fall = (groundLayers: number[]) => {
const s = scene();
s.add({ x: 0, y: 104 }, { width: 400, height: 8, ...on(1, groundLayers) });
const box = s.add({ x: 0, y: 0 }, { width: 20, height: 20, ...on(2, [1, 2]) }, { bodyType: 'dynamic' });
const r = run(s, true);
for (let i = 0; i < 90; i++) r.frame(1 / 60);
return r.entity(box).y;
};
// Ground that names the box's layer holds it just above its top face at y = 100.
expect(fall([1, 2])).toBeCloseTo(90, 0);
// Ground that does not is not there for this box at all: after 1.5 s it is far below.
expect(fall([1])).toBeGreaterThan(400);
});
});
+1
View File
@@ -150,6 +150,7 @@ describe('CommandStack', () => {
activeSceneId: '',
startSceneId: '',
plugins: [],
collisionLayers: ['Default'],
};
const stack = new CommandStack(project);
let failing = false;
+93 -1
View File
@@ -1,6 +1,9 @@
import { inView } from './lib/scaffold.mjs';
/**
* Colliders: "Add collider" from the context menu, collision events reaching the owner's
* behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling.
* behavior, Move with "Collide with solids" – blocking, triggers, no tunnelling – and the
* collision layers that decide which colliders meet at all (#152).
*/
export async function colliders(t) {
const {
@@ -10,11 +13,14 @@ export async function colliders(t) {
json,
shot,
click,
doubleClick,
clickText,
locate,
press,
typeText,
holdKey,
undo,
redo,
mutate,
select,
state,
@@ -127,4 +133,90 @@ export async function colliders(t) {
await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed
await select({ kind: 'object', objectId: 'oc2' });
await shot('22-trigger-collider');
// 8d. Collision layers (#152). The project starts with the one layer every collider is on; a
// second one is added and named in the dialog behind Project → "Collision layers…".
await clickText('Project');
check(
'the Project menu offers the collision layers',
(await json('__gm.menuRows()')).includes('Collision layers…'),
JSON.stringify(await json('__gm.menuRows()')),
);
await clickText('Collision layers…');
const opened = await json('__gm.collisionLayers()');
check(
'the dialog opens on the one layer a project starts with',
opened.project.length === 1 && opened.dialog?.length === 1 && opened.dialog[0] === 'Default',
JSON.stringify(opened),
);
await clickText('Add layer');
const added = await json('__gm.collisionLayers()');
check(
'Add layer appends a second one, named after its number until it is typed over',
added.dialog?.length === 2 && added.dialog[1] === 'Layer 1' && added.project.length === 1,
JSON.stringify(added),
);
const layerField = await locate('Layer 1');
await doubleClick(layerField.x, layerField.y);
await typeText('Shots');
await press('Enter');
await shot('22b-collision-layers');
await clickText('Apply');
const applied = await json('__gm.collisionLayers()');
check(
'Apply writes the names into the project and closes the dialog',
applied.dialog === null && JSON.stringify(applied.project) === JSON.stringify(['Default', 'Shots']),
JSON.stringify(applied),
);
await undo();
check(
'the layers are an ordinary undo step',
JSON.stringify((await json('__gm.collisionLayers()')).project) === JSON.stringify(['Default']),
JSON.stringify(await json('__gm.collisionLayers()')),
);
await redo();
// The collider inspector carries both rows, and "Collides with" ticks every layer by default.
// They are the last rows of a long schema, so the inspector is scrolled to them first.
// Clicked rather than selected through the hook: after the play runs above, a selection set
// from outside leaves the inspector empty – only the click rebuilds it.
const crateRow = await inView(t, '__gm.leftColumn().objects', "__gm.hierarchyRow('oc2')");
await click(crateRow.x, crateRow.y);
const layerRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Layer')");
check('the collider inspector shows the Layer row', Boolean(layerRow), JSON.stringify(layerRow));
const collidesWith = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Collides with')");
await click(collidesWith.x, collidesWith.y);
await shot('22c-collides-with');
const ticks = await json('__gm.menuRows()');
check(
'"Collides with" lists every layer of the project, all ticked',
JSON.stringify(ticks) === JSON.stringify(['☑ Default', '☑ Shots']),
JSON.stringify(ticks),
);
await clickText('☑ Default');
const unticked = await json('__gm.menuRows()');
check(
'picking one unticks it without closing the menu',
JSON.stringify(unticked) === JSON.stringify(['☐ Default', '☑ Shots']),
JSON.stringify(unticked),
);
await press('Escape');
// Putting the blue crate's collider on "Shots" – which the red crate's does not meet – is what
// 8c blocked on: the very same run now walks straight through it.
await mutate(`
const scene = __gm.state.activeScene;
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
scene.objects.find(o => o.id === 'oc2').overrides = { width: 140, layer: 1, collidesWith: 2 };
__gm.state.select(null)`);
await press('Enter', { ctrl: true });
await holdKey('ArrowRight', 700);
const throughLayer = await entity('o1');
await stop();
check(
'a collider on a layer the mover does not meet stops nothing',
throughLayer && throughLayer.x > -120 + 20,
`world x=${throughLayer?.x}`,
);
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'oc2').overrides = { width: 140 }`);
}