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,118 @@
|
|
|
1
|
+
// {{GAME_TITLE}} — the game simulation (movement, hitscan, damage) on top of
|
|
2
|
+
// vimp-engine-core. Compiles to WASM for the host Worker and the browser, and
|
|
3
|
+
// to a Node build for tests and the headless runner (`npm run core:build`).
|
|
4
|
+
//
|
|
5
|
+
// Two exported classes: GameCore — the authoritative match in the host
|
|
6
|
+
// Worker, ClientCore — the per-player prediction in the tab. Everything the
|
|
7
|
+
// engine needs from them is generated by the two ABI macros below; only
|
|
8
|
+
// `new` and the game-specific client methods are hand-written.
|
|
9
|
+
|
|
10
|
+
use wasm_bindgen::prelude::*;
|
|
11
|
+
|
|
12
|
+
pub mod actor;
|
|
13
|
+
pub mod body_tag;
|
|
14
|
+
pub mod client;
|
|
15
|
+
pub mod config;
|
|
16
|
+
pub mod game;
|
|
17
|
+
pub mod motion;
|
|
18
|
+
|
|
19
|
+
use client::ClientState;
|
|
20
|
+
use config::{RootClientConfig, RootConfig};
|
|
21
|
+
use game::GameState;
|
|
22
|
+
use vimp_engine_core::snapshot::SnapshotPacker;
|
|
23
|
+
|
|
24
|
+
/// Public ABI of the core for the JS host (Worker / Node test harness).
|
|
25
|
+
/// The field names `state` and `packer` are a contract: the macro looks them
|
|
26
|
+
/// up literally.
|
|
27
|
+
#[wasm_bindgen]
|
|
28
|
+
pub struct GameCore {
|
|
29
|
+
state: GameState,
|
|
30
|
+
packer: SnapshotPacker,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[wasm_bindgen]
|
|
34
|
+
impl GameCore {
|
|
35
|
+
/// Builds the core from the JSON config `{engine: {...}, game: {...}}`
|
|
36
|
+
/// assembled by the engine from src/config/.
|
|
37
|
+
#[wasm_bindgen(constructor)]
|
|
38
|
+
pub fn new(config_json: &str) -> Result<GameCore, JsError> {
|
|
39
|
+
let cfg: RootConfig =
|
|
40
|
+
serde_json::from_str(config_json).map_err(|e| JsError::new(&e.to_string()))?;
|
|
41
|
+
|
|
42
|
+
// both checks belong on the construction boundary: a duplicate block
|
|
43
|
+
// id or a panel key without a weapon corrupts frames silently
|
|
44
|
+
cfg.engine.snapshot.validate().map_err(|e| JsError::new(&e))?;
|
|
45
|
+
cfg.game.validate().map_err(|e| JsError::new(&e))?;
|
|
46
|
+
|
|
47
|
+
let packer = SnapshotPacker::new(cfg.engine.snapshot.clone());
|
|
48
|
+
|
|
49
|
+
Ok(GameCore {
|
|
50
|
+
state: GameState::new(cfg.engine, &cfg.game),
|
|
51
|
+
packer,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
vimp_engine_core::export_game_core_abi!(GameCore);
|
|
57
|
+
|
|
58
|
+
impl GameCore {
|
|
59
|
+
/// Access to the simulation for native tests (not exported to JS).
|
|
60
|
+
pub fn state(&self) -> &GameState {
|
|
61
|
+
&self.state
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn state_mut(&mut self) -> &mut GameState {
|
|
65
|
+
&mut self.state
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Client half of the core: interpolation of snapshots, prediction of the
|
|
70
|
+
/// local actor and the locally spawned tracer. Lives in the main thread of
|
|
71
|
+
/// the tab.
|
|
72
|
+
#[wasm_bindgen]
|
|
73
|
+
pub struct ClientCore {
|
|
74
|
+
state: ClientState,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[wasm_bindgen]
|
|
78
|
+
impl ClientCore {
|
|
79
|
+
/// Builds the client core from `{engine: {...}, game: {...}}` assembled
|
|
80
|
+
/// by the engine from CONFIG_DATA.
|
|
81
|
+
#[wasm_bindgen(constructor)]
|
|
82
|
+
pub fn new(config_json: &str) -> Result<ClientCore, JsError> {
|
|
83
|
+
let cfg: RootClientConfig =
|
|
84
|
+
serde_json::from_str(config_json).map_err(|e| JsError::new(&e.to_string()))?;
|
|
85
|
+
|
|
86
|
+
cfg.engine.snapshot.validate().map_err(|e| JsError::new(&e))?;
|
|
87
|
+
|
|
88
|
+
Ok(ClientCore {
|
|
89
|
+
state: ClientState::new(cfg.engine, &cfg.game),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ***** game-specific methods: called only from ClientPlugin.hooks ***** //
|
|
94
|
+
|
|
95
|
+
/// Local visual shot; the gates (cooldown, ammo, alive) are inside.
|
|
96
|
+
/// Returns the spawn JSON for the renderer or nothing.
|
|
97
|
+
pub fn try_fire(&mut self, local_now: f64) -> Option<String> {
|
|
98
|
+
self.state.try_action(local_now)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Model of the local actor (known at authorization).
|
|
102
|
+
pub fn set_model(&mut self, model: &str) {
|
|
103
|
+
self.state.set_model(model);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Authoritative panel state (PANEL_DATA).
|
|
107
|
+
pub fn sync_panel(&mut self, panel_json: &str) {
|
|
108
|
+
self.state.sync_panel(panel_json);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Cycles the active item; the template has one weapon, so this is the
|
|
112
|
+
/// place a game with an arsenal grows into.
|
|
113
|
+
pub fn cycle_weapon(&mut self, back: bool) {
|
|
114
|
+
self.state.cycle_item(back);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
vimp_engine_core::export_client_core_abi!(ClientCore);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//! The ONLY source of movement math: pure functions, no Rapier bodies and no
|
|
2
|
+
//! world state. Both halves call them — the authoritative `Actor::update`
|
|
3
|
+
//! (which then writes the result onto the body) and the client `Predictor`
|
|
4
|
+
//! (which integrates it without collisions). Every change here is a change to
|
|
5
|
+
//! both halves at once; re-run `mod parity` in `client/predictor.rs` after it.
|
|
6
|
+
//!
|
|
7
|
+
//! The model is deliberately inertia-free: the velocity is written directly
|
|
8
|
+
//! from the held keys instead of being accumulated by impulses, so the
|
|
9
|
+
//! prediction parity is exact in open space and only collisions can pull the
|
|
10
|
+
//! two halves apart.
|
|
11
|
+
|
|
12
|
+
use rapier2d::prelude::Vector;
|
|
13
|
+
use vimp_engine_core::physics::normalize_angle;
|
|
14
|
+
|
|
15
|
+
use crate::config::ActorConfig;
|
|
16
|
+
|
|
17
|
+
/// Movement keys held on this step.
|
|
18
|
+
#[derive(Clone, Copy, Default)]
|
|
19
|
+
pub struct MoveInput {
|
|
20
|
+
pub forward: bool,
|
|
21
|
+
pub back: bool,
|
|
22
|
+
pub left: bool,
|
|
23
|
+
pub right: bool,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// New facing angle (radians, normalised to [-PI, PI]).
|
|
27
|
+
pub fn step_angle(angle: f32, input: MoveInput, model: &ActorConfig, dt: f32) -> f32 {
|
|
28
|
+
let mut delta = 0.0;
|
|
29
|
+
|
|
30
|
+
if input.left {
|
|
31
|
+
delta -= model.turn_speed * dt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if input.right {
|
|
35
|
+
delta += model.turn_speed * dt;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
normalize_angle(angle + delta)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// New signed speed along the facing direction: accelerate while a drive key
|
|
42
|
+
/// is held, brake towards zero otherwise.
|
|
43
|
+
pub fn step_speed(speed: f32, input: MoveInput, model: &ActorConfig, dt: f32) -> f32 {
|
|
44
|
+
if input.forward && !input.back {
|
|
45
|
+
(speed + model.acceleration * dt).min(model.max_speed)
|
|
46
|
+
} else if input.back && !input.forward {
|
|
47
|
+
(speed - model.acceleration * dt).max(-model.max_reverse_speed)
|
|
48
|
+
} else if speed > 0.0 {
|
|
49
|
+
(speed - model.braking * dt).max(0.0)
|
|
50
|
+
} else {
|
|
51
|
+
(speed + model.braking * dt).min(0.0)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Velocity vector of an actor facing `angle` and moving at `speed`.
|
|
56
|
+
pub fn velocity(angle: f32, speed: f32) -> Vector {
|
|
57
|
+
Vector::new(angle.cos() * speed, angle.sin() * speed)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Muzzle point: the edge of the body along the facing direction. The client
|
|
61
|
+
/// replicates it for the locally predicted tracer — keep the two in sync.
|
|
62
|
+
pub fn muzzle(x: f32, y: f32, angle: f32, model: &ActorConfig) -> Vector {
|
|
63
|
+
let offset = model.size * 0.6;
|
|
64
|
+
|
|
65
|
+
Vector::new(x + angle.cos() * offset, y + angle.sin() * offset)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[cfg(test)]
|
|
69
|
+
mod tests {
|
|
70
|
+
use super::*;
|
|
71
|
+
|
|
72
|
+
fn model() -> ActorConfig {
|
|
73
|
+
serde_json::from_value(serde_json::json!({
|
|
74
|
+
"currentWeapon": "w1",
|
|
75
|
+
"size": 32.0,
|
|
76
|
+
"maxSpeed": 200.0,
|
|
77
|
+
"maxReverseSpeed": 100.0,
|
|
78
|
+
"acceleration": 400.0,
|
|
79
|
+
"braking": 600.0,
|
|
80
|
+
"turnSpeed": 3.0,
|
|
81
|
+
"fixture": { "density": 1.0, "friction": 0.2, "restitution": 0.0 }
|
|
82
|
+
}))
|
|
83
|
+
.unwrap()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn speed_is_capped_by_the_model() {
|
|
88
|
+
let mut speed = 0.0;
|
|
89
|
+
let input = MoveInput {
|
|
90
|
+
forward: true,
|
|
91
|
+
..MoveInput::default()
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for _ in 0..600 {
|
|
95
|
+
speed = step_speed(speed, input, &model(), 1.0 / 120.0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
assert_eq!(speed, 200.0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#[test]
|
|
102
|
+
fn braking_stops_exactly_at_zero() {
|
|
103
|
+
let mut speed = 50.0;
|
|
104
|
+
|
|
105
|
+
for _ in 0..600 {
|
|
106
|
+
speed = step_speed(speed, MoveInput::default(), &model(), 1.0 / 120.0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assert_eq!(speed, 0.0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[test]
|
|
113
|
+
fn angle_stays_normalised() {
|
|
114
|
+
let mut angle = 0.0;
|
|
115
|
+
let input = MoveInput {
|
|
116
|
+
right: true,
|
|
117
|
+
..MoveInput::default()
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for _ in 0..1000 {
|
|
121
|
+
angle = step_angle(angle, input, &model(), 1.0 / 120.0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
assert!(angle.abs() <= core::f32::consts::PI);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// Integration tests of the simulation: they drive the real `GameCore` ABI —
|
|
2
|
+
// the very methods the host Worker calls — instead of the internals, so a
|
|
3
|
+
// change that breaks the host is caught here and not in the browser.
|
|
4
|
+
|
|
5
|
+
use vimp_engine_core::events::CoreEvent;
|
|
6
|
+
use {{CRATE_SNAKE}}::GameCore;
|
|
7
|
+
|
|
8
|
+
const DT: f32 = 1.0 / 120.0;
|
|
9
|
+
|
|
10
|
+
/// Core config — a mirror of src/config/game.js + src/data/. The same flat
|
|
11
|
+
/// object is put into both halves of `{engine, game}`: each side ignores the
|
|
12
|
+
/// fields that are not its own.
|
|
13
|
+
fn config_json() -> String {
|
|
14
|
+
let flat = flat_config_json();
|
|
15
|
+
|
|
16
|
+
serde_json::json!({ "engine": flat.clone(), "game": flat }).to_string()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn flat_config_json() -> serde_json::Value {
|
|
20
|
+
serde_json::json!({
|
|
21
|
+
"timeStep": DT,
|
|
22
|
+
"friendlyFire": false,
|
|
23
|
+
"mapScale": 1,
|
|
24
|
+
"mapSetId": "m1",
|
|
25
|
+
"models": {
|
|
26
|
+
"a1": {
|
|
27
|
+
"currentWeapon": "w1",
|
|
28
|
+
"size": 32.0,
|
|
29
|
+
"maxSpeed": 200.0,
|
|
30
|
+
"maxReverseSpeed": 100.0,
|
|
31
|
+
"acceleration": 400.0,
|
|
32
|
+
"braking": 600.0,
|
|
33
|
+
"turnSpeed": 3.0,
|
|
34
|
+
"fixture": { "density": 1.0, "friction": 0.2, "restitution": 0.0 }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"weapons": {
|
|
38
|
+
"w1": {
|
|
39
|
+
"damage": 25.0,
|
|
40
|
+
"range": 600.0,
|
|
41
|
+
"fireRate": 0.05,
|
|
42
|
+
"spread": 0,
|
|
43
|
+
"consumption": 1,
|
|
44
|
+
"cameraShake": { "intensity": 12, "duration": 150 }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"playerKeys": {
|
|
48
|
+
"forward": { "key": 1 },
|
|
49
|
+
"back": { "key": 2 },
|
|
50
|
+
"left": { "key": 4 },
|
|
51
|
+
"right": { "key": 8 },
|
|
52
|
+
"fire": { "key": 16, "type": 1 }
|
|
53
|
+
},
|
|
54
|
+
"panel": {
|
|
55
|
+
"health": { "value": 100 },
|
|
56
|
+
"w1": { "value": 30 }
|
|
57
|
+
},
|
|
58
|
+
"snapshot": {
|
|
59
|
+
"version": 3,
|
|
60
|
+
"port": 5,
|
|
61
|
+
"keys": {
|
|
62
|
+
"a1": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
|
|
63
|
+
{ "name": "x", "ty": "f32", "interp": "lerp" },
|
|
64
|
+
{ "name": "y", "ty": "f32", "interp": "lerp" },
|
|
65
|
+
{ "name": "angle", "ty": "f32", "interp": "lerpAngle" },
|
|
66
|
+
{ "name": "vx", "ty": "f32", "interp": "lerp" },
|
|
67
|
+
{ "name": "vy", "ty": "f32", "interp": "lerp" },
|
|
68
|
+
{ "name": "health", "ty": "u8" },
|
|
69
|
+
{ "name": "team", "ty": "u8" }
|
|
70
|
+
] },
|
|
71
|
+
"w1": { "id": 2, "kind": "list16", "class": "event", "fields": [
|
|
72
|
+
{ "name": "startX", "ty": "f32" },
|
|
73
|
+
{ "name": "startY", "ty": "f32" },
|
|
74
|
+
{ "name": "endX", "ty": "f32" },
|
|
75
|
+
{ "name": "endY", "ty": "f32" },
|
|
76
|
+
{ "name": "wasHit", "ty": "u8" },
|
|
77
|
+
{ "name": "shooterId", "ty": "u8" }
|
|
78
|
+
] },
|
|
79
|
+
"m1": { "id": 3, "kind": "indexedNoNull8", "class": "hot", "fields": [
|
|
80
|
+
{ "name": "x", "ty": "f32", "interp": "lerp" },
|
|
81
|
+
{ "name": "y", "ty": "f32", "interp": "lerp" },
|
|
82
|
+
{ "name": "angle", "ty": "f32", "interp": "lerpAngle" }
|
|
83
|
+
] }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"seed": 42
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// A walled 20x20 room, cell 32 — the shape of src/data/maps/arena.js.
|
|
91
|
+
fn map_json() -> String {
|
|
92
|
+
let mut grid: Vec<Vec<i32>> = vec![vec![0; 20]; 20];
|
|
93
|
+
|
|
94
|
+
for (y, row) in grid.iter_mut().enumerate() {
|
|
95
|
+
for (x, cell) in row.iter_mut().enumerate() {
|
|
96
|
+
if y == 0 || y == 19 || x == 0 || x == 19 {
|
|
97
|
+
*cell = 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
serde_json::json!({
|
|
103
|
+
"setId": "m1",
|
|
104
|
+
"scale": 1,
|
|
105
|
+
"step": 32,
|
|
106
|
+
"map": grid,
|
|
107
|
+
"physicsStatic": [1],
|
|
108
|
+
"physicsDynamic": [],
|
|
109
|
+
"respawns": {
|
|
110
|
+
"team1": [[100.0, 100.0, 0.0], [100.0, 200.0, 0.0]],
|
|
111
|
+
"team2": [[500.0, 100.0, 180.0], [500.0, 200.0, 180.0]]
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.to_string()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn make_core() -> GameCore {
|
|
118
|
+
GameCore::new(&config_json()).unwrap()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn steps(core: &mut GameCore, count: usize) {
|
|
122
|
+
for _ in 0..count {
|
|
123
|
+
core.step(DT);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fn events(core: &mut GameCore) -> Vec<CoreEvent> {
|
|
128
|
+
serde_json::from_str(&core.take_events()).unwrap()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// One broadcast frame as bytes — the unit the determinism tests compare.
|
|
132
|
+
fn frame(core: &mut GameCore, seq: u32) -> Vec<u8> {
|
|
133
|
+
core.pack_body().unwrap();
|
|
134
|
+
core.pack_frame(1000.0 + seq as f64, seq, false, 0.0, 0.0, false, None, -1);
|
|
135
|
+
|
|
136
|
+
core.frame_bytes()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Kills `victim` with shots from `shooter` (both must already exist and see
|
|
140
|
+
/// each other).
|
|
141
|
+
fn kill(core: &mut GameCore, shooter: u32, victim: u32) {
|
|
142
|
+
for _ in 0..8 {
|
|
143
|
+
if !core.is_alive(victim) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
core.apply_input(shooter, 1, "down", "fire");
|
|
148
|
+
steps(core, 8);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn spawns_actors_at_the_respawns_of_their_team() {
|
|
154
|
+
let mut core = make_core();
|
|
155
|
+
|
|
156
|
+
core.load_map(&map_json()).unwrap();
|
|
157
|
+
|
|
158
|
+
let info: serde_json::Value = serde_json::from_str(&core.map_info()).unwrap();
|
|
159
|
+
let team1 = info["respawns"]["team1"].as_array().unwrap();
|
|
160
|
+
let team2 = info["respawns"]["team2"].as_array().unwrap();
|
|
161
|
+
|
|
162
|
+
assert_eq!(team1.len(), 2);
|
|
163
|
+
assert_eq!(team2.len(), 2);
|
|
164
|
+
|
|
165
|
+
let spawn = |core: &mut GameCore, id: u32, team: u8, point: &serde_json::Value| {
|
|
166
|
+
let x = point[0].as_f64().unwrap() as f32;
|
|
167
|
+
let y = point[1].as_f64().unwrap() as f32;
|
|
168
|
+
let angle = point[2].as_f64().unwrap() as f32;
|
|
169
|
+
|
|
170
|
+
core.spawn_actor(id, "a1", team, x, y, angle).unwrap();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
spawn(&mut core, 1, 1, &team1[0]);
|
|
174
|
+
spawn(&mut core, 2, 2, &team2[0]);
|
|
175
|
+
|
|
176
|
+
assert_eq!(core.position_of(1), vec![100.0, 100.0]);
|
|
177
|
+
assert_eq!(core.position_of(2), vec![500.0, 100.0]);
|
|
178
|
+
|
|
179
|
+
// [id, team, x, y] per alive actor
|
|
180
|
+
assert_eq!(core.alive_players(), vec![1.0, 1.0, 100.0, 100.0, 2.0, 2.0, 500.0, 100.0]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn input_drives_the_actor_and_a_wall_stops_it() {
|
|
185
|
+
let mut core = make_core();
|
|
186
|
+
|
|
187
|
+
core.load_map(&map_json()).unwrap();
|
|
188
|
+
// facing 180 degrees — towards the left wall (inner face at x = 32)
|
|
189
|
+
core.spawn_actor(1, "a1", 1, 100.0, 100.0, 180.0).unwrap();
|
|
190
|
+
core.apply_input(1, 7, "down", "forward");
|
|
191
|
+
|
|
192
|
+
steps(&mut core, 60);
|
|
193
|
+
|
|
194
|
+
let moved = core.position_of(1);
|
|
195
|
+
|
|
196
|
+
assert!(moved[0] < 90.0, "the actor should drive off, x = {}", moved[0]);
|
|
197
|
+
assert_eq!(core.last_input_seq(1), 7);
|
|
198
|
+
|
|
199
|
+
steps(&mut core, 300);
|
|
200
|
+
|
|
201
|
+
let stopped = core.position_of(1);
|
|
202
|
+
|
|
203
|
+
assert!(
|
|
204
|
+
stopped[0] > 32.0,
|
|
205
|
+
"the wall should stop the actor, x = {}",
|
|
206
|
+
stopped[0]
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#[test]
|
|
211
|
+
fn hitscan_takes_health_and_death_removes_the_actor_from_the_canvas() {
|
|
212
|
+
let mut core = make_core();
|
|
213
|
+
|
|
214
|
+
// the shooter faces the target point blank
|
|
215
|
+
core.spawn_actor(1, "a1", 1, 0.0, 0.0, 0.0).unwrap();
|
|
216
|
+
core.spawn_actor(2, "a1", 2, 60.0, 0.0, 0.0).unwrap();
|
|
217
|
+
|
|
218
|
+
// warm-up: the broad phase learns about the new bodies on the first step
|
|
219
|
+
core.step(DT);
|
|
220
|
+
core.take_events();
|
|
221
|
+
|
|
222
|
+
core.apply_input(1, 1, "down", "fire");
|
|
223
|
+
steps(&mut core, 8);
|
|
224
|
+
|
|
225
|
+
let health = events(&mut core)
|
|
226
|
+
.into_iter()
|
|
227
|
+
.find_map(|event| match event {
|
|
228
|
+
CoreEvent::PanelSet { id: 2, field, value } if field == "health" => Some(value),
|
|
229
|
+
_ => None,
|
|
230
|
+
})
|
|
231
|
+
.expect("a hit must report the new health of the victim");
|
|
232
|
+
|
|
233
|
+
assert_eq!(health, 75.0);
|
|
234
|
+
assert!(core.is_alive(2));
|
|
235
|
+
|
|
236
|
+
kill(&mut core, 1, 2);
|
|
237
|
+
|
|
238
|
+
assert!(!core.is_alive(2));
|
|
239
|
+
assert!(
|
|
240
|
+
events(&mut core)
|
|
241
|
+
.iter()
|
|
242
|
+
.any(|event| matches!(event, CoreEvent::Death { victim: 2, killer: 1 })),
|
|
243
|
+
"death must report the kill to the engine scoring"
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// the dead actor leaves alive_players and the frame carries its removal
|
|
247
|
+
assert!(!core.alive_players().contains(&2.0));
|
|
248
|
+
|
|
249
|
+
frame(&mut core, 1);
|
|
250
|
+
|
|
251
|
+
assert!(
|
|
252
|
+
core.body_has_events(),
|
|
253
|
+
"a removal row is an event row: the frame must go over the reliable channel"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn friendly_fire_off_protects_the_own_team() {
|
|
259
|
+
let mut core = make_core();
|
|
260
|
+
|
|
261
|
+
core.spawn_actor(1, "a1", 1, 0.0, 0.0, 0.0).unwrap();
|
|
262
|
+
core.spawn_actor(2, "a1", 1, 60.0, 0.0, 0.0).unwrap();
|
|
263
|
+
|
|
264
|
+
core.step(DT);
|
|
265
|
+
core.take_events();
|
|
266
|
+
|
|
267
|
+
for _ in 0..4 {
|
|
268
|
+
core.apply_input(1, 1, "down", "fire");
|
|
269
|
+
steps(&mut core, 8);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
assert!(core.is_alive(2));
|
|
273
|
+
assert!(
|
|
274
|
+
!events(&mut core).iter().any(|event| matches!(
|
|
275
|
+
event,
|
|
276
|
+
CoreEvent::PanelSet { id: 2, field, .. } if field == "health"
|
|
277
|
+
)),
|
|
278
|
+
"a team mate must take no damage while friendlyFire is off"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[test]
|
|
283
|
+
fn wiping_a_team_leaves_only_the_other_one_alive() {
|
|
284
|
+
let mut core = make_core();
|
|
285
|
+
|
|
286
|
+
core.spawn_actor(1, "a1", 1, 0.0, 0.0, 0.0).unwrap();
|
|
287
|
+
core.spawn_actor(2, "a1", 2, 60.0, 0.0, 0.0).unwrap();
|
|
288
|
+
core.spawn_actor(3, "a1", 2, 120.0, 0.0, 0.0).unwrap();
|
|
289
|
+
|
|
290
|
+
core.step(DT);
|
|
291
|
+
core.take_events();
|
|
292
|
+
|
|
293
|
+
kill(&mut core, 1, 2);
|
|
294
|
+
kill(&mut core, 1, 3);
|
|
295
|
+
|
|
296
|
+
assert!(core.is_alive(1));
|
|
297
|
+
assert!(!core.is_alive(2));
|
|
298
|
+
assert!(!core.is_alive(3));
|
|
299
|
+
|
|
300
|
+
// [id, team, x, y] — only the surviving team is left for the round meta
|
|
301
|
+
let alive = core.alive_players();
|
|
302
|
+
|
|
303
|
+
assert_eq!(alive.len(), 4);
|
|
304
|
+
assert_eq!(alive[0], 1.0);
|
|
305
|
+
assert_eq!(alive[1], 1.0);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn serialize_deserialize_round_trips_the_frame() {
|
|
310
|
+
let mut core = make_core();
|
|
311
|
+
|
|
312
|
+
core.load_map(&map_json()).unwrap();
|
|
313
|
+
core.spawn_actor(1, "a1", 1, 100.0, 100.0, 0.0).unwrap();
|
|
314
|
+
core.apply_input(1, 1, "down", "forward");
|
|
315
|
+
steps(&mut core, 30);
|
|
316
|
+
|
|
317
|
+
let dump = core.serialize_state().unwrap();
|
|
318
|
+
let mut restored = make_core();
|
|
319
|
+
|
|
320
|
+
restored.deserialize_state(&dump).unwrap();
|
|
321
|
+
|
|
322
|
+
// both continue from the dump: the frames must stay bit-identical
|
|
323
|
+
for seq in 0..10 {
|
|
324
|
+
steps(&mut core, 6);
|
|
325
|
+
steps(&mut restored, 6);
|
|
326
|
+
|
|
327
|
+
assert_eq!(frame(&mut core, seq), frame(&mut restored, seq));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[test]
|
|
332
|
+
fn one_seed_gives_one_stream_of_frames() {
|
|
333
|
+
let run = || {
|
|
334
|
+
let mut core = make_core();
|
|
335
|
+
|
|
336
|
+
core.load_map(&map_json()).unwrap();
|
|
337
|
+
core.spawn_scripted_actor(1, "a1", 1, 100.0, 100.0, 0.0).unwrap();
|
|
338
|
+
core.spawn_scripted_actor(2, "a1", 2, 500.0, 100.0, 180.0).unwrap();
|
|
339
|
+
|
|
340
|
+
let mut frames: Vec<Vec<u8>> = Vec::new();
|
|
341
|
+
|
|
342
|
+
for seq in 0..40 {
|
|
343
|
+
steps(&mut core, 6);
|
|
344
|
+
frames.push(frame(&mut core, seq));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
frames
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
assert_eq!(run(), run());
|
|
351
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Entry point of the local standalone launch (`npm run dev`): a match against
|
|
2
|
+
// bots right in the tab — no master, no OAuth, no lobby screen. It is not part
|
|
3
|
+
// of the plugin build (--mode client|host) and is never published (files: ["dist"]).
|
|
4
|
+
import 'vimp-engine/style.css';
|
|
5
|
+
import { startStandaloneGame } from 'vimp-engine/standalone';
|
|
6
|
+
import hostPlugin from '../src/host/index.js';
|
|
7
|
+
import clientPlugin from '../src/client/index.js';
|
|
8
|
+
// This file does not exist until `npm run core:build` — Vite fails to resolve
|
|
9
|
+
// it, and that is the first thing `npm run dev` breaks on in a fresh checkout.
|
|
10
|
+
import wasmUrl from '../core/pkg-web/{{CRATE_SNAKE}}_bg.wasm?url';
|
|
11
|
+
|
|
12
|
+
await startStandaloneGame({
|
|
13
|
+
hostPlugin,
|
|
14
|
+
clientPlugin,
|
|
15
|
+
wasmUrl,
|
|
16
|
+
container: document.getElementById('game'),
|
|
17
|
+
// dev asset root: build/img (staged by `predev`) and build/sounds (the
|
|
18
|
+
// product of `npm run audio:process`). The engine client reads them as
|
|
19
|
+
// `${assetsBase}img/` and `${assetsBase}sounds/`.
|
|
20
|
+
assetsBase: '/build/',
|
|
21
|
+
playerName: localStorage.getItem('vimp_dev_nick') || 'Player',
|
|
22
|
+
playerModel: 'a1',
|
|
23
|
+
// leave the spectators first and only then ask for bots: the chat command
|
|
24
|
+
// is rejected for a spectator, and a joining participant is one
|
|
25
|
+
startupVotes: [['teamChange', 'team1']],
|
|
26
|
+
startupCommands: ['/spawn 3'],
|
|
27
|
+
devMode: true,
|
|
28
|
+
});
|