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,552 @@
|
|
|
1
|
+
//! Client-side prediction of the local actor: a replica of `crate::motion`
|
|
2
|
+
//! (the very same functions the authoritative `Actor::update` calls) plus the
|
|
3
|
+
//! reconciliation against the player block of every incoming frame.
|
|
4
|
+
//!
|
|
5
|
+
//! Reconciliation works in TIME, not in `seq`: the authoritative state is
|
|
6
|
+
//! stamped with `serverTime`, the input history with `performance.now`, and
|
|
7
|
+
//! the two are bridged by the clock `offset` the engine keeps. That is why
|
|
8
|
+
//! `replayed_inputs` reports a time window — it localises a drift in the
|
|
9
|
+
//! movement formula far better than an input counter.
|
|
10
|
+
|
|
11
|
+
use std::collections::VecDeque;
|
|
12
|
+
|
|
13
|
+
use indexmap::IndexMap;
|
|
14
|
+
|
|
15
|
+
use vimp_engine_core::config::PLAYER_STATE_LEN;
|
|
16
|
+
|
|
17
|
+
use crate::config::{ActorConfig, KeyConfig};
|
|
18
|
+
use crate::motion::{self, MoveInput};
|
|
19
|
+
|
|
20
|
+
/// Input older than this is dropped: replaying more than two seconds after a
|
|
21
|
+
/// stall costs more than the accuracy it buys.
|
|
22
|
+
const HISTORY_MAX_AGE: f64 = 2000.0;
|
|
23
|
+
/// Visual error left by a correction decays over ~100 ms instead of snapping.
|
|
24
|
+
const ERROR_DECAY_RATE: f64 = 10.0;
|
|
25
|
+
/// Beyond this the correction is a teleport, not a drift — snap at once.
|
|
26
|
+
const ERROR_SNAP_DISTANCE: f32 = 100.0;
|
|
27
|
+
/// Cap of the render-tick accumulator (a backgrounded tab must not replay
|
|
28
|
+
/// minutes of movement in one frame).
|
|
29
|
+
const MAX_ACCUMULATED_TIME: f64 = 100.0;
|
|
30
|
+
|
|
31
|
+
/// Predicted state of the local actor, in the player-block layout
|
|
32
|
+
/// `[x, y, angle, vx, vy, hp, ammo, 0]`.
|
|
33
|
+
#[derive(Clone, Copy, Default)]
|
|
34
|
+
pub struct ActorState {
|
|
35
|
+
pub x: f32,
|
|
36
|
+
pub y: f32,
|
|
37
|
+
pub angle: f32,
|
|
38
|
+
pub speed: f32,
|
|
39
|
+
pub health: f32,
|
|
40
|
+
pub ammo: f32,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl ActorState {
|
|
44
|
+
pub fn from_array(s: [f32; PLAYER_STATE_LEN]) -> Self {
|
|
45
|
+
let angle = s[2];
|
|
46
|
+
|
|
47
|
+
Self {
|
|
48
|
+
x: s[0],
|
|
49
|
+
y: s[1],
|
|
50
|
+
angle,
|
|
51
|
+
// the authoritative half carries the velocity vector, the replica
|
|
52
|
+
// works in signed speed along the facing — project it back
|
|
53
|
+
speed: s[3] * angle.cos() + s[4] * angle.sin(),
|
|
54
|
+
health: s[5],
|
|
55
|
+
ammo: s[6],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub fn to_array(self) -> [f32; PLAYER_STATE_LEN] {
|
|
60
|
+
let v = motion::velocity(self.angle, self.speed);
|
|
61
|
+
|
|
62
|
+
[
|
|
63
|
+
self.x,
|
|
64
|
+
self.y,
|
|
65
|
+
self.angle,
|
|
66
|
+
v.x,
|
|
67
|
+
v.y,
|
|
68
|
+
self.health,
|
|
69
|
+
self.ammo,
|
|
70
|
+
0.0,
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// What the renderer gets: the predicted state with the visual error of the
|
|
76
|
+
/// last correction blended into the position.
|
|
77
|
+
pub struct RenderState {
|
|
78
|
+
pub x: f32,
|
|
79
|
+
pub y: f32,
|
|
80
|
+
pub angle: f32,
|
|
81
|
+
pub speed: f32,
|
|
82
|
+
pub health: f32,
|
|
83
|
+
pub ammo: f32,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
struct HistoryEntry {
|
|
87
|
+
time: f64,
|
|
88
|
+
keys: u32,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pub struct Predictor {
|
|
92
|
+
step_ms: f64,
|
|
93
|
+
models: IndexMap<String, ActorConfig>,
|
|
94
|
+
model: Option<ActorConfig>,
|
|
95
|
+
|
|
96
|
+
forward_bit: u32,
|
|
97
|
+
back_bit: u32,
|
|
98
|
+
left_bit: u32,
|
|
99
|
+
right_bit: u32,
|
|
100
|
+
|
|
101
|
+
active: bool,
|
|
102
|
+
frozen: bool,
|
|
103
|
+
has_state: bool,
|
|
104
|
+
pending_reset: bool,
|
|
105
|
+
|
|
106
|
+
state: ActorState,
|
|
107
|
+
|
|
108
|
+
keys_mask: u32,
|
|
109
|
+
history: VecDeque<HistoryEntry>,
|
|
110
|
+
base_keys_mask: u32,
|
|
111
|
+
|
|
112
|
+
visual_error: [f32; 2],
|
|
113
|
+
|
|
114
|
+
accumulator: f64,
|
|
115
|
+
last_update_time: Option<f64>,
|
|
116
|
+
last_replay: Option<(f64, f64, usize)>,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
impl Predictor {
|
|
120
|
+
pub fn new(
|
|
121
|
+
step_ms: f64,
|
|
122
|
+
player_keys: &IndexMap<String, KeyConfig>,
|
|
123
|
+
models: &IndexMap<String, ActorConfig>,
|
|
124
|
+
) -> Self {
|
|
125
|
+
let bit = |name: &str| player_keys.get(name).map(|k| k.key).unwrap_or(0);
|
|
126
|
+
|
|
127
|
+
Self {
|
|
128
|
+
step_ms,
|
|
129
|
+
models: models.clone(),
|
|
130
|
+
model: None,
|
|
131
|
+
forward_bit: bit("forward"),
|
|
132
|
+
back_bit: bit("back"),
|
|
133
|
+
left_bit: bit("left"),
|
|
134
|
+
right_bit: bit("right"),
|
|
135
|
+
active: false,
|
|
136
|
+
frozen: false,
|
|
137
|
+
has_state: false,
|
|
138
|
+
pending_reset: true,
|
|
139
|
+
state: ActorState::default(),
|
|
140
|
+
keys_mask: 0,
|
|
141
|
+
history: VecDeque::new(),
|
|
142
|
+
base_keys_mask: 0,
|
|
143
|
+
visual_error: [0.0; 2],
|
|
144
|
+
accumulator: 0.0,
|
|
145
|
+
last_update_time: None,
|
|
146
|
+
last_replay: None,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pub fn set_model(&mut self, model_name: &str) {
|
|
151
|
+
self.model = self.models.get(model_name).cloned();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
pub fn set_active(&mut self, active: bool) {
|
|
155
|
+
if active && !self.active {
|
|
156
|
+
self.pending_reset = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
self.active = active;
|
|
160
|
+
|
|
161
|
+
if !active {
|
|
162
|
+
self.has_state = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// A dead actor stops predicting: the authoritative state stops moving
|
|
167
|
+
/// too, and predicting a corpse only produces corrections.
|
|
168
|
+
pub fn freeze(&mut self, frozen: bool) {
|
|
169
|
+
self.frozen = frozen;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn reset(&mut self) {
|
|
173
|
+
self.pending_reset = true;
|
|
174
|
+
self.history.clear();
|
|
175
|
+
self.base_keys_mask = 0;
|
|
176
|
+
self.keys_mask = 0;
|
|
177
|
+
self.visual_error = [0.0; 2];
|
|
178
|
+
self.accumulator = 0.0;
|
|
179
|
+
self.last_replay = None;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pub fn has_state(&self) -> bool {
|
|
183
|
+
self.active && self.has_state && self.model.is_some()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pub fn state(&self) -> ActorState {
|
|
187
|
+
self.state
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pub fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
|
|
191
|
+
self.last_replay
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Only the movement keys take part in the replica; `fire` changes no
|
|
195
|
+
/// predicted position and is handled by the local shot instead.
|
|
196
|
+
pub fn apply_input(&mut self, action: &str, key_name: &str, local_time: f64) {
|
|
197
|
+
let bit = match key_name {
|
|
198
|
+
"forward" => self.forward_bit,
|
|
199
|
+
"back" => self.back_bit,
|
|
200
|
+
"left" => self.left_bit,
|
|
201
|
+
"right" => self.right_bit,
|
|
202
|
+
_ => return,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if bit == 0 {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if action == "down" {
|
|
210
|
+
self.keys_mask |= bit;
|
|
211
|
+
} else if action == "up" {
|
|
212
|
+
self.keys_mask &= !bit;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
self.history.push_back(HistoryEntry {
|
|
216
|
+
time: local_time,
|
|
217
|
+
keys: self.keys_mask,
|
|
218
|
+
});
|
|
219
|
+
self.trim_history(local_time);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
pub fn update(&mut self, local_now: f64) {
|
|
223
|
+
let Some(last) = self.last_update_time else {
|
|
224
|
+
self.last_update_time = Some(local_now);
|
|
225
|
+
return;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
let elapsed = local_now - last;
|
|
229
|
+
|
|
230
|
+
self.last_update_time = Some(local_now);
|
|
231
|
+
|
|
232
|
+
let decay = (1.0 - (elapsed / 1000.0) * ERROR_DECAY_RATE).max(0.0) as f32;
|
|
233
|
+
|
|
234
|
+
for value in &mut self.visual_error {
|
|
235
|
+
*value *= decay;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if !self.has_state() || self.frozen {
|
|
239
|
+
self.accumulator = 0.0;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
self.accumulator = (self.accumulator + elapsed).min(MAX_ACCUMULATED_TIME);
|
|
244
|
+
|
|
245
|
+
while self.accumulator >= self.step_ms {
|
|
246
|
+
self.step(self.keys_mask);
|
|
247
|
+
self.accumulator -= self.step_ms;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// The authoritative state of the local actor: rewind to it, then replay
|
|
252
|
+
/// every input newer than the frame.
|
|
253
|
+
pub fn on_server_state(
|
|
254
|
+
&mut self,
|
|
255
|
+
state: [f32; PLAYER_STATE_LEN],
|
|
256
|
+
server_time: f64,
|
|
257
|
+
offset: f64,
|
|
258
|
+
local_now: f64,
|
|
259
|
+
) {
|
|
260
|
+
if !self.active || self.model.is_none() {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let old = self.has_state.then_some(self.state);
|
|
265
|
+
|
|
266
|
+
self.state = ActorState::from_array(state);
|
|
267
|
+
self.has_state = true;
|
|
268
|
+
|
|
269
|
+
let server_now_est = local_now + offset;
|
|
270
|
+
let mut history_index = 0;
|
|
271
|
+
let mut replay_keys = self.base_keys_mask;
|
|
272
|
+
let mut replayed = 0;
|
|
273
|
+
let mut t = server_time;
|
|
274
|
+
|
|
275
|
+
while history_index < self.history.len()
|
|
276
|
+
&& self.history[history_index].time + offset <= t
|
|
277
|
+
{
|
|
278
|
+
replay_keys = self.history[history_index].keys;
|
|
279
|
+
history_index += 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
while t + self.step_ms <= server_now_est {
|
|
283
|
+
t += self.step_ms;
|
|
284
|
+
|
|
285
|
+
while history_index < self.history.len()
|
|
286
|
+
&& self.history[history_index].time + offset <= t
|
|
287
|
+
{
|
|
288
|
+
replay_keys = self.history[history_index].keys;
|
|
289
|
+
history_index += 1;
|
|
290
|
+
replayed += 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
self.step(replay_keys);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
self.accumulator = server_now_est - t;
|
|
297
|
+
self.last_replay = Some((server_time - offset, local_now, replayed));
|
|
298
|
+
|
|
299
|
+
let Some(old) = old else {
|
|
300
|
+
self.pending_reset = false;
|
|
301
|
+
self.visual_error = [0.0; 2];
|
|
302
|
+
return;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if self.pending_reset {
|
|
306
|
+
self.pending_reset = false;
|
|
307
|
+
self.visual_error = [0.0; 2];
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
self.visual_error[0] += old.x - self.state.x;
|
|
312
|
+
self.visual_error[1] += old.y - self.state.y;
|
|
313
|
+
|
|
314
|
+
if self.visual_error[0].hypot(self.visual_error[1]) > ERROR_SNAP_DISTANCE {
|
|
315
|
+
self.visual_error = [0.0; 2];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
pub fn render_state(&self) -> Option<RenderState> {
|
|
320
|
+
if !self.has_state() {
|
|
321
|
+
return None;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
Some(RenderState {
|
|
325
|
+
x: self.state.x + self.visual_error[0],
|
|
326
|
+
y: self.state.y + self.visual_error[1],
|
|
327
|
+
angle: self.state.angle,
|
|
328
|
+
speed: self.state.speed,
|
|
329
|
+
health: self.state.health,
|
|
330
|
+
ammo: self.state.ammo,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fn trim_history(&mut self, local_now: f64) {
|
|
335
|
+
let min_time = local_now - HISTORY_MAX_AGE;
|
|
336
|
+
|
|
337
|
+
while let Some(entry) = self.history.front() {
|
|
338
|
+
if entry.time >= min_time {
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
self.base_keys_mask = entry.keys;
|
|
343
|
+
self.history.pop_front();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// One fixed step of the replica — the same order as `Actor::update`.
|
|
348
|
+
fn step(&mut self, keys: u32) {
|
|
349
|
+
let Some(model) = &self.model else {
|
|
350
|
+
return;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
let dt = (self.step_ms / 1000.0) as f32;
|
|
354
|
+
|
|
355
|
+
let input = MoveInput {
|
|
356
|
+
forward: keys & self.forward_bit != 0,
|
|
357
|
+
back: keys & self.back_bit != 0,
|
|
358
|
+
left: keys & self.left_bit != 0,
|
|
359
|
+
right: keys & self.right_bit != 0,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
self.state.angle = motion::step_angle(self.state.angle, input, model, dt);
|
|
363
|
+
self.state.speed = motion::step_speed(self.state.speed, input, model, dt);
|
|
364
|
+
|
|
365
|
+
let velocity = motion::velocity(self.state.angle, self.state.speed);
|
|
366
|
+
|
|
367
|
+
self.state.x += velocity.x * dt;
|
|
368
|
+
self.state.y += velocity.y * dt;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Parity of the replica with the authoritative simulation. This is the ONE
|
|
373
|
+
/// test that catches `crate::motion` drifting apart between the two halves —
|
|
374
|
+
/// re-run it after every movement change (`npm run core:test`).
|
|
375
|
+
#[cfg(test)]
|
|
376
|
+
mod parity {
|
|
377
|
+
use super::*;
|
|
378
|
+
use crate::config::ArenaConfig;
|
|
379
|
+
use crate::game::GameState;
|
|
380
|
+
|
|
381
|
+
const DT: f32 = 1.0 / 120.0;
|
|
382
|
+
const STEP_MS: f64 = 1000.0 / 120.0;
|
|
383
|
+
|
|
384
|
+
fn config_json() -> serde_json::Value {
|
|
385
|
+
serde_json::json!({
|
|
386
|
+
"timeStep": 1.0 / 120.0,
|
|
387
|
+
"snapshot": {
|
|
388
|
+
"version": 3,
|
|
389
|
+
"port": 5,
|
|
390
|
+
"keys": {
|
|
391
|
+
"a1": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
|
|
392
|
+
{ "name": "x", "ty": "f32", "interp": "lerp" },
|
|
393
|
+
{ "name": "y", "ty": "f32", "interp": "lerp" },
|
|
394
|
+
{ "name": "angle", "ty": "f32", "interp": "lerpAngle" },
|
|
395
|
+
{ "name": "vx", "ty": "f32", "interp": "lerp" },
|
|
396
|
+
{ "name": "vy", "ty": "f32", "interp": "lerp" },
|
|
397
|
+
{ "name": "health", "ty": "u8" },
|
|
398
|
+
{ "name": "team", "ty": "u8" }
|
|
399
|
+
] },
|
|
400
|
+
"w1": { "id": 2, "kind": "list16", "class": "event", "fields": [
|
|
401
|
+
{ "name": "startX", "ty": "f32" },
|
|
402
|
+
{ "name": "startY", "ty": "f32" },
|
|
403
|
+
{ "name": "endX", "ty": "f32" },
|
|
404
|
+
{ "name": "endY", "ty": "f32" },
|
|
405
|
+
{ "name": "wasHit", "ty": "u8" },
|
|
406
|
+
{ "name": "shooterId", "ty": "u8" }
|
|
407
|
+
] }
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
"seed": 42,
|
|
411
|
+
"friendlyFire": false,
|
|
412
|
+
"models": {
|
|
413
|
+
"a1": {
|
|
414
|
+
"currentWeapon": "w1",
|
|
415
|
+
"size": 32.0,
|
|
416
|
+
"maxSpeed": 200.0,
|
|
417
|
+
"maxReverseSpeed": 100.0,
|
|
418
|
+
"acceleration": 400.0,
|
|
419
|
+
"braking": 600.0,
|
|
420
|
+
"turnSpeed": 3.0,
|
|
421
|
+
"fixture": { "density": 1.0, "friction": 0.2, "restitution": 0.0 }
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
"weapons": {
|
|
425
|
+
"w1": { "damage": 25.0, "range": 600.0, "fireRate": 0.4 }
|
|
426
|
+
},
|
|
427
|
+
"playerKeys": {
|
|
428
|
+
"forward": { "key": 1 },
|
|
429
|
+
"back": { "key": 2 },
|
|
430
|
+
"left": { "key": 4 },
|
|
431
|
+
"right": { "key": 8 },
|
|
432
|
+
"fire": { "key": 16, "type": 1 }
|
|
433
|
+
},
|
|
434
|
+
"panel": {
|
|
435
|
+
"health": { "value": 100.0 },
|
|
436
|
+
"w1": { "value": 30.0 }
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fn game_config() -> ArenaConfig {
|
|
442
|
+
serde_json::from_value(config_json()).unwrap()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
fn engine_config() -> vimp_engine_core::config::EngineConfig {
|
|
446
|
+
serde_json::from_value(config_json()).unwrap()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
fn key_bit(name: &str) -> u32 {
|
|
450
|
+
game_config().player_keys[name].key
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// steps both halves through one schedule of key masks
|
|
454
|
+
// ({ step index → new mask }) and returns their final positions
|
|
455
|
+
fn simulate(steps: usize, schedule: &[(usize, u32)]) -> ([f32; 2], (f32, f32)) {
|
|
456
|
+
let cfg = game_config();
|
|
457
|
+
let mut game = GameState::new(engine_config(), &cfg);
|
|
458
|
+
|
|
459
|
+
game.spawn_actor(1, "a1", 1, 0.0, 0.0, 0.0).unwrap();
|
|
460
|
+
|
|
461
|
+
let mut predictor = Predictor::new(STEP_MS, &cfg.player_keys, &cfg.models);
|
|
462
|
+
|
|
463
|
+
predictor.set_model("a1");
|
|
464
|
+
predictor.set_active(true);
|
|
465
|
+
predictor.on_server_state([0.0; PLAYER_STATE_LEN], 0.0, 0.0, 0.0);
|
|
466
|
+
|
|
467
|
+
// step() directly instead of update(): parity compares the formulas
|
|
468
|
+
// of one tick, not the render-tick accumulator
|
|
469
|
+
let mut mask = 0u32;
|
|
470
|
+
let mut seq = 0u32;
|
|
471
|
+
|
|
472
|
+
for i in 0..steps {
|
|
473
|
+
if let Some((_, new_mask)) = schedule.iter().find(|(step, _)| *step == i) {
|
|
474
|
+
for (name, key) in &cfg.player_keys {
|
|
475
|
+
if !matches!(name.as_str(), "forward" | "back" | "left" | "right") {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let was = mask & key.key != 0;
|
|
480
|
+
let now = new_mask & key.key != 0;
|
|
481
|
+
|
|
482
|
+
if was != now {
|
|
483
|
+
seq += 1;
|
|
484
|
+
game.apply_input(1, seq, if now { "down" } else { "up" }, name);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
mask = *new_mask;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
game.step(DT);
|
|
492
|
+
predictor.step(mask);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let authoritative = game.actor_position(1).unwrap();
|
|
496
|
+
let render = predictor.render_state().unwrap();
|
|
497
|
+
|
|
498
|
+
(authoritative, (render.x, render.y))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn expect_close(authoritative: [f32; 2], replica: (f32, f32), tolerance: f32) {
|
|
502
|
+
assert!(
|
|
503
|
+
(replica.0 - authoritative[0]).abs() < tolerance,
|
|
504
|
+
"x: replica {} vs core {}",
|
|
505
|
+
replica.0,
|
|
506
|
+
authoritative[0]
|
|
507
|
+
);
|
|
508
|
+
assert!(
|
|
509
|
+
(replica.1 - authoritative[1]).abs() < tolerance,
|
|
510
|
+
"y: replica {} vs core {}",
|
|
511
|
+
replica.1,
|
|
512
|
+
authoritative[1]
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#[test]
|
|
517
|
+
fn drive_forward() {
|
|
518
|
+
let (core, replica) = simulate(120, &[(0, key_bit("forward"))]);
|
|
519
|
+
|
|
520
|
+
expect_close(core, replica, 0.5);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#[test]
|
|
524
|
+
fn drive_and_turn() {
|
|
525
|
+
let mask = key_bit("forward") | key_bit("right");
|
|
526
|
+
let (core, replica) = simulate(120, &[(0, mask)]);
|
|
527
|
+
|
|
528
|
+
expect_close(core, replica, 0.5);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#[test]
|
|
532
|
+
fn release_and_brake() {
|
|
533
|
+
let (core, replica) = simulate(180, &[(0, key_bit("forward")), (60, 0)]);
|
|
534
|
+
|
|
535
|
+
expect_close(core, replica, 0.5);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#[test]
|
|
539
|
+
fn reverse_after_forward() {
|
|
540
|
+
let (core, replica) =
|
|
541
|
+
simulate(180, &[(0, key_bit("forward")), (60, key_bit("back"))]);
|
|
542
|
+
|
|
543
|
+
expect_close(core, replica, 0.5);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
#[test]
|
|
547
|
+
fn no_input_stays_put() {
|
|
548
|
+
let (core, replica) = simulate(60, &[]);
|
|
549
|
+
|
|
550
|
+
expect_close(core, replica, 0.001);
|
|
551
|
+
}
|
|
552
|
+
}
|