From af9b20210085989e60900537bf3492884883d115 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 6 Oct 2026 21:52:24 +0200 Subject: [PATCH] Keep the game's music when Play ends a run in the editor A behavior with "Run in editor" kept its world until the frame after Play. By then the game's On start had already started its pattern or song, and ending the editor run called stopAll() on the sound library. The game came up silent. This is what turned the game blocks of e2e 64 and 65 red in suites 542 and 550. - EditorRun.modeChanged() ends the run the moment the mode leaves edit. enterPlay() announces the mode before the game is built, so the editor run's sounds stop first and the game's start after. - The Editor wires the mode event to it instead of invalidate(). Tests: - e2e 64 starts its game with an idle "Run in editor" behavior in the scene. Without the fix it fails like CI did, "game": [] and 2 FAILED. With it: ALL PASSED, 30 checks (stein2). - Unit editor-run: 21 green, 1 new. - Typecheck, ESLint, Prettier clean. Co-Authored-By: Claude Opus 5.5 --- src/editor/Editor.ts | 4 ++-- src/editor/EditorRun.ts | 9 +++++++++ tests/editor/editor-run.test.ts | 18 ++++++++++++++++++ tools/e2e/64-pattern.mjs | 23 +++++++++++++++++++++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 0285d7c6..b145dfa9 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -364,7 +364,7 @@ export class Editor implements PlayHost { const playing = this.play.machineStates(assetId); return playing.length > 0 ? playing : this.editorRun.machineStates(assetId); }; - // Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`). + // Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.modeChanged`). this.inspector.liveVariables = (assetId) => this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId); this.variablesEditor.liveVariables = this.inspector.liveVariables; @@ -560,7 +560,7 @@ export class Editor implements PlayHost { this.valueTargets.clear(); this.showTab('scene'); }); - state.on('mode', () => this.editorRun.invalidate()); + state.on('mode', () => this.editorRun.modeChanged()); state.on('pick', () => this.followPick()); } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index fadd0d91..42e85873 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -41,6 +41,15 @@ export class EditorRun { if (!this.writing) this.stale = true; } + /** + * Playing starts: the run ends at once, not on the next frame. The game's `On start` runs in the + * same click, and stopping the run's sounds a frame later would take its music along. + */ + modeChanged(): void { + if (this.state.mode !== 'edit') this.stop(); + this.invalidate(); + } + /** `step` is what the global clock moved this frame – already scaled and capped. */ tick(step: number): void { if (this.state.mode !== 'edit') { diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts index 62944c3c..76e9e0ad 100644 --- a/tests/editor/editor-run.test.ts +++ b/tests/editor/editor-run.test.ts @@ -178,6 +178,24 @@ describe('the run-in-editor switch', () => { expect(f.boxX()).toBe(0); }); + it('ends the moment playing starts, before the game’s On start, not a frame later', () => { + const f = scene(); + f.state.on('mode', () => f.run.modeChanged()); + f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true)); + f.frame(0.1); + expect(f.run.debugInfo().running).toBe(true); + + // The game is built right after `enterPlay` in the same click; a run ended only by the next + // frame stopped every sound, the music its On start had just begun with them. + f.state.enterPlay(); + expect(f.run.debugInfo().running).toBe(false); + expect(f.keyboard.world).toBeNull(); + + f.state.exitPlay(); + f.frame(0.1); + expect(f.run.debugInfo().running).toBe(true); + }); + it('does not run a behavior whose scene is not the loaded one', () => { const f = scene(); f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true)); diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index 58429565..a943181a 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -510,6 +510,21 @@ export async function pattern(t) { edges: [edge('pt64-e3', 'pt64-start', 'exec', 'pt64-play', 'exec')], }, }); + // A behavior running in the editor ends when the game starts, and its sounds with it – but only + // its own: the pattern the game's `On start` plays in the same click has to live on. + const idle = await scaffoldBehavior(t, { + assetId: 'pt64-idle', + name: 'Idle 64', + boxId: 'pt64-idlebox', + logicId: 'pt64-idler', + y: LANES.sound + 200, + props: { enabled: true, runInEditor: true }, + graph: { nodes: [node('pt64-idle-start', 'OnStart', 0, 0)], edges: [] }, + }); + await waitFor( + () => json('__gm.editorRun().running'), + (running) => running, + ); const patterns = () => json('__gm.sound.playbacks()'); await evaluate('__gm.sound.stopAll()'); await play(); @@ -556,6 +571,7 @@ export async function pattern(t) { JSON.stringify({ once: once.map((g) => g.planned.length), gone, onceStats }), ); const left = await rig.remove(); + const idleLeft = await idle.remove(); // 64m. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); @@ -571,7 +587,10 @@ export async function pattern(t) { `__gm.state.project.assets.filter((a) => a.id.startsWith('pt64') || a.type === 'pattern').length`, )) === 0 && left.objects === 0 && - left.assets === 0, - JSON.stringify(left), + left.assets === 0 && + idleLeft.objects === 0 && + idleLeft.assets === 0 && + !(await json('__gm.editorRun().running')), + JSON.stringify({ left, idleLeft }), ); }