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>
325 lines
12 KiB
TypeScript
325 lines
12 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 { 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 };
|
||
}
|