diff --git a/.env b/.env deleted file mode 100644 index d6b40d7..0000000 --- a/.env +++ /dev/null @@ -1,9 +0,0 @@ -POSTGRES_PASSWORD=coucou -JWT_SECRET=superlongsecretkeyatleast32characterspleasenevercommitthis -POSTGRES_DB=database -POSTGRES_HOST=database -POSTGRES_USER=user - -GITHUB_CLIENT_ID=Ov23liYIX8bJcdamjQJm -GITHUB_CLIENT_SECRET=9db75e695a8c028a80bb2e9b5604b2e44f76fb26 -GITHUB_CALLBACK_URL=http://localhost:8080/api/auth/github/callback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc42f23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Environment / secrets +.env +.env.* +.env.local +.env.production + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Logs +*.log +logs/ + +# Build output +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker volumes / data +postgres-data/ +data/ + +# OS +Thumbs.db diff --git a/Transcendence/.gitignore b/Transcendence/.gitignore index e909572..b60ca14 100644 --- a/Transcendence/.gitignore +++ b/Transcendence/.gitignore @@ -1,2 +1,35 @@ +# macOS +.DS_Store srcs/.DS_Store -*.DS_Store \ No newline at end of file +*.DS_Store + +# Environment / secrets +.env +.env.* + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* + +# Logs +*.log +logs/ + +# Build output +dist/ +build/ + +# Uploads utilisateurs (garder uniquement default.png) +srcs/backend/avatar/* +!srcs/backend/avatar/default.png + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker volumes / data +postgres-data/ +data/ diff --git a/Transcendence/README.md b/Transcendence/README.md deleted file mode 100644 index ab014cb..0000000 --- a/Transcendence/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Transcendence - -Exemple d'../.env fonctionnel: - -POSTGRES_PASSWORD=coucou -JWT_SECRET=superlongsecretkeyatleast32characterspleasenevercommitthis -POSTGRES_DB=database -POSTGRES_HOST=database -POSTGRES_USER=user - -GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxxxxxxxxxxxxx -GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -GITHUB_CALLBACK_URL=http://localhost:8080/api/auth/github/callback - -Les Variables d'env GITHUB_* sont a generer sur ce lien 'https://github.com/settings/applications/new' - - -Gestion de friendship dans POSTGRESQL: -'pending' → demande envoyée -'accepted' → amis -'blocked' → bloqué -'rejected' → refusé - -Ressource: - https://www.postgresql.org/docs/ - https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps - https://docs.github.com/fr/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app - -///////////////////////////////////////////////////////////////////////////////////////// - -BACKEND - -17/01 - Ajout du service/route pour le systeme de game_room - permet aux joueurs de creer et rejoindre des rooms - une room vide est automatiquement detruite. - - Presence d'une fonction affichant toutes les rooms joignables - ainsi qu'une autre fonction pour afficher tous les joueurs de la room avec - leur scores et leur etat actuel. - - Aucun moyen de changer l'etat de la room de waiting a en cours ou finished - ca attendra le systeme du jeu - -21/01 - Ajout du service/route pour le systeme d'avatar - permet aux utilisateurs de changer ou supprimer leur avatar actuel - - Ajout egalement d'une simple fonction pour recuperer l'avatar d'un utilisateur (pour le frontend) - -DATABASE - -17/01 Ajout des tables game_rooms, game_players, game_rounds, words - - nom, status et parametres de la game - - joueurs dans la game, leur scores et leur role actuel (dessinateur, devineur) - - historique de la game, qui a dessine quoi precedemment ainsi que les timers des rounds, sera aussi utile si on veut faire les stats de compte a l'avenir. - - contient la liste des mots utilisable par les joueurs - -21/01 Ajout de avatar_url dans la table users \ No newline at end of file diff --git a/Transcendence/docker-compose.yml b/Transcendence/docker-compose.yml index f3a647a..7445d2f 100644 --- a/Transcendence/docker-compose.yml +++ b/Transcendence/docker-compose.yml @@ -24,8 +24,6 @@ services: build: ./srcs/backend expose: - "3001" - # ports: - # - "3001:3001" depends_on: - database volumes: diff --git a/Transcendence/main.c b/Transcendence/main.c deleted file mode 100644 index efc4561..0000000 --- a/Transcendence/main.c +++ /dev/null @@ -1,7 +0,0 @@ -#include - -int main() -{ - printf("Program received signal SIGSEGV, Segmentation Fault.\n__GI_raise (sig=sig@entry=6) at 0x54ffg67a ../sysdeps/unix/sysv/linux/c_balo.ken:666\nSee #845515 --api-fuck-you to get more information about it"); - return 1; -} diff --git a/Transcendence/srcs/backend/avatar/user_1_1769596136442.jpg b/Transcendence/srcs/backend/avatar/user_1_1769596136442.jpg deleted file mode 100644 index 93327dd..0000000 Binary files a/Transcendence/srcs/backend/avatar/user_1_1769596136442.jpg and /dev/null differ diff --git a/Transcendence/srcs/backend/db.js b/Transcendence/srcs/backend/db.js index 5bf4ff6..bbb9c63 100644 --- a/Transcendence/srcs/backend/db.js +++ b/Transcendence/srcs/backend/db.js @@ -28,6 +28,53 @@ 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; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_best_score') THEN + ALTER TABLE users ADD COLUMN tetris_best_score INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_wins') THEN + ALTER TABLE users ADD COLUMN tetris_wins INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_games_played') THEN + ALTER TABLE users ADD COLUMN tetris_games_played INT DEFAULT 0; + END IF; + END $$; + `); + // Create tetris_game_history table if not exists + await pool.query(` + CREATE TABLE IF NOT EXISTS tetris_game_history ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + score INT NOT NULL DEFAULT 0, + game_type VARCHAR(10) NOT NULL DEFAULT 'solo', + result VARCHAR(10) DEFAULT NULL, + played_at TIMESTAMP DEFAULT NOW() + ); + `); + console.log('Migrations completed!'); + } + catch (err) + { + console.error('Error running migrations:', err); + } +} + async function createTables() { try @@ -39,6 +86,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() ); @@ -108,6 +158,15 @@ async function createTables() started_at TIMESTAMP DEFAULT NOW(), ended_at TIMESTAMP ); + + CREATE TABLE IF NOT EXISTS tetris_game_history ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + score INT NOT NULL DEFAULT 0, + game_type VARCHAR(10) NOT NULL DEFAULT 'solo', + result VARCHAR(10) DEFAULT NULL, + played_at TIMESTAMP DEFAULT NOW() + ); `); console.log('Tables created!'); } @@ -148,6 +207,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..81886f1 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,38 @@ router.get('/', authenticateToken, async(req, res) => } }); +// Get list of rooms currently being played (for spectators) +router.get('/playing', authenticateToken, async(req, res) => +{ + try + { + const rooms = await gameRoomService.listPlayingRooms(); + res.json(rooms); + } + catch (err) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + +// 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 +88,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 +109,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 +133,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) @@ -95,4 +149,37 @@ router.post('/:roomId/leave', authenticateToken, async(req, res) => } }); +// Join a room as spectator +router.post('/:roomId/spectate', authenticateToken, async(req, res) => +{ + try + { + const room = await gameRoomService.spectateRoom(req.params.roomId, req.user.userId); + res.json(room); + } + catch(err) + { + console.error(err); + if (err.message.includes('not found') || err.message.includes('not in playing') || err.message.includes('already in')) + res.status(400).json({error: err.message}); + else + res.status(500).json({error: err.message}); + } +}); + +// Leave spectator mode +router.post('/:roomId/leave-spectate', authenticateToken, async(req, res) => +{ + try + { + await gameRoomService.leaveSpectateRoom(req.params.roomId, req.user.userId); + res.json({message: 'Left spectator mode successfully'}); + } + catch(err) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + export default router; \ No newline at end of file diff --git a/Transcendence/srcs/backend/routes/player_stats.js b/Transcendence/srcs/backend/routes/player_stats.js new file mode 100644 index 0000000..fe78e01 --- /dev/null +++ b/Transcendence/srcs/backend/routes/player_stats.js @@ -0,0 +1,120 @@ +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 general 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' }); + } +}); + +// Save tetris score (solo) — updates best score if higher + saves to history +router.post('/tetris/score', authenticateToken, async (req, res) => { + try { + const { score } = req.body; + if (typeof score !== 'number' || score < 0) { + return res.status(400).json({ error: 'Invalid score' }); + } + const bestScore = await playerStatsService.updateTetrisBestScore(req.user.userId, score); + await playerStatsService.incrementTetrisGamesPlayed(req.user.userId); + await playerStatsService.addTetrisGameHistory(req.user.userId, score, 'solo', null); + res.json({ bestScore }); + } catch (err) { + console.error('Error saving tetris score:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Tetris best score leaderboard +router.get('/tetris/leaderboard/score', authenticateToken, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 10, 50); + const leaderboard = await playerStatsService.getTetrisBestScoreLeaderboard(limit); + res.json(leaderboard); + } catch (err) { + console.error('Error getting tetris score leaderboard:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Tetris duel wins leaderboard +router.get('/tetris/leaderboard/wins', authenticateToken, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 10, 50); + const leaderboard = await playerStatsService.getTetrisDuelWinsLeaderboard(limit); + res.json(leaderboard); + } catch (err) { + console.error('Error getting tetris wins leaderboard:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Current user's rank by tetris best score +router.get('/tetris/rank/score', authenticateToken, async (req, res) => { + try { + const rank = await playerStatsService.getTetrisScoreRank(req.user.userId); + res.json({ rank }); + } catch (err) { + console.error('Error getting tetris score rank:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get current user's tetris game history (last 15) +router.get('/tetris/history', authenticateToken, async (req, res) => { + try { + const history = await playerStatsService.getTetrisGameHistory(req.user.userId); + res.json(history); + } catch (err) { + console.error('Error getting tetris history:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Current user's rank by tetris duel wins +router.get('/tetris/rank/wins', authenticateToken, async (req, res) => { + try { + const rank = await playerStatsService.getTetrisDuelWinsRank(req.user.userId); + res.json({ rank }); + } catch (err) { + console.error('Error getting tetris wins rank:', 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..e412297 100644 --- a/Transcendence/srcs/backend/services/game_room.js +++ b/Transcendence/srcs/backend/services/game_room.js @@ -44,6 +44,70 @@ async function listActiveRooms() return (result.rows); } +async function listPlayingRooms() +{ + const result = await query + ( + `SELECT r.*, COUNT(p.id) as player_count + FROM game_rooms r + LEFT JOIN game_players p ON r.id = p.room_id + WHERE r.status = 'playing' + GROUP BY r.id + ORDER BY player_count DESC, r.created_at DESC` + ); + return (result.rows); +} + +async function spectateRoom(roomId, userId) +{ + const room = await getRoomById(roomId); + if (!room) + throw new Error('Room not found'); + + if (room.status !== 'playing') + throw new Error('Room is not in playing status'); + + // Check if user is already a player in any active game + const playerInGame = await query + ( + `SELECT r.id, r.name, r.status + FROM game_rooms r + JOIN game_players gp ON r.id = gp.room_id + WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing') + LIMIT 1`, + [userId] + ); + + if (playerInGame.rows.length > 0) + { + const gameRoom = playerInGame.rows[0]; + if (gameRoom.id === parseInt(roomId)) + throw new Error('You cannot spectate a game you are playing in'); + else + throw new Error('You are already in an active game'); + } + + return (room); +} + +async function leaveSpectateRoom(roomId, userId) +{ + const playerCount = await query + ( + 'SELECT COUNT(*) FROM game_players WHERE room_id = $1', + [roomId] + ); + + if (parseInt(playerCount.rows[0].count) === 0) + { + await query + ( + 'DELETE FROM game_rooms WHERE id = $1', + [roomId] + ); + } +} + async function joinRoom(roomId, userId) { const room = await getRoomById(roomId); @@ -98,7 +162,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,12 +172,83 @@ 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 IN ('waiting', 'playing') + LIMIT 1`, + [userId] + ); + return (result.rows[0] || null); +} + +// Update room status (waiting, playing, ended) +async function updateRoomStatus(roomId, status) +{ + const validStatuses = ['waiting', 'playing', 'ended']; + if (!validStatuses.includes(status)) + throw new Error('Invalid status'); + + let updateQuery = 'UPDATE game_rooms SET status = $1'; + const params = [status, roomId]; + + if (status === 'playing') + { + updateQuery += ', started_at = NOW()'; + } + else if (status === 'ended') + { + updateQuery += ', ended_at = NOW()'; + } + + updateQuery += ' WHERE id = $2 RETURNING *'; + + const result = await query(updateQuery, params); + return (result.rows[0]); +} + +async function resetRoomScores(roomId) +{ + await query + ( + 'UPDATE game_players SET score = 0 WHERE room_id = $1', + [roomId] + ); +} + +async function cleanupEndedRooms() +{ + await query + ( + 'DELETE FROM game_players WHERE room_id IN (SELECT id FROM game_rooms WHERE status = $1)', + ['ended'] + ); + + await query + ( + 'DELETE FROM game_rooms WHERE status = $1', + ['ended'] + ); +} + export default { createRoom, getRoomById, listActiveRooms, + listPlayingRooms, + spectateRoom, + leaveSpectateRoom, joinRoom, leaveRoom, - getRoomPlayers + getRoomPlayers, + getCurrentRoom, + updateRoomStatus, + resetRoomScores, + cleanupEndedRooms }; \ 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..8663915 --- /dev/null +++ b/Transcendence/srcs/backend/services/player_stats.js @@ -0,0 +1,204 @@ +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, + tetris_best_score, tetris_wins, tetris_games_played, 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, + tetris_best_score, tetris_wins, tetris_games_played, 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; +} + +// Update tetris best score (only if new score is higher) +async function updateTetrisBestScore(userId, score) { + const result = await query( + `UPDATE users SET tetris_best_score = GREATEST(COALESCE(tetris_best_score, 0), $1) WHERE id = $2 RETURNING tetris_best_score`, + [score, userId] + ); + return result.rows[0]?.tetris_best_score || 0; +} + +// Increment tetris duel wins +async function incrementTetrisWins(userId) { + await query( + `UPDATE users SET tetris_wins = COALESCE(tetris_wins, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Increment tetris games played +async function incrementTetrisGamesPlayed(userId) { + await query( + `UPDATE users SET tetris_games_played = COALESCE(tetris_games_played, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Leaderboard: best tetris scores +async function getTetrisBestScoreLeaderboard(limit = 10) { + const result = await query( + `SELECT id, username, avatar_url, tetris_best_score, tetris_wins, tetris_games_played + FROM users + WHERE tetris_best_score > 0 + ORDER BY tetris_best_score DESC + LIMIT $1`, + [limit] + ); + return result.rows; +} + +// Leaderboard: most tetris duel wins +async function getTetrisDuelWinsLeaderboard(limit = 10) { + const result = await query( + `SELECT id, username, avatar_url, tetris_wins, tetris_games_played, tetris_best_score + FROM users + WHERE tetris_wins > 0 + ORDER BY tetris_wins DESC + LIMIT $1`, + [limit] + ); + return result.rows; +} + +// Add a game to tetris history (keep max 15 per user) +async function addTetrisGameHistory(userId, score, gameType = 'solo', result = null) { + await query( + `INSERT INTO tetris_game_history (user_id, score, game_type, result) VALUES ($1, $2, $3, $4)`, + [userId, score, gameType, result] + ); + // Keep only the 15 most recent entries + await query( + `DELETE FROM tetris_game_history + WHERE id IN ( + SELECT id FROM tetris_game_history + WHERE user_id = $1 + ORDER BY played_at DESC + OFFSET 15 + )`, + [userId] + ); +} + +// Get the last 15 games for a user +async function getTetrisGameHistory(userId) { + const result = await query( + `SELECT id, score, game_type, result, played_at + FROM tetris_game_history + WHERE user_id = $1 + ORDER BY played_at DESC + LIMIT 15`, + [userId] + ); + return result.rows; +} + +// Rank of a user by tetris best score (1 = best) +async function getTetrisScoreRank(userId) { + const result = await query( + `SELECT COUNT(*) + 1 AS rank + FROM users + WHERE tetris_best_score > COALESCE((SELECT tetris_best_score FROM users WHERE id = $1), 0)`, + [userId] + ); + return parseInt(result.rows[0]?.rank || 1); +} + +// Rank of a user by tetris duel wins (1 = best) +async function getTetrisDuelWinsRank(userId) { + const result = await query( + `SELECT COUNT(*) + 1 AS rank + FROM users + WHERE tetris_wins > COALESCE((SELECT tetris_wins FROM users WHERE id = $1), 0)`, + [userId] + ); + return parseInt(result.rows[0]?.rank || 1); +} + +export default { + getStatsByUserId, + getStatsByUsername, + addPoints, + addPointsByUsername, + incrementGamesPlayed, + incrementGamesWon, + getLeaderboard, + getUserIdByUsername, + updateTetrisBestScore, + incrementTetrisWins, + incrementTetrisGamesPlayed, + getTetrisBestScoreLeaderboard, + getTetrisDuelWinsLeaderboard, + getTetrisScoreRank, + getTetrisDuelWinsRank, + addTetrisGameHistory, + getTetrisGameHistory +}; diff --git a/Transcendence/srcs/backend/services/socket.js b/Transcendence/srcs/backend/services/socket.js index 6f5cea1..39a0780 100644 --- a/Transcendence/srcs/backend/services/socket.js +++ b/Transcendence/srcs/backend/services/socket.js @@ -1,9 +1,90 @@ 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 tetris duel rooms { roomCode → Map } +const tetrisRooms = new Map(); + +// Matchmaking queue for tetris +const tetrisMatchmakingQueue = []; + +// 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); + } +} + +// Check if a playing game has only 1 player left and auto-stop it +async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) { + if (!dbRoomId) return; + + try { + // Check if room is in 'playing' status + const room = await gameRoomService.getRoomById(dbRoomId); + if (!room || room.status !== 'playing') return; + + // Count remaining players + const players = await gameRoomService.getRoomPlayers(dbRoomId); + if (players.length <= 1) { + console.log(`Room ${dbRoomId} has only ${players.length} player(s) left, ending game`); + + // Update room status to 'ended' + await gameRoomService.updateRoomStatus(dbRoomId, 'waiting'); + await gameRoomService.resetRoomScores(dbRoomId); + + // Remove from game state + gameRooms.delete(roomId); + + // Notify remaining player(s) + io.to(roomId).emit('game-ended'); + io.to(roomId).emit('game-message', { + message: 'La partie s\'est terminée car il ne reste qu\'un seul joueur', + type: 'info' + }); + + // Broadcast updated rooms list + broadcastRoomsList(io); + } + } catch (err) { + console.error('Error checking single player game:', 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 +144,784 @@ 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; + + // Check if game should auto-stop due to single player + await checkAndStopSinglePlayerGame(io, roomId, dbRoomId); + // Broadcast updated rooms list + broadcastRoomsList(io); + } + }); + + // Join a game room as spectator + socket.on('game-spectate-room', async (data) => { + console.log('Received game-spectate-room from', socket.user.username, 'data:', data); + const roomId = `game-room-${data.roomId}`; + + // Verify room exists and is in playing status, and user is not already in a game + try { + const room = await gameRoomService.spectateRoom(data.roomId, socket.user.userId); + + socket.join(roomId); + socket.gameRoomId = roomId; + socket.gameRoomDbId = data.roomId; + socket.isSpectator = true; + console.log(`${socket.user.username} joined ${roomId} as spectator`); + + // Send confirmation + socket.emit('game-spectate-joined', { + roomId: data.roomId, + success: true + }); + + // Notify others that a spectator joined + socket.to(roomId).emit('game-spectator-joined', { + username: socket.user.username + }); + + // Send current game state + 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, + scores: gameState.scores || {} + }); + } + } catch (err) { + console.error('Error joining as spectator:', err); + socket.emit('game-spectate-error', { + error: err.message || 'Cannot spectate this room' + }); + } + }); + + // Leave spectator mode + socket.on('game-leave-spectate', () => { + if (socket.gameRoomId && socket.isSpectator) { + const roomId = socket.gameRoomId; + + socket.to(roomId).emit('game-spectator-left', { + username: socket.user.username + }); + + socket.leave(roomId); + console.log(`${socket.user.username} left spectator mode in ${roomId}`); + + socket.gameRoomId = null; + socket.gameRoomDbId = null; + socket.isSpectator = false; + } + }); + + + // Start the game + socket.on('game-start', async (data) => { + console.log('Received game-start event from', socket.user.username); + console.log('socket.gameRoomId:', socket.gameRoomId); + + // Security check: need at least 2 players + if (!data.players || data.players.length < 2) { + console.log('Game start rejected: not enough players'); + socket.emit('game-start-error', { + error: 'Il faut au moins 2 joueurs pour commencer' + }); + return; + } + + 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; + } + + // Verify player count from database + const dbRoomId = socket.gameRoomDbId; + if (dbRoomId) { + try { + const players = await gameRoomService.getRoomPlayers(dbRoomId); + if (players.length < 2) { + console.log(`Game start rejected: only ${players.length} player(s) in room`); + socket.emit('game-start-error', { + error: 'Il faut au moins 2 joueurs pour commencer' + }); + return; + } + } catch (err) { + console.error('Error checking player count:', err); + } + } + + // Update room status to 'playing' in database + if (dbRoomId) { + try { + await gameRoomService.updateRoomStatus(dbRoomId, 'playing'); + console.log(`Room ${dbRoomId} status updated to 'playing'`); + } catch (err) { + console.error('Error updating room status to playing:', err); + } + } + + // 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}`); + + // Broadcast updated rooms list (this room should no longer appear) + broadcastRoomsList(io); + }); + + // 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; + + // Spectators cannot draw + if (socket.isSpectator) { + console.log(`Spectator ${socket.user.username} tried to draw - blocked`); + 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; + + // Spectators cannot clear canvas + if (socket.isSpectator) return; + + socket.to(roomId).emit('game-clear-canvas'); + }); + + // Player makes a guess + socket.on('game-guess', (data) => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + // Spectators cannot make guesses + if (socket.isSpectator) { + console.log(`Spectator ${socket.user.username} tried to guess - blocked`); + 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 + }); + }); + + socket.on('leave-room-during-game', async () => { + const roomId = socket.gameRoomId; + const dbRoomId = socket.gameRoomDbId; + const userId = socket.user.userId; + const username = socket.user.username; + + if (!roomId || !dbRoomId || !userId) return; + + console.log(`Player ${username} leaving room ${roomId} during game`); + + try + { + socket.leave(roomId); + + await gameRoomService.leaveRoom(dbRoomId, userId); + + io.to(roomId).emit('game-player-left', { + username: username, + message: `${username} a quitté la partie` + }); + + const gameState = gameRooms.get(roomId); + if (gameState) + { + const wasDrawer = gameState.drawer === username; + + gameState.players = gameState.players.filter(p => p !== username); + delete gameState.scores[username]; + + io.to(roomId).emit('scores-updated', gameState.scores); + + // If the drawer left and there are still enough players, choose a new drawer + if (wasDrawer && gameState.players.length >= 1) + { + // Pick the next player as the new drawer + gameState.currentPlayerIndex = gameState.currentPlayerIndex % gameState.players.length; + const newDrawer = gameState.players[gameState.currentPlayerIndex]; + gameState.drawer = newDrawer; + + // Reset the word state for the new round + gameState.currentWord = ''; + gameState.revealedLetters = []; + gameState.revealedWord = []; + gameState.guessedLetters = []; + gameState.wrongGuesses = 0; + + console.log(`Drawer ${username} left, new drawer is ${newDrawer}`); + + io.to(roomId).emit('game-drawer-changed', { + newDrawer: newDrawer, + reason: 'drawer_left', + message: `${username} (dessinateur) a quitté, ${newDrawer} devient le nouveau dessinateur` + }); + } + } + + await checkAndStopSinglePlayerGame(io, roomId, dbRoomId); + + socket.gameRoomId = null; + socket.gameRoomDbId = null; + + broadcastRoomsList(io); + } + catch (err) + { + console.error('Error leaving room during game:', err); + } + }); + + // End game + socket.on('game-end', async () => { + const roomId = socket.gameRoomId; + if (!roomId) return; + + // Update room status to 'waiting' in database + const dbRoomId = socket.gameRoomDbId; + if (dbRoomId) { + try { + await gameRoomService.updateRoomStatus(dbRoomId, 'waiting'); + await gameRoomService.resetRoomScores(dbRoomId); + console.log(`Room ${dbRoomId} status updated to 'waiting'`); + } catch (err) { + console.error('Error updating room status to waiting:', err); + } + } + + gameRooms.delete(roomId); + io.to(roomId).emit('game-ended'); + + // Broadcast updated rooms list + broadcastRoomsList(io); + }); + + // ============================================ + // TETRIS DUEL EVENTS + // ============================================ + + socket.on('tetris:join', ({ roomCode }) => { + const code = String(roomCode).toUpperCase().slice(0, 8); + + // Quitter l'ancienne room tetris si besoin + if (socket.tetrisRoomCode) { + _tetrisLeave(socket); + } + + if (!tetrisRooms.has(code)) { + tetrisRooms.set(code, new Map()); + } + const room = tetrisRooms.get(code); + + if (room.size >= 2) { + socket.emit('tetris:room-status', { status: 'full', players: [] }); + return; + } + + room.set(socket.id, socket); + socket.tetrisRoomCode = code; + + const players = [...room.values()].map(s => s.user.username); + + if (room.size === 1) { + socket.emit('tetris:room-status', { status: 'waiting', players }); + } else { + // Notifier les deux joueurs + for (const s of room.values()) { + s.emit('tetris:room-status', { status: 'ready', players }); + } + // Notifier l'adversaire qu'un nouveau joueur a rejoint + for (const [id, s] of room) { + if (id !== socket.id) { + s.emit('tetris:opponent-joined', { username: socket.user.username }); + } + } + } + }); + + socket.on('tetris:leave', () => { + _tetrisLeave(socket); + }); + + // Relay pur : grid-update → adversaire uniquement + socket.on('tetris:grid-update', (data) => { + if (data.score !== undefined) socket.tetrisLastScore = data.score; + _tetrisRelayToOpponent(socket, 'tetris:grid-update', data); + }); + + // Relay pur : lines-cleared → adversaire uniquement + socket.on('tetris:lines-cleared', (data) => { + _tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data); + }); + + // Relay pur : shield-activated → adversaire uniquement + socket.on('tetris:shield-activated', () => { + _tetrisRelayToOpponent(socket, 'tetris:shield-activated', {}); + }); + + // Relay pur : shield-deactivated → adversaire uniquement + socket.on('tetris:shield-deactivated', () => { + _tetrisRelayToOpponent(socket, 'tetris:shield-deactivated', {}); + }); + + // start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur) + socket.on('tetris:start-duel', () => { + const code = socket.tetrisRoomCode; + if (!code) return; + const room = tetrisRooms.get(code); + if (!room || room.size < 2) return; + for (const s of room.values()) { + s.emit('tetris:start-duel'); + } + }); + + // pause → relayé aux DEUX joueurs de la room + socket.on('tetris:pause', () => { + const code = socket.tetrisRoomCode; + if (!code) return; + const room = tetrisRooms.get(code); + if (!room) return; + for (const s of room.values()) { + s.emit('tetris:pause'); + } + }); + + // stop → relayé aux DEUX joueurs de la room + socket.on('tetris:stop', () => { + const code = socket.tetrisRoomCode; + if (!code) return; + const room = tetrisRooms.get(code); + if (!room) return; + for (const s of room.values()) { + s.emit('tetris:stop'); + } + }); + + // settings → relayé aux DEUX joueurs de la room + socket.on('tetris:settings', (data) => { + const code = socket.tetrisRoomCode; + if (!code) return; + const room = tetrisRooms.get(code); + if (!room) return; + for (const s of room.values()) { + s.emit('tetris:settings', data); + } + }); + + // game-over → save stats + relay opponent-game-over + socket.on('tetris:game-over', async (data) => { + const loserId = socket.user.userId; + try { + await playerStatsService.updateTetrisBestScore(loserId, data.score || 0); + await playerStatsService.incrementTetrisGamesPlayed(loserId); + await playerStatsService.addTetrisGameHistory(loserId, data.score || 0, 'duel', 'loss'); + } catch (err) { + console.error('Error saving tetris loser stats:', err); + } + + const code = socket.tetrisRoomCode; + if (code) { + const room = tetrisRooms.get(code); + if (room) { + for (const [id, s] of room) { + if (id !== socket.id) { + s.emit('tetris:opponent-game-over', data); + try { + await playerStatsService.incrementTetrisWins(s.user.userId); + await playerStatsService.incrementTetrisGamesPlayed(s.user.userId); + const winnerScore = s.tetrisLastScore || 0; + await playerStatsService.addTetrisGameHistory(s.user.userId, winnerScore, 'duel', 'win'); + } catch (err) { + console.error('Error saving tetris winner stats:', err); + } + } + } + } + } + }); + + // Matchmaking + socket.on('tetris:matchmaking-join', () => { + // Remove from queue if already there + const idx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id); + if (idx !== -1) tetrisMatchmakingQueue.splice(idx, 1); + + tetrisMatchmakingQueue.push(socket); + socket.emit('tetris:matchmaking-status', { status: 'searching', position: tetrisMatchmakingQueue.length }); + + if (tetrisMatchmakingQueue.length >= 2) { + const player1 = tetrisMatchmakingQueue.shift(); + const player2 = tetrisMatchmakingQueue.shift(); + const roomCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + player1.emit('tetris:matched', { roomCode, opponent: player2.user.username }); + player2.emit('tetris:matched', { roomCode, opponent: player1.user.username }); + } + }); + + socket.on('tetris:matchmaking-leave', () => { + const idx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id); + if (idx !== -1) tetrisMatchmakingQueue.splice(idx, 1); + socket.emit('tetris:matchmaking-status', { status: 'idle' }); + }); + + socket.on('disconnect', async () => { + // Nettoyage matchmaking tetris + const mqIdx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id); + if (mqIdx !== -1) tetrisMatchmakingQueue.splice(mqIdx, 1); + + // Nettoyage room tetris + if (socket.tetrisRoomCode) { + _tetrisLeave(socket); + } + console.log(`User disconnected: ${socket.user.username}`); + + // Notify game room if player/spectator was in one + if (socket.gameRoomId) { + const roomId = socket.gameRoomId; + const dbRoomId = socket.gameRoomDbId; + + // If spectator, just notify and leave + if (socket.isSpectator) { + socket.to(roomId).emit('game-spectator-left', { + username: socket.user.username + }); + console.log(`Spectator ${socket.user.username} disconnected from ${roomId}`); + } + else + { + // Regular player disconnect + 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); + } + } + + // Check if game should auto-stop due to single player + await checkAndStopSinglePlayerGame(io, roomId, dbRoomId); + + // Broadcast updated rooms list + broadcastRoomsList(io); + } + } }); }); } +// ── Helpers tetris duel ────────────────────────────────────────────────── + +function _tetrisLeave(socket) +{ + const code = socket.tetrisRoomCode; + if (!code) return; + const room = tetrisRooms.get(code); + if (room) { + room.delete(socket.id); + // Notifier l'adversaire restant + for (const s of room.values()) { + s.emit('tetris:opponent-left'); + s.emit('tetris:room-status', { status: 'waiting', players: [s.user.username] }); + } + if (room.size === 0) tetrisRooms.delete(code); + } + socket.tetrisRoomCode = null; +} + +function _tetrisRelayToOpponent(socket, event, data) { + const code = socket.tetrisRoomCode; + if (!code) return; + const room = tetrisRooms.get(code); + if (!room) return; + for (const [id, s] of room) { + if (id !== socket.id) s.emit(event, data); + } +} + +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 5058dd8..b260673 100644 --- a/Transcendence/srcs/frontend/src/app.js +++ b/Transcendence/srcs/frontend/src/app.js @@ -7,6 +7,8 @@ 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'; +import { StatsWindow } from './stats.js'; /** * Main application class @@ -16,7 +18,7 @@ class App { constructor() { this.initWindows(); this.initMenu(); - this.initGame(); + this.initPage(); this.initEasterEgg(); } @@ -24,11 +26,12 @@ class App { * Initializes all windows */ initWindows() { - // Windows automatically register themselves in the registry new LoginWindow(); new GlobalChat(); new AvatarWindow(); new FriendsWindow(); + new GameRoomWindow(); + new StatsWindow(); } /** @@ -42,7 +45,6 @@ class App { return; } - // Action to window name mapping const actionMap = { 'login': 'login', 'chat': 'chat', @@ -66,44 +68,14 @@ class App { }); } - initGame() { - const game = document.querySelector('.game'); - if (!game) { - console.warn('Game not found'); - return; - } - - // Action to window name mapping - const actionMap = { - 'new_game': 'new_game' - }; - - // Event delegation on the menu - game.addEventListener('click', (e) => { - const button = e.target.closest('.game__item'); - if (!button) return; - - const action = button.dataset.action; - - // Actions with associated windows - if (actionMap[action]) { - windowRegistry.toggle(actionMap[action]); - return; - } - - }); - } - initPage() { const page = document.querySelector('.page'); if (!page) { - console.warn('Page not found'); return; } - // Action to window name mapping const actionMap = { - 'new_page': 'new_page' + 'gameroom': 'gameroom' }; // Event delegation on the menu @@ -125,14 +97,14 @@ class App { /** * Initializes the easter egg button */ - // initEasterEgg() { - // const easterEgg = document.querySelector('.easter-egg'); - // if (easterEgg) { - // easterEgg.addEventListener('click', () => { - // alert('You clicked when we told you not to!'); - // }); - // } - // } + initEasterEgg() { + const easterEgg = document.querySelector('.easter-egg'); + if (easterEgg) { + easterEgg.addEventListener('click', () => { + alert('DONT CLICK!'); + }); + } + } } // Start the application when DOM is ready diff --git a/Transcendence/srcs/frontend/src/avatar.js b/Transcendence/srcs/frontend/src/avatar.js index 3201dad..45e3103 100644 --- a/Transcendence/srcs/frontend/src/avatar.js +++ b/Transcendence/srcs/frontend/src/avatar.js @@ -1,4 +1,4 @@ -import { Window } from './windows.js'; +import { Window, windowRegistry } from './windows.js'; import { API, STORAGE_KEYS, CSS } from './config.js'; import { eventBus, Events } from './events.js'; @@ -16,7 +16,9 @@ export class AvatarWindow extends Window { this.buildUI(); this.bindEvents(); - this.loadAvatar(); + if (localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN)) { + this.loadAvatar(); + } // Listen for login events eventBus.on(Events.USER_LOGGED_IN, () => this.loadAvatar()); @@ -34,6 +36,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', @@ -43,6 +52,10 @@ export class AvatarWindow extends Window { // Controls this.controls = this.createElement('div', CSS.AVATAR_CONTROLS); + this.statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Mes statistiques' + }); + this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Choose image' }); @@ -55,7 +68,7 @@ export class AvatarWindow extends Window { text: 'Refresh' }); - this.controls.append(this.chooseBtn, this.saveBtn, this.refreshBtn); + this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn); // Feedback message this.message = this.createElement('div', CSS.MESSAGE); @@ -64,6 +77,7 @@ export class AvatarWindow extends Window { this.body.append( this.preview, this.username, + this.statsContainer, this.fileInput, this.controls, this.message @@ -75,6 +89,7 @@ export class AvatarWindow extends Window { */ bindEvents() { this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); + this.statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showMe()); this.chooseBtn.addEventListener('click', () => this.fileInput.click()); this.saveBtn.addEventListener('click', () => this.uploadAvatar()); this.refreshBtn.addEventListener('click', () => this.loadAvatar()); @@ -148,6 +163,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..22fff46 100644 --- a/Transcendence/srcs/frontend/src/config.js +++ b/Transcendence/srcs/frontend/src/config.js @@ -20,6 +20,26 @@ export const API = { REQUEST: '/api/friends/request', ACCEPT: '/api/friends/accept', DECLINE: '/api/friends/decline' + }, + ROOMS: { + LIST: '/api/rooms', + PLAYING: '/api/rooms/playing', + 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`, + SPECTATE: (id) => `/api/rooms/${id}/spectate`, + LEAVE_SPECTATE: (id) => `/api/rooms/${id}/leave-spectate`, + CURRENT: '/api/rooms/current' + }, + STATS: { + ME: '/api/stats/me', + USER: (username) => `/api/stats/user/${username}`, + LEADERBOARD: '/api/stats/leaderboard', + TETRIS_SCORE: '/api/stats/tetris/score', + TETRIS_LEADERBOARD_SCORE: '/api/stats/tetris/leaderboard/score', + TETRIS_LEADERBOARD_WINS: '/api/stats/tetris/leaderboard/wins' } }; @@ -94,7 +114,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/duel.js b/Transcendence/srcs/frontend/src/duel.js new file mode 100644 index 0000000..745f4e7 --- /dev/null +++ b/Transcendence/srcs/frontend/src/duel.js @@ -0,0 +1,218 @@ +// ───────────────────────────────────────────── +// DUEL +// ───────────────────────────────────────────── + +class Duel { + constructor(socket, tetrisGame, onStatusChange, onStart) { + this.socket = socket; + this.tetrisGame = tetrisGame; + this.onStatusChange = onStatusChange; // (status, opponentName) => void + this.onStart = onStart; // () => void — déclenche le début du jeu local + + this.action_queue = []; + this.opponentGrid = this._emptyGrid(); + this.opponentScore = 0; + this.opponentShieldActive = false; + this.roomCode = null; + this.isReady = false; + + this._bindSocketEvents(); + } + + // ─── Connexion ──────────────────────────── + + join(roomCode) { + this.roomCode = roomCode; + this.socket.emit('tetris:join', { roomCode }); + } + + startDuel() { + if (!this.isReady) return; + this.socket.emit('tetris:start-duel'); + } + + leave() { + if (!this.roomCode) return; + this.socket.emit('tetris:leave'); + this.roomCode = null; + this.isReady = false; + this.opponentGrid = this._emptyGrid(); + this.opponentScore = 0; + } + + // ─── Hooks appelés par tetris.js ────────── + + onLocalBlockPlaced(grid, score) { + if (!this.isReady) return; + this.socket.emit('tetris:grid-update', { grid, score }); + } + + onLocalLinesCleared(count, holeCol) { + if (!this.isReady) return; + const garbageLines = []; + for (let i = 0; i < count; i++) + garbageLines.push(this._buildGarbageLine(holeCol)); + this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines }); + } + + onLocalGameOver(score, validBlock) { + if (!this.isReady) return; + this.socket.emit('tetris:game-over', { score, validBlock }); + this.endDuel(); + } + + onLocalShieldChanged(event) { + if (!this.isReady) return; + if (event === 'activated') { + this.socket.emit('tetris:shield-activated'); + } else if (event === 'deactivated') { + this.socket.emit('tetris:shield-deactivated'); + } + } + + endDuel() { + this.isReady = false; + this.action_queue = []; + if (this.tetrisGame.isRunning) this.tetrisGame.stop(); + } + + // ─── Traitement de la queue ─────────────── + + synchronize_game() { + while (this.action_queue.length > 0) { + const action = this.action_queue.shift(); + this._processAction(action); + } + } + + _processAction(action) { + switch (action.type) { + case 'GRID_UPDATE': + this.opponentGrid = action.grid; + this.opponentScore = action.score; + document.getElementById('opponent-score').textContent = action.score; + renderOpponent(this.opponentGrid); + break; + + case 'LINES_CLEARED': + this.tetrisGame.addGarbageLines(action.garbageLines); + break; + + case 'OPPONENT_GAME_OVER': + showOverlay('YOU WIN', action.score); + this.endDuel(); + break; + + case 'OPPONENT_SHIELD_ACTIVATED': + this.opponentShieldActive = true; + break; + + case 'OPPONENT_SHIELD_DEACTIVATED': + this.opponentShieldActive = false; + break; + } + } + + // ─── Liaison socket ─────────────────────── + + _bindSocketEvents() { + this.socket.on('tetris:room-status', (data) => { + this.isReady = data.status === 'ready'; + const opponentName = data.players.find(p => p !== this.socket.username) || 'Adversaire'; + document.getElementById('opponent-name').textContent = opponentName; + this.onStatusChange(data.status, opponentName); + }); + + this.socket.on('tetris:opponent-joined', (data) => { + document.getElementById('opponent-name').textContent = data.username; + }); + + this.socket.on('tetris:opponent-left', () => { + this.isReady = false; + this.onStatusChange('waiting', null); + this._showOpponentOverlay('DÉCONNECTÉ'); + }); + + this.socket.on('tetris:grid-update', (data) => { + this.action_queue.push({ type: 'GRID_UPDATE', grid: data.grid, score: data.score }); + }); + + this.socket.on('tetris:lines-cleared', (data) => { + this.action_queue.push({ type: 'LINES_CLEARED', garbageLines: data.garbageLines }); + }); + + this.socket.on('tetris:opponent-game-over', (data) => { + this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score, validBlock: data.validBlock }); + }); + + this.socket.on('tetris:shield-activated', () => { + this.action_queue.push({ type: 'OPPONENT_SHIELD_ACTIVATED' }); + }); + + this.socket.on('tetris:shield-deactivated', () => { + this.action_queue.push({ type: 'OPPONENT_SHIELD_DEACTIVATED' }); + }); + + this.socket.on('tetris:start-duel', () => { + if (this.onStart) this.onStart(); + }); + + this.socket.on('tetris:pause', () => { + this.tetrisGame.pause(); + updateButtons(); + if (this.tetrisGame.isPaused) showOverlay('PAUSE'); + else hideOverlay(); + }); + + this.socket.on('tetris:stop', () => { + this.tetrisGame.stop(); + updateButtons(); + render(); + showOverlay('STOPPED'); + }); + + this.socket.on('tetris:settings', (data) => { + document.getElementById('input-ttd').value = data.timeToDown; + document.getElementById('input-hardening').value = data.hardening; + document.getElementById('input-decrement').value = data.decrementTTD; + this.tetrisGame.configure(data); + }); + } + + togglePause() { + if (!this.isReady) return; + this.socket.emit('tetris:pause'); + } + + stop() { + if (!this.isReady) return; + this.socket.emit('tetris:stop'); + } + + syncSettings(settings) { + if (!this.isReady) return; + this.socket.emit('tetris:settings', settings); + } + + // ─── Utilitaires ───────────────────────── + + _buildGarbageLine(holeCol) { + return Array.from({ length: 10 }, (_, i) => i === holeCol ? 0 : 8); + } + + _emptyGrid() { + return Array.from({ length: 20 }, () => Array(10).fill(0)); + } + + _showOpponentOverlay(title, score) { + const overlayEl = document.getElementById('overlay-opponent'); + document.getElementById('overlay-opponent-title').textContent = title; + const scoreEl = document.getElementById('overlay-opponent-score'); + if (scoreEl) scoreEl.textContent = score !== undefined ? `Score : ${score}` : ''; + overlayEl.classList.add('visible'); + } + + hideOpponentOverlay() { + document.getElementById('overlay-opponent').classList.remove('visible'); + } +} diff --git a/Transcendence/srcs/frontend/src/events.js b/Transcendence/srcs/frontend/src/events.js index 5bb0aa6..f323d30 100644 --- a/Transcendence/srcs/frontend/src/events.js +++ b/Transcendence/srcs/frontend/src/events.js @@ -53,11 +53,13 @@ class EventBus { */ emit(event, data) { if (this.listeners.has(event)) { + const listeners = this.listeners.get(event); this.listeners.get(event).forEach(callback => { try { callback(data); - } catch (error) { - console.error(`Error in listener for "${event}":`, error); + } + catch (err) { + // Show that some events are not fully handled, but don't break the app } }); } @@ -84,5 +86,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..fc8ca24 100644 --- a/Transcendence/srcs/frontend/src/friends.js +++ b/Transcendence/srcs/frontend/src/friends.js @@ -1,4 +1,4 @@ -import { Window } from './windows.js'; +import { Window, windowRegistry } from './windows.js'; import { API, STORAGE_KEYS, CSS } from './config.js'; import { eventBus, Events } from './events.js'; @@ -290,18 +290,35 @@ 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') { + const statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Stats' + }); + statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showUser(user.username)); + const removeBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { text: 'Retirer' }); removeBtn.addEventListener('click', () => this.removeFriend(user.id)); - actions.appendChild(removeBtn); + actions.append(statsBtn, removeBtn); } else if (type === 'request') { const acceptBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], { text: 'Accepter' @@ -315,14 +332,19 @@ export class FriendsWindow extends Window { actions.append(acceptBtn, declineBtn); } else if (type === 'search') { + const statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Stats' + }); + statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showUser(user.username)); + const addBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { text: 'Ajouter' }); addBtn.addEventListener('click', () => this.sendRequest(user.id, addBtn)); - actions.appendChild(addBtn); + actions.append(statsBtn, addBtn); } - item.append(avatar, name, actions); + item.append(avatar, infoContainer, actions); return item; } diff --git a/Transcendence/srcs/frontend/src/game.css b/Transcendence/srcs/frontend/src/game.css index 2ec61f3..6e424fd 100644 --- a/Transcendence/srcs/frontend/src/game.css +++ b/Transcendence/srcs/frontend/src/game.css @@ -705,3 +705,317 @@ body { color: var(--color-text-muted); padding: var(--spacing-lg); } + +/* ============================================ + GAME ROOM WINDOW + ============================================ */ +.gameroom-window { + width: 600px; + height: 800px; +} + +.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; +} diff --git a/Transcendence/srcs/frontend/src/game.html b/Transcendence/srcs/frontend/src/game.html index 0412d41..c836d29 100644 --- a/Transcendence/srcs/frontend/src/game.html +++ b/Transcendence/srcs/frontend/src/game.html @@ -25,8 +25,7 @@
- - +
diff --git a/Transcendence/srcs/frontend/src/game_room.js b/Transcendence/srcs/frontend/src/game_room.js new file mode 100644 index 0000000..362cdae --- /dev/null +++ b/Transcendence/srcs/frontend/src/game_room.js @@ -0,0 +1,1604 @@ +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.isSpectating = false; + this.messageTimeout = null; + this.buildUI(); + this.bindEvents(); + + // Handle page close/refresh to disconnect socket + window.addEventListener('beforeunload', () => { + if (this.socket?.connected) { + this.socket.disconnect(); + } + }); + 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 + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (token) + 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.spectatorTab = this.createElement('button', CSS.GAMEROOM_TAB, { + text: 'Spectateur' + }); + this.spectatorTab.dataset.tab = 'spectator'; + + 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.spectatorTab, 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.spectatorList = this.createElement('div', CSS.GAMEROOM_LIST); + this.spectatorList.style.display = 'none'; + + this.message = this.createElement('div', CSS.MESSAGE); + + this.content.append(this.createContainer, this.lobbyContainer, this.list, this.spectatorList, 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: 'Quitter la partie' }); + 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: {}, + counter: 0 + }; + + this.initDrawing(); + } + + initDrawing() { + this.isDrawing = false; + this.lastX = 0; + this.lastY = 0; + + this.canvas.addEventListener('mousedown', (e) => { + if (!this.gameState.isPlaying || !this.isCurrentUserDrawer() || this.isSpectating) 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) => { + this.showMessage(`${data.username} a quitté le salon`, 'info'); + + if (this.gameState.isPlaying) + { + if (this.gameState.players) + this.gameState.players = this.gameState.players.filter(p => p !== data.username); + } + + if (this.gameState.scores) + { + delete this.gameState.scores[data.username]; + this.updateScoresDisplay(this.gameState.scores); + } + + // Note: If the drawer left, the server will emit 'game-drawer-changed' + // with the new drawer, so we don't need to handle it here + + if (this.currentRoom && !this.gameState.isPlaying) + this.loadLobby(); + }); + + // 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(); + }); + + // Game start error + this.socket.on('game-start-error', (data) => { + console.error('Game start error:', data.error); + this.showMessage(data.error || 'Impossible de démarrer la partie', 'error'); + }); + + // 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(); + + // Don't change UI for spectators + if (this.isSpectating) { + this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie'; + return; + } + + 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', () => { + // If spectating, return to spectator list + if (this.isSpectating) { + this.resetGameUI(); + this.currentRoom = null; + this.isSpectating = false; + this.switchTab('spectator'); + this.showMessage('La partie est terminée', 'info'); + } else { + this.resetGameUI(); + this.loadLobby(); + } + }); + + // Game message from server + this.socket.on('game-message', (data) => { + this.showMessage(data.message, data.type || 'info'); + }); + + // 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.gameState.scores = data.scores || {}; + + this.showGameUI(); + this.updateWordDisplay(); + + // Update scores display + if (data.scores) { + this.updateScoresDisplay(data.scores); + } + + this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`; + + // Don't enable input for spectators + if (this.isSpectating) { + this.guessContainer.style.display = 'none'; + this.wordInputContainer.style.display = 'none'; + this.drawTools.style.display = 'none'; + this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie'; + } else 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...'; + } + } + } + }); + + // Spectator events + this.socket.on('game-spectate-joined', (data) => { + console.log('Successfully joined as spectator:', data.roomId); + this.isSpectating = true; + + // Prepare UI for spectating + this.spectatorList.style.display = 'none'; + this.list.style.display = 'none'; + this.createContainer.style.display = 'none'; + this.lobbyContainer.style.display = 'flex'; + + // Hide lobby elements, keep game container for when state syncs + this.playerList.style.display = 'none'; + this.lobbyButtons.style.display = 'none'; + this.lobbyTitle.textContent = 'Mode Spectateur'; + + this.showMessage('Vous regardez la partie...', 'success'); + // The game state will be synced via game-state-sync event + }); + + this.socket.on('game-spectate-error', (data) => { + console.error('Spectate error:', data.error); + this.showMessage(data.error || 'Impossible de regarder cette partie', 'error'); + }); + + this.socket.on('game-spectator-joined', (data) => { + console.log(`Spectator ${data.username} joined`); + }); + + this.socket.on('game-spectator-left', (data) => { + console.log(`Spectator ${data.username} left`); + }); + + // Drawer changed (when drawer leaves during game) + this.socket.on('game-drawer-changed', (data) => { + console.log('Drawer changed:', data); + this.showMessage(data.message, 'info'); + + // Update game state with new drawer + this.gameState.drawer = data.newDrawer; + this.gameState.currentPlayerIndex = this.gameState.players.indexOf(data.newDrawer); + + // Reset round state + this.gameState.currentWord = ''; + this.gameState.wordLength = 0; + this.gameState.revealedLetters = []; + this.gameState.revealedWord = []; + this.gameState.guessedLetters = []; + + // Clear canvas and history + this.clearCanvas(); + this.guessHistory.innerHTML = ''; + this.wordDisplay.textContent = ''; + + // Setup UI for new round with new drawer + this.setupRound(); + }); + } + + disconnectGameSocket() { + if (this.socket) { + if (this.isSpectating) { + this.socket.emit('game-leave-spectate'); + } else { + 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.spectatorTab, 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.spectatorList.style.display = tabName === 'spectator' ? '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 'spectator': + this.loadPlayingRooms(); + 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'); + } + } + + createSpectatorRoomItem(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 status = this.createElement('span', 'gameroom__status', { + text: '🎮 En cours' + }); + status.style.color = '#4CAF50'; + status.style.fontWeight = 'bold'; + + const actions = this.createElement('div', CSS.GAMEROOM_ACTIONS); + + const spectateBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Regarder' + }); + spectateBtn.addEventListener('click', () => this.spectateRoom(room.id)); + actions.appendChild(spectateBtn); + + item.append(name, players, status, actions); + return item; + } + + async loadPlayingRooms() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour voir les parties en cours', 'info'); + return; + } + + try { + const response = await fetch(API.ROOMS.PLAYING, { + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.renderPlayingRoomsList(data || []); + } catch (error) { + console.error('Load playing rooms error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + renderPlayingRoomsList(rooms) { + this.spectatorList.innerHTML = ''; + this.message.textContent = ''; + + if (rooms.length === 0) { + this.showMessage('Aucune partie en cours', 'info'); + return; + } + + rooms.forEach(room => { + const item = this.createSpectatorRoomItem(room); + this.spectatorList.appendChild(item); + }); + } + + async spectateRoom(roomId) { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour regarder', 'info'); + return; + } + + // Check if user is already in a room as a player + if (this.currentRoom && !this.isSpectating) { + this.showMessage('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'error'); + return; + } + + // Check if already spectating another game + if (this.isSpectating && this.currentRoom && this.currentRoom.id !== roomId) { + this.showMessage('Vous regardez déjà une autre partie', 'error'); + return; + } + + try { + const response = await fetch(API.ROOMS.SPECTATE(roomId), { + method: 'POST', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Impossible de regarder cette partie', 'error'); + return; + } + + // Store room info and mark as spectating + this.currentRoom = data; + this.isSpectating = true; + + // Join as spectator via socket + await this.ensureSocketConnected(); + if (this.socket?.connected) { + this.socket.emit('game-spectate-room', { roomId: roomId }); + } + + this.showMessage('Connexion à la partie...', 'info'); + } catch (error) { + console.error('Spectate 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; + + this.gameState.scores = {}; + + 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); + // Disable start button if no players + this.startGameBtn.disabled = true; + this.startGameBtn.style.opacity = '0.5'; + this.startGameBtn.title = 'Il faut au moins 2 joueurs'; + 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); + }); + + // Enable/disable start button based on player count + if (players.length < 2) { + this.startGameBtn.disabled = true; + this.startGameBtn.style.opacity = '0.5'; + this.startGameBtn.title = 'Il faut au moins 2 joueurs'; + } else { + this.startGameBtn.disabled = false; + this.startGameBtn.style.opacity = '1'; + this.startGameBtn.title = ''; + } + } + + 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') { + // Clear any existing timeout + if (this.messageTimeout) { + clearTimeout(this.messageTimeout); + } + + 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); + } + + // Auto-clear message after 5 seconds + this.messageTimeout = setTimeout(() => { + this.message.textContent = ''; + this.message.className = CSS.MESSAGE; + }, 5000); + } + + // ============================================ + // 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 = ''; + + // If spectating, show indicator and disable interactions + if (this.isSpectating) { + this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie'; + this.currentDrawerInfo.style.backgroundColor = '#2196F3'; + this.currentDrawerInfo.style.color = 'white'; + this.currentDrawerInfo.style.padding = '8px'; + this.currentDrawerInfo.style.borderRadius = '4px'; + this.currentDrawerInfo.style.textAlign = 'center'; + + // Change button text for spectators + this.backToLobbyBtn.textContent = 'Arrêter de regarder'; + this.endRoundBtn.style.display = 'none'; // Hide end game button for spectators + } else { + this.backToLobbyBtn.textContent = 'Quitter la partie'; + this.endRoundBtn.style.display = 'inline-block'; + } + } + + resetGameUI() { + this.gameState.isPlaying = false; + this.gameState.currentWord = ''; + this.gameState.wordLength = 0; + this.gameState.revealedLetters = []; + this.gameState.revealedWord = []; + this.gameState.drawer = null; + this.isSpectating = false; + + this.gameState.scores = {}; + this.gameState.players = []; + this.gameState.currentPlayerIndex = 0; + this.gameState.guessedLetters = []; + + // Clear scores display + if (this.scoresDisplay) + this.scoresDisplay.textContent = ''; + + if (this.guessHistory) + this.guessHistory.innerHTML = ''; + + this.clearCanvas(); + + 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'; + + // Reset spectator styling + this.currentDrawerInfo.style.backgroundColor = ''; + this.currentDrawerInfo.style.color = ''; + this.currentDrawerInfo.style.padding = ''; + this.currentDrawerInfo.style.borderRadius = ''; + this.currentDrawerInfo.style.textAlign = ''; + 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 < 2) { + this.showMessage('Il faut au moins 2 joueurs pour commencer', '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(); + + // Spectators cannot interact + if (this.isSpectating) { + this.wordInputContainer.style.display = 'none'; + this.guessContainer.style.display = 'none'; + this.drawTools.style.display = 'none'; + this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie'; + return; + } + + 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.counter++; + if (this.gameState.counter >= this.gameState.players.length) { + this.gameState.counter = 0; + } + const nextDrawer = this.gameState.players[this.gameState.counter]; + + if (this.socket?.connected) { + this.socket.emit('game-next-round', { drawer: nextDrawer }); + } else { + this.gameState.drawer = nextDrawer; + this.setupRound(); + } + } + + backToLobby() { + if (this.socket?.connected) { + this.socket.emit('leave-room-during-game'); + } + + // Return to lobby without ending game for others + this.resetGameUI(); + this.exitLobby(); + this.showMessage('Vous avez quitté la partie', 'info'); + } + + endGame() { + if (this.socket?.connected) { + this.socket.emit('game-end'); + } + this.resetGameUI(); + this.showMessage('Jeu termine', 'info'); + } +} diff --git a/Transcendence/srcs/frontend/src/global_chat.js b/Transcendence/srcs/frontend/src/global_chat.js index b2b121b..6e64598 100644 --- a/Transcendence/srcs/frontend/src/global_chat.js +++ b/Transcendence/srcs/frontend/src/global_chat.js @@ -17,6 +17,8 @@ export class GlobalChat extends Window { this.socket = null; this.connected = false; this.friendIds = new Set(); + this.currentUserId = null; + this.currentUsername = null; this.buildUI(); this.bindEvents(); @@ -169,6 +171,19 @@ export class GlobalChat extends Window { await this.connect(); } + decodeToken(token) + { + try + { + const payload = token.split('.')[1]; + return (JSON.parse(atob(payload))); + } + catch + { + return (null); + } + } + /** * Connects to the Socket.IO server */ @@ -180,6 +195,13 @@ export class GlobalChat extends Window { return; } + const tokenData = this.decodeToken(token); + + if (tokenData) { + this.currentUserId = tokenData.id || tokenData.userId || tokenData.user_id || tokenData.sub || null; + this.currentUsername = tokenData.username || tokenData.name || null; + } + if (this.socket?.connected) { this.addSystemMessage('Already connected to global chat'); return; @@ -239,6 +261,7 @@ export class GlobalChat extends Window { this.socket.on('connect', () => { console.log('Socket connected, ID:', this.socket.id); this.connected = true; + this.output.innerHTML = ''; this.addSystemMessage('Connected to global chat', 'success'); eventBus.emit(Events.CHAT_CONNECTED, { socketId: this.socket.id }); }); @@ -262,15 +285,38 @@ export class GlobalChat extends Window { // Display recent messages data.messages.forEach(msg => { - const isFriend = this.friendIds.has(msg.sender_id); - this.addChatMessage(msg.username, msg.content, false, isFriend); + const isOwn = this.isOwnMessage(msg); + const isFriend = !isOwn && this.friendIds.has(msg.sender_id); + const displayUsername = isOwn ? 'Me' : msg.username; + this.addChatMessage(displayUsername, msg.content, isOwn, isFriend); }); }); this.socket.on('chat-message', (msg) => { + const isOwn = this.isOwnMessage(msg); + if (isOwn) + return; + const isFriend = this.friendIds.has(msg.sender_id); this.addChatMessage(msg.username, msg.content, false, isFriend); eventBus.emit(Events.CHAT_MESSAGE_RECEIVED, msg); }); } + + isOwnMessage(msg) + { + if (this.currentUserId !== null && msg.sender_id !== undefined && msg.sender_id !== null) + { + if (String(this.currentUserId) === String(msg.sender_id)) + return (true); + } + + if (this.currentUsername && msg.username) + { + if (this.currentUsername.toLowerCase() === msg.username.toLowerCase()) + return (true); + } + + return (false); + } } diff --git a/Transcendence/srcs/frontend/src/index.css b/Transcendence/srcs/frontend/src/index.css index b8c8788..fdcce5c 100644 --- a/Transcendence/srcs/frontend/src/index.css +++ b/Transcendence/srcs/frontend/src/index.css @@ -531,6 +531,76 @@ body { display: none; } +/* ============================================ + STATS WINDOW + ============================================ */ +.stats-window { + width: 320px; +} + +.stats__avatar { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: var(--radius-full); + border: 2px solid var(--color-text); + align-self: center; + display: block; + margin: 0 auto var(--spacing-xs); +} + +.stats__username { + font-size: var(--font-size-lg); + font-weight: 600; + text-align: center; + color: #000; + margin-bottom: var(--spacing-md); +} + +.stats__section { + margin-bottom: var(--spacing-md); +} + +.stats__section-title { + font-size: var(--font-size-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-primary); + border-bottom: 1px solid var(--color-surface-light); + padding-bottom: var(--spacing-xs); + margin-bottom: var(--spacing-xs); +} + +.stats__section-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.stats__row { + display: flex; + justify-content: space-between; + font-size: var(--font-size-sm); + padding: 3px 0; +} + +.stats__label { + color: #333; +} + +.stats__value { + font-weight: 600; + color: #000; +} + +.stats__loading { + font-size: var(--font-size-sm); + color: #333; + text-align: center; + padding: var(--spacing-sm) 0; +} + /* ============================================ EASTER EGG BUTTON ============================================ */ diff --git a/Transcendence/srcs/frontend/src/index.html b/Transcendence/srcs/frontend/src/index.html index 4da5cae..ee45125 100644 --- a/Transcendence/srcs/frontend/src/index.html +++ b/Transcendence/srcs/frontend/src/index.html @@ -22,10 +22,10 @@ - - \ No newline at end of file diff --git a/Transcendence/srcs/frontend/src/login.js b/Transcendence/srcs/frontend/src/login.js index c55949e..5708dbd 100644 --- a/Transcendence/srcs/frontend/src/login.js +++ b/Transcendence/srcs/frontend/src/login.js @@ -17,6 +17,7 @@ export class LoginWindow extends Window { this.buildUI(); this.bindEvents(); this.checkIfAlreadyLoggedIn(); + this.NotficationContainer(); } /** @@ -129,6 +130,7 @@ export class LoginWindow extends Window { if (response.ok && data.token) { localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token); this.showMessage('Login successful! Welcome.', 'success'); + this.showNotification('Login successful', 'green'); // Emit login event eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token }); @@ -138,6 +140,7 @@ export class LoginWindow extends Window { } else { const errorMsg = data?.message || 'Login failed'; this.showMessage(errorMsg, 'error'); + this.showNotification(errorMsg, 'red'); } } catch (error) { console.error('Login error:', error); @@ -170,10 +173,12 @@ export class LoginWindow extends Window { if (response.ok) { this.showMessage('Registration successful! You can now sign in.', 'success'); + this.showNotification('Registration successful', 'green'); eventBus.emit(Events.USER_REGISTERED, { username }); } else { const errorMsg = data?.message || 'Registration failed'; this.showMessage(errorMsg, 'error'); + this.showNotification(errorMsg, 'red'); } } catch (error) { console.error('Registration error:', error); @@ -200,6 +205,7 @@ export class LoginWindow extends Window { if (event.data?.token) { localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token); this.showMessage('GitHub login successful! Welcome.', 'success'); + this.showNotification('GitHub login successful', 'green'); // Emit login event eventBus.emit(Events.USER_LOGGED_IN, { @@ -215,6 +221,55 @@ export class LoginWindow extends Window { window.addEventListener('message', handleMessage, { once: true }); } + NotficationContainer() + { + if (document.getElementById('notification-container')) return; + + const container = this.createElement('div'); + container.id = 'notification-container'; + Object.assign(container.style, { + position: 'fixed', + top: '20px', + right: '20px', + zIndex: 1000, + display: 'flex', + flexDirection: 'column', + gap: '10px' + }); + document.body.appendChild(container); + } + + showNotification(message, color) { + const container = document.getElementById('notification-container'); + if (!container) return; + + const notification = document.createElement('div'); + notification.textContent = message; + Object.assign(notification.style, { + backgroundColor: color, + color: 'white', + padding: '10px 20px', + borderRadius: '5px', + boxShadow: '0 2px 6px rgba(0,0,0,0.3)', + opacity: '0', + transform: 'translateY(-8px)', + transition: 'opacity 0.5s ease, transform 0.5s ease' + }); + + container.appendChild(notification); + + requestAnimationFrame(() => { + notification.style.opacity = '1'; + notification.style.transform = 'translateY(0)'; + }); + + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transform = 'translateY(-8px)'; + setTimeout(() => notification.remove(), 500); + }, 2200); + } + /** * Displays a feedback message * @param {string} text - Message text diff --git a/Transcendence/srcs/frontend/src/pieces.js b/Transcendence/srcs/frontend/src/pieces.js new file mode 100644 index 0000000..f541825 --- /dev/null +++ b/Transcendence/srcs/frontend/src/pieces.js @@ -0,0 +1,99 @@ +// ───────────────────────────────────────────── +// PIÈCES +// ───────────────────────────────────────────── + +class Piece { + constructor(startX, startY) { + this.position = { x: startX, y: startY }; + this.currentRotation = 0; + this.rotations = this.defineRotations(); + this.shape = this.rotations[0]; + this.color = this.getColor(); + } + defineRotations() { return [[[1]]]; } + getColor() { return 1; } + getPosition() { return { ...this.position }; } + getShape() { return this.shape; } + moveDown() { this.position.y++; } + moveLeft() { this.position.x--; } + moveRight() { this.position.x++; } + rotateLeft() { + this.currentRotation = (this.currentRotation - 1 + this.rotations.length) % this.rotations.length; + this.shape = this.rotations[this.currentRotation]; + } + rotateRight() { + this.currentRotation = (this.currentRotation + 1) % this.rotations.length; + this.shape = this.rotations[this.currentRotation]; + } +} + +class PieceT extends Piece { + defineRotations() { + return [ + [[0,1,0],[1,1,1],[0,0,0]], + [[0,1,0],[0,1,1],[0,1,0]], + [[0,0,0],[1,1,1],[0,1,0]], + [[0,1,0],[1,1,0],[0,1,0]] + ]; + } + getColor() { return 1; } +} + +class PieceL extends Piece { + defineRotations() { + return [ + [[0,0,1],[1,1,1],[0,0,0]], + [[0,1,0],[0,1,0],[0,1,1]], + [[0,0,0],[1,1,1],[1,0,0]], + [[1,1,0],[0,1,0],[0,1,0]] + ]; + } + getColor() { return 2; } +} + +class PieceReverseL extends Piece { + defineRotations() { + return [ + [[1,0,0],[1,1,1],[0,0,0]], + [[0,1,1],[0,1,0],[0,1,0]], + [[0,0,0],[1,1,1],[0,0,1]], + [[0,1,0],[0,1,0],[1,1,0]] + ]; + } + getColor() { return 3; } +} + +class PieceI extends Piece { + defineRotations() { + return [ + [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], + [[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]] + ]; + } + getColor() { return 4; } +} + +class PieceZ extends Piece { + defineRotations() { + return [ + [[1,1,0],[0,1,1],[0,0,0]], + [[0,0,1],[0,1,1],[0,1,0]] + ]; + } + getColor() { return 5; } +} + +class PieceReverseZ extends Piece { + defineRotations() { + return [ + [[0,1,1],[1,1,0],[0,0,0]], + [[0,1,0],[0,1,1],[0,0,1]] + ]; + } + getColor() { return 6; } +} + +class PieceO extends Piece { + defineRotations() { return [[[1,1],[1,1]]]; } + getColor() { return 7; } +} diff --git a/Transcendence/srcs/frontend/src/renderer.js b/Transcendence/srcs/frontend/src/renderer.js new file mode 100644 index 0000000..b3efcfc --- /dev/null +++ b/Transcendence/srcs/frontend/src/renderer.js @@ -0,0 +1,236 @@ +// ───────────────────────────────────────────── +// RENDU +// ───────────────────────────────────────────── + +const CELL = 30; + +const THEMES = { + green: { + bg: '#000500', panel: '#000d00', border: '#004400', + accent: '#00ff41', accent2: '#39ff14', dim: '#1a5c1a', text: '#00cc26', + grid: 'rgba(0,255,65,0.06)', ghost: 'rgba(0,255,65,0.25)', highlight: 'rgba(200,255,200,0.2)', + colors: ['#000500','#00ff41','#39ff14','#00e676','#76ff03','#b2ff59','#00ffaa','#ccff00','#2d5a2d'] + }, + red: { + bg: '#050000', panel: '#0d0000', border: '#440000', + accent: '#ff1744', accent2: '#ff4569', dim: '#5c1a1a', text: '#cc2626', + grid: 'rgba(255,23,68,0.06)', ghost: 'rgba(255,23,68,0.25)', highlight: 'rgba(255,200,200,0.2)', + colors: ['#050000','#ff1744','#ff4569','#e53935','#ff6d00','#ff8a65','#ff5252','#ff6e40','#5a2d2d'] + }, + yellow: { + bg: '#050500', panel: '#0d0d00', border: '#444400', + accent: '#ffd600', accent2: '#ffea00', dim: '#5c5c1a', text: '#ccaa00', + grid: 'rgba(255,214,0,0.06)', ghost: 'rgba(255,214,0,0.25)', highlight: 'rgba(255,255,200,0.2)', + colors: ['#050500','#ffd600','#ffea00','#ffab00','#fff176','#ffe57f','#ffff00','#ffc400','#5a5a2d'] + }, + blue: { + bg: '#000005', panel: '#00000d', border: '#000044', + accent: '#00b0ff', accent2: '#40c4ff', dim: '#1a1a5c', text: '#2626cc', + grid: 'rgba(0,176,255,0.06)', ghost: 'rgba(0,176,255,0.25)', highlight: 'rgba(200,200,255,0.2)', + colors: ['#000005','#00b0ff','#40c4ff','#0091ea','#448aff','#82b1ff','#00e5ff','#2979ff','#2d2d5a'] + } +}; + +let currentTheme = THEMES.green; +let COLORS = [...currentTheme.colors]; + +function setColorTheme(themeName) { + currentTheme = THEMES[themeName] || THEMES.green; + COLORS = [...currentTheme.colors]; + const root = document.documentElement; + root.style.setProperty('--bg', currentTheme.bg); + root.style.setProperty('--panel', currentTheme.panel); + root.style.setProperty('--border', currentTheme.border); + root.style.setProperty('--accent', currentTheme.accent); + root.style.setProperty('--accent2', currentTheme.accent2); + root.style.setProperty('--dim', currentTheme.dim); + root.style.setProperty('--text', currentTheme.text); + localStorage.setItem('tetris-theme', themeName); + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.theme === themeName); + }); +} + +const ctxMain = document.getElementById('canvas-main').getContext('2d'); +const ctxNext = document.getElementById('canvas-next').getContext('2d'); +const ctxHold = document.getElementById('canvas-hold').getContext('2d'); +const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d'); + +function drawCell(ctx, x, y, colorIndex, size) { + const p = 1; + const color = COLORS[colorIndex]; + ctx.fillStyle = color; + ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2); + // Glow inner + ctx.shadowColor = color; + ctx.shadowBlur = 6; + ctx.fillStyle = color; + ctx.fillRect(x * size + p + 2, y * size + p + 2, size - p * 2 - 4, size - p * 2 - 4); + ctx.shadowBlur = 0; + // Highlight top/left + ctx.fillStyle = currentTheme.highlight; + ctx.fillRect(x * size + p, y * size + p, size - p * 2, 2); + ctx.fillRect(x * size + p, y * size + p, 2, size - p * 2); + // Shadow bottom/right + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(x * size + p, (y + 1) * size - p - 2, size - p * 2, 2); + ctx.fillRect((x + 1) * size - p - 2, y * size + p, 2, size - p * 2); +} + +function clearCanvas(ctx, w, h) { + ctx.fillStyle = currentTheme.bg; + ctx.fillRect(0, 0, w, h); +} + +function drawGridLines(ctx, cols, rows, size) { + ctx.strokeStyle = currentTheme.grid; + ctx.lineWidth = 1; + for (let x = 0; x <= cols; x++) { + ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke(); + } + for (let y = 0; y <= rows; y++) { + ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke(); + } +} + +function drawGhost(ctx, piece, grid) { + if (!piece) return; + const ghost = { x: piece.getPosition().x, y: piece.getPosition().y }; + const shape = piece.getShape(); + + while (true) { + ghost.y++; + let valid = true; + for (let row = 0; row < shape.length && valid; row++) + for (let col = 0; col < shape[row].length && valid; col++) + if (shape[row][col] !== 0) { + const ny = ghost.y + row; + const nx = ghost.x + col; + if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false; + } + if (!valid) { ghost.y--; break; } + } + + if (ghost.y === piece.getPosition().y) return; + + ctx.strokeStyle = currentTheme.ghost; + ctx.lineWidth = 1; + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) + ctx.strokeRect( + (ghost.x + col) * CELL + 2, + (ghost.y + row) * CELL + 2, + CELL - 4, CELL - 4 + ); +} + +function drawMiniPiece(ctx, piece, canvasW, canvasH) { + clearCanvas(ctx, canvasW, canvasH); + if (!piece) return; + const shape = piece.getShape(); + const color = piece.getColor(); + const s = 20; + const offsetX = Math.floor((canvasW / s - shape[0].length) / 2); + const offsetY = Math.floor((canvasH / s - shape.length) / 2); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) + drawCell(ctx, offsetX + col, offsetY + row, color, s); +} + +function _drawShieldOverlay(ctx, w, h, alpha) { + ctx.save(); + ctx.strokeStyle = `rgba(0,212,255,${alpha})`; + ctx.lineWidth = 4; + ctx.shadowColor = '#00d4ff'; + ctx.shadowBlur = 16; + ctx.strokeRect(2, 2, w - 4, h - 4); + ctx.shadowBlur = 0; + ctx.restore(); +} + +function render() { + // Grille principale + clearCanvas(ctxMain, 300, 600); + drawGridLines(ctxMain, 10, 20, CELL); + + for (let y = 0; y < game.grid.length; y++) + for (let x = 0; x < game.grid[y].length; x++) + if (game.grid[y][x] !== 0) + drawCell(ctxMain, x, y, game.grid[y][x], CELL); + + // Ghost + pièce courante + if (game.currentPiece) { + drawGhost(ctxMain, game.currentPiece, game.grid); + const { x, y } = game.currentPiece.getPosition(); + const shape = game.currentPiece.getShape(); + const color = game.currentPiece.getColor(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) + drawCell(ctxMain, x + col, y + row, color, CELL); + } + + // Shield overlay (bordure cyan pulsée) + if (game.shieldActive) { + const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150); + _drawShieldOverlay(ctxMain, 300, 600, pulse); + } + + // Panneaux miniatures + drawMiniPiece(ctxNext, game.nextPiece, 100, 80); + drawMiniPiece(ctxHold, game.storedPiece, 100, 80); + + // Score + document.getElementById('score-display').textContent = game.score; + + // Shield status UI + const shieldEl = document.getElementById('shield-status-display'); + const shieldBar = document.getElementById('shield-bar'); + if (shieldEl) { + if (game.shieldActive) { + const secs = Math.ceil(game.shieldActiveMs / 1000); + shieldEl.textContent = `ACTIF ${secs}s`; + shieldEl.className = 'score-value shield-active'; + if (shieldBar) shieldBar.style.width = (game.shieldActiveMs / 3000 * 100) + '%'; + } else if (game.shieldReady) { + shieldEl.textContent = 'PRÊT'; + shieldEl.className = 'score-value shield-ready'; + if (shieldBar) shieldBar.style.width = '100%'; + } else { + const secs = Math.ceil(game.shieldCooldownMs / 1000); + shieldEl.textContent = `${secs}s`; + shieldEl.className = 'score-value shield-cooldown'; + if (shieldBar) shieldBar.style.width = ((1 - game.shieldCooldownMs / 60000) * 100) + '%'; + } + } +} + +function renderOpponent(opponentGrid) { + clearCanvas(ctxOpponent, 300, 600); + drawGridLines(ctxOpponent, 10, 20, CELL); + for (let y = 0; y < opponentGrid.length; y++) + for (let x = 0; x < opponentGrid[y].length; x++) + if (opponentGrid[y][x] !== 0) + drawCell(ctxOpponent, x, y, opponentGrid[y][x], CELL); + + // Shield overlay adversaire + if (typeof duel !== 'undefined' && duel && duel.opponentShieldActive) { + const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150); + _drawShieldOverlay(ctxOpponent, 300, 600, pulse); + } + + // Indicateur HTML adversaire + const oppShieldEl = document.getElementById('opponent-shield-indicator'); + if (oppShieldEl) { + const active = typeof duel !== 'undefined' && duel && duel.opponentShieldActive; + oppShieldEl.style.display = active ? 'block' : 'none'; + } +} + +// Restore saved theme +(function() { + const saved = localStorage.getItem('tetris-theme'); + if (saved && THEMES[saved]) setColorTheme(saved); +})(); diff --git a/Transcendence/srcs/frontend/src/stats.js b/Transcendence/srcs/frontend/src/stats.js new file mode 100644 index 0000000..0a55cbe --- /dev/null +++ b/Transcendence/srcs/frontend/src/stats.js @@ -0,0 +1,122 @@ +import { Window } from './windows.js'; +import { API, STORAGE_KEYS } from './config.js'; + +/** + * Stats window — displays Scribble + Tetris stats for any user + * Usage: windowRegistry.get('stats').showUser(username) + */ +export class StatsWindow extends Window { + constructor() { + super({ + name: 'stats', + title: 'Statistiques', + cssClasses: ['stats-window'] + }); + + this.buildUI(); + } + + buildUI() { + this.avatarEl = this.createElement('img', 'stats__avatar', { alt: 'Avatar' }); + this.avatarEl.src = '/avatar/default.png'; + + this.usernameEl = this.createElement('div', 'stats__username'); + + // Scribble section + const scribbleSection = this.createElement('div', 'stats__section'); + const scribbleTitle = this.createElement('div', 'stats__section-title', { text: 'Scribble' }); + this.scribbleBody = this.createElement('div', 'stats__section-body'); + scribbleSection.append(scribbleTitle, this.scribbleBody); + + // Tetris section + const tetrisSection = this.createElement('div', 'stats__section'); + const tetrisTitle = this.createElement('div', 'stats__section-title', { text: 'Tetris' }); + this.tetrisBody = this.createElement('div', 'stats__section-body'); + tetrisSection.append(tetrisTitle, this.tetrisBody); + + this.body.append(this.avatarEl, this.usernameEl, scribbleSection, tetrisSection); + } + + async showUser(username) { + this.show(); + this.setTitle('Statistiques'); + this.usernameEl.textContent = username; + this.avatarEl.src = '/avatar/default.png'; + this.scribbleBody.innerHTML = '
Chargement…
'; + this.tetrisBody.innerHTML = ''; + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return; + + try { + const res = await fetch(API.STATS.USER(username), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) { + this.scribbleBody.innerHTML = '
Erreur
'; + return; + } + const data = await res.json(); + this.renderStats(data); + } catch (err) { + console.error('Stats load error:', err); + } + } + + async showMe() { + this.show(); + this.setTitle('Mes statistiques'); + this.scribbleBody.innerHTML = '
Chargement…
'; + this.tetrisBody.innerHTML = ''; + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return; + + try { + const res = await fetch(API.STATS.ME, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) return; + const data = await res.json(); + this.renderStats(data); + } catch (err) { + console.error('Stats load error:', err); + } + } + + renderStats(data) { + this.setTitle(`Stats — ${data.username}`); + this.usernameEl.textContent = data.username; + this.avatarEl.src = data.avatar_url || '/avatar/default.png'; + + this.scribbleBody.innerHTML = ` +
+ Points + ${data.total_points || 0} +
+
+ Parties + ${data.games_played || 0} +
+
+ Victoires + ${data.games_won || 0} +
+ `; + + this.tetrisBody.innerHTML = ` +
+ Meilleur score + ${data.tetris_best_score || 0} +
+
+ Duels gagnés + ${data.tetris_wins || 0} +
+
+ Parties + ${data.tetris_games_played || 0} +
+ `; + } +} diff --git a/Transcendence/srcs/frontend/src/tetris.css b/Transcendence/srcs/frontend/src/tetris.css new file mode 100644 index 0000000..36de98c --- /dev/null +++ b/Transcendence/srcs/frontend/src/tetris.css @@ -0,0 +1,686 @@ +:root { + --bg: #000500; + --panel: #000d00; + --border: #004400; + --accent: #00ff41; + --accent2:#39ff14; + --dim: #1a5c1a; + --text: #00cc26; +} + +@keyframes flicker { + 0%, 89%, 91%, 93%, 95%, 100% { opacity: 1; } + 90%, 92%, 94% { opacity: 0.82; } +} + +@keyframes glitch-before { + 0%, 100% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); } + 5% { clip-path: polygon(0 15%, 100% 15%, 100% 25%, 0 25%); transform: translate(-4px, 0); color: #ff003c; } + 10% { clip-path: polygon(0 60%, 100% 60%, 100% 70%, 0 70%); transform: translate(4px, 0); color: #ff003c; } + 15%, 85% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); } + 90% { clip-path: polygon(0 40%, 100% 40%, 100% 55%, 0 55%); transform: translate(-3px, 0); color: #ff003c; } +} + +@keyframes glitch-after { + 0%, 100% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); } + 5% { clip-path: polygon(0 70%, 100% 70%, 100% 80%, 0 80%); transform: translate(4px, 0); color: #00ffff; } + 10% { clip-path: polygon(0 30%, 100% 30%, 100% 45%, 0 45%); transform: translate(-4px, 0); color: #00ffff; } + 15%, 85% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); } + 90% { clip-path: polygon(0 10%, 100% 10%, 100% 25%, 0 25%); transform: translate(3px, 0); color: #00ffff; } +} + +@keyframes cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +@keyframes scan { + 0% { background-position: 0 0; } + 100% { background-position: 0 100%; } +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--bg); + font-family: 'Share Tech Mono', monospace; + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + overflow: hidden; + animation: flicker 8s infinite; +} + +#scale-container { + display: flex; + flex-direction: column; + align-items: center; + width: max-content; + position: relative; + z-index: 1; + /* transform et margin-bottom gérés par JS */ +} + +/* Grid lines */ +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(0,255,65,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,255,65,0.04) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; +} + +/* Scanlines CRT */ +body::after { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.12) 2px, + rgba(0, 0, 0, 0.12) 4px + ); + pointer-events: none; + z-index: 9998; +} + +h1 { + font-family: 'Share Tech Mono', monospace; + font-weight: 900; + font-size: 2.2rem; + letter-spacing: 0.4em; + color: var(--accent); + text-shadow: 0 0 10px var(--accent), 0 0 30px var(--accent), 0 0 60px rgba(0,255,65,0.4); + margin-bottom: 20px; + position: relative; + z-index: 1; +} + +h1::before { + content: attr(data-text); + position: absolute; + top: 0; left: 0; width: 100%; + color: var(--accent); + animation: glitch-before 6s infinite; +} + +h1::after { + content: attr(data-text); + position: absolute; + top: 0; left: 0; width: 100%; + color: var(--accent); + animation: glitch-after 6s infinite; +} + +.cursor { + animation: cursor-blink 1s step-end infinite; + color: var(--accent); +} + +/* ── Zone de jeu globale ── */ +#game-area { + display: flex; + gap: 32px; + align-items: flex-start; + position: relative; + z-index: 1; +} + +/* ── Section locale ── */ +#local-section { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +#app { + display: flex; + gap: 16px; + align-items: flex-start; +} + +/* ── Section adversaire ── */ +#opponent-section { + display: none; /* masqué jusqu'à connexion duel */ + gap: 16px; + align-items: flex-start; +} +#opponent-section.visible { + display: flex; +} + +.opponent-info-panel { + width: 130px; +} + +/* ── Panneaux ── */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0; + padding: 14px; + width: 130px; + box-shadow: 0 0 20px rgba(0,255,65,0.07), inset 0 0 20px rgba(0,0,0,0.5); +} + +.panel-title { + font-family: 'Orbitron', monospace; + font-size: 0.6rem; + letter-spacing: 0.2em; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 10px; + text-align: center; +} + +canvas { display: block; border-radius: 0; } + +#canvas-main { + border: 1px solid var(--accent); + box-shadow: 0 0 20px rgba(0,255,65,0.15), 0 0 40px rgba(0,255,65,0.06), inset 0 0 30px rgba(0,0,0,0.7); +} + +#canvas-next, #canvas-hold { + border: 1px solid var(--border); + margin: 0 auto; +} + +/* ── Canvas adversaire ── */ +#canvas-opponent { + border: 1px solid var(--accent2); + box-shadow: 0 0 20px rgba(57,255,20,0.12), inset 0 0 30px rgba(0,0,0,0.5); +} + +/* ── Score ── */ +.score-block { + margin-top: 14px; + text-align: center; +} + +.score-label { + font-size: 0.55rem; + letter-spacing: 0.2em; + color: var(--dim); + text-transform: uppercase; + margin-bottom: 4px; +} + +.score-value { + font-family: 'Orbitron', monospace; + font-size: 1.4rem; + font-weight: 700; + color: var(--accent); + text-shadow: 0 0 10px var(--accent); +} + +/* ── Boutons ── */ +.btn-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; +} + +#btn-home { + color: var(--text); +} + +button { + font-family: 'Share Tech Mono', monospace; + font-size: 0.6rem; + letter-spacing: 0.12em; + font-weight: 700; + text-transform: uppercase; + padding: 10px 8px; + border: 1px solid; + border-radius: 0; + cursor: pointer; + transition: all 0.15s; + background: transparent; + width: 100%; +} + +#btn-start { + color: var(--accent); + border-color: var(--accent); +} + +#btn-start:hover:not(:disabled) +{ + background: var(--accent); + color: var(--bg); + box-shadow: 0 0 15px var(--accent); +} + +#btn-restart { + color: var(--accent2); + border-color: var(--accent2); +} + +#btn-restart:hover:not(:disabled){ + background: var(--accent2); + color: var(--bg); + box-shadow: 0 0 15px var(--accent2); +} + +#btn-pause { + color: var(--accent2); + border-color: var(--accent2); +} +#btn-pause:hover:not(:disabled) { + background: var(--accent2); + color: var(--bg); box-shadow: 0 0 15px var(--accent2); +} + +#btn-stop { color: #ef4444; border-color: #ef4444; } +#btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; } + +button:disabled { opacity: 0.3; cursor: not-allowed; } + +/* ── Contrôles ── */ +.controls-list { + margin-top: 14px; + font-size: 0.6rem; + line-height: 2; + color: var(--dim); +} +.controls-list span { color: var(--text); } + +/* ── Overlays ── */ +#main-wrapper, +#opponent-wrapper { position: relative; } + +#overlay, +#overlay-opponent { + display: none; + position: absolute; + top: 0; left: 0; + width: 300px; + height: 600px; + background: rgba(0,5,0,0.9); + border-radius: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + z-index: 10; + pointer-events: none; +} +#overlay.visible, +#overlay-opponent.visible { display: flex; } + +#overlay-title { + font-family: 'Share Tech Mono', monospace; + font-size: 1.4rem; + font-weight: 900; + letter-spacing: 0.2em; + color: #ff003c; + text-shadow: 0 0 20px #ff003c, 0 0 40px #ff003c; +} + +#overlay-score { + font-family: 'Share Tech Mono', monospace; + font-size: 0.9rem; + color: var(--accent); + text-shadow: 0 0 10px var(--accent); +} + +#overlay-opponent-title { + font-family: 'Share Tech Mono', monospace; + font-size: 1.4rem; + font-weight: 900; + letter-spacing: 0.2em; + color: var(--accent); + text-shadow: 0 0 20px var(--accent); +} + +#overlay-opponent-score { + font-family: 'Share Tech Mono', monospace; + font-size: 0.9rem; + color: var(--accent2); +} + +/* ── Panneau duel ── */ +#duel-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0; + padding: 12px 20px; + margin-bottom: 14px; + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 0 20px rgba(0,255,65,0.04); +} + +.duel-row { + display: flex; + gap: 8px; + align-items: center; +} + +#input-room-code { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent2); + font-family: 'Orbitron', monospace; + font-size: 0.7rem; + letter-spacing: 0.15em; + padding: 6px 10px; + width: 120px; + text-transform: uppercase; + outline: none; + transition: border-color 0.2s; +} +#input-room-code:focus { + border-color: var(--accent2); + box-shadow: 0 0 8px rgba(255,0,170,0.2); +} + +#btn-join-duel { color: var(--accent2); border-color: var(--accent2); width: auto; padding: 6px 14px; } +#btn-join-duel:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 12px var(--accent2); } + +#btn-leave-duel { color: #ef4444; border-color: #ef4444; width: auto; padding: 6px 14px; } +#btn-leave-duel:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 12px #ef4444; } + +#duel-status { + font-size: 0.6rem; + letter-spacing: 0.1em; + color: var(--dim); + min-width: 120px; +} +#duel-status.waiting { color: #f97316; } +#duel-status.ready { color: var(--accent); } + +/* ── Colonne gauche (panel + settings empilés) ── */ +#left-column { + display: flex; + flex-direction: column; + gap: 16px; + width: 130px; + flex-shrink: 0; +} + +/* ── Settings Panel ── */ +#settings-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0; + padding: 14px; + box-shadow: 0 0 20px rgba(0,255,65,0.05); + display: flex; + flex-direction: column; + gap: 10px; + width: 130px; +} + +.settings-title { + font-family: 'Orbitron', monospace; + font-size: 0.6rem; + letter-spacing: 0.2em; + color: var(--accent); + text-transform: uppercase; + text-align: center; + margin-bottom: 4px; +} + +.settings-row { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.55rem; + color: var(--dim); + letter-spacing: 0.05em; +} + +/* ── Theme color picker ── */ +.theme-btns { + display: flex; + gap: 6px; + margin-top: 2px; +} + +.theme-btn { + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} + +.theme-btn[data-theme="green"] { background: #00ff41; } +.theme-btn[data-theme="red"] { background: #ff1744; } +.theme-btn[data-theme="yellow"] { background: #ffd600; } +.theme-btn[data-theme="blue"] { background: #00b0ff; } + +.theme-btn:hover { transform: scale(1.2); } + +.theme-btn.active { + border-color: #ffffff; + box-shadow: 0 0 8px currentColor; + transform: scale(1.15); +} + +#settings-panel input[type="number"] { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + font-family: 'Orbitron', monospace; + font-size: 0.65rem; + padding: 4px 8px; + width: 100%; + text-align: right; + outline: none; + transition: border-color 0.2s; +} + +#settings-panel input[type="number"]:focus { + border-color: var(--accent); + box-shadow: 0 0 8px rgba(0,255,231,0.2); +} + +#settings-panel input[type="number"]:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ── Matchmaking ── */ +#btn-matchmaking, #btn-matchmaking-cancel { + background: transparent; + border: 1px solid var(--accent2); + border-radius: 4px; + color: var(--accent2); + font-family: 'Share Tech Mono', monospace; + font-size: 0.65rem; + padding: 5px 10px; + cursor: pointer; + transition: background 0.2s, box-shadow 0.2s; + flex: 1; +} + +#btn-matchmaking:hover:not(:disabled) { + background: rgba(255,0,170,0.15); + box-shadow: 0 0 8px rgba(255,0,170,0.3); +} + +#btn-matchmaking-cancel { + border-color: var(--dim); + color: var(--dim); +} + +#btn-matchmaking-cancel:not(:disabled) { + border-color: var(--accent2); + color: var(--accent2); +} + +#btn-matchmaking:disabled, #btn-matchmaking-cancel:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +#matchmaking-status { + font-size: 0.6rem; + min-height: 1rem; + text-align: center; + letter-spacing: 0.05em; +} + +#matchmaking-status.waiting { color: #ffcc00; } +#matchmaking-status.ready { color: var(--accent); } + +/* ── Leaderboards ── */ +#leaderboard-section { + position: relative; + z-index: 1; + width: 100%; + max-width: 620px; + margin: 20px auto 30px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0; + overflow: hidden; + box-shadow: 0 0 20px rgba(0,255,65,0.05); +} + +.leaderboard-tabs { + display: flex; + border-bottom: 1px solid var(--border); +} + +.lb-tab { + flex: 1; + background: transparent; + border: none; + color: var(--dim); + font-family: 'Orbitron', monospace; + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 10px; + cursor: pointer; + transition: color 0.2s, background 0.2s; +} + +.lb-tab:hover { color: var(--text); } + +.lb-tab--active { + color: var(--accent); + background: rgba(0,255,65,0.05); + border-bottom: 2px solid var(--accent); +} + +.lb-content { display: none; } +.lb-content--active { display: block; } + +.lb-table { + width: 100%; + border-collapse: collapse; + font-size: 0.7rem; +} + +.lb-table th { + text-align: left; + padding: 8px 12px; + color: var(--accent); + font-family: 'Orbitron', monospace; + font-size: 0.55rem; + letter-spacing: 0.1em; + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} + +.lb-table td { + padding: 7px 12px; + border-bottom: 1px solid rgba(26,26,62,0.5); + color: var(--text); +} + +.lb-table tr:last-child td { border-bottom: none; } + +.lb-table tr:hover td { + background: rgba(0,255,231,0.03); +} + +.lb-table tr.lb-me td { + background: rgba(0,255,231,0.07); + color: var(--accent); +} + +.lb-you { + color: var(--dim); + font-size: 0.6rem; +} + +.lb-table tr.lb-separator td { + text-align: center; + color: var(--dim); + padding: 4px; + font-size: 0.6rem; + border-bottom: none; +} + +.lb-table td:first-child { + color: var(--dim); + font-size: 0.6rem; + width: 30px; +} + +.hist-win { + color: var(--accent); + font-weight: bold; +} + +.hist-loss { + color: var(--accent2); +} + +body { overflow: hidden; } + + +/* ── Shield ───────────────────────────────── */ +.shield-bar-bg { + width: 100%; + height: 4px; + background: rgba(0,212,255,0.15); + border-radius: 2px; + margin-top: 4px; + overflow: hidden; +} + +.shield-bar { + height: 100%; + background: #00d4ff; + border-radius: 2px; + transition: width 0.1s linear; + box-shadow: 0 0 6px #00d4ff; +} + +.shield-ready { color: #00d4ff !important; } +.shield-active { color: #00ffff !important; text-shadow: 0 0 8px #00ffff; } +.shield-cooldown { color: var(--dim) !important; } + +kbd { + display: inline-block; + padding: 0 3px; + border: 1px solid var(--border); + border-radius: 2px; + font-size: 0.6rem; + font-family: inherit; + color: var(--dim); +} diff --git a/Transcendence/srcs/frontend/src/tetris.html b/Transcendence/srcs/frontend/src/tetris.html new file mode 100644 index 0000000..970ce88 --- /dev/null +++ b/Transcendence/srcs/frontend/src/tetris.html @@ -0,0 +1,248 @@ + + + + + + TETRIS + + + + + + + +
+ +

TETRIS_

+ + + Home + + +
+ Duel +
+ + + +
+
+ + +
+
+
+
+ +
+ + +
+
+ + +
+
+
Hold
+ + +
+
Score
+
0
+
+ +
+
Shield E
+
PRÊT
+
+
+ +
+ + + +
+
+ + +
+
Paramètres
+
+ +
+ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
GAME OVER
+
+
+
+ + +
+
Next
+ + +
+
← → Déplacer
+
Descendre
+
Q Rot. gauche
+
W Rot. droite
+
Espace Drop
+
C Hold
+
E Shield
+
+
+ +
+
+ + +
+
+
Adversaire
+
+
Score
+
+
+ +
+ +
+ +
+
+
+
+
+
+ +
+ + +
+
+ + + +
+ +
+ + + + + + + +
#JoueurMeilleur scoreParties
Chargement…
+
+ +
+ + + + + + + +
#JoueurVictoiresParties
Chargement…
+
+ +
+ + + + + + + +
#DateTypeScoreRésultat
Chargement…
+
+
+ +
+ + + + + + + + + + + + + diff --git a/Transcendence/srcs/frontend/src/tetris.js b/Transcendence/srcs/frontend/src/tetris.js new file mode 100644 index 0000000..ec40e7f --- /dev/null +++ b/Transcendence/srcs/frontend/src/tetris.js @@ -0,0 +1,452 @@ +// ───────────────────────────────────────────── +// LOGIQUE TETRIS +// ─────────────────────────────────────────── + +class Tetris { + constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null, onShieldChanged = null) { + this.onRender = onRender; + this.onGameOver = onGameOver; + this.onBlockPlaced = onBlockPlaced; + this.onLinesCleared = onLinesCleared; + this.onShieldChanged = onShieldChanged; + + this.grid = this._createGrid(10, 20); + this.bufferGrid = this._createGrid(10, 5); + this.currentPiece = null; + this.storedPiece = null; + this.nextPiece = null; + + this.score = 0; + this.initialTimeToDown = 1000; + this.timeToDown = 1000; + this.hardening = 1000; + this.count = 0; + this.decrementTTD = 100; + + this.lastLandingCol = 4; + + this.isRunning = false; + this.isPaused = false; + this.canStore = true; + + // Shield + this.shieldActive = false; + this.shieldActiveMs = 0; + this.shieldCooldownMs = 0; + this.shieldReady = true; // prêt dès le début + + this.animationFrameId = null; + this.lastTime = 0; + this.accumulator = 0; + + this._keyHandler = this._handleKey.bind(this); + } + + configure({ timeToDown, hardening, decrementTTD }) { + if (timeToDown !== undefined) this.initialTimeToDown = this.timeToDown = timeToDown; + if (hardening !== undefined) this.hardening = hardening; + if (decrementTTD !== undefined) this.decrementTTD = decrementTTD; + } + + _createGrid(w, h) { + return Array.from({ length: h }, () => Array(w).fill(0)); + } + + start() { + if (this.isRunning) return; + this.isRunning = true; + this.isPaused = false; + this.grid = this._createGrid(10, 20); + this.score = 0; + this.count = 0; + this.timeToDown = this.initialTimeToDown; + this.storedPiece = null; + this.canStore = true; + this.shieldActive = false; + this.shieldActiveMs = 0; + this.shieldCooldownMs = 0; + this.shieldReady = true; + this._spawnNewPiece(); + document.addEventListener('keydown', this._keyHandler); + this._startGameLoop(); + } + + stop() { + this.isRunning = false; + this.isPaused = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.accumulator = 0; + this.lastTime = 0; + document.removeEventListener('keydown', this._keyHandler); + } + + restart() { + this.stop(); + this.start(); + } + + pause() { + if (!this.isRunning) return; + this.isPaused = !this.isPaused; + if (!this.isPaused) { + this.lastTime = 0; + this._startGameLoop(); + } + } + + _startGameLoop() { + this.lastTime = 0; + this.accumulator = 0; + + const gameLoop = (currentTime) => { + if (!this.isRunning) return; + + if (this.isPaused) { + this.animationFrameId = requestAnimationFrame(gameLoop); + return; + } + + if (this.lastTime === 0) { + this.lastTime = currentTime; + this.animationFrameId = requestAnimationFrame(gameLoop); + return; + } + + const deltaTime = currentTime - this.lastTime; + this.lastTime = currentTime; + this.accumulator += deltaTime; + + this._updateShield(deltaTime); + + while (this.isRunning && this.accumulator >= this.timeToDown) { + this._tick(); + this.accumulator -= this.timeToDown; + if (this.accumulator > this.timeToDown * 3) { + this.accumulator = 0; + break; + } + } + + this.onRender(); + this.animationFrameId = requestAnimationFrame(gameLoop); + }; + + this.animationFrameId = requestAnimationFrame(gameLoop); + } + + _tick() { + if (!this.currentPiece) return; + if (this._canMoveDown()) { + this.currentPiece.moveDown(); + } else { + this._lockPiece(); + this.verifierLignes(); + this._makeHarder(); + this._spawnNewPiece(); + this.canStore = true; + if (!this._canSpawn()) this._gameOver(true); + } + } + + _handleKey(e) { + if (!this.isRunning || !this.currentPiece) return; + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + if (!this.isPaused && this._canMoveLeft()) this.currentPiece.moveLeft(); + break; + case 'ArrowRight': + e.preventDefault(); + if (!this.isPaused && this._canMoveRight()) this.currentPiece.moveRight(); + break; + case 'ArrowDown': + e.preventDefault(); + if (!this.isPaused && this._canMoveDown()) { + this.currentPiece.moveDown(); + this.score += 1; + this.accumulator = 0; + } + break; + case ' ': + e.preventDefault(); + if (!this.isPaused) this._hardDrop(); + break; + case 'q': case 'Q': + e.preventDefault(); + if (!this.isPaused) this._rotatePiece(-1); + break; + case 'w': case 'W': + e.preventDefault(); + if (!this.isPaused) this._rotatePiece(1); + break; + case 'c': case 'C': + e.preventDefault(); + if (!this.isPaused) this._storePiece(); + break; + case 'e': case 'E': + e.preventDefault(); + if (!this.isPaused) this._activateShield(); + break; + } + + this.onRender(); + } + + _activateShield() { + if (!this.shieldReady || this.shieldActive) return; + this.shieldActive = true; + this.shieldActiveMs = 3000; + this.shieldReady = false; + if (this.onShieldChanged) this.onShieldChanged('activated'); + } + + _updateShield(deltaTime) { + if (this.shieldActive) { + this.shieldActiveMs -= deltaTime; + if (this.shieldActiveMs <= 0) { + this.shieldActive = false; + this.shieldActiveMs = 0; + this.shieldCooldownMs = 60000; + if (this.onShieldChanged) this.onShieldChanged('deactivated'); + } + } else if (!this.shieldReady) { + this.shieldCooldownMs -= deltaTime; + if (this.shieldCooldownMs <= 0) { + this.shieldCooldownMs = 0; + this.shieldReady = true; + if (this.onShieldChanged) this.onShieldChanged('ready'); + } + } + } + + _hardDrop() { + if (!this.currentPiece) return; + let dist = 0; + while (this._canMoveDown()) { this.currentPiece.moveDown(); dist++; } + this.score += dist * 2; + this._lockPiece(); + this.verifierLignes(); + this._makeHarder(); + this._spawnNewPiece(); + this.canStore = true; + this.accumulator = 0; + if (!this._canSpawn()) this._gameOver(true); + } + + _rotatePiece(direction) { + if (!this.currentPiece) return; + const originalPos = { ...this.currentPiece.getPosition() }; + + if (direction === -1) this.currentPiece.rotateLeft(); + else this.currentPiece.rotateRight(); + + if (!this._isValidPosition()) { + this.currentPiece.moveRight(); + if (this._isValidPosition()) return; + + this.currentPiece.moveLeft(); + this.currentPiece.moveLeft(); + if (this._isValidPosition()) return; + + this.currentPiece.moveLeft(); + if (this._isValidPosition()) return; + + this.currentPiece.moveRight(); + this.currentPiece.moveRight(); + this.currentPiece.position.y--; + if (this._isValidPosition()) return; + + this.currentPiece.position.y = originalPos.y; + this.currentPiece.position.x = originalPos.x; + if (direction === -1) this.currentPiece.rotateRight(); + else this.currentPiece.rotateLeft(); + } + } + + _storePiece() { + if (!this.canStore || !this.currentPiece) return; + + if (this.storedPiece === null) { + this.storedPiece = this.currentPiece; + this._spawnNewPiece(); + } else { + const temp = this.storedPiece; + this.storedPiece = this.currentPiece; + this.currentPiece = temp; + this.currentPiece.position.x = 3; + this.currentPiece.position.y = 0; + } + this.canStore = false; + this.accumulator = 0; + } + + _spawnNewPiece() { + this.currentPiece = this.nextPiece || this._createRandomPiece(); + this.nextPiece = this._createRandomPiece(); + this._updateBufferGrid(); + } + + _createRandomPiece() { + const types = [PieceT, PieceL, PieceReverseL, PieceI, PieceZ, PieceReverseZ, PieceO]; + return new types[Math.floor(Math.random() * types.length)](3, 0); + } + + _updateBufferGrid() { + this.bufferGrid = this._createGrid(10, 5); + if (!this.nextPiece) return; + const shape = this.nextPiece.getShape(); + const offsetX = Math.floor((10 - shape[0].length) / 2); + for (let y = 0; y < shape.length; y++) + for (let x = 0; x < shape[y].length; x++) + if (shape[y][x] !== 0) + this.bufferGrid[y + 1][x + offsetX] = this.nextPiece.getColor(); + } + + verifierLignes() { + let cleared = 0; + for (let y = this.grid.length - 1; y >= 0; y--) { + if (this.grid[y].every(c => c !== 0)) { + this.grid.splice(y, 1); + this.grid.unshift(Array(10).fill(0)); + cleared++; + y++; + } + } + const points = [0, 100, 300, 500, 800]; + this.score += points[cleared]; + this.count += points[cleared]; + if (cleared > 0) { + // Chaque ligne remplie réduit le cooldown du shield de 10s + if (!this.shieldActive && !this.shieldReady) { + this.shieldCooldownMs = Math.max(0, this.shieldCooldownMs - cleared * 10000); + if (this.shieldCooldownMs === 0) { + this.shieldReady = true; + if (this.onShieldChanged) this.onShieldChanged('ready'); + } + } + if (this.onLinesCleared) this.onLinesCleared(cleared, this.lastLandingCol); + } + } + + _makeHarder() { + if (this.count >= this.hardening) { + this.count = 0; + this.timeToDown = Math.max(100, this.timeToDown - this.decrementTTD); + } + } + + _canMoveDown() { + if (!this.currentPiece) return false; + const { x, y } = this.currentPiece.getPosition(); + const shape = this.currentPiece.getShape(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) { + const ny = y + row + 1; + const nx = x + col; + if (ny < 0) continue; // encore au-dessus de la grille + if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false; + } + return true; + } + + _canMoveLeft() { + if (!this.currentPiece) return false; + const { x, y } = this.currentPiece.getPosition(); + const shape = this.currentPiece.getShape(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) { + if (y + row < 0) continue; // au-dessus de la grille + const nx = x + col - 1; + if (nx < 0 || this.grid[y + row][nx] !== 0) return false; + } + return true; + } + + _canMoveRight() { + if (!this.currentPiece) return false; + const { x, y } = this.currentPiece.getPosition(); + const shape = this.currentPiece.getShape(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) { + if (y + row < 0) continue; // au-dessus de la grille + const nx = x + col + 1; + if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false; + } + return true; + } + + _isValidPosition() { + if (!this.currentPiece) return false; + const { x, y } = this.currentPiece.getPosition(); + const shape = this.currentPiece.getShape(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) { + const gx = x + col; + const gy = y + row; + if (gx < 0 || gx >= this.grid[0].length || + gy < 0 || gy >= this.grid.length || + this.grid[gy][gx] !== 0) return false; + } + return true; + } + + _canSpawn() { return this._isValidPosition(); } + + _lockPiece() { + if (!this.currentPiece) return; + const { x, y } = this.currentPiece.getPosition(); + const shape = this.currentPiece.getShape(); + const color = this.currentPiece.getColor(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0 && y + row >= 0) + this.grid[y + row][x + col] = color; + this.lastLandingCol = x + Math.floor(shape[0].length / 2); + if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r])); + } + + addGarbageLines(lines) { + if (this.shieldActive) return; // shield bloque les lignes garbage + if (!this.isRunning || !lines.length) return; + this.grid.splice(0, lines.length); + for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante + // La grille a remonté de lines.length lignes — on remonte la pièce du même décalage + // pour qu'elle reste dans la même position relative aux blocs verrouillés. + if (this.currentPiece) { + this.currentPiece.position.y -= lines.length; + } + if (this.grid[0].some(c => c !== 0)) { this._gameOver(false); return; } + if (!this._isValidPositionAllowTop()) this._gameOver(false); + } + + // Comme _isValidPosition mais tolère gy < 0 (zone tampon au-dessus de la grille après garbage) + _isValidPositionAllowTop() { + if (!this.currentPiece) return true; + const { x, y } = this.currentPiece.getPosition(); + const shape = this.currentPiece.getShape(); + for (let row = 0; row < shape.length; row++) + for (let col = 0; col < shape[row].length; col++) + if (shape[row][col] !== 0) { + const gy = y + row; + const gx = x + col; + if (gy < 0) continue; // au-dessus de la grille : OK + if (gx < 0 || gx >= this.grid[0].length || + gy >= this.grid.length || + this.grid[gy][gx] !== 0) return false; + } + return true; + } + + _gameOver(validBlock = false) { + this.stop(); + this.onGameOver(this.score, validBlock); + } +} diff --git a/Transcendence/srcs/frontend/src/ui.js b/Transcendence/srcs/frontend/src/ui.js new file mode 100644 index 0000000..280b07c --- /dev/null +++ b/Transcendence/srcs/frontend/src/ui.js @@ -0,0 +1,412 @@ +// ───────────────────────────────────────────── +// UI +// ───────────────────────────────────────────── + +const btnStart = document.getElementById('btn-start'); +const btnPause = document.getElementById('btn-pause'); +const btnStop = document.getElementById('btn-stop'); +const overlay = document.getElementById('overlay'); +const inputTTD = document.getElementById('input-ttd'); +const inputHardening = document.getElementById('input-hardening'); +const inputDecrement = document.getElementById('input-decrement'); + +// Duel UI +const btnJoinDuel = document.getElementById('btn-join-duel'); +const btnLeaveDuel = document.getElementById('btn-leave-duel'); +const inputRoomCode = document.getElementById('input-room-code'); +const duelStatusEl = document.getElementById('duel-status'); +const opponentSection = document.getElementById('opponent-section'); + +// Matchmaking UI +const btnMatchmaking = document.getElementById('btn-matchmaking'); +const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel'); +const matchmakingStatusEl = document.getElementById('matchmaking-status'); + +function updateButtons() { + btnStart.disabled = game.isRunning; + btnPause.disabled = !game.isRunning; + btnStop.disabled = !game.isRunning; + btnPause.textContent = game.isPaused ? 'Resume' : 'Pause'; + inputTTD.disabled = game.isRunning; + inputHardening.disabled = game.isRunning; + inputDecrement.disabled = game.isRunning; +} + +function showOverlay(title, score) { + document.getElementById('overlay-title').textContent = title; + document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : ''; + overlay.classList.add('visible'); +} + +function hideOverlay() { + overlay.classList.remove('visible'); +} + +// ───────────────────────────────────────────── +// SOCKET + DUEL +// ───────────────────────────────────────────── + +const socket = io({ + auth: { token: localStorage.getItem('auth_token') }, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + transports: ['websocket', 'polling'] +}); + +let duel = null; + +function updateDuelStatus(status, opponentName) { + duelStatusEl.className = ''; + if (status === 'waiting') { + duelStatusEl.textContent = 'En attente d\'un adversaire…'; + duelStatusEl.classList.add('waiting'); + opponentSection.classList.remove('visible'); + } else if (status === 'ready') { + duelStatusEl.textContent = `Prêt — ${opponentName}`; + duelStatusEl.classList.add('ready'); + opponentSection.classList.add('visible'); + if (duel) duel.hideOpponentOverlay(); + renderOpponent(duel ? duel.opponentGrid : Array.from({length:20}, () => Array(10).fill(0))); + } else { + duelStatusEl.textContent = '—'; + opponentSection.classList.remove('visible'); + } +} + +function startLocalGame() { + hideOverlay(); + game.start(); + updateButtons(); + render(); +} + +// ───────────────────────────────────────────── +// SCORE SAVE (solo) +// ───────────────────────────────────────────── + +function saveTetrisScore(score) { + const token = localStorage.getItem('auth_token'); + if (!token) return; + fetch('/api/stats/tetris/score', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ score }) + }) + .then(r => r.json()) + .then(data => { + if (data.bestScore !== undefined) { + console.log('Meilleur score tetris:', data.bestScore); + } + }) + .catch(err => console.error('Erreur sauvegarde score tetris:', err)); +} + +// ───────────────────────────────────────────── +// DUEL BUTTONS +// ───────────────────────────────────────────── + +btnJoinDuel.addEventListener('click', () => { + const code = inputRoomCode.value.trim().toUpperCase(); + if (!code) return; + if (duel) { duel.leave(); } + if (game.isRunning) { game.stop(); hideOverlay(); render(); updateButtons(); } + duel = new Duel(socket, game, updateDuelStatus, startLocalGame); + duel.join(code); + btnJoinDuel.disabled = true; + btnLeaveDuel.disabled = false; + inputRoomCode.disabled = true; + updateDuelStatus('waiting', null); +}); + +btnLeaveDuel.addEventListener('click', () => { + if (duel) { duel.leave(); duel = null; } + btnJoinDuel.disabled = false; + btnLeaveDuel.disabled = true; + inputRoomCode.disabled = false; + updateDuelStatus(null, null); +}); + +// ───────────────────────────────────────────── +// MATCHMAKING +// ───────────────────────────────────────────── + +btnMatchmaking.addEventListener('click', () => { + socket.emit('tetris:matchmaking-join'); + btnMatchmaking.disabled = true; + btnMatchmakingCancel.disabled = false; + btnJoinDuel.disabled = true; + matchmakingStatusEl.textContent = 'Recherche en cours…'; + matchmakingStatusEl.className = 'waiting'; +}); + +btnMatchmakingCancel.addEventListener('click', () => { + socket.emit('tetris:matchmaking-leave'); + btnMatchmaking.disabled = false; + btnMatchmakingCancel.disabled = true; + btnJoinDuel.disabled = false; + matchmakingStatusEl.textContent = ''; +}); + +socket.on('tetris:matchmaking-status', (data) => { + if (data.status === 'searching') { + matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`; + } else if (data.status === 'idle') { + matchmakingStatusEl.textContent = ''; + btnMatchmaking.disabled = false; + btnMatchmakingCancel.disabled = true; + btnJoinDuel.disabled = false; + } +}); + +socket.on('tetris:matched', (data) => { + matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`; + matchmakingStatusEl.className = 'ready'; + btnMatchmaking.disabled = false; + btnMatchmakingCancel.disabled = true; + btnJoinDuel.disabled = false; + + // Auto-rejoindre la salle générée + if (duel) { duel.leave(); } + if (game.isRunning) { game.stop(); hideOverlay(); render(); updateButtons(); } + duel = new Duel(socket, game, updateDuelStatus, startLocalGame); + duel.join(data.roomCode); + inputRoomCode.value = data.roomCode; + btnJoinDuel.disabled = true; + btnLeaveDuel.disabled = false; + inputRoomCode.disabled = true; + updateDuelStatus('waiting', null); +}); + +// ───────────────────────────────────────────── +// INIT +// ───────────────────────────────────────────── + +const game = new Tetris( + // onRender + () => { + if (duel) duel.synchronize_game(); + render(); + updateButtons(); + }, + // onGameOver + (score, validBlock) => { + const isDuel = duel && duel.isReady; + if (isDuel) { + duel.onLocalGameOver(score, validBlock); + } else { + saveTetrisScore(score); + } + render(); + updateButtons(); + showOverlay('GAME OVER', score); + loadLeaderboards(); + loadGameHistory(); + }, + // onBlockPlaced — relay duel + (grid) => { + if (duel) duel.onLocalBlockPlaced(grid, game.score); + }, + // onLinesCleared — relay duel + (count, holeCol) => { + if (duel) duel.onLocalLinesCleared(count, holeCol); + }, + // onShieldChanged — relay duel + (event) => { + if (duel) duel.onLocalShieldChanged(event); + } +); + +btnStart.addEventListener('click', () => { + if (duel && duel.isReady) { + duel.startDuel(); // déclenche les deux parties via le serveur + } else { + startLocalGame(); // solo + } +}); + +btnPause.addEventListener('click', () => { + if (duel && duel.isReady) { + duel.togglePause(); + } else { + game.pause(); + updateButtons(); + if (game.isPaused) showOverlay('PAUSE'); + else hideOverlay(); + } +}); + +btnStop.addEventListener('click', () => { + if (duel && duel.isReady) { + duel.stop(); + } else { + game.stop(); + updateButtons(); + render(); + showOverlay('STOPPED'); + } +}); + +function applySettings() { + const settings = { + timeToDown: parseInt(inputTTD.value, 10), + hardening: parseInt(inputHardening.value, 10), + decrementTTD: parseInt(inputDecrement.value, 10), + }; + game.configure(settings); + if (duel && duel.isReady) duel.syncSettings(settings); +} + +inputTTD.addEventListener('change', applySettings); +inputHardening.addEventListener('change', applySettings); +inputDecrement.addEventListener('change', applySettings); + +const btnRestart = document.getElementById('btn-restart'); +if (btnRestart) { + btnRestart.addEventListener('click', () => { + if (duel && duel.isReady) return; + game.restart(); + updateButtons(); + render(); + }); +} + +// ───────────────────────────────────────────── +// GAME HISTORY +// ───────────────────────────────────────────── + +async function loadGameHistory() { + const token = localStorage.getItem('auth_token'); + if (!token) return; + + try { + const res = await fetch('/api/stats/tetris/history', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) return; + const history = await res.json(); + renderGameHistory(history); + } catch (err) { + console.error('Erreur chargement historique:', err); + } +} + +function renderGameHistory(history) { + const tbody = document.getElementById('lb-history-body'); + if (!tbody) return; + if (!history.length) { + tbody.innerHTML = 'Aucune partie jouée'; + return; + } + + tbody.innerHTML = history.map((entry, i) => { + const date = new Date(entry.played_at).toLocaleDateString('fr-FR', { + day: '2-digit', month: '2-digit', year: '2-digit', + hour: '2-digit', minute: '2-digit' + }); + const type = entry.game_type === 'duel' ? 'Duel' : 'Solo'; + let resultHtml = '—'; + if (entry.result === 'win') resultHtml = 'Victoire'; + if (entry.result === 'loss') resultHtml = 'Défaite'; + return ` + ${i + 1} + ${date} + ${type} + ${entry.score} + ${resultHtml} + `; + }).join(''); +} + +// ───────────────────────────────────────────── +// LEADERBOARDS +// ───────────────────────────────────────────── + +async function loadLeaderboards() { + const token = localStorage.getItem('auth_token'); + if (!token) return; + + const headers = { 'Authorization': `Bearer ${token}` }; + + try { + const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([ + fetch('/api/stats/tetris/leaderboard/score', { headers }), + fetch('/api/stats/tetris/leaderboard/wins', { headers }), + fetch('/api/stats/me', { headers }), + fetch('/api/stats/tetris/rank/score', { headers }), + fetch('/api/stats/tetris/rank/wins', { headers }) + ]); + + const me = meRes.ok ? await meRes.json() : null; + const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null; + const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null; + + if (scoresRes.ok) { + const scores = await scoresRes.json(); + renderLeaderboard('lb-scores-body', scores, ['tetris_best_score', 'tetris_games_played'], me, rankScore); + } + + if (winsRes.ok) { + const wins = await winsRes.json(); + renderLeaderboard('lb-wins-body', wins, ['tetris_wins', 'tetris_games_played'], me, rankWins); + } + } catch (err) { + console.error('Erreur chargement leaderboards:', err); + } +} + +function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) { + const tbody = document.getElementById(tbodyId); + if (!tbody) return; + if (!rows.length && !me) { + tbody.innerHTML = 'Aucun résultat'; + return; + } + + const myUsername = me?.username; + const inTop = rows.some(r => r.username === myUsername); + + let html = rows.map((r, i) => { + const isMe = r.username === myUsername; + return ` + ${i + 1} + ${escapeHtml(r.username)}${isMe ? ' (vous)' : ''} + ${r[col1] ?? 0} + ${r[col2] ?? 0} + `; + }).join(''); + + if (!inTop && me && myRank !== null) { + html += `· · ·`; + html += ` + ${myRank} + ${escapeHtml(myUsername)} (vous) + ${me[col1] ?? 0} + ${me[col2] ?? 0} + `; + } + + tbody.innerHTML = html || 'Aucun résultat'; +} + +function escapeHtml(str) { + return String(str).replace(/&/g,'&').replace(//g,'>'); +} + +// Tabs leaderboard +document.querySelectorAll('.lb-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active')); + document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active')); + tab.classList.add('lb-tab--active'); + document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active'); + if (tab.dataset.tab === 'history') loadGameHistory(); + }); +}); + +// Chargement initial des leaderboards +loadLeaderboards(); +loadGameHistory();