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.
Files changed (88) hide show
  1. package/README.md +2 -2
  2. package/bin/vimp-sim.js +2 -2
  3. package/core/Cargo.toml +2 -2
  4. package/package.json +3 -4
  5. package/src/client/InputListener.js +3 -0
  6. package/src/client/SoundManager.js +13 -0
  7. package/src/client/boot.js +11 -87
  8. package/src/client/components/controller/Controls.js +10 -0
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/Controls.js +64 -0
  11. package/src/client/components/model/RoomMenu.js +1 -1
  12. package/src/client/components/model/Voice.js +96 -0
  13. package/src/client/components/model/Vote.js +2 -1
  14. package/src/client/components/view/Auth.js +3 -3
  15. package/src/client/components/view/Voice.js +172 -0
  16. package/src/client/lib/formBuilder.js +7 -6
  17. package/src/client/lib/hostVoteCommand.js +2 -2
  18. package/src/client/lib/pickActiveGame.js +3 -12
  19. package/src/client/lib/retryOnNetworkError.js +0 -9
  20. package/src/client/lib/voiceState.js +78 -0
  21. package/src/client/main.js +160 -90
  22. package/src/client/network/HostConnectionManager.js +46 -5
  23. package/src/client/network/HostController.js +43 -0
  24. package/src/client/network/LoopbackTransport.js +14 -0
  25. package/src/client/network/SessionSupervisor.js +38 -14
  26. package/src/client/network/WebRtcManager.js +46 -5
  27. package/src/client/network/policyClose.js +10 -77
  28. package/src/client/style.css +150 -9
  29. package/src/client/views/gameShell.js +2 -4
  30. package/src/client/views/includes/auth.pug +1 -4
  31. package/src/client/views/includes/lobby.pug +3 -6
  32. package/src/client/voice/JitterBuffer.js +137 -0
  33. package/src/client/voice/MicCapture.js +307 -0
  34. package/src/client/voice/PttMachine.js +369 -0
  35. package/src/client/voice/VoiceClient.js +336 -0
  36. package/src/client/voice/VoiceCues.js +295 -0
  37. package/src/client/voice/VoiceEncoder.js +174 -0
  38. package/src/client/voice/VoicePlayback.js +558 -0
  39. package/src/client/voice/audioData.js +35 -0
  40. package/src/client/voice/captureProcessor.worklet.js +136 -0
  41. package/src/client/voice/opusConfig.js +49 -0
  42. package/src/client/voice/voiceOutput.js +72 -0
  43. package/src/client/voice/voiceSupport.js +71 -0
  44. package/src/client/voice/voiceTones.js +90 -0
  45. package/src/config/clientServices.js +2 -0
  46. package/src/config/closeCodes.js +7 -9
  47. package/src/config/gameCodes.js +1 -1
  48. package/src/config/hostDefaults.js +2 -2
  49. package/src/config/lobby.js +3 -4
  50. package/src/config/voiceDefaults.js +44 -0
  51. package/src/config/voiceProtocol.js +80 -0
  52. package/src/config/voteKeys.js +4 -0
  53. package/src/config/wsports.js +13 -1
  54. package/src/devtools/ScenarioRunner.js +1 -1
  55. package/src/devtools/builtinScenario.js +2 -2
  56. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  57. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  58. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  59. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  60. package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
  61. package/src/devtools/contract/rules/index.js +4 -0
  62. package/src/devtools/invariants.js +1 -1
  63. package/src/devtools/pluginLoader.js +2 -2
  64. package/src/host/HostGame.js +62 -4
  65. package/src/host/PortMachine.js +23 -24
  66. package/src/host/commandFailure.js +2 -2
  67. package/src/host/host.worker.js +7 -0
  68. package/src/host/identity.js +3 -3
  69. package/src/host/meta/SocketManager.js +6 -0
  70. package/src/host/meta/core/CommandProcessor.js +2 -2
  71. package/src/host/meta/modules/Accolades.js +1 -1
  72. package/src/host/meta/modules/voice/Voice.js +430 -0
  73. package/src/host/meta/modules/voice/channels.js +58 -0
  74. package/src/host/meta/modules/voice/index.js +1 -0
  75. package/src/lib/VoiceRelay.js +200 -0
  76. package/src/lib/buildClientConfig.js +7 -0
  77. package/src/lib/capabilities.js +4 -0
  78. package/src/lib/createHostRuntime.js +8 -0
  79. package/src/lib/gameConfigView.js +11 -1
  80. package/src/lib/loadGamePackage.js +7 -9
  81. package/src/lib/offlinePlayerData.js +1 -1
  82. package/src/lib/validators.js +2 -2
  83. package/src/lib/voiceConfig.js +136 -0
  84. package/src/lib/voiceMessages.js +172 -0
  85. package/src/lib/voicePacket.js +142 -0
  86. package/src/standalone/index.js +3 -2
  87. package/src/client/lib/footerLink.js +0 -30
  88. package/src/client/network/WebSocketTransport.js +0 -78
@@ -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
  }
@@ -52,8 +52,10 @@ export default class SessionSupervisor {
52
52
  * @param {Object} opts
53
53
  * @param {Function} opts.onMessage - входящее сообщение текущего
54
54
  * транспорта (строка или ArrayBuffer).
55
- * @param {Function} opts.onTerminal - (closeCode) сессия закрыта
56
- * окончательно: путь прежнего handleDisconnect.
55
+ * @param {Function} opts.onTerminal - сессия закрыта окончательно
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) хост отказал.
@@ -61,10 +63,10 @@ export default class SessionSupervisor {
61
63
  * матче (до переподключения): свидетельство мастеру (host_unreachable).
62
64
  * @param {Function} [opts.onColdRestart] - новый хост поднял матч заново
63
65
  * (host_changed cold): возобновлять нечего, только чистый вход.
64
- * @param {Function} [opts.isTerminalClose] - (closeCode) закрытие по
65
- * политике или кику: возвращаться некуда.
66
+ * @param {Function} [opts.isTerminalClose] - () закрытие после
67
+ * терминальной причины (кик, полная комната): возвращаться некуда.
66
68
  * @param {Object|null} [opts.reconnect] - null — переподключения нет
67
- * (solo, dedicated, вкладка-хост на loopback). Иначе:
69
+ * (solo, вкладка-хост на loopback). Иначе:
68
70
  * { createTransport() → новый транспорт с resume (уже connect'нутый),
69
71
  * getToken() → identity-токен для RESUME_REQUEST }.
70
72
  * @param {Object} [opts.timing] - { reconnectWindowMs, reconnectBaseDelayMs,
@@ -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,7 +492,8 @@ export default class SessionSupervisor {
477
492
 
478
493
  const handlers = {
479
494
  message: data => this._handleMessage(transport, data),
480
- close: closeCode => this._handleClose(transport, closeCode),
495
+ voice: data => this._handleVoice(transport, data),
496
+ close: () => this._handleClose(transport),
481
497
  open: () => this._handleOpen(transport),
482
498
  };
483
499
 
@@ -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;
@@ -549,7 +573,7 @@ export default class SessionSupervisor {
549
573
  }
550
574
  }
551
575
 
552
- _handleClose(transport, closeCode) {
576
+ _handleClose(transport) {
553
577
  if (transport !== this._transport) {
554
578
  return;
555
579
  }
@@ -566,7 +590,7 @@ export default class SessionSupervisor {
566
590
  // транспорт, выданный resumeWith без фабрики попыток: повторять нечем
567
591
  if (this._reconnect === null) {
568
592
  if (!this._failRoleResume('closed')) {
569
- this._terminate(closeCode);
593
+ this._terminate();
570
594
  }
571
595
 
572
596
  return;
@@ -576,23 +600,23 @@ export default class SessionSupervisor {
576
600
  return;
577
601
  }
578
602
 
579
- if (this._canResume(closeCode)) {
603
+ if (this._canResume()) {
580
604
  this._startReconnect();
581
605
  return;
582
606
  }
583
607
 
584
- this._terminate(closeCode);
608
+ this._terminate();
585
609
  }
586
610
 
587
611
  // нетерминальным бывает только обрыв посреди матча, у которого есть место
588
612
  // для возврата и кому возвращаться
589
- _canResume(closeCode) {
613
+ _canResume() {
590
614
  return (
591
615
  this._reconnect !== null &&
592
616
  !this._userLeft &&
593
617
  this._state === S.inGame &&
594
618
  this._session !== null &&
595
- !this._isTerminalClose(closeCode)
619
+ !this._isTerminalClose()
596
620
  );
597
621
  }
598
622
 
@@ -704,7 +728,7 @@ export default class SessionSupervisor {
704
728
 
705
729
  // ***** завершение ***** //
706
730
 
707
- _terminate(closeCode) {
731
+ _terminate() {
708
732
  if (this._state === S.closed) {
709
733
  return;
710
734
  }
@@ -714,7 +738,7 @@ export default class SessionSupervisor {
714
738
  this._clearMigrationTimer();
715
739
  this._onRoleResumeFailed = null;
716
740
  this._setState(S.closed);
717
- this._onTerminal(closeCode);
741
+ this._onTerminal();
718
742
  }
719
743
 
720
744
  _setState(state) {
@@ -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,8 +487,13 @@ export default class WebRtcManager {
451
487
  channel.onclose = () => this._emitClose();
452
488
  }
453
489
 
454
- // кода закрытия у data channel нет: 'close' эмитится без аргумента, как
455
- // и раньше (код приходит только от WebSocket-транспорта dedicated)
490
+ // onopen/onclose не ставим: голос не трогает _openChannels и не закрывает
491
+ // менеджер
492
+ _wireVoiceChannel(channel) {
493
+ channel.onmessage = event => this.publisher.emit('voice', event.data);
494
+ }
495
+
496
+ // кода закрытия у data channel нет: 'close' эмитится без аргумента
456
497
  _emitClose() {
457
498
  if (this._closed) {
458
499
  return;
@@ -1,84 +1,17 @@
1
- // Отказы по политике: коды закрытия, которыми dedicated-сервер отбивает
2
- // соединение (`src/dedicated/main.js` — invalidOrigin, handshakeTimeout,
3
- // tooManyConnections) и которыми порт-машина отбивает полную комнату
4
- // (`src/host/PortMachine.js` — roomFull). Числа берутся из общей карты
5
- // `src/config/closeCodes.js`, чтобы серверные контуры и клиент не разъехались
6
- // молча; тест модуля требует явного решения по каждому коду карты.
7
- //
8
- // Клиенту от кода нужны два разных ответа, и совпадают они не всегда, поэтому
9
- // знание разведено на два экспорта (review-4.md, R4-2):
10
- //
11
- // - перезагружаться ли. Ни на одном из этих кодов перезагрузка не помогает:
12
- // лимит частоты подключений съел бы ей же очередное соединение, таймаут
13
- // хендшейка запустился бы заново, origin страницы не меняется вовсе, а
14
- // слот в полной комнате перезагрузка не освобождает. Брошенная вкладка
15
- // перезагружалась бы вечно, а ждущий слот игрок за 30 витков сам упёрся бы
16
- // в лимит подключений и получил бы чужую причину отказа;
17
- // - какой текст показать. Тексты запасные: `handleDisconnect` берёт их
18
- // только тогда, когда сервер причину не прислал сам (`terminalInformShown`
19
- // — `client/main.js`). Полная комната, например, приезжает с причиной от
20
- // порт-машины (TECH_INFORM перед `close`), и она всегда побеждает —
21
- // запись 4006 ниже отработает, только если тот кадр не доехал. Тексты
22
- // здесь, а не в `techInformList`: список переопределяет игра, и движковый
23
- // индекс дал бы у неё «Unknown error».
24
-
25
- import closeCodes from '../../config/closeCodes.js';
26
-
27
- const {
28
- invalidOrigin,
29
- roomFull,
30
- handshakeTimeout,
31
- tooManyConnections,
32
- kickIdle,
33
- kickForMaxLatency,
34
- kickForMissedPings,
35
- } = closeCodes;
36
-
37
- const NO_RELOAD_CLOSE_CODES = new Set([
38
- invalidOrigin,
39
- roomFull,
40
- handshakeTimeout,
41
- tooManyConnections,
42
- ]);
43
-
44
- export const POLICY_CLOSE_INFORMS = {
45
- [invalidOrigin]:
46
- 'This page is not allowed by the game server. Check the address you opened.',
47
- [roomFull]: 'The room is full. Try again later.',
48
- [handshakeTimeout]:
49
- 'Idle connection closed before the match started. Reload the page when you are ready to play.',
50
- [tooManyConnections]:
51
- 'Too many connections from your address. Wait a minute and reload the page.',
52
- };
53
-
54
- /**
55
- * @param {number} [closeCode] - Код закрытия транспорта. Его отдаёт только
56
- * WebSocket-транспорт (dedicated); в P2P и solo кода нет вовсе.
57
- * @returns {boolean} Уместна ли перезагрузка страницы после разрыва.
58
- */
59
- export function shouldReloadAfterClose(closeCode) {
60
- return !NO_RELOAD_CLOSE_CODES.has(closeCode);
61
- }
62
-
63
- // Кики хоста (host/HostGame.js). В P2P кода закрытия нет вовсе — причина
64
- // приезжает только TECH_INFORM-ключом перед закрытием (индексы
65
- // TECH_CODES из host/meta/SocketManager.js = индексы techInformList);
66
- // в dedicated есть и код. Лобби-режим уводит кикнутого на главную без
67
- // перезагрузки (client/lib/roomLink.js decideExitRoute): быстрая игра по
68
- // hash вернула бы его в ту же комнату
69
- const KICK_CLOSE_CODES = new Set([
70
- kickIdle,
71
- kickForMaxLatency,
72
- kickForMissedPings,
73
- ]);
74
-
1
+ // Кики хоста (host/HostGame.js). Кода закрытия у транспортов клиента нет —
2
+ // причина приезжает только TECH_INFORM-ключом перед закрытием (индексы
3
+ // TECH_CODES из host/meta/SocketManager.js = индексы techInformList).
4
+ // Лобби-режим уводит кикнутого на главную без перезагрузки
5
+ // (client/lib/roomLink.js decideExitRoute): быстрая игра по hash вернула бы
6
+ // его в ту же комнату.
7
+
8
+ // kickIdle, kickForMaxLatency, kickForMissedPings
75
9
  export const KICK_TECH_KEYS = new Set([3, 4, 5]);
76
10
 
77
11
  /**
78
- * @param {number} [closeCode] - Код закрытия транспорта (только WebSocket).
79
12
  * @param {number|null} [techKey] - Последний терминальный TECH_INFORM-ключ.
80
13
  * @returns {boolean} Закрыто ли соединение киком хоста.
81
14
  */
82
- export function isKickClose(closeCode, techKey) {
83
- return KICK_CLOSE_CODES.has(closeCode) || KICK_TECH_KEYS.has(techKey);
15
+ export function isKickClose(techKey) {
16
+ return KICK_TECH_KEYS.has(techKey);
84
17
  }
@@ -4,6 +4,14 @@
4
4
  не в index.html, потому что в репозитории игры такого файла нет.
5
5
  Standalone SDK импортирует этот файл целиком, поэтому всё, что здесь
6
6
  есть, применяется и к встраивающей странице. */
7
+ /* Цвета команд 1…3: общие у ника в чате и в HUD связи; игра, перекрасившая
8
+ команды, переопределяет переменные. */
9
+ :root {
10
+ --vimp-team-color-1: #f56;
11
+ --vimp-team-color-2: #7ed;
12
+ --vimp-team-color-3: #fe5;
13
+ }
14
+
7
15
  html,
8
16
  body {
9
17
  width: 100%;
@@ -306,6 +314,141 @@ body.hide-cursor {
306
314
  text-align: right;
307
315
  }
308
316
 
317
+ /*voice*/
318
+
319
+ /* Нейтральный вид HUD связи; игра перестилизует его по #voice-hud и
320
+ классам. Селектор по id перебивает `.vimp-shell > * { display: none }` */
321
+ #voice-hud {
322
+ display: none;
323
+ position: absolute;
324
+ right: 24px;
325
+ top: 50%;
326
+ transform: translateY(-50%);
327
+ z-index: 4;
328
+ pointer-events: none;
329
+ flex-direction: column;
330
+ align-items: flex-end;
331
+ gap: 8px;
332
+ color: #fff;
333
+ font-size: 14px;
334
+ text-shadow: 1px 1px 1px #000;
335
+ }
336
+
337
+ #voice-hud.is-talking,
338
+ #voice-hud.is-busy,
339
+ #voice-hud.is-cooldown,
340
+ #voice-hud.is-error,
341
+ #voice-hud.is-incoming {
342
+ display: flex;
343
+ }
344
+
345
+ #voice-hud .voice-own,
346
+ #voice-hud .voice-incoming {
347
+ display: none;
348
+ position: relative;
349
+ align-items: center;
350
+ gap: 8px;
351
+ }
352
+
353
+ #voice-hud.is-talking .voice-own,
354
+ #voice-hud.is-busy .voice-own,
355
+ #voice-hud.is-cooldown .voice-own,
356
+ #voice-hud.is-error .voice-own,
357
+ #voice-hud.is-incoming .voice-incoming {
358
+ display: flex;
359
+ }
360
+
361
+ #voice-hud .voice-icon {
362
+ width: 32px;
363
+ height: 32px;
364
+ fill: none;
365
+ stroke: currentColor;
366
+ stroke-width: 2;
367
+ stroke-linecap: round;
368
+ }
369
+
370
+ #voice-hud .voice-icon rect {
371
+ fill: currentColor;
372
+ }
373
+
374
+ #voice-hud .voice-cross {
375
+ display: none;
376
+ }
377
+
378
+ #voice-hud .voice-ring {
379
+ display: none;
380
+ position: absolute;
381
+ left: -2px;
382
+ top: -2px;
383
+ width: 36px;
384
+ height: 36px;
385
+ fill: none;
386
+ stroke: currentColor;
387
+ stroke-width: 2;
388
+ transform: rotate(-90deg);
389
+ }
390
+
391
+ #voice-hud.is-talking .voice-ring {
392
+ display: block;
393
+ }
394
+
395
+ #voice-hud .voice-own {
396
+ flex-direction: row-reverse;
397
+ }
398
+
399
+ #voice-hud.is-talking .voice-own {
400
+ color: #3ad16b;
401
+ }
402
+
403
+ #voice-hud.is-busy .voice-own {
404
+ color: #e5484d;
405
+ }
406
+
407
+ #voice-hud.is-cooldown .voice-own,
408
+ #voice-hud.is-error .voice-own {
409
+ color: #8a8f98;
410
+ }
411
+
412
+ #voice-hud.is-busy .voice-cross,
413
+ #voice-hud.is-error .voice-cross {
414
+ display: inline;
415
+ }
416
+
417
+ #voice-hud .voice-incoming {
418
+ flex-direction: row-reverse;
419
+ color: #f5a524;
420
+ animation: voice-pulse 1s ease-in-out infinite;
421
+ }
422
+
423
+ #voice-hud .voice-name {
424
+ color: var(--voice-name-color, #fff);
425
+ font-weight: bold;
426
+ }
427
+
428
+ #voice-hud .voice-team1 {
429
+ color: var(--voice-name-color, var(--vimp-team-color-1));
430
+ }
431
+
432
+ #voice-hud .voice-team2 {
433
+ color: var(--voice-name-color, var(--vimp-team-color-2));
434
+ }
435
+
436
+ #voice-hud .voice-team3 {
437
+ color: var(--voice-name-color, var(--vimp-team-color-3));
438
+ }
439
+
440
+ @keyframes voice-pulse {
441
+ 50% {
442
+ opacity: 0.55;
443
+ }
444
+ }
445
+
446
+ @media (prefers-reduced-motion: reduce) {
447
+ #voice-hud .voice-incoming {
448
+ animation: none;
449
+ }
450
+ }
451
+
309
452
  /*vote*/
310
453
 
311
454
  #vote {
@@ -456,15 +599,15 @@ body.hide-cursor {
456
599
  }
457
600
 
458
601
  #chat-box .line1:before {
459
- color: var(--chat-name-color, #f56);
602
+ color: var(--chat-name-color, var(--vimp-team-color-1));
460
603
  }
461
604
 
462
605
  #chat-box .line2:before {
463
- color: var(--chat-name-color, #7ed);
606
+ color: var(--chat-name-color, var(--vimp-team-color-2));
464
607
  }
465
608
 
466
609
  #chat-box .line3:before {
467
- color: var(--chat-name-color, #fe5);
610
+ color: var(--chat-name-color, var(--vimp-team-color-3));
468
611
  }
469
612
 
470
613
  #cmd {
@@ -583,8 +726,8 @@ body.hide-cursor {
583
726
  display: block;
584
727
  }
585
728
 
586
- /* футеры лобби и формы входа: одна и та же полоса из трёх ячеек —
587
- ссылка на пакет, его версия, копирайт. Оба заполняет JS, поэтому вид
729
+ /* футеры лобби (Terms/Privacy | версия движка | копирайт) и формы входа
730
+ (версия игры | копирайт): одна полоса, оба заполняет JS, поэтому вид
588
731
  держится в одном месте (#lobby-footer лежит в секции lobby ниже только
589
732
  разметкой) */
590
733
  #auth-link,
@@ -618,9 +761,8 @@ body.hide-cursor {
618
761
  padding-left: 10px;
619
762
  }
620
763
 
621
- /* три равные ячейки: середину держит не space-between, а сама равность —
622
- любая из крайних бывает пустой (у пакета игры может не быть метаданных),
623
- и при space-between версия съезжала бы с центра */
764
+ /* равные ячейки: версия игры у левого края, копирайт у правого; пустая
765
+ версия (у пакета игры может не быть метаданных) не сдвигает копирайт */
624
766
  #auth-link p {
625
767
  margin: 0;
626
768
  flex: 1;
@@ -638,7 +780,6 @@ body.hide-cursor {
638
780
  text-align: left;
639
781
  }
640
782
 
641
- #auth-version,
642
783
  #lobby-version {
643
784
  text-align: center;
644
785
  }
@@ -55,9 +55,7 @@ const SHELL = [
55
55
  tag: 'div',
56
56
  id: 'auth-link',
57
57
  children: [
58
- // версия пакета ИГРЫ (из манифеста) и ссылка на документацию
59
- // (только в dev-сборке) — заполняет main.js по AUTH_DATA
60
- { tag: 'p', children: [{ tag: 'a', id: 'auth-docs-link' }] },
58
+ // версия пакета ИГРЫ (из манифеста) — заполняет main.js по AUTH_DATA
61
59
  { tag: 'p', id: 'auth-version' },
62
60
  { tag: 'p', text: `© ${new Date().getFullYear()} VIMP` },
63
61
  ],
@@ -67,7 +65,7 @@ const SHELL = [
67
65
  ],
68
66
  },
69
67
  { tag: 'div', id: 'game-informer' },
70
- // оверлей переподключения (host-migration этап 4): в solo/dedicated не
68
+ // оверлей переподключения (host-migration этап 4): в solo не
71
69
  // показывается, но разметка общая с informer.pug
72
70
  {
73
71
  tag: 'div',