Files
feuerwerk/src/editor/PlaySession.ts
T
adminandClaude Opus 5.5 4194f3e150 Share a link that plays the game without the editor (#266)
A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.

- Menu -> Release... opens a dialog with the state of the release,
  the link with Copy link and Open, Publish or Update release, and
  Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
  snapshots. Updating keeps the link. Take down kills it, and a new
  Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
  paints Theme.colors.background, zooms with Auto and waits for
  "Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
  are stored per link in the browser. Plugins are not loaded, and the
  dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
  without a login. The public answer carries the token as project.id,
  so the project id stays hidden. Release files are written with 0640
  and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
  Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
  EditorState. Pointer forwarding (gamePointer.ts) and setting up
  textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
  plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
  without the editor.

Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.

Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
  session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:30:38 +02:00

325 lines
12 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 { namedValue } from '../core/graph/values';
import { GameRun, type RunServices } from '../runtime/GameRun';
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 { gameClock } from '../core/time';
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { Input } from '../runtime/systems/Input';
import type { World } from '../runtime/World';
import { ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
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';
/** 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;
}
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 };
constructor(
private readonly state: EditorState,
private readonly host: PlayHost,
) {
// 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;
}
/** Pause or resume; does nothing while not playing. */
togglePause(): void {
if (!this.world) return;
this.state.setPaused(!this.state.paused);
// What you inspect while paused is exact, whatever the panels were holding back.
if (this.state.paused) 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(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?.syncFromModel();
}
/** 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 services: RunServices = {
audio: soundLibrary,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
};
this.run = GameRun.start(runtime, scene.id, this.input, services);
this.updates = { structure: 0, values: 0 };
this.input.attach();
this.host.gameView.setWorld(this.run.world);
this.host.showGame();
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.host.gameView.setWorld(run.world);
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');
},
);
}
private playNotice(): string {
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · 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';
}
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.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;
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;
}
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') {
this.stop();
this.play();
return;
}
if (request === 'scene') {
this.sceneLoaded(run);
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.
this.follow(world.paused && !wasPaused ? this.panels.flush() : update);
}
/** 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();
}
/** 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 };
}