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 и зависит от опубликованного движкового крейта:
[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.
// хост — 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). Больше предсказываемого состояния — либо выводится на клиенте из этих восьми значений, либо не предсказывается.
Паттерн предсказания
- Клиент применяет ввод локально через
ClientCore.apply_inputи на каждом тике рендера шагает свою копию модели движения (update). - Каждый кадр с player-блоком вызывает
on_server_state(...): клиент откатывается к авторитетному состоянию и проигрывает заново ввод новееinput_seq. render_overlay()возвращает предсказанный хвост, дописываемый в hot-буфер;None(нет локального актора или модели) заставляет движок вернуться к интерполированной камере и сбрасывает флагPREDICTED.- Игра, предсказывающая ещё и другие тела (динамика карты, акторы в контакте с локальным), читает их авторитетное состояние в
begin_reconcile(snapshot), даёт проигрышу их нести, сводит расхождение вfinish_reconcile()и возвращает их изrender_rows(); каждая строка дописывается после предсказанного хвоста и перекрывает интерполированную строку той же сущности.
Паритет движения — жёсткое требование: предсказанное клиентом движение и движение хоста в on_fixed_step обязаны давать одинаковый результат. Держите математику движения в одном общем модуле, используемом обеими половинами, и добавьте cargo-тест паритета, который шагает обе и сверяет равенство; перезапускайте его после любого изменения движения.
Предсказание выстрела и подавление дублей
Локально предсказанный выстрел иначе рисовался бы дважды — сразу и снова, когда придёт авторитетное событие.
try_actionсоздаёт локальный эффект с id видаL<n>и возвращает его spawn-JSON вClientPlugin.hooks.onLocalAction, который передаёт его рендереру.- Последнее поле каждого блока события оружия — game id автора.
filter_frame_gameпросматривает входящие строки событий и, когда id автора равен локальному игроку, отбрасывает строку (или заменяет локальный id).- Строка
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:
{
"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_on | 0 (без ограничения) |
comfort_clearance | мировые единицы | желательный запас от стен | 0 (без предпочтения) |
narrow_cost | доля длины ребра | добавка при нулевом запасе, линейно до 0 при comfort_clearance | 0 |
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 достаются меньшему индексу узла, поэтому одинаковые запросы всегда дают одинаковые маршруты.
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_frequency30 Гц,contact_damping_ratio5,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).
← Предыдущая: Схема снапшота и форматы передачи · Следующая: Мета-слой хоста →