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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/client/InputListener.js +3 -0
  3. package/src/client/SoundManager.js +13 -0
  4. package/src/client/components/controller/Controls.js +10 -0
  5. package/src/client/components/controller/Voice.js +31 -0
  6. package/src/client/components/model/Controls.js +64 -0
  7. package/src/client/components/model/Voice.js +96 -0
  8. package/src/client/components/model/Vote.js +2 -1
  9. package/src/client/components/view/Voice.js +172 -0
  10. package/src/client/lib/voiceState.js +78 -0
  11. package/src/client/main.js +117 -0
  12. package/src/client/network/HostConnectionManager.js +46 -5
  13. package/src/client/network/HostController.js +43 -0
  14. package/src/client/network/LoopbackTransport.js +14 -0
  15. package/src/client/network/SessionSupervisor.js +24 -0
  16. package/src/client/network/WebRtcManager.js +45 -3
  17. package/src/client/style.css +146 -3
  18. package/src/client/voice/JitterBuffer.js +137 -0
  19. package/src/client/voice/MicCapture.js +307 -0
  20. package/src/client/voice/PttMachine.js +369 -0
  21. package/src/client/voice/VoiceClient.js +336 -0
  22. package/src/client/voice/VoiceCues.js +295 -0
  23. package/src/client/voice/VoiceEncoder.js +174 -0
  24. package/src/client/voice/VoicePlayback.js +558 -0
  25. package/src/client/voice/audioData.js +35 -0
  26. package/src/client/voice/captureProcessor.worklet.js +136 -0
  27. package/src/client/voice/opusConfig.js +49 -0
  28. package/src/client/voice/voiceOutput.js +72 -0
  29. package/src/client/voice/voiceSupport.js +71 -0
  30. package/src/client/voice/voiceTones.js +90 -0
  31. package/src/config/clientServices.js +2 -0
  32. package/src/config/voiceDefaults.js +44 -0
  33. package/src/config/voiceProtocol.js +80 -0
  34. package/src/config/voteKeys.js +4 -0
  35. package/src/config/wsports.js +12 -0
  36. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  37. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  38. package/src/devtools/contract/rules/index.js +4 -0
  39. package/src/host/HostGame.js +58 -0
  40. package/src/host/PortMachine.js +19 -3
  41. package/src/host/host.worker.js +7 -0
  42. package/src/host/meta/SocketManager.js +6 -0
  43. package/src/host/meta/modules/voice/Voice.js +430 -0
  44. package/src/host/meta/modules/voice/channels.js +58 -0
  45. package/src/host/meta/modules/voice/index.js +1 -0
  46. package/src/lib/VoiceRelay.js +200 -0
  47. package/src/lib/buildClientConfig.js +7 -0
  48. package/src/lib/capabilities.js +4 -0
  49. package/src/lib/createHostRuntime.js +8 -0
  50. package/src/lib/gameConfigView.js +11 -1
  51. package/src/lib/voiceConfig.js +136 -0
  52. package/src/lib/voiceMessages.js +172 -0
  53. package/src/lib/voicePacket.js +142 -0
@@ -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 if (channel.label === 'state') {
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: два RTCDataChannel вместо одного сокета.
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() {