From d9c6bbfa4d96eeca01154cdcdc7fec9edb9ee542 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 19:25:47 +0200 Subject: [PATCH] Add a node that names the render layer of an object (#305) 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 --- README.md | 2 +- src/core/graph/NodeContext.ts | 5 + src/core/graph/lookup.ts | 7 +- src/core/graph/nodes/data/RenderLayerOf.ts | 22 ++++ src/core/graph/nodes/index.ts | 2 + src/core/model/graphLookup.ts | 1 + src/core/properties/PropertySchema.ts | 7 +- src/runtime/systems/behaviorContext.ts | 11 ++ tests/core/node-catalog.test.ts | 22 ++++ tests/helpers.ts | 1 + tests/viewport/render-layers.test.ts | 127 ++++++++++++++++++++- tools/e2e/63-render-layers.mjs | 51 ++++++++- 12 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 src/core/graph/nodes/data/RenderLayerOf.ts diff --git a/README.md b/README.md index f8909882..2f23bb93 100644 --- a/README.md +++ b/README.md @@ -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: ```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 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/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 73032566..1f818610 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -205,6 +205,11 @@ export interface NodeContext { * other object's that of its first collider child; null for an object without a collider. */ 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). */ ignoreOneWay(target: EntityHandle, seconds: number): void; /** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */ diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index 31e5f218..65347e16 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -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 * 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 - * (#233) and what the collision layers are called (#211). One handle, so no caller can resolve a - * node with half of it. + * (#233) and what the collision and render layers are called (#211, #305). One handle, so no caller + * can resolve a node with half of it. */ export interface GraphLookup { behavior(assetId: string): BehaviorProgram | null; @@ -62,6 +62,8 @@ export interface GraphLookup { animation(assetId: string): AnimationClip | null; /** The project's collision layers by number, as `Layer of` names them (#211). */ 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. */ ownerOf(graph: Graph): string; /** 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, animation: () => null, collisionLayers: () => [], + renderLayers: () => [], ownerOf: () => '', variableTyping: () => undefined, }; diff --git a/src/core/graph/nodes/data/RenderLayerOf.ts b/src/core/graph/nodes/data/RenderLayerOf.ts new file mode 100644 index 00000000..0cf9a59b --- /dev/null +++ b/src/core/graph/nodes/data/RenderLayerOf.ts @@ -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 }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 1147aaae..b1d5be87 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -88,6 +88,7 @@ import { PackStruct } from './data/PackStruct'; import { Properties } from './data/Properties'; import { Parent } from './data/Parent'; import { Random } from './data/Random'; +import { RenderLayerOf } from './data/RenderLayerOf'; import { Round } from './data/Round'; import { Select } from './data/Select'; import { Self } from './data/Self'; @@ -261,6 +262,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ IsHovered, IsTouching, LayerOf, + RenderLayerOf, Move, MoveToward, SetPosition, diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index e9945e4f..0af9ac05 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -60,6 +60,7 @@ export function graphLookup(project: Project): GraphLookup { return asset ? clipOf(asset) : null; }, collisionLayers: () => project.collisionLayers, + renderLayers: () => project.renderLayers, ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '', variableTyping: (assetId, key) => { const owner = findAsset(project, assetId); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 191e088c..74e1b62f 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -402,8 +402,13 @@ const RENDER_LAYER_TYPES: ReadonlySet = new Set([ '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 { - return RENDER_LAYER_TYPES.has(type) ? [RENDER_LAYER_ROW] : []; + return hasRenderLayer(type) ? [RENDER_LAYER_ROW] : []; } const COMMON_KEYS = new Set( diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 660696df..1e2be81d 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -10,7 +10,9 @@ import type { Vec2 } from '../../core/model/Properties'; import { JOINT_CONNECTED_KEY } from '../../core/model/joint'; import { objectReference } from '../../core/model/objectRefs'; import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project'; +import { renderLayerAlong } from '../../core/model/renderLayers'; import { soundFileOf } from '../../core/model/sound'; +import { hasRenderLayer } from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import { loopsOf, volumeOf } from '../../core/properties/sounds'; import type { Entity } from '../Entity'; @@ -128,6 +130,15 @@ export function behaviorContext( const holder = found && layerHolderOf(world, found); 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) => { const found = resolve(target); if (found) world.ignoreOneWay(found, seconds); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index d586dbfd..f1f0362c 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -609,6 +609,28 @@ const CASES: Record = { }, { 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: [ { name: 'every object so named, in scene order', diff --git a/tests/helpers.ts b/tests/helpers.ts index ed45b59f..05a62671 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -244,6 +244,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { raycast: () => null, raycastAll: () => [], collisionLayerOf: () => null, + renderLayerOf: () => null, ignoreOneWay: () => {}, isTilemap: () => false, tileAt: () => null, diff --git a/tests/viewport/render-layers.test.ts b/tests/viewport/render-layers.test.ts index 7b5985a8..0f434b3f 100644 --- a/tests/viewport/render-layers.test.ts +++ b/tests/viewport/render-layers.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; 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 { cameraSees, INHERIT_LAYER, @@ -13,10 +16,15 @@ import { } from '../../src/core/model/renderLayers'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; 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 { cameraView, overlayView } from '../../src/viewport/view'; -import { makeSpriteScene, place, startWorld } from '../helpers'; +import { type GraphEditor, graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; function sceneWithCamera() { 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']); + }); +}); diff --git a/tools/e2e/63-render-layers.mjs b/tools/e2e/63-render-layers.mjs index d13e657a..d249a7bb 100644 --- a/tools/e2e/63-render-layers.mjs +++ b/tools/e2e/63-render-layers.mjs @@ -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 * 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. - * `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 * the eye, in the scene view. * @@ -187,6 +188,54 @@ export async function renderLayers(t) { 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. await t.clickToolbar('menu'); await clickText('Project settings…');