import { edge, node } from '../lib/builders.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** * "Run in editor": a behavior that keeps running while the project is not playing, and whose * work lands in the saved project. * * The step brings its own box and its own behavior instead of using the fixture's Mover, so that * the drifting checks measure nothing but time. Everything it adds is removed again at the end. */ const DRIFT_PER_SECOND = 100; const WALK_PER_SECOND = 200; /** Must stay in step with `GameViewport.editorRunCaption`. */ const EDITOR_RUN_CAPTION = 'Behaviors run here · keys and clicks go to the game'; /** `On key held(ArrowRight) → Move(owner)`: moves only while that key reaches the world. */ const walkGraph = { nodes: [ node('k-held', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' }), node('k-owner', 'Owner', 0, 120), node('k-move', 'Move', 340, 0, { dx: 1, scale: WALK_PER_SECOND }), ], edges: [ edge('k-e1', 'k-held', 'exec', 'k-move', 'exec'), edge('k-e2', 'k-held', 'dt', 'k-move', 'dt'), edge('k-e3', 'k-owner', 'object', 'k-move', 'object'), ], }; /** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */ const driftGraph = { nodes: [ node('d-update', 'OnUpdate', 0, 0), node('d-owner', 'Owner', 0, 120), node('d-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }), ], edges: [ edge('d-e1', 'd-update', 'exec', 'd-move', 'exec'), edge('d-e2', 'd-update', 'dt', 'd-move', 'dt'), edge('d-e3', 'd-owner', 'object', 'd-move', 'object'), ], }; export async function editorRun(t) { const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId, showTab, } = t; // 17a. A box with a drifting behavior below it. Written into the model directly: what is under // test is the switch, not another way of building a graph. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(asset, { id: 'drifter', name: 'Drifter', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(driftGraph)}, }); p.assets.push(asset); ${MAKE_OBJECT} scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter')); `); const boxX = async () => (await object('er-box')).position.x; const startX = await boxX(); await sleep(500); const idle = await json('__gm.editorRun()'); check( 'a placed behavior does not run outside play mode until it is asked to', idle.running === false && (await boxX()) === startX, `${JSON.stringify(idle)} x=${startX}`, ); // 17b. The switch itself, through the inspector. It belongs with Enabled and above the // VARIABLES section: it is a switch of the behavior, not something the behavior declares. await evaluate( `__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`, ); await sleep(200); const enabledBox = await json(`__gm.inspectorField('Enabled')`); const runBox = await json(`__gm.inspectorField('Run in editor')`); const variables = await json(`__gm.locate('VARIABLES')`); check( 'the inspector offers the switch on a behavior object, with Enabled and above the variables', Boolean(runBox) && Boolean(enabledBox) && Boolean(variables) && runBox.y > enabledBox.y && runBox.y < variables.y, JSON.stringify({ enabledBox, runBox, variables }), ); await click(runBox.x, runBox.y); const logic = await object('er-logic'); check( 'switching it on is an ordinary override on the object', logic.overrides.runInEditor === true, JSON.stringify(logic.overrides), ); const running = await json('__gm.editorRun()'); const mode = await evaluate('__gm.state.mode'); check( 'the behavior runs although Play was never pressed', running.running === true && running.behaviors.join() === 'er-logic' && mode === 'edit', `${JSON.stringify(running)} mode=${mode}`, ); // 17c. It really drives its owner, and it is the stored scene object that moves – no runtime // copy is involved here, which is the whole difference to playing. const before = await boxX(); await sleep(600); const after = await boxX(); check( 'the owner drifts on, and the moved object is the stored one', after - before > DRIFT_PER_SECOND * 0.3 && (await evaluate('__gm.state.mode')) === 'edit', `x ${before} → ${after}`, ); await shot('36-editor-run'); // 17d. The point of this semantic: the change is a real edit, so it reaches the server. const first = await api(`projects/${projectId}`); await sleep(2000); const second = await api(`projects/${projectId}`); const savedX = second.project.assets.find((a) => a.type === 'scene').objects.find((o) => o.id === 'er-box') .position.x; check( 'what the behavior moved is saved, not just shown', savedX > startX, `stored x=${savedX} (started at ${startX})`, ); // A behavior writing every frame must not turn into a save per frame. That it saves at all is // the check above; this one is only about the ceiling – 60 frames a second would be ~120. const saves = second.revision - first.revision; check( 'saving stays debounced while it runs, instead of once per frame', saves <= 6, `${saves} saves in 2 s`, ); // 17e. Taking the switch back stops it again. await click(runBox.x, runBox.y); const stoppedAt = await boxX(); await sleep(500); const stopped = await json('__gm.editorRun()'); check( 'taking the switch back stops the run and the object stands still', stopped.running === false && (await boxX()) === stoppedAt, `${JSON.stringify(stopped)} x=${stoppedAt}`, ); // 17f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first // undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock. await undo(); await undo(); const afterUndo = await object('er-box'); check( 'undo takes back the switch, never the movement it caused', (await object('er-logic')).overrides.runInEditor === undefined && afterUndo.position.x >= stoppedAt && afterUndo.position.x > startX, `overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`, ); // 17g. The case a real project has: the behavior sits inside a *placed container* – a level // packed up once and dropped into the scene. The scene stores the container object alone, // so this only works if the run resolves the template instead of expecting stored objects. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; ${MAKE_OBJECT} p.assets.push({ id: 'er-level', type: 'container', name: 'Editor level', props: { enabled: true }, objects: [make('t-box', 'crate', '', 800), make('t-logic', 'drifter', 't-box', 0)], }); const instance = make('er-inst', 'er-level', '', 0); instance.instanceEdits = { 't-logic': { position: {}, overrides: { runInEditor: true } } }; scene.objects.push(instance); `); await sleep(600); const packedX = async () => (await object('er-inst~t-box')).position.x; const packedStart = await packedX(); const packedRun = await json('__gm.editorRun()'); await sleep(600); const packedNow = await packedX(); check( 'a behavior inside a placed container runs too, although the scene stores no object for it', packedRun.behaviors.includes('er-inst~t-logic') && packedNow - packedStart > DRIFT_PER_SECOND * 0.3, `${JSON.stringify(packedRun.behaviors)} x ${packedStart} → ${packedNow}`, ); const stored = await json(`__gm.state.activeScene.objects.map((o) => o.id)`); const edits = await json(`__gm.state.activeScene.objects.find((o) => o.id === 'er-inst').instanceEdits`); const template = await json( `__gm.state.project.assets.find((a) => a.id === 'er-level').objects.find((o) => o.id === 't-box').position`, ); check( 'and what it moved is kept as an instance edit – the instance is not baked out, the template not touched', stored.includes('er-inst') && !stored.some((id) => id.startsWith('er-inst~')) && edits['t-box'].position.x > packedStart && template.x === 0, `stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`, ); // 17h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the // arrow keys walking the hierarchy must not steer a game at the same time. The game tab is // where no key means anything to the editor, so that is where a behavior may have them. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const asset = structuredClone(p.assets.find((a) => a.id === 'drifter')); Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} }); p.assets.push(asset); ${MAKE_OBJECT} scene.objects.push( make('er-walkbox', 'crate', '', 1200), Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }), ); `); await sleep(600); const walkX = async () => (await object('er-walkbox')).position.x; const onScene = await json('__gm.editorRun()'); const sceneBefore = await walkX(); await holdKey('ArrowRight', 500); await sleep(200); check( 'a key pressed while the scene is on screen belongs to the editor, not to the run', onScene.running === true && onScene.keyboard === false && (await json('__gm.tab()')) === 'scene' && (await walkX()) === sceneBefore, `${JSON.stringify(onScene)} x=${sceneBefore} → ${await walkX()}`, ); await showTab('game'); // Clicking the view first: a checkbox left focused by an earlier check would still want the keys. const gameCentre = await json('__gm.gameCenter()'); await click(gameCentre.x, gameCentre.y); const onGame = await json('__gm.editorRun()'); const gameBefore = await walkX(); await holdKey('ArrowRight', 500); await sleep(200); const gameAfter = await walkX(); check( 'in the game tab the same key reaches the behavior and the box walks', onGame.keyboard === true && gameAfter - gameBefore > WALK_PER_SECOND * 0.3, `${JSON.stringify(onGame)} x ${gameBefore} → ${gameAfter}`, ); const caption = await json(`__gm.locate(${JSON.stringify(EDITOR_RUN_CAPTION)})`); check( 'and the tab says so, instead of leaving the keyboard a guess', caption !== null, JSON.stringify(caption), ); await shot('37-editor-run-keys'); await showTab('scene'); const backOnScene = await walkX(); await holdKey('ArrowRight', 400); await sleep(200); check( 'leaving the tab takes the keyboard back, and a key held across it does not stay held', (await json('__gm.editorRun()')).keyboard === false && (await walkX()) === backOnScene, `x=${backOnScene} → ${await walkX()}`, ); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-')); p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id)); `); check( 'the step leaves nothing of its own behind', (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 && (await json('__gm.editorRun()')).running === false, ); }