diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 715888d8..9b12a14d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -33,12 +33,13 @@ jobs: env: GM_TEST_WORKERS: 2 - # The API's own rules (#180, #179, #231). Not part of check.sh: the host the editor is built + # The API's own rules (#180, #179, #231, #266). Not part of check.sh: the host the editor is built # on has no PHP, the image does. - name: PHP tests run: | php tests/php/accounts.test.php php tests/php/snapshots.test.php + php tests/php/releases.test.php suite: runs-on: game-maker-ci diff --git a/.gitignore b/.gitignore index f7cc58de..0512a5bd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /data/e2e-admin-password.txt /data/sessions/ /data/snapshots/ +/data/releases/ /data/users.json /data/textures/ /data/fonts/ @@ -17,4 +18,5 @@ /tmp/ /wiki/ /index.html +/play.html /pasted-*.png diff --git a/.prettierignore b/.prettierignore index 27bcd3f6..1a7c994d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ node_modules/ dist/ data/ index.html +play.html package-lock.json # Markdown is left alone: Prettier pads table cells to equal width, which turns a diff --git a/README.md b/README.md index ae6d01c9..70030d51 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1562 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1572 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index de0546a8..d3ffeb94 100644 --- a/api/index.php +++ b/api/index.php @@ -31,7 +31,7 @@ declare(strict_types=1); * POST ?r=projects {name} → {id,name,revision,updatedAt,project} * GET ?r=projects/{id} → {id,name,revision,updatedAt,project} * PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision} - * DELETE ?r=projects/{id} → 204 (its snapshots go with it) + * DELETE ?r=projects/{id} → 204 (its snapshots and its release go with it) * * GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first * POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt} @@ -40,6 +40,15 @@ declare(strict_types=1); * (a snapshot copies the project as stored; restoring keeps the * stand it replaces as the snapshot `before`, #231) * + * GET ?r=projects/{id}/release → {token,revision,publishedAt} | null + * POST ?r=projects/{id}/release → {token,revision,publishedAt} (publishes the stored project) + * DELETE ?r=projects/{id}/release → 204 | 404 + * (one link per project, kept when publishing again, #266) + * + * Released games are the one thing open to everybody, the token being the key: + * + * GET ?r=releases/{token} → {name,publishedAt,project} | 404 + * * GET ?r=textures → [{file,name,width,height,bytes,updatedAt}] * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} * DELETE ?r=textures/{file} → 204 @@ -70,6 +79,7 @@ require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/OriginalName.php'; require __DIR__ . '/lib/PixelStore.php'; require __DIR__ . '/lib/PluginStore.php'; +require __DIR__ . '/lib/ReleaseStore.php'; require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/SessionStore.php'; require __DIR__ . '/lib/SnapshotStore.php'; @@ -180,6 +190,10 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl if (($parts[2] ?? null) === 'snapshots') { routeSnapshots($parts, $method, $store, $snapshots, $id); } + $releases = new ReleaseStore(dirname(__DIR__) . '/data/releases'); + if (($parts[2] ?? null) === 'release' && count($parts) === 3) { + routeRelease($method, $store, $releases, $id); + } if (count($parts) > 2) { Response::error('Not found', 404); } @@ -213,6 +227,7 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl Response::error('Project not found', 404); } $snapshots->deleteAll($id); + $releases->takeDown($id); Response::noContent(); } @@ -289,6 +304,46 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt'], 'before' => $before]); } +/** + * The released copy of one project (#266). Whoever may load the project may publish it, update it + * and take it down; publishing copies the project as stored, not what a tab has not saved yet. + */ +function routeRelease(string $method, Store $store, ReleaseStore $releases, string $id): never +{ + $doc = $store->load($id); + if ($doc === null) { + Response::error('Project not found', 404); + } + if ($method === 'GET') { + Response::json($releases->of($id)); + } + if ($method === 'POST') { + Response::json($releases->publish($doc)); + } + if ($method === 'DELETE') { + if (!$releases->takeDown($id)) { + Response::error('Project is not released', 404); + } + Response::noContent(); + } + Response::error('Method not allowed', 405); +} + +/** The one route open without a sign-in: a released game, found by its token. */ +function routeReleases(array $parts, string $method): never +{ + if ($method !== 'GET' || count($parts) !== 2) { + Response::error('Not found', 404); + } + $release = (new ReleaseStore(dirname(__DIR__) . '/data/releases'))->load($parts[1]); + if ($release === null) { + Response::error('Game not found', 404); + } + // The copy goes out under the token, not the project's own id: that one is nobody's business. + $project = ['id' => $release['token']] + $release['project']; + Response::json(['name' => $release['name'], 'publishedAt' => $release['publishedAt'], 'project' => $project]); +} + function snapshotName(array $body): string { $name = trim((string) ($body['name'] ?? '')); @@ -581,6 +636,9 @@ try { if ($parts[0] === 'users') { routeUsers($parts, $method, $auth, $users); } + if ($parts[0] === 'releases') { + routeReleases($parts, $method); + } // Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the // price of admission, so an uploaded file cannot be dropped in by a stranger. $user = $auth->require(); diff --git a/api/lib/ReleaseStore.php b/api/lib/ReleaseStore.php new file mode 100644 index 00000000..b26f305f --- /dev/null +++ b/api/lib/ReleaseStore.php @@ -0,0 +1,160 @@ +tokenOf($projectId); + $release = $token === null ? null : $this->load($token); + return $release === null ? null : self::summary($release); + } + + /** + * Copies a stored project document into the project's release, under the token it already has. + * + * @param array{id:string,name:string,revision:int,project:array} $doc + * @return array{token:string,revision:int,publishedAt:string} + */ + public function publish(array $doc): array + { + $token = $this->tokenOf($doc['id']); + if ($token === null) { + do { + $token = bin2hex(random_bytes(16)); + } while (file_exists($this->path($token))); + } + $release = [ + 'token' => $token, + 'projectId' => $doc['id'], + 'name' => $doc['name'], + 'revision' => $doc['revision'], + 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), + 'project' => $doc['project'], + ]; + // The copy first, the pointer to it second: a crash in between leaves an unused file, never + // a project pointing at nothing. + $this->write($this->path($token), $release); + $this->write($this->indexPath($doc['id']), ['token' => $token]); + return self::summary($release); + } + + /** @return array{token:string,projectId:string,name:string,revision:int,publishedAt:string,project:array}|null */ + public function load(string $token): ?array + { + if (!self::isValidToken($token)) { + return null; + } + $release = $this->read($this->path($token)); + if ($release === null || !is_array($release['project'] ?? null) || !is_string($release['projectId'] ?? null)) { + return null; + } + return [ + 'token' => $token, + 'projectId' => $release['projectId'], + 'name' => is_string($release['name'] ?? null) ? $release['name'] : '', + 'revision' => (int) ($release['revision'] ?? 0), + 'publishedAt' => is_string($release['publishedAt'] ?? null) ? $release['publishedAt'] : '', + 'project' => $release['project'], + ]; + } + + /** Makes the link dead. False when the project had none. */ + public function takeDown(string $projectId): bool + { + $token = $this->tokenOf($projectId); + $index = $this->indexPath($projectId); + if (is_file($index)) { + unlink($index); + } + if ($token === null) { + return false; + } + $path = $this->path($token); + if (is_file($path)) { + unlink($path); + } + return true; + } + + private function tokenOf(string $projectId): ?string + { + $token = $this->read($this->indexPath($projectId))['token'] ?? null; + return is_string($token) && self::isValidToken($token) ? $token : null; + } + + /** @return array{token:string,revision:int,publishedAt:string} */ + private static function summary(array $release): array + { + return [ + 'token' => $release['token'], + 'revision' => $release['revision'], + 'publishedAt' => $release['publishedAt'], + ]; + } + + private function path(string $token): string + { + return $this->dir . '/' . $token . '.json'; + } + + private function indexPath(string $projectId): string + { + return $this->dir . '/projects/' . $projectId . '.json'; + } + + private function read(string $path): ?array + { + if (!is_file($path)) { + return null; + } + $raw = file_get_contents($path); + $data = $raw === false ? null : json_decode($raw, true); + return is_array($data) ? $data : null; + } + + private function write(string $path, array $data): void + { + $folder = dirname($path); + if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) { + throw new RuntimeException("Cannot create release directory {$folder}"); + } + $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('Cannot encode release'); + } + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (file_put_contents($tmp, $json, LOCK_EX) === false) { + @unlink($tmp); + throw new RuntimeException('Cannot write release file'); + } + chmod($tmp, 0640); + if (!rename($tmp, $path)) { + @unlink($tmp); + throw new RuntimeException('Cannot write release file'); + } + } +} diff --git a/eslint.config.js b/eslint.config.js index 840639c6..ad62df29 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,7 +10,7 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( // `rpgmz/` is gitignored: a copy of the RPG Maker MZ runtime kept next to the project to read the // autotile tables from (#118). It is nobody's source, so it is not linted either. - { ignores: ['dist/**', 'data/**', 'node_modules/**', 'rpgmz/**', 'wiki/**', 'index.html'] }, + { ignores: ['dist/**', 'data/**', 'node_modules/**', 'rpgmz/**', 'wiki/**', 'index.html', 'play.html'] }, // Type-aware linting for everything the tsconfig covers (src, tests, vite.config.ts). { diff --git a/src/core/store/HttpReleaseStore.ts b/src/core/store/HttpReleaseStore.ts new file mode 100644 index 00000000..40db7af6 --- /dev/null +++ b/src/core/store/HttpReleaseStore.ts @@ -0,0 +1,27 @@ +import { apiRequest } from './http'; +import type { ReleasedGame, ReleaseStore, ReleaseSummary } from './ReleaseStore'; + +export class HttpReleaseStore implements ReleaseStore { + constructor(private readonly endpoint: string) {} + + of(projectId: string): Promise { + return apiRequest(this.endpoint, 'GET', route(projectId)); + } + + publish(projectId: string): Promise { + return apiRequest(this.endpoint, 'POST', route(projectId)); + } + + takeDown(projectId: string): Promise { + return apiRequest(this.endpoint, 'DELETE', route(projectId)); + } +} + +/** The one call that needs no sign-in (#266). */ +export function loadReleasedGame(endpoint: string, token: string): Promise { + return apiRequest(endpoint, 'GET', `releases/${token}`); +} + +function route(projectId: string): string { + return `projects/${encodeURIComponent(projectId)}/release`; +} diff --git a/src/core/store/ReleaseStore.ts b/src/core/store/ReleaseStore.ts new file mode 100644 index 00000000..73f978e6 --- /dev/null +++ b/src/core/store/ReleaseStore.ts @@ -0,0 +1,40 @@ +/** A project's released copy as the release dialog shows it (#266). */ +export interface ReleaseSummary { + /** What the link carries: 32 hex digits, the same for as long as the project stays released. */ + token: string; + /** The project revision the copy was taken from. */ + revision: number; + /** ISO time, UTC. */ + publishedAt: string; +} + +/** What the play page loads: the copy alone, under the token rather than the project's own id. */ +export interface ReleasedGame { + name: string; + publishedAt: string; + project: unknown; +} + +/** + * The released copy of a project. Publishing copies the project as the server stored it, so what a + * tab has not saved yet is not part of it; publishing again replaces the copy and keeps the link. + */ +export interface ReleaseStore { + /** null while the project is not released. */ + of(projectId: string): Promise; + publish(projectId: string): Promise; + takeDown(projectId: string): Promise; +} + +/** The page a released game is played on, next to the editor's own. */ +export const PLAY_PAGE = '/game-maker/play.html'; + +export function releaseUrl(origin: string, token: string): string { + return `${origin}${PLAY_PAGE}?game=${token}`; +} + +/** The token a play page was opened with, or null when the address carries none that could be one. */ +export function releaseToken(search: string): string | null { + const token = new URLSearchParams(search).get('game') ?? ''; + return /^[a-f0-9]{32}$/.test(token) ? token : null; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 9863d5c7..e64c165d 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,21 +1,18 @@ import type { Application } from 'pixi.js'; -import { projectFiles } from '../core/model/projectFiles'; import type { AuthStore, SignedIn } from '../core/store/AuthStore'; import { HttpAuthStore } from '../core/store/HttpAuthStore'; import { HttpFileStore } from '../core/store/HttpFileStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; +import { HttpReleaseStore } from '../core/store/HttpReleaseStore'; import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore'; import { HttpUsersStore } from '../core/store/HttpUsersStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; import { loadPhysics } from '../runtime/physics/engine'; -import { requestRender, type VStack } from '../ui'; -import { fontLibrary } from '../viewport/FontLibrary'; -import { soundLibrary } from '../viewport/SoundLibrary'; import { projectPictureFactory } from '../viewport/generatedPictures'; +import { preloadProjectFiles, setUpProjectFiles } from '../viewport/projectFiles'; import { textureLibrary } from '../viewport/TextureLibrary'; -import { installPixelArtPictures } from '../viewport/pixelArtPicture'; +import type { VStack } from '../ui'; import { pixelCels } from '../viewport/PixelCels'; -import { installVectorArtPictures } from '../viewport/vectorArtPicture'; import { LoginScreen } from './auth/LoginScreen'; import { installLoginHook } from './auth/loginHook'; import { consoleLog } from './console/log'; @@ -27,22 +24,6 @@ import { EditorState } from './state'; const API_ENDPOINT = '/game-maker/api/index.php'; -/** - * Where uploaded images live: the API writes them, the browser reads them straight off disk as - * static files named by their content hash. Fonts and sounds work the same way, one directory over. - */ -const TEXTURE_BASE = '/game-maker/data/textures'; -const FONT_BASE = '/game-maker/data/fonts'; -const SOUND_BASE = '/game-maker/data/sounds'; -const PIXEL_BASE = '/game-maker/data/pixels'; - -function preloadProjectFiles(state: EditorState): void { - const files = projectFiles(state.project); - textureLibrary.preload(files.textures); - fontLibrary.preload(files.fonts); - pixelCels.preload(files.cels); -} - /** * Shows the sign-in screen until somebody is signed in, and answers with their name. Returns * right away when the session from an earlier visit is still good – which is what lets a @@ -82,19 +63,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr */ export async function startEditor(app: Application, root: VStack): Promise { const auth = new HttpAuthStore(API_ENDPOINT); - textureLibrary.configure(TEXTURE_BASE); - fontLibrary.configure(FONT_BASE); - soundLibrary.configure(SOUND_BASE); - pixelCels.configure(PIXEL_BASE); - // An image or a font arrives long after the click that asked for it, and the widget waiting for - // it repaints without telling anyone: both libraries are a wake-up of their own. Registered once - // for the page, not per session – the files they hold are shared by every account anyway. - textureLibrary.onChange(() => requestRender()); - fontLibrary.onChange(() => requestRender()); - // Vector art draws its own picture and needs the renderer to do it (#129). Registered once for - // the page like the libraries above: which project it is asked about comes from the factory below. - installVectorArtPictures(app.renderer); - installPixelArtPictures(); + setUpProjectFiles(app.renderer); const openSession = async (): Promise => { const { user, role } = await signedInUser(app, root, auth); @@ -105,10 +74,8 @@ export async function startEditor(app: Application, root: VStack): Promise // Drawn pictures (#185) are looked up in the project this session holds; the next session // installs its own, which is also what lets go of everything the old one had drawn. textureLibrary.setPictureFactory(projectPictureFactory(() => state.project)); - // Everything the project names is fetched as soon as it is named, not when it is first drawn: - // an animation reaching a frame from another sheet would otherwise blink out once. Asked on - // every change, which is cheap – a file that is held or on its way is skipped. - state.on('project', () => preloadProjectFiles(state)); + // Asked on every change, which is cheap – a file that is held or on its way is skipped. + state.on('project', () => preloadProjectFiles(state.project)); const editor = new Editor( app, root, @@ -122,6 +89,7 @@ export async function startEditor(app: Application, root: VStack): Promise new HttpFileStore(API_ENDPOINT, 'fonts'), new HttpFileStore(API_ENDPOINT, 'sounds'), new HttpSnapshotStore(API_ENDPOINT), + new HttpReleaseStore(API_ENDPOINT), { user, role, diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 5ad66d77..a85f95b2 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -38,6 +38,8 @@ import { attachAutosave } from './autosave'; import { PasswordDialog } from './auth/PasswordDialog'; import type { OpenProjectDialog } from './OpenProjectDialog'; import type { SnapshotsDialog } from './SnapshotsDialog'; +import type { ReleaseStore } from '../core/store/ReleaseStore'; +import type { ReleaseDialog } from './ReleaseDialog'; import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import { dialogSlot } from './FormDialog'; import { ContainerEditor } from './container/ContainerEditor'; @@ -271,6 +273,7 @@ export class Editor implements PlayHost { fonts: FontStore, sounds: SoundStore, snapshots: SnapshotStore, + releases: ReleaseStore, account: EditorAccount, ) { this.viewport = new SceneViewport(state); @@ -307,7 +310,7 @@ export class Editor implements PlayHost { () => { if (this.play.isPlaying) this.play.stop(); }, - { store: snapshots, flush: () => this.autosave.flush() }, + { snapshots, releases, flush: () => this.autosave.flush() }, // Every project brings its own plugins (#119); until `startPlugins` has run there is no // manager, and a project with plugins simply has none of them. async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))), @@ -580,6 +583,11 @@ export class Editor implements PlayHost { return this.projects.snapshotList(); } + /** The open "Release" dialog (#266), for the check scripts. */ + releaseDialog(): ReleaseDialog | null { + return this.projects.releaseView(); + } + /** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */ hasTab(id: string): boolean { return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 19020191..2c5cc674 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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 diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 6bd8ebed..5b3bf565 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -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(); private readonly pickerDialog = dialogSlot(); private readonly snapshotsDialog = dialogSlot(); + private readonly releaseDialog = dialogSlot(); /** * `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 }, + /** + * 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 }, /** * 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 { + 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 { + await navigator.clipboard.writeText(url); + this.state.setNotice('Link copied'); + } + private async createSnapshot(projectId: string, name: string): Promise { - 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 { 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(); })(), ), diff --git a/src/editor/ReleaseDialog.ts b/src/editor/ReleaseDialog.ts new file mode 100644 index 00000000..592ff03c --- /dev/null +++ b/src/editor/ReleaseDialog.ts @@ -0,0 +1,58 @@ +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] }; + } +} diff --git a/src/editor/animation/PreviewCanvas.ts b/src/editor/animation/PreviewCanvas.ts index e00652f2..200830d2 100644 --- a/src/editor/animation/PreviewCanvas.ts +++ b/src/editor/animation/PreviewCanvas.ts @@ -6,7 +6,7 @@ import type { EditorState } from '../state'; import { Theme } from '../../ui'; import { collectHolderItems } from '../../viewport/items'; import { FreeViewport } from '../viewport/FreeViewport'; -import type { Frame } from '../viewport/WorldViewport'; +import type { Frame } from '../../viewport/WorldViewport'; /** * The picture above the timeline (#99): the animation's root with every row's value at the diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 5a77c8c2..1ff509fb 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -1,24 +1,16 @@ import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture'; import { effectiveScale, gameClock } from '../../core/time'; -import { afterFrames, isRendering, renderCount, requestRender } from '../../ui'; +import { afterFrames, isRendering, renderCount, requestRender, stagePixel } from '../../ui'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary'; -import { type Container, Mesh, Rectangle } from 'pixi.js'; +import { type Container, Mesh } from 'pixi.js'; import type { HookContext } from './hookHelpers'; /** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */ export function renderHooks({ app, state, editor }: HookContext) { const { viewport, gameView } = editor; - /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ - const pixelAt = (x: number, y: number): string => { - const { pixels } = app.renderer.extract.pixels({ - target: app.stage, - frame: new Rectangle(x, y, 1, 1), - }); - const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); - return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; - }; + const pixelAt = (x: number, y: number): string => stagePixel(app, x, y); return { pixelAt, /** diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 0d374cdf..65953314 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -255,6 +255,8 @@ export function uiHooks({ app, state, editor }: HookContext) { const dialog = editor.snapshotsDialog(); return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null; }, + /** The "Release" dialog (#266): the link, the state line and the warnings. */ + release: () => editor.releaseDialog()?.info() ?? null, /** The dividers left and right of the centre column – dragging them narrows the tab row. */ panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }), /** The left column: asset panel on top, scene objects below, and the divider between them. */ diff --git a/src/editor/releases.ts b/src/editor/releases.ts new file mode 100644 index 00000000..b6c59bd2 --- /dev/null +++ b/src/editor/releases.ts @@ -0,0 +1,24 @@ +import { type Project, startScene } from '../core/model/Project'; +import type { ReleaseSummary } from '../core/store/ReleaseStore'; +import { snapshotTime } from './snapshots'; + +/** What the release dialog warns about (#266): what a player would miss, before it is published. */ +export function releaseWarnings(project: Project): string[] { + const warnings: string[] = []; + if (!startScene(project)) warnings.push('The project has no scene, so there is nothing to play.'); + if (project.plugins.length > 0) + warnings.push('Plugins are not loaded in a released game. Behaviors that need them will not work.'); + return warnings; +} + +/** + * The line under the dialog's title: not released, or when the copy was taken and whether the saved + * project has moved on since. `revision` is the saved one, which is what publishing copies. + */ +export function releaseState(release: ReleaseSummary | null, revision: number): string { + if (!release) return 'Not released. Publishing makes a link that plays only the game.'; + const when = `Released ${snapshotTime(new Date(release.publishedAt))}`; + return release.revision === revision + ? `${when} · the link plays the project as saved` + : `${when} · the project has changed since`; +} diff --git a/src/editor/saves.ts b/src/editor/saves.ts index dfa62a9f..af83ba3a 100644 --- a/src/editor/saves.ts +++ b/src/editor/saves.ts @@ -1,14 +1,16 @@ /** * The saves of persistent Variables assets (#166), kept in this browser per project. Played and * editor runs write to separate areas, so trying a behavior in the editor leaves the player's save - * alone. Storage is best effort: a browser that refuses `localStorage` plays every run from the + * alone. A released game keeps its own, under the token of its link. Storage is best effort: a browser that refuses `localStorage` plays every run from the * declared values. */ import type { SaveArea, SavedValues } from '../core/graph'; import type { PropertyBag } from '../core/model/Properties'; -export type SaveAreaName = 'play' | 'editor'; +/** `release` is a released game's (#266), kept per link rather than per project. */ +export type SaveAreaName = 'play' | 'editor' | 'release'; +/** The areas a project's own saves are kept in – what the editor shows and clears. */ const AREAS: readonly SaveAreaName[] = ['play', 'editor']; type Store = Pick; diff --git a/src/editor/snapshots.ts b/src/editor/snapshots.ts index d0279f98..5088075e 100644 --- a/src/editor/snapshots.ts +++ b/src/editor/snapshots.ts @@ -9,16 +9,19 @@ export function snapshotTime(at: Date): string { /** * Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies * what it has stored, so the editor has to have saved everything first: a snapshot without the - * last edits would be a stand nobody ever saw. + * last edits would be a stand nobody ever saw. A release is a copy as well (#266) and asks the same. */ -export function snapshotBlocker(status: SaveStatus): string | null { +export function snapshotBlocker( + status: SaveStatus, + copy: 'snapshot' | 'release' = 'snapshot', +): string | null { switch (status) { case 'saved': return null; case 'conflict': return 'This project was changed elsewhere. Reload to continue.'; case 'error': - return 'The project could not be saved, so there is nothing current to take a snapshot of'; + return `The project could not be saved, so there is nothing current to ${copy === 'release' ? 'release' : 'take a snapshot of'}`; default: return 'The latest changes are not saved yet – stop the game or wait a moment'; } diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 8edb2d1b..0fec2278 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -12,7 +12,7 @@ import { wheelSteps, zoomStepBelow, } from '../../viewport/view'; -import { WorldViewport } from './WorldViewport'; +import { WorldViewport } from '../../viewport/WorldViewport'; /** Breathing room left around the content by `fit()`, in screen pixels. */ const FIT_MARGIN = 40; @@ -22,7 +22,7 @@ const FIT_MARGIN = 40; * preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content. * The zoom only takes whole steps in device pixels (`FREE_ZOOM_STEPS`), so pixel art stays even. */ -export abstract class FreeViewport extends WorldViewport { +export abstract class FreeViewport extends WorldViewport { /** World point at the viewport centre, and zoom in device pixels per world unit. */ protected view = { x: 0, y: 0, zoom: 1 }; private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index ddf2072a..6c7ecfa7 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -2,30 +2,18 @@ * The scene seen through its active camera at the chosen scale, letterboxed: the running world while * playing, the model as a still preview otherwise. */ -import { - type FederatedMouseEvent, - type FederatedPointerEvent, - type FederatedWheelEvent, - Text, -} from 'pixi.js'; +import { Text } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; import { Theme, textStyle, ui } from '../../ui'; import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { collectHolderItems, collectWorldItems } from '../../viewport/items'; -import { - cameraFactor, - cameraView, - type GameScale, - isInside, - screenToWorld, - type View, - wheelNotches, -} from '../../viewport/view'; -import { type Frame, WorldViewport } from './WorldViewport'; +import { routePointer } from '../../viewport/gamePointer'; +import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view'; +import { type Frame, WorldViewport } from '../../viewport/WorldViewport'; -export class GameViewport extends WorldViewport { +export class GameViewport extends WorldViewport { private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); private readonly editorRunCaption = new Text({ text: 'Behaviors run here · keys and clicks go to the game', @@ -46,19 +34,11 @@ export class GameViewport extends WorldViewport { this.addChild(this.hint); this.editorRunCaption.eventMode = 'none'; this.addChild(this.editorRunCaption); - - this.on('pointerdown', (e: FederatedPointerEvent) => { - if (e.button !== 0) return; - this.reportPointer(e); - this.pointerWorld()?.setPointerDown(true); - }); - this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e)); - this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false)); - this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false)); - this.on('wheel', (e: FederatedWheelEvent) => { - this.reportPointer(e); - this.pointerWorld()?.turnWheel(wheelNotches(e.deltaY, e.deltaMode)); - }); + routePointer( + this, + () => this.pointerWorld(), + () => this.currentView(), + ); } /** null = not playing. */ @@ -155,14 +135,4 @@ export class GameViewport extends WorldViewport { this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld; this.editorRunCaption.position.set(8, 6); } - - /** Outside the camera frame counts as "not in view": that region is letterbox, not game. */ - private reportPointer(e: FederatedMouseEvent): void { - const world = this.pointerWorld(); - const view = this.currentView(); - if (!world || !view) return; - const local = this.toLocal(e.global); - const point = screenToWorld(view, local.x, local.y); - world.movePointer(point.x, point.y, isInside(view, local.x, local.y)); - } } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index b0d8826b..909c33fb 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -49,7 +49,7 @@ import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor'; import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost'; import { type PaintTarget, TilemapPainter } from './tilemapPainting'; import { TilemapSolidOutlines } from './tilemapSolids'; -import type { Frame } from './WorldViewport'; +import type { Frame } from '../../viewport/WorldViewport'; /** Pixels of travel before a press turns into a drag. */ const MARQUEE_THRESHOLD = 3; diff --git a/src/main.ts b/src/main.ts index 685d64dd..1d63288c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,62 +1,20 @@ -import { Application, Container } from 'pixi.js'; // Static imports on purpose: Pixi registers render pipes (graphics, text, sprite) as a // module side effect, and a renderer only knows the pipes that exist when it is created. // Lazy chunks loaded after `app.init()` would therefore fail with "unknown render pipe". import { captureConsole } from './editor/console/log'; import { startEditor } from './editor/App'; import { mountPlayground } from './playground'; -import { initRenderLoop, initUI, loadEditorFonts, Theme, VStack } from './ui'; +import { createPage, showFailure } from './page'; // Before anything else: a renderer warns while it is being created, and those warnings are // exactly the ones worth having in the panel. captureConsole(); -// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may -// tear; `?vsync` opts out. Pixi has no option for it, hence our own context. -function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2RenderingContext } { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('webgl2', { - alpha: false, - antialias: true, - premultipliedAlpha: true, - stencil: true, - desynchronized: true, - }); - return context ? { canvas, context } : {}; -} - async function main(): Promise { const params = new URLSearchParams(location.search); - const fonts = loadEditorFonts(); - const app = new Application(); - await app.init({ - resizeTo: window, - background: Theme.colors.background, - antialias: true, - resolution: Math.min(2, window.devicePixelRatio || 1), - autoDensity: true, - preference: 'webgl', - ...(params.has('vsync') ? {} : lowLatencyContext()), - }); - document.body.appendChild(app.canvas); - await fonts; - - const root = new VStack(); - const overlay = new Container(); - app.stage.addChild(root, overlay); - app.stage.eventMode = 'static'; - app.stage.hitArea = app.screen; - initUI(app, root, overlay); - // From here on the picture is drawn when something changes, not sixty times a second. - initRenderLoop(app); - + const { app, root } = await createPage(params); if (params.has('playground')) mountPlayground(app, root); else await startEditor(app, root); - - app.renderer.on('resize', () => { - app.stage.hitArea = app.screen; - root.resize(app.screen.width, app.screen.height); - }); } window.addEventListener('error', (event) => { @@ -64,7 +22,4 @@ window.addEventListener('error', (event) => { console.error('[uncaught]', error instanceof Error ? error.stack : event.message); }); -main().catch((error) => { - console.error(error); - document.body.innerHTML = `
${String(error)}
`; -}); +main().catch(showFailure); diff --git a/src/page.ts b/src/page.ts new file mode 100644 index 00000000..dd51aedb --- /dev/null +++ b/src/page.ts @@ -0,0 +1,56 @@ +import { Application, Container } from 'pixi.js'; +import { initRenderLoop, initUI, loadEditorFonts, Theme, VStack } from './ui'; + +// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may +// tear; `?vsync` opts out. Pixi has no option for it, hence our own context. +function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2RenderingContext } { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('webgl2', { + alpha: false, + antialias: true, + premultipliedAlpha: true, + stencil: true, + desynchronized: true, + }); + return context ? { canvas, context } : {}; +} + +/** + * The one canvas a page is: the editor's and a released game's alike (#266). `root` fills the + * window and follows it when it is resized. + */ +export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> { + const fonts = loadEditorFonts(); + const app = new Application(); + await app.init({ + resizeTo: window, + background: Theme.colors.background, + antialias: true, + resolution: Math.min(2, window.devicePixelRatio || 1), + autoDensity: true, + preference: 'webgl', + ...(params.has('vsync') ? {} : lowLatencyContext()), + }); + document.body.appendChild(app.canvas); + await fonts; + + const root = new VStack(); + const overlay = new Container(); + app.stage.addChild(root, overlay); + app.stage.eventMode = 'static'; + app.stage.hitArea = app.screen; + initUI(app, root, overlay); + // From here on the picture is drawn when something changes, not sixty times a second. + initRenderLoop(app); + + app.renderer.on('resize', () => { + app.stage.hitArea = app.screen; + root.resize(app.screen.width, app.screen.height); + }); + return { app, root }; +} + +export function showFailure(error: unknown): void { + console.error(error); + document.body.innerHTML = `
${String(error)}
`; +} diff --git a/src/play.ts b/src/play.ts new file mode 100644 index 00000000..4600d37e --- /dev/null +++ b/src/play.ts @@ -0,0 +1,65 @@ +// The page of a released game (#266): the game view and nothing else, no editor and no sign-in. +// Static imports for the same reason as in `main.ts`: Pixi only knows the render pipes that exist +// when the renderer is created. +import type { Ticker } from 'pixi.js'; +import { deserializeProject } from './core/store/serialize'; +import { loadReleasedGame } from './core/store/HttpReleaseStore'; +import { releaseToken } from './core/store/ReleaseStore'; +import { saveArea } from './editor/saves'; +import { createPage, showFailure } from './page'; +import { GameScreen } from './player/GameScreen'; +import { installPlayerHook } from './player/playerHook'; +import { Player } from './player/Player'; +import { ui } from './ui'; +import { projectPictureFactory } from './viewport/generatedPictures'; +import { preloadProjectFiles, setUpProjectFiles } from './viewport/projectFiles'; +import { textureLibrary } from './viewport/TextureLibrary'; +import { createEmptyProject } from './core/model/Project'; + +const API_ENDPOINT = '/game-maker/api/index.php'; + +async function main(): Promise { + const params = new URLSearchParams(location.search); + const { app, root } = await createPage(params); + // Every key belongs to the game, Tab included: there is nothing else on the page to move to. + ui().focus.tabBelongsElsewhere = () => true; + setUpProjectFiles(app.renderer); + + const source = { project: createEmptyProject('', ''), on: () => {} }; + const screen = new GameScreen(source); + root.add(screen, 1); + root.resize(app.screen.width, app.screen.height); + let player: Player | null = null; + installPlayerHook(screen, () => player); + + const token = releaseToken(location.search); + if (!token) { + screen.say('This link does not lead to a game.'); + return; + } + screen.say('Loading…'); + let game; + try { + game = await loadReleasedGame(API_ENDPOINT, token); + } catch (error) { + console.error('[release]', error); + screen.say('This game is not available.'); + return; + } + const project = deserializeProject(game.project, token); + source.project = project; + document.title = project.name || 'Game'; + textureLibrary.setPictureFactory(projectPictureFactory(() => project)); + preloadProjectFiles(project); + + const loaded = new Player(project, screen, saveArea(token, 'release')); + player = loaded; + app.ticker.add((ticker: Ticker) => loaded.tick(ticker.deltaMS / 1000)); + // A key starts the game as well as a click does: both count as the gesture sound waits for. + window.addEventListener('keydown', (e) => { + if ((e.key === 'Enter' || e.key === ' ') && screen.canStart) loaded.start(); + }); + await loaded.prepare(); +} + +main().catch(showFailure); diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts new file mode 100644 index 00000000..3f92081f --- /dev/null +++ b/src/player/GameScreen.ts @@ -0,0 +1,95 @@ +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); + } +} diff --git a/src/player/Player.ts b/src/player/Player.ts new file mode 100644 index 00000000..7ae40365 --- /dev/null +++ b/src/player/Player.ts @@ -0,0 +1,95 @@ +import type { SaveArea } from '../core/graph/savedValues'; +import { type Project, startScene } from '../core/model/Project'; +import { gameClock, timeStep } from '../core/time'; +import { GameRun } from '../runtime/GameRun'; +import { loadPhysics, readyPhysics } from '../runtime/physics/engine'; +import { behaviorError } from '../runtime/systems/BehaviorSystem'; +import { Input } from '../runtime/systems/Input'; +import { requestRender } from '../ui'; +import { soundLibrary } from '../viewport/SoundLibrary'; +import type { GameScreen } from './GameScreen'; + +/** + * Plays a released game (#266): from the project's start scene, on a click, until the game stops + * itself – then the screen offers to play again. What the editor's Play adds on top (panels, pause + * from a toolbar, the scene the editor has open) has no place here. + */ +export class Player { + private run: GameRun | null = null; + private readonly input = new Input(); + + constructor( + private readonly project: Project, + private readonly screen: GameScreen, + private readonly saves: SaveArea, + ) { + screen.onStart = () => this.start(); + } + + get isRunning(): boolean { + return this.run !== null; + } + + /** What `Log` wrote this run, oldest first; empty while none runs. */ + log(): string[] { + return [...(this.run?.world.log ?? [])]; + } + + /** Loads what a run needs, then offers to start one. */ + async prepare(): Promise { + if (!startScene(this.project)) { + this.screen.say('This game has no scene to play.'); + return; + } + this.screen.say('Loading…'); + await loadPhysics(); + this.screen.say('Click to play', true); + } + + start(): void { + const scene = startScene(this.project); + const physics = readyPhysics(); + if (this.run || !scene || !physics) return; + gameClock.reset(); + this.run = GameRun.start(this.project, scene.id, this.input, { + audio: soundLibrary, + physics, + clock: gameClock, + saves: this.saves, + }); + this.input.attach(); + this.screen.setWorld(this.run.world); + if (this.run.world.stopRequested) this.stop(); + } + + stop(): void { + const run = this.run; + if (!run) return; + this.run = null; + this.input.detach(); + soundLibrary.stopAll(); + gameClock.reset(); + const error = behaviorError(run.world); + if (error) console.error('[game]', error); + run.world.destroy(); + this.screen.setWorld(null); + this.screen.say('Click to play again', true); + } + + tick(dt: number): void { + const run = this.run; + if (!run) return; + const step = gameClock.advance(dt, run.world.paused); + const request = run.tick(step, timeStep(dt, gameClock.scale, false)); + if (request === 'stop') this.stop(); + else if (request === 'restart') { + this.stop(); + this.start(); + } else if (request === 'scene') { + this.screen.setWorld(run.world); + if (run.world.stopRequested) this.stop(); + } + if (this.run) this.screen.redraw(); + requestRender(); + } +} diff --git a/src/player/playerHook.ts b/src/player/playerHook.ts new file mode 100644 index 00000000..8e338f25 --- /dev/null +++ b/src/player/playerHook.ts @@ -0,0 +1,17 @@ +import { stagePixel, ui } from '../ui'; +import type { GameScreen } from './GameScreen'; +import type { Player } from './Player'; + +/** + * `window.__gameMakerPlay`: what the check scripts read off a released game (#266). The editor's + * `__gm` is not on this page, and neither is anything it would have to reach into. Installed before + * the game is loaded, so a link that leads nowhere can be read as well: `player` is null until then. + */ +export function installPlayerHook(screen: GameScreen, player: () => Player | null): void { + (window as unknown as { __gameMakerPlay: unknown }).__gameMakerPlay = { + running: () => player()?.isRunning ?? false, + screen: () => screen.info(), + log: () => player()?.log() ?? [], + pixelAt: (x: number, y: number) => stagePixel(ui().app, x, y), + }; +} diff --git a/src/runtime/GameRun.ts b/src/runtime/GameRun.ts new file mode 100644 index 00000000..ba31e3a9 --- /dev/null +++ b/src/runtime/GameRun.ts @@ -0,0 +1,62 @@ +/** + * 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 }); + } +} diff --git a/src/ui/index.ts b/src/ui/index.ts index c0a2b274..5236d926 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -12,6 +12,7 @@ export { renderCount, requestRender, resetRenderLoop, + stagePixel, } from './render'; export { drawBand, diff --git a/src/ui/render.ts b/src/ui/render.ts index c5e404f0..fb8c89a2 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -1,4 +1,4 @@ -import { type Application, UPDATE_PRIORITY } from 'pixi.js'; +import { type Application, Rectangle, UPDATE_PRIORITY } from 'pixi.js'; /** * Drives the renderer from changes instead of from the clock. @@ -128,3 +128,10 @@ export function resetRenderLoop(): void { frameWaiters = []; frameWork = new Map(); } + +/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ +export function stagePixel(app: Application, x: number, y: number): string { + const { pixels } = app.renderer.extract.pixels({ target: app.stage, frame: new Rectangle(x, y, 1, 1) }); + const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); + return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; +} diff --git a/src/editor/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts similarity index 93% rename from src/editor/viewport/WorldViewport.ts rename to src/viewport/WorldViewport.ts index 7c52c8ff..6f8e8a26 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -1,37 +1,46 @@ import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js'; -import { isSymbolType } from '../../core/model/AssetTypes'; -import { type Extent, scaleOf } from '../../core/model/matrix'; -import type { Vec2 } from '../../core/model/Properties'; -import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds'; -import { rectsIntersect } from '../../core/properties/camera'; -import type { ClipRegion } from '../../core/properties/clip'; -import type { EditorState } from '../state'; -import { type Size, Widget } from '../../ui'; -import { fontLibrary } from '../../viewport/FontLibrary'; -import { drawSlots } from '../../viewport/drawOrder'; -import type { DrawItem } from '../../viewport/items'; -import { hashId } from '../../core/particles/nodes'; -import { ParticleField } from '../../viewport/particles'; -import { particleLibrary } from '../../viewport/ParticleLibrary'; -import { SHEET_EDGE } from '../../core/shader/nodes'; -import { shaderLibrary } from '../../viewport/ShaderLibrary'; -import { type ShaderSource, wornShader } from '../../viewport/shaders'; -import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols'; -import { TextPicture } from '../../viewport/textPicture'; -import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap'; -import { textureLibrary } from '../../viewport/TextureLibrary'; -import { gameClock } from '../../core/time'; -import { type View, worldBounds, worldToScreen } from '../../viewport/view'; +import { isSymbolType } from '../core/model/AssetTypes'; +import { type Extent, scaleOf } from '../core/model/matrix'; +import type { Vec2 } from '../core/model/Properties'; +import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds'; +import { rectsIntersect } from '../core/properties/camera'; +import type { ClipRegion } from '../core/properties/clip'; +import type { Project } from '../core/model/Project'; +import { type Size, Widget } from '../ui'; +import { fontLibrary } from './FontLibrary'; +import { drawSlots } from './drawOrder'; +import type { DrawItem } from './items'; +import { hashId } from '../core/particles/nodes'; +import { ParticleField } from './particles'; +import { particleLibrary } from './ParticleLibrary'; +import { SHEET_EDGE } from '../core/shader/nodes'; +import { shaderLibrary } from './ShaderLibrary'; +import { type ShaderSource, wornShader } from './shaders'; +import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from './symbols'; +import { TextPicture } from './textPicture'; +import { cellCentre, type LayerFocus, TilemapField } from './tilemap'; +import { textureLibrary } from './TextureLibrary'; +import { gameClock } from '../core/time'; +import { type View, worldBounds, worldToScreen } from './view'; /** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */ export const CLIP_GHOST_ALPHA = 0.3; +/** + * What a view draws from: the project and word of when it changed. The editor's state is one; a + * released game (#266), which has no editor, hands over its own. + */ +export interface ViewSource { + readonly project: Project; + on(event: 'project' | 'selection' | 'mode' | 'runtime', listener: () => void): unknown; +} + export interface Frame { items: DrawItem[]; background: number; } -export abstract class WorldViewport extends Widget { +export abstract class WorldViewport extends Widget { protected readonly bg = new Graphics(); protected readonly content = new Container(); protected readonly world = new Container(); @@ -77,7 +86,7 @@ export abstract class WorldViewport extends Widget { private cameraMoves = 0; private readonly stopFiles: (() => void)[]; - protected constructor(protected readonly state: EditorState) { + protected constructor(protected readonly state: S) { super(); this.world.addChild(this.masks, this.objects, this.overlay); this.content.addChild(this.world); diff --git a/src/viewport/gamePointer.ts b/src/viewport/gamePointer.ts new file mode 100644 index 00000000..511aaea0 --- /dev/null +++ b/src/viewport/gamePointer.ts @@ -0,0 +1,31 @@ +import type { Container, FederatedMouseEvent, FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js'; +import type { World } from '../runtime/World'; +import { isInside, screenToWorld, type View, wheelNotches } from './view'; + +/** + * Hands the mouse over `target` to a running world: position, left button and wheel. Shared by the + * editor's game view and a released game (#266). Outside the camera frame counts as "not in view": + * that region is letterbox, not game. + */ +export function routePointer(target: Container, world: () => World | null, view: () => View | null): void { + const report = (e: FederatedMouseEvent): void => { + const w = world(); + const v = view(); + if (!w || !v) return; + const local = target.toLocal(e.global); + const point = screenToWorld(v, local.x, local.y); + w.movePointer(point.x, point.y, isInside(v, local.x, local.y)); + }; + target.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button !== 0) return; + report(e); + world()?.setPointerDown(true); + }); + target.on('globalpointermove', (e: FederatedPointerEvent) => report(e)); + target.on('pointerup', () => world()?.setPointerDown(false)); + target.on('pointerupoutside', () => world()?.setPointerDown(false)); + target.on('wheel', (e: FederatedWheelEvent) => { + report(e); + world()?.turnWheel(wheelNotches(e.deltaY, e.deltaMode)); + }); +} diff --git a/src/viewport/projectFiles.ts b/src/viewport/projectFiles.ts new file mode 100644 index 00000000..dfb774a0 --- /dev/null +++ b/src/viewport/projectFiles.ts @@ -0,0 +1,49 @@ +import type { Renderer } from 'pixi.js'; +import type { Project } from '../core/model/Project'; +import { projectFiles } from '../core/model/projectFiles'; +import { requestRender } from '../ui'; +import { fontLibrary } from './FontLibrary'; +import { installPixelArtPictures } from './pixelArtPicture'; +import { pixelCels } from './PixelCels'; +import { soundLibrary } from './SoundLibrary'; +import { textureLibrary } from './TextureLibrary'; +import { installVectorArtPictures } from './vectorArtPicture'; + +/** + * Where uploaded images live: the API writes them, the browser reads them straight off disk as + * static files named by their content hash. Fonts and sounds work the same way, one directory over. + */ +const TEXTURE_BASE = '/game-maker/data/textures'; +const FONT_BASE = '/game-maker/data/fonts'; +const SOUND_BASE = '/game-maker/data/sounds'; +const PIXEL_BASE = '/game-maker/data/pixels'; + +/** + * Points the file libraries at the server, once per page: the editor's and a released game's (#266). + */ +export function setUpProjectFiles(renderer: Renderer): void { + textureLibrary.configure(TEXTURE_BASE); + fontLibrary.configure(FONT_BASE); + soundLibrary.configure(SOUND_BASE); + pixelCels.configure(PIXEL_BASE); + // An image or a font arrives long after the click that asked for it, and the widget waiting for + // it repaints without telling anyone: both libraries are a wake-up of their own. Registered once + // for the page, not per session – the files they hold are shared by every account anyway. + textureLibrary.onChange(() => requestRender()); + fontLibrary.onChange(() => requestRender()); + // Vector art draws its own picture and needs the renderer to do it (#129). Registered once for + // the page like the libraries above: which project it is asked about comes from the factory. + installVectorArtPictures(renderer); + installPixelArtPictures(); +} + +/** + * Everything the project names is fetched as soon as it is named, not when it is first drawn: an + * animation reaching a frame from another sheet would otherwise blink out once. + */ +export function preloadProjectFiles(project: Project): void { + const files = projectFiles(project); + textureLibrary.preload(files.textures); + fontLibrary.preload(files.fonts); + pixelCels.preload(files.cels); +} diff --git a/tests/core/releases.test.ts b/tests/core/releases.test.ts new file mode 100644 index 00000000..f3016eb5 --- /dev/null +++ b/tests/core/releases.test.ts @@ -0,0 +1,156 @@ +/** + * Released games (#266): the link a project is played by, what the release dialog says about it, + * and the run a released game shares with the editor's Play – one way to start, tick, swap scenes + * and end, whichever page it is on. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import type { SavedValues } from '../../src/core/graph'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { releaseToken, releaseUrl, type ReleaseSummary } from '../../src/core/store/ReleaseStore'; +import { releaseState, releaseWarnings } from '../../src/editor/releases'; +import { saveArea, saveKey } from '../../src/editor/saves'; +import { snapshotBlocker } from '../../src/editor/snapshots'; +import { GameRun, type RunServices } from '../../src/runtime/GameRun'; +import { loadPhysics, type Rapier } from '../../src/runtime/physics/engine'; +import { SILENT_AUDIO } from '../../src/runtime/systems/Audio'; +import { Input } from '../../src/runtime/systems/Input'; +import { + graphEditor, + makeBehavior, + makeSpriteScene, + makeVariables, + place, + type SceneFixture, +} from '../helpers'; + +const TOKEN = '0123456789abcdef0123456789abcdef'; + +let physics: Rapier; +beforeAll(async () => { + physics = await loadPhysics(); +}); + +describe('the link of a release', () => { + it('leads to the play page with the token, and the play page reads it back', () => { + const url = releaseUrl('https://nockal.com', TOKEN); + expect(url).toBe(`https://nockal.com/game-maker/play.html?game=${TOKEN}`); + expect(releaseToken(new URL(url).search)).toBe(TOKEN); + }); + + it('takes nothing from an address that carries no token of the right shape', () => { + expect(releaseToken('')).toBeNull(); + expect(releaseToken('?game=')).toBeNull(); + expect(releaseToken('?game=2072b56583af')).toBeNull(); + expect(releaseToken(`?game=${TOKEN.toUpperCase()}`)).toBeNull(); + expect(releaseToken(`?game=../${TOKEN}`)).toBeNull(); + }); +}); + +describe('the release dialog', () => { + const release = (revision: number): ReleaseSummary => ({ + token: TOKEN, + revision, + publishedAt: '2026-09-29T10:39:31Z', + }); + + it('says whether the saved project has moved on since the copy was taken', () => { + expect(releaseState(null, 3)).toMatch(/^Not released/); + expect(releaseState(release(3), 3)).toMatch( + /^Released 2026-09-29 \d\d:39 · the link plays the project as saved$/, + ); + expect(releaseState(release(3), 5)).toMatch(/the project has changed since$/); + }); + + it('warns about a project without a scene and about plugins, and about nothing else', () => { + const f = makeSpriteScene('Box'); + expect(releaseWarnings(f.project)).toEqual([]); + f.project.plugins = ['inventory.js']; + expect(releaseWarnings(f.project)).toEqual([expect.stringMatching(/^Plugins are not loaded/)]); + f.project.plugins = []; + f.project.assets = f.project.assets.filter((a) => a.type !== 'scene'); + expect(releaseWarnings(f.project)).toEqual([expect.stringMatching(/no scene/)]); + }); + + it('refuses to publish what is not saved, like a snapshot', () => { + expect(snapshotBlocker('saved', 'release')).toBeNull(); + expect(snapshotBlocker('dirty', 'release')).toMatch(/not saved yet/); + expect(snapshotBlocker('error', 'release')).toMatch(/nothing current to release$/); + expect(snapshotBlocker('error')).toMatch(/nothing current to take a snapshot of$/); + }); + + it('keeps a released game’s saves under its link, apart from the project’s', () => { + const store = new Map(); + const memory = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }; + const saved: SavedValues = { vars: { score: 3 } }; + saveArea(TOKEN, 'release', memory).write(saved); + expect([...store.keys()]).toEqual([saveKey(TOKEN, 'release')]); + expect(saveArea('project', 'play', memory).read()).toEqual({}); + }); +}); + +describe('a run of the game', () => { + function services(): RunServices { + const saved: SavedValues = {}; + return { + audio: SILENT_AUDIO, + physics, + clock: { scale: 1 }, + saves: { read: () => saved, write: (values) => Object.assign(saved, values) }, + }; + } + + /** A behavior below a box that runs `type` on its first frame. */ + function onStart(f: SceneFixture, type: string, params: PropertyBag = {}) { + const behavior = makeBehavior(f, 'Logic'); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const node = edit.addNode(type, params); + edit.connect(start, 'exec', node, 'exec'); + } + + it('reports nothing while the game just runs', () => { + const f = makeSpriteScene('Box'); + place(f, f.spriteId, { x: 0, y: 0 }); + const run = GameRun.start(f.project, f.sceneId, new Input(), services()); + expect(run.tick(1 / 60, 1 / 60)).toBeNull(); + expect(run.world.entities.map((e) => e.name)).toContain('Box'); + }); + + it('reports a Stop game and a Restart game for the page to act on', () => { + const stopping = makeSpriteScene('Box'); + onStart(stopping, 'StopGame'); + expect( + GameRun.start(stopping.project, stopping.sceneId, new Input(), services()).tick(0.016, 0.016), + ).toBe('stop'); + + const restarting = makeSpriteScene('Box'); + onStart(restarting, 'RestartGame'); + expect( + GameRun.start(restarting.project, restarting.sceneId, new Input(), services()).tick(0.016, 0.016), + ).toBe('restart'); + }); + + it('carries out a Load scene itself, and carries the shared variables into the new world', () => { + const f = makeSpriteScene('Box'); + const second = new CreateAssetCommand('scene', 'Level 2'); + f.stack.execute(second); + const vars = makeVariables(f, 'Game', ['score']); + onStart(f, 'LoadScene', { scene: second.asset.id }); + + const run = GameRun.start(f.project, f.sceneId, new Input(), services()); + const first = run.world; + run.behaviors.liveVariables(vars.id)!.score = 7; + expect(run.tick(0.016, 0.016)).toBe('scene'); + expect(run.world).not.toBe(first); + expect(run.world.sceneId).toBe(second.asset.id); + expect(run.behaviors.liveVariables(vars.id)?.score).toBe(7); + expect(run.world.entities.some((e) => e.name === 'Box')).toBe(false); + }); +}); diff --git a/tests/php/releases.test.php b/tests/php/releases.test.php new file mode 100644 index 00000000..d91be487 --- /dev/null +++ b/tests/php/releases.test.php @@ -0,0 +1,77 @@ +of('p1') === null); +check('taking down a project without a release says so', $releases->takeDown('p1') === false); + +$doc = ['id' => 'p1', 'name' => 'Game', 'revision' => 4, 'project' => ['id' => 'p1', 'assets' => [['id' => 'a']]]]; +$first = $releases->publish($doc); +check('publishing hands out a 128-bit token', ReleaseStore::isValidToken($first['token']), $first); +check('the summary names the revision it copied', $first['revision'] === 4, $first); +check('the project now has that release', $releases->of('p1') === $first, $releases->of('p1')); + +$loaded = $releases->load($first['token']); +check( + 'the token loads the copy, its name and its project', + $loaded !== null && $loaded['project'] === $doc['project'] && $loaded['name'] === 'Game' && $loaded['projectId'] === 'p1', + $loaded, +); +check('the release file is not readable for the web server', (fileperms("{$dir}/{$first['token']}.json") & 0o007) === 0); +check('nor is the file that says which link a project has', (fileperms("{$dir}/projects/p1.json") & 0o007) === 0); + +$changed = ['revision' => 7, 'project' => ['id' => 'p1', 'assets' => []]] + $doc; +$second = $releases->publish($changed); +check('publishing again keeps the link', $second['token'] === $first['token'], $second); +check('and replaces the copy', $releases->load($first['token'])['project'] === $changed['project']); +check('with the new revision', $second['revision'] === 7, $second); + +$other = $releases->publish(['id' => 'p2'] + $doc); +check('another project gets another link', $other['token'] !== $first['token']); + +check('an unknown token loads nothing', $releases->load(str_repeat('0', 32)) === null); +check('a token of the wrong shape loads nothing', $releases->load('../projects/p1') === null); +check('a project id is not a token', $releases->load('p1') === null); + +check('taking down a release says so', $releases->takeDown('p1') === true); +check('the link is dead afterwards', $releases->load($first['token']) === null); +check('and the project has no release', $releases->of('p1') === null); +check('the other project keeps its own', $releases->load($other['token']) !== null); + +$again = $releases->publish($doc); +check('publishing after a take-down hands out a new link', $again['token'] !== $first['token'], $again); + +foreach (array_merge(glob("{$dir}/projects/*") ?: [], glob("{$dir}/*.json") ?: []) as $file) { + unlink($file); +} +@rmdir("{$dir}/projects"); +@rmdir($dir); + +echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index d0f48986..0ef5e893 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -56,6 +56,7 @@ export async function topbar(t) { 'Open project…', null, 'Snapshots…', + 'Release…', 'Project settings…', 'Clear saved data', null, diff --git a/tools/e2e/60-release.mjs b/tools/e2e/60-release.mjs new file mode 100644 index 00000000..c004b8cb --- /dev/null +++ b/tools/e2e/60-release.mjs @@ -0,0 +1,282 @@ +import { authHeaders, forgetSession } from '../lib/auth.mjs'; +import { edge, node, port, sceneObject, variable } from '../lib/builders.mjs'; + +/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */ +const TEMP_NAME = 'Release 60'; +const UPDATED = 'Release 60 updated'; +// Not a 16:9 multiple of the window on purpose: fitting it would need a scale between the steps. +const CAMERA = { width: 300, height: 180 }; +/** The zoom steps of the game view (`GAME_SCALE_STEPS`); Auto takes the largest that fits whole. */ +const STEPS = [0.25, 0.5, 1, 2, 4]; +const RED = '#e0553f'; +const BACKGROUND = '#22252c'; + +/** + * A small game of its own: a red crate in the middle of the camera that the arrow keys move, a + * `Log` on start, and S stopping the game. + */ +function game(id) { + const mover = { + nodes: [ + node('m-update', 'OnUpdate', 0, 0), + node('m-owner', 'Owner', 0, 120), + node('m-axis', 'AxisInput', 0, 200), + node('m-speed', 'Variables', 0, 400, {}, [port('speed', 'number')]), + node('m-move', 'Move', 340, 0), + ], + edges: [ + edge('m1', 'm-update', 'exec', 'm-move', 'exec'), + edge('m2', 'm-update', 'dt', 'm-move', 'dt'), + edge('m3', 'm-owner', 'object', 'm-move', 'object'), + edge('m4', 'm-axis', 'x', 'm-move', 'dx'), + edge('m5', 'm-axis', 'y', 'm-move', 'dy'), + edge('m6', 'm-speed', 'speed', 'm-move', 'scale'), + ], + }; + const control = { + nodes: [ + node('c-start', 'OnStart', 0, 0), + node('c-log', 'Log', 260, 0, { message: 'released game started' }), + node('c-key', 'OnKeyDown', 0, 160, { key: 's' }), + node('c-stop', 'StopGame', 260, 160), + ], + edges: [edge('c1', 'c-start', 'exec', 'c-log', 'exec'), edge('c2', 'c-key', 'exec', 'c-stop', 'exec')], + }; + return { + formatVersion: 2, + id, + name: TEMP_NAME, + activeSceneId: 'scene', + assets: [ + { + id: 'scene', + type: 'scene', + name: 'Level', + props: { backgroundColor: 0x22252c }, + objects: [ + sceneObject('crate', 'crate'), + sceneObject('mover', 'mover', { parentId: 'crate' }), + sceneObject('camera', 'camera'), + sceneObject('control', 'control'), + ], + }, + { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 40, height: 40, color: 0xe0553f } }, + { id: 'camera', type: 'camera', name: 'Camera', props: { ...CAMERA, zoom: 1 } }, + { + id: 'mover', + type: 'behavior', + name: 'Mover', + props: { speed: 200 }, + variables: [variable('speed', 'number', 0)], + graph: mover, + }, + { id: 'control', type: 'behavior', name: 'Control', props: {}, variables: [], graph: control }, + ], + }; +} + +/** + * Releasing a project (#266): published from the main menu, played from the link without a + * session – only the game, zoomed to Auto, on the page colour – updated under the same link, and + * taken down. + * + * Works on a throwaway project of its own, like step 50. The last thing it does is delete that + * project and open the fixture again. + */ +export async function release(t) { + const { check, cdp, json, click, locate, sleep, waitUntil, settle, holdKey, press } = t; + const { clickToolbar, clickText, shot, baseUrl, navigate, exceptions, saved } = t; + + const send = async (method, route, body, signedIn = true) => + fetch(`${baseUrl}api/index.php?r=${route}`, { + method, + headers: { ...(signedIn ? await authHeaders(baseUrl) : {}), 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); + // Step 45 changes the password of `e2e`, which ends the session the tools had cached. + const api = async (method, route, body) => { + const first = await send(method, route, body); + if (first.status !== 401) return first; + forgetSession(baseUrl); + return send(method, route, body); + }; + const sweep = async () => { + for (const project of await (await api('GET', 'projects')).json()) { + if (project.name === TEMP_NAME || project.name === UPDATED) + await api('DELETE', `projects/${project.id}`); + } + }; + const clickLast = async (text) => { + await settle(); + const at = await locate(text, true); + await click(at.x, at.y); + }; + const openRelease = async () => { + await clickToolbar('menu'); + await clickText('Release…'); + await waitUntil('__gm.release() !== null', { label: 'the release dialog opened' }); + return json('__gm.release()'); + }; + const player = (what) => json(`__gameMakerPlay.${what}`); + /** The play page as somebody without an account opens it. */ + const openLink = async (link) => { + await cdp.send('Network.clearBrowserCookies'); + await cdp.send('Page.navigate', { url: link }); + await waitUntil('Boolean(window.__gameMakerPlay)', { timeout: 15000, label: 'the play page is up' }); + }; + + // The step before may leave an edit on its way to the server; leaving the page then would stop at + // the browser's "leave site?" question, and nothing answers it. + await saved(); + await sweep(); + const made = await (await api('POST', 'projects', { name: TEMP_NAME })).json(); + const put = await api('PUT', `projects/${made.id}`, { revision: made.revision, project: game(made.id) }); + if (!put.ok) throw new Error(`could not write the game: ${put.status}`); + try { + await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` }); + await sleep(4000); + await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, { + timeout: 15000, + label: 'the throwaway project is open', + }); + + // 60a. Not released yet: no link, and Publish makes one from the saved project. + const before = await openRelease(); + await clickLast('Publish'); + await waitUntil('__gm.release()?.link != null', { label: 'the dialog shows the link' }); + const after = await json('__gm.release()'); + const token = /\?game=([a-f0-9]{32})$/.exec(after.link ?? '')?.[1] ?? ''; + const origin = new URL(baseUrl).origin; + check( + 'Publish in the release dialog makes a link to the play page, for the project as saved', + before.link === null && + before.state.startsWith('Not released') && + after.link === `${origin}/game-maker/play.html?game=${token}` && + after.state.endsWith('the link plays the project as saved') && + after.warnings.length === 0, + JSON.stringify({ before, after }), + ); + const open = await send('GET', `releases/${token}`, undefined, false); + const copy = open.ok ? await open.json() : null; + check( + 'the released copy is open without a session, under the token rather than the project id', + open.status === 200 && copy?.name === TEMP_NAME && copy?.project?.id === token, + JSON.stringify({ status: open.status, name: copy?.name, id: copy?.project?.id }), + ); + await shot('60-release-dialog'); + await clickLast('Cancel'); + + // 60b. A change to the saved project is not in the copy until Update release, which keeps the link. + const stored = await (await api('GET', `projects/${made.id}`)).json(); + await api('PUT', `projects/${made.id}`, { + revision: stored.revision, + project: { ...stored.project, name: UPDATED }, + }); + await cdp.send('Page.reload'); + await sleep(4000); + await waitUntil(`window.__gm?.state?.project?.name === ${JSON.stringify(UPDATED)}`, { + timeout: 15000, + label: 'the changed project is open', + }); + const stale = await openRelease(); + const nameBefore = (await (await send('GET', `releases/${token}`, undefined, false)).json()).name; + await clickLast('Update release'); + await waitUntil('__gm.release()?.state.endsWith("as saved")', { label: 'the release is up to date' }); + const updated = await json('__gm.release()'); + const nameAfter = (await (await send('GET', `releases/${token}`, undefined, false)).json()).name; + check( + 'the copy stays as it was until Update release, which keeps the link', + stale.state.endsWith('the project has changed since') && + nameBefore === TEMP_NAME && + nameAfter === UPDATED && + updated.link === after.link, + JSON.stringify({ stale: stale.state, nameBefore, nameAfter, link: updated.link }), + ); + await clickLast('Cancel'); + + // 60c. The link, without a session: the page colour, "Click to play", and nothing of the editor. + await openLink(after.link); + await waitUntil('__gameMakerPlay.screen().caption === "Click to play"', { + timeout: 15000, + label: 'the game offers to start', + }); + const page = { + background: await json('getComputedStyle(document.body).backgroundColor'), + title: await json('document.title'), + editor: await json('typeof window.__gm'), + running: await player('running()'), + }; + check( + 'the link opens without a session: only the game, on the page colour, waiting for a click', + page.background === 'rgb(27, 29, 34)' && + page.title === UPDATED && + page.editor === 'undefined' && + page.running === false, + JSON.stringify(page), + ); + + // 60d. A click starts it in the start scene, zoomed to Auto: a whole number of pixels per unit. + const size = { w: await json('innerWidth'), h: await json('innerHeight') }; + await click(size.w / 2, size.h / 2); + await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' }); + await sleep(300); + const screen = await player('screen()'); + const centre = { x: screen.frame.x + screen.frame.w / 2, y: screen.frame.y + screen.frame.h / 2 }; + const scale = screen.scale; + const pixels = { + centre: await player(`pixelAt(${centre.x}, ${centre.y})`), + beside: await player(`pixelAt(${centre.x - 40 * scale}, ${centre.y})`), + }; + const auto = Math.max(...STEPS.filter((s) => CAMERA.width * s <= size.w && CAMERA.height * s <= size.h)); + check( + 'a click starts the game in its start scene, zoomed to the largest step that fits whole', + scale === auto && + screen.frame.w === CAMERA.width * scale && + screen.frame.h === CAMERA.height * scale && + pixels.centre === RED && + pixels.beside === BACKGROUND && + JSON.stringify(await player('log()')) === JSON.stringify(['released game started']), + JSON.stringify({ size, auto, screen, pixels, log: await player('log()') }), + ); + await shot('60-release-playing'); + + // 60e. The keys go to the game: the arrow moves the crate out of the middle, S stops the game. + await holdKey('ArrowRight', 500); + await sleep(200); + const moved = await player(`pixelAt(${centre.x}, ${centre.y})`); + await press('s'); + await sleep(400); + check( + 'the keys reach the game, and a game that stops itself offers to play again', + moved === BACKGROUND && (await player('screen()')).caption === 'Click to play again', + JSON.stringify({ moved, screen: await player('screen()') }), + ); + check('the play page threw nothing', exceptions().length === 0, exceptions().join(' | ')); + + // 60f. Take down asks, then the link is dead – for the API and for the page. + await t.signIn(); + await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` }); + await sleep(4000); + await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, { + timeout: 15000, + label: 'the throwaway project is open again', + }); + await openRelease(); + await clickLast('Take down'); + await clickLast('Take down'); + await waitUntil('__gm.release()?.link === null', { label: 'the dialog shows no link' }); + await clickLast('Cancel'); + const dead = await send('GET', `releases/${token}`, undefined, false); + await openLink(after.link); + await waitUntil('__gameMakerPlay.screen().caption !== "Loading…"', { label: 'the page gave up' }); + check( + 'Take down makes the link dead', + dead.status === 404 && (await player('screen()')).caption === 'This game is not available.', + JSON.stringify({ status: dead.status, screen: await player('screen()') }), + ); + } finally { + await sweep(); + await navigate(); + } +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index de14ebda..28969885 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -62,6 +62,7 @@ import { privateVariables } from './56-private-variables.mjs'; import { objectReferences } from './57-object-references.mjs'; import { clipAreas } from './58-clip-areas.mjs'; import { whilePlaying } from './59-while-playing.mjs'; +import { release } from './60-release.mjs'; export const STEPS = [ hierarchy, @@ -123,4 +124,5 @@ export const STEPS = [ objectReferences, clipAreas, whilePlaying, + release, ]; diff --git a/vite.config.ts b/vite.config.ts index 5ad33c0f..32b66e18 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,16 +8,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); /** * The workspace folder itself is what nginx serves under https://nockal.com/game-maker/. - * The Vite source page lives in `web/index.html`; the build writes hashed assets into - * `dist/` and this plugin copies the built HTML to the folder root so that the site URL - * loads the production bundle while `api/` and `data/` sit next to it. + * The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game + * (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the + * folder root, so that the site URLs load the production bundles while `api/` and `data/` sit next + * to them. */ -function publishIndexHtml(): Plugin { +const PAGES = ['index.html', 'play.html']; + +function publishPages(): Plugin { return { - name: 'game-maker:publish-index-html', + name: 'game-maker:publish-pages', apply: 'build', closeBundle() { - copyFileSync(resolve(__dirname, 'dist/index.html'), resolve(__dirname, 'index.html')); + for (const page of PAGES) copyFileSync(resolve(__dirname, 'dist', page), resolve(__dirname, page)); }, }; } @@ -42,12 +45,17 @@ export default defineConfig({ root: 'web', base: '/game-maker/dist/', publicDir: false, - plugins: [publishIndexHtml(), shipPlugins()], + plugins: [publishPages(), shipPlugins()], build: { outDir: '../dist', emptyOutDir: true, target: 'es2022', sourcemap: true, + rollupOptions: { + input: Object.fromEntries( + PAGES.map((page) => [page.replace('.html', ''), resolve(__dirname, 'web', page)]), + ), + }, }, server: { port: 5175, diff --git a/web/play.html b/web/play.html new file mode 100644 index 00000000..5a1c93c9 --- /dev/null +++ b/web/play.html @@ -0,0 +1,15 @@ + + + + + + Game + + + + + +