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:
adminandClaude Opus 5.5 committed 2026-10-13 13:27:43 +02:00
1 parent f92d69d902
commit 043c224cc3
66 files changed
+212 -112

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
# Game Maker # Feuerwerk
[![game-maker](https://git.nockal.com/meleki/game-maker/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.nockal.com/meleki/game-maker/actions?workflow=ci.yml) [![game-maker](https://git.nockal.com/meleki/game-maker/actions/workflows/ci.yml/badge.svg?branch=main)](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.
![](screenshot.png) ![](screenshot.png)
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
View File
@@ -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
View File
@@ -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 . '/';
} }
+1 -1
View File
@@ -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) {}
+2 -2
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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);
+39
View File
@@ -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;
}
+3 -1
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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());
+6
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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
View File
@@ -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);
+2 -1
View File
@@ -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');
} }
/** /**
+2 -1
View File
@@ -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');
} }
/** /**
+1 -1
View File
@@ -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[], {
+2 -1
View File
@@ -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. */
+2 -1
View File
@@ -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());
} }
+2 -1
View File
@@ -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 {
+5 -4
View File
@@ -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).
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -49,4 +49,4 @@ class TakeProcessor extends AudioWorkletProcessor {
} }
} }
registerProcessor('game-maker-take', TakeProcessor); registerProcessor('feuerwerk-take', TakeProcessor);
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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);
}); });
+50
View File
@@ -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': '[]' });
});
});
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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);
}); });
+2 -2
View File
@@ -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);
}); });
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 &
+1 -1
View File
@@ -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');
+2 -2
View File
@@ -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 }),
); );
+2 -2
View File
@@ -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) }),
); );
+3 -3
View File
@@ -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(
+2 -2
View File
@@ -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);
+1 -1
View 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(
+4 -4
View File
@@ -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);
+1 -1
View 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
+1 -1
View File
@@ -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(
+2 -4
View File
@@ -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(
+3 -3
View File
@@ -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);
+8 -8
View File
@@ -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.',
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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(
+4 -4
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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; }