/** * 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; 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(); 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({ 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; }