Skip to content

Rust-ядро движка (vimp-engine-core) ​

vimp-engine-core — rlib-crate (vimp-engine-core/, без wasm-bindgen), реализующий обобщённый каркас симуляции: физику, фикс-шаг тика, фрейминг снапшота, примитивы интерполяции/предикта/raycast и нав-утилиты. Игровой специфики в нём нет — ни «танка», ни «бомбы»: игровой crate (публикуется в собственном репозитории игры, например vimp-tanks-core) зависит от него, реализует трейты GameDef/GameSim/ GameClientDef и выполняет собственно #[wasm_bindgen]-обвязку (wasm-bindgen не умеет экспортировать дженерики, поэтому конкретные классы GameCore/ClientCore существуют только в игровом crate). Движковый crate не может импортировать ничего игрового, поэтому вторая игра просто добавляет свой crate рядом со своим репозиторием, переиспользуя vimp-engine-core без изменений.

Эта страница документирует только движковый crate — его трейты, обобщённые механизмы и сборку движкового crate. Конкретный WASM ABI, который обязан реализовать игровой crate, — контракт в plugin-api.md; собственная реализация ядра игры документируется в её собственном репозитории (например, docs/ru/core.md в @vimp-games/tanks).

Граница ядра — симуляция, а не мета: чат, голосования, статистика, панель, оркестрация раундов, реестр участников и auth остаются на JS. Мета управляет ядром командами и питается его событиями.

Структура ​

vimp-engine-core/             # vimp-engine-core — rlib, без wasm-bindgen
├── Cargo.toml                    # rapier2d (enhanced-determinism, serde) — без wasm-bindgen
├── src/
│   ├── lib.rs                    # только объявления pub mod
│   ├── sim.rs                    # GameDef/GameSim/SimCtx — граница трейтов движок↔игра
│   ├── game.rs                   # EngineSim<G> — тик, контакты, очередь удаления, handoff
│   ├── abi.rs                    # export_game_core_abi!/export_client_core_abi! — макросы
│   │                              #   wasm-bindgen-обвязки (см. разделы ABI ниже);
│   │                              #   раскрываются в игровом crate, который поставляет
│   │                              #   #[wasm_bindgen]
│   ├── map.rs                    # GameMap — статические/динамические тела, масштаб карты,
│   │                              #   soft-CCD предсказание у динамики (см. «Тела карты»)
│   ├── snapshot.rs                # SnapshotPacker + Block — упаковка бинарного кадра v5;
│   │                              #   Block обобщён по форме строки (Indexed8/Indexed32/
│   │                              #   List16/IndexedNoNull8), не по игровой сущности —
│   │                              #   движок не знает слов «танк»/«бомба», только форму строки
│   ├── events.rs                  # CoreEvent — стандартный словарь событий для JS-меты
│   ├── config.rs                  # EngineConfig/EngineClientConfig + типы схемы снапшота
│   │                              #   (BlockKind — enum формы строки, не игровой сущности)
│   ├── physics.rs                 # тег тела статики карты (encode_map_object/is_map_object),
│   │                              #   округления, углы — игровые теги тел (например, игрок/выстрел)
│   │                              #   живут в собственном модуле body-тегов игрового crate
│   ├── rng.rs                     # детерминированный PRNG (SplitMix64)
│   ├── debug.rs                   # курированный дамп мира (debug_json) — тела, коллайдеры,
│   │                              #   карта, нав, spatial, rng, аккумулятор фикс-шага
│   ├── nav/                       # обобщённые утилиты, смежные с ИИ (без слова «bot»)
│   │   ├── navigation.rs         # нав-сетка + граф + видимость (NavigationSystem)
│   │   ├── pathfinder.rs         # A*
│   │   └── spatial.rs            # пространственная сетка поиска целей
│   └── client/                    # обобщённые клиентские примитивы + оркестрация
│       ├── game.rs                # трейт GameClientDef + generic ClientState<G> — конвейер
│       │                          #   sample(), hot-буфер, очередь кадров; игра поставляет
│       │                          #   предикт/спавн выстрела через трейт
│       ├── unpack.rs              # декодер кадра v5 + JSON-формы
│       ├── divergence.rs          # детектор рассинхрона предикта (кольцевой буфер записей)
│       ├── interpolator.rs        # буфер снапшотов, seq, лерп (schema-driven)
│       ├── raycast.rs             # DDA по тайлам + OBB slab-тест
│       ├── collision.rs           # SAT-контакты: OBB против OBB и против тайловой сетки
│       └── rigid_body.rs          # импульсный решатель контактов + поверхность карты

Сборка ​

У движкового crate нет собственного WASM-таргета — это обычный rlib, покрытый своими unit-тестами и используемый любым игровым crate, который от него зависит.

Настоящая сборка WASM (wasm-pack build, таргеты web + nodejs) происходит в собственном crate игры, поскольку именно там определены классы #[wasm_bindgen] — скрипт core:build лежит в шаблоне скаффолдера (см. packaging.md); @vimp-games/tanks собирает своё ядро так же.

ABI: макросы ​

Обвязка wasm-bindgen для двух экспортируемых классов игры (механические 1:1-делегации в generic EngineSim<G>/ClientState<G>) генерируется двумя макросами в vimp-engine-core/src/abi.rs — export_game_core_abi! и export_client_core_abi! — единственным источником истины обязательного набора методов, дрейф игрового crate от него исключён. Игровой crate зовёт каждый макрос рядом со своими дополнительными методами (например, действие огня/перезарядки/смены модели или сигнатура спавна, зависящая от конфига); new (парсинг конфига) и не-#[wasm_bindgen] тестовые аксессоры остаются рукописными в игровом crate. Точный обязательный набор методов документирован как контракт в plugin-api.md.

Этот набор заморожен: таблица экспортов уже опубликованного .wasm вырасти не может — значит, не растёт и набор макросов. Дополнительно оба макроса генерируют abi_describe() (самоописание ядра: версия формата, версия vimp-engine-core, с которым оно собрано, понимаемые опкоды) и dispatch(op, payload) — единственную точку входа, через которую любая будущая возможность приезжает опкодом, а не символом. Пустой возврат — «опкод не обработан», [0x00] — «обработан, ответа нет».

Игра подключается к dispatch методами GameSim::dispatch_op / dispatch_ops (и их зеркалами в GameClientDef) — оба с реализацией по умолчанию: обязательный метод трейта не дал бы игровому крейту скомпилироваться, а это ровно тот слом, который вся конструкция и запрещает. Макрос раскрывается в крейте игры, поэтому игра, пересобранная с более новым движком, получает все известные ему опкоды даром.

Детерминизм ​

  • rapier2d собирается с enhanced-determinism (бит-в-бит на всех платформах при одинаковом вводе);
  • вся случайность (разброс оружия, решения ботов и т.п.), как ожидается, проходит через встроенный SplitMix64 PRNG с сидом из конфига (seed), без Math.random — обеспечивается соглашением в играх, построенных на этом движке;
  • handoff-дамп должен восстанавливать симуляцию бит-в-бит; движок предоставляет хуки serialize/deserialize в GameSim, игра закрепляет это своими тестами state_dump_restores_identical_simulation;
  • округляйте то, что упаковываете сами: упаковщик пишет сырые f32, а декодер восстанавливает каждое поле снапшота через round2, поэтому неокруглённое значение вернётся клиенту другим числом, чем то, что держит хост, — см. snapshot.md (player-блок — единственное исключение);
  • все значимые для геймплея вычисления остаются в Rust: арифметика на JS-стороне над значениями снапшота не совпадёт с ядром.

Настройка игрового крейта ​

core/Cargo.toml игры (крейт с классами #[wasm_bindgen]) собирается как cdylib и зависит от опубликованного движкового крейта:

toml
[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
vimp-engine-core = "0.23"
rapier2d = { workspace = true }   # features: enhanced-determinism, serde-serialize
serde = { workspace = true }
serde_json = { workspace = true }
wasm-bindgen = "0.2"

enhanced-determinism обязателен: хост и каждый клиент обязаны получать бит-в-бит одинаковый результат из одинаковых входов, иначе предсказание расходится (правило контракта A это проверяет, см. debugging.md). Макросы требуют, чтобы у структуры были ровно поля state: EngineSim<G> и packer: SnapshotPacker (у ClientCore — state: ClientState<G>), и раскрываться должны в модуле, где wasm-bindgen — зависимость; new не генерируется никогда, его пишет игра (он разбирает init JSON ниже).

Init JSON ​

Оба ядра получают одну JSON-строку вида { engine, game }, которую собирают coreConfig (хост) и clientCoreConfig (клиент). Структуры Config игры должны совпадать с половиной game.

jsonc
// хост — GameCore
{
  "engine": {
    "timeStep": 0.008333333, // СЕКУНДЫ
    "mapScale": 0.3,
    "mapSetId": "c1",
    "mapFallTime": 0.35,
    "snapshot": { "version": 5, "port": 5, "keys": {} },
    "seed": null
  },
  "game": {
    "friendlyFire": false,
    "models": {}, // gameConfig.parts.models
    "weapons": {}, // gameConfig.parts.weapons
    "playerKeys": {},
    "panel": {} // gameConfig.panel.fields
    // + каждый ключ gameConfig.coreParams как есть
  }
}

// клиент — ClientCore, из CONFIG_DATA
{
  "engine": {
    "timeStepMs": 8.333333, // МИЛЛИСЕКУНДЫ
    "snapshot": { "version": 5, "port": 5, "keys": {} },
    "interpolation": { "delay": 100, "maxFrameAge": 1000 }
    // + "divergence" — только в отладочном конфиге
  },
  "game": { "playerKeys": {}, "models": {}, "weapons": {}, "seed": null }
  // + каждый ключ prediction.coreParams как есть
}

Разница единиц намеренная и зашита в имена: timeStep — секунды на хосте, timeStepMs — миллисекунды на клиенте. coreParams — непрозрачный словарь игры, который движок лишь доставляет в GameSim::new / GameClientDef::new; собственные ключи движка важнее одноимённых.

Теги тел ​

Твёрдые тела несут u128-тег в user-data. Младший байт 1 зарезервирован движком (MAP_OBJECT_TAG); игра нумерует свои виды с 2 и выше и упаковывает дополнительные данные в старшие биты. Динамическое тело карты несёт свой индекс выше байта тега (encode_map_object_at(i), обратно — map_object_index) — тела карты проверяйте через is_map_object, а не через user_data == MAP_OBJECT_TAG.

Клавиши ввода ​

apply_input(game_id, seq, action, key_name) получает сырые события провода (action — "down" или "up"). Таблица playerKeys (имя действия → бит, type: 0 | 1) приходит в половине game init JSON, а движок её никогда не интерпретирует — сопоставление имён битам и учёт type — забота ядра. Эталонный паттерн: битовая маска current_keys для зажатых клавиш (down ставит бит, up снимает) плюс одноразовая маска для клавиш type: 1 (down ставит отложенный бит, который следующий фиксированный шаг потребляет ровно один раз, up игнорируется). Клиентский предиктор обязан применять то же правило в своём apply_input, иначе предсказание расходится на каждом нажатии триггера.

apply_aim(game_id, seq, x, y, flags) — аналог для канала указателя: мировая точка плюс битовая маска (бит 0 «нажато», бит 1 «двойной тап»). Оба метода трейта по умолчанию имеют пустое тело; ядро, реализующее указатель, обязано подавать цель в ТУ ЖЕ функцию поворота, что и клавиши (и в историю ввода предиктора), иначе две половины расходятся по кривой.

PLAYER_STATE_LEN = 8 — бюджет предсказания ​

Пользовательский player-блок несёт ровно восемь f32, описывающих авторитетное состояние локального актора, плюс флаг centering (snapshot.md). Это весь канал reconciliation: всё, что клиент должен предсказывать, обязано уместиться в восемь float (танки: [x, y, angle, vx, vy, angvel, gunRotation, engineThrottle], флаг — centeringGun). Больше предсказываемого состояния — либо выводится на клиенте из этих восьми значений, либо не предсказывается.

Паттерн предсказания ​

  1. Клиент применяет ввод локально через ClientCore.apply_input и на каждом тике рендера шагает свою копию модели движения (update).
  2. Каждый кадр с player-блоком вызывает on_server_state(...): клиент откатывается к авторитетному состоянию и проигрывает заново ввод новее input_seq.
  3. render_overlay() возвращает предсказанный хвост, дописываемый в hot-буфер; None (нет локального актора или модели) заставляет движок вернуться к интерполированной камере и сбрасывает флаг PREDICTED.
  4. Игра, предсказывающая ещё и другие тела (динамика карты, акторы в контакте с локальным), читает их авторитетное состояние в begin_reconcile(snapshot), даёт проигрышу их нести, сводит расхождение в finish_reconcile() и возвращает их из render_rows(); каждая строка дописывается после предсказанного хвоста и перекрывает интерполированную строку той же сущности.

Паритет движения — жёсткое требование: предсказанное клиентом движение и движение хоста в on_fixed_step обязаны давать одинаковый результат. Держите математику движения в одном общем модуле, используемом обеими половинами, и добавьте cargo-тест паритета, который шагает обе и сверяет равенство; перезапускайте его после любого изменения движения.

Предсказание выстрела и подавление дублей ​

Локально предсказанный выстрел иначе рисовался бы дважды — сразу и снова, когда придёт авторитетное событие.

  1. try_action создаёт локальный эффект с id вида L<n> и возвращает его spawn-JSON в ClientPlugin.hooks.onLocalAction, который передаёт его рендереру.
  2. Последнее поле каждого блока события оружия — game id автора.
  3. filter_frame_game просматривает входящие строки событий и, когда id автора равен локальному игроку, отбрасывает строку (или заменяет локальный id).
  4. Строка null в индексированном блоке — маркер удаления сущности.

Сохранение и восстановление ​

serialize_state() / deserialize_state(bytes) переносят матч на другой хост, когда игра задаёт gameConfig.migration.midRound (чекпоинт хоста — упакованное состояние матча, которое движок передаёт следующему хосту): дамп — это GameSim::serialize игры плюс мир, карта, PRNG и аккумулятор движка, и новый хост продолжает с того же тика. serialize/deserialize игры тогда обязаны покрывать всё состояние симуляции — пропущенное поле молча сбрасывается при каждой смене хоста. Без флага дамп не используется: мигрирующая комната пересоздаёт карту и респавнит всех.

Тела карты ​

GameMap::create собирает физику карты из её JSON: статические стены объединяются в прямоугольные блоки (RigidBodyBuilder::fixed()), каждая запись physicsDynamic становится динамическим телом с коллайдером, смещённым на половину размера (позиция тела — угол объекта, как в данных карты).

Каждое динамическое тело создаётся с soft_ccd_prediction(width.min(height)) — на собственную толщину. Дефолтная дистанция предсказания Rapier, 0.002 юнита, рассчитана на метровый масштаб; тело в матче за шаг 1/120 проходит на порядки больше, поэтому без предсказания контакт рождается уже по факту глубокого перекрытия, и объект заметно «въезжает» в стену перед выталкиванием. Статическим стенам это не нужно (они не двигаются).

Собственные тела игры (акторы, снаряды) создаются в игровом crate и должны задавать дистанцию предсказания сами тем же способом.

user_data динамического тела несёт его индекс над байтом тега карты (encode_map_object_at(i), обратно — map_object_index), поэтому игра сопоставляет пару контакта с телом карты без поиска: GameMap::dynamic_index_of(world, handle), а обратно — GameMap::dynamic_handle(i). Удалять тело карты игре нельзя: движок удаляет их все при следующей загрузке карты, а повторное удаление в Rapier недопустимо. Вместо этого тело отключают (set_enabled(false)): step_dynamic_levels отключённое тело пропускает (смена групп вернула бы ему коллизии), а его строка в снапшоте остаётся — позиция замерла, хвоста скоростей нет.

Поле карты game и physicsDynamic[i].game — непрозрачные данные игры (MapConfig::game, DynamicObjectConfig::game): движок хранит их как объявлены, без масштаба, и отдаёт через GameMap::game_data() и GameMap::dynamic_game_data(i); в дамп эстафеты они уходят вместе с картой. MapConfig::validate фиксирует только форму — объект или ничего.

Построив тела и нав-граф, EngineSim::load_map обнуляет map_body_state (байт на каждое динамическое тело) и зовёт GameSim::on_map_loaded(ctx) — на каждой загрузке, в том числе при повторной загрузке той же карты в начале раунда. Ошибка хука делает load_map неуспешным, и откатываться некуда: прежняя карта удалена ещё до постройки новой, поэтому движок удаляет и новые тела, очищает nav и map_body_state — мир остаётся без карты. (Ошибка MapConfig::validate — другое дело: она случается до того, как старая карта тронута.) При deserialize_state хук не зовётся — выведенное из карты игра восстанавливает из своего дампа, а map_body_state входит в дамп движка.

Слоёные карты (2.5D) ​

Карта может нести надземные уровни рядом с землёй: уровень 0 — земля, уровни 1..7 — надземные (MAX_LEVELS = 8, по биту маски Rapier на уровень). Уровень 0 остаётся в полях map / physicsStatic / layers, поэтому карта без новых полей грузится ровно как раньше. Остальные уровни приходят необязательным полем levels, переходы между ними — необязательным полем ramps:

json
{
  "levels": {
    "1": { "map": [[0, 9]], "floor": [9], "walls": [7], "layers": {} }
  },
  "ramps": [{ "tile": 3, "dir": "east", "from": 0, "to": 1 }]
}
  • map — грид тайлов уровня; его размерность обязана совпадать с map, а 0 означает «уровня здесь нет, виден нижний».
  • floor — тайлы, по которым можно ездить (плита моста). walls — перила: они блокируют движение и луч на этом уровне, и каждый тайл перил обязан входить и в floor, иначе он висит в воздухе и не экранирует луч снизу.
  • dir — направление подъёма (north = -y, south = +y, west = -x, east = +x); from/to по умолчанию 0/1 и могут перекрывать больше одного уровня.
  • levelHeight — необязательное поле в корне карты: высота ОДНОГО уровня в мировых единицах (по умолчанию — размер тайла). Она делает RampSample::slope безразмерным уклоном (rise * levelHeight / span) вместо «уровней на пиксель», под который и подобраны все константы подъёма.
  • volumes — необязательное поле рядом с layers на любом уровне: визуальная высота рендер-слоя в уровнях ({ "<ключ layers>": 0.6 }). Ядро её не использует — поле едет клиенту и живёт в рендере, но проверяется здесь: опечатка в ключе слоя иначе молча даёт плоскую карту.
  • physicsDynamic[].level ставит динамическое тело на уровень; точка респауна может назвать свой уровень 4-м числом ([x, y, angleDeg, level]) — без него уровень выводится из геометрии (GameMap::level_at).

MapConfig::validate вызывается внутри load_map до создания тел и превращает каждую из этих ошибок в ошибку load_map; в рантайме все они молчат. Две проверки — про саму геометрию уровня:

  • рампа обязана куда-то приводить — клетка за верхним концом прогона должна быть проезжей поверхностью того уровня, на который рампа поднимает, иначе танк доезжает до вершины и в тот же шаг срывается вниз;
  • край плиты либо закрыт перилами, либо куда-то приземляет — клетка floor, у которой сосед не плита и не перила, это обрыв, и падение с него обязано приземлиться на проходимую поверхность MapLevels::landing_level (ближайший уровень ниже с полом в этой клетке, а если такого нет — земля); за границей грида или над стеной это ошибка (стены нижнего уровня танку на плите не преграда);
  • рампа не имеет права прошивать чужую плиту — прогон рампы from -> to не должен проходить под полом промежуточного уровня;
  • две рампы не имеют права делить клетку — общая клетка досталась бы прогону произвольно (первому объявленному), и подъём перестал бы выводиться из карты;
  • levelHeight, если задан, обязан быть конечным и больше нуля — он задаёт масштаб всего уклона рампы.

Проверки формы живут в map::validate_levels — свободной функции над сырыми полями levels/ramps, поэтому клиентская реплика игры гоняет ровно те же правила на MAP_DATA: карта приходит туда по сети, и грид уровня, разошедшийся с хостовым, разваливает предсказание молча. Rust-валидатор и правило контракта E4 гоняют один и тот же набор тестовых кейсов.

MapLevels держит слоистую геометрию без физического мира — гриды, списки сплошных тайлов и тайлов пола по уровням, прогоны рамп. Хост хранит её внутри GameMap, а клиентская реплика игры строит ту же структуру из тех же полей MAP_DATA, поэтому обе стороны отвечают на level_at, has_floor, is_solid и ramp_at одной реализацией: вторая копия правил разъехалась бы с авторитетной молча.

Физику уровней разделяют маски Rapier: level_interaction(level) кладёт тело в группу своего уровня (уровню N — бит N: GROUP_1 — земля, GROUP_2 — первая эстакада) и оставляет ему видимым только этот уровень; STATIC_LEVEL_GROUP (бит 8) — общая группа стен; RAMP_GUARD_GROUP (бит 9) — группа стражей рампы: коллайдеров по обоим бортам блока полос рампы и поперёк его дальнего торца, которые GameMap::create_static строит только для слоёных карт (полосы одной широкой горки несут общий RampRun::block и огораживаются вместе, а не поодиночке). Геометрия стражей живёт в одном месте — map::ramp_guards(&MapLevels) -> Vec<RampGuard>: по ней хост ставит свои коллайдеры, и клиентская реплика игры обязана строить стражей тем же вызовом, а не выводить боксы заново — две копии расходятся молча, и заметно это ровно на предсказанном подъёме по рампе. Борт начинается НА КЛЕТКУ дальше подножия: клетка подножия открыта со всех сторон, поэтому заехать на горку можно в лоб, наискось и сбоку, а законность такого входа судит ИГРА (у танков — level::entry_is_legal). Борта держат только СЕРЕДИНУ прогона, а прогон длиной в одну клетку — сплошное подножие — бортов не получает вовсе. Сами границы бортов — отдельный вызов map::ramp_rail_span(run, tile) -> Option<(f32, f32)>: игра, которая РИСУЕТ прогон (юбка клина), берёт их оттуда, а не повторяет отступ, иначе на картинке вырастает стена там, где физика пускает. Выезд с прогона борт не держит никогда: законно поднимающееся тело проходит стражей насквозь. Фильтр тела собирает body_filter(mask, on_ramp) (levels_interaction и вариант _on_ramp): тело уровня, с которого прогон начинается, стражей видит, законно поднимающееся по прогону — нет, а тело другого уровня не совпадает с ними вовсе. Собрать этот фильтр битами в игре значило бы завести вторую копию правила. Тело, не выставившее группы, остаётся в Group::ALL и продолжает взаимодействовать с уровнем 0 — одноуровневые миры и игры, не знающие про уровни, ничего не замечают.

ramp_at отдаёт рампу под точкой: progress по прогону, from/to, единичный вектор «в горку» dir и крутизну slope (уровней на мировую единицу — продольный уклон под курсом равен slope * dot(dir, heading)), а также run/axis, по которым заход на прогон с торца отличается от заезда сбоку.

Падение — один примитив на всю экосистему: FallModel, линейный по z, с длительностью, растущей с высотой (EngineConfig.mapFallTime, 0.35 с на уровень). Игра добавляет поверх свои правила (блокировка ввода, урон), но саму траекторию обязана брать здесь — иначе ящик и танк падают по-разному и расходятся молча. elapsed_at — обратная к z_at: клиентская реплика восстанавливает ею фазу падения из авторитетного кадра.

Тела карты живут по тем же правилам: GameMap::step_dynamic_levels (её зовёт EngineSim::step_fixed до шага мира — у игры карта только по &) гоняет step_body_level для каждого динамического тела. Ящик, под которым не осталось пола, срывается на landing_level, пока падает — несёт STATIC_LEVEL_GROUP (стены есть, тел нет), а по приземлении получает группу своего нового уровня. Рампы телам недоступны — их толкают, а не ведут, поэтому на клетке рампы тело сохраняет уровень. При dynamic_map_data(world, with_levels, ...) строка блока динамики карты становится [x, y, angle, z, level]; движок включает это, когда схема набора карт у игры называет поля 3 и 4 z и level, — игра со старой схемой остаётся при своих трёх полях. Пара стоит в голове строки, а не в необязательном хвосте: отсутствующий хвост распаковывается нулями, и покоящийся ящик на мосту у зрителя «упал» бы на землю. dynamic_map_data_with_state(world, with_levels, with_velocities, states) добавляет байт состояния тела сразу за этой головой (позиция 5 у слоёной строки, 3 — у плоской) и до хвоста скоростей; build_snapshot_blocks передаёт SimCtx::map_body_state, когда схема объявила поле с role: 'state' (BlockSchema::with_state), а dynamic_map_data сохраняет раскладку без него.

Уровень участника игра получает через GameSim::set_actor_level (одноимённый метод ABI), который движок зовёт сразу после spawn_actor/reset_actor, когда точка респауна назвала уровень. Реализация по умолчанию — пустая.

Навигацию ботов на такой карте строит NavigationSystem::generate_layered: узлы на каждом уровне, двусторонние рёбра рамп и односторонние рёбра обрывов (только сверху вниз, на landing_level, со штрафом за каждый уровень высоты — прыжок стоит игрового fallDamage). Пути ищутся через find_path_on(PathPoint, PathPoint); смена уровня между соседними точками пути означает рампу или обрыв.

Навигационные запросы ​

Поверх готового графа NavigationSystem держит на каждом уровне две производные сетки (строятся последними, так что построение графа от них не зависит). Клетка свободна ровно тогда, когда так считает is_walkable_on; край карты считается непроходимым:

  • fit — сторона (в клетках, не больше 15) наибольшего свободного квадрата, содержащего клетку; 0 — клетка непроходима. Корпус ширины w проходит по клетке, если fit ≥ ceil(w / grid_step).
  • clear — чебышёвское расстояние (в клетках) до ближайшей непроходимой клетки или края карты; 1 — клетка стоит вплотную к стене.

У каждого ребра есть и приватные метаданные: тип (пешком, рампа, обрыв) и минимумы fit/clear по клеткам его линии.

find_route(start, end, &PathQuery) -> Option<Route> ищет по правилам:

Поле PathQueryЕдиницыСмыслПо умолчанию
min_widthмировые единицыширина корпуса: ребро со слишком малым fit запрещено, прямому отрезку нужен коридор has_clear_corridor_on0 (без ограничения)
comfort_clearanceмировые единицыжелательный запас от стен0 (без предпочтения)
narrow_costдоля длины ребрадобавка при нулевом запасе, линейно до 0 при comfort_clearance0
ledge_cost_scaleмножительмасштаб штрафа обрыва LEDGE_PENALTY · height: 0 — прыжок стоит только длину, f32::INFINITY — обрывы запрещены1
penalties&[PenaltyZone]круги { level, center, radius, cost_per_unit }: ребро с серединой внутри дороже на cost_per_unit · (1 − d/radius) за единицу длины; прямой отрезок через зону своего уровня не срезается — решает A* по рёбрам&[]

PathQuery::default() воспроизводит find_path_on, который (как и find_path) теперь тонкая обёртка над find_route. Route — это legs: Vec<RouteLeg> и cost (длина плюс штрафы); каждый RouteLeg { point, kind } говорит, как в точку приезжают: LegKind::Walk, LegKind::Ramp { axis, sign } (как у RampRun) или LegKind::Ledge { height }. Последний участок — всегда end (Walk). Узлы входа в граф ищутся кольцами до 4 ячеек узловой сетки вокруг точки, и предпочтение отдаётся видимому узлу с подходящим корпусу fit; если старт и финиш притянулись к одному узлу, получается маршрут из двух участков, а не None.

Вспомогательные функции: clearance_on(level, x, y) (мировые единицы от центра клетки до ближайшей непроходимой, 0 на непроходимой клетке), fits_on(level, x, y, width), has_clear_corridor_on(level, start, end, half_width) (центральная линия и параллельные ей по обе стороны, не реже чем через клетку и до ±half_width, идут только по свободным клеткам), nearest_walkable_on(level, pos, width, max_radius) (ближайший центр свободной клетки, куда влезает корпус; при равенстве — по дистанции, затем y, затем x) и random_point_where(rng, level, width) (случайный узел уровня, куда влезает корпус; до 16 попыток, затем random_point).

A* (pathfinder::find_path_with с функцией стоимости ребра, которая может ребро запретить) работает на двоичной куче с ленивым удалением; ничьи по f достаются меньшему индексу узла, поэтому одинаковые запросы всегда дают одинаковые маршруты.

rust
let zones = [PenaltyZone { level: 0, center: danger, radius: 80.0, cost_per_unit: 4.0 }];
let query = PathQuery {
    min_width: 12.0,          // корпус танка
    comfort_clearance: 10.0,
    narrow_cost: 1.0,
    penalties: &zones,
    ..PathQuery::default()
};
let route = nav.find_route(from, to, &query);

Клетки прогона рампы непроходимы на уровне, с которого прогон начинается: бот попадает на клин только через ребро рампы, а не сойдя на него сбоку или с дальнего торца — ровно это закрывают коллайдеры-стражи в физике. Само ребро рампы держится на двух СОБСТВЕННЫХ узлах прогона — по центру его полосы сразу за кромками: узел общей сетки (её шаг — два тайла) лёг бы на борт прогона, где стоит страж. Такая клетка помечается в сетке проходимости значением 2, а не 1: is_walkable_on считает свободным только 0, а has_obstacle_between_on считает препятствием только 1, поэтому боты по-прежнему видят друг друга сквозь рампу и стреляют через неё.

client::raycast::walk_ray_cells обходит клетки луча и отдаёт каждую колбэку, чтобы игра могла сменить уровень луча на кромке плиты вместо единственного зашитого «стена — стоп». ray_vs_grid — тонкая обёртка над ним.

Rust-трейты (vimp-engine-core) ​

Движковый crate — чистый Rust без wasm-bindgen (ошибки — Result<_, String>; игровой crate маппит их в JsError). Статическая generic- диспетчеризация: EngineSim<G> (хост) и ClientState<G> (клиент) мономорфизируются под игровой GameDef G — нулевые накладные расходы на 120 Гц; dyn не нужен (один wasm-бандл = одна игра). Полные сигнатуры — в plugin-api.md; эта выжимка не должна с ними расходиться.

  • 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, dt), on_contacts(ctx, pairs), on_before_destroy, on_ai_tick(ctx, dt), refresh_cached, build_snapshot_blocks(&mut self) -> (Vec<(String, Block)>, has_events), remove_players_and_shots, clear, serialize/deserialize (контрольная точка хоста: при gameConfig.migration.midRound дамп serialize_state переносит матч к новому хосту посреди раунда), rebuild_spatial_grid; on_map_loaded(ctx) (дефолт Ok(()), см. Тела карты).

  • SimCtx<'a> — доступ игры к возможностям движка внутри тиковых колбэков; не генерик по игре: world (Rapier), cfg (EngineConfig), map (респауны — IndexMap<String, Vec<[f32;3]>>, произвольные команды), nav/spatial (A*/сетка — движковые утилиты в модуле nav/, без слова «bot»), rng, events, bodies_to_destroy, map_body_state (байт состояния каждого динамического тела карты). Поля game_cfg нет: игровой конфиг приходит игре один раз, в GameSim::new, и реализация сама хранит нужное.

  • Движок владеет: аккумулятором фикс-шага, сбором контактов, очередью удаления, schema-driven SnapshotPacker, скелетом handoff, CoreEvent.

  • Клиентская половина: trait GameClientDef — new, on_server_state, update, track_frame, filter_frame_game, update_world, update_world_interpolated, render_overlay, apply_input, apply_aim (ввод указателем, дефолт пустой), set_model, set_active, set_map, sync_panel, reset, cycle_item, try_action, три хука для тел, которые игра предсказывает сама, — begin_reconcile(snapshot)/finish_reconcile() вокруг replay и render_rows() для рендер-тика, все с пустым дефолтом (см. ниже), — плюс два хука детектора рассинхрона (predicted_state, replayed_inputs) с дефолтом None (см. ниже). Движок предоставляет Interpolator (schema-driven), generic-оркестровку ClientState<G> (сетевой буфер, очередь событийных кадров, hot-буфер рендер-тика), raycast и примитивы столкновений (collision, rigid_body). Предсказание актора, клиентский спавн выстрела и панель — целиком забота игрового crate внутри его реализации GameClientDef, которая вызывает движковые примитивы.

    collision и rigid_body позволяют клиенту предсказывать контакты так же, как их разрешает хост: obb_manifold / collect_block_contacts дают Contact (геометрию они читают тем же Box2, что и raycast, — луч и контакт не могут разойтись в том, где стена). Стены берутся склеенными блоками MapLevels::static_blocks — тем самым списком, по которому хост ставит коллайдеры. Собранные потайлово (collect_tile_contacts, оставлена для сетки, которую игра держит сама), они дают телу на длинной стене несколько контактов там, где у хоста один, и касательный удар об угол стороны разрешают по разным осям — расхождение молча. Кроме того, collect_tile_contacts не поддерживает предсказания: контакт рождается только по факту перекрытия, и на быстром касательном ударе он разойдётся с хостовым soft_ccd_prediction. Слоёная карта обязана читать стены через collect_block_contacts; collect_block_contacts_into — тот же сбор в буфер вызывающего, для шага, который собирает контакты по несколько раз за кадр.

    Четыре детали делают реплику ПОХОЖЕЙ на Rapier не внешне, а по поведению; игра, пропустившая любую из них, разъедется с сервером на касательных ударах:

    • Спекулятивные контакты. obb_vs_obb_within(a, b, prediction) и obb_manifold(a, b, prediction) возвращают контакт, пока тела ещё разведены, — до зазора prediction; Contact::depth знаковый (> 0 — проникновение, < 0 — зазор). prediction обязан быть тем же числом, что soft_ccd_prediction тела у хоста: иначе стороны видят контакт на разных шагах. Без него тело на скорости в сотни единиц реагирует уже изнутри стены, и плечо у него не хостовое. obb_vs_obb — обёртка без предсказания.

    • Двухточечный манифольд. Rapier отсекает опорные грани пары кубов, поэтому контакт грань-в-грань несёт две точки со своими глубинами. Единственная точка, смешанная в середину грани, не даёт плеча вовсе, и корпус не разворачивается там, где сервер его разворачивает. Manifold держит до двух Contact; Manifold::deepest() — та единственная точка, которой делается позиционная коррекция (separate_bodies — один раз на пару, а не на точку). Если грани не перекрываются вовсе, перекрытая пара откатывается к смешанной точке obb_vs_obb_within (тела всё равно надо развести), а разведённая — угол к углу в пределах предсказания — контакта не получает, как у parry. Иначе реплика тормозила о «зазор», мимо которого хост проезжает (корпус в проёме стены).

    • Накопленные импульсы. apply_contact_impulse принимает &mut ContactImpulses — свой на каждый контакт, живущий от первой итерации решателя до последней, — и клампит НАКОПЛЕННЫЙ импульс, а не приращение итерации. На приращениях первая точка манифольда забирает весь нормальный импульс, разворачивает корпус плечом и оставляет вторую расходящейся, и вернуть лишнее нечем; накопление возвращает. Туда же передаётся dt — он нужен спекулятивному контакту: зазор закрывается со скоростью -depth / dt, и гасится только то, что закрывает его быстрее, поэтому тело встаёт У стены, а не внутри неё.

    • Дозированная позиционная коррекция.separate_bodies(a, b, contact, dt) снимает за шаг не всю глубину, а penetration_correction(depth, dt) — min(contact_erp(dt) * (depth - ALLOWED_LINEAR_ERROR), MAX_CORRECTIVE_VELOCITY * dt), закон контактной пружины Rapier (contact_natural_frequency 30 Гц, contact_damping_ratio 5, normalized_allowed_linear_error и normalized_max_corrective_velocity на length_unit = 1 — параметры хоста по умолчанию). Глубокое перекрытие — тело, упавшее внутрь ящика, — Rapier растаскивает десятками шагов по доле юнита; реплика, выталкивающая его разом, прыгает на несколько юнитов и пробивает порог расхождения предсказания.

    separate_bodies + apply_contact_impulse разрешают их на телах Body, а MAP_SURFACE переиспользует map::DEFAULT_FRICTION / DEFAULT_RESTITUTION — те же значения, с которыми хост строит коллайдеры. Эта пара — приближение Rapier и остаётся для совместимости: на ударе с хостом она не совпадает.

    Шаг как у Rapier. step_bodies(bodies, rows, cache, dt) заменяет separate_bodies + итерации apply_contact_impulse + integrate портом TGS-решателя Rapier. Ограничения строятся раз на позе начала шага, затем SOLVER_SUBSTEPS (4) подшагов по dt / 4: зазор пересчитывается по текущим позам, warmstart импульсами прошлого подшага, проход со смещением (мягкая пружина снимает проникновение, зазор разрешает сближение со скоростью зазор / h), интеграция поз (поворот линеаризован, как у Rapier) и проход без смещения, цель которого — отскок новой точки. Две точки одного манифольда решаются вместе блоком 2×2, нормали — до трения. В отличие от Rapier, который проверяет только det > 0, блок с числом обусловленности больше 1000 (порог Box2D, MAX_BLOCK_CONDITION) считается вырожденным, и его вторая точка не толкает: две точки в микронах друг от друга оставляют блок вырожденным лишь до округления f32, и его обращение накачивало в тело энергию вплоть до inf/NaN. Демпфирование — один раз, на полный dt. Зачем: итог удара зависит от того, на каком подшаге закрывается зазор. Раньше последнего — тело уходит от стены с −e·v; на последнем — отскока нет, остаток скорости закрытия сохраняется, а на следующем шаге точка уже не новая и гасит его. При шаге 1/120 это примерно каждый четвёртый удар, и одно правило на весь шаг дёргает там до ~130 ед/с.

    • Строки. Собираются только через ContactRow::from_manifold(a, b, &manifold, surface, key_base): он берёт Manifold::solver_points() — середину между поверхностями тел, как у parry (у спекулятивного контакта — в середине зазора), — и кладёт пару точек подряд. Manifold::as_slice() отдаёт точки на поверхности падающего тела; их ждёт старый решатель, а в step_bodies они сдвигают плечо трения на полглубины: косой удар разворачивает корпус не как у хоста, молча. Блок 2×2 образуют только две строки одного манифольда — те же тела, те же имена в ключах, точки 0, затем 1, — поэтому вызывающий может держать все стены одним статическим телом среза.
    • Ключи. ContactKey::new(a, b, point) называет точку контакта между шагами устойчивыми именами тел (id игры, индекс блока на уровне), а не индексами в bodies: вызывающий пересобирает этот срез каждый шаг. Номер точки — вершина падающей грани, от которой она пришла, а не место среди уцелевших точек: как id признака у parry, он не сдвигается, когда соседняя точка уходит за предсказание.
    • Память. ContactCache хранит то, что parry держит на точку: импульсы последнего подшага (warmstart) и сумму нормального импульса за шаг (точка, толкавшая на прошлом шаге, не новая и не отскакивает). Один кэш на симуляцию; его Clone копирует только эту память. Предиктор хранит копию на каждый шаг в истории снимков и откатывает её вместе с состоянием; clear() — при телепорте, сбросе или откате за пределы истории. Очищать его на каждом кадре сервера нельзя: реплей у стены тогда отскакивал бы на каждом кадре.

Форма трейта проверяется фикстурным вторым клиентом (TestClient, тесты в vimp-engine-core/src/client/game.rs) ещё до появления настоящей второй игры — именно это гарантирует, что трейты остаются игро-независимыми.

Отладка: debug_json и детектор рассинхрона ​

Два отладочных механизма живут в движковом crate и экспортируются ABI-макросами, поэтому достаются каждой игре даром и не трогают ENGINE_API_VERSION. Контур, который их потребляет, целиком описан в debugging.md.

  • debug.rs — EngineSim::debug_json() (экспортируется как GameCore.debug_json()): курированный дамп мира рядом с сырым выводом serialize_state(), который нечитаем. Тела (tag, userData, translation, rotation, linvel, angvel, mass, bodyType, ccd), коллайдеры (shape + halfExtents/radius, isSensor, группы столкновений/солвера в hex, parent), карта (setId, шаг, сетка, счётчики статики/динамики, респауны), нав-граф (узлы/рёбра/шаг), spatial-сетка (размер ячейки, заполненность), rng.state и аккумулятор фикс-шага. Порядок записей детерминирован — два дампа сравниваются дифом. ClientState::debug_json() — зеркало на клиенте: глубина буфера интерполятора, окно seq, offset, seq/serverTime последнего кадра.
  • client/divergence.rs — детектор рассинхрона предикта. Непосредственно перед тем, как on_server_state затрёт предикт, ClientState сравнивает предсказанное состояние с авторитетным player-блоком и кладёт запись, если хоть один компонент вышел за свой порог. Уровню 0 от игры не нужно ничего (камера render_overlay() против x/y кадра); уровень 1 использует опциональные GameClientDef::predicted_state() (покомпонентно) и replayed_inputs() (окно переигранного ввода), оба с дефолтом None. Настройка — опциональная EngineClientConfig.divergence (thresholds позиционно, defaultThreshold, capacity кольцевого буфера, angles — компоненты-углы, чья разность приводится к (−π, π]); в боевом конфиге её нет, и путь кадра не меняется. ClientCore.take_divergence() вычерпывает буфер. Сопоставление — по времени кадра, а не по seq: реконсиляция переигрывает историю ввода от момента авторитетного состояния.

Блоки снапшота — декларативная схема ​

Раскладки фиксированных блоков — это схема, а не захардкоженные структуры: SnapshotConfig.keys сопоставляет каждому ключу BlockSchema ровно из четырёх полей — id (опкод блока в кадре), kind (BlockKind: форма строки, из неё и следуют ширины count/id и наличие null-маркера), class (hot — интерполируется / event — только кадром) и fields (у каждого тип f32/u8/u16/u32 и режим интерполяции lerp/lerpAngle/discrete). Префикс d у id блоков indexedNoNull8 — тоже не поле схемы, он захардкожен в распаковщиках. Упаковщик (snapshot.rs), распаковщик (client/unpack.rs), интерполятор и движковый hot-буфер — все интерпретаторы схемы; игровой crate лишь поставляет строки как плоский Vec<FieldValue>. Сама схема — игровые данные, поставляемые через HostPlugin.gameConfig.snapshot (см. plugin-api.md) — движковый бандл не несёт собственных snapshot-ключей. SNAPSHOT_FORMAT_VERSION (сейчас 5) — версия фрейминга движка; побайтовая совместимость между деплоями не требуется (хост и клиенты — один деплой, версия защищает только фрейминг внутри комнаты).

Тесты ​

Собственные unit-тесты движкового crate поставляются вместе с ним. Сценарии симуляции игры (вождение, оружие, боты, handoff и т.д.) тестируются в собственном crate игры через cargo test; сценарии целого матча — headless-раннером (npm run sim, см. debugging.md).


← Предыдущая: Схема снапшота и форматы передачи · Следующая: Мета-слой хоста →

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