diff --git a/SerpentRace_Frontend/package-lock.json b/SerpentRace_Frontend/package-lock.json index 02104eac..02cae938 100644 --- a/SerpentRace_Frontend/package-lock.json +++ b/SerpentRace_Frontend/package-lock.json @@ -16,6 +16,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.6.0", "react-toastify": "^11.0.5", + "socket.io-client": "^4.8.1", "tailwindcss": "^4.1.7" }, "devDependencies": { @@ -1259,6 +1260,11 @@ "win32" ] }, + "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==" + }, "node_modules/@tailwindcss/node": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", @@ -1957,6 +1963,42 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -3097,7 +3139,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3478,6 +3519,64 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3729,6 +3828,34 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/SerpentRace_Frontend/package.json b/SerpentRace_Frontend/package.json index d06aa2d6..96947923 100644 --- a/SerpentRace_Frontend/package.json +++ b/SerpentRace_Frontend/package.json @@ -18,6 +18,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.6.0", "react-toastify": "^11.0.5", + "socket.io-client": "^4.8.1", "tailwindcss": "^4.1.7" }, "devDependencies": { diff --git a/SerpentRace_Frontend/src/App.jsx b/SerpentRace_Frontend/src/App.jsx index f22180fa..561ad3c8 100644 --- a/SerpentRace_Frontend/src/App.jsx +++ b/SerpentRace_Frontend/src/App.jsx @@ -14,6 +14,7 @@ import CompanyHub from "./pages/Contacts/Contacts" import About from "./pages/About/About" import ScrollToTop from "./components/ScrollToTop" import GameScreen from "./pages/Game/GameScreen" +import GameTest from "./pages/Game/GameTest" import Reports from "./pages/Report/Reports" import Lobby from "./pages/Game/Lobby" import ProfileCard from "./components/Userdetails/Userdetails" @@ -68,6 +69,7 @@ function App() { } /> } /> } /> + } /> {/* } /> */} } /> } /> diff --git a/SerpentRace_Frontend/src/api/gameApi.js b/SerpentRace_Frontend/src/api/gameApi.js new file mode 100644 index 00000000..7a512847 --- /dev/null +++ b/SerpentRace_Frontend/src/api/gameApi.js @@ -0,0 +1,80 @@ +import { apiClient } from './userApi'; + +/** + * Create a new game + * @param {Object} gameData - Game creation data + * @param {string[]} gameData.deckids - Array of deck UUIDs + * @param {number} gameData.maxplayers - Maximum players (2-8) + * @param {number} gameData.logintype - 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION + * @returns {Promise} Game data with gameCode + */ +export const createGame = async (gameData) => { + try { + const response = await apiClient.post('/games/start', gameData); + return response.data; + } catch (error) { + console.error('Error creating game:', error); + throw error; + } +}; + +/** + * Join an existing game + * @param {Object} joinData - Join game data + * @param {string} joinData.gameCode - 6-character game code + * @param {string} [joinData.playerName] - Player name (required for public games) + * @returns {Promise} Game data with gameToken + */ +export const joinGame = async (joinData) => { + try { + const response = await apiClient.post('/games/join', joinData); + return response.data; + } catch (error) { + console.error('Error joining game:', error); + console.error('Join game error response:', error.response?.data); + throw error; + } +}; + +/** + * Start the game (gamemaster only) + * @param {string} gameId - Game UUID + * @returns {Promise} Game data with board + */ +export const startGame = async (gameId) => { + try { + const response = await apiClient.post(`/games/${gameId}/start`); + return response.data; + } catch (error) { + console.error('Error starting game:', error); + throw error; + } +}; + +/** + * Get user's games + * @returns {Promise} Array of games + */ +export const getMyGames = async () => { + try { + const response = await apiClient.get('/games/my-games'); + return response.data; + } catch (error) { + console.error('Error fetching games:', error); + throw error; + } +}; + +/** + * Get active public games + * @returns {Promise} Array of active games + */ +export const getActiveGames = async () => { + try { + const response = await apiClient.get('/games/active'); + return response.data; + } catch (error) { + console.error('Error fetching active games:', error); + throw error; + } +}; diff --git a/SerpentRace_Frontend/src/hooks/useGameWebSocket.js b/SerpentRace_Frontend/src/hooks/useGameWebSocket.js new file mode 100644 index 00000000..2c5a0cea --- /dev/null +++ b/SerpentRace_Frontend/src/hooks/useGameWebSocket.js @@ -0,0 +1,268 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { io } from 'socket.io-client'; +import { API_CONFIG } from '../api/userApi'; + +const isDev = import.meta.env.DEV; +const log = (...args) => isDev && console.log(...args); +const warn = (...args) => isDev && console.warn(...args); +const error = (...args) => console.error(...args); + +/** + * Optimized WebSocket hook for game connection + * @param {string} gameToken - JWT token from game join + * @returns {Object} WebSocket state and methods + */ +export const useGameWebSocket = (gameToken) => { + const socketRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [gameState, setGameState] = useState(null); + const [boardData, setBoardData] = useState(null); + const [error, setError] = useState(null); + const [isGamemaster, setIsGamemaster] = useState(false); + const [gameStarted, setGameStarted] = useState(false); + const eventListenersRef = useRef(new Map()); + + // Memoized derived values - no extra state needed + const players = useMemo(() => { + // Backend sends different player fields depending on game state + // connectedPlayers: array of player names (strings) who are connected via WebSocket + // players: full player objects with game data (positions, etc.) + const connectedPlayers = gameState?.connectedPlayers || []; + const gamePlayers = gameState?.players || []; + const currentPlayers = gameState?.currentPlayers || []; + + // If we have full player objects, use those + if (currentPlayers.length > 0) return currentPlayers; + if (gamePlayers.length > 0) return gamePlayers; + + // Otherwise, map connected player names to basic player objects + return connectedPlayers.map((name, index) => ({ + id: `player-${index}`, + name: typeof name === 'string' ? name : name.playerName || `Player ${index + 1}`, + isOnline: true, + isReady: gameState?.readyPlayers?.includes(name) || false, + })); + }, [gameState?.connectedPlayers, gameState?.players, gameState?.currentPlayers, gameState?.readyPlayers]); + const currentTurn = useMemo(() => gameState?.currentPlayer || null, [gameState?.currentPlayer]); + + // Connect to game WebSocket - only once per token + useEffect(() => { + if (!gameToken) return; + + log('🔌 Connecting to game WebSocket...'); + + // Connect to /game namespace + socketRef.current = io(`${API_CONFIG.wsURL}/game`, { + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + timeout: 5000, + }); + + const socket = socketRef.current; + + // Connection handlers + const handleConnect = () => { + log('✅ Connected to game WebSocket'); + setIsConnected(true); + setError(null); + socket.emit('game:join', { gameToken }); + }; + + const handleConnectError = (err) => { + error('❌ Connection error:', err); + setIsConnected(false); + setError(err.message); + }; + + const handleDisconnect = (reason) => { + log('🔌 Disconnected:', reason); + setIsConnected(false); + }; + + // Game state handlers - batch updates + const handleGameState = (state) => { + log('📊 Game state:', state); + setGameState(state); + }; + + const handleGameJoined = (data) => { + log('✅ Joined game:', data); + // Store if this user is the gamemaster + if (data.isGamemaster !== undefined) { + setIsGamemaster(data.isGamemaster); + } + // Backend will send game:state next + }; + + const handlePlayerJoined = (data) => { + log('đŸ‘€ Player joined:', data.playerName); + // Update game state to add the new player to connectedPlayers + setGameState(prev => { + if (!prev) return prev; + + const currentConnected = prev.connectedPlayers || []; + // Only add if not already in the list + if (!currentConnected.includes(data.playerName)) { + return { + ...prev, + connectedPlayers: [...currentConnected, data.playerName] + }; + } + return prev; + }); + }; + + const handleGameStarted = (data) => { + log('🎼 Game started:', data); + // Batch state updates + if (data.boardData) setBoardData(data.boardData); + if (data.gameState) setGameState(data.gameState); + // Signal that game has started + setGameStarted(true); + }; + + const handlePlayerMoved = (moveData) => { + log('🏃 Player moved:', moveData.playerName); + // Update only the moved player + setGameState(prev => { + if (!prev?.currentPlayers) return prev; + return { + ...prev, + currentPlayers: prev.currentPlayers.map(p => + p.playerId === moveData.playerId + ? { ...p, boardPosition: moveData.newPosition } + : p + ), + }; + }); + }; + + const handleTurnChanged = (data) => { + log('🔄 Turn changed to:', data.currentPlayerName); + setGameState(prev => prev ? { ...prev, currentPlayer: data.currentPlayer } : prev); + }; + + const handleError = (err) => { + error('❌ Game error:', err); + setError(err.message); + }; + + // Register all handlers + socket.on('connect', handleConnect); + socket.on('connect_error', handleConnectError); + socket.on('disconnect', handleDisconnect); + socket.on('game:state', handleGameState); + socket.on('game:state-update', handleGameState); + socket.on('game:joined', handleGameJoined); + socket.on('game:player-joined', handlePlayerJoined); + socket.on('game:started', handleGameStarted); + socket.on('game:player-moved', handlePlayerMoved); + socket.on('game:turn-changed', handleTurnChanged); + socket.on('game:error', handleError); + + // Cleanup + return () => { + log('đŸ§č Cleaning up WebSocket connection'); + socket.removeAllListeners(); + socket.disconnect(); + }; + }, [gameToken]); + + // Optimized event listener management + const addEventListener = useCallback((event, handler) => { + const socket = socketRef.current; + if (!socket) return; + + socket.on(event, handler); + eventListenersRef.current.set(event, handler); + }, []); + + const removeEventListener = useCallback((event) => { + const socket = socketRef.current; + if (!socket) return; + + const handler = eventListenersRef.current.get(event); + if (handler) { + socket.off(event, handler); + eventListenersRef.current.delete(event); + } + }, []); + + // Memoized action methods - stable references + const rollDice = useCallback((diceValue) => { + const socket = socketRef.current; + if (!socket || !isConnected) { + warn('⚠ Cannot roll dice: not connected'); + return false; + } + + log('đŸŽČ Rolling dice:', diceValue); + socket.emit('game:dice-roll', { + gameCode: gameState?.gameCode, + diceValue, + }); + return true; + }, [isConnected, gameState?.gameCode]); + + const sendMessage = useCallback((message) => { + const socket = socketRef.current; + if (!socket || !isConnected) { + warn('⚠ Cannot send message: not connected'); + return false; + } + + socket.emit('game:chat', { + gameCode: gameState?.gameCode, + message, + }); + return true; + }, [isConnected, gameState?.gameCode]); + + const setReady = useCallback((ready = true) => { + const socket = socketRef.current; + if (!socket || !isConnected) { + warn('⚠ Cannot set ready: not connected'); + return false; + } + + socket.emit('game:ready', { + gameCode: gameState?.gameCode, + ready, + }); + return true; + }, [isConnected, gameState?.gameCode]); + + const leaveGame = useCallback(() => { + const socket = socketRef.current; + if (!socket || !isConnected) { + warn('⚠ Cannot leave game: not connected'); + return false; + } + + socket.emit('game:leave', { + gameCode: gameState?.gameCode, + }); + return true; + }, [isConnected, gameState?.gameCode]); + + return { + socket: socketRef.current, + isConnected, + gameState, + players, + boardData, + currentTurn, + error, + isGamemaster, + gameStarted, + // Methods + rollDice, + sendMessage, + setReady, + leaveGame, + addEventListener, + removeEventListener, + }; +}; diff --git a/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx b/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx index 30038663..107ef68e 100644 --- a/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx +++ b/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx @@ -1,88 +1,189 @@ -import React, { useState } from "react" +import React, { useState, useEffect, useMemo, useCallback } from "react" import { getVerticalOffset } from "../../utils/randomUtils" import Dice from "../../utils/dice/Dice" +import { useGameWebSocket } from "../../hooks/useGameWebSocket" + +// Constants - outside component to prevent recreation +const PLAYER_STYLES = [ + { color: "bg-blue-600", emoji: "🐍" }, + { color: "bg-green-600", emoji: "🐱" }, + { color: "bg-purple-600", emoji: "🐇" }, + { color: "bg-yellow-600", emoji: "🩊" }, + { color: "bg-red-600", emoji: "🩁" }, + { color: "bg-pink-600", emoji: "đŸ·" }, + { color: "bg-orange-600", emoji: "🐯" }, + { color: "bg-indigo-600", emoji: "đŸș" }, +] + +const BOARD_CONFIG = { + rows: 5, + cols: 20, + cellSize: 40, + cellMargin: 2.5, + rowSpacing: 70, +} + +// Helper functions outside component +const mapFieldType = (backendType) => { + switch (backendType) { + case 'positive': return 'good' + case 'negative': return 'bad' + case 'luck': return 'clover' + default: return 'regular' + } +} + +const getDefaultFieldType = (count) => { + if (count % 17 === 0) return "clover" + if (count % 13 === 0) return "bad" + if ((count + 5) % 13 === 0) return "good" + return "regular" +} const GameScreen = () => { - const boardRows = 5 - const boardCols = 20 - const totalCells = boardRows * boardCols - const cellSize = 40 - const cellMargin = 2.5 - const rowSpacing = 70 // Extra spacing between rows - const topOffset = rowSpacing * 0.5 // Increase topOffset for more spacing - const bottomOffset = rowSpacing * 0.5 // Increase bottomOffset for more spacing - const boardWidthPx = boardCols * (cellSize + cellMargin * 2) - const boardHeightPx = - boardRows * (cellSize + cellMargin * 2 + rowSpacing) + topOffset + bottomOffset - rowSpacing + // WebSocket connection + const gameToken = localStorage.getItem('gameToken') + const { + isConnected, + gameState, + players: backendPlayers, + boardData, + currentTurn, + error, + rollDice, + addEventListener, + removeEventListener + } = useGameWebSocket(gameToken) + + const [path, setPath] = useState([]) + const [players, setPlayers] = useState([]) - // Generate a snake-like path with vertical spacing and vertical offsets - const generateWindingPath = () => { + // Memoized board dimensions + const { rows, cols, totalCells, cellSize, cellMargin, rowSpacing, topOffset, width, height } = useMemo(() => { + const { rows, cols, cellSize, cellMargin, rowSpacing } = BOARD_CONFIG + const topOffset = rowSpacing * 0.5 + const bottomOffset = rowSpacing * 0.5 + const totalCells = rows * cols + + return { + rows, + cols, + totalCells, + cellSize, + cellMargin, + rowSpacing, + topOffset, + bottomOffset, + width: cols * (cellSize + cellMargin * 2), + height: rows * (cellSize + cellMargin * 2 + rowSpacing) + topOffset + bottomOffset - rowSpacing, + } + }, []) + + // Memoized path generator - Snake pattern with proper turn handling + const generateWindingPath = useCallback((backendFields = null) => { const path = [] + const hasBackendData = backendFields && Array.isArray(backendFields) + let currentNum = 1 - - for (let row = 0; row < boardRows && currentNum <= totalCells; row++) { - // Calculate the y position with extra row spacing - const baseYPosition = topOffset + row * (cellSize + cellMargin * 2 + rowSpacing) - - // If row number is even, go right; if odd, go left - if (row % 2 === 0) { - // Left to right - for (let col = 0; col < boardCols && currentNum <= totalCells; col++) { - path.push({ - number: currentNum++, - x: col * (cellSize + cellMargin * 2), - y: baseYPosition + getVerticalOffset(currentNum - 1), - type: getFieldType(currentNum - 1), - }) - } - } else { - // Right to left - for (let col = boardCols - 1; col >= 0 && currentNum <= totalCells; col--) { - path.push({ - number: currentNum++, - x: col * (cellSize + cellMargin * 2), - y: baseYPosition + getVerticalOffset(currentNum - 1), - type: getFieldType(currentNum - 1), - }) - } + + // Generate all 100 positions + while (currentNum <= totalCells) { + const row = Math.floor((currentNum - 1) / cols) + const posInRow = (currentNum - 1) % cols + const isLeftToRight = row % 2 === 0 + + // Calculate column based on direction + const col = isLeftToRight ? posInRow : (cols - 1 - posInRow) + + // Base Y position for this row + let baseYPosition = topOffset + row * (cellSize + cellMargin * 2 + rowSpacing) + + // Apply vertical offset for wave effect + let yOffset = getVerticalOffset(currentNum - 1) + + // Special handling for turn positions (21, 41, 61, 81) + // These should be positioned between rows to show the turn + if (currentNum % cols === 1 && currentNum > 1) { + // This is the first element of a new row (21, 41, 61, 81) + // Position it halfway between the previous row and current row + baseYPosition = topOffset + (row - 0.5) * (cellSize + cellMargin * 2 + rowSpacing) + yOffset = 0 // Reset wave offset for turn positions } + + const backendField = hasBackendData ? backendFields.find(f => f.position === currentNum) : null + + path.push({ + number: currentNum, + x: col * (cellSize + cellMargin * 2), + y: baseYPosition + yOffset, + type: backendField ? mapFieldType(backendField.type) : getDefaultFieldType(currentNum - 1), + stepValue: backendField?.stepValue || 0, + }) + + currentNum++ } return path - } + }, [rows, cols, totalCells, cellSize, cellMargin, rowSpacing, topOffset]) - const getFieldType = (count) => { - if (count % 17 === 0) return "clover" - if (count % 13 === 0) return "bad" - if ((count + 5) % 13 === 0) return "good" - return "regular" - } + // Update path when boardData changes + useEffect(() => { + if (boardData?.fields) { + setPath(generateWindingPath(boardData.fields)) + } else if (path.length === 0) { + setPath(generateWindingPath()) + } + }, [boardData, generateWindingPath]) - const [path, setPath] = useState(generateWindingPath()) - const [players, setPlayers] = useState([ - { id: 1, name: "BĂ©la", position: 34, score: 25, color: "bg-blue-600", emoji: "🐍" }, - { id: 2, name: "Juci", position: 50, score: 30, color: "bg-green-600", emoji: "🐱" }, - { id: 3, name: "Kati", position: 70, score: 15, color: "bg-purple-600", emoji: "🐇" }, - { id: 3, name: "FĂŒrtös", position: 68, score: 14, color: "bg-yellow-600", emoji: "😂" }, - ]) + // Update players from backend - memoized mapping + useEffect(() => { + if (!backendPlayers?.length) return - // New: selected dice value from dropdown (null = none) - const [selectedDice, setSelectedDice] = useState(null) + const mappedPlayers = backendPlayers.map((player, index) => ({ + id: player.playerId || player.id || index, + name: player.playerName || player.name || `Player ${index + 1}`, + position: player.boardPosition || 0, + score: player.score || 0, + color: PLAYER_STYLES[index % PLAYER_STYLES.length].color, + emoji: PLAYER_STYLES[index % PLAYER_STYLES.length].emoji, + isOnline: player.isOnline !== undefined ? player.isOnline : true, + isReady: player.isReady || false, + })) + + setPlayers(mappedPlayers) + }, [backendPlayers]) - // Sort players by position in descending order - const sortedPlayers = [...players].sort((a, b) => b.position - a.position) + // Listen to player movement - optimized to update only moved player + useEffect(() => { + if (!addEventListener) return - // Handle dice roll completion - const handleDiceRoll = (value) => { - console.log("Rolled:", value) - // reset dropdown selection after roll - setSelectedDice(null) - // You can add logic here to move the current player based on the dice value - } + const handlePlayerMoved = (moveData) => { + setPlayers(prev => + prev.map(p => + p.id === moveData.playerId + ? { ...p, position: moveData.newPosition } + : p + ) + ) + } - console.log("Generated path length:", path.length) + addEventListener('game:player-moved', handlePlayerMoved) + return () => removeEventListener('game:player-moved') + }, [addEventListener, removeEventListener]) - const getFieldStyle = (type) => { + // Sorted players - memoized + const sortedPlayers = useMemo( + () => [...players].sort((a, b) => b.position - a.position), + [players] + ) + + // Handle dice roll + const handleDiceRoll = useCallback((value) => { + rollDice(value) + }, [rollDice]) + + // Get field style - memoized + const getFieldStyle = useCallback((type) => { switch (type) { case "clover": return "bg-teal-700 border-teal-500 shadow-teal-800" @@ -93,15 +194,16 @@ const GameScreen = () => { default: return "bg-gray-800 border-gray-600 shadow-gray-900" } - } + }, []) - const getPlayerPosition = (playerPosition) => { + // Get player position - memoized + const getPlayerPosition = useCallback((playerPosition) => { const field = path.find((p) => p.number === playerPosition) return field ? { top: `${field.y}px`, left: `${field.x}px` } : { top: 0, left: 0 } - } + }, [path]) - // Function to get medal style based on rank - const getMedalStyle = (rank) => { + // Get medal style - memoized + const getMedalStyle = useCallback((rank) => { switch (rank) { case 1: return "bg-yellow-400 text-yellow-900 border-yellow-500 shadow-yellow-600" @@ -112,20 +214,57 @@ const GameScreen = () => { default: return "bg-gray-700 text-gray-300 border-gray-600 shadow-gray-800" } - } + }, []) return (
+ {/* Connection Status Indicator */} +
+
+
+ + {isConnected ? '🟱 Csatlakozva' : '🔮 Kapcsolódás...'} + +
+ {error && ( +
+ ⚠ {error} +
+ )} +
+ + {/* Game Info Bar */} + {gameState && ( +
+
+
+ 🎼 JĂĄtĂ©k kĂłd: {gameState.gameCode || 'N/A'} +
+ {currentTurn && ( +
+ 🎯 Köron: {players.find(p => p.id === currentTurn)?.name || 'BetöltĂ©s...'} +
+ )} +
+
+ )} +
{/* Game Board */}
- {/* Håttér */} + {/* Background decoration */}
{[...Array(35)].map((_, i) => (
{ >
))}
-
- {/* MezƑk */} + +
+ {/* Fields */} {path.map((field) => (
{ className={`absolute w-6 h-6 ${player.color} rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold z-10 animate-bounce`} style={{ ...getPlayerPosition(player.position), - transform: "translate(18px, 18px)", + transform: "translate(17px, 17px)", }} > {player.emoji}
))}
- - {/* Game information */} - {/*
-

- Sima - LĂłhere - Rossz - JĂł -

-
*/}
{/* Right sidebar */}

Jåtékosok

+ + {/* Empty state */} + {players.length === 0 && ( +
+
đŸ‘„
+

Vårakozås jåtékosokra...

+
+ )} + + {/* Players list */} {sortedPlayers.map((player, index) => (
+ {/* Online indicator */} + {player.isOnline && ( +
+ )} +
{player.emoji}
-
+
{player.name} + + {/* Ready indicator */} + {player.isReady && ( + + ✓ KĂ©sz + + )} + + {/* Current turn indicator */} + {currentTurn === player.id && ( + + ▶ Köre + + )} + + {/* Rank medal */} @@ -225,31 +386,33 @@ const GameScreen = () => {

DobĂłkocka

- Kattints a kockĂĄra dobĂĄshoz vagy vĂĄlassz egy szĂĄmot az alĂĄbbibĂłl! + Kattints a kockĂĄra dobĂĄshoz!

- {/* Dropdown to select number 1-6 (triggers animated roll to that number) */} -
- -
- - + + + {/* Connection warning */} + {!isConnected && ( +
+ ⚠ Nincs kapcsolat a szerverrel +
+ )}
+ + {/* Debug Info Panel (Development only) */} + {import.meta.env.DEV && ( +
+

🔧 Debug Info

+
+
📡 Connected: {isConnected ? '✅' : '❌'}
+
🎼 Game Code: {gameState?.gameCode || 'N/A'}
+
đŸ‘„ Players: {backendPlayers?.length || 0}
+
đŸŽČ Board Fields: {boardData?.fields?.length || 0}
+
🏁 Current Turn: {currentTurn || 'N/A'}
+
🔑 Token: {gameToken ? '✅' : '❌'}
+
+
+ )}
diff --git a/SerpentRace_Frontend/src/pages/Game/GameTest.jsx b/SerpentRace_Frontend/src/pages/Game/GameTest.jsx new file mode 100644 index 00000000..7a743e28 --- /dev/null +++ b/SerpentRace_Frontend/src/pages/Game/GameTest.jsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { createGame, joinGame } from '../../api/gameApi'; + +const GameTest = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [gameCode, setGameCode] = useState(''); + const [createdGameCode, setCreatedGameCode] = useState(''); + const [showSuccess, setShowSuccess] = useState(false); + + const handleCreateGame = async () => { + setLoading(true); + setError(null); + setShowSuccess(false); + try { + const token = localStorage.getItem('token'); + if (!token) { + setError('Please login first at /login'); + return; + } + + const gameData = { + deckids: ['99333c9a-5928-4788-b852-fa482d34ce56'], // Test deck ID as array + maxplayers: 4, + logintype: 0, // 0=PUBLIC + }; + + const response = await createGame(gameData); + console.log('Game created:', response); + + // Backend returns game object directly + const code = response.gamecode || response.gameCode; + if (code) { + setCreatedGameCode(code); + setShowSuccess(true); + } + + // Store game token if provided + if (response.gameToken) { + localStorage.setItem('gameToken', response.gameToken); + } + + // Wait 3 seconds to show code, then navigate + setTimeout(() => { + navigate('/lobby', { state: { gameCode: code } }); + }, 3000); + } catch (err) { + setError(err.response?.data?.message || 'Failed to create game'); + console.error('Create game error:', err); + } finally { + setLoading(false); + } + }; + + const handleJoinGame = async () => { + setLoading(true); + setError(null); + try { + const token = localStorage.getItem('token'); + if (!token) { + setError('KĂ©rlek jelentkezz be elƑször a /login oldalon'); + return; + } + + const joinData = { + gameCode: gameCode.toUpperCase(), + playerName: localStorage.getItem('username') || 'Test Player', + }; + + const response = await joinGame(joinData); + console.log('Joined game:', response); + + // Store game token + if (response.data?.gameToken) { + localStorage.setItem('gameToken', response.data.gameToken); + navigate('/lobby', { state: { gameCode: gameCode.toUpperCase() } }); + } + } catch (err) { + setError(err.response?.data?.message || 'Nem sikerĂŒlt csatlakozni a jĂĄtĂ©khoz'); + console.error('Join game error:', err); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Game Test

+ + {error && ( +
+ {error} +
+ )} + + {showSuccess && createdGameCode && ( +
+

Game Created!

+

+ {createdGameCode} +

+

+ Share this code with other players so they can join! +

+

+ Redirecting to game in 3 seconds... +

+
+ )} + +
+ + +
OR
+ +
+ setGameCode(e.target.value)} + placeholder="Enter Game Code" + className="w-full bg-gray-700 text-white px-4 py-2 rounded mb-2" + /> + +
+
+ +
+

Quick Access (Dev Only):

+ +
+
+
+ ); +}; + +export default GameTest; diff --git a/SerpentRace_Frontend/src/pages/Game/Lobby.jsx b/SerpentRace_Frontend/src/pages/Game/Lobby.jsx index a1d6368c..fdc8df58 100644 --- a/SerpentRace_Frontend/src/pages/Game/Lobby.jsx +++ b/SerpentRace_Frontend/src/pages/Game/Lobby.jsx @@ -3,6 +3,8 @@ import { useNavigate, useLocation } from "react-router-dom" import Navbar from "../../components/Navbar/Navbar.jsx" import Background from "../../assets/backgrounds/Background.jsx" import useRequireAuth from "../../hooks/useRequireAuth.jsx" +import { useGameWebSocket } from "../../hooks/useGameWebSocket.js" +import { startGame } from "../../api/gameApi.js" const Lobby = () => { const [visible, setVisible] = useState(false) @@ -11,6 +13,30 @@ const Lobby = () => { const location = useLocation() const [user, setUser] = useRequireAuth() + + // Get game code from location state or WebSocket + const gameCodeFromState = location.state?.gameCode + const gameToken = localStorage.getItem('gameToken') + + const { + isConnected, + gameState, + players, + isGamemaster, + gameStarted, + } = useGameWebSocket(gameToken) + + const gameCode = gameCodeFromState || gameState?.gameCode || 'Loading...' + + // Filter out gamemaster from player list - gamemaster is NOT a player + const currentPlayers = (players || []).filter(p => { + // If we have userId info, filter by that + if (p.userId) { + return p.userId !== gameState?.createdBy + } + // Otherwise filter by name (less reliable but works for now) + return true + }) useEffect(() => { const observer = new IntersectionObserver( @@ -23,12 +49,48 @@ const Lobby = () => { return () => observer.disconnect() }, []) + // Auto-navigate when game starts + useEffect(() => { + if (gameStarted) { + console.log('🎼 Game started, navigating to /game') + navigate("/game") + } + }, [gameStarted, navigate]) + const handleExit = () => { if (window.confirm("Biztosan ki szeretnĂ©l lĂ©pni a lobbybĂłl?")) { + localStorage.removeItem('gameToken') navigate("/home") } } + const handleStartGame = async () => { + try { + // Get gameId from gameState + const gameId = gameState?.gameId + if (!gameId) { + alert('Hiba: JĂĄtĂ©k azonosĂ­tĂł nem talĂĄlhatĂł') + return + } + + console.log('Starting game with ID:', gameId) + const response = await startGame(gameId) + console.log('Game start response:', response) + + // Backend will broadcast game:started event to all players + // Navigate to game page + navigate("/game") + } catch (error) { + console.error('Failed to start game:', error) + alert(`Nem sikerĂŒlt elindĂ­tani a jĂĄtĂ©kot: ${error.response?.data?.error || error.message}`) + } + } + + const copyGameCode = () => { + navigator.clipboard.writeText(gameCode) + alert('JĂĄtĂ©k kĂłd vĂĄgĂłlapra mĂĄsolva: ' + gameCode) + } + const getInitials = (name) => { return name .split(" ") @@ -57,31 +119,121 @@ const Lobby = () => { style={{ background: "rgba(0,0,0,0.25)" }} >

- {user} Lobby-ja + Jåték Lobby

-

- Jåtékosok, akik csatlakoztak ehhez a szobåhoz: + {/* Game Code Display */} +

+

+ Jåték Kód: +

+
+

+ {gameCode} +

+ +
+

+ Oszd meg ezt a kódot måsokkal, hogy csatlakozhassanak a jåtékhoz! +

+
+ + {/* Connection Status */} +
+ + {isConnected ? '🟱 Kapcsolódva' : '🔮 Kapcsolat megszakadt'} + +
+ +

+ Jåtékosok ({currentPlayers.length}):

    -
  • -
    - {getInitials(user)} -
    - {user} -
  • + {currentPlayers.length === 0 ? ( +
  • + VĂĄrakozĂĄs jĂĄtĂ©kosokra... +
  • + ) : ( + currentPlayers.map((player, index) => ( +
  • +
    + {getInitials(player.name || `Player ${index + 1}`)} +
    + + {player.name || `Player ${index + 1}`} + + {player.isReady && ( + + KĂ©sz + + )} + {player.isOnline && ( + 🟱 + )} +
  • + )) + )}
-
+ {/* Role indicator */} +
+ {isGamemaster ? ( +
+

👑 Te vagy a Gamemaster!

+

Te nem jåtszol, csak indítod és moderålod a jåtékot.

+
+ ) : ( +
+

🎼 Te vagy egy JĂĄtĂ©kos!

+

Vårj, amíg a gamemaster elindítja a jåtékot.

+
+ )} +
+ +
+ {isGamemaster ? ( + /* Gamemaster view - can start game */ + + ) : ( + /* Player view - cannot start game, just wait */ +
+

VĂĄrakozĂĄs a gamemaster-re...

+

Csak a gamemaster indíthatja el a jåtékot

+
+ )} diff --git a/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx b/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx index 884b4516..c2c90e8d 100644 --- a/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx +++ b/SerpentRace_Frontend/src/pages/Game/PlayerSetup.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react" +import React, { useState, useEffect } from "react" import { useNavigate, useLocation } from "react-router-dom" import Navbar from "../../components/Navbar/Navbar.jsx" import Background from "../../assets/backgrounds/Background.jsx" @@ -6,6 +6,7 @@ import Footer from "../../components/Footer/Footer.jsx" import useRequireAuth from "../../hooks/useRequireAuth.jsx" import ButtonGreen from "../../components/Buttons/ButtonGreen.jsx" import { motion } from "framer-motion" +import { createGame, joinGame } from "../../api/gameApi.js" const GameLobbySetup = () => { const [username] = useRequireAuth({ key: "username", redirectTo: "/login" }) @@ -16,19 +17,82 @@ const GameLobbySetup = () => { const [maxPlayers, setMaxPlayers] = useState(4) const [isPublic, setIsPublic] = useState(true) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [createdGameCode, setCreatedGameCode] = useState('') + const [showSuccess, setShowSuccess] = useState(false) - const handleCreateLobby = () => { - console.log({ - deckIds, - maxPlayers, - isPublic, - }) - // Itt kĂŒldd el az API-nak a lobby lĂ©trehozĂĄsĂĄt - // navigate("/game-lobby", { state: { lobbyId: response.lobbyId } }) + const handleCreateLobby = async () => { + setLoading(true) + setError(null) + + try { + const username = localStorage.getItem('username') + + console.log('Creating game - username:', username) + + if (!username) { + setError('KĂ©rlek jelentkezz be elƑször!') + setLoading(false) + return + } + + // Backend expects deckids (array), maxplayers (number), logintype (0=PUBLIC, 1=PRIVATE) + const gameData = { + deckids: deckIds, // Array of deck UUIDs + maxplayers: maxPlayers, // Number + logintype: isPublic ? 0 : 1, // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION + } + + console.log('Creating game with data:', gameData) + const response = await createGame(gameData) + console.log('Game created:', response) + + // Verify localStorage still has username + console.log('After create - username:', localStorage.getItem('username')) + + // Backend returns game object directly + const code = response.gamecode || response.gameCode + if (code) { + setCreatedGameCode(code) + setShowSuccess(true) + } + + // Creator needs to join their own game to get a gameToken + // This allows the WebSocket to recognize them as the gamemaster + try { + const username = localStorage.getItem('username') + const joinResponse = await joinGame({ + gameCode: code, + playerName: username + }) + + if (joinResponse.gameToken) { + localStorage.setItem('gameToken', joinResponse.gameToken) + console.log('Creator joined game as gamemaster, token stored') + } + } catch (joinError) { + console.error('Failed to join game as creator:', joinError) + // Continue anyway - the creator can still try to join manually + } + + // Wait 3 seconds to show code, then navigate to lobby + setTimeout(() => { + console.log('Navigating to lobby with code:', code) + navigate('/lobby', { state: { gameCode: code } }) + }, 3000) + } catch (err) { + console.error('Create game error:', err) + console.error('Error response:', err.response?.data) + console.error('Error status:', err.response?.status) + setError(err.response?.data?.message || err.response?.data?.error || 'Nem sikerĂŒlt lĂ©trehozni a jĂĄtĂ©kot') + } finally { + setLoading(false) + } } if (deckIds.length === 0) { - navigate("/choose-deck") + navigate("/choosedeck") return null } @@ -67,6 +131,27 @@ const GameLobbySetup = () => { {deckIds.length} pakli kivĂĄlasztva. Add meg a jĂĄtĂ©k rĂ©szleteit. + {error && ( +
+ {error} +
+ )} + + {createdGameCode && ( +
+

JĂĄtĂ©k LĂ©trehozva! 🎉

+

+ {createdGameCode} +

+

+ Oszd meg ezt a kódot mås jåtékosokkal, hogy csatlakozhassanak! +

+

+ ÁtirĂĄnyĂ­tĂĄs a lobby-hoz 3 mĂĄsodperc mĂșlva... +

+
+ )} +
{/* Max Players */}
@@ -115,11 +200,17 @@ const GameLobbySetup = () => {
navigate("/choose-deck")} + onClick={() => navigate("/choosedeck")} width="w-auto px-8" className="bg-gray-600 hover:bg-gray-700" + disabled={loading} + /> + -
diff --git a/SerpentRace_Frontend/src/pages/Landing/Home.jsx b/SerpentRace_Frontend/src/pages/Landing/Home.jsx index 5dc095bc..b0da054a 100644 --- a/SerpentRace_Frontend/src/pages/Landing/Home.jsx +++ b/SerpentRace_Frontend/src/pages/Landing/Home.jsx @@ -1,24 +1,75 @@ // src/pages/Home/Home.jsx // RĂ©gi PlayMenu-s oldal, "Home" nĂ©ven -import { useEffect } from "react" +import { useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" import useRequireAuth from "../../hooks/useRequireAuth" import Navbar from "../../components/Navbar/Navbar" import Footer from "../../components/Footer/Footer.jsx" import Background from "../../assets/backgrounds/Background.jsx" import PlayMenu from "../../components/Landingpage/PlayMenu.jsx" +import { joinGame } from "../../api/gameApi.js" export default function Home() { + const navigate = useNavigate() // a hook inicializĂĄlja a user-t a localStorage-bĂłl Ă©s visszaadja a state-et + settert const [user, setUser] = useRequireAuth({ redirect: false }) // no redirect on unauthenticated visitors + const [isJoining, setIsJoining] = useState(false) - // Dummy callbackok Ă©s user pĂ©lda - const handleJoinGame = (code) => { - alert(`CsatlakozĂĄs jĂĄtĂ©khoz: ${code}`) + // Join game handler - csatlakozĂĄs jĂĄtĂ©khoz kĂłddal + const handleJoinGame = async (code) => { + if (!user) { + alert('KĂ©rlek elƑször jelentkezz be!') + navigate('/login') + return + } + + console.log('=== JOIN GAME DEBUG ===') + console.log('Current user:', user) + console.log('Game code:', code) + console.log('LocalStorage username:', localStorage.getItem('username')) + console.log('LocalStorage authLevel:', localStorage.getItem('authLevel')) + console.log('======================') + + setIsJoining(true) + try { + const joinData = { + gameCode: code.toUpperCase(), + playerName: user || 'Player', + } + + console.log('Sending join request with:', joinData) + const response = await joinGame(joinData) + console.log('Joined game:', response) + + // Backend returns game object directly + if (response.gameToken) { + localStorage.setItem('gameToken', response.gameToken) + } + + navigate('/lobby', { state: { gameCode: code.toUpperCase() } }) + } catch (err) { + const errorMsg = err.response?.data?.error || err.response?.data?.message || 'Nem sikerĂŒlt csatlakozni a jĂĄtĂ©khoz' + alert(errorMsg) + console.error('Join game error:', err) + console.error('Error details:', err.response?.data) + } finally { + setIsJoining(false) + } } + + // Create game handler - Ășj jĂĄtĂ©k lĂ©trehozĂĄsa const handleCreateGame = () => { - alert("Új jĂĄtĂ©k lĂ©trehozĂĄsa") + if (!user) { + alert('KĂ©rlek elƑször jelentkezz be!') + navigate('/login') + return + } + + // Navigate to choose deck page to start game creation flow + navigate('/choosedeck') } + const userObj = { name: user } // ha szĂŒksĂ©ges a user mĂłdosĂ­tĂĄsa mĂĄshol: setUser("ĂșjnĂ©v") automatikusan menti localStorage-be diff --git a/SerpentRace_Frontend/vite.config.js b/SerpentRace_Frontend/vite.config.js index bff480b0..59f06db4 100644 --- a/SerpentRace_Frontend/vite.config.js +++ b/SerpentRace_Frontend/vite.config.js @@ -13,6 +13,17 @@ export default defineConfig({ }, hmr: { clientPort: 5173, + }, + proxy: { + '/api': { + target: 'http://backend:3000', + changeOrigin: true, + }, + '/socket.io': { + target: 'http://backend:3000', + changeOrigin: true, + ws: true, + } } }, preview: {