Skip to content

Клиентская среда для авторов игр ​

С чем игра-плагин встречается в клиенте: единый экземпляр PixiJS, диспетчер и рендер-цикл, которые вызывают плагин, компоненты движка, которыми игра управляет через конфиг и данные снапшота, клиентское ядро (ClientCore), отрисовка через parts/, Factory и провайдеры, менеджер звука, ввод и слои UI.

Единый экземпляр PixiJS ​

Движок поставляет PixiJS странице через import map браузера. Плагин игры обязан объявлять pixi.js внешним peerDependency (и в списке external сборщика) и никогда не бандлить собственную копию: две независимые копии дают два реестра рендерера PixiJS, и отрисовка падает. Держите диапазон peerDependencies для pixi.js в согласии с версией, которую поставляет установленный vimp-engine.

Диспетчер и рендер-цикл ​

На что плагин может опираться при отрисовке кадра, после смены карты и потери контекста.

  • Рендер-цикл: на каждом кадре анимации движок зовёт clientCore.sample(now), читает плоский hot-буфер zero-copy из памяти WASM целиком, до любого другого вызова ядра, затем take_frames() для редких кадров событий и применяет результат в порядке кадры → hot-данные → камера (см. «Клиентское ядро» ниже).
  • Сбросы: смена карты (MAP_DATA → set_map) и CLEAR (→ reset) очищают буфер кадров и предикт в ядре; reset вдобавок обнуляет идентичность игрока (my_game_id), чтобы предикт не рисовал сущность, которой на хосте уже нет.
  • Карта отдаёт payload клиентскому ядру (set_map) и собирает статические данные рендера. В ядро слоёные поля уходят как приехали — levels (надземные уровни: map, floor, walls, layers) и ramps, — чтобы оно построило ту же геометрию, что и хост: без них предсказание уровня молча разъехалось бы с авторитетным. Данные рендера собираются по уровням: уровень 0 — из layers над гридом map, каждый надземный — из levels[n].layers над гридом levels[n].map. Ключи s0..sN сквозные по всем уровням (это id экземпляров парта в GameModel — два ключа s0 перетёрли бы друг друга, и половина карты не появилась бы вовсе), и каждый экземпляр получает level (номер своего уровня), solid (тайлы, блокирующие движение на нём: physicsStatic для уровня 0, levels[n].walls выше) и floor (levels[n].floor, на уровне 0 пусто) рядом с map, step, layer, tiles, physicsStatic, scale и spriteSheet, которые парт читал и раньше. Карта без levels даёт ровно то же, что и прежде.
  • Пробуждение вкладки: после паузы не меньше 3 с движок зовёт необязательный clientCore.resync() (старому ядру без него это не мешает); resync сбрасывает буфер кадров, включая кадры событий, поэтому после короткого alt-tab его не делают.
  • Потеря WebGL-контекста: когда контексты всех полотен снова живы, движок снимает все контроллеры (они держат мёртвые текстуры), перезапекает ассеты, пересобирает карту из закешированного MAP_DATA и перезапускает рендер-цикл; парты не должны хранить текстуры через эту пересборку.

Компоненты, которыми управляет игра ​

Компоненты движка, которыми игра управляет через конфиг и данные снапшота. Экраны лобби (LobbyAuth, Profile, Lobby) принадлежат платформе и в контракт плагина не входят.

  • Auth — комнатная форма входа только для игро-специфичных полей (например, model), с клиентской валидацией и localStorage; поля строятся из PS_AUTH_DATA.params[] — см. plugin-api.md. Ник здесь не вводится: он берётся из проверенной личности пользователя.
  • CanvasManager — управляет несколькими PixiJS Application одновременно: vimp (основной игровой canvas) и radar (мини-карта); canvas-элементы генерирует клиент движка из конфига канвасов игры (modules.canvasManager.canvases, включая стартовые width/height) — в HTML их нет. Адаптивное масштабирование (эталон 1920px), aspectRatio/fixSize/baseScale, динамическая камера (look-ahead, zoom от скорости) и тряска — параметры в game-config.md. Кадр камеры только двигает сцену; полотно рисуется раз за тик, после применения кадра целиком, поэтому onRender части никогда не видит наполовину применённый кадр. Координаты измеряются в единицах renderer.screen (не в пикселях буфера полотна), так что игра, восстанавливающая центр камеры по трансформу сцены (например, проекция высоты 2.5D в @vimp-games/tanks), остаётся верной при любом resolution.
  • Controls — перехват клавиатуры (InputListener), активный набор клавиш диктует сервер (порт 17), режимы chat/vote/stat, отправка ввода "seq:action:name". Дополнительно — канал указателя (мышь, палец, стилус: один набор Pointer Events): игра объявляет его как modules.controls.pointer, по проводу идёт "seq:aim:x:y:flags" с мировой точкой и битами: бит 0 «прижат», бит 1 «двойной тап». Гейты те же, что у клавиш: выключенный ввод, открытый режим или набор клавиш вне pointer.keySets гасят канал и отпускают прижатый указатель. Игра, не объявившая pointer, не получает ни слушателя, ни трафика — см. InputListener.
  • Game — ядро рендеринга: GameCtrl.parse(name, data) создаёт/обновляет/удаляет экземпляры сущностей по снапшот-данным через Factory.
  • Chat — вывод сообщений (лимит строк, время жизни), командная строка; экранирование на выводе (textContent); системные сообщения используют английские умолчания движка из config/chatMessages.js, если игра их не перекрыла.
  • Panel — HUD: время раунда, здоровье, боезапас, активное оружие (по строкам 'ключ:значение'). PanelView генерирует DOM по схеме игры (modules.panel.fields: упорядоченный список { name, elem, type }; семантику ячейки задаёт type: 'bar' | 'value' | 'time' | 'weapon', а не имя поля — у bar дополнительно max и blocks) внутри движкового контейнера #panel; внешний вид ячеек — CSS игры (блоки бара — нейтральные движковые классы panel-bar-*). Заголовок #logo внутри #panel показывает название игры из authSchema.texts.title (то же значение, что и #auth-title), применяется при получении PS_AUTH_DATA, до этого/при отсутствии — резервное 'VIMP'; CSS #panel/#logo построен на flex, так что таблица панели подстраивается под название любой длины.
  • Stat — таблицы scoreboard с сортировкой (sortList), показывается по Tab. StatView генерирует шапку и таблицы по схеме игры (modules.stat.params: columns — подписи колонок, bodies — произвольное число команд) внутри контейнера #stat; цвета/подписи команд — CSS игры.
  • Vote — окна голосований из шаблонов, пагинация, таймер жизни. Голосования движка (имя с @, зарезервировано — сейчас только @changeHost) приходят от платформы, а не от хоста; голосования движка и хоста друг друга не затирают — пришедшее вторым ждёт в очереди.

Клиентское ядро (ClientCore) ​

Клиентская математика — интерполяция снапшотов, предикт своего танка, визуальный спавн снарядов и распаковка кадров v3 — живёт в Rust-ядре (vimp-engine-core/src/client/ + собственный core/src/client/ игры-плагина, например в @vimp-games/tanks, wasm-bindgen класс ClientCore из того же WASM-бинаря, что GameCore хоста). JS-оболочка только пересылает данные и применяет результат к рендеру; ABI и раскладки — в core.md.

Что движок делает с ядром:

  • Кадры и порты. Бинарный кадр входит через push_frame(bytes, now); ядро декодирует его (несовпадение версии отбрасывает кадр), вставляет в буфер по seq с дедупликацией и, если в кадре есть player-блок, сверяет предиктор. MAP_DATA/PANEL_DATA/KEYSET_DATA/CLEAR зеркалятся в set_map/sync_panel/set_active/reset; модель задаётся set_model при авторизации. reset означает «мира нет»: он чистит буфер, предиктор и my_game_id (восстанавливается из следующего player-блока; у наблюдателя его нет).
  • resync() (необязательный) чистит только сетевую половину — буфер интерполяции и очередь исходящих кадров, — сохраняя предсказание и идентичность. Движок зовёт его, когда вкладка снова видима после долгой паузы.
  • Каждый рендер-тик. sample(now) возвращает длину плоского hot-буфера, читаемого zero-copy через hot_ptr() и Float32Array поверх памяти Wasm. View действителен лишь до роста памяти, поэтому всё нужное читайте до любого другого вызова ядра. Буфер несёт флаги, камеру (уже разрешённую), интерполированные записи и в конце — предсказанные записи игры: локального актора (render_overlay) и тела, которые игра предсказывает сама (render_rows). Движок превращает его в { key: { id: [...] } } для parts.
  • Кадры событий. take_frames() возвращает JSON-массив [{ game, camera }, …]: каждый пересечённый кадр ровно один раз (события, создания/удаления, сброс/тряска камеры), дубликаты собственных выстрелов уже подавлены. Звук и эффекты срабатывают из parts при создании сущности.
  • Ввод. apply_input(action, name, now) пишет историю предиктора; apply_aim(x, y, flags, now) пишет указатель (реализация по умолчанию пустая, ядру, игнорирующему указатель, правок не нужно). Игровые действия идут через ClientPlugin.hooks.onLocalAction (например, try_fire, cycle_weapon); отправка хосту "seq:action:name" не меняется.

ClientPlugin игры (index игры-плагина, например в @vimp-games/tanks; грузится движком динамически по entries.client манифеста — gamePlugin) поставляет parts (рендеры сущностей), bakers (процедурные текстуры), игровой CSS и хуки. Игровые методы ядра зовутся только из его хуков — onAuth (set_model при авторизации), onPanel (sync_panel на кадр панели), onLocalAction (напр. try_fire/cycle_weapon в @vimp-games/tanks); клиент движка игровых методов ядра не знает. Игровой CSS (ячейки панели, полотна, цвета команд) — собственный src/client/*.css игры-плагина (например, @vimp-games/tanks'ы tanks.css), движковый каркас UI — vimp-engine/src/client/style.css.

Что даёт ядро, коротко:

  • Интерполяция (vimp-engine-core): мир рисуется на serverNow − interpolation.delay (по умолчанию 100 мс); позиции интерполируются линейно (углы — по кратчайшему пути), дискретные поля берутся из опорного кадра, экстраполяции нет, события запоздавшего кадра выдаются сразу.
  • Предсказание (ваше ядро, client/predictor.rs): реплика авторитетного движения без коллизий физического движка, с фиксированным timeStep; формулы тика общие с кодом обновления актора вашего ядра, а cargo-набор паритета (client::predictor::parity) фиксирует паритет интегрирования. Он хранит историю ввода, переигрывает от serverTime кадра, затухает видимой ошибкой экспоненциально (со snap) и сбрасывается при форс-сбросе камеры, смене карты или набора клавиш.
  • Спавн выстрелов (ваше ядро, client/shot.rs): реплика авторитетных формул гейта и дула, рейкаст по тайлам стен плюс OBB-тест, единый гейт ожидающего снаряда, позиция с компенсацией RTT и подавление авторитетных дубликатов по id автора; имена полей и точный гейтинг задаёт игра (см. snapshot.md). Чисто клиентская визуальная случайность (например, разброс трассера) — только визуальна: авторитетная сущность приходит в кадре.

Рендеринг ​

parts/ — сущности ​

Собственный src/client/parts/ игры-плагина (например, @vimp-games/tanks'ы) — классы, отрисовываемые на PixiJS-полотнах, по одному на игровой тип сущности (напр. @vimp-games/tanks'ы Tank, TankRadar, Map, MapRadar, Bomb, Smoke, Tracks). Эффекты следуют той же плагин-собственной конвенции (напр. @vimp-games/tanks'ы parts/effects/), анимируются на Ticker.shared.

Соответствие снапшот-ключей классам и распределение по полотнам — gameSets/entitiesOnCanvas в client.js. Фиксированного контракта у part нет — при создании новой смотреть существующие как образец.

Factory ​

factory — реестр имя сущности → класс. GameCtrl.parse(name, data) по входным данным создаёт экземпляр, вызывает update(data) существующего или удаляет (null).

Провайдеры ​

  • BakingProvider — однократная генерация процедурных текстур при старте по конфигу bakedAssets; функции запекания — в src/client/bakers/ игры-плагина (например, в @vimp-games/tanks; фиксированного интерфейса нет, ориентироваться на существующие). Пекарь владеет тем, что вернул: перепечка уничтожает прежний результат (каждый объект ровно один раз за проход, даже если он лежал под несколькими ключами) вместе с его TextureSource, поэтому возвращать вьюху на общий атлас нельзя.
  • DependencyProvider — инъекция сервисов (renderer, soundManager, assetsBase, localPlayer) в компоненты по карте componentDependencies. localPlayer ({ id, is(id) }, localPlayer) отвечает, своя ли это сущность: part сравнивает id, полученный четвёртым аргументом конструктора ({ id }), со своим gameId, который сервис читает у клиентского ядра лениво. Так игра играет звук только за своего персонажа, а не за каждую сущность на полотне. assetsBase — база ассетов активной игры из её манифеста: part, рисующий из файлов-картинок, строит URL сам как ${assetsBase}img/<file> — тем же способом, каким разрешаются звуки (${assetsBase}sounds/). Картинок игры движок не везёт: они едут в пакете плагина (dist/img/). Рядом с движковыми сервисами в пуле лежат игровые: необязательный ClientPlugin.hooks.services(core) возвращает карту, которая подмешивается в пул перед движковыми ключами, — так игра достаёт из part'а своё ядро, а движок не знает, что именно она отдаёт (плагин танков раздаёт так mapDynamics — геометрию предсказанной динамики карты, по которой эффект выстрела привязывает осколки к задетому ящику). При совпадении имён движковый ключ побеждает, а сервис, не объявленный никем в componentDependencies, просто не выдаётся. Незнакомое имя — не отказ загрузки: part просто получает undefined (ради этого и существует правило C4 у vimp-contract: сервис, которого никто не дал, выглядит как чистый холст без единой ошибки). Имена, которые опубликованная игра написала в componentDependencies, работают вечно.

SoundManager ​

SoundManager (на Howler.js). Звуки описаны в src/config/sounds.js игры-плагина (например, в @vimp-games/tanks); поле path переопределяется на клиенте (на клиенте по CONFIG_DATA) на ${activeGameManifest.assetsBase}sounds/ — собственную копию звуков сборки игры рядом с её client/host-бандлами (dist/sounds/ игры-плагина), вместо бандловой /sounds/ движка.

  • UI/системные (без позиции): playSystemSound(name) — немедленно, в обход приоритетов (используется и для звуков порта 6).
  • Пространственные (позиция в мире): registerSound(name, { position }) → processAudibility() → updateActiveSounds() — менеджер сам решает, что слышно, соблюдая лимит голосов (WORLD_VOICE_LIMIT = 30) и приоритеты из конфига.
  • Непространственные (звук своего игрока): registerSound(name, { position, spatial: false }) — источник принадлежит игроку, а не миру; он играет «сухо» (без панорамы), потому что сидит на слушателе. Флаг можно менять позже через updateSoundData(id, { spatial }).
  • Снятие с регистрации: unregisterSound(id) останавливает инстанс и снимает регистрацию (звук сущности умирает вместе с ней); releaseSound(id) снимает регистрацию, но даёт уже звучащему одноразовому сэмплу доиграть (луп всё равно останавливается).
  • reset() останавливает все звучащие инстансы и сохраняет луповые регистрации (сущности снимают их сами в destroy()); одноразовые регистрации снимаются.

Виртуальная высота и рассеивание ​

Слушатель всегда стоит в (0, 0, 0), мировой источник задаётся вектором относительно него. Слушатель поднят над плоскостью игры на virtualElevation, а внутри innerRadius этот вектор плавно гасится через smoothstep, поэтому позиция считается одной непрерывной формулой на каждом кадре — ни порога, ни ступеньки, ни щелчка:

dx  = x - listenerX
dy  = y - listenerY
d2d = Math.hypot(dx, dy)

zoom = множитель зума камеры (1 = покой, < 1 = отдаление)
H    = virtualElevation / zoom
R    = innerRadius      / zoom

spread = smoothstep(0, R, d2d)     // 0 в центре, 1 за радиусом
sx = dx * spread
sy = dy * spread

Профиль проекции (mode) отображает (sx, sy, H) на оси Web Audio. Мировой x растёт вправо, мировой y — вниз, а вектор «вверх» слушателя (Howler.orientation(0, 0, -1, 0, 1, 0)) — это +Y, отсюда минус везде, где мировой y попадает на ось Y:

ПрофильXYZдефолтный panningModel
topDownsx-HsyHRTF
sideScrollersx-sy * verticalFactor-Hequalpower
cockpitsx-sy-HHRTF

При d2d = 0 источник в topDown стоит ровно в (0, -H, 0): строго снизу по центру, одинаково в оба уха, без ушной фильтрации. При dx = 20 и H = 180 угол atan(20/180) ≈ 6.3°, при dx = 600 — ≈ 73°. Реальная 3D-дистанция никогда не меньше H, поэтому refDistance стоит выше неё (200 > 180) — источник вплотную к игроку по-прежнему звучит на полной громкости. equalpower — дефолт sideScroller, потому что «спереди/сзади» в виде сбоку физически бессмысленно, а чистый тембр лево/право важен; verticalFactor (0.2) занижает вертикаль по той же причине, по которой высота в платформере почти не читается на слух.

Масштаб сцены (масштаб полотна × динамический зум камеры) делит высоту и радиус рассеивания, но не дистанции затухания: refDistance, maxDistance и rolloffFactor остаются в мировых единицах.

parts.sounds.spatial ​

Блок необязателен: игра, которая ничего не объявила, получает движковые дефолты (vimp-engine/src/config/clientDefaults.js) и звучит правильно. Каждый ключ проверяется отдельно и по одному падает на дефолт с предупреждением в консоль, поэтому неверное значение не может сломать аудиоконтекст:

javascript
parts: {
  sounds: {
    codecList: ['webm', 'mp3'],
    spatial: {
      mode: 'topDown',          // 'topDown' | 'sideScroller' | 'cockpit'
      virtualElevation: 180,    // мировых ед., высота ушей над плоскостью
      innerRadius: 40,          // мировых ед., габарит игрока
      verticalFactor: 0.2,      // только sideScroller: вклад вертикали
      panningModel: 'HRTF',     // 'HRTF' | 'equalpower' (дефолт профиля)
      distanceModel: 'inverse', // 'linear' | 'inverse' | 'exponential'
      refDistance: 200,         // мировых ед., до неё громкость 100 %
      maxDistance: 1200,        // мировых ед., дальше — тишина
      rolloffFactor: 0.9,
    },
    sounds: { /* каталог */ },
  },
}

Числа здесь — мировые единицы, а не экранные пиксели. В менеджер приходят мировые координаты (камера из горячего буфера ядра, regSound.position от частей игры), а экранный масштаб — отдельный множитель CanvasManager: currentScale = baseScale * (ширина окна / 1920). В @vimp-games/tanks (mapScale 0.3, baseScale 5) одна мировая единица — 5 экранных px, в @vimp-games/snakes (mapScale 1, baseScale 1) — ровно один.

  • innerRadius — это габарит игрока в мировых единицах: всё, что звучит внутри него, плавно сводится к центру и распределяется поровну в оба уха. Танки: корпус 8 × 6 единиц, полудиагональ ≈ 5. Змейка: baseRadius 14. Движковый дефолт 40 верен только для игры, где мировая единица равна экранному пикселю.
  • virtualElevation калибруется от видимой полувысоты экрана в расчётном окне (1920×1080): H ≈ (высота полотна / 2) / baseScale. Больше H — мягче панорама вблизи игрока, меньше — агрессивнее разделение по ушам. Для игры с mapScale 0.3 и baseScale 5 это даёт H ≈ 540 / 5 = 108, а половина экрана по горизонтали — 960 / 5 = 192 единицы, то есть угол atan(192/108) ≈ 60° на краю экрана и единицы градусов на дистанции габарита игрока. Калибровать нужно только расчётное окно: на любом другом размере движок пересчитает H сам через масштаб сцены выше, поэтому объявленное игрой число остаётся верным и на ноутбуке, и на 4K.

Опечатка в ключе или неизвестный движку mode — молчаливый откат на дефолт. Статически это ловит только правило E6 у vimp-contract, см. debugging.md.

InputListener ​

InputListener — низкоуровневый перехват keydown/keyup для Controls; modes/cmds имеют приоритет над игровым набором клавиш.

Иерархия UI (z-index) ​

vimp (1) → radar (2) → chat (3) → vote (5) → game-informer (6) → panel/stat (7) → lobby/auth/session-overlay (8) → tech-informer (9). Панель лежит выше прозрачного #game-informer, который иначе перехватывал бы клики по ней, и ниже экрана авторизации игры. Экраны лобби и меню комнаты принадлежат платформе и в контракт плагина не входят.


← Предыдущая: Мета-слой хоста · Следующая: Карты и ассеты →

vimp-engine 0.38.1 · vimp-engine-core 0.23.2 · create-vimp-game 0.5.4