@korial29/tiny-fps 0.0.0-stage → 0.2.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/dist/server.js ADDED
@@ -0,0 +1,814 @@
1
+ import { WebSocketServer } from "ws";
2
+ // src/shared/constants.ts
3
+ /** Player cylinder. */
4
+ var PLAYER_R = .4;
5
+ var PLAYER_H = 1.8;
6
+ /** Camera height above the feet. */
7
+ var EYE = 1.6;
8
+ /** Pitch is clamped to ±MAX_PITCH radians, just short of straight up or down. */
9
+ var MAX_PITCH = 1.55;
10
+ /** Speed factor while the trigger is held. */
11
+ var FIRE_SLOW = .5;
12
+ /** Largest angle, in radians, between a shot and the view direction. */
13
+ var SPREAD = .02;
14
+ var RULES = { maxPlayers: 16 };
15
+ // src/shared/level.ts
16
+ /** Joins every edge a→b with the sector that has b→a, if any: that edge is a portal. */
17
+ var compile = (map) => {
18
+ const key = (ax, az, bx, bz) => `${ax},${az},${bx},${bz}`;
19
+ const owner = /* @__PURE__ */ new Map();
20
+ map.sectors.forEach(({ pts }, s) => pts.forEach(([ax, az], i) => owner.set(key(ax, az, ...pts[(i + 1) % pts.length]), s)));
21
+ return {
22
+ map,
23
+ edges: map.sectors.flatMap(({ pts }, front) => pts.map(([ax, az], i) => {
24
+ const [bx, bz] = pts[(i + 1) % pts.length];
25
+ return {
26
+ ax,
27
+ az,
28
+ bx,
29
+ bz,
30
+ front,
31
+ back: owner.get(key(bx, bz, ax, az)) ?? -1
32
+ };
33
+ }))
34
+ };
35
+ };
36
+ /** Whether (x, z) is inside the sector, its edges included. */
37
+ var inside = ({ pts }, x, z) => pts.every(([ax, az], i) => {
38
+ const [bx, bz] = pts[(i + 1) % pts.length];
39
+ return (bx - ax) * (z - az) - (bz - az) * (x - ax) >= 0;
40
+ });
41
+ /** Index of the first sector holding (x, z), -1 outside the map. */
42
+ var sectorAt = ({ map }, x, z) => map.sectors.findIndex((s) => inside(s, x, z));
43
+ // src/shared/types.ts
44
+ /** Bits of Input.keys. Keys are read by KeyboardEvent.code, so WASD is ZQSD on AZERTY. */
45
+ var KEY = {
46
+ FORWARD: 1,
47
+ BACK: 2,
48
+ LEFT: 4,
49
+ RIGHT: 8,
50
+ JUMP: 16,
51
+ /** Left mouse button: the chaingun fires every FIRE_EVERY steps while held. */
52
+ FIRE: 32
53
+ };
54
+ /** Procedural textures, generated by the client. A sector names one per surface. */
55
+ var TEX = {
56
+ BRICK: 0,
57
+ TILE: 1,
58
+ LAVA: 2,
59
+ STONE: 3
60
+ };
61
+ // src/shared/map.ts
62
+ var sector = (pts, floor, ceil, ft, light) => ({
63
+ pts,
64
+ floor,
65
+ ceil,
66
+ ft,
67
+ wt: TEX.BRICK,
68
+ ct: TEX.STONE,
69
+ light
70
+ });
71
+ /**
72
+ * The first sector map, a test bed for heights: a start room, a low corridor, a tall hall around a
73
+ * lava pit, stairs up to a balcony. Each spot is reachable on foot; the pit is a 1 m drop.
74
+ */
75
+ var HALL = {
76
+ sectors: [
77
+ sector([
78
+ [0, 0],
79
+ [16, 0],
80
+ [16, 6],
81
+ [16, 10],
82
+ [16, 16],
83
+ [0, 16]
84
+ ], 1, 5, TEX.TILE),
85
+ sector([
86
+ [16, 6],
87
+ [20, 6],
88
+ [20, 10],
89
+ [16, 10]
90
+ ], 1, 3.5, TEX.TILE, .6),
91
+ sector([
92
+ [20, 0],
93
+ [36, 0],
94
+ [36, 6],
95
+ [30, 6],
96
+ [26, 6],
97
+ [20, 6]
98
+ ], 1, 7, TEX.TILE),
99
+ sector([
100
+ [20, 6],
101
+ [26, 6],
102
+ [26, 10],
103
+ [20, 10]
104
+ ], 1, 7, TEX.TILE),
105
+ sector([
106
+ [30, 6],
107
+ [36, 6],
108
+ [36, 10],
109
+ [30, 10]
110
+ ], 1, 7, TEX.TILE),
111
+ sector([
112
+ [20, 10],
113
+ [26, 10],
114
+ [30, 10],
115
+ [36, 10],
116
+ [36, 16],
117
+ [32, 16],
118
+ [28, 16],
119
+ [20, 16]
120
+ ], 1, 7, TEX.TILE),
121
+ sector([
122
+ [26, 6],
123
+ [30, 6],
124
+ [30, 10],
125
+ [26, 10]
126
+ ], 0, 7, TEX.LAVA),
127
+ sector([
128
+ [28, 16],
129
+ [32, 16],
130
+ [32, 17],
131
+ [28, 17]
132
+ ], 1.4, 7, TEX.STONE),
133
+ sector([
134
+ [28, 17],
135
+ [32, 17],
136
+ [32, 18],
137
+ [28, 18]
138
+ ], 1.8, 7, TEX.STONE),
139
+ sector([
140
+ [28, 18],
141
+ [32, 18],
142
+ [32, 19],
143
+ [28, 19]
144
+ ], 2.2, 7, TEX.STONE),
145
+ sector([
146
+ [24, 19],
147
+ [28, 19],
148
+ [32, 19],
149
+ [36, 19],
150
+ [36, 26],
151
+ [24, 26]
152
+ ], 2.6, 7, TEX.TILE, .8)
153
+ ],
154
+ spawns: [
155
+ [
156
+ 4,
157
+ 4,
158
+ Math.PI * 1.5
159
+ ],
160
+ [
161
+ 4,
162
+ 12,
163
+ Math.PI * 1.5
164
+ ],
165
+ [
166
+ 8,
167
+ 8,
168
+ Math.PI * 1.5
169
+ ],
170
+ [
171
+ 12,
172
+ 4,
173
+ Math.PI * 1.5
174
+ ],
175
+ [
176
+ 12,
177
+ 12,
178
+ Math.PI * 1.5
179
+ ],
180
+ [
181
+ 2,
182
+ 8,
183
+ Math.PI * 1.5
184
+ ],
185
+ [
186
+ 8,
187
+ 2,
188
+ Math.PI * 1.5
189
+ ],
190
+ [
191
+ 8,
192
+ 14,
193
+ Math.PI * 1.5
194
+ ]
195
+ ]
196
+ };
197
+ // src/shared/physics.ts
198
+ var DT = 1 / 60;
199
+ var TAU = Math.PI * 2;
200
+ /** Overlaps smaller than this are rounding noise, not contact. */
201
+ var EPS = 1e-6;
202
+ /**
203
+ * Feet this close below a floor still stand on it (rounding of y must not sink a player into
204
+ * the floor or flip a step test), and a fall that ends this close above a floor
205
+ * lands on it.
206
+ */
207
+ var SKIN = .01;
208
+ /** An angle in [0, 2π). */
209
+ var norm = (a) => (a % TAU + TAU) % TAU;
210
+ /** Unit vector the player faces at yaw, on the floor plane: yaw 0 looks toward -z. */
211
+ var forward = (yaw) => [-Math.sin(yaw), -Math.cos(yaw)];
212
+ var f32 = Math.fround;
213
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
214
+ /**
215
+ * Whether the edge stops a player whose feet are at y: a solid wall, a floor on the far side more
216
+ * than STEP_H above the feet, or an opening (the lower ceiling minus the higher floor) too short
217
+ * for the body or under the head. SKIN keeps rounding from flipping the answer
218
+ * for a player resting on a step or against a ceiling.
219
+ */
220
+ var shut = ({ map }, e, y) => {
221
+ if (e.back < 0) return true;
222
+ const f = map.sectors[e.front];
223
+ const k = map.sectors[e.back];
224
+ const top = Math.min(f.ceil, k.ceil);
225
+ return k.floor > y + .5 + SKIN || top - Math.max(f.floor, k.floor) < 1.8 - SKIN || top < y + 1.8 - SKIN;
226
+ };
227
+ /**
228
+ * Pushes the player out of every shut edge it overlaps, deepest first (up to 4 passes: two walls
229
+ * in a corner settle in two). The push is along the vector from the closest point of the edge, so
230
+ * against a wall it is the normal (the other axis keeps moving: the slide) and at an endpoint it
231
+ * is radial: a player running at a corner slides round it. Deepest first matters on a straight
232
+ * wall split by a collinear point: the endpoint of the next piece is always shallower than the
233
+ * normal push of the piece the player is on, so it never pulls him back along the wall.
234
+ * An edge only counts from its front side (the inside of its sector); across it, the twin edge of
235
+ * the neighbour decides.
236
+ * Tunnelling is impossible: a step is RUN_SPEED / TICK_HZ = 0.1 m and the axis ends each step at
237
+ * least PLAYER_R = 0.4 m from every shut edge it started beside, so it cannot cross one in 0.1 m.
238
+ */
239
+ var push = (p, lvl) => {
240
+ const reach = (PLAYER_R - EPS) ** 2;
241
+ for (let pass = 0; pass < 4; pass++) {
242
+ let best = 0;
243
+ let px = 0;
244
+ let pz = 0;
245
+ for (const e of lvl.edges) {
246
+ const ex = e.bx - e.ax;
247
+ const ez = e.bz - e.az;
248
+ const rx = p.x - e.ax;
249
+ const rz = p.z - e.az;
250
+ if (ex * rz - ez * rx < 0) continue;
251
+ const t = clamp((rx * ex + rz * ez) / (ex * ex + ez * ez), 0, 1);
252
+ const dx = rx - t * ex;
253
+ const dz = rz - t * ez;
254
+ const d2 = dx * dx + dz * dz;
255
+ if (d2 >= reach) continue;
256
+ const d = Math.sqrt(d2);
257
+ if (.4 - d <= best || !shut(lvl, e, p.y)) continue;
258
+ best = PLAYER_R - d;
259
+ const n = d || Math.hypot(ex, ez);
260
+ px = (d ? dx : -ez) / n * best;
261
+ pz = (d ? dz : ex) / n * best;
262
+ }
263
+ if (!best) return;
264
+ p.x += px;
265
+ p.z += pz;
266
+ }
267
+ };
268
+ /**
269
+ * The floor and the ceiling under the player: the highest floor and the lowest ceiling of the
270
+ * sector holding the axis and of every sector across a portal the circle overlaps and could walk
271
+ * through (a shut edge is a wall: the circle never counts what lies behind it).
272
+ */
273
+ var span = (lvl, x, z, y) => {
274
+ const { map, edges } = lvl;
275
+ const s = Math.max(sectorAt(lvl, x, z), 0);
276
+ let { floor, ceil } = map.sectors[s];
277
+ const reach = (PLAYER_R - EPS) ** 2;
278
+ for (const e of edges) {
279
+ if (e.front !== s || e.back < 0) continue;
280
+ const ex = e.bx - e.ax;
281
+ const ez = e.bz - e.az;
282
+ const rx = x - e.ax;
283
+ const rz = z - e.az;
284
+ const t = clamp((rx * ex + rz * ez) / (ex * ex + ez * ez), 0, 1);
285
+ if ((rx - t * ex) ** 2 + (rz - t * ez) ** 2 < reach && !shut(lvl, e, y)) {
286
+ const k = map.sectors[e.back];
287
+ floor = Math.max(floor, k.floor);
288
+ ceil = Math.min(ceil, k.ceil);
289
+ }
290
+ }
291
+ return [floor, ceil];
292
+ };
293
+ /**
294
+ * Applies one input for 1 / TICK_HZ s, in place: yaw and pitch from the input (pitch clamped to
295
+ * MAX_PITCH), RUN_SPEED along the held keys (diagonals not faster), a jump from the ground,
296
+ * GRAVITY, then collisions of the cylinder (PLAYER_R, PLAYER_H) with the sectors of the level:
297
+ * it slides along shut edges (see shut and push), stands on the highest floor under the circle
298
+ * (a step up to STEP_H is climbed at once, a higher one needs a jump, a ledge is a fall), and
299
+ * bumps its head on the lowest ceiling.
300
+ * The chaingun: `cool` counts down one per step, RUN_SPEED is scaled by FIRE_SLOW while KEY.FIRE
301
+ * is held, and the step that finds it held with `cool` at 0 re-arms it to FIRE_EVERY and returns
302
+ * true: the caller resolves a shot (one every FIRE_EVERY steps, the first at once).
303
+ */
304
+ var move = (p, i, lvl) => {
305
+ const fire = (i.keys & KEY.FIRE) !== 0;
306
+ p.cool = Math.max(0, p.cool - 1);
307
+ p.yaw = norm(i.yaw);
308
+ p.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, i.pitch));
309
+ const f = (i.keys & KEY.FORWARD ? 1 : 0) - (i.keys & KEY.BACK ? 1 : 0);
310
+ const s = (i.keys & KEY.RIGHT ? 1 : 0) - (i.keys & KEY.LEFT ? 1 : 0);
311
+ const d = 6 * DT * (fire ? FIRE_SLOW : 1) / (Math.hypot(f, s) || 1);
312
+ const [fx, fz] = forward(p.yaw);
313
+ p.x += (fx * f - fz * s) * d;
314
+ p.z += (fz * f + fx * s) * d;
315
+ push(p, lvl);
316
+ const [floor, ceil] = span(lvl, p.x, p.z, p.y);
317
+ if (p.ground && i.keys & KEY.JUMP) p.vy = 7;
318
+ const vy = p.vy - 20 * DT;
319
+ p.y += (p.vy + vy) / 2 * DT;
320
+ p.vy = vy;
321
+ p.ground = false;
322
+ if (p.y + 1.8 > ceil) {
323
+ p.y = ceil - PLAYER_H;
324
+ p.vy = Math.min(p.vy, 0);
325
+ }
326
+ if (p.vy <= 0 ? p.y <= floor + SKIN : p.y < floor) {
327
+ p.y = floor;
328
+ if (p.vy <= 0) {
329
+ p.vy = 0;
330
+ p.ground = true;
331
+ }
332
+ }
333
+ p.x = f32(p.x);
334
+ p.y = f32(p.y);
335
+ p.z = f32(p.z);
336
+ p.vy = f32(p.vy);
337
+ if (fire && !p.cool) {
338
+ p.cool = 5;
339
+ return true;
340
+ }
341
+ return false;
342
+ };
343
+ /** A player standing on the floor at spawn point n of the map (modulo the count). */
344
+ var spawn = (id, lvl, n) => {
345
+ const { spawns, sectors } = lvl.map;
346
+ const [x, z, yaw] = spawns[(n % spawns.length + spawns.length) % spawns.length];
347
+ const y = sectors[Math.max(sectorAt(lvl, x, z), 0)].floor;
348
+ return {
349
+ id,
350
+ x: f32(x),
351
+ y: f32(y),
352
+ z: f32(z),
353
+ vy: 0,
354
+ yaw: norm(yaw),
355
+ pitch: 0,
356
+ ground: true,
357
+ cool: 0
358
+ };
359
+ };
360
+ var TURN = 65536 / (2 * Math.PI);
361
+ /** Radians to 65536ths of a turn; the mask also folds negative and ≥ 2π angles into one turn. */
362
+ var toTurn = (yaw) => Math.round(yaw * TURN) & 65535;
363
+ var clamp16 = (v) => Math.max(-32768, Math.min(32767, Math.round(v)));
364
+ /** Pitch in 1/10000 rad, and back. */
365
+ var toPitch = (pitch) => clamp16(pitch * 1e4);
366
+ var fromPitch = (w) => w / 1e4;
367
+ var fromTurn = (w) => w / TURN;
368
+ /** Null on anything that is not a well-formed input frame. */
369
+ var decodeInput = (buf) => {
370
+ if (buf.byteLength !== 11) return null;
371
+ const v = new DataView(buf);
372
+ if (v.getUint8(0) !== 1) return null;
373
+ return {
374
+ seq: v.getUint16(1),
375
+ keys: v.getUint8(3),
376
+ yaw: fromTurn(v.getUint16(4)),
377
+ pitch: fromPitch(v.getInt16(6)),
378
+ view: (v.getUint16(8) * 256 + v.getUint8(10)) / 256
379
+ };
380
+ };
381
+ var encodeSnapshot = (s) => {
382
+ const shots = 4 + 24 * s.players.length;
383
+ const v = new DataView(new ArrayBuffer(shots + 1 + 14 * s.shots.length));
384
+ v.setUint8(0, 2);
385
+ v.setUint16(1, s.tick & 65535);
386
+ v.setUint8(3, s.players.length);
387
+ s.players.forEach((p, i) => {
388
+ const o = 4 + i * 24;
389
+ v.setUint8(o, p.id);
390
+ v.setFloat32(o + 1, p.x);
391
+ v.setFloat32(o + 5, p.y);
392
+ v.setFloat32(o + 9, p.z);
393
+ v.setFloat32(o + 13, p.vy);
394
+ v.setUint16(o + 17, toTurn(p.yaw));
395
+ v.setInt16(o + 19, toPitch(p.pitch));
396
+ v.setUint16(o + 21, p.ack & 65535);
397
+ v.setUint8(o + 23, +p.ground | p.cool << 1);
398
+ });
399
+ v.setUint8(shots, s.shots.length);
400
+ s.shots.forEach((h, i) => {
401
+ const o = shots + 1 + i * 14;
402
+ v.setUint8(o, h.id);
403
+ v.setFloat32(o + 1, h.x);
404
+ v.setFloat32(o + 5, h.y);
405
+ v.setFloat32(o + 9, h.z);
406
+ v.setUint8(o + 13, h.hit & 255);
407
+ });
408
+ return v.buffer;
409
+ };
410
+ /** Returns null on anything that is not a known text message. */
411
+ var parseText = (raw) => {
412
+ try {
413
+ const msg = JSON.parse(raw);
414
+ return msg && typeof msg.t === "string" ? msg : null;
415
+ } catch {
416
+ return null;
417
+ }
418
+ };
419
+ // src/shared/shoot.ts
420
+ /** 32-bit integer hash (a xorshift-multiply round, twice). */
421
+ var hash = (n) => {
422
+ for (let k = 0; k < 2; k++) n = Math.imul(n ^ n >>> 16, 73244475);
423
+ return (n ^ n >>> 16) >>> 0;
424
+ };
425
+ /**
426
+ * Unit direction of a shot: the view direction at yaw and pitch (yaw 0, pitch 0 is -z; pitch > 0
427
+ * looks up), turned by at most SPREAD in a direction hashed from seq, so the host and the
428
+ * shooter's client agree on it.
429
+ */
430
+ var aim = (yaw, pitch, seq) => {
431
+ const [sy, cy, sp, cp] = [
432
+ Math.sin(yaw),
433
+ Math.cos(yaw),
434
+ Math.sin(pitch),
435
+ Math.cos(pitch)
436
+ ];
437
+ const h = hash(seq + 1);
438
+ const a = (h & 65535) / 65536 * TAU;
439
+ const r = (h >>> 16) / 65535;
440
+ const [x, y] = [Math.cos(a) * r * SPREAD, Math.sin(a) * r * SPREAD];
441
+ const d = [
442
+ -sy * cp + cy * x + sy * sp * y,
443
+ sp + cp * y,
444
+ -cy * cp - sy * x + cy * sp * y
445
+ ];
446
+ const n = Math.hypot(...d);
447
+ return [
448
+ d[0] / n,
449
+ d[1] / n,
450
+ d[2] / n
451
+ ];
452
+ };
453
+ /**
454
+ * Distance along the ray from o (inside the map) in the unit direction d to the first wall, step
455
+ * face, floor or ceiling, at most max.
456
+ *
457
+ * Walks the sectors: in the current convex one the ray leaves through the edge with the least
458
+ * crossing distance among the edges it goes outward through, or ends earlier on the floor or the
459
+ * ceiling. A solid edge, or a portal whose opening does not hold the crossing height, ends it.
460
+ */
461
+ var trace = (lvl, o, d, max) => {
462
+ const { map, edges } = lvl;
463
+ let s = Math.max(sectorAt(lvl, o[0], o[2]), 0);
464
+ let t = 0;
465
+ for (let hop = 0; hop <= map.sectors.length; hop++) {
466
+ const { floor, ceil } = map.sectors[s];
467
+ let exit = Infinity;
468
+ let through;
469
+ for (const e of edges) {
470
+ if (e.front !== s) continue;
471
+ const [ex, ez] = [e.bx - e.ax, e.bz - e.az];
472
+ const out = d[2] * ex - d[0] * ez;
473
+ if (out >= 0) continue;
474
+ const x = Math.max(((o[2] - e.az) * ex - (o[0] - e.ax) * ez) / -out, t);
475
+ const u = ((o[0] + x * d[0] - e.ax) * ex + (o[2] + x * d[2] - e.az) * ez) / (ex * ex + ez * ez);
476
+ if (u > -1e-9 && u < 1 + 1e-9 && (x < exit - 1e-9 || x < exit + 1e-9 && e.back < 0)) [exit, through] = [x, e];
477
+ }
478
+ const flat = d[1] < 0 ? (floor - o[1]) / d[1] : d[1] > 0 ? (ceil - o[1]) / d[1] : Infinity;
479
+ if (flat <= exit || !through) return Math.min(Math.max(flat, t), max);
480
+ if (exit >= max) return max;
481
+ const y = o[1] + exit * d[1];
482
+ const k = through.back < 0 ? void 0 : map.sectors[through.back];
483
+ if (!k || y < Math.max(floor, k.floor) || y > Math.min(ceil, k.ceil)) return exit;
484
+ [s, t] = [through.back, exit];
485
+ }
486
+ return t;
487
+ };
488
+ /**
489
+ * Distance at which the ray enters the upright cylinder of t (sides and caps), Infinity on a miss:
490
+ * the span of the ray inside the infinite cylinder, cut to the span between the cap planes.
491
+ */
492
+ var hit = (o, d, t) => {
493
+ const [px, pz] = [o[0] - t.x, o[2] - t.z];
494
+ const a = d[0] * d[0] + d[2] * d[2];
495
+ const b = px * d[0] + pz * d[2];
496
+ const c = px * px + pz * pz - PLAYER_R * PLAYER_R;
497
+ let [lo, hi] = [0, Infinity];
498
+ if (a > 1e-12) {
499
+ const q = b * b - a * c;
500
+ if (q < 0) return Infinity;
501
+ [lo, hi] = [Math.max(0, (-b - Math.sqrt(q)) / a), (-b + Math.sqrt(q)) / a];
502
+ } else if (c > 0) return Infinity;
503
+ const [y0, y1] = [t.y - o[1], t.y + PLAYER_H - o[1]];
504
+ if (d[1]) {
505
+ const [u, v] = [y0 / d[1], y1 / d[1]];
506
+ lo = Math.max(lo, Math.min(u, v));
507
+ hi = Math.min(hi, Math.max(u, v));
508
+ } else if (y0 > 0 || y1 < 0) return Infinity;
509
+ return lo <= hi ? lo : Infinity;
510
+ };
511
+ /**
512
+ * Resolves one shot of `shooter` (state after the step that fired) from its eye (feet + EYE)
513
+ * along aim(yaw, pitch, seq): the nearest of the level (trace) and the upright cylinders
514
+ * (PLAYER_R, PLAYER_H, from the feet) of `targets` other than the shooter, within RANGE.
515
+ */
516
+ var fire = (lvl, shooter, seq, targets) => {
517
+ const o = [
518
+ shooter.x,
519
+ shooter.y + EYE,
520
+ shooter.z
521
+ ];
522
+ const d = aim(shooter.yaw, shooter.pitch, seq);
523
+ let t = trace(lvl, o, d, 100);
524
+ let id = -1;
525
+ for (const p of targets) {
526
+ const u = p.id === shooter.id ? Infinity : hit(o, d, p);
527
+ if (u < t) [t, id] = [u, p.id];
528
+ }
529
+ return {
530
+ id: shooter.id,
531
+ x: o[0] + d[0] * t,
532
+ y: o[1] + d[1] * t,
533
+ z: o[2] + d[2] * t,
534
+ hit: id
535
+ };
536
+ };
537
+ // src/shared/room.ts
538
+ /** Ticks run at most this many times per timer callback, so a stalled host does not spiral. */
539
+ var MAX_CATCH_UP = 20;
540
+ /** The map every room plays, compiled once. */
541
+ var LEVEL = compile(HALL);
542
+ /** A new player does not spawn within this many metres of another one. */
543
+ var SPAWN_CLEAR = 2;
544
+ var COLORS = [
545
+ "#e6194b",
546
+ "#3cb44b",
547
+ "#ffe119",
548
+ "#4363d8",
549
+ "#f58231",
550
+ "#911eb4",
551
+ "#42d4f4",
552
+ "#f032e6",
553
+ "#bfef45",
554
+ "#fabed4",
555
+ "#469990",
556
+ "#dcbeff",
557
+ "#9a6324",
558
+ "#800000",
559
+ "#aaffc3",
560
+ "#808000"
561
+ ];
562
+ /** First n characters (code points) of s, trimmed. */
563
+ var cut = (s, n) => Array.from(s).slice(0, n).join("").trim();
564
+ var cleanName = (raw) => cut((typeof raw === "string" ? raw : "").replace(/\s+/g, " ").replace(/\p{Cc}/gu, "").trim(), 16);
565
+ /** name, or name 2, name 3… (base cut to stay within MAX_NAME) when it is taken. */
566
+ var unique = (name, taken) => {
567
+ let out = name;
568
+ for (let n = 2; taken.includes(out); n++) out = `${cut(name, 15 - `${n}`.length)} ${n}`;
569
+ return out;
570
+ };
571
+ var setEvery = (cb, ms) => {
572
+ const id = setInterval(cb, ms);
573
+ return () => clearInterval(id);
574
+ };
575
+ /**
576
+ * One game, transport-agnostic. The Node server and the local host tab both drive a Room: they
577
+ * feed it connections and frames, the room answers through Conn.send.
578
+ *
579
+ * - A peer joins with { t: 'join', name }: unique name (`Bob 2`), unique colour, a spawn point not
580
+ * taken by another player; 'full' beyond rules.maxPlayers. Everyone gets a fresh 'roster' on
581
+ * every join and leave.
582
+ * - Each input frame moves its player at once with move(); the INPUT_BURST bank drops the excess.
583
+ * A step that fires resolves its shot at once with fire(), the other players rewound to the
584
+ * view tick of the input (lag compensation, at most MAX_REWIND ticks back).
585
+ * - Every SNAP_EVERY ticks of TICK_HZ, one snapshot with every player and the shots resolved
586
+ * since the last one goes to every peer.
587
+ */
588
+ var Room = class {
589
+ /** Every connected peer; null until it joins. */
590
+ peers = /* @__PURE__ */ new Map();
591
+ ticks = 0;
592
+ /** The players as each snapshot of the last MAX_REWIND ticks sent them. */
593
+ history = [];
594
+ /** Shots resolved since the last snapshot. */
595
+ shots = [];
596
+ halt;
597
+ rules;
598
+ /** Round-robin cursor over the spawn points, used once they are all taken. */
599
+ rr = 0;
600
+ /** Missing rules fall back to RULES. */
601
+ constructor(rules = {}) {
602
+ this.rules = {
603
+ ...RULES,
604
+ ...rules
605
+ };
606
+ }
607
+ /** Number of joined players. */
608
+ get size() {
609
+ return this.players().length;
610
+ }
611
+ /** A peer opened. Nothing is sent until it joins. */
612
+ connect(conn) {
613
+ this.peers.set(conn, null);
614
+ }
615
+ /** A text (JSON) or binary frame from a peer. */
616
+ receive(conn, data) {
617
+ const p = this.peers.get(conn);
618
+ if (typeof data === "string") {
619
+ const msg = parseText(data);
620
+ if (p === null && msg?.t === "join") this.join(conn, msg.name);
621
+ return;
622
+ }
623
+ const input = p && decodeInput(data);
624
+ if (input && p.bank > 0) {
625
+ p.bank--;
626
+ if (move(p.state, input, LEVEL)) this.shots.push(fire(LEVEL, p.state, input.seq, this.rewind(p, input.view)));
627
+ p.ack = input.seq;
628
+ }
629
+ }
630
+ /** A peer left: its player goes, the roster goes out again. */
631
+ disconnect(conn) {
632
+ const p = this.peers.get(conn);
633
+ this.peers.delete(conn);
634
+ if (p) this.roster();
635
+ }
636
+ /** One host tick. Public so tests can drive the room without a timer. */
637
+ tick() {
638
+ const players = this.players();
639
+ if (!players.length) return;
640
+ players.forEach((p) => p.bank = Math.min(10, p.bank + 1));
641
+ if (++this.ticks % 2 === 0) {
642
+ this.history.push({
643
+ tick: this.ticks,
644
+ players: players.map((p) => ({ ...p.state }))
645
+ });
646
+ while (this.history[0].tick < this.ticks - 30) this.history.shift();
647
+ const snap = encodeSnapshot({
648
+ tick: this.ticks,
649
+ players: players.map((p) => ({
650
+ ...p.state,
651
+ ack: p.ack
652
+ })),
653
+ shots: this.shots
654
+ });
655
+ this.shots = [];
656
+ players.forEach((p) => p.conn.send(snap));
657
+ }
658
+ }
659
+ /**
660
+ * Drives tick() with a timer. An accumulator on performance.now() keeps the
661
+ * simulation at TICK_HZ whatever the timer jitter; after a stall at most
662
+ * MAX_CATCH_UP ticks are replayed and the rest of the backlog is dropped.
663
+ * `every` replaces setInterval, e.g. with a Worker-backed timer that
664
+ * background tabs do not throttle.
665
+ */
666
+ start(every = setEvery) {
667
+ if (this.halt) return;
668
+ const dt = 1e3 / 60;
669
+ let last = performance.now();
670
+ let acc = 0;
671
+ this.halt = every(() => {
672
+ const now = performance.now();
673
+ acc += now - last;
674
+ last = now;
675
+ for (let n = 0; acc >= dt && n < MAX_CATCH_UP; n++) {
676
+ this.tick();
677
+ acc -= dt;
678
+ }
679
+ acc %= dt;
680
+ }, dt);
681
+ }
682
+ stop() {
683
+ this.halt?.();
684
+ this.halt = void 0;
685
+ }
686
+ /**
687
+ * The players other than `shooter` as the shooter drew them at `view` (a u16 tick, fractional,
688
+ * read against the current tick and clamped to the kept history): x, y, z interpolated between
689
+ * the two snapshots around it like the client does. A player the history does not hold stands
690
+ * where it is.
691
+ */
692
+ rewind(shooter, view = 0) {
693
+ const now = this.players().filter((p) => p !== shooter).map((p) => p.state);
694
+ const [first, last] = [this.history[0], this.history.at(-1)];
695
+ if (!first || !last) return now;
696
+ const back = ((this.ticks - view + 32768) % 65536 + 65536) % 65536 - 32768;
697
+ const t = Math.min(Math.max(this.ticks - back, first.tick), last.tick);
698
+ let k = 0;
699
+ while (k + 1 < this.history.length && this.history[k + 1].tick <= t) k++;
700
+ const [a, b] = [this.history[k], this.history[k + 1] ?? this.history[k]];
701
+ const u = b.tick > a.tick ? (t - a.tick) / (b.tick - a.tick) : 0;
702
+ return now.map((p) => {
703
+ const pa = a.players.find((q) => q.id === p.id);
704
+ const pb = b.players.find((q) => q.id === p.id);
705
+ const l = (m, n) => m + (n - m) * u;
706
+ return pa && pb ? {
707
+ ...pb,
708
+ x: l(pa.x, pb.x),
709
+ y: l(pa.y, pb.y),
710
+ z: l(pa.z, pb.z)
711
+ } : pb ?? pa ?? p;
712
+ });
713
+ }
714
+ players() {
715
+ return [...this.peers.values()].filter((p) => p !== null);
716
+ }
717
+ join(conn, name) {
718
+ const players = this.players();
719
+ if (players.length >= this.rules.maxPlayers) {
720
+ conn.send(JSON.stringify({ t: "full" }));
721
+ return;
722
+ }
723
+ let id = 0;
724
+ while (players.some((p) => p.info.id === id)) id++;
725
+ const color = COLORS.find((c) => !players.some((p) => p.info.color === c)) ?? COLORS[id % COLORS.length];
726
+ const near = (n) => {
727
+ const { x, z } = spawn(id, LEVEL, n);
728
+ return players.some((p) => Math.hypot(p.state.x - x, p.state.z - z) < SPAWN_CLEAR);
729
+ };
730
+ let n = 0;
731
+ while (n < HALL.spawns.length && near(n)) n++;
732
+ const player = {
733
+ conn,
734
+ info: {
735
+ id,
736
+ name: unique(cleanName(name) || `Player ${id}`, players.map((p) => p.info.name)),
737
+ color
738
+ },
739
+ state: spawn(id, LEVEL, n < HALL.spawns.length ? n : this.rr++),
740
+ bank: 10,
741
+ ack: 65535
742
+ };
743
+ this.peers.set(conn, player);
744
+ this.roster();
745
+ }
746
+ /** Each joined peer gets the full roster with its own id. */
747
+ roster() {
748
+ const players = this.players();
749
+ const list = players.map((p) => p.info).sort((a, b) => a.id - b.id);
750
+ players.forEach((p) => p.conn.send(JSON.stringify({
751
+ t: "roster",
752
+ you: p.info.id,
753
+ players: list
754
+ })));
755
+ }
756
+ };
757
+ // src/server/index.ts
758
+ /** `/demo` → "demo", `/` → "default". Undecodable paths are kept as is. */
759
+ var roomName = (url = "/") => {
760
+ const path = new URL(url, "http://x").pathname.slice(1);
761
+ let name = path;
762
+ try {
763
+ name = decodeURIComponent(path);
764
+ } catch {}
765
+ return name.slice(0, 64) || "default";
766
+ };
767
+ /**
768
+ * Starts the WebSocket server: one Room per URL path. Returns an idempotent stop
769
+ * function that closes every socket, room, the WebSocketServer, and the HTTP
770
+ * server only when serve created it (`{ port }`).
771
+ */
772
+ var serve = (opts) => {
773
+ const wss = new WebSocketServer({
774
+ ...opts.server ? { server: opts.server } : { port: opts.port },
775
+ maxPayload: 256
776
+ });
777
+ const rooms = /* @__PURE__ */ new Map();
778
+ wss.on("connection", (ws, req) => {
779
+ const name = roomName(req.url);
780
+ const entry = rooms.get(name) ?? {
781
+ room: new Room(opts.rules),
782
+ conns: 0
783
+ };
784
+ const { room } = entry;
785
+ if (!entry.conns++) {
786
+ rooms.set(name, entry);
787
+ room.start();
788
+ }
789
+ const conn = { send: (data) => {
790
+ if (ws.readyState === ws.OPEN) ws.send(data);
791
+ } };
792
+ room.connect(conn);
793
+ ws.on("message", (data, isBinary) => {
794
+ const buf = data;
795
+ room.receive(conn, isBinary ? new Uint8Array(buf).buffer : buf.toString());
796
+ });
797
+ ws.on("error", () => {});
798
+ ws.on("close", () => {
799
+ room.disconnect(conn);
800
+ if (!--entry.conns && rooms.get(name) === entry) {
801
+ room.stop();
802
+ rooms.delete(name);
803
+ }
804
+ });
805
+ });
806
+ let stopping;
807
+ return () => stopping ??= new Promise((resolve, reject) => {
808
+ wss.clients.forEach((ws) => ws.terminate());
809
+ rooms.forEach(({ room }) => room.stop());
810
+ rooms.clear();
811
+ wss.close((err) => err ? reject(err) : resolve());
812
+ });
813
+ };
814
+ export { serve };