Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
109 changes: 95 additions & 14 deletions server/src/socket/handlers.js
Original file line number Diff line number Diff line change
@@ -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}`);
Expand All @@ -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) || [];
Expand All @@ -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) || [];
Expand All @@ -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}`);
});
});
Expand Down
16 changes: 14 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
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"
Expand All @@ -56,13 +58,14 @@
| "profile"
| "fitness"
| "avatar"
| "multiplayer"
| "privacy"
| "terms&conditions";

type ScreenTransitionMap = Record<Screen, readonly Screen[]>;

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"],
Expand All @@ -77,7 +80,8 @@
about: ["welcome"],
contact: ["welcome"],
avatar: ["welcome"],
"privacy": ["welcome"],
multiplayer: ["welcome"],
privacy: ["welcome"],
"terms&conditions": ["welcome"],
};

Expand Down Expand Up @@ -391,7 +395,7 @@
</PageErrorBoundary>
)}

{currentScreen === "battle" && (

Check failure on line 398 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build-and-test (22.x)

This comparison appears to be unintentional because the types 'Screen' and '"battle"' have no overlap.
<PageErrorBoundary fallbackMessage="Failed to load Battle Mode. Please try again.">
<BattleMode onBack={() => navigateTo("welcome")} />
</PageErrorBoundary>
Expand Down Expand Up @@ -452,6 +456,14 @@
</Suspense>
)}

{currentScreen === "multiplayer" && (
<PageErrorBoundary fallbackMessage="Failed to load Multiplayer room. Please try again.">
<Suspense fallback={<div className="loading-fallback">Loading Multiplayer lobby...</div>}>
<MultiplayerRoomScreen onBack={() => navigateTo("welcome")} user={user} />
</Suspense>
</PageErrorBoundary>
)}

{currentScreen === "fitness" && (
<FitnessCalculator onBack={() => navigateTo("welcome")} />
)}
Expand Down
Loading
Loading