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
|
@@ -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 -
|
|
56
|
-
*
|
|
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] - (
|
|
65
|
-
*
|
|
66
|
+
* @param {Function} [opts.isTerminalClose] - () закрытие после
|
|
67
|
+
* терминальной причины (кик, полная комната): возвращаться некуда.
|
|
66
68
|
* @param {Object|null} [opts.reconnect] - null — переподключения нет
|
|
67
|
-
* (solo,
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
603
|
+
if (this._canResume()) {
|
|
580
604
|
this._startReconnect();
|
|
581
605
|
return;
|
|
582
606
|
}
|
|
583
607
|
|
|
584
|
-
this._terminate(
|
|
608
|
+
this._terminate();
|
|
585
609
|
}
|
|
586
610
|
|
|
587
611
|
// нетерминальным бывает только обрыв посреди матча, у которого есть место
|
|
588
612
|
// для возврата и кому возвращаться
|
|
589
|
-
_canResume(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
455
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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(
|
|
83
|
-
return
|
|
15
|
+
export function isKickClose(techKey) {
|
|
16
|
+
return KICK_TECH_KEYS.has(techKey);
|
|
84
17
|
}
|
package/src/client/style.css
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
68
|
+
// оверлей переподключения (host-migration этап 4): в solo не
|
|
71
69
|
// показывается, но разметка общая с informer.pug
|
|
72
70
|
{
|
|
73
71
|
tag: 'div',
|