@sansavision/create-pulse 0.4.3 → 0.4.4
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/README.md +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/templates/nextjs-auth-demo/README.md +1 -1
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +623 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +21 -5
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +220 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +199 -47
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +740 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +364 -51
- package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +4 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +53 -5
- package/templates/nextjs-auth-demo/src/app/layout.tsx +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ npx @sansavision/create-pulse my-pulse-app
|
|
|
27
27
|
When you run `create-pulse`, you'll be prompted to select a template.
|
|
28
28
|
|
|
29
29
|
### 1. Next.js + Auth (Full Demo) ⭐
|
|
30
|
-
|
|
30
|
+
A **full-featured** demo application showcasing the full capabilities of Pulse.
|
|
31
31
|
- **Better Auth** with email/password, local SQLite + Drizzle ORM
|
|
32
32
|
- **Webhook auth** — Pulse relay verifies tokens via your Next.js API
|
|
33
33
|
- 5 comprehensive demos: Real-time Chat, Watch Together, Durable Queues (with offline simulation), Game State Sync, E2E Encrypted Chat
|
package/dist/index.js
CHANGED
|
@@ -51,7 +51,7 @@ async function main() {
|
|
|
51
51
|
return p.select({
|
|
52
52
|
message: "Pick a template:",
|
|
53
53
|
options: [
|
|
54
|
-
{ value: "nextjs-auth-demo", label: "Next.js + Auth (Full Demo)", hint: "Better Auth, all features
|
|
54
|
+
{ value: "nextjs-auth-demo", label: "Next.js + Auth (Full Demo)", hint: "Better Auth, video calls, all features" },
|
|
55
55
|
{ value: "react-watch-together", label: "Watch Together (React + TS)", hint: "Synchronized video playback" },
|
|
56
56
|
{ value: "react-all-features", label: "All Features (React + TS)", hint: "Chat, Video, Audio, RPC" },
|
|
57
57
|
{ value: "react-queue-demo", label: "Durable Queues (React + TS)", hint: "Persistent queues with WAL/Postgres/Redis" },
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansavision/create-pulse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Scaffold a new Pulse application",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-pulse": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "tsup src/index.ts --format cjs
|
|
10
|
+
"build": "tsup src/index.ts --format cjs",
|
|
11
11
|
"dev": "tsup src/index.ts --format cjs --watch"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pulse + Next.js Auth Demo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A **full-featured** demo application showcasing the full capabilities of [Pulse](https://github.com/Sansa-Organisation/pulse) — the real-time protocol for modern applications.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import {
|
|
7
|
+
Gamepad2,
|
|
8
|
+
Shield,
|
|
9
|
+
Wifi,
|
|
10
|
+
WifiOff,
|
|
11
|
+
Loader2,
|
|
12
|
+
Users,
|
|
13
|
+
Copy,
|
|
14
|
+
Check,
|
|
15
|
+
Swords,
|
|
16
|
+
Trophy,
|
|
17
|
+
Zap,
|
|
18
|
+
Activity,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
21
|
+
|
|
22
|
+
interface PlayerState {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
color: string;
|
|
26
|
+
label: string;
|
|
27
|
+
score: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Collectible {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
color: string;
|
|
34
|
+
collected: boolean;
|
|
35
|
+
id: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function ArenaGamePage() {
|
|
39
|
+
const { data: session } = useSession();
|
|
40
|
+
const [connected, setConnected] = useState(false);
|
|
41
|
+
const [connecting, setConnecting] = useState(true);
|
|
42
|
+
const [authUser, setAuthUser] = useState<{
|
|
43
|
+
id: string;
|
|
44
|
+
claims: Record<string, string>;
|
|
45
|
+
} | null>(null);
|
|
46
|
+
|
|
47
|
+
// Room management
|
|
48
|
+
const [roomId, setRoomId] = useState("");
|
|
49
|
+
const [inputRoomId, setInputRoomId] = useState("");
|
|
50
|
+
const [inGame, setInGame] = useState(false);
|
|
51
|
+
const [copied, setCopied] = useState(false);
|
|
52
|
+
const [playerCount, setPlayerCount] = useState(1);
|
|
53
|
+
const [opponent, setOpponent] = useState<string | null>(null);
|
|
54
|
+
|
|
55
|
+
// Game state
|
|
56
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
57
|
+
const localPlayerRef = useRef<PlayerState>({
|
|
58
|
+
x: 0.3, y: 0.5, color: "#7C3AED", label: "P1", score: 0,
|
|
59
|
+
});
|
|
60
|
+
const remotePlayerRef = useRef<PlayerState>({
|
|
61
|
+
x: 0.7, y: 0.5, color: "#10B981", label: "P2", score: 0,
|
|
62
|
+
});
|
|
63
|
+
const collectiblesRef = useRef<Collectible[]>(
|
|
64
|
+
Array.from({ length: 5 }).map((_, i) => ({
|
|
65
|
+
x: 0.1 + Math.random() * 0.8,
|
|
66
|
+
y: 0.1 + Math.random() * 0.8,
|
|
67
|
+
color: "#F59E0B",
|
|
68
|
+
collected: false,
|
|
69
|
+
id: i,
|
|
70
|
+
}))
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const keysRef = useRef<{ [key: string]: boolean }>({});
|
|
74
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
75
|
+
const animationIdRef = useRef<number>(0);
|
|
76
|
+
const [scores, setScores] = useState({ p1: 0, p2: 0 });
|
|
77
|
+
const [syncMetrics, setSyncMetrics] = useState({ ups: 0, delay: "—" });
|
|
78
|
+
const statsRef = useRef({ updatesThisSecond: 0, lastDelay: 0 });
|
|
79
|
+
|
|
80
|
+
// Keyboard listeners
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
83
|
+
keysRef.current[e.key.toLowerCase()] = true;
|
|
84
|
+
};
|
|
85
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
86
|
+
keysRef.current[e.key.toLowerCase()] = false;
|
|
87
|
+
};
|
|
88
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
89
|
+
window.addEventListener("keyup", handleKeyUp);
|
|
90
|
+
return () => {
|
|
91
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
92
|
+
window.removeEventListener("keyup", handleKeyUp);
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Connect to Pulse
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!session) return;
|
|
99
|
+
let cancelled = false;
|
|
100
|
+
|
|
101
|
+
async function init() {
|
|
102
|
+
try {
|
|
103
|
+
const connection = await connectWithAuth();
|
|
104
|
+
if (cancelled) return;
|
|
105
|
+
connRef.current = connection;
|
|
106
|
+
setConnected(true);
|
|
107
|
+
setConnecting(false);
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
|
+
const user = (connection as any).user;
|
|
110
|
+
if (user) setAuthUser(user);
|
|
111
|
+
connection.on("disconnect", () => setConnected(false));
|
|
112
|
+
connection.on("reconnected", () => setConnected(true));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error("Failed to connect:", err);
|
|
115
|
+
setConnecting(false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
init();
|
|
120
|
+
return () => {
|
|
121
|
+
cancelled = true;
|
|
122
|
+
connRef.current?.disconnect();
|
|
123
|
+
};
|
|
124
|
+
}, [session]);
|
|
125
|
+
|
|
126
|
+
// Drawing helpers
|
|
127
|
+
const drawPlayer = (
|
|
128
|
+
ctx: CanvasRenderingContext2D,
|
|
129
|
+
x: number,
|
|
130
|
+
y: number,
|
|
131
|
+
color: string,
|
|
132
|
+
label: string
|
|
133
|
+
) => {
|
|
134
|
+
// Glow
|
|
135
|
+
ctx.beginPath();
|
|
136
|
+
ctx.arc(x, y, 24, 0, Math.PI * 2);
|
|
137
|
+
ctx.fillStyle = color + "30";
|
|
138
|
+
ctx.fill();
|
|
139
|
+
|
|
140
|
+
// Body
|
|
141
|
+
ctx.beginPath();
|
|
142
|
+
ctx.arc(x, y, 18, 0, Math.PI * 2);
|
|
143
|
+
ctx.fillStyle = color + "50";
|
|
144
|
+
ctx.strokeStyle = color;
|
|
145
|
+
ctx.lineWidth = 2;
|
|
146
|
+
ctx.fill();
|
|
147
|
+
ctx.stroke();
|
|
148
|
+
|
|
149
|
+
ctx.shadowColor = color;
|
|
150
|
+
ctx.shadowBlur = 15;
|
|
151
|
+
ctx.stroke();
|
|
152
|
+
ctx.shadowBlur = 0;
|
|
153
|
+
|
|
154
|
+
// Label
|
|
155
|
+
ctx.fillStyle = "white";
|
|
156
|
+
ctx.font = "bold 11px 'Inter', sans-serif";
|
|
157
|
+
ctx.textAlign = "center";
|
|
158
|
+
ctx.textBaseline = "middle";
|
|
159
|
+
ctx.fillText(label, x, y);
|
|
160
|
+
ctx.textAlign = "start";
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const drawArena = useCallback(
|
|
164
|
+
(
|
|
165
|
+
ctx: CanvasRenderingContext2D,
|
|
166
|
+
canvas: HTMLCanvasElement,
|
|
167
|
+
p1: PlayerState,
|
|
168
|
+
p2: PlayerState
|
|
169
|
+
) => {
|
|
170
|
+
const w = canvas.width;
|
|
171
|
+
const h = canvas.height;
|
|
172
|
+
|
|
173
|
+
ctx.fillStyle = "#0a0a14";
|
|
174
|
+
ctx.fillRect(0, 0, w, h);
|
|
175
|
+
|
|
176
|
+
// Grid
|
|
177
|
+
ctx.strokeStyle = "rgba(255,255,255,0.04)";
|
|
178
|
+
ctx.lineWidth = 1;
|
|
179
|
+
for (let x = 0; x < w; x += 40) {
|
|
180
|
+
ctx.beginPath();
|
|
181
|
+
ctx.moveTo(x, 0);
|
|
182
|
+
ctx.lineTo(x, h);
|
|
183
|
+
ctx.stroke();
|
|
184
|
+
}
|
|
185
|
+
for (let y = 0; y < h; y += 40) {
|
|
186
|
+
ctx.beginPath();
|
|
187
|
+
ctx.moveTo(0, y);
|
|
188
|
+
ctx.lineTo(w, y);
|
|
189
|
+
ctx.stroke();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Collectibles
|
|
193
|
+
collectiblesRef.current.forEach((c) => {
|
|
194
|
+
if (c.collected) return;
|
|
195
|
+
ctx.beginPath();
|
|
196
|
+
ctx.arc(c.x * w, c.y * h, 8, 0, Math.PI * 2);
|
|
197
|
+
ctx.fillStyle = c.color;
|
|
198
|
+
ctx.fill();
|
|
199
|
+
ctx.shadowColor = c.color;
|
|
200
|
+
ctx.shadowBlur = 12;
|
|
201
|
+
ctx.fill();
|
|
202
|
+
ctx.shadowBlur = 0;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Players
|
|
206
|
+
drawPlayer(ctx, p1.x * w, p1.y * h, p1.color, p1.label);
|
|
207
|
+
drawPlayer(ctx, p2.x * w, p2.y * h, p2.color, p2.label);
|
|
208
|
+
|
|
209
|
+
// Scores
|
|
210
|
+
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
|
211
|
+
ctx.font = "12px monospace";
|
|
212
|
+
ctx.fillText(
|
|
213
|
+
`${p1.label}: ${p1.score} ${p2.label}: ${p2.score}`,
|
|
214
|
+
10,
|
|
215
|
+
20
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Synced badge
|
|
219
|
+
ctx.fillStyle = "rgba(6,182,212,0.5)";
|
|
220
|
+
ctx.fillText("🔗 Synced via Pulse", w - 150, 20);
|
|
221
|
+
},
|
|
222
|
+
[]
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Join a game room
|
|
226
|
+
const joinGame = useCallback(
|
|
227
|
+
(room: string) => {
|
|
228
|
+
if (!connRef.current || !session) return;
|
|
229
|
+
|
|
230
|
+
const stream = connRef.current.stream(`arena:${room}`);
|
|
231
|
+
const playerName = session.user.name || "Anonymous";
|
|
232
|
+
|
|
233
|
+
// Announce join
|
|
234
|
+
stream.send(
|
|
235
|
+
JSON.stringify({
|
|
236
|
+
type: "join",
|
|
237
|
+
name: playerName,
|
|
238
|
+
userId: session.user.id,
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Listen for game messages
|
|
243
|
+
stream.on("data", (data: Uint8Array) => {
|
|
244
|
+
try {
|
|
245
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
246
|
+
|
|
247
|
+
if (msg.type === "join" && msg.userId !== session.user.id) {
|
|
248
|
+
setOpponent(msg.name);
|
|
249
|
+
setPlayerCount(2);
|
|
250
|
+
// Assign remote label
|
|
251
|
+
remotePlayerRef.current.label = msg.name.charAt(0).toUpperCase();
|
|
252
|
+
} else if (msg.type === "state" && msg.userId !== session.user.id) {
|
|
253
|
+
// Remote player position update
|
|
254
|
+
remotePlayerRef.current.x +=
|
|
255
|
+
(msg.x - remotePlayerRef.current.x) * 0.7;
|
|
256
|
+
remotePlayerRef.current.y +=
|
|
257
|
+
(msg.y - remotePlayerRef.current.y) * 0.7;
|
|
258
|
+
remotePlayerRef.current.score = msg.score || 0;
|
|
259
|
+
if (msg.ts) {
|
|
260
|
+
const delay = Date.now() - msg.ts;
|
|
261
|
+
statsRef.current.lastDelay = delay;
|
|
262
|
+
}
|
|
263
|
+
} else if (msg.type === "collect" && msg.userId !== session.user.id) {
|
|
264
|
+
// Remote player collected a collectible
|
|
265
|
+
const c = collectiblesRef.current.find(
|
|
266
|
+
(c) => c.id === msg.collectibleId
|
|
267
|
+
);
|
|
268
|
+
if (c) c.collected = true;
|
|
269
|
+
} else if (msg.type === "respawn") {
|
|
270
|
+
// Respawn collectibles
|
|
271
|
+
collectiblesRef.current.forEach((c, i) => {
|
|
272
|
+
c.x = msg.positions[i].x;
|
|
273
|
+
c.y = msg.positions[i].y;
|
|
274
|
+
c.collected = false;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
/* ignore */
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Set local player label
|
|
283
|
+
localPlayerRef.current.label = playerName.charAt(0).toUpperCase();
|
|
284
|
+
localPlayerRef.current.score = 0;
|
|
285
|
+
remotePlayerRef.current.score = 0;
|
|
286
|
+
|
|
287
|
+
// Game loop
|
|
288
|
+
const SPEED = 0.006;
|
|
289
|
+
let frameCount = 0;
|
|
290
|
+
|
|
291
|
+
const tick = () => {
|
|
292
|
+
if (!canvasRef.current) return;
|
|
293
|
+
const ctx = canvasRef.current.getContext("2d");
|
|
294
|
+
if (!ctx) return;
|
|
295
|
+
|
|
296
|
+
const keys = keysRef.current;
|
|
297
|
+
const p = localPlayerRef.current;
|
|
298
|
+
|
|
299
|
+
if (keys["w"] || keys["arrowup"]) p.y = Math.max(0.05, p.y - SPEED);
|
|
300
|
+
if (keys["s"] || keys["arrowdown"]) p.y = Math.min(0.95, p.y + SPEED);
|
|
301
|
+
if (keys["a"] || keys["arrowleft"]) p.x = Math.max(0.05, p.x - SPEED);
|
|
302
|
+
if (keys["d"] || keys["arrowright"])
|
|
303
|
+
p.x = Math.min(0.95, p.x + SPEED);
|
|
304
|
+
|
|
305
|
+
// Check collectible collisions
|
|
306
|
+
collectiblesRef.current.forEach((c) => {
|
|
307
|
+
if (c.collected) return;
|
|
308
|
+
const dist = Math.hypot(p.x - c.x, p.y - c.y);
|
|
309
|
+
if (dist < 0.04) {
|
|
310
|
+
c.collected = true;
|
|
311
|
+
p.score++;
|
|
312
|
+
setScores((prev) => ({
|
|
313
|
+
...prev,
|
|
314
|
+
p1: p.score,
|
|
315
|
+
}));
|
|
316
|
+
stream.send(
|
|
317
|
+
JSON.stringify({
|
|
318
|
+
type: "collect",
|
|
319
|
+
userId: session.user.id,
|
|
320
|
+
collectibleId: c.id,
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Respawn collectibles
|
|
327
|
+
if (collectiblesRef.current.every((c) => c.collected)) {
|
|
328
|
+
const newPositions = collectiblesRef.current.map(() => ({
|
|
329
|
+
x: 0.1 + Math.random() * 0.8,
|
|
330
|
+
y: 0.1 + Math.random() * 0.8,
|
|
331
|
+
}));
|
|
332
|
+
collectiblesRef.current.forEach((c, i) => {
|
|
333
|
+
c.x = newPositions[i].x;
|
|
334
|
+
c.y = newPositions[i].y;
|
|
335
|
+
c.collected = false;
|
|
336
|
+
});
|
|
337
|
+
stream.send(
|
|
338
|
+
JSON.stringify({ type: "respawn", positions: newPositions })
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Draw
|
|
343
|
+
drawArena(ctx, canvasRef.current, p, remotePlayerRef.current);
|
|
344
|
+
|
|
345
|
+
// Send state at ~30fps
|
|
346
|
+
frameCount++;
|
|
347
|
+
if (frameCount % 2 === 0) {
|
|
348
|
+
statsRef.current.updatesThisSecond++;
|
|
349
|
+
stream.send(
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
type: "state",
|
|
352
|
+
userId: session.user.id,
|
|
353
|
+
x: p.x,
|
|
354
|
+
y: p.y,
|
|
355
|
+
score: p.score,
|
|
356
|
+
ts: Date.now(),
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
animationIdRef.current = requestAnimationFrame(tick);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
animationIdRef.current = requestAnimationFrame(tick);
|
|
365
|
+
|
|
366
|
+
// Stats interval
|
|
367
|
+
const statsInterval = setInterval(() => {
|
|
368
|
+
setSyncMetrics({
|
|
369
|
+
ups: statsRef.current.updatesThisSecond,
|
|
370
|
+
delay:
|
|
371
|
+
statsRef.current.lastDelay > 0
|
|
372
|
+
? `${statsRef.current.lastDelay}ms`
|
|
373
|
+
: "—",
|
|
374
|
+
});
|
|
375
|
+
setScores({
|
|
376
|
+
p1: localPlayerRef.current.score,
|
|
377
|
+
p2: remotePlayerRef.current.score,
|
|
378
|
+
});
|
|
379
|
+
statsRef.current.updatesThisSecond = 0;
|
|
380
|
+
}, 1000);
|
|
381
|
+
|
|
382
|
+
setInGame(true);
|
|
383
|
+
setRoomId(room);
|
|
384
|
+
|
|
385
|
+
// Return cleanup
|
|
386
|
+
return () => {
|
|
387
|
+
cancelAnimationFrame(animationIdRef.current);
|
|
388
|
+
clearInterval(statsInterval);
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
[session, drawArena]
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Create room
|
|
395
|
+
function createRoom() {
|
|
396
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
397
|
+
setRoomId(id);
|
|
398
|
+
joinGame(id);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Join existing room
|
|
402
|
+
function handleJoinRoom(e: React.FormEvent) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
if (!inputRoomId.trim()) return;
|
|
405
|
+
joinGame(inputRoomId.trim());
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Copy room ID
|
|
409
|
+
function copyRoomId() {
|
|
410
|
+
navigator.clipboard.writeText(roomId);
|
|
411
|
+
setCopied(true);
|
|
412
|
+
setTimeout(() => setCopied(false), 2000);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Cleanup
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
return () => {
|
|
418
|
+
cancelAnimationFrame(animationIdRef.current);
|
|
419
|
+
};
|
|
420
|
+
}, []);
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<div className="p-8">
|
|
424
|
+
{/* Header */}
|
|
425
|
+
<div className="flex items-center justify-between mb-8">
|
|
426
|
+
<div className="flex items-center gap-3">
|
|
427
|
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-red-600 flex items-center justify-center">
|
|
428
|
+
<Swords className="w-6 h-6 text-white" />
|
|
429
|
+
</div>
|
|
430
|
+
<div>
|
|
431
|
+
<h1 className="text-2xl font-bold">Arena Game</h1>
|
|
432
|
+
<p className="text-sm text-slate-500">
|
|
433
|
+
Real-time multiplayer collectible arena via Pulse
|
|
434
|
+
</p>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
<div className="flex items-center gap-3">
|
|
438
|
+
{authUser && (
|
|
439
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
440
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
441
|
+
<span className="text-xs text-green-400">
|
|
442
|
+
{authUser.claims.name || authUser.id}
|
|
443
|
+
</span>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
{connected ? (
|
|
447
|
+
<div className="flex items-center gap-1.5">
|
|
448
|
+
<Wifi className="w-4 h-4 text-green-400" />
|
|
449
|
+
<span className="text-xs text-green-400">Connected</span>
|
|
450
|
+
</div>
|
|
451
|
+
) : (
|
|
452
|
+
<div className="flex items-center gap-1.5">
|
|
453
|
+
<WifiOff className="w-4 h-4 text-red-400" />
|
|
454
|
+
<span className="text-xs text-red-400">Disconnected</span>
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
{connecting ? (
|
|
461
|
+
<div className="flex items-center justify-center py-20">
|
|
462
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
|
|
463
|
+
<span className="text-slate-400">Connecting...</span>
|
|
464
|
+
</div>
|
|
465
|
+
) : !inGame ? (
|
|
466
|
+
/* Room selection */
|
|
467
|
+
<div className="max-w-xl mx-auto space-y-6">
|
|
468
|
+
<div className="glass rounded-2xl p-8 text-center">
|
|
469
|
+
<Gamepad2 className="w-16 h-16 text-orange-400 mx-auto mb-4" />
|
|
470
|
+
<h2 className="text-xl font-bold mb-2">Join or Create a Game</h2>
|
|
471
|
+
<p className="text-sm text-slate-400 mb-8">
|
|
472
|
+
Create a room and share the code with a friend, or join
|
|
473
|
+
an existing room. Move your player with WASD or Arrow keys
|
|
474
|
+
and collect golden orbs to score!
|
|
475
|
+
</p>
|
|
476
|
+
|
|
477
|
+
<button
|
|
478
|
+
onClick={createRoom}
|
|
479
|
+
disabled={!connected}
|
|
480
|
+
className="w-full px-6 py-3 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-400 hover:to-red-400 rounded-xl font-semibold text-lg transition-all disabled:opacity-50 mb-6"
|
|
481
|
+
>
|
|
482
|
+
🎮 Create New Room
|
|
483
|
+
</button>
|
|
484
|
+
|
|
485
|
+
<div className="relative mb-6">
|
|
486
|
+
<div className="absolute inset-0 flex items-center">
|
|
487
|
+
<div className="w-full border-t border-slate-700" />
|
|
488
|
+
</div>
|
|
489
|
+
<div className="relative flex justify-center text-xs uppercase">
|
|
490
|
+
<span className="bg-slate-900 px-3 text-slate-500">
|
|
491
|
+
or join existing
|
|
492
|
+
</span>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<form onSubmit={handleJoinRoom} className="flex gap-3">
|
|
497
|
+
<input
|
|
498
|
+
type="text"
|
|
499
|
+
value={inputRoomId}
|
|
500
|
+
onChange={(e) => setInputRoomId(e.target.value)}
|
|
501
|
+
placeholder="Enter room code..."
|
|
502
|
+
className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 outline-none text-sm transition-colors placeholder:text-slate-600"
|
|
503
|
+
/>
|
|
504
|
+
<button
|
|
505
|
+
type="submit"
|
|
506
|
+
disabled={!connected || !inputRoomId.trim()}
|
|
507
|
+
className="px-6 py-2.5 bg-orange-600 hover:bg-orange-500 rounded-xl font-medium transition-all disabled:opacity-50"
|
|
508
|
+
>
|
|
509
|
+
Join
|
|
510
|
+
</button>
|
|
511
|
+
</form>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
{/* How it works */}
|
|
515
|
+
<div className="glass rounded-xl p-5 border-l-4 border-orange-500">
|
|
516
|
+
<h3 className="text-sm font-semibold text-orange-400 mb-1">
|
|
517
|
+
💡 How It Works
|
|
518
|
+
</h3>
|
|
519
|
+
<p className="text-sm text-slate-400">
|
|
520
|
+
Player positions are synced ~30 times per second via Pulse
|
|
521
|
+
streams. Collectible spawns and collections are broadcast to
|
|
522
|
+
all players. The relay ensures consistent game state across
|
|
523
|
+
all connected players with sub-50ms latency.
|
|
524
|
+
</p>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
) : (
|
|
528
|
+
/* In Game */
|
|
529
|
+
<div className="space-y-4">
|
|
530
|
+
{/* Room info bar */}
|
|
531
|
+
<div className="flex items-center justify-between glass rounded-xl px-5 py-3">
|
|
532
|
+
<div className="flex items-center gap-4">
|
|
533
|
+
<div className="flex items-center gap-2">
|
|
534
|
+
<Swords className="w-4 h-4 text-orange-400" />
|
|
535
|
+
<span className="text-sm font-medium">Room:</span>
|
|
536
|
+
<code className="text-xs bg-slate-800 px-2 py-1 rounded font-mono text-orange-300">
|
|
537
|
+
{roomId}
|
|
538
|
+
</code>
|
|
539
|
+
<button
|
|
540
|
+
onClick={copyRoomId}
|
|
541
|
+
className="p-1.5 rounded-lg hover:bg-slate-700 transition-colors"
|
|
542
|
+
title="Copy room code"
|
|
543
|
+
>
|
|
544
|
+
{copied ? (
|
|
545
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
546
|
+
) : (
|
|
547
|
+
<Copy className="w-3.5 h-3.5 text-slate-400" />
|
|
548
|
+
)}
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
552
|
+
<Users className="w-4 h-4" />
|
|
553
|
+
{playerCount} player(s)
|
|
554
|
+
{opponent && (
|
|
555
|
+
<span className="text-green-400 ml-1">
|
|
556
|
+
vs {opponent}
|
|
557
|
+
</span>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
<div className="text-xs text-slate-500">
|
|
562
|
+
Use <kbd className="px-1.5 py-0.5 rounded bg-slate-700 text-slate-300 font-mono">WASD</kbd>{" "}
|
|
563
|
+
or <kbd className="px-1.5 py-0.5 rounded bg-slate-700 text-slate-300 font-mono">Arrow keys</kbd>{" "}
|
|
564
|
+
to move
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
{/* Game canvas */}
|
|
569
|
+
<div className="flex gap-4">
|
|
570
|
+
<div className="flex-1">
|
|
571
|
+
<canvas
|
|
572
|
+
ref={canvasRef}
|
|
573
|
+
width={900}
|
|
574
|
+
height={500}
|
|
575
|
+
className="w-full bg-black/40 border border-slate-800 rounded-2xl shadow-lg ring-1 ring-white/5"
|
|
576
|
+
/>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
{/* Metrics */}
|
|
581
|
+
<div className="grid grid-cols-4 gap-4">
|
|
582
|
+
<div className="glass rounded-xl p-4 text-center">
|
|
583
|
+
<Trophy className="w-5 h-5 text-purple-400 mx-auto mb-1" />
|
|
584
|
+
<div className="text-2xl font-bold font-mono text-purple-400">
|
|
585
|
+
{scores.p1}
|
|
586
|
+
</div>
|
|
587
|
+
<div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
|
|
588
|
+
Your Score
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
<div className="glass rounded-xl p-4 text-center">
|
|
592
|
+
<Trophy className="w-5 h-5 text-emerald-400 mx-auto mb-1" />
|
|
593
|
+
<div className="text-2xl font-bold font-mono text-emerald-400">
|
|
594
|
+
{scores.p2}
|
|
595
|
+
</div>
|
|
596
|
+
<div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
|
|
597
|
+
Opponent
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
<div className="glass rounded-xl p-4 text-center">
|
|
601
|
+
<Zap className="w-5 h-5 text-cyan-400 mx-auto mb-1" />
|
|
602
|
+
<div className="text-2xl font-bold font-mono text-cyan-400">
|
|
603
|
+
{syncMetrics.ups}
|
|
604
|
+
</div>
|
|
605
|
+
<div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
|
|
606
|
+
Updates/sec
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
<div className="glass rounded-xl p-4 text-center">
|
|
610
|
+
<Activity className="w-5 h-5 text-amber-400 mx-auto mb-1" />
|
|
611
|
+
<div className="text-2xl font-bold font-mono text-amber-400">
|
|
612
|
+
{syncMetrics.delay}
|
|
613
|
+
</div>
|
|
614
|
+
<div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
|
|
615
|
+
Latency
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
);
|
|
623
|
+
}
|