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,170 @@
|
|
|
1
|
+
import snapshot from './snapshot.js';
|
|
2
|
+
import models from '../data/models.js';
|
|
3
|
+
import weapons from '../data/weapons.js';
|
|
4
|
+
import maps from '../data/maps/index.js';
|
|
5
|
+
|
|
6
|
+
// HostPlugin.gameConfig — the authoritative description of the game: what the
|
|
7
|
+
// engine meta needs (rounds, teams, panel, stat, room form) and what it hands
|
|
8
|
+
// over to the Rust core untouched (models, weapons, playerKeys, snapshot).
|
|
9
|
+
//
|
|
10
|
+
// The engine asserts nine paths right after import — roomDefaults.maxPlayers,
|
|
11
|
+
// snapshot, parts.models, parts.weapons, parts.friendlyFire, panel.fields,
|
|
12
|
+
// playerKeys, teams, spectatorTeam — plus `spectatorTeam` being a key of
|
|
13
|
+
// `teams`. Everything else is read lazily, which is why a typo elsewhere
|
|
14
|
+
// surfaces as a black canvas rather than an error: run `npm run
|
|
15
|
+
// check:contract` after every change here.
|
|
16
|
+
export default {
|
|
17
|
+
title: '{{GAME_TITLE}}',
|
|
18
|
+
|
|
19
|
+
parts: {
|
|
20
|
+
models,
|
|
21
|
+
weapons,
|
|
22
|
+
// overridable per room through the lobby form below
|
|
23
|
+
friendlyFire: false,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// the binary protocol layout; the engine passes it to both cores and to the
|
|
27
|
+
// client, knowing nothing about the meaning of a field
|
|
28
|
+
snapshot,
|
|
29
|
+
|
|
30
|
+
// engine event -> name in the sound registry (src/config/sounds.js).
|
|
31
|
+
// All five keys exist so that filling one in is editing a value, not
|
|
32
|
+
// remembering the vocabulary; an unmapped cue is simply not sent, and the
|
|
33
|
+
// template ships only the two sounds a match cannot do without.
|
|
34
|
+
soundCues: {
|
|
35
|
+
roundStart: null,
|
|
36
|
+
victory: null,
|
|
37
|
+
defeat: null,
|
|
38
|
+
frag: null,
|
|
39
|
+
death: 'death',
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// the vote a player is offered right after their first frame: without it a
|
|
43
|
+
// joining participant stays a spectator with no way to ask for a team
|
|
44
|
+
initialVote: 'teamChange',
|
|
45
|
+
|
|
46
|
+
maps,
|
|
47
|
+
currentMap: 'arena',
|
|
48
|
+
mapScale: 1,
|
|
49
|
+
// fallback for a map without its own setId
|
|
50
|
+
mapSetId: 'c1',
|
|
51
|
+
mapsInVote: 1,
|
|
52
|
+
|
|
53
|
+
// starting profile of a player with no saved record on the auth service.
|
|
54
|
+
// The engine keeps it as opaque JSON — the game is the only reader
|
|
55
|
+
playerState: {
|
|
56
|
+
defaultState: { kills: 0, shots: 0 },
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
spectatorTeam: 'spectators',
|
|
60
|
+
teams: {
|
|
61
|
+
team1: 1,
|
|
62
|
+
team2: 2,
|
|
63
|
+
spectators: 3,
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// bots: the name prefix the engine numbers, and the model they spawn with
|
|
67
|
+
scripted: {
|
|
68
|
+
namePrefix: 'Bot',
|
|
69
|
+
defaultModel: 'a1',
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Action name -> bit and kind. The engine ships this table into both cores
|
|
73
|
+
// verbatim and never interprets it: `type: 1` (one-shot) is a CONVENTION
|
|
74
|
+
// core/src/actor.rs implements — `down` arms the bit, exactly one fixed step
|
|
75
|
+
// consumes it, `up` is ignored. Every name here must have a key in
|
|
76
|
+
// keySetList[1] (src/config/client.js) or it can never be pressed.
|
|
77
|
+
playerKeys: {
|
|
78
|
+
forward: { key: 1 << 0 },
|
|
79
|
+
back: { key: 1 << 1 },
|
|
80
|
+
left: { key: 1 << 2 },
|
|
81
|
+
right: { key: 1 << 3 },
|
|
82
|
+
fire: { key: 1 << 4, type: 1 },
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// HUD, host half: `key` is the short wire key, `value` the starting amount —
|
|
86
|
+
// and the same number is what the core hands the actor as health and ammo.
|
|
87
|
+
// `health` is read by the core by that exact name; every other key must name
|
|
88
|
+
// a weapon (the core refuses to boot otherwise).
|
|
89
|
+
// The key 't' is reserved by the engine for the round time — never declare it.
|
|
90
|
+
panel: {
|
|
91
|
+
fields: {
|
|
92
|
+
health: { key: 'h', value: 100 },
|
|
93
|
+
e1: { key: 'a', value: 100 },
|
|
94
|
+
},
|
|
95
|
+
// Cell showing the active weapon. It is NOT a field: the engine writes
|
|
96
|
+
// the weapon name into it whenever the core reports a change, and it does
|
|
97
|
+
// so unconditionally — leaving this null makes the panel send the literal
|
|
98
|
+
// key 'null' to every client, which nothing on the other side can render.
|
|
99
|
+
activeKey: 'wa',
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// Statistics table, host half. The engine writes exactly five names —
|
|
103
|
+
// name, status, score, deaths, latency — and nothing else; `key` is the
|
|
104
|
+
// column index on the wire, matched positionally by src/config/client.js.
|
|
105
|
+
stat: {
|
|
106
|
+
name: {
|
|
107
|
+
key: 0,
|
|
108
|
+
bodyMethod: '=',
|
|
109
|
+
headSync: true,
|
|
110
|
+
headMethod: '#',
|
|
111
|
+
},
|
|
112
|
+
status: {
|
|
113
|
+
key: 1,
|
|
114
|
+
bodyMethod: '=',
|
|
115
|
+
bodyValue: '',
|
|
116
|
+
headValue: '',
|
|
117
|
+
},
|
|
118
|
+
score: {
|
|
119
|
+
key: 2,
|
|
120
|
+
bodyMethod: '+',
|
|
121
|
+
bodyValue: 0,
|
|
122
|
+
headMethod: '+',
|
|
123
|
+
headValue: 0,
|
|
124
|
+
},
|
|
125
|
+
deaths: {
|
|
126
|
+
key: 3,
|
|
127
|
+
bodyMethod: '+',
|
|
128
|
+
bodyValue: 0,
|
|
129
|
+
headMethod: '+',
|
|
130
|
+
headValue: 0,
|
|
131
|
+
},
|
|
132
|
+
latency: {
|
|
133
|
+
key: 4,
|
|
134
|
+
bodyMethod: '=',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
roomDefaults: {
|
|
139
|
+
maxPlayers: 8,
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// The lobby "create server" form. The names must be exactly the keys the
|
|
143
|
+
// host honours (maxPlayers, map, roundTime, mapTime, friendlyFire) —
|
|
144
|
+
// anything else is accepted by the form and silently dropped. Numeric
|
|
145
|
+
// bounds are injected as `regExp` by scripts/build-game-manifest.js.
|
|
146
|
+
roomForm: [
|
|
147
|
+
{
|
|
148
|
+
name: 'maxPlayers',
|
|
149
|
+
control: 'text',
|
|
150
|
+
label: 'Max players',
|
|
151
|
+
numeric: true,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'roundTime',
|
|
155
|
+
control: 'text',
|
|
156
|
+
label: 'Round time',
|
|
157
|
+
unit: 's',
|
|
158
|
+
numeric: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'mapTime',
|
|
162
|
+
control: 'text',
|
|
163
|
+
label: 'Map time',
|
|
164
|
+
unit: 's',
|
|
165
|
+
numeric: true,
|
|
166
|
+
},
|
|
167
|
+
{ name: 'friendlyFire', control: 'checkbox', label: 'Friendly fire' },
|
|
168
|
+
{ name: 'map', control: 'select', label: 'Map', source: 'maps' },
|
|
169
|
+
],
|
|
170
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The wire layout of the game (gameConfig.snapshot): one block per entity
|
|
2
|
+
// kind. The engine never learns what a field means — only how many bytes it
|
|
3
|
+
// takes and whether it interpolates (docs/ai/06-snapshot-protocol.md).
|
|
4
|
+
//
|
|
5
|
+
// The KEY of a block is not free: the Rust core names the actor block after
|
|
6
|
+
// the model (`src/data/models.js`) and the tracer block after the weapon
|
|
7
|
+
// (`src/data/weapons.js`) that produced it, so `a1` and `e1` below are those
|
|
8
|
+
// two names. Renaming a model or a weapon renames its block. The third key is
|
|
9
|
+
// the `setId` of the map — see the comment on `c1`.
|
|
10
|
+
//
|
|
11
|
+
// The field ORDER is positionally bound to `core/src/game.rs` — `ActorRow`
|
|
12
|
+
// and `TracerRow` push their values in exactly this sequence. Nothing
|
|
13
|
+
// validates the correspondence: swapping two entries here without swapping
|
|
14
|
+
// them there produces garbage instead of an error.
|
|
15
|
+
export default {
|
|
16
|
+
// the actor: continuous state, so `hot` — the only class the render-rate
|
|
17
|
+
// buffer carries, and it carries only indexed8 / indexedNoNull8
|
|
18
|
+
a1: {
|
|
19
|
+
id: 1,
|
|
20
|
+
kind: 'indexed8',
|
|
21
|
+
class: 'hot',
|
|
22
|
+
fields: [
|
|
23
|
+
{ name: 'x', ty: 'f32', interp: 'lerp' },
|
|
24
|
+
{ name: 'y', ty: 'f32', interp: 'lerp' },
|
|
25
|
+
{ name: 'angle', ty: 'f32', interp: 'lerpAngle' },
|
|
26
|
+
// velocity is not interpolated: the client only reads it to tell a
|
|
27
|
+
// moving actor from a standing one, and a lerped copy of a value that
|
|
28
|
+
// already changes every frame buys nothing
|
|
29
|
+
{ name: 'vx', ty: 'f32' },
|
|
30
|
+
{ name: 'vy', ty: 'f32' },
|
|
31
|
+
{ name: 'health', ty: 'u8' },
|
|
32
|
+
{ name: 'team', ty: 'u8' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// the shot: an anonymous one-shot event, so `list16` + class 'event' —
|
|
37
|
+
// a frame carrying it goes over the reliable channel
|
|
38
|
+
e1: {
|
|
39
|
+
id: 2,
|
|
40
|
+
kind: 'list16',
|
|
41
|
+
class: 'event',
|
|
42
|
+
fields: [
|
|
43
|
+
{ name: 'startX', ty: 'f32' },
|
|
44
|
+
{ name: 'startY', ty: 'f32' },
|
|
45
|
+
{ name: 'endX', ty: 'f32' },
|
|
46
|
+
{ name: 'endY', ty: 'f32' },
|
|
47
|
+
{ name: 'wasHit', ty: 'u8' },
|
|
48
|
+
// the author id is LAST by convention: the client drops the
|
|
49
|
+
// authoritative twin of a tracer it already drew by this field
|
|
50
|
+
{ name: 'author', ty: 'u8' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// The movable bodies of the map (`physicsDynamic`). This block belongs to
|
|
55
|
+
// the ENGINE: it is packed by the engine half of the core, under the map's
|
|
56
|
+
// `setId` as the key, and its row is fixed at [x, y, angle]. The template
|
|
57
|
+
// has no dynamic bodies, and the block is still mandatory — the packer
|
|
58
|
+
// refuses a key it does not know, and `npm run sim` dies on the first tick
|
|
59
|
+
// with "unknown snapshot key 'c1'".
|
|
60
|
+
c1: {
|
|
61
|
+
id: 3,
|
|
62
|
+
kind: 'indexedNoNull8',
|
|
63
|
+
class: 'hot',
|
|
64
|
+
fields: [
|
|
65
|
+
{ name: 'x', ty: 'f32', interp: 'lerp' },
|
|
66
|
+
{ name: 'y', ty: 'f32', interp: 'lerp' },
|
|
67
|
+
{ name: 'angle', ty: 'f32', interp: 'lerpAngle' },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Sound registry of the client. Every entry must ship as a webm + mp3 PAIR:
|
|
2
|
+
// the client walks codecList and takes the first codec the browser supports,
|
|
3
|
+
// so a missing .mp3 breaks Safari only — i.e. never for the author.
|
|
4
|
+
//
|
|
5
|
+
// `file` is the base name in dist/sounds/ (assets/sounds/ or, after
|
|
6
|
+
// `npm run audio:process`, build/sounds/). Do NOT set `path`: the engine
|
|
7
|
+
// overwrites it with `${assetsBase}sounds/`, and a hand-written one only
|
|
8
|
+
// works until the game is served from the lobby.
|
|
9
|
+
const sounds = {
|
|
10
|
+
shot: { file: 'shot', priority: 100, volume: 0.4 },
|
|
11
|
+
death: { file: 'death', priority: 150, volume: 0.4 },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
codecList: ['webm', 'mp3'],
|
|
16
|
+
sounds,
|
|
17
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The single map of the game: a walled rectangle with four pillars, drawn
|
|
2
|
+
// procedurally by `src/client/parts/Map.js` — no `spriteSheet`, so the package
|
|
3
|
+
// ships no images at all (docs/ai/07-maps-and-assets.md).
|
|
4
|
+
//
|
|
5
|
+
// `map` is the grid of tiles (0 — empty, 1 — wall), `step` its cell size in
|
|
6
|
+
// world units; `physicsStatic` lists the tile values the core turns into
|
|
7
|
+
// colliders, and `layers` maps a render layer to the tile values drawn on it —
|
|
8
|
+
// WITHOUT it the client builds no map parts at all (the engine derives the
|
|
9
|
+
// static map data from `layers`, one entry per layer).
|
|
10
|
+
//
|
|
11
|
+
// `respawns` holds one entry per playing team, and the length of a list is the
|
|
12
|
+
// hard capacity of that team on this map: the engine hands the points out
|
|
13
|
+
// sequentially and refuses the next joiner when they run out. Keep every
|
|
14
|
+
// point on an EMPTY cell — the engine does not check, and an actor spawned
|
|
15
|
+
// inside a wall is stuck there for the round.
|
|
16
|
+
const W = 1;
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
// which parts.gameSets entry builds this map (src/config/client.js)
|
|
20
|
+
setId: 'c1',
|
|
21
|
+
scale: 1,
|
|
22
|
+
step: 64,
|
|
23
|
+
physicsStatic: [W],
|
|
24
|
+
physicsDynamic: [],
|
|
25
|
+
|
|
26
|
+
// render layer -> tile values drawn on it
|
|
27
|
+
layers: { 1: [W] },
|
|
28
|
+
|
|
29
|
+
map: [
|
|
30
|
+
[W, W, W, W, W, W, W, W, W, W, W, W],
|
|
31
|
+
[W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
|
|
32
|
+
[W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
|
|
33
|
+
[W, 0, 0, W, W, 0, 0, W, W, 0, 0, W],
|
|
34
|
+
[W, 0, 0, W, 0, 0, 0, 0, W, 0, 0, W],
|
|
35
|
+
[W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
|
|
36
|
+
[W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
|
|
37
|
+
[W, 0, 0, W, 0, 0, 0, 0, W, 0, 0, W],
|
|
38
|
+
[W, 0, 0, W, W, 0, 0, W, W, 0, 0, W],
|
|
39
|
+
[W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
|
|
40
|
+
[W, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W],
|
|
41
|
+
[W, W, W, W, W, W, W, W, W, W, W, W],
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
// [x, y, angleDeg] — DEGREES, not radians: the core converts them itself.
|
|
45
|
+
// The two teams start along the opposite short walls, in the two columns
|
|
46
|
+
// that are empty on every row (x = 96/160 and x = 608/672).
|
|
47
|
+
respawns: {
|
|
48
|
+
team1: [
|
|
49
|
+
[96, 96, 0],
|
|
50
|
+
[96, 224, 0],
|
|
51
|
+
[96, 352, 0],
|
|
52
|
+
[96, 480, 0],
|
|
53
|
+
[160, 160, 0],
|
|
54
|
+
[160, 288, 0],
|
|
55
|
+
[160, 416, 0],
|
|
56
|
+
[160, 544, 0],
|
|
57
|
+
],
|
|
58
|
+
team2: [
|
|
59
|
+
[672, 96, 180],
|
|
60
|
+
[672, 224, 180],
|
|
61
|
+
[672, 352, 180],
|
|
62
|
+
[672, 480, 180],
|
|
63
|
+
[608, 160, 180],
|
|
64
|
+
[608, 288, 180],
|
|
65
|
+
[608, 416, 180],
|
|
66
|
+
[608, 544, 180],
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Actor classes of the game (gameConfig.parts.models). The engine passes this
|
|
2
|
+
// object verbatim into BOTH cores — the authoritative one and the predictor —
|
|
3
|
+
// so every number here is gameplay, not decoration (core/src/config.rs,
|
|
4
|
+
// struct ActorConfig).
|
|
5
|
+
//
|
|
6
|
+
// The key is the model name: it is the snapshot block key of the actor
|
|
7
|
+
// (`src/config/snapshot.js`), the value of the `model` field of the auth form
|
|
8
|
+
// and `gameConfig.scripted.defaultModel`. One class is enough to play; adding
|
|
9
|
+
// a second one is adding a key here — nothing in the core is hard-coded to a
|
|
10
|
+
// name.
|
|
11
|
+
export default {
|
|
12
|
+
a1: {
|
|
13
|
+
// key of `src/data/weapons.js` the actor spawns with
|
|
14
|
+
currentWeapon: 'e1',
|
|
15
|
+
|
|
16
|
+
// diameter of the body in world units (the collider is a ball of size/2)
|
|
17
|
+
size: 32,
|
|
18
|
+
|
|
19
|
+
// units per second; the reverse cap is deliberately lower — an actor that
|
|
20
|
+
// backs away as fast as it charges makes every duel a stalemate
|
|
21
|
+
maxSpeed: 220,
|
|
22
|
+
maxReverseSpeed: 110,
|
|
23
|
+
|
|
24
|
+
// speed gained per second while a drive key is held / lost with none
|
|
25
|
+
acceleration: 520,
|
|
26
|
+
braking: 700,
|
|
27
|
+
|
|
28
|
+
// radians per second
|
|
29
|
+
turnSpeed: 3.2,
|
|
30
|
+
|
|
31
|
+
// Rapier collider parameters. restitution 0: actors that bounce off each
|
|
32
|
+
// other turn a crowd into a pinball table
|
|
33
|
+
fixture: {
|
|
34
|
+
density: 1,
|
|
35
|
+
friction: 0.2,
|
|
36
|
+
restitution: 0,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Weapons of the game (gameConfig.parts.weapons) — the same object reaches
|
|
2
|
+
// the authoritative core and the predictor (core/src/config.rs, struct
|
|
3
|
+
// WeaponConfig).
|
|
4
|
+
//
|
|
5
|
+
// The key is the weapon name, and the core names the tracer snapshot block
|
|
6
|
+
// after it: `e1` here is the `e1` block of `src/config/snapshot.js`. It is
|
|
7
|
+
// also the panel field holding the ammo (`src/config/game.js`) — the core
|
|
8
|
+
// validates that pairing at construction and refuses to boot without it.
|
|
9
|
+
export default {
|
|
10
|
+
e1: {
|
|
11
|
+
// hitscan: the ray is cast on the same step the trigger is consumed, so
|
|
12
|
+
// there is no projectile body and no contact handling in the core
|
|
13
|
+
damage: 25,
|
|
14
|
+
|
|
15
|
+
// ray length in world units — a bit less than the long side of the arena,
|
|
16
|
+
// so the map itself limits the duel range
|
|
17
|
+
range: 620,
|
|
18
|
+
|
|
19
|
+
// SECONDS between shots (the client predictor multiplies by 1000 itself)
|
|
20
|
+
fireRate: 0.35,
|
|
21
|
+
|
|
22
|
+
// radians of random spread; it goes through the engine Rng on both sides,
|
|
23
|
+
// never through Math.random — determinism is what makes a replay and a
|
|
24
|
+
// host handoff reproduce the same match
|
|
25
|
+
spread: 0.03,
|
|
26
|
+
|
|
27
|
+
// ammo spent per shot; the pool is the panel value of this key
|
|
28
|
+
consumption: 1,
|
|
29
|
+
|
|
30
|
+
// the victim's canvas shakes on a hit (needs shakeCamera on the canvas,
|
|
31
|
+
// `src/config/client.js`)
|
|
32
|
+
cameraShake: {
|
|
33
|
+
intensity: 4,
|
|
34
|
+
duration: 200,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The bot manager: the game half of "scripted participants". The engine calls
|
|
2
|
+
// exactly five methods on it — createMap, getCountsPerTeam, createScripted,
|
|
3
|
+
// removeScripted, removeOneForHuman — and never anything else
|
|
4
|
+
// (docs/ai/03-host-plugin.md § The `scripted` module contract).
|
|
5
|
+
//
|
|
6
|
+
// It holds no state that a host handoff may not lose: a restored room
|
|
7
|
+
// re-creates the map and respawns everyone, and this object is rebuilt from
|
|
8
|
+
// scratch. Worker-safe.
|
|
9
|
+
export default class ScriptedManager {
|
|
10
|
+
constructor({ participants, coreAdapter, panel, stat, scripted }) {
|
|
11
|
+
this._participants = participants;
|
|
12
|
+
this._coreAdapter = coreAdapter;
|
|
13
|
+
this._panel = panel;
|
|
14
|
+
this._stat = stat;
|
|
15
|
+
|
|
16
|
+
// `scripted` in the context is gameConfig.scripted — a config object, not
|
|
17
|
+
// a module
|
|
18
|
+
this._model = scripted.defaultModel;
|
|
19
|
+
this._respawns = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// called on every map load, with the map data ALREADY scaled
|
|
23
|
+
createMap(mapData) {
|
|
24
|
+
this._respawns = mapData.respawns;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// { teamName: count } — the engine balances the teams by it
|
|
28
|
+
getCountsPerTeam() {
|
|
29
|
+
const counts = {};
|
|
30
|
+
|
|
31
|
+
for (const participant of this._participants.getScripted()) {
|
|
32
|
+
counts[participant.team] = (counts[participant.team] ?? 0) + 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return counts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// returns how many were actually created — the caller reports that number,
|
|
39
|
+
// not the number asked for
|
|
40
|
+
createScripted(count, teamName = null) {
|
|
41
|
+
if (!this._respawns) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const playableTeams = this._participants.getPlayableTeams();
|
|
46
|
+
let created = 0;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < count; i += 1) {
|
|
49
|
+
if (this._participants.isFull) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// no team asked for: fill the emptiest one that still has room, so a
|
|
54
|
+
// room of bots stays balanced without anyone steering it. Trying only
|
|
55
|
+
// the emptiest team would stall the whole loop once that one team is
|
|
56
|
+
// out of respawn points while its neighbour still has some.
|
|
57
|
+
const targetTeam = teamName ?? this._pickTeam(playableTeams);
|
|
58
|
+
|
|
59
|
+
// team sizes only grow inside this loop, so «no room» is final: the
|
|
60
|
+
// remaining iterations would do nothing but burn `count`
|
|
61
|
+
if (!targetTeam || !this._hasRoom(targetTeam)) {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const gameId = this._participants.createScripted({
|
|
66
|
+
team: targetTeam,
|
|
67
|
+
model: this._model,
|
|
68
|
+
});
|
|
69
|
+
const participant = this._participants.get(gameId);
|
|
70
|
+
|
|
71
|
+
// a bot has no socket, so the engine never writes its latency cell —
|
|
72
|
+
// whatever is passed here is what the table shows for the whole match
|
|
73
|
+
this._stat.addUser(gameId, participant.teamId, {
|
|
74
|
+
name: participant.name,
|
|
75
|
+
status: 'dead',
|
|
76
|
+
latency: 'BOT',
|
|
77
|
+
});
|
|
78
|
+
this._panel.addUser(gameId);
|
|
79
|
+
|
|
80
|
+
created += 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return created;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// the number of respawn points is the hard capacity of a team: past it the
|
|
87
|
+
// engine has nowhere to put the actor
|
|
88
|
+
_hasRoom(teamName) {
|
|
89
|
+
const respawns = this._respawns[teamName];
|
|
90
|
+
|
|
91
|
+
return Boolean(
|
|
92
|
+
respawns && this._participants.getTeamSize(teamName) < respawns.length,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_pickTeam(playableTeams) {
|
|
97
|
+
return (
|
|
98
|
+
[...playableTeams]
|
|
99
|
+
.sort(
|
|
100
|
+
(a, b) =>
|
|
101
|
+
this._participants.getTeamSize(a) -
|
|
102
|
+
this._participants.getTeamSize(b),
|
|
103
|
+
)
|
|
104
|
+
.find(team => this._hasRoom(team)) ?? null
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
removeScripted(teamName = null) {
|
|
109
|
+
const scripted = this._participants.getScripted();
|
|
110
|
+
const toRemove = teamName
|
|
111
|
+
? scripted.filter(participant => participant.team === teamName)
|
|
112
|
+
: scripted;
|
|
113
|
+
|
|
114
|
+
toRemove.forEach(participant => this._remove(participant.gameId));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// a human needs a slot in a full team: free one and say whether it worked
|
|
118
|
+
removeOneForHuman(teamName) {
|
|
119
|
+
for (const participant of this._participants.getScripted()) {
|
|
120
|
+
if (participant.team === teamName) {
|
|
121
|
+
this._remove(participant.gameId);
|
|
122
|
+
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_remove(gameId) {
|
|
131
|
+
const participant = this._participants.get(gameId);
|
|
132
|
+
|
|
133
|
+
if (!participant || !participant.isScripted) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this._stat.removeUser(gameId, participant.teamId);
|
|
138
|
+
this._panel.removeUser(gameId);
|
|
139
|
+
this._coreAdapter.removePlayer(gameId);
|
|
140
|
+
this._participants.remove(gameId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ScriptedManager from './ScriptedManager.js';
|
|
2
|
+
|
|
3
|
+
// Factory of the game's host modules (HostPlugin.createModules). The engine
|
|
4
|
+
// reads exactly ONE key off the result — `scripted`; anything else returned
|
|
5
|
+
// here is never called by it.
|
|
6
|
+
//
|
|
7
|
+
// The context is { participants, coreAdapter, panel, stat, chat,
|
|
8
|
+
// socketManager, scripted } — there is no timerManager and no
|
|
9
|
+
// voteCoordinator in it (those exist only in a chat-command context).
|
|
10
|
+
export default function createModules(ctx) {
|
|
11
|
+
return { scripted: new ScriptedManager(ctx) };
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ENGINE_API_VERSION } from 'vimp-engine/config/opcodes.js';
|
|
2
|
+
import gameConfig from '../config/game.js';
|
|
3
|
+
import authSchema from '../config/auth.js';
|
|
4
|
+
import clientConfig from '../config/client.js';
|
|
5
|
+
import createModules from './createModules.js';
|
|
6
|
+
import spawnCommand from './spawnCommand.js';
|
|
7
|
+
import systemMessages from './systemMessages.js';
|
|
8
|
+
import { isNodeCore, loadNodeCore, loadWebCore } from './nodeCore.js';
|
|
9
|
+
|
|
10
|
+
// HostPlugin — the game half of the authoritative match, Worker-safe: no DOM,
|
|
11
|
+
// no PixiJS, no Node globals. Default export of the host entry
|
|
12
|
+
// (vite build --mode host); the engine loads it by GameManifest.entries.host.
|
|
13
|
+
//
|
|
14
|
+
// Every field below is dereferenced by the engine without a guard, so a
|
|
15
|
+
// missing one is a TypeError far from its cause (docs/ai/03-host-plugin.md).
|
|
16
|
+
export default {
|
|
17
|
+
id: '{{GAME_ID}}',
|
|
18
|
+
// never a literal: a number written by hand agrees with the engine on the
|
|
19
|
+
// day it is typed and silently disagrees after the next release
|
|
20
|
+
engineApi: ENGINE_API_VERSION,
|
|
21
|
+
|
|
22
|
+
// wasmUrl comes from the manifest: init() loads by an explicit URL instead
|
|
23
|
+
// of the glue module's own import.meta.url resolution, which does not
|
|
24
|
+
// survive inside a Worker
|
|
25
|
+
async createCore(coreConfigJson, { wasmUrl } = {}) {
|
|
26
|
+
if (isNodeCore(wasmUrl)) {
|
|
27
|
+
const node = await loadNodeCore(wasmUrl);
|
|
28
|
+
|
|
29
|
+
return new node.GameCore(coreConfigJson);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { default: init, GameCore } = await loadWebCore();
|
|
33
|
+
|
|
34
|
+
// module_or_path — the wasm-bindgen init() option name
|
|
35
|
+
await init({ module_or_path: wasmUrl });
|
|
36
|
+
|
|
37
|
+
return new GameCore(coreConfigJson);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
gameConfig,
|
|
41
|
+
authSchema,
|
|
42
|
+
|
|
43
|
+
// REQUIRED array — the engine iterates it unguarded; `[]` for no commands
|
|
44
|
+
chatCommands: [spawnCommand],
|
|
45
|
+
|
|
46
|
+
// merged into the engine chat registry by a blind Object.assign: a code in
|
|
47
|
+
// an engine group would overwrite an engine message without a word
|
|
48
|
+
systemMessages,
|
|
49
|
+
|
|
50
|
+
// the engine calls it and reads exactly one key off the result: `scripted`
|
|
51
|
+
createModules,
|
|
52
|
+
|
|
53
|
+
// the client half of the config has no file of its own on the client: the
|
|
54
|
+
// host builds it and sends it over
|
|
55
|
+
buildClientGameConfig: () => clientConfig,
|
|
56
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Loading the WASM core, both shapes of it. `wasmUrl` arrives from the
|
|
2
|
+
// manifest and differs between the two runtimes (docs/ai/03-host-plugin.md):
|
|
3
|
+
//
|
|
4
|
+
// browser — the hashed `.wasm` asset (entries.wasm): the `--target web`
|
|
5
|
+
// glue is imported and `init()` fetches the binary by that URL;
|
|
6
|
+
// Node — a file: URL of the `--target nodejs` glue (entries.wasmNode),
|
|
7
|
+
// used by `npm run sim`: that build pulls the wasm in itself, and
|
|
8
|
+
// fetch() cannot read file: URLs anyway.
|
|
9
|
+
//
|
|
10
|
+
// Both halves of the plugin branch through THIS file: a headless run that used
|
|
11
|
+
// a different core than the browser would prove nothing.
|
|
12
|
+
//
|
|
13
|
+
// The web glue is loaded by a dynamic import on purpose. As a static one it
|
|
14
|
+
// would make `src/host/index.js` unimportable in Node until `npm run
|
|
15
|
+
// core:build` has run — and with it `npm run check:contract` and every unit
|
|
16
|
+
// test of the host half.
|
|
17
|
+
|
|
18
|
+
export const isNodeCore = wasmUrl => (wasmUrl ?? '').endsWith('.js');
|
|
19
|
+
|
|
20
|
+
// @vite-ignore: the path is a runtime value, Vite must not try to resolve it
|
|
21
|
+
export const loadNodeCore = wasmUrl => import(/* @vite-ignore */ wasmUrl);
|
|
22
|
+
|
|
23
|
+
export const loadWebCore = () => import('../../core/pkg-web/{{CRATE_SNAKE}}.js');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Chat command '/spawn <count>' — fills the room with bots without a vote.
|
|
2
|
+
// It is also what `npm run dev` uses to get a match going (startupCommands in
|
|
3
|
+
// dev/main.js).
|
|
4
|
+
//
|
|
5
|
+
// The name must not collide with the engine's own commands (/name, /nr,
|
|
6
|
+
// /timeleft, /mapname, /rank): those are matched by a switch BEFORE the game
|
|
7
|
+
// registry, so a same-named command registers fine and never fires.
|
|
8
|
+
export default {
|
|
9
|
+
name: '/spawn',
|
|
10
|
+
|
|
11
|
+
// ctx = { participants, chat, scripted, roundManager, voteCoordinator,
|
|
12
|
+
// timerManager, playerDataSync, teams, spectatorTeam, spectatorId,
|
|
13
|
+
// isDevMode }
|
|
14
|
+
handler(ctx, gameId, args) {
|
|
15
|
+
// the argument is whatever a player typed: '/spawn -3' must not mean
|
|
16
|
+
// zero bots, and '/spawn 1e9' must not mean a billion loop iterations
|
|
17
|
+
const { maxPlayers } = ctx.participants;
|
|
18
|
+
const requested = Math.max(1, Math.trunc(Number(args[0])) || 1);
|
|
19
|
+
const count = maxPlayers > 0 ? Math.min(requested, maxPlayers) : requested;
|
|
20
|
+
const created = ctx.scripted.createScripted(count);
|
|
21
|
+
|
|
22
|
+
// the code is the game's own (src/host/systemMessages.js); the TEXT lives
|
|
23
|
+
// on the client, in modules.chat.params.messages
|
|
24
|
+
ctx.chat.pushSystem('BOTS_SPAWNED', [created]);
|
|
25
|
+
|
|
26
|
+
// restart the round so the fresh bots enter the world at once instead of
|
|
27
|
+
// waiting out the current one as corpses — pointless if nobody was added
|
|
28
|
+
if (created > 0) {
|
|
29
|
+
ctx.roundManager.initiateNewRound();
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|