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,385 @@
|
|
|
1
|
+
//! The game half of the client core: prediction of the local actor
|
|
2
|
+
//! (`Predictor`) plus the locally spawned tracer of its own shot. The network
|
|
3
|
+
//! buffer, the interpolation, the hot buffer and the frame queue are the
|
|
4
|
+
//! engine's (`vimp_engine_core::client::game::ClientState`).
|
|
5
|
+
|
|
6
|
+
pub mod predictor;
|
|
7
|
+
|
|
8
|
+
use std::collections::VecDeque;
|
|
9
|
+
|
|
10
|
+
use indexmap::IndexMap;
|
|
11
|
+
use serde::Deserialize;
|
|
12
|
+
use serde_json::{Map, Value, json};
|
|
13
|
+
|
|
14
|
+
use vimp_engine_core::client::game::{GameClientDef, RenderOverlay};
|
|
15
|
+
use vimp_engine_core::client::interpolator::{FrameData, InterpolatedGame};
|
|
16
|
+
use vimp_engine_core::client::raycast::ray_vs_grid;
|
|
17
|
+
use vimp_engine_core::client::unpack::{BlockData, DecodedSnapshot};
|
|
18
|
+
use vimp_engine_core::config::{EngineClientConfig, FieldValue, PLAYER_STATE_LEN};
|
|
19
|
+
use vimp_engine_core::rng::Rng;
|
|
20
|
+
|
|
21
|
+
use crate::config::{ActorConfig, ArenaClientConfig, WeaponConfig};
|
|
22
|
+
use crate::motion;
|
|
23
|
+
use predictor::Predictor;
|
|
24
|
+
|
|
25
|
+
/// Indices of the actor row (see `ActorRow` in `game.rs` and the actor key of
|
|
26
|
+
/// `src/config/snapshot.js`) — a positional contract with the schema.
|
|
27
|
+
const ACTOR_FIELD_HEALTH: usize = 5;
|
|
28
|
+
const ACTOR_FIELD_TEAM: usize = 6;
|
|
29
|
+
|
|
30
|
+
/// A locally drawn tracer is dropped from the frame that brings its
|
|
31
|
+
/// authoritative twin; anything older than this was never confirmed and stops
|
|
32
|
+
/// suppressing rows.
|
|
33
|
+
const PENDING_MAX_AGE: f64 = 2000.0;
|
|
34
|
+
|
|
35
|
+
fn field_u8(fields: &[FieldValue], index: usize) -> u8 {
|
|
36
|
+
match fields.get(index) {
|
|
37
|
+
Some(FieldValue::U8(v)) => *v,
|
|
38
|
+
_ => 0,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Wall grid of the current map, rebuilt on every MAP_DATA — the client half
|
|
43
|
+
/// of the hitscan: enough to stop the local tracer at a wall.
|
|
44
|
+
struct Grid {
|
|
45
|
+
map: Vec<Vec<i32>>,
|
|
46
|
+
solid_tiles: Vec<i32>,
|
|
47
|
+
tile_size: f32,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[derive(Deserialize)]
|
|
51
|
+
#[serde(rename_all = "camelCase")]
|
|
52
|
+
struct ClientMapConfig {
|
|
53
|
+
step: f32,
|
|
54
|
+
#[serde(default = "default_scale")]
|
|
55
|
+
scale: f32,
|
|
56
|
+
map: Vec<Vec<i32>>,
|
|
57
|
+
#[serde(default)]
|
|
58
|
+
physics_static: Vec<i32>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn default_scale() -> f32 {
|
|
62
|
+
1.0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub struct ArenaClient {
|
|
66
|
+
models: IndexMap<String, ActorConfig>,
|
|
67
|
+
weapons: IndexMap<String, WeaponConfig>,
|
|
68
|
+
/// id of every snapshot key of the game — the predicted tail of the hot
|
|
69
|
+
/// buffer starts with the key id of the actor block.
|
|
70
|
+
key_ids: IndexMap<String, u8>,
|
|
71
|
+
|
|
72
|
+
predictor: Predictor,
|
|
73
|
+
rng: Rng,
|
|
74
|
+
grid: Option<Grid>,
|
|
75
|
+
|
|
76
|
+
model_name: Option<String>,
|
|
77
|
+
model: Option<ActorConfig>,
|
|
78
|
+
/// (health, team) of the local actor from the last discrete frame.
|
|
79
|
+
my_meta: Option<(u8, u8)>,
|
|
80
|
+
|
|
81
|
+
// local shot: presses waiting for try_action, own cooldown and the queue
|
|
82
|
+
// of locally drawn tracers awaiting their authoritative twin
|
|
83
|
+
pending_fire: usize,
|
|
84
|
+
cooldown_until: f64,
|
|
85
|
+
pending_tracers: VecDeque<f64>,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
impl ArenaClient {
|
|
89
|
+
fn alive_with_state(&self) -> bool {
|
|
90
|
+
self.predictor.has_state() && self.my_meta.is_some_and(|(health, _)| health > 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// The single weapon of the template. A game with an arsenal keeps the
|
|
94
|
+
/// active index here and moves it in `cycle_item`.
|
|
95
|
+
fn weapon(&self) -> Option<(&String, &WeaponConfig)> {
|
|
96
|
+
self.weapons.get_index(0)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
impl GameClientDef for ArenaClient {
|
|
101
|
+
type Config = ArenaClientConfig;
|
|
102
|
+
|
|
103
|
+
fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self {
|
|
104
|
+
let key_ids = engine_cfg
|
|
105
|
+
.snapshot
|
|
106
|
+
.keys
|
|
107
|
+
.iter()
|
|
108
|
+
.map(|(key, schema)| (key.clone(), schema.id))
|
|
109
|
+
.collect();
|
|
110
|
+
|
|
111
|
+
Self {
|
|
112
|
+
models: cfg.models.clone(),
|
|
113
|
+
weapons: cfg.weapons.clone(),
|
|
114
|
+
key_ids,
|
|
115
|
+
predictor: Predictor::new(engine_cfg.time_step_ms, &cfg.player_keys, &cfg.models),
|
|
116
|
+
rng: Rng::new(cfg.seed),
|
|
117
|
+
grid: None,
|
|
118
|
+
model_name: None,
|
|
119
|
+
model: None,
|
|
120
|
+
my_meta: None,
|
|
121
|
+
pending_fire: 0,
|
|
122
|
+
cooldown_until: 0.0,
|
|
123
|
+
pending_tracers: VecDeque::new(),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fn on_server_state(
|
|
128
|
+
&mut self,
|
|
129
|
+
state: [f32; PLAYER_STATE_LEN],
|
|
130
|
+
_centering: bool,
|
|
131
|
+
server_time: f64,
|
|
132
|
+
offset: f64,
|
|
133
|
+
local_now: f64,
|
|
134
|
+
) {
|
|
135
|
+
self.predictor
|
|
136
|
+
.on_server_state(state, server_time, offset, local_now);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn update(&mut self, local_now: f64) {
|
|
140
|
+
self.predictor.update(local_now);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData) {
|
|
144
|
+
if frame.camera.as_ref().is_some_and(|camera| camera.force_reset) {
|
|
145
|
+
self.predictor.reset();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let (Some(my_id), Some(key)) = (my_game_id, self.model_name.clone()) else {
|
|
149
|
+
return;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
let Some(BlockData::Indexed8(items)) = frame.snapshot.block_by_key(&key) else {
|
|
153
|
+
return;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
match items.get(&(my_id as u8)) {
|
|
157
|
+
// a null row means the actor left the canvas (dead or removed)
|
|
158
|
+
Some(None) => {
|
|
159
|
+
self.my_meta = None;
|
|
160
|
+
self.predictor.freeze(true);
|
|
161
|
+
}
|
|
162
|
+
Some(Some(row)) => {
|
|
163
|
+
let health = field_u8(row, ACTOR_FIELD_HEALTH);
|
|
164
|
+
|
|
165
|
+
self.my_meta = Some((health, field_u8(row, ACTOR_FIELD_TEAM)));
|
|
166
|
+
self.predictor.freeze(health == 0);
|
|
167
|
+
}
|
|
168
|
+
None => {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Drops the authoritative twin of a tracer this client already drew:
|
|
173
|
+
/// FIFO by the author id in the last field of the row.
|
|
174
|
+
fn filter_frame_game(
|
|
175
|
+
&mut self,
|
|
176
|
+
game: &mut Map<String, Value>,
|
|
177
|
+
my_game_id: Option<u32>,
|
|
178
|
+
local_now: f64,
|
|
179
|
+
) {
|
|
180
|
+
while self
|
|
181
|
+
.pending_tracers
|
|
182
|
+
.front()
|
|
183
|
+
.is_some_and(|time| *time < local_now - PENDING_MAX_AGE)
|
|
184
|
+
{
|
|
185
|
+
self.pending_tracers.pop_front();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let (Some(my_id), Some((weapon_key, _))) = (my_game_id, self.weapon()) else {
|
|
189
|
+
return;
|
|
190
|
+
};
|
|
191
|
+
let weapon_key = weapon_key.clone();
|
|
192
|
+
|
|
193
|
+
let Some(Value::Array(rows)) = game.get_mut(&weapon_key) else {
|
|
194
|
+
return;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
rows.retain(|row| {
|
|
198
|
+
let is_mine = row
|
|
199
|
+
.as_array()
|
|
200
|
+
.and_then(|fields| fields.last())
|
|
201
|
+
.and_then(Value::as_u64)
|
|
202
|
+
.is_some_and(|id| id == my_id as u64);
|
|
203
|
+
|
|
204
|
+
if is_mine && !self.pending_tracers.is_empty() {
|
|
205
|
+
self.pending_tracers.pop_front();
|
|
206
|
+
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
true
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// The local hitscan only needs the walls, and those come with the map —
|
|
215
|
+
/// a game whose local effects depend on other actors updates its own
|
|
216
|
+
/// world copy from these two callbacks instead.
|
|
217
|
+
fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
|
|
218
|
+
|
|
219
|
+
fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
|
|
220
|
+
|
|
221
|
+
/// The predicted tail of the hot buffer: key id, game id and then the
|
|
222
|
+
/// fields of the actor row in schema order — the client part reads it
|
|
223
|
+
/// exactly like an interpolated row.
|
|
224
|
+
fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
|
|
225
|
+
let my_game_id = my_game_id?;
|
|
226
|
+
let key_id = *self.key_ids.get(self.model_name.as_ref()?)?;
|
|
227
|
+
let (_, team) = self.my_meta?;
|
|
228
|
+
let state = self.predictor.render_state()?;
|
|
229
|
+
let velocity = motion::velocity(state.angle, state.speed);
|
|
230
|
+
|
|
231
|
+
Some(RenderOverlay {
|
|
232
|
+
camera: [state.x, state.y],
|
|
233
|
+
tail: vec![
|
|
234
|
+
key_id as f32,
|
|
235
|
+
my_game_id as f32,
|
|
236
|
+
state.x,
|
|
237
|
+
state.y,
|
|
238
|
+
state.angle,
|
|
239
|
+
velocity.x,
|
|
240
|
+
velocity.y,
|
|
241
|
+
state.health.round(),
|
|
242
|
+
team as f32,
|
|
243
|
+
],
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
|
|
248
|
+
self.predictor
|
|
249
|
+
.has_state()
|
|
250
|
+
.then(|| self.predictor.state().to_array())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
|
|
254
|
+
self.predictor.replayed_inputs()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
|
|
258
|
+
self.predictor.apply_input(action, key_name, local_now);
|
|
259
|
+
|
|
260
|
+
// `fire` is a one-shot key on the host too: the press is queued and
|
|
261
|
+
// spent by exactly one try_action
|
|
262
|
+
if action == "down" && key_name == "fire" {
|
|
263
|
+
self.pending_fire += 1;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn set_model(&mut self, model_name: &str) {
|
|
268
|
+
self.predictor.set_model(model_name);
|
|
269
|
+
self.model = self.models.get(model_name).cloned();
|
|
270
|
+
self.model_name = Some(model_name.to_string());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn set_active(&mut self, active: bool) {
|
|
274
|
+
self.predictor.set_active(active);
|
|
275
|
+
self.pending_fire = 0;
|
|
276
|
+
self.pending_tracers.clear();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fn set_map(&mut self, map_json: &str) -> Result<(), String> {
|
|
280
|
+
let cfg: ClientMapConfig = serde_json::from_str(map_json).map_err(|e| e.to_string())?;
|
|
281
|
+
|
|
282
|
+
self.grid = Some(Grid {
|
|
283
|
+
map: cfg.map,
|
|
284
|
+
solid_tiles: cfg.physics_static,
|
|
285
|
+
tile_size: cfg.step * cfg.scale,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
self.predictor.reset();
|
|
289
|
+
self.pending_fire = 0;
|
|
290
|
+
self.pending_tracers.clear();
|
|
291
|
+
|
|
292
|
+
Ok(())
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// Health and ammo of the local actor arrive in the player block of every
|
|
296
|
+
/// frame (see the layout in `actor.rs`), so the panel needs no separate
|
|
297
|
+
/// synchronisation here.
|
|
298
|
+
fn sync_panel(&mut self, _items: &[String]) {}
|
|
299
|
+
|
|
300
|
+
fn reset(&mut self) {
|
|
301
|
+
self.predictor.reset();
|
|
302
|
+
self.my_meta = None;
|
|
303
|
+
self.pending_fire = 0;
|
|
304
|
+
self.pending_tracers.clear();
|
|
305
|
+
self.cooldown_until = 0.0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// One weapon, nothing to cycle. A game with an arsenal moves its active
|
|
309
|
+
/// index here — the authoritative confirmation still arrives by panel.
|
|
310
|
+
fn cycle_item(&mut self, _back: bool) {}
|
|
311
|
+
|
|
312
|
+
/// The locally predicted shot: the tracer is drawn at once instead of
|
|
313
|
+
/// after the round trip, and `filter_frame_game` drops the authoritative
|
|
314
|
+
/// copy of it. Gates (alive, cooldown, ammo) mirror the host — a local
|
|
315
|
+
/// guess the host would refuse is worse than no guess.
|
|
316
|
+
fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String> {
|
|
317
|
+
if self.pending_fire == 0 {
|
|
318
|
+
return None;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
self.pending_fire -= 1;
|
|
322
|
+
|
|
323
|
+
if !self.alive_with_state() || local_now < self.cooldown_until {
|
|
324
|
+
return None;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let my_game_id = my_game_id?;
|
|
328
|
+
let state = self.predictor.render_state()?;
|
|
329
|
+
let model = self.model.clone()?;
|
|
330
|
+
let (weapon_key, weapon) = self.weapon()?;
|
|
331
|
+
let (weapon_key, weapon) = (weapon_key.clone(), weapon.clone());
|
|
332
|
+
|
|
333
|
+
if state.ammo < weapon.consumption.unwrap_or(1.0) as f32 {
|
|
334
|
+
return None;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
self.cooldown_until = local_now + weapon.fire_rate as f64 * 1000.0;
|
|
338
|
+
|
|
339
|
+
let mut angle = state.angle;
|
|
340
|
+
|
|
341
|
+
if weapon.spread > 0.0 {
|
|
342
|
+
angle += self.rng.range(-weapon.spread, weapon.spread);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let start = motion::muzzle(state.x, state.y, state.angle, &model);
|
|
346
|
+
let direction = [angle.cos(), angle.sin()];
|
|
347
|
+
|
|
348
|
+
let distance = self
|
|
349
|
+
.grid
|
|
350
|
+
.as_ref()
|
|
351
|
+
.and_then(|grid| {
|
|
352
|
+
ray_vs_grid(
|
|
353
|
+
[start.x, start.y],
|
|
354
|
+
direction,
|
|
355
|
+
weapon.range,
|
|
356
|
+
&grid.map,
|
|
357
|
+
&grid.solid_tiles,
|
|
358
|
+
grid.tile_size,
|
|
359
|
+
)
|
|
360
|
+
})
|
|
361
|
+
.unwrap_or(weapon.range);
|
|
362
|
+
|
|
363
|
+
let was_hit = distance < weapon.range;
|
|
364
|
+
|
|
365
|
+
self.pending_tracers.push_back(local_now);
|
|
366
|
+
|
|
367
|
+
// the row repeats the wire layout of the tracer block, author id last
|
|
368
|
+
Some(
|
|
369
|
+
json!({
|
|
370
|
+
weapon_key: [[
|
|
371
|
+
start.x,
|
|
372
|
+
start.y,
|
|
373
|
+
start.x + direction[0] * distance,
|
|
374
|
+
start.y + direction[1] * distance,
|
|
375
|
+
was_hit as u8,
|
|
376
|
+
my_game_id,
|
|
377
|
+
]]
|
|
378
|
+
})
|
|
379
|
+
.to_string(),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// The client core of the game: engine orchestration + the prediction above.
|
|
385
|
+
pub type ClientState = vimp_engine_core::client::game::ClientState<ArenaClient>;
|