Plugin API
Контракты ниже — интерфейс между движком и плагином игры; модель за ними — в overview.md.
Движок — приложение (деплоится один раз: лобби и сигналинг платформы, транспорт, Worker-инфраструктура, мета-механизмы, MVC-каркас клиента, Rust-каркас). Игра — динамический плагин: JS-бандлы (client/host), WASM-бинарь и ассеты, загружаемые по манифесту, который отдаёт платформа. Одна платформа обслуживает много игр.
Четыре контракта, помеченные константой ENGINE_API_VERSION (владелец — движок, vimp-engine/src/config/opcodes.js, заморожена на 4). Метка больше не гейт: за возраст плагин не отвергается никогда. Единственная причина отказа — плагин просит через необязательное поле манифеста requires возможность, которой в этой сборке движка нет, то есть он новее движка:
- GameManifest — JSON-описание сборки игры (платформа → лобби/хост/клиент);
- HostPlugin API — default export host-entry игры (worker-safe);
- ClientPlugin API — default export client-entry игры;
- Wasm Host ABI — обязательный набор методов WASM-классов игры.
GameManifest
Генерируется сборкой игры в dist/manifest.json игры-плагина (например, в @vimp-games/tanks); версия — хеш контента бандлов. Платформа не исполняет код игры — ему хватает манифеста и статических JSON карт (продукт maps:export при сборке игры).
{
"id": "tanks",
"engineApi": 4,
"version": "<hash>", // gameVersion (контент client+host+wasm)
"title": "VIMP Tanks", // для лобби
"entries": {
"client": "/games/tanks/client-<hash>.js", // ESM, default export = ClientPlugin
"host": "/games/tanks/host-<hash>.js", // ESM worker-safe, default export = HostPlugin
"wasm": "/games/tanks/core-<hash>.wasm", // единый hashed .wasm обоих entry (общий HTTP-кеш)
"wasmNode": "./core-node/index.js", // ОПЦИОНАЛЬНО: node-сборка ядра внутри dist/, для `npm run sim` и dedicated-сервера
},
"assetsBase": "/games/tanks/", // база собственных ассетов пакета:
// sounds/ и img/ (см. ниже)
"requires": ["accolades"], // ОПЦИОНАЛЬНО: возможности движка, без
// которых игра не работает (см. ниже)
"maps": { "version": "<hash>", "list": ["pool mini", "canopy", "garden"] },
"roomDefaults": {
"maxPlayers": 8,
"roundTime": 120000,
"mapTime": 600000,
"friendlyFire": false,
"map": "pool mini",
},
"roomForm": [
// regExp И min/max у maxPlayers/roundTime/mapTime генерирует
// build-game-manifest.js из одних и тех же границ — точный диапазонный
// паттерн (например, "^([1-8])$" для maxPlayers 1..roomDefaults.maxPlayers)
// плюс сами два числа, из которых он собран, а не вручную в game.js;
// здесь опущен для читаемости
{
"name": "maxPlayers",
"control": "text",
"label": "Max players",
"numeric": true,
"min": 1,
"max": 8,
"regExp": "<сгенерирован>",
},
{
"name": "roundTime",
"control": "text",
"label": "Round time",
"unit": "s",
"numeric": true,
"min": 10,
"max": 3600,
"regExp": "<сгенерирован>",
},
{
"name": "mapTime",
"control": "text",
"label": "Map time",
"unit": "s",
"numeric": true,
"min": 10,
"max": 3600,
"regExp": "<сгенерирован>",
},
{ "name": "friendlyFire", "control": "checkbox", "label": "Friendly fire" },
{ "name": "map", "control": "select", "label": "Map", "source": "maps" },
],
}roomForm — явный упорядоченный массив дескрипторов полей, из которого рендерится форма «Create server» (см. Схему формы ниже); roomDefaults остаётся источником значений по умолчанию и начальным набором ключей комнаты. Манифест без roomForm рендерит пустую форму создания комнаты (с предупреждением в консоли) вместо вывода контролов из типа значений roomDefaults.
entries.wasmNode опционально и браузером не используется: это путь (относительно манифеста) к node-сборке того же WASM-ядра, которую берёт headless-runner npm run sim -- --game <пакет> (см. debugging.md) и dedicated-сервер (тот же матч в процессе Node), который крутит авторитетный матч внутри процесса Node. Для игры, которую собираются держать на dedicated-боксе, поле поэтому фактически обязательно: там нет подмены через --core, и игра без него роняет сервер на старте. Путь обязан вести внутрь публикуемого dist/ (конвенция — ./core-node/, куда сборка копирует свой core/pkg-node/): каталог wasm-pack'а обычно git-ignored, а npm применяет ignore-правила и внутри каталогов из files, так что манифест с путём наружу работает в чекауте и ломается в установленном пакете. Без него игру всё ещё можно прогнать headless, передав --core <путь> явно; игра, у которой нет ни того, ни другого, просто не симулируется на своём настоящем ядре. Если поле объявлено, файл обязан быть в пакете как опубликован — загрузчик проверяет это и называет нарушенный контракт; заодно импортируемые половины плагина сверяются с engineApi манифеста (пересобранный манифест рядом с отставшим dist/ — это отказ, а не зелёный прогон).
Половина, грузимая в Node, обязана быть самодостаточной. Внешние зависимости — в первую очередь pixi.js — допустимы только в клиентской половине и только в браузере, где их резолвит import map (правило контракта A1 держит pixi.js вне dependencies, чтобы движок и игра делили один экземпляр PixiJS). Поэтому клиентскую половину Node импортирует по требованию: dedicated-сервер её не касается вовсе (браузер получает её статикой), а headless-раннер, которому она нужна виртуальным клиентам, просит её сразу. Host-половина и entries.wasmNode грузятся в Node всегда, и неразрешимый импорт в них роняет старт именованным отказом с именем entry и ненайденного пакета, а не сырым ERR_MODULE_NOT_FOUND.
Из-за него createCore/createClientCore получают wasmUrl в двух видах: браузер передаёт URL .wasm-ассета, headless-раннер — file:-URL node-глюe (wasmNode). Плагин, которому нужны headless-прогоны, ветвится по суффиксу — у сборки --target nodejs wasm подгружает сам модуль, поэтому init() там нет:
async createCore(coreConfigJson, { wasmUrl }) {
if (wasmUrl?.endsWith('.js')) {
const node = await import(/* @vite-ignore */ wasmUrl);
return new node.GameCore(coreConfigJson);
}
await init({ module_or_path: wasmUrl });
return new GameCore(coreConfigJson);
}Проекции: платформа — весь манифест + файлы карт; хост — entries.host (dynamic import в Worker'е) + entries.wasm + карты с платформы; клиент — entries.client (dynamic import после выбора комнаты) + entries.wasm + assetsBase. Богатые схемы (панель, тексты, keysets) в манифест не входят — едут кодом плагинов и, как сейчас, данными CONFIG_DATA (порт 0) от хоста: клиентские данные игры всегда согласованы с хостом комнаты.
assetsBase — корень всего, что пакет везёт файлами; по соглашению это два каталога внутри dist/:
| Путь | Содержимое | Кто разрешает |
|---|---|---|
${assetsBase}sounds/ | пара webm+mp3 на каждый звук | движок (SoundManager, он перезаписывает sounds.path) |
${assetsBase}img/ | тайл-листы и спрайты динамических тел карт | собственный part плагина, через сервис assetsBase |
Движок не раздаёт ни одного игрового файла: имя из spriteSheet.img карты разрешается относительно пакета плагина, а не бандла движка. База доезжает до part'а сервисом assetsBase — объявите его в componentDependencies (см. client-api.md).
Эти URL-ы платформа переписывает в отдаваемом манифесте. Сборка игры не знает, по какому адресу будет раздаваться именно эта её версия, а платформа держит несколько версий одной игры рядом и раздаёт каждую по собственной версионной базе. Поэтому она переносит assetsBase и entries.client/host/wasm на эту базу и добавляет mapsBase — именно из этого поля лобби берёт адреса карт, а при его отсутствии откатывается на безверсионный адрес карт (dev, standalone, dedicated).
Два следствия для автора игры:
entries.wasmNodeне переписывается никогда — это путь внутриdist/для Node, а не URL;- entry, лежащий не под собственным
assetsBaseманифеста (например, абсолютный URL на ваш CDN), остаётся ровно таким, как записан, а не «чинится»; пакет с испорченным манифестом отсеивается на приёме структурной проверкой платформы, которая код плагина не исполняет.
Схема формы
Единый контракт дескриптора поля — общий для формы создания комнаты (roomForm, выше) и формы игрока (authSchema.params[].options, ниже): обе рендерятся одним и тем же модулем движка, formBuilder. Движок рендерит только то, что описано в дескрипторе — вывода контрола из типа значения больше нет. Каждый контрол — обычный нативный элемент формы, без тематической кастомизации: форма плагина всегда выглядит как остальная страница.
Разграничение назначения (важно для контракта):
- Room-форма (главное лобби) — настройки сервера: лимит игроков, число команд, время раунда/карты, карта, огонь по своим.
- Auth-форма (
#auth, показывается на комнату) — настройки игрока: цвет, модель, оружие и т.п.
Форма — упорядоченный массив дескрипторов (порядок массива = порядок полей):
{
name: 'maxPlayers', // ключ значения
control: 'text', // 'select'|'text'|'checkbox'|'radio'
label: 'Max players', // подпись (fallback — `name`)
default: 8, // значение по умолчанию
hidden: true, // поле строится и участвует в сабмите, но .form-row не рендерится
// числовые text-поля (unit задан, или numeric:true): значение парсится
// как Number и конвертируется через ту же единицу, что default/хранимое
// значение (мс для unit:'s') — formBuilder сам конвертирует туда-обратно.
// Чтение такого поля никогда не даёт 0 на пустом/невалидном вводе — оно
// откатывается к `default`, — но до сабмита дело не доходит: числовое
// поле неявно `required` («required» на пустом, «must be a number» на
// непарсящемся), потому что молча создать комнату по дефолту — не то, о
// чём просил игрок
numeric: true,
unit: 's', // значение хранится в мс, показывается/редактируется в секундах
// границы числового text-поля (в единице отображения, например в
// секундах для unit:'s'): рендерятся суффиксом «(min–max)» в подписи
// (вместе с unit — например «(s, 10–3600)»), и проверяются
// collectFormErrors ниже. Любой из ключей можно опустить (в подсказке
// покажется «…», в валидации не проверяется)
min: 1,
max: 8,
// валидация (text): движок никогда не показывает нативные браузерные
// попапы валидации — collectFormErrors()/renderFormErrors()
// (formBuilder.js) проверяют каждый отрисованный контрол перед сабмитом
// (room-форма — клик по «Create server»; auth-форма — клик по
// «#auth-enter») и рисуют каждое невалидное поле строкой в
// #lobby-error/#auth-error, заменяя reportValidity(); заголовок строки —
// `label` этого поля
regExp: '^#[0-9a-f]{6}$', // сверяется со ВСЕЙ отображаемой строкой:
// движок оборачивает его в ^(?:…)$ ровно так
// же, как браузер применяет атрибут
// `pattern`, — пишите без якорей (свои ^/$
// безвредны). Некомпилируемый паттерн — не
// ограничение вовсе: поле проходит, движок
// пишет console.error, а `vimp-contract`
// (правило B5) ловит это до раздачи игры
required: true,
maxlength: 32,
// варианты (select/radio):
options: [{ value, label }],// или простой массив, например ['a', 'b']
source: 'maps', // источник вариантов из каталога движка вместо `options` (сегодня только карты)
// auth-специфика (настройки игрока):
storage: 'playerColor', // ключ localStorage для запоминания выбора между сессиями
}control → разметка, всё рендерит formBuilder.js нативными элементами:
select—<select>(изoptionsилиsource:'maps'); дескрипторselect(илиradio), у которого после резолва ровно один вариант, строится и участвует в сабмите как любое другое поле, но его.form-rowне рендерится — игроку нечего в нём выбирать. Этот единственный вариант становится принудительным значением:setValue()у поля — no-op, поэтому ни запомненное вstorageзначение, ниdefaultдля варианта, которого в списке больше нет, не могут рассинхронизировать скрытый контрол. Ноль вариантов — это дефект схемы или каталога, а не «нечего выбирать»: строка рендерится (пустой контрол), движок пишетconsole.error, а поле всегда валится сno options available— вне зависимости отrequired: отправлять нечего, а «required» повело бы игрока туда, где ничего нет.text—<input type=text>; числовые поля (numeric/unit) конвертируют туда-обратно единицу хранения.regExp/requiredпроставляются на контрол какpattern/requiredтолько ради семантики (ни одна из форм не<form>, иreportValidity()движок не зовёт); реально работает сам по себе лишьmaxlength— он ограничивает ввод.checkbox—<input type=checkbox>(булевые настройки).radio— группа<input type=radio>с общим сгенерированнымname, по одному на вариант.
Словарь control — append-only. Он живёт в реестре движка (formControls), из которого строка не исчезает никогда: имя, которое уже опубликованная игра написала в своём манифесте, работает вечно (И1). Именно сокращение набора сломало v2 → v3 — выброшенные тогда четыре контрола вернулись навсегда, алиасами своих нативных замен:
Выведенный control | Строится как | Когда |
|---|---|---|
range | text + numeric: true | выведен в v3 |
number | text + numeric: true | выведен в v3 |
toggle | checkbox | выведен в v3 |
segmented | radio | выведен в v3 |
Алиас разрешается до выбора билдера и до валидации, поэтому выведенное числовое поле сохраняет проверки min/max/regExp, а не вырождается в свободный текст. Новая игра должна писать нативный контрол сама: vimp-contract (правило B5) сообщает о выведенном предупреждением, а не ошибкой. Контрол, которого реестр не знает вовсе, — это плагин, попросивший будущее: поле пропускается с console.error, остальная форма рендерится.
Разделение валидации. roomForm едет клиенту как JSON манифеста игры (/games/<id>/manifest.json) — функции не переживают сериализацию в JSON, поэтому room-форма получает только декларативные проверки выше, в таком порядке: no options available (пустой select/radio) → пустота (required, у числового поля включён всегда; текст перед проверкой триммится, поэтому поле из одних пробелов считается пустым) → «must be a number» → min/max → maxlength → regExp. Диапазон проверяется раньше паттерна намеренно: сгенерированный regExp кодирует те же границы, но «must be ≤ 32» повторяет подсказку из подписи поля, а «invalid format» не говорит ничего — паттерну остаётся ловить то, чего диапазон не ловит (дробное, ведущий ноль). Поля без отрисованной строки — hidden: true и select/radio с единственным вариантом выше — валидация пропускает: игрок такую ошибку не видит и исправить не может, показать её значит запереть форму. Авторитетную границу значений комнаты всё равно накладывает Worker хоста при создании комнаты (клампы таймеров/лимита в applyRoomOverrides.js, которую вызывает Worker хоста). Auth-форма приходит из кода плагина (authSchema), поэтому у неё есть и те же декларативные проверки, и JS-валидаторы (authSchema.validators, резолвятся через validateAuth на хосте и зеркалятся на клиенте, рендерятся тем же renderFormErrors).
Для auth-формы декларативную часть повторяет и сам хост — чтобы клиент, обошедший форму, не получал больше прав, чем заполнивший её. validateAuth сначала разрешает алиасы выведенных control — ровно как билдер формы, чтобы поле проверялось тем контролом, которым построено, — а затем проверяет, до JS-валидатора:
control, которого реестр не знает вовсе:unknown control. Билдер такое поле не строит, значит от честного клиента оно и не приезжает; статически это ловитC10.- числовое text-поле (
numeric: trueилиunit) — отдельной веткой, потому что форма отдаёт его числом:must be a number, затемmin/maxв единице отображения (toDisplay, общий с билдером).regExpздесь намеренно не применяется: клиент сверяет паттерн с сырой строкой, которую набрал игрок, а до хоста доезжает только число, и обратная сборка строки отбила бы каноническую запись, которую форма приняла («1.50» приезжает как 1.5), — вход оказался бы заперт, потому что строки, годной обеим сторонам, не существует. - иначе: длину (
too long), членство в объявленном спискеoptionsполяselect/radio(not an option— браузер другого значения и не отправит, значит не должен и обошедший форму клиент), затемregExp(invalid format, якорится ровно так же, как в браузере).maxlengthиregExpприменяются только к текстовым полям — ровно как в форме.
Поле select/radio, у которого список options пуст, отсутствует или заменён на source, не принимает ничего: auth-форма строится без ctx.sources, поэтому такое поле резолвится в пустой список, показывает no options available и заполнить его нельзя вовсе — пропустить его на хосте значило бы дать обошедшему форму клиенту то, чего у игрока нет. C10 запрещает source в authSchema; в roomForm (правило B5) он остаётся штатным способом отдать каталог карт.
Поле без maxlength всё равно ограничено потолком в 256 символов: regExp игры теперь исполняется на хосте — в Worker'е с авторитетным матчем или в процессе dedicated целиком, — а катастрофический паттерн вроде (a+)+b превращает несколько десятков символов в минуты заблокированного event loop.
Два осознанных пробела. required на хосте не проверяется: solo-путь (boot.autoAuth) отвечает дефолтами схемы, среди которых бывает '', поэтому пустое значение проверки выше пропускают и оно остаётся делом игрового валидатора. А regExp, который не компилируется, — дефект схемы, а не ограничение, и поле проходит (как и на клиенте).
Имя validator, которого нет в authSchema.validators как функции, оставляет поле непроверенным вовсе: хост молча пропускает его, а не зовёт не-функцию. Статически об этом сообщает C10, а при сборке порт-машины — console.error хоста, для тех, кто контракт-чекер не запускает.
Форма проверяет себя по ходу ввода: значение вне диапазона видно сразу, как введено, а не по следующему клику, и строка уходит, когда починено именно её поле, — список того, что ещё не так, переживает первое нажатие клавиши. До первого сабмита говорить о себе могут только поля, которых игрок уже касался: «required» на поле, которое ещё не открывали, — шум. Сабмит снимает этот фильтр: клик — ответ за форму целиком. Пересборка формы (смена игры в лобби) возвращает всё в исходное.
Ошибки от сервера (AuthView.renderError) — снимок последнего сабмита: перепроверить их движок не может, поэтому любая правка формы их снимает, а следующий сабмит принесёт заново те, что остались в силе. Текстовые значения триммятся не только при проверке, но и при чтении — поле отдаёт ту же строку, которую проверяли, и клиент не отправляет " Bob ", оставляя отказ хосту.
Где живёт каждая половина контракта:
- Room-форма:
GameManifest.roomForm(рядом сroomDefaults, который остаётся источником значений по умолчанию и начального набора ключей комнаты). - Auth-форма: те же дескрипторы едут по проводу в
PS_AUTH_DATA.params[].options— см. snapshot.md —params[i]— это{ name, value, options }, гдеoptionsнесётcontrol/label/unit/numeric/min/max/options/source/storage/regExp/required/maxlength/hidden(плюс уже существующий ключvalidator, резолвится черезauthSchema.validators).
Манифест/authSchema без схемы рендерит пустую форму (room-форма — предупреждение в консоль; auth-форма — без полей, #auth-enter по-прежнему работает) вместо тихого отката к выводу контрола из типа значения — плагин, переезжающий на этот контракт, получает явный сигнал, а не тихую потерю полей.
HostPlugin API
Default export host-entry игры. Обязан быть worker-safe (без DOM и Node-глобалов).
export default {
id: 'tanks',
engineApi: 4,
async createCore(coreConfigJson, { wasmUrl }) { /* init(wasmUrl); return new GameCore(...) */ },
gameConfig: { // игровая половина бывшего config/game.js
teams: { team1: 1, team2: 2, spectators: 3 }, // произвольное число команд — обязательное поле
spectatorTeam: 'spectators', // необязательное: по умолчанию ключ 'spectators' из teams
// noSpectators: true, // opt-in: наблюдателей нет вовсе — ровно одна команда,
// // подключившийся входит в неё сразу, без голосования
// endlessRound: true, // opt-in: движок сам раунд не перезапускает
// migration: { midRound: true }, // opt-in: новый хост продолжает раунд с того же тика
parts: { models, weapons, friendlyFire }, // из src/data игры-плагина (например, @vimp-games/tanks)
snapshot, // снапшот-схема ключей (config/snapshot.js) — обязательное поле
playerKeys, // spectatorKeys — движковые (наблюдение — механизм движка)
panel: { fields: { health: {key:'h', value:100}, w1: {…}, w2: {…} }, activeKey: 'wa' },
stat: { columns: {name:{…}, status:{…}, score:{…}, deaths:{…}, latency:{…}} },
scripted: { namePrefix: 'Bot', defaultModel: 'm1' }, // вместо хардкодов Bot${id}/'m1'
mapScale: 0.3, mapSetId: 'c1', mapsInVote: 4, defaultMap: 'pool mini',
lobbyInfo: 'map', // необязательно: карточка комнаты в лобби показывает текущую карту
chatMaxLength: 60,
initialVote: 'teamChange', // вместо хардкода SocketManager.sendFirstVote
soundCues: { roundStart:'roundStart', victory:'victory', defeat:'defeat',
frag:'frag', death:'gameOver' }, // вместо хардкодов SocketManager
},
buildClientGameConfig(), // game-секция CONFIG_DATA (см. ниже)
// init-JSON ядра движок собирает сам из gameConfig
// (vimp-engine/src/lib/coreConfig.js) — плагин-хук не нужен
authSchema: { elems: { authId:'auth', errorId:'auth-error',
enterId:'auth-enter', fieldsId:'auth-fields',
titleId:'auth-title', informsId:'auth-informs' },
params: [
{ name: 'model', value: 'm1', options: {
control: 'select', label: 'Model',
options: ['m1', 'm2'], storage: 'playerModel',
validator: 'isValidModel' } },
],
validators: { isValidModel: v => v in models },
texts: { title, sections } }, // тексты формы для нейтрального каркаса экрана авторизации
onCoreEvent(data, { vimp, panel }), // только 'custom'-события; стандартные роутит движок
chatCommands: [{ name: '/bot', handler(ctx, gameId, args) {…} }], // обязательное поле — регистрация в CommandProcessor
systemMessages: { BOT_PLAYERS_ONLY: 'b:0', … }, // merge в реестр кодов движка
// статических определений голосований нет: игровые голосования создаются
// динамически через ctx.voteCoordinator.createVote(...) из чат-команд
createModules(ctx) { return { scripted: new MyBotManager(ctx) }; },
// ctx = { participants, coreAdapter, panel, stat, chat, socketManager,
// scripted /* параметры gameConfig.scripted */,
// lobby /* { setInfo(text | null) } — нет на старых движках */ }
// обработчики чат-команд получают другой, мета-уровневый ctx от
// CommandProcessor: { participants, chat, scripted, roundManager,
// voteCoordinator, timerManager, teams, spectatorTeam, spectatorId,
// isDevMode }
// Контракт scripted-модуля (дергает движок — RoundManager/HostGame):
// createMap(scaledMapData), createScripted(count, team?), removeScripted(team?),
// removeOneForHuman(team), getCount(), getCountsPerTeam()
// Любой модуль может добавить serializeState() → JSON и restoreState(state):
// контрольные точки хоста (см. «Миграция хоста» ниже)
};Поля gameConfig и их умолчания
Движок читает gameConfig через один модуль — gameConfigView. createGameConfigView(gameConfig, gameId) проверяет обязательные пути, подставляет умолчание для всего остального и возвращает замороженное представление; хост, конфиг ядра, applyRoomOverrides и standalone SDK читают его, а не исходный объект (следит ESLint-правило). Игра, которая о поле никогда не слышала, поэтому остаётся валидной — инвариант И2 ниже.
Обязательные — четыре пути, и список может только сокращаться:
| Путь | Почему движку нечем его заменить |
|---|---|
parts.models | из чего состоит участник; синтезировать нечем |
playerKeys | без них ядро не знает ввода |
snapshot | раскладка кадра; движок её не придумывает |
teams | ParticipantManager выбирает команду входа |
У всего остального есть умолчание:
| Поле | Умолчание | Что будет, если не объявить |
|---|---|---|
title | null | лобби берёт HostPlugin.id |
maps | {} | карты приезжают из каталога платформы (room.maps) |
currentMap | null | берётся первая карта каталога |
mapsInVote | 1 | одна карта в голосовании |
mapScale | 1 | ядро масштабирует геометрию 1:1 |
mapSetId | null | нет id набора тайлов по умолчанию |
roomDefaults.maxPlayers | hostDefaults.maxPlayers (30) | комнату ограничивает движковый лимит |
parts.weapons | {} | игра без оружия (@vimp-games/snakes) |
parts.friendlyFire | false | огонь по своим выключен, пока не включит комната |
panel.fields | {} | пустая панель рисуется корректно |
panel.activeKey | null | подсвеченной ячейки панели нет |
stat | {} | своих колонок сверх движковых нет |
scripted | {} | ParticipantManager именует участников сам |
playerState | {} | стартового сохранённого состояния нет |
soundCues | {} | движок не шлёт звуковых сигналов |
initialVote | null | участник заходит без голосования |
statMode | 'table' | по Tab движок рисует таблицу комнаты |
noSpectators | false | наблюдатели существуют как концепция |
endlessRound | false | движок сам перезапускает раунд |
lobbyInfo | null | карточка комнаты в лобби не показывает текста игры |
migration.midRound | false | смена хоста переносит мету и начинает раунд заново (см. ниже) |
spectatorTeam | выводится | null под noSpectators; иначе ключ spectators из teams, иначе null с console.warn |
Строка карточки лобби (lobbyInfo) — собственная строка игры на карточке её комнаты в лобби (gameId/roomId, затем <текст> · игроки/макс · регион). lobbyInfo: 'map' показывает текущую карту комнаты и следит за её сменой; любое другое значение или отсутствие поля — ничего. Модуль может задать произвольный текст во время работы — опцию сервера, режим, фазу — через ctx.lobby из createModules: lobby?.setInfo('Hardcore') (важнее 'map'; setInfo(null) снимает его). Текст обрезается и ограничивается платформой (48 символов). Опциональная цепочка lobby?. нарочная: старый движок lobby не передаёт, и в requires игре ничего писать не нужно.
Миграция хоста (migration.midRound). Комната может переехать к другому хосту — движок снимает контрольную точку матча (см. Сохранение и восстановление) и поднимает её на новом. По умолчанию переезд мягкий: мета (участники, счёт, карта с остатком времени, профили) переносится, раунд начинается заново. migration: { midRound: true } — обещание игры, что её состояние переживает переезд посреди раунда:
GameSim::serialize/deserializeпереносят всё, чем владеет симуляция ядра (движок отправляет дампserialize_state);- каждый модуль из
createModules, держащий состояние в памяти, реализуетserializeState()→ JSON-значение иrestoreState(state). Движок зовёт их по ключу модуля в объектеcreateModulesпри снятии и восстановлении точки (восстановление — после того, как движок поднял свою мету, карту включительно). Модулю без методов ничего не переносится. Модуль, чейserializeStateбросил исключение, делает точку мягкой.
Возможность движка называется host.migration; писать её в requires не нужно — старый движок поле игнорирует, и игра мигрирует на нём мягко. Проверка — шаг vimp-sim checkpointRestore (или --checkpoint-every <мс>), см. debugging.md.
Согласованность того, что игра прислала, по-прежнему проверяется и по-прежнему бросает: объявленный spectatorTeam обязан быть ключом teams, а noSpectators требует ровно одной команды. Правило контракта B3 сообщает предупреждением о каждом поле, оставленном на умолчание, — опора на него остаётся осознанным выбором.
Через participants игра может задать и цвет ника в чате: participants.setChatColor(gameId, '#rrggbb') (или null — цвет команды). Ставится один раз, когда игра узнала цвет игрока, и применяется движком к каждому его сообщению — см. createModules.
HostPlugin: реальная обязательность каждого поля
| Поле | Обязательно? | Примечания |
|---|---|---|
id | да | должен совпадать с manifest.id |
engineApi | да | метка поколения; должна совпадать с манифестом и клиентской половиной |
createCore(json, { wasmUrl }) | да | async, возвращает экземпляр GameCore; у wasmUrl две формы |
gameConfig | да | проверяется по полям, см. выше |
authSchema | да | отправляется каждому входящему клиенту |
chatCommands | да, массив | движок обходит его без проверки — без него загрузка падает; для «нет команд» — [] |
createModules | да | движок вызывает её и читает .scripted из результата |
buildClientGameConfig() | да | вызывается безусловно при сборке CONFIG_DATA |
systemMessages | опционально | вливается в реестр кодов чата |
onCoreEvent(data, ctx) | опционально | получает только custom-события ядра; id приходят строками |
Поля views нет — отрисовка Panel и Stat принадлежит движку.
Слияние конфига и переопределения комнаты
Движок собирает конфиг матча как structuredClone({ ...hostDefaults, ...view }) и затем применяет комнату (applyRoomOverrides). Слияние неглубокое, на верхнем уровне: игра, задавшая timers, заменяет весь движковый объект timers и обязана повторить каждый ключ, который ей ещё нужен; то же для rtt и idleKickTimeout. Сами умолчания перечислены в game-config.md.
Ловушка: собственный
timeStepядра берётся из движковогоhostDefaultsпри сборке конфига ядра, а не из слитогоgameConfig.timers.timeStepигры. Переопределениеtimers.timeStepменяет цикл Worker'а, но не физический шаг, с которым сконфигурировано ядро.
applyRoomOverrides принимает из формы комнаты ровно этот набор и ничего больше:
| Поле | Эффект |
|---|---|
maps | заменяет бандл-каталог каталогом платформы; если карты по умолчанию нет — берётся первая карта каталога |
maxPlayers | зажимается в 1 .. roomDefaults.maxPlayers |
map | применяется, только если есть в каталоге |
roundTime | зажимается в roomTimeMin .. roomTimeMax |
mapTime | зажимается так же |
friendlyFire | только boolean |
isDevMode | только boolean; dev-режим вкладки хоста (рекордер, хостовый лог CONSOLE) — боевой бандл его не выставляет |
Любое другое поле формы комнаты молча отбрасывается, поэтому правила игры не могут зависеть от собственной настройки комнаты: пути до хоста у неё нет.
createModules(ctx) и контракт scripted-модуля
ctx — ровно { participants, coreAdapter, panel, stat, chat, socketManager, scripted, lobby }:
participants— реестр участников (люди и scripted):get,getAll,getHumans,getScripted,setChatColor(gameId, color);coreAdapter— JS-обёртка над WASM-GameCore;panel,stat,chat,socketManager— движковые мета-модули;scripted— объект конфигаgameConfig.scripted({ namePrefix, defaultModel }), а не модуль;lobby—{ setInfo(text | null) }, см. текст карточки лобби выше.
timerManager и voteCoordinator здесь нет — они есть только в контексте чат-команд. Менеджеру ботов, которому нужно время, дают тик от AI-тика ядра или от чат-команды. Движок читает из возвращённого объекта только ключ scripted (остальные модули безвредны, но не вызываются — кроме хуков serializeState/restoreState).
На scripted-модуле движок вызывает ровно пять методов:
| Метод | Когда | Возвращает |
|---|---|---|
createMap(scaledMapData) | при каждой загрузке карты (данные масштаб.) | — |
getCountsPerTeam() | при балансировке команд | { teamName: count } |
removeScripted(team?) | очистка ботов (всех или одной команды) | — |
createScripted(count, team?) | создание ботов | число реально созданных |
removeOneForHuman(team) | человеку нужно место в полной команде | boolean — освободили или нет |
getCount() движок не вызывает; добавляйте его только для собственных чат-команд игры.
Цвет ника в чате. По умолчанию ник окрашен цветом команды (классы line${teamId}). Игра, где команда не отличает игроков, задаёт цвет сама: participants.setChatColor(gameId, '#ff4d4d') ('#rgb' или '#rrggbb'; всё остальное сохраняется как null — цвет команды). Ставится один раз, когда игра узнала цвет игрока, и HostGame.pushMessage применяет его к каждому сообщению этого игрока; на провод цвет попадает (Chat.push дописывает четвёртый элемент), только когда задан.
Чат-команды
CommandProcessor — голый реестр: chatCommands — весь набор, который может набрать игрок, и одно имя в двух играх может значить разное. Четыре команды, которыми когда-то владел движок, — /nr (initiateNewRound, охраняйте через ctx.isDevMode), /timeleft (ctx.timerManager.getMapTimeLeft()), /mapname (ctx.roundManager.currentMap) и /rank (ctx.playerDataSync.refreshPlacement(gameId, period) или getRating(participantId, period) — значение, которое оставило последнее обновление) — теперь код игры; каркас кладёт их в metaCommands. Обработчик получает (ctx, gameId, args), где ctx — { participants, chat, scripted, roundManager, voteCoordinator, timerManager, playerDataSync, teams, spectatorTeam, spectatorId, isDevMode }. Двойная регистрация одного имени молча теряет обработчик. CommandProcessor не ждёт и не перехватывает обработчик, поэтому async-обработчик обязан сам гасить свои сбои — необработанный reject в Worker'е хоста роняет комнату. /changehost зарезервирован (см. таблицу выше). В движке нет API для переименования игрока — ник берётся из личности игрока.
Системные сообщения и голосования
Системные сообщения чата ходят кодами group:index[:p0,p1]; тексты живут на клиенте. Движок резервирует группы s, v, m, c и поставляет английские умолчания для каждого кода в src/config/chatMessages.js (полная таблица текстов — в game-config.md); s:8 и v:6–v:15 клиент добавляет сам, по проводу они не ходят. Для своих кодов игра берёт любую другую букву ({ BOTS_SPAWNED: 'g:0' }). Регистрация — слепой Object.assign в реестр движка: совпавший ключ перезаписывает сообщение движка без предупреждения. Никогда не шлите сырой массив текста вместо кода — данные идут в params (MAP_CURRENT с именем карты, а не [mapName]).
voteCoordinator (контекст чат-команд) даёт canCreateVote(voteCategory, gameId) → boolean и createVote({ voteName, voteCategory, payload, resultFunc, userList, gameId }): payload — { name, params?, values? }, где name — ключ клиентского шаблона, resultFunc(result) вызывается при закрытии голосования, userList ограничивает голосующих. canCreateVote — false, пока категория на кулдауне (timers.timeBlockedVote) или голосование этой категории уже идёт; активно одно голосование, остальные встают в очередь; ничья разрешается случайно; варианты листаются по 7 на странице (клавиши 1–7, 8/9 — страница, 0 — отмена). Зарезервированные имена: mapChange, teamChange и всё, что начинается с @ (правило контракта B9); зарезервированные сокращения values: 'teams', 'maps'.
Панель и статистика (хостовая половина)
gameConfig.panel — { fields: { health: { key: 'h', value: 100 } }, activeKey: 'wa' }: key — короткий ключ на проводе, value — начальное значение, которое также передаётся ядру как начальный запас ресурса (HP, патроны); activeKey называет ячейку активного оружия (null, если не нужна). Операции в рантайме приходят событиями ядра: set, decrement (по умолчанию), increment; значения не опускаются ниже 0. Движок жёстко задаёт ещё одну ячейку, t — время раунда в секундах, — поэтому клиентская схема панели обязана объявить поле type: 'time', привязанное к t, а хостовая схема не должна определять поле с ключом t.
gameConfig.stat — карта колонок { key, bodyMethod, headMethod?, headSync?, bodyValue?, headValue? }: key — индекс колонки на проводе, bodyMethod/headMethod — '=' (заменить), '+' (накопить), '#' (считать строки, только head), headSync: true пересчитывает ячейку head при изменении строк body. Движок пишет ровно пять имён: name, status, score, deaths, latency; придуманную колонку движок никогда не пишет, а пропущенная просто не появляется. Scripted-участнику выставляется status: 'dead', как и всем, но latency обновляется только по ответам pong — его ячейка хранит тот текст, который менеджер ботов передал в stat.addUser.
API ранга и состояния игрока
Из onCoreEvent объект vimp предоставляет профиль (пользователь, игра): addPlayerPoints(gameId, delta) (очки текущей игры), finishPlayerGame(gameId) (игра окончена: суммируется в месяц и берётся максимумом в день), getPlayerRating(gameId, period) ({ value, placement, total } или null), isPlayerRatingLoaded(gameId, period), refreshPlayerPlacement(gameId, period) (промис той же формы), getPlayerState(gameId) / setPlayerState(gameId, state) (непрозрачный JSON-блоб, стартующий с gameConfig.playerState.defaultState), flushPlayerData({ urgent }) (запрос на синхронизацию) и overrideMapData(scaledMapData); period — 'day' | 'month' | 'all'. getPlayerRank/isPlayerRankLoaded по-прежнему отвечают из среза all; addPlayerRank — устаревший алиас addPlayerPoints.
- Очки не рейтинг, пока игра не закончилась.
RoundManagerзакрывает игру каждого участника при смене карты и конце раунда; игра без того и другого (endlessRoundплюс геометрия, пересобираемая черезoverrideMapData) сама называет свою границу и вызывает тамfinishPlayerGame. getPlayerRatingотвечаетnullна неизвестный id и нулями на известный, чей срез ещё не пришёл. Игра, пишущая рейтинг в колонку статистики или сравнивающая с ним, обязана проверятьisPlayerRatingLoaded— он по срезам.{ urgent: true }обходит интервал записи и бэкофф; берегите его для границы, на которую игрок вот-вот посмотрит. Бюджет записи принадлежит движку: результат участника пишется не чаще раза в 5 минут (±20 % джиттера на комнату), комната шлёт не больше 1 запроса в секунду, а после сбоя сервера или сети откатывается экспоненциально от 30 с до 15 минут.overrideMapData— для игры, которая пересобирает геометрию на лету вместо смены карты: передавайте клиентскую форму (немасштабированные координаты плюсscale, тот же объект, что рассылается черезsendMap), потому что он заменяет обе копии, которые держит движок: ту, по которой_startRoundрасставляет людей, и ту, что уходит входящему клиенту.
Кики и технические сообщения
Коды киков: 4003 — EMA-задержка выше rtt.maxLatency, 4004 — больше rtt.maxMissedPings неотвеченных пингов, 4005 — простой дольше idleKickTimeout.<role>, 4006 — комната полна. Собственный клиент хоста (сокет 'local') неуязвим; голосования за кик нет. techInformList клиента индексирован: 0 fullServer, 1 anotherDevice, 2 loading, 3 kickIdle, 4 kickForMaxLatency, 5 kickForMissedPings, 6 roomFull.
Ботов в движке нет — только нейтральное понятие «скриптовый участник» (геттер isScripted; слова «bot» в коде движка не остаётся). Движковая политика «scripted уступают место людям» остаётся generic.
ClientPlugin API
Default export client-entry игры.
export default {
id: 'tanks',
engineApi: 4,
async createClientCore(clientConfigJson, { wasmUrl }) { /* init(wasmUrl); return { core, memory } */ },
parts: { Map, MapRadar, Tank, TankRadar, Bomb, ExplosionEffect, Smoke, Tracks, ShotEffect },
bakers: { explosionTexture, …, trackMarkTexture },
styles: '…css…', // игровой CSS (спрайты оружия панели и т.п.)
hooks: {
onAuth(core, authData) { core.set_model(authData.model); },
onPanel(core, panelData) { core.sync_panel(JSON.stringify(panelData)); },
onLocalAction(core, action, name, now) { /* try_fire / cycle_weapon; → JSON спавна | null */ },
services(core) { return { mapDynamics: /* … */ }; }, // необязательный
},
serviceNames: ['mapDynamics'], // необязательное: что вернёт services()
};hooks.services(core) — необязательный: возвращает игровые сервисы, которые подмешиваются в клиентский пул рядом с движковыми (renderer, soundManager, localPlayer, assetsBase, accolades, diagnostics) и доходят до part'а по componentDependencies. Так part говорит со своим игровым ядром, а движок не знает, что именно ему отдают: плагин танков раздаёт так mapDynamics (toWorld(key, localX, localY) поверх ClientCore.map_dynamics_to_world), чтобы эффект выстрела привязал осколки к задетому ящику. При совпадении имён побеждает движковый ключ.
Сервис diagnostics
Движковый сервис пула, через который игра кладёт свои предупреждения и пойманные ошибки в журнал клиентских ошибок. Part просит его как любой другой сервис:
// componentDependencies: { diagnostics: ['Map'] }
constructor(data, assets, dependencies) {
this._diagnostics = dependencies.diagnostics ?? null;
}
draw() {
if (!this._camera) {
this._diagnostics?.warn('mygame.camera.missing', { level: this._level });
}
}warn(code, details)— предупреждение игры:codeподходит под/^[a-z0-9][a-z0-9._-]{0,63}$/i(начинайте его с id игры; всё прочее записывается какinvalid-code),details— простой JSON-объект до 2048 байт (больше —{ truncated: true }). К записи прикладывается стек места вызова.capture(error)— ошибка, которую part поймал сам, но хочет видеть.
Оба вызова дешёвые и никогда не бросают: повторы только считаются, действуют лимиты журнала на сессию. Не пишите diagnostics в requires: сервис необязательный, и на движке без него part просто получит undefined — отсюда ?. выше. Возможность движка diagnostics существует лишь затем, чтобы реестр помнил, когда сервис появился.
serviceNames — тот же список, записанный статически. В рантайме его никто не читает: он нужен контрактному чекеру, который не может позвать services(core) (для этого нужно живое ядро) и потому не отличает игровой сервис от опечатки в componentDependencies. Без списка правило C4 сообщает о незнакомом имени предупреждением, со списком — снова ошибкой; а сервис, которого никто не дал, молча приходит в part как undefined.
ClientPlugin: обязательства
createClientCoreобязана вернуть{ core, memory }.memory— объект WebAssembly-памяти, из которого движок на каждом тике рендера читает hot-буфер; у Node-сборки (wasmUrlна.js) её нет, поэтому она возвращаетmemory: null, а headless-клиент читает буфер копированием (hot_values()).hooks.onAuth,hooks.onPanelиhooks.onLocalActionвызываются безусловно: пустое тело допустимо, отсутствующий хук — падение.styles— строка CSS (импортируйте через?inline: у сборки плагина нет HTML-входа, и Vite не должен сам вставлять<style>).- Поля
viewsнет.
Parts
Part — класс, который движок инстанцирует под одну сущность снапшота на одном canvas: constructor(data, assets, dependencies, context) (значения первого кадра), update(data) (следующие кадры), destroy() (сущность исчезла). data — массив полей сущности; assets — запечённые ассеты этого класса; dependencies — объявленные им сервисы; context — { id }: id сущности как в кадре (строка, null у эффекта). Part, который рисует, обязан быть Pixi-Container или содержать его и сам добавить себя на сцену — движок этого не делает.
Исключение — part карты: его статические экземпляры строит applyMapData из MAP_DATA, по одному на слой отрисовки на уровень карты, под сквозными ключами s0..sN, каждый с { type: 'static', spriteSheet, map, step, layer, tiles, physicsStatic, scale } и, для слоёных карт, ещё level (0 — земля), solid и floor. Одноуровневая карта даёт по экземпляру на запись layers, все с level: 0 (core.md).
Эффекты. Когда payload снапшота по ключу — массив записей, каждая запись создаёт короткоживущий экземпляр-эффект. Класс эффекта дополнительно реализует run(); движок оборачивает его destroy(), чтобы он снимал эффект из реестра, а сам эффект уничтожается по окончании анимации.
Регистрация. parts.gameSets сопоставляет ключ снапшота (или setId карты) списку классов part'ов для него; parts.entitiesOnCanvas — класс его canvas. До фабрики доходят только классы из entitiesOnCanvas — класс в gameSets, но не там, бросает Constructor for X not found. на первом кадре. Ключ кадра без записи в gameSets тоже бросает, поэтому у каждого ключа снапшота должен быть набор, пусть из одного класса. Одна сущность может появляться на нескольких canvas, если перечислить несколько классов (Tank на основном, TankRadar на радаре). Оба условия проверяет правило контракта C2.
Порядок отрисовки. stage.sortChildren() в PixiJS v8 не принимает компаратор: он сортирует по zIndex. Движок выставляет stage.sortableChildren = true и сортирует после каждого addChild, поэтому zIndex, назначенный в конструкторе part'а, работает, а одно свойство layer ничего не делает. Part'ы, не трогающие zIndex, делят 0 и рисуются в порядке вставки, на который влияет порядок классов в gameSets.
Bakers
bakedAssets — { canvas: [{ name, component, params }] }; каждое name указывает на функцию в bakers: (params, renderer) → Texture | словарь Texture. Baker запускается один раз на canvas при старте, до появления любого part'а; результат попадает в аргумент assets каждого экземпляра класса component под ключом name. Baker, чьего name нет в bakers, молча пропускается. Baker владеет тем, что вернул: перепекание (восстановление WebGL-контекста) уничтожает прежний результат вместе с TextureSource, поэтому не возвращайте вид на общий атлас или текстуру, которую держит кто-то ещё.
Сервисы
Пул — реестр только на добавление (игра добавляет свои через hooks.services(core)); имя никогда не исчезает, а part получает только то, что запросил в componentDependencies, поэтому добавленный позже сервис ничего не требует от старой игры.
| Сервис | Значение | Для чего |
|---|---|---|
renderer | Pixi-рендерер canvas | generateTexture, запекание карты в один спрайт |
soundManager | движковый SoundManager | регистрация позиционных голосов |
assetsBase | база ассетов активной игры, строка | URL в собственный пакет игры: ${assetsBase}img/<file> |
localPlayer | { id, is(id) } | отличить сущность локального игрока от чужих |
accolades | { placeOf(id), boardOf(period), selfOf(id, period) } | место сущности в глобальном топе игры |
diagnostics | { warn(code, details), capture(error) } | журнал ошибок клиента; опционален, может быть undefined |
Запрос чего-либо другого не даёт ничего: ключ молча отсутствует в dependencies. Part, зависящий от assetsBase, должен проверить его и написать понятный console.error — ${undefined}img/tiles.png ничего не загрузит и ничего не сообщит. Логируйте, не бросайте: конструкторы part'ов исполняются внутри тика рендера, и на этом пути ничего не перехватывает, так что исключение обрывает весь кадр.
localPlayer. id — собственный game id клиента или null до первого player-блока; is(id) сравнивает как строки (id кадров — ключи объекта). Спрашивайте в момент нужды, а не в конструкторе: сущности создаются из FIRST_SHOT_DATA, раньше первого бинарного кадра, поэтому part самого локального игрока строится, пока localPlayer.id ещё null. Типичное применение — звук: реплика, принадлежащая игроку, регистрируется только при this._isLocal(), визуальная половина остаётся у каждой сущности.
accolades. placeOf(id) возвращает { daily, monthly }, каждое — число или null (боты, гости и неизвестные id дают null; объект возвращается всегда); boardOf('day' | 'month') — строки топа { place, nick, score }; selfOf(id, period) — { place, score } или null. Комната спрашивает платформу, а не игрок: всё приходит одной рассылкой ACCOLADES_DATA от хоста, и клиент не делает собственных запросов. Места сопоставляются участникам по нику из публичного лидерборда и выдаются только участнику с проверенной личностью — ник гостя подделываем. Спрашивайте в момент отрисовки: первая рассылка может прийти сильно позже постройки part'а. Как выглядит место, движок не решает — значок рисует part.
Клиентский экран авторизации
authSchema на клиентской стороне — { elems, texts, params, validators } (форма на проводе — в snapshot.md). Id, который движок читает для контейнера полей, — fieldsId (не formId); texts.title рисуется в #logo; validators — функции и не сериализуются, они выполняются на хосте; storage: '<key>' сохраняет поле в localStorage. Поля ника нет: личность приходит из JWT лобби, хост читает его claim nick (ники валидируются глобально по ^[a-zA-Z]([\w\s#]{0,13})[\w]{1}$).
Ключевое: модули Stat/Panel/Vote/Chat — движковые, но вся их параметризация — из конфига игры. Следствия:
| Движковый модуль | Что поставляет игра (через CONFIG_DATA / gameConfig) |
|---|---|
| Panel (host + client MVC) | схема полей (fields + типы отображения: bar/число/время/иконка-оружия), activeKey; движковый PanelView генерирует DOM по схеме, внешний вид полей — CSS игры |
| Stat (host + client MVC) | колонки (имена/методы агрегации) и список команд произвольной длины; движковый StatView генерирует таблицы по числу команд |
| Vote (host + client MVC) | игровые голосования создаются динамически (voteCoordinator.createVote из обработчиков чат-команд) + все шаблоны/меню (тексты); движковые голосования механизмов (teamChange, mapChangeByUser/BySystem) остаются в движке, имена голосований с @ зарезервированы движком (@changeHost — окно «Change host?» платформы, лобби-режим), их тексты — тоже у игры |
| Chat (host + client MVC) | игровые коды системных сообщений (группа b:* и будущие) + тексты этих кодов; движок владеет механизмом, своими кодами (s/v/m/c) и их английскими текстами по умолчанию (src/config/chatMessages.js) — игра может перекрыть любой по индексу ('' — заглушить) |
| CommandProcessor | ВСЕ чат-команды: движок своих не разбирает, реестр целиком наполняет игра (/bot, /nr, /timeleft, /mapname, /rank). Исключение: в лобби-режиме клиент перехватывает /changehost (голосование «Change host» платформы) до хоста — игра не должна объявлять команду с таким именем |
| RoundManager / ParticipantManager | teams (произвольные), spectatorTeam, respawns из карт, scripted-параметры; в движке — нейтральный «scripted participant» |
| SocketManager | soundCues (какой звук на какое движковое событие), initialVote |
| SoundManager (client) | список звуков + файлы (assetsBase) |
| Controls (client) | player-keyset и раскладка; спектаторский набор — движковый |
| Auth | схема формы (authSchema) + валидатор модели |
Обхода схемы через views не существует: у ClientPlugin нет поля для своего Panel/Stat view-класса, и никакой код загрузки плагина его не валидирует и не читает — сегодня единственная реализация — движковые schema-generated PanelView/StatView. Радиальные/canvas-индикаторы возможны и без этого: HUD-сущность на canvas — обычный part.
Wasm Host ABI (v1)
Обёртки #[wasm_bindgen] GameCore/ClientCore живут в game-crate (wasm-bindgen не экспортирует generics), но обязательный набор методов фиксирует движок (часть engineApi) — их вызывает движковый JS. Принцип: горячий путь без JSON (скаляры + zero-copy указатели); JSON — конструктор/карта/события/редкие запросы.
Бойлерплейт делегации (40 методов на два класса) снимают движковые макросы export_game_core_abi!($Sim) / export_client_core_abi!($Client) (macro_rules! в vimp-engine-core — единственный источник истины обязательного набора, дрейф исключён): game-crate вызывает их рядом со своими дополнительными методами (try_fire, set_model, sync_panel, кастомные аргументы spawn_actor). Раскрытие происходит в game-crate, поэтому #[wasm_bindgen]/JsError резолвятся против его зависимостей — engine-crate от wasm-bindgen не зависит вовсе.
GameCore — методы жизненного цикла участников: spawn_actor/remove_actor/reset_actor (человек), spawn_scripted_actor/remove_scripted_actor (scripted, было spawn_tank/remove_tank/reset_tank/add_bot/remove_bot). Без изменений: new(configJson) (формат {engine:{timeStep,seed,snapshot,mapScale,mapSetId}, game:{models,weapons,panel,playerKeys,friendlyFire}}), load_map, map_info, apply_input, apply_aim (ввод указателем), step, take_events, pack_body, pack_frame, body_has_events, frame_ptr/frame_bytes, is_alive, position_of, players_data, alive_players, last_input_seq, reset_all_vitals, remove_players_and_shots, clear, serialize_state/deserialize_state, debug_json (курированный дамп мира для отладки — см. debugging.md; генерируется макросом, игра не реализует ничего).
Стандартный словарь событий take_events (убирает игровой словарь из GameCoreAdapter._drainEvents):
[{ "type": "panelSet", "id": 3, "field": "health", "value": 55 }, // field — имя поля схемы панели
{ "type": "panelActive", "id": 3, "field": "w2" },
{ "type": "death", "victim": 3, "killer": 1 },
{ "type": "shake", "id": 3, "intensity": 20, "duration": 200 },
{ "type": "custom", "data": {…} }] // → HostPlugin.onCoreEventClientCore — движковый минимум: new, push_frame, my_game_id, offset, sample, hot_ptr/hot_values, take_frames, apply_input, apply_aim (ввод указателем), set_active, set_map, reset, resync (чистит только сетевую половину — буфер и очередь кадров — после долгой паузы вкладки, предикт и идентичность сохраняются), decode_frame плюс отладочная пара debug_json и take_divergence (тоже из макроса). Игровые методы (set_model, try_fire, cycle_weapon, sync_panel) в минимум не входят — их зовут только хуки ClientPlugin; до своей половины ядра игра добирается через ClientState::game(), когда её обёртка ABI отдаёт наружу что-то своё (так плагин танков отдаёт геометрию предсказанной динамики карты).
Payload set_map, который движок передаёт ядру, — {map, step, scale, setId, physicsStatic, physicsDynamic, levels, ramps, levelHeight, game}: сырые поля MAP_DATA без масштаба (ядро масштабирует их само). levels и ramps — формат слоёной (2.5D) карты, оба необязательны: карта без них — ровно та одноуровневая карта, которую движок грузил всегда. Ключ levels — номер уровня строкой ("1" — первый надземный; уровень 0 остаётся в map/physicsStatic/layers), значение — {map, floor, walls, layers}: грид той же размерности, что и map, тайлы, по которым можно ездить, тайлы, блокирующие движение (перила), и рендер-слои этого грида. ramps — список {tile, dir, from, to}, направленные тайл-переходы между уровнями (dir — направление подъёма: north/south/west/east). Точка респауна может нести четвёртым числом свой уровень ([x, y, angle, level]), объект physicsDynamic — поле level; по умолчанию и то, и другое — земля. Весь формат проверяется в ядре (MapConfig::validate), а до сборки — правилом контракта E4. Игра, которая без него не работает, объявляет requires: ['map.layers'], а игра, которой нужно больше двух уровней, — ещё и map.levelsN.
levelHeight — высота ОДНОГО уровня в мировых единицах (без масштаба, как и step: ядро умножает её на масштаб карты само). Поле необязательное, по умолчанию — размер тайла, то есть подъём «уровень на тайл» даёт уклон 1.0. Оно существует потому, что всё, кроме номера уровня, меряется в мировых пикселях: без него RampSample::slope был бы «уровней на пиксель», и любая константа, подобранная под безразмерный уклон (тяга в горку, наклон корпуса, пыль), промахивалась бы на два порядка. Значение обязано быть конечным и больше нуля — проверяют ядро и правило E4. Игра, которой оно нужно, объявляет requires: ['map.levelHeight'].
game — непрозрачные данные игры в карте: объект или ничего, как и physicsDynamic[i].game. Движок их не читает и не масштабирует: координаты там задаются в клетках сетки или в немасштабированных единицах, которые игра умножает на scale сама. Поле доходит до ядра хоста (GameMap::game_data(), dynamic_game_data(i), читается в GameSim::on_map_loaded), до ядра клиента через set_map, до каждого статического парта карты (game) и до каждого динамического парта d{i} (собственные ключи объекта, game в том числе). Форму проверяют ядро и правило E7. Игра, которой оно нужно, объявляет requires: ['map.gameData']. Байт состояния тела карты — role: 'state' в строке динамики, SimCtx::map_body_state в ядре, доступ игры к телам карты по индексу — это requires: ['map.bodyState']; см. snapshot.md.
Прогон рампы закрыт физикой, а не одними правилами: движок строит коллайдеры-стражи по обоим бортам каждого прогона и поперёк его дальнего торца (RAMP_GUARD_GROUP). Тело уровня, с которого прогон начинается, их видит (заехать на клин сбоку или под него с «неправильного» торца нельзя), тело, законно поднимающееся по прогону, — нет (levels_interaction_on_ramp), тело другого уровня не встречает их вовсе. Подножие прогона не закрывается никогда — это законный вход. Широкая рампа режется на параллельные полосы-прогоны, но стражи огораживают блок полос, а не каждую полосу: борта ставятся только по внешним границам блока, а торцевой страж — один, во всю его ширину. Полосы одного блока несут общий RampRun::block — по нему игра отличает смену полосы от новой горки. Страж на полосу превратил бы широкую рампу в набор жёлобов шириной в тайл и останавливал бы тело, входящее по границе полос: подниматься оно ещё не начало и стражей видит. Клетки двух разных рамп пересекаться не имеют права: общая клетка досталась бы одному из прогонов произвольно.
Уровней бывает до MAX_LEVELS (8): земля и семь надземных, у каждого свой бит маски столкновений, — и рампа вправе перешагивать через несколько уровней. У любого уровня (включая землю) может быть необязательное поле volumes — словарь { "<ключ layers>": высота }: визуальная высота рендер-слоя в уровнях. Ядро её не использует (поле едет клиенту, геометрию строит рендер игры), но проверяют обе стороны: ключ обязан называть рендер-слой того же уровня, высота — лежать в (0, 8]. У динамических тел карты появились собственные правила уровня: тело, под которым не осталось плиты, падает на ближайшую плиту снизу (landing_level) по той же траектории (FallModel, gameConfig.mapFallTime, по умолчанию 0.35 с на уровень), что и всё, что роняет игра.
На клиенте те же levels доезжают до рендер-партов: applyMapData собирает статические данные по уровням и отдаёт каждому экземпляру парта его level, solid (блокирующие тайлы), floor, volume (высоту слоя из volumes, 0 — слой плоский), levelHeight (мировых единиц на уровень, уже с масштабом), ramps (конфиги рамп этого уровня — по ним парт строит клин над гридом, который у него уже есть) и game (поле карты game как объявлено) — см. client-api.md. setId — ключ снапшота, которым едет динамика этой карты (c1/c2): без него игра не отличит свой блок динамики от блока чужого конструктора карт.
Два опциональных trait-метода GameClientDef уточняют детектор рассинхрона предикта и имеют дефолт None, так что плагин вправе их игнорировать: predicted_state() -> Option<[f32; PLAYER_STATE_LEN]> (предсказанное состояние в раскладке player-блока, сравнивается покомпонентно с авторитетным кадром) и replayed_inputs() -> Option<(f64, f64, usize)> (окно истории ввода, переигранное последним реконсилем). Без них движок сравнивает камеру render_overlay() с state[0]/state[1] player-блока кадра — а это контракт на раскладку: если первые два компонента не мировые x/y, реализуйте predicted_state(), иначе фолбэк выдаёт бессмысленные нарушения. См. debugging.md.
Таблица экспортов заморожена
Набор методов выше заморожен навсегда: опубликованный .wasm не может нарастить таблицу экспортов, поэтому новые возможности движка доходят до ядра опкодами, которые ядро объявляет само через сгенерированные макросами методы abi_describe()/dispatch() — опкод, которого ядро не объявило, у него просто не просят. Метод может перестать вызываться движком, но не переименовывается и не меняет форму.
Snapshot-блоки — декларативная схема
Жёсткие раскладки блоков заменены схемой: SnapshotConfig.keys сопоставляет каждому ключу BlockSchema из пяти полей: id (опкод блока в кадре), kind (BlockKind: форма строки, из неё и следуют ширины count/id и наличие null-маркера), class (hot — интерполируется / event — только кадром), fields (у каждого тип f32/u8/u16/u32 и способ интерполяции lerp/lerpAngle/дискретное) и необязательный optionalFrom (индекс первого поля опционального хвоста строки: эти поля пишутся, только когда строка их несёт, а флаг-байт перед строкой сообщает, следуют ли они — см. snapshot.md; распаковка всегда отдаёт строку полной ширины, отсутствующий хвост читается нулями). Префикс d у id блоков indexedNoNull8 — тоже не поле схемы, он захардкожен в распаковщиках. Пакер (snapshot.rs), анпакер (client/unpack.rs), интерполятор и hot-буфер движка — интерпретаторы схемы; game-crate поставляет строки как плоские Vec<FieldValue>. Сама схема — данные игры: src/config/snapshot.js игры-плагина (например, в @vimp-games/tanks) (HostPlugin.gameConfig.snapshot, обязательное поле). Та же схема едет клиентскому JS в CONFIG_DATA → generic reconstructHot в reconstructHot (ширина записи = 2 служебных поля + число fields ключа); движковый бандл снапшот-ключей не содержит (схему всегда даёт хост — скрытой связи «бандл клиента обязан совпадать с хостом» нет). Player-блок схемой не описывается: его раскладка зафиксирована движком (PLAYER_STATE_LEN = 8 f32 + флаг centering) и захардкожена в пакере и распаковщике. (gameConfig.playerState — не про это: это стартовый профиль rank/state.) SNAPSHOT_FORMAT_VERSION остаётся 5 (фрейминг движка; байтовая раскладка не менялась); байт-совместимость между деплоями не требуется (хост и клиенты — один деплой; версия защищает только фрейминг внутри комнаты).
Инварианты: gameId в кадре — u8 (≤255 участников); порядок ключей weapons определяет weapon-index; соответствие полей panel ↔ ключей weapons валидируется в new GameCore.
CONFIG_DATA (порт 0)
Остаётся движковым механизмом; собирается buildClientConfig как merge: движковые дефолты (clientDefaults: interpolation, controls.modes/cmds, elems-структура, techInformList) + HostPlugin.buildClientGameConfig() (parts.gameSets/entitiesOnCanvas/bakedAssets/componentDependencies/sounds, keySetList, схемы panel/stat, тексты chat/vote/gameInform, prediction: models/weapons/playerKeys/timeStep) + снапшот-схема + производные комнаты (voteTime). initIdList и список канвасов — из конфига, не из хардкода.
Rust-трейты (vimp-engine-core)
Engine-crate — чистый Rust без wasm-bindgen (ошибки Result<_, String>; в JsError мапит game-crate). Статическая generic-диспетчеризация: EngineSim<TanksGame> / ClientState<TanksClient> мономорфизируются — ноль оверхеда на 120 Гц; dyn не нужен (один wasm-бандл = одна игра).
trait GameDef { type Config; type Sim: GameSim<Self>; }trait GameSim<G: GameDef>:new,spawn_actor,remove_actor,reset_actor,reset_all_vitals,spawn_scripted_actor,remove_scripted_actor,apply_input,apply_aim(ввод указателем, дефолт пустой),last_input_seq,is_alive,actor_position,prediction_state,alive_players_flat,players_json,on_fixed_step(ctx: &mut SimCtx, dt),on_contacts(ctx: &mut SimCtx, pairs),on_before_destroy,on_ai_tick(ctx: &mut SimCtx, dt),refresh_cached,build_snapshot_blocks(&mut self) -> (Vec<(String, Block)>, has_events),remove_players_and_shots,clear,serialize/deserialize(mid-round handoff — сохраняется как задел),rebuild_spatial_grid,on_map_loaded(ctx: &mut SimCtx) -> Result<(), String>(дефолтOk(()): каждая загрузка карты, но неdeserialize_state; ошибка оставляет мир без карты).SimCtx<'a>(не generic по игре) — доступ игры к движковому, передаётся в тиковые callback'и:world: &'a mut PhysicsWorld,cfg: &'a EngineConfig,map: &'a Option<GameMap>(respawns),nav/spatial: &'a Option<NavigationSystem>/&'a mut SpatialGrid(A*/сетка — движковые утилиты в модулеnav/, без слова «bot»),rng: &'a mut Rng,events: &'a mut Vec<CoreEvent>,bodies_to_destroy: &'a mut Vec<RigidBodyHandle>,map_body_state: &'a mut [u8](байт состояния каждого динамического тела карты). Поляgame_cfgнет — игровой конфиг передаётся только вGameSim::new, дальше игра хранит нужное сама.- Движок владеет: аккумулятор фикс-шага, сбор контактов, destroy-очередь, schema-driven
SnapshotPacker, handoff-каркас,CoreEvent. - Клиентская половина:
trait GameClientDef { type Config; fn new(cfg, engine_cfg); fn on_server_state(state, centering, server_time, offset, local_now); fn update(local_now); fn track_frame(my_game_id, frame); fn filter_frame_game(game, my_game_id, local_now); fn update_world(snapshot); fn update_world_interpolated(game); fn render_overlay(my_game_id) -> Option<RenderOverlay>; fn apply_input(...); fn apply_aim(...) (ввод указателем, дефолт пустой); fn set_model(...); fn set_active(...); fn set_map(...); fn sync_panel(...); fn reset(); fn cycle_item(back); fn try_action(...); fn begin_reconcile(snapshot) / fn finish_reconcile() (авторитетный кадр до replay и расхождение после — дефолт пустой); fn render_rows() -> Vec<PredictedRow> (строки тел, которые игра предсказывает сама, — дефолт пустой) }, плюс два опциональных метода ниже. Движок даётInterpolator(schema-driven), generic-оркестрациюClientState<G>(сетевой буфер, очередь событийных кадров, hot-буфер рендер-тика), hot-буфер, raycast. Предикт актора, визуальный спавн и панель — целиком забота игры внутри её реализацииGameClientDef.
Запись в профиль: rank и skills
Любая игра, зарегистрированная на платформе (см. submitting.md), может писать в общий профиль игрока. Каждая запись привязана к id игры; игра трогает только собственный namespace, никогда — чужой.
- rank — единый формат для всех игр: один клампленный integer на пару
(user, game)в диапазоне0–1000000, отображаемый одинаково везде, где его показывает платформа (например, лобби). Это часть публичного SDK-контракта.HostPluginсообщает дельту матча, а не абсолютное значение — леджером и клампингом владеет платформа. - skills — непрозрачный JSON
state, формат целиком на усмотрение игры. Движок никогда не читает и не валидирует его поля, только общий размер (не больше8192байт); SDK-контракт описывает только механизм namespace, не схему. - Игра никогда не пишет ник или идентичность — они берутся из identity-токена игрока, не из игрового кода.
- Обе записи идут через хост под собственной идентичностью сообщающего игрока и атрибутируются к комнате-источнику.
Инварианты совместимости
Движок никогда не удаляет и не переименовывает имя, которое игра может написать или прочитать (поле gameConfig, имя сервиса, control формы, метод wasm-ABI, номер порта, ключ блока снапшота), не делает ничего нового обязательным, не меняет форму существующих данных и не отвергает плагин, загружавшийся раньше. Rust-трейты растут только методами с реализацией по умолчанию. Плагин любого возраста поэтому остаётся валидным; исходный API vimp-engine-core может ломаться только для игр, которые сами решили пересобраться.
requires и возможности движка
GameManifest.requires — необязательный массив имён движковых возможностей, без которых игра не работает:
{
"id": "tanks",
"requires": ["accolades"], // необязательно; отсутствует = ничего сверх базового контракта
}Движок отвечает вердиктом, а не сравнением версий (checkPluginCompatibility): каждое имя ищется в append-only реестре возможностей capabilities, и неизвестное имя означает, что игра новее движка. Сейчас зарегистрированы: stat.leaderboard (срезы рейтинга), accolades (порт ACCOLADES_DATA и клиентский сервис), dispatch (dispatch/abi_describe в ядре), map.layers (слоёные 2.5D- карты: levels/ramps в формате карты, маски уровней в физике, set_actor_level, слоёный нав-граф) и map.levelsN (больше двух уровней, рампы через несколько уровней, правила уровня и падение у тел карты, z/level в строке динамики, volumes) и map.levelHeight (поле карты levelHeight, безразмерный уклон рампы, коллайдеры-стражи прогона, levelHeight/ramps в контексте парта), map.gameData (непрозрачное поле карты game в ядре, set_map и контексте парта; хук ядра on_map_loaded), map.bodyState (байт состояния тела карты, role: 'state', и доступ игры к телам карты по индексу), diagnostics (сервис журнала клиентских ошибок; необязательный, в requires его не пишут) и host.migration (контрольные точки хоста: gameConfig.migration, хуки модулей serializeState/restoreState; в requires его тоже не пишут). Зарегистрированное имя поддерживается вечно — опубликованная игра могла его написать, и её dist/ больше никто не тронет.
Объявляйте возможность, только если без неё игра ломается. Игра, которая деградирует штатно (не просит сервис, не подписана на порт), в requires не нуждается: умолчания движка (gameConfigView) и append-only словари примут её как есть.
HostPlugin.requires и ClientPlugin.requires — тот же необязательный массив на половинах плагина. Его читает standalone SDK: в solo-режиме платформы нет, а значит нет и манифеста, из которого можно взять requires. startStandaloneGame({ requires }) перекрывает обе половины, если встраивающий код знает лучше.
Три списка обязаны совпадать: игра, чей манифест просит возможность, которую половины не объявили, отвергается лобби и принимается SDK, после чего тихо недоигрывает — именно тот молчаливый режим, ради отказа от которого возможности и заводились. Расхождение отвергает правило контракта B2 — там его и встречает автор игры, до публикации. loadGamePackage только предупреждает: Node-пути читают авторитетный манифест, чья совместимость уже проверена, а пакет, который грузился раньше, обязан грузиться и дальше (И4). Имена сравниваются резолвнутыми по реестру возможностей, поэтому манифест с новым именем и половина, оставшаяся на выведенном алиасе, согласованы. Половина, вовсе не объявившая requires, из сверки исключена; requires: [] — нет, это уже утверждение, что игре ничего не нужно.
requires обязан быть массивом строк. Любая другая форма (голая строка, объект, не-строковый элемент) — битый манифест: вердикт {ok: false, reason: 'bad-manifest'}, и он ведёт себя ровно как несовместимая игра, а не роняет того, кто его прочитал. То же и для половин: SDK склеивает их через mergeRequires, который недоверенное значение никогда не спредит, — иначе голая строка разошлась бы списком отдельных букв.
Куда приходит вердикт:
| Вход | При отсутствующей возможности |
|---|---|
| каталог платформы | игра остаётся в каталоге с пометкой несовместимости; лобби показывает её недоступной с причиной, а комнату на ней создать нельзя |
loadGamePackage (dedicated, vimp-sim, inline-хост) | бросает — игра одна, подменить нечем |
loadClientPlugin (браузер) | бросает ещё до импорта бандла плагина |
| standalone SDK | бросает — одна проверка по объединению requires обеих половин |
Текст всегда называет сторону, которую надо обновить: «game "x" needs engine capabilities this build does not have: … — update the engine».
Версии и совместимость
| Константа | Владелец | Политика |
|---|---|---|
ENGINE_API_VERSION (=4) | движок | заморожена на 4 и больше не бампается; метка поколения, которую несут манифест и обе половины плагина, а не гейт совместимости. История: v2 ввела явную схему формы (roomForm, authSchema.params[].options) вместо вывода контрола из типа значения; v3 сократила набор control до нативных элементов (range/number/toggle/segmented вернулись вечными алиасами); v4 добавила порт ACCOLADES_DATA, сервис accolades и режим leaderboard у модуля stat. Движок держит рабочим каждое выведенное имя, поэтому игра, собранная под v2, идёт на сегодняшней сборке без правок. Правило контракта B2 требует лишь, чтобы манифест и обе половины плагина совпадали между собой и чтобы значение было импортом, а не литералом |
SNAPSHOT_FORMAT_VERSION (=5) | движок (фрейминг) | схема блоков едет в CONFIG_DATA → внутри комнаты всегда согласована |
← Предыдущая: Standalone SDK (браузер) · Следующая: Справочник конфигурации игры →