A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
game-maker / suite (push) Failing after 11m6s

Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.

A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.

Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.

The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
This commit is contained in:
admin committed 2026-09-15 13:24:26 +02:00
1 parent 30090fc48e
commit 1e9d21cd5d
30 files changed
+976 -57

No files matched your search

+8 -1
View File
@@ -142,7 +142,14 @@ describe('CommandStack', () => {
});
it('keeps a command on the redo stack when its redo is rejected', () => {
const project = { formatVersion: 1, id: 'p', name: 'P', assets: [], activeSceneId: '' };
const project = {
formatVersion: 1,
id: 'p',
name: 'P',
assets: [],
activeSceneId: '',
startSceneId: '',
};
const stack = new CommandStack(project);
let failing = false;
stack.execute({
+125
View File
@@ -0,0 +1,125 @@
/**
* A game is more than one scene. `Load scene` swaps the running one for another inside the same
* run: the scene is rebuilt from scratch, while what belongs to the run – the shared variables and
* the global machines – carries on. Where a run *begins* is a second question, and the project
* answers it separately from whatever scene is open in the editor.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
SetActiveSceneCommand,
SetStartSceneCommand,
} from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { startScene } from '../../src/core/model/Project';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
makeVariables,
place,
roundTrip,
startWorld,
type SceneFixture,
} from '../helpers';
/** The sprite scene plus a second scene, and the id of that second one. */
function twoScenes(): SceneFixture & { otherId: string } {
const f = makeSpriteScene('Box');
const second = new CreateAssetCommand('scene', 'Level 2');
f.stack.execute(second);
return { ...f, otherId: second.asset.id };
}
describe('the start scene', () => {
it('is what a run begins with, and falls back to the active scene while none is named', () => {
const f = twoScenes();
expect(f.project.startSceneId).toBe('');
expect(startScene(f.project)?.id).toBe(f.sceneId);
f.stack.execute(new SetStartSceneCommand(f.otherId));
f.stack.execute(new SetActiveSceneCommand(f.sceneId));
expect(startScene(f.project)?.id).toBe(f.otherId);
});
it('can be taken back, and reads as none once the scene it names is gone', () => {
const f = twoScenes();
f.stack.execute(new SetStartSceneCommand(f.otherId));
f.stack.execute(new SetStartSceneCommand(''));
expect(f.project.startSceneId).toBe('');
f.stack.undo();
expect(f.project.startSceneId).toBe(f.otherId);
f.project.startSceneId = 'no-such-scene';
expect(startScene(f.project)?.id).toBe(f.project.activeSceneId);
});
it('refuses an asset that is not a scene', () => {
const f = twoScenes();
expect(() => f.stack.execute(new SetStartSceneCommand(f.spriteId))).toThrow(CommandError);
});
it('survives a round trip and drops an id no scene answers to', () => {
const f = twoScenes();
f.stack.execute(new SetStartSceneCommand(f.otherId));
expect(roundTrip(f.project).startSceneId).toBe(f.otherId);
f.project.startSceneId = 'gone';
expect(roundTrip(f.project).startSceneId).toBe('');
});
});
describe('Load scene', () => {
/** A behavior asking for `sceneId` on start, placed below the box. */
function asking(f: SceneFixture, sceneId: string) {
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const load = edit.addNode('LoadScene', { scene: sceneId });
edit.connect(start, 'exec', load, 'exec');
return { behavior, load };
}
it('asks the world for the scene, once the frame is over rather than mid-flow', () => {
const f = twoScenes();
asking(f, f.otherId);
const { world, frame } = startWorld(f, { behaviors: true });
frame();
// Still standing in the old world: the swap is the caller's to make, once the tick is over.
expect(world.loadSceneRequested).toBe(f.otherId);
expect(world.entities.some((e) => e.name === 'Box')).toBe(true);
});
it('asks for nothing when the id names no scene, and says so in the log', () => {
const f = twoScenes();
asking(f, 'no-such-scene');
const { world, frame } = startWorld(f, { behaviors: true });
frame();
expect(world.loadSceneRequested).toBe('');
expect(world.log.some((line) => line.startsWith('Load scene: no scene'))).toBe(true);
});
/** The point of the whole exercise: a score that survives the next level. */
it('carries the shared variables into the world of the next scene', () => {
const f = twoScenes();
const vars = makeVariables(f, 'Game', ['score']);
const input = new Input();
const first = World.instantiate(f.project, f.sceneId);
const behaviors = addGameSystems(first, f.project, input);
behaviors.liveVariables(vars.id)!.score = 7;
const second = World.instantiate(f.project, f.otherId);
const carried = addGameSystems(second, f.project, input, undefined, undefined, behaviors.shared);
expect(carried.liveVariables(vars.id)?.score).toBe(7);
// A fresh run, by contrast, starts at what the asset declares.
const third = World.instantiate(f.project, f.otherId);
expect(addGameSystems(third, f.project, input).liveVariables(vars.id)?.score).toBe(0);
});
});
+26 -1
View File
@@ -419,6 +419,10 @@ const CASES: Record<string, Case[]> = {
{ name: 'outputs the id it was set to', params: { value: 'snd-1' }, outputs: { value: 'snd-1' } },
{ name: 'unset is no sound', outputs: { value: '' } },
],
Scene: [
{ name: 'outputs the id it was set to', params: { value: 'sc-1' }, outputs: { value: 'sc-1' } },
{ name: 'unset is no scene', outputs: { value: '' } },
],
Add: [
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
@@ -812,7 +816,14 @@ const CASES: Record<string, Case[]> = {
// What the two mean – a factor on the asset's volume, an answer that overrules its loop
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see
// `sounds.test.ts`).
after: (ctx) => expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true }]),
after: (ctx) =>
expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false }]),
},
{
name: 'detached says the voice is nobody’s, so destroying this object leaves it playing',
inputs: { sound: 'snd-1', volume: 1, loop: '', detached: true },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.detached).toBe(true),
},
{
name: 'saying nothing about looping leaves the answer to the asset',
@@ -827,6 +838,20 @@ const CASES: Record<string, Case[]> = {
after: (ctx) => expect(ctx.played[0]?.loop).toBe(false),
},
],
LoadScene: [
{
name: 'asks the context for the scene it names',
inputs: { scene: 'sc-1' },
next: 'exec',
after: (ctx) => expect(ctx.loaded).toEqual(['sc-1']),
},
{
name: 'naming no scene asks for nothing, and the flow carries on',
inputs: { scene: '' },
next: 'exec',
after: (ctx) => expect(ctx.loaded).toEqual([]),
},
],
StopSound: [
{
name: 'silences the sound it names',
+50 -4
View File
@@ -32,14 +32,18 @@ const FILE = `${'a1'.repeat(32)}.wav`;
/** Records what it was asked to play instead of making a noise – the AudioSink a test wants. */
function fakeAudio(): AudioSink & {
played: { file: string; volume: number; loop: boolean }[];
played: { file: string; volume: number; loop: boolean; owner: string }[];
stopped: string[];
stoppedOwners: string[];
} {
const sink = {
played: [] as { file: string; volume: number; loop: boolean }[],
played: [] as { file: string; volume: number; loop: boolean; owner: string }[],
stopped: [] as string[],
play: (file: string, volume: number, loop: boolean) => sink.played.push({ file, volume, loop }),
stoppedOwners: [] as string[],
play: (file: string, volume: number, loop: boolean, owner: string) =>
sink.played.push({ file, volume, loop, owner }),
stop: (file: string) => sink.stopped.push(file),
stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
};
return sink;
}
@@ -133,7 +137,7 @@ describe('Play sound', () => {
const f = playingScene();
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.played).toEqual([{ file: FILE, volume: 1, loop: false }]);
expect(audio.played).toMatchObject([{ file: FILE, volume: 1, loop: false }]);
});
it('scales the asset volume rather than replacing it, and clamps the product', () => {
@@ -203,6 +207,48 @@ describe('Play sound', () => {
});
});
/**
* A sound is something a thing in the world is *doing*, so it ends when that thing does – the
* `Stop sound` you would otherwise have to remember in every `On destroy`.
*/
describe('a voice belongs to the behavior that started it', () => {
it('names that behavior as its owner, and nobody when the node is detached', () => {
const owned = playingScene();
const ownedAudio = fakeAudio();
const run = startWorld(owned, { behaviors: true, audio: ownedAudio });
run.frame();
const logic = run.world.entities.find((e) => e.name === 'Logic')!;
expect(ownedAudio.played[0]?.owner).toBe(logic.id);
const free = playingScene({ detached: true });
const freeAudio = fakeAudio();
startWorld(free, { behaviors: true, audio: freeAudio }).frame();
expect(freeAudio.played[0]?.owner).toBe('');
});
it('is silenced when the behavior is destroyed, after On destroy has had its say', () => {
const f = playingScene();
const edit = graphEditor(f, f.behavior);
const key = edit.addNode('OnKeyDown', { key: 'x' });
const owner = edit.addNode('Owner');
const destroy = edit.addNode('Destroy');
edit.wire([
[key, 'exec', destroy, 'exec'],
[owner, 'object', destroy, 'object'],
]);
const audio = fakeAudio();
const { world, input, frame } = startWorld(f, { behaviors: true, audio });
frame();
const logic = world.entities.find((e) => e.name === 'Logic')!;
expect(audio.stoppedOwners).toEqual([]);
input.press('x');
frame();
expect(audio.stoppedOwners).toEqual([logic.id]);
});
});
describe('Stop sound', () => {
it('silences one sound by its file, and everything when it names none', () => {
const f = playingScene();
+6 -2
View File
@@ -195,8 +195,10 @@ export type FakeContext = NodeContext & {
stopped: boolean;
changes: { machineId: string; from: string; to: string }[];
/** What the context was asked to play and to silence, in order – nothing here makes a noise. */
played: { assetId: string; volume: number; loop: boolean | undefined }[];
played: { assetId: string; volume: number; loop: boolean | undefined; detached: boolean }[];
silenced: string[];
/** The scenes the context was asked to load, in order. */
loaded: string[];
};
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
@@ -227,8 +229,10 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
log: (message) => logs.push(message),
played: [],
silenced: [],
playSound: (assetId, volume, loop) => ctx.played.push({ assetId, volume, loop }),
loaded: [],
playSound: (assetId, volume, loop, detached) => ctx.played.push({ assetId, volume, loop, detached }),
stopSound: (assetId) => ctx.silenced.push(assetId),
loadScene: (sceneId) => ctx.loaded.push(sceneId),
spawn: () => null,
destroy: () => {},
stop: () => {