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>
59 lines
2.3 KiB
TypeScript
59 lines
2.3 KiB
TypeScript
import type { ReleaseSummary } from '../core/store/ReleaseStore';
|
||
import { Button, HStack, Label, Popup, TextField, Theme } from '../ui';
|
||
import { dialogButtons } from './FormDialog';
|
||
|
||
const LINK_WIDTH = 420;
|
||
|
||
/**
|
||
* "Release…" (#266): the link that plays only the game, with Copy and Open, and Publish – or Update
|
||
* release and Take down once there is one. Every action closes the dialog and hands the choice on;
|
||
* the caller opens it again on the new state.
|
||
*/
|
||
export class ReleaseDialog extends Popup {
|
||
private readonly linkField: TextField | null = null;
|
||
private readonly shown: { state: string; warnings: string[] };
|
||
|
||
constructor(
|
||
release: ReleaseSummary | null,
|
||
link: string | null,
|
||
state: string,
|
||
warnings: readonly string[],
|
||
actions: {
|
||
onPublish(): void;
|
||
onTakeDown(): void;
|
||
onCopy(link: string): void;
|
||
onOpen(link: string): void;
|
||
},
|
||
) {
|
||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||
this.minWidth = LINK_WIDTH;
|
||
this.shown = { state, warnings: [...warnings] };
|
||
this.content.add(new Label('Release', { style: 'bodyBold' }));
|
||
this.content.add(new Label(state, { style: 'muted' }));
|
||
|
||
if (link) {
|
||
this.linkField = new TextField(link, { readOnly: true, width: LINK_WIDTH });
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.add(this.linkField, 1);
|
||
row.add(new Button('Copy link', { onClick: () => actions.onCopy(link) }));
|
||
row.add(new Button('Open', { onClick: () => actions.onOpen(link) }));
|
||
this.content.add(row);
|
||
}
|
||
for (const warning of warnings) this.content.add(new Label(warning, { style: 'warning' }));
|
||
|
||
const publish = new Button(release ? 'Update release' : 'Publish', {
|
||
variant: 'primary',
|
||
onClick: this.closeThen(() => actions.onPublish()),
|
||
});
|
||
const takeDown = release
|
||
? new Button('Take down', { variant: 'danger', onClick: this.closeThen(() => actions.onTakeDown()) })
|
||
: undefined;
|
||
this.content.add(dialogButtons(this.closeThen(), publish, takeDown));
|
||
}
|
||
|
||
/** The link as shown – null while there is none – the state line and the warnings (test hook). */
|
||
info(): { link: string | null; state: string; warnings: string[] } {
|
||
return { link: this.linkField?.value ?? null, ...this.shown, warnings: [...this.shown.warnings] };
|
||
}
|
||
}
|