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.
- package/bin/create-vimp-game.js +15 -0
- package/package.json +40 -0
- package/src/cli.js +227 -0
- package/src/generator.js +196 -0
- package/src/preflight.js +48 -0
- package/src/prompts.js +79 -0
- package/src/tokens.js +99 -0
- package/src/ui.js +66 -0
- package/src/versions.generated.json +4 -0
- package/src/versions.js +87 -0
- package/templates/default/CLAUDE.md.tpl +51 -0
- package/templates/default/Cargo.toml.tpl +27 -0
- package/templates/default/LICENSE.tpl +21 -0
- package/templates/default/README.md.tpl +61 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/assets/audio-raw/death.wav +0 -0
- package/templates/default/assets/audio-raw/shot.wav +0 -0
- package/templates/default/assets/sounds/death.mp3 +0 -0
- package/templates/default/assets/sounds/death.webm +0 -0
- package/templates/default/assets/sounds/shot.mp3 +0 -0
- package/templates/default/assets/sounds/shot.webm +0 -0
- package/templates/default/core/Cargo.toml.tpl +19 -0
- package/templates/default/core/src/actor.rs +407 -0
- package/templates/default/core/src/body_tag.rs +56 -0
- package/templates/default/core/src/client/mod.rs +385 -0
- package/templates/default/core/src/client/predictor.rs +552 -0
- package/templates/default/core/src/config.rs +236 -0
- package/templates/default/core/src/game.rs +692 -0
- package/templates/default/core/src/lib.rs +118 -0
- package/templates/default/core/src/motion.rs +126 -0
- package/templates/default/core/tests/sim.rs +351 -0
- package/templates/default/dev/main.js +28 -0
- package/templates/default/eslint.config.js +84 -0
- package/templates/default/index.html.tpl +35 -0
- package/templates/default/package.json.tpl +48 -0
- package/templates/default/scripts/build-game-manifest.js +188 -0
- package/templates/default/scripts/copy-game-images.js +35 -0
- package/templates/default/scripts/copy-game-sounds.js +55 -0
- package/templates/default/scripts/export-maps.js +19 -0
- package/templates/default/scripts/lib/rangeToPattern.js +74 -0
- package/templates/default/scripts/process-audio.js +115 -0
- package/templates/default/src/client/bakers/actorTexture.js +40 -0
- package/templates/default/src/client/bakers/index.js +8 -0
- package/templates/default/src/client/index.js +67 -0
- package/templates/default/src/client/parts/Actor.js +69 -0
- package/templates/default/src/client/parts/Map.js +74 -0
- package/templates/default/src/client/parts/ShotEffect.js +107 -0
- package/templates/default/src/client/parts/index.js +13 -0
- package/templates/default/src/client/style.css +26 -0
- package/templates/default/src/config/auth.js +61 -0
- package/templates/default/src/config/client.js +195 -0
- package/templates/default/src/config/game.js +170 -0
- package/templates/default/src/config/snapshot.js +70 -0
- package/templates/default/src/config/sounds.js +17 -0
- package/templates/default/src/data/maps/arena.js +69 -0
- package/templates/default/src/data/maps/index.js +7 -0
- package/templates/default/src/data/models.js +39 -0
- package/templates/default/src/data/weapons.js +37 -0
- package/templates/default/src/host/ScriptedManager.js +142 -0
- package/templates/default/src/host/createModules.js +12 -0
- package/templates/default/src/host/index.js +56 -0
- package/templates/default/src/host/nodeCore.js +23 -0
- package/templates/default/src/host/spawnCommand.js +32 -0
- package/templates/default/src/host/systemMessages.js +10 -0
- package/templates/default/tests/client/parts.test.js +128 -0
- package/templates/default/tests/config/contract.test.js +132 -0
- package/templates/default/tests/config/game.test.js +45 -0
- package/templates/default/tests/core/nodeCore.test.js +61 -0
- package/templates/default/tests/host/hostPlugin.test.js +198 -0
- package/templates/default/tests/stubs/wasmCore.js +19 -0
- package/templates/default/vite.config.js +74 -0
- 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
|
+
});
|