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.
- package/bin/create-vimp-game.js +15 -0
- package/package.json +40 -0
- package/src/cli.js +227 -0
- package/src/generator.js +196 -0
- package/src/preflight.js +48 -0
- package/src/prompts.js +79 -0
- package/src/tokens.js +99 -0
- package/src/ui.js +66 -0
- package/src/versions.generated.json +4 -0
- package/src/versions.js +87 -0
- package/templates/default/CLAUDE.md.tpl +51 -0
- package/templates/default/Cargo.toml.tpl +27 -0
- package/templates/default/LICENSE.tpl +21 -0
- package/templates/default/README.md.tpl +61 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/assets/audio-raw/death.wav +0 -0
- package/templates/default/assets/audio-raw/shot.wav +0 -0
- package/templates/default/assets/sounds/death.mp3 +0 -0
- package/templates/default/assets/sounds/death.webm +0 -0
- package/templates/default/assets/sounds/shot.mp3 +0 -0
- package/templates/default/assets/sounds/shot.webm +0 -0
- package/templates/default/core/Cargo.toml.tpl +19 -0
- package/templates/default/core/src/actor.rs +407 -0
- package/templates/default/core/src/body_tag.rs +56 -0
- package/templates/default/core/src/client/mod.rs +385 -0
- package/templates/default/core/src/client/predictor.rs +552 -0
- package/templates/default/core/src/config.rs +236 -0
- package/templates/default/core/src/game.rs +692 -0
- package/templates/default/core/src/lib.rs +118 -0
- package/templates/default/core/src/motion.rs +126 -0
- package/templates/default/core/tests/sim.rs +351 -0
- package/templates/default/dev/main.js +28 -0
- package/templates/default/eslint.config.js +84 -0
- package/templates/default/index.html.tpl +35 -0
- package/templates/default/package.json.tpl +48 -0
- package/templates/default/scripts/build-game-manifest.js +188 -0
- package/templates/default/scripts/copy-game-images.js +35 -0
- package/templates/default/scripts/copy-game-sounds.js +55 -0
- package/templates/default/scripts/export-maps.js +19 -0
- package/templates/default/scripts/lib/rangeToPattern.js +74 -0
- package/templates/default/scripts/process-audio.js +115 -0
- package/templates/default/src/client/bakers/actorTexture.js +40 -0
- package/templates/default/src/client/bakers/index.js +8 -0
- package/templates/default/src/client/index.js +67 -0
- package/templates/default/src/client/parts/Actor.js +69 -0
- package/templates/default/src/client/parts/Map.js +74 -0
- package/templates/default/src/client/parts/ShotEffect.js +107 -0
- package/templates/default/src/client/parts/index.js +13 -0
- package/templates/default/src/client/style.css +26 -0
- package/templates/default/src/config/auth.js +61 -0
- package/templates/default/src/config/client.js +195 -0
- package/templates/default/src/config/game.js +170 -0
- package/templates/default/src/config/snapshot.js +70 -0
- package/templates/default/src/config/sounds.js +17 -0
- package/templates/default/src/data/maps/arena.js +69 -0
- package/templates/default/src/data/maps/index.js +7 -0
- package/templates/default/src/data/models.js +39 -0
- package/templates/default/src/data/weapons.js +37 -0
- package/templates/default/src/host/ScriptedManager.js +142 -0
- package/templates/default/src/host/createModules.js +12 -0
- package/templates/default/src/host/index.js +56 -0
- package/templates/default/src/host/nodeCore.js +23 -0
- package/templates/default/src/host/spawnCommand.js +32 -0
- package/templates/default/src/host/systemMessages.js +10 -0
- package/templates/default/tests/client/parts.test.js +128 -0
- package/templates/default/tests/config/contract.test.js +132 -0
- package/templates/default/tests/config/game.test.js +45 -0
- package/templates/default/tests/core/nodeCore.test.js +61 -0
- package/templates/default/tests/host/hostPlugin.test.js +198 -0
- package/templates/default/tests/stubs/wasmCore.js +19 -0
- package/templates/default/vite.config.js +74 -0
- 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
|
+
}
|