diff --git a/README.md b/README.md index 25dbc764..900d0f7d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index 0aa30b47..2f68a6d2 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -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; + } +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index fab4f02b..2df3ff55 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -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], }; } diff --git a/src/core/model/collisionLayers.ts b/src/core/model/collisionLayers.ts new file mode 100644 index 00000000..58a9be91 --- /dev/null +++ b/src/core/model/collisionLayers.ts @@ -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`; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 93f18d73..e9ead75b 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { 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 = { }, // How far a contact may lean from that side and still land (#159). ONE_WAY_ANGLE_ROW, + ...COLLISION_LAYER_ROWS, ], rigidbody: [ { diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 609389c7..897bf453 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -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]; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 9ca451e8..fea11fa3 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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; } diff --git a/src/editor/CollisionLayersDialog.ts b/src/editor/CollisionLayersDialog.ts new file mode 100644 index 00000000..61f200bd --- /dev/null +++ b/src/editor/CollisionLayersDialog.ts @@ -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); + } +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2cf4c82e..85231f19 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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}`); diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 82d440de..f275ab6b 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -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(); + /** * `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, diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index b779d9ab..a8c6033d 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index a26b0e2f..5a38ada4 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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)) }; diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index ea8d5931..2a2fa612 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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; } diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 6830eca1..242500a3 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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; diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index efbf159c..dd9164fe 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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, 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'; diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index 0ee7a0aa..4c8a6c69 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -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); diff --git a/tests/core/collision-layers.test.ts b/tests/core/collision-layers.test.ts new file mode 100644 index 00000000..28e6285d --- /dev/null +++ b/tests/core/collision-layers.test.ts @@ -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 => ({ + [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, body?: Record) => { + 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); + }); +}); diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 7ebfb3f6..52612af2 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -150,6 +150,7 @@ describe('CommandStack', () => { activeSceneId: '', startSceneId: '', plugins: [], + collisionLayers: ['Default'], }; const stack = new CommandStack(project); let failing = false; diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index 798f4188..f1f95b4e 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -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 }`); }