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 <noreply@anthropic.com>
This commit is contained in:
1 parent
70f3608733
commit
d9c6bbfa4d
12 files changed
+250
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -402,8 +402,13 @@ const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = 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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -609,6 +609,28 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ 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',
|
||||
|
||||
@@ -244,6 +244,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
raycast: () => null,
|
||||
raycastAll: () => [],
|
||||
collisionLayerOf: () => null,
|
||||
renderLayerOf: () => null,
|
||||
ignoreOneWay: () => {},
|
||||
isTilemap: () => false,
|
||||
tileAt: () => null,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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…');
|
||||
|
||||
Reference in new issue
Block a user