create-vimp-game 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bin/create-vimp-game.js +15 -0
  2. package/package.json +40 -0
  3. package/src/cli.js +227 -0
  4. package/src/generator.js +196 -0
  5. package/src/preflight.js +48 -0
  6. package/src/prompts.js +79 -0
  7. package/src/tokens.js +99 -0
  8. package/src/ui.js +66 -0
  9. package/src/versions.generated.json +4 -0
  10. package/src/versions.js +87 -0
  11. package/templates/default/CLAUDE.md.tpl +51 -0
  12. package/templates/default/Cargo.toml.tpl +27 -0
  13. package/templates/default/LICENSE.tpl +21 -0
  14. package/templates/default/README.md.tpl +61 -0
  15. package/templates/default/_gitignore +8 -0
  16. package/templates/default/assets/audio-raw/death.wav +0 -0
  17. package/templates/default/assets/audio-raw/shot.wav +0 -0
  18. package/templates/default/assets/sounds/death.mp3 +0 -0
  19. package/templates/default/assets/sounds/death.webm +0 -0
  20. package/templates/default/assets/sounds/shot.mp3 +0 -0
  21. package/templates/default/assets/sounds/shot.webm +0 -0
  22. package/templates/default/core/Cargo.toml.tpl +19 -0
  23. package/templates/default/core/src/actor.rs +407 -0
  24. package/templates/default/core/src/body_tag.rs +56 -0
  25. package/templates/default/core/src/client/mod.rs +385 -0
  26. package/templates/default/core/src/client/predictor.rs +552 -0
  27. package/templates/default/core/src/config.rs +236 -0
  28. package/templates/default/core/src/game.rs +692 -0
  29. package/templates/default/core/src/lib.rs +118 -0
  30. package/templates/default/core/src/motion.rs +126 -0
  31. package/templates/default/core/tests/sim.rs +351 -0
  32. package/templates/default/dev/main.js +28 -0
  33. package/templates/default/eslint.config.js +84 -0
  34. package/templates/default/index.html.tpl +35 -0
  35. package/templates/default/package.json.tpl +48 -0
  36. package/templates/default/scripts/build-game-manifest.js +188 -0
  37. package/templates/default/scripts/copy-game-images.js +35 -0
  38. package/templates/default/scripts/copy-game-sounds.js +55 -0
  39. package/templates/default/scripts/export-maps.js +19 -0
  40. package/templates/default/scripts/lib/rangeToPattern.js +74 -0
  41. package/templates/default/scripts/process-audio.js +115 -0
  42. package/templates/default/src/client/bakers/actorTexture.js +40 -0
  43. package/templates/default/src/client/bakers/index.js +8 -0
  44. package/templates/default/src/client/index.js +67 -0
  45. package/templates/default/src/client/parts/Actor.js +69 -0
  46. package/templates/default/src/client/parts/Map.js +74 -0
  47. package/templates/default/src/client/parts/ShotEffect.js +107 -0
  48. package/templates/default/src/client/parts/index.js +13 -0
  49. package/templates/default/src/client/style.css +26 -0
  50. package/templates/default/src/config/auth.js +61 -0
  51. package/templates/default/src/config/client.js +195 -0
  52. package/templates/default/src/config/game.js +170 -0
  53. package/templates/default/src/config/snapshot.js +70 -0
  54. package/templates/default/src/config/sounds.js +17 -0
  55. package/templates/default/src/data/maps/arena.js +69 -0
  56. package/templates/default/src/data/maps/index.js +7 -0
  57. package/templates/default/src/data/models.js +39 -0
  58. package/templates/default/src/data/weapons.js +37 -0
  59. package/templates/default/src/host/ScriptedManager.js +142 -0
  60. package/templates/default/src/host/createModules.js +12 -0
  61. package/templates/default/src/host/index.js +56 -0
  62. package/templates/default/src/host/nodeCore.js +23 -0
  63. package/templates/default/src/host/spawnCommand.js +32 -0
  64. package/templates/default/src/host/systemMessages.js +10 -0
  65. package/templates/default/tests/client/parts.test.js +128 -0
  66. package/templates/default/tests/config/contract.test.js +132 -0
  67. package/templates/default/tests/config/game.test.js +45 -0
  68. package/templates/default/tests/core/nodeCore.test.js +61 -0
  69. package/templates/default/tests/host/hostPlugin.test.js +198 -0
  70. package/templates/default/tests/stubs/wasmCore.js +19 -0
  71. package/templates/default/vite.config.js +74 -0
  72. package/templates/default/vitest.config.js +55 -0
@@ -0,0 +1,84 @@
1
+ import js from '@eslint/js';
2
+ import globals from 'globals';
3
+
4
+ // Minimal config: the split that matters is by environment. src/host/ runs
5
+ // inside a Web Worker (no DOM, no PixiJS) and src/client/ in the main thread;
6
+ // mixing their globals hides the mistake the engine cannot catch for you.
7
+ export default [
8
+ js.configs.recommended,
9
+
10
+ // build scripts and root configs
11
+ {
12
+ files: ['*.js', 'scripts/**/*.js'],
13
+ languageOptions: {
14
+ ecmaVersion: 'latest',
15
+ sourceType: 'module',
16
+ globals: { ...globals.es2023, ...globals.node },
17
+ },
18
+ },
19
+
20
+ // host half: Worker-safe — no browser globals on purpose
21
+ {
22
+ files: ['src/host/**/*.js', 'src/config/**/*.js', 'src/data/**/*.js'],
23
+ languageOptions: {
24
+ ecmaVersion: 'latest',
25
+ sourceType: 'module',
26
+ globals: { ...globals.es2023, ...globals.worker },
27
+ },
28
+ },
29
+
30
+ // client half and the dev harness: main thread
31
+ {
32
+ files: ['src/client/**/*.js', 'dev/**/*.js'],
33
+ languageOptions: {
34
+ ecmaVersion: 'latest',
35
+ sourceType: 'module',
36
+ globals: { ...globals.es2023, ...globals.browser },
37
+ },
38
+ },
39
+
40
+ {
41
+ files: ['tests/**/*.js'],
42
+ languageOptions: {
43
+ ecmaVersion: 'latest',
44
+ sourceType: 'module',
45
+ globals: {
46
+ ...globals.es2023,
47
+ ...globals.node,
48
+ ...globals.browser,
49
+ // vitest globals: true
50
+ describe: 'readonly',
51
+ it: 'readonly',
52
+ expect: 'readonly',
53
+ vi: 'readonly',
54
+ beforeEach: 'readonly',
55
+ afterEach: 'readonly',
56
+ beforeAll: 'readonly',
57
+ afterAll: 'readonly',
58
+ },
59
+ },
60
+ },
61
+
62
+ {
63
+ rules: {
64
+ eqeqeq: ['error', 'always'],
65
+ curly: ['error', 'all'],
66
+ 'no-var': 'error',
67
+ 'prefer-const': 'warn',
68
+ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
69
+ },
70
+ },
71
+
72
+ {
73
+ ignores: [
74
+ 'node_modules/**',
75
+ 'dist/**',
76
+ 'build/**',
77
+ 'target/**',
78
+ 'core/pkg-web/**',
79
+ 'core/pkg-node/**',
80
+ '**/.*',
81
+ '**/_*',
82
+ ],
83
+ },
84
+ ];
@@ -0,0 +1,35 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{GAME_TITLE}} — dev</title>
7
+ <!--
8
+ No engine stylesheet here: the entry point imports it (and the game CSS),
9
+ Vite bundles them. Below are only the rules of the SDK container itself.
10
+
11
+ #game must be full-screen and positioned: both the interface frame and
12
+ the canvases mount into it, and the engine's #panel/#stat/#vote are
13
+ position: absolute — their containing block is the nearest positioned
14
+ ancestor.
15
+ -->
16
+ <style>
17
+ html,
18
+ body {
19
+ margin: 0;
20
+ height: 100%;
21
+ }
22
+
23
+ #game {
24
+ position: relative;
25
+ width: 100%;
26
+ height: 100%;
27
+ overflow: hidden;
28
+ }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <div id="game"></div>
33
+ <script type="module" src="/dev/main.js"></script>
34
+ </body>
35
+ </html>
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "{{PACKAGE_NAME}}",
3
+ "version": "0.1.0",
4
+ "description": "{{GAME_TITLE}} — a VIMP game plugin",
5
+ "author": "{{AUTHOR}}",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=20.11.0"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "rm -rf dist && npm run build:client && npm run build:host && npm run build:assets && npm run build:manifest",
19
+ "build:client": "vite build --mode client",
20
+ "build:host": "vite build --mode host",
21
+ "build:assets": "node ./scripts/export-maps.js && node ./scripts/copy-game-sounds.js && node ./scripts/copy-game-images.js",
22
+ "build:manifest": "node ./scripts/build-game-manifest.js",
23
+ "audio:process": "node ./scripts/process-audio.js",
24
+ "core:build": "npm run core:build:web && npm run core:build:node",
25
+ "core:build:web": "wasm-pack build core --release --target web --out-dir pkg-web",
26
+ "core:build:node": "wasm-pack build core --release --target nodejs --out-dir pkg-node",
27
+ "core:test": "cargo test --workspace",
28
+ "check:contract": "vimp-contract --game .",
29
+ "sim": "vimp-sim --game .",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "predev": "node ./scripts/copy-game-images.js && node ./scripts/copy-game-sounds.js",
33
+ "dev": "vite"
34
+ },
35
+ "peerDependencies": {
36
+ "pixi.js": "^8.14.0"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^9.37.0",
40
+ "eslint": "^9.37.0",
41
+ "globals": "^16.2.0",
42
+ "happy-dom": "^20.10.6",
43
+ "pixi.js": "^8.14.0",
44
+ "vimp-engine": "{{ENGINE_VERSION}}",
45
+ "vite": "^7.1.11",
46
+ "vitest": "^4.1.9"
47
+ }
48
+ }
@@ -0,0 +1,188 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { ENGINE_API_VERSION } from 'vimp-engine/config/opcodes.js';
6
+ import hostDefaults from 'vimp-engine/config/hostDefaults.js';
7
+ import gameConfig from '../src/config/game.js';
8
+ import { rangeToPattern } from './lib/rangeToPattern.js';
9
+
10
+ // Writes dist/manifest.json — the only thing the master reads about a built
11
+ // game (docs/ai/02-packaging.md). Runs last, after both Vite bundles and the
12
+ // asset steps: it hashes what they produced.
13
+ // Run: node scripts/build-game-manifest.js (build:manifest)
14
+
15
+ const distPath = fileURLToPath(new URL('../dist/', import.meta.url));
16
+ const assetsPath = path.join(distPath, 'assets');
17
+ const mapsPath = path.join(distPath, 'maps');
18
+
19
+ // The nodejs build of the same core, for the engine's headless runner
20
+ // (`npm run sim`). Unlike the other entries it is a filesystem path relative
21
+ // to the manifest, and it must point INSIDE dist/ — dist/ is the only
22
+ // published content, so pkg-node is copied here rather than referenced in
23
+ // place (../core/pkg-node/ works in the checkout and breaks once installed).
24
+ const NODE_CORE_SRC = fileURLToPath(new URL('../core/pkg-node/', import.meta.url));
25
+ const NODE_CORE_DIR = 'core-node';
26
+ const NODE_CORE_ENTRY = `./${NODE_CORE_DIR}/{{CRATE_SNAKE}}.js`;
27
+
28
+ function hashFile(filePath) {
29
+ return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
30
+ }
31
+
32
+ function findOne(dir, pattern) {
33
+ const files = fs.readdirSync(dir).filter(name => pattern.test(name));
34
+
35
+ if (files.length !== 1) {
36
+ throw new Error(
37
+ `expected exactly one file matching ${pattern} in ${dir}, found: ` +
38
+ `${files.join(', ') || 'none'}`,
39
+ );
40
+ }
41
+
42
+ return files[0];
43
+ }
44
+
45
+ const clientFile = findOne(distPath, /^client-.+\.js$/);
46
+ const hostFile = findOne(distPath, /^host-.+\.js$/);
47
+ const wasmFile = findOne(assetsPath, /\.wasm$/);
48
+
49
+ // clients compare this to detect a stale bundle
50
+ const version = createHash('sha256')
51
+ .update(hashFile(path.join(distPath, clientFile)))
52
+ .update(hashFile(path.join(distPath, hostFile)))
53
+ .update(hashFile(path.join(assetsPath, wasmFile)))
54
+ .digest('hex')
55
+ .slice(0, 16);
56
+
57
+ const mapNames = fs
58
+ .readdirSync(mapsPath)
59
+ .filter(name => name.endsWith('.json'))
60
+ .map(name => name.slice(0, -'.json'.length))
61
+ .sort();
62
+
63
+ const mapsHash = createHash('sha256');
64
+ const maps = [];
65
+
66
+ for (const name of mapNames) {
67
+ const raw = fs.readFileSync(path.join(mapsPath, `${name}.json`));
68
+
69
+ mapsHash.update(name).update(raw);
70
+ maps.push(JSON.parse(raw));
71
+ }
72
+
73
+ // A map may name images (spriteSheet.img, physicsDynamic[].img); the engine
74
+ // serves none of them, and a missing file fails silently at runtime — an
75
+ // empty canvas and a clean console. So it is a build gate. A procedural map
76
+ // names nothing and the gate is a no-op.
77
+ const requiredImages = [
78
+ ...new Set(
79
+ maps.flatMap(map => [
80
+ map?.spriteSheet?.img,
81
+ ...(map?.physicsDynamic ?? []).map(body => body?.img),
82
+ ]),
83
+ ),
84
+ ]
85
+ .filter(Boolean)
86
+ .sort();
87
+
88
+ const missingImages = requiredImages.filter(
89
+ file => !fs.existsSync(path.join(distPath, 'img', file)),
90
+ );
91
+
92
+ if (missingImages.length) {
93
+ throw new Error(
94
+ `maps reference image(s) missing from dist/img/: ${missingImages.join(', ')} — ` +
95
+ 'add them to assets/img/ and run `npm run build:assets`',
96
+ );
97
+ }
98
+
99
+ // one value per roomForm field; roomForm is the single source of the names,
100
+ // which keeps roomDefaults from drifting away from the form
101
+ const roomValues = {
102
+ maxPlayers: gameConfig.roomDefaults.maxPlayers,
103
+ roundTime: hostDefaults.timers.roundTime,
104
+ mapTime: hostDefaults.timers.mapTime,
105
+ friendlyFire: gameConfig.parts.friendlyFire,
106
+ map: gameConfig.currentMap,
107
+ };
108
+
109
+ // bounds of the numeric fields (v3 text controls know no min/max): the same
110
+ // numbers the host clamps by, not an independent copy
111
+ const { roomTimeMin, roomTimeMax } = hostDefaults.timers;
112
+ const roomTimeRegExp = rangeToPattern(roomTimeMin / 1000, roomTimeMax / 1000);
113
+
114
+ const fieldRegExp = {
115
+ maxPlayers: rangeToPattern(1, gameConfig.roomDefaults.maxPlayers),
116
+ roundTime: roomTimeRegExp,
117
+ mapTime: roomTimeRegExp,
118
+ };
119
+
120
+ const roomForm = gameConfig.roomForm.map(field =>
121
+ field.name in fieldRegExp
122
+ ? { ...field, regExp: fieldRegExp[field.name] }
123
+ : field,
124
+ );
125
+
126
+ const hasNodeCore = fs.existsSync(NODE_CORE_SRC);
127
+
128
+ if (hasNodeCore) {
129
+ fs.rmSync(path.join(distPath, NODE_CORE_DIR), {
130
+ recursive: true,
131
+ force: true,
132
+ });
133
+
134
+ fs.cpSync(NODE_CORE_SRC, path.join(distPath, NODE_CORE_DIR), {
135
+ recursive: true,
136
+ // wasm-pack drops a `.gitignore` with `*` next to the glue, and npm
137
+ // applies ignore files inside dist/ too — with it the directory would
138
+ // never reach the tarball. package.json stays: without it Node reads the
139
+ // CommonJS glue as ESM (the package root is "type": "module").
140
+ filter: src => path.basename(src) !== '.gitignore',
141
+ });
142
+ }
143
+
144
+ const manifest = {
145
+ id: '{{GAME_ID}}',
146
+ engineApi: ENGINE_API_VERSION,
147
+ version,
148
+ title: '{{GAME_TITLE}}',
149
+ entries: {
150
+ client: `/games/{{GAME_ID}}/${clientFile}`,
151
+ host: `/games/{{GAME_ID}}/${hostFile}`,
152
+ wasm: `/games/{{GAME_ID}}/assets/${wasmFile}`,
153
+ ...(hasNodeCore ? { wasmNode: NODE_CORE_ENTRY } : {}),
154
+ },
155
+ assetsBase: '/games/{{GAME_ID}}/',
156
+ maps: {
157
+ version: mapsHash.digest('hex').slice(0, 16),
158
+ list: mapNames,
159
+ },
160
+ roomDefaults: Object.fromEntries(
161
+ roomForm.map(({ name }) => {
162
+ if (!(name in roomValues)) {
163
+ throw new Error(`roomForm field "${name}" has no value source`);
164
+ }
165
+
166
+ return [name, roomValues[name]];
167
+ }),
168
+ ),
169
+ roomForm,
170
+ };
171
+
172
+ fs.writeFileSync(
173
+ path.join(distPath, 'manifest.json'),
174
+ JSON.stringify(manifest, null, 2),
175
+ );
176
+
177
+ console.log(`manifest written: ${path.join(distPath, 'manifest.json')}`);
178
+ console.log(` version: ${version}`);
179
+ console.log(` maps: ${mapNames.join(', ')}`);
180
+
181
+ if (hasNodeCore) {
182
+ console.log(` wasmNode: ${NODE_CORE_ENTRY}`);
183
+ } else {
184
+ console.warn(
185
+ ` wasmNode: skipped (no ${NODE_CORE_SRC}) — the headless run needs ` +
186
+ '`npm run core:build:node` or an explicit --core flag',
187
+ );
188
+ }
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ // Lays the game images (map tile sheets, dynamic-body sprites) out for their
5
+ // two consumers:
6
+ //
7
+ // build/img/ — dev root of the standalone launch (`npm run dev`): the SDK
8
+ // gets assetsBase '/build/', so a tile lives at /build/img/<file>
9
+ // dist/img/ — the packaged asset under GameManifest.assetsBase: the master
10
+ // mounts dist/ at /games/<id>/
11
+ //
12
+ // Images need no processing step, so both targets are written in one pass.
13
+ // A scaffolded game draws everything procedurally and ships no images at all,
14
+ // so a missing assets/img/ is a no-op, not an error.
15
+ // Run: node scripts/copy-game-images.js (build:assets and predev)
16
+
17
+ const sourceDir = fileURLToPath(new URL('../assets/img/', import.meta.url));
18
+ const targetDirs = [
19
+ fileURLToPath(new URL('../build/img/', import.meta.url)),
20
+ fileURLToPath(new URL('../dist/img/', import.meta.url)),
21
+ ];
22
+
23
+ if (!fs.existsSync(sourceDir)) {
24
+ console.log(`no images: '${sourceDir}' does not exist — nothing to copy`);
25
+ process.exit(0);
26
+ }
27
+
28
+ for (const targetDir of targetDirs) {
29
+ // full replacement, not a merge: a file deleted from assets/img/ would
30
+ // otherwise survive in dist/ and travel into the published tarball
31
+ fs.rmSync(targetDir, { recursive: true, force: true });
32
+ fs.cpSync(sourceDir, targetDir, { recursive: true });
33
+
34
+ console.log(`copied images: ${sourceDir} -> ${targetDir}`);
35
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ // Copies the processed sounds into dist/sounds/ — the asset the client reads
5
+ // as `${assetsBase}sounds/<file>.<codec>`.
6
+ //
7
+ // Source order matters:
8
+ // build/sounds/ — the product of `npm run audio:process` (ffmpeg);
9
+ // assets/sounds/ — placeholders shipped with the template.
10
+ //
11
+ // The fallback is deliberate: the first build of a freshly scaffolded game
12
+ // must be green WITHOUT ffmpeg installed. Once you run `npm run audio:process`
13
+ // on your own assets/audio-raw/, build/sounds/ wins and the placeholders stop
14
+ // being used.
15
+ // Run: node scripts/copy-game-sounds.js (build:assets)
16
+
17
+ const processedDir = fileURLToPath(new URL('../build/sounds/', import.meta.url));
18
+ const placeholderDir = fileURLToPath(
19
+ new URL('../assets/sounds/', import.meta.url),
20
+ );
21
+ const distDir = fileURLToPath(new URL('../dist/sounds/', import.meta.url));
22
+
23
+ const sourceDir = fs.existsSync(processedDir) ? processedDir : placeholderDir;
24
+
25
+ // dist/ is the packaged asset; build/sounds/ is also the dev asset root of
26
+ // `npm run dev` (assetsBase '/build/'), so the placeholders are staged there
27
+ // as well — otherwise a developer without ffmpeg plays a silent match
28
+ const targetDirs =
29
+ sourceDir === placeholderDir
30
+ ? [distDir, fileURLToPath(new URL('../build/sounds/', import.meta.url))]
31
+ : [distDir];
32
+
33
+ if (!fs.existsSync(sourceDir)) {
34
+ console.error(
35
+ `Error: no sounds found (looked in '${processedDir}' and ` +
36
+ `'${placeholderDir}'). Run 'npm run audio:process' first.`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+
41
+ for (const targetDir of targetDirs) {
42
+ // full replacement, not a merge: a file deleted from the source would
43
+ // otherwise survive in dist/ and travel into the published tarball
44
+ fs.rmSync(targetDir, { recursive: true, force: true });
45
+ fs.cpSync(sourceDir, targetDir, { recursive: true });
46
+
47
+ console.log(`copied sounds: ${sourceDir} -> ${targetDir}`);
48
+ }
49
+
50
+ if (sourceDir === placeholderDir) {
51
+ console.warn(
52
+ ' using the template placeholders — run `npm run audio:process` to ' +
53
+ 'build your own sounds from assets/audio-raw/',
54
+ );
55
+ }
@@ -0,0 +1,19 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import maps from '../src/data/maps/index.js';
3
+
4
+ // Serialises the maps from JS modules into static JSON: the format the Rust
5
+ // core loads (load_map) and the master serves without rebuilding the halves
6
+ // (GameManifest.maps -> /games/<id>/maps/<name>).
7
+ // Run: node scripts/export-maps.js -> dist/maps/<name>.json
8
+
9
+ const outDir = new URL('../dist/maps/', import.meta.url);
10
+
11
+ await mkdir(outDir, { recursive: true });
12
+
13
+ for (const [name, map] of Object.entries(maps)) {
14
+ // the URL constructor encodes spaces, fs decodes them back on write
15
+ const file = new URL(`${name}.json`, outDir);
16
+
17
+ await writeFile(file, JSON.stringify(map));
18
+ console.log(`exported: ${name}.json`);
19
+ }
@@ -0,0 +1,74 @@
1
+ // Exact regExp pattern for an integer range [min, max]. Engine API v3 text
2
+ // controls have no min/max attributes — only `pattern` — so the room form
3
+ // gets its numeric bounds as a generated regex (build-game-manifest.js).
4
+ // The pattern is a UX hint; the authoritative clamp happens on the host.
5
+
6
+ // wraps a pattern in a non-capturing group when it contains an alternation:
7
+ // otherwise concatenation with a neighbouring prefix binds to the first
8
+ // branch only ('9' + 'a|b' matches '9a' or 'b', not '9a'/'9b')
9
+ function wrapAlternation(pattern) {
10
+ return pattern.includes('|') ? `(?:${pattern})` : pattern;
11
+ }
12
+
13
+ function digitsPattern(len) {
14
+ return len === 1 ? '[0-9]' : `[0-9]{${len}}`;
15
+ }
16
+
17
+ // lo/hi are equal-length digit strings, lo <= hi, no leading zeros: eats the
18
+ // common prefix, then splits into "tail of the lo digit", "full middle
19
+ // digits" and "tail of the hi digit"
20
+ function digitGroupPattern(lo, hi) {
21
+ if (lo === hi) {
22
+ return lo;
23
+ }
24
+
25
+ if (lo.length === 1) {
26
+ return `[${lo}-${hi}]`;
27
+ }
28
+
29
+ if (lo[0] === hi[0]) {
30
+ return lo[0] + wrapAlternation(digitGroupPattern(lo.slice(1), hi.slice(1)));
31
+ }
32
+
33
+ const restLen = lo.length - 1;
34
+ const zeros = '0'.repeat(restLen);
35
+ const nines = '9'.repeat(restLen);
36
+ const parts = [
37
+ lo[0] + wrapAlternation(digitGroupPattern(lo.slice(1), nines)),
38
+ ];
39
+
40
+ const midLo = Number(lo[0]) + 1;
41
+ const midHi = Number(hi[0]) - 1;
42
+
43
+ if (midLo <= midHi) {
44
+ const midDigit = midLo === midHi ? String(midLo) : `[${midLo}-${midHi}]`;
45
+
46
+ parts.push(`${midDigit}${digitsPattern(restLen)}`);
47
+ }
48
+
49
+ parts.push(hi[0] + wrapAlternation(digitGroupPattern(zeros, hi.slice(1))));
50
+
51
+ return parts.join('|');
52
+ }
53
+
54
+ // Pattern for an integer in [min, max] (0 <= min <= max), unanchored and
55
+ // without an enclosing group: the caller uses it as the HTML `pattern`
56
+ // attribute (implicitly anchored by the browser).
57
+ export function rangeToPattern(min, max) {
58
+ if (min > max) {
59
+ throw new Error(`rangeToPattern: min ${min} > max ${max}`);
60
+ }
61
+
62
+ const groups = [];
63
+ let lo = min;
64
+
65
+ while (lo <= max) {
66
+ const digits = String(lo).length;
67
+ const hi = Math.min(max, 10 ** digits - 1);
68
+
69
+ groups.push(digitGroupPattern(String(lo), String(hi)));
70
+ lo = hi + 1;
71
+ }
72
+
73
+ return wrapAlternation(groups.join('|'));
74
+ }
@@ -0,0 +1,115 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ // ffmpeg pipeline: normalises loudness (EBU R128) and emits BOTH .webm and
7
+ // .mp3 for every source sound — the client's codec list is ['webm', 'mp3']
8
+ // and it picks per browser support, so a missing .mp3 breaks Safari only.
9
+ //
10
+ // assets/audio-raw/* -> build/sounds/*.{mp3,webm}
11
+ //
12
+ // Optional for a fresh checkout: without ffmpeg the build falls back to the
13
+ // placeholders in assets/sounds/ (see copy-game-sounds.js).
14
+ // Run: npm run audio:process
15
+
16
+ const execPromise = promisify(exec);
17
+
18
+ const sourceDir = 'assets/audio-raw';
19
+ const outputDir = 'build/sounds';
20
+
21
+ const AUDIO_EXTENSIONS = ['.wav', '.mp3', '.flac', '.aiff', '.ogg', '.m4a'];
22
+
23
+ // loudness targets (EBU R128)
24
+ const LOUDNESS = {
25
+ // integrated loudness, a good balance for games and web content
26
+ I: -16,
27
+ // loudness range: keeps quiet and loud sounds apart without extremes
28
+ LRA: 7,
29
+ // true peak: a safe ceiling against clipping
30
+ TP: -1.5,
31
+ };
32
+
33
+ // leading silence trim
34
+ const SILENCE_THRESHOLD = '-50dB';
35
+
36
+ const CODEC_SETTINGS = {
37
+ MP3_QUALITY: '2', // VBR ~190 kbps
38
+ WEBM_BITRATE: '96k', // Opus bitrate
39
+ };
40
+
41
+ async function isFfmpegInstalled() {
42
+ try {
43
+ await execPromise('ffmpeg -version');
44
+
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function main() {
52
+ console.log('--- Starting Audio Processing ---');
53
+
54
+ if (!(await isFfmpegInstalled())) {
55
+ console.error('Error: ffmpeg is not installed.');
56
+ process.exit(1);
57
+ }
58
+
59
+ try {
60
+ await fs.access(sourceDir);
61
+ } catch {
62
+ console.error(`Error: source directory '${sourceDir}' not found.`);
63
+ process.exit(1);
64
+ }
65
+
66
+ await fs.mkdir(outputDir, { recursive: true });
67
+ console.log(`> output directory: ${outputDir}`);
68
+
69
+ const sourceFiles = await fs.readdir(sourceDir);
70
+
71
+ for (const file of sourceFiles) {
72
+ const extension = path.extname(file).toLowerCase();
73
+
74
+ if (!AUDIO_EXTENSIONS.includes(extension)) {
75
+ continue;
76
+ }
77
+
78
+ const sourcePath = path.join(sourceDir, file);
79
+ const baseName = path.basename(file, extension);
80
+ const outputMp3 = path.join(outputDir, `${baseName}.mp3`);
81
+ const outputWebm = path.join(outputDir, `${baseName}.webm`);
82
+
83
+ console.log(`\nprocessing: ${file}`);
84
+
85
+ const audioFilters = [
86
+ `silenceremove=start_periods=1:start_threshold=${SILENCE_THRESHOLD}`,
87
+ `loudnorm=I=${LOUDNESS.I}:LRA=${LOUDNESS.LRA}:tp=${LOUDNESS.TP}`,
88
+ ].join(',');
89
+
90
+ const command = [
91
+ 'ffmpeg',
92
+ '-nostdin',
93
+ `-i "${sourcePath}"`,
94
+ '-hide_banner -loglevel error -y',
95
+ `-af "${audioFilters}"`,
96
+ `-c:a libmp3lame -q:a ${CODEC_SETTINGS.MP3_QUALITY} "${outputMp3}"`,
97
+ `-c:a libopus -b:a ${CODEC_SETTINGS.WEBM_BITRATE} "${outputWebm}"`,
98
+ ].join(' ');
99
+
100
+ try {
101
+ await execPromise(command);
102
+ console.log(`done: ${outputMp3}, ${outputWebm}`);
103
+ } catch (error) {
104
+ console.error(`error processing: ${file}`);
105
+ console.error(error.stderr);
106
+ }
107
+ }
108
+
109
+ console.log('\nAll files processed.');
110
+ }
111
+
112
+ main().catch(error => {
113
+ console.error('\nCritical script error:', error);
114
+ process.exit(1);
115
+ });