@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/LICENSE +177 -0
- package/README.md +8 -2
- package/dist/cli.js +7 -0
- package/dist/index.js +11 -0
- package/dist/local.js +1 -0
- package/dist/server.js +624 -0
- package/dist/types/client/index.d.ts +22 -0
- package/dist/types/client/input.d.ts +17 -0
- package/dist/types/client/predict.d.ts +23 -0
- package/dist/types/client/render.d.ts +28 -0
- package/dist/types/client/textures.d.ts +4 -0
- package/dist/types/local/index.d.ts +14 -0
- package/dist/types/server/cli.d.ts +2 -0
- package/dist/types/server/index.d.ts +16 -0
- package/dist/types/shared/constants.d.ts +33 -0
- package/dist/types/shared/level.d.ts +13 -0
- package/dist/types/shared/map.d.ts +6 -0
- package/dist/types/shared/physics.d.ts +17 -0
- package/dist/types/shared/protocol.d.ts +30 -0
- package/dist/types/shared/room.d.ts +45 -0
- package/dist/types/shared/types.d.ts +131 -0
- package/package.json +87 -4
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;
|