Files
feuerwerk/tools/e2e/63-render-layers.mjs
T
adminandClaude Opus 5.5 d9c6bbfa4d
game-maker / gate (push) Successful in 3m14s
game-maker / suite (push) Failing after 14m18s
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>
2026-10-03 19:25:47 +02:00

469 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Render layers and overlay cameras (#302). The main camera sees only `Default`, an overlay camera
* 2000 units to the right sees only `UI`. Each draws its own picture, the overlay's laid on top:
* 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), 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.
*
* Everything it adds carries an `rl63` id and goes again at the end.
*/
import { MAKE_OBJECT } from './lib/scaffold.mjs';
const OVERLAY = 'rl63-cam';
export async function renderLayers(t) {
const { check, json, evaluate, mutate, sleep, shot, showTab, play, stop, clickText, press } = t;
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const main = scene.objects.find((o) => o.id === 'o3');
window.__rl63 = { layers: p.renderLayers, overrides: structuredClone(main.overrides) };
p.renderLayers = ['Default', 'UI'];
main.overrides = { ...main.overrides, sees: 0b01 };
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
const square = (id, name, dx, z, size, color, layer, from) => {
const o = make(id, sprite.id, '', 0, name);
Object.assign(o.position, { x: from.x + dx, y: from.y, z });
o.overrides = { width: size, height: size, color, texture: '', shader: '', renderLayer: layer };
return o;
};
const overlay = structuredClone(main);
Object.assign(overlay, { id: '${OVERLAY}', name: 'Overlay 63', instanceEdits: {} });
overlay.position = { ...main.position, x: main.position.x + 2000 };
overlay.overrides = { ...main.overrides, overlay: true, sees: 0b10 };
scene.objects.push(
overlay,
square('rl63-a', 'A 63', 70, 1000, 60, 0xff0000, -1, main.position),
square('rl63-d', 'D 63', -70, 1000, 60, 0xffff00, 1, main.position),
square('rl63-b', 'B 63', 0, -10, 40, 0x00ff00, 1, overlay.position),
square('rl63-c', 'C 63', 70, 1000, 60, 0x0000ff, 0, overlay.position),
);
`);
await evaluate('__gm.state.select(null)');
await showTab('game');
await sleep(300);
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const is = (hex, [r, g, b]) => {
const [pr, pg, pb] = channels(hex);
return Math.abs(pr - r) < 30 && Math.abs(pg - g) < 30 && Math.abs(pb - b) < 30;
};
/** The three points of the picture the squares sit on: the centre and 70 units either side. */
const picture = async () => {
const { frame, scale } = await json('__gm.gameScale()');
const cx = Math.round(frame.x + frame.w / 2);
const cy = Math.round(frame.y + frame.h / 2);
const at = (dx) => json(`__gm.pixelAt(${Math.round(cx + dx * scale)}, ${cy})`);
return {
centre: await at(0),
right: await at(70),
left: await at(-70),
main: await json('__gm.gameDrawOrder()'),
overlays: await json('__gm.gameOverlays()'),
};
};
const GREEN = [0, 255, 0];
const RED = [255, 0, 0];
const YELLOW = [255, 255, 0];
const stacked = (seen) =>
seen.overlays.length === 1 &&
seen.overlays[0].camera === OVERLAY &&
seen.overlays[0].drawOrder.includes('rl63-b') &&
!seen.overlays[0].drawOrder.includes('rl63-c') &&
seen.main.includes('rl63-a') &&
!seen.main.includes('rl63-d') &&
!seen.main.includes('rl63-b') &&
is(seen.centre, GREEN) &&
is(seen.right, RED) &&
!is(seen.left, YELLOW);
// 63a. The preview of the Game tab: each camera draws what it sees, the overlay on top.
const preview = await picture();
await shot('63-overlay-preview');
check(
'each camera draws only its layers, and the overlay lies on top whatever the z',
stacked(preview),
JSON.stringify(preview),
);
// 63b. Without Overlay the second camera is a plain one after the first: it draws nothing.
await mutate(`
const cam = __gm.state.activeScene.objects.find((o) => o.id === '${OVERLAY}');
cam.overrides = { ...cam.overrides, overlay: false };
`);
await sleep(200);
const plain = await picture();
check(
'two plain cameras switched on draw through the first one only',
plain.overlays.length === 0 && !is(plain.centre, GREEN) && is(plain.right, RED),
JSON.stringify(plain),
);
await mutate(`
const cam = __gm.state.activeScene.objects.find((o) => o.id === '${OVERLAY}');
cam.overrides = { ...cam.overrides, overlay: true };
`);
// 63c. The running game stacks them the same way.
await play();
await sleep(400);
const running = await picture();
check(
'the running game lays the overlay camera on top the same way',
stacked(running),
JSON.stringify(running),
);
await stop();
// 63c2. (#304) The mouse through the overlay camera, zoomed 2x: 30 units right of the centre of
// the picture it lies 15 units right of the overlay's centre, on the 40 wide square B. Through
// the main camera nothing is there. Logged each tick: hovered through it, without it, its x.
const node = (id, type, x, params = {}) => ({ id, type, x, y: 0, params });
const edge = (id, from, fromPort, to, toPort) => ({
id,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
});
const hoverGraph = {
nodes: [
node('rl63-tick', 'OnUpdate', 0),
node('rl63-b-find', 'ObjectByName', 0, { name: 'B 63' }),
node('rl63-cam-find', 'ObjectByName', 0, { name: 'Overlay 63' }),
node('rl63-through', 'IsHovered', 200),
node('rl63-plain', 'IsHovered', 200),
node('rl63-mouse', 'MousePosition', 200),
node('rl63-log1', 'Log', 400),
node('rl63-log2', 'Log', 600),
node('rl63-log3', 'Log', 800),
],
edges: [
edge('rl63-e1', 'rl63-tick', 'exec', 'rl63-log1', 'exec'),
edge('rl63-e2', 'rl63-log1', 'exec', 'rl63-log2', 'exec'),
edge('rl63-e3', 'rl63-log2', 'exec', 'rl63-log3', 'exec'),
edge('rl63-e4', 'rl63-b-find', 'object', 'rl63-through', 'object'),
edge('rl63-e5', 'rl63-cam-find', 'object', 'rl63-through', 'camera'),
edge('rl63-e6', 'rl63-b-find', 'object', 'rl63-plain', 'object'),
edge('rl63-e7', 'rl63-cam-find', 'object', 'rl63-mouse', 'camera'),
edge('rl63-e8', 'rl63-through', 'result', 'rl63-log1', 'message'),
edge('rl63-e9', 'rl63-plain', 'result', 'rl63-log2', 'message'),
edge('rl63-e10', 'rl63-mouse', 'x', 'rl63-log3', 'message'),
],
};
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({ id: 'rl63-hover', type: 'behavior', name: 'Hover 63', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(hoverGraph)} });
${MAKE_OBJECT}
scene.objects.push(make('rl63-logic', 'rl63-hover', '', 0, 'Hover 63'));
const cam = scene.objects.find((o) => o.id === '${OVERLAY}');
window.__rl63.overlay = structuredClone(cam.overrides);
cam.overrides = { ...cam.overrides, zoom: 2 };
`);
await play();
await sleep(300);
const game = await json('__gm.gameScale()');
const centre = { x: game.frame.x + game.frame.w / 2, y: game.frame.y + game.frame.h / 2 };
await t.mouse('mouseMoved', centre.x + 30 * game.scale, centre.y);
await sleep(300);
const ran = await json('__gm.world()');
const hovered = ran?.log.slice(-3) ?? [];
const overlayX = ran?.entities.find((e) => e.objectId === OVERLAY)?.worldX;
await stop();
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'rl63-logic');
p.assets = p.assets.filter((a) => a.id !== 'rl63-hover');
scene.objects.find((o) => o.id === '${OVERLAY}').overrides = window.__rl63.overlay;
`);
check(
'Is hovered through the overlay camera finds what only it shows, through the main one not',
hovered[0] === 'true' && hovered[1] === 'false' && Math.abs(Number(hovered[2]) - (overlayX + 15)) < 1,
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…');
const opened = await json('__gm.renderLayers()');
await clickText('Add render layer');
const added = await json('__gm.renderLayers()');
const collision = await json('__gm.collisionLayers()');
await press('Escape');
await sleep(150);
check(
'the project settings list the render layers and add one at the end',
JSON.stringify(opened.dialog) === JSON.stringify(['Default', 'UI']) &&
JSON.stringify(added.dialog) === JSON.stringify(['Default', 'UI', 'Layer 2']) &&
!collision.dialog.includes('UI'),
JSON.stringify({ opened, added, collision }),
);
// 63e. (#299) The main camera wears a shader over its whole picture. One that changes nothing
// draws the picture byte for byte as before, the right way up: a blue square 100 units above
// the centre stays above it. At 100 % so the picture's pixels sit on the screen's.
await showTab('game');
const scaleBefore = await json('__gm.state.gameScale');
await mutate(`
${MAKE_OBJECT}
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const main = scene.objects.find((o) => o.id === 'o3');
const sprite = p.assets.find((a) => a.type === 'sprite');
const o = make('rl63-e', sprite.id, '', 0, 'E 63');
Object.assign(o.position, { x: main.position.x, y: main.position.y - 100, z: 0 });
o.overrides = { width: 30, height: 30, color: 0x3366ff, texture: '', shader: '', renderLayer: 0 };
scene.objects.push(o);
__gm.state.setGameScale(1);
`);
const shaderOf = (id, nodes, edges) => ({
id,
type: 'shader',
name: id,
props: {},
graph: { nodes, edges },
});
const link = (from, out, to, port) => ({ from: { node: from, port: out }, to: { node: to, port } });
const PASS = shaderOf('rl63-pass', [{ id: 'out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], []);
const FLAT_RED = shaderOf(
'rl63-red',
[
{ id: 'red', type: 'ShaderColor', x: 0, y: 0, params: { value: 0xff0000 } },
{ id: 'one', type: 'ShaderNumber', x: 0, y: 80, params: { value: 1 } },
{ id: 'out', type: 'ShaderOutput', x: 200, y: 0, params: {} },
],
[link('red', 'color', 'out', 'color'), link('one', 'value', 'out', 'alpha')],
);
// Red is the width, green the height, both in thousandths: 640 × 360 comes out as 163 / 92.
const SIZE = shaderOf(
'rl63-size',
[
{ id: 'size', type: 'ShaderTextureSize', x: 0, y: 0, params: {} },
{ id: 'red', type: 'ShaderColor', x: 0, y: 80, params: { value: 0xff0000 } },
{ id: 'green', type: 'ShaderColor', x: 0, y: 160, params: { value: 0x00ff00 } },
{ id: 'milli', type: 'ShaderNumber', x: 0, y: 240, params: { value: 0.001 } },
{ id: 'one', type: 'ShaderNumber', x: 0, y: 320, params: { value: 1 } },
{ id: 'w', type: 'ShaderMultiply', x: 200, y: 0, params: {} },
{ id: 'h', type: 'ShaderMultiply', x: 200, y: 160, params: {} },
{ id: 'sum', type: 'ShaderAdd', x: 400, y: 0, params: {} },
{ id: 'scaled', type: 'ShaderMultiply', x: 600, y: 0, params: {} },
{ id: 'out', type: 'ShaderOutput', x: 800, y: 0, params: {} },
],
[
link('red', 'color', 'w', 'a'),
link('size', 'width', 'w', 'b'),
link('green', 'color', 'h', 'a'),
link('size', 'height', 'h', 'b'),
link('w', 'result', 'sum', 'a'),
link('h', 'result', 'sum', 'b'),
link('sum', 'result', 'scaled', 'a'),
link('milli', 'value', 'scaled', 'b'),
link('scaled', 'result', 'out', 'color'),
link('one', 'value', 'out', 'alpha'),
],
);
/** Puts `shader` on the main camera (null takes it off), with `resolution` as its row. */
const wear = (shader, resolution = 'screen') =>
mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('rl63-'));
const shader = ${JSON.stringify(shader)};
if (shader) p.assets.push(shader);
const main = __gm.state.activeScene.objects.find((o) => o.id === 'o3');
main.overrides = { ...main.overrides, shader: shader ? shader.id : '', shaderResolution: '${resolution}' };
`);
/** Two rows of the picture, through the centre and through the blue square, and the square's spot. */
const rows = async () => {
const { frame, scale } = await json('__gm.gameScale()');
const cx = Math.round(frame.x + frame.w / 2);
const cy = Math.round(frame.y + frame.h / 2);
const row = (dy) =>
json(`__gm.pixelRow(${Math.round(frame.x)}, ${Math.round(cy + dy * scale)}, ${Math.round(frame.w)})`);
return {
centre: await row(0),
above: await row(-100),
blueAbove: await json(`__gm.pixelAt(${cx}, ${Math.round(cy - 100 * scale)})`),
blueBelow: await json(`__gm.pixelAt(${cx}, ${Math.round(cy + 100 * scale)})`),
pixels: await json('__gm.cameraShaderPixels()'),
};
};
const same = (a, b) => JSON.stringify([a.centre, a.above]) === JSON.stringify([b.centre, b.above]);
const unshaded = await rows();
await wear(PASS);
const handedThrough = await rows();
const differing = handedThrough.above.filter((hex, i) => hex !== unshaded.above[i]).length;
check(
'a camera shader that changes nothing draws the game picture byte for byte, the right way up',
unshaded.pixels === null &&
handedThrough.pixels !== null &&
is(unshaded.blueAbove, [0x33, 0x66, 0xff]) &&
!is(unshaded.blueBelow, [0x33, 0x66, 0xff]) &&
same(handedThrough, unshaded),
JSON.stringify({ differing, pixels: handedThrough.pixels, blue: handedThrough.blueAbove }),
);
// 63f. A flat red fills the main camera's whole picture, corners included; the overlay camera's
// UI square lies on top of it in its own green (the proof moved here from #302).
await wear(FLAT_RED);
const { frame } = await json('__gm.gameScale()');
const corners = await json(
`__gm.pixels(${JSON.stringify([
{ x: frame.x + 2, y: frame.y + 2 },
{ x: frame.x + frame.w - 3, y: frame.y + 2 },
{ x: frame.x + 2, y: frame.y + frame.h - 3 },
{ x: frame.x + frame.w - 3, y: frame.y + frame.h - 3 },
])})`,
);
const reddened = await picture();
await shot('63-camera-shader-red');
check(
'a flat red fills the whole game picture, and the overlay camera’s UI keeps its colour',
corners.every((hex) => is(hex, RED)) && is(reddened.left, RED) && is(reddened.centre, GREEN),
JSON.stringify({ corners, reddened }),
);
// 63g. `Texture size` is the camera's raster in either resolution – a pixel counts as a camera
// pixel (the user's choice, #299) – while Screen pixels draws the picture with as many pixels
// as it covers on screen. Fitted, so that is not 640 by chance.
await mutate(`__gm.state.setGameScale('fit')`);
const measured = {};
for (const resolution of ['screen', 'camera']) {
await wear(SIZE, resolution);
measured[resolution] = {
colour: (await picture()).left,
pixels: await json('__gm.cameraShaderPixels()'),
};
}
const fitted = await json('__gm.gameScale()');
const ratio = (await json('__gm.pixelGrid()')).resolution;
// Tight: counted in screen pixels the fitted 670 would come out as 171 / 96, close to the real thing.
const exactly = (hex, rgb) => channels(hex).every((v, i) => Math.abs(v - rgb[i]) <= 2);
check(
'Texture size is View width × View height in both resolutions, and only Camera pixels draws 640 × 360',
['screen', 'camera'].every((r) => exactly(measured[r].colour, [163, 92, 0])) &&
measured.camera.pixels?.w === 640 &&
measured.camera.pixels?.h === 360 &&
measured.screen.pixels?.w === Math.round(fitted.frame.w * ratio) &&
measured.screen.pixels?.w !== 640,
JSON.stringify({ measured, frame: fitted.frame, ratio }),
);
// 63h. Taken off again, the camera draws as before and lays no picture over its view.
await mutate(`__gm.state.setGameScale(1)`);
await wear(null);
const back = await rows();
check(
'a camera without a shader draws as it did before',
back.pixels === null && same(back, unshaded),
JSON.stringify({ pixels: back.pixels }),
);
// 63i. The scene view lays the shader over the main camera's frame only while the eye's Shader
// switch is on – a real click on it – and never past the frame.
await wear(FLAT_RED);
await showTab('scene');
await evaluate(`(() => {
const main = __gm.state.activeScene.objects.find((o) => o.id === 'o3');
__gm.lookAt(main.position.x, main.position.y);
})()`);
await sleep(150);
const sceneSpots = async () => {
const r = await json('__gm.objectRect("o3")');
const [inside, outside] = await json(
`__gm.pixels(${JSON.stringify([
{ x: Math.round(r.x - r.w * 0.3), y: Math.round(r.y - r.h * 0.3) },
{ x: Math.round(r.x - r.w * 0.7), y: Math.round(r.y - r.h * 0.3) },
])})`,
);
return { inside, outside, pixels: await json(`__gm.cameraShaderPixels('scene')`) };
};
const switchedOff = await sceneSpots();
const eye = await json(`__gm.sceneHeaderAt('visibility')`);
await t.click(eye.x, eye.y);
const shaderSwitch = (await json('__gm.visibilityPopup()')).find(
(e) => e.type === 'camera' && e.part === 'shader',
);
await t.click(shaderSwitch.x, shaderSwitch.y);
await press('Escape');
await sleep(150);
const switchedOn = await sceneSpots();
await shot('63-camera-shader-scene');
check(
'the scene view shows the camera shader over the frame only while the eye switch is on',
shaderSwitch.on === false &&
switchedOff.pixels === null &&
!is(switchedOff.inside, RED) &&
switchedOn.pixels !== null &&
is(switchedOn.inside, RED) &&
!is(switchedOn.outside, RED),
JSON.stringify({ switchedOff, switchedOn, shaderSwitch }),
);
// Clean up.
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('rl63-'));
scene.objects.find((o) => o.id === 'o3').overrides = window.__rl63.overrides;
p.renderLayers = window.__rl63.layers;
p.assets = p.assets.filter((a) => !a.id.startsWith('rl63-'));
__gm.state.setSceneVisibility('camera', 'shader', false);
__gm.state.setGameScale(${JSON.stringify(scaleBefore)});
`);
}