create-vimp-game 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bin/create-vimp-game.js +15 -0
  2. package/package.json +40 -0
  3. package/src/cli.js +227 -0
  4. package/src/generator.js +196 -0
  5. package/src/preflight.js +48 -0
  6. package/src/prompts.js +79 -0
  7. package/src/tokens.js +99 -0
  8. package/src/ui.js +66 -0
  9. package/src/versions.generated.json +4 -0
  10. package/src/versions.js +87 -0
  11. package/templates/default/CLAUDE.md.tpl +51 -0
  12. package/templates/default/Cargo.toml.tpl +27 -0
  13. package/templates/default/LICENSE.tpl +21 -0
  14. package/templates/default/README.md.tpl +61 -0
  15. package/templates/default/_gitignore +8 -0
  16. package/templates/default/assets/audio-raw/death.wav +0 -0
  17. package/templates/default/assets/audio-raw/shot.wav +0 -0
  18. package/templates/default/assets/sounds/death.mp3 +0 -0
  19. package/templates/default/assets/sounds/death.webm +0 -0
  20. package/templates/default/assets/sounds/shot.mp3 +0 -0
  21. package/templates/default/assets/sounds/shot.webm +0 -0
  22. package/templates/default/core/Cargo.toml.tpl +19 -0
  23. package/templates/default/core/src/actor.rs +407 -0
  24. package/templates/default/core/src/body_tag.rs +56 -0
  25. package/templates/default/core/src/client/mod.rs +385 -0
  26. package/templates/default/core/src/client/predictor.rs +552 -0
  27. package/templates/default/core/src/config.rs +236 -0
  28. package/templates/default/core/src/game.rs +692 -0
  29. package/templates/default/core/src/lib.rs +118 -0
  30. package/templates/default/core/src/motion.rs +126 -0
  31. package/templates/default/core/tests/sim.rs +351 -0
  32. package/templates/default/dev/main.js +28 -0
  33. package/templates/default/eslint.config.js +84 -0
  34. package/templates/default/index.html.tpl +35 -0
  35. package/templates/default/package.json.tpl +48 -0
  36. package/templates/default/scripts/build-game-manifest.js +188 -0
  37. package/templates/default/scripts/copy-game-images.js +35 -0
  38. package/templates/default/scripts/copy-game-sounds.js +55 -0
  39. package/templates/default/scripts/export-maps.js +19 -0
  40. package/templates/default/scripts/lib/rangeToPattern.js +74 -0
  41. package/templates/default/scripts/process-audio.js +115 -0
  42. package/templates/default/src/client/bakers/actorTexture.js +40 -0
  43. package/templates/default/src/client/bakers/index.js +8 -0
  44. package/templates/default/src/client/index.js +67 -0
  45. package/templates/default/src/client/parts/Actor.js +69 -0
  46. package/templates/default/src/client/parts/Map.js +74 -0
  47. package/templates/default/src/client/parts/ShotEffect.js +107 -0
  48. package/templates/default/src/client/parts/index.js +13 -0
  49. package/templates/default/src/client/style.css +26 -0
  50. package/templates/default/src/config/auth.js +61 -0
  51. package/templates/default/src/config/client.js +195 -0
  52. package/templates/default/src/config/game.js +170 -0
  53. package/templates/default/src/config/snapshot.js +70 -0
  54. package/templates/default/src/config/sounds.js +17 -0
  55. package/templates/default/src/data/maps/arena.js +69 -0
  56. package/templates/default/src/data/maps/index.js +7 -0
  57. package/templates/default/src/data/models.js +39 -0
  58. package/templates/default/src/data/weapons.js +37 -0
  59. package/templates/default/src/host/ScriptedManager.js +142 -0
  60. package/templates/default/src/host/createModules.js +12 -0
  61. package/templates/default/src/host/index.js +56 -0
  62. package/templates/default/src/host/nodeCore.js +23 -0
  63. package/templates/default/src/host/spawnCommand.js +32 -0
  64. package/templates/default/src/host/systemMessages.js +10 -0
  65. package/templates/default/tests/client/parts.test.js +128 -0
  66. package/templates/default/tests/config/contract.test.js +132 -0
  67. package/templates/default/tests/config/game.test.js +45 -0
  68. package/templates/default/tests/core/nodeCore.test.js +61 -0
  69. package/templates/default/tests/host/hostPlugin.test.js +198 -0
  70. package/templates/default/tests/stubs/wasmCore.js +19 -0
  71. package/templates/default/vite.config.js +74 -0
  72. package/templates/default/vitest.config.js +55 -0
@@ -0,0 +1,692 @@
1
+ //! The authoritative simulation on top of the engine frame (`EngineSim`):
2
+ //! actors, bots, hitscan and the snapshot blocks. The engine owns the physics
3
+ //! world, the map, navigation, the RNG and the destroy queue and calls into
4
+ //! this file through `SimCtx` — see `docs/ai/05-wasm-core.md`.
5
+
6
+ use indexmap::IndexMap;
7
+ use rapier2d::prelude::*;
8
+ use serde::{Deserialize, Serialize};
9
+
10
+ use vimp_engine_core::config::{EngineConfig, FieldValue, PLAYER_STATE_LEN};
11
+ use vimp_engine_core::events::CoreEvent;
12
+ use vimp_engine_core::nav::spatial::{SpatialEntity, SpatialGrid};
13
+ use vimp_engine_core::physics::{round1, round2};
14
+ use vimp_engine_core::rng::Rng;
15
+ use vimp_engine_core::sim::{GameDef, GameSim, SimCtx};
16
+ use vimp_engine_core::snapshot::Block;
17
+
18
+ use crate::actor::{Actor, PlayerKeyBits, ShotCommand};
19
+ use crate::body_tag::BodyTag;
20
+ use crate::config::{ActorConfig, ArenaConfig, KeyConfig, PanelValue, WeaponConfig};
21
+
22
+ /// Marker type binding the config and the simulation together.
23
+ pub struct ArenaGame;
24
+
25
+ impl GameDef for ArenaGame {
26
+ type Config = ArenaConfig;
27
+ type Sim = ArenaSim;
28
+ }
29
+
30
+ /// The engine frame parametrised by this game — the type the ABI macro and
31
+ /// the tests work with.
32
+ pub type GameState = vimp_engine_core::game::EngineSim<ArenaGame>;
33
+
34
+ /// Row of the actor block (`Indexed8`): x, y, angle, vx, vy + health + team.
35
+ /// The order is positional — it must match the `fields` of the actor key in
36
+ /// `src/config/snapshot.js`.
37
+ #[derive(Clone, Copy)]
38
+ struct ActorRow {
39
+ floats: [f32; 5],
40
+ health: u8,
41
+ team: u8,
42
+ }
43
+
44
+ impl ActorRow {
45
+ fn fields(&self) -> Vec<FieldValue> {
46
+ let mut fields: Vec<FieldValue> =
47
+ self.floats.iter().copied().map(FieldValue::F32).collect();
48
+
49
+ fields.push(FieldValue::U8(self.health));
50
+ fields.push(FieldValue::U8(self.team));
51
+ fields
52
+ }
53
+ }
54
+
55
+ /// Row of the tracer block (`List16`): the ray plus whether it hit. The LAST
56
+ /// field is the author's game id — the client drops its own rows by it, so a
57
+ /// locally predicted shot is not drawn twice.
58
+ struct TracerRow {
59
+ floats: [f32; 4],
60
+ was_hit: bool,
61
+ shooter: u8,
62
+ }
63
+
64
+ impl TracerRow {
65
+ fn fields(&self) -> Vec<FieldValue> {
66
+ let mut fields: Vec<FieldValue> =
67
+ self.floats.iter().copied().map(FieldValue::F32).collect();
68
+
69
+ fields.push(FieldValue::U8(self.was_hit as u8));
70
+ fields.push(FieldValue::U8(self.shooter));
71
+ fields
72
+ }
73
+ }
74
+
75
+ /// Scripted actor: the whole AI of the template. It holds one key mask for a
76
+ /// while, then rolls a new one — enough to make a match with bots watchable
77
+ /// and to prove the `spawn_scripted_actor` / `on_ai_tick` path works.
78
+ #[derive(Serialize, Deserialize)]
79
+ struct Bot {
80
+ keys: u32,
81
+ /// Seconds left before the next decision.
82
+ timer: f32,
83
+ }
84
+
85
+ pub struct ArenaSim {
86
+ key_bits: PlayerKeyBits,
87
+ player_keys: IndexMap<String, KeyConfig>,
88
+ friendly_fire: bool,
89
+ models: IndexMap<String, ActorConfig>,
90
+ weapons: IndexMap<String, WeaponConfig>,
91
+ panel: IndexMap<String, PanelValue>,
92
+
93
+ actors: IndexMap<u32, Actor>,
94
+ bots: IndexMap<u32, Bot>,
95
+
96
+ // snapshot accumulators, drained by build_snapshot_blocks
97
+ new_tracers: IndexMap<usize, Vec<TracerRow>>,
98
+ pending_null_actors: Vec<(String, u32)>,
99
+ cached_actors: IndexMap<u32, (String, ActorRow)>,
100
+ }
101
+
102
+ impl GameSim<ArenaGame> for ArenaSim {
103
+ fn new(cfg: &ArenaConfig, _engine_cfg: &EngineConfig) -> Self {
104
+ Self {
105
+ key_bits: PlayerKeyBits::from_config(&cfg.player_keys),
106
+ player_keys: cfg.player_keys.clone(),
107
+ friendly_fire: cfg.friendly_fire,
108
+ models: cfg.models.clone(),
109
+ weapons: cfg.weapons.clone(),
110
+ panel: cfg.panel.clone(),
111
+ actors: IndexMap::new(),
112
+ bots: IndexMap::new(),
113
+ new_tracers: IndexMap::new(),
114
+ pending_null_actors: Vec::new(),
115
+ cached_actors: IndexMap::new(),
116
+ }
117
+ }
118
+
119
+ fn spawn_actor(
120
+ &mut self,
121
+ world: &mut PhysicsWorld,
122
+ events: &mut Vec<CoreEvent>,
123
+ game_id: u32,
124
+ model_name: &str,
125
+ team_id: u8,
126
+ x: f32,
127
+ y: f32,
128
+ angle_deg: f32,
129
+ ) -> Result<(), String> {
130
+ let model = self
131
+ .models
132
+ .get(model_name)
133
+ .ok_or_else(|| format!("unknown model '{model_name}'"))?
134
+ .clone();
135
+
136
+ let actor = Actor::new(
137
+ world,
138
+ &self.weapons,
139
+ &self.panel,
140
+ model_name,
141
+ &model,
142
+ game_id,
143
+ team_id,
144
+ x,
145
+ y,
146
+ angle_deg,
147
+ );
148
+
149
+ events.push(CoreEvent::PanelActive {
150
+ id: game_id,
151
+ field: self
152
+ .weapons
153
+ .get_index(actor.current_weapon)
154
+ .map(|(name, _)| name.clone())
155
+ .unwrap_or_default(),
156
+ });
157
+ events.push(CoreEvent::PanelSet {
158
+ id: game_id,
159
+ field: "health".to_string(),
160
+ value: actor.health,
161
+ });
162
+
163
+ self.actors.insert(game_id, actor);
164
+
165
+ Ok(())
166
+ }
167
+
168
+ fn remove_actor(&mut self, world: &mut PhysicsWorld, game_id: u32) {
169
+ if let Some(actor) = self.actors.shift_remove(&game_id) {
170
+ world.remove_body(actor.body);
171
+ self.cached_actors.shift_remove(&game_id);
172
+ self.pending_null_actors.push((actor.model, game_id));
173
+ }
174
+ }
175
+
176
+ fn reset_actor(
177
+ &mut self,
178
+ world: &mut PhysicsWorld,
179
+ game_id: u32,
180
+ team_id: u8,
181
+ x: f32,
182
+ y: f32,
183
+ angle_deg: f32,
184
+ ) {
185
+ let Some(actor) = self.actors.get_mut(&game_id) else {
186
+ return;
187
+ };
188
+ let Some(body) = world.bodies.get_mut(actor.body) else {
189
+ return;
190
+ };
191
+
192
+ actor.change_player_data(team_id, x, y, angle_deg, body);
193
+ }
194
+
195
+ fn reset_all_vitals(&mut self, events: &mut Vec<CoreEvent>) {
196
+ for actor in self.actors.values_mut() {
197
+ actor.reset_vitals(&self.panel, &self.weapons, events);
198
+ }
199
+ }
200
+
201
+ fn spawn_scripted_actor(
202
+ &mut self,
203
+ world: &mut PhysicsWorld,
204
+ rng: &mut Rng,
205
+ events: &mut Vec<CoreEvent>,
206
+ game_id: u32,
207
+ model_name: &str,
208
+ team_id: u8,
209
+ x: f32,
210
+ y: f32,
211
+ angle_deg: f32,
212
+ ) -> Result<(), String> {
213
+ self.spawn_actor(world, events, game_id, model_name, team_id, x, y, angle_deg)?;
214
+
215
+ self.bots.entry(game_id).or_insert(Bot {
216
+ keys: self.key_bits.forward,
217
+ timer: rng.range(0.2, 1.0),
218
+ });
219
+
220
+ Ok(())
221
+ }
222
+
223
+ fn remove_scripted_actor(&mut self, world: &mut PhysicsWorld, game_id: u32) {
224
+ self.bots.shift_remove(&game_id);
225
+ self.remove_actor(world, game_id);
226
+ }
227
+
228
+ fn apply_input(&mut self, game_id: u32, seq: u32, action: &str, key_name: &str) {
229
+ let bit = self.player_keys.get(key_name).map(|k| k.key).unwrap_or(0);
230
+
231
+ if let Some(actor) = self.actors.get_mut(&game_id) {
232
+ actor.last_input_seq = seq;
233
+ actor.update_keys(action, bit, &self.key_bits);
234
+ }
235
+ }
236
+
237
+ fn last_input_seq(&self, game_id: u32) -> u32 {
238
+ self.actors
239
+ .get(&game_id)
240
+ .map(|actor| actor.last_input_seq)
241
+ .unwrap_or(0)
242
+ }
243
+
244
+ fn is_alive(&self, game_id: u32) -> bool {
245
+ self.actors
246
+ .get(&game_id)
247
+ .is_some_and(|actor| actor.is_alive())
248
+ }
249
+
250
+ fn actor_position(&self, world: &PhysicsWorld, game_id: u32) -> Option<[f32; 2]> {
251
+ let actor = self.actors.get(&game_id)?;
252
+ let body = world.bodies.get(actor.body)?;
253
+ let pos = body.translation();
254
+
255
+ Some([round2(pos.x), round2(pos.y)])
256
+ }
257
+
258
+ fn prediction_state(
259
+ &self,
260
+ world: &PhysicsWorld,
261
+ game_id: u32,
262
+ ) -> Option<([f32; PLAYER_STATE_LEN], bool)> {
263
+ let actor = self.actors.get(&game_id)?;
264
+ let body = world.bodies.get(actor.body)?;
265
+
266
+ Some(actor.prediction_state(body))
267
+ }
268
+
269
+ fn alive_players_flat(&self, world: &PhysicsWorld) -> Vec<f32> {
270
+ let mut out = Vec::new();
271
+
272
+ for (id, actor) in &self.actors {
273
+ if !actor.is_alive() {
274
+ continue;
275
+ }
276
+
277
+ let Some(body) = world.bodies.get(actor.body) else {
278
+ continue;
279
+ };
280
+ let pos = body.translation();
281
+
282
+ out.push(*id as f32);
283
+ out.push(actor.team_id as f32);
284
+ out.push(round2(pos.x));
285
+ out.push(round2(pos.y));
286
+ }
287
+
288
+ out
289
+ }
290
+
291
+ fn players_json(&self) -> String {
292
+ use serde_json::{Map, Value};
293
+
294
+ let mut by_model: Map<String, Value> = Map::new();
295
+
296
+ for (game_id, (model, row)) in &self.cached_actors {
297
+ let mut arr: Vec<Value> = Vec::with_capacity(7);
298
+
299
+ for value in row.floats {
300
+ arr.push(Value::from(value as f64));
301
+ }
302
+
303
+ arr.push(Value::from(row.health));
304
+ arr.push(Value::from(row.team));
305
+
306
+ by_model
307
+ .entry(model.clone())
308
+ .or_insert_with(|| Value::Object(Map::new()))
309
+ .as_object_mut()
310
+ .unwrap()
311
+ .insert(game_id.to_string(), Value::Array(arr));
312
+ }
313
+
314
+ Value::Object(by_model).to_string()
315
+ }
316
+
317
+ fn on_fixed_step(&mut self, ctx: &mut SimCtx, dt: f32) {
318
+ let ids: Vec<u32> = self.actors.keys().copied().collect();
319
+
320
+ for id in ids {
321
+ let shot;
322
+
323
+ {
324
+ let Some(actor) = self.actors.get_mut(&id) else {
325
+ continue;
326
+ };
327
+
328
+ if !actor.is_alive() {
329
+ continue;
330
+ }
331
+
332
+ let Some(body) = ctx.world.bodies.get_mut(actor.body) else {
333
+ continue;
334
+ };
335
+ let Some(model) = self.models.get(&actor.model) else {
336
+ continue;
337
+ };
338
+
339
+ shot = actor.update(
340
+ dt,
341
+ body,
342
+ model,
343
+ &self.weapons,
344
+ &self.key_bits,
345
+ ctx.rng,
346
+ ctx.events,
347
+ );
348
+ }
349
+
350
+ if let Some(shot) = shot {
351
+ let weapon_index = self.actors[&id].current_weapon;
352
+ let tracer = self.process_hitscan(ctx, id, weapon_index, &shot);
353
+
354
+ self.new_tracers
355
+ .entry(weapon_index)
356
+ .or_default()
357
+ .push(tracer);
358
+ }
359
+ }
360
+ }
361
+
362
+ /// The one weapon of the template is hitscan, so nothing of this game
363
+ /// reaches the world through contacts. A projectile weapon would decode
364
+ /// the body tags of the pair here and apply its damage.
365
+ fn on_contacts(&mut self, _ctx: &mut SimCtx, _pairs: &[(ColliderHandle, ColliderHandle)]) {}
366
+
367
+ /// Same: no game body is ever queued for destruction by this game.
368
+ fn on_before_destroy(&mut self, _world: &PhysicsWorld, _handle: RigidBodyHandle) {}
369
+
370
+ fn on_ai_tick(&mut self, ctx: &mut SimCtx, dt: f32) {
371
+ if self.bots.is_empty() {
372
+ return;
373
+ }
374
+
375
+ let ids: Vec<u32> = self.bots.keys().copied().collect();
376
+
377
+ for id in ids {
378
+ let alive = self.is_alive(id);
379
+ let Some(bot) = self.bots.get_mut(&id) else {
380
+ continue;
381
+ };
382
+
383
+ bot.timer -= dt;
384
+
385
+ if bot.timer > 0.0 {
386
+ continue;
387
+ }
388
+
389
+ // every decision goes through the engine Rng: two runs with one
390
+ // seed must produce the very same match
391
+ bot.timer = ctx.rng.range(0.4, 1.2);
392
+
393
+ let turn = ctx.rng.next_f32();
394
+ let mut keys = self.key_bits.forward;
395
+
396
+ if turn < 0.35 {
397
+ keys |= self.key_bits.left;
398
+ } else if turn < 0.7 {
399
+ keys |= self.key_bits.right;
400
+ }
401
+
402
+ bot.keys = keys;
403
+
404
+ let shoots = ctx.rng.next_f32() < 0.5;
405
+
406
+ if !alive {
407
+ continue;
408
+ }
409
+
410
+ if let Some(actor) = self.actors.get_mut(&id) {
411
+ actor.set_held_keys(keys);
412
+
413
+ if shoots {
414
+ actor.press_once(self.key_bits.fire);
415
+ }
416
+ }
417
+ }
418
+
419
+ self.rebuild_spatial_grid(ctx.world, ctx.spatial);
420
+ }
421
+
422
+ fn refresh_cached(&mut self, world: &PhysicsWorld) {
423
+ for (game_id, actor) in &self.actors {
424
+ let Some(body) = world.bodies.get(actor.body) else {
425
+ continue;
426
+ };
427
+
428
+ if !actor.is_alive() {
429
+ // a dead actor leaves the canvas: the null row below removes
430
+ // it on every client until the engine respawns it
431
+ if self.cached_actors.shift_remove(game_id).is_some() {
432
+ self.pending_null_actors
433
+ .push((actor.model.clone(), *game_id));
434
+ }
435
+
436
+ continue;
437
+ }
438
+
439
+ let (floats, health, team) = actor.snapshot_row(body);
440
+
441
+ self.cached_actors.insert(
442
+ *game_id,
443
+ (actor.model.clone(), ActorRow { floats, health, team }),
444
+ );
445
+ }
446
+ }
447
+
448
+ fn build_snapshot_blocks(&mut self) -> (Vec<(String, Block)>, bool) {
449
+ let mut blocks: Vec<(String, Block)> = Vec::new();
450
+ // a removal row is an event: the frame carrying it must go over the
451
+ // reliable channel, or a client can miss the removal forever
452
+ let mut had_events = !self.pending_null_actors.is_empty();
453
+
454
+ // Indexed8 addresses rows by a single byte, so `as u8` here truncates
455
+ // silently. The invariant that makes it safe: the engine's
456
+ // ParticipantManager hands out the lowest free game id, so ids stay
457
+ // below the participant count — assert it instead of trusting it.
458
+ let mut by_model: IndexMap<String, Vec<(u8, Option<ActorRow>)>> = IndexMap::new();
459
+
460
+ for (game_id, (model, row)) in &self.cached_actors {
461
+ debug_assert!(*game_id < 256, "Indexed8 block: game_id must fit in u8");
462
+ by_model
463
+ .entry(model.clone())
464
+ .or_default()
465
+ .push((*game_id as u8, Some(*row)));
466
+ }
467
+
468
+ for (model, game_id) in self.pending_null_actors.drain(..) {
469
+ debug_assert!(game_id < 256, "Indexed8 block: game_id must fit in u8");
470
+ by_model
471
+ .entry(model)
472
+ .or_default()
473
+ .push((game_id as u8, None));
474
+ }
475
+
476
+ for (model, rows) in by_model {
477
+ let rows = rows
478
+ .into_iter()
479
+ .map(|(id, row)| (id, row.map(|r| r.fields())))
480
+ .collect();
481
+
482
+ blocks.push((model, Block::Indexed8(rows)));
483
+ }
484
+
485
+ for (weapon_index, tracers) in self.new_tracers.drain(..) {
486
+ if tracers.is_empty() {
487
+ continue;
488
+ }
489
+
490
+ had_events = true;
491
+
492
+ let name = self.weapons.get_index(weapon_index).unwrap().0.clone();
493
+ let rows = tracers.iter().map(TracerRow::fields).collect();
494
+
495
+ blocks.push((name, Block::List16(rows)));
496
+ }
497
+
498
+ (blocks, had_events)
499
+ }
500
+
501
+ fn remove_players_and_shots(&mut self, world: &mut PhysicsWorld) -> Vec<String> {
502
+ let actors: Vec<Actor> = self.actors.drain(..).map(|(_, actor)| actor).collect();
503
+
504
+ for actor in actors {
505
+ world.remove_body(actor.body);
506
+ }
507
+
508
+ self.cached_actors.clear();
509
+ self.pending_null_actors.clear();
510
+ self.new_tracers.clear();
511
+
512
+ // every configured key, not only the ones in use: right after a map
513
+ // change nobody is alive, and a partial CLEAR would leave stale
514
+ // sprites on the clients
515
+ let mut names: Vec<String> = self.models.keys().cloned().collect();
516
+
517
+ for name in self.weapons.keys() {
518
+ names.push(name.clone());
519
+ }
520
+
521
+ names
522
+ }
523
+
524
+ fn clear(&mut self) {
525
+ self.actors.clear();
526
+ self.bots.clear();
527
+ self.new_tracers.clear();
528
+ self.pending_null_actors.clear();
529
+ self.cached_actors.clear();
530
+ }
531
+
532
+ fn serialize(&self) -> serde_json::Value {
533
+ let dump = ArenaDump {
534
+ actors: &self.actors,
535
+ bots: &self.bots,
536
+ };
537
+
538
+ serde_json::to_value(dump).unwrap_or(serde_json::Value::Null)
539
+ }
540
+
541
+ fn deserialize(&mut self, value: serde_json::Value) -> Result<(), String> {
542
+ let dump: ArenaDumpOwned = serde_json::from_value(value).map_err(|e| e.to_string())?;
543
+
544
+ self.actors = dump.actors;
545
+ self.bots = dump.bots;
546
+
547
+ self.new_tracers.clear();
548
+ self.pending_null_actors.clear();
549
+ self.cached_actors.clear();
550
+
551
+ Ok(())
552
+ }
553
+
554
+ fn rebuild_spatial_grid(&self, world: &PhysicsWorld, spatial: &mut SpatialGrid) {
555
+ spatial.clear();
556
+
557
+ for (game_id, actor) in &self.actors {
558
+ if !actor.is_alive() {
559
+ continue;
560
+ }
561
+
562
+ if let Some(body) = world.bodies.get(actor.body) {
563
+ let pos = body.translation();
564
+
565
+ spatial.insert(SpatialEntity {
566
+ game_id: *game_id,
567
+ team_id: actor.team_id,
568
+ x: round2(pos.x),
569
+ y: round2(pos.y),
570
+ });
571
+ }
572
+ }
573
+ }
574
+ }
575
+
576
+ impl ArenaSim {
577
+ /// Instant ray shot: cast, damage, tracer row for the clients.
578
+ fn process_hitscan(
579
+ &mut self,
580
+ ctx: &mut SimCtx,
581
+ shooter_id: u32,
582
+ weapon_index: usize,
583
+ shot: &ShotCommand,
584
+ ) -> TracerRow {
585
+ let range = self.weapons[weapon_index].range;
586
+ let ray_vector = shot.direction * range;
587
+ let ray = Ray::new(shot.start, ray_vector);
588
+ let shooter_body = self.actors[&shooter_id].body;
589
+
590
+ let hit = ctx.world.cast_ray(
591
+ &ray,
592
+ 1.0,
593
+ true,
594
+ QueryFilter::new()
595
+ .exclude_sensors()
596
+ .exclude_rigid_body(shooter_body),
597
+ );
598
+
599
+ let end = shot.start + ray_vector;
600
+ let mut end_x = round1(end.x);
601
+ let mut end_y = round1(end.y);
602
+
603
+ if let Some((collider_handle, toi)) = hit {
604
+ let impact = ray.point_at(toi);
605
+
606
+ end_x = round1(impact.x);
607
+ end_y = round1(impact.y);
608
+
609
+ let target = ctx
610
+ .world
611
+ .colliders
612
+ .get(collider_handle)
613
+ .and_then(|collider| collider.parent())
614
+ .and_then(|handle| ctx.world.bodies.get(handle))
615
+ .and_then(|body| BodyTag::decode(body.user_data));
616
+
617
+ if let Some(BodyTag::Player { game_id, .. }) = target {
618
+ self.apply_damage(ctx, game_id, shooter_id, weapon_index);
619
+ }
620
+ }
621
+
622
+ TracerRow {
623
+ floats: [round2(shot.start.x), round2(shot.start.y), end_x, end_y],
624
+ was_hit: hit.is_some(),
625
+ shooter: shooter_id as u8,
626
+ }
627
+ }
628
+
629
+ /// Damage with friendly fire, camera shake and the kill event that feeds
630
+ /// the engine scoring (`RoundManager.reportKill`).
631
+ fn apply_damage(
632
+ &mut self,
633
+ ctx: &mut SimCtx,
634
+ target_id: u32,
635
+ shooter_id: u32,
636
+ weapon_index: usize,
637
+ ) {
638
+ if !self.is_alive(target_id) {
639
+ return;
640
+ }
641
+
642
+ let target_team = self.actors[&target_id].team_id;
643
+ let shooter_team = self.actors.get(&shooter_id).map(|actor| actor.team_id);
644
+
645
+ if !self.friendly_fire && shooter_team == Some(target_team) {
646
+ return;
647
+ }
648
+
649
+ let weapon = &self.weapons[weapon_index];
650
+
651
+ if let Some(shake) = &weapon.camera_shake {
652
+ ctx.events.push(CoreEvent::Shake {
653
+ id: target_id,
654
+ intensity: shake.intensity,
655
+ duration: shake.duration,
656
+ });
657
+ }
658
+
659
+ let damage = weapon.damage;
660
+
661
+ let destroyed = {
662
+ let actor = self.actors.get_mut(&target_id).unwrap();
663
+ let Some(body) = ctx.world.bodies.get_mut(actor.body) else {
664
+ return;
665
+ };
666
+
667
+ actor.take_damage(damage, body, ctx.events)
668
+ };
669
+
670
+ if destroyed {
671
+ // CoreEvent::Custom is the extension point for anything outside
672
+ // this vocabulary — the engine routes it to HostPlugin.onCoreEvent
673
+ // and does not interpret it (see docs/ai/03-host-plugin.md)
674
+ ctx.events.push(CoreEvent::Death {
675
+ victim: target_id,
676
+ killer: shooter_id,
677
+ });
678
+ }
679
+ }
680
+ }
681
+
682
+ #[derive(Serialize)]
683
+ struct ArenaDump<'a> {
684
+ actors: &'a IndexMap<u32, Actor>,
685
+ bots: &'a IndexMap<u32, Bot>,
686
+ }
687
+
688
+ #[derive(Deserialize)]
689
+ struct ArenaDumpOwned {
690
+ actors: IndexMap<u32, Actor>,
691
+ bots: IndexMap<u32, Bot>,
692
+ }