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. * * A second Variables asset scoped to the scene is the other half of that witness (#164): the two * count the same way in the same graph, and only one of them is still counting after the swap. */ const SOURCE = { variablesAsset: 'ls-vars' }; const PER_SCENE = { variablesAsset: 'ls-level' }; /** * `On start → Variables{set score = score + 1} → the same for the per-scene count`, 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-read2', 'Variables', 0, 640, PER_SCENE, [port('count', 'number')]), node('ls-add2', 'Add', 260, 640, { b: 1 }), node('ls-write2', 'Variables', 560, 640, PER_SCENE, [written('count', '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-write2', 'exec'), edge('ls-e6', 'ls-read2', 'count', 'ls-add2', 'a'), edge('ls-e7', 'ls-add2', 'result', 'ls-write2', 'count'), edge('ls-e8', 'ls-write2', '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, doubleClick, clickText, clickToolbar, press, play, stop, shot, has, showTab, } = t; const playInfo = () => json('__gm.playInfo()'); const playFrom = () => json('__gm.playFrom()'); /** * The start-scene switch sits in the Project settings dialog since the top bar became icons * (#183): menu button → "Project settings…" → the switch → the mode → Apply. */ const setPlayFrom = async (mode) => { await clickToolbar('menu'); await clickText('Project settings…'); const at = await json('__gm.projectSettingsPlayFromAt()'); await click(at.x, at.y); await clickText(mode); const chosen = await json('__gm.projectSettings()'); await clickText('Apply'); return chosen; }; 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 showTab('scene'); 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-level', type: 'variables', name: 'Level', scope: 'scene', props: { count: 0 }, variables: ${JSON.stringify([variable('count', 'number', 0)])}, }, { id: 'ls-sound', type: 'sound', name: 'Hum', props: { volume: 1, loop: true }, file: ${JSON.stringify(file)}, });`); // 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, 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' })"); 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 clickText('Set as start scene'); 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()), ); // Under the Scenes heading the row no longer repeats its type – the marks are all it says. const marksOn = async (id) => { const row = await json(`__gm.assetRow(${JSON.stringify(id)})`); const on = async (name) => ((await json(`__gm.locateIcons(${JSON.stringify(name)})`)) ?? []).some( (hit) => row && Math.abs(hit.y - row.y) < 8, ); return { start: await on('play'), current: await on('current') }; }; const startMarks = await marksOn('ls-scene'); const activeMarks = await marksOn(await json('__gm.state.project.activeSceneId')); check( 'the assets panel marks the start scene apart from the active one', startMarks.start && !startMarks.current && activeMarks.current && !activeMarks.start, JSON.stringify({ startMarks, activeMarks }), ); 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². Switching the scene the editor shows keeps working while the run is on. Since the top // bar lost its scene selector (#183) the Assets panel is the one way there, and it was // never blocked in play mode; the run itself is untouched by it. const sceneRow = await json('__gm.assetRow("ls-scene")'); await doubleClick(sceneRow.x, sceneRow.y); const playing = await playInfo(); check( 'a scene opened from the Assets panel moves the editor in play mode, and the run runs on', (await json('__gm.state.project.activeSceneId')) === 'ls-scene' && playing.playing === true && playing.sceneId === 'ls-scene', JSON.stringify({ sceneRow, 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), ); // 35e². The other scope, counted by the same graph in the same frame (#164). The loader stayed // behind with its scene, so nobody counts in the new one – and the value is the declared 0 // rather than the 1 the score right beside it still carries. check( 'what belongs to the scene starts over: the per-scene count does not carry', (before.shared?.variables?.['ls-level']?.count ?? 0) === 1 && world.shared?.variables?.['ls-level']?.count === 0 && world.shared?.variables?.['ls-vars']?.score === 1, JSON.stringify({ before: before.shared?.variables, after: 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(); // 35f. The switch is the way to run what you are working on instead – saved with the project // (#226), so it holds between runs. const chosen = await setPlayFrom('Active scene'); 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' && // The dialog held the new mode before Apply, and Apply left the start scene alone. chosen.playFrom === 'active' && (await json('__gm.state.project.playFrom')) === 'active' && (await json('__gm.state.project.startSceneId')) === 'ls-scene', JSON.stringify({ fromActive, chosen, switch: await playFrom() }), ); await stop(); await setPlayFrom('Start scene'); 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-level', 'ls-sound']); p.assets = p.assets.filter((a) => !gone.has(a.id)); __gm.state.select(null);`); 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)')), ); }