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

+10 -2
View File
@@ -7,13 +7,21 @@
* holding the project. Everything below this line only knows bytes and voices.
*/
export interface AudioSink {
/** Starts one voice. `volume` is final (0–1) and every call overlaps whatever is already playing. */
play(file: string, volume: number, loop: boolean): void;
/**
* Starts one voice. `volume` is final (0–1) and every call overlaps whatever is already playing.
* `owner` is the behavior the sound belongs to: when that behavior is destroyed the voice goes
* with it, because a sound is something a thing in the world is doing, not a thing of its own.
* `''` means the voice has no owner and outlives whatever started it.
*/
play(file: string, volume: number, loop: boolean, owner: string): void;
/** Silences every voice of that file; `''` silences all of them. */
stop(file: string): void;
/** Silences every voice started by that owner – what destroying a behavior does. */
stopOwner(owner: string): void;
}
export const SILENT_AUDIO: AudioSink = {
play: () => {},
stop: () => {},
stopOwner: () => {},
};
+46 -9
View File
@@ -18,7 +18,7 @@ import {
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { graphLookup } from '../../core/model/graphLookup';
import { type PropertyBag, textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
import { soundFileOf } from '../../core/model/sound';
import { parentChain } from '../../core/model/walk';
import { resolveAssetProps } from '../../core/properties/resolve';
@@ -85,10 +85,13 @@ export function addGameSystems(
input: Input,
shouldRun?: (entity: Entity) => boolean,
audio: AudioSink = SILENT_AUDIO,
/** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */
carried: Readonly<SharedState> | null = null,
): BehaviorSystem {
const collisions = new CollisionSystem();
const animations = new AnimationSystem();
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun);
if (carried) behaviors.carryOver(carried);
world.addSystem(collisions);
world.addSystem(animations);
world.addSystem(behaviors);
@@ -112,6 +115,8 @@ export class BehaviorSystem implements System {
private instances: Instance[] = [];
private world: World | null = null;
private sharedState: SharedState = emptySharedState();
/** Handed over by the previous world of the same run; consumed by the next `start`. */
private carried: SharedState | null = null;
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
private pending: StateChange[] = [];
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
@@ -152,23 +157,43 @@ export class BehaviorSystem implements System {
this.declared = now;
}
/**
* Takes over the shared state of the world before it – the values of the Variables assets and
* where the global machines stand – instead of building a fresh one. That is what belongs to the
* run rather than to the scene, and it is what makes a score survive the next level. Call before
* the world is started.
*/
carryOver(state: Readonly<SharedState>): void {
// The bags themselves travel as they are – a running value is the point – while the map around
// them is copied, so the old system cannot still be writing into what the new one reads.
this.carried = { variables: { ...state.variables }, states: { ...state.states } };
}
start(world: World): void {
this.world = world;
this.instances = [];
this.pending = [];
const carried = this.carried;
this.carried = null;
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
this.sharedState = buildSharedState(this.project);
this.sharedState = carried ?? buildSharedState(this.project);
this.declared = declaredVariables(this.project);
// A `global` machine is entered once for the whole run, before the first instance exists – so
// the move is queued here and every instance the first sync creates hears that one entry.
for (const [machineId, start] of Object.entries(this.sharedState.states))
if (start) this.pending.push({ machineId, from: '', to: start, source: null });
// Not after a scene swap: the machine is already standing where it stands, and announcing the
// entry again would be a move that never happened.
if (!carried)
for (const [machineId, start] of Object.entries(this.sharedState.states))
if (start) this.pending.push({ machineId, from: '', to: start, source: null });
this.syncInstances(world);
}
/** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */
stop(): void {
for (const instance of this.instances) this.finish(instance);
for (const instance of this.instances) {
this.finish(instance);
this.audio.stopOwner(instance.entity.id);
}
this.instances = [];
this.pending = [];
this.world = null;
@@ -291,7 +316,12 @@ export class BehaviorSystem implements System {
const gone = this.instances.filter((i) => !world.entities.includes(i.entity));
if (gone.length === 0) return;
this.instances = this.instances.filter((i) => !gone.includes(i));
for (const instance of gone) this.finish(instance);
for (const instance of gone) {
this.finish(instance);
// After `On destroy`, not before: that graph may still say something, and a sound it wants
// to outlive the object says so itself by being detached.
this.audio.stopOwner(instance.entity.id);
}
}
private finish(instance: Instance): void {
@@ -454,12 +484,12 @@ export class BehaviorSystem implements System {
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
* *is* – a node that says nothing therefore plays it as the project describes it.
*/
private playSound(assetId: string, volume: number, loop: boolean | undefined): void {
private playSound(assetId: string, volume: number, loop: boolean | undefined, owner: string): void {
const asset = findAssetOf(this.project, assetId, 'sound');
if (!asset?.file) return;
const props = resolveAssetProps(this.project, asset);
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
this.audio.play(asset.file, level, loop ?? loopsOf(props));
this.audio.play(asset.file, level, loop ?? loopsOf(props), owner);
}
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
@@ -521,7 +551,8 @@ export class BehaviorSystem implements System {
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
playSound: (assetId, volume, loop) => this.playSound(assetId, volume, loop),
playSound: (assetId, volume, loop, detached) =>
this.playSound(assetId, volume, loop, detached ? '' : entity.id),
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
// only the empty id means "everything", which is what the node promises.
stopSound: (assetId) => {
@@ -533,6 +564,12 @@ export class BehaviorSystem implements System {
const found = resolve(target);
if (found) world.remove(found);
},
loadScene: (sceneId) => {
// An id naming no scene is dropped here rather than half way through the swap, where the
// run would already have torn down the world it is still standing in.
if (findScene(this.project, sceneId)) world.loadSceneRequested = sceneId;
else world.log.push(`Load scene: no scene ${sceneId}`);
},
stop: () => {
world.stopRequested = true;
},