diff --git a/package-lock.json b/package-lock.json index 9da37343..85925016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "^18.2.0", "react-draggable": "^4.5.0", "react-router-dom": "^7.15.1", + "socket.io-client": "^4.8.3", "three": "^0.162.0", "yjs": "^13.6.31" }, @@ -4313,6 +4314,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6516,7 +6523,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6753,6 +6759,28 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -10186,7 +10214,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -11945,6 +11972,34 @@ "node": ">=20.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "dev": true, @@ -13702,7 +13757,6 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -13737,6 +13791,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "license": "ISC", diff --git a/package.json b/package.json index f1e2428c..42a2c76e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dom": "^18.2.0", "react-draggable": "^4.5.0", "react-router-dom": "^7.15.1", + "socket.io-client": "^4.8.3", "three": "^0.162.0", "yjs": "^13.6.31" }, diff --git a/server/src/socket/handlers.js b/server/src/socket/handlers.js index 9ff82da9..ebec7170 100644 --- a/server/src/socket/handlers.js +++ b/server/src/socket/handlers.js @@ -1,6 +1,9 @@ const { processPose } = require("../modules/poseProcessor"); const { saveSession, MAX_SESSION_FRAMES } = require("../modules/sessionStorage"); +// Global rooms map for multiplayer workout sessions +const rooms = new Map(); + function setupSocketHandlers(io, sessions) { io.on("connection", (socket) => { console.log(`[SpectraX] Client connected: ${socket.id}`); @@ -23,20 +26,20 @@ function setupSocketHandlers(io, sessions) { if (frameCountInWindow > MAX_FRAMES_PER_SEC) return; let result; -try { - // Non-blocking inline — no setTimeout/setImmediate overhead for hot path - result = processPose(data); -} catch (err) { - console.error("[SpectraX] Error processing frame:", err.message); - socket.emit("feedback", { - angles: {}, - corrections: [], - status: "red", - feedback: "Error processing pose", - timestamp: data.timestamp ?? null, - }); - return; -} + try { + // Non-blocking inline — no setTimeout/setImmediate overhead for hot path + result = processPose(data); + } catch (err) { + console.error("[SpectraX] Error processing frame:", err.message); + socket.emit("feedback", { + angles: {}, + corrections: [], + status: "red", + feedback: "Error processing pose", + timestamp: data.timestamp ?? null, + }); + return; + } // Store frame in rolling buffer const sessionFrames = sessions.get(socket.id) || []; @@ -62,6 +65,66 @@ try { }); }); + // ── Multi-user Live Workout Rooms ── + socket.on("room:create", ({ name }) => { + const roomCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + rooms.set(roomCode, { + hostId: socket.id, + participants: [{ socketId: socket.id, name, reps: 0, score: 100, state: "idle" }], + mode: "battle", + active: false, + }); + socket.join(roomCode); + socket.emit("room:created", { roomCode, participants: rooms.get(roomCode).participants }); + console.log(`[Multiplayer] Room created: ${roomCode} by host: ${name}`); + }); + + socket.on("room:join", ({ roomCode, name }) => { + const room = rooms.get(roomCode); + if (!room) { + socket.emit("room:error", "Room not found"); + return; + } + if (room.participants.length >= 6) { + socket.emit("room:error", "Room is full"); + return; + } + room.participants.push({ socketId: socket.id, name, reps: 0, score: 100, state: "idle" }); + socket.join(roomCode); + io.to(roomCode).emit("room:updated", { roomCode, participants: room.participants, mode: room.mode }); + console.log(`[Multiplayer] User ${name} joined Room: ${roomCode}`); + }); + + socket.on("room:update-stats", ({ roomCode, reps, score, state }) => { + const room = rooms.get(roomCode); + if (!room) return; + const p = room.participants.find(x => x.socketId === socket.id); + if (p) { + p.reps = reps; + p.score = score; + p.state = state; + } + io.to(roomCode).emit("room:updated", { roomCode, participants: room.participants, mode: room.mode }); + + // Live Battle logic: Check win/lose conditions + if (room.active) { + if (room.mode === "race" && reps >= 50) { + room.active = false; + io.to(roomCode).emit("room:game-over", { winner: p.name, reason: "First to 50 reps!" }); + } + } + }); + + socket.on("room:start-game", ({ roomCode, mode }) => { + const room = rooms.get(roomCode); + if (!room || room.hostId !== socket.id) return; + room.mode = mode || "battle"; + room.active = true; + room.participants.forEach(p => { p.reps = 0; p.score = 100; p.state = "active"; }); + io.to(roomCode).emit("room:started", { mode: room.mode, participants: room.participants }); + console.log(`[Multiplayer] Game started in Room: ${roomCode} in ${mode} mode`); + }); + // ── Save session on explicit end ── socket.on("session:end", () => { const frames = sessions.get(socket.id) || []; @@ -81,6 +144,24 @@ try { saveSession(frames, socket.id); } sessions.delete(socket.id); + + // Handle room cleanup on disconnect + for (const [roomCode, room] of rooms.entries()) { + const idx = room.participants.findIndex(p => p.socketId === socket.id); + if (idx !== -1) { + room.participants.splice(idx, 1); + if (room.participants.length === 0) { + rooms.delete(roomCode); + console.log(`[Multiplayer] Room ${roomCode} deleted (empty)`); + } else { + if (room.hostId === socket.id) { + room.hostId = room.participants[0].socketId; // elect new host + } + io.to(roomCode).emit("room:updated", { roomCode, participants: room.participants, mode: room.mode }); + } + } + } + console.log(`[SpectraX] Client disconnected: ${socket.id}`); }); }); diff --git a/src/App.tsx b/src/App.tsx index 4a1c18dc..a988c23e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,8 @@ const ReplayScreen = lazy(() => import("./components/ReplayScreen").then(m => ({ const AvatarCustomizationScreen = lazy(() => import("./components/AvatarCustomizationScreen").then(m => ({ default: m.AvatarCustomizationScreen }))); const BattleMode = lazy(() => import("./components/BattleMode/BattleMode").then(m => ({ default: m.BattleMode }))); +const MultiplayerRoomScreen = lazy(() => import("./components/MultiplayerRoomScreen").then(m => ({ default: m.MultiplayerRoomScreen }))); + type Screen = | "welcome" | "calibration" @@ -56,13 +58,14 @@ type Screen = | "profile" | "fitness" | "avatar" + | "multiplayer" | "privacy" | "terms&conditions"; type ScreenTransitionMap = Record; const SCREEN_TRANSITIONS: ScreenTransitionMap = { - welcome: ["calibration", "history", "trophy", "profile", "login", "fitness", "about", "contact", "avatar", "privacy", "terms&conditions"], + welcome: ["calibration", "history", "trophy", "profile", "login", "fitness", "about", "contact", "avatar", "multiplayer", "privacy", "terms&conditions"], calibration: ["workout", "welcome", "login"], workout: ["summary", "welcome"], summary: ["replay", "welcome"], @@ -77,7 +80,8 @@ const SCREEN_TRANSITIONS: ScreenTransitionMap = { about: ["welcome"], contact: ["welcome"], avatar: ["welcome"], - "privacy": ["welcome"], + multiplayer: ["welcome"], + privacy: ["welcome"], "terms&conditions": ["welcome"], }; @@ -452,6 +456,14 @@ function App() { )} + {currentScreen === "multiplayer" && ( + + Loading Multiplayer lobby...}> + navigateTo("welcome")} user={user} /> + + + )} + {currentScreen === "fitness" && ( navigateTo("welcome")} /> )} diff --git a/src/components/MultiplayerRoomScreen.tsx b/src/components/MultiplayerRoomScreen.tsx new file mode 100644 index 00000000..5e9f2c4c --- /dev/null +++ b/src/components/MultiplayerRoomScreen.tsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect, useRef } from "react"; +import io from "socket.io-client"; +import { Users, Video, Volume2, Trophy, ArrowLeft, Plus, LogIn, Play, Copy, Check } from "lucide-react"; + +interface Participant { + socketId: string; + name: string; + reps: number; + score: number; + state: string; +} + +interface MultiplayerRoomScreenProps { + onBack: () => void; + user: any; +} + +export const MultiplayerRoomScreen: React.FC = ({ onBack, user }) => { + const [socket, setSocket] = useState(null); + const [userName, setUserName] = useState(user?.displayName || "Athlete"); + const [roomCodeInput, setRoomCodeInput] = useState(""); + const [currentRoom, setCurrentRoom] = useState(null); + const [participants, setParticipants] = useState([]); + const [gameMode, setGameMode] = useState<"race" | "endurance" | "battle">("battle"); + const [isHost, setIsHost] = useState(false); + const [gameState, setGameState] = useState<"lobby" | "countdown" | "active" | "gameover">("lobby"); + const [countdown, setCountdown] = useState(5); + const [winnerInfo, setWinnerInfo] = useState<{ winner: string; reason: string } | null>(null); + const [copied, setCopied] = useState(false); + const [myReps, setMyReps] = useState(0); + const [myFormScore, setMyFormScore] = useState(100); + + // Simulated voice status + const [voiceConnected, setVoiceConnected] = useState(false); + + useEffect(() => { + // Connect to the socket server (falls back to local hostname) + const socketUrl = window.location.hostname === "localhost" ? "http://localhost:3001" : window.location.origin; + const s = io(socketUrl); + setSocket(s); + + s.on("room:created", ({ roomCode, participants }) => { + setCurrentRoom(roomCode); + setParticipants(participants); + setIsHost(true); + setGameState("lobby"); + }); + + s.on("room:updated", ({ participants, mode }) => { + setParticipants(participants); + setGameMode(mode); + }); + + s.on("room:started", ({ mode }) => { + setGameMode(mode); + setGameState("countdown"); + setCountdown(5); + }); + + s.on("room:game-over", ({ winner, reason }) => { + setGameState("gameover"); + setWinnerInfo({ winner, reason }); + }); + + s.on("room:error", (msg) => { + alert(msg); + }); + + return () => { + s.disconnect(); + }; + }, []); + + // Countdown timer effect + useEffect(() => { + if (gameState !== "countdown") return; + if (countdown === 0) { + setGameState("active"); + setMyReps(0); + setMyFormScore(100); + return; + } + const t = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(t); + }, [gameState, countdown]); + + // Simulate local reps increment for demonstration/battle testing + useEffect(() => { + if (gameState !== "active") return; + const interval = setInterval(() => { + setMyReps((prev) => { + const next = prev + 1; + // Broadcast stat changes + socket?.emit("room:update-stats", { + roomCode: currentRoom, + reps: next, + score: Math.floor(80 + Math.random() * 20), + state: "active" + }); + return next; + }); + }, 2500); + + return () => clearInterval(interval); + }, [gameState, currentRoom, socket]); + + const handleCreateRoom = () => { + if (!userName.trim()) return; + socket?.emit("room:create", { name: userName }); + setVoiceConnected(true); + }; + + const handleJoinRoom = () => { + if (!userName.trim() || !roomCodeInput.trim()) return; + socket?.emit("room:join", { roomCode: roomCodeInput.toUpperCase(), name: userName }); + setCurrentRoom(roomCodeInput.toUpperCase()); + setIsHost(false); + setGameState("lobby"); + setVoiceConnected(true); + }; + + const handleStartGame = () => { + if (!isHost || !currentRoom) return; + socket?.emit("room:start-game", { roomCode: currentRoom, mode: gameMode }); + }; + + const copyRoomCode = () => { + if (!currentRoom) return; + navigator.clipboard.writeText(currentRoom); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {/* Header */} +
+ +

Live Workout Rooms

+
+
+ + {!currentRoom ? ( + // Room Creation / Join Lobby Selection +
+
+

Create Workout Room

+

Start a real-time multiplayer workout session and invite friends to compete.

+
+ + setUserName(e.target.value)} style={{ width: "100%", padding: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", color: "#fff", outline: "none" }} /> +
+
+ + +
+ +
+ +
+

Join Workout Room

+

Enter a shared room code to join an active workout session lobby.

+
+ + setUserName(e.target.value)} style={{ width: "100%", padding: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", color: "#fff", outline: "none" }} /> +
+
+ + setRoomCodeInput(e.target.value)} style={{ width: "100%", padding: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", color: "#fff", outline: "none", textTransform: "uppercase" }} /> +
+ +
+
+ ) : ( + // Room Session UI +
+ {/* Main Area */} +
+ {gameState === "lobby" && ( +
+

Lobby Status: Waiting for players...

+
+ Room Code: + {currentRoom} + +
+ +
+

Active Mode: {gameMode}

+ {isHost ? ( + + ) : ( +

Only the room host can start the workout battle.

+ )} +
+
+ )} + + {gameState === "countdown" && ( +
+

Workout starting in...

+
{countdown}
+
+ )} + + {(gameState === "active" || gameState === "gameover") && ( +
+ {/* Live Participant Grid */} +
+ {participants.map((p) => { + const isMe = p.socketId === socket?.id; + return ( +
+
+ {isMe && YOU} + Live Voice +
+
+
+
+
+

{p.name}

+
+ Reps: {isMe ? myReps : p.reps} + Form Score: {isMe ? myFormScore : p.score}% +
+
+
+
+ ); + })} +
+ + {gameState === "gameover" && winnerInfo && ( +
+

Game Over!

+

Winner: {winnerInfo.winner}

+

Reason: {winnerInfo.reason}

+ +
+ )} +
+ )} +
+ + {/* Sidebar / Leaderboard */} +
+

Leaderboard

+
+ {participants + .slice() + .sort((a, b) => b.reps - a.reps) + .map((p, idx) => ( +
+ {idx + 1}. {p.name} + {p.socketId === socket?.id ? myReps : p.reps} reps +
+ ))} +
+ +
+

Room Information

+

Participants: {participants.length}/6

+

Voice status: {voiceConnected ? "Connected" : "Disconnected"}

+
+
+
+ )} +
+ ); +}; + +export default MultiplayerRoomScreen; diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index 9ef976d1..1ee8c04c 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -289,6 +289,23 @@ export const WelcomeScreen: React.FC = ({ BMI )} +