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 /// Дамп состояния симуляции (Worker Handoff, Этап 5.2).
232 pub fn serialize_state(&self) -> Result<Vec<u8>, ::wasm_bindgen::JsError> {
233 self.state
234 .serialize_state()
235 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
236 }
237
238 pub fn deserialize_state(&mut self, data: &[u8]) -> Result<(), ::wasm_bindgen::JsError> {
239 self.state
240 .deserialize_state(data)
241 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
242 }
243 }
244 };
245}
246
247/// `export_client_core_abi!` — генерирует `#[wasm_bindgen] impl` для
248/// клиентского `ClientCore`, зеркально `export_game_core_abi!` (см. выше).
249/// Методы ниже — движковый минимум (§3.4 PLAN.md): 1:1-делегации в generic
250/// `vimp_engine_core::client::game::ClientState<G>`, чья сигнатура
251/// зафиксирована трейтом `GameClientDef` независимо от конкретной игры.
252///
253/// Контракт: тип-цель обязан иметь поле
254/// `state: vimp_engine_core::client::game::ClientState<G>` (под именем
255/// `ClientState<...>`, конкретный `G: GameClientDef` — забота игрового
256/// crate), и раскрываться в модуле, где уже подключён `wasm-bindgen`.
257/// Игровые методы вне минимума (`set_model`, `try_fire`, `cycle_weapon`,
258/// `sync_panel` и т.п. — сигнатуры которых по форме зависят от игры)
259/// остаются рукописными в game-crate рядом с раскрытием макроса.
260#[macro_export]
261macro_rules! export_client_core_abi {
262 ($ClientCoreTy:ty) => {
263 #[::wasm_bindgen::prelude::wasm_bindgen]
264 impl $ClientCoreTy {
265 // ***** сеть ***** //
266
267 /// Бинарный кадр из транспорта: распаковка, вставка в буфер по
268 /// seq (+дедупликация/опоздавшие), reconciliation предикта по
269 /// player-блоку. false — кадр отброшен (чужой порт/версия/
270 /// повреждён).
271 pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
272 self.state.push_frame(data, local_now)
273 }
274
275 /// Свой gameId из последнего player-блока; -1, если ещё не
276 /// приходил.
277 pub fn my_game_id(&self) -> i32 {
278 self.state.my_game_id().map(|id| id as i32).unwrap_or(-1)
279 }
280
281 /// EMA-оценка (serverTime − localNow); NaN, если кадров ещё не
282 /// было.
283 pub fn offset(&self) -> f64 {
284 self.state.offset().unwrap_or(f64::NAN)
285 }
286
287 // ***** рендер-тик ***** //
288
289 /// Весь рендер-тик: выдача пересечённых кадров (фильтр дублей
290 /// своих эффектов → JSON-очередь), интерполяция, шаг предикта,
291 /// запись hot-буфера. Возвращает длину hot-буфера в
292 /// f32-элементах.
293 pub fn sample(&mut self, local_now: f64) -> usize {
294 self.state.sample(local_now)
295 }
296
297 /// Указатель на hot-буфер (zero-copy чтение из JS:
298 /// new Float32Array(wasm.memory.buffer, ptr, len) — view
299 /// пересоздавать каждый тик, рост памяти WASM инвалидирует
300 /// buffer).
301 pub fn hot_ptr(&self) -> *const f32 {
302 self.state.hot().as_ptr()
303 }
304
305 /// Копия hot-буфера (nodejs-таргет; горячий путь браузера —
306 /// hot_ptr).
307 pub fn hot_values(&self) -> Vec<f32> {
308 self.state.hot().to_vec()
309 }
310
311 /// Событийные кадры JSON-строкой [{game, camera}, ...] в
312 /// форме, готовой для applyShot; вызывать при флаге hasFrames
313 /// hot-буфера, очередь очищается.
314 pub fn take_frames(&mut self) -> String {
315 self.state.take_frames()
316 }
317
318 // ***** ввод ***** //
319
320 /// Ввод игрока: action ('down'/'up') + имя клавиши — в историю
321 /// предикта.
322 pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
323 self.state.apply_input(action, key_name, local_now);
324 }
325
326 // ***** жизненный цикл ***** //
327
328 /// Смена режима игрок/спектатор (KEYSET_DATA).
329 pub fn set_active(&mut self, active: bool) {
330 self.state.set_active(active);
331 }
332
333 /// Данные карты (MAP_DATA): мир raycast + сброс буфера и
334 /// предикта.
335 pub fn set_map(&mut self, map_json: &str) -> Result<(), ::wasm_bindgen::JsError> {
336 self.state
337 .set_map(map_json)
338 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
339 }
340
341 /// Полный сброс (порт CLEAR).
342 pub fn reset(&mut self) {
343 self.state.reset();
344 }
345
346 // ***** тесты и харнесс ***** //
347
348 /// Чистая распаковка кадра v3 → JSON {port, seq, serverTime,
349 /// camera, player, snapshot} (замена unpackFrame в тестах);
350 /// 'null' при несовпадении версии или повреждённом кадре.
351 pub fn decode_frame(&self, data: &[u8]) -> String {
352 self.state.decode_frame(data)
353 }
354 }
355 };
356}