diff --git a/backend/src/config/socket-server.ts b/backend/src/config/socket-server.ts index 4f88309..efe6048 100644 --- a/backend/src/config/socket-server.ts +++ b/backend/src/config/socket-server.ts @@ -8,158 +8,160 @@ let ioServer: Server | undefined; export const roomManager = new RoomManager(); export function initializeSocket(server: HTTPServer) { - const io = new Server(server, { - cors: { - origin: process.env.CORS_ORIGIN || "*", - methods: ["GET", "POST"], - }, - }); - - ioServer = io; - - roomManager.clearAllRooms(); - - io.on("connection", (socket) => { - const userId = (socket.handshake.query.userId as string) || (socket.handshake.headers["user-id"] as string); - - if (userId) { - userSocketMap.set(userId, socket.id); - } + const io = new Server(server, { + cors: { + origin: process.env.CORS_ORIGIN || "*", + methods: ["GET", "POST"], + }, + }); - logger.info(`A user connected ${socket.id}`); + ioServer = io; - socket.emit("rooms:list", roomManager.listRooms()); + //roomManager.clearAllRooms(); - socket.on("room:create", (data: { roomName?: string }) => { - try { - const room = roomManager.createRoom(socket.id, data?.roomName); - socket.join(room.id); + io.on("connection", (socket) => { + const userId = + (socket.handshake.query.userId as string) || + (socket.handshake.headers["user-id"] as string); - socket.emit("room:created", { - success: true, - room: roomManager.getRoomInfo(room.id), - }); + if (userId) { + userSocketMap.set(userId, socket.id); + } - logger.info(`Created room ${room.id} for user ${socket.id}`); - io.emit("rooms:list", roomManager.listRooms()); - } catch (err) { - logger.error("Error creating room", { error: err }); - socket.emit("room:error", { message: "Failed to create room" }); - } - }); + logger.info(`A user connected ${socket.id}`); - socket.on("room:join", (data: { roomId: string }) => { - try { - const success = roomManager.joinRoom(data.roomId, socket.id); - console.log("joinRoom result:", data, success); + socket.emit("rooms:list", roomManager.listRooms()); - if (!success) { - socket.emit("room:error", { message: "Room not found" }); - return; - } + socket.on("room:create", (data: { roomName?: string }) => { + try { + const room = roomManager.createRoom(socket.id, data?.roomName); + socket.join(room.id); - socket.join(data.roomId); + socket.emit("room:created", { + success: true, + room: roomManager.getRoomInfo(room.id), + }); - socket.emit("room:joined", { - success: true, - room: roomManager.getRoomInfo(data.roomId), + logger.info(`Created room ${room.id} for user ${socket.id}`); + io.emit("rooms:list", roomManager.listRooms()); + } catch (err) { + logger.error("Error creating room", { error: err }); + socket.emit("room:error", { message: "Failed to create room" }); + } }); - // Notify others in the room - socket.to(data.roomId).emit("player:joined", { - playerId: socket.id, - room: roomManager.getRoomInfo(data.roomId), + socket.on("room:join", (data: { roomId: string }) => { + try { + const success = roomManager.joinRoom(data.roomId, socket.id); + console.log("joinRoom result:", data, success); + + if (!success) { + socket.emit("room:error", { message: "Room not found" }); + return; + } + + socket.join(data.roomId); + + socket.emit("room:joined", { + success: true, + room: roomManager.getRoomInfo(data.roomId), + }); + + // Notify others in the room + socket.to(data.roomId).emit("player:joined", { + playerId: socket.id, + room: roomManager.getRoomInfo(data.roomId), + }); + + // Broadcast updated room list + io.emit("rooms:list", roomManager.listRooms()); + } catch (err) { + logger.error("Error joining room", { error: err }); + socket.emit("room:error", { message: "Failed to join room" }); + } }); - // Broadcast updated room list - io.emit("rooms:list", roomManager.listRooms()); - } catch (err) { - logger.error("Error joining room", { error: err }); - socket.emit("room:error", { message: "Failed to join room" }); - } - }); - - socket.on("room:leave", (data: { roomId: string }) => { - try { - roomManager.leaveRoom(data.roomId, socket.id); - socket.leave(data.roomId); - - socket.emit("room:left", { success: true }); - - // Notify others in the room - socket.to(data.roomId).emit("player:left", { - playerId: socket.id, - room: roomManager.getRoomInfo(data.roomId), + socket.on("room:leave", (data: { roomId: string }) => { + try { + roomManager.leaveRoom(data.roomId, socket.id); + socket.leave(data.roomId); + + socket.emit("room:left", { success: true }); + + // Notify others in the room + socket.to(data.roomId).emit("player:left", { + playerId: socket.id, + room: roomManager.getRoomInfo(data.roomId), + }); + + // Broadcast updated room list + io.emit("rooms:list", roomManager.listRooms()); + } catch (err) { + logger.error("Error leaving room", { error: err }); + socket.emit("room:error", { message: "Failed to leave room" }); + } }); - // Broadcast updated room list - io.emit("rooms:list", roomManager.listRooms()); - } catch (err) { - logger.error("Error leaving room", { error: err }); - socket.emit("room:error", { message: "Failed to leave room" }); - } - }); + socket.on("rooms:get", () => { + socket.emit("rooms:list", roomManager.listRooms()); + }); - socket.on("rooms:get", () => { - socket.emit("rooms:list", roomManager.listRooms()); - }); + socket.on("room:get", (data: { roomId: string }) => { + const roomInfo = roomManager.getRoomInfo(data.roomId); + if (roomInfo) { + socket.emit("room:info", roomInfo); + } else { + socket.emit("room:error", { message: "Room not found" }); + } + }); - socket.on("room:get", (data: { roomId: string }) => { - const roomInfo = roomManager.getRoomInfo(data.roomId); - if (roomInfo) { - socket.emit("room:info", roomInfo); - } else { - socket.emit("room:error", { message: "Room not found" }); - } - }); + // CHAT MESSAGES - // CHAT MESSAGES + socket.on("chat message", (msg) => { + try { + logger.info("Message received from backend", { message: msg }); + io.emit("chat message", msg); + } catch (err) { + logger.error("Error handling chat message", { error: err }); + } + }); - socket.on("chat message", (msg) => { - try { - logger.info("Message received from backend", { message: msg }); - io.emit("chat message", msg); - } catch (err) { - logger.error("Error handling chat message", { error: err }); - } - }); + // DISCONNECT HANDLING - // DISCONNECT HANDLING + socket.on("disconnect", (reason) => { + logger.info(`User ${socket.id} disconnected`, { reason }); - socket.on("disconnect", (reason) => { - logger.info(`User ${socket.id} disconnected`, { reason }); + // Find and leave any room the player was in + const playerRoom = roomManager.getPlayerRoom(socket.id); + if (playerRoom) { + roomManager.leaveRoom(playerRoom.id, socket.id); - // Find and leave any room the player was in - const playerRoom = roomManager.getPlayerRoom(socket.id); - if (playerRoom) { - roomManager.leaveRoom(playerRoom.id, socket.id); + // Notify others in the room + socket.to(playerRoom.id).emit("player:left", { + playerId: socket.id, + room: roomManager.getRoomInfo(playerRoom.id), + }); - // Notify others in the room - socket.to(playerRoom.id).emit("player:left", { - playerId: socket.id, - room: roomManager.getRoomInfo(playerRoom.id), + // Broadcast updated room list + io.emit("rooms:list", roomManager.listRooms()); + } }); - // Broadcast updated room list - io.emit("rooms:list", roomManager.listRooms()); - } + socket.on("error", (err) => { + logger.error("Socket Error", { error: err }); + }); }); - socket.on("error", (err) => { - logger.error("Socket Error", { error: err }); + io.on("connect_error", (err) => { + logger.error("Global socket connection error", { error: err }); }); - }); - - io.on("connect_error", (err) => { - logger.error("Global socket connection error", { error: err }); - }); - return io; + return io; } export const getIO = (): Server => { - if (!ioServer) { - throw new Error("Socket.io not initialized! Call initSocket first."); - } - return ioServer; + if (!ioServer) { + throw new Error("Socket.io not initialized! Call initSocket first."); + } + return ioServer; }; diff --git a/frontend/TASK.md b/frontend/TASK.md index 130628d..8320799 100644 --- a/frontend/TASK.md +++ b/frontend/TASK.md @@ -120,19 +120,19 @@ This document contains step-by-step tasks for implementing the frontend UI for C - [x] Create choice selection interface - [x] Design choice buttons (rock/paper/scissors) -- [ ] Add choice animation -- [ ] Create result display -- [ ] Show round history -- [ ] Design score tracker +- [x] Add choice animation +- [x] Create result display +- [x] Show round history +- [x] Design score tracker ### TASK-F302: Rock Paper Scissors Game Page - [x] Create game page route -- [ ] Implement choice selection +- [x] Implement choice selection - [ ] Add countdown timer -- [ ] Display both players' choices -- [ ] Show round winner -- [ ] Track best-of-N series +- [x] Display both players' choices +- [x] Show round winner +- [x] Track best-of-N series ### TASK-F303: Rock Paper Scissors Multiplayer diff --git a/frontend/src/app/rock-paper-scissors/page.tsx b/frontend/src/app/rock-paper-scissors/page.tsx index 60d4778..24271ec 100644 --- a/frontend/src/app/rock-paper-scissors/page.tsx +++ b/frontend/src/app/rock-paper-scissors/page.tsx @@ -1,15 +1,88 @@ +"use client"; + +import { useRpsStore } from "@/store/useRpsStore"; import ChoiceSection from "@/components/rock-paper-scissors/ChoiceSection"; import ResultDisplay from "@/components/rock-paper-scissors/ResultDisplay"; import ScoreBoard from "@/components/rock-paper-scissors/ScoreBoard"; export default function Page() { + const game = useRpsStore(); + + if (game.phase === "idle") { + return ( +
+

+ Rock Paper Scissors +

+

+ Choose a mode +

+ +
+ + + +
+
+ ); + } + return (
-

Rock Paper Scissors

+
+

+ Rock Paper Scissors +

+ + {game.mode === "online" ? "🌐 Online" : "πŸ€– vs CPU"} + +
+ + + + - - - +
); } diff --git a/frontend/src/components/rock-paper-scissors/ChoiceSection.tsx b/frontend/src/components/rock-paper-scissors/ChoiceSection.tsx index ace42b2..ad093d4 100644 --- a/frontend/src/components/rock-paper-scissors/ChoiceSection.tsx +++ b/frontend/src/components/rock-paper-scissors/ChoiceSection.tsx @@ -1,50 +1,92 @@ "use client"; -import { useState } from "react"; import { FaRegHandRock, FaRegHandPaper, FaRegHandScissors, } from "react-icons/fa"; import ChoiceButton from "./ChoiceButton"; +import { Choice, GamePhase } from "@/types/rock-paper-scissor"; -export default function ChoiceSection() { - const [selected, setSelected] = useState(null); +const CHOICES: { id: Choice; icon: React.ReactNode; label: string }[] = [ + { + id: "rock", + icon: , + label: "Rock", + }, + { + id: "paper", + icon: , + label: "Paper", + }, + { + id: "scissors", + icon: , + label: "Scissors", + }, +]; - return ( -
- - } - label="Rock" - selected={selected === "rock"} - onClick={() => setSelected(selected === "rock" ? null : "rock")} - /> +type Props = { + phase: GamePhase; + playerChoice: Choice | null; + onChoice: (choice: Choice) => void; +}; - - } - label="Paper" - selected={selected === "paper"} - onClick={() => - setSelected(selected === "paper" ? null : "paper") - } - /> +export default function ChoiceSection({ + phase, + playerChoice, + onChoice, +}: Props) { + const isLocked = phase !== "choosing"; + const isRevealing = phase === "revealing"; - - } - label="Scissors" - selected={selected === "scissors"} - onClick={() => - setSelected(selected === "scissors" ? null : "scissors") + return ( +
+

+ Pick your move +

+
+ {CHOICES.map((choice) => { + const isSelected = playerChoice === choice.id; + return ( +
+ onChoice(choice.id)} + /> +
+ ); + })} +
+ +
); } diff --git a/frontend/src/components/rock-paper-scissors/ResultDisplay.tsx b/frontend/src/components/rock-paper-scissors/ResultDisplay.tsx index 5581c6f..dd8b936 100644 --- a/frontend/src/components/rock-paper-scissors/ResultDisplay.tsx +++ b/frontend/src/components/rock-paper-scissors/ResultDisplay.tsx @@ -1,3 +1,224 @@ -export default function ResultDisplay() { - return

Choose your move

; +"use client"; + +import { + FaRegHandRock, + FaRegHandPaper, + FaRegHandScissors, +} from "react-icons/fa"; +import { + Choice, + GamePhase, + Round, + RoundResult, +} from "@/types/rock-paper-scissor"; + +const ICONS: Record = { + rock: , + paper: , + scissors: , +}; + +const RESULT_STYLE: Record< + RoundResult, + { label: string; color: string; glow: string } +> = { + win: { + label: "You Win!", + color: "text-emerald-400", + glow: "drop-shadow(0 0 14px rgba(52,211,153,0.8))", + }, + lose: { + label: "You Lose", + color: "text-rose-400", + glow: "drop-shadow(0 0 14px rgba(251,113,133,0.8))", + }, + draw: { + label: "Draw!", + color: "text-amber-400", + glow: "drop-shadow(0 0 14px rgba(251,191,36,0.8))", + }, +}; + +type Props = { + phase: GamePhase; + currentRound: Round | null; + winnerId: "player" | "opponent" | null; + onNextRound: () => void; + onReset: () => void; + bestOf: number; + mode: "vs-cpu" | "online" | null; +}; + +export default function ResultDisplay({ + phase, + currentRound, + winnerId, + onNextRound, + onReset, + bestOf, + mode, +}: Props) { + // ── Waiting for opponent (online) ──────────────────────────────────── + if (phase === "waiting") { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+

+ Waiting for opponent… +

+ +
+ ); + } + + // ── CPU thinking ───────────────────────────────────────────────────── + if (phase === "revealing") { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+

+ {mode === "online" + ? "Waiting for opponent…" + : "CPU is choosing…"} +

+ +
+ ); + } + + // ── Idle / Choosing ────────────────────────────────────────────────── + if (phase === "idle" || phase === "choosing" || !currentRound) { + return ( +
+

+ Choose your move +

+
+ ); + } + + // ── Game over ──────────────────────────────────────────────────────── + if (phase === "game-over") { + const isPlayerWinner = winnerId === "player"; + return ( +
+

+ {isPlayerWinner + ? "πŸ† You win the match!" + : "πŸ’€ You lost the match"} +

+

+ Best of {bestOf} β€” match complete +

+ + +
+ ); + } + + // ── Round result ───────────────────────────────────────────────────── + const style = RESULT_STYLE[currentRound.result]; + + return ( +
+
+ {/* Player */} +
+ + You + +
+ {ICONS[currentRound.playerChoice]} +
+ + {currentRound.playerChoice} + +
+ + {/* Result badge */} + + {style.label} + + + {/* Opponent */} +
+ + {mode === "online" ? "Opponent" : "CPU"} + +
+ {ICONS[currentRound.opponentChoice]} +
+ + {currentRound.opponentChoice} + +
+
+ + + + +
+ ); } diff --git a/frontend/src/components/rock-paper-scissors/ScoreBoard.tsx b/frontend/src/components/rock-paper-scissors/ScoreBoard.tsx index db99a5e..2523917 100644 --- a/frontend/src/components/rock-paper-scissors/ScoreBoard.tsx +++ b/frontend/src/components/rock-paper-scissors/ScoreBoard.tsx @@ -1,8 +1,183 @@ -export default function ScoreBoard() { +"use client"; + +import React from "react"; +import { Round, Score, GameMode } from "@/types/rock-paper-scissor"; +import { + FaRegHandRock, + FaRegHandPaper, + FaRegHandScissors, +} from "react-icons/fa"; +import { IconType } from "react-icons"; + +const BADGE: Record = { + win: { + label: "W", + classes: "bg-emerald-500/20 text-emerald-400 border-emerald-500/40", + }, + lose: { + label: "L", + classes: "bg-rose-500/20 text-rose-400 border-rose-500/40", + }, + draw: { + label: "D", + classes: "bg-amber-500/20 text-amber-400 border-amber-500/40", + }, +}; + +const EMOJI: Record = { + rock: FaRegHandRock, + paper: FaRegHandPaper, + scissors: FaRegHandScissors, +}; + +type Props = { + score: Score; + history: Round[]; + mode: GameMode | null; + winsNeeded: number; + bestOf: number; + onReset: () => void; +}; + +export default function ScoreBoard({ + score, + history, + mode, + winsNeeded, + bestOf, + onReset, +}: Props) { + if (!mode) return null; + + const total = score.player + score.opponent; + const playerPct = + total === 0 ? 50 : Math.round((score.player / total) * 100); + const opponentLabel = mode === "online" ? "Opponent" : "CPU"; + return ( -
- Player: 0 - CPU: 0 +
+ {/* ── Score tracker ───────────────────────────────────── */} +
+
+
+

+ You +

+

+ {score.player} +

+

+ / {winsNeeded} to win +

+
+ +
+

+ Best of {bestOf} +

+

+ {history.length} round + {history.length !== 1 ? "s" : ""} +

+
+ +
+

+ {opponentLabel} +

+

+ {score.opponent} +

+

+ / {winsNeeded} to win +

+
+
+ + {/* Win-rate bar */} +
+
+
+
+ + You {playerPct}% + + + {opponentLabel} {100 - playerPct}% + +
+
+ + {/* ── Round history ───────────────────────────────────── */} + {history.length > 0 && ( +
+

+ Round History +

+
+ {history.map((round, i) => { + const badge = BADGE[round.result]; + return ( +
+
+ + {badge.label} + + + {React.createElement( + EMOJI[round.playerChoice], + )}{" "} + + {round.playerChoice} + + +
+ + + R{round.roundNumber} + + + + {React.createElement( + EMOJI[round.opponentChoice], + )} + + {round.opponentChoice} + {" "} + +
+ ); + })} +
+
+ )} + + {/* ── Reset ───────────────────────────────────────────── */} + {history.length > 0 && ( + + )} + +
); } diff --git a/frontend/src/store/useRpsStore.ts b/frontend/src/store/useRpsStore.ts new file mode 100644 index 0000000..1949205 --- /dev/null +++ b/frontend/src/store/useRpsStore.ts @@ -0,0 +1,188 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { + Choice, + RoundResult, + GameMode, + GamePhase, + Score, + Round, +} from "@/types/rock-paper-scissor"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const CHOICES: Choice[] = ["rock", "paper", "scissors"]; + +function randomChoice(): Choice { + return CHOICES[Math.floor(Math.random() * 3)]; +} + +function getResult(player: Choice, opponent: Choice): RoundResult { + if (player === opponent) return "draw"; + if ( + (player === "rock" && opponent === "scissors") || + (player === "paper" && opponent === "rock") || + (player === "scissors" && opponent === "paper") + ) + return "win"; + return "lose"; +} + +const BEST_OF = 5; +const WINS_NEEDED = Math.ceil(BEST_OF / 2); // 3 + +// ── Hook ─────────────────────────────────────────────────────────────────── + +export function useRpsStore() { + const [mode, setMode] = useState(null); + const [phase, setPhase] = useState("idle"); + const [score, setScore] = useState({ player: 0, opponent: 0 }); + const [history, setHistory] = useState([]); + const [currentRound, setCurrentRound] = useState(null); + const [roundNumber, setRoundNumber] = useState(1); + const [playerChoice, setPlayerChoice] = useState(null); + const [winnerId, setWinnerId] = useState<"player" | "opponent" | null>( + null, + ); + + const startVsCpu = useCallback(() => { + setMode("vs-cpu"); + setPhase("choosing"); + setScore({ player: 0, opponent: 0 }); + setHistory([]); + setCurrentRound(null); + setRoundNumber(1); + setPlayerChoice(null); + setWinnerId(null); + }, []); + + /** + * Online mode: call this to initialise β€” then wire up your + * real-time transport (Supabase, Socket.io, etc.) separately. + * When the opponent's choice arrives, call resolveOnlineRound(). + */ + const startOnline = useCallback(() => { + setMode("online"); + setPhase("waiting"); // waiting for opponent to connect + setScore({ player: 0, opponent: 0 }); + setHistory([]); + setCurrentRound(null); + setRoundNumber(1); + setPlayerChoice(null); + setWinnerId(null); + }, []); + + /** Called by your online transport once the opponent has joined */ + const opponentJoined = useCallback(() => { + setPhase("choosing"); + }, []); + + // ── Gameplay ───────────────────────────────────────────────────────── + + const submitChoice = useCallback( + (choice: Choice) => { + if (phase !== "choosing") return; + setPlayerChoice(choice); + setPhase("revealing"); + + if (mode === "vs-cpu") { + // Resolve locally after a short delay + setTimeout(() => { + const opponentChoice = randomChoice(); + // eslint-disable-next-line react-hooks/immutability + _resolveRound(choice, opponentChoice); + }, 900); + } + // online: wait for resolveOnlineRound() to be called externally + }, + [phase, mode], + ); // eslint-disable-line react-hooks/exhaustive-deps + + /** + * Call this from your online transport handler when the opponent's + * choice comes in (e.g. inside a Supabase channel.on() callback). + */ + const resolveOnlineRound = useCallback((opponentChoice: Choice) => { + setPlayerChoice((current) => { + if (current) _resolveRound(current, opponentChoice); + return current; + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Internal β€” shared by both modes + function _resolveRound(player: Choice, opponent: Choice) { + const result = getResult(player, opponent); + + const round: Round = { + roundNumber, + playerChoice: player, + opponentChoice: opponent, + result, + }; + + setCurrentRound(round); + setHistory((prev) => [round, ...prev].slice(0, 10)); + + setScore((prev) => { + const next = { + player: prev.player + (result === "win" ? 1 : 0), + opponent: prev.opponent + (result === "lose" ? 1 : 0), + }; + + if (next.player >= WINS_NEEDED) { + setWinnerId("player"); + setPhase("game-over"); + } else if (next.opponent >= WINS_NEEDED) { + setWinnerId("opponent"); + setPhase("game-over"); + } else { + setPhase("round-over"); + } + + return next; + }); + } + + const nextRound = useCallback(() => { + if (phase !== "round-over") return; + setCurrentRound(null); + setPlayerChoice(null); + setRoundNumber((n) => n + 1); + setPhase("choosing"); + }, [phase]); + + const reset = useCallback(() => { + setMode(null); + setPhase("idle"); + setScore({ player: 0, opponent: 0 }); + setHistory([]); + setCurrentRound(null); + setRoundNumber(1); + setPlayerChoice(null); + setWinnerId(null); + }, []); + + return { + // State + mode, + phase, + score, + history, + currentRound, + roundNumber, + playerChoice, + winnerId, + winsNeeded: WINS_NEEDED, + bestOf: BEST_OF, + + // Actions + startVsCpu, + startOnline, + opponentJoined, + submitChoice, + resolveOnlineRound, + nextRound, + reset, + }; +} diff --git a/frontend/src/types/rock-paper-scissor.ts b/frontend/src/types/rock-paper-scissor.ts new file mode 100644 index 0000000..772fcc1 --- /dev/null +++ b/frontend/src/types/rock-paper-scissor.ts @@ -0,0 +1,22 @@ +export type Choice = "rock" | "paper" | "scissors"; +export type RoundResult = "win" | "lose" | "draw"; +export type GameMode = "vs-cpu" | "online"; +export type GamePhase = + | "idle" + | "waiting" + | "choosing" + | "revealing" + | "round-over" + | "game-over"; + +export interface Round { + roundNumber: number; + playerChoice: Choice; + opponentChoice: Choice; + result: RoundResult; +} + +export interface Score { + player: number; + opponent: number; +}