vimp-engine 0.31.0 → 0.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.12.0"
3
+ version = "0.14.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,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,8 +3,11 @@ import { Howl, Howler } from 'howler';
3
3
  // глобальный лимит звуков
4
4
  const WORLD_VOICE_LIMIT = 30;
5
5
 
6
- // минимальная дистанция для панорамирования
7
- const MIN_SPATIAL_DISTANCE = 1;
6
+ // дед-зона панорамирования, в мировых пикселях (порядка половины тайла).
7
+ // Порог по НАПРАВЛЕНИЮ, а не по громкости: азимут в Web Audio зависит от
8
+ // вектора на источник, а не от расстояния до него, поэтому расхождение
9
+ // камеры и источника в пару пикселей давало полную панораму и её рывки
10
+ const MIN_SPATIAL_DISTANCE = 16;
8
11
 
9
12
  // настройки пространственного звука
10
13
  const PANNER_SETTINGS = {
@@ -36,6 +39,17 @@ export default class SoundManager {
36
39
  // реестр всех зарегистрированных звуковых источников
37
40
  this._registeredSounds = new Map();
38
41
 
42
+ // экземпляры, уже переведённые на equalpower (непространственные):
43
+ // pannerAttr ставится один раз на экземпляр, а не каждый кадр
44
+ this._equalPowerIds = new Set();
45
+
46
+ // экземпляры, у которых PannerNode УЖЕ создан. Howler создаёт узел
47
+ // лениво — на первом pos()/pannerAttr(id) — и заканчивает создание
48
+ // парой pause()/play() (setupPanner в howler.js), то есть щелчком.
49
+ // Пока источник стоит в точке слушателя, паннер не нужен вовсе:
50
+ // сигнал идёт прямо в gain, сохраняя стерео сэмпла
51
+ this._pannedIds = new Set();
52
+
39
53
  // позиция слушателя
40
54
  this._listenerX = 0;
41
55
  this._listenerY = 0;
@@ -154,6 +168,12 @@ export default class SoundManager {
154
168
  * @param {object} data.position - { x: number, y: number }.
155
169
  * @param {number} [data?.rate] - Скорость воспроизведения.
156
170
  * @param {number} [data?.volume] - Громкость.
171
+ * @param {boolean} [data?.spatial=true] - Принадлежит ли звук миру.
172
+ * `false` — источник игрока (двигатель и выстрел своего танка): он
173
+ * стоит ровно на слушателе, и HRTF на нулевой дистанции сворачивается
174
+ * в гребенчатую окраску («гул»), а не в тишину панорамы. Такому звуку
175
+ * PannerNode не создаётся вовсе — он идёт прямо в gain и остаётся
176
+ * стерео.
157
177
  * @param {function} [callback] - Функция, вызываемая по завершении.
158
178
  * @returns {symbol | null} Уникальный ID звука или null, если звук не найден.
159
179
  */
@@ -168,6 +188,7 @@ export default class SoundManager {
168
188
 
169
189
  const id = Symbol(soundName);
170
190
  const registration = {
191
+ spatial: true,
171
192
  ...soundData.config,
172
193
  ...data,
173
194
  sound: soundData.sound,
@@ -308,6 +329,7 @@ export default class SoundManager {
308
329
  candidate.position.x,
309
330
  candidate.position.y,
310
331
  candidate.volume,
332
+ candidate.spatial,
311
333
  );
312
334
  }
313
335
  }
@@ -334,15 +356,29 @@ export default class SoundManager {
334
356
  if (!regSound) {
335
357
  sound.stop(soundId);
336
358
  this._activeInstances.delete(soundId);
359
+ this._equalPowerIds.delete(soundId);
360
+ this._pannedIds.delete(soundId);
337
361
  continue;
338
362
  }
339
363
 
340
- const { position, volume, rate } = regSound;
364
+ const { position, volume, rate, spatial } = regSound;
341
365
 
342
- this._updateSpatialSound(sound, soundId, position.x, position.y, volume);
366
+ this._updateSpatialSound(
367
+ sound,
368
+ soundId,
369
+ position.x,
370
+ position.y,
371
+ volume,
372
+ spatial,
373
+ );
343
374
 
344
- if (typeof rate === 'number') {
375
+ // rate только на изменение: Howler на каждый вызов делает два seek(),
376
+ // переписывает _rateSeek/_playStart и пересоздаёт таймер конца петли
377
+ // — на 60 Гц это лишняя нагрузка и лишние события 'end' на каждом
378
+ // обороте
379
+ if (typeof rate === 'number' && rate !== activeInstance.rate) {
345
380
  sound.rate(rate, soundId);
381
+ activeInstance.rate = rate;
346
382
  }
347
383
  }
348
384
  }
@@ -380,6 +416,8 @@ export default class SoundManager {
380
416
  }
381
417
 
382
418
  this._activeInstances.delete(soundId);
419
+ this._equalPowerIds.delete(soundId);
420
+ this._pannedIds.delete(soundId);
383
421
  },
384
422
  soundId,
385
423
  );
@@ -397,6 +435,8 @@ export default class SoundManager {
397
435
  if (instanceData) {
398
436
  instanceData.sound.stop(soundId);
399
437
  this._activeInstances.delete(soundId);
438
+ this._equalPowerIds.delete(soundId);
439
+ this._pannedIds.delete(soundId);
400
440
  }
401
441
  }
402
442
 
@@ -408,12 +448,27 @@ export default class SoundManager {
408
448
  * @param {number} x - Координата X источника звука.
409
449
  * @param {number} y - Координата Y источника звука.
410
450
  * @param {number} volume - Громкость звука.
451
+ * @param {boolean} [spatial=true] - Принадлежит ли звук миру.
411
452
  */
412
- _updateSpatialSound(sound, soundId, x, y, volume) {
453
+ _updateSpatialSound(sound, soundId, x, y, volume, spatial = true) {
413
454
  if (!sound || typeof soundId !== 'number') {
414
455
  return;
415
456
  }
416
457
 
458
+ if (spatial === false) {
459
+ // источник игрока: звук не принадлежит миру, он принадлежит игроку.
460
+ // Паннер ему не нужен ни в каком виде — ни HRTF (на нулевой
461
+ // дистанции это не тишина панорамы, а фронтальная свёртка:
462
+ // гребенчатая окраска, из-за которой двигатель слышен как гул), ни
463
+ // equalpower (тот схлопывает стерео сэмпла в моно). Пока узла нет,
464
+ // pos() не зовётся вовсе: Howler создал бы паннер и щёлкнул
465
+ // pause()/play()
466
+ sound.volume(volume, soundId);
467
+ this._recenterIfPanned(sound, soundId, true);
468
+
469
+ return;
470
+ }
471
+
417
472
  const distance = Math.hypot(x - this._listenerX, y - this._listenerY);
418
473
 
419
474
  if (distance >= PANNER_SETTINGS.maxDistance) {
@@ -427,9 +482,47 @@ export default class SoundManager {
427
482
  // если дистанция позволяет панорамировать звук
428
483
  if (distance > MIN_SPATIAL_DISTANCE) {
429
484
  sound.pos(x - this._listenerX, 0, y - this._listenerY, soundId);
485
+ this._pannedIds.add(soundId);
430
486
  } else {
431
- sound.pos(0, 0, 0, soundId); // отключение панорамирования
487
+ // внутри дед-зоны звук идёт по центру. Экземпляру, у которого паннер
488
+ // ещё не создан, здесь не место его заводить: узел появится на
489
+ // первом же выходе из дед-зоны
490
+ this._recenterIfPanned(sound, soundId);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * @private Возвращает в центр экземпляр, у которого PannerNode УЖЕ есть
496
+ * (источник успел побывать в стороне от слушателя). Экземпляр без узла
497
+ * не трогается: создание паннера — это лишний узел в цепочке, потеря
498
+ * стерео и щелчок от pause()/play() внутри setupPanner Howler'а.
499
+ * @param {boolean} [equalPower=false] - Перевести узел на equalpower:
500
+ * так делается только для источника игрока, миру HRTF сохраняется —
501
+ * он снова понадобится, как только источник выйдет из дед-зоны.
502
+ */
503
+ _recenterIfPanned(sound, soundId, equalPower = false) {
504
+ if (!this._pannedIds.has(soundId)) {
505
+ return;
506
+ }
507
+
508
+ if (equalPower) {
509
+ this._applyEqualPower(sound, soundId);
510
+ }
511
+
512
+ sound.pos(0, 0, 0, soundId);
513
+ }
514
+
515
+ /**
516
+ * @private Переводит экземпляр звука на equalpower — один раз на
517
+ * экземпляр: pannerAttr на каждом кадре пересобирал бы panner-узел.
518
+ */
519
+ _applyEqualPower(sound, soundId) {
520
+ if (this._equalPowerIds.has(soundId)) {
521
+ return;
432
522
  }
523
+
524
+ sound.pannerAttr({ panningModel: 'equalpower' }, soundId);
525
+ this._equalPowerIds.add(soundId);
433
526
  }
434
527
 
435
528
  /**
@@ -467,6 +560,8 @@ export default class SoundManager {
467
560
  reset() {
468
561
  Howler.stop();
469
562
  this._activeInstances.clear();
563
+ this._equalPowerIds.clear();
564
+ this._pannedIds.clear();
470
565
 
471
566
  // луп переживает reset: его владелец жив, и ближайший
472
567
  // processAudibility() запустит звук заново. Одноразовый — нет:
@@ -167,8 +167,19 @@ export default class VoteModel {
167
167
  const value = this._currentValues[number];
168
168
 
169
169
  if (value) {
170
- this.publisher.emit('socket', [this._voteName, value]);
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
  }
@@ -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
 
@@ -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`
@@ -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: gameConfig.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,