A game should be able to start the next piece of music when one is over, or wait for a sound before it goes on. Until now a behavior could start and stop music and sounds, but never learn that they had ended. - Two new events: On music finished with the fields Song and Pattern and the input playback, and On sound finished with the field Sound and the input voice. Both give out what ended and stopped. - Two new data nodes, Music playing and Sound playing, ask whether something is still heard. - Over means nothing is heard any more: no tail of the effects, and a fade-out has run down. - stopped is on for Stop song, pattern or sound, when the behavior that owns it ends and on a scene change. For a sound also when a newer voice needs its place or the file does not load. - Only what the Play nodes of this run started is heard, across scene changes too. Previews in the tabs, compositions and notes of instruments do not count. - Switch song ends nothing. The number goes on, and the end is told under the song heard last. - Playbacks and the SoundLibrary tell each end once to a new PlayedEnds per run. The AudioSwitch passes them on from both sinks. Found and fixed: a pattern whose last bar ends in silence counted as done up to one planning window before its end. In e2e 65 the next pattern started after 1.962 s instead of 2 s. Scheduler.done now also wants the playhead to have reached the end. Cleaning up done playbacks therefore comes a few tenths of a second later. One test that expected the early end is adjusted. Tests: - Unit: 23 new (played-ends 12, sound-library 5, node catalog 5, audio-switch 1), all green. - Counter-probes: 19 bends, all red. One was green at first for want of a case, which is added now. - e2e 65 on atx: ALL PASSED, 78 checks. New 65k6: the event came once with stopped off, and the second pattern started 2.051 s after the stinger. Stop song gave exactly one end with stopped on, and the switch from 65k3 gave none. - Gate on atx: CHECK EXIT 0, 337 files, 5222 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
6.0 KiB
TypeScript
147 lines
6.0 KiB
TypeScript
/**
|
||
* One run of a game: the world of the scene it is in and the systems that play it. Shared by the
|
||
* editor's Play and a released game (#266), so both build, swap and end a scene the same way.
|
||
*/
|
||
import type { SaveArea } from '../core/graph/savedValues';
|
||
import type { SceneAsset } from '../core/model/Asset';
|
||
import { findScene, type Project } from '../core/model/Project';
|
||
import { mixOf, videoBusesOf } from '../core/music/mixer';
|
||
import type { RecordingSink } from '../core/recording/recording';
|
||
import type { AudioSink } from './systems/Audio';
|
||
import type { VideoPictures } from './systems/CompositionSystem';
|
||
import {
|
||
addGameSystems,
|
||
type BehaviorSystem,
|
||
type Breakpoint,
|
||
type TimeScaled,
|
||
} from './systems/BehaviorSystem';
|
||
import type { Input } from './systems/Input';
|
||
import { bringObjects, keptEntities, objectsToBring } from './keptObjects';
|
||
import type { Rapier } from './physics/engine';
|
||
import { PlayedEnds } from './systems/playedEnds';
|
||
import { World } from './World';
|
||
|
||
export interface RunServices {
|
||
audio: AudioSink;
|
||
physics: Rapier;
|
||
clock: TimeScaled;
|
||
saves: SaveArea;
|
||
/** Outlives a `Load scene` and a restart, so a recording goes on through both (#405). */
|
||
recorder?: RecordingSink;
|
||
/** The view the run is shown in, asked whether a video has its first picture yet (#417). */
|
||
pictures?: VideoPictures;
|
||
/** Where a `Break` node pauses: only the editor's Play has one, a released game runs past it. */
|
||
breakpoint?: Breakpoint;
|
||
}
|
||
|
||
/** What a scene holds of objects, which is all a run writes into it. */
|
||
type StoredObjects = Pick<SceneAsset, 'objects' | 'groups'>;
|
||
|
||
export class GameRun {
|
||
/**
|
||
* Every scene this run built, as it was before: a run writes what it does into the scene it is in,
|
||
* and a scene loaded again has to be the stored one, not what was left of it.
|
||
*/
|
||
private readonly stored = new Map<string, StoredObjects>();
|
||
|
||
private constructor(
|
||
private readonly project: Project,
|
||
private readonly input: Input,
|
||
private readonly services: RunServices,
|
||
public world: World,
|
||
public behaviors: BehaviorSystem,
|
||
/** What the run played, heard ending in whatever scene it is in by then (#373). */
|
||
private readonly ends: PlayedEnds,
|
||
) {}
|
||
|
||
static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun {
|
||
const scene = findScene(project, sceneId);
|
||
const before = scene && structuredClone<StoredObjects>({ objects: scene.objects, groups: scene.groups });
|
||
// Every run starts with the buses as their assets have them (#325).
|
||
services.audio.resetMix();
|
||
services.audio.setMix(mixOf(project), videoBusesOf(project));
|
||
const world = World.instantiate(project, sceneId);
|
||
const ends = new PlayedEnds();
|
||
const behaviors = addGameSystems(world, project, input, { ...services, ends });
|
||
const run = new GameRun(project, input, services, world, behaviors, ends);
|
||
if (before) run.stored.set(sceneId, before);
|
||
return run;
|
||
}
|
||
|
||
/**
|
||
* One frame. `awake` is the step the clock would have moved without the game's own pause, which
|
||
* stops the clock but not the behaviors that run while paused. A `Load scene` is carried out here;
|
||
* stopping and restarting are the host's business, so they are only reported.
|
||
*/
|
||
tick(step: number, awake: number): 'stop' | 'restart' | 'scene' | null {
|
||
const world = this.world;
|
||
world.tick(world.paused ? awake : step);
|
||
this.input.endFrame();
|
||
if (world.stopRequested) return 'stop';
|
||
if (world.restartRequested) return 'restart';
|
||
if (!world.loadSceneRequested) return null;
|
||
this.loadScene(world.loadSceneRequested);
|
||
return 'scene';
|
||
}
|
||
|
||
/**
|
||
* Swaps the running scene for another one, inside the same run: the world is torn down and built
|
||
* again from the scene as it is stored, while what belongs to the run – the clock, the shared
|
||
* variables, the global machines – carries on. So do the objects that are kept (#165): they move
|
||
* into the next scene as they are, with their running behaviors and how their bodies move. The
|
||
* sounds of the old scene end with it, because the behaviors playing them are destroyed; a voice
|
||
* that was started detached is nobody's and plays on, and so does one of a kept behavior.
|
||
*/
|
||
loadScene(sceneId: string): void {
|
||
const scene = findScene(this.project, sceneId);
|
||
const from = findScene(this.project, this.world.sceneId);
|
||
if (!scene) throw new Error(`Scene ${sceneId} not found`);
|
||
const leaving = from
|
||
? objectsToBring(from, keptEntities(this.world.entities))
|
||
: { entities: [], objects: [] };
|
||
const carried = this.behaviors.shared;
|
||
const kept = this.behaviors.release(leaving.entities);
|
||
this.world.destroy();
|
||
this.asStored(scene);
|
||
const standIns = bringObjects(this.project, scene, leaving.objects);
|
||
this.world = World.instantiate(this.project, sceneId, {
|
||
brought: { entities: leaving.entities, standIns },
|
||
});
|
||
this.behaviors = addGameSystems(this.world, this.project, this.input, {
|
||
...this.services,
|
||
carried,
|
||
kept,
|
||
ends: this.ends,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ends the run: the world is torn down, and the scenes get back what they held before it. A host
|
||
* that runs on a copy it throws away has no use for the second half, one that plays the project
|
||
* itself does.
|
||
*/
|
||
end(): void {
|
||
this.world.destroy();
|
||
this.services.audio.resetMix();
|
||
for (const [sceneId, before] of this.stored) {
|
||
const scene = findScene(this.project, sceneId);
|
||
if (scene) restore(scene, before);
|
||
}
|
||
this.stored.clear();
|
||
}
|
||
|
||
/** Puts a scene back to what it stored before this run first built it; remembers that on the first time. */
|
||
private asStored(scene: SceneAsset): void {
|
||
const before = this.stored.get(scene.id);
|
||
if (before) restore(scene, before);
|
||
else this.stored.set(scene.id, structuredClone({ objects: scene.objects, groups: scene.groups }));
|
||
}
|
||
}
|
||
|
||
function restore(scene: SceneAsset, before: StoredObjects): void {
|
||
const { objects, groups } = structuredClone(before);
|
||
scene.objects = objects;
|
||
if (groups) scene.groups = groups;
|
||
else delete scene.groups;
|
||
}
|