vimp-engine 0.32.3 → 0.33.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.32.3",
3
+ "version": "0.33.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,26 +1,88 @@
1
1
  import { Howl, Howler } from 'howler';
2
2
 
3
+ import {
4
+ SPATIAL_DEFAULTS,
5
+ SPATIAL_NUMERIC,
6
+ SPATIAL_MODES,
7
+ PANNING_MODELS,
8
+ DISTANCE_MODELS,
9
+ } from '../config/spatialDefaults.js';
10
+
3
11
  // глобальный лимит звуков
4
12
  const WORLD_VOICE_LIMIT = 30;
5
13
 
6
- // дед-зона панорамирования, в мировых пикселях (порядка половины тайла).
7
- // Порог по НАПРАВЛЕНИЮ, а не по громкости: азимут в Web Audio зависит от
8
- // вектора на источник, а не от расстояния до него, поэтому расхождение
9
- // камеры и источника в пару пикселей давало полную панораму и её рывки
10
- const MIN_SPATIAL_DISTANCE = 16;
11
-
12
- // настройки пространственного звука
13
- const PANNER_SETTINGS = {
14
- panningModel: 'HRTF', // модель панорамирования
15
- distanceModel: 'inverse', // модель затухания звука с расстоянием
16
- refDistance: 150, // расстояние px), на котором громкость равна 100%
17
- maxDistance: 1000, // расстояние, дальше которого звук не слышен
18
- rolloffFactor: 1, // коэффициент затухания (больше - быстрее затухает)
19
- coneInnerAngle: 360, // звук распространяется во все стороны одинаково
14
+ // Позиция паннера переписывается не чаще 30 Гц. Каждый pos() внутри
15
+ // Howler это три setValueAtTime на positionX/Y/Z плюс событие 'pos'; на
16
+ // 60 Гц и лимите в WORLD_VOICE_LIMIT голосов это тысячи записей
17
+ // автоматизации в секунду. WebKit отвечает на такой поток артефактами,
18
+ // клиппингом и обрывами (HRTF там — настоящая свёртка на каждый узел, а
19
+ // не дешёвая аппроксимация), а на слух панорама между 30 и 60 Гц не
20
+ // различается: 30 Гц — обычный темп обновления позиций в игровом звуке
21
+ const POSITION_UPDATE_INTERVAL = 1000 / 30;
22
+
23
+ // Допуск к границе интервала. Кадр 60 Гц приходит каждые ~16.7 мс, и без
24
+ // допуска кадр, опоздавший к границе на доли миллисекунды, уезжает на
25
+ // следующий: такт скачет между 30 и 20 Гц, а шаг панорамы становится
26
+ // неравномерным ровно то, ради чего гейт и ставился
27
+ const POSITION_UPDATE_TOLERANCE = 2;
28
+
29
+ // Порог смещения, ниже которого позиция не переписывается. Снимает поток
30
+ // pos() с неподвижных источников — их большинство: горящий остов,
31
+ // эмбиент, подобранный предмет
32
+ const POSITION_EPSILON = 0.01;
33
+
34
+ // Конусные атрибуты узла: звук распространяется во все стороны одинаково.
35
+ // Игрой не настраиваются — направленных источников в 2D нет
36
+ const CONE_SETTINGS = {
37
+ coneInnerAngle: 360,
20
38
  coneOuterAngle: 0,
21
39
  coneOuterGain: 0,
22
40
  };
23
41
 
42
+ // Профиль проекции: как мировой вектор (sx, sy) и высота слушателя H
43
+ // ложатся на оси Web Audio. Слушатель смотрит в -Z (верх экрана), его
44
+ // «вверх» — +Y, поэтому мировой y (растёт вниз) входит со знаком минус
45
+ // везде, где попадает на ось Y.
46
+ export const SPATIAL_PROFILES = {
47
+ // вид сверху 360°: уши над полем боя, источник под ними
48
+ topDown: {
49
+ panningModel: 'HRTF',
50
+ mapCoords: (sx, sy, h) => [sx, -h, sy],
51
+ },
52
+
53
+ // вид сбоку: слышимость лево/право, вертикаль занижена, глубина фиксирована
54
+ sideScroller: {
55
+ panningModel: 'equalpower',
56
+ mapCoords: (sx, sy, h, cfg) => [sx, -sy * cfg.verticalFactor, -h],
57
+ },
58
+
59
+ // вид из кабины: сфера перед глазами, глубина фиксирована
60
+ cockpit: {
61
+ panningModel: 'HRTF',
62
+ mapCoords: (sx, sy, h) => [sx, -sy, -h],
63
+ },
64
+ };
65
+
66
+ /**
67
+ * Кубическая интерполяция Эрмита: 0 при value <= min, 1 при value >= max,
68
+ * гладкая (вместе с первой производной) между ними. Гладкость здесь и есть
69
+ * смысл: любой порог с разрывом слышен как щелчок.
70
+ * @param {number} min
71
+ * @param {number} max
72
+ * @param {number} value
73
+ * @returns {number} Значение в [0, 1].
74
+ */
75
+ function smoothstep(min, max, value) {
76
+ // вырожденный интервал (innerRadius: 0) — деления не делаем
77
+ if (max <= min) {
78
+ return value > min ? 1 : 0;
79
+ }
80
+
81
+ const x = Math.max(0, Math.min(1, (value - min) / (max - min)));
82
+
83
+ return x * x * (3 - 2 * x);
84
+ }
85
+
24
86
  /**
25
87
  * @class SoundManager
26
88
  * @description Централизованный "режиссёр звука". Управляет загрузкой,
@@ -43,13 +105,31 @@ export default class SoundManager {
43
105
  // pannerAttr ставится один раз на экземпляр, а не каждый кадр
44
106
  this._equalPowerIds = new Set();
45
107
 
46
- // экземпляры, у которых PannerNode УЖЕ создан. Howler создаёт узел
47
- // лениво на первом pos()/pannerAttr(id)и заканчивает создание
48
- // парой pause()/play() (setupPanner в howler.js), то есть щелчком.
49
- // Пока источник стоит в точке слушателя, паннер не нужен вовсе:
50
- // сигнал идёт прямо в gain, сохраняя стерео сэмпла
108
+ // экземпляры, у которых PannerNode УЖЕ создан. Множество обслуживает
109
+ // только возврат в центр для spatial: false мировой источник
110
+ // получает узел сразу, на первом же кадре. Howler создаёт узел лениво
111
+ // на первом pos()/pannerAttr(id) и заканчивает создание парой
112
+ // pause()/play() (setupPanner в howler.js), то есть щелчком, поэтому
113
+ // источнику игрока узел не заводится вовсе: сигнал идёт прямо в gain,
114
+ // сохраняя стерео сэмпла
51
115
  this._pannedIds = new Set();
52
116
 
117
+ // последняя записанная в узел позиция: soundId -> [x, y, z]. Служит
118
+ // порогом POSITION_EPSILON — неподвижный источник не переписывает
119
+ // автоматизацию паннера впустую
120
+ this._pannerPos = new Map();
121
+
122
+ // время последней записи позиций (performance.now), общее на кадр:
123
+ // гейт POSITION_UPDATE_INTERVAL
124
+ this._lastPositionWrite = -Infinity;
125
+
126
+ // разрешённая геометрия пространственного звука: числа профиля + его
127
+ // mapCoords. Ставится в init(), между матчами (reset) не меняется
128
+ this._spatial = this._resolveSpatialConfig();
129
+
130
+ // множитель зума камеры: 1 — покой, < 1 — динамическое отдаление
131
+ this._listenerScale = 1;
132
+
53
133
  // позиция слушателя
54
134
  this._listenerX = 0;
55
135
  this._listenerY = 0;
@@ -65,10 +145,19 @@ export default class SoundManager {
65
145
  * @param {string} soundsConfig.path - Путь к директории со звуками.
66
146
  * @param {object} soundsConfig.sounds - Словарь, где ключ - имя звука,
67
147
  * а значение - объект конфигурации { file, priority, loop, volume }.
148
+ * @param {object} [soundsConfig.spatial] - Геометрия пространственного
149
+ * звука: mode ('topDown' | 'sideScroller' | 'cockpit'), virtualElevation,
150
+ * innerRadius, verticalFactor, panningModel ('HRTF' | 'equalpower'),
151
+ * distanceModel ('linear' | 'inverse' | 'exponential'), refDistance,
152
+ * maxDistance, rolloffFactor. Все ключи необязательны, отсутствующие
153
+ * берутся из движковых дефолтов.
68
154
  * @returns {Promise<void>} Promise, который разрешается после загрузки.
69
155
  */
70
156
  async init(soundsConfig) {
71
- const { codecList, path, sounds } = soundsConfig;
157
+ const { codecList, path, sounds, spatial } = soundsConfig;
158
+
159
+ this._spatial = this._resolveSpatialConfig(spatial);
160
+
72
161
  const supportedCodec = codecList.find(codec => Howler.codecs(codec));
73
162
 
74
163
  Howler.usingWebAudio = true;
@@ -102,7 +191,14 @@ export default class SoundManager {
102
191
  volume,
103
192
  onload: () => {
104
193
  this._sounds.set(soundName, {
105
- sound: soundInstance.pannerAttr(PANNER_SETTINGS),
194
+ sound: soundInstance.pannerAttr({
195
+ panningModel: this._spatial.panningModel,
196
+ distanceModel: this._spatial.distanceModel,
197
+ refDistance: this._spatial.refDistance,
198
+ maxDistance: this._spatial.maxDistance,
199
+ rolloffFactor: this._spatial.rolloffFactor,
200
+ ...CONE_SETTINGS,
201
+ }),
106
202
  config: { ...soundData, priority: soundData.priority ?? 50 },
107
203
  });
108
204
 
@@ -140,14 +236,17 @@ export default class SoundManager {
140
236
  }
141
237
 
142
238
  /**
143
- * Устанавливает позицию слушателя (игрока) в 2D-пространстве для
144
- * корректного расчета 3D-звука.
145
- * @param {number} x - Координата X слушателя.
146
- * @param {number} y - Координата Y слушателя.
239
+ * Устанавливает позицию слушателя и текущий зум камеры.
240
+ * @param {number} x - Мировая координата X слушателя.
241
+ * @param {number} y - Мировая координата Y слушателя.
242
+ * @param {number} [scale=1] - Множитель зума камеры: 1 в покое, меньше
243
+ * единицы при динамическом отдалении. Вызов с двумя аргументами
244
+ * сохраняет прежнее поведение полностью.
147
245
  */
148
- setListenerPosition(x, y) {
246
+ setListenerPosition(x, y, scale = 1) {
149
247
  this._listenerX = x;
150
248
  this._listenerY = y;
249
+ this._listenerScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
151
250
  }
152
251
 
153
252
  /**
@@ -173,7 +272,10 @@ export default class SoundManager {
173
272
  * стоит ровно на слушателе, и HRTF на нулевой дистанции сворачивается
174
273
  * в гребенчатую окраску («гул»), а не в тишину панорамы. Такому звуку
175
274
  * PannerNode не создаётся вовсе — он идёт прямо в gain и остаётся
176
- * стерео.
275
+ * стерео. Флаг рассчитан на ОДНОКРАТНОЕ переключение (владелец
276
+ * узнаёт, что танк локальный, уже после конструктора): экземпляр,
277
+ * который успел побывать мировым, переводится на equalpower
278
+ * необратимо — HRTF обратно не возвращается.
177
279
  * @param {function} [callback] - Функция, вызываемая по завершении.
178
280
  * @returns {symbol | null} Уникальный ID звука или null, если звук не найден.
179
281
  */
@@ -262,7 +364,7 @@ export default class SoundManager {
262
364
  processAudibility() {
263
365
  const candidates = [];
264
366
  const maxDistSquared =
265
- PANNER_SETTINGS.maxDistance * PANNER_SETTINGS.maxDistance;
367
+ this._spatial.maxDistance * this._spatial.maxDistance;
266
368
  const { _listenerX: lx, _listenerY: ly } = this;
267
369
  const deleteList = [];
268
370
 
@@ -323,14 +425,30 @@ export default class SoundManager {
323
425
 
324
426
  if (newSoundId !== null) {
325
427
  candidate.activeSoundId = newSoundId;
326
- this._updateSpatialSound(
327
- this._activeInstances.get(newSoundId)?.sound,
328
- newSoundId,
329
- candidate.position.x,
330
- candidate.position.y,
331
- candidate.volume,
332
- candidate.spatial,
333
- );
428
+
429
+ const started = this._activeInstances.get(newSoundId)?.sound;
430
+ const { x, y } = candidate.position;
431
+
432
+ // на старте звука гейт частоты не применяется: позиция обязана
433
+ // попасть в узел сразу, иначе первый кадр сэмпла звучит из центра
434
+ if (
435
+ this._applyVolume(
436
+ started,
437
+ newSoundId,
438
+ x,
439
+ y,
440
+ candidate.volume,
441
+ candidate.spatial,
442
+ )
443
+ ) {
444
+ this._updateSpatialSound(
445
+ started,
446
+ newSoundId,
447
+ x,
448
+ y,
449
+ candidate.spatial,
450
+ );
451
+ }
334
452
  }
335
453
  }
336
454
  }
@@ -345,6 +463,20 @@ export default class SoundManager {
345
463
  * Вызывается каждый кадр после `processAudibility`.
346
464
  */
347
465
  updateActiveSounds() {
466
+ // гейт частоты: под него попадает ТОЛЬКО запись позиции в паннер.
467
+ // Громкость, уборка мёртвых экземпляров и rate идут каждый кадр —
468
+ // громкость игра ведёт от скорости (двигатель), и ступенька в 30 Гц
469
+ // была бы слышна, а глушение за maxDistance обязано срабатывать в том
470
+ // же кадре, в котором источник ушёл за радиус
471
+ const now = performance.now();
472
+ const writePosition =
473
+ now - this._lastPositionWrite >=
474
+ POSITION_UPDATE_INTERVAL - POSITION_UPDATE_TOLERANCE;
475
+
476
+ if (writePosition) {
477
+ this._lastPositionWrite = now;
478
+ }
479
+
348
480
  for (const [soundId, activeInstance] of this._activeInstances.entries()) {
349
481
  if (!activeInstance.loop) {
350
482
  continue;
@@ -355,15 +487,12 @@ export default class SoundManager {
355
487
 
356
488
  if (!regSound) {
357
489
  sound.stop(soundId);
358
- this._activeInstances.delete(soundId);
359
- this._equalPowerIds.delete(soundId);
360
- this._pannedIds.delete(soundId);
490
+ this._forgetInstance(soundId);
361
491
  continue;
362
492
  }
363
493
 
364
494
  const { position, volume, rate, spatial } = regSound;
365
-
366
- this._updateSpatialSound(
495
+ const audible = this._applyVolume(
367
496
  sound,
368
497
  soundId,
369
498
  position.x,
@@ -372,6 +501,16 @@ export default class SoundManager {
372
501
  spatial,
373
502
  );
374
503
 
504
+ if (audible && writePosition) {
505
+ this._updateSpatialSound(
506
+ sound,
507
+ soundId,
508
+ position.x,
509
+ position.y,
510
+ spatial,
511
+ );
512
+ }
513
+
375
514
  // rate только на изменение: Howler на каждый вызов делает два seek(),
376
515
  // переписывает _rateSeek/_playStart и пересоздаёт таймер конца петли
377
516
  // — на 60 Гц это лишняя нагрузка и лишние события 'end' на каждом
@@ -415,9 +554,7 @@ export default class SoundManager {
415
554
  this._registeredSounds.delete(id);
416
555
  }
417
556
 
418
- this._activeInstances.delete(soundId);
419
- this._equalPowerIds.delete(soundId);
420
- this._pannedIds.delete(soundId);
557
+ this._forgetInstance(soundId);
421
558
  },
422
559
  soundId,
423
560
  );
@@ -434,23 +571,180 @@ export default class SoundManager {
434
571
 
435
572
  if (instanceData) {
436
573
  instanceData.sound.stop(soundId);
437
- this._activeInstances.delete(soundId);
438
- this._equalPowerIds.delete(soundId);
439
- this._pannedIds.delete(soundId);
574
+ this._forgetInstance(soundId);
575
+ }
576
+ }
577
+
578
+ /**
579
+ * @private Забывает всё, что менеджер помнил про экземпляр Howler.
580
+ * Единственная точка уборки: набор коллекций растёт, а пропущенная точка
581
+ * — это утечка, которую видно только по памяти, ни один тест её не
582
+ * поймает.
583
+ * @param {number} soundId - ID экземпляра от Howler.
584
+ */
585
+ _forgetInstance(soundId) {
586
+ this._activeInstances.delete(soundId);
587
+ this._equalPowerIds.delete(soundId);
588
+ this._pannedIds.delete(soundId);
589
+ this._pannerPos.delete(soundId);
590
+ }
591
+
592
+ /**
593
+ * @private Сводит объявленную игрой геометрию с движковыми дефолтами.
594
+ * Плагин объявляет её в parts.sounds.spatial; неверное значение не
595
+ * должно ломать аудиоконтекст, поэтому каждый ключ проверяется отдельно
596
+ * и по одному падает на дефолт с предупреждением в консоль. Статически
597
+ * то же самое ловит правило контракта E6 — здесь страховка на прод.
598
+ * @param {object} [custom] - Блок parts.sounds.spatial из конфига игры.
599
+ * @returns {object} Числа геометрии + panningModel + mapCoords профиля.
600
+ */
601
+ _resolveSpatialConfig(custom = {}) {
602
+ const source = custom && typeof custom === 'object' ? custom : {};
603
+ const warn = (key, value, fallback) =>
604
+ console.warn(
605
+ `[SoundManager] spatial.${key}: invalid value ${JSON.stringify(
606
+ value,
607
+ )}, using ${JSON.stringify(fallback)}`,
608
+ );
609
+
610
+ // число нужного знака, иначе дефолт. Знак объявлен в SPATIAL_NUMERIC —
611
+ // там же, откуда его читает правило контракта E6
612
+ const num = key => {
613
+ const value = source[key];
614
+ const fallback = SPATIAL_DEFAULTS[key];
615
+
616
+ if (value === undefined) {
617
+ return fallback;
618
+ }
619
+
620
+ const ok =
621
+ Number.isFinite(value) &&
622
+ (SPATIAL_NUMERIC[key] === 'positive' ? value > 0 : value >= 0);
623
+
624
+ if (!ok) {
625
+ warn(key, value, fallback);
626
+ }
627
+
628
+ return ok ? value : fallback;
629
+ };
630
+
631
+ // значение из закрытого списка, иначе дефолт
632
+ const pick = (key, list, fallback) => {
633
+ const value = source[key];
634
+
635
+ if (value === undefined) {
636
+ return fallback;
637
+ }
638
+
639
+ if (!list.includes(value)) {
640
+ warn(key, value, fallback);
641
+
642
+ return fallback;
643
+ }
644
+
645
+ return value;
646
+ };
647
+
648
+ const mode = pick('mode', SPATIAL_MODES, SPATIAL_DEFAULTS.mode);
649
+ const profile = SPATIAL_PROFILES[mode];
650
+
651
+ let refDistance = num('refDistance');
652
+ let maxDistance = num('maxDistance');
653
+
654
+ // PannerNode с maxDistance <= refDistance ведёт себя неопределённо.
655
+ // Откатываются ОБА ключа: пара обязана остаться согласованной, а
656
+ // починка одной половины дала бы геометрию, которую не просил никто.
657
+ // Сравниваются разрешённые значения, поэтому объявить одну дистанцию
658
+ // против дефолта второй тоже нарушение — то же условие проверяет
659
+ // статически правило контракта E6
660
+ if (maxDistance <= refDistance) {
661
+ console.warn(
662
+ `[SoundManager] spatial.maxDistance (${maxDistance}) must exceed ` +
663
+ `spatial.refDistance (${refDistance}); both fall back to ` +
664
+ `${SPATIAL_DEFAULTS.refDistance}/${SPATIAL_DEFAULTS.maxDistance}`,
665
+ );
666
+ refDistance = SPATIAL_DEFAULTS.refDistance;
667
+ maxDistance = SPATIAL_DEFAULTS.maxDistance;
440
668
  }
669
+
670
+ return {
671
+ mode,
672
+ virtualElevation: num('virtualElevation'),
673
+ innerRadius: num('innerRadius'),
674
+ verticalFactor: num('verticalFactor'),
675
+ distanceModel: pick(
676
+ 'distanceModel',
677
+ DISTANCE_MODELS,
678
+ SPATIAL_DEFAULTS.distanceModel,
679
+ ),
680
+ panningModel: pick('panningModel', PANNING_MODELS, profile.panningModel),
681
+ refDistance,
682
+ maxDistance,
683
+ rolloffFactor: num('rolloffFactor'),
684
+ mapCoords: profile.mapCoords,
685
+ };
441
686
  }
442
687
 
443
688
  /**
444
- * Обновляет громкость и 3D-позицию/панораму.
689
+ * @private Применяет громкость и решает, слышим ли источник. Отделено от
690
+ * записи позиции нарочно: позиция пишется под гейтом 30 Гц, а громкость
691
+ * обязана идти каждый кадр — игра ведёт её от скорости (двигатель), и
692
+ * ступенька в 30 Гц слышна, а глушение за maxDistance должно срабатывать
693
+ * в том же кадре, в котором источник ушёл за радиус.
694
+ * @param {Howl} sound - Экземпляр Howl.
695
+ * @param {number} soundId - ID конкретного проигрываемого экземпляра.
696
+ * @param {number} x - Мировая координата X источника.
697
+ * @param {number} y - Мировая координата Y источника.
698
+ * @param {number} volume - Громкость.
699
+ * @param {boolean} [spatial=true] - Принадлежит ли звук миру.
700
+ * @returns {boolean} `false`, если источник заглушен и позицию писать
701
+ * незачем.
702
+ */
703
+ _applyVolume(sound, soundId, x, y, volume, spatial = true) {
704
+ if (!sound || typeof soundId !== 'number') {
705
+ return false;
706
+ }
707
+
708
+ if (spatial === false) {
709
+ sound.volume(volume, soundId);
710
+
711
+ return true;
712
+ }
713
+
714
+ const dx = x - this._listenerX;
715
+ const dy = y - this._listenerY;
716
+
717
+ // отсечка в мировых координатах: она обязана совпадать с maxDistance
718
+ // самого PannerNode, поэтому зумом НЕ масштабируется — иначе движок
719
+ // считал бы источник слышимым там, где узел уже отдал тишину
720
+ if (Math.hypot(dx, dy) >= this._spatial.maxDistance) {
721
+ sound.volume(0, soundId);
722
+
723
+ return false;
724
+ }
725
+
726
+ sound.volume(volume, soundId);
727
+
728
+ return true;
729
+ }
730
+
731
+ /**
732
+ * Обновляет 3D-позицию источника. Позиция считается ОДНОЙ непрерывной
733
+ * формулой на каждом кадре: слушатель поднят над плоскостью игры на
734
+ * virtualElevation, а внутри innerRadius вектор на источник плавно
735
+ * гасится к нулю (smoothstep). Прежняя дед-зона по направлению
736
+ * (MIN_SPATIAL_DISTANCE) убрана: она переключала источник между двумя
737
+ * разными состояниями — «узла нет, сухое стерео» и «HRTF в крайнем ухе»
738
+ * — и этот разрыв тембра был слышен на дистанции в пару единиц.
739
+ * Громкость здесь не трогается — ей занимается `_applyVolume`.
445
740
  * @private
446
741
  * @param {Howl} sound - Экземпляр Howl.
447
- * @param {number} soundId - ID конкретного проигрываемого звука.
448
- * @param {number} x - Координата X источника звука.
449
- * @param {number} y - Координата Y источника звука.
450
- * @param {number} volume - Громкость звука.
742
+ * @param {number} soundId - ID конкретного проигрываемого экземпляра.
743
+ * @param {number} x - Мировая координата X источника.
744
+ * @param {number} y - Мировая координата Y источника.
451
745
  * @param {boolean} [spatial=true] - Принадлежит ли звук миру.
452
746
  */
453
- _updateSpatialSound(sound, soundId, x, y, volume, spatial = true) {
747
+ _updateSpatialSound(sound, soundId, x, y, spatial = true) {
454
748
  if (!sound || typeof soundId !== 'number') {
455
749
  return;
456
750
  }
@@ -463,32 +757,66 @@ export default class SoundManager {
463
757
  // equalpower (тот схлопывает стерео сэмпла в моно). Пока узла нет,
464
758
  // pos() не зовётся вовсе: Howler создал бы паннер и щёлкнул
465
759
  // pause()/play()
466
- sound.volume(volume, soundId);
467
760
  this._recenterIfPanned(sound, soundId, true);
468
761
 
469
762
  return;
470
763
  }
471
764
 
472
- const distance = Math.hypot(x - this._listenerX, y - this._listenerY);
473
-
474
- if (distance >= PANNER_SETTINGS.maxDistance) {
475
- sound.volume(0, soundId);
765
+ const dx = x - this._listenerX;
766
+ const dy = y - this._listenerY;
767
+ const distance = Math.hypot(dx, dy);
768
+
769
+ // зум камеры поднимает уши вместе с камерой: при отдалении картинка
770
+ // сжимается, и стереобаза обязана сжаться так же, иначе звук шире
771
+ // того, что видит глаз
772
+ const zoom = this._listenerScale;
773
+ const elevation = this._spatial.virtualElevation / zoom;
774
+ const spread = smoothstep(0, this._spatial.innerRadius / zoom, distance);
775
+
776
+ const [px, py, pz] = this._spatial.mapCoords(
777
+ dx * spread,
778
+ dy * spread,
779
+ elevation,
780
+ this._spatial,
781
+ );
476
782
 
477
- return;
478
- }
783
+ this._writePos(sound, soundId, px, py, pz);
784
+ }
479
785
 
480
- sound.volume(volume, soundId);
786
+ /**
787
+ * @private Пишет позицию в паннер, если она изменилась заметнее
788
+ * POSITION_EPSILON. Единственная точка записи: у неподвижного источника
789
+ * позиция уже в узле, и повторная запись — только лишняя автоматизация,
790
+ * а именно её поток WebKit и не переносит.
791
+ * @param {Howl} sound - Экземпляр Howl.
792
+ * @param {number} soundId - ID конкретного проигрываемого экземпляра.
793
+ * @param {number} px - Координата X в осях Web Audio.
794
+ * @param {number} py - Координата Y в осях Web Audio.
795
+ * @param {number} pz - Координата Z в осях Web Audio.
796
+ */
797
+ _writePos(sound, soundId, px, py, pz) {
798
+ const written = this._pannerPos.get(soundId);
799
+
800
+ if (written !== undefined) {
801
+ if (
802
+ Math.abs(written[0] - px) < POSITION_EPSILON &&
803
+ Math.abs(written[1] - py) < POSITION_EPSILON &&
804
+ Math.abs(written[2] - pz) < POSITION_EPSILON
805
+ ) {
806
+ return;
807
+ }
481
808
 
482
- // если дистанция позволяет панорамировать звук
483
- if (distance > MIN_SPATIAL_DISTANCE) {
484
- sound.pos(x - this._listenerX, 0, y - this._listenerY, soundId);
485
- this._pannedIds.add(soundId);
809
+ // массив переиспользуется: до WORLD_VOICE_LIMIT записей в 33 мс —
810
+ // это сотни лишних аллокаций в секунду на ровном месте
811
+ written[0] = px;
812
+ written[1] = py;
813
+ written[2] = pz;
486
814
  } else {
487
- // внутри дед-зоны звук идёт по центру. Экземпляру, у которого паннер
488
- // ещё не создан, здесь не место его заводить: узел появится на
489
- // первом же выходе из дед-зоны
490
- this._recenterIfPanned(sound, soundId);
815
+ this._pannerPos.set(soundId, [px, py, pz]);
491
816
  }
817
+
818
+ sound.pos(px, py, pz, soundId);
819
+ this._pannedIds.add(soundId);
492
820
  }
493
821
 
494
822
  /**
@@ -496,9 +824,12 @@ export default class SoundManager {
496
824
  * (источник успел побывать в стороне от слушателя). Экземпляр без узла
497
825
  * не трогается: создание паннера — это лишний узел в цепочке, потеря
498
826
  * стерео и щелчок от pause()/play() внутри setupPanner Howler'а.
499
- * @param {boolean} [equalPower=false] - Перевести узел на equalpower:
500
- * так делается только для источника игрока, миру HRTF сохраняется —
501
- * он снова понадобится, как только источник выйдет из дед-зоны.
827
+ * @param {boolean} [equalPower=false] - Перевести узел на equalpower.
828
+ * Так делается только для источника игрока, и перевод ОДНОСТОРОННИЙ:
829
+ * HRTF обратно не возвращается. Рассчитано на однократное
830
+ * переключение флага `spatial` (владелец узнаёт, что танк локальный,
831
+ * уже после конструктора); обратный переход потребовал бы явного
832
+ * восстановления HRTF.
502
833
  */
503
834
  _recenterIfPanned(sound, soundId, equalPower = false) {
504
835
  if (!this._pannedIds.has(soundId)) {
@@ -509,7 +840,10 @@ export default class SoundManager {
509
840
  this._applyEqualPower(sound, soundId);
510
841
  }
511
842
 
512
- sound.pos(0, 0, 0, soundId);
843
+ // через тот же порог: собственный двигатель игрока звучит непрерывно и
844
+ // всегда, и переписывать ему центр каждый кадр — последнее, что стоит
845
+ // оставлять в потоке, который чинится ради WebKit
846
+ this._writePos(sound, soundId, 0, 0, 0);
513
847
  }
514
848
 
515
849
  /**
@@ -562,6 +896,8 @@ export default class SoundManager {
562
896
  this._activeInstances.clear();
563
897
  this._equalPowerIds.clear();
564
898
  this._pannedIds.clear();
899
+ this._pannerPos.clear();
900
+ this._lastPositionWrite = -Infinity;
565
901
 
566
902
  // луп переживает reset: его владелец жив, и ближайший
567
903
  // processAudibility() запустит звук заново. Одноразовый — нет:
@@ -577,6 +913,7 @@ export default class SoundManager {
577
913
 
578
914
  this._listenerX = 0;
579
915
  this._listenerY = 0;
916
+ this._listenerScale = 1;
580
917
  }
581
918
 
582
919
  /**
@@ -19,6 +19,11 @@ export default class CanvasManagerCtrl {
19
19
  this._model.updateCoords(x, y, cameraReset, shakeData);
20
20
  }
21
21
 
22
+ // текущий множитель зума камеры (для позиции слушателя звука)
23
+ getCameraZoom() {
24
+ return this._model.getCameraZoom();
25
+ }
26
+
22
27
  // экранная точка указателя -> мировая (в координатах игрового полотна)
23
28
  toWorld(clientX, clientY) {
24
29
  return this._view.toWorld(this._model.pointerCanvasId, clientX, clientY);
@@ -14,6 +14,8 @@ export default class CanvasManagerModel {
14
14
  canvasManagerModel = this;
15
15
 
16
16
  this._data = {};
17
+ // хотя бы одно полотно с динамической камерой
18
+ this._hasDynamicCamera = false;
17
19
  this._coordX = 0; // текущая координата X игрока
18
20
  this._coordY = 0; // текущая координата Y игрока
19
21
 
@@ -79,6 +81,8 @@ export default class CanvasManagerModel {
79
81
  dynamicCamera: !!canvasData.dynamicCamera,
80
82
  shakeCamera: !!canvasData.shakeCamera,
81
83
  };
84
+
85
+ this._hasDynamicCamera ||= !!canvasData.dynamicCamera;
82
86
  }
83
87
  }
84
88
 
@@ -89,6 +93,32 @@ export default class CanvasManagerModel {
89
93
  return this._pointerCanvasId;
90
94
  }
91
95
 
96
+ // Множитель масштаба сцены для позиции слушателя звука: размер полотна
97
+ // (currentScale) И динамический зум. Звук обязан сжиматься вместе с
98
+ // картинкой — иначе стереобаза шире того, что видит глаз, — а сжимают её
99
+ // оба множителя: finalScale полотна это ровно их произведение.
100
+ // Отношение currentScale/baseScale — это доля расчётной ширины 1920
101
+ // (_designWidth), записанная через уже посчитанные поля, чтобы геттер не
102
+ // отстал, если формула масштаба изменится. Эталон — полотно указателя: у
103
+ // игры оно, как правило, одно и именно оно игровое.
104
+ // Если ни одно полотно динамическую камеру не использует, её множитель
105
+ // обязан быть 1: сам модификатор считается всегда, и звук «отъезжал» бы
106
+ // там, где картинка стоит на месте
107
+ getCameraZoom() {
108
+ const canvas = this._data[this._pointerCanvasId];
109
+
110
+ // до первого resize (и у полотна с fixSize) currentScale равен
111
+ // baseScale, то есть множитель ровно 1
112
+ if (!canvas || !canvas.baseScale) {
113
+ return 1;
114
+ }
115
+
116
+ const canvasScale = canvas.currentScale / canvas.baseScale;
117
+ const zoom = this._hasDynamicCamera ? this._camZoomModifier : 1;
118
+
119
+ return canvasScale * zoom;
120
+ }
121
+
92
122
  // рассчитывает размеры элементов с учетом пропорций
93
123
  resize(data) {
94
124
  const screenWidth = data.width;
@@ -0,0 +1,29 @@
1
+ // Применение данных камеры: позиция слушателя звука + полотно. Вынесено из
2
+ // main.js отдельным модулем ради одной вещи — порядка вызовов, который
3
+ // ничем больше не защищён: main.js целиком поднять в тесте нельзя, у него
4
+ // побочные эффекты уровня приложения.
5
+
6
+ /**
7
+ * Применяет кадр камеры: сначала полотно, затем слушатель звука.
8
+ *
9
+ * Порядок обязателен. Множитель зума пересчитывается внутри
10
+ * `updateCoords`, и слушателю нужен зум ЭТОГО кадра, а не прошлого: при
11
+ * обратном порядке стереобаза отстаёт от картинки на кадр, и на разгоне
12
+ * (когда зум как раз и меняется) это слышно как запаздывающую панораму.
13
+ * @param {object} canvasManager - Контроллер CanvasManager.
14
+ * @param {object} soundManager - Экземпляр SoundManager.
15
+ * @param {Array | number} camera - `[x, y, cameraReset, shakeData]` из
16
+ * горячего буфера ядра; `0` или пустое значение — кадра камеры нет.
17
+ */
18
+ export default function applyCamera(canvasManager, soundManager, camera) {
19
+ if (!camera || camera === 0) {
20
+ return;
21
+ }
22
+
23
+ canvasManager.updateCoords(camera);
24
+ soundManager.setListenerPosition(
25
+ camera[0],
26
+ camera[1],
27
+ canvasManager.getCameraZoom(),
28
+ );
29
+ }
@@ -94,6 +94,7 @@ import {
94
94
  import lobbyConfig from '../config/lobby.js';
95
95
  import authClientConfig from '../config/authClient.js';
96
96
  import clientDefaults from '../config/clientDefaults.js';
97
+ import applyCamera from './lib/applyCamera.js';
97
98
 
98
99
  // Динамическая загрузка игры по каталогу мастера (Этап 6.3): ClientPlugin
99
100
  // (parts, bakers, игровой CSS, хуки ядра) грузится по entries.client манифеста
@@ -900,18 +901,10 @@ function applyGameData(game) {
900
901
  });
901
902
  }
902
903
 
903
- // применяет данные камеры (позиция слушателя звука + полотно)
904
- function applyCamera(camera) {
905
- if (camera && camera !== 0) {
906
- soundManager.setListenerPosition(camera[0], camera[1]);
907
- modules.canvasManager.updateCoords(camera);
908
- }
909
- }
910
-
911
904
  // применяет кадр целиком (первый кадр и дискретные кадры интерполяции)
912
905
  function applyShot(game, camera) {
913
906
  applyGameData(game);
914
- applyCamera(camera);
907
+ applyCamera(modules.canvasManager, soundManager, camera);
915
908
  }
916
909
 
917
910
  // рендер-тик: ядро выдаёт пересечённые кадры (события, создания/удаления)
@@ -949,7 +942,7 @@ function renderTick() {
949
942
 
950
943
  if (flags & HOT_FLAGS.CAMERA) {
951
944
  // камера уже разрешена ядром: предсказанная позиция либо интерполированная
952
- applyCamera([hot[1], hot[2]]);
945
+ applyCamera(modules.canvasManager, soundManager, [hot[1], hot[2]]);
953
946
  }
954
947
 
955
948
  soundManager.processAudibility();
@@ -1,3 +1,5 @@
1
+ import { SPATIAL_DEFAULTS } from './spatialDefaults.js';
2
+
1
3
  // Движковые дефолты клиентского CONFIG_DATA (порт 0): интерполяция,
2
4
  // режимы/служебные клавиши, DOM-структуры движковых модулей, технические
3
5
  // сообщения. Игровая половина бывшего config/client.js — src/config/client.js
@@ -11,6 +13,17 @@ export default {
11
13
  maxFrameAge: 1000, // мс; страховочная очистка старых кадров буфера
12
14
  },
13
15
 
16
+ // ***** parts ***** //
17
+ // Геометрия пространственного звука. Значения, их единицы измерения и
18
+ // причина, по которой здесь нет panningModel — в
19
+ // src/config/spatialDefaults.js: этот же объект читают SoundManager и
20
+ // правило контракта E6, поэтому дефолт объявлен один раз там.
21
+ parts: {
22
+ sounds: {
23
+ spatial: { ...SPATIAL_DEFAULTS },
24
+ },
25
+ },
26
+
14
27
  // ***** modules ***** //
15
28
  modules: {
16
29
  canvasManager: {
@@ -0,0 +1,54 @@
1
+ // Дефолты и словарь допустимых значений пространственного звука.
2
+ // Единственный источник для трёх потребителей: clientDefaults.js (движковый
3
+ // конфиг игры), SoundManager (страховка на пустой или битый конфиг) и
4
+ // правило контракта E6 (статическая проверка). Держать их синхронными
5
+ // «по договорённости» не получается: расхождение проявляется только на
6
+ // слух и неотличимо от «звук просто другой».
7
+ //
8
+ // Числа — в МИРОВЫХ единицах игры, не в экранных пикселях: у игры с
9
+ // mapScale 0.3 и baseScale 5 мировая единица впятеро меньше пикселя.
10
+ // Дефолты рассчитаны на масштаб 1:1, игра со своим масштабом объявляет
11
+ // parts.sounds.spatial сама.
12
+ //
13
+ // panningModel здесь НЕТ намеренно: его приносит профиль проекции
14
+ // (SPATIAL_PROFILES в SoundManager.js), иначе sideScroller потерял бы свой
15
+ // equalpower — жёсткий дефолт перекрыл бы профиль на уровне слияния
16
+ // конфигов.
17
+ export const SPATIAL_DEFAULTS = {
18
+ mode: 'topDown',
19
+ virtualElevation: 180,
20
+ innerRadius: 40,
21
+ verticalFactor: 0.2,
22
+ distanceModel: 'inverse',
23
+ refDistance: 200,
24
+ maxDistance: 1200,
25
+ rolloffFactor: 0.9,
26
+ };
27
+
28
+ // Требуемый знак каждого числового поля. Список закрыт: лишний ключ в
29
+ // spatial — всегда опечатка, deepMerge молча положит его в конфиг, а
30
+ // SoundManager молча проигнорирует
31
+ export const SPATIAL_NUMERIC = {
32
+ virtualElevation: 'positive',
33
+ innerRadius: 'non-negative',
34
+ verticalFactor: 'non-negative',
35
+ refDistance: 'positive',
36
+ maxDistance: 'positive',
37
+ rolloffFactor: 'non-negative',
38
+ };
39
+
40
+ // Профили проекции; геометрия каждого живёт в SPATIAL_PROFILES
41
+ // (SoundManager.js), здесь — только имена для валидации
42
+ export const SPATIAL_MODES = ['topDown', 'sideScroller', 'cockpit'];
43
+
44
+ export const PANNING_MODELS = ['HRTF', 'equalpower'];
45
+
46
+ export const DISTANCE_MODELS = ['linear', 'inverse', 'exponential'];
47
+
48
+ // Все ключи, которые SoundManager читает из блока spatial
49
+ export const SPATIAL_KEYS = [
50
+ 'mode',
51
+ 'panningModel',
52
+ 'distanceModel',
53
+ ...Object.keys(SPATIAL_NUMERIC),
54
+ ];
@@ -0,0 +1,115 @@
1
+ import {
2
+ SPATIAL_DEFAULTS,
3
+ SPATIAL_NUMERIC,
4
+ SPATIAL_MODES,
5
+ SPATIAL_KEYS,
6
+ PANNING_MODELS,
7
+ DISTANCE_MODELS,
8
+ } from '../../../config/spatialDefaults.js';
9
+ import { WARN, skip, verdict } from '../result.js';
10
+
11
+ // verticalFactor, объявленный не при sideScroller, здесь НЕ отмечается: он
12
+ // входит в полный блок дефолтов, который документация показывает как
13
+ // образец, и правило кричало бы на честный копипаст из неё. Лишний ключ
14
+ // безвреден, а неверное число ловится проверкой знака.
15
+ //
16
+ // Блок spatial необязателен: игра без него получает движковые дефолты и
17
+ // звучит правильно. Но объявленный блок с опечаткой — это тихий отказ:
18
+ // движок падает на дефолт и ничего не говорит. Списки допустимых значений,
19
+ // знаки чисел и сами дефолты берутся из src/config/spatialDefaults.js —
20
+ // того же модуля, что читает SoundManager, иначе правило и рантайм
21
+ // разошлись бы и проверка пропускала бы то, что движок молча откатывает.
22
+ export default {
23
+ id: 'E6',
24
+ name: 'soundSpatial',
25
+ level: WARN,
26
+ title: 'the spatial sound block is well-formed',
27
+
28
+ check(ctx) {
29
+ const sounds = ctx.clientConfig?.parts?.sounds;
30
+
31
+ if (!sounds) {
32
+ return skip('no client sound config');
33
+ }
34
+
35
+ const spatial = sounds.spatial;
36
+
37
+ if (spatial === undefined) {
38
+ return skip('no spatial block — engine defaults apply');
39
+ }
40
+
41
+ if (!spatial || typeof spatial !== 'object' || Array.isArray(spatial)) {
42
+ return verdict(['parts.sounds.spatial must be a plain object']);
43
+ }
44
+
45
+ const violations = [];
46
+
47
+ for (const key of Object.keys(spatial)) {
48
+ if (!SPATIAL_KEYS.includes(key)) {
49
+ violations.push(
50
+ `unknown key "${key}": valid keys are ${SPATIAL_KEYS.join(', ')}`,
51
+ );
52
+ }
53
+ }
54
+
55
+ if (spatial.mode !== undefined && !SPATIAL_MODES.includes(spatial.mode)) {
56
+ violations.push(
57
+ `mode "${spatial.mode}" is unknown: valid modes are ${SPATIAL_MODES.join(', ')}`,
58
+ );
59
+ }
60
+
61
+ if (
62
+ spatial.panningModel !== undefined &&
63
+ !PANNING_MODELS.includes(spatial.panningModel)
64
+ ) {
65
+ violations.push(
66
+ `panningModel "${spatial.panningModel}" is unknown: valid models are ${PANNING_MODELS.join(', ')}`,
67
+ );
68
+ }
69
+
70
+ if (
71
+ spatial.distanceModel !== undefined &&
72
+ !DISTANCE_MODELS.includes(spatial.distanceModel)
73
+ ) {
74
+ violations.push(
75
+ `distanceModel "${spatial.distanceModel}" is unknown: valid models are ${DISTANCE_MODELS.join(', ')}`,
76
+ );
77
+ }
78
+
79
+ for (const [key, sign] of Object.entries(SPATIAL_NUMERIC)) {
80
+ const value = spatial[key];
81
+
82
+ if (value === undefined) {
83
+ continue;
84
+ }
85
+
86
+ const ok =
87
+ Number.isFinite(value) &&
88
+ (sign === 'positive' ? value > 0 : value >= 0);
89
+
90
+ if (!ok) {
91
+ violations.push(`${key} must be a ${sign} number, got ${value}`);
92
+ }
93
+ }
94
+
95
+ // Сравниваются РАЗРЕШЁННЫЕ значения, как в
96
+ // SoundManager._resolveSpatialConfig: объявить одну дистанцию против
97
+ // дефолта второй — то же нарушение, и рантайм откатит обе молча
98
+ const refDistance = Number.isFinite(spatial.refDistance)
99
+ ? spatial.refDistance
100
+ : SPATIAL_DEFAULTS.refDistance;
101
+ const maxDistance = Number.isFinite(spatial.maxDistance)
102
+ ? spatial.maxDistance
103
+ : SPATIAL_DEFAULTS.maxDistance;
104
+
105
+ if (maxDistance <= refDistance) {
106
+ violations.push(
107
+ `maxDistance (${maxDistance}) must be greater than refDistance ` +
108
+ `(${refDistance}); an undeclared distance falls back to the ` +
109
+ `engine default, and the engine resets both at runtime`,
110
+ );
111
+ }
112
+
113
+ return verdict(violations);
114
+ },
115
+ };
@@ -34,6 +34,7 @@ import e2 from './e2-map-images.js';
34
34
  import e3 from './e3-sound-registry.js';
35
35
  import e4 from './e4-map-layers.js';
36
36
  import e5 from './e5-map-radar-walls.js';
37
+ import e6 from './e6-sound-spatial.js';
37
38
 
38
39
  // Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
39
40
  // D снапшот, E ассеты (plan/create-vimp-game/stage_1.md).
@@ -42,7 +43,7 @@ export const rules = [
42
43
  b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
43
44
  c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11,
44
45
  d1, d2, d3,
45
- e1, e2, e3, e4, e5,
46
+ e1, e2, e3, e4, e5, e6,
46
47
  ];
47
48
 
48
49
  export default rules;