Files
feuerwerk/src/editor/PlaySession.ts
T
adminandClaude Opus 5 193378e6a9 An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)
Part (a) of the animation rework: model, commands, saving, runtime and nodes.

- AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one
  property of the root or of an object inside it, keys sit on whole frames.
- The last key decides the length (its frame counts as a frame of its own). The playhead runs
  between frames, so numbers and colors blend every tick even at 10 fps; everything else holds.
- Playback gains `once-reset`, which puts back what was there before the start. A row can be
  relative, adding to where its target stood.
- Play / Pause / Stop animation act on an object; each instance plays on its own, the later
  start wins on a shared property, and a behavior writing the same property still writes last.
- A sprite no longer wears an animation through `texture`. "Create animation from texture"
  builds a sprite plus a texture row keyed frame by frame instead.
- The Animation tab shows a placeholder until the timeline editor (part b) lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:02:25 +02:00

232 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { 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();
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;
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, undefined, soundLibrary);
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;
old.destroy();
const world = World.instantiate(runtime, sceneId);
this.world = world;
this.behaviors = addGameSystems(world, runtime, this.input, undefined, soundLibrary, carried);
this.host.gameView.setWorld(world);
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
}
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],
};
}
}