261 lines
8.9 KiB
TypeScript
261 lines
8.9 KiB
TypeScript
/**
|
||
* Owns everything that only exists while the project is playing: the world, its systems, the
|
||
* keyboard and the per-frame step. Play never touches the saved model – the world is built
|
||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||
*/
|
||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||
import {
|
||
addGameSystems,
|
||
type BehaviorSystem,
|
||
behaviorError,
|
||
type Trace,
|
||
} from '../runtime/systems/BehaviorSystem';
|
||
import type { AnimationPlayer } from '../core/model/animation';
|
||
import type { PropertyBag } from '../core/model/Properties';
|
||
import { gameClock } from '../core/time';
|
||
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
|
||
import { Input } from '../runtime/systems/Input';
|
||
import { World } from '../runtime/World';
|
||
import { ui } from '../ui';
|
||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||
import type { GameViewport } from './viewport/GameViewport';
|
||
import type { GraphEditor } from './graph/GraphEditor';
|
||
import type { LiveMachineState } from './graph/GraphCanvas';
|
||
import type { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||
import type { EditorState } from './state';
|
||
|
||
/** Facts about the running world for the check scripts. */
|
||
export interface WorldSnapshot {
|
||
entities: {
|
||
objectId: string;
|
||
assetId: string;
|
||
name: string;
|
||
x: number;
|
||
y: number;
|
||
worldX: number;
|
||
worldY: number;
|
||
parent: string;
|
||
props: Record<string, unknown>;
|
||
animations: AnimationPlayer[];
|
||
}[];
|
||
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
|
||
shared: SharedState;
|
||
log: string[];
|
||
}
|
||
|
||
export interface PlayHost {
|
||
gameView: GameViewport;
|
||
graphEditor: GraphEditor;
|
||
stateMachineEditor: StateMachineEditor;
|
||
showGame(): void;
|
||
/** Leave the game view if it is the one on screen. */
|
||
leaveGame(): void;
|
||
}
|
||
|
||
export class PlaySession {
|
||
private world: World | null = null;
|
||
private behaviors: BehaviorSystem | null = null;
|
||
private readonly input = new Input();
|
||
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
|
||
private waiting = false;
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly host: PlayHost,
|
||
) {}
|
||
|
||
get isPlaying(): boolean {
|
||
return this.world !== null;
|
||
}
|
||
|
||
get isPaused(): boolean {
|
||
return this.world !== null && this.state.paused;
|
||
}
|
||
|
||
/** Pause or resume; does nothing while not playing. */
|
||
togglePause(): void {
|
||
if (!this.world) return;
|
||
this.state.setPaused(!this.state.paused);
|
||
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
|
||
// on mid-note, rather than playing to their end over a still image.
|
||
soundLibrary.setSuspended(this.state.paused);
|
||
ui().focus.blur();
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
}
|
||
|
||
traceFor(assetId: string): Trace | null {
|
||
return this.behaviors?.traceFor(assetId) ?? null;
|
||
}
|
||
|
||
/** Which state each running instance of a machine holds; empty when nothing is playing. */
|
||
machineStates(machineId: string): LiveMachineState[] {
|
||
return this.behaviors?.machineStates(machineId) ?? [];
|
||
}
|
||
|
||
get time(): number {
|
||
return this.world?.time ?? 0;
|
||
}
|
||
|
||
/** The scene the run is in – not the editor's active one, which a `Load scene` leaves alone. */
|
||
get sceneId(): string {
|
||
return this.world?.sceneId ?? '';
|
||
}
|
||
|
||
syncFromModel(): void {
|
||
this.world?.syncFromModel();
|
||
this.behaviors?.syncDeclarations();
|
||
}
|
||
|
||
/** The values a Variables asset shares in this run; null when nothing is playing. */
|
||
liveVariables(assetId: string): PropertyBag | null {
|
||
return this.behaviors?.liveVariables(assetId) ?? null;
|
||
}
|
||
|
||
play(): void {
|
||
const scene = this.state.sceneToPlay();
|
||
if (!scene) return;
|
||
// The editor loads the engine right after opening, so it is almost always there already; a game
|
||
// without it would collide differently, so Play waits for it rather than running without (#146).
|
||
const physics = readyPhysics();
|
||
if (!physics) {
|
||
this.waitForPhysics();
|
||
return;
|
||
}
|
||
ui().focus.blur();
|
||
// A run starts at second zero, whatever the editor's previews have been counting up to.
|
||
gameClock.reset();
|
||
const runtime = this.state.enterPlay();
|
||
const world = World.instantiate(runtime, scene.id);
|
||
this.world = world;
|
||
this.input.attach();
|
||
this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, physics });
|
||
this.host.gameView.setWorld(world);
|
||
this.host.showGame();
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (world.stopRequested) this.stop();
|
||
}
|
||
|
||
/**
|
||
* Swaps the running scene for another one, inside the same run: the world is torn down and built
|
||
* again, while what belongs to the run – the clock, the shared variables, the global machines –
|
||
* carries on. 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.
|
||
*/
|
||
private loadScene(sceneId: string): void {
|
||
const old = this.world;
|
||
const runtime = this.state.runtimeProject;
|
||
if (!old || !runtime) return;
|
||
const carried = this.behaviors?.shared ?? null;
|
||
const physics = readyPhysics();
|
||
old.destroy();
|
||
const world = World.instantiate(runtime, sceneId);
|
||
this.world = world;
|
||
this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, carried, physics });
|
||
this.host.gameView.setWorld(world);
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (world.stopRequested) this.stop();
|
||
}
|
||
|
||
private waitForPhysics(): void {
|
||
if (this.waiting) return;
|
||
this.waiting = true;
|
||
this.state.setNotice('Loading the physics engine…', 'quiet');
|
||
loadPhysics().then(
|
||
() => {
|
||
this.waiting = false;
|
||
if (this.state.mode === 'edit') this.play();
|
||
},
|
||
(error: unknown) => {
|
||
this.waiting = false;
|
||
const reason = error instanceof Error ? error.message : String(error);
|
||
console.error('[physics] Play needs the physics engine, which could not be loaded:', error);
|
||
this.state.setNotice(`Play needs the physics engine, which could not be loaded: ${reason}`, 'error');
|
||
},
|
||
);
|
||
}
|
||
|
||
private playNotice(): string {
|
||
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
|
||
return this.world?.activeCamera()
|
||
? 'Playing · edits are not saved · Ctrl+Enter pauses · Esc stops'
|
||
: 'No camera is switched on – nothing is rendered · Esc stops';
|
||
}
|
||
|
||
stop(): void {
|
||
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
|
||
gameClock.reset();
|
||
// And its sounds end with it, looping music included – nothing may outlive the world.
|
||
soundLibrary.setSuspended(false);
|
||
soundLibrary.stopAll();
|
||
const world = this.world;
|
||
this.world = null;
|
||
this.behaviors = null;
|
||
this.input.detach();
|
||
ui().focus.blur();
|
||
world?.destroy();
|
||
this.host.gameView.setWorld(null);
|
||
this.state.exitPlay();
|
||
this.host.leaveGame();
|
||
const lastError = world && behaviorError(world);
|
||
this.state.setNotice(lastError ?? '', 'error');
|
||
}
|
||
|
||
/** `step` is what the global clock moved this frame – already scaled and capped. */
|
||
tick(step: number): void {
|
||
const world = this.world;
|
||
if (!world) return;
|
||
if (this.state.paused) {
|
||
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
|
||
// resuming would deliver every one of them in a single frame.
|
||
this.input.endFrame();
|
||
world.dropPointerEdges();
|
||
return;
|
||
}
|
||
world.tick(step);
|
||
this.input.endFrame();
|
||
if (world.stopRequested) {
|
||
this.stop();
|
||
return;
|
||
}
|
||
if (world.restartRequested) {
|
||
this.stop();
|
||
this.play();
|
||
return;
|
||
}
|
||
if (world.loadSceneRequested) {
|
||
this.loadScene(world.loadSceneRequested);
|
||
return;
|
||
}
|
||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||
if (world.syncToModel().structure) this.state.preview();
|
||
this.state.runtimeTick();
|
||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
|
||
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
|
||
}
|
||
|
||
snapshot(): WorldSnapshot | null {
|
||
const world = this.world;
|
||
if (!world) return null;
|
||
return {
|
||
entities: world.entities.map((e) => {
|
||
const w = worldPositionOf(e);
|
||
return {
|
||
objectId: e.objectId,
|
||
assetId: e.assetId,
|
||
name: e.name,
|
||
x: e.x,
|
||
y: e.y,
|
||
worldX: w.x,
|
||
worldY: w.y,
|
||
parent: e.parent?.objectId ?? '',
|
||
props: { ...e.props },
|
||
animations: structuredClone(e.animations ?? []),
|
||
};
|
||
}),
|
||
shared: this.behaviors?.shared ?? emptySharedState(),
|
||
log: [...world.log],
|
||
};
|
||
}
|
||
}
|