#!/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); });