Share a link that plays the game without the editor (#266)
A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.
- Menu -> Release... opens a dialog with the state of the release,
the link with Copy link and Open, Publish or Update release, and
Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
snapshots. Updating keeps the link. Take down kills it, and a new
Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
paints Theme.colors.background, zooms with Auto and waits for
"Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
are stored per link in the browser. Plugins are not loaded, and the
dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
without a login. The public answer carries the token as project.id,
so the project id stays hidden. Release files are written with 0640
and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
EditorState. Pointer forwarding (gamePointer.ts) and setting up
textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
without the editor.
Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.
Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94c1055895
commit
4194f3e150
42 files changed
+1600
-246
No files matched your search
@@ -33,12 +33,13 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GM_TEST_WORKERS: 2
|
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.
|
# on has no PHP, the image does.
|
||||||
- name: PHP tests
|
- name: PHP tests
|
||||||
run: |
|
run: |
|
||||||
php tests/php/accounts.test.php
|
php tests/php/accounts.test.php
|
||||||
php tests/php/snapshots.test.php
|
php tests/php/snapshots.test.php
|
||||||
|
php tests/php/releases.test.php
|
||||||
|
|
||||||
suite:
|
suite:
|
||||||
runs-on: game-maker-ci
|
runs-on: game-maker-ci
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/data/e2e-admin-password.txt
|
/data/e2e-admin-password.txt
|
||||||
/data/sessions/
|
/data/sessions/
|
||||||
/data/snapshots/
|
/data/snapshots/
|
||||||
|
/data/releases/
|
||||||
/data/users.json
|
/data/users.json
|
||||||
/data/textures/
|
/data/textures/
|
||||||
/data/fonts/
|
/data/fonts/
|
||||||
@@ -17,4 +18,5 @@
|
|||||||
/tmp/
|
/tmp/
|
||||||
/wiki/
|
/wiki/
|
||||||
/index.html
|
/index.html
|
||||||
|
/play.html
|
||||||
/pasted-*.png
|
/pasted-*.png
|
||||||
@@ -2,6 +2,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
data/
|
data/
|
||||||
index.html
|
index.html
|
||||||
|
play.html
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# Markdown is left alone: Prettier pads table cells to equal width, which turns a
|
# Markdown is left alone: Prettier pads table cells to equal width, which turns a
|
||||||
|
|||||||
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+59
-1
@@ -31,7 +31,7 @@ declare(strict_types=1);
|
|||||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||||||
* GET ?r=projects/{id} → {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}
|
* 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
|
* GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first
|
||||||
* POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt}
|
* 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
|
* (a snapshot copies the project as stored; restoring keeps the
|
||||||
* stand it replaces as the snapshot `before`, #231)
|
* 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}]
|
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
|
||||||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||||||
* DELETE ?r=textures/{file} → 204
|
* DELETE ?r=textures/{file} → 204
|
||||||
@@ -70,6 +79,7 @@ require __DIR__ . '/lib/FontStore.php';
|
|||||||
require __DIR__ . '/lib/OriginalName.php';
|
require __DIR__ . '/lib/OriginalName.php';
|
||||||
require __DIR__ . '/lib/PixelStore.php';
|
require __DIR__ . '/lib/PixelStore.php';
|
||||||
require __DIR__ . '/lib/PluginStore.php';
|
require __DIR__ . '/lib/PluginStore.php';
|
||||||
|
require __DIR__ . '/lib/ReleaseStore.php';
|
||||||
require __DIR__ . '/lib/Response.php';
|
require __DIR__ . '/lib/Response.php';
|
||||||
require __DIR__ . '/lib/SessionStore.php';
|
require __DIR__ . '/lib/SessionStore.php';
|
||||||
require __DIR__ . '/lib/SnapshotStore.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') {
|
if (($parts[2] ?? null) === 'snapshots') {
|
||||||
routeSnapshots($parts, $method, $store, $snapshots, $id);
|
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) {
|
if (count($parts) > 2) {
|
||||||
Response::error('Not found', 404);
|
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);
|
Response::error('Project not found', 404);
|
||||||
}
|
}
|
||||||
$snapshots->deleteAll($id);
|
$snapshots->deleteAll($id);
|
||||||
|
$releases->takeDown($id);
|
||||||
Response::noContent();
|
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]);
|
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
|
function snapshotName(array $body): string
|
||||||
{
|
{
|
||||||
$name = trim((string) ($body['name'] ?? ''));
|
$name = trim((string) ($body['name'] ?? ''));
|
||||||
@@ -581,6 +636,9 @@ try {
|
|||||||
if ($parts[0] === 'users') {
|
if ($parts[0] === 'users') {
|
||||||
routeUsers($parts, $method, $auth, $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
|
// 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.
|
// price of admission, so an uploaded file cannot be dropped in by a stranger.
|
||||||
$user = $auth->require();
|
$user = $auth->require();
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Released games (#266): a frozen copy of a project that anybody with the link can play.
|
||||||
|
*
|
||||||
|
* data/releases/{token}.json { token, projectId, name, revision, publishedAt, project }
|
||||||
|
* data/releases/projects/{projectId}.json { token } which link a project has
|
||||||
|
*
|
||||||
|
* One link per project: publishing again replaces the copy and keeps the token, so a link handed out
|
||||||
|
* stays good. The token is 128 random bits and is the only secret, so both kinds of file are written
|
||||||
|
* 0640 – the web server serves `data/` as static files and must not hand them out by name.
|
||||||
|
*
|
||||||
|
* Like the snapshots, the store checks nothing about who asks: the router reaches the project half
|
||||||
|
* only after `Store::load` found the project for that user.
|
||||||
|
*/
|
||||||
|
final class ReleaseStore
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $dir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidToken(string $token): bool
|
||||||
|
{
|
||||||
|
return (bool) preg_match('/^[a-f0-9]{32}$/', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{token:string,revision:int,publishedAt:string}|null */
|
||||||
|
public function of(string $projectId): ?array
|
||||||
|
{
|
||||||
|
$token = $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ import tseslint from 'typescript-eslint';
|
|||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
// `rpgmz/` is gitignored: a copy of the RPG Maker MZ runtime kept next to the project to read the
|
// `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.
|
// 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).
|
// Type-aware linting for everything the tsconfig covers (src, tests, vite.config.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<ReleaseSummary | null> {
|
||||||
|
return apiRequest(this.endpoint, 'GET', route(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(projectId: string): Promise<ReleaseSummary> {
|
||||||
|
return apiRequest(this.endpoint, 'POST', route(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
takeDown(projectId: string): Promise<void> {
|
||||||
|
return apiRequest(this.endpoint, 'DELETE', route(projectId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one call that needs no sign-in (#266). */
|
||||||
|
export function loadReleasedGame(endpoint: string, token: string): Promise<ReleasedGame> {
|
||||||
|
return apiRequest(endpoint, 'GET', `releases/${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function route(projectId: string): string {
|
||||||
|
return `projects/${encodeURIComponent(projectId)}/release`;
|
||||||
|
}
|
||||||
@@ -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<ReleaseSummary | null>;
|
||||||
|
publish(projectId: string): Promise<ReleaseSummary>;
|
||||||
|
takeDown(projectId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
+7
-39
@@ -1,21 +1,18 @@
|
|||||||
import type { Application } from 'pixi.js';
|
import type { Application } from 'pixi.js';
|
||||||
import { projectFiles } from '../core/model/projectFiles';
|
|
||||||
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
||||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||||
|
import { HttpReleaseStore } from '../core/store/HttpReleaseStore';
|
||||||
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
|
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
|
||||||
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
||||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||||
import { loadPhysics } from '../runtime/physics/engine';
|
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 { projectPictureFactory } from '../viewport/generatedPictures';
|
||||||
|
import { preloadProjectFiles, setUpProjectFiles } from '../viewport/projectFiles';
|
||||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||||
import { installPixelArtPictures } from '../viewport/pixelArtPicture';
|
import type { VStack } from '../ui';
|
||||||
import { pixelCels } from '../viewport/PixelCels';
|
import { pixelCels } from '../viewport/PixelCels';
|
||||||
import { installVectorArtPictures } from '../viewport/vectorArtPicture';
|
|
||||||
import { LoginScreen } from './auth/LoginScreen';
|
import { LoginScreen } from './auth/LoginScreen';
|
||||||
import { installLoginHook } from './auth/loginHook';
|
import { installLoginHook } from './auth/loginHook';
|
||||||
import { consoleLog } from './console/log';
|
import { consoleLog } from './console/log';
|
||||||
@@ -27,22 +24,6 @@ import { EditorState } from './state';
|
|||||||
|
|
||||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
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
|
* 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
|
* 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<void> {
|
export async function startEditor(app: Application, root: VStack): Promise<void> {
|
||||||
const auth = new HttpAuthStore(API_ENDPOINT);
|
const auth = new HttpAuthStore(API_ENDPOINT);
|
||||||
textureLibrary.configure(TEXTURE_BASE);
|
setUpProjectFiles(app.renderer);
|
||||||
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();
|
|
||||||
|
|
||||||
const openSession = async (): Promise<void> => {
|
const openSession = async (): Promise<void> => {
|
||||||
const { user, role } = await signedInUser(app, root, auth);
|
const { user, role } = await signedInUser(app, root, auth);
|
||||||
@@ -105,10 +74,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
// Drawn pictures (#185) are looked up in the project this session holds; the next session
|
// 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.
|
// installs its own, which is also what lets go of everything the old one had drawn.
|
||||||
textureLibrary.setPictureFactory(projectPictureFactory(() => state.project));
|
textureLibrary.setPictureFactory(projectPictureFactory(() => state.project));
|
||||||
// Everything the project names is fetched as soon as it is named, not when it is first drawn:
|
// Asked on every change, which is cheap – a file that is held or on its way is skipped.
|
||||||
// an animation reaching a frame from another sheet would otherwise blink out once. Asked on
|
state.on('project', () => preloadProjectFiles(state.project));
|
||||||
// every change, which is cheap – a file that is held or on its way is skipped.
|
|
||||||
state.on('project', () => preloadProjectFiles(state));
|
|
||||||
const editor = new Editor(
|
const editor = new Editor(
|
||||||
app,
|
app,
|
||||||
root,
|
root,
|
||||||
@@ -122,6 +89,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
||||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||||
new HttpSnapshotStore(API_ENDPOINT),
|
new HttpSnapshotStore(API_ENDPOINT),
|
||||||
|
new HttpReleaseStore(API_ENDPOINT),
|
||||||
{
|
{
|
||||||
user,
|
user,
|
||||||
role,
|
role,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import { attachAutosave } from './autosave';
|
|||||||
import { PasswordDialog } from './auth/PasswordDialog';
|
import { PasswordDialog } from './auth/PasswordDialog';
|
||||||
import type { OpenProjectDialog } from './OpenProjectDialog';
|
import type { OpenProjectDialog } from './OpenProjectDialog';
|
||||||
import type { SnapshotsDialog } from './SnapshotsDialog';
|
import type { SnapshotsDialog } from './SnapshotsDialog';
|
||||||
|
import type { ReleaseStore } from '../core/store/ReleaseStore';
|
||||||
|
import type { ReleaseDialog } from './ReleaseDialog';
|
||||||
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
||||||
import { dialogSlot } from './FormDialog';
|
import { dialogSlot } from './FormDialog';
|
||||||
import { ContainerEditor } from './container/ContainerEditor';
|
import { ContainerEditor } from './container/ContainerEditor';
|
||||||
@@ -271,6 +273,7 @@ export class Editor implements PlayHost {
|
|||||||
fonts: FontStore,
|
fonts: FontStore,
|
||||||
sounds: SoundStore,
|
sounds: SoundStore,
|
||||||
snapshots: SnapshotStore,
|
snapshots: SnapshotStore,
|
||||||
|
releases: ReleaseStore,
|
||||||
account: EditorAccount,
|
account: EditorAccount,
|
||||||
) {
|
) {
|
||||||
this.viewport = new SceneViewport(state);
|
this.viewport = new SceneViewport(state);
|
||||||
@@ -307,7 +310,7 @@ export class Editor implements PlayHost {
|
|||||||
() => {
|
() => {
|
||||||
if (this.play.isPlaying) this.play.stop();
|
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
|
// 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.
|
// manager, and a project with plugins simply has none of them.
|
||||||
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
||||||
@@ -580,6 +583,11 @@ export class Editor implements PlayHost {
|
|||||||
return this.projects.snapshotList();
|
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. */
|
/** 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 {
|
hasTab(id: string): boolean {
|
||||||
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
||||||
|
|||||||
+34
-53
@@ -5,18 +5,14 @@
|
|||||||
*/
|
*/
|
||||||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||||||
import { namedValue } from '../core/graph/values';
|
import { namedValue } from '../core/graph/values';
|
||||||
import {
|
import { GameRun, type RunServices } from '../runtime/GameRun';
|
||||||
addGameSystems,
|
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
|
||||||
type BehaviorSystem,
|
|
||||||
behaviorError,
|
|
||||||
type Trace,
|
|
||||||
} from '../runtime/systems/BehaviorSystem';
|
|
||||||
import type { AnimationPlayer } from '../core/model/animation';
|
import type { AnimationPlayer } from '../core/model/animation';
|
||||||
import type { PropertyBag } from '../core/model/Properties';
|
import type { PropertyBag } from '../core/model/Properties';
|
||||||
import { gameClock } from '../core/time';
|
import { gameClock } from '../core/time';
|
||||||
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
|
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
|
||||||
import { Input } from '../runtime/systems/Input';
|
import { Input } from '../runtime/systems/Input';
|
||||||
import { World } from '../runtime/World';
|
import type { World } from '../runtime/World';
|
||||||
import { ui } from '../ui';
|
import { ui } from '../ui';
|
||||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||||
import { saveArea } from './saves';
|
import { saveArea } from './saves';
|
||||||
@@ -58,8 +54,7 @@ export interface PlayHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PlaySession {
|
export class PlaySession {
|
||||||
private world: World | null = null;
|
private run: GameRun | null = null;
|
||||||
private behaviors: BehaviorSystem | null = null;
|
|
||||||
private readonly input = new Input();
|
private readonly input = new Input();
|
||||||
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
|
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
|
||||||
private waiting = false;
|
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 {
|
get isPlaying(): boolean {
|
||||||
return this.world !== null;
|
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.
|
// A run starts at second zero, whatever the editor's previews have been counting up to.
|
||||||
gameClock.reset();
|
gameClock.reset();
|
||||||
const runtime = this.state.enterPlay();
|
const runtime = this.state.enterPlay();
|
||||||
const world = World.instantiate(runtime, scene.id);
|
const services: RunServices = {
|
||||||
this.world = world;
|
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.updates = { structure: 0, values: 0 };
|
||||||
this.input.attach();
|
this.input.attach();
|
||||||
this.behaviors = addGameSystems(world, runtime, this.input, {
|
this.host.gameView.setWorld(this.run.world);
|
||||||
audio: soundLibrary,
|
|
||||||
physics,
|
|
||||||
clock: gameClock,
|
|
||||||
saves: saveArea(runtime.id, 'play'),
|
|
||||||
});
|
|
||||||
this.host.gameView.setWorld(world);
|
|
||||||
this.host.showGame();
|
this.host.showGame();
|
||||||
this.state.setNotice(this.playNotice(), 'quiet');
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
if (world.stopRequested) this.stop();
|
if (this.run.world.stopRequested) this.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
|
||||||
* Swaps the running scene for another one, inside the same run: the world is torn down and built
|
private sceneLoaded(run: GameRun): void {
|
||||||
* again, while what belongs to the run – the clock, the shared variables, the global machines –
|
this.host.gameView.setWorld(run.world);
|
||||||
* 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);
|
|
||||||
this.state.setNotice(this.playNotice(), 'quiet');
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
if (world.stopRequested) this.stop();
|
if (run.world.stopRequested) this.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private waitForPhysics(): void {
|
private waitForPhysics(): void {
|
||||||
@@ -222,8 +204,7 @@ export class PlaySession {
|
|||||||
soundLibrary.setSuspended(false);
|
soundLibrary.setSuspended(false);
|
||||||
soundLibrary.stopAll();
|
soundLibrary.stopAll();
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
this.world = null;
|
this.run = null;
|
||||||
this.behaviors = null;
|
|
||||||
this.input.detach();
|
this.input.detach();
|
||||||
ui().focus.blur();
|
ui().focus.blur();
|
||||||
world?.destroy();
|
world?.destroy();
|
||||||
@@ -240,8 +221,9 @@ export class PlaySession {
|
|||||||
* behaviors that run while paused.
|
* behaviors that run while paused.
|
||||||
*/
|
*/
|
||||||
tick(step: number, awake: number): void {
|
tick(step: number, awake: number): void {
|
||||||
const world = this.world;
|
const run = this.run;
|
||||||
if (!world) return;
|
if (!run) return;
|
||||||
|
const world = run.world;
|
||||||
if (this.state.paused) {
|
if (this.state.paused) {
|
||||||
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
|
// 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.
|
// resuming would deliver every one of them in a single frame.
|
||||||
@@ -251,20 +233,19 @@ export class PlaySession {
|
|||||||
}
|
}
|
||||||
const wasPaused = world.paused;
|
const wasPaused = world.paused;
|
||||||
world.held = this.state.heldObjectIds;
|
world.held = this.state.heldObjectIds;
|
||||||
world.tick(world.paused ? awake : step);
|
const request = run.tick(step, awake);
|
||||||
this.input.endFrame();
|
|
||||||
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
|
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
if (world.stopRequested) {
|
if (request === 'stop') {
|
||||||
this.stop();
|
this.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (world.restartRequested) {
|
if (request === 'restart') {
|
||||||
this.stop();
|
this.stop();
|
||||||
this.play();
|
this.play();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (world.loadSceneRequested) {
|
if (request === 'scene') {
|
||||||
this.loadScene(world.loadSceneRequested);
|
this.sceneLoaded(run);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
|
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import {
|
|||||||
SetWhilePlayingCommand,
|
SetWhilePlayingCommand,
|
||||||
} from '../core/commands/projectCommands';
|
} from '../core/commands/projectCommands';
|
||||||
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
|
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 type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
|
||||||
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
|
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
|
||||||
import { dialogSlot } from './FormDialog';
|
import { dialogSlot } from './FormDialog';
|
||||||
import { OpenProjectDialog } from './OpenProjectDialog';
|
import { OpenProjectDialog } from './OpenProjectDialog';
|
||||||
|
import { ReleaseDialog } from './ReleaseDialog';
|
||||||
|
import { releaseState, releaseWarnings } from './releases';
|
||||||
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
|
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
|
||||||
import { clearSaved, hasSaved } from './saves';
|
import { clearSaved, hasSaved } from './saves';
|
||||||
import { snapshotBlocker, snapshotTime } from './snapshots';
|
import { snapshotBlocker, snapshotTime } from './snapshots';
|
||||||
@@ -23,6 +26,7 @@ export class ProjectSession {
|
|||||||
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
|
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
|
||||||
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
||||||
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
|
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
|
||||||
|
private readonly releaseDialog = dialogSlot<ReleaseDialog>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
* `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 state: EditorState,
|
||||||
private readonly store: ProjectStore,
|
private readonly store: ProjectStore,
|
||||||
private readonly beforeSwitch: () => void,
|
private readonly beforeSwitch: () => void,
|
||||||
/** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */
|
/**
|
||||||
private readonly snapshots: { store: SnapshotStore; flush: () => Promise<void> },
|
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
|
||||||
|
* what the autosave still holds, so a copy is of the stand on screen.
|
||||||
|
*/
|
||||||
|
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
|
||||||
/**
|
/**
|
||||||
* Runs on the project as it came off the server, before it is read into the model: the plugins
|
* 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
|
* 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() },
|
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
|
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
|
||||||
|
{ label: 'Release…', onSelect: () => this.openRelease() },
|
||||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||||
...more,
|
...more,
|
||||||
@@ -154,7 +162,7 @@ export class ProjectSession {
|
|||||||
const projectId = this.state.project.id;
|
const projectId = this.state.project.id;
|
||||||
void this.run(
|
void this.run(
|
||||||
(async () => {
|
(async () => {
|
||||||
const list = await this.snapshots.store.list(projectId);
|
const list = await this.copies.snapshots.list(projectId);
|
||||||
this.snapshotsDialog.open(
|
this.snapshotsDialog.open(
|
||||||
new SnapshotsDialog(list, {
|
new SnapshotsDialog(list, {
|
||||||
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
|
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
|
||||||
@@ -171,14 +179,80 @@ export class ProjectSession {
|
|||||||
return this.snapshotsDialog.current();
|
return this.snapshotsDialog.current();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The link that plays only the game (#266). */
|
||||||
|
openRelease(): void {
|
||||||
|
const projectId = this.state.project.id;
|
||||||
|
void this.run(
|
||||||
|
(async () => {
|
||||||
|
const release = await this.copies.releases.of(projectId);
|
||||||
|
const link = release && releaseUrl(location.origin, release.token);
|
||||||
|
this.releaseDialog.open(
|
||||||
|
new ReleaseDialog(
|
||||||
|
release,
|
||||||
|
link,
|
||||||
|
releaseState(release, this.state.revision),
|
||||||
|
releaseWarnings(this.state.project),
|
||||||
|
{
|
||||||
|
onPublish: () => void this.run(this.publish(projectId, release !== null)),
|
||||||
|
onTakeDown: () => this.confirmTakeDown(projectId),
|
||||||
|
onCopy: (url) => void this.run(this.copyLink(url)),
|
||||||
|
onOpen: (url) => window.open(url, '_blank', 'noopener'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open release dialog, for the check scripts. */
|
||||||
|
releaseView(): ReleaseDialog | null {
|
||||||
|
return this.releaseDialog.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publishing copies what the server has stored, so everything on screen is saved first. */
|
||||||
|
private async publish(projectId: string, again: boolean): Promise<void> {
|
||||||
|
await this.copies.flush();
|
||||||
|
const blocker = snapshotBlocker(this.state.saveStatus, 'release');
|
||||||
|
if (blocker) {
|
||||||
|
this.state.setNotice(blocker, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.copies.releases.publish(projectId);
|
||||||
|
this.state.setNotice(again ? 'Release updated' : 'Released – the link plays the game');
|
||||||
|
this.openRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmTakeDown(projectId: string): void {
|
||||||
|
new Dialog('Take the release down?', 'The link stops working. Publishing again makes a new one.', [
|
||||||
|
{ label: 'Cancel', onClick: () => this.openRelease() },
|
||||||
|
{
|
||||||
|
label: 'Take down',
|
||||||
|
danger: true,
|
||||||
|
onClick: () =>
|
||||||
|
void this.run(
|
||||||
|
(async () => {
|
||||||
|
await this.copies.releases.takeDown(projectId);
|
||||||
|
this.state.setNotice('Release taken down');
|
||||||
|
this.openRelease();
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]).openCentered();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyLink(url: string): Promise<void> {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
this.state.setNotice('Link copied');
|
||||||
|
}
|
||||||
|
|
||||||
private async createSnapshot(projectId: string, name: string): Promise<void> {
|
private async createSnapshot(projectId: string, name: string): Promise<void> {
|
||||||
await this.snapshots.flush();
|
await this.copies.flush();
|
||||||
const blocker = snapshotBlocker(this.state.saveStatus);
|
const blocker = snapshotBlocker(this.state.saveStatus);
|
||||||
if (blocker) {
|
if (blocker) {
|
||||||
this.state.setNotice(blocker, 'error');
|
this.state.setNotice(blocker, 'error');
|
||||||
return;
|
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.state.setNotice(`Snapshot "${made.name}" created`);
|
||||||
this.openSnapshots();
|
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. */
|
/** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */
|
||||||
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
|
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
|
||||||
this.beforeSwitch();
|
this.beforeSwitch();
|
||||||
await this.snapshots.flush();
|
await this.copies.flush();
|
||||||
const blocker = snapshotBlocker(this.state.saveStatus);
|
const blocker = snapshotBlocker(this.state.saveStatus);
|
||||||
if (blocker) {
|
if (blocker) {
|
||||||
this.state.setNotice(blocker, 'error');
|
this.state.setNotice(blocker, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
await this.open(projectId);
|
||||||
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
|
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -227,7 +301,7 @@ export class ProjectSession {
|
|||||||
onClick: () =>
|
onClick: () =>
|
||||||
void this.run(
|
void this.run(
|
||||||
(async () => {
|
(async () => {
|
||||||
await this.snapshots.store.delete(projectId, snapshot.id);
|
await this.copies.snapshots.delete(projectId, snapshot.id);
|
||||||
this.openSnapshots();
|
this.openSnapshots();
|
||||||
})(),
|
})(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import type { EditorState } from '../state';
|
|||||||
import { Theme } from '../../ui';
|
import { Theme } from '../../ui';
|
||||||
import { collectHolderItems } from '../../viewport/items';
|
import { collectHolderItems } from '../../viewport/items';
|
||||||
import { FreeViewport } from '../viewport/FreeViewport';
|
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
|
* The picture above the timeline (#99): the animation's root with every row's value at the
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
||||||
import { effectiveScale, gameClock } from '../../core/time';
|
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 { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
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';
|
import type { HookContext } from './hookHelpers';
|
||||||
|
|
||||||
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
|
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
|
||||||
export function renderHooks({ app, state, editor }: HookContext) {
|
export function renderHooks({ app, state, editor }: HookContext) {
|
||||||
const { viewport, gameView } = editor;
|
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 => stagePixel(app, x, y);
|
||||||
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])}`;
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
pixelAt,
|
pixelAt,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
const dialog = editor.snapshotsDialog();
|
const dialog = editor.snapshotsDialog();
|
||||||
return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null;
|
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. */
|
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
||||||
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
||||||
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
+4
-2
@@ -1,14 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* The saves of persistent Variables assets (#166), kept in this browser per project. Played and
|
* 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
|
* 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.
|
* declared values.
|
||||||
*/
|
*/
|
||||||
import type { SaveArea, SavedValues } from '../core/graph';
|
import type { SaveArea, SavedValues } from '../core/graph';
|
||||||
import type { PropertyBag } from '../core/model/Properties';
|
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'];
|
const AREAS: readonly SaveAreaName[] = ['play', 'editor'];
|
||||||
|
|
||||||
type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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) {
|
switch (status) {
|
||||||
case 'saved':
|
case 'saved':
|
||||||
return null;
|
return null;
|
||||||
case 'conflict':
|
case 'conflict':
|
||||||
return 'This project was changed elsewhere. Reload to continue.';
|
return 'This project was changed elsewhere. Reload to continue.';
|
||||||
case 'error':
|
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:
|
default:
|
||||||
return 'The latest changes are not saved yet – stop the game or wait a moment';
|
return 'The latest changes are not saved yet – stop the game or wait a moment';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
wheelSteps,
|
wheelSteps,
|
||||||
zoomStepBelow,
|
zoomStepBelow,
|
||||||
} from '../../viewport/view';
|
} from '../../viewport/view';
|
||||||
import { WorldViewport } from './WorldViewport';
|
import { WorldViewport } from '../../viewport/WorldViewport';
|
||||||
|
|
||||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||||
const FIT_MARGIN = 40;
|
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.
|
* 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.
|
* 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<EditorState> {
|
||||||
/** World point at the viewport centre, and zoom in device pixels per world unit. */
|
/** World point at the viewport centre, and zoom in device pixels per world unit. */
|
||||||
protected view = { x: 0, y: 0, zoom: 1 };
|
protected view = { x: 0, y: 0, zoom: 1 };
|
||||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||||
|
|||||||
@@ -2,30 +2,18 @@
|
|||||||
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
|
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
|
||||||
* playing, the model as a still preview otherwise.
|
* playing, the model as a still preview otherwise.
|
||||||
*/
|
*/
|
||||||
import {
|
import { Text } from 'pixi.js';
|
||||||
type FederatedMouseEvent,
|
|
||||||
type FederatedPointerEvent,
|
|
||||||
type FederatedWheelEvent,
|
|
||||||
Text,
|
|
||||||
} from 'pixi.js';
|
|
||||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import type { World } from '../../runtime/World';
|
import type { World } from '../../runtime/World';
|
||||||
import { Theme, textStyle, ui } from '../../ui';
|
import { Theme, textStyle, ui } from '../../ui';
|
||||||
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
||||||
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
||||||
import {
|
import { routePointer } from '../../viewport/gamePointer';
|
||||||
cameraFactor,
|
import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view';
|
||||||
cameraView,
|
import { type Frame, WorldViewport } from '../../viewport/WorldViewport';
|
||||||
type GameScale,
|
|
||||||
isInside,
|
|
||||||
screenToWorld,
|
|
||||||
type View,
|
|
||||||
wheelNotches,
|
|
||||||
} from '../../viewport/view';
|
|
||||||
import { type Frame, WorldViewport } from './WorldViewport';
|
|
||||||
|
|
||||||
export class GameViewport extends WorldViewport {
|
export class GameViewport extends WorldViewport<EditorState> {
|
||||||
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
||||||
private readonly editorRunCaption = new Text({
|
private readonly editorRunCaption = new Text({
|
||||||
text: 'Behaviors run here · keys and clicks go to the game',
|
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.addChild(this.hint);
|
||||||
this.editorRunCaption.eventMode = 'none';
|
this.editorRunCaption.eventMode = 'none';
|
||||||
this.addChild(this.editorRunCaption);
|
this.addChild(this.editorRunCaption);
|
||||||
|
routePointer(
|
||||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
this,
|
||||||
if (e.button !== 0) return;
|
() => this.pointerWorld(),
|
||||||
this.reportPointer(e);
|
() => this.currentView(),
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** null = not playing. */
|
/** null = not playing. */
|
||||||
@@ -155,14 +135,4 @@ export class GameViewport extends WorldViewport {
|
|||||||
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
|
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
|
||||||
this.editorRunCaption.position.set(8, 6);
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor';
|
|||||||
import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost';
|
import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost';
|
||||||
import { type PaintTarget, TilemapPainter } from './tilemapPainting';
|
import { type PaintTarget, TilemapPainter } from './tilemapPainting';
|
||||||
import { TilemapSolidOutlines } from './tilemapSolids';
|
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. */
|
/** Pixels of travel before a press turns into a drag. */
|
||||||
const MARQUEE_THRESHOLD = 3;
|
const MARQUEE_THRESHOLD = 3;
|
||||||
|
|||||||
+3
-48
@@ -1,62 +1,20 @@
|
|||||||
import { Application, Container } from 'pixi.js';
|
|
||||||
// Static imports on purpose: Pixi registers render pipes (graphics, text, sprite) as a
|
// 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.
|
// 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".
|
// Lazy chunks loaded after `app.init()` would therefore fail with "unknown render pipe".
|
||||||
import { captureConsole } from './editor/console/log';
|
import { captureConsole } from './editor/console/log';
|
||||||
import { startEditor } from './editor/App';
|
import { startEditor } from './editor/App';
|
||||||
import { mountPlayground } from './playground';
|
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
|
// Before anything else: a renderer warns while it is being created, and those warnings are
|
||||||
// exactly the ones worth having in the panel.
|
// exactly the ones worth having in the panel.
|
||||||
captureConsole();
|
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<void> {
|
async function main(): Promise<void> {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const fonts = loadEditorFonts();
|
const { app, root } = await createPage(params);
|
||||||
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);
|
|
||||||
|
|
||||||
if (params.has('playground')) mountPlayground(app, root);
|
if (params.has('playground')) mountPlayground(app, root);
|
||||||
else await startEditor(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) => {
|
window.addEventListener('error', (event) => {
|
||||||
@@ -64,7 +22,4 @@ window.addEventListener('error', (event) => {
|
|||||||
console.error('[uncaught]', error instanceof Error ? error.stack : event.message);
|
console.error('[uncaught]', error instanceof Error ? error.stack : event.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch(showFailure);
|
||||||
console.error(error);
|
|
||||||
document.body.innerHTML = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
|
|
||||||
});
|
|
||||||
+56
@@ -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 = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
|
||||||
|
}
|
||||||
+65
@@ -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<void> {
|
||||||
|
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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export {
|
|||||||
renderCount,
|
renderCount,
|
||||||
requestRender,
|
requestRender,
|
||||||
resetRenderLoop,
|
resetRenderLoop,
|
||||||
|
stagePixel,
|
||||||
} from './render';
|
} from './render';
|
||||||
export {
|
export {
|
||||||
drawBand,
|
drawBand,
|
||||||
|
|||||||
+8
-1
@@ -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.
|
* Drives the renderer from changes instead of from the clock.
|
||||||
@@ -128,3 +128,10 @@ export function resetRenderLoop(): void {
|
|||||||
frameWaiters = [];
|
frameWaiters = [];
|
||||||
frameWork = new Map();
|
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])}`;
|
||||||
|
}
|
||||||
@@ -1,37 +1,46 @@
|
|||||||
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js';
|
||||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
import { isSymbolType } from '../core/model/AssetTypes';
|
||||||
import { type Extent, scaleOf } from '../../core/model/matrix';
|
import { type Extent, scaleOf } from '../core/model/matrix';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds';
|
import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds';
|
||||||
import { rectsIntersect } from '../../core/properties/camera';
|
import { rectsIntersect } from '../core/properties/camera';
|
||||||
import type { ClipRegion } from '../../core/properties/clip';
|
import type { ClipRegion } from '../core/properties/clip';
|
||||||
import type { EditorState } from '../state';
|
import type { Project } from '../core/model/Project';
|
||||||
import { type Size, Widget } from '../../ui';
|
import { type Size, Widget } from '../ui';
|
||||||
import { fontLibrary } from '../../viewport/FontLibrary';
|
import { fontLibrary } from './FontLibrary';
|
||||||
import { drawSlots } from '../../viewport/drawOrder';
|
import { drawSlots } from './drawOrder';
|
||||||
import type { DrawItem } from '../../viewport/items';
|
import type { DrawItem } from './items';
|
||||||
import { hashId } from '../../core/particles/nodes';
|
import { hashId } from '../core/particles/nodes';
|
||||||
import { ParticleField } from '../../viewport/particles';
|
import { ParticleField } from './particles';
|
||||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
import { particleLibrary } from './ParticleLibrary';
|
||||||
import { SHEET_EDGE } from '../../core/shader/nodes';
|
import { SHEET_EDGE } from '../core/shader/nodes';
|
||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from './ShaderLibrary';
|
||||||
import { type ShaderSource, wornShader } from '../../viewport/shaders';
|
import { type ShaderSource, wornShader } from './shaders';
|
||||||
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols';
|
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from './symbols';
|
||||||
import { TextPicture } from '../../viewport/textPicture';
|
import { TextPicture } from './textPicture';
|
||||||
import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap';
|
import { cellCentre, type LayerFocus, TilemapField } from './tilemap';
|
||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
import { textureLibrary } from './TextureLibrary';
|
||||||
import { gameClock } from '../../core/time';
|
import { gameClock } from '../core/time';
|
||||||
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
|
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). */
|
/** 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;
|
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 {
|
export interface Frame {
|
||||||
items: DrawItem[];
|
items: DrawItem[];
|
||||||
background: number;
|
background: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class WorldViewport extends Widget {
|
export abstract class WorldViewport<S extends ViewSource = ViewSource> extends Widget {
|
||||||
protected readonly bg = new Graphics();
|
protected readonly bg = new Graphics();
|
||||||
protected readonly content = new Container();
|
protected readonly content = new Container();
|
||||||
protected readonly world = new Container();
|
protected readonly world = new Container();
|
||||||
@@ -77,7 +86,7 @@ export abstract class WorldViewport extends Widget {
|
|||||||
private cameraMoves = 0;
|
private cameraMoves = 0;
|
||||||
private readonly stopFiles: (() => void)[];
|
private readonly stopFiles: (() => void)[];
|
||||||
|
|
||||||
protected constructor(protected readonly state: EditorState) {
|
protected constructor(protected readonly state: S) {
|
||||||
super();
|
super();
|
||||||
this.world.addChild(this.masks, this.objects, this.overlay);
|
this.world.addChild(this.masks, this.objects, this.overlay);
|
||||||
this.content.addChild(this.world);
|
this.content.addChild(this.world);
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The release store (#266) on a scratch directory. Plain PHP like snapshots.test.php; CI runs it
|
||||||
|
* with `php tests/php/releases.test.php` and reads the last line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../../api/lib/Store.php';
|
||||||
|
require __DIR__ . '/../../api/lib/ReleaseStore.php';
|
||||||
|
|
||||||
|
$failed = 0;
|
||||||
|
$passed = 0;
|
||||||
|
|
||||||
|
function check(string $name, bool $ok, mixed $got = null): void
|
||||||
|
{
|
||||||
|
global $failed, $passed;
|
||||||
|
if ($ok) {
|
||||||
|
$passed++;
|
||||||
|
echo "PASS {$name}\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$failed++;
|
||||||
|
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = sys_get_temp_dir() . '/game-maker-php-releases-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($dir);
|
||||||
|
$releases = new ReleaseStore($dir);
|
||||||
|
|
||||||
|
check('a project that was never released has none', $releases->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);
|
||||||
@@ -56,6 +56,7 @@ export async function topbar(t) {
|
|||||||
'Open project…',
|
'Open project…',
|
||||||
null,
|
null,
|
||||||
'Snapshots…',
|
'Snapshots…',
|
||||||
|
'Release…',
|
||||||
'Project settings…',
|
'Project settings…',
|
||||||
'Clear saved data',
|
'Clear saved data',
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import { privateVariables } from './56-private-variables.mjs';
|
|||||||
import { objectReferences } from './57-object-references.mjs';
|
import { objectReferences } from './57-object-references.mjs';
|
||||||
import { clipAreas } from './58-clip-areas.mjs';
|
import { clipAreas } from './58-clip-areas.mjs';
|
||||||
import { whilePlaying } from './59-while-playing.mjs';
|
import { whilePlaying } from './59-while-playing.mjs';
|
||||||
|
import { release } from './60-release.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -123,4 +124,5 @@ export const STEPS = [
|
|||||||
objectReferences,
|
objectReferences,
|
||||||
clipAreas,
|
clipAreas,
|
||||||
whilePlaying,
|
whilePlaying,
|
||||||
|
release,
|
||||||
];
|
];
|
||||||
+15
-7
@@ -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 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
|
* The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game
|
||||||
* `dist/` and this plugin copies the built HTML to the folder root so that the site URL
|
* (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the
|
||||||
* loads the production bundle while `api/` and `data/` sit next to it.
|
* 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 {
|
return {
|
||||||
name: 'game-maker:publish-index-html',
|
name: 'game-maker:publish-pages',
|
||||||
apply: 'build',
|
apply: 'build',
|
||||||
closeBundle() {
|
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',
|
root: 'web',
|
||||||
base: '/game-maker/dist/',
|
base: '/game-maker/dist/',
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
plugins: [publishIndexHtml(), shipPlugins()],
|
plugins: [publishPages(), shipPlugins()],
|
||||||
build: {
|
build: {
|
||||||
outDir: '../dist',
|
outDir: '../dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: Object.fromEntries(
|
||||||
|
PAGES.map((page) => [page.replace('.html', ''), resolve(__dirname, 'web', page)]),
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5175,
|
port: 5175,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
<title>Game</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1b1d22; }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="../src/play.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user