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>
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
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);
|
||
}
|
||
}
|