vimp-engine 0.32.3 → 0.33.1
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 +1 -1
- package/src/client/SoundManager.js +410 -73
- package/src/client/components/controller/CanvasManager.js +5 -0
- package/src/client/components/model/CanvasManager.js +30 -0
- package/src/client/lib/applyCamera.js +29 -0
- package/src/client/main.js +9 -12
- package/src/config/clientDefaults.js +13 -0
- package/src/config/spatialDefaults.js +54 -0
- package/src/devtools/contract/rules/e6-sound-spatial.js +115 -0
- package/src/devtools/contract/rules/index.js +2 -1
package/core/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vimp-engine-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.18.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 УЖЕ создан.
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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(
|
|
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
|
-
* Устанавливает позицию слушателя
|
|
144
|
-
*
|
|
145
|
-
* @param {number}
|
|
146
|
-
* @param {number}
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
*
|
|
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 -
|
|
449
|
-
* @param {number} 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,
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
478
|
-
|
|
783
|
+
this._writePos(sound, soundId, px, py, pz);
|
|
784
|
+
}
|
|
479
785
|
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -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 манифеста
|
|
@@ -641,8 +642,12 @@ function applyMapData(data, { notifyHost = true } = {}) {
|
|
|
641
642
|
const staticData = {};
|
|
642
643
|
let staticIndex = 0;
|
|
643
644
|
|
|
644
|
-
// мировых единиц на
|
|
645
|
-
//
|
|
645
|
+
// мировых единиц на уровень — величина ФИЗИКИ: по ней ядро считает
|
|
646
|
+
// уклон рампы и падение. Вертикальный масштаб КАРТИНКИ задаёт игра
|
|
647
|
+
// (у танков — `parallax.shear`) и намеренно не зависит от карты, иначе
|
|
648
|
+
// слои разных карт разъезжались бы по виду. Поле едет в парт как есть —
|
|
649
|
+
// партам, которые мерят высоту в мировых единицах.
|
|
650
|
+
// 0/undefined — движок подставил размер тайла
|
|
646
651
|
const levelHeight = (Number(data.levelHeight) || step) * scale;
|
|
647
652
|
|
|
648
653
|
const pushLayers = (levelLayers, levelMap, level, solid, floor, volumes) => {
|
|
@@ -900,18 +905,10 @@ function applyGameData(game) {
|
|
|
900
905
|
});
|
|
901
906
|
}
|
|
902
907
|
|
|
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
908
|
// применяет кадр целиком (первый кадр и дискретные кадры интерполяции)
|
|
912
909
|
function applyShot(game, camera) {
|
|
913
910
|
applyGameData(game);
|
|
914
|
-
applyCamera(camera);
|
|
911
|
+
applyCamera(modules.canvasManager, soundManager, camera);
|
|
915
912
|
}
|
|
916
913
|
|
|
917
914
|
// рендер-тик: ядро выдаёт пересечённые кадры (события, создания/удаления)
|
|
@@ -949,7 +946,7 @@ function renderTick() {
|
|
|
949
946
|
|
|
950
947
|
if (flags & HOT_FLAGS.CAMERA) {
|
|
951
948
|
// камера уже разрешена ядром: предсказанная позиция либо интерполированная
|
|
952
|
-
applyCamera([hot[1], hot[2]]);
|
|
949
|
+
applyCamera(modules.canvasManager, soundManager, [hot[1], hot[2]]);
|
|
953
950
|
}
|
|
954
951
|
|
|
955
952
|
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;
|