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
+34
-53
@@ -5,18 +5,14 @@
|
||||
*/
|
||||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||||
import { namedValue } from '../core/graph/values';
|
||||
import {
|
||||
addGameSystems,
|
||||
type BehaviorSystem,
|
||||
behaviorError,
|
||||
type Trace,
|
||||
} from '../runtime/systems/BehaviorSystem';
|
||||
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 { World } from '../runtime/World';
|
||||
import type { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { saveArea } from './saves';
|
||||
@@ -58,8 +54,7 @@ export interface PlayHost {
|
||||
}
|
||||
|
||||
export class PlaySession {
|
||||
private world: World | null = null;
|
||||
private behaviors: BehaviorSystem | null = null;
|
||||
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;
|
||||
@@ -78,6 +73,14 @@ export class PlaySession {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -146,47 +149,26 @@ export class PlaySession {
|
||||
// A run starts at second zero, whatever the editor's previews have been counting up to.
|
||||
gameClock.reset();
|
||||
const runtime = this.state.enterPlay();
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
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.behaviors = addGameSystems(world, runtime, this.input, {
|
||||
audio: soundLibrary,
|
||||
physics,
|
||||
clock: gameClock,
|
||||
saves: saveArea(runtime.id, 'play'),
|
||||
});
|
||||
this.host.gameView.setWorld(world);
|
||||
this.host.gameView.setWorld(this.run.world);
|
||||
this.host.showGame();
|
||||
this.state.setNotice(this.playNotice(), 'quiet');
|
||||
if (world.stopRequested) this.stop();
|
||||
if (this.run.world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the running scene for another one, inside the same run: the world is torn down and built
|
||||
* again, while what belongs to the run – the clock, the shared variables, the global machines –
|
||||
* carries on. The sounds of the old scene end with it, because the behaviors playing them are
|
||||
* destroyed; a voice that was started detached is nobody's and plays on.
|
||||
*/
|
||||
private loadScene(sceneId: string): void {
|
||||
const old = this.world;
|
||||
const runtime = this.state.runtimeProject;
|
||||
if (!old || !runtime) return;
|
||||
const carried = this.behaviors?.shared ?? null;
|
||||
const physics = readyPhysics();
|
||||
old.destroy();
|
||||
const world = World.instantiate(runtime, sceneId);
|
||||
this.world = world;
|
||||
this.behaviors = addGameSystems(world, runtime, this.input, {
|
||||
audio: soundLibrary,
|
||||
carried,
|
||||
physics,
|
||||
clock: gameClock,
|
||||
saves: saveArea(runtime.id, 'play'),
|
||||
});
|
||||
this.host.gameView.setWorld(world);
|
||||
/** 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 (world.stopRequested) this.stop();
|
||||
if (run.world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
private waitForPhysics(): void {
|
||||
@@ -222,8 +204,7 @@ export class PlaySession {
|
||||
soundLibrary.setSuspended(false);
|
||||
soundLibrary.stopAll();
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
this.run = null;
|
||||
this.input.detach();
|
||||
ui().focus.blur();
|
||||
world?.destroy();
|
||||
@@ -240,8 +221,9 @@ export class PlaySession {
|
||||
* behaviors that run while paused.
|
||||
*/
|
||||
tick(step: number, awake: number): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
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.
|
||||
@@ -251,20 +233,19 @@ export class PlaySession {
|
||||
}
|
||||
const wasPaused = world.paused;
|
||||
world.held = this.state.heldObjectIds;
|
||||
world.tick(world.paused ? awake : step);
|
||||
this.input.endFrame();
|
||||
const request = run.tick(step, awake);
|
||||
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
|
||||
if (world.stopRequested) {
|
||||
if (request === 'stop') {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
if (world.restartRequested) {
|
||||
if (request === 'restart') {
|
||||
this.stop();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
if (world.loadSceneRequested) {
|
||||
this.loadScene(world.loadSceneRequested);
|
||||
if (request === 'scene') {
|
||||
this.sceneLoaded(run);
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
|
||||
|
||||
Reference in new issue
Block a user