vimp-engine 0.38.3 → 0.39.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 +1 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- 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/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +117 -0
- 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 +24 -0
- package/src/client/network/WebRtcManager.js +45 -3
- package/src/client/style.css +146 -3
- 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/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +12 -0
- 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/index.js +4 -0
- package/src/host/HostGame.js +58 -0
- package/src/host/PortMachine.js +19 -3
- package/src/host/host.worker.js +7 -0
- package/src/host/meta/SocketManager.js +6 -0
- 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/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
package/src/client/main.js
CHANGED
|
@@ -26,6 +26,9 @@ 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,
|
|
@@ -45,6 +48,8 @@ import {
|
|
|
45
48
|
import { createContextTracker } from './lib/contextTracker.js';
|
|
46
49
|
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
47
50
|
import { createAccolades } from './lib/accolades.js';
|
|
51
|
+
import { createVoiceState } from './lib/voiceState.js';
|
|
52
|
+
import { parseVoiceMessage } from '../lib/voiceMessages.js';
|
|
48
53
|
import { createDiagnostics } from './lib/diagnostics.js';
|
|
49
54
|
import { fetchRetrying, isNetworkFailure } from './lib/retryOnNetworkError.js';
|
|
50
55
|
import { dispatchSocketMessage } from './lib/socketDispatch.js';
|
|
@@ -313,6 +318,7 @@ const PS_CHAT_DATA = wsports.server.CHAT_DATA;
|
|
|
313
318
|
const PS_VOTE_DATA = wsports.server.VOTE_DATA;
|
|
314
319
|
const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
|
|
315
320
|
const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
|
|
321
|
+
const PS_VOICE_DATA = wsports.server.VOICE_DATA;
|
|
316
322
|
const PS_SESSION_DATA = wsports.server.SESSION_DATA;
|
|
317
323
|
const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
|
|
318
324
|
|
|
@@ -325,6 +331,7 @@ const PC_MAP_READY = wsports.client.MAP_READY;
|
|
|
325
331
|
const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
|
|
326
332
|
const PC_KEYS_DATA = wsports.client.KEYS_DATA;
|
|
327
333
|
const PC_CHAT_DATA = wsports.client.CHAT_DATA;
|
|
334
|
+
const PC_VOICE_FLOOR = wsports.client.VOICE_FLOOR;
|
|
328
335
|
const PC_VOTE_DATA = wsports.client.VOTE_DATA;
|
|
329
336
|
const PC_PONG = wsports.client.PONG;
|
|
330
337
|
const PC_LEAVE = wsports.client.LEAVE;
|
|
@@ -433,6 +440,15 @@ const localPlayer = createLocalPlayer(() => clientCore);
|
|
|
433
440
|
// сервис пула зависимостей: «какое место у этой сущности в глобальном
|
|
434
441
|
// топе?». Места считает хост, part рисует за них знак (см. lib/accolades.js)
|
|
435
442
|
const accolades = createAccolades();
|
|
443
|
+
// голосовая связь: кто в эфире (сервис партов `voice`) — хранилище пишет
|
|
444
|
+
// движок по VOICE_DATA, парты только читают (lib/voiceState.js)
|
|
445
|
+
const voice = createVoiceState();
|
|
446
|
+
// подсистема голоса грузится лениво — только у игры с включённой связью
|
|
447
|
+
let voiceClient = null;
|
|
448
|
+
let voiceConfig = null;
|
|
449
|
+
// номер запуска подсистемы: чанк, догрузившийся после закрытия матча,
|
|
450
|
+
// уничтожает свой клиент сам
|
|
451
|
+
let voiceSession = 0;
|
|
436
452
|
let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
|
|
437
453
|
|
|
438
454
|
// обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
|
|
@@ -451,6 +467,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
451
467
|
return;
|
|
452
468
|
}
|
|
453
469
|
|
|
470
|
+
voiceConfig = data.voice ?? null;
|
|
471
|
+
voice.store.setEnabled(voiceConfig?.enabled === true);
|
|
472
|
+
|
|
454
473
|
gameSets = data.parts.gameSets;
|
|
455
474
|
entitiesOnCanvas = data.parts.entitiesOnCanvas;
|
|
456
475
|
|
|
@@ -541,6 +560,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
541
560
|
// места в глобальном топе: знак за место рисует part игры, движок
|
|
542
561
|
// раздаёт только числа (см. lib/accolades.js)
|
|
543
562
|
accolades,
|
|
563
|
+
// индикаторы эфира связи для партов игры: кто сейчас говорит
|
|
564
|
+
// (только чтение, см. lib/voiceState.js)
|
|
565
|
+
voice: voice.service,
|
|
544
566
|
// база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
|
|
545
567
|
// картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
|
|
546
568
|
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
@@ -909,6 +931,19 @@ socketMethods[PS_ACCOLADES_DATA] = data => {
|
|
|
909
931
|
modules.stat?.applyAccolades?.();
|
|
910
932
|
};
|
|
911
933
|
|
|
934
|
+
// голосовая связь: кортеж разбирается один раз (lib/voiceMessages.js) —
|
|
935
|
+
// хост в P2P чужой, непрошедшее проверку не доходит ни до кого
|
|
936
|
+
socketMethods[PS_VOICE_DATA] = data => {
|
|
937
|
+
const message = parseVoiceMessage(data);
|
|
938
|
+
|
|
939
|
+
if (!message) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
voice.store.apply(message);
|
|
944
|
+
voiceClient?.handleFloorMessage(message);
|
|
945
|
+
};
|
|
946
|
+
|
|
912
947
|
// chat data
|
|
913
948
|
socketMethods[PS_CHAT_DATA] = data => {
|
|
914
949
|
modules.chat.add(data);
|
|
@@ -1214,6 +1249,20 @@ function runModules(data) {
|
|
|
1214
1249
|
|
|
1215
1250
|
modules.vote = new VoteCtrl(voteModel, voteView);
|
|
1216
1251
|
|
|
1252
|
+
//==========================================//
|
|
1253
|
+
// Voice Module (голосовая связь)
|
|
1254
|
+
//==========================================//
|
|
1255
|
+
|
|
1256
|
+
if (voiceConfig?.enabled) {
|
|
1257
|
+
const voiceModel = new VoiceModel({ texts: voiceConfig.texts });
|
|
1258
|
+
const voiceView = new VoiceView(voiceModel, gameContainer);
|
|
1259
|
+
|
|
1260
|
+
modules.voice = new VoiceCtrl(voiceModel, voiceView);
|
|
1261
|
+
modules.controls.setPushToTalk(voiceConfig.hotkey);
|
|
1262
|
+
controlsModel.publisher.on('ptt', held => voiceClient?.setPttHeld(held));
|
|
1263
|
+
startVoiceClient();
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1217
1266
|
//==========================================//
|
|
1218
1267
|
// Подписка на события
|
|
1219
1268
|
//==========================================//
|
|
@@ -1234,6 +1283,8 @@ function runModules(data) {
|
|
|
1234
1283
|
'keyUp',
|
|
1235
1284
|
modules.controls.remove.bind(modules.controls),
|
|
1236
1285
|
);
|
|
1286
|
+
// потеря фокуса окна отпускает связь: keyup снаружи не придёт
|
|
1287
|
+
inputListener.publisher.on('blur', () => modules.controls?.releasePtt());
|
|
1237
1288
|
inputListener.publisher.on(
|
|
1238
1289
|
'mouseAction',
|
|
1239
1290
|
modules.controls.resetCursorHideTimer.bind(modules.controls),
|
|
@@ -1337,6 +1388,58 @@ function sending(name, data, reliable = true) {
|
|
|
1337
1388
|
supervisor?.send(JSON.stringify([name, data]), reliable);
|
|
1338
1389
|
}
|
|
1339
1390
|
|
|
1391
|
+
// подсистема голоса — отдельный чанк: игре без связи и standalone он не нужен
|
|
1392
|
+
function startVoiceClient() {
|
|
1393
|
+
const session = ++voiceSession;
|
|
1394
|
+
|
|
1395
|
+
import('./voice/VoiceClient.js')
|
|
1396
|
+
.then(async ({ default: VoiceClient }) => {
|
|
1397
|
+
const client = new VoiceClient({
|
|
1398
|
+
config: voiceConfig,
|
|
1399
|
+
sendFloor: payload => sending(PC_VOICE_FLOOR, payload),
|
|
1400
|
+
sendAudio: bytes => supervisor?.sendVoice(bytes),
|
|
1401
|
+
soundManager,
|
|
1402
|
+
localPlayer,
|
|
1403
|
+
hud: modules.voice,
|
|
1404
|
+
voiceStore: voice.store,
|
|
1405
|
+
// стиль связи — хуки игры: звуки сигналов и окраска речи; старый
|
|
1406
|
+
// плагин их не имеет — нейтральные тоны, чистый голос
|
|
1407
|
+
voiceSounds: clientPlugin.hooks.voiceSounds,
|
|
1408
|
+
voiceFilter: clientPlugin.hooks.voiceFilter,
|
|
1409
|
+
diagnostics,
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
try {
|
|
1413
|
+
await client.init();
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
// недособранный клиент освобождает то, что успел создать; сбой
|
|
1416
|
+
// уборки не подменяет первопричину
|
|
1417
|
+
try {
|
|
1418
|
+
client.destroy();
|
|
1419
|
+
} catch {
|
|
1420
|
+
// клиент собран не до конца
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
throw error;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// матч закрылся, пока грузился чанк: клиент не нужен
|
|
1427
|
+
if (session !== voiceSession) {
|
|
1428
|
+
client.destroy();
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
voiceClient = client;
|
|
1433
|
+
})
|
|
1434
|
+
.catch(error => {
|
|
1435
|
+
diagnostics.capture(error, { source: 'voice' });
|
|
1436
|
+
|
|
1437
|
+
if (session === voiceSession) {
|
|
1438
|
+
modules.voice?.showOwn('error', { textKey: 'unavailable' });
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1340
1443
|
// отправка сообщения чата хосту (точка перехвата команд, адресованных
|
|
1341
1444
|
// мастеру, а не хосту)
|
|
1342
1445
|
function handleChatSend(message) {
|
|
@@ -1397,6 +1500,8 @@ function handleVisibilityChange() {
|
|
|
1397
1500
|
// если вкладка неактивна, выключение звука
|
|
1398
1501
|
if (document.visibilityState === 'hidden') {
|
|
1399
1502
|
hiddenAt = performance.now();
|
|
1503
|
+
// скрытая вкладка не получит keyup
|
|
1504
|
+
modules.controls?.releasePtt();
|
|
1400
1505
|
soundManager.mute();
|
|
1401
1506
|
// иначе включение звука при возвращении (кроме паузы переподключения и
|
|
1402
1507
|
// смены хоста — его включит возобновление сессии)
|
|
@@ -1594,6 +1699,13 @@ function handleDisconnect(closeCode) {
|
|
|
1594
1699
|
lastMapData = null;
|
|
1595
1700
|
|
|
1596
1701
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
1702
|
+
// строго до soundManager.destroy(): Howler.unload() закрывает контекст, на
|
|
1703
|
+
// котором сидят захват и воспроизведение связи
|
|
1704
|
+
voiceSession += 1;
|
|
1705
|
+
voiceClient?.destroy();
|
|
1706
|
+
voiceClient = null;
|
|
1707
|
+
voice.store.clear();
|
|
1708
|
+
modules.voice?.destroy();
|
|
1597
1709
|
soundManager.destroy();
|
|
1598
1710
|
modules.controls?.disableKeys();
|
|
1599
1711
|
|
|
@@ -1687,6 +1799,7 @@ function startSession(transport, reconnect = null) {
|
|
|
1687
1799
|
onTerminal: handleDisconnect,
|
|
1688
1800
|
onStateChange: handleSessionState,
|
|
1689
1801
|
onResumed: handleSessionResumed,
|
|
1802
|
+
onVoice: bytes => voiceClient?.handleAudio(bytes),
|
|
1690
1803
|
onResumeRejected: handleResumeRejected,
|
|
1691
1804
|
onHostLost: reportHostUnreachable,
|
|
1692
1805
|
onColdRestart: handleColdRestart,
|
|
@@ -1730,6 +1843,8 @@ function isSessionPaused() {
|
|
|
1730
1843
|
function handleSessionState(state, prev) {
|
|
1731
1844
|
if (state === SESSION_STATES.migrating) {
|
|
1732
1845
|
showSessionOverlay('Switching host…');
|
|
1846
|
+
voiceClient?.suspend();
|
|
1847
|
+
voice.store.clear();
|
|
1733
1848
|
modules.controls?.disableKeys();
|
|
1734
1849
|
soundManager.mute();
|
|
1735
1850
|
} else if (state === SESSION_STATES.reconnecting) {
|
|
@@ -1738,6 +1853,8 @@ function handleSessionState(state, prev) {
|
|
|
1738
1853
|
showSessionOverlay('Reconnecting…');
|
|
1739
1854
|
}
|
|
1740
1855
|
|
|
1856
|
+
voiceClient?.suspend();
|
|
1857
|
+
voice.store.clear();
|
|
1741
1858
|
modules.controls?.disableKeys();
|
|
1742
1859
|
soundManager.mute();
|
|
1743
1860
|
} else if (state === SESSION_STATES.inGame) {
|
|
@@ -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;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import VoiceRelay from '../../lib/VoiceRelay.js';
|
|
2
|
+
|
|
1
3
|
// Мост главного потока между Worker'ом хоста (авторитетная симуляция) и
|
|
2
4
|
// транспортами клиентов. Worker не имеет доступа к RTCPeerConnection — главный
|
|
3
5
|
// поток роутит пакеты: исходящие кадры Worker'а (to_client) → нужному клиенту,
|
|
@@ -9,6 +11,9 @@
|
|
|
9
11
|
// версию кода без разрыва P2P — старый Worker отдаёт handoff-состояние на
|
|
10
12
|
// границе раунда, новый поднимается с ним, клиенты переподключаются
|
|
11
13
|
// внутренними connect'ами (WebRTC-каналы живут здесь и не трогаются).
|
|
14
|
+
//
|
|
15
|
+
// Здесь же живёт ретранслятор голоса (VoiceRelay): пакеты голоса в Worker не
|
|
16
|
+
// уходят, маршруты ему присылает мета-модуль Voice сообщением voice_route.
|
|
12
17
|
|
|
13
18
|
// предохранитель от зависшего init нового Worker'а: не дождались ready —
|
|
14
19
|
// своп отменяется, комната продолжает жить на старом Worker'е
|
|
@@ -102,6 +107,8 @@ export default class HostController {
|
|
|
102
107
|
|
|
103
108
|
this._swap = null; // состояние эстафеты (Этап 5.2)
|
|
104
109
|
|
|
110
|
+
this._voiceRelay = new VoiceRelay();
|
|
111
|
+
|
|
105
112
|
// ожидание границы раунда плановой передачей (host-migration этап 8d)
|
|
106
113
|
this._roundBoundaryCallback = null;
|
|
107
114
|
|
|
@@ -202,8 +209,36 @@ export default class HostController {
|
|
|
202
209
|
this._worker.postMessage(msg);
|
|
203
210
|
}
|
|
204
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Выход голоса к соединению (WebRTC-канал гостя или loopback хоста-игрока).
|
|
214
|
+
* @param {string} socketId
|
|
215
|
+
* @param {{ send: Function, bufferedAmount?: Function }} sink
|
|
216
|
+
*/
|
|
217
|
+
attachVoice(socketId, sink) {
|
|
218
|
+
this._voiceRelay.attach(socketId, sink);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Отцепляет выход голоса (канал voice закрылся при живом соединении).
|
|
223
|
+
* @param {string} socketId
|
|
224
|
+
*/
|
|
225
|
+
detachVoice(socketId) {
|
|
226
|
+
this._voiceRelay.detach(socketId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Пакет голоса от соединения: Worker его не видит.
|
|
231
|
+
* @param {string} socketId
|
|
232
|
+
* @param {ArrayBuffer|Uint8Array} data
|
|
233
|
+
*/
|
|
234
|
+
voiceIngress(socketId, data) {
|
|
235
|
+
this._voiceRelay.ingress(socketId, data);
|
|
236
|
+
}
|
|
237
|
+
|
|
205
238
|
// отключает клиента
|
|
206
239
|
disconnect(socketId) {
|
|
240
|
+
// ретранслятор живёт в главном потоке: ждать паузу эстафеты ему нечего
|
|
241
|
+
this._voiceRelay.detach(socketId);
|
|
207
242
|
this._deliveries.delete(socketId);
|
|
208
243
|
|
|
209
244
|
const msg = { type: 'disconnect', socketId };
|
|
@@ -580,6 +615,7 @@ export default class HostController {
|
|
|
580
615
|
this._shutdownResolve?.();
|
|
581
616
|
|
|
582
617
|
this._rejectDebugRequests('host destroyed');
|
|
618
|
+
this._voiceRelay.clear();
|
|
583
619
|
|
|
584
620
|
if (this._swap) {
|
|
585
621
|
const { reject } = this._swap;
|
|
@@ -705,6 +741,9 @@ export default class HostController {
|
|
|
705
741
|
this._postCheckpointState(next);
|
|
706
742
|
}
|
|
707
743
|
|
|
744
|
+
// маршруты старого Worker'а мертвы (новый начинает без эфиров); выходы
|
|
745
|
+
// остаются — каналы WebRTC эстафету переживают
|
|
746
|
+
this._voiceRelay.clear();
|
|
708
747
|
this._worker.terminate();
|
|
709
748
|
this._worker = next;
|
|
710
749
|
this._worker.onmessage = e => this._onWorkerMessage(e.data);
|
|
@@ -761,6 +800,10 @@ export default class HostController {
|
|
|
761
800
|
this._reportWorkerMessage(msg, msg.kind);
|
|
762
801
|
break;
|
|
763
802
|
|
|
803
|
+
case 'voice_route':
|
|
804
|
+
this._voiceRelay.applyRoute(msg);
|
|
805
|
+
break;
|
|
806
|
+
|
|
764
807
|
case 'map_changed':
|
|
765
808
|
this._onMapChange?.(msg.mapName);
|
|
766
809
|
break;
|
|
@@ -32,6 +32,13 @@ export default class LoopbackTransport {
|
|
|
32
32
|
resume: this._resume,
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// голос хоста-игрока не идёт через Worker: ретранслятор живёт здесь же, в
|
|
36
|
+
// главном потоке, и отдаёт пакеты прямым вызовом. ?. — у InlineHostBridge
|
|
37
|
+
// (standalone) голоса нет (D4)
|
|
38
|
+
this._controller.attachVoice?.(this._socketId, {
|
|
39
|
+
send: bytes => this.publisher.emit('voice', bytes),
|
|
40
|
+
});
|
|
41
|
+
|
|
35
42
|
// подписчики появляются после connect() (супервизор берёт уже
|
|
36
43
|
// подключённый транспорт) — 'open' уходит следующей микрозадачей
|
|
37
44
|
if (this._resume) {
|
|
@@ -50,6 +57,13 @@ export default class LoopbackTransport {
|
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
// голосовой пакет хоста-игрока — ретранслятору, минуя Worker
|
|
61
|
+
sendVoice(bytes) {
|
|
62
|
+
if (!this._closed) {
|
|
63
|
+
this._controller.voiceIngress?.(this._socketId, bytes);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
close() {
|
|
54
68
|
this._emitClose();
|
|
55
69
|
}
|
|
@@ -54,6 +54,8 @@ export default class SessionSupervisor {
|
|
|
54
54
|
* транспорта (строка или ArrayBuffer).
|
|
55
55
|
* @param {Function} opts.onTerminal - (closeCode) сессия закрыта
|
|
56
56
|
* окончательно: путь прежнего handleDisconnect.
|
|
57
|
+
* @param {Function} [opts.onVoice] - (data) голосовой пакет текущего
|
|
58
|
+
* транспорта в состоянии inGame (бинарный, без разбора).
|
|
57
59
|
* @param {Function} [opts.onStateChange] - (state, prev).
|
|
58
60
|
* @param {Function} [opts.onResumed] - место возвращено (RESUME_RESULT ok).
|
|
59
61
|
* @param {Function} [opts.onResumeRejected] - (reason) хост отказал.
|
|
@@ -76,6 +78,7 @@ export default class SessionSupervisor {
|
|
|
76
78
|
constructor({
|
|
77
79
|
onMessage,
|
|
78
80
|
onTerminal,
|
|
81
|
+
onVoice = null,
|
|
79
82
|
onStateChange = () => {},
|
|
80
83
|
onResumed = () => {},
|
|
81
84
|
onResumeRejected = () => {},
|
|
@@ -88,6 +91,7 @@ export default class SessionSupervisor {
|
|
|
88
91
|
}) {
|
|
89
92
|
this._onMessage = onMessage;
|
|
90
93
|
this._onTerminal = onTerminal;
|
|
94
|
+
this._onVoice = onVoice;
|
|
91
95
|
this._onStateChange = onStateChange;
|
|
92
96
|
this._onResumed = onResumed;
|
|
93
97
|
this._onResumeRejected = onResumeRejected;
|
|
@@ -166,6 +170,17 @@ export default class SessionSupervisor {
|
|
|
166
170
|
this._transport?.send(data, reliable);
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Голосовой пакет текущему транспорту (только в inGame; транспорт без
|
|
175
|
+
* голоса — выброс).
|
|
176
|
+
* @param {Uint8Array} bytes
|
|
177
|
+
*/
|
|
178
|
+
sendVoice(bytes) {
|
|
179
|
+
if (this._state === S.inGame) {
|
|
180
|
+
this._transport?.sendVoice?.(bytes);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
/**
|
|
170
185
|
* Пользователь уходит сам (или сессию закрывает политика): закрытие
|
|
171
186
|
* транспорта терминально, переподключения не будет.
|
|
@@ -477,6 +492,7 @@ export default class SessionSupervisor {
|
|
|
477
492
|
|
|
478
493
|
const handlers = {
|
|
479
494
|
message: data => this._handleMessage(transport, data),
|
|
495
|
+
voice: data => this._handleVoice(transport, data),
|
|
480
496
|
close: closeCode => this._handleClose(transport, closeCode),
|
|
481
497
|
open: () => this._handleOpen(transport),
|
|
482
498
|
};
|
|
@@ -523,6 +539,14 @@ export default class SessionSupervisor {
|
|
|
523
539
|
this._onMessage(data);
|
|
524
540
|
}
|
|
525
541
|
|
|
542
|
+
// голос не обновляет метку живости: поток голоса не доказывает живость
|
|
543
|
+
// протокола хоста
|
|
544
|
+
_handleVoice(transport, data) {
|
|
545
|
+
if (transport === this._transport && this._state === S.inGame) {
|
|
546
|
+
this._onVoice?.(data);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
526
550
|
_handleOpen(transport) {
|
|
527
551
|
if (transport !== this._transport) {
|
|
528
552
|
return;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { VOICE_BACKPRESSURE_BYTES } from '../../config/voiceProtocol.js';
|
|
1
2
|
import Publisher from '../../lib/Publisher.js';
|
|
2
3
|
|
|
3
4
|
// Транспорт P2P-соединения клиента с браузерным хостом. Заменяет игровой
|
|
4
|
-
// WebSocket:
|
|
5
|
+
// WebSocket: три RTCDataChannel вместо одного сокета.
|
|
5
6
|
//
|
|
6
7
|
// - meta (reliable-ordered) — весь JSON-протокол [portId, payload] и
|
|
7
8
|
// бинарные кадры с одноразовыми событиями;
|
|
8
9
|
// - state (unreliable-unordered) — чисто позиционные бинарные кадры, их
|
|
9
|
-
// потерю компенсирует следующий
|
|
10
|
+
// потерю компенсирует следующий кадр;
|
|
11
|
+
// - voice (unreliable-unordered) — только голосовые пакеты,
|
|
12
|
+
// в готовность соединения не входит.
|
|
10
13
|
//
|
|
11
14
|
// Классификация кадров meta/state — на стороне хоста при упаковке. Клиент лишь
|
|
12
15
|
// принимает данные из обоих каналов и отдаёт их одним потоком (событие
|
|
@@ -103,6 +106,7 @@ export default class WebRtcManager {
|
|
|
103
106
|
this._pc = null;
|
|
104
107
|
this._meta = null;
|
|
105
108
|
this._state = null;
|
|
109
|
+
this._voice = null;
|
|
106
110
|
this._roomId = null;
|
|
107
111
|
// id вкладки — хост хранит его рядом с clientId пира
|
|
108
112
|
this._memberId = memberId ?? null;
|
|
@@ -164,8 +168,18 @@ export default class WebRtcManager {
|
|
|
164
168
|
});
|
|
165
169
|
this._state.binaryType = 'arraybuffer';
|
|
166
170
|
|
|
171
|
+
// voice — голос: ненадёжный неупорядоченный, как state,
|
|
172
|
+
// но в готовность соединения не входит — связь не обязательна для матча, а
|
|
173
|
+
// потеря пакета голоса лечится следующим
|
|
174
|
+
this._voice = pc.createDataChannel('voice', {
|
|
175
|
+
ordered: false,
|
|
176
|
+
maxRetransmits: 0,
|
|
177
|
+
});
|
|
178
|
+
this._voice.binaryType = 'arraybuffer';
|
|
179
|
+
|
|
167
180
|
this._wireChannel(this._meta);
|
|
168
181
|
this._wireChannel(this._state);
|
|
182
|
+
this._wireVoiceChannel(this._voice);
|
|
169
183
|
|
|
170
184
|
// канал standby открывает хост — только преемнику (этап 6)
|
|
171
185
|
pc.ondatachannel = event => {
|
|
@@ -319,6 +333,28 @@ export default class WebRtcManager {
|
|
|
319
333
|
}
|
|
320
334
|
}
|
|
321
335
|
|
|
336
|
+
// голосовой пакет хосту; закрытый или переполненный канал — выброс (живой
|
|
337
|
+
// голос важнее старого)
|
|
338
|
+
sendVoice(bytes) {
|
|
339
|
+
const channel = this._voice;
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
!channel ||
|
|
343
|
+
channel.readyState !== 'open' ||
|
|
344
|
+
channel.bufferedAmount > VOICE_BACKPRESSURE_BYTES
|
|
345
|
+
) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// канал мог закрыться между проверкой и отправкой — исключение не должно
|
|
350
|
+
// уйти в колбэк энкодера
|
|
351
|
+
try {
|
|
352
|
+
channel.send(bytes);
|
|
353
|
+
} catch {
|
|
354
|
+
// пакет потерян, как и при закрытом канале
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
322
358
|
close() {
|
|
323
359
|
this._emitClose();
|
|
324
360
|
}
|
|
@@ -420,7 +456,7 @@ export default class WebRtcManager {
|
|
|
420
456
|
|
|
421
457
|
// поздние события каналов закрытого менеджера не должны ничего эмитить
|
|
422
458
|
_dropChannelHandlers() {
|
|
423
|
-
for (const channel of [this._meta, this._state]) {
|
|
459
|
+
for (const channel of [this._meta, this._state, this._voice]) {
|
|
424
460
|
if (channel) {
|
|
425
461
|
channel.onopen = null;
|
|
426
462
|
channel.onmessage = null;
|
|
@@ -451,6 +487,12 @@ export default class WebRtcManager {
|
|
|
451
487
|
channel.onclose = () => this._emitClose();
|
|
452
488
|
}
|
|
453
489
|
|
|
490
|
+
// onopen/onclose не ставим: голос не трогает _openChannels и не закрывает
|
|
491
|
+
// менеджер
|
|
492
|
+
_wireVoiceChannel(channel) {
|
|
493
|
+
channel.onmessage = event => this.publisher.emit('voice', event.data);
|
|
494
|
+
}
|
|
495
|
+
|
|
454
496
|
// кода закрытия у data channel нет: 'close' эмитится без аргумента, как
|
|
455
497
|
// и раньше (код приходит только от WebSocket-транспорта dedicated)
|
|
456
498
|
_emitClose() {
|