vimp-engine 0.38.3 → 0.40.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/README.md +2 -2
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +2 -2
- package/package.json +3 -4
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +11 -87
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/RoomMenu.js +1 -1
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/Auth.js +3 -3
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/lib/formBuilder.js +7 -6
- package/src/client/lib/hostVoteCommand.js +2 -2
- package/src/client/lib/pickActiveGame.js +3 -12
- package/src/client/lib/retryOnNetworkError.js +0 -9
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +160 -90
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +43 -0
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +38 -14
- package/src/client/network/WebRtcManager.js +46 -5
- package/src/client/network/policyClose.js +10 -77
- package/src/client/style.css +150 -9
- package/src/client/views/gameShell.js +2 -4
- package/src/client/views/includes/auth.pug +1 -4
- package/src/client/views/includes/lobby.pug +3 -6
- package/src/client/voice/JitterBuffer.js +137 -0
- package/src/client/voice/MicCapture.js +307 -0
- package/src/client/voice/PttMachine.js +369 -0
- package/src/client/voice/VoiceClient.js +336 -0
- package/src/client/voice/VoiceCues.js +295 -0
- package/src/client/voice/VoiceEncoder.js +174 -0
- package/src/client/voice/VoicePlayback.js +558 -0
- package/src/client/voice/audioData.js +35 -0
- package/src/client/voice/captureProcessor.worklet.js +136 -0
- package/src/client/voice/opusConfig.js +49 -0
- package/src/client/voice/voiceOutput.js +72 -0
- package/src/client/voice/voiceSupport.js +71 -0
- package/src/client/voice/voiceTones.js +90 -0
- package/src/config/clientServices.js +2 -0
- package/src/config/closeCodes.js +7 -9
- package/src/config/gameCodes.js +1 -1
- package/src/config/hostDefaults.js +2 -2
- package/src/config/lobby.js +3 -4
- package/src/config/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +13 -1
- package/src/devtools/ScenarioRunner.js +1 -1
- package/src/devtools/builtinScenario.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/b11-voice-config.js +34 -0
- package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
- package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
- package/src/devtools/contract/rules/index.js +4 -0
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +2 -2
- package/src/host/HostGame.js +62 -4
- package/src/host/PortMachine.js +23 -24
- package/src/host/commandFailure.js +2 -2
- package/src/host/host.worker.js +7 -0
- package/src/host/identity.js +3 -3
- package/src/host/meta/SocketManager.js +6 -0
- package/src/host/meta/core/CommandProcessor.js +2 -2
- package/src/host/meta/modules/Accolades.js +1 -1
- package/src/host/meta/modules/voice/Voice.js +430 -0
- package/src/host/meta/modules/voice/channels.js +58 -0
- package/src/host/meta/modules/voice/index.js +1 -0
- package/src/lib/VoiceRelay.js +200 -0
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +4 -0
- package/src/lib/createHostRuntime.js +8 -0
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/loadGamePackage.js +7 -9
- package/src/lib/offlinePlayerData.js +1 -1
- package/src/lib/validators.js +2 -2
- package/src/lib/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
- package/src/standalone/index.js +3 -2
- package/src/client/lib/footerLink.js +0 -30
- package/src/client/network/WebSocketTransport.js +0 -78
package/src/client/main.js
CHANGED
|
@@ -26,17 +26,19 @@ import StatCtrl from './components/controller/Stat.js';
|
|
|
26
26
|
import VoteModel from './components/model/Vote.js';
|
|
27
27
|
import VoteView from './components/view/Vote.js';
|
|
28
28
|
import VoteCtrl from './components/controller/Vote.js';
|
|
29
|
+
import VoiceModel from './components/model/Voice.js';
|
|
30
|
+
import VoiceView from './components/view/Voice.js';
|
|
31
|
+
import VoiceCtrl from './components/controller/Voice.js';
|
|
29
32
|
import {
|
|
30
33
|
buildForm,
|
|
31
34
|
mergeRoomDefaults,
|
|
32
35
|
bindLiveErrors,
|
|
33
36
|
} from './lib/formBuilder.js';
|
|
34
37
|
import { normalizeAuthParams } from './lib/authParams.js';
|
|
35
|
-
import { renderDocsLink } from './lib/footerLink.js';
|
|
36
38
|
import { createGameActivator } from './lib/gameActivator.js';
|
|
37
39
|
import createAutostart from './lib/autostart.js';
|
|
38
40
|
import { applyCatalogState } from './lib/catalogState.js';
|
|
39
|
-
import {
|
|
41
|
+
import { resolveBootConfig } from './boot.js';
|
|
40
42
|
import {
|
|
41
43
|
ensureGameShell,
|
|
42
44
|
ensureCanvas,
|
|
@@ -45,6 +47,8 @@ import {
|
|
|
45
47
|
import { createContextTracker } from './lib/contextTracker.js';
|
|
46
48
|
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
47
49
|
import { createAccolades } from './lib/accolades.js';
|
|
50
|
+
import { createVoiceState } from './lib/voiceState.js';
|
|
51
|
+
import { parseVoiceMessage } from '../lib/voiceMessages.js';
|
|
48
52
|
import { createDiagnostics } from './lib/diagnostics.js';
|
|
49
53
|
import { fetchRetrying, isNetworkFailure } from './lib/retryOnNetworkError.js';
|
|
50
54
|
import { dispatchSocketMessage } from './lib/socketDispatch.js';
|
|
@@ -62,7 +66,6 @@ import { validateAuth } from '../lib/validators.js';
|
|
|
62
66
|
import SoundManager from './SoundManager.js';
|
|
63
67
|
import SignalingClient from './network/SignalingClient.js';
|
|
64
68
|
import LoopbackTransport from './network/LoopbackTransport.js';
|
|
65
|
-
import WebSocketTransport from './network/WebSocketTransport.js';
|
|
66
69
|
import InlineHostBridge from './network/InlineHostBridge.js';
|
|
67
70
|
import SessionSupervisor, {
|
|
68
71
|
SESSION_STATES,
|
|
@@ -78,11 +81,7 @@ import {
|
|
|
78
81
|
import { createVerifier, challengeOf } from './lib/pkce.js';
|
|
79
82
|
import { createTurnstile } from './lib/turnstile.js';
|
|
80
83
|
import TokenRenewer from './network/TokenRenewer.js';
|
|
81
|
-
import {
|
|
82
|
-
isKickClose,
|
|
83
|
-
POLICY_CLOSE_INFORMS,
|
|
84
|
-
shouldReloadAfterClose,
|
|
85
|
-
} from './network/policyClose.js';
|
|
84
|
+
import { isKickClose } from './network/policyClose.js';
|
|
86
85
|
import LobbyModel from './components/model/Lobby.js';
|
|
87
86
|
import LobbyView from './components/view/Lobby.js';
|
|
88
87
|
import LobbyCtrl from './components/controller/Lobby.js';
|
|
@@ -140,8 +139,8 @@ import runHotTick from './lib/hotTick.js';
|
|
|
140
139
|
|
|
141
140
|
// Динамическая загрузка игры по каталогу мастера (Этап 6.3): ClientPlugin
|
|
142
141
|
// (parts, bakers, игровой CSS, хуки ядра) грузится по entries.client манифеста
|
|
143
|
-
// — движок не импортирует игру статически. Первой активируется
|
|
144
|
-
//
|
|
142
|
+
// — движок не импортирует игру статически. Первой активируется первая
|
|
143
|
+
// доступная игра каталога, но активная игра не заморожена: выбор в #lobby-game и
|
|
145
144
|
// вход в чужую комнату переключают её через gameActivator (см. bindActiveGame).
|
|
146
145
|
// Переключение безопасно ровно до старта матча: всё пер-игровое состояние
|
|
147
146
|
// (Factory, Pixi-приложения, clientCore, звук) появляется только в CONFIG_DATA,
|
|
@@ -174,37 +173,24 @@ function bindActiveGame(manifest, plugin) {
|
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
// режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
|
|
177
|
-
// solo — хост в этой же вкладке (standalone SDK)
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
// каталог манифестов нужен только лобби-контуру, а его запрос не зависит от
|
|
182
|
-
// ответа /config — пускаем оба в полёт разом, иначе старт лобби платит лишний
|
|
183
|
-
// последовательный round-trip
|
|
184
|
-
const injectedBoot = getBootConfig();
|
|
185
|
-
const manifestPromise = injectedBoot
|
|
186
|
-
? null
|
|
187
|
-
: fetchGamesManifest(lobbyConfig.gamesManifestUrl, (...args) =>
|
|
188
|
-
fetchRetrying(fetch, ...args),
|
|
189
|
-
).catch(err => err);
|
|
190
|
-
|
|
191
|
-
let boot;
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
boot = injectedBoot ?? (await resolveBootConfig());
|
|
195
|
-
} catch (e) {
|
|
196
|
-
// сети нет и режим неизвестен: репортёр ещё не создан (ему нужен режим),
|
|
197
|
-
// игроку остаётся надпись отказа
|
|
198
|
-
showBootFailure(`Failed to load the game: ${e.message}`);
|
|
199
|
-
throw e;
|
|
200
|
-
}
|
|
201
|
-
|
|
176
|
+
// solo — хост в этой же вкладке (standalone SDK). Ветвлений ровно пять:
|
|
177
|
+
// манифест, сигналинг/лобби, транспорт, авто-аутентификация и точка
|
|
178
|
+
// монтирования канвасов.
|
|
179
|
+
const boot = resolveBootConfig();
|
|
202
180
|
const bootMode = boot.mode;
|
|
203
181
|
const isLobbyMode = bootMode === 'lobby';
|
|
204
182
|
|
|
183
|
+
// каталог манифестов нужен только лобби-контуру: запрос уходит до создания
|
|
184
|
+
// журнала и каркаса, а ждут его только в загрузке игры ниже
|
|
185
|
+
const manifestPromise = isLobbyMode
|
|
186
|
+
? fetchGamesManifest(lobbyConfig.gamesManifestUrl, (...args) =>
|
|
187
|
+
fetchRetrying(fetch, ...args),
|
|
188
|
+
).catch(err => err)
|
|
189
|
+
: null;
|
|
190
|
+
|
|
205
191
|
// журнал клиентских ошибок: ставится как можно
|
|
206
|
-
// раньше, чтобы ловить и сбои самого старта. Лобби
|
|
207
|
-
//
|
|
192
|
+
// раньше, чтобы ловить и сбои самого старта. Лобби шлёт на свой бокс;
|
|
193
|
+
// SDK (solo) — только если встраивающий передал reportUrl
|
|
208
194
|
const diagnostics = createDiagnostics({
|
|
209
195
|
url:
|
|
210
196
|
bootMode === 'solo'
|
|
@@ -253,7 +239,7 @@ try {
|
|
|
253
239
|
|
|
254
240
|
try {
|
|
255
241
|
// недоступная игра активной быть не может (lib/pickActiveGame.js)
|
|
256
|
-
activeGameManifest = pickActiveGame(gamesManifest
|
|
242
|
+
activeGameManifest = pickActiveGame(gamesManifest);
|
|
257
243
|
syncDiagnosticsGame();
|
|
258
244
|
} catch (e) {
|
|
259
245
|
// «каталог непустой, но играбельного в нём нет» (движок обновили, все
|
|
@@ -261,7 +247,7 @@ try {
|
|
|
261
247
|
// лобби это то же самое, что пустой каталог: играть не во что, но
|
|
262
248
|
// модерация и заявка на месте, и только они это чинят. Терять их из-за
|
|
263
249
|
// такого нельзя, поэтому причина едет строкой отказа лобби, а не
|
|
264
|
-
// терминальным оверлеем. В solo
|
|
250
|
+
// терминальным оверлеем. В solo отказ остаётся отказом
|
|
265
251
|
if (!isLobbyMode) {
|
|
266
252
|
throw e;
|
|
267
253
|
}
|
|
@@ -273,7 +259,7 @@ try {
|
|
|
273
259
|
// пустой каталог — состояние лобби, а не отказ загрузки: модератор вправе
|
|
274
260
|
// отключить последнюю игру, реестр вправе ещё ничего не одобрить, и вернуть
|
|
275
261
|
// каталог к жизни можно только из панели, которая живёт в этой же вкладке.
|
|
276
|
-
// В solo
|
|
262
|
+
// В solo игры нет — это отказ: там весь смысл вкладки в матче
|
|
277
263
|
if (!activeGameManifest && !isLobbyMode) {
|
|
278
264
|
throw new Error('master has no games in its catalog');
|
|
279
265
|
}
|
|
@@ -313,6 +299,7 @@ const PS_CHAT_DATA = wsports.server.CHAT_DATA;
|
|
|
313
299
|
const PS_VOTE_DATA = wsports.server.VOTE_DATA;
|
|
314
300
|
const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
|
|
315
301
|
const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
|
|
302
|
+
const PS_VOICE_DATA = wsports.server.VOICE_DATA;
|
|
316
303
|
const PS_SESSION_DATA = wsports.server.SESSION_DATA;
|
|
317
304
|
const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
|
|
318
305
|
|
|
@@ -325,13 +312,14 @@ const PC_MAP_READY = wsports.client.MAP_READY;
|
|
|
325
312
|
const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
|
|
326
313
|
const PC_KEYS_DATA = wsports.client.KEYS_DATA;
|
|
327
314
|
const PC_CHAT_DATA = wsports.client.CHAT_DATA;
|
|
315
|
+
const PC_VOICE_FLOOR = wsports.client.VOICE_FLOOR;
|
|
328
316
|
const PC_VOTE_DATA = wsports.client.VOTE_DATA;
|
|
329
317
|
const PC_PONG = wsports.client.PONG;
|
|
330
318
|
const PC_LEAVE = wsports.client.LEAVE;
|
|
331
319
|
|
|
332
320
|
// сигнальный WebSocket мастера (лобби + установка P2P); игровой трафик идёт
|
|
333
321
|
// по WebRTC (transport), не через мастер
|
|
334
|
-
// (только lobby: solo
|
|
322
|
+
// (только lobby: у solo мастера нет вовсе)
|
|
335
323
|
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
336
324
|
const signaling = isLobbyMode
|
|
337
325
|
? new SignalingClient(`${wsProtocol}//${location.host}/`)
|
|
@@ -339,7 +327,7 @@ const signaling = isLobbyMode
|
|
|
339
327
|
|
|
340
328
|
// супервизор сессии (host-migration этап 4): держит текущий транспорт к
|
|
341
329
|
// хосту и переживает его обрыв — переподключается и возвращает место в
|
|
342
|
-
// матче. Создаётся при входе в комнату (или в solo
|
|
330
|
+
// матче. Создаётся при входе в комнату (или в solo-матч)
|
|
343
331
|
let supervisor = null;
|
|
344
332
|
|
|
345
333
|
// оверлей «Reconnecting…» на время переподключения (только лобби-режим)
|
|
@@ -399,7 +387,7 @@ const GAME_ROUND_START_CODE = GAME_CODES.roundStart[0];
|
|
|
399
387
|
// соединения важнее общего сообщения handleDisconnect
|
|
400
388
|
let terminalInformShown = false;
|
|
401
389
|
// ключ последнего терминального tech-кода: по нему лобби-режим отличает кик
|
|
402
|
-
// (policyClose.isKickClose) от закрытия комнаты —
|
|
390
|
+
// (policyClose.isKickClose) от закрытия комнаты — у транспортов кода закрытия нет
|
|
403
391
|
let terminalTechKey = null;
|
|
404
392
|
// снятие предыдущего animationend-листенера логотипа при повторном
|
|
405
393
|
// playLogoRoundStart — иначе листенеры копятся при частых стартах раунда
|
|
@@ -433,6 +421,15 @@ const localPlayer = createLocalPlayer(() => clientCore);
|
|
|
433
421
|
// сервис пула зависимостей: «какое место у этой сущности в глобальном
|
|
434
422
|
// топе?». Места считает хост, part рисует за них знак (см. lib/accolades.js)
|
|
435
423
|
const accolades = createAccolades();
|
|
424
|
+
// голосовая связь: кто в эфире (сервис партов `voice`) — хранилище пишет
|
|
425
|
+
// движок по VOICE_DATA, парты только читают (lib/voiceState.js)
|
|
426
|
+
const voice = createVoiceState();
|
|
427
|
+
// подсистема голоса грузится лениво — только у игры с включённой связью
|
|
428
|
+
let voiceClient = null;
|
|
429
|
+
let voiceConfig = null;
|
|
430
|
+
// номер запуска подсистемы: чанк, догрузившийся после закрытия матча,
|
|
431
|
+
// уничтожает свой клиент сам
|
|
432
|
+
let voiceSession = 0;
|
|
436
433
|
let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
|
|
437
434
|
|
|
438
435
|
// обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
|
|
@@ -451,6 +448,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
451
448
|
return;
|
|
452
449
|
}
|
|
453
450
|
|
|
451
|
+
voiceConfig = data.voice ?? null;
|
|
452
|
+
voice.store.setEnabled(voiceConfig?.enabled === true);
|
|
453
|
+
|
|
454
454
|
gameSets = data.parts.gameSets;
|
|
455
455
|
entitiesOnCanvas = data.parts.entitiesOnCanvas;
|
|
456
456
|
|
|
@@ -541,6 +541,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
541
541
|
// места в глобальном топе: знак за место рисует part игры, движок
|
|
542
542
|
// раздаёт только числа (см. lib/accolades.js)
|
|
543
543
|
accolades,
|
|
544
|
+
// индикаторы эфира связи для партов игры: кто сейчас говорит
|
|
545
|
+
// (только чтение, см. lib/voiceState.js)
|
|
546
|
+
voice: voice.service,
|
|
544
547
|
// база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
|
|
545
548
|
// картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
|
|
546
549
|
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
@@ -596,9 +599,9 @@ socketMethods[PS_AUTH_DATA] = data => {
|
|
|
596
599
|
|
|
597
600
|
document.getElementById('logo').textContent = texts?.title || 'VIMP';
|
|
598
601
|
|
|
599
|
-
// футер формы входа: версия пакета
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
+
// футер формы входа: версия пакета ИГРЫ. Здесь, а не в AuthView: его
|
|
603
|
+
// elems приходят из authSchema.elems игрового плагина, и футер каркаса —
|
|
604
|
+
// не его забота.
|
|
602
605
|
// Версию кладёт в манифест мастер (GameCatalog читает package.json
|
|
603
606
|
// пакета); в standalone-манифесте её может не быть — тогда ячейка пустая,
|
|
604
607
|
// раскладку space-between это не ломает
|
|
@@ -608,8 +611,6 @@ socketMethods[PS_AUTH_DATA] = data => {
|
|
|
608
611
|
authVersion.textContent = activeGameManifest?.packageVersion ?? '';
|
|
609
612
|
}
|
|
610
613
|
|
|
611
|
-
renderDocsLink(document.getElementById('auth-docs-link'), isDevBuild);
|
|
612
|
-
|
|
613
614
|
// память клиента (localStorage) + принудительное значение поля с
|
|
614
615
|
// единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
|
|
615
616
|
// хосту вообще без формы и обязан прийти к тем же значениям
|
|
@@ -909,6 +910,19 @@ socketMethods[PS_ACCOLADES_DATA] = data => {
|
|
|
909
910
|
modules.stat?.applyAccolades?.();
|
|
910
911
|
};
|
|
911
912
|
|
|
913
|
+
// голосовая связь: кортеж разбирается один раз (lib/voiceMessages.js) —
|
|
914
|
+
// хост в P2P чужой, непрошедшее проверку не доходит ни до кого
|
|
915
|
+
socketMethods[PS_VOICE_DATA] = data => {
|
|
916
|
+
const message = parseVoiceMessage(data);
|
|
917
|
+
|
|
918
|
+
if (!message) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
voice.store.apply(message);
|
|
923
|
+
voiceClient?.handleFloorMessage(message);
|
|
924
|
+
};
|
|
925
|
+
|
|
912
926
|
// chat data
|
|
913
927
|
socketMethods[PS_CHAT_DATA] = data => {
|
|
914
928
|
modules.chat.add(data);
|
|
@@ -952,7 +966,7 @@ socketMethods[PS_GAME_INFORM_DATA] = data => {
|
|
|
952
966
|
|
|
953
967
|
// проигрывает shimmer-волну по логотипу в начале раунда; логотип живёт вне
|
|
954
968
|
// PanelView.containerId, поэтому панель им не управляет
|
|
955
|
-
// (см. docs
|
|
969
|
+
// (см. VIMP docs: client-api.md)
|
|
956
970
|
const playLogoRoundStart = () => {
|
|
957
971
|
const logo = document.getElementById('logo');
|
|
958
972
|
|
|
@@ -1214,6 +1228,20 @@ function runModules(data) {
|
|
|
1214
1228
|
|
|
1215
1229
|
modules.vote = new VoteCtrl(voteModel, voteView);
|
|
1216
1230
|
|
|
1231
|
+
//==========================================//
|
|
1232
|
+
// Voice Module (голосовая связь)
|
|
1233
|
+
//==========================================//
|
|
1234
|
+
|
|
1235
|
+
if (voiceConfig?.enabled) {
|
|
1236
|
+
const voiceModel = new VoiceModel({ texts: voiceConfig.texts });
|
|
1237
|
+
const voiceView = new VoiceView(voiceModel, gameContainer);
|
|
1238
|
+
|
|
1239
|
+
modules.voice = new VoiceCtrl(voiceModel, voiceView);
|
|
1240
|
+
modules.controls.setPushToTalk(voiceConfig.hotkey);
|
|
1241
|
+
controlsModel.publisher.on('ptt', held => voiceClient?.setPttHeld(held));
|
|
1242
|
+
startVoiceClient();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1217
1245
|
//==========================================//
|
|
1218
1246
|
// Подписка на события
|
|
1219
1247
|
//==========================================//
|
|
@@ -1234,6 +1262,8 @@ function runModules(data) {
|
|
|
1234
1262
|
'keyUp',
|
|
1235
1263
|
modules.controls.remove.bind(modules.controls),
|
|
1236
1264
|
);
|
|
1265
|
+
// потеря фокуса окна отпускает связь: keyup снаружи не придёт
|
|
1266
|
+
inputListener.publisher.on('blur', () => modules.controls?.releasePtt());
|
|
1237
1267
|
inputListener.publisher.on(
|
|
1238
1268
|
'mouseAction',
|
|
1239
1269
|
modules.controls.resetCursorHideTimer.bind(modules.controls),
|
|
@@ -1337,6 +1367,58 @@ function sending(name, data, reliable = true) {
|
|
|
1337
1367
|
supervisor?.send(JSON.stringify([name, data]), reliable);
|
|
1338
1368
|
}
|
|
1339
1369
|
|
|
1370
|
+
// подсистема голоса — отдельный чанк: игре без связи и standalone он не нужен
|
|
1371
|
+
function startVoiceClient() {
|
|
1372
|
+
const session = ++voiceSession;
|
|
1373
|
+
|
|
1374
|
+
import('./voice/VoiceClient.js')
|
|
1375
|
+
.then(async ({ default: VoiceClient }) => {
|
|
1376
|
+
const client = new VoiceClient({
|
|
1377
|
+
config: voiceConfig,
|
|
1378
|
+
sendFloor: payload => sending(PC_VOICE_FLOOR, payload),
|
|
1379
|
+
sendAudio: bytes => supervisor?.sendVoice(bytes),
|
|
1380
|
+
soundManager,
|
|
1381
|
+
localPlayer,
|
|
1382
|
+
hud: modules.voice,
|
|
1383
|
+
voiceStore: voice.store,
|
|
1384
|
+
// стиль связи — хуки игры: звуки сигналов и окраска речи; старый
|
|
1385
|
+
// плагин их не имеет — нейтральные тоны, чистый голос
|
|
1386
|
+
voiceSounds: clientPlugin.hooks.voiceSounds,
|
|
1387
|
+
voiceFilter: clientPlugin.hooks.voiceFilter,
|
|
1388
|
+
diagnostics,
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
try {
|
|
1392
|
+
await client.init();
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
// недособранный клиент освобождает то, что успел создать; сбой
|
|
1395
|
+
// уборки не подменяет первопричину
|
|
1396
|
+
try {
|
|
1397
|
+
client.destroy();
|
|
1398
|
+
} catch {
|
|
1399
|
+
// клиент собран не до конца
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
throw error;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// матч закрылся, пока грузился чанк: клиент не нужен
|
|
1406
|
+
if (session !== voiceSession) {
|
|
1407
|
+
client.destroy();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
voiceClient = client;
|
|
1412
|
+
})
|
|
1413
|
+
.catch(error => {
|
|
1414
|
+
diagnostics.capture(error, { source: 'voice' });
|
|
1415
|
+
|
|
1416
|
+
if (session === voiceSession) {
|
|
1417
|
+
modules.voice?.showOwn('error', { textKey: 'unavailable' });
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1340
1422
|
// отправка сообщения чата хосту (точка перехвата команд, адресованных
|
|
1341
1423
|
// мастеру, а не хосту)
|
|
1342
1424
|
function handleChatSend(message) {
|
|
@@ -1397,6 +1479,8 @@ function handleVisibilityChange() {
|
|
|
1397
1479
|
// если вкладка неактивна, выключение звука
|
|
1398
1480
|
if (document.visibilityState === 'hidden') {
|
|
1399
1481
|
hiddenAt = performance.now();
|
|
1482
|
+
// скрытая вкладка не получит keyup
|
|
1483
|
+
modules.controls?.releasePtt();
|
|
1400
1484
|
soundManager.mute();
|
|
1401
1485
|
// иначе включение звука при возвращении (кроме паузы переподключения и
|
|
1402
1486
|
// смены хоста — его включит возобновление сессии)
|
|
@@ -1567,10 +1651,8 @@ function handleMessage(data) {
|
|
|
1567
1651
|
|
|
1568
1652
|
// терминальное закрытие сессии (супервизор: возвращаться некуда — кик,
|
|
1569
1653
|
// политика, уход хоста, окно переподключения истекло). Останавливаем
|
|
1570
|
-
// рендер, показываем заглушку и возвращаемся в лобби
|
|
1571
|
-
|
|
1572
|
-
// транспорты эмитят close без него
|
|
1573
|
-
function handleDisconnect(closeCode) {
|
|
1654
|
+
// рендер, показываем заглушку и возвращаемся в лобби перезагрузкой
|
|
1655
|
+
function handleDisconnect() {
|
|
1574
1656
|
showSessionOverlay(null);
|
|
1575
1657
|
|
|
1576
1658
|
// app.stop() здесь не зовём: при sharedTicker это Ticker.shared.stop()
|
|
@@ -1594,6 +1676,13 @@ function handleDisconnect(closeCode) {
|
|
|
1594
1676
|
lastMapData = null;
|
|
1595
1677
|
|
|
1596
1678
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
1679
|
+
// строго до soundManager.destroy(): Howler.unload() закрывает контекст, на
|
|
1680
|
+
// котором сидят захват и воспроизведение связи
|
|
1681
|
+
voiceSession += 1;
|
|
1682
|
+
voiceClient?.destroy();
|
|
1683
|
+
voiceClient = null;
|
|
1684
|
+
voice.store.clear();
|
|
1685
|
+
modules.voice?.destroy();
|
|
1597
1686
|
soundManager.destroy();
|
|
1598
1687
|
modules.controls?.disableKeys();
|
|
1599
1688
|
|
|
@@ -1617,31 +1706,18 @@ function handleDisconnect(closeCode) {
|
|
|
1617
1706
|
inlineHost?.destroy();
|
|
1618
1707
|
inlineHost = null;
|
|
1619
1708
|
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
// не пишется вообще ничего, и побеждает серверный текст
|
|
1623
|
-
const policyInform = POLICY_CLOSE_INFORMS[closeCode];
|
|
1624
|
-
|
|
1625
|
-
// терминальную причину закрытия (кик, полная комната) не затираем
|
|
1709
|
+
// терминальную причину закрытия (кик, полная комната — TECH_INFORM перед
|
|
1710
|
+
// закрытием) не затираем
|
|
1626
1711
|
if (!terminalInformShown) {
|
|
1627
1712
|
socketMethods[PS_TECH_INFORM_DATA](
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
: 'The match is over — connection to the host is closed.'),
|
|
1713
|
+
isLobbyMode
|
|
1714
|
+
? 'Host left — the room is closed. Finding another room…'
|
|
1715
|
+
: 'The match is over — connection to the host is closed.',
|
|
1632
1716
|
);
|
|
1633
1717
|
}
|
|
1634
1718
|
|
|
1635
|
-
// в solo перезагружаться некуда: лобби нет, а матч поднимается с
|
|
1636
|
-
// В dedicated сервер жив — переподключение перезагрузкой уместно, но не
|
|
1637
|
-
// тогда, когда он сам нас и отбил по политике (network/policyClose.js)
|
|
1638
|
-
if (bootMode === 'solo' || !shouldReloadAfterClose(closeCode)) {
|
|
1639
|
-
return;
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1719
|
+
// в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля
|
|
1642
1720
|
if (!isLobbyMode) {
|
|
1643
|
-
setTimeout(() => reloadPage(), 3000);
|
|
1644
|
-
|
|
1645
1721
|
return;
|
|
1646
1722
|
}
|
|
1647
1723
|
|
|
@@ -1651,7 +1727,7 @@ function handleDisconnect(closeCode) {
|
|
|
1651
1727
|
// — на главную без перезагрузки (причина остаётся в #tech-informer, клик
|
|
1652
1728
|
// по нему открывает лобби); закрытие комнаты — в быструю игру той же игры
|
|
1653
1729
|
const exit = decideExitRoute({
|
|
1654
|
-
kicked: isKickClose(
|
|
1730
|
+
kicked: isKickClose(terminalTechKey) || roomCtx.startFailed,
|
|
1655
1731
|
gameId: activeGameManifest?.id,
|
|
1656
1732
|
});
|
|
1657
1733
|
|
|
@@ -1687,13 +1763,13 @@ function startSession(transport, reconnect = null) {
|
|
|
1687
1763
|
onTerminal: handleDisconnect,
|
|
1688
1764
|
onStateChange: handleSessionState,
|
|
1689
1765
|
onResumed: handleSessionResumed,
|
|
1766
|
+
onVoice: bytes => voiceClient?.handleAudio(bytes),
|
|
1690
1767
|
onResumeRejected: handleResumeRejected,
|
|
1691
1768
|
onHostLost: reportHostUnreachable,
|
|
1692
1769
|
onColdRestart: handleColdRestart,
|
|
1693
|
-
// кик (TECH_INFORM перед закрытием)
|
|
1770
|
+
// кик и полная комната (TECH_INFORM перед закрытием) не лечатся
|
|
1694
1771
|
// переподключением
|
|
1695
|
-
isTerminalClose:
|
|
1696
|
-
terminalInformShown || !shouldReloadAfterClose(closeCode),
|
|
1772
|
+
isTerminalClose: () => terminalInformShown,
|
|
1697
1773
|
reconnect,
|
|
1698
1774
|
timing: lobbyConfig.session,
|
|
1699
1775
|
});
|
|
@@ -1730,6 +1806,8 @@ function isSessionPaused() {
|
|
|
1730
1806
|
function handleSessionState(state, prev) {
|
|
1731
1807
|
if (state === SESSION_STATES.migrating) {
|
|
1732
1808
|
showSessionOverlay('Switching host…');
|
|
1809
|
+
voiceClient?.suspend();
|
|
1810
|
+
voice.store.clear();
|
|
1733
1811
|
modules.controls?.disableKeys();
|
|
1734
1812
|
soundManager.mute();
|
|
1735
1813
|
} else if (state === SESSION_STATES.reconnecting) {
|
|
@@ -1738,6 +1816,8 @@ function handleSessionState(state, prev) {
|
|
|
1738
1816
|
showSessionOverlay('Reconnecting…');
|
|
1739
1817
|
}
|
|
1740
1818
|
|
|
1819
|
+
voiceClient?.suspend();
|
|
1820
|
+
voice.store.clear();
|
|
1741
1821
|
modules.controls?.disableKeys();
|
|
1742
1822
|
soundManager.mute();
|
|
1743
1823
|
} else if (state === SESSION_STATES.inGame) {
|
|
@@ -2104,14 +2184,6 @@ async function connectSolo() {
|
|
|
2104
2184
|
transport.connect();
|
|
2105
2185
|
}
|
|
2106
2186
|
|
|
2107
|
-
// dedicated-режим: прямой WebSocket к Node-серверу игры
|
|
2108
|
-
function connectDedicated() {
|
|
2109
|
-
const transport = new WebSocketTransport(boot.wsUrl);
|
|
2110
|
-
|
|
2111
|
-
startSession(transport);
|
|
2112
|
-
transport.connect();
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
2187
|
// REST-запрос списка серверов у мастера (поиск игнорирует пагинацию)
|
|
2116
2188
|
async function fetchServers({ offset, limit, search }) {
|
|
2117
2189
|
const params = new URLSearchParams();
|
|
@@ -2200,7 +2272,7 @@ let roomFormFields = new Map();
|
|
|
2200
2272
|
let roomFormDescriptors = [];
|
|
2201
2273
|
|
|
2202
2274
|
// генерирует форму создания комнаты по явной схеме манифеста (roomForm,
|
|
2203
|
-
// docs
|
|
2275
|
+
// VIMP docs: plugin-api.md § "Form schema") — движок не выводит контролы
|
|
2204
2276
|
// из типа значения, схема плагина полностью описывает форму
|
|
2205
2277
|
function populateRoomForm(manifest) {
|
|
2206
2278
|
const container = document.getElementById(lobbyConfig.elems.fieldsId);
|
|
@@ -2588,7 +2660,7 @@ function initLobby() {
|
|
|
2588
2660
|
// валидация (pattern/required/min/max) — единственная граница
|
|
2589
2661
|
// room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
|
|
2590
2662
|
// (как в auth-форме) туда не сериализуются
|
|
2591
|
-
// (docs
|
|
2663
|
+
// (VIMP docs: plugin-api.md § "Form schema"); авторитетный клампинг
|
|
2592
2664
|
// всё равно в applyRoomOverrides.js (вызывается из host.worker.js при
|
|
2593
2665
|
// создании комнаты). Проверяем до активации игры: неверная форма не должна
|
|
2594
2666
|
// стоить загрузки плагина. arm() и рисует ошибки, и снимает фильтр «только
|
|
@@ -2696,7 +2768,7 @@ function leaveRoomWith(message) {
|
|
|
2696
2768
|
}
|
|
2697
2769
|
|
|
2698
2770
|
// точка 2 ветвления: лобби, экран профиля и сигналинг живут только в lobby;
|
|
2699
|
-
// solo
|
|
2771
|
+
// solo идёт сразу к транспорту
|
|
2700
2772
|
if (isLobbyMode) {
|
|
2701
2773
|
authApi = createAuthApi({ serviceUrl: authClientConfig.serviceUrl });
|
|
2702
2774
|
|
|
@@ -2839,7 +2911,7 @@ if (isLobbyMode) {
|
|
|
2839
2911
|
window.addEventListener('hashchange', () => route.handleHashChange());
|
|
2840
2912
|
|
|
2841
2913
|
signaling.connect();
|
|
2842
|
-
} else
|
|
2914
|
+
} else {
|
|
2843
2915
|
try {
|
|
2844
2916
|
await connectSolo();
|
|
2845
2917
|
} catch (e) {
|
|
@@ -2848,6 +2920,4 @@ if (isLobbyMode) {
|
|
|
2848
2920
|
);
|
|
2849
2921
|
throw e;
|
|
2850
2922
|
}
|
|
2851
|
-
} else {
|
|
2852
|
-
connectDedicated();
|
|
2853
2923
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// WebRTC-answerer браузерного хоста (Фаза 2 Этапа 4). Зеркало WebRtcManager:
|
|
2
|
-
// клиент — offerer (создаёт каналы meta/state и оффер), хост здесь — answerer.
|
|
2
|
+
// клиент — offerer (создаёт каналы meta/state/voice и оффер), хост здесь — answerer.
|
|
3
3
|
// Живёт в главном потоке (RTCPeerConnection в Worker'е недоступны); входящие
|
|
4
4
|
// кадры каналов уходят в Worker через HostController, исходящие Worker-кадры
|
|
5
5
|
// раскладываются по каналам meta (reliable) / state (unreliable) по флагу
|
|
6
6
|
// reliable из ядра (body_has_events). Медленным пирам state-кадры дропаются
|
|
7
|
-
// по bufferedAmount (бэкпрешер) — meta не дропается никогда.
|
|
7
|
+
// по bufferedAmount (бэкпрешер) — meta не дропается никогда. Канал voice в
|
|
8
|
+
// готовность не входит и в Worker не ходит: пакеты голоса идут ретранслятору
|
|
9
|
+
// HostController.
|
|
8
10
|
export default class HostConnectionManager {
|
|
9
11
|
/**
|
|
10
12
|
* @param {SignalingClient} signaling - сигнальный WS мастера.
|
|
@@ -26,7 +28,7 @@ export default class HostConnectionManager {
|
|
|
26
28
|
this._threshold = opts.backpressureThreshold ?? 262144; // 256 КБ
|
|
27
29
|
this._onPeersChange = opts.onPeersChange;
|
|
28
30
|
|
|
29
|
-
// clientId → { pc, meta, state, openCount, memberId }
|
|
31
|
+
// clientId → { pc, meta, state, voice, openCount, memberId }
|
|
30
32
|
this._peers = new Map();
|
|
31
33
|
|
|
32
34
|
signaling.publisher.on('webrtc_offer', 'onOffer', this);
|
|
@@ -59,6 +61,7 @@ export default class HostConnectionManager {
|
|
|
59
61
|
pc,
|
|
60
62
|
meta: null,
|
|
61
63
|
state: null,
|
|
64
|
+
voice: null,
|
|
62
65
|
openCount: 0,
|
|
63
66
|
memberId,
|
|
64
67
|
resume,
|
|
@@ -122,11 +125,21 @@ export default class HostConnectionManager {
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
_wireChannel(clientId, peer, channel) {
|
|
128
|
+
if (channel.label === 'voice') {
|
|
129
|
+
this._wireVoiceChannel(clientId, peer, channel);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// чужая метка — не наш канал: ни обработчиков, ни счёта готовности
|
|
134
|
+
if (channel.label !== 'meta' && channel.label !== 'state') {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
channel.binaryType = 'arraybuffer';
|
|
126
139
|
|
|
127
140
|
if (channel.label === 'meta') {
|
|
128
141
|
peer.meta = channel;
|
|
129
|
-
} else
|
|
142
|
+
} else {
|
|
130
143
|
peer.state = channel;
|
|
131
144
|
}
|
|
132
145
|
|
|
@@ -159,6 +172,34 @@ export default class HostConnectionManager {
|
|
|
159
172
|
};
|
|
160
173
|
}
|
|
161
174
|
|
|
175
|
+
_wireVoiceChannel(clientId, peer, channel) {
|
|
176
|
+
channel.binaryType = 'arraybuffer';
|
|
177
|
+
peer.voice = channel;
|
|
178
|
+
|
|
179
|
+
// голос — только ретранслятору главного потока; в Worker он не уходит
|
|
180
|
+
// никогда
|
|
181
|
+
channel.onmessage = event =>
|
|
182
|
+
this._controller.voiceIngress(clientId, event.data);
|
|
183
|
+
|
|
184
|
+
channel.onopen = () => {
|
|
185
|
+
if (this._peers.get(clientId) !== peer) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this._controller.attachVoice(clientId, {
|
|
190
|
+
send: bytes => {
|
|
191
|
+
if (channel.readyState === 'open') {
|
|
192
|
+
channel.send(bytes);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
bufferedAmount: () => channel.bufferedAmount,
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// потеря одного канала голоса — не повод рвать матч
|
|
200
|
+
channel.onclose = () => this._controller.detachVoice(clientId);
|
|
201
|
+
}
|
|
202
|
+
|
|
162
203
|
// раскладывает исходящий Worker-кадр по каналам meta/state
|
|
163
204
|
_deliver(peer, payload, reliable) {
|
|
164
205
|
// JSON-протокол и событийные кадры — надёжно по meta; позиционные кадры
|
|
@@ -189,7 +230,7 @@ export default class HostConnectionManager {
|
|
|
189
230
|
this._controller.disconnect(clientId); // Worker: removeUser
|
|
190
231
|
|
|
191
232
|
// снять обработчики каналов — замыкания не должны удерживать peer
|
|
192
|
-
for (const channel of [peer.meta, peer.state]) {
|
|
233
|
+
for (const channel of [peer.meta, peer.state, peer.voice]) {
|
|
193
234
|
if (channel) {
|
|
194
235
|
channel.onopen = null;
|
|
195
236
|
channel.onmessage = null;
|