vimp-engine 0.38.3 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +117 -0
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +43 -0
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +24 -0
- package/src/client/network/WebRtcManager.js +45 -3
- package/src/client/style.css +146 -3
- package/src/client/voice/JitterBuffer.js +137 -0
- package/src/client/voice/MicCapture.js +307 -0
- package/src/client/voice/PttMachine.js +369 -0
- package/src/client/voice/VoiceClient.js +336 -0
- package/src/client/voice/VoiceCues.js +295 -0
- package/src/client/voice/VoiceEncoder.js +174 -0
- package/src/client/voice/VoicePlayback.js +558 -0
- package/src/client/voice/audioData.js +35 -0
- package/src/client/voice/captureProcessor.worklet.js +136 -0
- package/src/client/voice/opusConfig.js +49 -0
- package/src/client/voice/voiceOutput.js +72 -0
- package/src/client/voice/voiceSupport.js +71 -0
- package/src/client/voice/voiceTones.js +90 -0
- package/src/config/clientServices.js +2 -0
- package/src/config/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +12 -0
- package/src/devtools/contract/rules/b11-voice-config.js +34 -0
- package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
- package/src/devtools/contract/rules/index.js +4 -0
- package/src/host/HostGame.js +58 -0
- package/src/host/PortMachine.js +19 -3
- package/src/host/host.worker.js +7 -0
- package/src/host/meta/SocketManager.js +6 -0
- package/src/host/meta/modules/voice/Voice.js +430 -0
- package/src/host/meta/modules/voice/channels.js +58 -0
- package/src/host/meta/modules/voice/index.js +1 -0
- package/src/lib/VoiceRelay.js +200 -0
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +4 -0
- package/src/lib/createHostRuntime.js +8 -0
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
package/package.json
CHANGED
|
@@ -20,6 +20,9 @@ export default class InputListener {
|
|
|
20
20
|
this.publisher.emit('keyUp', event),
|
|
21
21
|
);
|
|
22
22
|
|
|
23
|
+
// keyup за пределами окна не приходит — эфир залип бы до таймаута
|
|
24
|
+
window.addEventListener('blur', () => this.publisher.emit('blur'));
|
|
25
|
+
|
|
23
26
|
const emitMouse = () => this.publisher.emit('mouseAction');
|
|
24
27
|
|
|
25
28
|
window.addEventListener('mouseup', emitMouse);
|
|
@@ -892,6 +892,19 @@ export default class SoundManager {
|
|
|
892
892
|
}
|
|
893
893
|
}
|
|
894
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Выход Web Audio движка для голосовой связи: контекст Howler и его master
|
|
897
|
+
* gain. Голос подчиняется общей громкости и mute (скрытая вкладка, пауза
|
|
898
|
+
* миграции).
|
|
899
|
+
* @returns {{ ctx: AudioContext, destination: AudioNode } | null} null —
|
|
900
|
+
* Web Audio недоступен.
|
|
901
|
+
*/
|
|
902
|
+
getAudioOutput() {
|
|
903
|
+
return Howler.usingWebAudio && Howler.ctx && Howler.masterGain
|
|
904
|
+
? { ctx: Howler.ctx, destination: Howler.masterGain }
|
|
905
|
+
: null;
|
|
906
|
+
}
|
|
907
|
+
|
|
895
908
|
/**
|
|
896
909
|
* Выключает все звуки.
|
|
897
910
|
*/
|
|
@@ -54,6 +54,16 @@ export default class ControlsCtrl {
|
|
|
54
54
|
this._model.resendHeld();
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// назначает клавишу связи (push-to-talk)
|
|
58
|
+
setPushToTalk(keyCode) {
|
|
59
|
+
this._model.setPushToTalk(keyCode);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// отпускает связь (потеря фокуса, скрытая вкладка)
|
|
63
|
+
releasePtt() {
|
|
64
|
+
this._model.releasePtt();
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
// скрывает курсор
|
|
58
68
|
resetCursorHideTimer() {
|
|
59
69
|
this._view.resetCursorHideTimer();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Singleton VoiceCtrl
|
|
2
|
+
|
|
3
|
+
let voiceCtrl;
|
|
4
|
+
|
|
5
|
+
export default class VoiceCtrl {
|
|
6
|
+
constructor(model, view) {
|
|
7
|
+
if (voiceCtrl) {
|
|
8
|
+
return voiceCtrl;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
voiceCtrl = this;
|
|
12
|
+
|
|
13
|
+
this._model = model;
|
|
14
|
+
this._view = view;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// состояние своего эфира
|
|
18
|
+
showOwn(kind, data) {
|
|
19
|
+
this._model.showOwn(kind, data);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// чужой эфир (null — скрыть)
|
|
23
|
+
setIncoming(speaker) {
|
|
24
|
+
this._model.setIncoming(speaker);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
destroy() {
|
|
28
|
+
this._model.destroy();
|
|
29
|
+
this._view.destroy();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -2,6 +2,17 @@ import Publisher from '../../../lib/Publisher.js';
|
|
|
2
2
|
|
|
3
3
|
// Singleton ControlsModel
|
|
4
4
|
|
|
5
|
+
// keyCode самих модификаторов: Ctrl, Alt, Meta (два кода и Firefox)
|
|
6
|
+
const MODIFIER_CODES = new Set([17, 18, 91, 93, 224]);
|
|
7
|
+
|
|
8
|
+
// фокус в поле ввода: клавиша печатает букву
|
|
9
|
+
function isEditableTarget(target) {
|
|
10
|
+
return (
|
|
11
|
+
target?.isContentEditable === true ||
|
|
12
|
+
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
let controlsModel;
|
|
6
17
|
|
|
7
18
|
export default class ControlsModel {
|
|
@@ -25,6 +36,8 @@ export default class ControlsModel {
|
|
|
25
36
|
// возобновления сессии должен знать, что игрок держит на самом деле
|
|
26
37
|
this._heldCodes = new Set();
|
|
27
38
|
this._areKeysEnabled = false; // статус возможности нажатия клавиш
|
|
39
|
+
this._pttKey = null; // keyCode клавиши связи, null — связи нет
|
|
40
|
+
this._pttHeld = false;
|
|
28
41
|
|
|
29
42
|
// канал указателя: игра, не объявившая modules.controls.pointer, его не
|
|
30
43
|
// получает вовсе — ни одного лишнего сообщения по проводу
|
|
@@ -79,6 +92,34 @@ export default class ControlsModel {
|
|
|
79
92
|
this.publisher.emit('mode', mode);
|
|
80
93
|
}
|
|
81
94
|
} else {
|
|
95
|
+
// Cmd, нажатый при уже зажатой клавише связи: её keyup на macOS не
|
|
96
|
+
// придёт — эфир отпускается сразу
|
|
97
|
+
if (event.metaKey && this._pttHeld && keyCode !== this._pttKey) {
|
|
98
|
+
this.releasePtt();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// клавиша связи никогда не уходит в игру командой. Автоповтор keydown
|
|
102
|
+
// игнорируется — удержание и так длится до keyup. Фокус в поле ввода
|
|
103
|
+
// (любом, не только в строке чата) — клавиша печатает букву. С
|
|
104
|
+
// Ctrl/Cmd/Alt — системная комбинация (вставка), не связь: на macOS при
|
|
105
|
+
// Cmd keyup обычной клавиши не приходит и связь залипла бы
|
|
106
|
+
// Сам модификатор может быть клавишей связи: его keydown несёт свой флаг
|
|
107
|
+
if (
|
|
108
|
+
keyCode === this._pttKey &&
|
|
109
|
+
(MODIFIER_CODES.has(keyCode) ||
|
|
110
|
+
!(event.ctrlKey || event.metaKey || event.altKey)) &&
|
|
111
|
+
!isEditableTarget(event.target)
|
|
112
|
+
) {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
|
|
115
|
+
if (!event.repeat && !this._pttHeld) {
|
|
116
|
+
this._pttHeld = true;
|
|
117
|
+
this.publisher.emit('ptt', true);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
82
123
|
// если клавиша ещё не зажата
|
|
83
124
|
if (!this._pressedKeys[keyCode]) {
|
|
84
125
|
const name = this._currentKeySet[keyCode];
|
|
@@ -111,6 +152,11 @@ export default class ControlsModel {
|
|
|
111
152
|
|
|
112
153
|
this._heldCodes.delete(keyCode);
|
|
113
154
|
|
|
155
|
+
// отпускание засчитывается всегда, без проверки цели
|
|
156
|
+
if (keyCode === this._pttKey && this._pttHeld) {
|
|
157
|
+
this.releasePtt();
|
|
158
|
+
}
|
|
159
|
+
|
|
114
160
|
// если клавиша была зажата
|
|
115
161
|
if (this._pressedKeys[keyCode]) {
|
|
116
162
|
const name = this._currentKeySet[keyCode];
|
|
@@ -131,6 +177,10 @@ export default class ControlsModel {
|
|
|
131
177
|
if (status === 'opened') {
|
|
132
178
|
this._currentModes[mode] = true;
|
|
133
179
|
|
|
180
|
+
if (mode === 'chat') {
|
|
181
|
+
this.releasePtt();
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
// иначе змейка так и ехала бы к последней точке, пока игрок набирает
|
|
135
185
|
// сообщение в чат
|
|
136
186
|
this._releasePointer(this._lastTapX, this._lastTapY);
|
|
@@ -260,6 +310,20 @@ export default class ControlsModel {
|
|
|
260
310
|
|
|
261
311
|
if (isEnabled === false) {
|
|
262
312
|
this._releasePointer(this._lastTapX, this._lastTapY);
|
|
313
|
+
this.releasePtt();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// клавиша связи (push-to-talk); null выключает
|
|
318
|
+
setPushToTalk(keyCode) {
|
|
319
|
+
this._pttKey = keyCode ?? null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// отпускает связь (клавиша вверх, потеря фокуса, чат, запрет ввода)
|
|
323
|
+
releasePtt() {
|
|
324
|
+
if (this._pttHeld) {
|
|
325
|
+
this._pttHeld = false;
|
|
326
|
+
this.publisher.emit('ptt', false);
|
|
263
327
|
}
|
|
264
328
|
}
|
|
265
329
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
|
|
3
|
+
// вспышка ошибки гаснет сама
|
|
4
|
+
const ERROR_FLASH_MS = 1500;
|
|
5
|
+
|
|
6
|
+
// Singleton VoiceModel
|
|
7
|
+
|
|
8
|
+
let voiceModel;
|
|
9
|
+
|
|
10
|
+
export default class VoiceModel {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} data
|
|
13
|
+
* @param {Object} data.texts - CONFIG_DATA.voice.texts.
|
|
14
|
+
*/
|
|
15
|
+
constructor({ texts }) {
|
|
16
|
+
if (voiceModel) {
|
|
17
|
+
return voiceModel;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
voiceModel = this;
|
|
21
|
+
|
|
22
|
+
this._texts = texts ?? {};
|
|
23
|
+
// один таймер авто-скрытия: новый показ снимает прежний, иначе таймер
|
|
24
|
+
// старой ошибки погасил бы эфир, начатый после неё
|
|
25
|
+
this._timer = null;
|
|
26
|
+
|
|
27
|
+
// 'own' ({ kind, text, endsAt, totalMs }) и 'incoming' (speaker | null)
|
|
28
|
+
this.publisher = new Publisher();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Состояние своего эфира.
|
|
33
|
+
* @param {'hidden'|'talking'|'busy'|'cooldown'|'error'} kind
|
|
34
|
+
* @param {Object} [data] - talking: { endsAt, totalMs }, cooldown:
|
|
35
|
+
* { endsAt }, error: { textKey }.
|
|
36
|
+
*/
|
|
37
|
+
showOwn(kind, data = {}) {
|
|
38
|
+
this._clearTimer();
|
|
39
|
+
|
|
40
|
+
let text = '';
|
|
41
|
+
let endsAt = 0;
|
|
42
|
+
let totalMs = 0;
|
|
43
|
+
|
|
44
|
+
if (kind === 'busy') {
|
|
45
|
+
text = this._texts.busy ?? '';
|
|
46
|
+
} else if (kind === 'cooldown') {
|
|
47
|
+
text = this._texts.cooldown ?? '';
|
|
48
|
+
endsAt = data.endsAt;
|
|
49
|
+
} else if (kind === 'error') {
|
|
50
|
+
text = this._texts[data.textKey] ?? '';
|
|
51
|
+
} else if (kind === 'talking') {
|
|
52
|
+
endsAt = data.endsAt;
|
|
53
|
+
totalMs = data.totalMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.publisher.emit('own', { kind, text, endsAt, totalMs });
|
|
57
|
+
|
|
58
|
+
if (kind === 'error') {
|
|
59
|
+
this._arm(ERROR_FLASH_MS);
|
|
60
|
+
} else if (kind === 'cooldown') {
|
|
61
|
+
this._arm(Math.max(0, endsAt - performance.now()));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Чужой эфир.
|
|
67
|
+
* @param {{ name: string, teamId: number|null, color: string|null }|null}
|
|
68
|
+
* speaker - null скрывает.
|
|
69
|
+
*/
|
|
70
|
+
setIncoming(speaker) {
|
|
71
|
+
this.publisher.emit('incoming', speaker);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
destroy() {
|
|
75
|
+
this._clearTimer();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_arm(ms) {
|
|
79
|
+
this._timer = setTimeout(() => {
|
|
80
|
+
this._timer = null;
|
|
81
|
+
this.publisher.emit('own', {
|
|
82
|
+
kind: 'hidden',
|
|
83
|
+
text: '',
|
|
84
|
+
endsAt: 0,
|
|
85
|
+
totalMs: 0,
|
|
86
|
+
});
|
|
87
|
+
}, ms);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_clearTimer() {
|
|
91
|
+
if (this._timer !== null) {
|
|
92
|
+
clearTimeout(this._timer);
|
|
93
|
+
this._timer = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
|
|
2
3
|
|
|
3
4
|
// Singleton VoteModel
|
|
4
5
|
|
|
@@ -215,7 +216,7 @@ export default class VoteModel {
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
// если keyCode это число от 0 до 9
|
|
218
|
-
if (
|
|
219
|
+
if (VOTE_ANSWER_KEYS[0] <= keyCode && keyCode <= VOTE_ANSWER_KEYS[1]) {
|
|
219
220
|
number = String.fromCharCode(keyCode);
|
|
220
221
|
number = parseInt(number, 10);
|
|
221
222
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|