import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** * More than one scene (#39): the project names the scene a run begins with – which is not the same * question as which scene is open in the editor – and `Load scene` swaps the running one for * another from inside a graph. * * What the swap keeps is the point: the scene is built from scratch, while what belongs to the * *run* carries on. A Variables asset is the witness, because a fresh run would put it back to * what it declares. The sounds of the old scene end with it – not by a rule of their own, but * because the behaviors playing them are destroyed like everything else in that scene. */ const SOURCE = { variablesAsset: 'ls-vars' }; /** `On start → Variables{set score = score + 1}`, plus `On key down g → Load scene`. */ const graphFor = (sceneId, soundId) => ({ nodes: [ node('ls-start', 'OnStart', 0, 0), node('ls-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]), node('ls-add', 'Add', 260, 200, { b: 1 }), node('ls-write', 'Variables', 560, 0, SOURCE, [written('score', 'number')]), node('ls-play', 'PlaySound', 820, 0, { sound: soundId, volume: 1, loop: 'true' }), node('ls-key', 'OnKeyDown', 0, 420, { key: 'g' }), node('ls-load', 'LoadScene', 260, 420, { scene: sceneId }), ], edges: [ edge('ls-e1', 'ls-start', 'exec', 'ls-write', 'exec'), edge('ls-e2', 'ls-read', 'score', 'ls-add', 'a'), edge('ls-e3', 'ls-add', 'result', 'ls-write', 'score'), edge('ls-e4', 'ls-write', 'exec', 'ls-play', 'exec'), edge('ls-e5', 'ls-key', 'exec', 'ls-load', 'exec'), ], }); export async function loadScene(t) { const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, has } = t; const playInfo = () => json('__gm.playInfo()'); const playFrom = () => json('__gm.playFrom()'); /** The switch is opened by position: its caption is elided at the width it has. */ const openPlayFrom = async () => { const at = await json('__gm.playFromAt()'); await click(at.x, at.y); await sleep(250); }; const stats = () => json('__gm.sound.stats()'); const waitFor = async (read, done, tries = 25) => { let value = await read(); for (let i = 0; i < tries && !done(value); i++) { await sleep(150); value = await read(); } return value; }; await press('Escape'); await evaluate('__gm.state.select(null)'); await evaluate("__gm.showTab('scene')"); await sleep(200); const firstId = await json('__gm.state.project.activeSceneId'); const firstName = await json('__gm.state.activeScene.name'); // A sound to have something playing across the swap; the seed put the files on the server. const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); const file = stored[0]?.file ?? ''; // 35a. A second scene with a box of its own, a Variables asset, and the behavior that counts and // loads. Everything lives in the new scene, so nothing later in the suite sees any of it. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; ${MAKE_OBJECT} const second = structuredClone(scene); second.id = 'ls-scene'; second.name = 'Level 2'; second.objects = [ make('ls-box', 'crate', '', 0, 'Second box'), make('ls-brain', 'ls-logic', 'ls-box', 0, 'Loader'), ]; const logic = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(logic, { id: 'ls-logic', name: 'Loader', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graphFor(firstId, 'ls-sound'))}, }); p.assets.push(logic, second, { id: 'ls-vars', type: 'variables', name: 'Run', props: { score: 0 }, variables: ${JSON.stringify([variable('score', 'number', 0)])}, }, { id: 'ls-sound', type: 'sound', name: 'Hum', props: { volume: 1, loop: true }, file: ${JSON.stringify(file)}, });`); await sleep(400); // 35b. Without a start scene the switch says the active scene – the two questions have the same // answer until someone answers them differently. check( 'with no start scene named, Play starts from the active one', (await json('__gm.state.project.startSceneId')) === '' && (await playFrom()).mode === 'start' && (await playFrom()).scene === firstName && // The caption is the mode, worded like the menu entry it came from. (await playFrom()).label === 'Start scene', JSON.stringify(await playFrom()), ); // 35c. Naming one: right-click the scene in the assets panel. By this point in the suite the // project holds every kind of asset, so the new scene is the last row of a list far longer // than the panel – selecting it brings it into view, which is what makes the click land. await evaluate("__gm.state.select({ kind: 'asset', assetId: 'ls-scene' })"); await sleep(200); check( 'selecting an asset scrolls its row into the panel', await has('Level 2'), JSON.stringify({ row: await json('__gm.assetRow("ls-scene")'), panel: await json('__gm.assetsRect()') }), ); await clickText('Level 2', 'right'); await sleep(250); await clickText('Set as start scene'); await sleep(300); check( 'the scene’s context menu names it the start scene, and the switch follows', (await json('__gm.state.project.startSceneId')) === 'ls-scene' && (await playFrom()).scene === 'Level 2', JSON.stringify(await playFrom()), ); check( 'the assets panel marks the start scene apart from the active one', await has('scene ▶'), JSON.stringify( await json('__gm.state.project.assets.filter((a) => a.type === "scene").map((a) => a.name)'), ), ); await shot('35-start-scene'); // 35d. Play starts there rather than in the scene the editor is showing. await play(); const started = await playInfo(); check( 'Play runs the start scene while the editor still shows the active one', started.playing === true && started.sceneId === 'ls-scene' && (await json('__gm.state.project.activeSceneId')) === firstId, JSON.stringify(started), ); // 35d². The scene selector keeps working while the run is on. It only moves the editor's view, // which a double-click on a scene in the Assets panel has always done – disabling the one // and not the other was the inconsistency. The run itself is untouched by it. const select = await json('__gm.sceneSelect()'); await click(select.x, select.y); await sleep(250); const rows = await json('__gm.menuRows()'); // The topmost hit, because the scene's name is on its row in the Assets panel as well. const entry = await locate('Level 2', true); await click(entry.x, entry.y); await sleep(300); const playing = await playInfo(); check( 'the scene selector stays usable in play mode, and the run runs on', select.enabled === true && rows.some((row) => (row ?? '').includes('Level 2')) && (await json('__gm.state.project.activeSceneId')) === 'ls-scene' && playing.playing === true && playing.sceneId === 'ls-scene', JSON.stringify({ select, rows, playing }), ); // 35e. The swap itself. The score was counted in the old scene; a fresh run would show 0. const before = await waitFor( () => json('__gm.world()'), (w) => (w?.shared?.variables?.['ls-vars']?.score ?? 0) === 1, ); const humming = await waitFor(stats, (s) => s.playing > 0); await press('g'); const after = await waitFor( () => json('__gm.playInfo()'), (info) => info.sceneId === firstId, ); const world = await json('__gm.world()'); check( 'Load scene swaps the running scene without ending the run', after.playing === true && after.sceneId === firstId, JSON.stringify({ before: before.shared?.variables, after }), ); check( 'the objects of the old scene are gone and the new scene is built', !world.entities.some((e) => e.objectId === 'ls-box') && world.entities.length > 0, JSON.stringify(world.entities.map((e) => e.name)), ); check( 'what belongs to the run carries over: the shared score survives the swap', world.shared?.variables?.['ls-vars']?.score === 1, JSON.stringify(world.shared?.variables), ); check( 'the sounds of the old scene end with the behaviors that were playing them', humming.playing >= 1 && (await stats()).playing === 0, JSON.stringify({ humming, now: await stats() }), ); await stop(); await sleep(300); // 35f. The switch is the way to run what you are working on instead – and it is remembered // between runs, because it says how you work rather than what the game does. await openPlayFrom(); await clickText('Active scene'); await sleep(300); await play(); const fromActive = await playInfo(); check( 'switched to "active scene", Play runs the scene the editor shows', fromActive.sceneId === firstId && (await playFrom()).mode === 'active' && (await playFrom()).label === 'Active scene' && (await json('__gm.state.project.startSceneId')) === 'ls-scene', JSON.stringify({ fromActive, switch: await playFrom() }), ); await stop(); await sleep(300); await openPlayFrom(); await clickText('Start scene'); await sleep(300); check( 'the switch goes back to the start scene', (await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2', JSON.stringify(await playFrom()), ); // 35g. Everything this step made goes again, the start scene included. await mutate(` const p = __gm.state.project; p.startSceneId = ''; const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']); p.assets = p.assets.filter((a) => !gone.has(a.id)); __gm.state.select(null);`); await sleep(300); check( 'the step leaves nothing of its own behind', (await json('__gm.state.project.assets.filter((a) => a.id.startsWith("ls-")).length')) === 0 && (await json('__gm.state.project.startSceneId')) === '' && (await stats()).playing === 0, JSON.stringify(await json('__gm.state.project.assets.map((a) => a.id)')), ); }