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
|
@@ -41,6 +41,7 @@ export default class SocketManager {
|
|
|
41
41
|
this._PORT_ACCOLADES_DATA = ports.ACCOLADES_DATA;
|
|
42
42
|
this._PORT_SESSION_DATA = ports.SESSION_DATA;
|
|
43
43
|
this._PORT_RESUME_RESULT = ports.RESUME_RESULT;
|
|
44
|
+
this._PORT_VOICE_DATA = ports.VOICE_DATA;
|
|
44
45
|
|
|
45
46
|
this._soundCues = soundCues;
|
|
46
47
|
this._initialVote = initialVote;
|
|
@@ -311,8 +312,8 @@ export default class SocketManager {
|
|
|
311
312
|
}
|
|
312
313
|
|
|
313
314
|
/**
|
|
314
|
-
* Отправка отладочного лога хоста в консоль клиента (порт CONSOLE
|
|
315
|
-
*
|
|
315
|
+
* Отправка отладочного лога хоста в консоль клиента (порт CONSOLE).
|
|
316
|
+
* Worker изолирован от DevTools вкладки —
|
|
316
317
|
* без этого канала его события в браузере не видны вовсе.
|
|
317
318
|
* @param {string} socketId
|
|
318
319
|
* @param {*} data
|
|
@@ -349,6 +350,11 @@ export default class SocketManager {
|
|
|
349
350
|
this._send(socketId, this._PORT_ACCOLADES_DATA, data);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
// голосовая связь: управление эфиром (lib/voiceMessages.js), персонально
|
|
354
|
+
sendVoice(socketId, data) {
|
|
355
|
+
this._send(socketId, this._PORT_VOICE_DATA, data);
|
|
356
|
+
}
|
|
357
|
+
|
|
352
358
|
/**
|
|
353
359
|
* Отправка сообщения чата.
|
|
354
360
|
* @param {string} socketId
|
|
@@ -4,11 +4,19 @@
|
|
|
4
4
|
// разных играх может делать разное или отсутствовать вовсе; незнакомая
|
|
5
5
|
// команда — COMMANDS_NOT_FOUND.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Контекст команд: roundManager (новый раунд, текущая карта, смена карты),
|
|
8
|
+
// timerManager (остаток времени карты), playerDataSync (ранг), chat,
|
|
9
|
+
// isDevMode и остальные поля deps.
|
|
10
|
+
//
|
|
11
|
+
// Обработчик — код игры и вызывается под защитой: его сбой (исключение или
|
|
12
|
+
// отклонённый промис) уходит в onError, матч продолжается.
|
|
10
13
|
class CommandProcessor {
|
|
11
|
-
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} deps - контекст игровых команд.
|
|
16
|
+
* @param {Object} [options]
|
|
17
|
+
* @param {Function} [options.onError] - (command, error) сбой обработчика.
|
|
18
|
+
*/
|
|
19
|
+
constructor(deps, { onError = null } = {}) {
|
|
12
20
|
this._chat = deps.chat;
|
|
13
21
|
|
|
14
22
|
// контекст игровых команд (participants, chat, scripted, roundManager,
|
|
@@ -17,6 +25,7 @@ class CommandProcessor {
|
|
|
17
25
|
this._ctx = deps;
|
|
18
26
|
|
|
19
27
|
this._commands = new Map();
|
|
28
|
+
this._onError = onError;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -36,10 +45,31 @@ class CommandProcessor {
|
|
|
36
45
|
const cmd = arr.shift();
|
|
37
46
|
const handler = this._commands.get(cmd);
|
|
38
47
|
|
|
39
|
-
if (handler) {
|
|
40
|
-
handler(this._ctx, gameId, arr);
|
|
41
|
-
} else {
|
|
48
|
+
if (!handler) {
|
|
42
49
|
this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
|
|
50
|
+
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// в Worker'е сбой стал бы необработанным reject'ом, в dedicated-процессе —
|
|
55
|
+
// uncaughtException, то есть падением матча всех игроков
|
|
56
|
+
try {
|
|
57
|
+
const result = handler(this._ctx, gameId, arr);
|
|
58
|
+
|
|
59
|
+
if (result && typeof result.then === 'function') {
|
|
60
|
+
result.then(null, error => this._fail(cmd, error));
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this._fail(cmd, error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// сбой приёмника не должен породить новый сбой
|
|
68
|
+
_fail(command, error) {
|
|
69
|
+
try {
|
|
70
|
+
this._onError?.(command, error);
|
|
71
|
+
} catch {
|
|
72
|
+
// приёмник молчит о своих сбоях
|
|
43
73
|
}
|
|
44
74
|
}
|
|
45
75
|
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import clock from '../../../../lib/clock.js';
|
|
2
|
+
import {
|
|
3
|
+
denyMessage,
|
|
4
|
+
endMessage,
|
|
5
|
+
grantMessage,
|
|
6
|
+
startMessage,
|
|
7
|
+
stopMessage,
|
|
8
|
+
} from '../../../../lib/voiceMessages.js';
|
|
9
|
+
import {
|
|
10
|
+
VOICE_DENY,
|
|
11
|
+
VOICE_END,
|
|
12
|
+
VOICE_HOST_GRACE_MS,
|
|
13
|
+
VOICE_MAX_TALK_ID,
|
|
14
|
+
VOICE_REQUEST_MIN_INTERVAL_MS,
|
|
15
|
+
} from '../../../../config/voiceProtocol.js';
|
|
16
|
+
import { isVoiceSpeakerId } from '../../../../lib/voicePacket.js';
|
|
17
|
+
import { VOICE_DEFAULTS } from '../../../../config/voiceDefaults.js';
|
|
18
|
+
import { listenChannel, speakChannel } from './channels.js';
|
|
19
|
+
|
|
20
|
+
// Управление эфиром (floor control): кто говорит, кто слышит, таймауты,
|
|
21
|
+
// отзыв. Аудио сюда не попадает никогда — пакеты раздаёт ретранслятор
|
|
22
|
+
// транспортного уровня по маршрутам, которые модуль публикует через
|
|
23
|
+
// setRouteSink. Состояние эфемерно: в контрольные точки не пишется.
|
|
24
|
+
export default class Voice {
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} [config] - нормализованный gameConfig.voice; без него
|
|
27
|
+
* берутся VOICE_DEFAULTS (связь выключена).
|
|
28
|
+
* @param {Object} deps
|
|
29
|
+
* @param {Object} deps.participants - ParticipantManager.
|
|
30
|
+
* @param {Object} deps.socketManager - sendVoice(socketId, data).
|
|
31
|
+
* @param {Function} [deps.isAvailable] - () => boolean: хост может выдавать
|
|
32
|
+
* эфир (матч не стоит; причины паузы знает HostGame).
|
|
33
|
+
* @param {Function} [deps.now] - часы.
|
|
34
|
+
*/
|
|
35
|
+
constructor(config, { participants, socketManager, isAvailable, now }) {
|
|
36
|
+
this._config = config ?? VOICE_DEFAULTS;
|
|
37
|
+
this._participants = participants;
|
|
38
|
+
this._socketManager = socketManager;
|
|
39
|
+
this._isAvailable = isAvailable ?? (() => true);
|
|
40
|
+
this._now = now ?? (() => clock.now());
|
|
41
|
+
this._routeSink = null;
|
|
42
|
+
|
|
43
|
+
// канал → эфир; gameId говорящего → эфир
|
|
44
|
+
this._talks = new Map();
|
|
45
|
+
this._speakerTalks = new Map();
|
|
46
|
+
this._cooldownUntil = new Map();
|
|
47
|
+
this._lastRequestAt = new Map();
|
|
48
|
+
this._nextTalkId = 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Приёмник маршрутов ретранслятора. Без приёмника модуль работает,
|
|
53
|
+
* маршруты не публикуются.
|
|
54
|
+
* @param {Function|null} sink - ({ speakerSocketId, route }) => void.
|
|
55
|
+
*/
|
|
56
|
+
setRouteSink(sink) {
|
|
57
|
+
this._routeSink = typeof sink === 'function' ? sink : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get enabled() {
|
|
61
|
+
return this._config.enabled === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Запрос эфира. Порядок проверок фиксирован.
|
|
66
|
+
* @param {string} gameId
|
|
67
|
+
*/
|
|
68
|
+
request(gameId) {
|
|
69
|
+
const participant = this._participants.get(gameId);
|
|
70
|
+
|
|
71
|
+
if (!participant?.isNetworked) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const now = this._now();
|
|
76
|
+
|
|
77
|
+
if (!this.enabled || !this._isAvailable()) {
|
|
78
|
+
this._sendTo(gameId, denyMessage(VOICE_DENY.UNAVAILABLE, 0));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const wireId = Number(gameId);
|
|
83
|
+
|
|
84
|
+
// id на проводе — u8; при ≤ 64 участниках недостижимо
|
|
85
|
+
if (!isVoiceSpeakerId(wireId)) {
|
|
86
|
+
this._sendTo(gameId, denyMessage(VOICE_DENY.FORBIDDEN, 0));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lastRequestAt = this._lastRequestAt.get(gameId);
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
lastRequestAt !== undefined &&
|
|
94
|
+
now - lastRequestAt < VOICE_REQUEST_MIN_INTERVAL_MS
|
|
95
|
+
) {
|
|
96
|
+
this._sendTo(
|
|
97
|
+
gameId,
|
|
98
|
+
denyMessage(
|
|
99
|
+
VOICE_DENY.COOLDOWN,
|
|
100
|
+
VOICE_REQUEST_MIN_INTERVAL_MS - (now - lastRequestAt),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this._lastRequestAt.set(gameId, now);
|
|
107
|
+
|
|
108
|
+
const own = this._speakerTalks.get(gameId);
|
|
109
|
+
|
|
110
|
+
// повторный запрос во время своего эфира идемпотентен
|
|
111
|
+
if (own) {
|
|
112
|
+
this._sendTo(
|
|
113
|
+
gameId,
|
|
114
|
+
grantMessage(
|
|
115
|
+
own.talkId,
|
|
116
|
+
Math.max(0, this._config.maxDurationMs - (now - own.startedAt)),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const channel = speakChannel(participant, this._config);
|
|
123
|
+
|
|
124
|
+
if (!this._isReady(participant) || channel === null) {
|
|
125
|
+
this._sendTo(gameId, denyMessage(VOICE_DENY.FORBIDDEN, 0));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cooldownUntil = this._cooldownUntil.get(gameId) ?? 0;
|
|
130
|
+
|
|
131
|
+
if (cooldownUntil > now) {
|
|
132
|
+
this._sendTo(
|
|
133
|
+
gameId,
|
|
134
|
+
denyMessage(VOICE_DENY.COOLDOWN, cooldownUntil - now),
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (this._talks.has(channel)) {
|
|
140
|
+
this._sendTo(gameId, denyMessage(VOICE_DENY.BUSY, 0));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const talk = {
|
|
145
|
+
talkId: this._allocTalkId(),
|
|
146
|
+
speakerId: gameId,
|
|
147
|
+
wireId,
|
|
148
|
+
speakerSocketId: participant.socketId,
|
|
149
|
+
channel,
|
|
150
|
+
startedAt: now,
|
|
151
|
+
listeners: new Map(),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
this._talks.set(channel, talk);
|
|
155
|
+
this._speakerTalks.set(gameId, talk);
|
|
156
|
+
|
|
157
|
+
// маршрут и START раньше GRANT: ретранслятор должен знать маршрут до
|
|
158
|
+
// первого пакета говорящего
|
|
159
|
+
this._refreshListeners(talk, this._bucketListeners().get(channel));
|
|
160
|
+
this._sendTo(gameId, grantMessage(talk.talkId, this._config.maxDurationMs));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Отпускание эфира; запоздавшее отпускание старого эфира игнорируется.
|
|
165
|
+
* @param {string} gameId
|
|
166
|
+
* @param {number} talkId
|
|
167
|
+
*/
|
|
168
|
+
release(gameId, talkId) {
|
|
169
|
+
const talk = this._speakerTalks.get(gameId);
|
|
170
|
+
|
|
171
|
+
if (talk && talk.talkId === talkId) {
|
|
172
|
+
this._end(talk, VOICE_END.RELEASED);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Раз в снапшот-тик, после рассылки кадров: отзыв, таймаут, слушатели.
|
|
178
|
+
*/
|
|
179
|
+
tick() {
|
|
180
|
+
if (this._talks.size === 0) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const now = this._now();
|
|
185
|
+
const alive = [];
|
|
186
|
+
|
|
187
|
+
for (const talk of [...this._talks.values()]) {
|
|
188
|
+
const speaker = this._participants.get(talk.speakerId);
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
!this._isReady(speaker) ||
|
|
192
|
+
speakChannel(speaker, this._config) !== talk.channel ||
|
|
193
|
+
speaker.socketId !== talk.speakerSocketId
|
|
194
|
+
) {
|
|
195
|
+
this._end(talk, VOICE_END.REVOKED);
|
|
196
|
+
} else if (
|
|
197
|
+
now - talk.startedAt >=
|
|
198
|
+
this._config.maxDurationMs + VOICE_HOST_GRACE_MS
|
|
199
|
+
) {
|
|
200
|
+
this._end(talk, VOICE_END.TIMEOUT);
|
|
201
|
+
} else {
|
|
202
|
+
alive.push(talk);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (alive.length === 0) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const buckets = this._bucketListeners();
|
|
211
|
+
|
|
212
|
+
for (const talk of alive) {
|
|
213
|
+
this._refreshListeners(talk, buckets.get(talk.channel));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Участник снят: его эфир кончается без отправок ему, из слушателей он
|
|
219
|
+
* исчезает без уведомлений.
|
|
220
|
+
* @param {string} gameId
|
|
221
|
+
*/
|
|
222
|
+
removeUser(gameId) {
|
|
223
|
+
const own = this._speakerTalks.get(gameId);
|
|
224
|
+
|
|
225
|
+
if (own) {
|
|
226
|
+
this._end(own, VOICE_END.REVOKED, { speaker: false });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const talk of this._talks.values()) {
|
|
230
|
+
if (talk.listeners.delete(gameId)) {
|
|
231
|
+
this._publishRoute(talk);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this._cooldownUntil.delete(gameId);
|
|
236
|
+
this._lastRequestAt.delete(gameId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Все эфиры заканчиваются (REVOKED), кулдауны забываются.
|
|
241
|
+
* @param {Object} [options]
|
|
242
|
+
* @param {boolean} [options.notify=true] - слать END/STOP.
|
|
243
|
+
*/
|
|
244
|
+
reset({ notify = true } = {}) {
|
|
245
|
+
for (const talk of [...this._talks.values()]) {
|
|
246
|
+
this._end(talk, VOICE_END.REVOKED, {
|
|
247
|
+
speaker: notify,
|
|
248
|
+
listeners: notify,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this._cooldownUntil.clear();
|
|
253
|
+
this._lastRequestAt.clear();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
debugState() {
|
|
257
|
+
const now = this._now();
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
talks: [...this._talks.values()].map(talk => ({
|
|
261
|
+
talkId: talk.talkId,
|
|
262
|
+
speakerId: talk.speakerId,
|
|
263
|
+
channel: talk.channel,
|
|
264
|
+
listeners: talk.listeners.size,
|
|
265
|
+
ageMs: now - talk.startedAt,
|
|
266
|
+
})),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_isReady(participant) {
|
|
271
|
+
return Boolean(
|
|
272
|
+
participant?.isNetworked &&
|
|
273
|
+
participant.isReady &&
|
|
274
|
+
participant.detachedAt === null,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// отправка по ТЕКУЩЕМУ socketId: неизвестный сокет SocketManager ругается
|
|
279
|
+
_sendTo(gameId, message) {
|
|
280
|
+
const participant = this._participants.get(gameId);
|
|
281
|
+
|
|
282
|
+
if (participant) {
|
|
283
|
+
this._socketManager.sendVoice(participant.socketId, message);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 1..VOICE_MAX_TALK_ID по кругу, занятые активными эфирами пропускаются
|
|
288
|
+
// (эфиров не больше, чем каналов, свободный найдётся всегда)
|
|
289
|
+
_allocTalkId() {
|
|
290
|
+
const busy = new Set();
|
|
291
|
+
|
|
292
|
+
for (const talk of this._talks.values()) {
|
|
293
|
+
busy.add(talk.talkId);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (;;) {
|
|
297
|
+
const talkId = this._nextTalkId;
|
|
298
|
+
|
|
299
|
+
this._nextTalkId = talkId === VOICE_MAX_TALK_ID ? 1 : talkId + 1;
|
|
300
|
+
|
|
301
|
+
if (!busy.has(talkId)) {
|
|
302
|
+
return talkId;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_end(talk, reason, { speaker = true, listeners = true } = {}) {
|
|
308
|
+
this._talks.delete(talk.channel);
|
|
309
|
+
this._speakerTalks.delete(talk.speakerId);
|
|
310
|
+
|
|
311
|
+
this._routeSink?.({
|
|
312
|
+
speakerSocketId: talk.speakerSocketId,
|
|
313
|
+
route: null,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (listeners) {
|
|
317
|
+
for (const gameId of talk.listeners.keys()) {
|
|
318
|
+
if (gameId !== talk.speakerId) {
|
|
319
|
+
this._sendTo(gameId, stopMessage(talk.wireId, talk.talkId));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (speaker) {
|
|
325
|
+
this._sendTo(talk.speakerId, stopMessage(talk.wireId, talk.talkId));
|
|
326
|
+
this._sendTo(talk.speakerId, endMessage(talk.talkId, reason));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this._cooldownUntil.set(
|
|
330
|
+
talk.speakerId,
|
|
331
|
+
this._now() + this._config.cooldownMs,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// канал → Map<gameId, socketId> слушателей; один проход на тик
|
|
336
|
+
_bucketListeners() {
|
|
337
|
+
const activeIds = new Set(this._participants.getActiveList());
|
|
338
|
+
const buckets = new Map();
|
|
339
|
+
|
|
340
|
+
for (const participant of this._participants.getNetworkedReady()) {
|
|
341
|
+
const channel = listenChannel(participant, {
|
|
342
|
+
scope: this._config.scope,
|
|
343
|
+
participants: this._participants,
|
|
344
|
+
activeIds,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (channel === null) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let bucket = buckets.get(channel);
|
|
352
|
+
|
|
353
|
+
if (!bucket) {
|
|
354
|
+
bucket = new Map();
|
|
355
|
+
buckets.set(channel, bucket);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
bucket.set(participant.gameId, participant.socketId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return buckets;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// дифф слушателей эфира: START добавленным, STOP ушедшим, новый маршрут.
|
|
365
|
+
// Переподключённому (socketId сменился без отсоединения) STOP на старый
|
|
366
|
+
// сокет не шлётся — получателя у него нет
|
|
367
|
+
_refreshListeners(talk, bucket) {
|
|
368
|
+
const desired = new Map(bucket ?? []);
|
|
369
|
+
|
|
370
|
+
desired.set(talk.speakerId, talk.speakerSocketId);
|
|
371
|
+
|
|
372
|
+
const added = [];
|
|
373
|
+
const gone = [];
|
|
374
|
+
|
|
375
|
+
for (const [gameId, socketId] of desired) {
|
|
376
|
+
if (talk.listeners.get(gameId) !== socketId) {
|
|
377
|
+
added.push([gameId, socketId]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const gameId of talk.listeners.keys()) {
|
|
382
|
+
if (!desired.has(gameId)) {
|
|
383
|
+
gone.push(gameId);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (added.length === 0 && gone.length === 0) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
talk.listeners = desired;
|
|
392
|
+
this._publishRoute(talk);
|
|
393
|
+
|
|
394
|
+
for (const gameId of gone) {
|
|
395
|
+
this._sendTo(gameId, stopMessage(talk.wireId, talk.talkId));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const speaker = this._participants.get(talk.speakerId);
|
|
399
|
+
const start = startMessage({
|
|
400
|
+
speakerId: talk.wireId,
|
|
401
|
+
talkId: talk.talkId,
|
|
402
|
+
name: speaker.name,
|
|
403
|
+
teamId: speaker.teamId,
|
|
404
|
+
chatColor: speaker.chatColor ?? null,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
for (const [, socketId] of added) {
|
|
408
|
+
this._socketManager.sendVoice(socketId, start);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_publishRoute(talk) {
|
|
413
|
+
const listenerSocketIds = [];
|
|
414
|
+
|
|
415
|
+
for (const [gameId, socketId] of talk.listeners) {
|
|
416
|
+
if (gameId !== talk.speakerId) {
|
|
417
|
+
listenerSocketIds.push(socketId);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
this._routeSink?.({
|
|
422
|
+
speakerSocketId: talk.speakerSocketId,
|
|
423
|
+
route: {
|
|
424
|
+
talkId: talk.talkId,
|
|
425
|
+
speakerGameId: talk.wireId,
|
|
426
|
+
listenerSocketIds,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// канал «вся комната» (scope 'global')
|
|
2
|
+
export const GLOBAL_CHANNEL = 'global';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Канал, в который участник может говорить; null — говорить нельзя.
|
|
6
|
+
* Говорит только человек, сам управляющий живым актором.
|
|
7
|
+
* @param {Object} participant
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} options.scope - 'team' | 'global'.
|
|
10
|
+
* @returns {number|string|null}
|
|
11
|
+
*/
|
|
12
|
+
export function speakChannel(participant, { scope }) {
|
|
13
|
+
if (!participant?.isNetworked || participant.isWatching !== false) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return scope === 'global' ? GLOBAL_CHANNEL : participant.teamId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Канал, который участник слышит; null — не слышит ничего. Живой игрок —
|
|
22
|
+
* свою команду; любой наблюдающий (наблюдатель, погибший, ждущий респауна) —
|
|
23
|
+
* команду того, за чьей камерой следит.
|
|
24
|
+
* @param {Object} participant
|
|
25
|
+
* @param {Object} options
|
|
26
|
+
* @param {string} options.scope - 'team' | 'global'.
|
|
27
|
+
* @param {Object} options.participants - ParticipantManager.
|
|
28
|
+
* @param {Set<string>} [options.activeIds] - живые id (Voice строит один раз
|
|
29
|
+
* на тик); без него берётся participants.getActiveList().
|
|
30
|
+
* @returns {number|string|null}
|
|
31
|
+
*/
|
|
32
|
+
export function listenChannel(participant, { scope, participants, activeIds }) {
|
|
33
|
+
if (!participant?.isNetworked) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (scope === 'global') {
|
|
38
|
+
return GLOBAL_CHANNEL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (participant.isWatching === false) {
|
|
42
|
+
return participant.teamId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const watchedId = participant.watchedGameId;
|
|
46
|
+
|
|
47
|
+
// наблюдаемый обязан быть в активном списке: камера наблюдателя смотрит
|
|
48
|
+
// только на него (HostGame._onShotTick → getCamera)
|
|
49
|
+
const isActive = activeIds
|
|
50
|
+
? activeIds.has(watchedId)
|
|
51
|
+
: participants.getActiveList().includes(watchedId);
|
|
52
|
+
|
|
53
|
+
if (watchedId === null || !isActive) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return participants.get(watchedId)?.teamId ?? null;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './Voice.js';
|