vimp_engine_core/abi.rs
1//! `export_game_core_abi!` — генерирует `#[wasm_bindgen] impl` для игрового
2//! `GameCore`, снимая ~25-метод boilerplate, зафиксированный §3.4 PLAN.md.
3//! Все методы ниже — механические 1:1-делегации в уже generic
4//! `EngineSim<G>`/`SnapshotPacker`, чья сигнатура зафиксирована трейтом
5//! `crate::sim::GameSim<G>` независимо от конкретной игры — не дизайн под
6//! гипотетическую вторую игру, а извлечение уже существующего соответствия
7//! (см. PLAN_4_details.md §5).
8//!
9//! Контракт: тип-цель обязан иметь поля `state: vimp_engine_core::game::EngineSim<G>`
10//! (под именем `EngineSim<...>`, конкретный `G` — забота игрового crate) и
11//! `packer: vimp_engine_core::snapshot::SnapshotPacker`, и раскрываться в
12//! модуле, где уже подключён `wasm-bindgen` как зависимость (пути ниже
13//! квалифицированы через `::wasm_bindgen`, поэтому не зависят от локальных
14//! `use`, но зависят от наличия крейта в `Cargo.toml` вызывающей стороны).
15//! `new` (парсинг игрового конфига) и не-`#[wasm_bindgen]` тестовые
16//! аксессоры в макрос не входят — остаются рукописными в game-crate.
17#[macro_export]
18macro_rules! export_game_core_abi {
19 ($GameCoreTy:ty) => {
20 #[::wasm_bindgen::prelude::wasm_bindgen]
21 impl $GameCoreTy {
22 /// Загружает карту из JSON (см. scripts/export-maps.js).
23 pub fn load_map(&mut self, map_json: &str) -> Result<(), ::wasm_bindgen::JsError> {
24 self.state
25 .load_map(map_json)
26 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
27 }
28
29 /// Информация о загруженной карте: setId, масштабированные
30 /// респауны, размеры мира (JSON).
31 pub fn map_info(&self) -> String {
32 self.state.map_info_json()
33 }
34
35 // ***** участники ***** //
36
37 pub fn spawn_actor(
38 &mut self,
39 game_id: u32,
40 model: &str,
41 team_id: u8,
42 x: f32,
43 y: f32,
44 angle_deg: f32,
45 ) -> Result<(), ::wasm_bindgen::JsError> {
46 self.state
47 .spawn_actor(game_id, model, team_id, x, y, angle_deg)
48 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
49 }
50
51 pub fn remove_actor(&mut self, game_id: u32) {
52 self.state.remove_actor(game_id);
53 }
54
55 /// Респаун/смена команды (аналог Game.changePlayerData).
56 pub fn reset_actor(
57 &mut self,
58 game_id: u32,
59 team_id: u8,
60 x: f32,
61 y: f32,
62 angle_deg: f32,
63 ) {
64 self.state.reset_actor(game_id, team_id, x, y, angle_deg);
65 }
66
67 /// Сброс здоровья/боезапаса всех танков (аналог Panel.reset).
68 pub fn reset_all_vitals(&mut self) {
69 self.state.reset_all_vitals();
70 }
71
72 pub fn spawn_scripted_actor(
73 &mut self,
74 game_id: u32,
75 model: &str,
76 team_id: u8,
77 x: f32,
78 y: f32,
79 angle_deg: f32,
80 ) -> Result<(), ::wasm_bindgen::JsError> {
81 self.state
82 .spawn_scripted_actor(game_id, model, team_id, x, y, angle_deg)
83 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
84 }
85
86 pub fn remove_scripted_actor(&mut self, game_id: u32) {
87 self.state.remove_scripted_actor(game_id);
88 }
89
90 // ***** ввод и шаг ***** //
91
92 /// Ввод игрока: seq + action ('down'/'up') + имя клавиши
93 /// (wire-формат 'seq:action:name' разбирает JS-оболочка).
94 pub fn apply_input(&mut self, game_id: u32, seq: u32, action: &str, key_name: &str) {
95 self.state.apply_input(game_id, seq, action, key_name);
96 }
97
98 /// Шаг симуляции: фиксированные подшаги физики + ИИ ботов.
99 pub fn step(&mut self, dt: f32) {
100 self.state.step(dt);
101 }
102
103 /// События за тик (kill/health/ammo/weapon/shake) одной
104 /// JSON-строкой; буфер очищается.
105 pub fn take_events(&mut self) -> String {
106 self.state.take_events_json()
107 }
108
109 // ***** запросы состояния ***** //
110
111 pub fn last_input_seq(&self, game_id: u32) -> u32 {
112 self.state.last_input_seq(game_id)
113 }
114
115 pub fn is_alive(&self, game_id: u32) -> bool {
116 self.state.is_alive(game_id)
117 }
118
119 /// Координаты танка [x, y] (скруглены до 2 знаков) или пустой
120 /// массив.
121 pub fn position_of(&self, game_id: u32) -> Vec<f32> {
122 self.state
123 .actor_position(game_id)
124 .map(|p| p.to_vec())
125 .unwrap_or_default()
126 }
127
128 /// Полные данные всех игроков (Game.getPlayersData) одной
129 /// JSON-строкой для первого кадра (FIRST_SHOT_DATA). Не
130 /// дренирует накопители.
131 pub fn players_data(&self) -> String {
132 self.state.players_json()
133 }
134
135 /// Живые игроки плоским массивом [id, teamId, x, y, ...]
136 /// (аналог Game.getAlivePlayers для меты).
137 pub fn alive_players(&self) -> Vec<f32> {
138 self.state.alive_players_flat()
139 }
140
141 // ***** снапшот ***** //
142
143 /// Пакует broadcast-тело кадра, дренируя накопленные события
144 /// снапшота. Вызывать один раз на отправляемый кадр (throttle
145 /// частоты отправки — забота JS-оболочки).
146 pub fn pack_body(&mut self) -> Result<(), ::wasm_bindgen::JsError> {
147 let blocks = self.state.build_snapshot_blocks();
148
149 self.packer
150 .pack_body(&blocks)
151 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
152 }
153
154 /// Собирает per-user кадр v3 во внутренний буфер, возвращает
155 /// длину. Кадр читается zero-copy через frame_ptr() + память
156 /// WASM. player_id < 0 — кадр без player-блока (наблюдатель).
157 #[allow(clippy::too_many_arguments)]
158 pub fn pack_frame(
159 &mut self,
160 server_time: f64,
161 seq: u32,
162 has_camera: bool,
163 camera_x: f32,
164 camera_y: f32,
165 force_reset: bool,
166 shake: Option<String>,
167 player_id: i32,
168 ) -> usize {
169 let camera = has_camera.then_some($crate::snapshot::CameraData {
170 x: camera_x,
171 y: camera_y,
172 force_reset,
173 shake,
174 });
175
176 let player = if player_id >= 0 {
177 let game_id = player_id as u32;
178
179 self.state.prediction_state(game_id).map(|(state, centering)| {
180 $crate::snapshot::PlayerBlock {
181 game_id: game_id as u8,
182 input_seq: self.state.last_input_seq(game_id),
183 state,
184 centering,
185 }
186 })
187 } else {
188 None
189 };
190
191 self.packer
192 .pack_frame(server_time, seq, camera.as_ref(), player.as_ref())
193 .len()
194 }
195
196 /// Содержал ли последний `pack_body()` событийные блоки
197 /// (трассеры/бомбы/взрывы/удаления). JS-Worker вызывает после
198 /// `pack_body()` для выбора канала WebRTC: события → meta
199 /// (reliable), только позиции → state.
200 pub fn body_has_events(&self) -> bool {
201 self.state.body_has_events()
202 }
203
204 /// Указатель на буфер последнего кадра (zero-copy чтение из JS:
205 /// new Uint8Array(wasm.memory.buffer, ptr, len)).
206 pub fn frame_ptr(&self) -> *const u8 {
207 self.packer.frame_bytes().as_ptr()
208 }
209
210 /// Копия последнего кадра (nodejs-таргет не отдаёт память
211 /// наружу; горячий путь браузера использует frame_ptr + память
212 /// WASM).
213 pub fn frame_bytes(&self) -> Vec<u8> {
214 self.packer.frame_bytes().to_vec()
215 }
216
217 // ***** очистка и handoff ***** //
218
219 /// Удаляет игроков и снаряды, возвращает JSON-массив имён для
220 /// очистки полотна клиентов (Game.removePlayersAndShots).
221 pub fn remove_players_and_shots(&mut self) -> String {
222 ::serde_json::to_string(&self.state.remove_players_and_shots())
223 .unwrap_or_else(|_| "[]".to_string())
224 }
225
226 /// Полная очистка мира (смена карты).
227 pub fn clear(&mut self) {
228 self.state.clear();
229 }
230
231 /// Курированный дамп мира для отладки (тела, коллайдеры,
232 /// карта, нав-граф, spatial-сетка, rng, аккумулятор) —
233 /// читаемая альтернатива serialize_state.
234 pub fn debug_json(&self) -> String {
235 self.state.debug_json()
236 }
237
238 /// Дамп состояния симуляции (Worker Handoff, Этап 5.2).
239 pub fn serialize_state(&self) -> Result<Vec<u8>, ::wasm_bindgen::JsError> {
240 self.state
241 .serialize_state()
242 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
243 }
244
245 pub fn deserialize_state(&mut self, data: &[u8]) -> Result<(), ::wasm_bindgen::JsError> {
246 self.state
247 .deserialize_state(data)
248 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
249 }
250 }
251 };
252}
253
254/// `export_client_core_abi!` — генерирует `#[wasm_bindgen] impl` для
255/// клиентского `ClientCore`, зеркально `export_game_core_abi!` (см. выше).
256/// Методы ниже — движковый минимум (§3.4 PLAN.md): 1:1-делегации в generic
257/// `vimp_engine_core::client::game::ClientState<G>`, чья сигнатура
258/// зафиксирована трейтом `GameClientDef` независимо от конкретной игры.
259///
260/// Контракт: тип-цель обязан иметь поле
261/// `state: vimp_engine_core::client::game::ClientState<G>` (под именем
262/// `ClientState<...>`, конкретный `G: GameClientDef` — забота игрового
263/// crate), и раскрываться в модуле, где уже подключён `wasm-bindgen`.
264/// Игровые методы вне минимума (`set_model`, `try_fire`, `cycle_weapon`,
265/// `sync_panel` и т.п. — сигнатуры которых по форме зависят от игры)
266/// остаются рукописными в game-crate рядом с раскрытием макроса.
267#[macro_export]
268macro_rules! export_client_core_abi {
269 ($ClientCoreTy:ty) => {
270 #[::wasm_bindgen::prelude::wasm_bindgen]
271 impl $ClientCoreTy {
272 // ***** сеть ***** //
273
274 /// Бинарный кадр из транспорта: распаковка, вставка в буфер по
275 /// seq (+дедупликация/опоздавшие), reconciliation предикта по
276 /// player-блоку. false — кадр отброшен (чужой порт/версия/
277 /// повреждён).
278 pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
279 self.state.push_frame(data, local_now)
280 }
281
282 /// Свой gameId из последнего player-блока; -1, если ещё не
283 /// приходил.
284 pub fn my_game_id(&self) -> i32 {
285 self.state.my_game_id().map(|id| id as i32).unwrap_or(-1)
286 }
287
288 /// EMA-оценка (serverTime − localNow); NaN, если кадров ещё не
289 /// было. Это разница часов (`Date.now` хоста против
290 /// `performance.now` клиента), а **не** латентность: за оценку
291 /// RTT её принимать нельзя.
292 pub fn offset(&self) -> f64 {
293 self.state.offset().unwrap_or(f64::NAN)
294 }
295
296 // ***** рендер-тик ***** //
297
298 /// Весь рендер-тик: выдача пересечённых кадров (фильтр дублей
299 /// своих эффектов → JSON-очередь), интерполяция, шаг предикта,
300 /// запись hot-буфера. Возвращает длину hot-буфера в
301 /// f32-элементах.
302 pub fn sample(&mut self, local_now: f64) -> usize {
303 self.state.sample(local_now)
304 }
305
306 /// Указатель на hot-буфер (zero-copy чтение из JS:
307 /// new Float32Array(wasm.memory.buffer, ptr, len) — view
308 /// пересоздавать каждый тик, рост памяти WASM инвалидирует
309 /// buffer).
310 pub fn hot_ptr(&self) -> *const f32 {
311 self.state.hot().as_ptr()
312 }
313
314 /// Копия hot-буфера (nodejs-таргет; горячий путь браузера —
315 /// hot_ptr).
316 pub fn hot_values(&self) -> Vec<f32> {
317 self.state.hot().to_vec()
318 }
319
320 /// Событийные кадры JSON-строкой [{game, camera}, ...] в
321 /// форме, готовой для applyShot; вызывать при флаге hasFrames
322 /// hot-буфера, очередь очищается.
323 pub fn take_frames(&mut self) -> String {
324 self.state.take_frames()
325 }
326
327 // ***** ввод ***** //
328
329 /// Ввод игрока: action ('down'/'up') + имя клавиши — в историю
330 /// предикта.
331 pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
332 self.state.apply_input(action, key_name, local_now);
333 }
334
335 // ***** жизненный цикл ***** //
336
337 /// Смена режима игрок/спектатор (KEYSET_DATA).
338 pub fn set_active(&mut self, active: bool) {
339 self.state.set_active(active);
340 }
341
342 /// Данные карты (MAP_DATA): мир raycast + сброс буфера и
343 /// предикта.
344 pub fn set_map(&mut self, map_json: &str) -> Result<(), ::wasm_bindgen::JsError> {
345 self.state
346 .set_map(map_json)
347 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
348 }
349
350 /// Полный сброс (порт CLEAR).
351 pub fn reset(&mut self) {
352 self.state.reset();
353 }
354
355 /// Ресинк часов после долгой паузы вкладки: сброс сетевого
356 /// буфера и очереди кадров без обнуления предикта.
357 pub fn resync(&mut self) {
358 self.state.resync();
359 }
360
361 // ***** тесты и харнесс ***** //
362
363 /// Дамп клиентского состояния для отладки: сетевой буфер
364 /// (глубина, окно seq, оффсет, последний кадр), свой gameId,
365 /// размеры hot-буфера и очереди событийных кадров.
366 pub fn debug_json(&self) -> String {
367 self.state.debug_json()
368 }
369
370 /// Записи расхождения предикта с авторитетным состоянием
371 /// (JSON {samples, violations, dropped, maxDelta, records});
372 /// очередь очищается. 'null' — детектор выключен (конфиг без
373 /// секции divergence, боевой путь).
374 pub fn take_divergence(&mut self) -> String {
375 self.state.take_divergence()
376 }
377
378 /// Чистая распаковка кадра v3 → JSON {port, seq, serverTime,
379 /// camera, player, snapshot} (замена unpackFrame в тестах);
380 /// 'null' при несовпадении версии или повреждённом кадре.
381 pub fn decode_frame(&self, data: &[u8]) -> String {
382 self.state.decode_frame(data)
383 }
384 }
385 };
386}