From 8bdb8f47ef29079947f45ebcb1159fb62515862f Mon Sep 17 00:00:00 2001 From: Ralex <95540504+Ralex91@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:20:03 +0200 Subject: [PATCH] feat(reconnect): add reconnect for player & manager --- package.json | 2 +- packages/common/src/types/game/index.ts | 1 + packages/common/src/types/game/socket.ts | 20 +- packages/socket/src/index.ts | 93 ++--- packages/socket/src/services/game.ts | 333 +++++++++++------- packages/socket/src/utils/game.ts | 74 ++++ packages/socket/src/utils/inviteCode.ts | 14 - packages/web/package.json | 1 + packages/web/src/app/(auth)/manager/page.tsx | 6 +- packages/web/src/app/game/[gameId]/page.tsx | 42 ++- .../src/app/game/manager/[gameId]/page.tsx | 45 ++- .../web/src/components/game/GameWrapper.tsx | 13 +- .../web/src/components/game/join/Username.tsx | 8 +- .../src/components/game/states/Answers.tsx | 10 +- .../web/src/components/game/states/Room.tsx | 4 +- packages/web/src/contexts/socketProvider.tsx | 37 +- packages/web/src/stores/game.tsx | 44 --- packages/web/src/stores/manager.tsx | 35 ++ packages/web/src/stores/player.tsx | 39 +- packages/web/src/stores/question.tsx | 14 + packages/web/src/utils/createStatus.ts | 8 + pnpm-lock.yaml | 26 +- 22 files changed, 593 insertions(+), 276 deletions(-) create mode 100644 packages/socket/src/utils/game.ts delete mode 100644 packages/socket/src/utils/inviteCode.ts delete mode 100644 packages/web/src/stores/game.tsx create mode 100644 packages/web/src/stores/manager.tsx create mode 100644 packages/web/src/stores/question.tsx create mode 100644 packages/web/src/utils/createStatus.ts diff --git a/package.json b/package.json index eee3bf7..b5f5cf8 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,6 @@ }, "devDependencies": { "dotenv-cli": "^10.0.0", - "typescript": "^5.0.0" + "typescript": "^5.9.3" } } diff --git a/packages/common/src/types/game/index.ts b/packages/common/src/types/game/index.ts index 06698a8..a47afdf 100644 --- a/packages/common/src/types/game/index.ts +++ b/packages/common/src/types/game/index.ts @@ -1,5 +1,6 @@ export type Player = { id: string + clientId: string username: string points: number } diff --git a/packages/common/src/types/game/socket.ts b/packages/common/src/types/game/socket.ts index 5a83c1f..fb0d4e1 100644 --- a/packages/common/src/types/game/socket.ts +++ b/packages/common/src/types/game/socket.ts @@ -1,5 +1,5 @@ import { Server as ServerIO, Socket as SocketIO } from "socket.io" -import { Player, QuizzWithId } from "." +import { GameUpdateQuestion, Player, QuizzWithId } from "." import { Status, StatusDataMap } from "./status" export type Server = ServerIO @@ -21,6 +21,8 @@ export type MessageGameId = { } export interface ServerToClientEvents { + connect: () => void + // Game events "game:status": (data: { name: Status; data: StatusDataMap[Status] }) => void "game:successRoom": (data: string) => void @@ -33,9 +35,23 @@ export interface ServerToClientEvents { "game:reset": () => void "game:updateQuestion": (data: { current: number; total: number }) => void "game:playerAnswer": (count: number) => void + + // Player events + "player:successReconnect": (data: { + gameId: string + status: { name: Status; data: StatusDataMap[Status] } + player: { username: string; points: number } + currentQuestion: GameUpdateQuestion + }) => void "player:updateLeaderboard": (data: { leaderboard: Player[] }) => void // Manager events + "manager:successReconnect": (data: { + gameId: string + status: { name: Status; data: StatusDataMap[Status] } + players: Player[] + currentQuestion: GameUpdateQuestion + }) => void "manager:quizzList": (quizzList: QuizzWithId[]) => void "manager:gameCreated": (data: { gameId: string; inviteCode: string }) => void "manager:statusUpdate": (data: { @@ -52,6 +68,7 @@ export interface ClientToServerEvents { // Manager actions "game:create": (quizzId: string) => void "manager:auth": (password: string) => void + "manager:reconnect": (message: { gameId: string }) => void "manager:kickPlayer": ( message: MessageWithoutStatus<{ playerId: string }> ) => void @@ -63,6 +80,7 @@ export interface ClientToServerEvents { // Player actions "player:join": (inviteCode: string) => void "player:login": (message: MessageWithoutStatus<{ username: string }>) => void + "player:reconnect": (message: { gameId: string }) => void "player:selectedAnswer": ( message: MessageWithoutStatus<{ answerKey: number }> ) => void diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts index cf051ba..d784de6 100644 --- a/packages/socket/src/index.ts +++ b/packages/socket/src/index.ts @@ -1,7 +1,12 @@ -import { Server, Socket } from "@rahoot/common/types/game/socket" +import { Server } from "@rahoot/common/types/game/socket" import env from "@rahoot/socket/env" import Config from "@rahoot/socket/services/config" import Game from "@rahoot/socket/services/game" +import { + findManagerGameByClientId, + findPlayerGameByClientId, + withGame, +} from "@rahoot/socket/utils/game" import { inviteCodeValidator } from "@rahoot/socket/utils/validator" import { Server as ServerIO } from "socket.io" @@ -14,35 +19,37 @@ const port = env.SOCKER_PORT || 3001 console.log(`Socket server running on port ${port}`) io.listen(Number(port)) -function withGame( - gameId: string | undefined, - socket: Socket, - games: Game[], - handler: (game: Game) => T -): T | void { - let game = null - - if (gameId) { - game = games.find((g) => g.gameId === gameId) - } else { - game = games.find( - (g) => - g.players.find((p) => p.id === socket.id) || g.managerId === socket.id - ) - } - - if (!game) { - socket.emit("game:errorMessage", "Game not found") - return - } - - return handler(game) -} - io.on("connection", (socket) => { console.log(`A user connected ${socket.id}`) console.log(socket.handshake.auth) + socket.on("player:reconnect", () => { + const game = findPlayerGameByClientId(socket.handshake.auth.clientId, games) + + if (game) { + game.reconnect(socket) + + return + } + + socket.emit("game:reset") + }) + + socket.on("manager:reconnect", () => { + const game = findManagerGameByClientId( + socket.handshake.auth.clientId, + games + ) + + if (game) { + game.reconnect(socket) + + return + } + + socket.emit("game:reset") + }) + socket.on("manager:auth", (password) => { try { const config = Config.game() @@ -121,14 +128,14 @@ io.on("connection", (socket) => { ) socket.on("manager:showLeaderboard", ({ gameId }) => - withGame(gameId, socket, games, (game) => game.showLeaderboard(socket)) + withGame(gameId, socket, games, (game) => game.showLeaderboard()) ) socket.on("disconnect", () => { console.log(`user disconnected ${socket.id}`) - const managerGame = games.find((g) => g.managerId === socket.id) + const managerGame = games.find((g) => g.manager.id === socket.id) - if (managerGame) { + if (managerGame && !managerGame.started) { console.log("Reset game (manager disconnected)") managerGame.abortCooldown() @@ -141,19 +148,21 @@ io.on("connection", (socket) => { const game = games.find((g) => g.players.some((p) => p.id === socket.id)) - if (game) { - const player = game.players.find((p) => p.id === socket.id) - - if (player) { - game.players = game.players.filter((p) => p.id !== socket.id) - - io.to(game.managerId).emit("manager:removePlayer", player.id) - io.to(game.gameId).emit("game:totalPlayers", game.players.length) - - console.log( - `Removed player ${player.username} from game ${game.gameId}` - ) - } + if (!game || game.started) { + return } + + const player = game.players.find((p) => p.id === socket.id) + + if (!player) { + return + } + + game.players = game.players.filter((p) => p.id !== socket.id) + + io.to(game.manager.id).emit("manager:removePlayer", player.id) + io.to(game.gameId).emit("game:totalPlayers", game.players.length) + + console.log(`Removed player ${player.username} from game ${game.gameId}`) }) }) diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts index b66c68f..3e24428 100644 --- a/packages/socket/src/services/game.ts +++ b/packages/socket/src/services/game.ts @@ -1,18 +1,27 @@ import { Answer, Player, Quizz } from "@rahoot/common/types/game" import { Server, Socket } from "@rahoot/common/types/game/socket" -import { Status } from "@rahoot/common/types/game/status" -import createInviteCode from "@rahoot/socket/utils/inviteCode" +import { Status, StatusDataMap } from "@rahoot/common/types/game/status" +import { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game" +import sleep from "@rahoot/socket/utils/sleep" import { v4 as uuid } from "uuid" -import sleep from "../utils/sleep" class Game { io: Server gameId: string - managerId: string + manager: { + id: string + clientId: string + } inviteCode: string started: boolean - status: Status + + lastBroadcastStatus: { name: Status; data: StatusDataMap[Status] } | null = + null + managerStatus: { name: Status; data: StatusDataMap[Status] } | null = null + playerStatus: Map = + new Map() + quizz: Quizz players: Player[] @@ -34,10 +43,17 @@ class Game { this.io = io this.gameId = uuid() - this.managerId = "" + this.manager = { + id: "", + clientId: "", + } this.inviteCode = "" this.started = false - this.status = Status.SHOW_START + + this.lastBroadcastStatus = null + this.managerStatus = null + this.playerStatus = new Map() + this.players = [] this.round = { @@ -53,7 +69,10 @@ class Game { const roomInvite = createInviteCode() this.inviteCode = roomInvite - this.managerId = socket.id + this.manager = { + id: socket.id, + clientId: socket.handshake.auth.clientId, + } this.quizz = quizz socket.join(this.gameId) @@ -67,25 +86,48 @@ class Game { ) } + broadcastStatus(status: T, data: StatusDataMap[T]) { + const statusData = { name: status, data } + this.lastBroadcastStatus = statusData + this.io.to(this.gameId).emit("game:status", statusData) + } + + sendStatus( + target: string, + status: T, + data: StatusDataMap[T] + ) { + const statusData = { name: status, data } + + if (this.manager.id === target) { + this.managerStatus = statusData + } else { + this.playerStatus.set(target, statusData) + } + + this.io.to(target).emit("game:status", statusData) + } + join(socket: Socket, username: string) { socket.join(this.gameId) const playerData = { id: socket.id, + clientId: socket.handshake.auth.clientId, username: username, points: 0, } this.players.push(playerData) - this.io.to(this.managerId).emit("manager:newPlayer", playerData) + this.io.to(this.manager.id).emit("manager:newPlayer", playerData) this.io.to(this.gameId).emit("game:totalPlayers", this.players.length) socket.emit("game:successJoin", this.gameId) } kickPlayer(socket: Socket, playerId: string) { - if (this.managerId !== socket.id) { + if (this.manager.id !== socket.id) { return } @@ -96,21 +138,97 @@ class Game { } this.players = this.players.filter((p) => p.id !== playerId) + this.playerStatus.delete(playerId) this.io.in(playerId).socketsLeave(this.gameId) this.io.to(player.id).emit("game:kick") - this.io.to(this.managerId).emit("manager:playerKicked", player.id) + this.io.to(this.manager.id).emit("manager:playerKicked", player.id) this.io.to(this.gameId).emit("game:totalPlayers", this.players.length) } + reconnect(socket: Socket) { + const clientId = socket.handshake.auth.clientId + const isManager = this.manager.clientId === clientId + + if (!isManager) { + const player = this.players.find((p) => p.clientId === clientId) + + if (!player) { + return false + } + } + + socket.join(this.gameId) + + const commonData = { + gameId: this.gameId, + started: this.started, + currentQuestion: { + current: this.round.currentQuestion, + total: this.quizz.questions.length, + }, + } + + if (isManager) { + this.manager.id = socket.id + + const status = this.managerStatus || + this.lastBroadcastStatus || { + name: Status.WAIT, + data: { text: "Waiting for players" }, + } + + socket.emit("manager:successReconnect", { + ...commonData, + status, + players: this.players, + }) + socket.emit("game:totalPlayers", this.players.length) + + console.log(`Manager reconnected to game ${this.inviteCode}`) + + return + } + + const player = this.players.find((p) => p.clientId === clientId)! + const oldSocketId = player.id + player.id = socket.id + + const status = this.playerStatus.get(oldSocketId) || + this.lastBroadcastStatus || { + name: Status.WAIT, + data: { text: "Waiting for players" }, + } + + if (this.playerStatus.has(oldSocketId)) { + const oldStatus = this.playerStatus.get(oldSocketId)! + this.playerStatus.delete(oldSocketId) + this.playerStatus.set(socket.id, oldStatus) + } + + socket.emit("player:successReconnect", { + ...commonData, + status, + player: { + username: player.username, + points: player.points, + }, + }) + socket.emit("game:totalPlayers", this.players.length) + console.log( + `Player ${player.username} reconnected to game ${this.inviteCode}` + ) + + return true + } + async startCooldown(seconds: number) { if (this.cooldown.active) { return } this.cooldown.active = true - let count = seconds - 1 return new Promise((resolve) => { @@ -119,10 +237,11 @@ class Game { this.cooldown.active = false clearInterval(cooldownTimeout) resolve() - } else { - this.io.to(this.gameId).emit("game:cooldown", count) - count -= 1 + return } + + this.io.to(this.gameId).emit("game:cooldown", count) + count -= 1 }, 1000) }) } @@ -134,7 +253,7 @@ class Game { } async start(socket: Socket) { - if (this.managerId !== socket.id) { + if (this.manager.id !== socket.id) { return } @@ -143,18 +262,15 @@ class Game { } this.started = true - this.io.to(this.gameId).emit("game:status", { - name: Status.SHOW_START, - data: { - time: 3, - subject: this.quizz.subject, - }, + + this.broadcastStatus(Status.SHOW_START, { + time: 3, + subject: this.quizz.subject, }) await sleep(3) this.io.to(this.gameId).emit("game:startCooldown") - await this.startCooldown(3) this.newRound() @@ -167,17 +283,16 @@ class Game { return } + this.playerStatus.clear() + this.io.to(this.gameId).emit("game:updateQuestion", { current: this.round.currentQuestion + 1, total: this.quizz.questions.length, }) - this.io.to(this.gameId).emit("game:status", { - name: Status.SHOW_PREPARED, - data: { - totalAnswers: question.answers.length, - questionNumber: this.round.currentQuestion + 1, - }, + this.broadcastStatus(Status.SHOW_PREPARED, { + totalAnswers: question.answers.length, + questionNumber: this.round.currentQuestion + 1, }) await sleep(2) @@ -186,13 +301,10 @@ class Game { return } - this.io.to(this.gameId).emit("game:status", { - name: Status.SHOW_QUESTION, - data: { - question: question.question, - image: question.image, - cooldown: question.cooldown, - }, + this.broadcastStatus(Status.SHOW_QUESTION, { + question: question.question, + image: question.image, + cooldown: question.cooldown, }) await sleep(question.cooldown) @@ -203,15 +315,12 @@ class Game { this.round.startTime = Date.now() - this.io.to(this.gameId).emit("game:status", { - name: Status.SELECT_ANSWER, - data: { - question: question.question, - answers: question.answers, - image: question.image, - time: question.time, - totalPlayer: this.players.length, - }, + this.broadcastStatus(Status.SELECT_ANSWER, { + question: question.question, + answers: question.answers, + image: question.image, + time: question.time, + totalPlayer: this.players.length, }) await this.startCooldown(question.time) @@ -220,42 +329,10 @@ class Game { return } - this.players = this.players.map((player) => { - const playerAnswer = this.round.playersAnswers.find( - (a) => a.playerId === player.id - ) - - const isCorrect = playerAnswer - ? playerAnswer.answerId === question.solution - : false - - const points = - playerAnswer && isCorrect - ? Math.round(playerAnswer && playerAnswer.points) - : 0 - - player.points += points - - const sortPlayers = this.players.sort((a, b) => b.points - a.points) - - const rank = sortPlayers.findIndex((p) => p.id === player.id) + 1 - const aheadPlayer = sortPlayers[rank - 2] - - this.io.to(player.id).emit("game:status", { - name: Status.SHOW_RESULT, - data: { - correct: isCorrect, - message: isCorrect ? "Nice !" : "Too bad", - points, - myPoints: player.points, - rank, - aheadOfMe: aheadPlayer ? aheadPlayer.username : null, - }, - }) - - return player - }) + await this.showResults(question) + } + async showResults(question: any) { const totalType = this.round.playersAnswers.reduce( (acc: Record, { answerId }) => { acc[answerId] = (acc[answerId] || 0) + 1 @@ -264,36 +341,54 @@ class Game { {} ) - // Manager - this.io.to(this.gameId).emit("game:status", { - name: Status.SHOW_RESPONSES, - data: { - question: question.question, - responses: totalType, - correct: question.solution, - answers: question.answers, - image: question.image, - }, + const sortedPlayers = this.players + .map((player) => { + const playerAnswer = this.round.playersAnswers.find( + (a) => a.playerId === player.id + ) + + const isCorrect = playerAnswer + ? playerAnswer.answerId === question.solution + : false + + const points = + playerAnswer && isCorrect ? Math.round(playerAnswer.points) : 0 + + player.points += points + + return { ...player, lastCorrect: isCorrect, lastPoints: points } + }) + .sort((a, b) => b.points - a.points) + + this.players = sortedPlayers + + sortedPlayers.forEach((player, index) => { + const rank = index + 1 + const aheadPlayer = sortedPlayers[index - 1] + + this.sendStatus(player.id, Status.SHOW_RESULT, { + correct: player.lastCorrect, + message: player.lastCorrect ? "Nice!" : "Too bad", + points: player.lastPoints, + myPoints: player.points, + rank, + aheadOfMe: aheadPlayer ? aheadPlayer.username : null, + }) + }) + + this.sendStatus(this.manager.id, Status.SHOW_RESPONSES, { + question: question.question, + responses: totalType, + correct: question.solution, + answers: question.answers, + image: question.image, }) this.round.playersAnswers = [] } - timeToPoint(startTime: number, secondes: number) { - let points = 1000 - - const actualTime = Date.now() - const tempsPasseEnSecondes = (actualTime - startTime) / 1000 - - points -= (1000 / secondes) * tempsPasseEnSecondes - points = Math.max(0, points) - - return points - } - async selectAnswer(socket: Socket, answerId: number) { const player = this.players.find((player) => player.id === socket.id) - const question = this.quizz.questions[this.round.currentQuestion] if (!player) { @@ -307,13 +402,13 @@ class Game { this.round.playersAnswers.push({ playerId: player.id, answerId, - points: this.timeToPoint(this.round.startTime, question.time), + points: timeToPoint(this.round.startTime, question.time), }) - socket.emit("game:status", { - name: Status.WAIT, - data: { text: "Waiting for the players to answer" }, + this.sendStatus(socket.id, Status.WAIT, { + text: "Waiting for the players to answer", }) + socket .to(this.gameId) .emit("game:playerAnswer", this.round.playersAnswers.length) @@ -330,7 +425,7 @@ class Game { return } - if (socket.id !== this.managerId) { + if (socket.id !== this.manager.id) { return } @@ -347,36 +442,32 @@ class Game { return } - if (socket.id !== this.managerId) { + if (socket.id !== this.manager.id) { return } this.abortCooldown() } - showLeaderboard(socket: Socket) { + showLeaderboard() { const isLastRound = this.round.currentQuestion + 1 === this.quizz.questions.length const sortedPlayers = this.players.sort((a, b) => b.points - a.points) if (isLastRound) { - socket.emit("game:status", { - name: Status.FINISHED, - data: { - subject: this.quizz.subject, - top: sortedPlayers.slice(0, 3), - }, + this.started = false + + this.broadcastStatus(Status.FINISHED, { + subject: this.quizz.subject, + top: sortedPlayers.slice(0, 3), }) return } - socket.emit("game:status", { - name: Status.SHOW_LEADERBOARD, - data: { - leaderboard: sortedPlayers.slice(0, 5), - }, + this.sendStatus(this.manager.id, Status.SHOW_LEADERBOARD, { + leaderboard: sortedPlayers.slice(0, 5), }) } } diff --git a/packages/socket/src/utils/game.ts b/packages/socket/src/utils/game.ts new file mode 100644 index 0000000..9a7f92a --- /dev/null +++ b/packages/socket/src/utils/game.ts @@ -0,0 +1,74 @@ +import { Socket } from "@rahoot/common/types/game/socket" +import Game from "@rahoot/socket/services/game" + +export const withGame = ( + gameId: string | undefined, + socket: Socket, + games: Game[], + handler: (game: Game) => T +): T | void => { + let game = null + + if (gameId) { + game = games.find((g) => g.gameId === gameId) + } else { + game = games.find( + (g) => + g.players.find((p) => p.id === socket.id) || g.manager.id === socket.id + ) + } + + if (!game) { + socket.emit("game:errorMessage", "Game not found") + return + } + + return handler(game) +} + +export const createInviteCode = (length = 6) => { + let result = "" + const characters = "0123456789" + const charactersLength = characters.length + + for (let i = 0; i < length; i += 1) { + const randomIndex = Math.floor(Math.random() * charactersLength) + result += characters.charAt(randomIndex) + } + + return result +} + +export const timeToPoint = (startTime: number, secondes: number): number => { + let points = 1000 + + const actualTime = Date.now() + const tempsPasseEnSecondes = (actualTime - startTime) / 1000 + + points -= (1000 / secondes) * tempsPasseEnSecondes + points = Math.max(0, points) + + return points +} + +export const findPlayerGameByClientId = (clientId: string, games: Game[]) => { + const playerGame = games.find((g) => + g.players.find((p) => p.clientId === clientId) + ) + + if (playerGame) { + return playerGame + } + + return null +} + +export const findManagerGameByClientId = (clientId: string, games: Game[]) => { + const managerGame = games.find((g) => g.manager.clientId === clientId) + + if (managerGame) { + return managerGame + } + + return null +} diff --git a/packages/socket/src/utils/inviteCode.ts b/packages/socket/src/utils/inviteCode.ts deleted file mode 100644 index f6837ee..0000000 --- a/packages/socket/src/utils/inviteCode.ts +++ /dev/null @@ -1,14 +0,0 @@ -const createInviteCode = (length = 6) => { - let result = "" - const characters = "0123456789" - const charactersLength = characters.length - - for (let i = 0; i < length; i += 1) { - const randomIndex = Math.floor(Math.random() * charactersLength) - result += characters.charAt(randomIndex) - } - - return result -} - -export default createInviteCode diff --git a/packages/web/package.json b/packages/web/package.json index 71aca3e..7fa8e4c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "react-hot-toast": "^2.6.0", "socket.io-client": "^4.8.1", "use-sound": "^5.0.0", + "uuid": "^13.0.0", "yup": "^1.7.1", "zod": "^4.1.12", "zustand": "^5.0.8" diff --git a/packages/web/src/app/(auth)/manager/page.tsx b/packages/web/src/app/(auth)/manager/page.tsx index 507b27d..63b63a0 100644 --- a/packages/web/src/app/(auth)/manager/page.tsx +++ b/packages/web/src/app/(auth)/manager/page.tsx @@ -4,12 +4,12 @@ import { QuizzWithId } from "@rahoot/common/types/game" import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword" import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" -import { useManagerGameStore } from "@rahoot/web/stores/game" +import { useManagerStore } from "@rahoot/web/stores/manager" import { useRouter } from "next/navigation" import { useState } from "react" export default function Manager() { - const { setStatus } = useManagerGameStore() + const { setGameId, setStatus } = useManagerStore() const router = useRouter() const { socket } = useSocket() @@ -22,6 +22,7 @@ export default function Manager() { }) useEvent("manager:gameCreated", ({ gameId, inviteCode }) => { + setGameId(gameId) setStatus("SHOW_ROOM", { text: "Waiting for the players", inviteCode }) router.push(`/game/manager/${gameId}`) }) @@ -32,7 +33,6 @@ export default function Manager() { const handleCreate = (quizzId: string) => { console.log(quizzId) socket?.emit("game:create", quizzId) - console.log("create room") } if (!isAuth) { diff --git a/packages/web/src/app/game/[gameId]/page.tsx b/packages/web/src/app/game/[gameId]/page.tsx index ad6907e..6bcd0b9 100644 --- a/packages/web/src/app/game/[gameId]/page.tsx +++ b/packages/web/src/app/game/[gameId]/page.tsx @@ -8,24 +8,36 @@ import Question from "@rahoot/web/components/game/states/Question" import Result from "@rahoot/web/components/game/states/Result" import Start from "@rahoot/web/components/game/states/Start" import Wait from "@rahoot/web/components/game/states/Wait" -import { useEvent } from "@rahoot/web/contexts/socketProvider" -import { usePlayerGameStore } from "@rahoot/web/stores/game" +import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { usePlayerStore } from "@rahoot/web/stores/player" +import { useQuestionStore } from "@rahoot/web/stores/question" import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants" -import { useRouter } from "next/navigation" -import { useEffect } from "react" +import { useParams, useRouter } from "next/navigation" import toast from "react-hot-toast" export default function Game() { const router = useRouter() - const { player, logout } = usePlayerStore() - const { status, setStatus, resetStatus } = usePlayerGameStore() + const { socket, isConnected } = useSocket() + const { gameId: gameIdParam }: { gameId?: string } = useParams() + const { status, setPlayer, logout, setGameId, setStatus, resetStatus } = + usePlayerStore() + const { setQuestionStates } = useQuestionStore() - useEffect(() => { - if (!player) { - router.replace("/") + useEvent("connect", () => { + if (gameIdParam) { + socket?.emit("player:reconnect", { gameId: gameIdParam }) } - }, [player, router]) + }) + + useEvent( + "player:successReconnect", + ({ gameId, status, player, currentQuestion }) => { + setGameId(gameId) + setStatus(status.name, status.data) + setPlayer(player) + setQuestionStates(currentQuestion) + }, + ) useEvent("game:status", ({ name, data }) => { if (name in GAME_STATE_COMPONENTS) { @@ -35,11 +47,19 @@ export default function Game() { useEvent("game:reset", () => { router.replace("/") - logout() resetStatus() + logout() toast("The game has been reset by the host") }) + if (!isConnected) { + return null + } + + if (!gameIdParam) { + return null + } + let component = null switch (status.name) { diff --git a/packages/web/src/app/game/manager/[gameId]/page.tsx b/packages/web/src/app/game/manager/[gameId]/page.tsx index 7d457d8..67cf094 100644 --- a/packages/web/src/app/game/manager/[gameId]/page.tsx +++ b/packages/web/src/app/game/manager/[gameId]/page.tsx @@ -11,16 +11,20 @@ import Responses from "@rahoot/web/components/game/states/Responses" import Room from "@rahoot/web/components/game/states/Room" import Start from "@rahoot/web/components/game/states/Start" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" -import { useManagerGameStore } from "@rahoot/web/stores/game" +import { useManagerStore } from "@rahoot/web/stores/manager" +import { useQuestionStore } from "@rahoot/web/stores/question" import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants" -import { useParams } from "next/navigation" +import { useParams, useRouter } from "next/navigation" import { useEffect, useState } from "react" +import toast from "react-hot-toast" export default function ManagerGame() { - const { socket } = useSocket() + const router = useRouter() + const { gameId: gameIdParam }: { gameId?: string } = useParams() + const { socket, isConnected } = useSocket() const [nextText, setNextText] = useState("Start") - const { status, setStatus } = useManagerGameStore() - const { gameId }: { gameId?: string } = useParams() + const { gameId, status, setGameId, setStatus, setPlayers } = useManagerStore() + const { setQuestionStates } = useQuestionStore() useEvent("game:status", ({ name, data }) => { if (name in GAME_STATE_COMPONENTS_MANAGER) { @@ -28,12 +32,41 @@ export default function ManagerGame() { } }) + useEvent("connect", () => { + if (gameIdParam) { + socket?.emit("manager:reconnect", { gameId: gameIdParam }) + } + }) + + useEvent( + "manager:successReconnect", + ({ gameId, status, players, currentQuestion }) => { + setGameId(gameId) + setStatus(status.name, status.data) + setPlayers(players) + setQuestionStates(currentQuestion) + }, + ) + + useEvent("game:reset", () => { + router.replace("/manager") + toast("Game is not available anymore") + }) + useEffect(() => { - if (status.name === "SHOW_START") { + if (status.name === Status.SHOW_START) { setNextText("Start") } }, [status.name]) + if (!isConnected) { + return null + } + + if (!gameId) { + return null + } + const handleSkip = () => { setNextText("Skip") diff --git a/packages/web/src/components/game/GameWrapper.tsx b/packages/web/src/components/game/GameWrapper.tsx index be74569..225b9ea 100644 --- a/packages/web/src/components/game/GameWrapper.tsx +++ b/packages/web/src/components/game/GameWrapper.tsx @@ -1,13 +1,13 @@ "use client" -import { GameUpdateQuestion } from "@rahoot/common/types/game" import background from "@rahoot/web/assets/background.webp" import Button from "@rahoot/web/components/Button" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { usePlayerStore } from "@rahoot/web/stores/player" +import { useQuestionStore } from "@rahoot/web/stores/question" import Image from "next/image" import { useRouter } from "next/navigation" -import { PropsWithChildren, useEffect, useState } from "react" +import { PropsWithChildren, useEffect } from "react" type Props = PropsWithChildren & { textNext?: string @@ -23,10 +23,9 @@ export default function GameWrapper({ }: Props) { const { isConnected, connect } = useSocket() const { player, logout } = usePlayerStore() + const { questionStates, setQuestionStates } = useQuestionStore() const router = useRouter() - const [questionState, setQuestionState] = useState() - useEffect(() => { if (!isConnected) { connect() @@ -39,7 +38,7 @@ export default function GameWrapper({ }) useEvent("game:updateQuestion", ({ current, total }) => { - setQuestionState({ + setQuestionStates({ current, total, }) @@ -56,9 +55,9 @@ export default function GameWrapper({
- {questionState && ( + {questionStates && (
- {`${questionState.current} / ${questionState.total}`} + {`${questionStates.current} / ${questionStates.total}`}
)} diff --git a/packages/web/src/components/game/join/Username.tsx b/packages/web/src/components/game/join/Username.tsx index 9425c19..4567cb2 100644 --- a/packages/web/src/components/game/join/Username.tsx +++ b/packages/web/src/components/game/join/Username.tsx @@ -11,12 +11,16 @@ import { KeyboardEvent, useState } from "react" export default function Username() { const { socket } = useSocket() - const { player, login } = usePlayerStore() + const { gameId, login } = usePlayerStore() const router = useRouter() const [username, setUsername] = useState("") const handleLogin = () => { - socket?.emit("player:login", { gameId: player?.gameId, data: { username } }) + if (!gameId) { + return + } + + socket?.emit("player:login", { gameId, data: { username } }) } const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/web/src/components/game/states/Answers.tsx b/packages/web/src/components/game/states/Answers.tsx index f906c99..f8d1253 100644 --- a/packages/web/src/components/game/states/Answers.tsx +++ b/packages/web/src/components/game/states/Answers.tsx @@ -33,9 +33,10 @@ export default function Answers({ volume: 0.1, }) - const [playMusic] = useSound(SFX_ANSWERS_MUSIC, { + const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, { volume: 0.2, interrupt: true, + loop: true, }) const handleAnswer = (answerKey: number) => () => { @@ -53,9 +54,12 @@ export default function Answers({ } useEffect(() => { - console.log("play music") playMusic() - }, []) + + return () => { + stopMusic() + } + }, [playMusic]) useEvent("game:cooldown", (sec) => { setCooldown(sec) diff --git a/packages/web/src/components/game/states/Room.tsx b/packages/web/src/components/game/states/Room.tsx index 0190347..533dcdb 100644 --- a/packages/web/src/components/game/states/Room.tsx +++ b/packages/web/src/components/game/states/Room.tsx @@ -3,6 +3,7 @@ import { Player } from "@rahoot/common/types/game" import { ManagerStatusDataMap } from "@rahoot/common/types/game/status" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" +import { useManagerStore } from "@rahoot/web/stores/manager" import { useState } from "react" type Props = { @@ -11,7 +12,8 @@ type Props = { export default function Room({ data: { text, inviteCode } }: Props) { const { socket } = useSocket() - const [playerList, setPlayerList] = useState([]) + const { players } = useManagerStore() + const [playerList, setPlayerList] = useState(players) const [totalPlayers, setTotalPlayers] = useState(0) useEvent("manager:newPlayer", (player) => { diff --git a/packages/web/src/contexts/socketProvider.tsx b/packages/web/src/contexts/socketProvider.tsx index 24d8a6e..7451e48 100644 --- a/packages/web/src/contexts/socketProvider.tsx +++ b/packages/web/src/contexts/socketProvider.tsx @@ -1,5 +1,6 @@ /* eslint-disable no-empty-function */ "use client" + import { ClientToServerEvents, ServerToClientEvents, @@ -13,12 +14,14 @@ import React, { useState, } from "react" import { io, Socket } from "socket.io-client" +import { v7 as uuid } from "uuid" type TypedSocket = Socket interface SocketContextValue { socket: TypedSocket | null isConnected: boolean + clientId: string connect: () => void disconnect: () => void reconnect: () => void @@ -27,6 +30,7 @@ interface SocketContextValue { const SocketContext = createContext({ socket: null, isConnected: false, + clientId: "", connect: () => {}, disconnect: () => {}, reconnect: () => {}, @@ -38,11 +42,33 @@ const getSocketServer = async () => { return res.url } +const getClientId = (): string => { + try { + const stored = localStorage.getItem("client_id") + + if (stored) { + return stored + } + + const newId = uuid() + localStorage.setItem("client_id", newId) + + return newId + } catch { + return uuid() + } +} + export const SocketProvider = ({ children }: { children: React.ReactNode }) => { const [socket, setSocket] = useState(null) const [isConnected, setIsConnected] = useState(false) + const [clientId] = useState(() => getClientId()) useEffect(() => { + if (socket) { + return + } + let s: TypedSocket | null = null const initSocket = async () => { @@ -52,6 +78,9 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => { s = io(socketUrl, { transports: ["websocket"], autoConnect: false, + auth: { + clientId, + }, }) setSocket(s) @@ -61,7 +90,6 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => { }) s.on("disconnect", () => { - console.log("Socket disconnected") setIsConnected(false) }) @@ -75,28 +103,26 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => { initSocket() + // eslint-disable-next-line consistent-return return () => { s?.disconnect() } - }, []) + }, [clientId]) const connect = useCallback(() => { if (socket && !socket.connected) { - console.log("🔌 Manual connect") socket.connect() } }, [socket]) const disconnect = useCallback(() => { if (socket && socket.connected) { - console.log("🧹 Manual disconnect") socket.disconnect() } }, [socket]) const reconnect = useCallback(() => { if (socket) { - console.log("♻️ Manual reconnect") socket.disconnect() socket.connect() } @@ -107,6 +133,7 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => { value={{ socket, isConnected, + clientId, connect, disconnect, reconnect, diff --git a/packages/web/src/stores/game.tsx b/packages/web/src/stores/game.tsx deleted file mode 100644 index 2d22aac..0000000 --- a/packages/web/src/stores/game.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { StatusDataMap } from "@rahoot/common/types/game/status" -import { create } from "zustand" - -export type Status = { - [K in keyof T]: { name: K; data: T[K] } -}[keyof T] - -export function createStatus( - name: K, - data: T[K], -): Status { - return { name, data } -} - -type GameStore = { - status: Status - // eslint-disable-next-line no-unused-vars - setStatus: (name: K, data: T[K]) => void - resetStatus: () => void -} - -export const usePlayerGameStore = create>((set) => { - const initialStatus = createStatus("WAIT", { - text: "Waiting for the players", - }) - - return { - status: initialStatus, - setStatus: (name, data) => set({ status: createStatus(name, data) }), - resetStatus: () => set({ status: initialStatus }), - } -}) - -export const useManagerGameStore = create>((set) => { - const initialStatus = createStatus("SHOW_ROOM", { - text: "Waiting for the players", - }) - - return { - status: initialStatus, - setStatus: (name, data) => set({ status: createStatus(name, data) }), - resetStatus: () => set({ status: initialStatus }), - } -}) diff --git a/packages/web/src/stores/manager.tsx b/packages/web/src/stores/manager.tsx new file mode 100644 index 0000000..0699dbd --- /dev/null +++ b/packages/web/src/stores/manager.tsx @@ -0,0 +1,35 @@ +/* eslint-disable no-unused-vars */ +import { Player } from "@rahoot/common/types/game" +import { StatusDataMap } from "@rahoot/common/types/game/status" +import { createStatus, Status } from "@rahoot/web/utils/createStatus" +import { create } from "zustand" + +type ManagerStore = { + gameId: string | null + status: Status + players: Player[] + + setGameId: (gameId: string | null) => void + + setStatus: (name: K, data: T[K]) => void + resetStatus: () => void + + setPlayers: (players: Player[]) => void +} + +const initialStatus = createStatus("SHOW_ROOM", { + text: "Waiting for the players", +}) + +export const useManagerStore = create>((set) => ({ + gameId: null, + status: initialStatus, + players: [], + + setGameId: (gameId) => set({ gameId }), + + setStatus: (name, data) => set({ status: createStatus(name, data) }), + resetStatus: () => set({ status: initialStatus }), + + setPlayers: (players) => set({ players }), +})) diff --git a/packages/web/src/stores/player.tsx b/packages/web/src/stores/player.tsx index 9f9b046..2517947 100644 --- a/packages/web/src/stores/player.tsx +++ b/packages/web/src/stores/player.tsx @@ -1,32 +1,54 @@ /* eslint-disable no-unused-vars */ +import { StatusDataMap } from "@rahoot/common/types/game/status" +import { createStatus, Status } from "@rahoot/web/utils/createStatus" import { create } from "zustand" type PlayerState = { - gameId?: string username?: string points?: number } -type PlayerStore = { +type PlayerStore = { + gameId: string | null player: PlayerState | null + status: Status + + setGameId: (gameId: string | null) => void + + setPlayer: (state: PlayerState) => void login: (gameId: string) => void join: (username: string) => void updatePoints: (points: number) => void logout: () => void + + setStatus: (name: K, data: T[K]) => void + resetStatus: () => void } -export const usePlayerStore = create((set) => ({ - player: null, +const initialStatus = createStatus("WAIT", { + text: "Waiting for the players", +}) +export const usePlayerStore = create>((set) => ({ + gameId: null, + player: null, + status: initialStatus, + currentQuestion: null, + + setGameId: (gameId) => set({ gameId }), + + setPlayer: (player: PlayerState) => set({ player }), login: (username) => set((state) => ({ player: { ...state.player, username }, })), - join: (gameId) => + join: (gameId) => { set((state) => ({ - player: { ...state.player, gameId, points: 0 }, - })), + gameId, + player: { ...state.player, points: 0 }, + })) + }, updatePoints: (points) => set((state) => ({ @@ -34,4 +56,7 @@ export const usePlayerStore = create((set) => ({ })), logout: () => set({ player: null }), + + setStatus: (name, data) => set({ status: createStatus(name, data) }), + resetStatus: () => set({ status: initialStatus }), })) diff --git a/packages/web/src/stores/question.tsx b/packages/web/src/stores/question.tsx new file mode 100644 index 0000000..6180ee3 --- /dev/null +++ b/packages/web/src/stores/question.tsx @@ -0,0 +1,14 @@ +/* eslint-disable no-unused-vars */ +import { GameUpdateQuestion } from "@rahoot/common/types/game" +import { create } from "zustand" + +type QuestionStore = { + questionStates: GameUpdateQuestion | null + setQuestionStates: (state: GameUpdateQuestion) => void +} + +export const useQuestionStore = create((set) => ({ + questionStates: null, + setQuestionStates: (state: GameUpdateQuestion) => + set({ questionStates: state }), +})) diff --git a/packages/web/src/utils/createStatus.ts b/packages/web/src/utils/createStatus.ts new file mode 100644 index 0000000..8c9dba4 --- /dev/null +++ b/packages/web/src/utils/createStatus.ts @@ -0,0 +1,8 @@ +export type Status = { + [K in keyof T]: { name: K; data: T[K] } +}[keyof T] + +export const createStatus = ( + name: K, + data: T[K], +): Status => ({ name, data }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6804027..4cb3b04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^10.0.0 version: 10.0.0 typescript: - specifier: ^5.0.0 - version: 5.9.2 + specifier: ^5.9.3 + version: 5.9.3 packages/common: dependencies: @@ -38,7 +38,7 @@ importers: version: link:../common '@t3-oss/env-core': specifier: ^0.13.8 - version: 0.13.8(typescript@5.9.2)(zod@4.1.11) + version: 0.13.8(typescript@5.9.3)(zod@4.1.11) socket.io: specifier: ^4.8.1 version: 4.8.1 @@ -94,6 +94,9 @@ importers: use-sound: specifier: ^5.0.0 version: 5.0.0(react@19.1.0) + uuid: + specifier: ^13.0.0 + version: 13.0.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -2360,6 +2363,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -2906,16 +2914,16 @@ snapshots: dependencies: tslib: 2.8.1 - '@t3-oss/env-core@0.13.8(typescript@5.9.2)(zod@4.1.11)': - optionalDependencies: - typescript: 5.9.2 - zod: 4.1.11 - '@t3-oss/env-core@0.13.8(typescript@5.9.2)(zod@4.1.12)': optionalDependencies: typescript: 5.9.2 zod: 4.1.12 + '@t3-oss/env-core@0.13.8(typescript@5.9.3)(zod@4.1.11)': + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.11 + '@t3-oss/env-nextjs@0.13.8(typescript@5.9.2)(zod@4.1.12)': dependencies: '@t3-oss/env-core': 0.13.8(typescript@5.9.2)(zod@4.1.12) @@ -4779,6 +4787,8 @@ snapshots: typescript@5.9.2: {} + typescript@5.9.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4