The agent could not look at a short moment of a running game. Its
calls took longer than the moment lasted, so an intro window from
0.5 to 2 s slipped past twice in the 5d test. Now the editor itself
stops the clock at the time the agent names.
- play takes until: play, resume or pause let the game run on to
that game time and hold it there. The call answers once the game
is paused, so get_runtime and screenshot show that moment.
- The frame that would step past the time is cut short
(frameWithin in src/core/time.ts, used in PlaySession). The game
stands at 0.5 s, not one frame later.
- play_input takes a last step {"pause": true}. After a wait it
holds the game at the time of the wait, after a key or click at
once. Its answer names the game time and whether it is paused.
- A pause that comes first (toolbar or a Break node), a Load scene
or a Restart game call the until off, and the answer says so.
- When the clock stands still, the call gives up after 25 s with
the time it is at. A tab in the background is refused at once.
- A recording that does not run in real time is not cut. There the
game stops in the first frame that reaches the time.
- get_runtime and play_input give times to the millisecond, not to
the hundredth, so 0.505 no longer reads as 0.51.
- The relay's instructions name both ways.
Tests:
- Unit agent-play (6 new) and time (2 new): 19/19, related editor
and relay tests 1158/1158.
- 6 counter-checks, all compile, all red. One stayed green at
first: 0.3 s is exactly 18 frames. The test now takes 0.31 s.
- e2e 71 (atx): ALL PASSED, 27 checks. New 71h3 holds at 0.505 s
and 0.8 s and after a wait of 0.31 s. Without the cut in
PlaySession it went red at 0.5166 s.
- Gate (atx): CHECK EXIT 0, 327 files, 5033 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
22 KiB
TypeScript
572 lines
22 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 { namedValue } from '../core/graph/values';
|
||
import { AudioSwitch } from '../runtime/audioSwitch';
|
||
import { GameRun, type RunServices } from '../runtime/GameRun';
|
||
import { AnimationSystem } from '../runtime/systems/AnimationSystem';
|
||
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
|
||
import type { AnimationPlayer } from '../core/model/animation';
|
||
import type { PropertyBag } from '../core/model/Properties';
|
||
import { VIDEO_WAIT_MS } from '../core/model/video';
|
||
import { frameWithin, gameClock } from '../core/time';
|
||
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
|
||
import { Input } from '../runtime/systems/Input';
|
||
import type { World } from '../runtime/World';
|
||
import { onFrame, ui } from '../ui';
|
||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||
import { GameRecorder } from '../viewport/GameRecorder';
|
||
import { consoleLine, type MadeRecording } from '../viewport/RecordingTake';
|
||
import type { RecordingUploadOptions } from '../core/recording/recording';
|
||
import { videoLibrary } from '../viewport/VideoLibrary';
|
||
import { FrameMeter, type FrameTimes } from './frameMeter';
|
||
import { saveArea } from './saves';
|
||
import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle';
|
||
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';
|
||
import { stopPreviews } from './music/transport';
|
||
|
||
/** 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;
|
||
/** The scene view: while a run holds the panels back, it still draws every frame it is on screen. */
|
||
viewport: { visible: boolean; redraw(): void };
|
||
graphEditor: GraphEditor;
|
||
stateMachineEditor: StateMachineEditor;
|
||
showGame(): void;
|
||
/** Leave the game view if it is the one on screen. */
|
||
leaveGame(): void;
|
||
/** A recording of the game view is made (#405): it goes up, and into the project as the node asked. */
|
||
recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): void;
|
||
}
|
||
|
||
export class PlaySession {
|
||
private run: GameRun | 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;
|
||
/** When the panels catch up with the run (#265). */
|
||
private readonly panels = new PanelClock();
|
||
/** How often the panels were brought up to date this run (test hook). */
|
||
private updates = { structure: 0, values: 0 };
|
||
/** Where the frames of this run go (#275). */
|
||
private readonly meter = new FrameMeter();
|
||
/**
|
||
* Until when the run waits at its start for the Game tab's driven videos (#404), by
|
||
* `performance.now()`; null once it goes. Its players are opened only at Play, and the first
|
||
* frames of a cut showed white without them.
|
||
*/
|
||
private videoWait: number | null = null;
|
||
/** Records the game view for `Start recording` (#405); one for the editor, so it outlives a restart. */
|
||
private readonly recorder: GameRecorder;
|
||
/** The sound of the run, diverted while a recording not in real time goes (#423). */
|
||
private readonly audio = new AudioSwitch(soundLibrary);
|
||
/** What a recording not in real time asked the run to move by this frame, see `recordingStep`. */
|
||
private recordStep: number | null = null;
|
||
/** When the notice last told how far such a recording has got, by `performance.now()`, and whether it did. */
|
||
private toldPace = 0;
|
||
private toldSteady = false;
|
||
/** `step` asked for a frame; `stepping` is set for the frame that takes it. */
|
||
private stepAsked = false;
|
||
private stepping = false;
|
||
/** The object whose `Break` node paused the run, until it is resumed or stepped on. */
|
||
private breakAt: string | null = null;
|
||
/** The time the agent wants the run held at (#435), on the world it named; a `Load scene` calls it off. */
|
||
private pausing: { world: World; time: number } | null = null;
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly host: PlayHost,
|
||
) {
|
||
this.recorder = new GameRecorder({
|
||
view: () => host.gameView,
|
||
log: (line, level) => {
|
||
this.world?.log.push(line);
|
||
consoleLine(line, level);
|
||
},
|
||
saved: (recording, options) => host.recordingSaved(recording, options),
|
||
audio: this.audio,
|
||
});
|
||
// An object picked during a run is looked at now: the hierarchy has to list it to show it picked.
|
||
state.on('selection', () => {
|
||
if (this.world && this.panels.holdsStructure) this.follow(this.panels.flush());
|
||
});
|
||
}
|
||
|
||
private get world(): World | null {
|
||
return this.run?.world ?? null;
|
||
}
|
||
|
||
private get behaviors(): BehaviorSystem | null {
|
||
return this.run?.behaviors ?? null;
|
||
}
|
||
|
||
get isPlaying(): boolean {
|
||
return this.world !== null;
|
||
}
|
||
|
||
get isPaused(): boolean {
|
||
return this.world !== null && this.state.paused;
|
||
}
|
||
|
||
/** Paused by a behavior (Pause game) rather than by the toolbar – the game's own pause. */
|
||
get isGamePaused(): boolean {
|
||
return this.world?.paused ?? false;
|
||
}
|
||
|
||
/**
|
||
* Held at its start until the Game tab has a picture of its driven videos (#404), and while a
|
||
* recording sets up its encoders.
|
||
*/
|
||
get isWaiting(): boolean {
|
||
return this.videoWait !== null || this.recorder.isStarting || this.recordStep === 0;
|
||
}
|
||
|
||
/**
|
||
* Called before the clock moves: the step a recording not in real time (#423) moves the run by
|
||
* this frame, 0 while it holds the run; null when the run keeps the frame's own time.
|
||
*/
|
||
recordingStep(): number | null {
|
||
this.recordStep =
|
||
this.world && !this.state.paused && this.videoWait === null ? this.recorder.step() : null;
|
||
return this.recordStep;
|
||
}
|
||
|
||
/** Pause or resume; before Play it switches whether Play starts paused. */
|
||
togglePause(): void {
|
||
if (!this.world) {
|
||
if (this.state.mode === 'edit') this.state.startPaused = !this.state.startPaused;
|
||
return;
|
||
}
|
||
this.state.setPaused(!this.state.paused);
|
||
this.breakAt = null;
|
||
this.pausing = null;
|
||
this.applyPause(true);
|
||
ui().focus.blur();
|
||
}
|
||
|
||
/** Holds the run once its time reaches `time` (#435), as if the pause button had been pressed; null calls it off. */
|
||
pauseAt(time: number | null): void {
|
||
this.pausing = time === null || !this.world ? null : { world: this.world, time };
|
||
}
|
||
|
||
/** Called before the clock moves: the frame cut short so the run does not go past the time `pauseAt` named. */
|
||
frameBeforePause(dt: number, scale: number): number {
|
||
const pausing = this.pausing;
|
||
if (!pausing || pausing.world !== this.world || this.state.paused || this.isGamePaused || this.isWaiting)
|
||
return dt;
|
||
return frameWithin(dt, scale, pausing.time - pausing.world.time);
|
||
}
|
||
|
||
/** Lets a paused run on by exactly one frame, at the next tick; does nothing unless paused. */
|
||
step(): void {
|
||
if (this.isPaused) this.stepAsked = true;
|
||
}
|
||
|
||
/**
|
||
* Called before the clock moves: true when this frame is the one `step` asked for. It then moves
|
||
* the run by one fixed step, whatever the frame took, and stays paused afterwards.
|
||
*/
|
||
beginFrame(): boolean {
|
||
this.stepping = this.stepAsked && this.isPaused;
|
||
this.stepAsked = false;
|
||
if (this.stepping) this.breakAt = null;
|
||
return this.stepping;
|
||
}
|
||
|
||
/** Puts sounds, videos, the recording and the panels in line with the toolbar's pause. */
|
||
private applyPause(flush: boolean): void {
|
||
const { paused } = this.state;
|
||
// What you inspect while paused is exact, whatever the panels were holding back.
|
||
if (paused && flush) this.follow(this.panels.flush());
|
||
// 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(paused);
|
||
videoLibrary.setPaused(paused);
|
||
this.recorder.setPaused(paused);
|
||
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?.syncFromModel();
|
||
}
|
||
|
||
/** The run as the agent drives and reads it (#429); null when nothing is playing. */
|
||
agentRun(): { world: World; input: Input; shared: SharedState } | null {
|
||
const world = this.world;
|
||
return world ? { world, input: this.input, shared: this.behaviors?.shared ?? emptySharedState() } : null;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** `paused`: whether the run starts held; a `Restart game` keeps what the run had instead. */
|
||
play(paused = this.state.startPaused): 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();
|
||
stopPreviews();
|
||
// A run starts at second zero, whatever the editor's previews have been counting up to.
|
||
gameClock.reset();
|
||
const runtime = this.state.enterPlay();
|
||
const services: RunServices = {
|
||
audio: this.audio,
|
||
physics,
|
||
clock: gameClock,
|
||
saves: saveArea(runtime.id, 'play'),
|
||
recorder: this.recorder,
|
||
pictures: { missing: (id) => this.host.gameView.missesVideoOf(id) },
|
||
breakpoint: (where) => (this.breakAt ??= where),
|
||
};
|
||
this.run = GameRun.start(runtime, scene.id, this.input, services);
|
||
videoLibrary.startRun();
|
||
this.updates = { structure: 0, values: 0 };
|
||
this.meter.reset();
|
||
this.timeSystems(this.run);
|
||
this.input.attach();
|
||
this.host.gameView.setWorld(this.run.world);
|
||
this.host.showGame();
|
||
this.waitForVideos();
|
||
this.recorder.prepare();
|
||
if (paused) {
|
||
this.state.setPaused(true);
|
||
this.applyPause(false);
|
||
} else this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (this.run.world.stopRequested) this.stop();
|
||
}
|
||
|
||
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
|
||
private sceneLoaded(run: GameRun): void {
|
||
this.timeSystems(run);
|
||
this.host.gameView.setWorld(run.world);
|
||
this.waitForVideos();
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (run.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');
|
||
},
|
||
);
|
||
}
|
||
|
||
/** The Game tab has drawn the new world once by now, which opened the players it shows. */
|
||
private waitForVideos(): void {
|
||
this.videoWait = this.host.gameView.videosLoading() ? performance.now() + VIDEO_WAIT_MS : null;
|
||
}
|
||
|
||
/** Whether the run still waits for its videos; the frame it stops waiting, the notice says so. */
|
||
private waitsForVideos(): boolean {
|
||
if (this.videoWait === null) return false;
|
||
if (performance.now() < this.videoWait && this.host.gameView.videosLoading()) return true;
|
||
this.videoWait = null;
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
return false;
|
||
}
|
||
|
||
/** While a recording not in real time goes (#423), the notice tells how far it has got, twice a second. */
|
||
private tellPace(): void {
|
||
const now = performance.now();
|
||
const steady = this.recordStep !== null;
|
||
if (steady === this.toldSteady && (!steady || now - this.toldPace < 500)) return;
|
||
this.toldSteady = steady;
|
||
this.toldPace = now;
|
||
this.state.setNotice(this.playNotice(), 'quiet');
|
||
}
|
||
|
||
private playNotice(): string {
|
||
if (this.videoWait !== null) return 'Loading the videos… · Esc stops';
|
||
const steady = this.recorder.steadyPace();
|
||
if (steady && !this.state.paused)
|
||
return (
|
||
`Recording "${steady.name}" not in real time: ${steady.seconds.toFixed(1)} s of video, ` +
|
||
`${steady.speed.toFixed(2)}x real time · Esc stops`
|
||
);
|
||
if (this.state.paused) {
|
||
const why = this.breakAt === null ? 'Paused' : `Break in ${this.breakAt}`;
|
||
return `${why} · Ctrl+Enter resumes · Ctrl+Shift+Enter steps · Esc stops`;
|
||
}
|
||
if (this.world?.paused) return 'The game paused itself · 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';
|
||
}
|
||
|
||
/** `restarting`: a `Restart game` plays on at once, and a recording goes on with it. */
|
||
stop(restarting = false): void {
|
||
if (!restarting) {
|
||
this.recorder.end();
|
||
this.recorder.setPaused(false);
|
||
}
|
||
// 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);
|
||
this.audio.stop('');
|
||
this.recordStep = null;
|
||
videoLibrary.endRun();
|
||
this.videoWait = null;
|
||
this.stepAsked = false;
|
||
this.breakAt = null;
|
||
this.pausing = null;
|
||
const world = this.world;
|
||
this.run = 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. `awake` is the
|
||
* step it would have moved without the game's own pause, which stops the clock but not the
|
||
* behaviors that run while paused.
|
||
*/
|
||
tick(step: number, awake: number): void {
|
||
const run = this.run;
|
||
if (!run) return;
|
||
const world = run.world;
|
||
this.tellPace();
|
||
if (
|
||
(this.state.paused && !this.stepping) ||
|
||
this.waitsForVideos() ||
|
||
this.recorder.isStarting ||
|
||
this.recordStep === 0
|
||
) {
|
||
// 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;
|
||
}
|
||
const wasPaused = world.paused;
|
||
world.held = this.state.heldObjectIds;
|
||
const request = run.tick(step, awake);
|
||
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
|
||
if (request === 'stop') {
|
||
this.stop();
|
||
return;
|
||
}
|
||
if (request === 'restart') {
|
||
const held = this.state.paused || this.breakAt !== null;
|
||
this.stop(true);
|
||
this.play(held);
|
||
return;
|
||
}
|
||
if (request === 'scene') {
|
||
this.sceneLoaded(run);
|
||
this.pauseAtBreak();
|
||
return;
|
||
}
|
||
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
|
||
// edit made during the run lands on the model, and it must not land on an old picture of it.
|
||
const structure = world.syncToModel().structure;
|
||
const update = this.panels.frame(
|
||
panelPolicy(this.state.project.whilePlaying),
|
||
performance.now(),
|
||
structure,
|
||
);
|
||
// A game that pauses itself is looked at like one paused from the toolbar, and so is a step.
|
||
this.follow((world.paused && !wasPaused) || this.stepping ? this.panels.flush() : update);
|
||
this.pauseAtBreak();
|
||
this.pauseWhenDue();
|
||
}
|
||
|
||
/** The run reached the time `pauseAt` named: it holds there. A pause that came first calls it off. */
|
||
private pauseWhenDue(): void {
|
||
const pausing = this.pausing;
|
||
if (!pausing) return;
|
||
if (pausing.world !== this.world || this.state.paused) {
|
||
this.pausing = null;
|
||
return;
|
||
}
|
||
if (pausing.world.time < pausing.time - 1e-9) return;
|
||
this.pausing = null;
|
||
this.state.setPaused(true);
|
||
this.applyPause(true);
|
||
}
|
||
|
||
/**
|
||
* A `Break` node ran this frame: the run holds as if the pause button had been pressed. A step
|
||
* stays held anyway, and the notice says whether it ran into a break.
|
||
*/
|
||
private pauseAtBreak(): void {
|
||
if (this.state.paused) {
|
||
if (this.stepping) this.state.setNotice(this.playNotice(), 'quiet');
|
||
} else if (this.breakAt !== null) {
|
||
this.state.setPaused(true);
|
||
this.applyPause(true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Called once everything that draws the game view this frame is queued: a recording takes the
|
||
* frame after all of it, behind another tab too.
|
||
*/
|
||
recordFrame(): void {
|
||
if (!this.recorder.isRecording || !this.world) return;
|
||
onFrame('recording', () => {
|
||
if (!this.host.gameView.visible) this.host.gameView.redrawHidden();
|
||
this.recorder.capture();
|
||
});
|
||
}
|
||
|
||
/** What became of the recordings (#405, test hook). */
|
||
recordingInfo(): ReturnType<GameRecorder['info']> {
|
||
return this.recorder.info();
|
||
}
|
||
|
||
/** Brings the panels up to date as far as `update` says; the game's own picture never waits. */
|
||
private follow(update: PanelUpdate): void {
|
||
if (update.structure) {
|
||
this.updates.structure++;
|
||
this.state.preview();
|
||
}
|
||
if (update.values) {
|
||
this.updates.values++;
|
||
this.state.runtimeTick();
|
||
} else {
|
||
// The views draw on `runtime`, and what is on screen of the game has to move every frame.
|
||
if (this.host.gameView.visible) this.host.gameView.redraw();
|
||
if (this.host.viewport.visible) this.host.viewport.redraw();
|
||
}
|
||
if (!update.graphs) return;
|
||
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();
|
||
}
|
||
|
||
/** The world tells the meter what each system took; a `Load scene` brings a world of its own. */
|
||
private timeSystems(run: GameRun): void {
|
||
run.world.timeSystem = (system, ms) => {
|
||
const area =
|
||
system === run.behaviors ? 'behaviors' : system instanceof AnimationSystem ? 'animation' : 'physics';
|
||
this.meter.addSystem(area, ms);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Closes the editor's frame for the meter (see `FrameMeter.endFrame`); true when a new average is
|
||
* ready. Nothing is measured while nothing plays.
|
||
*/
|
||
measureFrame(dt: number, busy: number, drawn: number, rendered: number): boolean {
|
||
return this.isPlaying && this.meter.endFrame(dt, busy, drawn, rendered);
|
||
}
|
||
|
||
/** How many entities run and where the frames went lately; null while nothing plays. */
|
||
performance(): { entities: number; times: FrameTimes | null } | null {
|
||
const world = this.world;
|
||
return world ? { entities: world.entities.length, times: this.meter.average } : null;
|
||
}
|
||
|
||
/** How often the panels caught up with this run, by kind (test hook). */
|
||
panelUpdates(): { structure: number; values: number } {
|
||
return { ...this.updates };
|
||
}
|
||
|
||
/** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */
|
||
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: namedBag(e.props),
|
||
animations: structuredClone(e.animations ?? []),
|
||
};
|
||
}),
|
||
shared: namedShared(this.behaviors?.shared ?? emptySharedState()),
|
||
log: [...world.log],
|
||
};
|
||
}
|
||
}
|
||
|
||
function namedBag(bag: PropertyBag): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(bag)) out[key] = namedValue(value);
|
||
return out;
|
||
}
|
||
|
||
function namedShared(shared: SharedState): SharedState {
|
||
const variables: Record<string, PropertyBag> = {};
|
||
for (const [id, bag] of Object.entries(shared.variables)) variables[id] = namedBag(bag) as PropertyBag;
|
||
return { variables, states: shared.states };
|
||
}
|