diff --git a/Transcendence/.gitignore b/Transcendence/.gitignore index e909572..5e74a24 100644 --- a/Transcendence/.gitignore +++ b/Transcendence/.gitignore @@ -1,2 +1,3 @@ srcs/.DS_Store -*.DS_Store \ No newline at end of file +*.DS_Store +srcs/backend/avatar/* \ No newline at end of file diff --git a/Transcendence/srcs/backend/db.js b/Transcendence/srcs/backend/db.js index 5bf4ff6..35ec2a2 100644 --- a/Transcendence/srcs/backend/db.js +++ b/Transcendence/srcs/backend/db.js @@ -28,6 +28,33 @@ async function waitForDb(retries = 10, delay = 2000) throw new Error('Could not connect to database after multiple attempts'); } +async function runMigrations() +{ + try + { + // Add total_points column if it doesn't exist + await pool.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='total_points') THEN + ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_played') THEN + ALTER TABLE users ADD COLUMN games_played INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_won') THEN + ALTER TABLE users ADD COLUMN games_won INT DEFAULT 0; + END IF; + END $$; + `); + console.log('Migrations completed!'); + } + catch (err) + { + console.error('Error running migrations:', err); + } +} + async function createTables() { try @@ -39,6 +66,9 @@ async function createTables() password_hash TEXT NOT NULL, email VARCHAR(100), avatar_url TEXT DEFAULT '/avatar/default.png', + total_points INT DEFAULT 0, + games_played INT DEFAULT 0, + games_won INT DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); @@ -148,6 +178,7 @@ export { waitForDb, createTables, + runMigrations, query, ensureOauthClient }; diff --git a/Transcendence/srcs/backend/index.js b/Transcendence/srcs/backend/index.js index f9e7961..f098e36 100644 --- a/Transcendence/srcs/backend/index.js +++ b/Transcendence/srcs/backend/index.js @@ -7,7 +7,8 @@ import chatRouter from './routes/global_chat.js'; import gameRoomRouter from './routes/game_room.js'; import avatarRouter from './routes/avatar.js'; import friendsRouter from './routes/friends.js'; -import {waitForDb, createTables, ensureOauthClient} from './db.js'; +import playerStatsRouter from './routes/player_stats.js'; +import {waitForDb, createTables, runMigrations, ensureOauthClient} from './db.js'; import setupSocketIO from './services/socket.js'; import avatarService from './services/avatar.js'; @@ -31,6 +32,7 @@ async function startServer() { await waitForDb(); await createTables(); + await runMigrations(); // Ensure GitHub OAuth client is registered in DB try { @@ -45,6 +47,7 @@ async function startServer() app.use('/api/rooms', gameRoomRouter); app.use('/api/avatar', avatarRouter); app.use('/api/friends', friendsRouter); + app.use('/api/stats', playerStatsRouter); app.get('/api', (req, res) => res.send('Backend running')); server.listen(3001, () => diff --git a/Transcendence/srcs/backend/routes/game_room.js b/Transcendence/srcs/backend/routes/game_room.js index 0a98719..0d19d24 100644 --- a/Transcendence/srcs/backend/routes/game_room.js +++ b/Transcendence/srcs/backend/routes/game_room.js @@ -1,6 +1,7 @@ import express from 'express'; import gameRoomService from '../services/game_room.js'; import authenticateToken from '../middleware/auth.js'; +import { getIO, broadcastRoomsList } from '../services/socket.js'; const router = express.Router(); router.get('/', authenticateToken, async(req, res) => @@ -17,6 +18,23 @@ router.get('/', authenticateToken, async(req, res) => } }); +// IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId +router.get('/current', authenticateToken, async(req, res) => +{ + try + { + const room = await gameRoomService.getCurrentRoom(req.user.userId); + if (!room) + return res.status(204).send(); // No content - user is not in any room + res.json(room); + } + catch(err) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + router.get('/:roomId', authenticateToken, async(req, res) => { try @@ -55,6 +73,13 @@ router.post('/', authenticateToken, async(req, res) => if (!name) return (res.status(400).json({error: 'Room name required'})); const room = await gameRoomService.createRoom(name, req.user.userId); + + // Broadcast updated rooms list to all clients + const io = getIO(); + if (io) { + broadcastRoomsList(io); + } + res.status(201).json(room); } catch(err) @@ -69,6 +94,13 @@ router.post('/:roomId/join', authenticateToken, async(req, res) => try { const player = await gameRoomService.joinRoom(req.params.roomId, req.user.userId); + + // Broadcast updated rooms list to all clients + const io = getIO(); + if (io) { + broadcastRoomsList(io); + } + res.json(player); } catch(err) @@ -86,6 +118,13 @@ router.post('/:roomId/leave', authenticateToken, async(req, res) => try { await gameRoomService.leaveRoom(req.params.roomId, req.user.userId); + + // Broadcast updated rooms list to all clients + const io = getIO(); + if (io) { + broadcastRoomsList(io); + } + res.json({message: 'Left room successfully'}); } catch(err) diff --git a/Transcendence/srcs/backend/routes/player_stats.js b/Transcendence/srcs/backend/routes/player_stats.js new file mode 100644 index 0000000..db9a06e --- /dev/null +++ b/Transcendence/srcs/backend/routes/player_stats.js @@ -0,0 +1,46 @@ +import express from 'express'; +import playerStatsService from '../services/player_stats.js'; +import authenticateToken from '../middleware/auth.js'; +const router = express.Router(); + +// Get current user's stats +router.get('/me', authenticateToken, async (req, res) => { + try { + const stats = await playerStatsService.getStatsByUserId(req.user.userId); + if (!stats) { + return res.status(404).json({ error: 'User not found' }); + } + res.json(stats); + } catch (err) { + console.error('Error getting user stats:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get stats by username +router.get('/user/:username', authenticateToken, async (req, res) => { + try { + const stats = await playerStatsService.getStatsByUsername(req.params.username); + if (!stats) { + return res.status(404).json({ error: 'User not found' }); + } + res.json(stats); + } catch (err) { + console.error('Error getting user stats:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get leaderboard +router.get('/leaderboard', authenticateToken, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 10, 50); + const leaderboard = await playerStatsService.getLeaderboard(limit); + res.json(leaderboard); + } catch (err) { + console.error('Error getting leaderboard:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +export default router; diff --git a/Transcendence/srcs/backend/services/friends.js b/Transcendence/srcs/backend/services/friends.js index a9de380..9220dd1 100644 --- a/Transcendence/srcs/backend/services/friends.js +++ b/Transcendence/srcs/backend/services/friends.js @@ -6,7 +6,7 @@ import { query } from '../db.js'; async function getFriends(userId) { try { const result = await query( - `SELECT u.id, u.username, u.avatar_url + `SELECT u.id, u.username, u.avatar_url, u.total_points, u.games_played, u.games_won FROM friendship f JOIN users u ON ( CASE diff --git a/Transcendence/srcs/backend/services/game_room.js b/Transcendence/srcs/backend/services/game_room.js index 393858d..e2fd1f6 100644 --- a/Transcendence/srcs/backend/services/game_room.js +++ b/Transcendence/srcs/backend/services/game_room.js @@ -98,7 +98,7 @@ async function getRoomPlayers(roomId) { const result = await query ( - `SELECT gp.*, u.username + `SELECT gp.*, u.username, u.avatar_url, u.total_points, u.games_played, u.games_won FROM game_players gp JOIN users u ON gp.user_id = u.id WHERE gp.room_id = $1 @@ -108,6 +108,21 @@ async function getRoomPlayers(roomId) return (result.rows); } +// Get the current room of a user (if any) +async function getCurrentRoom(userId) +{ + const result = await query + ( + `SELECT r.* + FROM game_rooms r + JOIN game_players gp ON r.id = gp.room_id + WHERE gp.user_id = $1 AND r.status = 'waiting' + LIMIT 1`, + [userId] + ); + return (result.rows[0] || null); +} + export default { createRoom, @@ -115,5 +130,6 @@ export default listActiveRooms, joinRoom, leaveRoom, - getRoomPlayers + getRoomPlayers, + getCurrentRoom }; \ No newline at end of file diff --git a/Transcendence/srcs/backend/services/player_stats.js b/Transcendence/srcs/backend/services/player_stats.js new file mode 100644 index 0000000..c6c7e71 --- /dev/null +++ b/Transcendence/srcs/backend/services/player_stats.js @@ -0,0 +1,88 @@ +import { query } from '../db.js'; + +// Get player stats by user ID +async function getStatsByUserId(userId) { + const result = await query( + `SELECT id, username, avatar_url, total_points, games_played, games_won, created_at + FROM users WHERE id = $1`, + [userId] + ); + return result.rows[0] || null; +} + +// Get player stats by username +async function getStatsByUsername(username) { + const result = await query( + `SELECT id, username, avatar_url, total_points, games_played, games_won, created_at + FROM users WHERE username = $1`, + [username] + ); + return result.rows[0] || null; +} + +// Update player points (add points to total) +async function addPoints(userId, points) { + const result = await query( + `UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE id = $2 RETURNING total_points`, + [points, userId] + ); + return result.rows[0]?.total_points || 0; +} + +// Update player points by username +async function addPointsByUsername(username, points) { + const result = await query( + `UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE username = $2 RETURNING total_points`, + [points, username] + ); + return result.rows[0]?.total_points || 0; +} + +// Increment games played +async function incrementGamesPlayed(userId) { + await query( + `UPDATE users SET games_played = COALESCE(games_played, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Increment games won +async function incrementGamesWon(userId) { + await query( + `UPDATE users SET games_won = COALESCE(games_won, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Get leaderboard (top players by points) +async function getLeaderboard(limit = 10) { + const result = await query( + `SELECT id, username, avatar_url, total_points, games_played, games_won + FROM users + WHERE total_points > 0 + ORDER BY total_points DESC + LIMIT $1`, + [limit] + ); + return result.rows; +} + +// Get user ID by username +async function getUserIdByUsername(username) { + const result = await query( + `SELECT id FROM users WHERE username = $1`, + [username] + ); + return result.rows[0]?.id || null; +} + +export default { + getStatsByUserId, + getStatsByUsername, + addPoints, + addPointsByUsername, + incrementGamesPlayed, + incrementGamesWon, + getLeaderboard, + getUserIdByUsername +}; diff --git a/Transcendence/srcs/backend/services/socket.js b/Transcendence/srcs/backend/services/socket.js index 6f5cea1..ac2cf65 100644 --- a/Transcendence/srcs/backend/services/socket.js +++ b/Transcendence/srcs/backend/services/socket.js @@ -1,9 +1,48 @@ import jwt from 'jsonwebtoken'; import chatService from './global_chat.js'; import friendsService from './friends.js'; +import gameRoomService from './game_room.js'; +import playerStatsService from './player_stats.js'; + +// Store game state per room +const gameRooms = new Map(); + +// Store io instance globally for use in routes +let ioInstance = null; + +export function getIO() { + return ioInstance; +} + +// Broadcast rooms list to all connected clients +async function broadcastRoomsList(io) { + try { + const rooms = await gameRoomService.listActiveRooms(); + io.emit('game-rooms-updated', { rooms }); + } catch (err) { + console.error('Error broadcasting rooms list:', err); + } +} + +// Save round points to database (only the difference from round start) +async function saveRoundPoints(currentScores, roundStartScores) { + for (const [username, currentPoints] of Object.entries(currentScores)) { + const startPoints = roundStartScores[username] || 0; + const pointsEarned = currentPoints - startPoints; + if (pointsEarned !== 0) { + try { + await playerStatsService.addPointsByUsername(username, pointsEarned); + console.log(`Saved ${pointsEarned} points for ${username}`); + } catch (err) { + console.error(`Error saving points for ${username}:`, err); + } + } + } +} function setupSocketIO(io) { + ioInstance = io; io.use((socket, next) => { const token = socket.handshake.auth.token; @@ -63,11 +102,356 @@ function setupSocketIO(io) socket.emit('error', {message: 'Failed to send message'}); } }); - socket.on('disconnect', () => + + // ============================================ + // GAME ROOM EVENTS + // ============================================ + + // Join a game room + socket.on('game-join-room', async (data) => { + console.log('Received game-join-room from', socket.user.username, 'data:', data); + const roomId = `game-room-${data.roomId}`; + socket.join(roomId); + socket.gameRoomId = roomId; + socket.gameRoomDbId = data.roomId; + console.log(`${socket.user.username} joined ${roomId}, socket.gameRoomId set to:`, socket.gameRoomId); + + // Send confirmation to the socket that joined + socket.emit('game-room-joined', { + roomId: data.roomId, + success: true + }); + + // Get updated player list from DB + try { + const players = await gameRoomService.getRoomPlayers(data.roomId); + // Notify ALL players in the room (including the one who joined) with updated player list + io.to(roomId).emit('game-players-updated', { players }); + } catch (err) { + console.error('Error getting room players:', err); + } + + // Notify others in the room that someone joined + socket.to(roomId).emit('game-player-joined', { + username: socket.user.username, + userId: socket.user.userId + }); + + // Broadcast rooms list update to everyone + broadcastRoomsList(io); + + // Send current game state if game is in progress + const gameState = gameRooms.get(roomId); + if (gameState && gameState.isPlaying) { + socket.emit('game-state-sync', { + isPlaying: gameState.isPlaying, + drawer: gameState.drawer, + wordLength: gameState.currentWord ? gameState.currentWord.length : 0, + revealedLetters: gameState.revealedLetters, + revealedWord: gameState.revealedWord || [], + guessedLetters: gameState.guessedLetters, + players: gameState.players + }); + } + }); + + // Leave a game room + socket.on('game-leave-room', async () => { + if (socket.gameRoomId) { + const roomId = socket.gameRoomId; + const dbRoomId = socket.gameRoomDbId; + + socket.to(roomId).emit('game-player-left', { + username: socket.user.username, + userId: socket.user.userId + }); + socket.leave(roomId); + console.log(`${socket.user.username} left ${roomId}`); + + // Get updated player list and broadcast to remaining players + if (dbRoomId) { + try { + const players = await gameRoomService.getRoomPlayers(dbRoomId); + io.to(roomId).emit('game-players-updated', { players }); + } catch (err) { + // Room may have been deleted + console.log('Room may have been deleted:', err.message); + } + } + + socket.gameRoomId = null; + socket.gameRoomDbId = null; + + // Broadcast updated rooms list + broadcastRoomsList(io); + } + }); + + // Start the game + socket.on('game-start', (data) => { + console.log('Received game-start event from', socket.user.username); + console.log('socket.gameRoomId:', socket.gameRoomId); + + const gameStartedData = { + drawer: data.drawer, + players: data.players + }; + + const roomId = socket.gameRoomId; + + // If no roomId, still start the game for this socket only + if (!roomId) { + console.log('WARNING: No roomId for socket, starting game for this socket only'); + socket.emit('game-started', gameStartedData); + return; + } + + // Initialize scores for all players + const scores = {}; + data.players.forEach(p => scores[p] = 0); + + const gameState = { + isPlaying: true, + currentWord: '', + revealedLetters: [], + drawer: data.drawer, + players: data.players, + currentPlayerIndex: 0, + guessedLetters: [], + scores: scores, + roundStartScores: { ...scores } + }; + gameRooms.set(roomId, gameState); + + // Emit to OTHER players in the room + socket.to(roomId).emit('game-started', gameStartedData); + + // Emit directly to this socket (the one who started the game) + socket.emit('game-started', gameStartedData); + + console.log(`Game started in ${roomId} by ${socket.user.username}`); + }); + + // Drawer sets the word + socket.on('game-set-word', (data) => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + const gameState = gameRooms.get(roomId); + if (!gameState) return; + + gameState.currentWord = data.word.toLowerCase(); + gameState.revealedLetters = new Array(data.word.length).fill(false); + gameState.revealedWord = new Array(data.word.length).fill('_'); + gameState.guessedLetters = []; + gameState.wrongGuesses = 0; + + // Initialize scores if not already done + if (!gameState.scores) { + gameState.scores = {}; + gameState.players.forEach(p => gameState.scores[p] = 0); + } + + // Notify all players (without revealing the word) + io.to(roomId).emit('game-word-set', { + wordLength: data.word.length, + drawer: socket.user.username, + revealedWord: gameState.revealedWord, + scores: gameState.scores + }); + }); + + // Drawing data (real-time) + socket.on('game-draw', (data) => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + // Broadcast drawing to all other players in the room + socket.to(roomId).emit('game-draw', { + x1: data.x1, + y1: data.y1, + x2: data.x2, + y2: data.y2, + color: data.color, + lineWidth: data.lineWidth + }); + }); + + // Clear canvas + socket.on('game-clear-canvas', () => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + socket.to(roomId).emit('game-clear-canvas'); + }); + + // Player makes a guess + socket.on('game-guess', (data) => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + const gameState = gameRooms.get(roomId); + if (!gameState || !gameState.currentWord) return; + + const guess = data.guess.toLowerCase(); + const isLetter = guess.length === 1; + let success = false; + let points = 0; + const username = socket.user.username; + + // Initialize scores if needed + if (!gameState.scores) { + gameState.scores = {}; + gameState.players.forEach(p => gameState.scores[p] = 0); + } + if (!gameState.scores[username]) { + gameState.scores[username] = 0; + } + + if (isLetter) { + // Check if letter was already guessed + if (gameState.guessedLetters.includes(guess)) { + socket.emit('game-guess-result', { + guess, + success: false, + type: 'letter', + message: 'Lettre deja proposee', + username: username, + scores: gameState.scores + }); + return; + } + + gameState.guessedLetters.push(guess); + + // Check each position and reveal the actual letter + let lettersFound = 0; + for (let i = 0; i < gameState.currentWord.length; i++) { + if (gameState.currentWord[i] === guess) { + gameState.revealedLetters[i] = true; + gameState.revealedWord[i] = guess; + success = true; + lettersFound++; + } + } + + // Points: 10 per letter found, -5 for wrong guess + if (success) { + points = lettersFound * 10; + gameState.scores[username] += points; + } else { + points = -5; + gameState.scores[username] += points; + gameState.wrongGuesses++; + } + } else { + // Full word guess + success = guess === gameState.currentWord; + if (success) { + gameState.revealedLetters = gameState.revealedLetters.map(() => true); + gameState.revealedWord = gameState.currentWord.split(''); + // Bonus points for guessing the whole word + const remainingLetters = gameState.revealedLetters.filter(r => !r).length; + points = 50 + (remainingLetters * 5); + gameState.scores[username] += points; + } else { + points = -10; + gameState.scores[username] += points; + gameState.wrongGuesses++; + } + } + + // Broadcast result to all players with the revealed word (actual letters) + io.to(roomId).emit('game-guess-result', { + guess, + success, + type: isLetter ? 'letter' : 'word', + username: username, + revealedLetters: gameState.revealedLetters, + revealedWord: gameState.revealedWord, + points: points, + scores: gameState.scores + }); + + // Check if word is complete + if (gameState.revealedLetters.every(r => r)) { + // Bonus points for the drawer + const drawerBonus = Math.max(0, 30 - (gameState.wrongGuesses * 5)); + if (gameState.scores[gameState.drawer]) { + gameState.scores[gameState.drawer] += drawerBonus; + } + + // Save points to database for all players + saveRoundPoints(gameState.scores, gameState.roundStartScores || {}); + // Update round start scores for next round + gameState.roundStartScores = { ...gameState.scores }; + + io.to(roomId).emit('game-word-found', { + word: gameState.currentWord, + winner: username, + scores: gameState.scores, + drawerBonus: drawerBonus + }); + } + }); + + // Next round + socket.on('game-next-round', (data) => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + const gameState = gameRooms.get(roomId); + if (!gameState) return; + + gameState.currentWord = ''; + gameState.revealedLetters = []; + gameState.guessedLetters = []; + gameState.drawer = data.drawer; + + io.to(roomId).emit('game-new-round', { + drawer: data.drawer + }); + }); + + // End game + socket.on('game-end', () => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + gameRooms.delete(roomId); + io.to(roomId).emit('game-ended'); + }); + + socket.on('disconnect', async () => { console.log(`User disconnected: ${socket.user.username}`); + + // Notify game room if player was in one + if (socket.gameRoomId) { + const roomId = socket.gameRoomId; + const dbRoomId = socket.gameRoomDbId; + + socket.to(roomId).emit('game-player-left', { + username: socket.user.username, + userId: socket.user.userId + }); + + // Get updated player list and broadcast + if (dbRoomId) { + try { + const players = await gameRoomService.getRoomPlayers(dbRoomId); + io.to(roomId).emit('game-players-updated', { players }); + } catch (err) { + console.log('Room may have been deleted on disconnect:', err.message); + } + } + + // Broadcast updated rooms list + broadcastRoomsList(io); + } }); }); } +export { broadcastRoomsList }; export default setupSocketIO; \ No newline at end of file diff --git a/Transcendence/srcs/frontend/src/app.js b/Transcendence/srcs/frontend/src/app.js index ec84433..563844c 100644 --- a/Transcendence/srcs/frontend/src/app.js +++ b/Transcendence/srcs/frontend/src/app.js @@ -7,6 +7,7 @@ import { LoginWindow } from './login.js'; import { GlobalChat } from './global_chat.js'; import { AvatarWindow } from './avatar.js'; import { FriendsWindow } from './friends.js'; +import { GameRoomWindow } from './game_room.js'; /** * Main application class @@ -23,11 +24,11 @@ class App { * Initializes all windows */ initWindows() { - // Windows automatically register themselves in the registry new LoginWindow(); new GlobalChat(); new AvatarWindow(); new FriendsWindow(); + new GameRoomWindow(); } /** @@ -41,12 +42,12 @@ class App { return; } - // Action to window name mapping const actionMap = { 'login': 'login', 'chat': 'chat', 'avatar': 'avatar', - 'friends': 'friends' + 'friends': 'friends', + 'gameroom': 'gameroom' }; // Event delegation on the menu @@ -72,7 +73,7 @@ class App { const easterEgg = document.querySelector('.easter-egg'); if (easterEgg) { easterEgg.addEventListener('click', () => { - alert('You clicked when we told you not to!'); + alert('DONT CLICK!'); }); } } diff --git a/Transcendence/srcs/frontend/src/avatar.js b/Transcendence/srcs/frontend/src/avatar.js index 3201dad..be7c939 100644 --- a/Transcendence/srcs/frontend/src/avatar.js +++ b/Transcendence/srcs/frontend/src/avatar.js @@ -34,6 +34,13 @@ export class AvatarWindow extends Window { // Username display this.username = this.createElement('div', CSS.AVATAR_USERNAME); + // Stats display + this.statsContainer = this.createElement('div', 'avatar__stats'); + this.pointsDisplay = this.createElement('div', 'avatar__stat'); + this.gamesPlayedDisplay = this.createElement('div', 'avatar__stat'); + this.gamesWonDisplay = this.createElement('div', 'avatar__stat'); + this.statsContainer.append(this.pointsDisplay, this.gamesPlayedDisplay, this.gamesWonDisplay); + // Hidden file input this.fileInput = this.createElement('input', 'avatar__file-input', { type: 'file', @@ -64,6 +71,7 @@ export class AvatarWindow extends Window { this.body.append( this.preview, this.username, + this.statsContainer, this.fileInput, this.controls, this.message @@ -148,6 +156,46 @@ export class AvatarWindow extends Window { } catch (error) { console.error('Error loading avatar:', error); } + + // Load stats + await this.loadStats(); + } + + /** + * Loads player stats from the server + */ + async loadStats() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return; + + try { + const response = await fetch(API.STATS.ME, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + console.warn('Failed to load stats, status:', response.status); + return; + } + + const data = await response.json(); + this.updateStatsDisplay(data); + } catch (error) { + console.error('Error loading stats:', error); + } + } + + /** + * Updates the stats display + * @param {object} stats + */ + updateStatsDisplay(stats) { + this.pointsDisplay.innerHTML = `Points: ${stats.total_points || 0}`; + this.gamesPlayedDisplay.innerHTML = `Parties: ${stats.games_played || 0}`; + this.gamesWonDisplay.innerHTML = `Victoires: ${stats.games_won || 0}`; } /** diff --git a/Transcendence/srcs/frontend/src/config.js b/Transcendence/srcs/frontend/src/config.js index 646575f..9fae344 100644 --- a/Transcendence/srcs/frontend/src/config.js +++ b/Transcendence/srcs/frontend/src/config.js @@ -20,6 +20,20 @@ export const API = { REQUEST: '/api/friends/request', ACCEPT: '/api/friends/accept', DECLINE: '/api/friends/decline' + }, + ROOMS: { + LIST: '/api/rooms', + CREATE: '/api/rooms', + GET: (id) => `/api/rooms/${id}`, + PLAYERS: (id) => `/api/rooms/${id}/players`, + JOIN: (id) => `/api/rooms/${id}/join`, + LEAVE: (id) => `/api/rooms/${id}/leave`, + CURRENT: '/api/rooms/current' + }, + STATS: { + ME: '/api/stats/me', + USER: (username) => `/api/stats/user/${username}`, + LEADERBOARD: '/api/stats/leaderboard' } }; @@ -94,7 +108,26 @@ export const CSS = { FRIENDS_NAME: 'friends__name', FRIENDS_ACTIONS: 'friends__actions', FRIENDS_SEARCH: 'friends__search', - FRIENDS_EMPTY: 'friends__empty' + FRIENDS_EMPTY: 'friends__empty', + + // Game Rooms + GAMEROOM: 'gameroom', + GAMEROOM_TABS: 'gameroom__tabs', + GAMEROOM_TAB: 'gameroom__tab', + GAMEROOM_TAB_ACTIVE: 'gameroom__tab--active', + GAMEROOM_CONTENT: 'gameroom__content', + GAMEROOM_LIST: 'gameroom__list', + GAMEROOM_ITEM: 'gameroom__item', + GAMEROOM_NAME: 'gameroom__name', + GAMEROOM_PLAYERS: 'gameroom__players', + GAMEROOM_ACTIONS: 'gameroom__actions', + GAMEROOM_CREATE: 'gameroom__create', + GAMEROOM_LOBBY: 'gameroom__lobby', + GAMEROOM_PLAYER_LIST: 'gameroom__player-list', + GAMEROOM_PLAYER: 'gameroom__player', + GAMEROOM_PLAYER_AVATAR: 'gameroom__player-avatar', + GAMEROOM_PLAYER_NAME: 'gameroom__player-name', + GAMEROOM_PLAYER_SCORE: 'gameroom__player-score' }; // Colors (for reference, mainly used in CSS) diff --git a/Transcendence/srcs/frontend/src/events.js b/Transcendence/srcs/frontend/src/events.js index 5bb0aa6..632a64c 100644 --- a/Transcendence/srcs/frontend/src/events.js +++ b/Transcendence/srcs/frontend/src/events.js @@ -84,5 +84,10 @@ export const Events = { // Chat CHAT_CONNECTED: 'chat:connected', CHAT_DISCONNECTED: 'chat:disconnected', - CHAT_MESSAGE_RECEIVED: 'chat:message-received' + CHAT_MESSAGE_RECEIVED: 'chat:message-received', + + // Game Rooms + ROOM_JOINED: 'room:joined', + ROOM_LEFT: 'room:left', + ROOM_CREATED: 'room:created' }; diff --git a/Transcendence/srcs/frontend/src/friends.js b/Transcendence/srcs/frontend/src/friends.js index e262af5..cedb40f 100644 --- a/Transcendence/srcs/frontend/src/friends.js +++ b/Transcendence/srcs/frontend/src/friends.js @@ -290,10 +290,22 @@ export class FriendsWindow extends Window { }); avatar.src = user.avatar_url || '/avatar/default.png'; + const infoContainer = this.createElement('div', 'friends__info'); + const name = this.createElement('span', CSS.FRIENDS_NAME, { text: user.username }); + infoContainer.appendChild(name); + + // Show stats for friends + if (type === 'friend' && user.total_points !== undefined) { + const stats = this.createElement('span', 'friends__stats', { + text: `${user.total_points || 0} pts` + }); + infoContainer.appendChild(stats); + } + const actions = this.createElement('div', CSS.FRIENDS_ACTIONS); if (type === 'friend') { @@ -322,7 +334,7 @@ export class FriendsWindow extends Window { actions.appendChild(addBtn); } - item.append(avatar, name, actions); + item.append(avatar, infoContainer, actions); return item; } diff --git a/Transcendence/srcs/frontend/src/game_room.js b/Transcendence/srcs/frontend/src/game_room.js new file mode 100644 index 0000000..4a1ed59 --- /dev/null +++ b/Transcendence/srcs/frontend/src/game_room.js @@ -0,0 +1,1261 @@ +import { Window } from './windows.js'; +import { API, STORAGE_KEYS, CSS } from './config.js'; +import { eventBus, Events } from './events.js'; + +export class GameRoomWindow extends Window { + constructor() { + super({ + name: 'gameroom', + title: 'Game Rooms', + cssClasses: ['gameroom-window'] + }); + + this.currentTab = 'browse'; + this.currentRoom = null; + this.roomsList = []; + this.socket = null; + this.buildUI(); + this.bindEvents(); + + eventBus.on(Events.USER_LOGGED_IN, () => { + this.updateTabsAccess(); + this.checkCurrentRoom(); + }); + eventBus.on(Events.USER_LOGGED_OUT, () => { + this.handleLogout(); + }); + + this.updateTabsAccess(); + + // Verifier si l'utilisateur est deja dans un salon au chargement + if (this.isLoggedIn()) { + this.checkCurrentRoom(); + } + } + + buildUI() { + this.tabs = this.createElement('div', CSS.GAMEROOM_TABS); + + this.browseTab = this.createElement('button', [CSS.GAMEROOM_TAB, CSS.GAMEROOM_TAB_ACTIVE], { + text: 'Salons' + }); + this.browseTab.dataset.tab = 'browse'; + + this.createTab = this.createElement('button', CSS.GAMEROOM_TAB, { + text: 'Creer' + }); + this.createTab.dataset.tab = 'create'; + + this.lobbyTab = this.createElement('button', CSS.GAMEROOM_TAB, { + text: 'Lobby' + }); + this.lobbyTab.dataset.tab = 'lobby'; + this.lobbyTab.style.display = 'none'; + + this.tabs.append(this.browseTab, this.createTab, this.lobbyTab); + + this.content = this.createElement('div', CSS.GAMEROOM_CONTENT); + + this.createContainer = this.createElement('div', CSS.GAMEROOM_CREATE); + this.roomNameInput = this.createElement('input', CSS.INPUT, { + type: 'text', + placeholder: 'Nom du salon...' + }); + this.createBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Creer le salon' + }); + this.createContainer.append(this.roomNameInput, this.createBtn); + this.createContainer.style.display = 'none'; + + this.lobbyContainer = this.createElement('div', CSS.GAMEROOM_LOBBY); + this.lobbyTitle = this.createElement('h3', 'gameroom__lobby-title', { text: '' }); + this.playerList = this.createElement('div', CSS.GAMEROOM_PLAYER_LIST); + + // Boutons du lobby + this.lobbyButtons = this.createElement('div', 'gameroom__lobby-buttons'); + this.startGameBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], { + text: 'Lancer le jeu' + }); + this.leaveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { + text: 'Quitter' + }); + this.lobbyButtons.append(this.startGameBtn, this.leaveBtn); + + // Container du jeu (caché par défaut) + this.gameContainer = this.createElement('div', 'gameroom__game'); + this.gameContainer.style.display = 'none'; + this.buildGameUI(); + + this.lobbyContainer.append(this.lobbyTitle, this.playerList, this.lobbyButtons, this.gameContainer); + this.lobbyContainer.style.display = 'none'; + + this.list = this.createElement('div', CSS.GAMEROOM_LIST); + + this.message = this.createElement('div', CSS.MESSAGE); + + this.content.append(this.createContainer, this.lobbyContainer, this.list, this.message); + + this.body.append(this.tabs, this.content); + } + + buildGameUI() { + // Zone d'info du jeu + this.gameInfo = this.createElement('div', 'gameroom__game-info'); + this.currentDrawerInfo = this.createElement('div', 'gameroom__drawer-info', { text: '' }); + this.scoresDisplay = this.createElement('div', 'gameroom__scores-display'); + this.gameInfo.append(this.currentDrawerInfo, this.scoresDisplay); + + // Affichage du mot caché + this.wordDisplay = this.createElement('div', 'gameroom__word-display'); + + // Canvas de dessin + this.canvasContainer = this.createElement('div', 'gameroom__canvas-container'); + this.canvas = document.createElement('canvas'); + this.canvas.className = 'gameroom__canvas'; + this.canvas.width = 380; + this.canvas.height = 200; + this.ctx = this.canvas.getContext('2d'); + this.canvasContainer.appendChild(this.canvas); + + // Outils de dessin + this.drawTools = this.createElement('div', 'gameroom__draw-tools'); + this.colorPicker = this.createElement('input', 'gameroom__color-picker'); + this.colorPicker.type = 'color'; + this.colorPicker.value = '#ffffff'; + this.clearCanvasBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Effacer' }); + this.drawTools.append(this.colorPicker, this.clearCanvasBtn); + this.drawTools.style.display = 'none'; + + // Zone pour choisir le mot (pour le dessinateur) + this.wordInputContainer = this.createElement('div', 'gameroom__word-input-container'); + this.wordInput = this.createElement('input', CSS.INPUT, { + type: 'text', + placeholder: 'Entrez le mot a faire deviner...' + }); + this.confirmWordBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { text: 'OK' }); + this.wordInputContainer.append(this.wordInput, this.confirmWordBtn); + this.wordInputContainer.style.display = 'none'; + + // Zone pour deviner (pour les autres joueurs) + this.guessContainer = this.createElement('div', 'gameroom__guess-container'); + this.letterInput = this.createElement('input', CSS.INPUT, { + type: 'text', + placeholder: 'Proposez une lettre ou le mot...', + maxLength: '50' + }); + this.guessBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { text: 'Deviner' }); + this.guessContainer.append(this.letterInput, this.guessBtn); + this.guessContainer.style.display = 'none'; + + // Historique des tentatives + this.guessHistory = this.createElement('div', 'gameroom__guess-history'); + + // Boutons du jeu + this.gameButtons = this.createElement('div', 'gameroom__game-buttons'); + this.backToLobbyBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Retour au lobby' }); + this.endRoundBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { text: 'Terminer le jeu' }); + this.gameButtons.append(this.backToLobbyBtn, this.endRoundBtn); + + this.gameContainer.append( + this.gameInfo, + this.wordDisplay, + this.canvasContainer, + this.drawTools, + this.wordInputContainer, + this.guessContainer, + this.guessHistory, + this.gameButtons + ); + + // Initialiser les variables du jeu + this.gameState = { + isPlaying: false, + currentWord: '', + wordLength: 0, + revealedLetters: [], + revealedWord: [], + drawer: null, + players: [], + currentPlayerIndex: 0, + guessedLetters: [], + scores: {} + }; + + this.initDrawing(); + } + + initDrawing() { + this.isDrawing = false; + this.lastX = 0; + this.lastY = 0; + + this.canvas.addEventListener('mousedown', (e) => { + if (!this.gameState.isPlaying || !this.isCurrentUserDrawer()) return; + this.isDrawing = true; + [this.lastX, this.lastY] = [e.offsetX, e.offsetY]; + }); + + this.canvas.addEventListener('mousemove', (e) => { + if (!this.isDrawing) return; + + const x1 = this.lastX; + const y1 = this.lastY; + const x2 = e.offsetX; + const y2 = e.offsetY; + const color = this.colorPicker.value; + const lineWidth = 3; + + // Dessiner localement + this.drawLine(x1, y1, x2, y2, color, lineWidth); + + // Envoyer aux autres joueurs via WebSocket + if (this.socket?.connected) { + this.socket.emit('game-draw', { x1, y1, x2, y2, color, lineWidth }); + } + + [this.lastX, this.lastY] = [x2, y2]; + }); + + this.canvas.addEventListener('mouseup', () => this.isDrawing = false); + this.canvas.addEventListener('mouseout', () => this.isDrawing = false); + + this.clearCanvasBtn.addEventListener('click', () => { + this.clearCanvas(); + // Notifier les autres + if (this.socket?.connected) { + this.socket.emit('game-clear-canvas'); + } + }); + } + + drawLine(x1, y1, x2, y2, color, lineWidth) { + this.ctx.beginPath(); + this.ctx.strokeStyle = color; + this.ctx.lineWidth = lineWidth; + this.ctx.lineCap = 'round'; + this.ctx.moveTo(x1, y1); + this.ctx.lineTo(x2, y2); + this.ctx.stroke(); + } + + clearCanvas() { + this.ctx.fillStyle = '#333'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + bindEvents() { + this.tabs.addEventListener('click', (e) => { + const tab = e.target.closest(`.${CSS.GAMEROOM_TAB}`); + if (tab) { + this.switchTab(tab.dataset.tab); + } + }); + + this.createBtn.addEventListener('click', () => this.createRoom()); + this.roomNameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.createRoom(); + }); + + this.leaveBtn.addEventListener('click', () => this.leaveRoom()); + this.startGameBtn.addEventListener('click', () => this.startGame()); + + // Events du jeu + this.confirmWordBtn.addEventListener('click', () => this.confirmWord()); + this.wordInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.confirmWord(); + }); + + this.guessBtn.addEventListener('click', () => this.makeGuess()); + this.letterInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.makeGuess(); + }); + + this.backToLobbyBtn.addEventListener('click', () => this.backToLobby()); + this.endRoundBtn.addEventListener('click', () => this.endGame()); + } + + // ============================================ + // SOCKET.IO CONNECTION + // ============================================ + + async connectToGameSocket() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token || !this.currentRoom) return; + + // Ensure socket is connected + await this.ensureSocketConnected(); + + // Join the room + if (this.socket?.connected) { + console.log('Socket connected, joining room:', this.currentRoom.id); + this.socket.emit('game-join-room', { roomId: this.currentRoom.id }); + } + } + + async loadSocketIO() { + if (window.io) return; + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = '/socket.io/socket.io.js'; + + script.onload = () => { + console.log('Socket.IO loaded for game'); + resolve(); + }; + + script.onerror = () => { + console.error('Failed to load Socket.IO'); + reject(new Error('Socket.IO load failed')); + }; + + document.head.appendChild(script); + }); + } + + setupGameSocketListeners() { + this.socketReady = false; + + this.socket.on('connect', () => { + console.log('Game socket connected, id:', this.socket.id); + if (this.currentRoom) { + console.log('Joining room:', this.currentRoom.id); + this.socket.emit('game-join-room', { roomId: this.currentRoom.id }); + } + }); + + this.socket.on('connect_error', (err) => { + console.error('Game socket connection error:', err.message); + }); + + // Confirmation that we joined the room + this.socket.on('game-room-joined', (data) => { + console.log('Successfully joined game room:', data.roomId); + this.socketReady = true; + }); + + // Real-time rooms list update + this.socket.on('game-rooms-updated', (data) => { + console.log('Rooms list updated:', data.rooms?.length, 'rooms'); + if (this.currentTab === 'browse') { + this.roomsList = data.rooms || []; + this.renderRoomsList(this.roomsList); + } + }); + + // Real-time player list update in lobby + this.socket.on('game-players-updated', (data) => { + console.log('Players list updated:', data.players?.length, 'players'); + if (this.currentRoom) { + this.renderPlayersList(data.players || []); + } + }); + + // Player joined/left + this.socket.on('game-player-joined', (data) => { + console.log(`${data.username} joined the room`); + }); + + this.socket.on('game-player-left', (data) => { + console.log(`${data.username} left the room`); + }); + + // Game started + this.socket.on('game-started', (data) => { + console.log('Received game-started event:', data); + this.gameState.isPlaying = true; + this.gameState.drawer = data.drawer; + this.gameState.players = data.players; + this.gameState.currentPlayerIndex = data.players.indexOf(data.drawer); + + // Initialize scores + this.gameState.scores = {}; + data.players.forEach(p => this.gameState.scores[p] = 0); + + this.showGameUI(); + this.setupRound(); + }); + + // Word was set by drawer + this.socket.on('game-word-set', (data) => { + console.log(`Word set by ${data.drawer}, length: ${data.wordLength}`); + this.gameState.wordLength = data.wordLength; + this.gameState.revealedLetters = new Array(data.wordLength).fill(false); + this.gameState.revealedWord = data.revealedWord || new Array(data.wordLength).fill('_'); + + if (data.scores) { + this.updateScoresDisplay(data.scores); + } + + this.updateWordDisplay(); + this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`; + + // Enable guess input for non-drawers + if (!this.isCurrentUserDrawer()) { + this.guessContainer.style.display = 'flex'; + this.letterInput.disabled = false; + this.guessBtn.disabled = false; + this.letterInput.placeholder = 'Proposez une lettre ou le mot...'; + this.letterInput.focus(); + } + }); + + // Drawing received + this.socket.on('game-draw', (data) => { + this.drawLine(data.x1, data.y1, data.x2, data.y2, data.color, data.lineWidth); + }); + + // Clear canvas + this.socket.on('game-clear-canvas', () => { + this.clearCanvas(); + }); + + // Guess result + this.socket.on('game-guess-result', (data) => { + this.addGuessToHistory(data.guess, data.success, data.type, data.username, data.points || 0); + + if (data.revealedLetters) { + this.gameState.revealedLetters = data.revealedLetters; + } + if (data.revealedWord) { + this.gameState.revealedWord = data.revealedWord; + } + if (data.scores) { + this.updateScoresDisplay(data.scores); + } + this.updateWordDisplay(); + }); + + // Word found + this.socket.on('game-word-found', (data) => { + if (data.scores) { + this.updateScoresDisplay(data.scores); + } + this.wordFound(data.word, data.winner, data.drawerBonus || 0); + }); + + // New round + this.socket.on('game-new-round', (data) => { + this.gameState.drawer = data.drawer; + this.gameState.currentPlayerIndex = this.gameState.players.indexOf(data.drawer); + this.setupRound(); + }); + + // Game ended + this.socket.on('game-ended', () => { + this.resetGameUI(); + }); + + // Sync state for late joiners + this.socket.on('game-state-sync', (data) => { + if (data.isPlaying) { + this.gameState.isPlaying = true; + this.gameState.drawer = data.drawer; + this.gameState.wordLength = data.wordLength; + this.gameState.revealedLetters = data.revealedLetters || []; + this.gameState.revealedWord = data.revealedWord || new Array(data.wordLength).fill('_'); + this.gameState.players = data.players; + + this.showGameUI(); + this.updateWordDisplay(); + this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`; + + if (!this.isCurrentUserDrawer()) { + this.guessContainer.style.display = 'flex'; + if (data.wordLength > 0) { + this.letterInput.disabled = false; + this.guessBtn.disabled = false; + this.letterInput.placeholder = 'Proposez une lettre ou le mot...'; + } else { + this.letterInput.disabled = true; + this.guessBtn.disabled = true; + this.letterInput.placeholder = 'En attente du mot...'; + } + } + } + }); + } + + disconnectGameSocket() { + if (this.socket) { + this.socket.emit('game-leave-room'); + } + } + + // ============================================ + // UI HELPERS + // ============================================ + + isLoggedIn() { + return !!localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + } + + updateTabsAccess() { + const loggedIn = this.isLoggedIn(); + + this.createTab.disabled = !loggedIn; + this.createTab.style.opacity = loggedIn ? '1' : '0.5'; + this.createTab.title = loggedIn ? '' : 'Connectez-vous pour creer un salon'; + + if (!loggedIn && this.currentTab === 'create') { + this.switchTab('browse'); + } + } + + handleLogout() { + this.disconnectGameSocket(); + if (this.currentRoom) { + this.exitLobby(); + } + this.updateTabsAccess(); + if (this.currentTab !== 'browse') { + this.switchTab('browse'); + } + } + + switchTab(tabName) { + if (tabName === 'lobby' && !this.currentRoom) { + return; + } + + if (tabName === 'create' && !this.isLoggedIn()) { + this.showMessage('Connectez-vous pour creer un salon', 'info'); + return; + } + + this.currentTab = tabName; + + [this.browseTab, this.createTab, this.lobbyTab].forEach(tab => { + tab.classList.toggle(CSS.GAMEROOM_TAB_ACTIVE, tab.dataset.tab === tabName); + }); + + this.createContainer.style.display = tabName === 'create' ? 'flex' : 'none'; + this.lobbyContainer.style.display = tabName === 'lobby' ? 'flex' : 'none'; + this.list.style.display = tabName === 'browse' ? 'flex' : 'none'; + + this.loadCurrentTab(); + } + + loadCurrentTab() { + switch (this.currentTab) { + case 'browse': + this.loadRooms(); + // Connect to socket to receive real-time room updates + this.ensureSocketConnected(); + break; + case 'create': + this.message.textContent = ''; + this.ensureSocketConnected(); + break; + case 'lobby': + if (this.currentRoom) { + this.loadLobby(); + } + break; + } + } + + async ensureSocketConnected() { + if (!this.isLoggedIn()) return; + if (this.socket?.connected) return; + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return; + + await this.loadSocketIO(); + + const ioConfig = { + auth: { token }, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + transports: ['websocket', 'polling'] + }; + + const altPort = window.GLOBAL_CHAT_ALT_PORT; + if (altPort) { + const host = location.hostname || 'localhost'; + this.socket = io(`http://${host}:${altPort}`, ioConfig); + } else { + this.socket = io(ioConfig); + } + + this.setupGameSocketListeners(); + } + + getHeaders() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; + } + + async loadRooms() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour voir les salons', 'info'); + return; + } + + try { + const response = await fetch(API.ROOMS.LIST, { + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.roomsList = data || []; + this.renderRoomsList(this.roomsList); + } catch (error) { + console.error('Load rooms error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + async checkCurrentRoom() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + return null; + } + + try { + const response = await fetch(API.ROOMS.CURRENT, { + headers: this.getHeaders() + }); + + // 204 No Content means user is not in any room + if (response.status === 204) { + return null; + } + + if (!response.ok) { + return null; + } + + const data = await response.json(); + if (data && data.id) { + this.currentRoom = data; + this.enterLobby(data); + return data; + } + return null; + } catch (error) { + console.error('Check current room error:', error); + return null; + } + } + + roomNameExists(name) { + const normalizedName = name.toLowerCase().trim(); + return this.roomsList.some(room => room.name.toLowerCase().trim() === normalizedName); + } + + renderRoomsList(rooms) { + this.list.innerHTML = ''; + this.message.textContent = ''; + + if (rooms.length === 0) { + this.showMessage('Aucun salon disponible', 'info'); + return; + } + + rooms.forEach(room => { + const item = this.createRoomItem(room); + this.list.appendChild(item); + }); + } + + createRoomItem(room) { + const item = this.createElement('div', CSS.GAMEROOM_ITEM); + + const name = this.createElement('span', CSS.GAMEROOM_NAME, { + text: room.name + }); + + const players = this.createElement('span', CSS.GAMEROOM_PLAYERS, { + text: `${room.player_count || 0}/${room.max_players || 8}` + }); + + const actions = this.createElement('div', CSS.GAMEROOM_ACTIONS); + + const joinBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], { + text: 'Rejoindre' + }); + joinBtn.addEventListener('click', () => this.joinRoom(room.id)); + actions.appendChild(joinBtn); + + item.append(name, players, actions); + return item; + } + + async createRoom() { + const name = this.roomNameInput.value.trim(); + if (!name) { + this.showMessage('Entrez un nom pour le salon', 'error'); + return; + } + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour creer un salon', 'info'); + return; + } + + if (this.currentRoom) { + this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error'); + return; + } + + try { + const currentResponse = await fetch(API.ROOMS.CURRENT, { + headers: this.getHeaders() + }); + if (currentResponse.ok && currentResponse.status !== 204) { + const currentData = await currentResponse.json(); + if (currentData && currentData.id) { + this.currentRoom = currentData; + this.enterLobby(currentData); + this.showMessage('Vous etes deja dans un salon', 'error'); + return; + } + } + } catch (e) { + // Continue + } + + try { + const listResponse = await fetch(API.ROOMS.LIST, { + headers: this.getHeaders() + }); + if (listResponse.ok) { + this.roomsList = await listResponse.json() || []; + } + } catch (e) { + // Continue + } + + if (this.roomNameExists(name)) { + this.showMessage('Un salon avec ce nom existe deja', 'error'); + return; + } + + try { + const response = await fetch(API.ROOMS.CREATE, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ name }) + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.roomNameInput.value = ''; + this.currentRoom = data; + this.showMessage('Salon cree', 'success'); + eventBus.emit(Events.ROOM_CREATED, data); + this.enterLobby(data); + } catch (error) { + console.error('Create room error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + async joinRoom(roomId) { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour rejoindre', 'info'); + return; + } + + if (this.currentRoom) { + this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error'); + return; + } + + try { + const currentResponse = await fetch(API.ROOMS.CURRENT, { + headers: this.getHeaders() + }); + if (currentResponse.ok && currentResponse.status !== 204) { + const currentData = await currentResponse.json(); + if (currentData && currentData.id) { + this.currentRoom = currentData; + this.enterLobby(currentData); + this.showMessage('Vous etes deja dans un salon', 'error'); + return; + } + } + } catch (e) { + // Continue + } + + try { + const response = await fetch(API.ROOMS.JOIN(roomId), { + method: 'POST', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + const roomResponse = await fetch(API.ROOMS.GET(roomId), { + headers: this.getHeaders() + }); + const roomData = await roomResponse.json(); + + this.currentRoom = roomData; + eventBus.emit(Events.ROOM_JOINED, roomData); + this.enterLobby(roomData); + } catch (error) { + console.error('Join room error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + enterLobby(room) { + this.currentRoom = room; + this.lobbyTab.style.display = 'block'; + this.lobbyTitle.textContent = room.name; + this.switchTab('lobby'); + + // Connect to WebSocket for real-time sync + this.connectToGameSocket(); + } + + async loadLobby() { + if (!this.currentRoom) return; + + try { + const response = await fetch(API.ROOMS.PLAYERS(this.currentRoom.id), { + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.renderPlayersList(data || []); + } catch (error) { + console.error('Load lobby error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + renderPlayersList(players) { + this.playerList.innerHTML = ''; + + if (players.length === 0) { + const empty = this.createElement('div', 'gameroom__empty', { + text: 'Aucun joueur' + }); + this.playerList.appendChild(empty); + return; + } + + players.forEach(player => { + const item = this.createElement('div', CSS.GAMEROOM_PLAYER); + + const avatar = this.createElement('img', CSS.GAMEROOM_PLAYER_AVATAR, { + alt: player.username + }); + avatar.src = player.avatar_url || '/avatar/default.png'; + + const name = this.createElement('span', CSS.GAMEROOM_PLAYER_NAME, { + text: player.username + }); + + const statsContainer = this.createElement('div', 'gameroom__player-stats'); + + const score = this.createElement('span', CSS.GAMEROOM_PLAYER_SCORE, { + text: `${player.score || 0} pts` + }); + + const totalPoints = this.createElement('span', 'gameroom__player-total', { + text: `Total: ${player.total_points || 0}` + }); + + statsContainer.append(score, totalPoints); + item.append(avatar, name, statsContainer); + this.playerList.appendChild(item); + }); + } + + async leaveRoom() { + if (!this.currentRoom) return; + + // End game if playing + if (this.gameState.isPlaying) { + this.endGame(); + } + + this.disconnectGameSocket(); + + try { + const response = await fetch(API.ROOMS.LEAVE(this.currentRoom.id), { + method: 'POST', + headers: this.getHeaders() + }); + + if (!response.ok) { + const data = await response.json(); + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + eventBus.emit(Events.ROOM_LEFT, this.currentRoom); + this.exitLobby(); + } catch (error) { + console.error('Leave room error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + exitLobby() { + this.currentRoom = null; + this.lobbyTab.style.display = 'none'; + this.playerList.innerHTML = ''; + this.lobbyTitle.textContent = ''; + this.resetGameUI(); + this.switchTab('browse'); + } + + showMessage(text, type = 'info') { + this.message.textContent = text; + this.message.className = CSS.MESSAGE; + + if (type === 'success') { + this.message.classList.add(CSS.MESSAGE_SUCCESS); + } else if (type === 'error') { + this.message.classList.add(CSS.MESSAGE_ERROR); + } else { + this.message.classList.add(CSS.MESSAGE_INFO); + } + } + + // ============================================ + // LOGIQUE DU JEU + // ============================================ + + getCurrentUsername() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return null; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.username || payload.sub || 'Joueur'; + } catch { + return 'Joueur'; + } + } + + isCurrentUserDrawer() { + return this.gameState.drawer === this.getCurrentUsername(); + } + + showGameUI() { + this.gameContainer.style.display = 'flex'; + this.playerList.style.display = 'none'; + this.lobbyButtons.style.display = 'none'; + this.clearCanvas(); + this.guessHistory.innerHTML = ''; + } + + resetGameUI() { + this.gameState.isPlaying = false; + this.gameState.currentWord = ''; + this.gameState.wordLength = 0; + this.gameState.revealedLetters = []; + this.gameState.revealedWord = []; + this.gameState.drawer = null; + + this.gameContainer.style.display = 'none'; + this.playerList.style.display = 'flex'; + this.lobbyButtons.style.display = 'flex'; + + this.wordInputContainer.style.display = 'none'; + this.guessContainer.style.display = 'none'; + this.drawTools.style.display = 'none'; + + this.currentDrawerInfo.classList.remove('gameroom__drawer-info--winner'); + } + + async startGame() { + console.log('startGame called'); + + // Load player list + await this.loadLobby(); + + const playerElements = this.playerList.querySelectorAll('.gameroom__player-name'); + const players = Array.from(playerElements).map(el => el.textContent); + + console.log('Players found:', players); + + if (players.length < 1) { + this.showMessage('Il faut au moins 1 joueur pour jouer', 'error'); + return; + } + + const drawer = players[0]; + + console.log('Socket connected:', this.socket?.connected, 'Socket ready:', this.socketReady); + + // Send start game event via WebSocket + if (this.socket?.connected) { + console.log('Emitting game-start event'); + this.socket.emit('game-start', { drawer, players }); + } else { + console.log('No socket, using local fallback'); + // Fallback local - start immediately + this.gameState.isPlaying = true; + this.gameState.players = players; + this.gameState.drawer = drawer; + this.gameState.currentPlayerIndex = 0; + this.showGameUI(); + this.setupRound(); + } + } + + setupRound() { + this.gameState.currentWord = ''; + this.gameState.wordLength = 0; + this.gameState.revealedLetters = []; + this.gameState.revealedWord = []; + this.gameState.guessedLetters = []; + + this.currentDrawerInfo.textContent = `C'est au tour de ${this.gameState.drawer} de dessiner`; + this.currentDrawerInfo.classList.remove('gameroom__drawer-info--winner'); + this.wordDisplay.textContent = ''; + this.guessHistory.innerHTML = ''; + this.clearCanvas(); + + if (this.isCurrentUserDrawer()) { + // Drawer chooses a word + this.wordInputContainer.style.display = 'flex'; + this.guessContainer.style.display = 'none'; + this.drawTools.style.display = 'none'; + this.currentDrawerInfo.textContent = 'Choisissez un mot a faire deviner'; + } else { + // Others see the guess input (disabled while waiting for word) + this.wordInputContainer.style.display = 'none'; + this.guessContainer.style.display = 'flex'; + this.drawTools.style.display = 'none'; + this.letterInput.disabled = true; + this.guessBtn.disabled = true; + this.letterInput.placeholder = 'En attente du mot...'; + this.currentDrawerInfo.textContent = `${this.gameState.drawer} choisit un mot...`; + } + } + + confirmWord() { + const word = this.wordInput.value.trim().toLowerCase(); + if (!word || word.length < 2) { + this.showMessage('Le mot doit faire au moins 2 lettres', 'error'); + return; + } + + if (!/^[a-z]+$/.test(word)) { + this.showMessage('Le mot ne doit contenir que des lettres', 'error'); + return; + } + + this.gameState.currentWord = word; + this.gameState.wordLength = word.length; + this.gameState.revealedLetters = new Array(word.length).fill(false); + this.gameState.revealedWord = new Array(word.length).fill('_'); + + this.wordInput.value = ''; + this.wordInputContainer.style.display = 'none'; + this.drawTools.style.display = 'flex'; + + // Send word to server via WebSocket + if (this.socket?.connected) { + this.socket.emit('game-set-word', { word }); + } + + this.updateWordDisplay(); + this.currentDrawerInfo.textContent = `Dessinez pour faire deviner le mot (${word.length} lettres)`; + } + + updateWordDisplay() { + // If drawer, show from currentWord + if (this.isCurrentUserDrawer() && this.gameState.currentWord) { + let display = ''; + for (let i = 0; i < this.gameState.currentWord.length; i++) { + if (this.gameState.revealedLetters && this.gameState.revealedLetters[i]) { + display += this.gameState.currentWord[i] + ' '; + } else { + display += '_ '; + } + } + this.wordDisplay.textContent = display.trim(); + return; + } + + // For guessers, use revealedWord from server + if (this.gameState.revealedWord && this.gameState.revealedWord.length > 0) { + this.wordDisplay.textContent = this.gameState.revealedWord.join(' '); + return; + } + + // Fallback: show underscores based on wordLength + if (this.gameState.wordLength > 0) { + this.wordDisplay.textContent = '_ '.repeat(this.gameState.wordLength).trim(); + } + } + + makeGuess() { + const guess = this.letterInput.value.trim().toLowerCase(); + if (!guess) return; + + this.letterInput.value = ''; + + // Send guess via WebSocket + if (this.socket?.connected) { + this.socket.emit('game-guess', { guess }); + } else { + // Fallback local (for testing) + this.processGuessLocally(guess); + } + } + + processGuessLocally(guess) { + const username = this.getCurrentUsername(); + + if (guess.length > 1) { + const success = guess === this.gameState.currentWord; + this.addGuessToHistory(guess, success, 'word', username); + if (success) { + this.gameState.revealedWord = this.gameState.currentWord.split(''); + this.wordFound(this.gameState.currentWord, username); + } + return; + } + + if (this.gameState.guessedLetters.includes(guess)) { + this.showMessage('Lettre deja proposee', 'info'); + return; + } + + this.gameState.guessedLetters.push(guess); + + let found = false; + for (let i = 0; i < this.gameState.currentWord.length; i++) { + if (this.gameState.currentWord[i] === guess) { + this.gameState.revealedLetters[i] = true; + this.gameState.revealedWord[i] = guess; + found = true; + } + } + + this.addGuessToHistory(guess, found, 'letter', username); + this.updateWordDisplay(); + + if (this.gameState.revealedLetters.every(r => r)) { + this.wordFound(this.gameState.currentWord, username); + } + } + + addGuessToHistory(guess, success, type, username, points = 0) { + const item = this.createElement('div', 'gameroom__guess-item'); + item.classList.add(success ? 'gameroom__guess-item--success' : 'gameroom__guess-item--fail'); + + const typeText = type === 'letter' ? 'lettre' : 'mot'; + const pointsText = points !== 0 ? ` (${points > 0 ? '+' : ''}${points} pts)` : ''; + + if (success) { + item.textContent = `${username}: "${guess}" - Bonne ${typeText}!${pointsText}`; + } else { + item.textContent = `${username}: "${guess}" - Mauvais ${typeText}${pointsText}`; + } + + this.guessHistory.appendChild(item); + this.guessHistory.scrollTop = this.guessHistory.scrollHeight; + } + + updateScoresDisplay(scores) { + if (!scores) return; + this.gameState.scores = scores; + + // Update scores display in game UI + if (this.scoresDisplay) { + const sortedScores = Object.entries(scores) + .sort((a, b) => b[1] - a[1]) + .map(([name, score]) => `${name}: ${score}`) + .join(' | '); + this.scoresDisplay.textContent = sortedScores; + } + + // Update player list with scores if visible + const playerItems = this.playerList.querySelectorAll('.gameroom__player'); + playerItems.forEach(item => { + const nameEl = item.querySelector('.gameroom__player-name'); + const scoreEl = item.querySelector('.gameroom__player-score'); + if (nameEl && scoreEl) { + const playerName = nameEl.textContent; + const score = scores[playerName] || 0; + scoreEl.textContent = `${score} pts`; + } + }); + } + + wordFound(word, winner, drawerBonus = 0) { + let message = `${winner} a trouve le mot: ${word}!`; + if (drawerBonus > 0 && this.gameState.drawer) { + message += ` (${this.gameState.drawer} +${drawerBonus} pts)`; + } + this.currentDrawerInfo.textContent = message; + this.currentDrawerInfo.classList.add('gameroom__drawer-info--winner'); + + this.guessContainer.style.display = 'none'; + this.drawTools.style.display = 'none'; + + // Reveal full word + this.wordDisplay.textContent = word.split('').join(' '); + + // Auto next round after delay + setTimeout(() => { + if (this.gameState.isPlaying) { + this.nextRound(); + } + }, 3000); + } + + nextRound() { + // Move to next player + this.gameState.currentPlayerIndex = (this.gameState.currentPlayerIndex + 1) % this.gameState.players.length; + const nextDrawer = this.gameState.players[this.gameState.currentPlayerIndex]; + + if (this.socket?.connected) { + this.socket.emit('game-next-round', { drawer: nextDrawer }); + } else { + this.gameState.drawer = nextDrawer; + this.setupRound(); + } + } + + backToLobby() { + // Return to lobby without ending game for others + this.resetGameUI(); + this.loadLobby(); + } + + endGame() { + if (this.socket?.connected) { + this.socket.emit('game-end'); + } + this.resetGameUI(); + this.showMessage('Jeu termine', 'info'); + } +} diff --git a/Transcendence/srcs/frontend/src/index.html b/Transcendence/srcs/frontend/src/index.html index 0003109..771cbcc 100644 --- a/Transcendence/srcs/frontend/src/index.html +++ b/Transcendence/srcs/frontend/src/index.html @@ -17,6 +17,7 @@ + diff --git a/Transcendence/srcs/frontend/src/style.css b/Transcendence/srcs/frontend/src/style.css index 4714622..3809dcf 100644 --- a/Transcendence/srcs/frontend/src/style.css +++ b/Transcendence/srcs/frontend/src/style.css @@ -460,6 +460,34 @@ body { display: none; } +.avatar__stats { + display: flex; + justify-content: center; + gap: var(--spacing-lg); + margin: var(--spacing-md) 0; + padding: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); +} + +.avatar__stat { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); +} + +.avatar__stat-label { + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.avatar__stat-value { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-success); +} + /* ============================================ EASTER EGG BUTTON ============================================ */ @@ -588,12 +616,23 @@ body { border: 2px solid var(--color-surface-light); } -.friends__name { +.friends__info { flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.friends__name { font-size: var(--font-size-md); font-weight: 500; } +.friends__stats { + font-size: var(--font-size-sm); + color: var(--color-success); +} + .friends__actions { display: flex; gap: var(--spacing-xs); @@ -609,3 +648,316 @@ body { color: var(--color-text-muted); padding: var(--spacing-lg); } + +/* ============================================ + GAME ROOM WINDOW + ============================================ */ +.gameroom-window { + width: 420px; + height: 480px; +} + +.gameroom__tabs { + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + +.gameroom__tab { + flex: 1; + padding: var(--spacing-sm); + background: var(--color-surface); + border: 1px solid var(--color-surface-light); + color: var(--color-text); + cursor: pointer; + font-size: var(--font-size-sm); + transition: all var(--transition-fast); +} + +.gameroom__tab:hover { + background: var(--color-surface-light); +} + +.gameroom__tab--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +.gameroom__content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.gameroom__create { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.gameroom__list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.gameroom__item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); +} + +.gameroom__name { + flex: 1; + font-size: var(--font-size-md); + font-weight: 500; +} + +.gameroom__players { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-surface-light); + border-radius: var(--radius-sm); +} + +.gameroom__actions { + display: flex; + gap: var(--spacing-xs); +} + +.gameroom__actions .btn { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-sm); +} + +.gameroom__lobby { + display: flex; + flex-direction: column; + flex: 1; + gap: var(--spacing-sm); +} + +.gameroom__lobby-title { + margin: 0; + font-size: var(--font-size-lg); + text-align: center; + color: var(--color-success); +} + +.gameroom__player-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); + padding: var(--spacing-sm); +} + +.gameroom__player { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-surface-light); + border-radius: var(--radius-sm); +} + +.gameroom__player-avatar { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + object-fit: cover; + border: 2px solid var(--color-surface-light); +} + +.gameroom__player-name { + flex: 1; + font-size: var(--font-size-md); +} + +.gameroom__player-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.gameroom__player-score { + font-size: var(--font-size-sm); + color: var(--color-success); + font-weight: 500; +} + +.gameroom__player-total { + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.gameroom__empty { + text-align: center; + color: var(--color-text-muted); + padding: var(--spacing-lg); +} + +/* ============================================ + GAME - JEU DU PENDU/DESSIN + ============================================ */ +.gameroom__lobby-buttons { + display: flex; + gap: var(--spacing-sm); + margin-top: auto; +} + +.gameroom__lobby-buttons .btn { + flex: 1; +} + +.gameroom__game { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + flex: 1; +} + +.gameroom__game-info { + text-align: center; +} + +.gameroom__drawer-info { + font-size: var(--font-size-md); + color: var(--color-text-muted); + padding: var(--spacing-xs); +} + +.gameroom__scores-display { + font-size: var(--font-size-sm); + color: var(--color-success); + padding: var(--spacing-xs); + background: var(--color-surface); + border-radius: var(--radius-sm); + margin-top: var(--spacing-xs); +} + +.gameroom__drawer-info--winner { + color: var(--color-success); + font-weight: bold; + animation: pulse 0.5s ease-in-out 3; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.gameroom__word-display { + font-size: var(--font-size-xl); + font-family: monospace; + text-align: center; + letter-spacing: 8px; + padding: var(--spacing-md); + background: var(--color-surface); + border-radius: var(--radius-md); + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-success); +} + +.gameroom__canvas-container { + display: flex; + justify-content: center; +} + +.gameroom__canvas { + background: var(--color-surface-light); + border-radius: var(--radius-md); + cursor: crosshair; + border: 2px solid var(--color-surface-light); +} + +.gameroom__draw-tools { + display: flex; + gap: var(--spacing-sm); + justify-content: center; + align-items: center; +} + +.gameroom__color-picker { + width: 40px; + height: 32px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + background: transparent; +} + +.gameroom__word-input-container, +.gameroom__guess-container { + display: flex; + gap: var(--spacing-sm); +} + +.gameroom__word-input-container .input, +.gameroom__guess-container .input { + flex: 1; +} + +.gameroom__guess-container .input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gameroom__guess-container .btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gameroom__guess-history { + flex: 1; + min-height: 60px; + max-height: 100px; + overflow-y: auto; + background: var(--color-surface); + border-radius: var(--radius-md); + padding: var(--spacing-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.gameroom__guess-item { + font-size: var(--font-size-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); +} + +.gameroom__guess-item--success { + background: rgba(60, 255, 1, 0.2); + color: var(--color-success); +} + +.gameroom__guess-item--fail { + background: rgba(255, 77, 77, 0.2); + color: var(--color-error); +} + +.gameroom__game-buttons { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +.gameroom__game-buttons .btn { + flex: 1; +}