vimp-engine 0.7.2 → 0.8.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/package.json +11 -4
- package/src/client/InputListener.js +36 -0
- package/src/client/SoundManager.js +498 -0
- package/src/client/boot.js +113 -0
- package/src/client/components/controller/Auth.js +44 -0
- package/src/client/components/controller/CanvasManager.js +26 -0
- package/src/client/components/controller/Chat.js +56 -0
- package/src/client/components/controller/Controls.js +51 -0
- package/src/client/components/controller/Game.js +37 -0
- package/src/client/components/controller/Lobby.js +81 -0
- package/src/client/components/controller/LobbyAuth.js +41 -0
- package/src/client/components/controller/Panel.js +23 -0
- package/src/client/components/controller/Stat.js +33 -0
- package/src/client/components/controller/Vote.js +55 -0
- package/src/client/components/model/Auth.js +92 -0
- package/src/client/components/model/CanvasManager.js +319 -0
- package/src/client/components/model/Chat.js +119 -0
- package/src/client/components/model/Controls.js +120 -0
- package/src/client/components/model/Game.js +172 -0
- package/src/client/components/model/Lobby.js +226 -0
- package/src/client/components/model/LobbyAuth.js +161 -0
- package/src/client/components/model/Panel.js +70 -0
- package/src/client/components/model/Stat.js +77 -0
- package/src/client/components/model/Vote.js +216 -0
- package/src/client/components/view/Auth.js +167 -0
- package/src/client/components/view/CanvasManager.js +44 -0
- package/src/client/components/view/Chat.js +90 -0
- package/src/client/components/view/Controls.js +30 -0
- package/src/client/components/view/Game.js +48 -0
- package/src/client/components/view/Lobby.js +303 -0
- package/src/client/components/view/LobbyAuth.js +111 -0
- package/src/client/components/view/Panel.js +314 -0
- package/src/client/components/view/Stat.js +238 -0
- package/src/client/components/view/Vote.js +108 -0
- package/src/client/debug.js +158 -0
- package/src/client/lib/autostart.js +61 -0
- package/src/client/lib/contextTracker.js +46 -0
- package/src/client/lib/formBuilder.js +280 -0
- package/src/client/lib/hostGate.js +16 -0
- package/src/client/main.js +2011 -0
- package/src/client/network/HostConnectionManager.js +196 -0
- package/src/client/network/HostController.js +422 -0
- package/src/client/network/InlineHostBridge.js +152 -0
- package/src/client/network/LoopbackTransport.js +51 -0
- package/src/client/network/SignalingClient.js +146 -0
- package/src/client/network/WebRtcManager.js +147 -0
- package/src/client/network/WebSocketTransport.js +78 -0
- package/src/client/network/policyClose.js +54 -0
- package/src/client/network/workerSupport.js +34 -0
- package/src/client/providers/BakingProvider.js +88 -0
- package/src/client/providers/DependencyProvider.js +41 -0
- package/src/client/style.css +898 -0
- package/src/client/views/gameShell.js +190 -0
- package/src/client/views/includes/auth.pug +20 -0
- package/src/client/views/includes/chat.pug +5 -0
- package/src/client/views/includes/informer.pug +2 -0
- package/src/client/views/includes/lobby.pug +48 -0
- package/src/client/views/includes/lobbyAuth.pug +19 -0
- package/src/client/views/includes/panel.pug +3 -0
- package/src/client/views/includes/stat.pug +2 -0
- package/src/client/views/index.pug +8 -0
- package/src/config/closeCodes.js +18 -0
- package/src/config/env.js +61 -0
- package/src/devtools/ScenarioRunner.js +3 -8
- package/src/devtools/pluginLoader.js +8 -93
- package/src/host/HostGame.js +41 -3
- package/src/host/PortMachine.js +294 -0
- package/src/host/host.worker.js +29 -246
- package/src/host/identity.js +100 -0
- package/src/lib/clientIp.js +39 -0
- package/src/lib/loadGamePackage.js +122 -0
- package/src/lib/offlinePlayerData.js +18 -0
- package/src/standalone/index.js +125 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Стратегии идентичности хоста: как порт-машина (PortMachine.js) узнаёт,
|
|
2
|
+
// под каким ником участник входит в комнату. Прод-лобби доверяет только
|
|
3
|
+
// подписанному identity-токену центрального auth-сервиса; standalone- и
|
|
4
|
+
// dedicated-контуры мастера не имеют вовсе и пускают гостей по нику из формы.
|
|
5
|
+
//
|
|
6
|
+
// Контракт стратегии:
|
|
7
|
+
// { params: Array, errorField: string, resolve(data, socketId): Promise<string> }
|
|
8
|
+
// params доклеиваются к authSchema.params игры (и в форму клиента, и в
|
|
9
|
+
// validateAuth), resolve возвращает проверенный ник или бросает.
|
|
10
|
+
|
|
11
|
+
import { verifyIdentityToken } from '../lib/jwt.js';
|
|
12
|
+
import { isValidName } from '../lib/validators.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Прод-лобби: ник берётся из claim проверенного identity-токена (Этап B3).
|
|
16
|
+
* @param {Object} deps
|
|
17
|
+
* @param {string} deps.jwksUrl - URL JWKS (прокси мастера).
|
|
18
|
+
* @param {string} deps.issuer - ожидаемый iss токена.
|
|
19
|
+
* @returns {Object} Стратегия идентичности.
|
|
20
|
+
*/
|
|
21
|
+
export function createTokenIdentity({ jwksUrl, issuer }) {
|
|
22
|
+
// JWKS central auth-сервиса, проксированный мастером (Этап B3), кэшируется
|
|
23
|
+
// на время жизни стратегии — ключ меняется только при ротации
|
|
24
|
+
let jwksPromise = null;
|
|
25
|
+
|
|
26
|
+
const getJwks = () => {
|
|
27
|
+
if (!jwksPromise) {
|
|
28
|
+
jwksPromise = fetch(jwksUrl).then(res => {
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`jwks: HTTP ${res.status}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return res.json();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// сбой не должен закэшироваться навсегда — следующий вход попробует снова
|
|
37
|
+
jwksPromise.catch(() => {
|
|
38
|
+
jwksPromise = null;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return jwksPromise;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
// токен не поле игровой формы — в authSchema его нет, и validateAuth
|
|
47
|
+
// его не касается: подпись авторитетнее любой схемы
|
|
48
|
+
params: [],
|
|
49
|
+
errorField: 'token',
|
|
50
|
+
|
|
51
|
+
// проверяет identity-токен клиента: подпись RS256 по JWKS мастера,
|
|
52
|
+
// issuer, срок годности — возвращает проверенный ник или бросает.
|
|
53
|
+
// Свободный ввод имени в игре не источник ника: он берётся из claim
|
|
54
|
+
async resolve(data) {
|
|
55
|
+
const jwks = await getJwks();
|
|
56
|
+
const payload = await verifyIdentityToken(data.token, { jwks, issuer });
|
|
57
|
+
|
|
58
|
+
return payload.nick;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Гостевой вход (standalone/dedicated): ник — поле формы, которое хост
|
|
65
|
+
* валидирует сам движковым isValidName, без кода игры.
|
|
66
|
+
*
|
|
67
|
+
* Ограничение (принимается осознанно): гостевые ники не уникальны и не
|
|
68
|
+
* защищены от подмены — центральной идентичности в этом контуре нет.
|
|
69
|
+
* @param {Object} [options]
|
|
70
|
+
* @param {string} [options.fallbackPrefix] - префикс ника-заглушки.
|
|
71
|
+
* @returns {Object} Стратегия идентичности.
|
|
72
|
+
*/
|
|
73
|
+
export function createGuestIdentity({ fallbackPrefix = 'Player_' } = {}) {
|
|
74
|
+
return {
|
|
75
|
+
params: [
|
|
76
|
+
{
|
|
77
|
+
name: 'name',
|
|
78
|
+
value: '',
|
|
79
|
+
options: {
|
|
80
|
+
control: 'text',
|
|
81
|
+
label: 'Name',
|
|
82
|
+
validator: 'isValidName',
|
|
83
|
+
storage: 'playerName',
|
|
84
|
+
required: true,
|
|
85
|
+
maxlength: 15,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
errorField: 'name',
|
|
90
|
+
|
|
91
|
+
// fallback нужен не форме (её ввод отсекает validateAuth по тому же
|
|
92
|
+
// isValidName), а прямым вызовам стратегии — гость без ника всё равно
|
|
93
|
+
// должен получить имя, под которым его увидят остальные
|
|
94
|
+
async resolve(data, socketId) {
|
|
95
|
+
return isValidName(data?.name)
|
|
96
|
+
? data.name
|
|
97
|
+
: `${fallbackPrefix}${String(socketId).slice(0, 4)}`;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Адрес клиента для rate-limit'ов серверных контуров.
|
|
2
|
+
//
|
|
3
|
+
// `X-Forwarded-For` для этого не годится: Nginx деплоя ставит его через
|
|
4
|
+
// `$proxy_add_x_forwarded_for` (.github/deployment/install-system.sh), то есть
|
|
5
|
+
// ДОПИСЫВАЕТ реальный адрес к тому, что прислал клиент. Первый элемент списка
|
|
6
|
+
// задаёт сам клиент — на нём лимит и обходится (свой заголовок на каждое
|
|
7
|
+
// соединение), и выбирается чужой бакет (заголовок с адресом жертвы закрывает
|
|
8
|
+
// ей вход). Тот же Nginx перезаписывает `X-Real-IP` значением `$remote_addr`,
|
|
9
|
+
// поэтому за прокси доверяем только ему, а без прокси — адресу сокета.
|
|
10
|
+
|
|
11
|
+
// один раз на процесс: предупреждение о прокси без X-Real-IP на каждое
|
|
12
|
+
// соединение превратилось бы в лог-флуд
|
|
13
|
+
let proxyHeaderWarned = false;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} req - Запрос (http.IncomingMessage или express Request).
|
|
17
|
+
* @param {Object} [options]
|
|
18
|
+
* @param {boolean} [options.trustProxy] - Стоит ли перед процессом обратный
|
|
19
|
+
* прокси, перезаписывающий `X-Real-IP` (в этом репозитории — прод-Nginx).
|
|
20
|
+
* @returns {string} Адрес клиента или '' (сокет уже разорван).
|
|
21
|
+
*/
|
|
22
|
+
export function clientIp(req, { trustProxy = false } = {}) {
|
|
23
|
+
const header = trustProxy ? req.headers['x-real-ip'] : undefined;
|
|
24
|
+
|
|
25
|
+
// за прокси без X-Real-IP ключом станет адрес самого прокси — один общий
|
|
26
|
+
// бакет на всех клиентов сразу: правило «одна комната на IP» пустило бы
|
|
27
|
+
// одну комнату на весь мастер, а лимит пингов стал бы общим. Падать на
|
|
28
|
+
// первом запросе нельзя (сервер должен подняться), молчать — тоже
|
|
29
|
+
if (trustProxy && !header && !proxyHeaderWarned) {
|
|
30
|
+
proxyHeaderWarned = true;
|
|
31
|
+
console.warn(
|
|
32
|
+
'[clientIp] trustProxy is on but no X-Real-IP header arrived: every ' +
|
|
33
|
+
"client now keys on the proxy's own address, one shared rate-limit " +
|
|
34
|
+
'bucket. Check `proxy_set_header X-Real-IP $remote_addr`.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return String(header || req.socket?.remoteAddress || '').trim();
|
|
39
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { assertEngineApiCompatible } from './gamePlugin.js';
|
|
5
|
+
|
|
6
|
+
// Загрузка пакета игры в Node по его собранному dist/ (Этап 4 плана
|
|
7
|
+
// standalone-sdk). В браузере плагин грузится по URL из GameManifest мастера;
|
|
8
|
+
// в Node URL-ов нет — оба Node-контура (headless-runner devtools/pluginLoader.js
|
|
9
|
+
// и dedicated-сервер) читают манифест с диска и импортируют половины плагина
|
|
10
|
+
// как обычные модули. Модуль живёт в lib/, а не в devtools/: прод-сервер не
|
|
11
|
+
// должен зависеть от отладочного контура (граница из CLAUDE.md).
|
|
12
|
+
//
|
|
13
|
+
// entries.wasmNode — node-сборка ядра игры (конвенция core/pkg-node,
|
|
14
|
+
// docs/ai/02-packaging.md). Для боевой игры она обязательна: без ядра
|
|
15
|
+
// авторитетную симуляцию крутить нечем.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} distDir - Каталог собранного пакета игры (с manifest.json)
|
|
19
|
+
* или прямой путь к файлу манифеста.
|
|
20
|
+
* @param {Object} [options]
|
|
21
|
+
* @param {string} [options.core] - Путь к node-сборке ядра, перекрывающий
|
|
22
|
+
* entries.wasmNode манифеста.
|
|
23
|
+
* @returns {Promise<Object>} { id, manifest, hostPlugin, clientPlugin, wasmUrl,
|
|
24
|
+
* distDir, manifestPath }.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadGamePackage(distDir, { core = null } = {}) {
|
|
27
|
+
// манифест под нестандартным именем нужен headless-прогону (--game
|
|
28
|
+
// <path>/manifest-variant.json); dedicated-сервер всегда даёт каталог
|
|
29
|
+
const manifestPath = distDir.endsWith('.json')
|
|
30
|
+
? path.resolve(distDir)
|
|
31
|
+
: path.join(path.resolve(distDir), 'manifest.json');
|
|
32
|
+
const baseDir = path.dirname(manifestPath);
|
|
33
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
34
|
+
|
|
35
|
+
assertEngineApiCompatible(manifest);
|
|
36
|
+
|
|
37
|
+
const { assetsBase } = manifest;
|
|
38
|
+
const hostPlugin = await importDefault(
|
|
39
|
+
baseDir,
|
|
40
|
+
manifest.entries.host,
|
|
41
|
+
assetsBase,
|
|
42
|
+
);
|
|
43
|
+
const clientPlugin = await importDefault(
|
|
44
|
+
baseDir,
|
|
45
|
+
manifest.entries.client,
|
|
46
|
+
assetsBase,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
assertPluginMatchesManifest(manifest, {
|
|
50
|
+
host: hostPlugin,
|
|
51
|
+
client: clientPlugin,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const nodeCore = core ?? manifest.entries.wasmNode ?? null;
|
|
55
|
+
|
|
56
|
+
if (!nodeCore) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${manifestPath}: no node build of the core — add entries.wasmNode ` +
|
|
59
|
+
`to the manifest (core/pkg-node) or pass --core <path>`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const corePath = path.resolve(baseDir, nodeCore);
|
|
64
|
+
|
|
65
|
+
// манифест объявляет поле, но опубликованный пакет мог не довезти файл
|
|
66
|
+
// (ignore-правила срезают каталог внутри files) — без этой проверки отказ
|
|
67
|
+
// приходит сырым ERR_MODULE_NOT_FOUND из резолвера
|
|
68
|
+
try {
|
|
69
|
+
await access(corePath);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${manifestPath}: entries.wasmNode points at '${nodeCore}', but ` +
|
|
73
|
+
`${corePath} does not exist — the game package was published ` +
|
|
74
|
+
`without its node core (npm run core:build:node) or pass --core <path>`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id: manifest.id,
|
|
80
|
+
manifest,
|
|
81
|
+
hostPlugin,
|
|
82
|
+
clientPlugin,
|
|
83
|
+
wasmUrl: pathToFileURL(corePath).href,
|
|
84
|
+
distDir: baseDir,
|
|
85
|
+
manifestPath,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// зеркало lib/gamePlugin.js:loadClientPlugin — манифест мог быть пересобран
|
|
90
|
+
// без dist/: прогон на старом плагине даёт зелёный вердикт о том, чего в
|
|
91
|
+
// сборке уже нет
|
|
92
|
+
function assertPluginMatchesManifest(manifest, plugins) {
|
|
93
|
+
for (const [half, plugin] of Object.entries(plugins)) {
|
|
94
|
+
if (plugin.engineApi !== manifest.engineApi) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`game "${manifest.id}": ${half} plugin engineApi ` +
|
|
97
|
+
`v${plugin.engineApi} does not match manifest engineApi ` +
|
|
98
|
+
`v${manifest.engineApi} — stale dist/`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function importDefault(baseDir, entry, assetsBase) {
|
|
105
|
+
const file = path.resolve(baseDir, stripBase(entry, assetsBase));
|
|
106
|
+
|
|
107
|
+
return import(pathToFileURL(file).href).then(module => module.default);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// entries.host/client — URL-ы, какими их видит браузер (`assetsBase` + путь
|
|
111
|
+
// внутри пакета). На диске этой базе соответствует каталог манифеста,
|
|
112
|
+
// поэтому абсолютный URL сначала обрезается до пути внутри пакета — иначе
|
|
113
|
+
// path.resolve увёл бы в корень файловой системы.
|
|
114
|
+
function stripBase(entry, assetsBase) {
|
|
115
|
+
if (assetsBase && entry.startsWith(assetsBase)) {
|
|
116
|
+
return entry.slice(assetsBase.length);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return entry.replace(/^\/+/, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default loadGamePackage;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Профиль участника в контуре без мастера и auth-сервиса (headless-прогон,
|
|
2
|
+
// standalone SDK, dedicated-сервер). Это не заглушка ради тишины: пустой
|
|
3
|
+
// профиль и есть корректное состояние такого матча, а настоящий fetch по
|
|
4
|
+
// относительному URL вне браузерной вкладки просто не разрешается.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Фабрика fetchImpl для HostGame (hostOptions.playerDataFetch): любой запрос
|
|
8
|
+
* профиля отвечает дефолтами вместо сети — вместе с сетевыми вызовами уходят
|
|
9
|
+
* предупреждения PlayerDataSync и его ретраи.
|
|
10
|
+
* @returns {Function} fetchImpl, отдающий { rank: 0, state: null }.
|
|
11
|
+
*/
|
|
12
|
+
export function offlinePlayerData() {
|
|
13
|
+
return async () => ({
|
|
14
|
+
ok: true,
|
|
15
|
+
status: 200,
|
|
16
|
+
json: async () => ({ rank: 0, state: null }),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
|
|
2
|
+
import { setBootConfig } from '../client/boot.js';
|
|
3
|
+
import { ensureGameShell } from '../client/views/gameShell.js';
|
|
4
|
+
|
|
5
|
+
// Standalone SDK (Этап 3 плана standalone-sdk): единственная точка входа для
|
|
6
|
+
// репозитория игры — `npm run dev` открывает вкладку с работающим матчем без
|
|
7
|
+
// мастера, без OAuth и без экрана лобби.
|
|
8
|
+
//
|
|
9
|
+
// Отличия от прода (осознанные, см. plan/standalone-sdk/README.md):
|
|
10
|
+
// - хост крутится в главном потоке (inline), а не в Worker'е: HostPlugin
|
|
11
|
+
// непередаваем постмесседжем, а SDK получает живой объект плагина;
|
|
12
|
+
// - личность гостевая (ник из формы или из playerName), рейтинг и
|
|
13
|
+
// сохранение состояния выключены;
|
|
14
|
+
// - мастера нет вовсе: манифест игры собирается в памяти, каталог карт и
|
|
15
|
+
// эстафета Worker'ов не участвуют, WebRTC и module-worker'ы не нужны.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Поднимает матч во вкладке: каркас интерфейса, inline-хост и клиент движка.
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @param {Object} options.hostPlugin - Живой HostPlugin игры.
|
|
21
|
+
* @param {Object} options.clientPlugin - Живой ClientPlugin игры.
|
|
22
|
+
* @param {string} options.wasmUrl - URL web-сборки ядра игры (?url-импорт).
|
|
23
|
+
* @param {HTMLElement} [options.container] - Точка монтирования каркаса И
|
|
24
|
+
* канвасов; обязана быть полноэкранной и `position: relative`.
|
|
25
|
+
* @param {string} [options.assetsBase] - База ассетов игры (звуки берутся из
|
|
26
|
+
* `${assetsBase}sounds/`).
|
|
27
|
+
* @param {string} [options.playerName] - Задан → вход без формы авторизации.
|
|
28
|
+
* @param {string} [options.playerModel] - Модель игрока (поле authSchema).
|
|
29
|
+
* @param {Object} [options.auth] - Прочие поля авторизации игры.
|
|
30
|
+
* @param {Array} [options.startupVotes] - Ответы на initialVote, например
|
|
31
|
+
* `[['teamChange', 'team1']]`; без них участник остаётся наблюдателем.
|
|
32
|
+
* @param {Array<string>} [options.startupCommands] - Чат-команды игры после
|
|
33
|
+
* голосований, например `['/bot 4']`.
|
|
34
|
+
* @param {Object} [options.room] - Переопределения комнаты (map, maxPlayers,
|
|
35
|
+
* roundTime, friendlyFire, seed).
|
|
36
|
+
* @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
|
|
37
|
+
* CONSOLE-лог.
|
|
38
|
+
* @returns {Promise<Object>} `{ stop() }` — останов матча.
|
|
39
|
+
*/
|
|
40
|
+
export async function startStandaloneGame({
|
|
41
|
+
hostPlugin,
|
|
42
|
+
clientPlugin,
|
|
43
|
+
wasmUrl,
|
|
44
|
+
container = document.body,
|
|
45
|
+
assetsBase = '/',
|
|
46
|
+
playerName,
|
|
47
|
+
playerModel,
|
|
48
|
+
auth = {},
|
|
49
|
+
startupVotes = [],
|
|
50
|
+
startupCommands = [],
|
|
51
|
+
room = {},
|
|
52
|
+
devMode = false,
|
|
53
|
+
} = {}) {
|
|
54
|
+
requireOption(hostPlugin, 'hostPlugin');
|
|
55
|
+
requireOption(clientPlugin, 'clientPlugin');
|
|
56
|
+
requireOption(wasmUrl, 'wasmUrl');
|
|
57
|
+
|
|
58
|
+
// несовпадение ENGINE_API_VERSION иначе всплыло бы где-то в середине
|
|
59
|
+
// хендшейка непрозрачной ошибкой
|
|
60
|
+
assertEngineApiCompatible(hostPlugin);
|
|
61
|
+
assertEngineApiCompatible(clientPlugin);
|
|
62
|
+
|
|
63
|
+
ensureGameShell(container);
|
|
64
|
+
|
|
65
|
+
setBootConfig({
|
|
66
|
+
mode: 'solo',
|
|
67
|
+
container,
|
|
68
|
+
manifest: buildManifest(hostPlugin, { wasmUrl, assetsBase }),
|
|
69
|
+
clientPlugin,
|
|
70
|
+
hostPlugin,
|
|
71
|
+
room: {
|
|
72
|
+
...hostPlugin.gameConfig.roomDefaults,
|
|
73
|
+
...room,
|
|
74
|
+
isDevMode: devMode,
|
|
75
|
+
},
|
|
76
|
+
autoAuth: playerName
|
|
77
|
+
? pruneUndefined({ name: playerName, model: playerModel, ...auth })
|
|
78
|
+
: null,
|
|
79
|
+
startupVotes,
|
|
80
|
+
startupCommands,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// конфиг загрузки задан — дальше работает обычный клиент движка в
|
|
84
|
+
// solo-режиме (import динамический: main.js исполняется при импорте)
|
|
85
|
+
const client = await import('../client/main.js');
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
stop() {
|
|
89
|
+
client.stopGame();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// незаданное поле — это «взять дефолт схемы», а не «отправить undefined»:
|
|
95
|
+
// JSON.stringify ключ с undefined выбрасывает, и хост отвечает
|
|
96
|
+
// 'Property is missing' (main.js накрывает autoAuth поверх дефолтов схемы)
|
|
97
|
+
function pruneUndefined(source) {
|
|
98
|
+
return Object.fromEntries(
|
|
99
|
+
Object.entries(source).filter(([, value]) => value !== undefined),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// манифест-подобный объект в памяти: мастера нет, а клиент читает из
|
|
104
|
+
// манифеста id/version, entries.wasm и assetsBase. entries.client/host не
|
|
105
|
+
// нужны — оба плагина уже живые объекты
|
|
106
|
+
function buildManifest(hostPlugin, { wasmUrl, assetsBase }) {
|
|
107
|
+
const { gameConfig } = hostPlugin;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id: hostPlugin.id,
|
|
111
|
+
engineApi: hostPlugin.engineApi,
|
|
112
|
+
version: 'dev',
|
|
113
|
+
title: gameConfig.title ?? hostPlugin.id,
|
|
114
|
+
entries: { wasm: wasmUrl },
|
|
115
|
+
assetsBase,
|
|
116
|
+
maps: { version: 'dev', list: Object.keys(gameConfig.maps) },
|
|
117
|
+
roomDefaults: gameConfig.roomDefaults,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requireOption(value, name) {
|
|
122
|
+
if (value === undefined || value === null) {
|
|
123
|
+
throw new Error(`startStandaloneGame: "${name}" is required`);
|
|
124
|
+
}
|
|
125
|
+
}
|