@korial29/tiny-fps 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,624 @@
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
+ /** Pitch is clamped to ±MAX_PITCH radians, just short of straight up or down. */
7
+ var MAX_PITCH = 1.55;
8
+ var RULES = { maxPlayers: 16 };
9
+ // src/shared/level.ts
10
+ /** Joins every edge a→b with the sector that has b→a, if any: that edge is a portal. */
11
+ var compile = (map) => {
12
+ const key = (ax, az, bx, bz) => `${ax},${az},${bx},${bz}`;
13
+ const owner = /* @__PURE__ */ new Map();
14
+ map.sectors.forEach(({ pts }, s) => pts.forEach(([ax, az], i) => owner.set(key(ax, az, ...pts[(i + 1) % pts.length]), s)));
15
+ return {
16
+ map,
17
+ edges: map.sectors.flatMap(({ pts }, front) => pts.map(([ax, az], i) => {
18
+ const [bx, bz] = pts[(i + 1) % pts.length];
19
+ return {
20
+ ax,
21
+ az,
22
+ bx,
23
+ bz,
24
+ front,
25
+ back: owner.get(key(bx, bz, ax, az)) ?? -1
26
+ };
27
+ }))
28
+ };
29
+ };
30
+ /** Whether (x, z) is inside the sector, its edges included. */
31
+ var inside = ({ pts }, x, z) => pts.every(([ax, az], i) => {
32
+ const [bx, bz] = pts[(i + 1) % pts.length];
33
+ return (bx - ax) * (z - az) - (bz - az) * (x - ax) >= 0;
34
+ });
35
+ /** Index of the first sector holding (x, z), -1 outside the map. */
36
+ var sectorAt = ({ map }, x, z) => map.sectors.findIndex((s) => inside(s, x, z));
37
+ // src/shared/types.ts
38
+ /** Bits of Input.keys. Keys are read by KeyboardEvent.code, so WASD is ZQSD on AZERTY. */
39
+ var KEY = {
40
+ FORWARD: 1,
41
+ BACK: 2,
42
+ LEFT: 4,
43
+ RIGHT: 8,
44
+ JUMP: 16
45
+ };
46
+ /** Procedural textures, generated by the client. A sector names one per surface. */
47
+ var TEX = {
48
+ BRICK: 0,
49
+ TILE: 1,
50
+ LAVA: 2,
51
+ STONE: 3
52
+ };
53
+ // src/shared/map.ts
54
+ var sector = (pts, floor, ceil, ft, light) => ({
55
+ pts,
56
+ floor,
57
+ ceil,
58
+ ft,
59
+ wt: TEX.BRICK,
60
+ ct: TEX.STONE,
61
+ light
62
+ });
63
+ /**
64
+ * The first sector map, a test bed for heights: a start room, a low corridor, a tall hall around a
65
+ * lava pit, stairs up to a balcony. Each spot is reachable on foot; the pit is a 1 m drop.
66
+ */
67
+ var HALL = {
68
+ sectors: [
69
+ sector([
70
+ [0, 0],
71
+ [16, 0],
72
+ [16, 6],
73
+ [16, 10],
74
+ [16, 16],
75
+ [0, 16]
76
+ ], 1, 5, TEX.TILE),
77
+ sector([
78
+ [16, 6],
79
+ [20, 6],
80
+ [20, 10],
81
+ [16, 10]
82
+ ], 1, 3.5, TEX.TILE, .6),
83
+ sector([
84
+ [20, 0],
85
+ [36, 0],
86
+ [36, 6],
87
+ [30, 6],
88
+ [26, 6],
89
+ [20, 6]
90
+ ], 1, 7, TEX.TILE),
91
+ sector([
92
+ [20, 6],
93
+ [26, 6],
94
+ [26, 10],
95
+ [20, 10]
96
+ ], 1, 7, TEX.TILE),
97
+ sector([
98
+ [30, 6],
99
+ [36, 6],
100
+ [36, 10],
101
+ [30, 10]
102
+ ], 1, 7, TEX.TILE),
103
+ sector([
104
+ [20, 10],
105
+ [26, 10],
106
+ [30, 10],
107
+ [36, 10],
108
+ [36, 16],
109
+ [32, 16],
110
+ [28, 16],
111
+ [20, 16]
112
+ ], 1, 7, TEX.TILE),
113
+ sector([
114
+ [26, 6],
115
+ [30, 6],
116
+ [30, 10],
117
+ [26, 10]
118
+ ], 0, 7, TEX.LAVA),
119
+ sector([
120
+ [28, 16],
121
+ [32, 16],
122
+ [32, 17],
123
+ [28, 17]
124
+ ], 1.4, 7, TEX.STONE),
125
+ sector([
126
+ [28, 17],
127
+ [32, 17],
128
+ [32, 18],
129
+ [28, 18]
130
+ ], 1.8, 7, TEX.STONE),
131
+ sector([
132
+ [28, 18],
133
+ [32, 18],
134
+ [32, 19],
135
+ [28, 19]
136
+ ], 2.2, 7, TEX.STONE),
137
+ sector([
138
+ [24, 19],
139
+ [28, 19],
140
+ [32, 19],
141
+ [36, 19],
142
+ [36, 26],
143
+ [24, 26]
144
+ ], 2.6, 7, TEX.TILE, .8)
145
+ ],
146
+ spawns: [
147
+ [
148
+ 4,
149
+ 4,
150
+ Math.PI * 1.5
151
+ ],
152
+ [
153
+ 4,
154
+ 12,
155
+ Math.PI * 1.5
156
+ ],
157
+ [
158
+ 8,
159
+ 8,
160
+ Math.PI * 1.5
161
+ ],
162
+ [
163
+ 12,
164
+ 4,
165
+ Math.PI * 1.5
166
+ ],
167
+ [
168
+ 12,
169
+ 12,
170
+ Math.PI * 1.5
171
+ ],
172
+ [
173
+ 2,
174
+ 8,
175
+ Math.PI * 1.5
176
+ ],
177
+ [
178
+ 8,
179
+ 2,
180
+ Math.PI * 1.5
181
+ ],
182
+ [
183
+ 8,
184
+ 14,
185
+ Math.PI * 1.5
186
+ ]
187
+ ]
188
+ };
189
+ // src/shared/physics.ts
190
+ var DT = 1 / 60;
191
+ var TAU = Math.PI * 2;
192
+ /** Overlaps smaller than this are rounding noise, not contact. */
193
+ var EPS = 1e-6;
194
+ /**
195
+ * Feet this close below a floor still stand on it (rounding of y must not sink a player into
196
+ * the floor or flip a step test), and a fall that ends this close above a floor
197
+ * lands on it.
198
+ */
199
+ var SKIN = .01;
200
+ /** An angle in [0, 2π). */
201
+ var norm = (a) => (a % TAU + TAU) % TAU;
202
+ /** Unit vector the player faces at yaw, on the floor plane: yaw 0 looks toward -z. */
203
+ var forward = (yaw) => [-Math.sin(yaw), -Math.cos(yaw)];
204
+ var f32 = Math.fround;
205
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
206
+ /**
207
+ * Whether the edge stops a player whose feet are at y: a solid wall, a floor on the far side more
208
+ * than STEP_H above the feet, or an opening (the lower ceiling minus the higher floor) too short
209
+ * for the body or under the head. SKIN keeps rounding from flipping the answer
210
+ * for a player resting on a step or against a ceiling.
211
+ */
212
+ var shut = ({ map }, e, y) => {
213
+ if (e.back < 0) return true;
214
+ const f = map.sectors[e.front];
215
+ const k = map.sectors[e.back];
216
+ const top = Math.min(f.ceil, k.ceil);
217
+ return k.floor > y + .5 + SKIN || top - Math.max(f.floor, k.floor) < 1.8 - SKIN || top < y + 1.8 - SKIN;
218
+ };
219
+ /**
220
+ * Pushes the player out of every shut edge it overlaps, deepest first (up to 4 passes: two walls
221
+ * in a corner settle in two). The push is along the vector from the closest point of the edge, so
222
+ * against a wall it is the normal (the other axis keeps moving: the slide) and at an endpoint it
223
+ * is radial: a player running at a corner slides round it. Deepest first matters on a straight
224
+ * wall split by a collinear point: the endpoint of the next piece is always shallower than the
225
+ * normal push of the piece the player is on, so it never pulls him back along the wall.
226
+ * An edge only counts from its front side (the inside of its sector); across it, the twin edge of
227
+ * the neighbour decides.
228
+ * Tunnelling is impossible: a step is RUN_SPEED / TICK_HZ = 0.1 m and the axis ends each step at
229
+ * least PLAYER_R = 0.4 m from every shut edge it started beside, so it cannot cross one in 0.1 m.
230
+ */
231
+ var push = (p, lvl) => {
232
+ const reach = (PLAYER_R - EPS) ** 2;
233
+ for (let pass = 0; pass < 4; pass++) {
234
+ let best = 0;
235
+ let px = 0;
236
+ let pz = 0;
237
+ for (const e of lvl.edges) {
238
+ const ex = e.bx - e.ax;
239
+ const ez = e.bz - e.az;
240
+ const rx = p.x - e.ax;
241
+ const rz = p.z - e.az;
242
+ if (ex * rz - ez * rx < 0) continue;
243
+ const t = clamp((rx * ex + rz * ez) / (ex * ex + ez * ez), 0, 1);
244
+ const dx = rx - t * ex;
245
+ const dz = rz - t * ez;
246
+ const d2 = dx * dx + dz * dz;
247
+ if (d2 >= reach) continue;
248
+ const d = Math.sqrt(d2);
249
+ if (.4 - d <= best || !shut(lvl, e, p.y)) continue;
250
+ best = PLAYER_R - d;
251
+ const n = d || Math.hypot(ex, ez);
252
+ px = (d ? dx : -ez) / n * best;
253
+ pz = (d ? dz : ex) / n * best;
254
+ }
255
+ if (!best) return;
256
+ p.x += px;
257
+ p.z += pz;
258
+ }
259
+ };
260
+ /**
261
+ * The floor and the ceiling under the player: the highest floor and the lowest ceiling of the
262
+ * sector holding the axis and of every sector across a portal the circle overlaps and could walk
263
+ * through (a shut edge is a wall: the circle never counts what lies behind it).
264
+ */
265
+ var span = (lvl, x, z, y) => {
266
+ const { map, edges } = lvl;
267
+ const s = Math.max(sectorAt(lvl, x, z), 0);
268
+ let { floor, ceil } = map.sectors[s];
269
+ const reach = (PLAYER_R - EPS) ** 2;
270
+ for (const e of edges) {
271
+ if (e.front !== s || e.back < 0) continue;
272
+ const ex = e.bx - e.ax;
273
+ const ez = e.bz - e.az;
274
+ const rx = x - e.ax;
275
+ const rz = z - e.az;
276
+ const t = clamp((rx * ex + rz * ez) / (ex * ex + ez * ez), 0, 1);
277
+ if ((rx - t * ex) ** 2 + (rz - t * ez) ** 2 < reach && !shut(lvl, e, y)) {
278
+ const k = map.sectors[e.back];
279
+ floor = Math.max(floor, k.floor);
280
+ ceil = Math.min(ceil, k.ceil);
281
+ }
282
+ }
283
+ return [floor, ceil];
284
+ };
285
+ /**
286
+ * Applies one input for 1 / TICK_HZ s, in place: yaw and pitch from the input (pitch clamped to
287
+ * MAX_PITCH), RUN_SPEED along the held keys (diagonals not faster), a jump from the ground,
288
+ * GRAVITY, then collisions of the cylinder (PLAYER_R, PLAYER_H) with the sectors of the level:
289
+ * it slides along shut edges (see shut and push), stands on the highest floor under the circle
290
+ * (a step up to STEP_H is climbed at once, a higher one needs a jump, a ledge is a fall), and
291
+ * bumps its head on the lowest ceiling.
292
+ */
293
+ var move = (p, i, lvl) => {
294
+ p.yaw = norm(i.yaw);
295
+ p.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, i.pitch));
296
+ const f = (i.keys & KEY.FORWARD ? 1 : 0) - (i.keys & KEY.BACK ? 1 : 0);
297
+ const s = (i.keys & KEY.RIGHT ? 1 : 0) - (i.keys & KEY.LEFT ? 1 : 0);
298
+ const d = 6 * DT / (Math.hypot(f, s) || 1);
299
+ const [fx, fz] = forward(p.yaw);
300
+ p.x += (fx * f - fz * s) * d;
301
+ p.z += (fz * f + fx * s) * d;
302
+ push(p, lvl);
303
+ const [floor, ceil] = span(lvl, p.x, p.z, p.y);
304
+ if (p.ground && i.keys & KEY.JUMP) p.vy = 7;
305
+ const vy = p.vy - 20 * DT;
306
+ p.y += (p.vy + vy) / 2 * DT;
307
+ p.vy = vy;
308
+ p.ground = false;
309
+ if (p.y + 1.8 > ceil) {
310
+ p.y = ceil - PLAYER_H;
311
+ p.vy = Math.min(p.vy, 0);
312
+ }
313
+ if (p.vy <= 0 ? p.y <= floor + SKIN : p.y < floor) {
314
+ p.y = floor;
315
+ if (p.vy <= 0) {
316
+ p.vy = 0;
317
+ p.ground = true;
318
+ }
319
+ }
320
+ p.x = f32(p.x);
321
+ p.y = f32(p.y);
322
+ p.z = f32(p.z);
323
+ p.vy = f32(p.vy);
324
+ };
325
+ /** A player standing on the floor at spawn point n of the map (modulo the count). */
326
+ var spawn = (id, lvl, n) => {
327
+ const { spawns, sectors } = lvl.map;
328
+ const [x, z, yaw] = spawns[(n % spawns.length + spawns.length) % spawns.length];
329
+ const y = sectors[Math.max(sectorAt(lvl, x, z), 0)].floor;
330
+ return {
331
+ id,
332
+ x: f32(x),
333
+ y: f32(y),
334
+ z: f32(z),
335
+ vy: 0,
336
+ yaw: norm(yaw),
337
+ pitch: 0,
338
+ ground: true
339
+ };
340
+ };
341
+ var TURN = 65536 / (2 * Math.PI);
342
+ /** Radians to 65536ths of a turn; the mask also folds negative and ≥ 2π angles into one turn. */
343
+ var toTurn = (yaw) => Math.round(yaw * TURN) & 65535;
344
+ var clamp16 = (v) => Math.max(-32768, Math.min(32767, Math.round(v)));
345
+ /** Pitch in 1/10000 rad, and back. */
346
+ var toPitch = (pitch) => clamp16(pitch * 1e4);
347
+ var fromPitch = (w) => w / 1e4;
348
+ var fromTurn = (w) => w / TURN;
349
+ /** Null on anything that is not a well-formed input frame. */
350
+ var decodeInput = (buf) => {
351
+ if (buf.byteLength !== 8) return null;
352
+ const v = new DataView(buf);
353
+ if (v.getUint8(0) !== 1) return null;
354
+ return {
355
+ seq: v.getUint16(1),
356
+ keys: v.getUint8(3),
357
+ yaw: fromTurn(v.getUint16(4)),
358
+ pitch: fromPitch(v.getInt16(6))
359
+ };
360
+ };
361
+ var encodeSnapshot = (s) => {
362
+ const v = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(4 + 24 * s.players.length));
363
+ v.setUint8(0, 2);
364
+ v.setUint16(1, s.tick & 65535);
365
+ v.setUint8(3, s.players.length);
366
+ s.players.forEach((p, i) => {
367
+ const o = 4 + i * 24;
368
+ v.setUint8(o, p.id);
369
+ v.setFloat32(o + 1, p.x);
370
+ v.setFloat32(o + 5, p.y);
371
+ v.setFloat32(o + 9, p.z);
372
+ v.setFloat32(o + 13, p.vy);
373
+ v.setUint16(o + 17, toTurn(p.yaw));
374
+ v.setInt16(o + 19, toPitch(p.pitch));
375
+ v.setUint16(o + 21, p.ack & 65535);
376
+ v.setUint8(o + 23, +p.ground);
377
+ });
378
+ return v.buffer;
379
+ };
380
+ /** Returns null on anything that is not a known text message. */
381
+ var parseText = (raw) => {
382
+ try {
383
+ const msg = JSON.parse(raw);
384
+ return msg && typeof msg.t === "string" ? msg : null;
385
+ } catch {
386
+ return null;
387
+ }
388
+ };
389
+ // src/shared/room.ts
390
+ /** Ticks run at most this many times per timer callback, so a stalled host does not spiral. */
391
+ var MAX_CATCH_UP = 20;
392
+ /** The map every room plays, compiled once. */
393
+ var LEVEL = compile(HALL);
394
+ /** A new player does not spawn within this many metres of another one. */
395
+ var SPAWN_CLEAR = 2;
396
+ var COLORS = [
397
+ "#e6194b",
398
+ "#3cb44b",
399
+ "#ffe119",
400
+ "#4363d8",
401
+ "#f58231",
402
+ "#911eb4",
403
+ "#42d4f4",
404
+ "#f032e6",
405
+ "#bfef45",
406
+ "#fabed4",
407
+ "#469990",
408
+ "#dcbeff",
409
+ "#9a6324",
410
+ "#800000",
411
+ "#aaffc3",
412
+ "#808000"
413
+ ];
414
+ /** First n characters (code points) of s, trimmed. */
415
+ var cut = (s, n) => Array.from(s).slice(0, n).join("").trim();
416
+ var cleanName = (raw) => cut((typeof raw === "string" ? raw : "").replace(/\s+/g, " ").replace(/\p{Cc}/gu, "").trim(), 16);
417
+ /** name, or name 2, name 3… (base cut to stay within MAX_NAME) when it is taken. */
418
+ var unique = (name, taken) => {
419
+ let out = name;
420
+ for (let n = 2; taken.includes(out); n++) out = `${cut(name, 15 - `${n}`.length)} ${n}`;
421
+ return out;
422
+ };
423
+ var setEvery = (cb, ms) => {
424
+ const id = setInterval(cb, ms);
425
+ return () => clearInterval(id);
426
+ };
427
+ /**
428
+ * One game, transport-agnostic. The Node server and the local host tab both drive a Room: they
429
+ * feed it connections and frames, the room answers through Conn.send.
430
+ *
431
+ * - A peer joins with { t: 'join', name }: unique name (`Bob 2`), unique colour, a spawn point not
432
+ * taken by another player; 'full' beyond rules.maxPlayers. Everyone gets a fresh 'roster' on
433
+ * every join and leave.
434
+ * - Each input frame moves its player at once with move(); the INPUT_BURST bank drops the excess.
435
+ * - Every SNAP_EVERY ticks of TICK_HZ, one snapshot with every player goes to every peer.
436
+ */
437
+ var Room = class {
438
+ /** Every connected peer; null until it joins. */
439
+ peers = /* @__PURE__ */ new Map();
440
+ ticks = 0;
441
+ halt;
442
+ rules;
443
+ /** Round-robin cursor over the spawn points, used once they are all taken. */
444
+ rr = 0;
445
+ /** Missing rules fall back to RULES. */
446
+ constructor(rules = {}) {
447
+ this.rules = {
448
+ ...RULES,
449
+ ...rules
450
+ };
451
+ }
452
+ /** Number of joined players. */
453
+ get size() {
454
+ return this.players().length;
455
+ }
456
+ /** A peer opened. Nothing is sent until it joins. */
457
+ connect(conn) {
458
+ this.peers.set(conn, null);
459
+ }
460
+ /** A text (JSON) or binary frame from a peer. */
461
+ receive(conn, data) {
462
+ const p = this.peers.get(conn);
463
+ if (typeof data === "string") {
464
+ const msg = parseText(data);
465
+ if (p === null && msg?.t === "join") this.join(conn, msg.name);
466
+ return;
467
+ }
468
+ const input = p && decodeInput(data);
469
+ if (input && p.bank > 0) {
470
+ p.bank--;
471
+ move(p.state, input, LEVEL);
472
+ p.ack = input.seq;
473
+ }
474
+ }
475
+ /** A peer left: its player goes, the roster goes out again. */
476
+ disconnect(conn) {
477
+ const p = this.peers.get(conn);
478
+ this.peers.delete(conn);
479
+ if (p) this.roster();
480
+ }
481
+ /** One host tick. Public so tests can drive the room without a timer. */
482
+ tick() {
483
+ const players = this.players();
484
+ if (!players.length) return;
485
+ players.forEach((p) => p.bank = Math.min(10, p.bank + 1));
486
+ if (++this.ticks % 2 === 0) {
487
+ const snap = encodeSnapshot({
488
+ tick: this.ticks,
489
+ players: players.map((p) => ({
490
+ ...p.state,
491
+ ack: p.ack
492
+ }))
493
+ });
494
+ players.forEach((p) => p.conn.send(snap));
495
+ }
496
+ }
497
+ /**
498
+ * Drives tick() with a timer. An accumulator on performance.now() keeps the
499
+ * simulation at TICK_HZ whatever the timer jitter; after a stall at most
500
+ * MAX_CATCH_UP ticks are replayed and the rest of the backlog is dropped.
501
+ * `every` replaces setInterval, e.g. with a Worker-backed timer that
502
+ * background tabs do not throttle.
503
+ */
504
+ start(every = setEvery) {
505
+ if (this.halt) return;
506
+ const dt = 1e3 / 60;
507
+ let last = performance.now();
508
+ let acc = 0;
509
+ this.halt = every(() => {
510
+ const now = performance.now();
511
+ acc += now - last;
512
+ last = now;
513
+ for (let n = 0; acc >= dt && n < MAX_CATCH_UP; n++) {
514
+ this.tick();
515
+ acc -= dt;
516
+ }
517
+ acc %= dt;
518
+ }, dt);
519
+ }
520
+ stop() {
521
+ this.halt?.();
522
+ this.halt = void 0;
523
+ }
524
+ players() {
525
+ return [...this.peers.values()].filter((p) => p !== null);
526
+ }
527
+ join(conn, name) {
528
+ const players = this.players();
529
+ if (players.length >= this.rules.maxPlayers) {
530
+ conn.send(JSON.stringify({ t: "full" }));
531
+ return;
532
+ }
533
+ let id = 0;
534
+ while (players.some((p) => p.info.id === id)) id++;
535
+ const color = COLORS.find((c) => !players.some((p) => p.info.color === c)) ?? COLORS[id % COLORS.length];
536
+ const near = (n) => {
537
+ const { x, z } = spawn(id, LEVEL, n);
538
+ return players.some((p) => Math.hypot(p.state.x - x, p.state.z - z) < SPAWN_CLEAR);
539
+ };
540
+ let n = 0;
541
+ while (n < HALL.spawns.length && near(n)) n++;
542
+ const player = {
543
+ conn,
544
+ info: {
545
+ id,
546
+ name: unique(cleanName(name) || `Player ${id}`, players.map((p) => p.info.name)),
547
+ color
548
+ },
549
+ state: spawn(id, LEVEL, n < HALL.spawns.length ? n : this.rr++),
550
+ bank: 10,
551
+ ack: 65535
552
+ };
553
+ this.peers.set(conn, player);
554
+ this.roster();
555
+ }
556
+ /** Each joined peer gets the full roster with its own id. */
557
+ roster() {
558
+ const players = this.players();
559
+ const list = players.map((p) => p.info).sort((a, b) => a.id - b.id);
560
+ players.forEach((p) => p.conn.send(JSON.stringify({
561
+ t: "roster",
562
+ you: p.info.id,
563
+ players: list
564
+ })));
565
+ }
566
+ };
567
+ // src/server/index.ts
568
+ /** `/demo` → "demo", `/` → "default". Undecodable paths are kept as is. */
569
+ var roomName = (url = "/") => {
570
+ const path = new URL(url, "http://x").pathname.slice(1);
571
+ let name = path;
572
+ try {
573
+ name = decodeURIComponent(path);
574
+ } catch {}
575
+ return name.slice(0, 64) || "default";
576
+ };
577
+ /**
578
+ * Starts the WebSocket server: one Room per URL path. Returns an idempotent stop
579
+ * function that closes every socket, room, the WebSocketServer, and the HTTP
580
+ * server only when serve created it (`{ port }`).
581
+ */
582
+ var serve = (opts) => {
583
+ const wss = new WebSocketServer({
584
+ ...opts.server ? { server: opts.server } : { port: opts.port },
585
+ maxPayload: 256
586
+ });
587
+ const rooms = /* @__PURE__ */ new Map();
588
+ wss.on("connection", (ws, req) => {
589
+ const name = roomName(req.url);
590
+ const entry = rooms.get(name) ?? {
591
+ room: new Room(opts.rules),
592
+ conns: 0
593
+ };
594
+ const { room } = entry;
595
+ if (!entry.conns++) {
596
+ rooms.set(name, entry);
597
+ room.start();
598
+ }
599
+ const conn = { send: (data) => {
600
+ if (ws.readyState === ws.OPEN) ws.send(data);
601
+ } };
602
+ room.connect(conn);
603
+ ws.on("message", (data, isBinary) => {
604
+ const buf = data;
605
+ room.receive(conn, isBinary ? new Uint8Array(buf).buffer : buf.toString());
606
+ });
607
+ ws.on("error", () => {});
608
+ ws.on("close", () => {
609
+ room.disconnect(conn);
610
+ if (!--entry.conns && rooms.get(name) === entry) {
611
+ room.stop();
612
+ rooms.delete(name);
613
+ }
614
+ });
615
+ });
616
+ let stopping;
617
+ return () => stopping ??= new Promise((resolve, reject) => {
618
+ wss.clients.forEach((ws) => ws.terminate());
619
+ rooms.forEach(({ room }) => room.stop());
620
+ rooms.clear();
621
+ wss.close((err) => err ? reject(err) : resolve());
622
+ });
623
+ };
624
+ export { serve };
@@ -0,0 +1,22 @@
1
+ import type { PlayerInfo, SocketLike } from '../shared/types';
2
+ export type { PlayerInfo, SocketLike } from '../shared/types';
3
+ export interface TinyFpsOptions {
4
+ name: string;
5
+ /** WebSocket URL, e.g. ws://localhost:8787/demo (the path picks the room). */
6
+ url?: string;
7
+ /** Any WebSocket-like socket, e.g. localSocket('room') from '@korial29/tiny-fps/local'. */
8
+ socket?: SocketLike;
9
+ }
10
+ export type TinyFpsEvent = 'roster' | 'open' | 'close';
11
+ export interface TinyFps {
12
+ on(ev: TinyFpsEvent, cb: () => void): void;
13
+ /** Everyone in the room, the own player included. */
14
+ players(): PlayerInfo[];
15
+ /** Closes the socket, stops the loop and removes the canvas. */
16
+ destroy(): void;
17
+ }
18
+ /**
19
+ * Mounts the game in a Shadow DOM inside container: one WebGL2 canvas filling it, a click locks the
20
+ * pointer, the mouse looks, WASD (ZQSD on AZERTY) moves, Space jumps.
21
+ */
22
+ export declare const tinyFps: (container: HTMLElement, opts: TinyFpsOptions) => TinyFps;
@@ -0,0 +1,17 @@
1
+ /** The KEY bit of a KeyboardEvent.code, 0 for a key the game ignores. */
2
+ export declare const keyBit: (code: string) => number;
3
+ /** Live look angles and held keys; the loop reads them once per step and per frame. */
4
+ export interface Controls {
5
+ /** Radians, [0, 2π). Mouse right turns right, which lowers yaw. */
6
+ yaw: number;
7
+ /** Radians, ±MAX_PITCH. Positive looks up. */
8
+ pitch: number;
9
+ /** KEY bits held now. */
10
+ keys: number;
11
+ /** The canvas holds the pointer lock. */
12
+ locked(): boolean;
13
+ /** Removes every listener. */
14
+ destroy(): void;
15
+ }
16
+ /** Click the canvas to lock the pointer; while locked the mouse looks and the keys move. */
17
+ export declare const createInput: (canvas: HTMLCanvasElement) => Controls;
@@ -0,0 +1,23 @@
1
+ import type { Input, Level, PlayerState, Snapshot } from '../shared/types';
2
+ export interface Predictor {
3
+ /** The own player id, from the roster. Snapshots before it are only used for the others. */
4
+ you(id: number): void;
5
+ /**
6
+ * One local step: gives the input the next seq, rounds its yaw and pitch to the wire, moves the
7
+ * own player with it, keeps it for replay, and returns it for the caller to send (the very
8
+ * values predicted with, which the host reads back). Until the own player is known (no snapshot
9
+ * shows it yet) it only numbers and keeps the input: the first snapshot replays it.
10
+ */
11
+ step(i: Omit<Input, 'seq'>): Input;
12
+ /** A decoded snapshot, received at `now` (ms, performance.now()). */
13
+ snapshot(s: Snapshot, now: number): void;
14
+ /**
15
+ * The own player, eased between the state before and after the last step by alpha in [0, 1]
16
+ * (the share of a step elapsed since it), so the camera moves smoothly at any frame rate.
17
+ * Undefined until a snapshot shows the own player.
18
+ */
19
+ me(alpha: number): PlayerState | undefined;
20
+ /** The other players at now - INTERP_DELAY; a player missing from the latest snapshot is gone. */
21
+ others(now: number): PlayerState[];
22
+ }
23
+ export declare const createPredictor: (lvl: Level) => Predictor;