Files
feuerwerk/tools/e2e.mjs
T
rootandClaude Opus 5 76aa441dce Enabled switch, text assets, mouse input and the jump-and-run example game
Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
  override on the scene object). A switched-off object is not drawn, does not
  collide and its behavior does not run - and neither does anything parented
  below it. Disabled objects are now hidden in the scene view as well, so the
  editor shows what the game shows; they stay in the hierarchy panel, which is
  where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
  `Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
  Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.

Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:52:10 +02:00

768 lines
52 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.
#!/usr/bin/env node
/**
* Headless end-to-end test driven over the Chrome DevTools Protocol.
* Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs [projectId] [baseUrl]
* Writes screenshots to /tmp/gm-e2e-*.png and prints PASS/FAIL checks. No dependencies.
*
* Runs against the dedicated fixture project (the demo project is refused) and mutates it:
* node tools/seed-e2e.mjs && node tools/e2e.mjs
* The scene it expects is produced by tools/seed-e2e.mjs.
*/
import { runChecks } from './harness.mjs';
import { PROJECTS, cliArgs } from './projects.mjs';
const { projectId, baseUrl } = cliArgs('e2e.mjs', PROJECTS.fixture);
await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async (t) => {
const {
cdp, consoleLog, check, sleep,
evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson,
} = t;
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(4000);
await shot('01-loaded');
check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
// 1. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = JSON.parse(await stateJson());
check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
await shot('02-object-selected');
// 2. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);
await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 }); // Ctrl+A in the bridge input
await typeText('Renamed crate');
await shot('03-typing');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
s = JSON.parse(await stateJson());
const nameNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`);
check('typing + Enter renamed the object', nameNow === 'Renamed crate', JSON.stringify(nameNow));
await shot('04-renamed');
// 3. Scrub the X field (right of the "X" label) by 100 px.
const xLabel = await locate('X');
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
const xNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
await shot('05-scrubbed');
// 3b. Type into X, then click straight into the empty viewport: the typed value must be
// committed (not reverted) even though the click deselects and rebuilds the inspector.
const center = await evaluate('__gm.viewportCenter()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
await typeText('300');
await click(center.x, center.y + 250);
const xTyped = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
s = JSON.parse(await stateJson());
check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`);
// 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport widget bounds.
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
const o1Now = await evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === 'o1').position)`);
check('viewport drag moved the red crate', JSON.parse(o1Now).x > -150, o1Now);
await shot('06-dragged');
// 5. Autosave → server.
await sleep(1800);
const saved = await api(`projects/${projectId}`);
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
check('rename persisted', renamed.name === 'Renamed crate', renamed.name);
check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position));
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
check('save status is saved', (JSON.parse(await stateJson())).save === 'saved');
// 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) {
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(80);
}
const afterUndo = JSON.parse(await evaluate(`JSON.stringify(__gm.state.activeScene.objects.map(o => ({ id: o.id, name: o.name, x: o.position.x })))`));
check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo));
await keyPress('z', 'KeyZ', { modifiers: 10, windowsVirtualKeyCode: 90 }); // Ctrl+Shift+Z
const afterRedo = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`);
check('redo restored the rename', afterRedo === 'Renamed crate', afterRedo);
await shot('07-undo-redo');
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
// origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate:
// holding ArrowRight moves it in the world (Axis input × speed × dt), Esc stops without
// touching the model.
await clickText('Renamed crate');
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
await clickText('▶ Play');
await sleep(300);
s = JSON.parse(await stateJson());
check('play mode entered', s.mode === 'play', JSON.stringify(s));
let info = await evaluate('JSON.stringify(__gm.gameInfo())');
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
await shot('08-playing');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(400);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
const worldAfterRight = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
const movedCrate = worldAfterRight.entities.find((e) => e.objectId === 'o1');
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await sleep(250);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1');
check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
s = JSON.parse(await stateJson());
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
await shot('09-stopped');
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`);
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
await clickText('▶ Play');
await sleep(300);
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 7);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`);
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`);
await sleep(100);
let liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2');
check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
await shot('08b-editing-while-playing');
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(150);
const liveIds = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.map((e) => e.objectId);
check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2');
check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
// 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object
// shows up in the runtime scene (hierarchy), in the world and in the Game view at once;
// Stop discards it with the rest of the runtime copy.
const visibleBeforeSpawn = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo())')).visibleItems;
await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 });
await sleep(200);
const spawnedObjects = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)'));
const spawned = spawnedObjects.find((o) => !['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'].includes(o.id));
check('Spawn node adds a crate object to the runtime scene at (0, 150)', spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, JSON.stringify(spawned));
const spawnWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyOrder().length')) === 8);
info = await evaluate('JSON.stringify(__gm.gameInfo())');
check('the Game view renders the spawned crate', JSON.parse(info).visibleItems === visibleBeforeSpawn + 1, `${info} before=${visibleBeforeSpawn}`);
await shot('08c-spawned');
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
const o2AfterEdit = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await evaluate('__gm.state.activeScene.objects.length')) === 7, `x=${o2AfterEdit}`);
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
await clickText('▶ Play');
await sleep(300);
info = await evaluate('JSON.stringify(__gm.gameInfo())');
check('objects outside the camera frame are culled', JSON.parse(info).visibleItems === 1, info);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}; __gm.state.preview()`);
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(200);
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 6);
await clickText('▶ Play');
await sleep(300);
info = await evaluate('JSON.stringify(__gm.gameInfo())');
s = JSON.parse(await stateJson());
check('play without camera renders nothing', s.mode === 'play' && !JSON.parse(info).rendering && JSON.parse(info).visibleItems === 0, info);
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
await shot('10-play-no-camera');
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 7);
// 8. Context menu on the Crate asset → menu shows "Place in scene".
await clickText('Crate', 'right');
await sleep(200);
const menuVisible = await evaluate(`Boolean(__gm.locate('Place in scene'))`);
check('asset context menu opens', menuVisible);
await shot('11-context-menu');
await clickText('Place in scene');
await sleep(200);
const count = await evaluate('__gm.state.activeScene.objects.length');
check('placing from the menu adds an object', count === 8, `objects=${count}`);
// 9. Create a new asset through the + menu.
await clickText('+');
await sleep(150);
await clickText('Sprite');
await sleep(200);
const assets = await evaluate(`JSON.stringify(__gm.state.project.assets.map(a => a.name))`);
check('+ menu creates a sprite asset', JSON.parse(assets).includes('Sprite'), assets);
await shot('12-new-asset');
// 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// right on top of its icon → the icon must still be drawn above the sprite.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0];
objs.unshift(cam);
const crate = objs.find(o => o.id === 'o1');
crate.position.x = 0; crate.position.y = 0;
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const layers = await evaluate(`JSON.stringify([__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')])`);
check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers);
await shot('13-symbol-on-top');
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
await evaluate(`(() => {
const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2');
o2.position.x = 0; o2.position.y = 0;
__gm.state.preview();
})()`);
await sleep(200);
const orderBefore = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`);
check('equal z keeps hierarchy order', orderBefore === '["o1","o2"]', orderBefore);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: __gm.state.activeScene.id, objectId: 'o1' })`);
await sleep(200);
const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
await typeText('5');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
const zNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`);
check('Z field sets position.z', zNow === 5, `z=${zNow}`);
const orderAfter = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`);
check('higher z is drawn on top', orderAfter === '["o2","o1"]', orderAfter);
await shot('14-z-order');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
const zUndone = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`);
check('undo restores z', zUndone === 0, `z=${zUndone}`);
// 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and
// color at their defaults → exactly one reset button is visible. Clicking it puts
// height back to 100 and hides the button; undo brings 60 back.
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'crate' })`);
await sleep(200);
const resetButton = await evaluate(`__gm.locate('↺')`);
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
await click(resetButton.x, resetButton.y);
await sleep(200);
const heightReset = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`);
check('reset puts the asset property back to its default', heightReset === 100, `height=${heightReset}`);
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
await shot('15-asset-reset');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
const heightUndone = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`);
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
// 13. Behavior graph editor. Selecting the Mover asset binds the graph; the Graph tab shows it.
await clickText('Mover');
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
await shot('16-graph');
const nodeOf = (id) => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === ${JSON.stringify(id)}))`).then(JSON.parse);
const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse);
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
const mulBefore = await nodeOf('n-axis');
const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-axis'))`));
await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40);
s = JSON.parse(await stateJson());
const mulAfter = await nodeOf('n-axis');
check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`);
check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
// The Move node's unconnected `scale` shows its port default (1) once its edge is removed;
// with the edge present the inspector hides it. Port defaults are covered by unit tests.
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const mulUndone = await nodeOf('n-axis');
check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`);
// 13a. Regression: typing a query and then *clicking* a row must add exactly one node.
// (Previously the search field's blur-commit picked the first match and the row click
// picked it again → two nodes.)
const gcenter = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.center())`));
const beforeClickPick = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await sleep(250);
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop');
await sleep(150);
const stopRow = await locate('Stop game'); // unique on screen (nothing else on screen shows this label)
await click(stopRow.x, stopRow.y);
await sleep(250);
const afterClickPick = await graphCounts();
check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick }));
check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`)));
// Undo so 13b finds the same empty spot and the expected graph.
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
// 13b. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await sleep(250);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('log');
await sleep(100);
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(250);
const after13 = await graphCounts();
s = JSON.parse(await stateJson());
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
await shot('17-node-added');
// 13c. Connect: drag from the upper Move node's exec output onto the Log node's exec input.
const fromPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt('n-move', 'exec', true))`));
const toPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false))`));
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
const connected = await graphCounts();
check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
// An exec output must not connect to a data input: dragging onto "message" does nothing.
const dataPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false))`));
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
await shot('18-connected');
// 13d. Delete removes the selected node together with its edge; undo brings both back.
await evaluate(`__gm.state.select({ kind: 'node', assetId: 'mover', nodeId: ${JSON.stringify(newNode.id)} })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(150);
const deleted = await graphCounts();
check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const restored = await graphCounts();
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
// 13e. Play always starts on the Game tab; switching to Graph while playing shows
// the live highlight: hold ArrowRight → the exec edge is hot.
const fpsEdit = await evaluate('__gm.fps()');
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
// Single evaluate: the readout refreshes twice a second, so read value and label together.
const fpsLabel = await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`);
check('fps label is visible in the toolbar', fpsLabel === true);
await clickText('▶ Play');
await sleep(200);
check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game');
await sleep(600);
const fpsPlay = await evaluate('__gm.fps()');
check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`);
await evaluate(`__gm.showTab('graph')`);
await sleep(100);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(300);
await shot('19-graph-live');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
// 13f. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it.
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
await sleep(150);
await clickText('+ Variable ▾');
await sleep(150);
await clickText('Number');
await sleep(200);
const vars = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
check('adding a variable declares it on the behavior', vars.join(',') === 'speed,var1', vars.join(','));
await shot('20-variable-added');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const varsUndone = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
check('undo removes the variable', varsUndone.join(',') === 'speed', varsUndone.join(','));
// 13g. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor.
await evaluate(`__gm.state.select(null)`);
await sleep(100);
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'cam' })`);
await sleep(100);
check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null);
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
await sleep(100);
check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover');
await evaluate(`__gm.showTab('scene')`);
// 14. Hierarchy / parenting. Give both crates unique names, then drag the blue crate's row
// onto the red crate's row: it becomes a child, keeps its world position (local becomes
// the difference) and is listed right below its parent.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const redRow = await locate('Red crate');
const blueRow = await locate('Blue crate');
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
await sleep(200);
const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`);
let o2 = JSON.parse(await objectJson('o2'));
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`));
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
const o1At = order.indexOf('o1');
check('children are listed directly below their parent', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order));
await shot('20-parented');
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
await clickText('Red crate');
const xField = await locate('X');
await click(xField.x + 160, xField.y);
await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 });
await typeText('-50');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
const worldOfO2 = await evaluate(`(() => { const s = __gm.state.activeScene; const child = s.objects.find(x => x.id === 'o2'); const parent = s.objects.find(x => x.id === child.parentId); return JSON.stringify({ x: child.position.x + parent.position.x, y: child.position.y + parent.position.y }); })()`);
check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && worldOfO2 === '{"x":220,"y":-80}', `${JSON.stringify(o2.position)} world=${worldOfO2}`);
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
await evaluate(`__gm.state.select(null)`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(300);
const playWorld = await evaluate('JSON.stringify(__gm.world())');
const e2 = JSON.parse(playWorld).entities.find((e) => e.objectId === 'o2');
check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2));
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo move
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo parent
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
// 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu:
// the world position is kept both ways.
await clickText('Blue crate');
await clickText('None ▾');
await sleep(150);
const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text
await click(menuEntry.x, menuEntry.y);
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2));
const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right');
await sleep(150);
await clickText('Unparent (move to top level)');
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
// 15. Colliders. Right-click the red crate → "Add collider": a collider asset sized like the
// crate (100×60) is placed as its child at the centre, selected and drawn in the overlay.
// The seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one
// is undone again afterwards to keep exactly one collider per body for 15a/15b.
const objectsBeforeCollider = await evaluate('__gm.state.activeScene.objects.length');
const redRow3 = await locate('Red crate');
await click(redRow3.x, redRow3.y, 'right');
await sleep(150);
await clickText('Add collider');
await sleep(200);
s = JSON.parse(await stateJson());
const colObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null;
const colAsset = colObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${colObj.assetId}'))`)) : null;
check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset }));
check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay');
check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`)));
await shot('21-collider');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
const colliderAssetGone = colAsset ? !(await evaluate(`__gm.state.project.assets.some(a => a.id === '${colAsset.id}')`)) : false;
check('undo removes the added collider object and its asset', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeCollider && colliderAssetGone);
// 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
// the crates overlapping (each carries its seeded collider): the log names the blue crate
// exactly once (enter, not stay), and colliders are not rendered in play mode.
await evaluate(`(() => {
const scene = __gm.state.activeScene;
const mover = __gm.state.project.assets.find(a => a.id === 'mover');
mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} });
mover.graph.edges.push(
{ id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } },
{ id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } });
scene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap
__gm.state.select(null);
__gm.state.preview();
})()`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(500);
const collisionWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
check('On collision enter fired once with the other body', collisionWorld && collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld?.log));
const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`);
info = await evaluate('JSON.stringify(__gm.gameInfo())');
check('colliders are not rendered in the game view', JSON.parse(info).visibleItems === spriteCount, `${info} sprites=${spriteCount}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
// Making the blue collider a trigger lets it pass through instead (drawn amber).
const holdRight = async (ms) => {
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(ms);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
};
const playAgainstBlue = async (trigger, speed = 0) => {
await evaluate(`(() => {
const scene = __gm.state.activeScene;
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'};
${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''}
__gm.state.select(null);
__gm.state.preview();
})()`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(300);
await holdRight(700);
const w = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
return w.entities.find((e) => e.objectId === 'o1');
};
const blocked = await playAgainstBlue(false);
check('Move with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider', blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`);
const passed = await playAgainstBlue(true);
check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`);
// A frame step far larger than the collider must not jump across it (the movement is split
// into sub-steps); speed is restored afterwards so later checks see the seeded value.
const fast = await playAgainstBlue(false, 40000);
check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`);
await evaluate(`(() => { __gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200; __gm.state.preview(); })()`);
await evaluate(`__gm.state.select({ kind: 'object', objectId: 'oc2' })`);
await sleep(200);
await shot('22-trigger-collider');
// 16. Multi-selection. Reset both crates to known spots, then Ctrl-click the second row in
// the hierarchy: both are selected, the inspector shows the multi view.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = '';
o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
objs.find(o => o.id === 'oc2').overrides = { width: 140 };
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const clickMod = async (x, y, modifiers) => {
await mouse('mouseMoved', x, y);
await mouse('mousePressed', x, y, 'left', { modifiers });
await mouse('mouseReleased', x, y, 'left', { modifiers });
await sleep(150);
};
await clickText('Red crate');
const blueRow3 = await locate('Blue crate');
await clickMod(blueRow3.x, blueRow3.y, 2); // Ctrl
s = JSON.parse(await stateJson());
check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1","o2"]' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
check('inspector shows the multi-selection', Boolean(await evaluate(`__gm.locate('2 OBJECTS SELECTED')`)));
await shot('23-multi-selected');
await clickMod(blueRow3.x, blueRow3.y, 2);
s = JSON.parse(await stateJson());
check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1"]', JSON.stringify(s.selection));
await clickMod(blueRow3.x, blueRow3.y, 2);
// 16a. Create container: both crates (with their colliders and the Mover) become the content
// of a new container asset; the scene gets one container object at the selection's
// centre (-15,-40), the crates hang below it with recomputed local positions, world
// positions are unchanged, and the container is selected.
const assetsBeforeContainer = await evaluate('__gm.state.project.assets.length');
const objectsBeforeContainer = await evaluate('__gm.state.activeScene.objects.length');
await clickText('Create container');
await sleep(250);
s = JSON.parse(await stateJson());
const containerObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null;
const containerAsset = containerObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerObj.assetId}'))`)) : null;
check('Create container adds one container asset and one placed container, selected', containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await evaluate('__gm.state.project.assets.length')) === assetsBeforeContainer + 1 && (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeContainer + 1, JSON.stringify({ containerObj, type: containerAsset?.type }));
check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && containerObj.position.x === -15 && containerObj.position.y === -40 && containerObj.position.z === 0, JSON.stringify(containerObj?.position));
let o1c = JSON.parse(await objectJson('o1'));
const o2c = JSON.parse(await objectJson('o2'));
check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }));
check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child'])));
const orderC = JSON.parse(await evaluate('JSON.stringify(__gm.hierarchyOrder())'));
const cAt = orderC.indexOf(containerObj?.id);
check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
await shot('24-container-created');
// 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
const cScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('${containerObj.id}'))`));
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
const containerMoved = JSON.parse(await objectJson(containerObj.id));
o1c = JSON.parse(await objectJson('o1'));
check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position }));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
check('undo moves the container back', JSON.parse(await objectJson(containerObj.id)).position.x === -15);
// 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
// the objects whose position lies inside – the crate and its collider, not the Mover
// (at y = 110), not the camera (at the origin).
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
s = JSON.parse(await stateJson());
check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && JSON.stringify([...s.selection.objectIds].sort()) === '["o1","oc1"]', JSON.stringify(s.selection));
await shot('25-marquee');
// 16d. Dragging one of several selected objects moves all of them as one undo step.
const redScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('o1'))`));
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`);
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
o1c = JSON.parse(await objectJson('o1'));
const o2d = JSON.parse(await objectJson('o2'));
check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position }));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
o1c = JSON.parse(await objectJson('o1'));
check('one undo restores both', o1c.position.x === -135 && JSON.parse(await objectJson('o2')).position.x === 135);
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
const objectsBeforeDelete = await evaluate('__gm.state.activeScene.objects.length');
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(150);
check('Delete removes the whole multi-selection with its subtrees', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete - 5);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
check('undo restores all of them', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete);
// 16f. Placing the container asset again from the asset panel creates a second instance:
// the root plus 5 content objects with fresh ids, the crate copy at the same offset.
await clickText(containerAsset.name, 'right');
await sleep(150);
await clickText('Place in scene');
await sleep(250);
const afterPlace = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)'));
const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
await shot('26-container-placed');
// 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
// container; the second instance has an unmodified copy of it. Moving o1 on x shows
// ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
// both back; ↺ puts o1 back to the template value.
const templateA = () => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerAsset.id}').objects.find(t => t.id === '${o1c.templateId}'))`).then(JSON.parse);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' })`);
await sleep(200);
check('inspector names the container the object is part of', Boolean(await evaluate(`__gm.locate(${JSON.stringify(containerAsset.name)})`)));
check('no apply/revert while the instance matches its template', !(await evaluate(`__gm.locate('↑')`)) && !(await evaluate(`__gm.locate('↺')`)));
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100; __gm.state.preview(); })()`);
await sleep(200);
const applyButton = await evaluate(`__gm.locate('↑')`);
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && Boolean(await evaluate(`__gm.locate('↺')`)));
await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y);
await sleep(200);
check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && JSON.parse(await objectJson(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: JSON.parse(await objectJson(secondCrate.id)).position }));
check('apply hides the buttons again', !(await evaluate(`__gm.locate('↑')`)));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restores template and copy', (await templateA()).position.x === -135 && JSON.parse(await objectJson(secondCrate.id)).position.x === -135);
const revertButton = await evaluate(`__gm.locate('↺')`);
await click(revertButton.x, revertButton.y);
await sleep(200);
check('revert puts the instance back to the template position', JSON.parse(await objectJson('o1')).position.x === -135);
// 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77; __gm.state.preview(); })()`);
await sleep(200);
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
await click(applyMenu.x, applyMenu.y);
await sleep(150);
check('the menu offers container and asset', Boolean(await evaluate(`__gm.locate('Apply to container')`)) && Boolean(await evaluate(`__gm.locate('Apply to asset')`)));
await shot('28-apply-menu');
await clickText('Apply to asset');
await sleep(200);
const crateAfterApply = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'crate').props)`));
check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && JSON.parse(await objectJson('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restores asset and override', (await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.width`)) === 100 && JSON.parse(await objectJson('o1')).overrides.width === 77);
await click(applyMenu.x, applyMenu.y);
await sleep(150);
await clickText('Apply to container');
await sleep(200);
check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77);
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+');
await sleep(150);
await clickText('Text');
await sleep(200);
const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
check('+ menu creates a text asset', Boolean(textAssetId));
await evaluate(`(() => {
const id = ${JSON.stringify(textAssetId)};
__gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world';
// On the camera so the game view really renders it (outside the frame it would be culled).
const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
__gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, overrides: {}, parentId: '', templateId: '' });
__gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' });
__gm.state.preview();
})()`);
await sleep(300);
check('a placed text object draws its content in the scene', Boolean(await evaluate(`Boolean(__gm.locate('Hello world'))`)));
await shot('29-text-object');
// 17a. The enabled switch: every object has it, switching it off is a normal override, and a
// switched-off object disappears from the scene view too (it stays in the hierarchy).
const enabledRow = await evaluate(`__gm.locate('Enabled')`);
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
const visibleBefore = (await evaluate('__gm.viewportInfo()')).visibleItems;
// The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
await click(enabledRow.x + 55, enabledRow.y);
await sleep(250);
const textObject = JSON.parse(await objectJson('otext'));
check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides));
check('a switched-off object is not drawn in the scene view', (await evaluate(`JSON.stringify(__gm.drawOrder().includes('otext'))`)) === 'false');
const visibleAfter = (await evaluate('__gm.viewportInfo()')).visibleItems;
check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`);
check('it stays selectable in the hierarchy, so its switch stays reachable', Boolean(await evaluate(`__gm.locate('Enabled')`)));
await shot('30-disabled');
// 17b. In the running game a switched-off object does not exist: it is not rendered, and
// switching it back on brings it back.
await clickText('▶ Play');
await sleep(500);
const offItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)'));
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true; __gm.state.preview(); })()`);
await sleep(400);
const onItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)'));
check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
check('stop leaves the play-mode switch behind', JSON.parse(await objectJson('otext')).overrides.enabled === false);
});