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,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main, reportError } from '../src/cli.js';
|
|
3
|
+
|
|
4
|
+
// Точка входа `npm create vimp-game <dir>`: разбор argv и печать ошибок
|
|
5
|
+
// живут в src/cli.js, здесь остаётся только контракт процесса.
|
|
6
|
+
|
|
7
|
+
main(process.argv.slice(2)).then(
|
|
8
|
+
code => {
|
|
9
|
+
process.exitCode = code;
|
|
10
|
+
},
|
|
11
|
+
error => {
|
|
12
|
+
reportError(error);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
},
|
|
15
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-vimp-game",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffolder for VIMP game plugins",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vimp",
|
|
7
|
+
"scaffold",
|
|
8
|
+
"game",
|
|
9
|
+
"template"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/lgick/vimp#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/lgick/vimp/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+ssh://git@github.com/lgick/vimp.git",
|
|
18
|
+
"directory": "packages/create-vimp-game"
|
|
19
|
+
},
|
|
20
|
+
"author": "lgick",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.11.0"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"create-vimp-game": "./bin/create-vimp-game.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src",
|
|
32
|
+
"templates"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"prepack": "node ./scripts/write-versions.js"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { generate, GenerateError } from './generator.js';
|
|
6
|
+
import { askAnswers } from './prompts.js';
|
|
7
|
+
import { checkTools, initGit } from './preflight.js';
|
|
8
|
+
import {
|
|
9
|
+
buildTokens,
|
|
10
|
+
defaultPackageName,
|
|
11
|
+
defaultTitle,
|
|
12
|
+
toGameId,
|
|
13
|
+
TokenError,
|
|
14
|
+
} from './tokens.js';
|
|
15
|
+
import { resolveVersions, toPins } from './versions.js';
|
|
16
|
+
import { color, log, nextSteps, warn } from './ui.js';
|
|
17
|
+
|
|
18
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const packageRoot = path.resolve(moduleDir, '..');
|
|
20
|
+
|
|
21
|
+
export class UsageError extends Error {}
|
|
22
|
+
|
|
23
|
+
export const USAGE = `Usage: create-vimp-game <directory> [options]
|
|
24
|
+
|
|
25
|
+
--id <id> game id, kebab-case (default: directory name)
|
|
26
|
+
--title <title> human-readable title
|
|
27
|
+
--package <name> npm package name (default: @vimp-games/<id>)
|
|
28
|
+
--author <name>
|
|
29
|
+
--yes, -y accept all defaults, no prompts
|
|
30
|
+
--force allow a non-empty target directory
|
|
31
|
+
--no-git skip \`git init\`
|
|
32
|
+
--engine-path <p> dev only: link a local engine checkout
|
|
33
|
+
--core-path <p> dev only: [patch.crates-io] for vimp-engine-core
|
|
34
|
+
--help, --version
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const VALUE_OPTIONS = new Map([
|
|
38
|
+
['--id', 'id'],
|
|
39
|
+
['--title', 'title'],
|
|
40
|
+
['--package', 'packageName'],
|
|
41
|
+
['--author', 'author'],
|
|
42
|
+
['--engine-path', 'enginePath'],
|
|
43
|
+
['--core-path', 'corePath'],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export function parseArgs(argv) {
|
|
47
|
+
const args = { git: true };
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
|
|
52
|
+
if (VALUE_OPTIONS.has(arg)) {
|
|
53
|
+
i += 1;
|
|
54
|
+
|
|
55
|
+
// значение, начинающееся с '--', — почти всегда забытый аргумент:
|
|
56
|
+
// без проверки следующий флаг тихо стал бы названием игры
|
|
57
|
+
if (argv[i] === undefined || argv[i].startsWith('--')) {
|
|
58
|
+
throw new UsageError(`option '${arg}' needs a value\n\n${USAGE}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
args[VALUE_OPTIONS.get(arg)] = argv[i];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
switch (arg) {
|
|
66
|
+
case '--help':
|
|
67
|
+
case '-h':
|
|
68
|
+
args.help = true;
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case '--version':
|
|
72
|
+
case '-v':
|
|
73
|
+
args.version = true;
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case '--yes':
|
|
77
|
+
case '-y':
|
|
78
|
+
args.yes = true;
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case '--force':
|
|
82
|
+
args.force = true;
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case '--no-git':
|
|
86
|
+
args.git = false;
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
if (arg.startsWith('-')) {
|
|
91
|
+
throw new UsageError(`unknown option '${arg}'\n\n${USAGE}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (args.directory !== undefined) {
|
|
95
|
+
throw new UsageError(`unexpected argument '${arg}'\n\n${USAGE}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
args.directory = arg;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return args;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// дефолты для интерактива и для --yes считаются одинаково: ответы человека
|
|
106
|
+
// лишь перекрывают их
|
|
107
|
+
export function buildDefaults(args) {
|
|
108
|
+
const directory = args.directory ?? 'my-game';
|
|
109
|
+
const id = args.id ?? toGameId(path.basename(path.resolve(directory)));
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
directory,
|
|
113
|
+
id,
|
|
114
|
+
title: args.title ?? defaultTitle(id),
|
|
115
|
+
packageName: args.packageName ?? defaultPackageName(id),
|
|
116
|
+
author: args.author ?? '',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// пересчёт дефолтов по ходу интерактива: каталог, названный вручную,
|
|
121
|
+
// обязан тянуть за собой id, а тот — заголовок и имя пакета. Флаги из argv
|
|
122
|
+
// остаются сильнее всего, что выводится из ответов
|
|
123
|
+
export function buildDerivers(args) {
|
|
124
|
+
return {
|
|
125
|
+
id: directory =>
|
|
126
|
+
args.id ?? toGameId(path.basename(path.resolve(directory))),
|
|
127
|
+
title: id => args.title ?? defaultTitle(id),
|
|
128
|
+
packageName: id => args.packageName ?? defaultPackageName(id),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function ensurePathExists(option, value) {
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await stat(path.resolve(value));
|
|
139
|
+
} catch {
|
|
140
|
+
throw new UsageError(`option '${option}': no such path '${value}'`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function ownVersion() {
|
|
145
|
+
const manifest = JSON.parse(
|
|
146
|
+
await readFile(path.join(packageRoot, 'package.json'), 'utf8'),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return manifest.version;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function main(argv) {
|
|
153
|
+
const args = parseArgs(argv);
|
|
154
|
+
|
|
155
|
+
if (args.help) {
|
|
156
|
+
process.stdout.write(USAGE);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (args.version) {
|
|
161
|
+
process.stdout.write(`${await ownVersion()}\n`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// интерактив имеет смысл только у живого терминала: в CI и под пайпом
|
|
166
|
+
// вопросы повисли бы без ответа
|
|
167
|
+
const interactive = args.yes !== true && process.stdin.isTTY === true;
|
|
168
|
+
|
|
169
|
+
if (args.directory === undefined && !interactive) {
|
|
170
|
+
throw new UsageError(`missing <directory>\n\n${USAGE}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// опечатку в dev-путях иначе видно только на `npm install`, невнятной
|
|
174
|
+
// ошибкой npm — и уже поверх сгенерированного проекта
|
|
175
|
+
await Promise.all([
|
|
176
|
+
ensurePathExists('--engine-path', args.enginePath),
|
|
177
|
+
ensurePathExists('--core-path', args.corePath),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
const answers = await askAnswers(buildDefaults(args), {
|
|
181
|
+
interactive,
|
|
182
|
+
derive: buildDerivers(args),
|
|
183
|
+
});
|
|
184
|
+
const pins = toPins(await resolveVersions());
|
|
185
|
+
|
|
186
|
+
const tokens = buildTokens({
|
|
187
|
+
id: answers.id,
|
|
188
|
+
title: answers.title,
|
|
189
|
+
packageName: answers.packageName,
|
|
190
|
+
author: answers.author,
|
|
191
|
+
...pins,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const targetDir = path.resolve(answers.directory);
|
|
195
|
+
|
|
196
|
+
await generate({
|
|
197
|
+
templateDir: path.join(packageRoot, 'templates', 'default'),
|
|
198
|
+
targetDir,
|
|
199
|
+
tokens,
|
|
200
|
+
force: args.force === true,
|
|
201
|
+
enginePath: args.enginePath,
|
|
202
|
+
corePath: args.corePath,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (args.git) {
|
|
206
|
+
await initGit(targetDir);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const tool of await checkTools()) {
|
|
210
|
+
warn(`${color.bold(tool.command)} not found — ${tool.hint}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
log(nextSteps({ directory: answers.directory, tokens }));
|
|
214
|
+
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function reportError(error) {
|
|
219
|
+
const known =
|
|
220
|
+
error instanceof UsageError ||
|
|
221
|
+
error instanceof GenerateError ||
|
|
222
|
+
error instanceof TokenError;
|
|
223
|
+
|
|
224
|
+
process.stderr.write(
|
|
225
|
+
known ? `${error.message}\n` : `${error.stack ?? error.message}\n`,
|
|
226
|
+
);
|
|
227
|
+
}
|
package/src/generator.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readdir,
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
writeFile,
|
|
6
|
+
copyFile,
|
|
7
|
+
stat,
|
|
8
|
+
} from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Разворачивание templates/default в каталог игры: обход дерева, подстановка
|
|
12
|
+
// {{ТОКЕНОВ}}, переименования служебных имён.
|
|
13
|
+
|
|
14
|
+
export class GenerateError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'GenerateError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// подстановка идёт только в текстовых файлах: ассеты (webm/mp3) копируются
|
|
22
|
+
// побайтово, иначе кодировка их портит
|
|
23
|
+
const TEXT_EXTENSIONS = new Set([
|
|
24
|
+
'.js',
|
|
25
|
+
'.json',
|
|
26
|
+
'.rs',
|
|
27
|
+
'.toml',
|
|
28
|
+
'.md',
|
|
29
|
+
'.html',
|
|
30
|
+
'.css',
|
|
31
|
+
'.txt',
|
|
32
|
+
'.gitignore',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const TOKEN_RE = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
36
|
+
|
|
37
|
+
// внутри репозитория движка файлы шаблона не должны выглядеть настоящими:
|
|
38
|
+
// package.json.tpl не подхватят workspaces, _gitignore — git
|
|
39
|
+
export function targetName(name) {
|
|
40
|
+
const withoutTpl = name.endsWith('.tpl') ? name.slice(0, -4) : name;
|
|
41
|
+
|
|
42
|
+
return withoutTpl.startsWith('_') ? `.${withoutTpl.slice(1)}` : withoutTpl;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isTextFile(sourceName) {
|
|
46
|
+
if (sourceName.endsWith('.tpl')) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = targetName(sourceName);
|
|
51
|
+
|
|
52
|
+
return TEXT_EXTENSIONS.has(path.extname(name) || name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderTokens(content, tokens, file) {
|
|
56
|
+
const rendered = content.replace(TOKEN_RE, (match, key) => {
|
|
57
|
+
if (!(key in tokens)) {
|
|
58
|
+
throw new GenerateError(`unknown token ${match} in ${file}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return tokens[key];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// токен неизвестного вида ({{ foo }}, {{Bar}}) TOKEN_RE не ловит: такой
|
|
65
|
+
// мусор уехал бы в файлы игры молча.
|
|
66
|
+
//
|
|
67
|
+
// Цена стража: любая пара `{{…}}` в шаблоне запрещена, включая
|
|
68
|
+
// экранирование фигурной скобки в Rust (`format!("{{")`). Такой код в
|
|
69
|
+
// шаблоне пишется через промежуточную переменную или обходом форматтера
|
|
70
|
+
const leftover = rendered.match(/\{\{[^}]*\}\}/);
|
|
71
|
+
|
|
72
|
+
if (leftover !== null) {
|
|
73
|
+
throw new GenerateError(`unsubstituted token ${leftover[0]} in ${file}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return rendered;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// каталог должен отсутствовать или быть пустым: генератор пишет поверх и
|
|
80
|
+
// без проверки затёр бы чужую работу
|
|
81
|
+
export async function ensureTargetDir(dir, { force = false } = {}) {
|
|
82
|
+
let entries;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(dir);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code === 'ENOENT') {
|
|
88
|
+
await mkdir(dir, { recursive: true });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (entries.length > 0 && !force) {
|
|
96
|
+
throw new GenerateError(
|
|
97
|
+
`target directory is not empty: ${dir} (use --force to write into it)`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// rootDir отдельно от targetDir: список созданных файлов собирается путями
|
|
103
|
+
// от корня игры, а рекурсия спускается по подкаталогам
|
|
104
|
+
async function copyTree(sourceDir, targetDir, tokens, created, rootDir) {
|
|
105
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const source = path.join(sourceDir, entry.name);
|
|
109
|
+
const target = path.join(targetDir, targetName(entry.name));
|
|
110
|
+
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
await mkdir(target, { recursive: true });
|
|
113
|
+
await copyTree(source, target, tokens, created, rootDir);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!entry.isFile()) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isTextFile(entry.name)) {
|
|
122
|
+
const content = await readFile(source, 'utf8');
|
|
123
|
+
|
|
124
|
+
await writeFile(target, renderTokens(content, tokens, source), 'utf8');
|
|
125
|
+
} else {
|
|
126
|
+
await copyFile(source, target);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
created.push(path.relative(rootDir, target));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// dev-режимы этапа 6: игра собирается против несобранного релиза движка —
|
|
134
|
+
// пакет берётся из локального чекаута, крейт подменяется patch.crates-io
|
|
135
|
+
async function applyEnginePath(targetDir, enginePath) {
|
|
136
|
+
const file = path.join(targetDir, 'package.json');
|
|
137
|
+
const manifest = JSON.parse(await readFile(file, 'utf8'));
|
|
138
|
+
|
|
139
|
+
manifest.devDependencies = {
|
|
140
|
+
...manifest.devDependencies,
|
|
141
|
+
'vimp-engine': `file:${path.resolve(enginePath)}`,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function applyCorePath(targetDir, corePath) {
|
|
148
|
+
const file = path.join(targetDir, 'Cargo.toml');
|
|
149
|
+
const patch =
|
|
150
|
+
'\n[patch.crates-io]\n' +
|
|
151
|
+
`vimp-engine-core = { path = "${path.resolve(corePath)}" }\n`;
|
|
152
|
+
|
|
153
|
+
const content = await readFile(file, 'utf8');
|
|
154
|
+
|
|
155
|
+
await writeFile(file, `${content.trimEnd()}\n${patch}`, 'utf8');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function exists(file) {
|
|
159
|
+
try {
|
|
160
|
+
await stat(file);
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function generate({
|
|
168
|
+
templateDir,
|
|
169
|
+
targetDir,
|
|
170
|
+
tokens,
|
|
171
|
+
force = false,
|
|
172
|
+
enginePath,
|
|
173
|
+
corePath,
|
|
174
|
+
}) {
|
|
175
|
+
await ensureTargetDir(targetDir, { force });
|
|
176
|
+
|
|
177
|
+
const created = [];
|
|
178
|
+
|
|
179
|
+
await copyTree(templateDir, targetDir, tokens, created, targetDir);
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
enginePath !== undefined &&
|
|
183
|
+
(await exists(path.join(targetDir, 'package.json')))
|
|
184
|
+
) {
|
|
185
|
+
await applyEnginePath(targetDir, enginePath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
corePath !== undefined &&
|
|
190
|
+
(await exists(path.join(targetDir, 'Cargo.toml')))
|
|
191
|
+
) {
|
|
192
|
+
await applyCorePath(targetDir, corePath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return created.sort();
|
|
196
|
+
}
|
package/src/preflight.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Проверка окружения и git init. Отсутствие cargo/wasm-pack генерацию не
|
|
4
|
+
// блокирует: файлы игры создаются, ошибка всплывёт на npm run core:build —
|
|
5
|
+
// к тому моменту у автора уже есть проект и подсказка по установке.
|
|
6
|
+
|
|
7
|
+
const TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
command: 'cargo',
|
|
10
|
+
hint: 'install Rust: https://rustup.rs',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
command: 'wasm-pack',
|
|
14
|
+
hint: 'install wasm-pack: cargo install wasm-pack',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function run(command, args, options = {}) {
|
|
19
|
+
return new Promise(resolve => {
|
|
20
|
+
// timeout: зависший `cargo --version` (сеть, битый тулчейн) иначе
|
|
21
|
+
// держал бы скаффолдер бесконечно — проверка окружения не стоит того
|
|
22
|
+
const child = spawn(command, args, {
|
|
23
|
+
cwd: options.cwd,
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
shell: false,
|
|
26
|
+
timeout: 5000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
child.on('error', () => resolve(null));
|
|
30
|
+
child.on('close', code => resolve(code));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function checkTools() {
|
|
35
|
+
const codes = await Promise.all(
|
|
36
|
+
TOOLS.map(tool => run(tool.command, ['--version'])),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return TOOLS.filter((tool, index) => codes[index] !== 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// первый коммит остаётся за автором игры: скаффолдер только заводит
|
|
43
|
+
// репозиторий, чтобы `git diff` работал с первой правки
|
|
44
|
+
export async function initGit(dir) {
|
|
45
|
+
const code = await run('git', ['init', '--quiet'], { cwd: dir });
|
|
46
|
+
|
|
47
|
+
return code === 0;
|
|
48
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
|
|
3
|
+
import { isValidGameId, isValidPackageName } from './tokens.js';
|
|
4
|
+
|
|
5
|
+
// Интерактив на node:readline/promises — у пакета нулевые рантайм-зависимости:
|
|
6
|
+
// он ставится через `npm create`, и каждая зависимость здесь превращается в
|
|
7
|
+
// секунду ожидания пользователя.
|
|
8
|
+
|
|
9
|
+
let rl = null;
|
|
10
|
+
|
|
11
|
+
function reader() {
|
|
12
|
+
if (rl === null) {
|
|
13
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return rl;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function closePrompts() {
|
|
20
|
+
rl?.close();
|
|
21
|
+
rl = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ask(question, fallback = '') {
|
|
25
|
+
const suffix = fallback === '' ? '' : ` (${fallback})`;
|
|
26
|
+
const answer = await reader().question(`${question}${suffix}: `);
|
|
27
|
+
|
|
28
|
+
return answer.trim() === '' ? fallback : answer.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// переспрос на месте: без него невалидный id всплывал бы TokenError-ом уже
|
|
32
|
+
// после всех пяти вопросов, и вводить пришлось бы заново
|
|
33
|
+
export async function askValid(question, fallback, isValid, hint) {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const answer = await ask(question, fallback);
|
|
36
|
+
|
|
37
|
+
if (isValid(answer)) {
|
|
38
|
+
return answer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
process.stdout.write(` ${hint}\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// один проход вопросов; --yes и не-TTY (CI, пайп) отвечают дефолтами.
|
|
46
|
+
//
|
|
47
|
+
// Дефолты пересчитываются по ходу: человек, назвавший каталог
|
|
48
|
+
// `space-arena`, ждёт «Space Arena» и `@vimp-games/space-arena`, а не
|
|
49
|
+
// значения, выведенные из аргументов до вопросов
|
|
50
|
+
export async function askAnswers(defaults, { interactive = true, derive } = {}) {
|
|
51
|
+
if (!interactive) {
|
|
52
|
+
return { ...defaults };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const directory = await ask('Directory', defaults.directory);
|
|
57
|
+
const id = await askValid(
|
|
58
|
+
'Game id',
|
|
59
|
+
derive.id(directory),
|
|
60
|
+
isValidGameId,
|
|
61
|
+
"expected kebab-case, e.g. 'space-arena'",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
directory,
|
|
66
|
+
id,
|
|
67
|
+
title: await ask('Title', derive.title(id)),
|
|
68
|
+
packageName: await askValid(
|
|
69
|
+
'Package name',
|
|
70
|
+
derive.packageName(id),
|
|
71
|
+
isValidPackageName,
|
|
72
|
+
'expected an npm package name, e.g. @vimp-games/space-arena',
|
|
73
|
+
),
|
|
74
|
+
author: await ask('Author', defaults.author),
|
|
75
|
+
};
|
|
76
|
+
} finally {
|
|
77
|
+
closePrompts();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/tokens.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Подстановки шаблона: из ответов CLI выводится полный набор токенов
|
|
2
|
+
// {{…}}, которые generator.js подставляет в файлы templates/default.
|
|
3
|
+
|
|
4
|
+
// id игры — kebab-case: он же сегмент URL (/games/<id>/), ключ каталога и
|
|
5
|
+
// HostPlugin.id, поэтому пробелы и заглавные в нём недопустимы
|
|
6
|
+
const GAME_ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
7
|
+
|
|
8
|
+
// имя npm-пакета: правила реестра (опциональная область видимости,
|
|
9
|
+
// нижний регистр, без пробелов)
|
|
10
|
+
const PACKAGE_NAME_RE =
|
|
11
|
+
/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
12
|
+
|
|
13
|
+
export class TokenError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'TokenError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isValidGameId(id) {
|
|
21
|
+
return typeof id === 'string' && GAME_ID_RE.test(id) && !id.endsWith('-');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isValidPackageName(name) {
|
|
25
|
+
return (
|
|
26
|
+
typeof name === 'string' &&
|
|
27
|
+
name.length > 0 &&
|
|
28
|
+
name.length <= 214 &&
|
|
29
|
+
PACKAGE_NAME_RE.test(name)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// имя каталога встречается любое («My Game 2», «game_v2»); приводим к
|
|
34
|
+
// допустимому id, чтобы --yes не падал на первом же запуске
|
|
35
|
+
export function toGameId(value) {
|
|
36
|
+
return String(value)
|
|
37
|
+
.trim()
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
40
|
+
.replace(/^-+|-+$/g, '')
|
|
41
|
+
.replace(/^([0-9])/, 'game-$1');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function defaultTitle(id) {
|
|
45
|
+
return id
|
|
46
|
+
.split('-')
|
|
47
|
+
.filter(part => part !== '')
|
|
48
|
+
.map(part => part[0].toUpperCase() + part.slice(1))
|
|
49
|
+
.join(' ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function defaultPackageName(id) {
|
|
53
|
+
return `@vimp-games/${id}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function toCrateName(id) {
|
|
57
|
+
return `${id}-core`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// wasm-bindgen выдаёт файлы по имени крейта в snake_case: pkg-web/<crate>_bg.wasm
|
|
61
|
+
export function toCrateSnake(crateName) {
|
|
62
|
+
return crateName.replace(/-/g, '_');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildTokens({
|
|
66
|
+
id,
|
|
67
|
+
title,
|
|
68
|
+
packageName,
|
|
69
|
+
author,
|
|
70
|
+
engineVersion,
|
|
71
|
+
coreVersion,
|
|
72
|
+
year = new Date().getFullYear(),
|
|
73
|
+
}) {
|
|
74
|
+
if (!isValidGameId(id)) {
|
|
75
|
+
throw new TokenError(
|
|
76
|
+
`invalid game id '${id}': expected kebab-case, e.g. 'space-arena'`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolvedPackage = packageName ?? defaultPackageName(id);
|
|
81
|
+
|
|
82
|
+
if (!isValidPackageName(resolvedPackage)) {
|
|
83
|
+
throw new TokenError(`invalid npm package name '${resolvedPackage}'`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const crateName = toCrateName(id);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
GAME_ID: id,
|
|
90
|
+
GAME_TITLE: title ?? defaultTitle(id),
|
|
91
|
+
PACKAGE_NAME: resolvedPackage,
|
|
92
|
+
CRATE_NAME: crateName,
|
|
93
|
+
CRATE_SNAKE: toCrateSnake(crateName),
|
|
94
|
+
ENGINE_VERSION: engineVersion,
|
|
95
|
+
CORE_VERSION: coreVersion,
|
|
96
|
+
AUTHOR: author ?? '',
|
|
97
|
+
YEAR: String(year),
|
|
98
|
+
};
|
|
99
|
+
}
|