diff --git a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx new file mode 100644 index 0000000..5e95588 --- /dev/null +++ b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx @@ -0,0 +1,114 @@ +import { useNavigate } from "@tanstack/react-router"; +import type { LobbyPlayer } from "@lila/shared"; + +type MultiplayerScoreScreenProps = { + players: LobbyPlayer[]; + winnerIds: string[]; + currentUserId: string; + lobbyCode: string; +}; + +export const MultiplayerScoreScreen = ({ + players, + winnerIds, + currentUserId, + lobbyCode, +}: MultiplayerScoreScreenProps) => { + const navigate = useNavigate(); + + const sortedPlayers = [...players].sort((a, b) => b.score - a.score); + + const isWinner = winnerIds.includes(currentUserId); + const isTie = winnerIds.length > 1; + + const winnerNames = winnerIds + .map((id) => players.find((p) => p.userId === id)?.user.name ?? id) + .join(" and "); + + return ( +
+
+ {/* Result header */} +
+

+ {isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"} +

+

+ {isTie ? `${winnerNames} tied` : `${winnerNames} wins!`} +

+
+ +
+ + {/* Score list */} +
+ {sortedPlayers.map((player, index) => { + const isCurrentUser = player.userId === currentUserId; + const isPlayerWinner = winnerIds.includes(player.userId); + return ( +
+
+ + {index + 1}. + + + {player.user.name} + {isCurrentUser && ( + + (you) + + )} + + {isPlayerWinner && ( + + 👑 + + )} +
+ + {player.score} pts + +
+ ); + })} +
+ +
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; diff --git a/apps/web/src/routes/multiplayer.tsx b/apps/web/src/routes/multiplayer.tsx index 9acb22f..cf25437 100644 --- a/apps/web/src/routes/multiplayer.tsx +++ b/apps/web/src/routes/multiplayer.tsx @@ -9,6 +9,7 @@ export const Route = createFileRoute("/multiplayer")({ if (!session) { throw redirect({ to: "/login" }); } + return { session }; }, }); diff --git a/apps/web/src/routes/multiplayer/game.$code.tsx b/apps/web/src/routes/multiplayer/game.$code.tsx index 2b46605..ebe602c 100644 --- a/apps/web/src/routes/multiplayer/game.$code.tsx +++ b/apps/web/src/routes/multiplayer/game.$code.tsx @@ -1,9 +1,205 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js"; +import { QuestionCard } from "../../components/game/QuestionCard.js"; +import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js"; +import { GameRouteSearchSchema } from "@lila/shared"; -export const Route = createFileRoute('/multiplayer/game/$code')({ - component: RouteComponent, -}) +import type { + WsGameQuestion, + WsGameAnswerResult, + WsGameFinished, + WsError, +} from "@lila/shared"; -function RouteComponent() { - return
Hello "/multiplayer/game/$code"!
+const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; + +export const Route = createFileRoute("/multiplayer/game/$code")({ + component: GamePage, + validateSearch: GameRouteSearchSchema, +}); + +function GamePage() { + const { code } = Route.useParams(); + const { lobbyId } = Route.useSearch(); + const { session } = Route.useRouteContext(); + const currentUserId = session.user.id; + const client = useWsClient(); + const connect = useWsConnect(); + + const [currentQuestion, setCurrentQuestion] = useState( + null, + ); + const [answerResult, setAnswerResult] = useState( + null, + ); + const [gameFinished, setGameFinished] = useState(null); + const [error, setError] = useState(null); + const [hasAnswered, setHasAnswered] = useState(false); + const isConnectedRef = useRef(false); + + const handleGameQuestion = useCallback((msg: WsGameQuestion) => { + setCurrentQuestion(msg); + setAnswerResult(null); + setHasAnswered(false); + setError(null); + }, []); + + const handleAnswerResult = useCallback((msg: WsGameAnswerResult) => { + setAnswerResult(msg); + }, []); + + const handleGameFinished = useCallback((msg: WsGameFinished) => { + setGameFinished(msg); + }, []); + + const handleWsError = useCallback((msg: WsError) => { + setError(msg.message); + }, []); + + useEffect(() => { + client.on("game:question", handleGameQuestion); + client.on("game:answer_result", handleAnswerResult); + client.on("game:finished", handleGameFinished); + client.on("error", handleWsError); + + if (!client.isConnected()) { + void connect(API_URL) + .then(() => { + client.send({ type: "game:ready", lobbyId }); + isConnectedRef.current = true; + }) + .catch((err) => { + console.error("Failed to connect to WebSocket:", err); + setError("Could not connect to server. Please try again."); + }); + } else { + client.send({ type: "game:ready", lobbyId }); + isConnectedRef.current = true; + } + + return () => { + client.off("game:question", handleGameQuestion); + client.off("game:answer_result", handleAnswerResult); + client.off("game:finished", handleGameFinished); + client.off("error", handleWsError); + }; + // stable deps: client, connect, lobbyId is a search param stable for + // this route. handlers wrapped in useCallback with stable deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleAnswer = useCallback( + (optionId: number) => { + if (hasAnswered || !currentQuestion) return; + setHasAnswered(true); + client.send({ + type: "game:answer", + lobbyId, + questionId: currentQuestion.question.questionId, + selectedOptionId: optionId, + }); + }, + [hasAnswered, currentQuestion, client, lobbyId], + ); + + // Phase: finished + if (gameFinished) { + return ( + + ); + } + + // Phase: loading + if (!currentQuestion) { + return ( +
+

+ {error ?? "Loading game..."} +

+
+ ); + } + + // Phase: playing + return ( +
+
+ {/* Progress */} +

+ Question {currentQuestion.questionNumber} of{" "} + {currentQuestion.totalQuestions} +

+ + {/* Question */} + r.userId === currentUserId) + ?.isCorrect ?? false, + correctOptionId: answerResult.correctOptionId, + selectedOptionId: + answerResult.results.find((r) => r.userId === currentUserId) + ?.selectedOptionId ?? 0, + } + : null + } + onAnswer={handleAnswer} + onNext={() => { + setAnswerResult(null); + }} + /> + + {/* Error */} + {error &&

{error}

} + + {/* Round results */} + {answerResult && ( +
+

+ Round results +

+ {answerResult.players.map((player) => { + const result = answerResult.results.find( + (r) => r.userId === player.userId, + ); + return ( +
+ {player.user.name} + + {result?.selectedOptionId === null + ? "Timed out" + : result?.isCorrect + ? "✓ Correct" + : "✗ Wrong"} + + {player.score} pts +
+ ); + })} +
+ )} +
+
+ ); } diff --git a/apps/web/src/routes/multiplayer/lobby.$code.tsx b/apps/web/src/routes/multiplayer/lobby.$code.tsx index dcebfa2..57bb098 100644 --- a/apps/web/src/routes/multiplayer/lobby.$code.tsx +++ b/apps/web/src/routes/multiplayer/lobby.$code.tsx @@ -1,11 +1,16 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useWsClient, useWsConnect, useWsDisconnect, } from "../../lib/ws-hooks.js"; -import type { Lobby, WsLobbyState, WsError } from "@lila/shared"; +import type { + Lobby, + WsLobbyState, + WsError, + WsGameQuestion, +} from "@lila/shared"; const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; @@ -26,14 +31,24 @@ function LobbyPage() { const [error, setError] = useState(null); const [isStarting, setIsStarting] = useState(false); + const lobbyIdRef = useRef(null); + const handleLobbyState = useCallback((msg: WsLobbyState) => { setLobby(msg.lobby); + lobbyIdRef.current = msg.lobby.id; setError(null); }, []); - const handleGameQuestion = useCallback(() => { - void navigate({ to: "/multiplayer/game/$code", params: { code } }); - }, [navigate, code]); + const handleGameQuestion = useCallback( + (_msg: WsGameQuestion) => { + void navigate({ + to: "/multiplayer/game/$code", + params: { code }, + search: { lobbyId: lobbyIdRef.current ?? "" }, + }); + }, + [navigate, code], + ); const handleWsError = useCallback((msg: WsError) => { setError(msg.message); diff --git a/packages/shared/src/schemas/lobby.ts b/packages/shared/src/schemas/lobby.ts index 7b7b7d5..c9687c4 100644 --- a/packages/shared/src/schemas/lobby.ts +++ b/packages/shared/src/schemas/lobby.ts @@ -27,6 +27,9 @@ export const JoinLobbyResponseSchema = LobbySchema; export type JoinLobbyResponse = z.infer; +export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() }); +export type GameRouteSearch = z.infer; + // ---------------------------------------------------------------------------- // WebSocket: Client → Server // ----------------------------------------------------------------------------