Клиентская среда для авторов игр
С чем игра-плагин встречается в клиенте: единый экземпляр 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:
| Профиль | X | Y | Z | дефолтный panningModel |
|---|---|---|---|---|
topDown | sx | -H | sy | HRTF |
sideScroller | sx | -sy * verticalFactor | -H | equalpower |
cockpit | sx | -sy | -H | HRTF |
При 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) и звучит правильно. Каждый ключ проверяется отдельно и по одному падает на дефолт с предупреждением в консоль, поэтому неверное значение не может сломать аудиоконтекст:
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, который иначе перехватывал бы клики по ней, и ниже экрана авторизации игры. Экраны лобби и меню комнаты принадлежат платформе и в контракт плагина не входят.