vimp-engine 0.38.2 → 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/core/Cargo.toml +1 -1
- package/package.json +3 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +152 -18
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +47 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- 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/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +151 -9
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +4 -4
- package/src/client/views/includes/reports.pug +1 -1
- 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/authClient.js +3 -3
- package/src/config/clientServices.js +2 -0
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- 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/loadContext.js +2 -2
- package/src/devtools/contract/rules/a7-package-repository.js +7 -8
- 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 +5 -1
- package/src/devtools/invariants.js +1 -1
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +93 -15
- package/src/host/PortMachine.js +19 -3
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +15 -2
- package/src/host/meta/SocketManager.js +8 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- 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/applyRoomOverrides.js +1 -1
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +5 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +9 -1
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/packageLink.js +18 -61
- package/src/lib/validators.js +3 -3
- 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 +1 -1
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Singleton VoiceView
|
|
2
|
+
|
|
3
|
+
let voiceView;
|
|
4
|
+
|
|
5
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
6
|
+
const RING_RADIUS = 15;
|
|
7
|
+
const RING_LENGTH = 2 * Math.PI * RING_RADIUS;
|
|
8
|
+
|
|
9
|
+
// константная разметка значка микрофона: капсула, дуга держателя, ножка
|
|
10
|
+
// (перечёркивание — .voice-cross)
|
|
11
|
+
const MIC_SVG =
|
|
12
|
+
'<rect x="9" y="3" width="6" height="11" rx="3"/>' +
|
|
13
|
+
'<path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>' +
|
|
14
|
+
'<path class="voice-cross" d="M4 4l16 16"/>';
|
|
15
|
+
|
|
16
|
+
function createIcon() {
|
|
17
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
18
|
+
|
|
19
|
+
svg.setAttribute('class', 'voice-icon');
|
|
20
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
21
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
22
|
+
svg.innerHTML = MIC_SVG;
|
|
23
|
+
|
|
24
|
+
return svg;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default class VoiceView {
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} model
|
|
30
|
+
* @param {HTMLElement} container - точка монтирования (контейнер игры).
|
|
31
|
+
*/
|
|
32
|
+
constructor(model, container = document.body) {
|
|
33
|
+
if (voiceView) {
|
|
34
|
+
return voiceView;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
voiceView = this;
|
|
38
|
+
|
|
39
|
+
this._hud = document.createElement('div');
|
|
40
|
+
this._hud.id = 'voice-hud';
|
|
41
|
+
|
|
42
|
+
const own = document.createElement('div');
|
|
43
|
+
const incoming = document.createElement('div');
|
|
44
|
+
const ring = document.createElementNS(SVG_NS, 'svg');
|
|
45
|
+
|
|
46
|
+
own.className = 'voice-own';
|
|
47
|
+
incoming.className = 'voice-incoming';
|
|
48
|
+
|
|
49
|
+
ring.setAttribute('class', 'voice-ring');
|
|
50
|
+
ring.setAttribute('aria-hidden', 'true');
|
|
51
|
+
ring.setAttribute('viewBox', '0 0 36 36');
|
|
52
|
+
ring.innerHTML = `<circle cx="18" cy="18" r="${RING_RADIUS}"/>`;
|
|
53
|
+
this._circle = ring.firstChild;
|
|
54
|
+
this._circle.style.strokeDasharray = String(RING_LENGTH);
|
|
55
|
+
|
|
56
|
+
this._timerElem = document.createElement('span');
|
|
57
|
+
this._timerElem.className = 'voice-timer';
|
|
58
|
+
this._timerElem.setAttribute('aria-hidden', 'true');
|
|
59
|
+
|
|
60
|
+
this._textElem = document.createElement('span');
|
|
61
|
+
this._textElem.className = 'voice-text';
|
|
62
|
+
this._textElem.setAttribute('role', 'status');
|
|
63
|
+
this._textElem.setAttribute('aria-live', 'polite');
|
|
64
|
+
|
|
65
|
+
this._nameElem = document.createElement('span');
|
|
66
|
+
this._nameElem.className = 'voice-name';
|
|
67
|
+
|
|
68
|
+
own.append(createIcon(), ring, this._timerElem, this._textElem);
|
|
69
|
+
incoming.append(createIcon(), this._nameElem);
|
|
70
|
+
this._hud.append(own, incoming);
|
|
71
|
+
container.appendChild(this._hud);
|
|
72
|
+
|
|
73
|
+
this._own = null;
|
|
74
|
+
this._raf = null;
|
|
75
|
+
|
|
76
|
+
model.publisher.on('own', 'renderOwn', this);
|
|
77
|
+
model.publisher.on('incoming', 'renderIncoming', this);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// свой эфир: класс состояния, текст, цикл отсчёта
|
|
81
|
+
renderOwn(data) {
|
|
82
|
+
const { kind, text, endsAt, totalMs } = data;
|
|
83
|
+
|
|
84
|
+
for (const name of ['talking', 'busy', 'cooldown', 'error']) {
|
|
85
|
+
this._hud.classList.toggle(`is-${name}`, name === kind);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._textElem.textContent = text;
|
|
89
|
+
|
|
90
|
+
if (kind === 'talking' || kind === 'cooldown') {
|
|
91
|
+
this._own = { kind, endsAt, totalMs };
|
|
92
|
+
|
|
93
|
+
if (this._raf === null) {
|
|
94
|
+
this._raf = requestAnimationFrame(() => this._tick());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this._stopCountdown();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// чужой эфир: ник как текст, цвет как у чата
|
|
104
|
+
renderIncoming(speaker) {
|
|
105
|
+
this._hud.classList.toggle('is-incoming', Boolean(speaker));
|
|
106
|
+
|
|
107
|
+
for (const className of [...this._nameElem.classList]) {
|
|
108
|
+
if (className.startsWith('voice-team')) {
|
|
109
|
+
this._nameElem.classList.remove(className);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!speaker) {
|
|
114
|
+
this._nameElem.textContent = '';
|
|
115
|
+
this._nameElem.style.removeProperty('--voice-name-color');
|
|
116
|
+
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this._nameElem.textContent = speaker.name;
|
|
121
|
+
|
|
122
|
+
if (Number.isInteger(speaker.teamId)) {
|
|
123
|
+
this._nameElem.classList.add(`voice-team${speaker.teamId}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (speaker.color) {
|
|
127
|
+
this._nameElem.style.setProperty('--voice-name-color', speaker.color);
|
|
128
|
+
} else {
|
|
129
|
+
this._nameElem.style.removeProperty('--voice-name-color');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
destroy() {
|
|
134
|
+
this._stopCountdown();
|
|
135
|
+
this._hud.remove();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_tick() {
|
|
139
|
+
const own = this._own;
|
|
140
|
+
|
|
141
|
+
this._raf = null;
|
|
142
|
+
|
|
143
|
+
// вид сменился после планирования кадра
|
|
144
|
+
if (!own || (own.kind !== 'talking' && own.kind !== 'cooldown')) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const remaining = Math.max(0, own.endsAt - performance.now());
|
|
149
|
+
|
|
150
|
+
this._timerElem.textContent = `${(remaining / 1000).toFixed(1)}s`;
|
|
151
|
+
|
|
152
|
+
// у кулдауна кольца нет
|
|
153
|
+
this._circle.style.strokeDashoffset = String(
|
|
154
|
+
own.kind === 'talking' && own.totalMs > 0
|
|
155
|
+
? RING_LENGTH * (1 - remaining / own.totalMs)
|
|
156
|
+
: RING_LENGTH,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
this._raf = requestAnimationFrame(() => this._tick());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_stopCountdown() {
|
|
163
|
+
if (this._raf !== null) {
|
|
164
|
+
cancelAnimationFrame(this._raf);
|
|
165
|
+
this._raf = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this._own = null;
|
|
169
|
+
this._timerElem.textContent = '';
|
|
170
|
+
this._circle.style.strokeDashoffset = String(RING_LENGTH);
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/client/debug.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Браузерная половина отладочного
|
|
1
|
+
// Браузерная половина отладочного контура.
|
|
2
2
|
//
|
|
3
3
|
// Всё, что headless-прогон не воспроизводит — реальный WebRTC, PixiJS, живой
|
|
4
4
|
// ввод человека — читается и записывается отсюда: `window.__vimpDebug`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Журнал клиентских
|
|
1
|
+
// Журнал клиентских ошибок: неперехваченные
|
|
2
2
|
// ошибки и отклонения страницы, ошибки Worker'а хоста, нарушения CSP и явные
|
|
3
3
|
// warn/capture плагинов уходят на свой же бокс (POST /client-reports), а тот
|
|
4
4
|
// пересылает их в центральный журнал.
|
|
@@ -175,7 +175,7 @@ function noop() {}
|
|
|
175
175
|
* @param {number} [opts.flushDelayMs=2000] - задержка первой отправки
|
|
176
176
|
* @param {number} [opts.minIntervalMs=10000] - интервал между отправками по
|
|
177
177
|
* таймеру; прямой flush() его не ждёт
|
|
178
|
-
* @returns {{ capture, warn, flush, setContext, install }}
|
|
178
|
+
* @returns {{ capture, ignore, warn, flush, setContext, install }}
|
|
179
179
|
*/
|
|
180
180
|
export function createDiagnostics({
|
|
181
181
|
url,
|
|
@@ -189,6 +189,7 @@ export function createDiagnostics({
|
|
|
189
189
|
if (!url) {
|
|
190
190
|
return {
|
|
191
191
|
capture: noop,
|
|
192
|
+
ignore: noop,
|
|
192
193
|
warn: noop,
|
|
193
194
|
flush: noop,
|
|
194
195
|
setContext: noop,
|
|
@@ -211,7 +212,7 @@ export function createDiagnostics({
|
|
|
211
212
|
let busy = false;
|
|
212
213
|
|
|
213
214
|
function record(item) {
|
|
214
|
-
const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${topFrame(item.stack)}`;
|
|
215
|
+
const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${item.code ? '' : topFrame(item.stack)}`;
|
|
215
216
|
const at = now();
|
|
216
217
|
const known = records.get(key);
|
|
217
218
|
|
|
@@ -276,8 +277,22 @@ export function createDiagnostics({
|
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
// ошибки, о которых вызывающий уже позаботился сам (сбой сети при
|
|
281
|
+
// загрузке): перехватчик окна их не записывает
|
|
282
|
+
const ignored = new WeakSet();
|
|
283
|
+
|
|
284
|
+
function ignore(error) {
|
|
285
|
+
if (error !== null && typeof error === 'object') {
|
|
286
|
+
ignored.add(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
279
290
|
function capture(error, { source = 'client', kind = 'error' } = {}) {
|
|
280
291
|
guarded(() => {
|
|
292
|
+
if (error !== null && typeof error === 'object' && ignored.has(error)) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
281
296
|
const raw =
|
|
282
297
|
error !== null && error !== undefined && error.message !== undefined
|
|
283
298
|
? error.message
|
|
@@ -485,5 +500,5 @@ export function createDiagnostics({
|
|
|
485
500
|
};
|
|
486
501
|
}
|
|
487
502
|
|
|
488
|
-
return { capture, warn, flush, setContext, install };
|
|
503
|
+
return { capture, ignore, warn, flush, setContext, install };
|
|
489
504
|
}
|
|
@@ -7,11 +7,5 @@
|
|
|
7
7
|
// бандлу (в npm-тарболле vimp-engine package.json есть всегда, поэтому
|
|
8
8
|
// standalone SDK собирается у игры тем же импортом).
|
|
9
9
|
import pkg from '../../../package.json' with { type: 'json' };
|
|
10
|
-
import { resolveProjectUrl } from '../../lib/packageLink.js';
|
|
11
10
|
|
|
12
11
|
export const ENGINE_VERSION = pkg.version;
|
|
13
|
-
|
|
14
|
-
// адрес проекта для ссылки в футере лобби — то же поле, что мастер отдаёт
|
|
15
|
-
// про пакет игры (GameCatalog.packageUrl), чтобы оба футера строил один
|
|
16
|
-
// projectLink
|
|
17
|
-
export const ENGINE_PROJECT_URL = resolveProjectUrl(pkg);
|
|
@@ -1,29 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
// прячется: ячейка футера остаётся пустой, и раскладка space-between не едет.
|
|
1
|
+
// Ссылка на документацию в футере формы входа игры. Показывается только в
|
|
2
|
+
// dev-сборке: разработчик игры при локальном запуске видит, где читать
|
|
3
|
+
// документацию, а игрок на проде — нет. Футер лобби несёт ту же ссылку
|
|
4
|
+
// статичной разметкой (lobby.pug). Тот же адрес — литералом в lobby.pug и в
|
|
5
|
+
// `homepage` пакета; совпадение сторожит tests/client/footerLink.test.js.
|
|
6
|
+
export const DOCS_URL = 'https://docs.vimp.lgick.dev';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* @param {HTMLAnchorElement|null} anchor - якорь футера
|
|
11
|
-
* @param {
|
|
10
|
+
* @param {boolean} enabled - true только в dev-сборке
|
|
12
11
|
*/
|
|
13
|
-
export function
|
|
12
|
+
export function renderDocsLink(anchor, enabled) {
|
|
14
13
|
if (!anchor) {
|
|
15
14
|
return;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (!link) {
|
|
17
|
+
if (!enabled) {
|
|
21
18
|
anchor.textContent = '';
|
|
22
19
|
anchor.removeAttribute('href');
|
|
20
|
+
anchor.removeAttribute('target');
|
|
21
|
+
anchor.removeAttribute('rel');
|
|
23
22
|
|
|
24
23
|
return;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
anchor.textContent =
|
|
28
|
-
anchor.href =
|
|
26
|
+
anchor.textContent = 'docs';
|
|
27
|
+
anchor.href = DOCS_URL;
|
|
28
|
+
anchor.target = '_blank';
|
|
29
|
+
anchor.rel = 'noopener';
|
|
29
30
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Повтор запроса загрузки при сбое сети. fetch отклоняется TypeError'ом,
|
|
2
|
+
// только когда ответа нет вовсе (обрыв, смена сети, вкладка из фона);
|
|
3
|
+
// HTTP-статус и разбор ответа сбоем сети не являются и не повторяются.
|
|
4
|
+
// Повторяется только сам вызов fetch: TypeError из разбора ответа — ошибка
|
|
5
|
+
// кода, а не сети.
|
|
6
|
+
|
|
7
|
+
// паузы перед повторами: три запроса, отказ не позже ~2 с сверх самих запросов
|
|
8
|
+
export const BOOT_RETRY_DELAYS_MS = [500, 1500];
|
|
9
|
+
|
|
10
|
+
const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
11
|
+
|
|
12
|
+
/** Сбой сети, оставшийся после повторов; отличает его от ошибок кода. */
|
|
13
|
+
export class NetworkFailure extends Error {
|
|
14
|
+
constructor(cause) {
|
|
15
|
+
super(cause?.message ?? 'Network failure', { cause });
|
|
16
|
+
this.name = 'NetworkFailure';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const isNetworkFailure = error => error instanceof NetworkFailure;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Оборачивает отказ чтения тела ответа: SyntaxError — это мусор в JSON,
|
|
24
|
+
* любой другой отказ (TypeError обрыва) — сбой сети.
|
|
25
|
+
* @param {Error} error
|
|
26
|
+
* @returns {Error}
|
|
27
|
+
*/
|
|
28
|
+
export const bodyReadFailure = error =>
|
|
29
|
+
error instanceof SyntaxError ? error : new NetworkFailure(error);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Function} doFetch - (...args) => Promise<Response>: сам fetch.
|
|
33
|
+
* @param {*} url
|
|
34
|
+
* @param {Object} [options]
|
|
35
|
+
* @param {number[]} [options.delaysMs] - паузы перед повторами, мс.
|
|
36
|
+
* @param {Function} [options.sleep] - (ms) => Promise (тесты подставляют свою).
|
|
37
|
+
* @returns {Promise<Response>} ответ первой удавшейся попытки; отклоняется
|
|
38
|
+
* NetworkFailure, когда сеть не ответила во всех попытках.
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchRetrying(
|
|
41
|
+
doFetch,
|
|
42
|
+
url,
|
|
43
|
+
{ delaysMs = BOOT_RETRY_DELAYS_MS, sleep = defaultSleep } = {},
|
|
44
|
+
) {
|
|
45
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
46
|
+
try {
|
|
47
|
+
return await doFetch(url);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (!(e instanceof TypeError)) {
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (attempt >= delaysMs.length) {
|
|
54
|
+
throw new NetworkFailure(e);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await sleep(delaysMs[attempt]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Сервис пула зависимостей `voice`: кто сейчас в эфире.
|
|
2
|
+
//
|
|
3
|
+
// Сервис только читает (его получают парты игры — индикаторы над танком,
|
|
4
|
+
// на радаре); пишет хранилище, которым владеет движок: оно применяет
|
|
5
|
+
// разобранные сообщения VOICE_DATA. START/STOP приходят только слушателям
|
|
6
|
+
// канала, поэтому противник при командном режиме не узнаёт о говорящем
|
|
7
|
+
// ничего.
|
|
8
|
+
//
|
|
9
|
+
// Ключи — строки: id в part приходит ключом кадра (строкой), а на проводе
|
|
10
|
+
// это число.
|
|
11
|
+
export function createVoiceState({ now = () => performance.now() } = {}) {
|
|
12
|
+
// String(speakerId) → { talkId, since, name }
|
|
13
|
+
const talks = new Map();
|
|
14
|
+
let enabled = false;
|
|
15
|
+
|
|
16
|
+
const service = Object.freeze({
|
|
17
|
+
// связь в этом матче включена (конфиг + контур с ретранслятором)
|
|
18
|
+
get enabled() {
|
|
19
|
+
return enabled;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
isTalking(id) {
|
|
23
|
+
return talks.has(String(id));
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// момент START по часам клиента (мс performance.now) или null — фаза
|
|
27
|
+
// анимации индикатора
|
|
28
|
+
talkingSince(id) {
|
|
29
|
+
return talks.get(String(id))?.since ?? null;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const store = {
|
|
34
|
+
setEnabled(flag) {
|
|
35
|
+
enabled = flag === true;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// разобранное parseVoiceMessage; прочие типы игнорируются
|
|
39
|
+
apply(message) {
|
|
40
|
+
if (message?.type === 'start') {
|
|
41
|
+
talks.set(String(message.speakerId), {
|
|
42
|
+
talkId: message.talkId,
|
|
43
|
+
since: now(),
|
|
44
|
+
name: message.name,
|
|
45
|
+
});
|
|
46
|
+
} else if (message?.type === 'stop') {
|
|
47
|
+
const key = String(message.speakerId);
|
|
48
|
+
|
|
49
|
+
// поздний STOP старого эфира не гасит новый
|
|
50
|
+
if (talks.get(key)?.talkId === message.talkId) {
|
|
51
|
+
talks.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// эфир, не закрытый STOP дольше maxAgeMs, гаснет сам (страховка при
|
|
57
|
+
// потерянном STOP). Возвращает число погашенных
|
|
58
|
+
expire(maxAgeMs) {
|
|
59
|
+
const current = now();
|
|
60
|
+
let removed = 0;
|
|
61
|
+
|
|
62
|
+
for (const [key, talk] of talks) {
|
|
63
|
+
if (current - talk.since > maxAgeMs) {
|
|
64
|
+
talks.delete(key);
|
|
65
|
+
removed += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return removed;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
clear() {
|
|
73
|
+
talks.clear();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return { service, store };
|
|
78
|
+
}
|