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

+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)) };