Files
feuerwerk/tools/e2e/63-render-layers.mjs
T
adminandClaude Opus 5.5 92395f6773
game-maker / gate (push) Successful in 3m7s
game-maker / suite (push) Successful in 15m54s
Find the render layer log pair in e2e 63c3 instead of the tail (#305)
The suite went red three times in a row (tasks 488, 491, 492) on one
check only: 63c3 read `named: ["",""]` while the picture half passed.
Alone the module was green. In the full run objects left by earlier
modules are still in the scene, and their behaviors log two empty
lines after the OnStart of the check. `log.slice(-2)` took those.

- The check now searches the log for the pair "UI", "1". Both lines
  come from one exec chain, so they stand next to each other.
- Without the pair it shows the last 6 lines for diagnosis.
- Not found out: which earlier module writes the empty lines.

Tests:
- e2e 63 on melektaus-atx with a temporary behavior that logs two
  empty lines after the switch: ALL PASSED, 13 checks.
- The same with the old `slice(-2)`: 1 FAILED, `named: ["",""]`,
  the picture from CI. The disturbing behavior is removed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:39:59 +02:00

473 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();
// Searched, not taken from the end: in the full suite behaviors left by earlier modules log
// after this OnStart. Its two lines come from one exec chain, so they stand together.
const log = (await json('__gm.world()'))?.log ?? [];
const at = log.findIndex((line, i) => line === 'UI' && log[i + 1] === '1');
const named = at < 0 ? log.slice(-6) : log.slice(at, at + 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)});
`);
}