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
|
@@ -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
|
+
}
|
|
@@ -4,9 +4,10 @@ import { resolveDescriptor } from '../../lib/formControls.js';
|
|
|
4
4
|
import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
|
|
5
5
|
|
|
6
6
|
// Общий билдер полей форм (room-форма и auth-форма используют один
|
|
7
|
-
// контракт дескрипторов —
|
|
8
|
-
// schema"). control:
|
|
9
|
-
// нативные элементы формы,
|
|
7
|
+
// контракт дескрипторов —
|
|
8
|
+
// VIMP docs: plugin-api.md § "Form schema"). control:
|
|
9
|
+
// 'select'|'text'|'checkbox'|'radio'; все контролы — нативные элементы формы,
|
|
10
|
+
// без визуальной кастомизации.
|
|
10
11
|
//
|
|
11
12
|
// Имя контрола приезжает от игры и разрешается через append-only реестр
|
|
12
13
|
// (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
|
|
@@ -270,8 +271,8 @@ const builders = {
|
|
|
270
271
|
// control» на имени, которое реестр считает живым
|
|
271
272
|
export const FORM_CONTROLS = Object.keys(builders);
|
|
272
273
|
|
|
273
|
-
// строит одно поле формы по дескриптору
|
|
274
|
-
// "Form schema")
|
|
274
|
+
// строит одно поле формы по дескриптору
|
|
275
|
+
// (VIMP docs: plugin-api.md § "Form schema")
|
|
275
276
|
export function buildField(descriptor, ctx = {}) {
|
|
276
277
|
// алиас выведенного контрола ('range' → numeric 'text') разрешается
|
|
277
278
|
// здесь же, где строится поле, и теми же правилами, что в валидации
|
|
@@ -292,7 +293,7 @@ export function buildField(descriptor, ctx = {}) {
|
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
// засеивает default каждого дескриптора room-формы значением из roomDefaults
|
|
295
|
-
// (docs
|
|
296
|
+
// (VIMP docs: plugin-api.md § "Form schema": roomDefaults остаётся
|
|
296
297
|
// единственным источником значений по умолчанию); явный descriptor.default,
|
|
297
298
|
// если он есть в схеме, побеждает. Вынесено из populateRoomForm (main.js),
|
|
298
299
|
// чтобы этот же код был накрыт тестом, а не продублирован в нём
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Голосование «Change host» (host-migration этап 10): команда /changehost и
|
|
2
2
|
// окно у гостей. Голоса считает мастер, поэтому команда в лобби-режиме не
|
|
3
|
-
// уходит хосту. В solo
|
|
3
|
+
// уходит хосту. В solo мастера нет — команда остаётся обычным
|
|
4
4
|
// текстом для игры.
|
|
5
5
|
|
|
6
6
|
// имя голосования движка; префикс '@' зарезервирован движком
|
|
@@ -13,7 +13,7 @@ const COMMAND = '/changehost';
|
|
|
13
13
|
/**
|
|
14
14
|
* Разбор сообщения чата.
|
|
15
15
|
* @param {string} text
|
|
16
|
-
* @param {string} bootMode - 'lobby' | 'solo'
|
|
16
|
+
* @param {string} bootMode - 'lobby' | 'solo'.
|
|
17
17
|
* @returns {'start'|'usage'|null} null — не перехватывать.
|
|
18
18
|
*/
|
|
19
19
|
export function parseChangeHost(text, bootMode) {
|
|
@@ -19,23 +19,14 @@ export function isGameAvailable(manifest) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Активная игра
|
|
23
|
-
*
|
|
24
|
-
* Обе ветки фильтруются одинаково: сохранённый выбор игры (`gameId`) тоже мог
|
|
25
|
-
* указывать на игру, ставшую недоступной после обновления движка, и брать её
|
|
26
|
-
* «раз уж попросили» значило бы падать там, где можно объяснить.
|
|
22
|
+
* Активная игра вкладки: первая доступная игра каталога.
|
|
27
23
|
* @param {Array<Object>} manifests - Каталог мастера.
|
|
28
|
-
* @param {string} [gameId] - Сохранённый выбор игры (boot.gameId).
|
|
29
24
|
* @returns {Object|undefined} Манифест активной игры.
|
|
30
25
|
* @throws {Error} Если каталог непустой, но играбельной игры в нём нет —
|
|
31
26
|
* с причиной по каждой строке.
|
|
32
27
|
*/
|
|
33
|
-
export function pickActiveGame(manifests
|
|
34
|
-
const active =
|
|
35
|
-
? manifests.find(
|
|
36
|
-
manifest => manifest.id === gameId && isGameAvailable(manifest),
|
|
37
|
-
)
|
|
38
|
-
: manifests.find(isGameAvailable);
|
|
28
|
+
export function pickActiveGame(manifests) {
|
|
29
|
+
const active = manifests.find(isGameAvailable);
|
|
39
30
|
|
|
40
31
|
if (active) {
|
|
41
32
|
return active;
|
|
@@ -19,15 +19,6 @@ export class NetworkFailure extends Error {
|
|
|
19
19
|
|
|
20
20
|
export const isNetworkFailure = error => error instanceof NetworkFailure;
|
|
21
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
22
|
/**
|
|
32
23
|
* @param {Function} doFetch - (...args) => Promise<Response>: сам fetch.
|
|
33
24
|
* @param {*} url
|
|
@@ -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
|
+
}
|