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>
63 lines
2.5 KiB
TypeScript
63 lines
2.5 KiB
TypeScript
/**
|
||
* One run of a game: the world of the scene it is in and the systems that play it. Shared by the
|
||
* editor's Play and a released game (#266), so both build, swap and end a scene the same way.
|
||
*/
|
||
import type { SaveArea } from '../core/graph/savedValues';
|
||
import type { Project } from '../core/model/Project';
|
||
import type { AudioSink } from './systems/Audio';
|
||
import { addGameSystems, type BehaviorSystem, type TimeScaled } from './systems/BehaviorSystem';
|
||
import type { Input } from './systems/Input';
|
||
import type { Rapier } from './physics/engine';
|
||
import { World } from './World';
|
||
|
||
export interface RunServices {
|
||
audio: AudioSink;
|
||
physics: Rapier;
|
||
clock: TimeScaled;
|
||
saves: SaveArea;
|
||
}
|
||
|
||
export class GameRun {
|
||
private constructor(
|
||
private readonly project: Project,
|
||
private readonly input: Input,
|
||
private readonly services: RunServices,
|
||
public world: World,
|
||
public behaviors: BehaviorSystem,
|
||
) {}
|
||
|
||
static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun {
|
||
const world = World.instantiate(project, sceneId);
|
||
return new GameRun(project, input, services, world, addGameSystems(world, project, input, services));
|
||
}
|
||
|
||
/**
|
||
* One frame. `awake` is the step the clock would have moved without the game's own pause, which
|
||
* stops the clock but not the behaviors that run while paused. A `Load scene` is carried out here;
|
||
* stopping and restarting are the host's business, so they are only reported.
|
||
*/
|
||
tick(step: number, awake: number): 'stop' | 'restart' | 'scene' | null {
|
||
const world = this.world;
|
||
world.tick(world.paused ? awake : step);
|
||
this.input.endFrame();
|
||
if (world.stopRequested) return 'stop';
|
||
if (world.restartRequested) return 'restart';
|
||
if (!world.loadSceneRequested) return null;
|
||
this.loadScene(world.loadSceneRequested);
|
||
return 'scene';
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
loadScene(sceneId: string): void {
|
||
const carried = this.behaviors.shared;
|
||
this.world.destroy();
|
||
this.world = World.instantiate(this.project, sceneId);
|
||
this.behaviors = addGameSystems(this.world, this.project, this.input, { ...this.services, carried });
|
||
}
|
||
}
|