Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f92d69d902
commit
043c224cc3
66 files changed
+212
-112
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
# Game Maker
|
# Feuerwerk
|
||||||
|
|
||||||
[](https://git.nockal.com/meleki/game-maker/actions?workflow=ci.yml)
|
[](https://git.nockal.com/meleki/game-maker/actions?workflow=ci.yml)
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ all in one program, all drawn inside a single WebGL canvas.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com/game-maker/?playground>
|
Live: <https://nockal.com/feuerwerk/> · Widget playground: <https://nockal.com/feuerwerk/?playground>
|
||||||
|
|
||||||
**The documentation is the wiki:** <https://git.nockal.com/meleki/game-maker/wiki> – every concept,
|
**The documentation is the wiki:** <https://git.nockal.com/meleki/game-maker/wiki> – every concept,
|
||||||
node, tab and shortcut, plus the architecture and the development workflow. This file is the short
|
node, tab and shortcut, plus the architecture and the development workflow. This file is the short
|
||||||
@@ -39,7 +39,7 @@ Node 22 is required.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
|
npm run dev # http://localhost:5175/feuerwerk/dist/ – API calls are proxied to production
|
||||||
npm run check # typecheck + lint + format:check + unit tests – the gate CI runs too
|
npm run check # typecheck + lint + format:check + unit tests – the gate CI runs too
|
||||||
npm run build # writes dist/ and regenerates index.html; the folder is what nginx serves
|
npm run build # writes dist/ and regenerates index.html; the folder is what nginx serves
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Game Maker API.
|
* Feuerwerk API.
|
||||||
*
|
*
|
||||||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
||||||
* site's nginx configuration does not pass PATH_INFO to PHP.
|
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||||||
@@ -736,6 +736,6 @@ try {
|
|||||||
default => Response::error('Not found', 404),
|
default => Response::error('Not found', 404),
|
||||||
};
|
};
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log('[game-maker api] ' . $e->getMessage());
|
error_log('[feuerwerk api] ' . $e->getMessage());
|
||||||
Response::error('Internal server error', 500);
|
Response::error('Internal server error', 500);
|
||||||
}
|
}
|
||||||
+2
-2
@@ -121,12 +121,12 @@ final class Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The path the editor is served under (`/game-maker/`), read off the script rather than
|
* The path the editor is served under (`/feuerwerk/`), read off the script rather than
|
||||||
* written down: the same checkout answers under a different prefix in CI.
|
* written down: the same checkout answers under a different prefix in CI.
|
||||||
*/
|
*/
|
||||||
private static function basePath(): string
|
private static function basePath(): string
|
||||||
{
|
{
|
||||||
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/game-maker/api/index.php');
|
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/feuerwerk/api/index.php');
|
||||||
$base = dirname(dirname($script));
|
$base = dirname(dirname($script));
|
||||||
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
|
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ final class SessionStore
|
|||||||
/** How long a login lasts without being used; every request that finds it pushes it out again. */
|
/** How long a login lasts without being used; every request that finds it pushes it out again. */
|
||||||
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
|
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
public const COOKIE = 'game_maker_session';
|
public const COOKIE = 'feuerwerk_session';
|
||||||
|
|
||||||
public function __construct(private readonly string $dir) {}
|
public function __construct(private readonly string $dir) {}
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "game-maker",
|
"name": "feuerwerk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "game-maker",
|
"name": "feuerwerk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier2d-compat": "^0.20.0",
|
"@dimforge/rapier2d-compat": "^0.20.0",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "game-maker",
|
"name": "feuerwerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface OpusStream {
|
|||||||
/** What a page holds at most: 255 lacing values, and so at most a packet of about 64 kB. */
|
/** What a page holds at most: 255 lacing values, and so at most a packet of about 64 kB. */
|
||||||
const MAX_SEGMENTS = 255;
|
const MAX_SEGMENTS = 255;
|
||||||
const SERIAL = 0x6d616b65;
|
const SERIAL = 0x6d616b65;
|
||||||
const VENDOR = 'game-maker';
|
const VENDOR = 'feuerwerk';
|
||||||
|
|
||||||
const CRC_TABLE = (() => {
|
const CRC_TABLE = (() => {
|
||||||
const table = new Uint32Array(256);
|
const table = new Uint32Array(256);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** The path the site is served under: the editor, the play page, the API and every data file. */
|
||||||
|
export const SITE_PATH = '/feuerwerk/';
|
||||||
|
|
||||||
|
/** The API's one entry point. */
|
||||||
|
export const API_ENDPOINT = `${SITE_PATH}api/index.php`;
|
||||||
|
|
||||||
|
/** Where the uploaded files of one kind are served, e.g. `dataPath('textures')`. */
|
||||||
|
export function dataPath(kind: string): string {
|
||||||
|
return `${SITE_PATH}data/${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prefix of every `localStorage` key while the editor was called Game Maker. */
|
||||||
|
const OLD_KEY_PREFIX = 'game-maker.';
|
||||||
|
const KEY_PREFIX = 'feuerwerk.';
|
||||||
|
|
||||||
|
type KeyStore = Pick<Storage, 'length' | 'key' | 'getItem' | 'setItem' | 'removeItem'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves what a browser kept under the old name to the new one: settings, panel sizes, folds and
|
||||||
|
* saves survive the rename. A key the new name already has keeps its value. Answers how many moved.
|
||||||
|
*/
|
||||||
|
export function moveOldStoredKeys(storage: KeyStore): number {
|
||||||
|
const old: string[] = [];
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (key?.startsWith(OLD_KEY_PREFIX)) old.push(key);
|
||||||
|
}
|
||||||
|
let moved = 0;
|
||||||
|
for (const key of old) {
|
||||||
|
const value = storage.getItem(key);
|
||||||
|
const renamed = KEY_PREFIX + key.slice(OLD_KEY_PREFIX.length);
|
||||||
|
if (value !== null && storage.getItem(renamed) === null) {
|
||||||
|
storage.setItem(renamed, value);
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
storage.removeItem(key);
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SITE_PATH } from '../site';
|
||||||
|
|
||||||
/** A project's released copy as the release dialog shows it (#266). */
|
/** A project's released copy as the release dialog shows it (#266). */
|
||||||
export interface ReleaseSummary {
|
export interface ReleaseSummary {
|
||||||
/** What the link carries: 32 hex digits, the same for as long as the project stays released. */
|
/** What the link carries: 32 hex digits, the same for as long as the project stays released. */
|
||||||
@@ -27,7 +29,7 @@ export interface ReleaseStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The page a released game is played on, next to the editor's own. */
|
/** The page a released game is played on, next to the editor's own. */
|
||||||
const PLAY_PAGE = '/game-maker/play.html';
|
const PLAY_PAGE = `${SITE_PATH}play.html`;
|
||||||
|
|
||||||
export function releaseUrl(origin: string, token: string): string {
|
export function releaseUrl(origin: string, token: string): string {
|
||||||
return `${origin}${PLAY_PAGE}?game=${token}`;
|
return `${origin}${PLAY_PAGE}?game=${token}`;
|
||||||
|
|||||||
+3
-4
@@ -21,10 +21,9 @@ import { Editor } from './Editor';
|
|||||||
import { isDragFrame } from './framePreview';
|
import { isDragFrame } from './framePreview';
|
||||||
import { startPlugins } from './plugins/startPlugins';
|
import { startPlugins } from './plugins/startPlugins';
|
||||||
import { CelUploadStore } from './pixelart/celUploadStore';
|
import { CelUploadStore } from './pixelart/celUploadStore';
|
||||||
|
import { API_ENDPOINT } from '../core/site';
|
||||||
import { EditorState } from './state';
|
import { EditorState } from './state';
|
||||||
|
|
||||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -70,7 +69,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
const { user, role } = await signedInUser(app, root, auth);
|
const { user, role } = await signedInUser(app, root, auth);
|
||||||
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
|
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
|
||||||
// also what tells two tabs on two accounts apart.
|
// also what tells two tabs on two accounts apart.
|
||||||
document.title = `Game Maker – ${user}`;
|
document.title = `Feuerwerk – ${user}`;
|
||||||
const state = new EditorState();
|
const state = new EditorState();
|
||||||
// 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.
|
||||||
@@ -123,7 +122,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
*/
|
*/
|
||||||
const endSession = async (editor: Editor): Promise<void> => {
|
const endSession = async (editor: Editor): Promise<void> => {
|
||||||
await editor.dispose();
|
await editor.dispose();
|
||||||
document.title = 'Game Maker';
|
document.title = 'Feuerwerk';
|
||||||
// The console panel is page-wide, the lines in it are not: what one account's project printed
|
// The console panel is page-wide, the lines in it are not: what one account's project printed
|
||||||
// is none of the next one's business.
|
// is none of the next one's business.
|
||||||
consoleLog.clear();
|
consoleLog.clear();
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class LoginScreen extends VStack {
|
|||||||
this.add(new Spacer(), 1);
|
this.add(new Spacer(), 1);
|
||||||
|
|
||||||
const card = new Card();
|
const card = new Card();
|
||||||
card.add(new Label('Game Maker', { style: 'title' }));
|
card.add(new Label('Feuerwerk', { style: 'title' }));
|
||||||
card.add(new Label('Sign in to open your projects', { style: 'muted' }));
|
card.add(new Label('Sign in to open your projects', { style: 'muted' }));
|
||||||
card.add(new Spacer(Theme.gap));
|
card.add(new Spacer(Theme.gap));
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { editorRecordingSettingsOf, type EditorRecordingSettings } from '../core/recording/editorRecording';
|
import { editorRecordingSettingsOf, type EditorRecordingSettings } from '../core/recording/editorRecording';
|
||||||
|
|
||||||
const STORAGE_KEY = 'game-maker.editor-recording';
|
const STORAGE_KEY = 'feuerwerk.editor-recording';
|
||||||
|
|
||||||
let kept: EditorRecordingSettings | null = null;
|
let kept: EditorRecordingSettings | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { clamp } from '../ui/math';
|
import { clamp } from '../ui/math';
|
||||||
|
|
||||||
const STORAGE_KEY = 'game-maker.general-settings';
|
const STORAGE_KEY = 'feuerwerk.general-settings';
|
||||||
|
|
||||||
export interface GeneralSettings {
|
export interface GeneralSettings {
|
||||||
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
|
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { clamp } from '../ui/math';
|
import { clamp } from '../ui/math';
|
||||||
|
|
||||||
const STORAGE_KEY = 'game-maker.panel-sizes';
|
const STORAGE_KEY = 'feuerwerk.panel-sizes';
|
||||||
|
|
||||||
let sizes: Map<string, number> | null = null;
|
let sizes: Map<string, number> | null = null;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ function browserStore(): Store | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveKey(projectId: string, area: SaveAreaName): string {
|
export function saveKey(projectId: string, area: SaveAreaName): string {
|
||||||
return `game-maker.saves.${projectId}.${area}`;
|
return `feuerwerk.saves.${projectId}.${area}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readArea(store: Store | null, key: string): SavedValues {
|
function readArea(store: Store | null, key: string): SavedValues {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Theme } from '../ui/Theme';
|
import { Theme } from '../ui/Theme';
|
||||||
|
|
||||||
const STORAGE_KEY = 'game-maker.folded-sections';
|
const STORAGE_KEY = 'feuerwerk.folded-sections';
|
||||||
|
|
||||||
let folded: Set<string> | null = null;
|
let folded: Set<string> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const INFO_WIDTH = 200;
|
|||||||
const GRID_WIDTH = 64;
|
const GRID_WIDTH = 64;
|
||||||
const INPUT_WIDTH = 104;
|
const INPUT_WIDTH = 104;
|
||||||
/** Where the browser keeps the input picked last. */
|
/** Where the browser keeps the input picked last. */
|
||||||
const INPUT_KEY = 'game-maker.record-input';
|
const INPUT_KEY = 'feuerwerk.record-input';
|
||||||
|
|
||||||
/** What the tab shows below its header: the tracks along time, or where they go (#350). */
|
/** What the tab shows below its header: the tracks along time, or where they go (#350). */
|
||||||
export type SongView = 'timeline' | 'routing';
|
export type SongView = 'timeline' | 'routing';
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
zoomWave,
|
zoomWave,
|
||||||
} from '../sound/waveView';
|
} from '../sound/waveView';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import { openVideo } from '../../viewport/VideoLibrary';
|
import { openVideo, videoFileUrl } from '../../viewport/VideoLibrary';
|
||||||
import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek';
|
import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { containRect } from '../texture/imageGrid';
|
import { containRect } from '../texture/imageGrid';
|
||||||
@@ -376,7 +376,7 @@ export class VideoCanvas extends Widget {
|
|||||||
chase: seekChase(),
|
chase: seekChase(),
|
||||||
asked: held,
|
asked: held,
|
||||||
gain: null,
|
gain: null,
|
||||||
element: openVideo(`/game-maker/data/videos/${asset.file}`, held, (texture) => {
|
element: openVideo(videoFileUrl(asset.file), held, (texture) => {
|
||||||
if (this.preview !== preview) return texture.destroy(true);
|
if (this.preview !== preview) return texture.destroy(true);
|
||||||
preview.texture = texture;
|
preview.texture = texture;
|
||||||
texture.source.on('update', () => this.invalidate());
|
texture.source.on('update', () => this.invalidate());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Application, Container } from 'pixi.js';
|
import { Application, Container } from 'pixi.js';
|
||||||
import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui';
|
import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui';
|
||||||
import { fixedPageSize, type PageSize, placePage } from './ui/pageSize';
|
import { fixedPageSize, type PageSize, placePage } from './ui/pageSize';
|
||||||
|
import { moveOldStoredKeys } from './core/site';
|
||||||
|
|
||||||
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
|
// `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.
|
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
|
||||||
@@ -21,6 +22,11 @@ function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2Rend
|
|||||||
* window and follows it when it is resized, unless `?w=&h=` give it a fixed size.
|
* window and follows it when it is resized, unless `?w=&h=` give it a fixed size.
|
||||||
*/
|
*/
|
||||||
export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> {
|
export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> {
|
||||||
|
try {
|
||||||
|
moveOldStoredKeys(window.localStorage);
|
||||||
|
} catch {
|
||||||
|
// A browser that refuses `localStorage` has nothing to move.
|
||||||
|
}
|
||||||
const fonts = loadEditorFonts();
|
const fonts = loadEditorFonts();
|
||||||
const fixed = fixedPageSize(params);
|
const fixed = fixedPageSize(params);
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
|
|||||||
+1
-2
@@ -15,8 +15,7 @@ import { projectPictureFactory } from './viewport/generatedPictures';
|
|||||||
import { preloadProjectFiles, setUpProjectFiles } from './viewport/projectFiles';
|
import { preloadProjectFiles, setUpProjectFiles } from './viewport/projectFiles';
|
||||||
import { textureLibrary } from './viewport/TextureLibrary';
|
import { textureLibrary } from './viewport/TextureLibrary';
|
||||||
import { createEmptyProject } from './core/model/Project';
|
import { createEmptyProject } from './core/model/Project';
|
||||||
|
import { API_ENDPOINT } from './core/site';
|
||||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import type { GameScreen } from './GameScreen';
|
|||||||
import type { Player } from './Player';
|
import type { Player } from './Player';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `window.__gameMakerPlay`: what the check scripts read off a released game (#266). The editor's
|
* `window.__feuerwerkPlay`: 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
|
* `__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.
|
* 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 {
|
export function installPlayerHook(screen: GameScreen, player: () => Player | null): void {
|
||||||
(window as unknown as { __gameMakerPlay: unknown }).__gameMakerPlay = {
|
(window as unknown as { __feuerwerkPlay: unknown }).__feuerwerkPlay = {
|
||||||
running: () => player()?.isRunning ?? false,
|
running: () => player()?.isRunning ?? false,
|
||||||
screen: () => screen.info(),
|
screen: () => screen.info(),
|
||||||
log: () => player()?.log() ?? [],
|
log: () => player()?.log() ?? [],
|
||||||
|
|||||||
+1
-1
@@ -119,7 +119,7 @@ export function mountPlayground(app: Application, root: VStack): void {
|
|||||||
listPanel.content.add(scroll, 1);
|
listPanel.content.add(scroll, 1);
|
||||||
row.add(listPanel, 1);
|
row.add(listPanel, 1);
|
||||||
|
|
||||||
root.add(new Label('Game Maker – UI playground', { style: 'bodyBold', padding: Theme.padding }));
|
root.add(new Label('Feuerwerk – UI playground', { style: 'bodyBold', padding: Theme.padding }));
|
||||||
root.add(row, 1);
|
root.add(row, 1);
|
||||||
root.add(status);
|
root.add(status);
|
||||||
root.resize(app.screen.width, app.screen.height);
|
root.resize(app.screen.width, app.screen.height);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fontFamilyOf } from '../core/model/font';
|
import { fontFamilyOf } from '../core/model/font';
|
||||||
|
import { dataPath } from '../core/site';
|
||||||
import { FileLibrary } from './FileLibrary';
|
import { FileLibrary } from './FileLibrary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +10,7 @@ class FontLibrary extends FileLibrary<FontFace> {
|
|||||||
private readonly ready = new Set<string>();
|
private readonly ready = new Set<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('/game-maker/data/fonts', 'fonts');
|
super(dataPath('fonts'), 'fonts');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Project } from '../core/model/Project';
|
import type { Project } from '../core/model/Project';
|
||||||
import { celFilesOf } from '../core/model/pixelart';
|
import { celFilesOf } from '../core/model/pixelart';
|
||||||
import { type CelPixels, celFileName, decodeCel, encodeCel, isEmptyCel } from '../core/model/pixelCel';
|
import { type CelPixels, celFileName, decodeCel, encodeCel, isEmptyCel } from '../core/model/pixelCel';
|
||||||
|
import { dataPath } from '../core/site';
|
||||||
import { FileLibrary } from './FileLibrary';
|
import { FileLibrary } from './FileLibrary';
|
||||||
|
|
||||||
/** Where cels go on the server: raw bytes in, the stored name back. */
|
/** Where cels go on the server: raw bytes in, the stored name back. */
|
||||||
@@ -20,7 +21,7 @@ export class PixelCels extends FileLibrary<CelPixels> {
|
|||||||
private readonly local = new Set<string>();
|
private readonly local = new Set<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('/game-maker/data/pixels', 'pixels');
|
super(dataPath('pixels'), 'pixels');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export class Take {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
durationMs: this.lastMs + frame,
|
durationMs: this.lastMs + frame,
|
||||||
app: 'game-maker',
|
app: 'feuerwerk',
|
||||||
});
|
});
|
||||||
this.collect();
|
this.collect();
|
||||||
const blob = new Blob([head, ...this.folded, ...this.loose, ...rest] as BlobPart[], {
|
const blob = new Blob([head, ...this.folded, ...this.loose, ...rest] as BlobPart[], {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
stageLatency,
|
stageLatency,
|
||||||
} from '../core/soundfx/nodes';
|
} from '../core/soundfx/nodes';
|
||||||
import { releaseTime } from '../core/instrument/envelope';
|
import { releaseTime } from '../core/instrument/envelope';
|
||||||
|
import { dataPath } from '../core/site';
|
||||||
import type { EaseShare } from '../core/model/animation';
|
import type { EaseShare } from '../core/model/animation';
|
||||||
import { BAKE_RATE } from '../core/music/bake';
|
import { BAKE_RATE } from '../core/music/bake';
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +67,7 @@ import { wake } from './wake';
|
|||||||
/** A voice is no louder than a sound item of a song turned all the way up. */
|
/** A voice is no louder than a sound item of a song turned all the way up. */
|
||||||
const MAX_VOLUME = dbToGain(SOUND_ITEM_MAX_DB);
|
const MAX_VOLUME = dbToGain(SOUND_ITEM_MAX_DB);
|
||||||
|
|
||||||
const DEFAULT_BASE = '/game-maker/data/sounds';
|
const DEFAULT_BASE = dataPath('sounds');
|
||||||
/** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */
|
/** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */
|
||||||
const DECODE_RATE = 48000;
|
const DECODE_RATE = 48000;
|
||||||
/** The rate the picture of a streamed sound is decoded at (#349): enough for its peaks, a sixth of the size. */
|
/** The rate the picture of a streamed sound is decoded at (#349): enough for its peaks, a sixth of the size. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { isGeneratedFile } from '../core/model/generated';
|
|||||||
import { isVideoKey } from '../core/model/video';
|
import { isVideoKey } from '../core/model/video';
|
||||||
import type { FrameRect, TextureRef } from '../core/model/texture';
|
import type { FrameRect, TextureRef } from '../core/model/texture';
|
||||||
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
|
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
|
||||||
|
import { dataPath } from '../core/site';
|
||||||
import { FileLibrary } from './FileLibrary';
|
import { FileLibrary } from './FileLibrary';
|
||||||
import { GeneratedPictures, type PictureFactory } from './generatedPictures';
|
import { GeneratedPictures, type PictureFactory } from './generatedPictures';
|
||||||
import { videoLibrary } from './VideoLibrary';
|
import { videoLibrary } from './VideoLibrary';
|
||||||
@@ -46,7 +47,7 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('/game-maker/data/textures', 'textures');
|
super(dataPath('textures'), 'textures');
|
||||||
videoLibrary.onChange(() => this.notifyChange());
|
videoLibrary.onChange(() => this.notifyChange());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Assets, Texture, VideoSource } from 'pixi.js';
|
import { Assets, Texture, VideoSource } from 'pixi.js';
|
||||||
import type { PropertyBag } from '../core/model/Properties';
|
import type { PropertyBag } from '../core/model/Properties';
|
||||||
|
import { dataPath } from '../core/site';
|
||||||
import type { TextureRef } from '../core/model/texture';
|
import type { TextureRef } from '../core/model/texture';
|
||||||
import {
|
import {
|
||||||
drivenTimeOf,
|
drivenTimeOf,
|
||||||
@@ -27,7 +28,7 @@ import { soundLibrary } from './SoundLibrary';
|
|||||||
import { followVideo, readSpeed, type SpeedReading } from './videoFollow';
|
import { followVideo, readSpeed, type SpeedReading } from './videoFollow';
|
||||||
import { chaseSeeks, seekVideo, type SeekChase } from './videoSeek';
|
import { chaseSeeks, seekVideo, type SeekChase } from './videoSeek';
|
||||||
|
|
||||||
const VIDEO_BASE = '/game-maker/data/videos';
|
const VIDEO_BASE = dataPath('videos');
|
||||||
|
|
||||||
/** Where the MP4 of a video asset's `file` is served. */
|
/** Where the MP4 of a video asset's `file` is served. */
|
||||||
export function videoFileUrl(file: string): string {
|
export function videoFileUrl(file: string): string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Renderer } from 'pixi.js';
|
import type { Renderer } from 'pixi.js';
|
||||||
import type { Project } from '../core/model/Project';
|
import type { Project } from '../core/model/Project';
|
||||||
|
import { dataPath } from '../core/site';
|
||||||
import { projectFiles } from '../core/model/projectFiles';
|
import { projectFiles } from '../core/model/projectFiles';
|
||||||
import { clipCopyTable, streamTable } from '../core/music/streams';
|
import { clipCopyTable, streamTable } from '../core/music/streams';
|
||||||
import { requestRender } from '../ui';
|
import { requestRender } from '../ui';
|
||||||
@@ -14,10 +15,10 @@ import { installVectorArtPictures } from './vectorArtPicture';
|
|||||||
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
|
* 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.
|
* 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 TEXTURE_BASE = dataPath('textures');
|
||||||
const FONT_BASE = '/game-maker/data/fonts';
|
const FONT_BASE = dataPath('fonts');
|
||||||
const SOUND_BASE = '/game-maker/data/sounds';
|
const SOUND_BASE = dataPath('sounds');
|
||||||
const PIXEL_BASE = '/game-maker/data/pixels';
|
const PIXEL_BASE = dataPath('pixels');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Points the file libraries at the server, once per page: the editor's and a released game's (#266).
|
* Points the file libraries at the server, once per page: the editor's and a released game's (#266).
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export class TakeRecorder {
|
|||||||
await loadWorklet(context);
|
await loadWorklet(context);
|
||||||
if (context.state === 'suspended') await context.resume();
|
if (context.state === 'suspended') await context.resume();
|
||||||
const source = context.createMediaStreamSource(stream);
|
const source = context.createMediaStreamSource(stream);
|
||||||
const node = new AudioWorkletNode(context, 'game-maker-take', {
|
const node = new AudioWorkletNode(context, 'feuerwerk-take', {
|
||||||
numberOfInputs: 1,
|
numberOfInputs: 1,
|
||||||
numberOfOutputs: 1,
|
numberOfOutputs: 1,
|
||||||
outputChannelCount: [1],
|
outputChannelCount: [1],
|
||||||
|
|||||||
@@ -49,4 +49,4 @@ class TakeProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerProcessor('game-maker-take', TakeProcessor);
|
registerProcessor('feuerwerk-take', TakeProcessor);
|
||||||
@@ -80,7 +80,7 @@ const HEAD: MatroskaHead = {
|
|||||||
},
|
},
|
||||||
audio: { channels: 2, private: new TextEncoder().encode('OpusHead'), preSkip: 312 },
|
audio: { channels: 2, private: new TextEncoder().encode('OpusHead'), preSkip: 312 },
|
||||||
durationMs: 1234.5,
|
durationMs: 1234.5,
|
||||||
app: 'game-maker',
|
app: 'feuerwerk',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The whole file: the head, then the clusters handed out before (`earlier`) and at the end. */
|
/** The whole file: the head, then the clusters handed out before (`earlier`) and at the end. */
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ beforeAll(async () => {
|
|||||||
describe('the link of a release', () => {
|
describe('the link of a release', () => {
|
||||||
it('leads to the play page with the token, and the play page reads it back', () => {
|
it('leads to the play page with the token, and the play page reads it back', () => {
|
||||||
const url = releaseUrl('https://nockal.com', TOKEN);
|
const url = releaseUrl('https://nockal.com', TOKEN);
|
||||||
expect(url).toBe(`https://nockal.com/game-maker/play.html?game=${TOKEN}`);
|
expect(url).toBe(`https://nockal.com/feuerwerk/play.html?game=${TOKEN}`);
|
||||||
expect(releaseToken(new URL(url).search)).toBe(TOKEN);
|
expect(releaseToken(new URL(url).search)).toBe(TOKEN);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { API_ENDPOINT, dataPath, moveOldStoredKeys } from '../../src/core/site';
|
||||||
|
|
||||||
|
function memoryStore(initial: Record<string, string>) {
|
||||||
|
const items = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
get length() {
|
||||||
|
return items.size;
|
||||||
|
},
|
||||||
|
key: (i: number) => [...items.keys()][i] ?? null,
|
||||||
|
getItem: (key: string) => items.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => void items.set(key, value),
|
||||||
|
removeItem: (key: string) => void items.delete(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('site', () => {
|
||||||
|
it('serves the API and the data files under /feuerwerk/', () => {
|
||||||
|
expect(API_ENDPOINT).toBe('/feuerwerk/api/index.php');
|
||||||
|
expect(dataPath('textures')).toBe('/feuerwerk/data/textures');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the keys stored under the old name and leaves the others alone', () => {
|
||||||
|
const store = memoryStore({
|
||||||
|
'game-maker.panel-sizes': '{"left":300}',
|
||||||
|
'game-maker.saves.p1.Savegame': '{"Lives":2}',
|
||||||
|
'other-site.key': 'x',
|
||||||
|
});
|
||||||
|
expect(moveOldStoredKeys(store)).toBe(2);
|
||||||
|
expect(Object.fromEntries(store.items)).toEqual({
|
||||||
|
'feuerwerk.panel-sizes': '{"left":300}',
|
||||||
|
'feuerwerk.saves.p1.Savegame': '{"Lives":2}',
|
||||||
|
'other-site.key': 'x',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a value the new name already has and drops the old one', () => {
|
||||||
|
const store = memoryStore({ 'game-maker.general-settings': 'old', 'feuerwerk.general-settings': 'new' });
|
||||||
|
expect(moveOldStoredKeys(store)).toBe(0);
|
||||||
|
expect(Object.fromEntries(store.items)).toEqual({ 'feuerwerk.general-settings': 'new' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing the second time', () => {
|
||||||
|
const store = memoryStore({ 'game-maker.folded-sections': '[]' });
|
||||||
|
moveOldStoredKeys(store);
|
||||||
|
expect(moveOldStoredKeys(store)).toBe(0);
|
||||||
|
expect(Object.fromEntries(store.items)).toEqual({ 'feuerwerk.folded-sections': '[]' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { keepPanelSize, panelSize, resetPanelSizes } from '../../src/editor/panelSizes';
|
import { keepPanelSize, panelSize, resetPanelSizes } from '../../src/editor/panelSizes';
|
||||||
|
|
||||||
const STORAGE_KEY = 'game-maker.panel-sizes';
|
const STORAGE_KEY = 'feuerwerk.panel-sizes';
|
||||||
|
|
||||||
function memoryStore(initial: Record<string, string> = {}) {
|
function memoryStore(initial: Record<string, string> = {}) {
|
||||||
const items = new Map(Object.entries(initial));
|
const items = new Map(Object.entries(initial));
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ describe('the memory of folded groups', () => {
|
|||||||
setFolded(key, true);
|
setFolded(key, true);
|
||||||
expect(isFolded(key)).toBe(true);
|
expect(isFolded(key)).toBe(true);
|
||||||
resetFolds();
|
resetFolds();
|
||||||
vi.stubGlobal('window', { localStorage: memoryStore({ 'game-maker.folded-sections': '{broken' }) });
|
vi.stubGlobal('window', { localStorage: memoryStore({ 'feuerwerk.folded-sections': '{broken' }) });
|
||||||
expect(isFolded(key)).toBe(false);
|
expect(isFolded(key)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ describe('the general settings', () => {
|
|||||||
keepGeneralSettings({ zoomTransitionMs: 350 });
|
keepGeneralSettings({ zoomTransitionMs: 350 });
|
||||||
resetGeneralSettings();
|
resetGeneralSettings();
|
||||||
expect(generalSettings().zoomTransitionMs).toBe(350);
|
expect(generalSettings().zoomTransitionMs).toBe(350);
|
||||||
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
|
expect(JSON.parse(store.items.get('feuerwerk.general-settings') ?? '')).toEqual({
|
||||||
zoomTransitionMs: 350,
|
zoomTransitionMs: 350,
|
||||||
showEmptyAssetGroups: false,
|
showEmptyAssetGroups: false,
|
||||||
customCursor: true,
|
customCursor: true,
|
||||||
@@ -437,7 +437,7 @@ describe('the general settings', () => {
|
|||||||
resetGeneralSettings();
|
resetGeneralSettings();
|
||||||
expect(generalSettings().customCursor).toBe(false);
|
expect(generalSettings().customCursor).toBe(false);
|
||||||
// Settings kept before the switch existed show it.
|
// Settings kept before the switch existed show it.
|
||||||
store.setItem('game-maker.general-settings', JSON.stringify({ zoomTransitionMs: 100 }));
|
store.setItem('feuerwerk.general-settings', JSON.stringify({ zoomTransitionMs: 100 }));
|
||||||
resetGeneralSettings();
|
resetGeneralSettings();
|
||||||
expect(generalSettings().customCursor).toBe(true);
|
expect(generalSettings().customCursor).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function throws(callable $work, string $class = InvalidArgumentException::class)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dir = sys_get_temp_dir() . '/game-maker-php-test-' . bin2hex(random_bytes(4));
|
$dir = sys_get_temp_dir() . '/feuerwerk-php-test-' . bin2hex(random_bytes(4));
|
||||||
mkdir($dir);
|
mkdir($dir);
|
||||||
$file = "{$dir}/users.json";
|
$file = "{$dir}/users.json";
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function wav(string $tag): string
|
|||||||
return 'RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32) . $tag;
|
return 'RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32) . $tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = sys_get_temp_dir() . '/game-maker-php-files-' . bin2hex(random_bytes(4));
|
$data = sys_get_temp_dir() . '/feuerwerk-php-files-' . bin2hex(random_bytes(4));
|
||||||
mkdir("{$data}/projects", 0775, true);
|
mkdir("{$data}/projects", 0775, true);
|
||||||
$users = new UserStore("{$data}/users.json");
|
$users = new UserStore("{$data}/users.json");
|
||||||
$users->put('ann', 'password1');
|
$users->put('ann', 'password1');
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function check(string $name, bool $ok, mixed $got = null): void
|
|||||||
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
$dir = sys_get_temp_dir() . '/game-maker-php-releases-' . bin2hex(random_bytes(4));
|
$dir = sys_get_temp_dir() . '/feuerwerk-php-releases-' . bin2hex(random_bytes(4));
|
||||||
mkdir($dir);
|
mkdir($dir);
|
||||||
$releases = new ReleaseStore($dir);
|
$releases = new ReleaseStore($dir);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function check(string $name, bool $ok, mixed $got = null): void
|
|||||||
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
$dir = sys_get_temp_dir() . '/game-maker-php-snapshots-' . bin2hex(random_bytes(4));
|
$dir = sys_get_temp_dir() . '/feuerwerk-php-snapshots-' . bin2hex(random_bytes(4));
|
||||||
mkdir($dir);
|
mkdir($dir);
|
||||||
$snapshots = new SnapshotStore($dir);
|
$snapshots = new SnapshotStore($dir);
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ check('other lines say nothing', VideoFormat::progress('frame=12', 10.0) === nul
|
|||||||
check('progress never passes the end', VideoFormat::progress('out_time_us=99000000', 10.0) === 1.0);
|
check('progress never passes the end', VideoFormat::progress('out_time_us=99000000', 10.0) === 1.0);
|
||||||
|
|
||||||
// The store and its queue.
|
// The store and its queue.
|
||||||
$data = sys_get_temp_dir() . '/game-maker-php-videos-' . bin2hex(random_bytes(4));
|
$data = sys_get_temp_dir() . '/feuerwerk-php-videos-' . bin2hex(random_bytes(4));
|
||||||
$store = new VideoStore("{$data}/videos");
|
$store = new VideoStore("{$data}/videos");
|
||||||
$folder = 'a1b2c3d4e5f6';
|
$folder = 'a1b2c3d4e5f6';
|
||||||
$rejected = static function (string $bytes) use ($store, $folder): bool {
|
$rejected = static function (string $bytes) use ($store, $folder): bool {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
// the server as before.
|
// the server as before.
|
||||||
|
|
||||||
$path = rawurldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH));
|
$path = rawurldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH));
|
||||||
if (!preg_match('#^/game-maker/data/videos/[0-9a-z]+/[0-9a-f]{64}\.mp4$#', $path)) {
|
if (!preg_match('#^/feuerwerk/data/videos/[0-9a-z]+/[0-9a-f]{64}\.mp4$#', $path)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$file = $_SERVER['DOCUMENT_ROOT'] . $path;
|
$file = $_SERVER['DOCUMENT_ROOT'] . $path;
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$(readlink -f "$0")")/../.."
|
cd "$(dirname "$(readlink -f "$0")")/../.."
|
||||||
source tools/ci/serve.sh
|
source tools/ci/serve.sh
|
||||||
game_maker_serve
|
feuerwerk_serve
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs
|
node tools/seed-e2e.mjs && node tools/e2e.mjs
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$(readlink -f "$0")")/../.."
|
cd "$(dirname "$(readlink -f "$0")")/../.."
|
||||||
source tools/ci/serve.sh
|
source tools/ci/serve.sh
|
||||||
game_maker_serve
|
feuerwerk_serve
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
code=0
|
code=0
|
||||||
|
|||||||
+4
-4
@@ -2,12 +2,12 @@
|
|||||||
# Sourced, not run: it leaves the PHP server in the caller's background and exports
|
# Sourced, not run: it leaves the PHP server in the caller's background and exports
|
||||||
# GAME_MAKER_BASE_URL, which every tool reads.
|
# GAME_MAKER_BASE_URL, which every tool reads.
|
||||||
#
|
#
|
||||||
# The build wires its assets absolutely under /game-maker/dist/, so the checkout has to be
|
# The build wires its assets absolutely under /feuerwerk/dist/, so the checkout has to be
|
||||||
# served under that path segment – hence the symlink into /srv rather than `-t .`.
|
# served under that path segment – hence the symlink into /srv rather than `-t .`.
|
||||||
|
|
||||||
export GAME_MAKER_BASE_URL=http://127.0.0.1:8080/game-maker/
|
export GAME_MAKER_BASE_URL=http://127.0.0.1:8080/feuerwerk/
|
||||||
|
|
||||||
game_maker_serve() {
|
feuerwerk_serve() {
|
||||||
local root
|
local root
|
||||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ game_maker_serve() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p /srv && ln -sfn "$root" /srv/game-maker
|
mkdir -p /srv && ln -sfn "$root" /srv/feuerwerk
|
||||||
# Uploaded videos are converted by the worker (#402); on stein2 that is the container
|
# Uploaded videos are converted by the worker (#402); on stein2 that is the container
|
||||||
# game-maker-video, here a process beside the server.
|
# game-maker-video, here a process beside the server.
|
||||||
php "$root/tools/video-worker.php" "$root/data" >/tmp/video-worker.log 2>&1 &
|
php "$root/tools/video-worker.php" "$root/data" >/tmp/video-worker.log 2>&1 &
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function inspector(t) {
|
|||||||
JSON.stringify({ switches: switches?.y, enabled: enabledRow?.y, position: position?.y }),
|
JSON.stringify({ switches: switches?.y, enabled: enabledRow?.y, position: position?.y }),
|
||||||
);
|
);
|
||||||
// A click on the band folds the group: its rows go, and the next band moves up against it.
|
// A click on the band folds the group: its rows go, and the next band moves up against it.
|
||||||
const folds = () => json(`localStorage.getItem('game-maker.folded-sections')`);
|
const folds = () => json(`localStorage.getItem('feuerwerk.folded-sections')`);
|
||||||
await click(transform.x, transform.y);
|
await click(transform.x, transform.y);
|
||||||
await settle();
|
await settle();
|
||||||
transform = await section('TRANSFORM');
|
transform = await section('TRANSFORM');
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export async function play(t) {
|
|||||||
Math.abs(frame.x + frame.w / 2 - (view.x + view.w / 2)) <= 1 &&
|
Math.abs(frame.x + frame.w / 2 - (view.x + view.w / 2)) <= 1 &&
|
||||||
Math.abs(frame.y + frame.h / 2 - (view.y + view.h / 2)) <= 1;
|
Math.abs(frame.y + frame.h / 2 - (view.y + view.h / 2)) <= 1;
|
||||||
const undoSteps = () => json('__gm.state.commands.undoStack.length');
|
const undoSteps = () => json('__gm.state.commands.undoStack.length');
|
||||||
await evaluate(`localStorage.removeItem('game-maker.editor')`);
|
await evaluate(`localStorage.removeItem('feuerwerk.editor')`);
|
||||||
const stepsBefore = await undoSteps();
|
const stepsBefore = await undoSteps();
|
||||||
let g = await scale();
|
let g = await scale();
|
||||||
const crateAt100 = (await json(`__gm.gameRect('o1')`)).w;
|
const crateAt100 = (await json(`__gm.gameRect('o1')`)).w;
|
||||||
@@ -364,7 +364,7 @@ export async function play(t) {
|
|||||||
check(
|
check(
|
||||||
'the scale is saved with the project (#226), not in this browser, and is no undo step',
|
'the scale is saved with the project (#226), not in this browser, and is no undo step',
|
||||||
(await json('__gm.state.project.gameScale')) === 'auto' &&
|
(await json('__gm.state.project.gameScale')) === 'auto' &&
|
||||||
(await evaluate(`localStorage.getItem('game-maker.editor')`)) === null &&
|
(await evaluate(`localStorage.getItem('feuerwerk.editor')`)) === null &&
|
||||||
(await undoSteps()) === stepsBefore,
|
(await undoSteps()) === stepsBefore,
|
||||||
JSON.stringify({ steps: await undoSteps(), stepsBefore }),
|
JSON.stringify({ steps: await undoSteps(), stepsBefore }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -629,7 +629,7 @@ async function emptyGroupsOnSwitch(t) {
|
|||||||
all.filter((g) => g.category === c).every((g) => g.ids.length === 0),
|
all.filter((g) => g.category === c).every((g) => g.ids.length === 0),
|
||||||
);
|
);
|
||||||
const seen = {
|
const seen = {
|
||||||
stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`),
|
stored: await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings') ?? 'null')`),
|
||||||
emptyAlpha: await json(`__gm.assetRowAlpha('group:${empty?.type}')`),
|
emptyAlpha: await json(`__gm.assetRowAlpha('group:${empty?.type}')`),
|
||||||
fullAlpha: await json(`__gm.assetRowAlpha('group:${full?.type}')`),
|
fullAlpha: await json(`__gm.assetRowAlpha('group:${full?.type}')`),
|
||||||
categoryAlpha: emptyCategory ? await json(`__gm.assetRowAlpha('category:${emptyCategory}')`) : null,
|
categoryAlpha: emptyCategory ? await json(`__gm.assetRowAlpha('category:${emptyCategory}')`) : null,
|
||||||
@@ -676,7 +676,7 @@ async function emptyGroupsOnSwitch(t) {
|
|||||||
check(
|
check(
|
||||||
'switched off again, only the types with assets are listed',
|
'switched off again, only the types with assets are listed',
|
||||||
shape(off) === shape(before) &&
|
shape(off) === shape(before) &&
|
||||||
(await json(`JSON.parse(localStorage.getItem('game-maker.general-settings'))`)).showEmptyAssetGroups ===
|
(await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings'))`)).showEmptyAssetGroups ===
|
||||||
false,
|
false,
|
||||||
JSON.stringify({ before: shape(before), off: shape(off) }),
|
JSON.stringify({ before: shape(before), off: shape(off) }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -398,8 +398,8 @@ export async function textures(t) {
|
|||||||
// a new name renames it (the user's rule), a path is stripped to its last part, and an
|
// a new name renames it (the user's rule), a path is stripped to its last part, and an
|
||||||
// upload carrying no name at all leaves the known one alone rather than erasing it.
|
// upload carrying no name at all leaves the known one alone rather than erasing it.
|
||||||
const renamed = await evaluate(`(async () => {
|
const renamed = await evaluate(`(async () => {
|
||||||
const bytes = await fetch('/game-maker/data/textures/${spare}').then((r) => r.blob());
|
const bytes = await fetch('/feuerwerk/data/textures/${spare}').then((r) => r.blob());
|
||||||
const post = (query) => fetch('/game-maker/api/index.php?r=textures' + query,
|
const post = (query) => fetch('/feuerwerk/api/index.php?r=textures' + query,
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: bytes }).then((r) => r.json());
|
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: bytes }).then((r) => r.json());
|
||||||
const evil = await post('&name=' + encodeURIComponent('../../evil/renamed.png'));
|
const evil = await post('&name=' + encodeURIComponent('../../evil/renamed.png'));
|
||||||
const nameless = await post('');
|
const nameless = await post('');
|
||||||
@@ -417,7 +417,7 @@ export async function textures(t) {
|
|||||||
JSON.stringify(await json('__gm.state.notice')),
|
JSON.stringify(await json('__gm.state.notice')),
|
||||||
);
|
);
|
||||||
// Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error.
|
// Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error.
|
||||||
const refusedUpload = await evaluate(`fetch('/game-maker/api/index.php?r=textures&name=x.png',
|
const refusedUpload = await evaluate(`fetch('/feuerwerk/api/index.php?r=textures&name=x.png',
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' })
|
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' })
|
||||||
.then(async (r) => ({ status: r.status, error: (await r.json()).error }))`);
|
.then(async (r) => ({ status: r.status, error: (await r.json()).error }))`);
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function font(t) {
|
|||||||
|
|
||||||
// 30a. What the server holds, straight from the API: the two files the seed put there.
|
// 30a. What the server holds, straight from the API: the two files the seed put there.
|
||||||
// `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields.
|
// `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields.
|
||||||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=fonts').then((r) => r.json())`);
|
const stored = await evaluate(`fetch('/feuerwerk/api/index.php?r=fonts').then((r) => r.json())`);
|
||||||
const serif = stored.find((item) => item.bytes === SERIF_BYTES);
|
const serif = stored.find((item) => item.bytes === SERIF_BYTES);
|
||||||
const mono = stored.find((item) => item.bytes === MONO_BYTES);
|
const mono = stored.find((item) => item.bytes === MONO_BYTES);
|
||||||
check(
|
check(
|
||||||
@@ -208,7 +208,7 @@ export async function font(t) {
|
|||||||
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
|
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
|
||||||
const dropped = await evaluate(`
|
const dropped = await evaluate(`
|
||||||
(async () => {
|
(async () => {
|
||||||
const bytes = await fetch('/game-maker/data/fonts/${mono.file}').then((r) => r.arrayBuffer());
|
const bytes = await fetch('/feuerwerk/data/fonts/${mono.file}').then((r) => r.arrayBuffer());
|
||||||
const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' });
|
const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' });
|
||||||
const data = new DataTransfer();
|
const data = new DataTransfer();
|
||||||
data.items.add(file);
|
data.items.add(file);
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export async function scope(t) {
|
|||||||
g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = 'sc-vars';
|
g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = 'sc-vars';
|
||||||
`);
|
`);
|
||||||
const pid = await json('__gm.state.project.id');
|
const pid = await json('__gm.state.project.id');
|
||||||
const saveKey = (area) => `game-maker.saves.${pid}.${area}`;
|
const saveKey = (area) => `feuerwerk.saves.${pid}.${area}`;
|
||||||
const saved = (area) =>
|
const saved = (area) =>
|
||||||
json(`JSON.parse(localStorage.getItem(${JSON.stringify(saveKey(area))}) ?? 'null')`);
|
json(`JSON.parse(localStorage.getItem(${JSON.stringify(saveKey(area))}) ?? 'null')`);
|
||||||
await t.evaluate(
|
await t.evaluate(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function sound(t) {
|
|||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
|
|
||||||
// 34a. What the server holds, straight from the API: the two files the seed put there.
|
// 34a. What the server holds, straight from the API: the two files the seed put there.
|
||||||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
const stored = await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`);
|
||||||
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
||||||
const drone = stored.find((item) => item.bytes === DRONE_BYTES);
|
const drone = stored.find((item) => item.bytes === DRONE_BYTES);
|
||||||
check(
|
check(
|
||||||
@@ -75,7 +75,7 @@ export async function sound(t) {
|
|||||||
const bytes = crypto.getRandomValues(new Uint8Array(48));
|
const bytes = crypto.getRandomValues(new Uint8Array(48));
|
||||||
bytes.set([...'RIFF'].map((c) => c.charCodeAt(0)), 0);
|
bytes.set([...'RIFF'].map((c) => c.charCodeAt(0)), 0);
|
||||||
bytes.set([...'WAVE'].map((c) => c.charCodeAt(0)), 8);
|
bytes.set([...'WAVE'].map((c) => c.charCodeAt(0)), 8);
|
||||||
const r = await fetch('/game-maker/api/index.php?r=sounds&name=spare.wav', {
|
const r = await fetch('/feuerwerk/api/index.php?r=sounds&name=spare.wav', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: bytes,
|
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: bytes,
|
||||||
});
|
});
|
||||||
return (await r.json()).file;
|
return (await r.json()).file;
|
||||||
@@ -115,7 +115,7 @@ export async function sound(t) {
|
|||||||
() => json('__gm.soundPicker()'),
|
() => json('__gm.soundPicker()'),
|
||||||
(p) => !p.files.includes(spare),
|
(p) => !p.files.includes(spare),
|
||||||
);
|
);
|
||||||
const served = await evaluate(`fetch('/game-maker/data/sounds/${spare}').then((r) => r.status)`);
|
const served = await evaluate(`fetch('/feuerwerk/data/sounds/${spare}').then((r) => r.status)`);
|
||||||
check(
|
check(
|
||||||
'the first press asks, the second deletes the file on the server',
|
'the first press asks, the second deletes the file on the server',
|
||||||
armed.deleteButton.shown === 'Delete for good' &&
|
armed.deleteButton.shown === 'Delete for good' &&
|
||||||
@@ -3327,7 +3327,7 @@ export async function sound(t) {
|
|||||||
// 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file.
|
// 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file.
|
||||||
const dropped = await evaluate(`
|
const dropped = await evaluate(`
|
||||||
(async () => {
|
(async () => {
|
||||||
const bytes = await fetch('/game-maker/data/sounds/${drone.file}').then((r) => r.arrayBuffer());
|
const bytes = await fetch('/feuerwerk/data/sounds/${drone.file}').then((r) => r.arrayBuffer());
|
||||||
const file = new File([bytes], 'drone.wav', { type: 'audio/wav' });
|
const file = new File([bytes], 'drone.wav', { type: 'audio/wav' });
|
||||||
const data = new DataTransfer();
|
const data = new DataTransfer();
|
||||||
data.items.add(file);
|
data.items.add(file);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function loadScene(t) {
|
|||||||
const firstName = await json('__gm.state.activeScene.name');
|
const firstName = await json('__gm.state.activeScene.name');
|
||||||
|
|
||||||
// A sound to have something playing across the swap; the seed put the files on the server.
|
// A sound to have something playing across the swap; the seed put the files on the server.
|
||||||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
const stored = await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`);
|
||||||
const file = stored[0]?.file ?? '';
|
const file = stored[0]?.file ?? '';
|
||||||
|
|
||||||
// 35a. A second scene with a box of its own, a Variables asset, and the behavior that counts and
|
// 35a. A second scene with a box of its own, a Variables asset, and the behavior that counts and
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export async function plugins(t) {
|
|||||||
// The switch is the project's (#119): it stands in the project file and travels with it. The
|
// The switch is the project's (#119): it stands in the project file and travels with it. The
|
||||||
// server's own list knows nothing about it any more – it only says which files there are.
|
// server's own list knows nothing about it any more – it only says which files there are.
|
||||||
const listed = await evaluate(
|
const listed = await evaluate(
|
||||||
`fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
|
`fetch('/feuerwerk/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
|
||||||
);
|
);
|
||||||
const file = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js');
|
const file = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js');
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export async function topbar(t) {
|
|||||||
const kept = {
|
const kept = {
|
||||||
dialog: await json('__gm.generalSettingsDialog()'),
|
dialog: await json('__gm.generalSettingsDialog()'),
|
||||||
ms: await json('__gm.zoomTransition()'),
|
ms: await json('__gm.zoomTransition()'),
|
||||||
stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`),
|
stored: await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings') ?? 'null')`),
|
||||||
};
|
};
|
||||||
check(
|
check(
|
||||||
'"General settings…" opens on the kept transition, and Apply keeps the one typed in the browser',
|
'"General settings…" opens on the kept transition, and Apply keeps the one typed in the browser',
|
||||||
@@ -463,9 +463,7 @@ export async function topbar(t) {
|
|||||||
const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() };
|
const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() };
|
||||||
await switchCursor();
|
await switchCursor();
|
||||||
const cursorOff = { css: await json('__gm.canvasCursor()') };
|
const cursorOff = { css: await json('__gm.canvasCursor()') };
|
||||||
const storedOff = await json(
|
const storedOff = await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings')).customCursor`);
|
||||||
`JSON.parse(localStorage.getItem('game-maker.general-settings')).customCursor`,
|
|
||||||
);
|
|
||||||
await switchCursor();
|
await switchCursor();
|
||||||
const cursorBack = await json('__gm.canvasCursor()');
|
const cursorBack = await json('__gm.canvasCursor()');
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -818,7 +818,7 @@ export async function pixelArt(t) {
|
|||||||
const files = [...new Set(stored.frames.flatMap((f) => Object.values(f.cels)))];
|
const files = [...new Set(stored.frames.flatMap((f) => Object.values(f.cels)))];
|
||||||
const statuses = JSON.parse(
|
const statuses = JSON.parse(
|
||||||
await evaluate(
|
await evaluate(
|
||||||
`Promise.all(${JSON.stringify(files)}.map((f) => fetch('/game-maker/data/pixels/' + f).then((r) => r.status))).then((s) => JSON.stringify(s))`,
|
`Promise.all(${JSON.stringify(files)}.map((f) => fetch('/feuerwerk/data/pixels/' + f).then((r) => r.status))).then((s) => JSON.stringify(s))`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
@@ -828,7 +828,7 @@ export async function pixelArt(t) {
|
|||||||
);
|
);
|
||||||
// 48m-ii. A divider stays where it was left: its place is kept in the browser, not in the project,
|
// 48m-ii. A divider stays where it was left: its place is kept in the browser, not in the project,
|
||||||
// so the reload brings the wider panel back. Dragged back after, for the modules to come.
|
// so the reload brings the wider panel back. Dragged back after, for the modules to come.
|
||||||
const keptSizes = () => json(`JSON.parse(localStorage.getItem('game-maker.panel-sizes') ?? '{}')`);
|
const keptSizes = () => json(`JSON.parse(localStorage.getItem('feuerwerk.panel-sizes') ?? '{}')`);
|
||||||
const columnBefore = await json('__gm.leftColumn()');
|
const columnBefore = await json('__gm.leftColumn()');
|
||||||
const bars = await json('__gm.panelSplitters()');
|
const bars = await json('__gm.panelSplitters()');
|
||||||
await drag(bars.left.x, bars.left.y, bars.left.x + 40, bars.left.y, { steps: 6 });
|
await drag(bars.left.x, bars.left.y, bars.left.x + 40, bars.left.y, { steps: 6 });
|
||||||
@@ -878,7 +878,7 @@ export async function pixelArt(t) {
|
|||||||
folded: foldedHead.folded,
|
folded: foldedHead.folded,
|
||||||
h: foldedHead.h,
|
h: foldedHead.h,
|
||||||
merge: await control('layer:merge'),
|
merge: await control('layer:merge'),
|
||||||
stored: await json(`localStorage.getItem('game-maker.folded-sections')`),
|
stored: await json(`localStorage.getItem('feuerwerk.folded-sections')`),
|
||||||
};
|
};
|
||||||
await shot('48-list-folded');
|
await shot('48-list-folded');
|
||||||
await click(foldedHead.x, foldedHead.y);
|
await click(foldedHead.x, foldedHead.y);
|
||||||
|
|||||||
@@ -123,12 +123,12 @@ export async function release(t) {
|
|||||||
await waitUntil('__gm.release() !== null', { label: 'the release dialog opened' });
|
await waitUntil('__gm.release() !== null', { label: 'the release dialog opened' });
|
||||||
return json('__gm.release()');
|
return json('__gm.release()');
|
||||||
};
|
};
|
||||||
const player = (what) => json(`__gameMakerPlay.${what}`);
|
const player = (what) => json(`__feuerwerkPlay.${what}`);
|
||||||
/** The play page as somebody without an account opens it. */
|
/** The play page as somebody without an account opens it. */
|
||||||
const openLink = async (link) => {
|
const openLink = async (link) => {
|
||||||
await cdp.send('Network.clearBrowserCookies');
|
await cdp.send('Network.clearBrowserCookies');
|
||||||
await cdp.send('Page.navigate', { url: link });
|
await cdp.send('Page.navigate', { url: link });
|
||||||
await waitUntil('Boolean(window.__gameMakerPlay)', { timeout: 15000, label: 'the play page is up' });
|
await waitUntil('Boolean(window.__feuerwerkPlay)', { 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 step before may leave an edit on its way to the server; leaving the page then would stop at
|
||||||
@@ -157,7 +157,7 @@ export async function release(t) {
|
|||||||
'Publish in the release dialog makes a link to the play page, for the project as saved',
|
'Publish in the release dialog makes a link to the play page, for the project as saved',
|
||||||
before.link === null &&
|
before.link === null &&
|
||||||
before.state.startsWith('Not released') &&
|
before.state.startsWith('Not released') &&
|
||||||
after.link === `${origin}/game-maker/play.html?game=${token}` &&
|
after.link === `${origin}/feuerwerk/play.html?game=${token}` &&
|
||||||
after.state.endsWith('the link plays the project as saved') &&
|
after.state.endsWith('the link plays the project as saved') &&
|
||||||
after.warnings.length === 0,
|
after.warnings.length === 0,
|
||||||
JSON.stringify({ before, after }),
|
JSON.stringify({ before, after }),
|
||||||
@@ -202,7 +202,7 @@ export async function release(t) {
|
|||||||
|
|
||||||
// 60c. The link, without a session: the page colour, "Click to play", and nothing of the editor.
|
// 60c. The link, without a session: the page colour, "Click to play", and nothing of the editor.
|
||||||
await openLink(after.link);
|
await openLink(after.link);
|
||||||
await waitUntil('__gameMakerPlay.screen().caption === "Click to play"', {
|
await waitUntil('__feuerwerkPlay.screen().caption === "Click to play"', {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
label: 'the game offers to start',
|
label: 'the game offers to start',
|
||||||
});
|
});
|
||||||
@@ -236,7 +236,7 @@ export async function release(t) {
|
|||||||
HTMLAnchorElement.prototype.click = function () { window.__downloads.push({ name: this.download, href: this.href }); };`);
|
HTMLAnchorElement.prototype.click = function () { window.__downloads.push({ name: this.download, href: this.href }); };`);
|
||||||
const size = { w: await json('innerWidth'), h: await json('innerHeight') };
|
const size = { w: await json('innerWidth'), h: await json('innerHeight') };
|
||||||
await click(size.w / 2, size.h / 2);
|
await click(size.w / 2, size.h / 2);
|
||||||
await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' });
|
await waitUntil('__feuerwerkPlay.running()', { label: 'the game runs' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
const cornerAfterClick = await player('fullscreen()');
|
const cornerAfterClick = await player('fullscreen()');
|
||||||
const screen = await player('screen()');
|
const screen = await player('screen()');
|
||||||
@@ -366,12 +366,12 @@ export async function release(t) {
|
|||||||
const releasedZoom = (await (await send('GET', `releases/${token}`, undefined, false)).json()).project
|
const releasedZoom = (await (await send('GET', `releases/${token}`, undefined, false)).json()).project
|
||||||
.gameLinkZoom;
|
.gameLinkZoom;
|
||||||
await openLink(after.link);
|
await openLink(after.link);
|
||||||
await waitUntil('__gameMakerPlay.screen().caption === "Click to play"', {
|
await waitUntil('__feuerwerkPlay.screen().caption === "Click to play"', {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
label: 'the fitted game offers to start',
|
label: 'the fitted game offers to start',
|
||||||
});
|
});
|
||||||
await click(size.w / 2, size.h / 2);
|
await click(size.w / 2, size.h / 2);
|
||||||
await waitUntil('__gameMakerPlay.running()', { label: 'the fitted game runs' });
|
await waitUntil('__feuerwerkPlay.running()', { label: 'the fitted game runs' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
const fitted = await player('screen()');
|
const fitted = await player('screen()');
|
||||||
const fit = Math.min(size.w / CAMERA.width, size.h / CAMERA.height);
|
const fit = Math.min(size.w / CAMERA.width, size.h / CAMERA.height);
|
||||||
@@ -418,7 +418,7 @@ export async function release(t) {
|
|||||||
await clickLast('Cancel');
|
await clickLast('Cancel');
|
||||||
const dead = await send('GET', `releases/${token}`, undefined, false);
|
const dead = await send('GET', `releases/${token}`, undefined, false);
|
||||||
await openLink(after.link);
|
await openLink(after.link);
|
||||||
await waitUntil('__gameMakerPlay.screen().caption !== "Loading…"', { label: 'the page gave up' });
|
await waitUntil('__feuerwerkPlay.screen().caption !== "Loading…"', { label: 'the page gave up' });
|
||||||
check(
|
check(
|
||||||
'Take down makes the link dead',
|
'Take down makes the link dead',
|
||||||
dead.status === 404 && (await player('screen()')).caption === 'This game is not available.',
|
dead.status === 404 && (await player('screen()')).caption === 'This game is not available.',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function pattern(t) {
|
|||||||
await press('Escape');
|
await press('Escape');
|
||||||
await evaluate('__gm.state.select(null)');
|
await evaluate('__gm.state.select(null)');
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
const stored = await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`);
|
||||||
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
__gm.state.project.assets.push(
|
__gm.state.project.assets.push(
|
||||||
@@ -600,7 +600,7 @@ export async function pattern(t) {
|
|||||||
await click(unbaked.button.x, unbaked.button.y);
|
await click(unbaked.button.x, unbaked.button.y);
|
||||||
const bakedNow = await waitFor(bakeInfo, (info) => info.status === 'baked', 100);
|
const bakedNow = await waitFor(bakeInfo, (info) => info.status === 'baked', 100);
|
||||||
const bake = bakedNow.bake;
|
const bake = bakedNow.bake;
|
||||||
const listed = (await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`)).find(
|
const listed = (await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`)).find(
|
||||||
(item) => item.file === bake?.file,
|
(item) => item.file === bake?.file,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
@@ -678,7 +678,7 @@ export async function pattern(t) {
|
|||||||
const removed = await bakeInfo();
|
const removed = await bakeInfo();
|
||||||
const fileGone = await waitFor(
|
const fileGone = await waitFor(
|
||||||
async () =>
|
async () =>
|
||||||
(await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`)).some(
|
(await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`)).some(
|
||||||
(item) => item.file === bake?.file,
|
(item) => item.file === bake?.file,
|
||||||
),
|
),
|
||||||
(there) => !there,
|
(there) => !there,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export async function song(t) {
|
|||||||
await press('Escape');
|
await press('Escape');
|
||||||
await evaluate('__gm.state.select(null)');
|
await evaluate('__gm.state.select(null)');
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
const stored = await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`);
|
||||||
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
__gm.state.project.assets.push(
|
__gm.state.project.assets.push(
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export async function recording(t) {
|
|||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.onerror = () => resolve('null');
|
video.onerror = () => resolve('null');
|
||||||
video.src = '/game-maker/data/videos/${file}';
|
video.src = '/feuerwerk/data/videos/${file}';
|
||||||
video.play().then(() => video.requestVideoFrameCallback(() => {
|
video.play().then(() => video.requestVideoFrameCallback(() => {
|
||||||
video.pause();
|
video.pause();
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -297,7 +297,7 @@ export async function recording(t) {
|
|||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.onerror = () => resolve('null');
|
video.onerror = () => resolve('null');
|
||||||
video.src = '/game-maker/data/videos/${file}';
|
video.src = '/feuerwerk/data/videos/${file}';
|
||||||
video.play().then(() => video.requestVideoFrameCallback(() => {
|
video.play().then(() => video.requestVideoFrameCallback(() => {
|
||||||
video.pause();
|
video.pause();
|
||||||
video.onseeked = () => setTimeout(() => {
|
video.onseeked = () => setTimeout(() => {
|
||||||
@@ -386,7 +386,7 @@ export async function recording(t) {
|
|||||||
const tabHead = await firstBytes(tabDownload.href);
|
const tabHead = await firstBytes(tabDownload.href);
|
||||||
check(
|
check(
|
||||||
"the video tab's Download saves the recording as it was recorded, not the MP4",
|
"the video tab's Download saves the recording as it was recorded, not the MP4",
|
||||||
tabDownload.href === `/game-maker/data/videos/${file.replace(/\.mp4$/, '.source.mkv')}` &&
|
tabDownload.href === `/feuerwerk/data/videos/${file.replace(/\.mp4$/, '.source.mkv')}` &&
|
||||||
tabDownload.name === `${take.name}.mkv` &&
|
tabDownload.name === `${take.name}.mkv` &&
|
||||||
(await json('window.__downloads')).some((d) => d.name === tabDownload.name) &&
|
(await json('window.__downloads')).some((d) => d.name === tabDownload.name) &&
|
||||||
tabHead === '1a45dfa3',
|
tabHead === '1a45dfa3',
|
||||||
@@ -457,7 +457,7 @@ export async function recording(t) {
|
|||||||
pickerDownload.enabled &&
|
pickerDownload.enabled &&
|
||||||
pickerConvert.enabled &&
|
pickerConvert.enabled &&
|
||||||
pickerSaved?.name === `${sharp.name}.mkv` &&
|
pickerSaved?.name === `${sharp.name}.mkv` &&
|
||||||
pickerSaved.href === `/game-maker/data/videos/${kept.file.replace(/\.mp4$/, '.source.mkv')}` &&
|
pickerSaved.href === `/feuerwerk/data/videos/${kept.file.replace(/\.mp4$/, '.source.mkv')}` &&
|
||||||
pickerHead === '1a45dfa3' &&
|
pickerHead === '1a45dfa3' &&
|
||||||
converted.detail.includes('2304 × 1440') &&
|
converted.detail.includes('2304 × 1440') &&
|
||||||
converted.extras[1].enabled === false &&
|
converted.extras[1].enabled === false &&
|
||||||
|
|||||||
+3
-3
@@ -16,7 +16,7 @@ export const E2E_ADMIN = 'e2e-admin';
|
|||||||
const DATA = resolve(dirname(fileURLToPath(import.meta.url)), '../../data');
|
const DATA = resolve(dirname(fileURLToPath(import.meta.url)), '../../data');
|
||||||
const PASSWORD_FILE = resolve(DATA, 'e2e-password.txt');
|
const PASSWORD_FILE = resolve(DATA, 'e2e-password.txt');
|
||||||
const ADMIN_PASSWORD_FILE = resolve(DATA, 'e2e-admin-password.txt');
|
const ADMIN_PASSWORD_FILE = resolve(DATA, 'e2e-admin-password.txt');
|
||||||
const COOKIE = 'game_maker_session';
|
const COOKIE = 'feuerwerk_session';
|
||||||
|
|
||||||
/** One session per server for the whole process; logging in per request would be pointless load. */
|
/** One session per server for the whole process; logging in per request would be pointless load. */
|
||||||
const cookies = new Map();
|
const cookies = new Map();
|
||||||
@@ -48,7 +48,7 @@ export async function e2eAdminPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signs in as anyone and answers with a fresh `game_maker_session=…`, uncached – for a step that
|
* Signs in as anyone and answers with a fresh `feuerwerk_session=…`, uncached – for a step that
|
||||||
* needs a second account beside the tools' own.
|
* needs a second account beside the tools' own.
|
||||||
*/
|
*/
|
||||||
export async function logIn(baseUrl, username, password) {
|
export async function logIn(baseUrl, username, password) {
|
||||||
@@ -68,7 +68,7 @@ export async function logIn(baseUrl, username, password) {
|
|||||||
return set.split(';')[0];
|
return set.split(';')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `game_maker_session=…` for `Cookie` headers, logging in on first use. */
|
/** `feuerwerk_session=…` for `Cookie` headers, logging in on first use. */
|
||||||
export async function sessionCookie(baseUrl) {
|
export async function sessionCookie(baseUrl) {
|
||||||
const cached = cookies.get(baseUrl);
|
const cached = cookies.get(baseUrl);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const PROJECTS = {
|
|||||||
actor: '3ccb5c67220b',
|
actor: '3ccb5c67220b',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/';
|
export const DEFAULT_BASE_URL = 'https://nockal.com/feuerwerk/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId` and to
|
* Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId` and to
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ declare(strict_types=1);
|
|||||||
* A file is only taken away from where it was when every document naming it could be rewritten:
|
* A file is only taken away from where it was when every document naming it could be rewritten:
|
||||||
* one named by a document without an owner (or of an account that no longer exists) stays as well.
|
* one named by a document without an owner (or of an account that no longer exists) stays as well.
|
||||||
*
|
*
|
||||||
* docker exec -u www-data web-php-fpm php /var/www/html/game-maker/tools/migrate-file-owners.php [--dry-run] [data dir]
|
* docker exec -u www-data web-php-fpm php /var/www/html/feuerwerk/tools/migrate-file-owners.php [--dry-run] [data dir]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require __DIR__ . '/../api/lib/AtomicFile.php';
|
require __DIR__ . '/../api/lib/AtomicFile.php';
|
||||||
|
|||||||
+6
-5
@@ -3,11 +3,12 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import { publishPlugins } from './tools/lib/plugins.mjs';
|
import { publishPlugins } from './tools/lib/plugins.mjs';
|
||||||
|
import { SITE_PATH } from './src/core/site';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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/feuerwerk/.
|
||||||
* The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game
|
* The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game
|
||||||
* (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the
|
* (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the
|
||||||
* folder root, so that the site URLs load the production bundles while `api/` and `data/` sit next
|
* folder root, so that the site URLs load the production bundles while `api/` and `data/` sit next
|
||||||
@@ -17,7 +18,7 @@ const PAGES = ['index.html', 'play.html'];
|
|||||||
|
|
||||||
function publishPages(): Plugin {
|
function publishPages(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'game-maker:publish-pages',
|
name: 'feuerwerk:publish-pages',
|
||||||
apply: 'build',
|
apply: 'build',
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
for (const page of PAGES) copyFileSync(resolve(__dirname, 'dist', page), resolve(__dirname, page));
|
for (const page of PAGES) copyFileSync(resolve(__dirname, 'dist', page), resolve(__dirname, page));
|
||||||
@@ -33,7 +34,7 @@ function publishPages(): Plugin {
|
|||||||
*/
|
*/
|
||||||
function shipPlugins(): Plugin {
|
function shipPlugins(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'game-maker:publish-plugins',
|
name: 'feuerwerk:publish-plugins',
|
||||||
apply: 'build',
|
apply: 'build',
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
publishPlugins(__dirname);
|
publishPlugins(__dirname);
|
||||||
@@ -43,7 +44,7 @@ function shipPlugins(): Plugin {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: 'web',
|
root: 'web',
|
||||||
base: '/game-maker/dist/',
|
base: `${SITE_PATH}dist/`,
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
plugins: [publishPages(), shipPlugins()],
|
plugins: [publishPages(), shipPlugins()],
|
||||||
build: {
|
build: {
|
||||||
@@ -61,7 +62,7 @@ export default defineConfig({
|
|||||||
port: 5175,
|
port: 5175,
|
||||||
// During development the PHP API is served by the production nginx; proxy it.
|
// During development the PHP API is served by the production nginx; proxy it.
|
||||||
proxy: {
|
proxy: {
|
||||||
'/game-maker/api': {
|
[`${SITE_PATH}api`]: {
|
||||||
target: 'https://nockal.com',
|
target: 'https://nockal.com',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
<title>Game Maker</title>
|
<title>Feuerwerk</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1b1d22; }
|
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1b1d22; }
|
||||||
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
|
|||||||
Reference in new issue
Block a user