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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/client/InputListener.js +3 -0
  3. package/src/client/SoundManager.js +13 -0
  4. package/src/client/components/controller/Controls.js +10 -0
  5. package/src/client/components/controller/Voice.js +31 -0
  6. package/src/client/components/model/Controls.js +64 -0
  7. package/src/client/components/model/Voice.js +96 -0
  8. package/src/client/components/model/Vote.js +2 -1
  9. package/src/client/components/view/Voice.js +172 -0
  10. package/src/client/lib/voiceState.js +78 -0
  11. package/src/client/main.js +117 -0
  12. package/src/client/network/HostConnectionManager.js +46 -5
  13. package/src/client/network/HostController.js +43 -0
  14. package/src/client/network/LoopbackTransport.js +14 -0
  15. package/src/client/network/SessionSupervisor.js +24 -0
  16. package/src/client/network/WebRtcManager.js +45 -3
  17. package/src/client/style.css +146 -3
  18. package/src/client/voice/JitterBuffer.js +137 -0
  19. package/src/client/voice/MicCapture.js +307 -0
  20. package/src/client/voice/PttMachine.js +369 -0
  21. package/src/client/voice/VoiceClient.js +336 -0
  22. package/src/client/voice/VoiceCues.js +295 -0
  23. package/src/client/voice/VoiceEncoder.js +174 -0
  24. package/src/client/voice/VoicePlayback.js +558 -0
  25. package/src/client/voice/audioData.js +35 -0
  26. package/src/client/voice/captureProcessor.worklet.js +136 -0
  27. package/src/client/voice/opusConfig.js +49 -0
  28. package/src/client/voice/voiceOutput.js +72 -0
  29. package/src/client/voice/voiceSupport.js +71 -0
  30. package/src/client/voice/voiceTones.js +90 -0
  31. package/src/config/clientServices.js +2 -0
  32. package/src/config/voiceDefaults.js +44 -0
  33. package/src/config/voiceProtocol.js +80 -0
  34. package/src/config/voteKeys.js +4 -0
  35. package/src/config/wsports.js +12 -0
  36. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  37. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  38. package/src/devtools/contract/rules/index.js +4 -0
  39. package/src/host/HostGame.js +58 -0
  40. package/src/host/PortMachine.js +19 -3
  41. package/src/host/host.worker.js +7 -0
  42. package/src/host/meta/SocketManager.js +6 -0
  43. package/src/host/meta/modules/voice/Voice.js +430 -0
  44. package/src/host/meta/modules/voice/channels.js +58 -0
  45. package/src/host/meta/modules/voice/index.js +1 -0
  46. package/src/lib/VoiceRelay.js +200 -0
  47. package/src/lib/buildClientConfig.js +7 -0
  48. package/src/lib/capabilities.js +4 -0
  49. package/src/lib/createHostRuntime.js +8 -0
  50. package/src/lib/gameConfigView.js +11 -1
  51. package/src/lib/voiceConfig.js +136 -0
  52. package/src/lib/voiceMessages.js +172 -0
  53. package/src/lib/voicePacket.js +142 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.3",
3
+ "version": "0.39.0",
4
4
  "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 (48 <= keyCode && keyCode <= 57) {
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
+ }