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,158 @@
|
|
|
1
|
+
// Браузерная половина отладочного контура (этап 6 плана plan/done/ai-debug).
|
|
2
|
+
//
|
|
3
|
+
// Всё, что headless-прогон не воспроизводит — реальный WebRTC, PixiJS, живой
|
|
4
|
+
// ввод человека — читается и записывается отсюда: `window.__vimpDebug`
|
|
5
|
+
// вызывается из DevTools или через Chrome MCP (javascript_tool,
|
|
6
|
+
// read_console_messages), без участия человека в разборе.
|
|
7
|
+
//
|
|
8
|
+
// Записанный сценарий уезжает на мастер (POST /debug/report, только dev) в
|
|
9
|
+
// тот же `.debug/`, откуда его берёт `npm run sim:replay` — браузерный баг
|
|
10
|
+
// становится headless-прогоном.
|
|
11
|
+
|
|
12
|
+
// Единый префикс структурированного лога: по нему Chrome MCP фильтрует
|
|
13
|
+
// консоль (read_console_messages pattern) вместо чтения всего потока.
|
|
14
|
+
export const DEBUG_PREFIX = '[vimp:debug]';
|
|
15
|
+
|
|
16
|
+
export function debugLog(...args) {
|
|
17
|
+
console.log(DEBUG_PREFIX, ...args);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Собирает отладочный API вкладки.
|
|
22
|
+
* @param {Object} params
|
|
23
|
+
* @param {Function} params.getHostController - Текущий HostController (null,
|
|
24
|
+
* если вкладка не хостит комнату).
|
|
25
|
+
* @param {Function} params.getClientCore - Текущий ClientCore.
|
|
26
|
+
* @param {string} params.reportUrl - Эндпоинт выгрузки на мастере.
|
|
27
|
+
* @param {Function} [params.fetchImpl] - fetch (подменяется в тестах).
|
|
28
|
+
* @param {Function} [params.log] - Логгер (подменяется в тестах).
|
|
29
|
+
* @returns {Object} API для window.__vimpDebug.
|
|
30
|
+
*/
|
|
31
|
+
export function createDebugApi({
|
|
32
|
+
getHostController,
|
|
33
|
+
getClientCore,
|
|
34
|
+
reportUrl,
|
|
35
|
+
fetchImpl,
|
|
36
|
+
log = debugLog,
|
|
37
|
+
}) {
|
|
38
|
+
const post = fetchImpl ?? ((...args) => fetch(...args));
|
|
39
|
+
|
|
40
|
+
// отладка не должна молча возвращать null там, где вкладка просто не
|
|
41
|
+
// хостит комнату: причина «нечего дампить» и «дамп сломался» — разные
|
|
42
|
+
const requireHost = () => {
|
|
43
|
+
const hostController = getHostController();
|
|
44
|
+
|
|
45
|
+
if (!hostController) {
|
|
46
|
+
throw new Error('this tab is not hosting a room');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return hostController;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Выгружает произвольную полезную нагрузку на мастер.
|
|
54
|
+
* @param {string} kind - scenario | dump | divergence.
|
|
55
|
+
* @param {*} payload
|
|
56
|
+
* @param {string} [note] - Пометка человека («танк в стене на respawn»).
|
|
57
|
+
* @returns {Promise<Object>} Ответ мастера ({ file, bytes }).
|
|
58
|
+
*/
|
|
59
|
+
const save = async (kind, payload, note) => {
|
|
60
|
+
const res = await post(reportUrl, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ kind, payload, note }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`debug report rejected: ${body.error ?? res.status}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log(`${kind} saved to .debug/${body.file}`);
|
|
73
|
+
|
|
74
|
+
return body;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
save,
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Дамп авторитетной половины (мета хоста + мир ядра, этап 4) рядом со
|
|
82
|
+
* сценой этого клиента — «в ядре тело есть, на холсте пусто» видно
|
|
83
|
+
* только при обеих половинах разом.
|
|
84
|
+
* @param {Object} [options]
|
|
85
|
+
* @param {boolean} [options.save] - Выгрузить на мастер.
|
|
86
|
+
* @param {string} [options.note]
|
|
87
|
+
* @returns {Promise<Object>}
|
|
88
|
+
*/
|
|
89
|
+
async dump({ save: doSave = false, note } = {}) {
|
|
90
|
+
const host = await requireHost().dump();
|
|
91
|
+
|
|
92
|
+
log('dump', host);
|
|
93
|
+
|
|
94
|
+
if (doSave) {
|
|
95
|
+
await save('dump', host, note);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return host;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Начинает запись живого матча в формат сценария.
|
|
103
|
+
* @returns {Promise<boolean>} false — комната поднята без dev-режима.
|
|
104
|
+
*/
|
|
105
|
+
async startRecording() {
|
|
106
|
+
const started = await requireHost().startRecording();
|
|
107
|
+
|
|
108
|
+
log(started ? 'recording started' : 'recording unavailable (no dev mode)');
|
|
109
|
+
|
|
110
|
+
return started;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Останавливает запись и (по умолчанию) выгружает сценарий на мастер.
|
|
115
|
+
* @param {Object} [options]
|
|
116
|
+
* @param {boolean} [options.save]
|
|
117
|
+
* @param {string} [options.note]
|
|
118
|
+
* @returns {Promise<Object|null>} { scenario, file } или null.
|
|
119
|
+
*/
|
|
120
|
+
async stopRecording({ save: doSave = true, note } = {}) {
|
|
121
|
+
const scenario = await requireHost().stopRecording();
|
|
122
|
+
|
|
123
|
+
if (!scenario) {
|
|
124
|
+
log('recording was not running');
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log(
|
|
130
|
+
`recording stopped: ${scenario.ticks} tick(s), ` +
|
|
131
|
+
`${scenario.timeline.length} op(s)`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const saved = doSave ? await save('scenario', scenario, note) : null;
|
|
135
|
+
|
|
136
|
+
return { scenario, file: saved?.file ?? null };
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Записи детектора рассинхрона предикта (этап 5) этого клиента.
|
|
141
|
+
* Вычерпывающая операция: ядро отдаёт накопленное и обнуляет буфер.
|
|
142
|
+
* @returns {Object|null} null — ядро без детектора/секции divergence.
|
|
143
|
+
*/
|
|
144
|
+
divergence() {
|
|
145
|
+
const core = getClientCore();
|
|
146
|
+
|
|
147
|
+
if (!core || typeof core.take_divergence !== 'function') {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const dump = JSON.parse(core.take_divergence());
|
|
152
|
+
|
|
153
|
+
log('divergence', dump);
|
|
154
|
+
|
|
155
|
+
return dump;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Автостарт клиента в solo-режиме (Этап 2 плана standalone-sdk): без лобби и
|
|
2
|
+
// без живого игрока за клавиатурой матч должен сам довести себя до игрового
|
|
3
|
+
// состояния — выйти из наблюдателей и позвать ботов игры.
|
|
4
|
+
//
|
|
5
|
+
// Порядок «сначала голоса, потом чат-команды» обязателен, и он не про гонку
|
|
6
|
+
// доставки:
|
|
7
|
+
// - порт CHAT_DATA открывается ещё на MODULES_READY, а реальный гейт —
|
|
8
|
+
// HostGame.pushMessage: он отбрасывает сообщение, пока user.isReady=false
|
|
9
|
+
// (флаг ставится синхронно в firstShotReady);
|
|
10
|
+
// - настоящая блокировка — команда: участник входит наблюдателем, а игра
|
|
11
|
+
// вправе требовать активной команды (у танков /bot отбивается
|
|
12
|
+
// наблюдателю). Выйти из наблюдателей можно только ответом на initialVote
|
|
13
|
+
// (['teamChange', '<team>'] на порт VOTE_DATA).
|
|
14
|
+
// Отсюда: голоса строго раньше команд, и вся пачка — после FIRST_SHOT_READY.
|
|
15
|
+
//
|
|
16
|
+
// Дробить команды по кадрам не нужно: лимита частоты чата на хосте нет,
|
|
17
|
+
// ограничение только по длине (chatMaxLength).
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} deps
|
|
21
|
+
* @param {Array} [deps.votes] - Ответы на initialVote (VOTE_DATA).
|
|
22
|
+
* @param {Array<string>} [deps.commands] - Чат-команды игры (CHAT_DATA).
|
|
23
|
+
* @param {Function} deps.sendVote - (payload) => void.
|
|
24
|
+
* @param {Function} deps.sendCommand - (message) => void.
|
|
25
|
+
* @param {Function} deps.schedule - (fn) => void: отложить на первый
|
|
26
|
+
* renderTick (страховка поверх синхронного гейта isReady и симметрия с
|
|
27
|
+
* задержкой интерполяции).
|
|
28
|
+
* @returns {Function} start(): запускает автостарт не более одного раза.
|
|
29
|
+
*/
|
|
30
|
+
export default function createAutostart({
|
|
31
|
+
votes = [],
|
|
32
|
+
commands = [],
|
|
33
|
+
sendVote,
|
|
34
|
+
sendCommand,
|
|
35
|
+
schedule,
|
|
36
|
+
}) {
|
|
37
|
+
let started = false;
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
// FIRST_SHOT_DATA приходит и после смены карты — автостарт разовый
|
|
41
|
+
if (started) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
started = true;
|
|
46
|
+
|
|
47
|
+
if (!votes.length && !commands.length) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
schedule(() => {
|
|
52
|
+
for (const vote of votes) {
|
|
53
|
+
sendVote(vote);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const command of commands) {
|
|
57
|
+
sendCommand(command);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Учёт потери/восстановления WebGL-контекста по каждому полотну отдельно.
|
|
2
|
+
// Полотен несколько (игровое, радар и т.п.) — это независимые контексты, и
|
|
3
|
+
// браузер шлёт webglcontextrestored по каждому из них асинхронно. Общий
|
|
4
|
+
// булев флаг здесь не годится: перепечка ассетов в ещё мёртвый контекст
|
|
5
|
+
// даёт пустые текстуры, а второго шанса не будет — событие уже прошло.
|
|
6
|
+
// Вынесено из main.js (бутстрап тестами не покрывается), чтобы поведение
|
|
7
|
+
// проверялось юнит-тестом.
|
|
8
|
+
export function createContextTracker() {
|
|
9
|
+
const lost = new Set();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
// true — рендер надо остановить (первая потеря из всех полотен)
|
|
13
|
+
markLost(id) {
|
|
14
|
+
if (lost.has(id)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const wasAlive = lost.size === 0;
|
|
19
|
+
|
|
20
|
+
lost.add(id);
|
|
21
|
+
|
|
22
|
+
return wasAlive;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// true — пора пересобирать сцену: живы все контексты
|
|
26
|
+
markRestored(id) {
|
|
27
|
+
if (!lost.delete(id)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return lost.size === 0;
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
isLost(id) {
|
|
35
|
+
return lost.has(id);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
hasLost() {
|
|
39
|
+
return lost.size > 0;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
reset() {
|
|
43
|
+
lost.clear();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Общий билдер полей форм (room-форма и auth-форма используют один
|
|
2
|
+
// контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
|
|
3
|
+
// control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
|
|
4
|
+
// элементы формы, без визуальной кастомизации.
|
|
5
|
+
|
|
6
|
+
function normalizeOptions(list) {
|
|
7
|
+
return (list || []).map(opt =>
|
|
8
|
+
opt !== null && typeof opt === 'object' ? opt : { value: opt, label: String(opt) },
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 'source' — спец-источник вариантов из каталога движка (например карты),
|
|
13
|
+
// прокидывается вызывающей стороной через ctx.sources
|
|
14
|
+
function resolveOptions(descriptor, ctx) {
|
|
15
|
+
if (descriptor.source) {
|
|
16
|
+
return normalizeOptions(ctx?.sources?.[descriptor.source]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return normalizeOptions(descriptor.options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// unit:'s' — значение хранится в мс, показывается/редактируется в секундах
|
|
23
|
+
function toDisplay(descriptor, value) {
|
|
24
|
+
return descriptor.unit === 's' ? value / 1000 : value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toStored(descriptor, value) {
|
|
28
|
+
return descriptor.unit === 's' ? value * 1000 : value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildSelect(descriptor, ctx) {
|
|
32
|
+
const el = document.createElement('select');
|
|
33
|
+
|
|
34
|
+
el.name = descriptor.name;
|
|
35
|
+
|
|
36
|
+
resolveOptions(descriptor, ctx).forEach(({ value, label }) => {
|
|
37
|
+
const option = document.createElement('option');
|
|
38
|
+
|
|
39
|
+
option.value = value;
|
|
40
|
+
option.textContent = label;
|
|
41
|
+
el.appendChild(option);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
el,
|
|
46
|
+
getValue: () => el.value,
|
|
47
|
+
setValue(value) {
|
|
48
|
+
el.value = value;
|
|
49
|
+
},
|
|
50
|
+
onChange(cb) {
|
|
51
|
+
el.addEventListener('change', () => cb({ name: descriptor.name, value: el.value }));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// numeric text-поле (unit задан или numeric:true) — переиспользует
|
|
57
|
+
// toDisplay/toStored, откатывается к default при пустом/невалидном вводе,
|
|
58
|
+
// вместо превращения его в 0 на сабмите
|
|
59
|
+
function buildText(descriptor) {
|
|
60
|
+
const el = document.createElement('input');
|
|
61
|
+
const numeric = descriptor.numeric || descriptor.unit !== undefined;
|
|
62
|
+
|
|
63
|
+
el.type = 'text';
|
|
64
|
+
el.name = descriptor.name;
|
|
65
|
+
|
|
66
|
+
if (descriptor.regExp) {
|
|
67
|
+
el.pattern = descriptor.regExp;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (descriptor.required) {
|
|
71
|
+
el.required = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (descriptor.maxlength !== undefined) {
|
|
75
|
+
el.maxLength = descriptor.maxlength;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fallback = () => toDisplay(descriptor, descriptor.default ?? 0);
|
|
79
|
+
|
|
80
|
+
const getValue = () => {
|
|
81
|
+
if (!numeric) {
|
|
82
|
+
return el.value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const n = Number(el.value);
|
|
86
|
+
const value = el.value !== '' && Number.isFinite(n) ? n : fallback();
|
|
87
|
+
|
|
88
|
+
return toStored(descriptor, value);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
el,
|
|
93
|
+
getValue,
|
|
94
|
+
setValue(value) {
|
|
95
|
+
el.value = numeric ? toDisplay(descriptor, value) : (value ?? '');
|
|
96
|
+
},
|
|
97
|
+
onChange(cb) {
|
|
98
|
+
el.addEventListener('change', () => cb({ name: descriptor.name, value: getValue() }));
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// уникальный id для <label for> подписи (несколько форм с одноимённым полем
|
|
104
|
+
// могут сосуществовать в DOM — room-форма и auth-форма рендерятся в разные
|
|
105
|
+
// моменты, но обе всегда присутствуют в разметке)
|
|
106
|
+
let idSeq = 0;
|
|
107
|
+
|
|
108
|
+
function buildCheckbox(descriptor) {
|
|
109
|
+
const el = document.createElement('input');
|
|
110
|
+
|
|
111
|
+
el.type = 'checkbox';
|
|
112
|
+
el.name = descriptor.name;
|
|
113
|
+
el.id = `field-checkbox-${++idSeq}`;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
el,
|
|
117
|
+
labelFor: el.id,
|
|
118
|
+
getValue: () => el.checked,
|
|
119
|
+
setValue(value) {
|
|
120
|
+
el.checked = Boolean(value);
|
|
121
|
+
},
|
|
122
|
+
onChange(cb) {
|
|
123
|
+
el.addEventListener('change', () => cb({ name: descriptor.name, value: el.checked }));
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// группа нативных radio с общим name; подпись каждого варианта — соседний
|
|
129
|
+
// <label for>, как у checkbox
|
|
130
|
+
function buildRadio(descriptor, ctx) {
|
|
131
|
+
const el = document.createElement('div');
|
|
132
|
+
const groupName = `field-radio-${descriptor.name}-${++idSeq}`;
|
|
133
|
+
const inputs = [];
|
|
134
|
+
|
|
135
|
+
resolveOptions(descriptor, ctx).forEach(({ value, label }) => {
|
|
136
|
+
const wrap = document.createElement('span');
|
|
137
|
+
const input = document.createElement('input');
|
|
138
|
+
|
|
139
|
+
input.type = 'radio';
|
|
140
|
+
input.name = groupName;
|
|
141
|
+
input.id = `${groupName}-${inputs.length}`;
|
|
142
|
+
input.value = value;
|
|
143
|
+
|
|
144
|
+
const optionLabel = document.createElement('label');
|
|
145
|
+
|
|
146
|
+
optionLabel.htmlFor = input.id;
|
|
147
|
+
optionLabel.textContent = label;
|
|
148
|
+
|
|
149
|
+
wrap.append(input, optionLabel);
|
|
150
|
+
el.appendChild(wrap);
|
|
151
|
+
inputs.push(input);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const getValue = () => inputs.find(input => input.checked)?.value;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
el,
|
|
158
|
+
getValue,
|
|
159
|
+
setValue(value) {
|
|
160
|
+
inputs.forEach(input => {
|
|
161
|
+
input.checked = input.value === String(value);
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
onChange(cb) {
|
|
165
|
+
inputs.forEach(input => {
|
|
166
|
+
input.addEventListener('change', () => cb({ name: descriptor.name, value: getValue() }));
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const builders = {
|
|
173
|
+
select: buildSelect,
|
|
174
|
+
text: buildText,
|
|
175
|
+
checkbox: buildCheckbox,
|
|
176
|
+
radio: buildRadio,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
|
|
180
|
+
export function buildField(descriptor, ctx = {}) {
|
|
181
|
+
const build = builders[descriptor.control];
|
|
182
|
+
|
|
183
|
+
if (!build) {
|
|
184
|
+
throw new Error(`formBuilder: unknown control "${descriptor.control}"`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const field = build(descriptor, ctx);
|
|
188
|
+
|
|
189
|
+
if (descriptor.default !== undefined) {
|
|
190
|
+
field.setValue(descriptor.default);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return field;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// засеивает default каждого дескриптора room-формы значением из roomDefaults
|
|
197
|
+
// (docs/en/plugin-api.md "Form schema": roomDefaults остаётся единственным
|
|
198
|
+
// источником значений по умолчанию); явный descriptor.default, если он есть
|
|
199
|
+
// в схеме, побеждает. Вынесено из populateRoomForm (main.js), чтобы этот же
|
|
200
|
+
// код был накрыт тестом, а не продублирован в нём
|
|
201
|
+
export function mergeRoomDefaults(descriptors, roomDefaults) {
|
|
202
|
+
return descriptors.map(descriptor => ({
|
|
203
|
+
default: roomDefaults[descriptor.name],
|
|
204
|
+
...descriptor,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// нативная валидация (pattern/required/maxlength) перед сабмитом room- и
|
|
209
|
+
// auth-формы: обе — обычные div (lobby.pug/auth.pug), не <form>, поэтому
|
|
210
|
+
// reportValidity вызывается на каждом контроле напрямую (Constraint
|
|
211
|
+
// Validation API работает и вне <form>). Останавливается на первом
|
|
212
|
+
// невалидном контроле — иначе браузер фокусирует/подсвечивает последний
|
|
213
|
+
// вызванный reportValidity, а не первый по порядку поля
|
|
214
|
+
export function reportFormValidity(container) {
|
|
215
|
+
if (!container) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const el of container.querySelectorAll('input, select')) {
|
|
220
|
+
if (!el.reportValidity()) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// собирает форму (упорядоченный массив дескрипторов = порядок полей) в
|
|
229
|
+
// контейнер: одна .form-row на дескриптор (.form-label + контрол); onChange,
|
|
230
|
+
// если передан, подписывается на все поля разом. hidden:true — поле строится
|
|
231
|
+
// и участвует в сабмите (fields Map), но строка не попадает в DOM
|
|
232
|
+
export function buildForm(descriptors, container, ctx = {}, onChange) {
|
|
233
|
+
const fields = new Map();
|
|
234
|
+
|
|
235
|
+
container.textContent = '';
|
|
236
|
+
|
|
237
|
+
descriptors.forEach(descriptor => {
|
|
238
|
+
let field;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
field = buildField(descriptor, ctx);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
// одно поле с кривой/неизвестной схемой не должно ронять всю форму
|
|
244
|
+
// (room-форма и особенно auth-форма — единственный путь к кнопке Start)
|
|
245
|
+
console.error(`formBuilder: skipping field "${descriptor.name}" — ${e.message}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (onChange) {
|
|
250
|
+
field.onChange(onChange);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fields.set(descriptor.name, field);
|
|
254
|
+
|
|
255
|
+
if (descriptor.hidden) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const row = document.createElement('div');
|
|
260
|
+
|
|
261
|
+
row.className = 'form-row';
|
|
262
|
+
|
|
263
|
+
// checkbox подписывается через <label for>, чтобы клик по тексту
|
|
264
|
+
// переключал его (a11y) и строка выглядела как у остальных полей
|
|
265
|
+
// (подпись слева, контрол справа)
|
|
266
|
+
const label = document.createElement(field.labelFor ? 'label' : 'span');
|
|
267
|
+
|
|
268
|
+
label.className = 'form-label';
|
|
269
|
+
label.textContent = (descriptor.label || descriptor.name) + (descriptor.unit === 's' ? ' (s)' : '');
|
|
270
|
+
|
|
271
|
+
if (field.labelFor) {
|
|
272
|
+
label.htmlFor = field.labelFor;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
row.append(label, field.el);
|
|
276
|
+
container.appendChild(row);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return fields;
|
|
280
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Пока хост всегда поднимает activeGameManifest (динамическая загрузка
|
|
2
|
+
// ClientPlugin по выбору игры отложена — граница задачи, plan/lobby-page-plan.md),
|
|
3
|
+
// нельзя дать создать комнату с полями формы игры, отличной от активной
|
|
4
|
+
// (code review lobby-page 2.1). Вынесено в чистую функцию, чтобы поведение
|
|
5
|
+
// было проверяемо юнит-тестом отдельно от main.js (main.js — бутстрап, тестами
|
|
6
|
+
// не покрывается).
|
|
7
|
+
export function getHostGateState(selectedGameId, activeGameManifest) {
|
|
8
|
+
if (selectedGameId === activeGameManifest.id) {
|
|
9
|
+
return { disabled: false, title: '' };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
disabled: true,
|
|
14
|
+
title: `Hosting ${activeGameManifest.title} — switch back to create a server`,
|
|
15
|
+
};
|
|
16
|
+
}
|