vimp-engine 0.30.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.11.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
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,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(sound, soundId, position.x, position.y, volume);
356
+ this._updateSpatialSound(
357
+ sound,
358
+ soundId,
359
+ position.x,
360
+ position.y,
361
+ volume,
362
+ spatial,
363
+ );
343
364
 
344
- if (typeof rate === 'number') {
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
- 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,7 +641,11 @@ function applyMapData(data, { notifyHost = true } = {}) {
637
641
  const staticData = {};
638
642
  let staticIndex = 0;
639
643
 
640
- const pushLayers = (levelLayers, levelMap, level, solid, floor) => {
644
+ // мировых единиц на уровень: партам нужен для параллакса слоёв и
645
+ // экструзии объёмов. 0/undefined — движок подставил размер тайла
646
+ const levelHeight = (Number(data.levelHeight) || step) * scale;
647
+
648
+ const pushLayers = (levelLayers, levelMap, level, solid, floor, volumes) => {
641
649
  for (const [layer, tiles] of Object.entries(levelLayers || {})) {
642
650
  staticData[`s${staticIndex}`] = {
643
651
  type: 'static',
@@ -649,6 +657,14 @@ function applyMapData(data, { notifyHost = true } = {}) {
649
657
  level,
650
658
  solid,
651
659
  floor,
660
+ // визуальная высота слоя в уровнях: ядро её не знает, поле едет из
661
+ // карты прямо в парт. 0 — слой плоский
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),
652
668
  // прежнее имя оставлено для парта, который его уже читает
653
669
  physicsStatic,
654
670
  scale,
@@ -658,7 +674,7 @@ function applyMapData(data, { notifyHost = true } = {}) {
658
674
  }
659
675
  };
660
676
 
661
- pushLayers(layers, map, 0, physicsStatic, []);
677
+ pushLayers(layers, map, 0, physicsStatic, [], data.volumes);
662
678
 
663
679
  for (const [key, levelData] of Object.entries(data.levels || {})) {
664
680
  const level = Number(key);
@@ -669,6 +685,7 @@ function applyMapData(data, { notifyHost = true } = {}) {
669
685
  level,
670
686
  levelData.walls || [],
671
687
  levelData.floor || [],
688
+ levelData.volumes,
672
689
  );
673
690
  }
674
691
 
@@ -40,12 +40,25 @@ export default {
40
40
 
41
41
  const RAMP_DIRS = ['north', 'south', 'west', 'east'];
42
42
 
43
+ // Зеркало `vimp_engine_core::map::MAX_LEVELS`: уровни занимают биты 0..7
44
+ // маски Rapier, бит 8 отдан статике. Константа живёт в Rust, поэтому здесь
45
+ // она — копия, и расходится она молча: сторожем стоит фикстура
46
+ // `bad-levels-too-many.json` общего корпуса.
47
+ const MAX_LEVELS = 8;
48
+
43
49
  function checkMap(name, map, violations) {
44
50
  const at = `map "${name}"`;
45
51
  const levels = map.levels ?? {};
46
52
  // уровень 0 живёт в map/physicsStatic/layers и в `levels` не значится
47
53
  const levelCount = Object.keys(levels).length + 1;
48
54
 
55
+ // 0. потолок уровней: больше битов у маски нет
56
+ if (levelCount > MAX_LEVELS) {
57
+ violations.push(
58
+ `${at}: ${levelCount} levels, at most ${MAX_LEVELS} supported`,
59
+ );
60
+ }
61
+
49
62
  // 1. ключи — целые >= 1, подряд от 1
50
63
  const keys = Object.keys(levels).map(Number);
51
64
 
@@ -69,6 +82,21 @@ function checkMap(name, map, violations) {
69
82
  }
70
83
  });
71
84
 
85
+ // 11. высоты объёмов уровня 0 живут в корне карты, как и его слои
86
+ checkVolumes(at, '0', map.layers, map.volumes, violations);
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
+
72
100
  const grid = map.map ?? [];
73
101
 
74
102
  for (const [key, level] of Object.entries(levels)) {
@@ -106,6 +134,9 @@ function checkMap(name, map, violations) {
106
134
 
107
135
  // 8. тайл рендер-слоя обязан существовать в тайл-листе по индексу
108
136
  checkLayerFrames(at, `level ${key}`, level?.layers, map.spriteSheet, violations);
137
+
138
+ // 11. высоты объёмов уровня
139
+ checkVolumes(at, key, level?.layers, level?.volumes, violations);
109
140
  }
110
141
 
111
142
  for (const [index, ramp] of (map.ramps ?? []).entries()) {
@@ -154,8 +185,17 @@ function checkMap(name, map, violations) {
154
185
  );
155
186
  }
156
187
  }
188
+
189
+ // 12. рампа через несколько уровней не имеет права прошивать плиту
190
+ // промежуточного: танк на ней въехал бы внутрь чужого моста
191
+ checkRampSlabs(at, map, index, ramp, from, to, fromGrid ?? [], violations);
157
192
  }
158
193
 
194
+ // 14. клетки разных рамп не пересекаются: ядро отдаёт общую клетку
195
+ // первой объявленной рампе, и поведение подъёма перестаёт выводиться
196
+ // из карты
197
+ checkRampOverlap(at, map, levels, grid, violations);
198
+
159
199
  // 10. край плиты без перил — обрыв; приземлиться с него нужно на
160
200
  // проходимую землю, иначе танк уезжает за карту (стены уровня 0 плите не
161
201
  // преграда) или приземляется внутри стены
@@ -309,7 +349,12 @@ function checkLevelEdges(at, map, violations) {
309
349
  continue;
310
350
  }
311
351
 
312
- if (groundWalkable(map, nx, ny)) {
352
+ // сосед вне плиты — обрыв: под ним обязана быть проходимая
353
+ // поверхность того уровня, на который тело приземлится (плита
354
+ // этажом ниже — законный обрыв, а не дыра)
355
+ const landing = landingLevel(map, Number(key), nx, ny);
356
+
357
+ if (walkable(map, landing, nx, ny)) {
313
358
  continue;
314
359
  }
315
360
 
@@ -324,6 +369,106 @@ function checkLevelEdges(at, map, violations) {
324
369
  }
325
370
  }
326
371
 
372
+ // зеркало `MapLevels::landing_level` (и `config_landing_level` валидатора
373
+ // ядра): ближайший уровень строго ниже `from` с плитой в этой клетке, иначе
374
+ // земля
375
+ function landingLevel(map, from, x, y) {
376
+ for (let level = from - 1; level >= 1; level -= 1) {
377
+ const cfg = (map.levels ?? {})[String(level)];
378
+ const tile = cellAt(cfg?.map ?? [], x, y);
379
+
380
+ if (tile !== undefined && (cfg?.floor ?? []).includes(tile)) {
381
+ return level;
382
+ }
383
+ }
384
+
385
+ return 0;
386
+ }
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
+
418
+ // клетки прогона рампы не должны лежать под плитой промежуточного уровня:
419
+ // иначе подъём прошивает чужой мост
420
+ function checkRampSlabs(at, map, index, ramp, from, to, grid, violations) {
421
+ const low = Math.min(from, to);
422
+ const high = Math.max(from, to);
423
+
424
+ for (let level = low + 1; level < high; level += 1) {
425
+ const cfg = (map.levels ?? {})[String(level)];
426
+
427
+ if (!cfg) {
428
+ continue;
429
+ }
430
+
431
+ grid.forEach((row, y) => {
432
+ row.forEach((tile, x) => {
433
+ if (tile !== ramp?.tile) {
434
+ return;
435
+ }
436
+
437
+ const over = cellAt(cfg.map ?? [], x, y);
438
+
439
+ if (over !== undefined && (cfg.floor ?? []).includes(over)) {
440
+ violations.push(
441
+ `${at}: ramp ${index} climbs ${from}->${to} through level ` +
442
+ `${level} floor at (${x}, ${y})`,
443
+ );
444
+ }
445
+ });
446
+ });
447
+ }
448
+ }
449
+
450
+ // высота рендер-слоя: ключ обязан быть ключом `layers` того же уровня,
451
+ // значение — конечная высота в уровнях. Ядро высоты не использует, поэтому
452
+ // опечатка в ключе даёт плоскую карту и ни одной строки в консоли
453
+ function checkVolumes(at, key, layers, volumes, violations) {
454
+ for (const [layer, height] of Object.entries(volumes ?? {})) {
455
+ if (!Object.hasOwn(layers ?? {}, layer)) {
456
+ violations.push(
457
+ `${at}: level ${key} volumes names layer ${layer}, which is not a ` +
458
+ 'render layer',
459
+ );
460
+ continue;
461
+ }
462
+
463
+ if (!Number.isFinite(height) || height <= 0 || height > MAX_LEVELS) {
464
+ violations.push(
465
+ `${at}: level ${key} volumes layer ${layer} height ${height} is not ` +
466
+ `in (0, ${MAX_LEVELS}]`,
467
+ );
468
+ }
469
+ }
470
+ }
471
+
327
472
  function checkLayerFrames(at, where, layers, spriteSheet, violations) {
328
473
  const frames = spriteSheet?.frames;
329
474
 
@@ -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
 
@@ -27,6 +27,14 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
27
27
  // слоёные карты: `levels`/`ramps` в формате карты, маски уровней в
28
28
  // физике, уровень участника (`set_actor_level`), слоёный нав-граф
29
29
  { value: 'map.layers', since: '0.25.0' },
30
+ // многоуровневые карты: уровней до MAX_LEVELS = 8 (бит на уровень),
31
+ // рампы через несколько уровней, правила уровня и падение у тел карты,
32
+ // `z`/`level` в строке динамики, `volumes` (высота рендер-слоя)
33
+ { value: 'map.levelsN', since: '0.31.0' },
34
+ // высота уровня карты в мировых единицах (`levelHeight`): безразмерный
35
+ // уклон рампы в ядре, поле `levelHeight` в контексте статического парта,
36
+ // прогоны рамп уровня (`ramps`) там же, коллайдеры-стражи прогона
37
+ { value: 'map.levelHeight', since: '0.32.0' },
30
38
  ]);
31
39
 
32
40
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`
@@ -25,11 +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,
40
+ mapFallTime:
41
+ Number.isFinite(gameFallTime) && gameFallTime > 0
42
+ ? gameFallTime
43
+ : gameConfig.mapFallTime,
33
44
  models,
34
45
  weapons,
35
46
  playerKeys: gameConfig.playerKeys,
@@ -50,6 +61,9 @@ export const buildCoreConfig = (gameConfig, overrides = {}) => {
50
61
  timeStep: flat.timeStep,
51
62
  mapScale: flat.mapScale,
52
63
  mapSetId: flat.mapSetId,
64
+ // движковый ключ, а не coreParams: траектория падения одна на танки и
65
+ // на тела карты, подменять её игровой половиной нельзя
66
+ mapFallTime: flat.mapFallTime,
53
67
  snapshot: flat.snapshot,
54
68
  seed: flat.seed,
55
69
  },
@@ -28,6 +28,9 @@ const FIELDS = {
28
28
  mapsInVote: { default: 1 },
29
29
  mapScale: { default: 1 },
30
30
  mapSetId: { default: null },
31
+ // секунды падения на уровень высоты (FallModel ядра): игра, не знающая о
32
+ // многоуровневых картах, получает движковое умолчание и не замечает поля
33
+ mapFallTime: { default: 0.35 },
31
34
 
32
35
  // лимит комнаты: без него берётся движковый (hostDefaults.maxPlayers)
33
36
  'roomDefaults.maxPlayers': { default: hostDefaults.maxPlayers },