vimp-engine 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/SoundManager.js +60 -6
- package/src/client/components/model/Vote.js +12 -1
- package/src/client/main.js +13 -0
- package/src/devtools/contract/rules/e4-map-layers.js +47 -0
- package/src/host/meta/core/RoundManager.js +5 -0
- package/src/lib/capabilities.js +4 -0
- package/src/lib/coreConfig.js +11 -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.13.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
|
@@ -3,8 +3,11 @@ import { Howl, Howler } from 'howler';
|
|
|
3
3
|
// глобальный лимит звуков
|
|
4
4
|
const WORLD_VOICE_LIMIT = 30;
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
|
|
6
|
+
// дед-зона панорамирования, в мировых пикселях (порядка половины тайла).
|
|
7
|
+
// Порог по НАПРАВЛЕНИЮ, а не по громкости: азимут в Web Audio зависит от
|
|
8
|
+
// вектора на источник, а не от расстояния до него, поэтому расхождение
|
|
9
|
+
// камеры и источника в пару пикселей давало полную панораму и её рывки
|
|
10
|
+
const MIN_SPATIAL_DISTANCE = 16;
|
|
8
11
|
|
|
9
12
|
// настройки пространственного звука
|
|
10
13
|
const PANNER_SETTINGS = {
|
|
@@ -36,6 +39,10 @@ export default class SoundManager {
|
|
|
36
39
|
// реестр всех зарегистрированных звуковых источников
|
|
37
40
|
this._registeredSounds = new Map();
|
|
38
41
|
|
|
42
|
+
// экземпляры, уже переведённые на equalpower (непространственные):
|
|
43
|
+
// pannerAttr ставится один раз на экземпляр, а не каждый кадр
|
|
44
|
+
this._equalPowerIds = new Set();
|
|
45
|
+
|
|
39
46
|
// позиция слушателя
|
|
40
47
|
this._listenerX = 0;
|
|
41
48
|
this._listenerY = 0;
|
|
@@ -154,6 +161,10 @@ export default class SoundManager {
|
|
|
154
161
|
* @param {object} data.position - { x: number, y: number }.
|
|
155
162
|
* @param {number} [data?.rate] - Скорость воспроизведения.
|
|
156
163
|
* @param {number} [data?.volume] - Громкость.
|
|
164
|
+
* @param {boolean} [data?.spatial=true] - Принадлежит ли звук миру.
|
|
165
|
+
* `false` — источник игрока (двигатель и выстрел своего танка): он
|
|
166
|
+
* стоит ровно на слушателе, и HRTF на нулевой дистанции сворачивается
|
|
167
|
+
* в гребенчатую окраску («гул»), а не в тишину панорамы.
|
|
157
168
|
* @param {function} [callback] - Функция, вызываемая по завершении.
|
|
158
169
|
* @returns {symbol | null} Уникальный ID звука или null, если звук не найден.
|
|
159
170
|
*/
|
|
@@ -168,6 +179,7 @@ export default class SoundManager {
|
|
|
168
179
|
|
|
169
180
|
const id = Symbol(soundName);
|
|
170
181
|
const registration = {
|
|
182
|
+
spatial: true,
|
|
171
183
|
...soundData.config,
|
|
172
184
|
...data,
|
|
173
185
|
sound: soundData.sound,
|
|
@@ -308,6 +320,7 @@ export default class SoundManager {
|
|
|
308
320
|
candidate.position.x,
|
|
309
321
|
candidate.position.y,
|
|
310
322
|
candidate.volume,
|
|
323
|
+
candidate.spatial,
|
|
311
324
|
);
|
|
312
325
|
}
|
|
313
326
|
}
|
|
@@ -334,15 +347,28 @@ export default class SoundManager {
|
|
|
334
347
|
if (!regSound) {
|
|
335
348
|
sound.stop(soundId);
|
|
336
349
|
this._activeInstances.delete(soundId);
|
|
350
|
+
this._equalPowerIds.delete(soundId);
|
|
337
351
|
continue;
|
|
338
352
|
}
|
|
339
353
|
|
|
340
|
-
const { position, volume, rate } = regSound;
|
|
354
|
+
const { position, volume, rate, spatial } = regSound;
|
|
341
355
|
|
|
342
|
-
this._updateSpatialSound(
|
|
356
|
+
this._updateSpatialSound(
|
|
357
|
+
sound,
|
|
358
|
+
soundId,
|
|
359
|
+
position.x,
|
|
360
|
+
position.y,
|
|
361
|
+
volume,
|
|
362
|
+
spatial,
|
|
363
|
+
);
|
|
343
364
|
|
|
344
|
-
|
|
365
|
+
// rate только на изменение: Howler на каждый вызов делает два seek(),
|
|
366
|
+
// переписывает _rateSeek/_playStart и пересоздаёт таймер конца петли
|
|
367
|
+
// — на 60 Гц это лишняя нагрузка и лишние события 'end' на каждом
|
|
368
|
+
// обороте
|
|
369
|
+
if (typeof rate === 'number' && rate !== activeInstance.rate) {
|
|
345
370
|
sound.rate(rate, soundId);
|
|
371
|
+
activeInstance.rate = rate;
|
|
346
372
|
}
|
|
347
373
|
}
|
|
348
374
|
}
|
|
@@ -380,6 +406,7 @@ export default class SoundManager {
|
|
|
380
406
|
}
|
|
381
407
|
|
|
382
408
|
this._activeInstances.delete(soundId);
|
|
409
|
+
this._equalPowerIds.delete(soundId);
|
|
383
410
|
},
|
|
384
411
|
soundId,
|
|
385
412
|
);
|
|
@@ -397,6 +424,7 @@ export default class SoundManager {
|
|
|
397
424
|
if (instanceData) {
|
|
398
425
|
instanceData.sound.stop(soundId);
|
|
399
426
|
this._activeInstances.delete(soundId);
|
|
427
|
+
this._equalPowerIds.delete(soundId);
|
|
400
428
|
}
|
|
401
429
|
}
|
|
402
430
|
|
|
@@ -408,12 +436,24 @@ export default class SoundManager {
|
|
|
408
436
|
* @param {number} x - Координата X источника звука.
|
|
409
437
|
* @param {number} y - Координата Y источника звука.
|
|
410
438
|
* @param {number} volume - Громкость звука.
|
|
439
|
+
* @param {boolean} [spatial=true] - Принадлежит ли звук миру.
|
|
411
440
|
*/
|
|
412
|
-
_updateSpatialSound(sound, soundId, x, y, volume) {
|
|
441
|
+
_updateSpatialSound(sound, soundId, x, y, volume, spatial = true) {
|
|
413
442
|
if (!sound || typeof soundId !== 'number') {
|
|
414
443
|
return;
|
|
415
444
|
}
|
|
416
445
|
|
|
446
|
+
if (spatial === false) {
|
|
447
|
+
// источник игрока: звук не принадлежит миру, он принадлежит игроку.
|
|
448
|
+
// equalpower вместо HRTF — HRTF на нулевой дистанции даёт гребенчатую
|
|
449
|
+
// окраску («гул»), а не тишину панорамы
|
|
450
|
+
this._applyEqualPower(sound, soundId);
|
|
451
|
+
sound.pos(0, 0, 0, soundId);
|
|
452
|
+
sound.volume(volume, soundId);
|
|
453
|
+
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
417
457
|
const distance = Math.hypot(x - this._listenerX, y - this._listenerY);
|
|
418
458
|
|
|
419
459
|
if (distance >= PANNER_SETTINGS.maxDistance) {
|
|
@@ -432,6 +472,19 @@ export default class SoundManager {
|
|
|
432
472
|
}
|
|
433
473
|
}
|
|
434
474
|
|
|
475
|
+
/**
|
|
476
|
+
* @private Переводит экземпляр звука на equalpower — один раз на
|
|
477
|
+
* экземпляр: pannerAttr на каждом кадре пересобирал бы panner-узел.
|
|
478
|
+
*/
|
|
479
|
+
_applyEqualPower(sound, soundId) {
|
|
480
|
+
if (this._equalPowerIds.has(soundId)) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
sound.pannerAttr({ panningModel: 'equalpower' }, soundId);
|
|
485
|
+
this._equalPowerIds.add(soundId);
|
|
486
|
+
}
|
|
487
|
+
|
|
435
488
|
/**
|
|
436
489
|
* @private
|
|
437
490
|
* Удаляет из реестра одноразовые звуки, которые были заявлены,
|
|
@@ -467,6 +520,7 @@ export default class SoundManager {
|
|
|
467
520
|
reset() {
|
|
468
521
|
Howler.stop();
|
|
469
522
|
this._activeInstances.clear();
|
|
523
|
+
this._equalPowerIds.clear();
|
|
470
524
|
|
|
471
525
|
// луп переживает reset: его владелец жив, и ближайший
|
|
472
526
|
// processAudibility() запустит звук заново. Одноразовый — нет:
|
|
@@ -167,8 +167,19 @@ export default class VoteModel {
|
|
|
167
167
|
const value = this._currentValues[number];
|
|
168
168
|
|
|
169
169
|
if (value) {
|
|
170
|
-
|
|
170
|
+
const name = this._voteName;
|
|
171
|
+
|
|
172
|
+
// Своё голосование закрывается ДО отправки ответа. Транспорт
|
|
173
|
+
// бывает синхронным (solo/standalone — хост живёт в том же
|
|
174
|
+
// потоке), и тогда ответ хоста приходит прямо внутри
|
|
175
|
+
// emit('socket'): смена карты сразу присылает новое
|
|
176
|
+
// голосование (initialVote, «выбери команду»), оно ставит
|
|
177
|
+
// _waitingValues и запрашивает значения. Прежний порядок
|
|
178
|
+
// «отправить, потом complete()» сбрасывал этот флаг уже
|
|
179
|
+
// ПОСЛЕ создания нового голосования, и пришедшие значения
|
|
180
|
+
// отбрасывались в updateValues — диалог не открывался вовсе
|
|
171
181
|
this.complete();
|
|
182
|
+
this.publisher.emit('socket', [name, value]);
|
|
172
183
|
}
|
|
173
184
|
}
|
|
174
185
|
}
|
package/src/client/main.js
CHANGED
|
@@ -591,6 +591,10 @@ function applyMapData(data, { notifyHost = true } = {}) {
|
|
|
591
591
|
// разъедется с авторитетным молча
|
|
592
592
|
levels: data.levels,
|
|
593
593
|
ramps: data.ramps,
|
|
594
|
+
// высота уровня в мировых единицах: от неё зависит уклон рампы, а
|
|
595
|
+
// значит и предсказание подъёма. Не доехав до клиентской `MapLevels`,
|
|
596
|
+
// она дала бы другой уклон, чем у хоста, — расхождение молчаливое
|
|
597
|
+
levelHeight: data.levelHeight,
|
|
594
598
|
}),
|
|
595
599
|
);
|
|
596
600
|
} catch (e) {
|
|
@@ -637,6 +641,10 @@ function applyMapData(data, { notifyHost = true } = {}) {
|
|
|
637
641
|
const staticData = {};
|
|
638
642
|
let staticIndex = 0;
|
|
639
643
|
|
|
644
|
+
// мировых единиц на уровень: партам нужен для параллакса слоёв и
|
|
645
|
+
// экструзии объёмов. 0/undefined — движок подставил размер тайла
|
|
646
|
+
const levelHeight = (Number(data.levelHeight) || step) * scale;
|
|
647
|
+
|
|
640
648
|
const pushLayers = (levelLayers, levelMap, level, solid, floor, volumes) => {
|
|
641
649
|
for (const [layer, tiles] of Object.entries(levelLayers || {})) {
|
|
642
650
|
staticData[`s${staticIndex}`] = {
|
|
@@ -652,6 +660,11 @@ function applyMapData(data, { notifyHost = true } = {}) {
|
|
|
652
660
|
// визуальная высота слоя в уровнях: ядро её не знает, поле едет из
|
|
653
661
|
// карты прямо в парт. 0 — слой плоский
|
|
654
662
|
volume: Number(volumes?.[layer]) || 0,
|
|
663
|
+
levelHeight,
|
|
664
|
+
// прогоны рамп ЭТОГО уровня как объявлены в карте
|
|
665
|
+
// ({ tile, dir, from, to }): парт строит по ним клин с нарастающей
|
|
666
|
+
// высотой — грид уровня у него уже есть
|
|
667
|
+
ramps: (data.ramps || []).filter(ramp => (ramp.from ?? 0) === level),
|
|
655
668
|
// прежнее имя оставлено для парта, который его уже читает
|
|
656
669
|
physicsStatic,
|
|
657
670
|
scale,
|
|
@@ -85,6 +85,18 @@ function checkMap(name, map, violations) {
|
|
|
85
85
|
// 11. высоты объёмов уровня 0 живут в корне карты, как и его слои
|
|
86
86
|
checkVolumes(at, '0', map.layers, map.volumes, violations);
|
|
87
87
|
|
|
88
|
+
// 13. высота уровня в мировых единицах: от неё зависит уклон рампы, а
|
|
89
|
+
// значит тяга в горку, наклон корпуса и пыль. Ноль превращает подъём в
|
|
90
|
+
// плоскость, NaN растекается по физике и по рендеру
|
|
91
|
+
if (map.levelHeight !== undefined) {
|
|
92
|
+
if (!Number.isFinite(map.levelHeight) || map.levelHeight <= 0) {
|
|
93
|
+
violations.push(
|
|
94
|
+
`${at}: levelHeight ${map.levelHeight} is not a finite number ` +
|
|
95
|
+
'greater than 0',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
const grid = map.map ?? [];
|
|
89
101
|
|
|
90
102
|
for (const [key, level] of Object.entries(levels)) {
|
|
@@ -179,6 +191,11 @@ function checkMap(name, map, violations) {
|
|
|
179
191
|
checkRampSlabs(at, map, index, ramp, from, to, fromGrid ?? [], violations);
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
// 14. клетки разных рамп не пересекаются: ядро отдаёт общую клетку
|
|
195
|
+
// первой объявленной рампе, и поведение подъёма перестаёт выводиться
|
|
196
|
+
// из карты
|
|
197
|
+
checkRampOverlap(at, map, levels, grid, violations);
|
|
198
|
+
|
|
182
199
|
// 10. край плиты без перил — обрыв; приземлиться с него нужно на
|
|
183
200
|
// проходимую землю, иначе танк уезжает за карту (стены уровня 0 плите не
|
|
184
201
|
// преграда) или приземляется внутри стены
|
|
@@ -368,6 +385,36 @@ function landingLevel(map, from, x, y) {
|
|
|
368
385
|
return 0;
|
|
369
386
|
}
|
|
370
387
|
|
|
388
|
+
// зеркало проверки пересечения прогонов в `validate_levels`: клетка,
|
|
389
|
+
// объявленная двумя рампами, достаётся первой из них — молча
|
|
390
|
+
function checkRampOverlap(at, map, levels, grid, violations) {
|
|
391
|
+
const claimed = new Map();
|
|
392
|
+
|
|
393
|
+
for (const [index, ramp] of (map.ramps ?? []).entries()) {
|
|
394
|
+
const from = ramp?.from ?? 0;
|
|
395
|
+
const fromGrid = from === 0 ? grid : levels[String(from)]?.map;
|
|
396
|
+
|
|
397
|
+
(fromGrid ?? []).forEach((row, y) => {
|
|
398
|
+
row.forEach((tile, x) => {
|
|
399
|
+
if (tile !== ramp?.tile) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const key = `${x},${y}`;
|
|
404
|
+
const first = claimed.get(key);
|
|
405
|
+
|
|
406
|
+
if (first === undefined) {
|
|
407
|
+
claimed.set(key, index);
|
|
408
|
+
} else if (first !== index) {
|
|
409
|
+
violations.push(
|
|
410
|
+
`${at}: ramps ${first} and ${index} share cell (${x}, ${y})`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
371
418
|
// клетки прогона рампы не должны лежать под плитой промежуточного уровня:
|
|
372
419
|
// иначе подъём прошивает чужой мост
|
|
373
420
|
function checkRampSlabs(at, map, index, ramp, from, to, grid, violations) {
|
|
@@ -27,6 +27,11 @@ function scaleMapData(mapData) {
|
|
|
27
27
|
physicsDynamic,
|
|
28
28
|
respawns,
|
|
29
29
|
scale,
|
|
30
|
+
// высота уровня — мировая величина, как и step: ядро получает эти
|
|
31
|
+
// данные со scale: 1 и повторно её не масштабирует
|
|
32
|
+
...(mapData.levelHeight === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { levelHeight: mapData.levelHeight * scale }),
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
|
package/src/lib/capabilities.js
CHANGED
|
@@ -31,6 +31,10 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
31
31
|
// рампы через несколько уровней, правила уровня и падение у тел карты,
|
|
32
32
|
// `z`/`level` в строке динамики, `volumes` (высота рендер-слоя)
|
|
33
33
|
{ value: 'map.levelsN', since: '0.31.0' },
|
|
34
|
+
// высота уровня карты в мировых единицах (`levelHeight`): безразмерный
|
|
35
|
+
// уклон рампы в ядре, поле `levelHeight` в контексте статического парта,
|
|
36
|
+
// прогоны рамп уровня (`ramps`) там же, коллайдеры-стражи прогона
|
|
37
|
+
{ value: 'map.levelHeight', since: '0.32.0' },
|
|
34
38
|
]);
|
|
35
39
|
|
|
36
40
|
// плоский список имён — то, чем правило контракта B2 проверяет `requires`
|
package/src/lib/coreConfig.js
CHANGED
|
@@ -25,12 +25,22 @@ import wsports from '../config/wsports.js';
|
|
|
25
25
|
export const buildCoreConfig = (gameConfig, overrides = {}) => {
|
|
26
26
|
const { models, weapons } = gameConfig.parts;
|
|
27
27
|
|
|
28
|
+
// длительность падения — одна на всю экосистему: траекторию ящика карты
|
|
29
|
+
// считает движок, траекторию танка и предсказание того же ящика на
|
|
30
|
+
// клиенте — ядро игры. Игра, объявившая своё `coreParams.levels.fallTime`,
|
|
31
|
+
// владеет обеими: иначе ящик у хоста и у его же клиента летит с разной
|
|
32
|
+
// скоростью, и расходятся они молча
|
|
33
|
+
const gameFallTime = Number(gameConfig.coreParams?.levels?.fallTime);
|
|
34
|
+
|
|
28
35
|
const flat = {
|
|
29
36
|
timeStep: hostDefaults.timers.timeStep / 1000,
|
|
30
37
|
friendlyFire: gameConfig.parts.friendlyFire,
|
|
31
38
|
mapScale: gameConfig.mapScale,
|
|
32
39
|
mapSetId: gameConfig.mapSetId,
|
|
33
|
-
mapFallTime:
|
|
40
|
+
mapFallTime:
|
|
41
|
+
Number.isFinite(gameFallTime) && gameFallTime > 0
|
|
42
|
+
? gameFallTime
|
|
43
|
+
: gameConfig.mapFallTime,
|
|
34
44
|
models,
|
|
35
45
|
weapons,
|
|
36
46
|
playerKeys: gameConfig.playerKeys,
|