Add a node that names the render layer of an object (#305)
game-maker / gate (push) Successful in 3m14s
game-maker / suite (push) Failing after 14m18s

The issue asked for Set render layer, Set sees and Render layer of. As
with #211, a probe showed that writing already works. A Properties
node of the object's type writes Render layer during a run, Inherit
included, and one of type Camera writes Sees. The next picture follows
both. As agreed, only Render layer of is built, and writing stays with
Properties.

- Render layer of is a data node with the input object and the outputs
  name and number. It answers the layer that holds, so an object
  without a choice of its own names its parent's.
- NodeContext.renderLayerOf answers the number with the same rule the
  drawing uses (renderLayerAlong). The new field renderLayers on
  GraphLookup brings the names to the node.
- hasRenderLayer in PropertySchema says which types are drawn.
- Decided here: for an object that is not drawn (a camera, a
  behavior, a collider) the name is empty and the number -1, as Layer
  of does without a collider.

Changing the render layer or Sees during a run had no test so far.
Three unit tests now hold the proof from the issue.

Tests:
- Gate: 231 files, 3534 tests (3526 + 8), exit 0
- e2e 6: 149 checks passed, e2e 63: 13 checks passed (+1)
- Unit: the coin moves into the overlay with Set and back with
  Inherit, a HUD container takes its sprite along, the main camera sees
  a second layer once its Sees is set, and Render layer of names the
  inherited layer, and none for a behavior
- Node catalog: four cases for Render layer of
- 63c3 moves a sprite onto UI in a Play run: the main camera no longer
  draws it, and the log reads "UI" and "1"
- Three counter-probes fail 1, 1 and 2 tests: without inheriting in
  the node, without the check for a drawn type, and without
  inheriting in the drawing
- "layer of" in the palette still finds Layer of first (6z-x)
- The e2e sum is 1731 (1730 plus 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 19:25:47 +02:00
1 parent 70f3608733
commit d9c6bbfa4d
12 files changed
+250 -8

No files matched your search

+1 -1
View File
@@ -47,7 +47,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: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1730 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1731 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+5
View File
@@ -205,6 +205,11 @@ export interface NodeContext {
* other object's that of its first collider child; null for an object without a collider. * other object's that of its first collider child; null for an object without a collider.
*/ */
collisionLayerOf(target: EntityHandle): number | null; collisionLayerOf(target: EntityHandle): number | null;
/**
* The number of the render layer `target` is drawn on (#305): its own choice, else its parent's,
* at the top 0; null for an object that is not drawn.
*/
renderLayerOf(target: EntityHandle): number | null;
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */ /** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
ignoreOneWay(target: EntityHandle, seconds: number): void; ignoreOneWay(target: EntityHandle, seconds: number): void;
/** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */ /** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */
+5 -2
View File
@@ -45,8 +45,8 @@ export interface StateMachine {
* What a node may look up in the project around its graph: the behaviors it can run, the state * 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` * 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), how long an animation is * 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 * (#233) and what the collision and render layers are called (#211, #305). One handle, so no caller
* node with half of it. * can resolve a node with half of it.
*/ */
export interface GraphLookup { export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null; behavior(assetId: string): BehaviorProgram | null;
@@ -62,6 +62,8 @@ export interface GraphLookup {
animation(assetId: string): AnimationClip | null; animation(assetId: string): AnimationClip | null;
/** The project's collision layers by number, as `Layer of` names them (#211). */ /** The project's collision layers by number, as `Layer of` names them (#211). */
collisionLayers(): readonly string[]; collisionLayers(): readonly string[];
/** The project's render layers by number, as `Render layer of` names them (#305). */
renderLayers(): readonly string[];
/** The asset whose graph this is, `''` for none. */ /** The asset whose graph this is, `''` for none. */
ownerOf(graph: Graph): string; ownerOf(graph: Graph): string;
/** What a variable of that asset declares about the structs it holds (#271); none for nothing typed. */ /** What a variable of that asset declares about the structs it holds (#271); none for nothing typed. */
@@ -94,6 +96,7 @@ export const NO_LOOKUP: GraphLookup = {
textureSize: () => null, textureSize: () => null,
animation: () => null, animation: () => null,
collisionLayers: () => [], collisionLayers: () => [],
renderLayers: () => [],
ownerOf: () => '', ownerOf: () => '',
variableTyping: () => undefined, variableTyping: () => undefined,
}; };
@@ -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 RenderLayerOf: NodeDef = {
type: 'RenderLayerOf',
label: 'Render layer of',
category: 'data',
description:
'The render layer an object is drawn on, by name and by number: its own, else the one it ' +
'inherits from its parent. For an object that is not drawn 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.renderLayerOf(target) : null;
if (layer === null) return { name: '', number: -1 };
return { name: layerName(lookup.renderLayers(), layer), number: layer };
},
};
+2
View File
@@ -88,6 +88,7 @@ import { PackStruct } from './data/PackStruct';
import { Properties } from './data/Properties'; import { Properties } from './data/Properties';
import { Parent } from './data/Parent'; import { Parent } from './data/Parent';
import { Random } from './data/Random'; import { Random } from './data/Random';
import { RenderLayerOf } from './data/RenderLayerOf';
import { Round } from './data/Round'; import { Round } from './data/Round';
import { Select } from './data/Select'; import { Select } from './data/Select';
import { Self } from './data/Self'; import { Self } from './data/Self';
@@ -261,6 +262,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
IsHovered, IsHovered,
IsTouching, IsTouching,
LayerOf, LayerOf,
RenderLayerOf,
Move, Move,
MoveToward, MoveToward,
SetPosition, SetPosition,
+1
View File
@@ -60,6 +60,7 @@ export function graphLookup(project: Project): GraphLookup {
return asset ? clipOf(asset) : null; return asset ? clipOf(asset) : null;
}, },
collisionLayers: () => project.collisionLayers, collisionLayers: () => project.collisionLayers,
renderLayers: () => project.renderLayers,
ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '', ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '',
variableTyping: (assetId, key) => { variableTyping: (assetId, key) => {
const owner = findAsset(project, assetId); const owner = findAsset(project, assetId);
+6 -1
View File
@@ -402,8 +402,13 @@ const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
'tilemap', 'tilemap',
]); ]);
/** Whether objects of that type are drawn and so sit on a render layer. */
export function hasRenderLayer(type: AssetType): boolean {
return RENDER_LAYER_TYPES.has(type);
}
function renderLayerPropsOf(type: AssetType): PropertySchema { function renderLayerPropsOf(type: AssetType): PropertySchema {
return RENDER_LAYER_TYPES.has(type) ? [RENDER_LAYER_ROW] : []; return hasRenderLayer(type) ? [RENDER_LAYER_ROW] : [];
} }
const COMMON_KEYS = new Set( const COMMON_KEYS = new Set(
+11
View File
@@ -10,7 +10,9 @@ import type { Vec2 } from '../../core/model/Properties';
import { JOINT_CONNECTED_KEY } from '../../core/model/joint'; import { JOINT_CONNECTED_KEY } from '../../core/model/joint';
import { objectReference } from '../../core/model/objectRefs'; import { objectReference } from '../../core/model/objectRefs';
import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project'; import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project';
import { renderLayerAlong } from '../../core/model/renderLayers';
import { soundFileOf } from '../../core/model/sound'; import { soundFileOf } from '../../core/model/sound';
import { hasRenderLayer } from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve'; import { resolveAssetProps } from '../../core/properties/resolve';
import { loopsOf, volumeOf } from '../../core/properties/sounds'; import { loopsOf, volumeOf } from '../../core/properties/sounds';
import type { Entity } from '../Entity'; import type { Entity } from '../Entity';
@@ -128,6 +130,15 @@ export function behaviorContext(
const holder = found && layerHolderOf(world, found); const holder = found && layerHolderOf(world, found);
return holder ? collisionFilterOf(holder.props).layer : null; return holder ? collisionFilterOf(holder.props).layer : null;
}, },
renderLayerOf: (target) => {
const found = resolve(target);
if (!found || !hasRenderLayer(found.type)) return null;
return renderLayerAlong(
found,
(at) => at.props,
(at) => at.parent ?? null,
);
},
ignoreOneWay: (target, seconds) => { ignoreOneWay: (target, seconds) => {
const found = resolve(target); const found = resolve(target);
if (found) world.ignoreOneWay(found, seconds); if (found) world.ignoreOneWay(found, seconds);
+22
View File
@@ -609,6 +609,28 @@ const CASES: Record<string, Case[]> = {
}, },
{ name: 'no object is on no layer', inputs: { object: null }, outputs: { name: '', number: -1 } }, { name: 'no object is on no layer', inputs: { object: null }, outputs: { name: '', number: -1 } },
], ],
RenderLayerOf: [
{
name: 'names the render layer the context says the object is drawn on (#305)',
inputs: { object: fakeEntity('Coin') },
context: { renderLayerOf: (target) => (target.name === 'Coin' ? 1 : null) },
lookup: { ...NO_LOOKUP, renderLayers: () => ['Default', 'UI'] },
outputs: { name: 'UI', number: 1 },
},
{
name: 'a render layer the project has not named goes by its number',
inputs: { object: fakeEntity('Coin') },
context: { renderLayerOf: () => 3 },
lookup: { ...NO_LOOKUP, renderLayers: () => ['Default'] },
outputs: { name: 'Layer 3', number: 3 },
},
{
name: 'an object that is not drawn is on no render layer',
inputs: { object: fakeEntity('Cam') },
outputs: { name: '', number: -1 },
},
{ name: 'no object is on no render layer', inputs: { object: null }, outputs: { name: '', number: -1 } },
],
ObjectsByName: [ ObjectsByName: [
{ {
name: 'every object so named, in scene order', name: 'every object so named, in scene order',
+1
View File
@@ -244,6 +244,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
raycast: () => null, raycast: () => null,
raycastAll: () => [], raycastAll: () => [],
collisionLayerOf: () => null, collisionLayerOf: () => null,
renderLayerOf: () => null,
ignoreOneWay: () => {}, ignoreOneWay: () => {},
isTilemap: () => false, isTilemap: () => false,
tileAt: () => null, tileAt: () => null,
+124 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetLayerNamesCommand } from '../../src/core/commands/projectCommands'; import { SetLayerNamesCommand } from '../../src/core/commands/projectCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { GraphNode } from '../../src/core/graph';
import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts';
import type { PropertyBag } from '../../src/core/model/Properties';
import { import {
cameraSees, cameraSees,
INHERIT_LAYER, INHERIT_LAYER,
@@ -13,10 +16,15 @@ import {
} from '../../src/core/model/renderLayers'; } from '../../src/core/model/renderLayers';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { settingsChanges } from '../../src/editor/ProjectSettingsDialog'; import { settingsChanges } from '../../src/editor/ProjectSettingsDialog';
import { cameraOfItems, overlaysOfItems, overlaysOfWorld } from '../../src/viewport/activeCamera'; import {
cameraOfItems,
cameraOfWorld,
overlaysOfItems,
overlaysOfWorld,
} from '../../src/viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items'; import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { cameraView, overlayView } from '../../src/viewport/view'; import { cameraView, overlayView } from '../../src/viewport/view';
import { makeSpriteScene, place, startWorld } from '../helpers'; import { type GraphEditor, graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
function sceneWithCamera() { function sceneWithCamera() {
const fixture = makeSpriteScene(); const fixture = makeSpriteScene();
@@ -134,3 +142,116 @@ describe('render layers (#302)', () => {
}); });
}); });
}); });
/**
* A coin on `Default` and a HUD container with a sprite in it, a main camera that sees `Default` and
* an overlay that sees `UI`. Behaviors change the layers with Properties nodes, the way a graph does
* it (#305): the coin goes to `UI` on `u` and back to Inherit on `i`, the HUD to `UI` on `h`, and the
* main camera sees both layers on `s`. On `l` the HUD's sprite asks `Render layer of` of itself, of
* the HUD and of its behavior, which is not drawn.
*/
function layerSwitchScene() {
const f = sceneWithCamera();
f.stack.execute(new SetLayerNamesCommand('renderLayers', ['Default', 'UI']));
const coin = place(f, f.spriteId, { x: 0, y: 0 });
const hud = place(f, f.containerId, { x: 0, y: 0 });
const inside = place(f, f.spriteId, { x: 0, y: 0 }, hud);
const main = place(f, f.cameraId, { x: 0, y: 0 });
const over = place(f, f.cameraId, { x: 0, y: 0 });
f.set(main, SEES_KEY, 0b01);
f.set(over, SEES_KEY, 0b10);
f.set(over, OVERLAY_KEY, true);
const behaviorOn = (objectId: string, name: string) => {
const behavior = makeBehavior(f, name);
f.stack.execute(new PlaceAssetCommand(f.sceneId, behavior.id, { x: 0, y: 0 }, objectId));
return graphEditor(f, behavior);
};
const setOnKey = (edit: GraphEditor, key: string, type: string, port: string, value: number) => {
const set = edit.properties([{ key: port, kind: 'number', write: true }], {
[OBJECT_TYPE_PARAM]: type,
[port]: value,
});
edit.connect(edit.addNode('Owner'), 'object', set, 'object');
edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', set, 'exec');
};
const onCoin = behaviorOn(coin, 'Coin');
setOnKey(onCoin, 'u', 'sprite', RENDER_LAYER_KEY, 1);
setOnKey(onCoin, 'i', 'sprite', RENDER_LAYER_KEY, INHERIT_LAYER);
const onHud = behaviorOn(hud, 'Hud');
setOnKey(onHud, 'h', 'container', RENDER_LAYER_KEY, 1);
const onCamera = behaviorOn(main, 'Cam');
setOnKey(onCamera, 's', 'camera', SEES_KEY, 0b11);
const onInside = behaviorOn(inside, 'Inside');
let from = onInside.addNode('OnKeyDown', { key: 'l' });
const ask = (source: GraphNode, port: string) => {
const of = onInside.addNode('RenderLayerOf');
onInside.connect(source, port, of, 'object');
for (const output of ['name', 'number']) {
const log = onInside.addNode('Log');
onInside.connect(of, output, log, 'message');
onInside.connect(from, 'exec', log, 'exec');
from = log;
}
};
const owner = onInside.addNode('Owner');
const parent = onInside.addNode('Parent');
onInside.connect(owner, 'object', parent, 'object');
ask(owner, 'object');
ask(parent, 'parent');
ask(onInside.addNode('Self'), 'object');
return { ...f, coin, hud, inside };
}
describe('a graph changes render layers and what a camera sees during the run (#305)', () => {
const start = () => {
const s = layerSwitchScene();
const started = startWorld(s, { behaviors: true });
const press = (key: string) => {
started.input.press(key);
started.frame();
started.input.release(key);
};
const seenBy = (camera: { props: PropertyBag } | null) => {
const items = collectWorldItems(started.world).items.filter((item) => item.type === 'sprite');
const seen = items.filter((item) => camera && cameraSees(camera.props, item.renderLayer));
return seen.map((item) => (item.id === s.coin ? 'coin' : item.id === s.inside ? 'inside' : item.id));
};
const main = () => seenBy(cameraOfWorld(started.world));
const overlay = () => seenBy(overlaysOfWorld(started.world)[0]!);
started.frame();
return { ...s, ...started, press, main, overlay };
};
it('moves the coin into the overlay with Set, and back with Inherit', () => {
const r = start();
expect([r.main(), r.overlay()]).toEqual([['coin', 'inside'], []]);
r.press('u');
expect([r.main(), r.overlay()]).toEqual([['inside'], ['coin']]);
r.press('i');
expect([r.main(), r.overlay()]).toEqual([['coin', 'inside'], []]);
});
it('takes the HUD container’s sprite along when the container changes', () => {
const r = start();
r.press('h');
expect([r.main(), r.overlay()]).toEqual([['coin'], ['inside']]);
});
it('lets the main camera see a second layer once its Sees is set', () => {
const r = start();
r.press('h');
r.press('s');
expect(r.main()).toEqual(['coin', 'inside']);
});
it('names the layer that holds, the inherited one too, and none for what is not drawn', () => {
const r = start();
r.press('l');
expect(r.world.log).toEqual(['Default', '0', 'Default', '0', '', '-1']);
r.press('h');
r.press('l');
expect(r.world.log.slice(6)).toEqual(['UI', '1', 'UI', '1', '', '-1']);
});
});
+50 -1
View File
@@ -4,7 +4,8 @@
* a green sprite of the overlay with z -10 covers a red one of the main camera with z 1000, and * a green sprite of the overlay with z -10 covers a red one of the main camera with z 1000, and
* what a camera does not see stays away – a yellow `UI` sprite in front of the main camera, a blue * what a camera does not see stays away – a yellow `UI` sprite in front of the main camera, a blue
* `Default` one in front of the overlay. Switched to no overlay, the second camera draws nothing. * `Default` one in front of the overlay. Switched to no overlay, the second camera draws nothing.
* `Is hovered` and `Mouse position` find the mouse through the overlay camera too (#304). * `Is hovered` and `Mouse position` find the mouse through the overlay camera too (#304), and a
* graph moves a sprite onto another layer while the game runs (#305).
* Then the main camera wears a shader over its picture (#299), in the Game tab and, switched on in * Then the main camera wears a shader over its picture (#299), in the Game tab and, switched on in
* the eye, in the scene view. * the eye, in the scene view.
* *
@@ -187,6 +188,54 @@ export async function renderLayers(t) {
JSON.stringify({ hovered, overlayX }), JSON.stringify({ hovered, overlayX }),
); );
// 63c3. (#305) A graph moves the red square A onto `UI` with a Properties node once the game runs:
// the main camera no longer draws it, and `Render layer of` names its new layer.
const switchGraph = {
nodes: [
node('rl63-start', 'OnStart', 0),
node('rl63-a-find', 'ObjectByName', 0, { name: 'A 63' }),
{
...node('rl63-set', 'Properties', 200, { objectType: 'sprite', renderLayer: 1 }),
ports: [{ key: 'renderLayer', kind: 'number', write: true }],
},
node('rl63-of', 'RenderLayerOf', 200),
node('rl63-name', 'Log', 400),
node('rl63-number', 'Log', 600),
],
edges: [
edge('rl63-s1', 'rl63-start', 'exec', 'rl63-set', 'exec'),
edge('rl63-s2', 'rl63-set', 'exec', 'rl63-name', 'exec'),
edge('rl63-s3', 'rl63-name', 'exec', 'rl63-number', 'exec'),
edge('rl63-s4', 'rl63-a-find', 'object', 'rl63-set', 'object'),
edge('rl63-s5', 'rl63-a-find', 'object', 'rl63-of', 'object'),
edge('rl63-s6', 'rl63-of', 'name', 'rl63-name', 'message'),
edge('rl63-s7', 'rl63-of', 'number', 'rl63-number', 'message'),
],
};
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({ id: 'rl63-switch', type: 'behavior', name: 'Switch 63', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(switchGraph)} });
${MAKE_OBJECT}
scene.objects.push(make('rl63-switcher', 'rl63-switch', '', 0, 'Switch 63'));
`);
await play();
await sleep(400);
const switched = await picture();
const named = (await json('__gm.world()'))?.log.slice(-2) ?? [];
await stop();
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'rl63-switcher');
p.assets = p.assets.filter((a) => a.id !== 'rl63-switch');
`);
check(
'a graph moves a sprite onto UI while the game runs, and Render layer of names it',
!switched.main.includes('rl63-a') && !is(switched.right, RED) && JSON.stringify(named) === '["UI","1"]',
JSON.stringify({ switched, named }),
);
// 63d. The project settings list the render layers, apart from the collision layers. // 63d. The project settings list the render layers, apart from the collision layers.
await t.clickToolbar('menu'); await t.clickToolbar('menu');
await clickText('Project settings…'); await clickText('Project settings…');