@signe/room 2.10.0 → 3.0.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/chunk-EUXUH3YW.js +15 -0
  3. package/dist/chunk-EUXUH3YW.js.map +1 -0
  4. package/dist/cloudflare/index.d.ts +71 -0
  5. package/dist/cloudflare/index.js +320 -0
  6. package/dist/cloudflare/index.js.map +1 -0
  7. package/dist/index.d.ts +66 -187
  8. package/dist/index.js +727 -106
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +164 -0
  11. package/dist/node/index.js +786 -0
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/party-dNs-hqkq.d.ts +175 -0
  14. package/examples/cloudflare/README.md +62 -0
  15. package/examples/cloudflare/node_modules/.bin/tsc +17 -0
  16. package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
  17. package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
  18. package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
  19. package/examples/cloudflare/package.json +24 -0
  20. package/examples/cloudflare/public/index.html +443 -0
  21. package/examples/cloudflare/src/index.ts +28 -0
  22. package/examples/cloudflare/src/room.ts +44 -0
  23. package/examples/cloudflare/tsconfig.json +10 -0
  24. package/examples/cloudflare/wrangler.jsonc +25 -0
  25. package/examples/node/README.md +57 -0
  26. package/examples/node/node_modules/.bin/tsc +17 -0
  27. package/examples/node/node_modules/.bin/tsserver +17 -0
  28. package/examples/node/node_modules/.bin/tsx +17 -0
  29. package/examples/node/package.json +23 -0
  30. package/examples/node/public/index.html +443 -0
  31. package/examples/node/room.ts +44 -0
  32. package/examples/node/server.sqlite.ts +52 -0
  33. package/examples/node/server.ts +51 -0
  34. package/examples/node/tsconfig.json +10 -0
  35. package/examples/node-game/README.md +66 -0
  36. package/examples/node-game/package.json +23 -0
  37. package/examples/node-game/public/index.html +705 -0
  38. package/examples/node-game/room.ts +145 -0
  39. package/examples/node-game/server.sqlite.ts +54 -0
  40. package/examples/node-game/server.ts +53 -0
  41. package/examples/node-game/tsconfig.json +10 -0
  42. package/examples/node-shard/README.md +32 -0
  43. package/examples/node-shard/dev.ts +39 -0
  44. package/examples/node-shard/package.json +24 -0
  45. package/examples/node-shard/public/index.html +777 -0
  46. package/examples/node-shard/room-server.ts +68 -0
  47. package/examples/node-shard/room.ts +105 -0
  48. package/examples/node-shard/shared.ts +6 -0
  49. package/examples/node-shard/tsconfig.json +14 -0
  50. package/examples/node-shard/world-server.ts +169 -0
  51. package/package.json +14 -5
  52. package/readme.md +371 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/jwt.ts +1 -5
  55. package/src/mock.ts +29 -7
  56. package/src/node/index.ts +1112 -0
  57. package/src/server.ts +600 -51
  58. package/src/session.guard.ts +6 -2
  59. package/src/shard.ts +91 -23
  60. package/src/storage.ts +29 -5
  61. package/src/testing.ts +4 -3
  62. package/src/types/party.ts +4 -1
  63. package/src/world.guard.ts +23 -4
  64. package/src/world.ts +121 -21
  65. package/examples/game/.vscode/launch.json +0 -11
  66. package/examples/game/.vscode/settings.json +0 -11
  67. package/examples/game/README.md +0 -40
  68. package/examples/game/app/client.tsx +0 -15
  69. package/examples/game/app/components/Admin.tsx +0 -1089
  70. package/examples/game/app/components/Room.tsx +0 -162
  71. package/examples/game/app/styles.css +0 -31
  72. package/examples/game/package-lock.json +0 -225
  73. package/examples/game/package.json +0 -20
  74. package/examples/game/party/game.room.ts +0 -32
  75. package/examples/game/party/server.ts +0 -10
  76. package/examples/game/party/shard.ts +0 -5
  77. package/examples/game/partykit.json +0 -14
  78. package/examples/game/public/favicon.ico +0 -0
  79. package/examples/game/public/index.html +0 -27
  80. package/examples/game/public/normalize.css +0 -351
  81. package/examples/game/shared/room.schema.ts +0 -14
  82. package/examples/game/tsconfig.json +0 -109
@@ -0,0 +1,705 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Signe Node Game Room</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
11
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ background: #eef2f6;
21
+ color: #17191c;
22
+ }
23
+
24
+ main {
25
+ width: min(1180px, calc(100vw - 32px));
26
+ margin: 0 auto;
27
+ padding: 28px 0;
28
+ display: grid;
29
+ gap: 18px;
30
+ }
31
+
32
+ header {
33
+ display: flex;
34
+ align-items: end;
35
+ justify-content: space-between;
36
+ gap: 16px;
37
+ }
38
+
39
+ h1,
40
+ h2 {
41
+ margin: 0;
42
+ line-height: 1.1;
43
+ }
44
+
45
+ h1 {
46
+ font-size: 30px;
47
+ }
48
+
49
+ h2 {
50
+ font-size: 17px;
51
+ }
52
+
53
+ .muted,
54
+ .status {
55
+ color: #5b6472;
56
+ }
57
+
58
+ .panel,
59
+ .card {
60
+ border: 1px solid #d6dbe2;
61
+ border-radius: 8px;
62
+ background: #ffffff;
63
+ }
64
+
65
+ .panel {
66
+ padding: 20px;
67
+ display: grid;
68
+ gap: 16px;
69
+ }
70
+
71
+ .join {
72
+ max-width: 520px;
73
+ }
74
+
75
+ form {
76
+ display: grid;
77
+ gap: 12px;
78
+ }
79
+
80
+ label {
81
+ display: grid;
82
+ gap: 6px;
83
+ font-size: 14px;
84
+ font-weight: 600;
85
+ }
86
+
87
+ input {
88
+ min-height: 42px;
89
+ border: 1px solid #c9d0d9;
90
+ border-radius: 6px;
91
+ padding: 0 12px;
92
+ font: inherit;
93
+ }
94
+
95
+ button {
96
+ min-height: 40px;
97
+ border: 1px solid #17191c;
98
+ border-radius: 6px;
99
+ background: #17191c;
100
+ color: #ffffff;
101
+ padding: 0 14px;
102
+ font: inherit;
103
+ cursor: pointer;
104
+ }
105
+
106
+ button.secondary {
107
+ background: #ffffff;
108
+ color: #17191c;
109
+ }
110
+
111
+ .game {
112
+ display: grid;
113
+ grid-template-columns: minmax(0, 1fr) 300px;
114
+ gap: 18px;
115
+ align-items: start;
116
+ }
117
+
118
+ .arena-panel {
119
+ padding: 16px;
120
+ gap: 12px;
121
+ }
122
+
123
+ .arena-toolbar {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 12px;
128
+ flex-wrap: wrap;
129
+ }
130
+
131
+ .controls {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 6px;
135
+ flex-wrap: wrap;
136
+ }
137
+
138
+ kbd {
139
+ min-width: 28px;
140
+ height: 28px;
141
+ border: 1px solid #c9d0d9;
142
+ border-bottom-width: 2px;
143
+ border-radius: 6px;
144
+ background: #f8fafc;
145
+ color: #2f3742;
146
+ display: inline-grid;
147
+ place-items: center;
148
+ font: 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
149
+ }
150
+
151
+ .canvas-wrap {
152
+ width: 100%;
153
+ aspect-ratio: 900 / 560;
154
+ border: 1px solid #c8d0da;
155
+ border-radius: 8px;
156
+ overflow: hidden;
157
+ background: #dfe7ef;
158
+ }
159
+
160
+ canvas {
161
+ display: block;
162
+ width: 100%;
163
+ height: 100%;
164
+ }
165
+
166
+ .session {
167
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
168
+ font-size: 12px;
169
+ }
170
+
171
+ .sidebar {
172
+ padding: 16px;
173
+ display: grid;
174
+ gap: 14px;
175
+ align-content: start;
176
+ }
177
+
178
+ .score-list {
179
+ display: grid;
180
+ gap: 8px;
181
+ }
182
+
183
+ .player {
184
+ display: grid;
185
+ grid-template-columns: 14px minmax(0, 1fr) auto;
186
+ align-items: center;
187
+ gap: 10px;
188
+ min-height: 44px;
189
+ padding: 8px 10px;
190
+ border: 1px solid #e0e5eb;
191
+ border-radius: 6px;
192
+ }
193
+
194
+ .swatch {
195
+ width: 12px;
196
+ height: 12px;
197
+ border-radius: 999px;
198
+ }
199
+
200
+ .player-name {
201
+ overflow: hidden;
202
+ text-overflow: ellipsis;
203
+ white-space: nowrap;
204
+ }
205
+
206
+ .score {
207
+ min-width: 32px;
208
+ text-align: right;
209
+ font-weight: 700;
210
+ }
211
+
212
+ .badge {
213
+ border-radius: 999px;
214
+ padding: 3px 8px;
215
+ font-size: 12px;
216
+ background: #edf1f5;
217
+ color: #4b5563;
218
+ white-space: nowrap;
219
+ }
220
+
221
+ .badge.online {
222
+ background: #e7f7ed;
223
+ color: #146c36;
224
+ }
225
+
226
+ .side-actions {
227
+ display: grid;
228
+ gap: 8px;
229
+ }
230
+
231
+ .hidden {
232
+ display: none;
233
+ }
234
+
235
+ @media (max-width: 840px) {
236
+ header,
237
+ .game {
238
+ grid-template-columns: 1fr;
239
+ display: grid;
240
+ }
241
+ }
242
+ </style>
243
+ </head>
244
+ <body>
245
+ <main>
246
+ <header>
247
+ <div>
248
+ <h1>Signe Node Game</h1>
249
+ <div class="muted" id="roomLabel">Choose a room</div>
250
+ </div>
251
+ <button id="leaveRoom" class="secondary hidden" type="button">Change room</button>
252
+ </header>
253
+
254
+ <section class="panel join" id="joinPanel">
255
+ <h2>Join a game</h2>
256
+ <form id="joinForm">
257
+ <label>
258
+ Room
259
+ <input id="roomInput" name="room" autocomplete="off" required value="demo" />
260
+ </label>
261
+ <label>
262
+ Name
263
+ <input id="nameInput" name="name" autocomplete="name" required />
264
+ </label>
265
+ <button type="submit">Enter arena</button>
266
+ </form>
267
+ </section>
268
+
269
+ <section class="game hidden" id="gamePanel">
270
+ <div class="panel arena-panel">
271
+ <div class="arena-toolbar">
272
+ <div>
273
+ <h2>Arena</h2>
274
+ <div class="status" id="status">Disconnected</div>
275
+ </div>
276
+ <div class="controls" aria-label="Movement keys">
277
+ <kbd>W</kbd>
278
+ <kbd>A</kbd>
279
+ <kbd>S</kbd>
280
+ <kbd>D</kbd>
281
+ <kbd>↑</kbd>
282
+ <kbd>←</kbd>
283
+ <kbd>↓</kbd>
284
+ <kbd>→</kbd>
285
+ </div>
286
+ </div>
287
+ <div class="canvas-wrap">
288
+ <canvas id="arena" width="900" height="560"></canvas>
289
+ </div>
290
+ <div class="muted session" id="sessionLabel"></div>
291
+ </div>
292
+
293
+ <aside class="card sidebar">
294
+ <h2>Players</h2>
295
+ <div class="score-list" id="players"></div>
296
+ <div class="side-actions">
297
+ <button id="reset" class="secondary" type="button">Reset scores</button>
298
+ <button id="resetSession" class="secondary" type="button">New session</button>
299
+ </div>
300
+ </aside>
301
+ </section>
302
+ </main>
303
+
304
+ <script>
305
+ const ARENA_WIDTH = 900;
306
+ const ARENA_HEIGHT = 560;
307
+ const PLAYER_RADIUS = 16;
308
+ const STAR_RADIUS = 13;
309
+ const PLAYER_SPEED = 230;
310
+ const SEND_INTERVAL = 50;
311
+ const COLLECT_INTERVAL = 120;
312
+
313
+ const state = {
314
+ socket: null,
315
+ roomId: getRoomIdFromPath(),
316
+ name: localStorage.getItem("signe-node-game-name") || "",
317
+ sessionId: localStorage.getItem("signe-node-game-session-id") || "",
318
+ publicId: "",
319
+ players: {},
320
+ star: { x: ARENA_WIDTH / 2, y: ARENA_HEIGHT / 2 },
321
+ keys: new Set(),
322
+ lastSent: 0,
323
+ lastCollect: 0,
324
+ lastFrame: performance.now(),
325
+ };
326
+
327
+ const joinPanel = document.querySelector("#joinPanel");
328
+ const gamePanel = document.querySelector("#gamePanel");
329
+ const roomLabel = document.querySelector("#roomLabel");
330
+ const leaveRoom = document.querySelector("#leaveRoom");
331
+ const joinForm = document.querySelector("#joinForm");
332
+ const roomInput = document.querySelector("#roomInput");
333
+ const nameInput = document.querySelector("#nameInput");
334
+ const status = document.querySelector("#status");
335
+ const sessionLabel = document.querySelector("#sessionLabel");
336
+ const players = document.querySelector("#players");
337
+ const canvas = document.querySelector("#arena");
338
+ const ctx = canvas.getContext("2d");
339
+
340
+ nameInput.value = state.name;
341
+
342
+ if (state.roomId) {
343
+ roomInput.value = state.roomId;
344
+ }
345
+
346
+ joinForm.addEventListener("submit", (event) => {
347
+ event.preventDefault();
348
+ const roomId = normalizeRoomId(roomInput.value);
349
+ const name = nameInput.value.trim() || "Anonymous";
350
+
351
+ localStorage.setItem("signe-node-game-name", name);
352
+ history.pushState({}, "", `/rooms/${encodeURIComponent(roomId)}`);
353
+ enterRoom(roomId, name);
354
+ });
355
+
356
+ leaveRoom.addEventListener("click", () => {
357
+ disconnect();
358
+ history.pushState({}, "", "/");
359
+ showJoin();
360
+ });
361
+
362
+ window.addEventListener("popstate", () => {
363
+ const roomId = getRoomIdFromPath();
364
+ if (roomId) {
365
+ enterRoom(roomId, state.name || "Anonymous");
366
+ } else {
367
+ disconnect();
368
+ showJoin();
369
+ }
370
+ });
371
+
372
+ window.addEventListener("keydown", (event) => {
373
+ if (isMovementKey(event.key)) {
374
+ state.keys.add(event.key.toLowerCase());
375
+ event.preventDefault();
376
+ }
377
+ });
378
+
379
+ window.addEventListener("keyup", (event) => {
380
+ if (isMovementKey(event.key)) {
381
+ state.keys.delete(event.key.toLowerCase());
382
+ event.preventDefault();
383
+ }
384
+ });
385
+
386
+ document.querySelector("#reset").addEventListener("click", async () => {
387
+ if (!state.roomId) return;
388
+ await fetch(`/parties/main/${encodeURIComponent(state.roomId)}/reset`, { method: "POST" });
389
+ });
390
+
391
+ document.querySelector("#resetSession").addEventListener("click", () => {
392
+ if (!state.roomId) return;
393
+ disconnect();
394
+ localStorage.removeItem("signe-node-game-session-id");
395
+ state.sessionId = "";
396
+ state.publicId = "";
397
+ enterRoom(state.roomId, state.name || "Anonymous");
398
+ });
399
+
400
+ requestAnimationFrame(frame);
401
+
402
+ if (state.roomId) {
403
+ enterRoom(state.roomId, state.name || "Anonymous");
404
+ } else {
405
+ showJoin();
406
+ draw();
407
+ }
408
+
409
+ function enterRoom(roomId, name) {
410
+ disconnect();
411
+ state.roomId = roomId;
412
+ state.name = name;
413
+ state.publicId = "";
414
+ state.players = {};
415
+ renderPlayers();
416
+ showGame();
417
+
418
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
419
+ const sessionId = getSessionId();
420
+ const params = new URLSearchParams({ name, id: sessionId });
421
+ state.socket = new WebSocket(`${protocol}://${location.host}/parties/main/${encodeURIComponent(roomId)}?${params}`);
422
+
423
+ state.socket.addEventListener("open", () => {
424
+ status.textContent = "Connected";
425
+ });
426
+
427
+ state.socket.addEventListener("close", () => {
428
+ status.textContent = "Disconnected";
429
+ });
430
+
431
+ state.socket.addEventListener("message", (event) => {
432
+ const packet = JSON.parse(event.data);
433
+ if (packet.type !== "sync" || !packet.value) return;
434
+
435
+ if (packet.value.pId) {
436
+ state.publicId = packet.value.pId;
437
+ }
438
+
439
+ if (packet.value.star) {
440
+ state.star = mergeValue(state.star, packet.value.star);
441
+ }
442
+
443
+ if (packet.value.players) {
444
+ mergePlayers(state.players, packet.value.players);
445
+ renderPlayers();
446
+ }
447
+ });
448
+ }
449
+
450
+ function frame(now) {
451
+ const delta = Math.min(0.05, (now - state.lastFrame) / 1000);
452
+ state.lastFrame = now;
453
+
454
+ updateLocalPlayer(delta, now);
455
+ draw();
456
+ requestAnimationFrame(frame);
457
+ }
458
+
459
+ function updateLocalPlayer(delta, now) {
460
+ const player = state.players[state.publicId];
461
+ if (!player || !state.socket || state.socket.readyState !== WebSocket.OPEN) {
462
+ return;
463
+ }
464
+
465
+ let dx = 0;
466
+ let dy = 0;
467
+
468
+ if (state.keys.has("arrowleft") || state.keys.has("a")) dx -= 1;
469
+ if (state.keys.has("arrowright") || state.keys.has("d")) dx += 1;
470
+ if (state.keys.has("arrowup") || state.keys.has("w")) dy -= 1;
471
+ if (state.keys.has("arrowdown") || state.keys.has("s")) dy += 1;
472
+
473
+ if (dx !== 0 || dy !== 0) {
474
+ const length = Math.hypot(dx, dy);
475
+ player.x = clamp(player.x + (dx / length) * PLAYER_SPEED * delta, PLAYER_RADIUS, ARENA_WIDTH - PLAYER_RADIUS);
476
+ player.y = clamp(player.y + (dy / length) * PLAYER_SPEED * delta, PLAYER_RADIUS, ARENA_HEIGHT - PLAYER_RADIUS);
477
+
478
+ if (now - state.lastSent >= SEND_INTERVAL) {
479
+ send("move", { x: player.x, y: player.y });
480
+ state.lastSent = now;
481
+ }
482
+ }
483
+
484
+ const distance = Math.hypot(player.x - state.star.x, player.y - state.star.y);
485
+ if (distance <= PLAYER_RADIUS + STAR_RADIUS + 8 && now - state.lastCollect >= COLLECT_INTERVAL) {
486
+ send("collect", {});
487
+ state.lastCollect = now;
488
+ }
489
+ }
490
+
491
+ function draw() {
492
+ ctx.clearRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT);
493
+ drawArena();
494
+ drawStar(state.star.x, state.star.y);
495
+
496
+ for (const [id, player] of Object.entries(state.players)) {
497
+ if (typeof player.x !== "number" || typeof player.y !== "number" || player.x < 0 || player.y < 0) {
498
+ continue;
499
+ }
500
+
501
+ drawPlayer(player, id === state.publicId);
502
+ }
503
+ }
504
+
505
+ function drawArena() {
506
+ ctx.fillStyle = "#f8fafc";
507
+ ctx.fillRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT);
508
+
509
+ ctx.strokeStyle = "#e2e8f0";
510
+ ctx.lineWidth = 1;
511
+ for (let x = 40; x < ARENA_WIDTH; x += 40) {
512
+ ctx.beginPath();
513
+ ctx.moveTo(x, 0);
514
+ ctx.lineTo(x, ARENA_HEIGHT);
515
+ ctx.stroke();
516
+ }
517
+ for (let y = 40; y < ARENA_HEIGHT; y += 40) {
518
+ ctx.beginPath();
519
+ ctx.moveTo(0, y);
520
+ ctx.lineTo(ARENA_WIDTH, y);
521
+ ctx.stroke();
522
+ }
523
+
524
+ ctx.strokeStyle = "#94a3b8";
525
+ ctx.lineWidth = 4;
526
+ ctx.strokeRect(2, 2, ARENA_WIDTH - 4, ARENA_HEIGHT - 4);
527
+ }
528
+
529
+ function drawStar(x, y) {
530
+ ctx.save();
531
+ ctx.translate(x, y);
532
+ ctx.rotate(-Math.PI / 2);
533
+ ctx.beginPath();
534
+ for (let index = 0; index < 10; index += 1) {
535
+ const radius = index % 2 === 0 ? STAR_RADIUS : STAR_RADIUS * 0.45;
536
+ const angle = (Math.PI * 2 * index) / 10;
537
+ const px = Math.cos(angle) * radius;
538
+ const py = Math.sin(angle) * radius;
539
+ if (index === 0) {
540
+ ctx.moveTo(px, py);
541
+ } else {
542
+ ctx.lineTo(px, py);
543
+ }
544
+ }
545
+ ctx.closePath();
546
+ ctx.fillStyle = "#f59e0b";
547
+ ctx.fill();
548
+ ctx.lineWidth = 3;
549
+ ctx.strokeStyle = "#92400e";
550
+ ctx.stroke();
551
+ ctx.restore();
552
+ }
553
+
554
+ function drawPlayer(player, isCurrentPlayer) {
555
+ ctx.save();
556
+ ctx.beginPath();
557
+ ctx.arc(player.x, player.y, PLAYER_RADIUS, 0, Math.PI * 2);
558
+ ctx.fillStyle = player.color || "#2563eb";
559
+ ctx.fill();
560
+ ctx.lineWidth = isCurrentPlayer ? 5 : 3;
561
+ ctx.strokeStyle = isCurrentPlayer ? "#111827" : "#ffffff";
562
+ ctx.stroke();
563
+
564
+ ctx.font = "600 13px Inter, system-ui, sans-serif";
565
+ ctx.textAlign = "center";
566
+ ctx.textBaseline = "bottom";
567
+ ctx.lineWidth = 4;
568
+ ctx.strokeStyle = "#ffffff";
569
+ ctx.strokeText(player.name || "Anonymous", player.x, player.y - PLAYER_RADIUS - 8);
570
+ ctx.fillStyle = "#111827";
571
+ ctx.fillText(player.name || "Anonymous", player.x, player.y - PLAYER_RADIUS - 8);
572
+ ctx.restore();
573
+ }
574
+
575
+ function send(action, value) {
576
+ state.socket?.send(JSON.stringify({ action, value }));
577
+ }
578
+
579
+ function disconnect() {
580
+ if (state.socket) {
581
+ state.socket.close();
582
+ state.socket = null;
583
+ }
584
+ }
585
+
586
+ function showJoin() {
587
+ state.roomId = null;
588
+ joinPanel.classList.remove("hidden");
589
+ gamePanel.classList.add("hidden");
590
+ leaveRoom.classList.add("hidden");
591
+ roomLabel.textContent = "Choose a room";
592
+ status.textContent = "Disconnected";
593
+ sessionLabel.textContent = "";
594
+ }
595
+
596
+ function showGame() {
597
+ joinPanel.classList.add("hidden");
598
+ gamePanel.classList.remove("hidden");
599
+ leaveRoom.classList.remove("hidden");
600
+ roomLabel.textContent = `/rooms/${state.roomId}`;
601
+ sessionLabel.textContent = `session ${getSessionId().slice(0, 8)}`;
602
+ }
603
+
604
+ function getSessionId() {
605
+ if (!state.sessionId) {
606
+ state.sessionId = crypto.randomUUID();
607
+ localStorage.setItem("signe-node-game-session-id", state.sessionId);
608
+ }
609
+
610
+ return state.sessionId;
611
+ }
612
+
613
+ function renderPlayers() {
614
+ const entries = Object.entries(state.players).sort(([, a], [, b]) => {
615
+ if ((b.score || 0) !== (a.score || 0)) {
616
+ return (b.score || 0) - (a.score || 0);
617
+ }
618
+
619
+ return String(a.name || "").localeCompare(String(b.name || ""));
620
+ });
621
+
622
+ players.innerHTML = entries.length
623
+ ? entries.map(([id, player]) => {
624
+ const name = escapeHtml(player.name || "Anonymous");
625
+ const color = escapeAttribute(player.color || "#2563eb");
626
+ const connected = Boolean(player.connected);
627
+ const score = Number(player.score || 0);
628
+ return `
629
+ <div class="player">
630
+ <span class="swatch" style="background: ${color}"></span>
631
+ <span>
632
+ <span class="player-name">${name}${id === state.publicId ? " (you)" : ""}</span>
633
+ <span class="badge ${connected ? "online" : ""}">${connected ? "connected" : "offline"}</span>
634
+ </span>
635
+ <span class="score">${score}</span>
636
+ </div>
637
+ `;
638
+ }).join("")
639
+ : `<div class="muted">No players yet</div>`;
640
+ }
641
+
642
+ function mergePlayers(target, patch) {
643
+ for (const [id, value] of Object.entries(patch)) {
644
+ if (value === "$delete") {
645
+ delete target[id];
646
+ continue;
647
+ }
648
+
649
+ target[id] = mergeValue(target[id], value);
650
+ }
651
+ }
652
+
653
+ function mergeValue(current, patch) {
654
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
655
+ return patch;
656
+ }
657
+
658
+ const next = current && typeof current === "object" && !Array.isArray(current)
659
+ ? { ...current }
660
+ : {};
661
+
662
+ for (const [key, value] of Object.entries(patch)) {
663
+ if (value === "$delete") {
664
+ delete next[key];
665
+ } else {
666
+ next[key] = mergeValue(next[key], value);
667
+ }
668
+ }
669
+
670
+ return next;
671
+ }
672
+
673
+ function isMovementKey(key) {
674
+ return ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "w", "a", "s", "d", "W", "A", "S", "D"].includes(key);
675
+ }
676
+
677
+ function getRoomIdFromPath() {
678
+ const match = location.pathname.match(/^\/rooms\/([^/]+)$/);
679
+ return match ? decodeURIComponent(match[1]) : null;
680
+ }
681
+
682
+ function normalizeRoomId(value) {
683
+ return value.trim().replace(/[^a-zA-Z0-9_-]/g, "-") || "demo";
684
+ }
685
+
686
+ function clamp(value, min, max) {
687
+ return Math.min(max, Math.max(min, value));
688
+ }
689
+
690
+ function escapeHtml(value) {
691
+ return String(value).replace(/[&<>"']/g, (char) => ({
692
+ "&": "&amp;",
693
+ "<": "&lt;",
694
+ ">": "&gt;",
695
+ "\"": "&quot;",
696
+ "'": "&#039;",
697
+ }[char]));
698
+ }
699
+
700
+ function escapeAttribute(value) {
701
+ return escapeHtml(String(value).replace(/[^#a-zA-Z0-9(),.% -]/g, ""));
702
+ }
703
+ </script>
704
+ </body>
705
+ </html>