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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 13:30:38 +02:00
1 parent 94c1055895
commit 4194f3e150
42 files changed
+1600 -246

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
import { Text } from 'pixi.js';
import type { World } from '../runtime/World';
import { Theme, textStyle, ui } from '../ui';
import { cameraOfWorld } from '../viewport/activeCamera';
import { routePointer } from '../viewport/gamePointer';
import { collectWorldItems } from '../viewport/items';
import { cameraView, type View } from '../viewport/view';
import { type Frame, type ViewSource, WorldViewport } from '../viewport/WorldViewport';
/**
* A released game on screen (#266): the running world through its active camera, at the largest
* zoom step that fits (Auto), letterboxed in the page colour. Before the game runs, and after it
* stopped itself, it shows a line of text instead – "Click to play", or why there is nothing to play.
*/
export class GameScreen extends WorldViewport {
private readonly caption = new Text({ text: '', style: textStyle('title') });
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
private running: World | null = null;
/** Asked when the caption is clicked while it offers to start. */
onStart: (() => void) | null = null;
private startable = false;
constructor(source: ViewSource) {
super(source);
for (const text of [this.caption, this.hint]) {
text.eventMode = 'none';
text.anchor.set(0.5);
this.addChild(text);
}
routePointer(
this,
() => this.running,
() => this.currentView(),
);
this.on('pointerdown', (e) => {
if (e.button === 0 && !this.running && this.startable) this.onStart?.();
});
}
/** What the screen says while no game runs; `startable` makes a click on it start one. */
say(text: string, startable = false): void {
this.caption.text = text;
this.startable = startable;
this.cursor = startable ? 'pointer' : 'default';
this.redraw();
}
get canStart(): boolean {
return this.startable && !this.running;
}
/** null = not running. */
setWorld(world: World | null): void {
this.running = world;
this.redraw();
}
/**
* What the screen shows, for the check scripts: the caption while no game runs, and the camera's
* picture as it lies on screen – world units per CSS pixel and the frame in global CSS pixels.
*/
info(): {
caption: string | null;
scale: number | null;
frame: { x: number; y: number; w: number; h: number } | null;
} {
const view = this.currentView();
const origin = this.getGlobalPosition();
return {
caption: this.running ? null : this.caption.text,
scale: view?.scale ?? null,
frame: view ? { ...view.rect, x: origin.x + view.rect.x, y: origin.y + view.rect.y } : null,
};
}
protected override currentView(): View | null {
const camera = this.running ? cameraOfWorld(this.running) : null;
return camera ? cameraView(camera, this.w, this.h, 'auto', ui().app.renderer.resolution) : null;
}
protected override collectFrame(): Frame {
return this.running ? collectWorldItems(this.running) : { items: [], background: this.baseColor };
}
protected override get baseColor(): number {
return Theme.colors.background;
}
protected override beforeItems(view: View | null): void {
this.caption.visible = this.running === null;
this.caption.position.set(this.w / 2, this.h / 2);
this.hint.visible = this.running !== null && view === null;
this.hint.position.set(this.w / 2, this.h / 2);
}
}
+95
View File
@@ -0,0 +1,95 @@
import type { SaveArea } from '../core/graph/savedValues';
import { type Project, startScene } from '../core/model/Project';
import { gameClock, timeStep } from '../core/time';
import { GameRun } from '../runtime/GameRun';
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { behaviorError } from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input';
import { requestRender } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import type { GameScreen } from './GameScreen';
/**
* Plays a released game (#266): from the project's start scene, on a click, until the game stops
* itself – then the screen offers to play again. What the editor's Play adds on top (panels, pause
* from a toolbar, the scene the editor has open) has no place here.
*/
export class Player {
private run: GameRun | null = null;
private readonly input = new Input();
constructor(
private readonly project: Project,
private readonly screen: GameScreen,
private readonly saves: SaveArea,
) {
screen.onStart = () => this.start();
}
get isRunning(): boolean {
return this.run !== null;
}
/** What `Log` wrote this run, oldest first; empty while none runs. */
log(): string[] {
return [...(this.run?.world.log ?? [])];
}
/** Loads what a run needs, then offers to start one. */
async prepare(): Promise<void> {
if (!startScene(this.project)) {
this.screen.say('This game has no scene to play.');
return;
}
this.screen.say('Loading…');
await loadPhysics();
this.screen.say('Click to play', true);
}
start(): void {
const scene = startScene(this.project);
const physics = readyPhysics();
if (this.run || !scene || !physics) return;
gameClock.reset();
this.run = GameRun.start(this.project, scene.id, this.input, {
audio: soundLibrary,
physics,
clock: gameClock,
saves: this.saves,
});
this.input.attach();
this.screen.setWorld(this.run.world);
if (this.run.world.stopRequested) this.stop();
}
stop(): void {
const run = this.run;
if (!run) return;
this.run = null;
this.input.detach();
soundLibrary.stopAll();
gameClock.reset();
const error = behaviorError(run.world);
if (error) console.error('[game]', error);
run.world.destroy();
this.screen.setWorld(null);
this.screen.say('Click to play again', true);
}
tick(dt: number): void {
const run = this.run;
if (!run) return;
const step = gameClock.advance(dt, run.world.paused);
const request = run.tick(step, timeStep(dt, gameClock.scale, false));
if (request === 'stop') this.stop();
else if (request === 'restart') {
this.stop();
this.start();
} else if (request === 'scene') {
this.screen.setWorld(run.world);
if (run.world.stopRequested) this.stop();
}
if (this.run) this.screen.redraw();
requestRender();
}
}
+17
View File
@@ -0,0 +1,17 @@
import { stagePixel, ui } from '../ui';
import type { GameScreen } from './GameScreen';
import type { Player } from './Player';
/**
* `window.__gameMakerPlay`: what the check scripts read off a released game (#266). The editor's
* `__gm` is not on this page, and neither is anything it would have to reach into. Installed before
* the game is loaded, so a link that leads nowhere can be read as well: `player` is null until then.
*/
export function installPlayerHook(screen: GameScreen, player: () => Player | null): void {
(window as unknown as { __gameMakerPlay: unknown }).__gameMakerPlay = {
running: () => player()?.isRunning ?? false,
screen: () => screen.info(),
log: () => player()?.log() ?? [],
pixelAt: (x: number, y: number) => stagePixel(ui().app, x, y),
};
}