@genex-ai/cli-demo 1.38.0 → 1.38.1-dev.1098
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/dist/{blender-mcp-QVEGHE5L.js → blender-mcp-WSURPZET.js} +2 -2
- package/dist/{blender-serve-66OHC4ML.js → blender-serve-EH2MOW3U.js} +1 -1
- package/dist/{chunk-4BBTPRJW.js → chunk-EABIVAGT.js} +2 -2
- package/dist/{chunk-QI7FIYBY.js → chunk-K4EHXOZK.js} +1 -1
- package/dist/index.js +1700 -811
- package/package.json +1 -1
- package/templates/skills/genex-authoritative-multiplayer/SKILL.md +208 -0
- package/templates/skills/genex-authoritative-multiplayer/references/module-api.md +172 -0
- package/templates/skills/genex-authoritative-multiplayer/references/physics.md +150 -0
- package/templates/skills/genex-authoritative-multiplayer/references/presets.md +394 -0
- package/templates/skills/genex-authoritative-multiplayer/references/projectiles.md +195 -0
- package/templates/skills/genex-authoritative-multiplayer/references/room-template.md +1189 -0
- package/templates/skills/genex-authoritative-multiplayer/references/settings.md +122 -0
- package/templates/skills/genex-authoritative-multiplayer/references/testing.md +118 -0
- package/templates/skills/genex-game-director/SKILL.md +2 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-llm-in-games/SKILL.md +209 -72
- package/templates/skills/genex-llm-in-games/references/pricing.md +126 -85
- package/templates/skills/genex-monetization/SKILL.md +2 -11
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -1
- package/templates/skills/genex-threejs-embed-auth/references/shells.md +104 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +4 -0
- package/templates/skills/genex-tool-llm/SKILL.md +11 -9
- package/templates/skills/genex-tool-publish/SKILL.md +7 -0
- package/templates/skills/genex-updates/SKILL.md +1 -1
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
# Room templates
|
|
2
|
+
|
|
3
|
+
Four complete server-authoritative games to start from. Each passes `npx genex-authority-dev
|
|
4
|
+
validate` as written; the fourth uses server physics ([physics.md](physics.md)), which needs a
|
|
5
|
+
validator with the physics core.
|
|
6
|
+
|
|
7
|
+
1. **A top-down arena**: an instant shot along the aim (hitscan, lag-compensated).
|
|
8
|
+
2. **A bolt arena**: bolts players can see and dodge, bouncing off the walls (projectiles, see
|
|
9
|
+
[projectiles.md](projectiles.md)). It reuses the first template's client setup.
|
|
10
|
+
3. **A car arena**: two against two with the `vehicle` preset in a walled arena, and one ball
|
|
11
|
+
(a projectile the module re-launches, see [presets.md](presets.md)).
|
|
12
|
+
4. **A physics car arena**: the car arena with a real physics ball and four crate stacks the server
|
|
13
|
+
simulates (see [physics.md](physics.md)). It reuses the third template's world and client.
|
|
14
|
+
|
|
15
|
+
## Template 1: a top-down arena
|
|
16
|
+
|
|
17
|
+
Players move with the `topdown` preset and fire an instant shot along their aim, judged against
|
|
18
|
+
where the shooter saw its targets. Three hits take a player down; they respawn elsewhere and the
|
|
19
|
+
shooter scores.
|
|
20
|
+
|
|
21
|
+
### The room module
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// server/room.js: runs on Genex's servers (and the local authority server), never in a browser.
|
|
25
|
+
|
|
26
|
+
/** The fire button's bit in input.buttons; the client sends the same bit. */
|
|
27
|
+
const FIRE = 1;
|
|
28
|
+
/** Half the arena's side, world units: the preset keeps every body inside it. */
|
|
29
|
+
const ARENA = 20;
|
|
30
|
+
/** Movement speed, units per second. */
|
|
31
|
+
const SPEED = 6;
|
|
32
|
+
/** A player's hit radius, units. */
|
|
33
|
+
const RADIUS = 0.6;
|
|
34
|
+
/** How far a shot reaches, units. */
|
|
35
|
+
const RANGE = 30;
|
|
36
|
+
/** Ticks between two shots of one player: 5 shots a second at 30 Hz. */
|
|
37
|
+
const COOLDOWN_TICKS = 6;
|
|
38
|
+
/** Hits that take a player down. */
|
|
39
|
+
const MAX_HP = 3;
|
|
40
|
+
|
|
41
|
+
/** Marks the room module, as defineRoom from @genex-ai/multiplayer/authority/module does. */
|
|
42
|
+
const defineRoom = (room) => room;
|
|
43
|
+
|
|
44
|
+
/** Back to full health at a seeded spot; the epoch bump makes every client snap there. */
|
|
45
|
+
function respawn(ctx, id) {
|
|
46
|
+
const body = ctx.entities.get(id);
|
|
47
|
+
const angle = ctx.random() * Math.PI * 2;
|
|
48
|
+
ctx.entities.update(id, {
|
|
49
|
+
x: Math.fround(Math.cos(angle) * ARENA * 0.6),
|
|
50
|
+
y: Math.fround(Math.sin(angle) * ARENA * 0.6),
|
|
51
|
+
hp: MAX_HP,
|
|
52
|
+
epoch: body ? body.epoch + 1 : 0,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One shot along the shooter's aim; the third hit scores and respawns the target. */
|
|
57
|
+
function fire(ctx, playerId) {
|
|
58
|
+
const me = ctx.entities.ownedBy(playerId);
|
|
59
|
+
if (!me) return;
|
|
60
|
+
const hit = ctx.rewind.hitscan(
|
|
61
|
+
{ x: me.x, y: me.y, z: 0 },
|
|
62
|
+
{ x: Math.cos(me.yaw), y: Math.sin(me.yaw), z: 0 },
|
|
63
|
+
{ range: RANGE, radius: RADIUS, kinds: ["player"] },
|
|
64
|
+
);
|
|
65
|
+
if (!hit) {
|
|
66
|
+
ctx.emit("shot", { shooter: playerId, hit: null, dist: RANGE });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const target = ctx.entities.get(hit.id);
|
|
70
|
+
ctx.emit("shot", { shooter: playerId, hit: target.owner, dist: Math.fround(hit.dist) });
|
|
71
|
+
if (target.hp > 1) {
|
|
72
|
+
ctx.entities.update(target.id, { hp: target.hp - 1 });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const score = (ctx.state.score[playerId] ?? 0) + 1;
|
|
76
|
+
ctx.state.score[playerId] = score;
|
|
77
|
+
ctx.entities.update(me.id, { data: { score } });
|
|
78
|
+
ctx.emit("kill", { by: playerId, victim: target.owner });
|
|
79
|
+
respawn(ctx, target.id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default defineRoom({
|
|
83
|
+
contract: 1,
|
|
84
|
+
config: {
|
|
85
|
+
tickHz: 30,
|
|
86
|
+
maxPlayers: 8,
|
|
87
|
+
preset: { name: "topdown", params: { speed: SPEED, arena: ARENA } },
|
|
88
|
+
lagComp: { buttons: FIRE },
|
|
89
|
+
},
|
|
90
|
+
init() {
|
|
91
|
+
return { cooldown: {}, score: {} };
|
|
92
|
+
},
|
|
93
|
+
onJoin(ctx, player) {
|
|
94
|
+
const id = ctx.entities.spawn({ kind: "player", owner: player.id, hp: MAX_HP, data: { score: 0 } });
|
|
95
|
+
respawn(ctx, id);
|
|
96
|
+
ctx.state.cooldown[player.id] = 0;
|
|
97
|
+
ctx.state.score[player.id] = 0;
|
|
98
|
+
},
|
|
99
|
+
onLeave(ctx, player) {
|
|
100
|
+
const body = ctx.entities.ownedBy(player.id);
|
|
101
|
+
if (body) ctx.entities.remove(body.id);
|
|
102
|
+
delete ctx.state.cooldown[player.id];
|
|
103
|
+
delete ctx.state.score[player.id];
|
|
104
|
+
},
|
|
105
|
+
onInput(ctx, playerId, input) {
|
|
106
|
+
// The preset has already moved and turned this player's body by this input.
|
|
107
|
+
if ((input.buttons & FIRE) === 0 || ctx.state.cooldown[playerId] > 0) return;
|
|
108
|
+
fire(ctx, playerId);
|
|
109
|
+
ctx.state.cooldown[playerId] = COOLDOWN_TICKS;
|
|
110
|
+
},
|
|
111
|
+
step(ctx) {
|
|
112
|
+
for (const id of Object.keys(ctx.state.cooldown)) {
|
|
113
|
+
ctx.state.cooldown[id] = Math.max(0, ctx.state.cooldown[id] - 1);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
package.json:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{ "genex": { "authority": { "module": "server/room.js", "maxPlayers": 8 } } }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Grow from here: pickups are entities the module spawns and removes; shots players can see and
|
|
126
|
+
dodge are projectiles (template 2); rounds and a scoreboard are `ctx.state` plus events. Anything
|
|
127
|
+
the module writes to an entity reaches every client; anything in `ctx.state` stays on the server.
|
|
128
|
+
|
|
129
|
+
### The client, with three.js
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// src/arena.ts: joins the room, sends input, draws what the server says.
|
|
133
|
+
import * as THREE from "three";
|
|
134
|
+
import { connectAuthority, type AuthorityEntityView } from "@genex-ai/multiplayer/authority";
|
|
135
|
+
import { getAuthorityUrls, getColyseusAuth, waitForPlayer } from "@genex-ai/embed-sdk";
|
|
136
|
+
import { GENEX } from "./genex.config";
|
|
137
|
+
|
|
138
|
+
/** The fire button: the same bit as FIRE in server/room.js. */
|
|
139
|
+
const FIRE = 1;
|
|
140
|
+
/** The arena's half side, as ARENA in server/room.js. */
|
|
141
|
+
const ARENA = 20;
|
|
142
|
+
|
|
143
|
+
/** A refused join's code (JoinError) in the player's words. */
|
|
144
|
+
const REFUSED: Record<number, string> = {
|
|
145
|
+
402: "Online play isn't available for this game right now.",
|
|
146
|
+
424: "This game's server isn't ready yet.",
|
|
147
|
+
426: "This game needs an update to play online.",
|
|
148
|
+
429: "The game's servers are busy right now. Try again later.",
|
|
149
|
+
503: "No game server is free right now. Try again in a moment.",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
153
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
154
|
+
document.body.append(renderer.domElement);
|
|
155
|
+
const scene = new THREE.Scene();
|
|
156
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
|
|
157
|
+
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 200);
|
|
158
|
+
camera.position.set(0, 34, 0);
|
|
159
|
+
camera.up.set(0, 0, -1); // the top of the screen is the server's -y
|
|
160
|
+
camera.lookAt(0, 0, 0);
|
|
161
|
+
const floor = new THREE.Mesh(new THREE.PlaneGeometry(ARENA * 2, ARENA * 2), new THREE.MeshStandardMaterial({ color: 0x2a3340 }));
|
|
162
|
+
floor.rotation.x = -Math.PI / 2;
|
|
163
|
+
scene.add(floor);
|
|
164
|
+
const hud = document.createElement("div");
|
|
165
|
+
hud.style.cssText = "position:fixed;left:12px;top:12px;color:#fff;font:14px system-ui";
|
|
166
|
+
document.body.append(hud);
|
|
167
|
+
|
|
168
|
+
const keys = new Set<string>();
|
|
169
|
+
addEventListener("keydown", (e) => keys.add(e.code));
|
|
170
|
+
addEventListener("keyup", (e) => keys.delete(e.code));
|
|
171
|
+
const pointer = new THREE.Vector2();
|
|
172
|
+
let firing = false;
|
|
173
|
+
addEventListener("pointermove", (e) => pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1));
|
|
174
|
+
renderer.domElement.addEventListener("pointerdown", () => (firing = true));
|
|
175
|
+
addEventListener("pointerup", () => (firing = false));
|
|
176
|
+
const raycaster = new THREE.Raycaster();
|
|
177
|
+
const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
178
|
+
const aim = new THREE.Vector3();
|
|
179
|
+
|
|
180
|
+
await waitForPlayer();
|
|
181
|
+
const session = await connectAuthority({
|
|
182
|
+
urls: getAuthorityUrls(),
|
|
183
|
+
room: GENEX.slug,
|
|
184
|
+
auth: () => getColyseusAuth(),
|
|
185
|
+
}).catch((err: { code?: number }) => {
|
|
186
|
+
hud.textContent = REFUSED[err.code ?? 0] ?? "Couldn't join the game.";
|
|
187
|
+
throw err;
|
|
188
|
+
});
|
|
189
|
+
session.on("disconnect", (code) => {
|
|
190
|
+
hud.textContent = code === 4500 ? "The game was stopped." : "Disconnected from the game.";
|
|
191
|
+
});
|
|
192
|
+
session.on("kill", () => (hud.textContent = `score ${JSON.parse(session.me()?.data || "{}").score ?? 0}`));
|
|
193
|
+
|
|
194
|
+
/** A body: a capsule with a nose along +x, so its aim shows. */
|
|
195
|
+
function makeBody(mine: boolean): THREE.Group {
|
|
196
|
+
const body = new THREE.Group();
|
|
197
|
+
body.add(new THREE.Mesh(new THREE.CapsuleGeometry(0.5, 0.6), new THREE.MeshStandardMaterial({ color: mine ? 0x4cc3ff : 0xff6b5b })));
|
|
198
|
+
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.2, 0.2), new THREE.MeshStandardMaterial({ color: 0xffffff }));
|
|
199
|
+
nose.position.x = 0.6;
|
|
200
|
+
body.add(nose);
|
|
201
|
+
return body;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const bodies = new Map<string, THREE.Group>();
|
|
205
|
+
function draw(entities: Map<string, AuthorityEntityView>): void {
|
|
206
|
+
for (const [id, e] of entities) {
|
|
207
|
+
let body = bodies.get(id);
|
|
208
|
+
if (!body) {
|
|
209
|
+
body = makeBody(e.owner === session.id);
|
|
210
|
+
scene.add(body);
|
|
211
|
+
bodies.set(id, body);
|
|
212
|
+
}
|
|
213
|
+
body.position.set(e.x, 0.8, e.y); // the server's y is three's z
|
|
214
|
+
body.rotation.y = -e.yaw;
|
|
215
|
+
}
|
|
216
|
+
for (const [id, body] of bodies) {
|
|
217
|
+
if (entities.has(id)) continue;
|
|
218
|
+
scene.remove(body);
|
|
219
|
+
bodies.delete(id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function loop(now: number): void {
|
|
224
|
+
const me = session.me();
|
|
225
|
+
session.input.moveX = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0);
|
|
226
|
+
session.input.moveY = (keys.has("KeyS") ? 1 : 0) - (keys.has("KeyW") ? 1 : 0);
|
|
227
|
+
raycaster.setFromCamera(pointer, camera);
|
|
228
|
+
if (me && raycaster.ray.intersectPlane(ground, aim)) session.input.yaw = Math.atan2(aim.z - me.y, aim.x - me.x);
|
|
229
|
+
session.input.buttons = firing ? FIRE : 0;
|
|
230
|
+
session.frame(now);
|
|
231
|
+
draw(session.entities());
|
|
232
|
+
renderer.render(scene, camera);
|
|
233
|
+
requestAnimationFrame(loop);
|
|
234
|
+
}
|
|
235
|
+
requestAnimationFrame(loop);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
What it shows, and what to keep when you change it:
|
|
239
|
+
|
|
240
|
+
- **Input is all the client decides.** It writes the stick, the aim and the fire bit into
|
|
241
|
+
`session.input`; `session.frame(now)` sends them at the room's tick rate.
|
|
242
|
+
- **The player's own body is predicted** by the session with the room's `topdown` preset; it moves
|
|
243
|
+
the instant a key is down. Every other body is drawn about 100 ms in the past.
|
|
244
|
+
- **Hits, health and score come only from the server**: the `shot` and `kill` events and each
|
|
245
|
+
entity's `hp` and `data`. A tracer drawn on `shot` lines up with what the server judged.
|
|
246
|
+
- **A respawn snaps** because the module bumps `epoch`; nothing in the client needs to know.
|
|
247
|
+
- **A refused join is said in the player's words** and never retried in a loop.
|
|
248
|
+
|
|
249
|
+
A game whose identity setup is not done yet loads `$genex-threejs-embed-auth` first: `initEmbed` runs
|
|
250
|
+
before anything here.
|
|
251
|
+
|
|
252
|
+
## Template 2: a bolt arena (projectiles)
|
|
253
|
+
|
|
254
|
+
Players move with the same `topdown` preset and fire bolts: projectiles that fly at 16 units a
|
|
255
|
+
second, bounce off the arena's walls twice and are judged by a sweep along their path every tick, so
|
|
256
|
+
a bolt never passes through a body between two ticks. Three hits take a player down. The shooter's
|
|
257
|
+
client draws its bolt the frame it fires; everyone else sees it where the server has it now. Read
|
|
258
|
+
[projectiles.md](projectiles.md) for the rules behind each line.
|
|
259
|
+
|
|
260
|
+
### The room module
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
// server/room.js (bolt arena): runs on Genex's servers (and the local authority server), never in a browser.
|
|
264
|
+
|
|
265
|
+
/** The fire button's bit in input.buttons; the client sends the same bit. */
|
|
266
|
+
const FIRE = 1;
|
|
267
|
+
/** Half the arena's side, world units: the preset keeps bodies inside it, and bolts bounce off it. */
|
|
268
|
+
const ARENA = 20;
|
|
269
|
+
/** Movement speed, units per second. */
|
|
270
|
+
const SPEED = 6;
|
|
271
|
+
/** A bolt's speed along the aim, units per second. The client predicts with the same number. */
|
|
272
|
+
const BOLT_SPEED = 16;
|
|
273
|
+
/** A body's radius plus a bolt's: how near a bolt's path must pass a body's centre to hit it. */
|
|
274
|
+
const HIT_RADIUS = 0.8;
|
|
275
|
+
/** Walls a bolt bounces off; the next wall removes it. */
|
|
276
|
+
const MAX_BOUNCES = 2;
|
|
277
|
+
/** A bolt still flying this many ticks after it was fired is removed (3 s at 30 Hz). */
|
|
278
|
+
const MAX_AGE_TICKS = 90;
|
|
279
|
+
/** Ticks between two shots of one player: at most 6 bolts each in the air, so hits per tick stay
|
|
280
|
+
* well under the 64 events a tick may send. */
|
|
281
|
+
const COOLDOWN_TICKS = 15;
|
|
282
|
+
/** Hits that take a player down. */
|
|
283
|
+
const MAX_HP = 3;
|
|
284
|
+
|
|
285
|
+
/** Marks the room module, as defineRoom from @genex-ai/multiplayer/authority/module does. */
|
|
286
|
+
const defineRoom = (room) => room;
|
|
287
|
+
|
|
288
|
+
/** Back to full health at a seeded spot; the epoch bump makes every client snap there. */
|
|
289
|
+
function respawn(ctx, id) {
|
|
290
|
+
const body = ctx.entities.get(id);
|
|
291
|
+
const angle = ctx.random() * Math.PI * 2;
|
|
292
|
+
ctx.entities.update(id, {
|
|
293
|
+
x: Math.fround(Math.cos(angle) * ARENA * 0.6),
|
|
294
|
+
y: Math.fround(Math.sin(angle) * ARENA * 0.6),
|
|
295
|
+
hp: MAX_HP,
|
|
296
|
+
epoch: body ? body.epoch + 1 : 0,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Fires a bolt from the shooter's body along its aim. Launched while handling the input, it gets
|
|
301
|
+
* the id the shooter's client predicted it under (`<seat>.<seq>.0`). */
|
|
302
|
+
function fire(ctx, playerId) {
|
|
303
|
+
const me = ctx.entities.ownedBy(playerId);
|
|
304
|
+
if (!me) return false;
|
|
305
|
+
const id = ctx.projectiles.launch({
|
|
306
|
+
kind: "bolt",
|
|
307
|
+
x: me.x,
|
|
308
|
+
y: me.y,
|
|
309
|
+
z: 0,
|
|
310
|
+
vx: Math.cos(me.yaw) * BOLT_SPEED,
|
|
311
|
+
vy: Math.sin(me.yaw) * BOLT_SPEED,
|
|
312
|
+
vz: 0,
|
|
313
|
+
});
|
|
314
|
+
// A bounce re-launches the bolt from step, which clears its src: who fired it is kept here.
|
|
315
|
+
ctx.state.bolts.push({ id, shooter: playerId, born: ctx.tick, bounces: 0 });
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The nearest body this bolt's path crossed since the last tick, other than its shooter's own. */
|
|
320
|
+
function victimOf(ctx, bolt) {
|
|
321
|
+
for (const hit of ctx.projectiles.sweep(bolt.id, { kinds: ["player"], radius: HIT_RADIUS })) {
|
|
322
|
+
const body = ctx.entities.get(hit.id);
|
|
323
|
+
if (body && body.owner !== bolt.shooter) return body;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One hit: a point of damage, and on the last one a respawn and a point for the shooter. */
|
|
329
|
+
function strike(ctx, bolt, body) {
|
|
330
|
+
const down = body.hp <= 1;
|
|
331
|
+
if (down) {
|
|
332
|
+
respawn(ctx, body.id);
|
|
333
|
+
// The shooter may have left; its bolts fly on, but nobody scores.
|
|
334
|
+
if (ctx.state.score[bolt.shooter] !== undefined) {
|
|
335
|
+
const score = ctx.state.score[bolt.shooter] + 1;
|
|
336
|
+
ctx.state.score[bolt.shooter] = score;
|
|
337
|
+
const shooter = ctx.entities.ownedBy(bolt.shooter);
|
|
338
|
+
if (shooter) ctx.entities.update(shooter.id, { data: { score } });
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
ctx.entities.update(body.id, { hp: body.hp - 1 });
|
|
342
|
+
}
|
|
343
|
+
ctx.emit("hit", { bolt: bolt.id, shooter: bolt.shooter, victim: body.owner, down });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A bolt past a wall flies on from its mirror image inside, that velocity reversed: a re-launch
|
|
347
|
+
* under the same id, which every client draws from the new launch. */
|
|
348
|
+
function bounce(ctx, bolt, at) {
|
|
349
|
+
const outX = Math.abs(at.x) > ARENA;
|
|
350
|
+
const outY = Math.abs(at.y) > ARENA;
|
|
351
|
+
ctx.projectiles.launch({
|
|
352
|
+
id: bolt.id,
|
|
353
|
+
kind: "bolt",
|
|
354
|
+
x: outX ? Math.sign(at.x) * 2 * ARENA - at.x : at.x,
|
|
355
|
+
y: outY ? Math.sign(at.y) * 2 * ARENA - at.y : at.y,
|
|
356
|
+
z: 0,
|
|
357
|
+
vx: outX ? -at.vx : at.vx,
|
|
358
|
+
vy: outY ? -at.vy : at.vy,
|
|
359
|
+
vz: 0,
|
|
360
|
+
});
|
|
361
|
+
bolt.bounces += 1;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Judges one bolt this tick; false once it is gone. The server never removes one by itself. */
|
|
365
|
+
function fly(ctx, bolt) {
|
|
366
|
+
if (!ctx.entities.get(bolt.id)) return false;
|
|
367
|
+
const body = victimOf(ctx, bolt);
|
|
368
|
+
if (body) {
|
|
369
|
+
strike(ctx, bolt, body);
|
|
370
|
+
ctx.entities.remove(bolt.id);
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
const at = ctx.projectiles.at(bolt.id);
|
|
374
|
+
const inside = Math.abs(at.x) <= ARENA && Math.abs(at.y) <= ARENA;
|
|
375
|
+
if (ctx.tick - bolt.born >= MAX_AGE_TICKS || (!inside && bolt.bounces >= MAX_BOUNCES)) {
|
|
376
|
+
ctx.entities.remove(bolt.id);
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
if (!inside) bounce(ctx, bolt, at);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export default defineRoom({
|
|
384
|
+
contract: 1,
|
|
385
|
+
config: {
|
|
386
|
+
tickHz: 30,
|
|
387
|
+
maxPlayers: 8,
|
|
388
|
+
preset: { name: "topdown", params: { speed: SPEED, arena: ARENA } },
|
|
389
|
+
},
|
|
390
|
+
init() {
|
|
391
|
+
// bolts: the bolts in the air, in launch order (the order step judges them in).
|
|
392
|
+
return { cooldown: {}, score: {}, bolts: [] };
|
|
393
|
+
},
|
|
394
|
+
onJoin(ctx, player) {
|
|
395
|
+
const id = ctx.entities.spawn({ kind: "player", owner: player.id, hp: MAX_HP, data: { score: 0 } });
|
|
396
|
+
respawn(ctx, id);
|
|
397
|
+
ctx.state.cooldown[player.id] = 0;
|
|
398
|
+
ctx.state.score[player.id] = 0;
|
|
399
|
+
},
|
|
400
|
+
onLeave(ctx, player) {
|
|
401
|
+
const body = ctx.entities.ownedBy(player.id);
|
|
402
|
+
if (body) ctx.entities.remove(body.id);
|
|
403
|
+
delete ctx.state.cooldown[player.id];
|
|
404
|
+
delete ctx.state.score[player.id];
|
|
405
|
+
},
|
|
406
|
+
onInput(ctx, playerId, input) {
|
|
407
|
+
// The preset has already moved and turned this player's body by this input.
|
|
408
|
+
if ((input.buttons & FIRE) === 0 || ctx.state.cooldown[playerId] > 0) return;
|
|
409
|
+
if (fire(ctx, playerId)) ctx.state.cooldown[playerId] = COOLDOWN_TICKS;
|
|
410
|
+
},
|
|
411
|
+
step(ctx) {
|
|
412
|
+
ctx.state.bolts = ctx.state.bolts.filter((bolt) => fly(ctx, bolt));
|
|
413
|
+
for (const id of Object.keys(ctx.state.cooldown)) {
|
|
414
|
+
ctx.state.cooldown[id] = Math.max(0, ctx.state.cooldown[id] - 1);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
package.json:
|
|
421
|
+
|
|
422
|
+
```json
|
|
423
|
+
{ "genex": { "authority": { "module": "server/room.js", "maxPlayers": 8 } } }
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
There is no `lagComp`: projectiles are judged where the bodies are on the server now, and the
|
|
427
|
+
bounce reverses one velocity component from the bolt's mirror image inside the wall.
|
|
428
|
+
|
|
429
|
+
### The client, with three.js
|
|
430
|
+
|
|
431
|
+
Start from template 1's client, in the same file: keep its imports, everything above `makeBody`
|
|
432
|
+
(the renderer, camera, floor, HUD, keys, pointer, `FIRE`, `REFUSED` and the join) and `makeBody`
|
|
433
|
+
itself, drop its `kill` handler, and replace its `bodies`, `draw` and `loop` with this:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
// src/arena.ts, template 2: bolts drawn from their flight; your own predicted the frame you fire.
|
|
437
|
+
/** The bolt's speed: BOLT_SPEED in server/room.js. */
|
|
438
|
+
const BOLT_SPEED = 16;
|
|
439
|
+
/** Inputs between two shots: the module's COOLDOWN_TICKS (15) plus slack for jitter, so the server
|
|
440
|
+
* never refuses a bolt this client already drew. */
|
|
441
|
+
const FIRE_EVERY_INPUTS = 18;
|
|
442
|
+
/** How high bolts fly above the floor; the server launches them at z = 0. */
|
|
443
|
+
const BOLT_HEIGHT = 0.8;
|
|
444
|
+
|
|
445
|
+
const boltGeometry = new THREE.SphereGeometry(0.2);
|
|
446
|
+
const mineMaterial = new THREE.MeshBasicMaterial({ color: 0x9be7ff });
|
|
447
|
+
const theirsMaterial = new THREE.MeshBasicMaterial({ color: 0xffb347 });
|
|
448
|
+
|
|
449
|
+
/** Every drawn entity by id. A predicted bolt keeps its id when the server's confirms it, so one
|
|
450
|
+
* mesh flies from the click to the hit. */
|
|
451
|
+
const meshes = new Map<string, THREE.Object3D>();
|
|
452
|
+
function draw(entities: Map<string, AuthorityEntityView>): void {
|
|
453
|
+
for (const [id, e] of entities) {
|
|
454
|
+
const bolt = e.t0 > 0; // a projectile
|
|
455
|
+
let mesh = meshes.get(id);
|
|
456
|
+
if (!mesh) {
|
|
457
|
+
mesh = bolt ? new THREE.Mesh(boltGeometry, e.src === session.id ? mineMaterial : theirsMaterial) : makeBody(e.owner === session.id);
|
|
458
|
+
scene.add(mesh);
|
|
459
|
+
meshes.set(id, mesh);
|
|
460
|
+
}
|
|
461
|
+
if (bolt) {
|
|
462
|
+
mesh.position.set(e.x, BOLT_HEIGHT + e.z, e.y); // where its flight is now: never smooth it
|
|
463
|
+
} else {
|
|
464
|
+
mesh.position.set(e.x, 0.8, e.y); // the server's y is three's z
|
|
465
|
+
mesh.rotation.y = -e.yaw;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (const [id, mesh] of meshes) {
|
|
469
|
+
if (entities.has(id)) continue; // gone: a hit, a wall after the last bounce, or a refused shot
|
|
470
|
+
scene.remove(mesh);
|
|
471
|
+
meshes.delete(id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
session.on("hit", (data) => {
|
|
476
|
+
const hit = data as { shooter: string; victim: string; down: boolean };
|
|
477
|
+
if (hit.victim === session.id) hud.textContent = hit.down ? "You're down" : "You're hit";
|
|
478
|
+
else if (hit.shooter === session.id) hud.textContent = hit.down ? "Down!" : "Hit!";
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
/** A fire input is staged and not sent yet, and inputsSent when the last one went. */
|
|
482
|
+
let armed = false;
|
|
483
|
+
let lastShot = -Infinity;
|
|
484
|
+
|
|
485
|
+
function loop(now: number): void {
|
|
486
|
+
const me = session.me();
|
|
487
|
+
session.input.moveX = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0);
|
|
488
|
+
session.input.moveY = (keys.has("KeyS") ? 1 : 0) - (keys.has("KeyW") ? 1 : 0);
|
|
489
|
+
raycaster.setFromCamera(pointer, camera);
|
|
490
|
+
// float32, as the server stores it, so the prediction flies along the module's own aim
|
|
491
|
+
if (me && raycaster.ray.intersectPlane(ground, aim)) session.input.yaw = Math.fround(Math.atan2(aim.z - me.y, aim.x - me.x));
|
|
492
|
+
if (me && firing && !armed && session.telemetry.inputsSent - lastShot >= FIRE_EVERY_INPUTS) {
|
|
493
|
+
const yaw = session.input.yaw;
|
|
494
|
+
session.input.buttons = FIRE;
|
|
495
|
+
// The module's launch for this input, predicted before the frame() that sends it.
|
|
496
|
+
session.predictProjectile({ kind: "bolt", x: me.x, y: me.y, z: 0, vx: Math.cos(yaw) * BOLT_SPEED, vy: Math.sin(yaw) * BOLT_SPEED, vz: 0 });
|
|
497
|
+
armed = true;
|
|
498
|
+
}
|
|
499
|
+
const sent = session.frame(now);
|
|
500
|
+
if (armed && sent > 0) {
|
|
501
|
+
session.input.buttons = 0; // only the input the prediction was made for fires
|
|
502
|
+
armed = false;
|
|
503
|
+
lastShot = session.telemetry.inputsSent;
|
|
504
|
+
}
|
|
505
|
+
draw(session.entities());
|
|
506
|
+
renderer.render(scene, camera);
|
|
507
|
+
requestAnimationFrame(loop);
|
|
508
|
+
}
|
|
509
|
+
requestAnimationFrame(loop);
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
What it shows, on top of template 1:
|
|
513
|
+
|
|
514
|
+
- **One prediction per shot, made while the firing input is staged.** `predictProjectile` runs
|
|
515
|
+
before the `frame()` that sends the fire bit, with the module's own start point, speed and aim;
|
|
516
|
+
the button is cleared once that input is sent, so no other input fires.
|
|
517
|
+
- **The client never moves a bolt.** Every bolt's `x`, `y`, `z` comes from `session.entities()`,
|
|
518
|
+
already where its flight is this frame: your own from the click, everyone else's at the server's
|
|
519
|
+
present. A bounce is a re-launch, so a bolt turns at the wall on every screen without any client
|
|
520
|
+
code.
|
|
521
|
+
- **The cooldown is mirrored with slack.** The client waits a few inputs longer than the module's
|
|
522
|
+
cooldown. A shot the server refuses anyway, or one that hits point blank before any state patch
|
|
523
|
+
carries it, simply leaves `session.entities()`; the `hit` event is what says something was hit.
|
|
524
|
+
- **Hits, health and score still come only from the server**: the `hit` event, `hp` and `data`.
|
|
525
|
+
|
|
526
|
+
## Template 3: a car arena (`vehicle`, and a ball)
|
|
527
|
+
|
|
528
|
+
Up to four cars, two a team, drive with the `vehicle` preset in a walled arena with a ceiling and
|
|
529
|
+
a goal mouth painted on each end wall, and play one ball. Boost refills slowly; a kickoff gives every
|
|
530
|
+
car a third of a tank. The ball is one server projectile the module re-launches whenever its flight
|
|
531
|
+
changes: off a car at their first contact in the tick, off the floor, walls and ceiling with
|
|
532
|
+
restitution, and every few ticks while it rolls, with rolling friction. A ball that touches an end
|
|
533
|
+
wall inside its goal mouth scores, and a kickoff puts the ball and every car back. The rules behind
|
|
534
|
+
each line are in [presets.md](presets.md) ("Running a ball").
|
|
535
|
+
|
|
536
|
+
### The world, shared by both sides
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
// src/car-world.ts: imported by server/room.js AND by the client, so both step through one world.
|
|
540
|
+
|
|
541
|
+
/** The arena (z up, units about a centimetre): half its length to the end walls, half its width,
|
|
542
|
+
* and the ceiling's height. */
|
|
543
|
+
export const X = 3072;
|
|
544
|
+
export const Y = 2048;
|
|
545
|
+
export const CEILING = 1536;
|
|
546
|
+
/** Wall thickness: more than the ball's diameter, so a ball never passes through one in a tick. */
|
|
547
|
+
export const WALL = 256;
|
|
548
|
+
/** Each goal mouth, painted on an end wall: half its width, and its height. */
|
|
549
|
+
export const GOAL_W = 640;
|
|
550
|
+
export const GOAL_H = 512;
|
|
551
|
+
/** The car's hull (the preset's radius and height) and the ball's radius. */
|
|
552
|
+
export const CAR_R = 50;
|
|
553
|
+
export const CAR_H = 36;
|
|
554
|
+
export const BALL_R = 92;
|
|
555
|
+
|
|
556
|
+
/** The collision world: boxes [minX, minY, minZ, maxX, maxY, maxZ]. Every value an integer, so a
|
|
557
|
+
* float32. The module exports it and the client hands it to its session: one world, one hash. */
|
|
558
|
+
export const WORLD = {
|
|
559
|
+
boxes: [
|
|
560
|
+
[-X - WALL, -Y - WALL, -64, X + WALL, Y + WALL, 0], // 0: the floor
|
|
561
|
+
[-X - WALL, -Y - WALL, CEILING, X + WALL, Y + WALL, CEILING + WALL], // 1: the ceiling
|
|
562
|
+
[-X, -Y - WALL, 0, X, -Y, CEILING], // 2, 3: the side walls
|
|
563
|
+
[-X, Y, 0, X, Y + WALL, CEILING],
|
|
564
|
+
[X, -Y - WALL, 0, X + WALL, Y + WALL, CEILING], // 4, 5: the end walls, a goal painted on each
|
|
565
|
+
[-X - WALL, -Y - WALL, 0, -X, Y + WALL, CEILING],
|
|
566
|
+
],
|
|
567
|
+
ramps: [],
|
|
568
|
+
};
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### The room module
|
|
572
|
+
|
|
573
|
+
```js
|
|
574
|
+
// server/room.js (car arena): runs on Genex's servers (and the local authority server), never in a browser.
|
|
575
|
+
import { BALL_R, CAR_H, CAR_R, GOAL_H, GOAL_W, WORLD, X } from "../src/car-world";
|
|
576
|
+
|
|
577
|
+
/** Spawn slots, two a team, and the heading each team faces: blue attacks +x, orange attacks -x
|
|
578
|
+
* (-float32(pi): the wire's own value for a heading of pi). */
|
|
579
|
+
const SLOTS = { blue: [[-1536, -512], [-1536, 512]], orange: [[1536, 512], [1536, -512]] };
|
|
580
|
+
const HEADING = { blue: 0, orange: -Math.fround(Math.PI) };
|
|
581
|
+
/** Fuel at a kickoff, and what a car gains every REFILL_TICKS ticks (a full tank in 10 s at 60 Hz). */
|
|
582
|
+
const KICKOFF_FUEL = Math.fround(0.34);
|
|
583
|
+
const REFILL = Math.fround(0.05);
|
|
584
|
+
const REFILL_TICKS = 30;
|
|
585
|
+
|
|
586
|
+
/** The ball: its gravity; restitution off the world, and the tangential speed a real impact
|
|
587
|
+
* (faster than IMPACT into the surface) keeps; restitution off a car, the share of the car's speed
|
|
588
|
+
* a hit adds, and the least speed a hit leaves it with, MIN_HIT_K times the car's own speed into
|
|
589
|
+
* it and at most MIN_HIT (a parked car adds nothing). */
|
|
590
|
+
const BALL_G = 650;
|
|
591
|
+
const WORLD_E = 0.6;
|
|
592
|
+
const WORLD_KEEP = 0.85;
|
|
593
|
+
const IMPACT = 120;
|
|
594
|
+
const CAR_E = 0.3;
|
|
595
|
+
const HIT_SHARE = 0.25;
|
|
596
|
+
const MIN_HIT = 300;
|
|
597
|
+
const MIN_HIT_K = 2;
|
|
598
|
+
/** A car driving into the ball at least this fast along the contact normal touches it. */
|
|
599
|
+
const TOUCH_DRIVE = 50;
|
|
600
|
+
/** The car-ball sweep: samples over the tick, then halvings of the sample that first touches. */
|
|
601
|
+
const SAMPLES = 8;
|
|
602
|
+
const HALVINGS = 8;
|
|
603
|
+
/** Under the ball's radius per tick at 60 Hz (92 * 60), so it never passes through a wall. */
|
|
604
|
+
const BALL_MAX = 4800;
|
|
605
|
+
/** While rolling: every ROLL_TICKS ticks its speed s becomes
|
|
606
|
+
* max(0, s * (1 - ROLL_K * ROLL_TICKS * dt) - ROLL_DECEL * ROLL_TICKS * dt), at rest below STOP_SPEED. */
|
|
607
|
+
const ROLL_TICKS = 6;
|
|
608
|
+
const ROLL_K = 0.25;
|
|
609
|
+
const ROLL_DECEL = 150;
|
|
610
|
+
const STOP_SPEED = 10;
|
|
611
|
+
/** Clearance a contact pushes the ball out to; how near the floor its bottom is to roll on it. */
|
|
612
|
+
const SKIN = 0.5;
|
|
613
|
+
const SUPPORT = 1;
|
|
614
|
+
|
|
615
|
+
/** Marks the room module, as defineRoom from @genex-ai/multiplayer/authority/module does. */
|
|
616
|
+
const defineRoom = (room) => room;
|
|
617
|
+
|
|
618
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
619
|
+
|
|
620
|
+
/** The ball at p against the box [x0, y0, z0, x1, y1, z1]: null, or the contact's outward normal n
|
|
621
|
+
* and how far p must move along it to clear the box by SKIN. The normal is from the box's closest
|
|
622
|
+
* point; from inside, the face p is least deep behind. */
|
|
623
|
+
function sphereBox(p, x0, y0, z0, x1, y1, z1) {
|
|
624
|
+
const cx = Math.min(Math.max(p[0], x0), x1);
|
|
625
|
+
const cy = Math.min(Math.max(p[1], y0), y1);
|
|
626
|
+
const cz = Math.min(Math.max(p[2], z0), z1);
|
|
627
|
+
const d = [p[0] - cx, p[1] - cy, p[2] - cz];
|
|
628
|
+
const d2 = dot(d, d);
|
|
629
|
+
if (d2 >= BALL_R * BALL_R) return null;
|
|
630
|
+
if (d2 > 0) {
|
|
631
|
+
const len = Math.sqrt(d2);
|
|
632
|
+
return { n: [d[0] / len, d[1] / len, d[2] / len], push: BALL_R - len + SKIN };
|
|
633
|
+
}
|
|
634
|
+
const faces = [
|
|
635
|
+
[p[0] - x0, -1, 0, 0], [x1 - p[0], 1, 0, 0],
|
|
636
|
+
[p[1] - y0, 0, -1, 0], [y1 - p[1], 0, 1, 0],
|
|
637
|
+
[p[2] - z0, 0, 0, -1], [z1 - p[2], 0, 0, 1],
|
|
638
|
+
];
|
|
639
|
+
let best = faces[0];
|
|
640
|
+
for (const face of faces) if (face[0] < best[0]) best = face;
|
|
641
|
+
return { n: [best[1], best[2], best[3]], push: best[0] + BALL_R + SKIN };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Off the world: moving into the surface, the normal speed reflects times WORLD_E, and a real
|
|
645
|
+
* impact keeps WORLD_KEEP of the tangential speed (a slow contact, a ball sliding, keeps all of it). */
|
|
646
|
+
function bounceWorld(v, n) {
|
|
647
|
+
const vn = dot(v, n);
|
|
648
|
+
if (!(vn < 0)) return;
|
|
649
|
+
const keep = -vn > IMPACT ? WORLD_KEEP : 1;
|
|
650
|
+
for (let i = 0; i < 3; i++) v[i] = (v[i] - vn * n[i]) * keep - WORLD_E * vn * n[i];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The ball's first contact with `car` during this tick, or null. The ball goes from b0 (its flight
|
|
654
|
+
* a tick ago) to b1 (now) and the car from where step last saw it to where it is now: relative to
|
|
655
|
+
* the car that is one segment, sampled from its start and then halved down to the first touch. */
|
|
656
|
+
function carContact(ctx, car, b0, b1) {
|
|
657
|
+
const was = ctx.state.carWas[car.id];
|
|
658
|
+
const from = was !== undefined && was[3] === car.epoch ? was : [car.x, car.y, car.z];
|
|
659
|
+
const r0 = [b0[0] - from[0], b0[1] - from[1], b0[2] - from[2]];
|
|
660
|
+
const r1 = [b1[0] - car.x, b1[1] - car.y, b1[2] - car.z];
|
|
661
|
+
const at = (t) => [r0[0] + (r1[0] - r0[0]) * t, r0[1] + (r1[1] - r0[1]) * t, r0[2] + (r1[2] - r0[2]) * t];
|
|
662
|
+
const test = (r) => sphereBox(r, -CAR_R, -CAR_R, 0, CAR_R, CAR_R, CAR_H);
|
|
663
|
+
let contact = test(r0);
|
|
664
|
+
if (contact !== null) return { s: 0, rel: r0, contact };
|
|
665
|
+
let k = 1;
|
|
666
|
+
for (; k <= SAMPLES && contact === null; k++) contact = test(at(k / SAMPLES));
|
|
667
|
+
if (contact === null) return null;
|
|
668
|
+
let lo = (k - 2) / SAMPLES;
|
|
669
|
+
let hi = (k - 1) / SAMPLES;
|
|
670
|
+
for (let i = 0; i < HALVINGS; i++) {
|
|
671
|
+
const mid = (lo + hi) / 2;
|
|
672
|
+
const c = test(at(mid));
|
|
673
|
+
if (c === null) lo = mid;
|
|
674
|
+
else {
|
|
675
|
+
hi = mid;
|
|
676
|
+
contact = c;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { s: hi, rel: at(hi), contact };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** A car back on its slot, at rest, facing the other goal, with KICKOFF_FUEL; spin 0 and mstate 0
|
|
683
|
+
* start a fresh life, and the epoch bump (unless it is a new car) makes every client snap. */
|
|
684
|
+
function toSlot(ctx, car, bump) {
|
|
685
|
+
const team = ctx.state.team[car.owner];
|
|
686
|
+
const [x, y] = SLOTS[team][ctx.state.slot[car.owner]];
|
|
687
|
+
ctx.entities.update(car.id, {
|
|
688
|
+
x, y, z: 0, vx: 0, vy: 0, vz: 0, yaw: HEADING[team], spin: 0, mstate: 0, fuel: KICKOFF_FUEL,
|
|
689
|
+
epoch: bump ? car.epoch + 1 : car.epoch,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** The ball, launched (or re-launched) from p with velocity v; a rolling ball has no gravity. Its
|
|
694
|
+
* data is the score, so every client can show it. */
|
|
695
|
+
function launchBall(ctx, p, v, rolling) {
|
|
696
|
+
ctx.projectiles.launch({
|
|
697
|
+
id: "ball", kind: "ball", x: p[0], y: p[1], z: p[2], vx: v[0], vy: v[1], vz: v[2], g: rolling ? 0 : BALL_G,
|
|
698
|
+
data: ctx.state.score,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function kickoff(ctx, cars, bump) {
|
|
703
|
+
launchBall(ctx, [0, 0, BALL_R], [0, 0, 0], true);
|
|
704
|
+
for (const car of cars) toSlot(ctx, car, bump);
|
|
705
|
+
ctx.state.lastTouch = "";
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** A hit: the car is far heavier, so the relative normal speed reflects times CAR_E, plus a share of
|
|
709
|
+
* the car's own speed into the ball, and at least the minimum hit away from the car. */
|
|
710
|
+
function hitBall(ctx, car, v, n) {
|
|
711
|
+
const vc = [car.vx, car.vy, car.vz];
|
|
712
|
+
const closing = (v[0] - vc[0]) * n[0] + (v[1] - vc[1]) * n[1] + (v[2] - vc[2]) * n[2];
|
|
713
|
+
if (!(closing < 0)) return;
|
|
714
|
+
const drive = Math.max(0, dot(vc, n));
|
|
715
|
+
const push = -(1 + CAR_E) * closing + HIT_SHARE * drive;
|
|
716
|
+
for (let i = 0; i < 3; i++) v[i] += n[i] * push;
|
|
717
|
+
const least = Math.min(MIN_HIT, MIN_HIT_K * drive);
|
|
718
|
+
const away = (v[0] - vc[0]) * n[0] + (v[1] - vc[1]) * n[1] + (v[2] - vc[2]) * n[2];
|
|
719
|
+
if (away < least) for (let i = 0; i < 3; i++) v[i] += n[i] * (least - away);
|
|
720
|
+
if (drive > TOUCH_DRIVE) {
|
|
721
|
+
ctx.state.lastTouch = car.owner;
|
|
722
|
+
ctx.emit("touch", { seat: car.owner, team: ctx.state.team[car.owner] ?? "" });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The ball's tick, against the cars where this tick's inputs left them. */
|
|
727
|
+
function stepBall(ctx, cars) {
|
|
728
|
+
const ball = ctx.entities.get("ball");
|
|
729
|
+
if (ball === undefined) return kickoff(ctx, cars, false);
|
|
730
|
+
const now = ctx.projectiles.at("ball");
|
|
731
|
+
const before = ctx.projectiles.at("ball", ctx.tick - 1);
|
|
732
|
+
const p = [now.x, now.y, now.z];
|
|
733
|
+
const v = [now.vx, now.vy, now.vz];
|
|
734
|
+
let changed = false;
|
|
735
|
+
// Off the cars first, each at its first contact this tick, placed by where the car is now.
|
|
736
|
+
for (const car of cars) {
|
|
737
|
+
const hit = carContact(ctx, car, [before.x, before.y, before.z], p);
|
|
738
|
+
if (hit === null) continue;
|
|
739
|
+
changed = true;
|
|
740
|
+
const { n, push } = hit.contact;
|
|
741
|
+
hitBall(ctx, car, v, n);
|
|
742
|
+
// From the contact on it moves with its new velocity relative to the car: never left inside it.
|
|
743
|
+
const base = [car.x, car.y, car.z];
|
|
744
|
+
const vc = [car.vx, car.vy, car.vz];
|
|
745
|
+
const rest = (1 - hit.s) * ctx.dt;
|
|
746
|
+
for (let i = 0; i < 3; i++) p[i] = base[i] + hit.rel[i] + n[i] * push + (v[i] - vc[i]) * rest;
|
|
747
|
+
}
|
|
748
|
+
// A goal: the ball touching an end wall inside its goal mouth.
|
|
749
|
+
if (Math.abs(p[1]) < GOAL_W && p[2] < GOAL_H && Math.abs(p[0]) + BALL_R >= X) {
|
|
750
|
+
const team = p[0] > 0 ? "blue" : "orange";
|
|
751
|
+
const score = { ...ctx.state.score, [team]: ctx.state.score[team] + 1 };
|
|
752
|
+
ctx.state.score = score;
|
|
753
|
+
ctx.emit("goal", { team, scorer: ctx.state.lastTouch, blue: score.blue, orange: score.orange });
|
|
754
|
+
return kickoff(ctx, cars, true);
|
|
755
|
+
}
|
|
756
|
+
// Then off the world, box by box: the world has the last word, so no hit leaves it in a wall.
|
|
757
|
+
for (const b of WORLD.boxes) {
|
|
758
|
+
const contact = sphereBox(p, b[0], b[1], b[2], b[3], b[4], b[5]);
|
|
759
|
+
if (contact === null) continue;
|
|
760
|
+
for (let i = 0; i < 3; i++) p[i] += contact.n[i] * contact.push;
|
|
761
|
+
bounceWorld(v, contact.n);
|
|
762
|
+
changed = true;
|
|
763
|
+
}
|
|
764
|
+
const onFloor = p[2] - BALL_R <= SUPPORT;
|
|
765
|
+
if (changed) {
|
|
766
|
+
const speed = Math.sqrt(dot(v, v));
|
|
767
|
+
if (speed > BALL_MAX) for (let i = 0; i < 3; i++) v[i] *= BALL_MAX / speed;
|
|
768
|
+
// Settled on the floor: it rolls there, with no vertical speed and no gravity.
|
|
769
|
+
const rolling = onFloor && Math.abs(v[2]) < IMPACT;
|
|
770
|
+
if (rolling) {
|
|
771
|
+
p[2] = BALL_R;
|
|
772
|
+
v[2] = 0;
|
|
773
|
+
}
|
|
774
|
+
return launchBall(ctx, p, v, rolling);
|
|
775
|
+
}
|
|
776
|
+
// Rolling friction, every ROLL_TICKS ticks while a rolling ball moves.
|
|
777
|
+
if (ball.g === 0 && ctx.tick - ball.t0 >= ROLL_TICKS && (v[0] !== 0 || v[1] !== 0)) {
|
|
778
|
+
const speed = Math.sqrt(v[0] * v[0] + v[1] * v[1]);
|
|
779
|
+
const next = Math.max(0, speed * (1 - ROLL_K * ROLL_TICKS * ctx.dt) - ROLL_DECEL * ROLL_TICKS * ctx.dt);
|
|
780
|
+
const k = next < STOP_SPEED ? 0 : next / speed;
|
|
781
|
+
launchBall(ctx, p, [v[0] * k, v[1] * k, 0], true);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** The cars, by id: one order on every run. */
|
|
786
|
+
function carsOf(ctx) {
|
|
787
|
+
return ctx.entities.all().filter((e) => e.kind === "car").sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export default defineRoom({
|
|
791
|
+
contract: 1,
|
|
792
|
+
config: {
|
|
793
|
+
tickHz: 60,
|
|
794
|
+
maxPlayers: 4,
|
|
795
|
+
// Every other parameter at its default (maxSpeed 1410, boost 2300, substeps 2); the sandbox
|
|
796
|
+
// writes the world's hash into params.world.
|
|
797
|
+
preset: { name: "vehicle", params: { radius: CAR_R, height: CAR_H } },
|
|
798
|
+
},
|
|
799
|
+
world: WORLD,
|
|
800
|
+
init() {
|
|
801
|
+
// carWas: each car's [x, y, z, epoch] at the end of the last tick, for the ball's sweep.
|
|
802
|
+
return { team: {}, slot: {}, score: { blue: 0, orange: 0 }, lastTouch: "", carWas: {} };
|
|
803
|
+
},
|
|
804
|
+
onJoin(ctx, player) {
|
|
805
|
+
// The team with fewer cars (blue on a tie), and its lowest free slot.
|
|
806
|
+
const seats = Object.keys(ctx.state.team);
|
|
807
|
+
const blue = seats.filter((s) => ctx.state.team[s] === "blue").length;
|
|
808
|
+
const team = blue <= seats.length - blue ? "blue" : "orange";
|
|
809
|
+
const taken = seats.filter((s) => ctx.state.team[s] === team).map((s) => ctx.state.slot[s]);
|
|
810
|
+
const slot = taken.includes(0) ? 1 : 0;
|
|
811
|
+
if (taken.length >= SLOTS[team].length) return;
|
|
812
|
+
ctx.state.team[player.id] = team;
|
|
813
|
+
ctx.state.slot[player.id] = slot;
|
|
814
|
+
const id = ctx.entities.spawn({ kind: "car", owner: player.id, data: { team } });
|
|
815
|
+
toSlot(ctx, ctx.entities.get(id), false);
|
|
816
|
+
},
|
|
817
|
+
onLeave(ctx, player) {
|
|
818
|
+
const car = ctx.entities.ownedBy(player.id);
|
|
819
|
+
if (car) ctx.entities.remove(car.id);
|
|
820
|
+
delete ctx.state.team[player.id];
|
|
821
|
+
delete ctx.state.slot[player.id];
|
|
822
|
+
if (ctx.state.lastTouch === player.id) ctx.state.lastTouch = "";
|
|
823
|
+
},
|
|
824
|
+
onInput() {
|
|
825
|
+
// The preset drives the car; the ball is judged once a tick, in step.
|
|
826
|
+
},
|
|
827
|
+
step(ctx) {
|
|
828
|
+
const cars = carsOf(ctx);
|
|
829
|
+
stepBall(ctx, cars);
|
|
830
|
+
if (ctx.tick % REFILL_TICKS === 0) {
|
|
831
|
+
for (const car of cars) if (car.fuel < 1) car.fuel = Math.fround(Math.min(1, car.fuel + REFILL));
|
|
832
|
+
}
|
|
833
|
+
const carWas = {};
|
|
834
|
+
for (const car of carsOf(ctx)) carWas[car.id] = [car.x, car.y, car.z, car.epoch];
|
|
835
|
+
ctx.state.carWas = carWas;
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
package.json:
|
|
841
|
+
|
|
842
|
+
```json
|
|
843
|
+
{ "genex": { "authority": { "module": "server/room.js", "maxPlayers": 4 } } }
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
Validate it with `--root .`: the module imports `src/car-world.ts`, which is outside `server/`. It
|
|
847
|
+
uses no trigonometry (only `+ - * /`, `Math.sqrt`, `min` and `max`), so a replay is exact on every
|
|
848
|
+
server. Grow from here: boost pads are entities the module spawns, hides for a cooldown and refuels
|
|
849
|
+
from (`fuel: 1`); a bank along a side wall is a ramp, and the ball then needs a sphere test against
|
|
850
|
+
the ramp too (its closest point, never its plane); more players need more slots and, at eight cars,
|
|
851
|
+
the sub-step budget in [presets.md](presets.md).
|
|
852
|
+
|
|
853
|
+
### The client, with three.js
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
// src/cars.ts: joins the car arena, sends throttle, steer and buttons, draws what the server says.
|
|
857
|
+
import * as THREE from "three";
|
|
858
|
+
import { connectAuthority, type AuthorityEntityView } from "@genex-ai/multiplayer/authority";
|
|
859
|
+
import { getAuthorityUrls, getColyseusAuth, waitForPlayer } from "@genex-ai/embed-sdk";
|
|
860
|
+
import { GENEX } from "./genex.config";
|
|
861
|
+
import { BALL_R, CAR_H, GOAL_H, GOAL_W, WORLD, X } from "./car-world";
|
|
862
|
+
|
|
863
|
+
/** The preset's default buttons: jump, boost, drift. */
|
|
864
|
+
const JUMP = 1;
|
|
865
|
+
const BOOST = 2;
|
|
866
|
+
const DRIFT = 4;
|
|
867
|
+
/** Team colours; the car's team is in its data. */
|
|
868
|
+
const COLOUR: Record<string, number> = { blue: 0x3d8bff, orange: 0xff8a2a };
|
|
869
|
+
|
|
870
|
+
/** A refused join's code (JoinError) in the player's words. */
|
|
871
|
+
const REFUSED: Record<number, string> = {
|
|
872
|
+
402: "Online play isn't available for this game right now.",
|
|
873
|
+
424: "This game's server isn't ready yet.",
|
|
874
|
+
426: "This game needs an update to play online.",
|
|
875
|
+
429: "The game's servers are busy right now. Try again later.",
|
|
876
|
+
503: "No game server is free right now. Try again in a moment.",
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
/** Server (x, y, z) to three.js (x, z, -y): z up becomes y up, with handedness kept. */
|
|
880
|
+
const toThree = (v: THREE.Vector3, x: number, y: number, z: number) => v.set(x, z, -y);
|
|
881
|
+
|
|
882
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
883
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
884
|
+
document.body.append(renderer.domElement);
|
|
885
|
+
const scene = new THREE.Scene();
|
|
886
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
|
|
887
|
+
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 1, 20000);
|
|
888
|
+
const hud = document.createElement("div");
|
|
889
|
+
hud.style.cssText = "position:fixed;left:12px;top:12px;color:#fff;font:14px system-ui";
|
|
890
|
+
document.body.append(hud);
|
|
891
|
+
|
|
892
|
+
// The level drawn from the same boxes the cars collide with (the ceiling left out, so the camera sees).
|
|
893
|
+
WORLD.boxes.forEach((b, i) => {
|
|
894
|
+
if (i === 1) return;
|
|
895
|
+
const box = new THREE.Mesh(
|
|
896
|
+
new THREE.BoxGeometry(b[3] - b[0], b[5] - b[2], b[4] - b[1]),
|
|
897
|
+
new THREE.MeshStandardMaterial({ color: i === 0 ? 0x2f6b3a : 0x8899aa, transparent: i > 0, opacity: i > 0 ? 0.25 : 1 }),
|
|
898
|
+
);
|
|
899
|
+
toThree(box.position, (b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2);
|
|
900
|
+
scene.add(box);
|
|
901
|
+
});
|
|
902
|
+
// The goal mouths: blue defends -x, orange defends +x.
|
|
903
|
+
for (const [side, team] of [[-1, "blue"], [1, "orange"]] as const) {
|
|
904
|
+
const goal = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_W * 2, GOAL_H), new THREE.MeshBasicMaterial({ color: COLOUR[team], side: THREE.DoubleSide }));
|
|
905
|
+
goal.rotation.y = Math.PI / 2;
|
|
906
|
+
toThree(goal.position, side * (X - 2), 0, GOAL_H / 2);
|
|
907
|
+
scene.add(goal);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const keys = new Set<string>();
|
|
911
|
+
addEventListener("keydown", (e) => keys.add(e.code));
|
|
912
|
+
addEventListener("keyup", (e) => keys.delete(e.code));
|
|
913
|
+
|
|
914
|
+
await waitForPlayer();
|
|
915
|
+
const session = await connectAuthority({
|
|
916
|
+
urls: getAuthorityUrls(),
|
|
917
|
+
room: GENEX.slug,
|
|
918
|
+
auth: () => getColyseusAuth(),
|
|
919
|
+
world: WORLD,
|
|
920
|
+
}).catch((err: { code?: number }) => {
|
|
921
|
+
hud.textContent = REFUSED[err.code ?? 0] ?? "Couldn't join the game.";
|
|
922
|
+
throw err;
|
|
923
|
+
});
|
|
924
|
+
session.on("disconnect", (code) => {
|
|
925
|
+
hud.textContent = code === 4500 ? "The game was stopped." : "Disconnected from the game.";
|
|
926
|
+
});
|
|
927
|
+
let banner = "";
|
|
928
|
+
session.on("goal", (data) => {
|
|
929
|
+
const goal = data as { team: string; scorer: string };
|
|
930
|
+
banner = goal.scorer === session.id ? "You scored!" : `${goal.team} scores`;
|
|
931
|
+
setTimeout(() => (banner = ""), 2000);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
/** A car: a box about 118 long and 84 wide over the preset's square hull, its nose along +x. */
|
|
935
|
+
function makeCar(team: string): THREE.Group {
|
|
936
|
+
const car = new THREE.Group();
|
|
937
|
+
const body = new THREE.Mesh(new THREE.BoxGeometry(118, CAR_H, 84), new THREE.MeshStandardMaterial({ color: COLOUR[team] ?? 0xffffff }));
|
|
938
|
+
body.position.y = CAR_H / 2;
|
|
939
|
+
const nose = new THREE.Mesh(new THREE.BoxGeometry(20, 12, 60), new THREE.MeshStandardMaterial({ color: 0xffffff }));
|
|
940
|
+
nose.position.set(59, CAR_H, 0);
|
|
941
|
+
car.add(body, nose);
|
|
942
|
+
return car;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const meshes = new Map<string, THREE.Object3D>();
|
|
946
|
+
function draw(entities: Map<string, AuthorityEntityView>): void {
|
|
947
|
+
for (const [id, e] of entities) {
|
|
948
|
+
let mesh = meshes.get(id);
|
|
949
|
+
if (!mesh) {
|
|
950
|
+
mesh = e.kind === "ball"
|
|
951
|
+
? new THREE.Mesh(new THREE.SphereGeometry(BALL_R, 24, 16), new THREE.MeshStandardMaterial({ color: 0xf2f2f2 }))
|
|
952
|
+
: makeCar(JSON.parse(e.data || "{}").team ?? "");
|
|
953
|
+
scene.add(mesh);
|
|
954
|
+
meshes.set(id, mesh);
|
|
955
|
+
}
|
|
956
|
+
toThree(mesh.position, e.x, e.y, e.z); // the ball from its flight; your car predicted; the others 100 ms behind
|
|
957
|
+
mesh.rotation.y = e.yaw; // the heading, about three's up axis
|
|
958
|
+
}
|
|
959
|
+
for (const [id, mesh] of meshes) {
|
|
960
|
+
if (entities.has(id)) continue;
|
|
961
|
+
scene.remove(mesh);
|
|
962
|
+
meshes.delete(id);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const eye = new THREE.Vector3();
|
|
967
|
+
const target = new THREE.Vector3();
|
|
968
|
+
function loop(now: number): void {
|
|
969
|
+
session.input.moveY = (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0); // throttle: below 0 brakes, then reverses
|
|
970
|
+
session.input.moveX = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0); // steer: +1 turns right
|
|
971
|
+
session.input.buttons = (keys.has("Space") ? JUMP : 0) | (keys.has("ShiftLeft") ? BOOST : 0) | (keys.has("KeyQ") ? DRIFT : 0);
|
|
972
|
+
session.frame(now);
|
|
973
|
+
draw(session.entities());
|
|
974
|
+
const me = session.me();
|
|
975
|
+
if (me) {
|
|
976
|
+
// A chase camera behind the heading.
|
|
977
|
+
toThree(eye, me.x - Math.cos(me.yaw) * 700, me.y - Math.sin(me.yaw) * 700, me.z + 300);
|
|
978
|
+
camera.position.lerp(eye, 0.2);
|
|
979
|
+
camera.lookAt(toThree(target, me.x, me.y, me.z + 100));
|
|
980
|
+
}
|
|
981
|
+
const score = JSON.parse(session.entity("ball")?.data || '{"blue":0,"orange":0}');
|
|
982
|
+
const why = session.prediction.reason === "world_mismatch" ? " · world mismatch: not predicted" : "";
|
|
983
|
+
hud.textContent = `blue ${score.blue} : ${score.orange} orange · boost ${Math.round((me?.fuel ?? 0) * 100)}${banner ? ` · ${banner}` : ""}${why}`;
|
|
984
|
+
renderer.render(scene, camera);
|
|
985
|
+
requestAnimationFrame(loop);
|
|
986
|
+
}
|
|
987
|
+
requestAnimationFrame(loop);
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
What it shows, and what to keep when you change it:
|
|
991
|
+
|
|
992
|
+
- **One world, imported by both sides.** The module exports `WORLD` and the client passes the same
|
|
993
|
+
object as `world`; a copy that differs by one number turns prediction off (the HUD says so).
|
|
994
|
+
- **The car is drawn with three's handedness kept**: `(x, z, -y)` and `rotation.y = yaw`, so the
|
|
995
|
+
steer-right key (`moveX = +1`) turns the car right on screen. A mirrored drawing turns it left.
|
|
996
|
+
- **The client never moves the ball.** It comes from `session.entities()` already where its flight
|
|
997
|
+
is this frame; every bounce, hit and roll is a re-launch the module wrote.
|
|
998
|
+
- **Fuel, score and goals come only from the server**: `fuel` on your car, the score in the ball's
|
|
999
|
+
`data`, and the `goal` and `touch` events.
|
|
1000
|
+
- **A touch looks a little early or late**, by about the one-way latency (presets.md says why).
|
|
1001
|
+
Test at `--latency 75` and judge whether it plays well there, not at zero.
|
|
1002
|
+
|
|
1003
|
+
## Template 4: a physics car arena (server physics)
|
|
1004
|
+
|
|
1005
|
+
The car arena of template 3 with the ball and four crate stacks simulated by the server's physics
|
|
1006
|
+
core ([physics.md](physics.md)). Each car's hull pushes the ball and topples the crates; the module
|
|
1007
|
+
only spawns the bodies, watches the ball for goals and puts everything back at a kickoff. Clients
|
|
1008
|
+
draw the ball and the crates from the server, at its present. It needs the physics core
|
|
1009
|
+
([physics.md](physics.md) says when Genex's servers run it).
|
|
1010
|
+
|
|
1011
|
+
### The world, shared by both sides
|
|
1012
|
+
|
|
1013
|
+
`src/car-world.ts` of template 3, plus the crates:
|
|
1014
|
+
|
|
1015
|
+
```ts
|
|
1016
|
+
// src/car-world.ts, appended: the crate stacks.
|
|
1017
|
+
|
|
1018
|
+
/** A crate's half size, and the stacks: a pyramid of `rows` rows at (x, y). */
|
|
1019
|
+
export const CRATE_HALF = 35;
|
|
1020
|
+
export const STACKS = [
|
|
1021
|
+
{ x: 1400, y: -1100, rows: 3 },
|
|
1022
|
+
{ x: 1400, y: 1100, rows: 3 },
|
|
1023
|
+
{ x: -1400, y: -1100, rows: 3 },
|
|
1024
|
+
{ x: -1400, y: 1100, rows: 3 },
|
|
1025
|
+
];
|
|
1026
|
+
|
|
1027
|
+
/** Every crate's id and home, bottom row first. */
|
|
1028
|
+
export function crateHomes(): { id: string; x: number; y: number; z: number }[] {
|
|
1029
|
+
const out: { id: string; x: number; y: number; z: number }[] = [];
|
|
1030
|
+
STACKS.forEach((stack, s) => {
|
|
1031
|
+
for (let row = 0; row < stack.rows; row++) {
|
|
1032
|
+
const count = stack.rows - row;
|
|
1033
|
+
for (let i = 0; i < count; i++) {
|
|
1034
|
+
const offset = (i - (count - 1) / 2) * (CRATE_HALF * 2 + 2);
|
|
1035
|
+
out.push({ id: `crate.${s}.${row}.${i}`, x: stack.x + offset, y: stack.y, z: CRATE_HALF + row * (CRATE_HALF * 2 + 1) });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### The room module
|
|
1044
|
+
|
|
1045
|
+
```js
|
|
1046
|
+
// server/room.js (physics car arena): runs on Genex's servers, never in a browser.
|
|
1047
|
+
import { BALL_R, CAR_H, CAR_R, CRATE_HALF, GOAL_H, GOAL_W, WORLD, X, Y, crateHomes } from "../src/car-world";
|
|
1048
|
+
|
|
1049
|
+
const defineRoom = (room) => room;
|
|
1050
|
+
const SLOTS = { blue: [[-1536, -512], [-1536, 512]], orange: [[1536, 512], [1536, -512]] };
|
|
1051
|
+
const HEADING = { blue: 0, orange: -Math.fround(Math.PI) };
|
|
1052
|
+
const KICKOFF_FUEL = Math.fround(0.34);
|
|
1053
|
+
const IDENTITY = { x: 0, y: 0, z: 0, w: 1 };
|
|
1054
|
+
|
|
1055
|
+
/** A car on its slot, at rest; the epoch bump makes every client snap. */
|
|
1056
|
+
function toSlot(ctx, car, bump) {
|
|
1057
|
+
const team = ctx.state.team[car.owner];
|
|
1058
|
+
const [x, y] = SLOTS[team][ctx.state.slot[car.owner]];
|
|
1059
|
+
ctx.entities.update(car.id, {
|
|
1060
|
+
x, y, z: 0, vx: 0, vy: 0, vz: 0, yaw: HEADING[team], spin: 0, mstate: 0, fuel: KICKOFF_FUEL,
|
|
1061
|
+
epoch: bump ? car.epoch + 1 : car.epoch,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** A body back home at rest, its epoch bumped so clients snap rather than slide. */
|
|
1066
|
+
function reset(ctx, id, p) {
|
|
1067
|
+
ctx.physics.setPose(id, { p, q: IDENTITY }, { resetVelocity: true, wake: true });
|
|
1068
|
+
const entity = ctx.entities.get(id);
|
|
1069
|
+
if (entity) ctx.entities.update(id, { epoch: entity.epoch + 1 });
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function kickoff(ctx, cars) {
|
|
1073
|
+
reset(ctx, "ball", { x: 0, y: 0, z: BALL_R });
|
|
1074
|
+
ctx.entities.update("ball", { data: ctx.state.score });
|
|
1075
|
+
for (const home of crateHomes()) reset(ctx, home.id, { x: home.x, y: home.y, z: home.z });
|
|
1076
|
+
for (const car of cars) toSlot(ctx, car, true);
|
|
1077
|
+
ctx.state.lastTouch = "";
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function carsOf(ctx) {
|
|
1081
|
+
return ctx.entities.all().filter((e) => e.kind === "car").sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export default defineRoom({
|
|
1085
|
+
contract: 1,
|
|
1086
|
+
config: {
|
|
1087
|
+
tickHz: 60,
|
|
1088
|
+
maxPlayers: 4,
|
|
1089
|
+
preset: { name: "vehicle", params: { radius: CAR_R, height: CAR_H } },
|
|
1090
|
+
// lengthUnit 100 and gravity 650 come from the vehicle preset.
|
|
1091
|
+
physics: { subscribe: ["ball"], player: { events: true } },
|
|
1092
|
+
},
|
|
1093
|
+
world: WORLD,
|
|
1094
|
+
init(ctx) {
|
|
1095
|
+
ctx.entities.spawn({ id: "ball", kind: "ball", z: BALL_R, data: { blue: 0, orange: 0 } });
|
|
1096
|
+
ctx.physics.spawn({
|
|
1097
|
+
key: "ball", kind: "ball", type: "dynamic", ccd: true, linearDamping: 0.15, angularDamping: 0.4,
|
|
1098
|
+
pose: { p: { x: 0, y: 0, z: BALL_R }, q: IDENTITY },
|
|
1099
|
+
colliders: [{ key: "ball", shape: { kind: "sphere", radius: BALL_R }, density: 0.2, restitution: 0.65, events: true }],
|
|
1100
|
+
});
|
|
1101
|
+
for (const home of crateHomes()) {
|
|
1102
|
+
ctx.entities.spawn({ id: home.id, kind: "crate", x: home.x, y: home.y, z: home.z });
|
|
1103
|
+
ctx.physics.spawn({
|
|
1104
|
+
key: home.id, kind: "crate", type: "dynamic",
|
|
1105
|
+
pose: { p: { x: home.x, y: home.y, z: home.z }, q: IDENTITY },
|
|
1106
|
+
colliders: [{ key: "box", shape: { kind: "box", half: { x: CRATE_HALF, y: CRATE_HALF, z: CRATE_HALF } }, density: 0.5, friction: 0.7 }],
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
return { team: {}, slot: {}, score: { blue: 0, orange: 0 }, lastTouch: "" };
|
|
1110
|
+
},
|
|
1111
|
+
onJoin(ctx, player) {
|
|
1112
|
+
const seats = Object.keys(ctx.state.team);
|
|
1113
|
+
const blue = seats.filter((s) => ctx.state.team[s] === "blue").length;
|
|
1114
|
+
const team = blue <= seats.length - blue ? "blue" : "orange";
|
|
1115
|
+
const taken = seats.filter((s) => ctx.state.team[s] === team).map((s) => ctx.state.slot[s]);
|
|
1116
|
+
if (taken.length >= SLOTS[team].length) return;
|
|
1117
|
+
ctx.state.team[player.id] = team;
|
|
1118
|
+
ctx.state.slot[player.id] = taken.includes(0) ? 1 : 0;
|
|
1119
|
+
const id = ctx.entities.spawn({ kind: "car", owner: player.id, data: { team } });
|
|
1120
|
+
toSlot(ctx, ctx.entities.get(id), false);
|
|
1121
|
+
},
|
|
1122
|
+
onLeave(ctx, player) {
|
|
1123
|
+
const car = ctx.entities.ownedBy(player.id);
|
|
1124
|
+
if (car) ctx.entities.remove(car.id);
|
|
1125
|
+
delete ctx.state.team[player.id];
|
|
1126
|
+
delete ctx.state.slot[player.id];
|
|
1127
|
+
},
|
|
1128
|
+
onInput() {
|
|
1129
|
+
// The preset drives the car; the server's physics moves the ball and the crates.
|
|
1130
|
+
},
|
|
1131
|
+
step(ctx) {
|
|
1132
|
+
const cars = carsOf(ctx);
|
|
1133
|
+
for (const ev of ctx.physics.events) {
|
|
1134
|
+
if (ev.kind !== "contactBegin" || (ev.a !== "ball" && ev.b !== "ball")) continue;
|
|
1135
|
+
const car = ctx.entities.get(ev.a === "ball" ? ev.b : ev.a);
|
|
1136
|
+
if (car && car.kind === "car") ctx.state.lastTouch = car.owner;
|
|
1137
|
+
}
|
|
1138
|
+
const ball = ctx.physics.body("ball");
|
|
1139
|
+
if (!ball) return;
|
|
1140
|
+
const { x, y, z } = ball.p;
|
|
1141
|
+
if (Math.abs(y) < GOAL_W && z < GOAL_H && Math.abs(x) + BALL_R >= X - 4) {
|
|
1142
|
+
const team = x > 0 ? "blue" : "orange";
|
|
1143
|
+
ctx.state.score = { ...ctx.state.score, [team]: ctx.state.score[team] + 1 };
|
|
1144
|
+
ctx.emit("goal", { team, scorer: ctx.state.lastTouch, blue: ctx.state.score.blue, orange: ctx.state.score.orange });
|
|
1145
|
+
kickoff(ctx, cars);
|
|
1146
|
+
} else if (Math.abs(x) > X + BALL_R || Math.abs(y) > Y + BALL_R || z < -BALL_R) {
|
|
1147
|
+
kickoff(ctx, cars);
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
### The client
|
|
1154
|
+
|
|
1155
|
+
Template 3's client, with two changes in `draw`: a crate gets its own mesh, and a body is turned
|
|
1156
|
+
by its quaternion instead of a heading (z up becomes y up for both):
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
const crate = () => new THREE.Mesh(new THREE.BoxGeometry(70, 70, 70), new THREE.MeshStandardMaterial({ color: 0xb07a3c }));
|
|
1160
|
+
|
|
1161
|
+
function draw(entities: Map<string, AuthorityEntityView>): void {
|
|
1162
|
+
for (const [id, e] of entities) {
|
|
1163
|
+
let mesh = meshes.get(id);
|
|
1164
|
+
if (!mesh) {
|
|
1165
|
+
mesh = e.kind === "ball"
|
|
1166
|
+
? new THREE.Mesh(new THREE.SphereGeometry(BALL_R, 24, 16), new THREE.MeshStandardMaterial({ color: 0xf2f2f2 }))
|
|
1167
|
+
: e.kind === "crate" ? crate() : makeCar(JSON.parse(e.data || "{}").team ?? "");
|
|
1168
|
+
scene.add(mesh);
|
|
1169
|
+
meshes.set(id, mesh);
|
|
1170
|
+
}
|
|
1171
|
+
toThree(mesh.position, e.x, e.y, e.z); // a body at the server's present; your car predicted; other cars 100 ms behind
|
|
1172
|
+
if (e.body) mesh.quaternion.set(e.qx, e.qz, -e.qy, e.qw);
|
|
1173
|
+
else mesh.rotation.set(0, e.yaw, 0);
|
|
1174
|
+
}
|
|
1175
|
+
for (const [id, mesh] of meshes) {
|
|
1176
|
+
if (entities.has(id)) continue;
|
|
1177
|
+
scene.remove(mesh);
|
|
1178
|
+
meshes.delete(id);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
What to keep when you change it:
|
|
1184
|
+
|
|
1185
|
+
- **Spawn the entity, then its body**, and never write a body entity's `x y z` from the module.
|
|
1186
|
+
- **A kickoff is `setPose` plus an `epoch` bump**, so clients snap; without the bump they slide the
|
|
1187
|
+
ball and the crates back across the arena.
|
|
1188
|
+
- **The client never moves a body.** It draws what `session.entities()` says, already extrapolated
|
|
1189
|
+
to the server's present.
|