vimp-engine 0.35.1 → 0.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -716,8 +716,11 @@ export default class SoundManager {
716
716
 
717
717
  // отсечка в мировых координатах: она обязана совпадать с maxDistance
718
718
  // самого PannerNode, поэтому зумом НЕ масштабируется — иначе движок
719
- // считал бы источник слышимым там, где узел уже отдал тишину
720
- if (Math.hypot(dx, dy) >= this._spatial.maxDistance) {
719
+ // считал бы источник слышимым там, где узел уже отдал тишину.
720
+ // «не ближе maxDistance», а не «дальше»: NaN-дистанция (координата
721
+ // источника или слушателя не число) иначе считалась бы слышимой на
722
+ // полной громкости
723
+ if (!(Math.hypot(dx, dy) < this._spatial.maxDistance)) {
721
724
  sound.volume(0, soundId);
722
725
 
723
726
  return false;
@@ -787,7 +790,8 @@ export default class SoundManager {
787
790
  * @private Пишет позицию в паннер, если она изменилась заметнее
788
791
  * POSITION_EPSILON. Единственная точка записи: у неподвижного источника
789
792
  * позиция уже в узле, и повторная запись — только лишняя автоматизация,
790
- * а именно её поток WebKit и не переносит.
793
+ * а именно её поток WebKit и не переносит. Неконечная позиция
794
+ * пропускается: не пишется и не запоминается.
791
795
  * @param {Howl} sound - Экземпляр Howl.
792
796
  * @param {number} soundId - ID конкретного проигрываемого экземпляра.
793
797
  * @param {number} px - Координата X в осях Web Audio.
@@ -795,6 +799,17 @@ export default class SoundManager {
795
799
  * @param {number} pz - Координата Z в осях Web Audio.
796
800
  */
797
801
  _writePos(sound, soundId, px, py, pz) {
802
+ // Неконечная координата в узел не пишется: Web Audio бросает на ней
803
+ // TypeError из setValueAtTime, исключение уходит из рендер-тика в тикер
804
+ // Pixi, и тот больше не запрашивает кадр — игра замирает целиком.
805
+ // Позиции источников задаёт и игра (registerSound/updateSoundData),
806
+ // поэтому одного фильтра камеры (lib/applyCamera.js) здесь мало.
807
+ // Запомненная позиция не трогается: следующую конечную сравним с
808
+ // настоящей
809
+ if (!Number.isFinite(px) || !Number.isFinite(py) || !Number.isFinite(pz)) {
810
+ return;
811
+ }
812
+
798
813
  const written = this._pannerPos.get(soundId);
799
814
 
800
815
  if (written !== undefined) {
@@ -14,12 +14,31 @@
14
14
  * @param {object} soundManager - Экземпляр SoundManager.
15
15
  * @param {Array | number} camera - `[x, y, cameraReset, shakeData]` из
16
16
  * горячего буфера ядра; `0` или пустое значение — кадра камеры нет.
17
+ * @param {Function} [onInvalid] - Зовётся с кадром, у которого x или y — не
18
+ * конечное число (NaN, Infinity, undefined, null); такой кадр не применяется.
17
19
  */
18
- export default function applyCamera(canvasManager, soundManager, camera) {
20
+ export default function applyCamera(
21
+ canvasManager,
22
+ soundManager,
23
+ camera,
24
+ onInvalid,
25
+ ) {
19
26
  if (!camera || camera === 0) {
20
27
  return;
21
28
  }
22
29
 
30
+ // Неконечная координата не применяется ни к полотну, ни к слушателю.
31
+ // Полотну один NaN вредит надолго: CanvasManagerModel сглаживает камеру
32
+ // через lerp, NaN оседает в его накопителях до cameraReset — масштаб
33
+ // сцены NaN, картинки нет. Слушателю — сразу: NaN уходит в Howl.pos(),
34
+ // тот бросает, и тикер Pixi останавливается. null отсекается тоже: serde
35
+ // пишет неконечное число JSON-кадра как null, а в арифметике null молча
36
+ // стал бы нулём — прыжок камеры в начало координат
37
+ if (!Number.isFinite(camera[0]) || !Number.isFinite(camera[1])) {
38
+ onInvalid?.(camera);
39
+ return;
40
+ }
41
+
23
42
  canvasManager.updateCoords(camera);
24
43
  soundManager.setListenerPosition(
25
44
  camera[0],
@@ -417,6 +417,7 @@ export function createDiagnostics({
417
417
  * @returns {Function} снятие перехватчиков
418
418
  */
419
419
  function install(target = window) {
420
+ const doc = target.document;
420
421
  // ошибки загрузки <img>/<script> приходят обычным Event на элементе —
421
422
  // это сбои ресурсов, а не исключения
422
423
  const onError = event => {
@@ -428,7 +429,14 @@ export function createDiagnostics({
428
429
  capture(event.reason, { kind: 'rejection' });
429
430
  };
430
431
  const onPageHide = () => flush();
431
- const doc = target.document;
432
+ // фоновую вкладку мобильный браузер убивает и без pagehide: скрытие —
433
+ // последний надёжный момент отправить накопленное (по таймеру отчёты
434
+ // ждут до minIntervalMs)
435
+ const onVisibility = () => {
436
+ if (doc?.visibilityState === 'hidden') {
437
+ flush();
438
+ }
439
+ };
432
440
 
433
441
  try {
434
442
  target.addEventListener('error', onError);
@@ -436,6 +444,7 @@ export function createDiagnostics({
436
444
  target.addEventListener('pagehide', onPageHide);
437
445
  // событие всплывает к документу
438
446
  doc?.addEventListener('securitypolicyviolation', reportCsp);
447
+ doc?.addEventListener('visibilitychange', onVisibility);
439
448
  } catch {
440
449
  // нет цели — просто не ловим
441
450
  }
@@ -446,6 +455,7 @@ export function createDiagnostics({
446
455
  target.removeEventListener('unhandledrejection', onRejection);
447
456
  target.removeEventListener('pagehide', onPageHide);
448
457
  doc?.removeEventListener('securitypolicyviolation', reportCsp);
458
+ doc?.removeEventListener('visibilitychange', onVisibility);
449
459
  } catch {
450
460
  // снятие не должно ронять вызывающего
451
461
  }
@@ -0,0 +1,66 @@
1
+ import { HOT_FLAGS } from '../../config/opcodes.js';
2
+ import { reconstructHot } from '../../lib/reconstructHot.js';
3
+
4
+ // Рендер-тик клиентского ядра: сэмпл, событийные кадры, горячие данные,
5
+ // камера. Вынесено из main.js ради одной вещи — порядка ЧТЕНИЯ, который
6
+ // ничем больше не защищён: main.js целиком в тесте не поднять.
7
+ //
8
+ // Hot-буфер читается view поверх памяти WASM (zero-copy), а view живёт
9
+ // ровно до следующего роста этой памяти: `memory.grow` отцепляет старый
10
+ // ArrayBuffer, длина view становится 0, hot[i] — undefined. Растить память
11
+ // может любой аллоцирующий вызов ядра: take_frames() собирает строку JSON
12
+ // в куче WASM, парты при разборе кадров сами зовут ядро. Поэтому всё нужное
13
+ // из буфера снимается копией СРАЗУ, а применяется в прежнем порядке.
14
+ // Раньше камера читалась после take_frames(): undefined в ней становился
15
+ // NaN, NaN навсегда оседал в сглаживании камеры (картинка пропадала до
16
+ // cameraReset), а слушатель звука с NaN ронял Howl.pos() — и тикер Pixi.
17
+
18
+ /**
19
+ * Один рендер-тик клиентского ядра.
20
+ * @param {Object} deps
21
+ * @param {Object} deps.core - ClientCore: sample, hot_ptr, take_frames.
22
+ * @param {WebAssembly.Memory} deps.memory - Память WASM клиентского ядра.
23
+ * @param {Object} deps.snapshotKeysById - Результат buildSnapshotKeysById.
24
+ * @param {number} deps.now - Время рендера (performance.now()).
25
+ * @param {Function} deps.applyShot - (game, camera): событийный кадр.
26
+ * @param {Function} deps.applyGameData - (game): горячие данные сущностей.
27
+ * @param {Function} deps.applyCamera - ([x, y]): камера тика.
28
+ */
29
+ export default function runHotTick({
30
+ core,
31
+ memory,
32
+ snapshotKeysById,
33
+ now,
34
+ applyShot,
35
+ applyGameData,
36
+ applyCamera,
37
+ }) {
38
+ const len = core.sample(now);
39
+ // указатель — раньше memory.buffer: аргументы `new Float32Array(...)`
40
+ // вычисляются слева направо, и buffer, взятый до вызова ядра, мог бы уже
41
+ // оказаться отцепленным
42
+ const ptr = core.hot_ptr();
43
+ const hot = new Float32Array(memory.buffer, ptr, len);
44
+ const flags = hot[0];
45
+ // reconstructHot копирует поля (Array.from): после разбора view не нужен
46
+ const game =
47
+ flags & (HOT_FLAGS.GAME | HOT_FLAGS.PREDICTED)
48
+ ? reconstructHot(hot, snapshotKeysById)
49
+ : null;
50
+ const camera = flags & HOT_FLAGS.CAMERA ? [hot[1], hot[2]] : null;
51
+
52
+ if (flags & HOT_FLAGS.FRAMES) {
53
+ JSON.parse(core.take_frames()).forEach(frame => {
54
+ applyShot(frame.game, frame.camera);
55
+ });
56
+ }
57
+
58
+ if (game) {
59
+ applyGameData(game);
60
+ }
61
+
62
+ // камера уже разрешена ядром: предсказанная позиция либо интерполированная
63
+ if (camera) {
64
+ applyCamera(camera);
65
+ }
66
+ }
@@ -52,10 +52,7 @@ import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
52
52
  import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
53
53
  import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
54
54
  import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
55
- import {
56
- buildSnapshotKeysById,
57
- reconstructHot,
58
- } from '../lib/reconstructHot.js';
55
+ import { buildSnapshotKeysById } from '../lib/reconstructHot.js';
59
56
  import Factory from '../lib/factory.js';
60
57
  import { formatMessage } from '../lib/formatters.js';
61
58
  import { sanitizeMessage } from '../lib/sanitizers.js';
@@ -87,7 +84,6 @@ import ClientReportsView from './components/view/ClientReports.js';
87
84
  import ClientReportsCtrl from './components/controller/ClientReports.js';
88
85
  import BakingProvider from './providers/BakingProvider.js';
89
86
  import DependencyProvider from './providers/DependencyProvider.js';
90
- import { HOT_FLAGS } from '../config/opcodes.js';
91
87
  import wsports from '../config/wsports.js';
92
88
  import GAME_CODES from '../config/gameCodes.js';
93
89
  import {
@@ -99,6 +95,7 @@ import lobbyConfig from '../config/lobby.js';
99
95
  import authClientConfig from '../config/authClient.js';
100
96
  import clientDefaults from '../config/clientDefaults.js';
101
97
  import applyCamera from './lib/applyCamera.js';
98
+ import runHotTick from './lib/hotTick.js';
102
99
 
103
100
  // Динамическая загрузка игры по каталогу мастера (Этап 6.3): ClientPlugin
104
101
  // (parts, bakers, игровой CSS, хуки ядра) грузится по entries.client манифеста
@@ -951,15 +948,28 @@ function applyGameData(game) {
951
948
  });
952
949
  }
953
950
 
951
+ // Камеру с NaN/undefined applyCamera не применяет (lib/applyCamera.js), но
952
+ // молчать о ней нельзя: это симптом сбоя выше по течению (ядро, чтение
953
+ // hot-буфера), и журнал — единственный способ узнать о нём с прода.
954
+ // String(): JSON.stringify превратил бы NaN в null, а undefined потерял бы
955
+ function reportBadCamera(camera) {
956
+ diagnostics.warn(
957
+ 'engine.camera.non-finite',
958
+ { x: String(camera[0]), y: String(camera[1]) },
959
+ { source: 'client' },
960
+ );
961
+ }
962
+
954
963
  // применяет кадр целиком (первый кадр и дискретные кадры интерполяции)
955
964
  function applyShot(game, camera) {
956
965
  applyGameData(game);
957
- applyCamera(modules.canvasManager, soundManager, camera);
966
+ applyCamera(modules.canvasManager, soundManager, camera, reportBadCamera);
958
967
  }
959
968
 
960
969
  // рендер-тик: ядро выдаёт пересечённые кадры (события, создания/удаления)
961
970
  // JSON-очередью, а горячие позиции (танки/динамика/камера + предсказанный
962
- // свой танк) — плоским Float32-буфером zero-copy из памяти WASM
971
+ // свой танк) — плоским Float32-буфером zero-copy из памяти WASM; порядок
972
+ // чтения буфера — в lib/hotTick.js
963
973
  function renderTick() {
964
974
  if (!clientCore) {
965
975
  return;
@@ -974,26 +984,16 @@ function renderTick() {
974
984
  task();
975
985
  }
976
986
 
977
- const len = clientCore.sample(performance.now());
978
-
979
- // view пересоздаётся каждый тик: рост памяти WASM детачит buffer
980
- const hot = new Float32Array(wasm.memory.buffer, clientCore.hot_ptr(), len);
981
- const flags = hot[0];
982
-
983
- if (flags & HOT_FLAGS.FRAMES) {
984
- JSON.parse(clientCore.take_frames()).forEach(frame => {
985
- applyShot(frame.game, frame.camera);
986
- });
987
- }
988
-
989
- if (flags & (HOT_FLAGS.GAME | HOT_FLAGS.PREDICTED)) {
990
- applyGameData(reconstructHot(hot, snapshotKeysById));
991
- }
992
-
993
- if (flags & HOT_FLAGS.CAMERA) {
994
- // камера уже разрешена ядром: предсказанная позиция либо интерполированная
995
- applyCamera(modules.canvasManager, soundManager, [hot[1], hot[2]]);
996
- }
987
+ runHotTick({
988
+ core: clientCore,
989
+ memory: wasm.memory,
990
+ snapshotKeysById,
991
+ now: performance.now(),
992
+ applyShot,
993
+ applyGameData,
994
+ applyCamera: camera =>
995
+ applyCamera(modules.canvasManager, soundManager, camera, reportBadCamera),
996
+ });
997
997
 
998
998
  soundManager.processAudibility();
999
999
  soundManager.updateActiveSounds();