create-vimp-game 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bin/create-vimp-game.js +15 -0
  2. package/package.json +40 -0
  3. package/src/cli.js +227 -0
  4. package/src/generator.js +196 -0
  5. package/src/preflight.js +48 -0
  6. package/src/prompts.js +79 -0
  7. package/src/tokens.js +99 -0
  8. package/src/ui.js +66 -0
  9. package/src/versions.generated.json +4 -0
  10. package/src/versions.js +87 -0
  11. package/templates/default/CLAUDE.md.tpl +51 -0
  12. package/templates/default/Cargo.toml.tpl +27 -0
  13. package/templates/default/LICENSE.tpl +21 -0
  14. package/templates/default/README.md.tpl +61 -0
  15. package/templates/default/_gitignore +8 -0
  16. package/templates/default/assets/audio-raw/death.wav +0 -0
  17. package/templates/default/assets/audio-raw/shot.wav +0 -0
  18. package/templates/default/assets/sounds/death.mp3 +0 -0
  19. package/templates/default/assets/sounds/death.webm +0 -0
  20. package/templates/default/assets/sounds/shot.mp3 +0 -0
  21. package/templates/default/assets/sounds/shot.webm +0 -0
  22. package/templates/default/core/Cargo.toml.tpl +19 -0
  23. package/templates/default/core/src/actor.rs +407 -0
  24. package/templates/default/core/src/body_tag.rs +56 -0
  25. package/templates/default/core/src/client/mod.rs +385 -0
  26. package/templates/default/core/src/client/predictor.rs +552 -0
  27. package/templates/default/core/src/config.rs +236 -0
  28. package/templates/default/core/src/game.rs +692 -0
  29. package/templates/default/core/src/lib.rs +118 -0
  30. package/templates/default/core/src/motion.rs +126 -0
  31. package/templates/default/core/tests/sim.rs +351 -0
  32. package/templates/default/dev/main.js +28 -0
  33. package/templates/default/eslint.config.js +84 -0
  34. package/templates/default/index.html.tpl +35 -0
  35. package/templates/default/package.json.tpl +48 -0
  36. package/templates/default/scripts/build-game-manifest.js +188 -0
  37. package/templates/default/scripts/copy-game-images.js +35 -0
  38. package/templates/default/scripts/copy-game-sounds.js +55 -0
  39. package/templates/default/scripts/export-maps.js +19 -0
  40. package/templates/default/scripts/lib/rangeToPattern.js +74 -0
  41. package/templates/default/scripts/process-audio.js +115 -0
  42. package/templates/default/src/client/bakers/actorTexture.js +40 -0
  43. package/templates/default/src/client/bakers/index.js +8 -0
  44. package/templates/default/src/client/index.js +67 -0
  45. package/templates/default/src/client/parts/Actor.js +69 -0
  46. package/templates/default/src/client/parts/Map.js +74 -0
  47. package/templates/default/src/client/parts/ShotEffect.js +107 -0
  48. package/templates/default/src/client/parts/index.js +13 -0
  49. package/templates/default/src/client/style.css +26 -0
  50. package/templates/default/src/config/auth.js +61 -0
  51. package/templates/default/src/config/client.js +195 -0
  52. package/templates/default/src/config/game.js +170 -0
  53. package/templates/default/src/config/snapshot.js +70 -0
  54. package/templates/default/src/config/sounds.js +17 -0
  55. package/templates/default/src/data/maps/arena.js +69 -0
  56. package/templates/default/src/data/maps/index.js +7 -0
  57. package/templates/default/src/data/models.js +39 -0
  58. package/templates/default/src/data/weapons.js +37 -0
  59. package/templates/default/src/host/ScriptedManager.js +142 -0
  60. package/templates/default/src/host/createModules.js +12 -0
  61. package/templates/default/src/host/index.js +56 -0
  62. package/templates/default/src/host/nodeCore.js +23 -0
  63. package/templates/default/src/host/spawnCommand.js +32 -0
  64. package/templates/default/src/host/systemMessages.js +10 -0
  65. package/templates/default/tests/client/parts.test.js +128 -0
  66. package/templates/default/tests/config/contract.test.js +132 -0
  67. package/templates/default/tests/config/game.test.js +45 -0
  68. package/templates/default/tests/core/nodeCore.test.js +61 -0
  69. package/templates/default/tests/host/hostPlugin.test.js +198 -0
  70. package/templates/default/tests/stubs/wasmCore.js +19 -0
  71. package/templates/default/vite.config.js +74 -0
  72. package/templates/default/vitest.config.js +55 -0
@@ -0,0 +1,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
+ });