Add a node that names the collision layer of an object (#211)
game-maker / gate (push) Successful in 2m36s
game-maker / suite (push) Successful in 13m16s

The issue asked for three nodes: Set layer, Set collides with and Layer
of. A probe showed that the finding behind the first two is outdated.
The Properties node of type Collider already writes both settings while
the game runs, and both collision systems see the change at once. As
agreed, only Layer of is built, and writing stays with Properties.

- Layer of is a data node with the input object and the outputs name
  and number. The name is what the project calls the layer.
- NodeContext.collisionLayerOf answers the number, and the new field
  collisionLayers on GraphLookup brings the names to the node.
- layerHolderOf in solids.ts says whose layer counts. An object stands
  for its first collider child, a collider or tilemap for itself.
- Decided here: a collider that is switched off counts too, because
  its layer is set either way.
- Decided here: without a collider the name is empty and the number
  is -1.
- The node is a read, not a run, so it answers at the moment it is
  asked. Right after a Set in the same flow it names the new layer.
- The output number was not asked for. It fits the layer port of
  Properties as it is.

Changing the layer during a run had no test at all so far. Five tests
now hold it in both systems.

Tests:
- Gate: 198 files, 2929 tests (2914 + 15), exit 0
- e2e 1-6: 285 checks passed (06: +2 for block 6z-x)
- Five unit tests for the change during a run: a mover is blocked,
  passes and is blocked again, a ray with a layer filter follows the
  change, and a dynamic body falls through the ground in Play
- Six unit tests for Layer of, three against each system, and four
  cases in the node catalog
- 6z-x finds the node in the palette, checks its ports and logs
  "Ghost" and "1" for the owner's collider in a Play run
- Two counter-probes for the change during a run fail 1 and 4 tests,
  and only new ones, so those places were unguarded before
- Six counter-probes for Layer of fail 2 to 3 tests each. All eight
  ran on stein2, not in the container.
- The browser counter-probe fails 6z-x with "Layer 1" and "1"
- The e2e sum of 1619 is calculated (1617 measured in CI run 276,
  plus 2). Neither the full suite nor the smokes ran for this state.
- Not looked at in the browser: that the layer port of Properties
  shows the same picker as the inspector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 16:16:43 +02:00
1 parent d39bf83ea9
commit 3e8c88c66d
12 files changed
+329 -7

No files matched your search

+1 -1
View File
@@ -45,7 +45,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, 1617 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1619 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
+5
View File
@@ -187,6 +187,11 @@ export interface NodeContext {
length: number,
options: { ignore: EntityHandle | null; triggers: boolean; layers: number; maxHits: number },
): RayOutcome[];
/**
* The number of the collision layer `target` is on (#211): a collider's or tilemap's own, any
* other object's that of its first collider child; null for an object without a collider.
*/
collisionLayerOf(target: EntityHandle): number | null;
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
ignoreOneWay(target: EntityHandle, seconds: number): void;
/** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */
+6 -3
View File
@@ -39,9 +39,9 @@ export interface StateMachine {
/**
* What a node may look up in the project around its graph: the behaviors it can run, the state
* machines it can read, the curves a `Lerp` eases along (#247) and the struct types `Add item`
* checks (#254), what a variable written by a graph is held to (#271) and how long an animation is
* (#233). One handle, so no caller
* can resolve a node with half of it.
* checks (#254), what a variable written by a graph is held to (#271), how long an animation is
* (#233) and what the collision layers are called (#211). One handle, so no caller can resolve a
* node with half of it.
*/
export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null;
@@ -53,6 +53,8 @@ export interface GraphLookup {
textureSize(pictureId: string): { w: number; h: number } | null;
/** An animation's length and playback, for a node that asks where a player stands in it (#233). */
animation(assetId: string): AnimationClip | null;
/** The project's collision layers by number, as `Layer of` names them (#211). */
collisionLayers(): readonly string[];
/** The asset whose graph this is, `''` for none. */
ownerOf(graph: Graph): string;
/** What a variable of that asset declares about the structs it holds (#271); none for nothing typed. */
@@ -83,6 +85,7 @@ export const NO_LOOKUP: GraphLookup = {
structType: () => null,
textureSize: () => null,
animation: () => null,
collisionLayers: () => [],
ownerOf: () => '',
variableTyping: () => undefined,
};
+22
View File
@@ -0,0 +1,22 @@
import { layerName } from '../../../model/collisionLayers';
import type { NodeDef } from '../../NodeRegistry';
import { asEntity } from '../../values';
import { num, obj, str } from '../shared';
export const LayerOf: NodeDef = {
type: 'LayerOf',
label: 'Layer of',
category: 'data',
description:
'The collision layer an object is on, by name and by number (an object stands for its first ' +
'collider child; a collider or tilemap for itself). Without a collider the name is empty and the number -1.',
inputs: [obj('object')],
outputs: [str('name'), num('number')],
params: [],
read: (ctx, _params, input, _def, lookup) => {
const target = asEntity(input('object'));
const layer = target ? ctx.collisionLayerOf(target) : null;
if (layer === null) return { name: '', number: -1 };
return { name: layerName(lookup.collisionLayers(), layer), number: layer };
},
};
+2
View File
@@ -50,6 +50,7 @@ import { IsInState } from './data/IsInState';
import { IsTouching } from './data/IsTouching';
import { Join } from './data/Join';
import { KeyIsDown } from './data/KeyIsDown';
import { LayerOf } from './data/LayerOf';
import { Length } from './data/Length';
import { Lerp } from './data/Lerp';
import {
@@ -249,6 +250,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
MouseButton,
IsHovered,
IsTouching,
LayerOf,
Move,
SetPosition,
GetVelocity,
+1
View File
@@ -50,6 +50,7 @@ export function graphLookup(project: Project): GraphLookup {
const asset = findAssetOf(project, assetId, 'animation');
return asset ? clipOf(asset) : null;
},
collisionLayers: () => project.collisionLayers,
ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '',
variableTyping: (assetId, key) => {
const owner = findAsset(project, assetId);
+7 -1
View File
@@ -4,6 +4,7 @@
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
*/
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
import { collisionFilterOf } from '../../core/model/collisionLayers';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
import { soundFileOf } from '../../core/model/sound';
@@ -15,7 +16,7 @@ import type { World } from '../World';
import type { AudioSink } from './Audio';
import type { Instance } from './behaviorPulses';
import type { Input } from './Input';
import { bodyOf, type CollisionSource, NOT_BLOCKED } from './solids';
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
export interface TimeScaled {
scale: number;
@@ -112,6 +113,11 @@ export function behaviorContext(
world.noteRay(from, end, hits.length > 0);
return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) }));
},
collisionLayerOf: (target) => {
const found = resolve(target);
const holder = found && layerHolderOf(world, found);
return holder ? collisionFilterOf(holder.props).layer : null;
},
ignoreOneWay: (target, seconds) => {
const found = resolve(target);
if (found) world.ignoreOneWay(found, seconds);
+8
View File
@@ -646,6 +646,14 @@ export function isCollisionBody(entity: Entity): boolean {
return entity.type === 'collider' || entity.type === 'tilemap';
}
/**
* Whose layer an object is on (#211): a collider or tilemap its own, anything else that of its first
* collider child, switched on or not – the layer is set either way.
*/
export function layerHolderOf(world: World, entity: Entity): Entity | undefined {
return isCollisionBody(entity) ? entity : world.childrenOf(entity).find((e) => e.type === 'collider');
}
/**
* The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap
* is its own body wherever it is placed – its cells are the thing, not a shape stuck on one.
+156 -2
View File
@@ -1,7 +1,9 @@
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 { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { DeclaredPort, GraphNode } from '../../src/core/graph';
import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts';
import {
ALL_LAYERS,
COLLIDES_WITH_KEY,
@@ -17,9 +19,18 @@ import {
maskWith,
MAX_COLLISION_LAYERS,
} from '../../src/core/model/collisionLayers';
import { findAsset } from '../../src/core/model/Project';
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';
import {
COLLISION_SYSTEMS,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
type SceneFixture,
startWorld,
} from '../helpers';
beforeAll(async () => {
await loadPhysics();
@@ -205,7 +216,150 @@ describe.each(COLLISION_SYSTEMS)('$system: layers filter both systems alike (#15
});
});
/**
* A wall and a walker left of it, both on layer 0 against every layer. The walker's behavior moves
* its collider to layer 3, colliding only with layer 3, on `g`, and back on `b` – with a Properties
* node of type Collider, the way a graph changes a layer (#211).
*/
function ghostScene() {
const s = scene();
s.add({ x: 0, y: 0 }, WALL);
const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20 });
const collider = s.viewOf().objects.find((o) => o.parentId === walker)!;
const behavior = makeBehavior(s, 'Ghost');
const edit = graphEditor(s, behavior);
const child = edit.addNode('ChildByName', { name: findAsset(s.project, collider.assetId)!.name });
edit.connect(edit.addNode('Owner'), 'object', child, 'object');
const ports = [COLLISION_LAYER_KEY, COLLIDES_WITH_KEY].map((key): DeclaredPort => ({
key,
kind: 'number',
write: true,
}));
const setter = (filter: Record<string, PropertyValue>) => {
const set = edit.properties(ports, { [OBJECT_TYPE_PARAM]: 'collider', ...filter });
edit.connect(child, 'child', set, 'object');
return set;
};
const press = (key: string, filter: Record<string, PropertyValue>) =>
edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', setter(filter), 'exec');
press('g', on(3, [3]));
press('b', on(0, [0]));
// `Layer of` (#211): asked of the walker on `l`, on `p` before and after moving it in one pulse,
// and on `c` of the collider itself and of the behavior, which has no collider.
const layerOf = (source: GraphNode, port: string) => {
const node = edit.addNode('LayerOf');
edit.connect(source, port, node, 'object');
return node;
};
const log = (from: GraphNode, source: GraphNode, port: string) => {
const node = edit.addNode('Log');
edit.connect(source, port, node, 'message');
edit.connect(from, 'exec', node, 'exec');
return node;
};
const ofWalker = layerOf(edit.addNode('Owner'), 'object');
const ofCollider = layerOf(child, 'child');
const ofBehavior = layerOf(edit.addNode('Self'), 'object');
log(log(edit.addNode('OnKeyDown', { key: 'l' }), ofWalker, 'name'), ofWalker, 'number');
const moved = setter(on(3, [3]));
edit.connect(log(edit.addNode('OnKeyDown', { key: 'p' }), ofWalker, 'name'), 'exec', moved, 'exec');
log(moved, ofWalker, 'name');
const fromC = log(edit.addNode('OnKeyDown', { key: 'c' }), ofCollider, 'number');
log(log(fromC, ofBehavior, 'name'), ofBehavior, 'number');
s.stack.execute(new PlaceAssetCommand(s.sceneId, behavior.id, { x: 0, y: 0 }, walker));
return { ...s, walker, colliderId: collider.id };
}
describe.each(COLLISION_SYSTEMS)(
'$system: a graph changes the layer during the run (#211)',
({ physics }) => {
const start = () => {
const s = ghostScene();
const started = startWorld(s, { physics, collisions: true, behaviors: true });
const walker = started.world.findByObjectId(s.walker)!;
const walk = () => {
walker.x = -100;
for (let i = 0; i < 20; i++) started.collisions.moveAndSlide(started.world, walker, 10, 0);
return walker.x;
};
const press = (key: string) => {
started.input.press(key);
started.frame();
started.input.release(key);
};
return { ...s, ...started, walk, press };
};
it('a blocked mover walks through once its collider left the layer, and is blocked again once back', () => {
const r = start();
r.frame();
expect(r.walk()).toBeCloseTo(-14, 9);
r.press('g');
expect(collisionFilterOf(r.world.findByObjectId(r.colliderId)!.props)).toEqual({ layer: 3, mask: 8 });
expect(r.walk()).toBeCloseTo(100, 9);
r.press('b');
expect(r.walk()).toBeCloseTo(-14, 9);
});
it('a ray filtered by layers finds the collider on its new layer only', () => {
const r = start();
r.frame();
const seen = (layer: number) =>
r.collisions.raycast(r.world, { x: -100, y: -50 }, { x: 0, y: 1 }, 200, {
ignore: null,
triggers: false,
layers: maskWith(0, layer, true),
}) !== null;
expect([seen(0), seen(3)]).toEqual([true, false]);
r.press('g');
expect([seen(0), seen(3)]).toEqual([false, true]);
});
it('Layer of names the layer of the object’s collider, already in the pulse that changed it', () => {
const r = start();
r.stack.execute(new SetCollisionLayersCommand(['Ground', 'Layer 1', 'Layer 2', 'Ghost']));
r.frame();
r.press('l');
expect(r.world.log).toEqual(['Ground', '0']);
r.press('p');
expect(r.world.log.slice(2)).toEqual(['Ground', 'Ghost']);
r.press('l');
expect(r.world.log.slice(4)).toEqual(['Ghost', '3']);
});
it('Layer of answers for a collider itself, and with no name for an object that has none', () => {
const r = start();
r.frame();
r.press('g');
r.press('c');
expect(r.world.log).toEqual(['3', '', '-1']);
});
it('Layer of still answers while the collider is switched off', () => {
const r = start();
r.frame();
r.press('g');
r.world.findByObjectId(r.colliderId)!.props.enabled = false;
r.press('l');
expect(r.world.log).toEqual(['Layer 3', '3']);
});
},
);
describe('a dynamic body falls through a layer it does not meet (#152)', () => {
it('drops through the ground from the step after a graph took it off the layer (#211)', () => {
const s = scene();
s.add({ x: 0, y: 104 }, { width: 400, height: 8 });
const box = s.add({ x: 0, y: 60 }, { width: 20, height: 20 }, { bodyType: 'dynamic' });
const r = run(s, true);
for (let i = 0; i < 90; i++) r.frame(1 / 60);
expect(r.entity(box).y).toBeCloseTo(90, 0);
const collider = r.world.entities.find((e) => e.type === 'collider' && e.parent === r.entity(box))!;
Object.assign(collider.props, on(3, [3]));
for (let i = 0; i < 60; i++) r.frame(1 / 60);
expect(r.entity(box).y).toBeGreaterThan(300);
});
it('lands on ground that meets it and keeps falling through ground that does not', () => {
const fall = (groundLayers: number[]) => {
const s = scene();
+22
View File
@@ -573,6 +573,28 @@ const CASES: Record<string, Case[]> = {
},
{ name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } },
],
LayerOf: [
{
name: 'names the layer the context says the object is on (#211)',
inputs: { object: fakeEntity('Player') },
context: { collisionLayerOf: (target) => (target.name === 'Player' ? 1 : null) },
lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground', 'Ghost'] },
outputs: { name: 'Ghost', number: 1 },
},
{
name: 'a layer the project has not named goes by its number',
inputs: { object: fakeEntity('Player') },
context: { collisionLayerOf: () => 4 },
lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground'] },
outputs: { name: 'Layer 4', number: 4 },
},
{
name: 'an object without a collider is on no layer',
inputs: { object: fakeEntity('Player') },
outputs: { name: '', number: -1 },
},
{ name: 'no object is on no layer', inputs: { object: null }, outputs: { name: '', number: -1 } },
],
ObjectsByName: [
{
name: 'every object so named, in scene order',
+1
View File
@@ -240,6 +240,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
isTouching: () => false,
raycast: () => null,
raycastAll: () => [],
collisionLayerOf: () => null,
ignoreOneWay: () => {},
isTilemap: () => false,
tileAt: () => null,
+98
View File
@@ -1107,6 +1107,7 @@ export async function graph(t) {
await pluralLookups();
await eventOrder();
await animationPlaying();
await layerOf();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1831,4 +1832,101 @@ export async function graph(t) {
scene.objects = scene.objects.filter((o) => !o.id.startsWith('ap-'));
`);
}
// 6z-x. Layer of (#211): typed into the palette it arrives with its ports. In a run it names the
// layer of the owner's collider as the project calls it, and its number.
async function layerOf() {
await mutate(`
const p = __gm.state.project;
window.__layersBefore = p.collisionLayers;
p.collisionLayers = ['Default', 'Ghost'];
p.assets.push(
{
id: 'lo-col',
type: 'collider',
name: 'Ghost collider',
props: { shape: 'box', width: 20, height: 20, radius: 10, isTrigger: false, layer: 1 },
},
{
id: 'loy',
type: 'behavior',
name: 'Layer asker',
props: { enabled: true, runInEditor: false },
variables: [],
graph: { nodes: [], edges: [] },
},
);
__gm.state.select({ kind: 'asset', assetId: 'loy' });
`);
await showTab('graph');
const spot = await json('__gm.graph.emptySpot()');
await click(spot.x, spot.y, 'right');
await typeText('layer of');
await press('Enter');
const asker = (await asset('loy')).graph.nodes[0];
const types = asker ? await json(`__gm.graph.portTypes(${JSON.stringify(asker.id)})`) : null;
check(
'typing "layer of" into the palette adds the node: object in, name and number out',
asker?.type === 'LayerOf' &&
JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['object']) &&
JSON.stringify(types?.outputs) === JSON.stringify({ name: 'string', number: 'number' }),
JSON.stringify({ asker, types }),
);
if (!asker) return;
const id = JSON.stringify(asker.id);
await mutate(`
const p = __gm.state.project;
const behavior = p.assets.find((a) => a.id === 'loy');
const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
const edge = (id, from, fromPort, to, toPort) =>
({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
behavior.graph.nodes.push(
node('lo-start', 'OnStart', -300, -200),
node('lo-owner', 'Owner', -560, 0),
node('lo-name', 'Log', 420, -200),
node('lo-number', 'Log', 700, -200),
);
behavior.graph.edges.push(
edge('lo-e1', 'lo-start', 'exec', 'lo-name', 'exec'),
edge('lo-e2', 'lo-name', 'exec', 'lo-number', 'exec'),
edge('lo-e3', 'lo-owner', 'object', ${id}, 'object'),
edge('lo-e4', ${id}, 'name', 'lo-name', 'message'),
edge('lo-e5', ${id}, 'number', 'lo-number', 'message'),
);
const scene = __gm.state.activeScene;
const sprite = p.assets.find((a) => a.type === 'sprite');
for (const [id, assetId, parentId, name, x] of [
['lo-target', sprite.id, '', 'Ghost 6', -6000],
['lo-part', 'lo-col', 'lo-target', 'Ghost part', 0],
['lo-holder', 'loy', 'lo-target', 'Layer holder', 0],
]) {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId, name, position: { x, y: x, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
}
`);
await clickText('Fit');
await shot('29-layer-of');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Layer holder]'))
.map((line) => line.split(']').pop().trim());
check(
'a run names the layer of the owner’s collider the way the project calls it, and its number',
JSON.stringify(logged) === JSON.stringify(['Ghost', '1']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.collisionLayers = window.__layersBefore;
p.assets = p.assets.filter((a) => a.id !== 'loy' && a.id !== 'lo-col');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('lo-'));
`);
}
}