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:
1 parent
94c1055895
commit
4194f3e150
42 files changed
+1600
-246
No files matched your search
@@ -9,10 +9,13 @@ import {
|
||||
SetWhilePlayingCommand,
|
||||
} from '../core/commands/projectCommands';
|
||||
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
|
||||
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
|
||||
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
|
||||
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
|
||||
import { dialogSlot } from './FormDialog';
|
||||
import { OpenProjectDialog } from './OpenProjectDialog';
|
||||
import { ReleaseDialog } from './ReleaseDialog';
|
||||
import { releaseState, releaseWarnings } from './releases';
|
||||
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
|
||||
import { clearSaved, hasSaved } from './saves';
|
||||
import { snapshotBlocker, snapshotTime } from './snapshots';
|
||||
@@ -23,6 +26,7 @@ export class ProjectSession {
|
||||
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
|
||||
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
||||
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
|
||||
private readonly releaseDialog = dialogSlot<ReleaseDialog>();
|
||||
|
||||
/**
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
||||
@@ -32,8 +36,11 @@ export class ProjectSession {
|
||||
private readonly state: EditorState,
|
||||
private readonly store: ProjectStore,
|
||||
private readonly beforeSwitch: () => void,
|
||||
/** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */
|
||||
private readonly snapshots: { store: SnapshotStore; flush: () => Promise<void> },
|
||||
/**
|
||||
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
|
||||
* what the autosave still holds, so a copy is of the stand on screen.
|
||||
*/
|
||||
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
|
||||
/**
|
||||
* Runs on the project as it came off the server, before it is read into the model: the plugins
|
||||
* this project switches on are loaded there (#119), so that what they register is in place
|
||||
@@ -102,6 +109,7 @@ export class ProjectSession {
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
{ separator: true },
|
||||
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
|
||||
{ label: 'Release…', onSelect: () => this.openRelease() },
|
||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||
...more,
|
||||
@@ -154,7 +162,7 @@ export class ProjectSession {
|
||||
const projectId = this.state.project.id;
|
||||
void this.run(
|
||||
(async () => {
|
||||
const list = await this.snapshots.store.list(projectId);
|
||||
const list = await this.copies.snapshots.list(projectId);
|
||||
this.snapshotsDialog.open(
|
||||
new SnapshotsDialog(list, {
|
||||
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
|
||||
@@ -171,14 +179,80 @@ export class ProjectSession {
|
||||
return this.snapshotsDialog.current();
|
||||
}
|
||||
|
||||
/** The link that plays only the game (#266). */
|
||||
openRelease(): void {
|
||||
const projectId = this.state.project.id;
|
||||
void this.run(
|
||||
(async () => {
|
||||
const release = await this.copies.releases.of(projectId);
|
||||
const link = release && releaseUrl(location.origin, release.token);
|
||||
this.releaseDialog.open(
|
||||
new ReleaseDialog(
|
||||
release,
|
||||
link,
|
||||
releaseState(release, this.state.revision),
|
||||
releaseWarnings(this.state.project),
|
||||
{
|
||||
onPublish: () => void this.run(this.publish(projectId, release !== null)),
|
||||
onTakeDown: () => this.confirmTakeDown(projectId),
|
||||
onCopy: (url) => void this.run(this.copyLink(url)),
|
||||
onOpen: (url) => window.open(url, '_blank', 'noopener'),
|
||||
},
|
||||
),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The open release dialog, for the check scripts. */
|
||||
releaseView(): ReleaseDialog | null {
|
||||
return this.releaseDialog.current();
|
||||
}
|
||||
|
||||
/** Publishing copies what the server has stored, so everything on screen is saved first. */
|
||||
private async publish(projectId: string, again: boolean): Promise<void> {
|
||||
await this.copies.flush();
|
||||
const blocker = snapshotBlocker(this.state.saveStatus, 'release');
|
||||
if (blocker) {
|
||||
this.state.setNotice(blocker, 'error');
|
||||
return;
|
||||
}
|
||||
await this.copies.releases.publish(projectId);
|
||||
this.state.setNotice(again ? 'Release updated' : 'Released – the link plays the game');
|
||||
this.openRelease();
|
||||
}
|
||||
|
||||
private confirmTakeDown(projectId: string): void {
|
||||
new Dialog('Take the release down?', 'The link stops working. Publishing again makes a new one.', [
|
||||
{ label: 'Cancel', onClick: () => this.openRelease() },
|
||||
{
|
||||
label: 'Take down',
|
||||
danger: true,
|
||||
onClick: () =>
|
||||
void this.run(
|
||||
(async () => {
|
||||
await this.copies.releases.takeDown(projectId);
|
||||
this.state.setNotice('Release taken down');
|
||||
this.openRelease();
|
||||
})(),
|
||||
),
|
||||
},
|
||||
]).openCentered();
|
||||
}
|
||||
|
||||
private async copyLink(url: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.state.setNotice('Link copied');
|
||||
}
|
||||
|
||||
private async createSnapshot(projectId: string, name: string): Promise<void> {
|
||||
await this.snapshots.flush();
|
||||
await this.copies.flush();
|
||||
const blocker = snapshotBlocker(this.state.saveStatus);
|
||||
if (blocker) {
|
||||
this.state.setNotice(blocker, 'error');
|
||||
return;
|
||||
}
|
||||
const made = await this.snapshots.store.create(projectId, name || snapshotTime(new Date()));
|
||||
const made = await this.copies.snapshots.create(projectId, name || snapshotTime(new Date()));
|
||||
this.state.setNotice(`Snapshot "${made.name}" created`);
|
||||
this.openSnapshots();
|
||||
}
|
||||
@@ -201,14 +275,14 @@ export class ProjectSession {
|
||||
/** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */
|
||||
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
|
||||
this.beforeSwitch();
|
||||
await this.snapshots.flush();
|
||||
await this.copies.flush();
|
||||
const blocker = snapshotBlocker(this.state.saveStatus);
|
||||
if (blocker) {
|
||||
this.state.setNotice(blocker, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { before } = await this.snapshots.store.restore(projectId, snapshot.id, this.state.revision);
|
||||
const { before } = await this.copies.snapshots.restore(projectId, snapshot.id, this.state.revision);
|
||||
await this.open(projectId);
|
||||
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
|
||||
} catch (error) {
|
||||
@@ -227,7 +301,7 @@ export class ProjectSession {
|
||||
onClick: () =>
|
||||
void this.run(
|
||||
(async () => {
|
||||
await this.snapshots.store.delete(projectId, snapshot.id);
|
||||
await this.copies.snapshots.delete(projectId, snapshot.id);
|
||||
this.openSnapshots();
|
||||
})(),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user