diff --git a/Transcendence/Transcendance-Test.zip b/Transcendence/Transcendance-Test.zip new file mode 100644 index 0000000..766bda1 Binary files /dev/null and b/Transcendence/Transcendance-Test.zip differ diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/db.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/db.js new file mode 100755 index 0000000..afc4f32 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/db.js @@ -0,0 +1,184 @@ +import 'dotenv/config'; +import { Pool } from 'pg'; + +const pool = new Pool +({ + user: process.env.POSTGRES_USER, + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DB, + password: process.env.POSTGRES_PASSWORD, + port: 5432, +}); + +async function waitForDb(retries = 10, delay = 2000) +{ + for (let i = 0; i < retries; i++) + { + try + { + await pool.query('SELECT 1'); + console.log('Database is ready!'); + return ; + } + catch (err) + { + await new Promise(r => setTimeout(r, delay)); + } + } + throw new Error('Could not connect to database after multiple attempts'); +} + +async function runMigrations() +{ + try + { + // Add total_points column if it doesn't exist + await pool.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='total_points') THEN + ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_played') THEN + ALTER TABLE users ADD COLUMN games_played INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_won') THEN + ALTER TABLE users ADD COLUMN games_won INT DEFAULT 0; + END IF; + END $$; + `); + console.log('Migrations completed!'); + } + catch (err) + { + console.error('Error running migrations:', err); + } +} + +async function createTables() +{ + try + { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + 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() + ); + + CREATE TABLE IF NOT EXISTS messages( + id SERIAL PRIMARY KEY, + sender_id INT REFERENCES users(id), + received_id INT REFERENCES users(id), + content TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS friendship ( + id_user1 INT NOT NULL, + id_user2 INT NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + CHECK (id_user1 < id_user2), + PRIMARY KEY (id_user1, id_user2), + FOREIGN KEY (id_user1) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (id_user2) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS oauth_clients ( + id SERIAL PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + client_id VARCHAR(200) NOT NULL, + client_secret TEXT, + redirect_uri VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(provider, client_id) + ); + + CREATE TABLE IF NOT EXISTS game_rooms ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + status VARCHAR(20) DEFAULT 'waiting', + max_players INT DEFAULT 8, + current_round INT DEFAULT 0, + max_rounds INT DEFAULT 3, + round_duration INT DEFAULT 90, + created_at TIMESTAMP DEFAULT NOW(), + started_at TIMESTAMP, + ended_at TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS game_players ( + id SERIAL PRIMARY KEY, + room_id INT REFERENCES game_rooms(id) ON DELETE CASCADE, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + score INT DEFAULT 0, + is_drawing BOOLEAN DEFAULT FALSE, + joined_at TIMESTAMP DEFAULT NOW(), + UNIQUE(room_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS words ( + id SERIAL PRIMARY KEY, + word VARCHAR(50) NOT NULL UNIQUE + ); + + CREATE TABLE IF NOT EXISTS game_rounds ( + id SERIAL PRIMARY KEY, + room_id INT REFERENCES game_rooms(id) ON DELETE CASCADE, + round_number INT NOT NULL, + word_id INT REFERENCES words(id), + drawer_id INT REFERENCES users(id), + started_at TIMESTAMP DEFAULT NOW(), + ended_at TIMESTAMP + ); + `); + console.log('Tables created!'); + } + catch (err) + { + console.error('Error creating tables:', err); + } +} + +async function query(text, params) +{ + return (pool.query(text, params)); +} + +async function ensureOauthClient(provider, client_id, client_secret, redirect_uri) +{ + try + { + const res = await pool.query( + `SELECT id FROM oauth_clients WHERE provider = $1 AND client_id = $2`, [provider, client_id] + ); + if (res.rows.length > 0) + return res.rows[0]; + const insert = await pool.query( + `INSERT INTO oauth_clients (provider, client_id, client_secret, redirect_uri) VALUES ($1, $2, $3, $4) RETURNING id`, + [provider, client_id, client_secret, redirect_uri] + ); + return insert.rows[0]; + } + catch (err) + { + console.error('Error ensuring oauth client:', err); + throw err; + } +} + +export +{ + waitForDb, + createTables, + runMigrations, + query, + ensureOauthClient +}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/dockerfile b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/dockerfile new file mode 100755 index 0000000..b660389 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 3001 + +ENV NODE_ENV=development + +CMD ["node", "index.js"] diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/index.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/index.js new file mode 100755 index 0000000..c09c61b --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/index.js @@ -0,0 +1,59 @@ +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import {Server} from 'socket.io'; +import authRouter from './routes/auth.js'; +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 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'; + +const app = express(); +const server = http.createServer(app); +const io = new Server(server, +{ + cors: + { + origin: "*", + methods: ["GET", "POST"] + } +}); + +app.use(cors()); +app.use(express.json()); + +setupSocketIO(io); + +async function startServer() +{ + await waitForDb(); + await createTables(); + await runMigrations(); + + // Ensure GitHub OAuth client is registered in DB + try { + await ensureOauthClient('github', process.env.GITHUB_CLIENT_ID, process.env.GITHUB_CLIENT_SECRET, process.env.GITHUB_CALLBACK_URL || process.env.GITHUB_REDIRECT_URI); + } catch (e) { + console.warn('OAuth client might already exist or failed to register:', e.message); + } + + app.use('/avatar', express.static(avatarService.AVATAR_DIR)); + app.use('/api/auth', authRouter); + app.use('/api/global_chat', chatRouter); + 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, () => + { + console.log('Server ready and listening'); + }); +} + +startServer(); \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/package.json b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/package.json new file mode 100755 index 0000000..a83699d --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/package.json @@ -0,0 +1,18 @@ +{ + "type": "module", + "dependencies": + { + "express": "^4.18.2", + "pg": "^8.11.3", + "bcrypt": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "dotenv": "^17.2.3", + "socket.io": "^4.6.1", + "cors": "^2.8.5", + "passport": "0.7.0", + "passport-github2": "0.1.12", + "express-session": "1.18.0", + "multer": "^1.4.5-lts.1", + "file-type": "^19.0.0" + } +} \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/auth.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/auth.js new file mode 100755 index 0000000..f80ff21 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/auth.js @@ -0,0 +1,88 @@ +import express from 'express'; +import authService from '../services/auth.js'; +import fetch from 'node-fetch'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import {query} from '../db.js'; +import crypto from 'crypto'; + +const router = express.Router(); + +router.post('/register', async(req, res) => +{ + const {username, password} = req.body; + if (!username || !password) + return (res.status(400).json({error: 'Missing fields'})); + + const result = await authService.register(username, password); + res.status(result.status).json(result.data); +}); + +router.post('/login', async(req, res) => +{ + console.log("received login!"); + const {username, password} = req.body; + const result = await authService.login(username, password); + res.status(result.status).json(result.data); +}); + +router.get('/github', (req, res) => { + const githubAuthUrl = `https://github.com/login/oauth/authorize?` + + `client_id=${process.env.GITHUB_CLIENT_ID}&` + + `redirect_uri=${encodeURIComponent(process.env.GITHUB_CALLBACK_URL || process.env.GITHUB_REDIRECT_URI)}&` + + `scope=user:email`; + + res.redirect(githubAuthUrl); +}); + +router.get('/github/callback', async (req, res) => { + const code = req.query.code; + if (!code) { + return res.status(400).send('Missing code'); + } + try { + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code: code + }) + }); + const tokenData = await tokenResponse.json(); + const accessToken = tokenData.access_token; + if (!accessToken) throw new Error('No access token'); + + + const userResponse = await fetch('https://api.github.com/user', { + headers: { 'Authorization': `Bearer ${accessToken}`, 'User-Agent': 'Transcendence' } + }); + const ghUser = await userResponse.json(); + const ghUsername = ghUser.login || `github_${ghUser.id}`; + + + let result = await query(`SELECT id FROM users WHERE username = $1`, [ghUsername]); + let userId; + if (result.rows.length > 0) { + userId = result.rows[0].id; + } else { + const randomPwd = crypto.randomBytes(16).toString('hex'); + const passwordHash = await bcrypt.hash(randomPwd, 10); + await query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [ghUsername, passwordHash]); + const inserted = await query(`SELECT id FROM users WHERE username = $1`, [ghUsername]); + userId = inserted.rows[0].id; + } + + // Issue JWT + const token = jwt.sign({ userId: userId, username: ghUsername }, process.env.JWT_SECRET, { expiresIn: '1h' }); + + // Send token to opener window and close popup + res.send(``); + } catch (err) { + console.error(err); + res.status(500).send('GitHub OAuth error'); + } +}); + +export default router; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/avatar.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/avatar.js new file mode 100755 index 0000000..117f5e1 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/avatar.js @@ -0,0 +1,51 @@ +import express from 'express'; +import multer from 'multer'; +import avatarService from '../services/avatar.js'; +import authenticateToken from '../middleware/auth.js'; + +const router = express.Router(); + +// Configue multer to use RAM +const storage = multer.memoryStorage(); +const upload = multer +({ + storage: storage, + limits: + { + fileSize: 5 * 1024 * 1024 // 5mb + } +}); + +router.post('/upload', authenticateToken, upload.single('avatar'), async(req, res) => +{ + if (!req.file) + return res.status(400).json({ error: 'No file uploaded' }); + + const result = await avatarService.uploadAvatar(req.user.userId, req.file); + res.status(result.status).json(result.data); +}); + +router.delete('/', authenticateToken, async(req, res) => +{ + const result = await avatarService.deleteAvatar(req.user.userId); + res.status(result.status).json(result.data); +}); + +router.get('/me', authenticateToken, async(req, res) => +{ + console.log('GET /me hit, user:', req.user); + const result = await avatarService.getAvatarUrl(req.user.userId); + res.status(result.status).json(result.data); +}); + +router.get('/user/:userId', async(req, res) => +{ + const userId = parseInt(req.params.userId); + if (isNaN(userId)) + return res.status(400).json({ error: 'Invalid user ID' }); + + const result = await avatarService.getAvatarUrl(userId); + res.status(result.status).json(result.data); +}); + +export default router; \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/friends.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/friends.js new file mode 100755 index 0000000..0722388 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/friends.js @@ -0,0 +1,69 @@ +import express from 'express'; +import friendsService from '../services/friends.js'; +import authenticateToken from '../middleware/auth.js'; + +const router = express.Router(); + +// Get friends list +router.get('/', authenticateToken, async (req, res) => { + const result = await friendsService.getFriends(req.user.userId); + res.status(result.status).json(result.data); +}); + +// Get pending friend requests +router.get('/requests', authenticateToken, async (req, res) => { + const result = await friendsService.getPendingRequests(req.user.userId); + res.status(result.status).json(result.data); +}); + +// Search users +router.get('/search', authenticateToken, async (req, res) => { + const { q } = req.query; + if (!q || q.trim().length === 0) { + return res.status(400).json({ error: 'Search query required' }); + } + const result = await friendsService.searchUsers(req.user.userId, q.trim()); + res.status(result.status).json(result.data); +}); + +// Send friend request +router.post('/request/:userId', authenticateToken, async (req, res) => { + const toUserId = parseInt(req.params.userId); + if (isNaN(toUserId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.sendFriendRequest(req.user.userId, toUserId); + res.status(result.status).json(result.data); +}); + +// Accept friend request +router.post('/accept/:userId', authenticateToken, async (req, res) => { + const fromUserId = parseInt(req.params.userId); + if (isNaN(fromUserId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.acceptFriendRequest(req.user.userId, fromUserId); + res.status(result.status).json(result.data); +}); + +// Decline friend request +router.post('/decline/:userId', authenticateToken, async (req, res) => { + const fromUserId = parseInt(req.params.userId); + if (isNaN(fromUserId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.declineFriendRequest(req.user.userId, fromUserId); + res.status(result.status).json(result.data); +}); + +// Remove friend +router.delete('/:userId', authenticateToken, async (req, res) => { + const friendId = parseInt(req.params.userId); + if (isNaN(friendId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + const result = await friendsService.removeFriend(req.user.userId, friendId); + res.status(result.status).json(result.data); +}); + +export default router; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/game_room.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/game_room.js new file mode 100755 index 0000000..b144b71 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/game_room.js @@ -0,0 +1,185 @@ +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) => +{ + try + { + const rooms = await gameRoomService.listActiveRooms(); + res.json(rooms); + } + catch (err) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + +// 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 + { + const room = await gameRoomService.getRoomById(req.params.roomId); + if (!room) + return (res.status(404).json({error: 'Room not found'})); + res.json(room); + } + catch(err) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + +router.get('/:roomId/players', authenticateToken, async(req, res) => +{ + try + { + const players = await gameRoomService.getRoomPlayers(req.params.roomId); + res.json(players); + } + catch(err) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + +router.post('/', authenticateToken, async(req, res) => +{ + try + { + const {name} = req.body; + 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) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + +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) + { + console.error(err); + if (err.message.includes('full') || err.message.includes('already')) + res.status(400).json({error: err.message}); + else + res.status(500).json({error: err.message}); + } +}); + +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) + { + console.error(err); + res.status(500).json({error: 'Server error'}); + } +}); + +// 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/Transcendance-Test/Transcendence/srcs/backend/routes/global_chat.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/global_chat.js new file mode 100755 index 0000000..41279c6 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/global_chat.js @@ -0,0 +1,20 @@ +import express from 'express'; +import chatService from '../services/global_chat.js'; +import authenticateToken from '../middleware/auth.js'; +const router = express.Router(); + +router.get('/messages', authenticateToken, async(req, res) => +{ + try + { + const messages = await chatService.getRecentMessages(50); + res.json(messages); + } + 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/Transcendance-Test/Transcendence/srcs/backend/routes/player_stats.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/player_stats.js new file mode 100755 index 0000000..661d715 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/routes/player_stats.js @@ -0,0 +1,46 @@ +import express from 'express'; +import playerStatsService from '../services/player_stats.js'; +import authenticateToken from '../middleware/auth.js'; +const router = express.Router(); + +// Get current user's stats +router.get('/me', authenticateToken, async (req, res) => { + try { + const stats = await playerStatsService.getStatsByUserId(req.user.userId); + if (!stats) { + return res.status(404).json({ error: 'User not found' }); + } + res.json(stats); + } catch (err) { + console.error('Error getting user stats:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get stats by username +router.get('/user/:username', authenticateToken, async (req, res) => { + try { + const stats = await playerStatsService.getStatsByUsername(req.params.username); + if (!stats) { + return res.status(404).json({ error: 'User not found' }); + } + res.json(stats); + } catch (err) { + console.error('Error getting user stats:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get leaderboard +router.get('/leaderboard', authenticateToken, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 10, 50); + const leaderboard = await playerStatsService.getLeaderboard(limit); + res.json(leaderboard); + } catch (err) { + console.error('Error getting leaderboard:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +export default router; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/auth.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/auth.js new file mode 100755 index 0000000..ca36c25 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/auth.js @@ -0,0 +1,63 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import {query} from '../db.js'; + +async function login(username, password) +{ + try + { + const result = await query + ( + `SELECT id, password_hash FROM users WHERE username = $1`, + [username] + ); + if (result.rows.length === 0) + return ({status: 401, data: {error: 'Invalid credentials'}}); + + const user = result.rows[0]; + const match = await bcrypt.compare(password, user.password_hash); + if (!match) + return ({status: 401, data: {error: 'Invalid credentials'}}); + + const token = jwt.sign + ( + { + userId: user.id, + username: username + }, + process.env.JWT_SECRET, + {expiresIn: '1h'} + ); + + return ({status: 200, data: {token}}); + } + catch (err) + { + console.error(err); + return ({status: 500, data: {error: 'Server error'}}); + } +}; + +async function register(username, password) +{ + try + { + const password_hash = await bcrypt.hash(password, 10); + await query + ( + `INSERT INTO users (username, password_hash) VALUES ($1, $2)`, + [username, password_hash] + ); + return ({status: 201, data: {message: 'User created'}}); + } + catch (err) + { + if (err.code === '23505') + return ({status: 409, data: {error: 'Username already exists'}}); + + console.error(err); + return ({status: 500, data: {error: 'Server error'}}); + } +}; + +export default {register, login}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/avatar.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/avatar.js new file mode 100755 index 0000000..05d3b66 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/avatar.js @@ -0,0 +1,148 @@ +import {query} from '../db.js'; +import path from 'path'; +import fs from 'fs'; +import {fileURLToPath} from 'url'; +import {fileTypeFromBuffer} from 'file-type'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const AVATAR_DIR = path.join(__dirname, '../avatar'); +const DEFAULT_AVATAR = '/avatar/default.png'; +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5mb +const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + +// Verify that the avatar folder already exists +if (!fs.existsSync(AVATAR_DIR)) +{ + fs.mkdirSync(AVATAR_DIR, { recursive: true }); +} + +async function uploadAvatar(userId, file) +{ + try + { + // File check (type and size) + if (!file) + return ({status: 400, data: {error: 'No file provided'}}); + + if (!ALLOWED_TYPES.includes(file.mimetype)) + return ({status: 400, data: { error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP allowed'}}); + + const fileType = await fileTypeFromBuffer(file.buffer); + if (!fileType || !['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(fileType.mime)) + return ({status: 400, data: {error: 'Invalid file content. File does not match allowed image types'}}); + + if (file.size > MAX_FILE_SIZE) + return ({status: 400, data: {error: 'File too large. Maximum size is 5MB'}}); + + + const currentAvatar = await getCurrentAvatar(userId); + if (currentAvatar === null) + return ({status: 404, data: {error: 'User not found'}}); + + // Create a unique name for the new avatar to avoid duplicates + const fileExt = path.extname(file.originalname); + const fileName = `user_${userId}_${Date.now()}${fileExt}`; + const avatarPath = `/avatar/${fileName}`; + + // Save the new avatar in the folder + const filePath = path.join(AVATAR_DIR, fileName); + fs.writeFileSync(filePath, file.buffer); + + await setAvatar(avatarPath, userId); + + deleteNonDefault(currentAvatar); + return ({status: 200, data: {avatar_url: avatarPath}}); + } + catch (err) + { + console.error('Avatar upload error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +async function deleteAvatar(userId) { + try + { + const currentAvatar = await getCurrentAvatar(userId); + if (currentAvatar === null) + return ({status: 404, data: {error: 'User not found'}}); + + // Reset the avatar to the default one + await setAvatar(DEFAULT_AVATAR, userId); + + deleteNonDefault(currentAvatar); + return ({status: 200, data: {avatar_url: DEFAULT_AVATAR}}); + } + catch (err) + { + console.error('Avatar delete error:', err); + return ({status: 500, data: {error: 'Server error'}}); + } +} + +async function setAvatar(newAvatar, userId) +{ + await query + ( + 'UPDATE users SET avatar_url = $1 WHERE id = $2', + [newAvatar, userId] + ); +} + +async function getCurrentAvatar(userId) +{ + const res = await query + ( + 'SELECT avatar_url FROM users WHERE id = $1', + [userId] + ); + if (res.rows.length === 0) + return (null); + return (res.rows[0].avatar_url); +} + +function deleteNonDefault(curAvatar) +{ + if (curAvatar && curAvatar !== DEFAULT_AVATAR) + { + const fileName = path.basename(curAvatar); + const filePath = path.join(AVATAR_DIR, fileName); + + if (fs.existsSync(filePath)) + fs.unlinkSync(filePath); + } +} + +async function getAvatarUrl(userId) +{ + try + { + const result = await query + ( + 'SELECT avatar_url FROM users WHERE id = $1', + [userId] + ); + + if (result.rows.length === 0) + return ({status: 404, data: {error: 'User not found'}}); + + const avatarUrl = result.rows[0].avatar_url || DEFAULT_AVATAR; + return ({status: 200, data: {avatar_url: avatarUrl}}); + } + catch (err) + { + console.error('Get avatar error:', err); + return ({status: 500, data: {error: 'Server error'}}); + } +} + +export default +{ + uploadAvatar, + deleteAvatar, + getAvatarUrl, + AVATAR_DIR, + DEFAULT_AVATAR +}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/friends.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/friends.js new file mode 100755 index 0000000..f912188 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/friends.js @@ -0,0 +1,240 @@ +import { query } from '../db.js'; + +/** + * Get list of friends for a user + */ +async function getFriends(userId) { + try { + const result = await query( + `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 + WHEN f.id_user1 = $1 THEN f.id_user2 = u.id + ELSE f.id_user1 = u.id + END + ) + WHERE (f.id_user1 = $1 OR f.id_user2 = $1) + AND f.status = 'accepted'`, + [userId] + ); + return { status: 200, data: { friends: result.rows } }; + } catch (err) { + console.error('Get friends error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +/** + * Get pending friend requests received by user + */ +async function getPendingRequests(userId) { + try { + const result = await query( + `SELECT u.id, u.username, u.avatar_url, f.created_at + FROM friendship f + JOIN users u ON ( + CASE + WHEN f.id_user1 = $1 THEN f.id_user2 = u.id + ELSE f.id_user1 = u.id + END + ) + WHERE (f.id_user1 = $1 OR f.id_user2 = $1) + AND f.status = 'pending_' || $1::text`, + [userId] + ); + return { status: 200, data: { requests: result.rows } }; + } catch (err) { + console.error('Get pending requests error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +/** + * Search users by username + */ +async function searchUsers(userId, searchTerm) { + try { + const result = await query( + `SELECT id, username, avatar_url + FROM users + WHERE username ILIKE $1 + AND id != $2 + LIMIT 20`, + [`%${searchTerm}%`, userId] + ); + return { status: 200, data: { users: result.rows } }; + } catch (err) { + console.error('Search users error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +/** + * Send a friend request + */ +async function sendFriendRequest(fromUserId, toUserId) { + try { + if (fromUserId === toUserId) { + return { status: 400, data: { error: 'Cannot add yourself as friend' } }; + } + + // Check if user exists + const userCheck = await query('SELECT id FROM users WHERE id = $1', [toUserId]); + if (userCheck.rows.length === 0) { + return { status: 404, data: { error: 'User not found' } }; + } + + // Ensure id_user1 < id_user2 for the constraint + const id1 = Math.min(fromUserId, toUserId); + const id2 = Math.max(fromUserId, toUserId); + + // Check existing friendship + const existing = await query( + 'SELECT status FROM friendship WHERE id_user1 = $1 AND id_user2 = $2', + [id1, id2] + ); + + if (existing.rows.length > 0) { + const status = existing.rows[0].status; + if (status === 'accepted') { + return { status: 400, data: { error: 'Already friends' } }; + } + if (status.startsWith('pending_')) { + return { status: 400, data: { error: 'Friend request already exists' } }; + } + } + + // Status indicates who needs to accept: pending_ + const status = `pending_${toUserId}`; + + await query( + `INSERT INTO friendship (id_user1, id_user2, status) + VALUES ($1, $2, $3) + ON CONFLICT (id_user1, id_user2) + DO UPDATE SET status = $3`, + [id1, id2, status] + ); + + return { status: 200, data: { message: 'Friend request sent' } }; + } catch (err) { + console.error('Send friend request error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +/** + * Accept a friend request + */ +async function acceptFriendRequest(userId, fromUserId) { + try { + const id1 = Math.min(userId, fromUserId); + const id2 = Math.max(userId, fromUserId); + + const result = await query( + `UPDATE friendship + SET status = 'accepted' + WHERE id_user1 = $1 AND id_user2 = $2 + AND status = $3 + RETURNING *`, + [id1, id2, `pending_${userId}`] + ); + + if (result.rows.length === 0) { + return { status: 404, data: { error: 'Friend request not found' } }; + } + + return { status: 200, data: { message: 'Friend request accepted' } }; + } catch (err) { + console.error('Accept friend request error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +/** + * Decline a friend request + */ +async function declineFriendRequest(userId, fromUserId) { + try { + const id1 = Math.min(userId, fromUserId); + const id2 = Math.max(userId, fromUserId); + + const result = await query( + `DELETE FROM friendship + WHERE id_user1 = $1 AND id_user2 = $2 + AND status = $3 + RETURNING *`, + [id1, id2, `pending_${userId}`] + ); + + if (result.rows.length === 0) { + return { status: 404, data: { error: 'Friend request not found' } }; + } + + return { status: 200, data: { message: 'Friend request declined' } }; + } catch (err) { + console.error('Decline friend request error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +/** + * Get list of friend IDs for a user (for quick lookup) + */ +async function getFriendIds(userId) { + try { + const result = await query( + `SELECT + CASE + WHEN f.id_user1 = $1 THEN f.id_user2 + ELSE f.id_user1 + END as friend_id + FROM friendship f + WHERE (f.id_user1 = $1 OR f.id_user2 = $1) + AND f.status = 'accepted'`, + [userId] + ); + return result.rows.map(row => row.friend_id); + } catch (err) { + console.error('Get friend IDs error:', err); + return []; + } +} + +/** + * Remove a friend + */ +async function removeFriend(userId, friendId) { + try { + const id1 = Math.min(userId, friendId); + const id2 = Math.max(userId, friendId); + + const result = await query( + `DELETE FROM friendship + WHERE id_user1 = $1 AND id_user2 = $2 + AND status = 'accepted' + RETURNING *`, + [id1, id2] + ); + + if (result.rows.length === 0) { + return { status: 404, data: { error: 'Friendship not found' } }; + } + + return { status: 200, data: { message: 'Friend removed' } }; + } catch (err) { + console.error('Remove friend error:', err); + return { status: 500, data: { error: 'Server error' } }; + } +} + +export default { + getFriends, + getFriendIds, + getPendingRequests, + searchUsers, + sendFriendRequest, + acceptFriendRequest, + declineFriendRequest, + removeFriend +}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/game_room.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/game_room.js new file mode 100755 index 0000000..e4afaa3 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/game_room.js @@ -0,0 +1,254 @@ +import {query} from '../db.js'; + +// Create the room with name as the only parameter +// max_players, status and the other variables have their default values defined in db.js +async function createRoom(name, userId) +{ + const result = await query + ( + `INSERT INTO game_rooms (name) VALUES ($1) RETURNING *`, + [name] + ); + const room = result.rows[0]; + + await query + ( + 'INSERT INTO game_players (room_id, user_id) VALUES ($1, $2)', + [room.id, userId] + ); + return (room); +} + +async function getRoomById(roomId) +{ + const result = await query + ( + 'SELECT * FROM game_rooms WHERE id = $1', + [roomId] + ); + return (result.rows[0]); +} + +// List all the waiting rooms and the player amount in each of them +async function listActiveRooms() +{ + 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 = 'waiting' + GROUP BY r.id + ORDER BY player_count DESC, r.created_at DESC` + ); + 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); + if (!room) + throw new Error('Room not found'); + + const playerCount = await query + ( + 'SELECT COUNT(*) FROM game_players WHERE room_id = $1', + [roomId] + ); + + if (parseInt(playerCount.rows[0].count) >= 8) + throw new Error('Room is full'); + if (room.status !== 'waiting') + throw new Error('Game already started or ended'); + const result = await query + ( + 'INSERT INTO game_players (room_id, user_id) VALUES ($1, $2) RETURNING *', + [roomId, userId] + ); + return (result.rows[0]); +} + +async function leaveRoom(roomId, userId) +{ + await query + ( + 'DELETE FROM game_players WHERE room_id = $1 AND user_id = $2', + [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] + ); + } +} + +// List the players in the room and their scores +// Useful for the scoreboard and also tell which player is currently drawing +async function getRoomPlayers(roomId) +{ + const result = await query + ( + `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 + ORDER BY gp.score DESC`, + [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, + getCurrentRoom, + updateRoomStatus, + resetRoomScores, + cleanupEndedRooms +}; \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/github.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/github.js new file mode 100755 index 0000000..e69de29 diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/global_chat.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/global_chat.js new file mode 100755 index 0000000..16e8065 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/global_chat.js @@ -0,0 +1,27 @@ +import {query} from '../db.js'; + +async function saveMessage(userId, content) +{ + const result = await query + ( + 'INSERT INTO messages (sender_id, content) VALUES ($1 ,$2) RETURNING *', + [userId, content] + ); + return (result.rows[0]); +} + +async function getRecentMessages(limit = 50) +{ + const result = await query + ( + `SELECT m.sender_id, m.content, m.created_at, u.username + FROM messages m + JOIN users u ON m.sender_id = u.id + ORDER BY m.created_at DESC + LIMIT $1`, + [limit] + ); + return (result.rows.reverse()); +} + +export default {saveMessage, getRecentMessages}; \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/player_stats.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/player_stats.js new file mode 100755 index 0000000..41d820a --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/player_stats.js @@ -0,0 +1,88 @@ +import { query } from '../db.js'; + +// Get player stats by user ID +async function getStatsByUserId(userId) { + const result = await query( + `SELECT id, username, avatar_url, total_points, games_played, games_won, created_at + FROM users WHERE id = $1`, + [userId] + ); + return result.rows[0] || null; +} + +// Get player stats by username +async function getStatsByUsername(username) { + const result = await query( + `SELECT id, username, avatar_url, total_points, games_played, games_won, created_at + FROM users WHERE username = $1`, + [username] + ); + return result.rows[0] || null; +} + +// Update player points (add points to total) +async function addPoints(userId, points) { + const result = await query( + `UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE id = $2 RETURNING total_points`, + [points, userId] + ); + return result.rows[0]?.total_points || 0; +} + +// Update player points by username +async function addPointsByUsername(username, points) { + const result = await query( + `UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE username = $2 RETURNING total_points`, + [points, username] + ); + return result.rows[0]?.total_points || 0; +} + +// Increment games played +async function incrementGamesPlayed(userId) { + await query( + `UPDATE users SET games_played = COALESCE(games_played, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Increment games won +async function incrementGamesWon(userId) { + await query( + `UPDATE users SET games_won = COALESCE(games_won, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Get leaderboard (top players by points) +async function getLeaderboard(limit = 10) { + const result = await query( + `SELECT id, username, avatar_url, total_points, games_played, games_won + FROM users + WHERE total_points > 0 + ORDER BY total_points DESC + LIMIT $1`, + [limit] + ); + return result.rows; +} + +// Get user ID by username +async function getUserIdByUsername(username) { + const result = await query( + `SELECT id FROM users WHERE username = $1`, + [username] + ); + return result.rows[0]?.id || null; +} + +export default { + getStatsByUserId, + getStatsByUsername, + addPoints, + addPointsByUsername, + incrementGamesPlayed, + incrementGamesWon, + getLeaderboard, + getUserIdByUsername +}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/socket.js b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/socket.js new file mode 100755 index 0000000..5888950 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/backend/services/socket.js @@ -0,0 +1,795 @@ +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(); + +// 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; + if (!token) + return (next(new Error('Authentication error'))); + + try + { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + socket.user = decoded; + next(); + } + catch(err) + { + next(new Error('Authentication error')); + } + }); + + io.on('connection', async (socket) => + { + console.log(`User connected: ${socket.user.username}`); + + socket.join('general-chat'); + + // Send recent messages and friend IDs on connection + try { + const [recentMessages, friendIds] = await Promise.all([ + chatService.getRecentMessages(50), + friendsService.getFriendIds(socket.user.userId) + ]); + + socket.emit('chat-init', { + messages: recentMessages, + friendIds: friendIds + }); + } catch (err) { + console.error('Error fetching initial data:', err); + } + + socket.on('chat-message', async(data) => + { + try + { + const message = await chatService.saveMessage(socket.user.userId, data.content); + socket.broadcast.to('general-chat').emit('chat-message', + { + id: message.id, + sender_id: socket.user.userId, + username: socket.user.username, + content: message.content, + created_at: message.created_at + }); + } + catch (err) + { + console.error('Error saving message:', err); + socket.emit('error', {message: 'Failed to send message'}); + } + }); + + // ============================================ + // 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) + { + gameState.players = gameState.players.filter(p => p !== username); + delete gameState.scores[username]; + + io.to(roomId).emit('scores-updated', gameState.scores); + } + + 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) => { + _tetrisRelayToOpponent(socket, 'tetris:grid-update', data); + }); + + // Relay pur : lines-cleared → adversaire uniquement + socket.on('tetris:lines-cleared', (data) => { + _tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data); + }); + + // 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'); + } + }); + + // game-over → relayé en opponent-game-over chez l'adversaire + socket.on('tetris:game-over', (data) => { + _tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data); + }); + + socket.on('disconnect', async () => + { + // 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; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/dockerfile b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/dockerfile new file mode 100755 index 0000000..310e2ed --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/dockerfile @@ -0,0 +1,5 @@ +FROM nginx:alpine +COPY src /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/nginx.conf b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/nginx.conf new file mode 100755 index 0000000..273e6d8 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/nginx.conf @@ -0,0 +1,38 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + # Frontend + location / { + try_files $uri /index.html; + } + + # Backend API + location /api/ { + proxy_pass http://backend:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Socket.IO WebSocket proxying + location /socket.io/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /avatar/ { + proxy_pass http://backend:3001/avatar/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_hide_header Content-Type; + add_header Cache-Control "public, max-age=3600"; + } +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ajout du multiplayer b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ajout du multiplayer new file mode 100755 index 0000000..a02e738 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ajout du multiplayer @@ -0,0 +1,45 @@ +Je veux faire un mode duel du tetris, +il est fonctionnel est solo. + +Pour commencer, je vais devoir +creer une div qui sera le rendu +du joueur second joueur. il sera a droite de la +div principale qui lui meme sera legerement decale vers la gauche, +cette div sera grossierement identitique en style qui la div principale + +je ne vais pas voir en temps reelle +les pieces du joueur qui tombe. +A la place quand le joueur 2 a mis une piece, il envoie un signal, +le joueur 1 envoie un signal egalement quand la piece tombe. + +en mode duel, quand une ligne est clear, il envoie la ligne +moins la cellule qui a provoquer le clear (donc avec un trou pile +la ou la derniere piece est arrive) au joueur opposant. + +Ce qui a pour effet de decaler toute les lignes vers le haut +a l'opposant pour recevoir la ligne recu avec le trou. + +Pour se faire je vais devoir connecter les deux joueurs. + +Il me faudra : + +une class Duel il aura pour methode et membre: + +action_queue: c'est un tableau qui repertorie tous les +signaux a traiter, c'est un tableau qui sera partager +entre le joueur1 et le joueur2 + + +Syncronise_game: fonction qui traite les +actions de action_queue et qui verifie l'integrite du duel, +il va par exemple regarder l'etat du jeu de chaque joueur +pour voir s'il correspond bien a ce qui est attendu + +il y aura different type de signaux. +Le signal bloc pose avec le type de bloc, +sa rotation et as position + +le signal line cleared, avec le nombre de ligne +cleared et on ajoute le trou aussi + +Aucune idee de si je dois utiliser web socket ou autre diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/app.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/app.js new file mode 100755 index 0000000..02998e6 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/app.js @@ -0,0 +1,113 @@ +/** + * Application entry point + * Initializes windows and handles menu interactions + */ +import { windowRegistry } from './windows.js'; +import { LoginWindow } from './login.js'; +import { GlobalChat } from './global_chat.js'; +import { AvatarWindow } from './avatar.js'; +import { FriendsWindow } from './friends.js'; +import { GameRoomWindow } from './game_room.js'; + +/** + * Main application class + * Handles initialization and menu interactions + */ +class App { + constructor() { + this.initWindows(); + this.initMenu(); + this.initPage(); + this.initEasterEgg(); + } + + /** + * Initializes all windows + */ + initWindows() { + new LoginWindow(); + new GlobalChat(); + new AvatarWindow(); + new FriendsWindow(); + new GameRoomWindow(); + } + + /** + * Initializes the main menu + * Uses event delegation instead of IDs + */ + initMenu() { + const menu = document.querySelector('.menu'); + if (!menu) { + console.warn('Menu not found'); + return; + } + + const actionMap = { + 'login': 'login', + 'chat': 'chat', + 'avatar': 'avatar', + 'friends': 'friends' + }; + + // Event delegation on the menu + menu.addEventListener('click', (e) => { + const button = e.target.closest('.menu__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) { + return; + } + + const actionMap = { + 'gameroom': 'gameroom' + }; + + // Event delegation on the menu + page.addEventListener('click', (e) => { + const button = e.target.closest('.page__item'); + if (!button) return; + + const action = button.dataset.action; + + // Actions with associated windows + if (actionMap[action]) { + windowRegistry.toggle(actionMap[action]); + return; + } + + }); + } + + /** + * Initializes the easter egg button + */ + initEasterEgg() { + const easterEgg = document.querySelector('.easter-egg'); + if (easterEgg) { + easterEgg.addEventListener('click', () => { + alert('DONT CLICK!'); + }); + } + } +} + +// Start the application when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new App()); +} else { + new App(); +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/background.png b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/background.png new file mode 100755 index 0000000..8afd0a3 Binary files /dev/null and b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/background.png differ diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/grwweg.png b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/grwweg.png new file mode 100755 index 0000000..716affb Binary files /dev/null and b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/assets/grwweg.png differ diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/avatar.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/avatar.js new file mode 100755 index 0000000..77dccf3 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/avatar.js @@ -0,0 +1,271 @@ +import { Window } from './windows.js'; +import { API, STORAGE_KEYS, CSS } from './config.js'; +import { eventBus, Events } from './events.js'; + +/** + * Avatar management window + * Allows viewing and modifying the user's avatar + */ +export class AvatarWindow extends Window { + constructor() { + super({ + name: 'avatar', + title: 'Avatar', + cssClasses: ['avatar-window'] + }); + + this.buildUI(); + this.bindEvents(); + if (localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN)) { + this.loadAvatar(); + } + + // Listen for login events + eventBus.on(Events.USER_LOGGED_IN, () => this.loadAvatar()); + } + + /** + * Builds the user interface + */ + buildUI() { + // Avatar preview + this.preview = this.createElement('img', CSS.AVATAR_PREVIEW, { + alt: 'Avatar' + }); + + // 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', + accept: 'image/*' + }); + + // Controls + this.controls = this.createElement('div', CSS.AVATAR_CONTROLS); + + this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Choose image' + }); + + this.saveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Save avatar' + }); + + this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Refresh' + }); + + this.controls.append(this.chooseBtn, this.saveBtn, this.refreshBtn); + + // Feedback message + this.message = this.createElement('div', CSS.MESSAGE); + + // Assembly + this.body.append( + this.preview, + this.username, + this.statsContainer, + this.fileInput, + this.controls, + this.message + ); + } + + /** + * Attaches event handlers + */ + bindEvents() { + this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); + this.chooseBtn.addEventListener('click', () => this.fileInput.click()); + this.saveBtn.addEventListener('click', () => this.uploadAvatar()); + this.refreshBtn.addEventListener('click', () => this.loadAvatar()); + } + + /** + * Handles file selection + * @param {Event} e + */ + handleFileSelect(e) { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (ev) => { + this.preview.src = ev.target.result; + }; + reader.readAsDataURL(file); + } + + /** + * Decodes a JWT token and returns the payload + * @param {string} token + * @returns {object|null} + */ + decodeToken(token) { + try { + const payload = token.split('.')[1]; + return JSON.parse(atob(payload)); + } catch { + return null; + } + } + + /** + * Loads avatar from the server + */ + async loadAvatar() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + console.log('No token, skipping avatar load'); + return; + } + + // Extract username from JWT token + const tokenData = this.decodeToken(token); + if (tokenData?.username) { + this.username.textContent = tokenData.username; + } + + try { + const response = await fetch(API.AVATAR.GET, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + console.warn('Failed to load avatar, status:', response.status); + return; + } + + const data = await response.json(); + + if (data?.avatar_url) { + this.preview.src = data.avatar_url; + } else { + console.warn('Avatar URL not found in response'); + } + } 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}`; + } + + /** + * Uploads avatar to the server + */ + async uploadAvatar() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('You must be logged in', 'error'); + return; + } + + const file = this.fileInput.files?.[0]; + if (!file) { + this.showMessage('Select an image first', 'error'); + return; + } + + const formData = new FormData(); + formData.append('avatar', file); + + try { + this.showMessage('Uploading...', 'info'); + + const response = await fetch(API.AVATAR.UPLOAD, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMsg = data?.error || data?.message || 'Upload failed'; + this.showMessage(errorMsg, 'error'); + return; + } + + if (data?.avatar_url) { + this.preview.src = data.avatar_url; + } + + this.showMessage('Avatar saved!', 'success'); + eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url }); + + } catch (error) { + console.error('Avatar upload error:', error); + this.showMessage('Upload error', 'error'); + } + } + + /** + * Displays a feedback message + * @param {string} text - Message text + * @param {'success'|'error'|'info'} type - Message type + */ + showMessage(text, type = 'info') { + this.message.textContent = text; + this.message.className = CSS.MESSAGE; + + if (type === 'success') { + this.message.classList.add(CSS.MESSAGE_SUCCESS); + } else if (type === 'error') { + this.message.classList.add(CSS.MESSAGE_ERROR); + } else { + this.message.classList.add(CSS.MESSAGE_INFO); + } + } +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/config.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/config.js new file mode 100755 index 0000000..aa39164 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/config.js @@ -0,0 +1,145 @@ +/** + * Centralized application configuration + */ + +// API Endpoints +export const API = { + AUTH: { + LOGIN: '/api/auth/login', + REGISTER: '/api/auth/register', + GITHUB: '/api/auth/github' + }, + AVATAR: { + GET: '/api/avatar/me', + UPLOAD: '/api/avatar/upload' + }, + FRIENDS: { + LIST: '/api/friends', + REQUESTS: '/api/friends/requests', + SEARCH: '/api/friends/search', + 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' + } +}; + +// localStorage keys +export const STORAGE_KEYS = { + AUTH_TOKEN: 'auth_token' +}; + +// Window configuration +export const WINDOW_CONFIG = { + DEFAULT_WIDTH: 320, + DEFAULT_HEIGHT: 220, + Z_INDEX_BASE: 100 +}; + +// CSS classes (BEM convention) +export const CSS = { + // Menu + MENU: 'menu', + MENU_ITEM: 'menu__item', + MENU_ITEM_ACTIVE: 'menu__item--active', + + // Windows + WINDOW: 'window', + WINDOW_VISIBLE: 'window--visible', + WINDOW_HEADER: 'window__header', + WINDOW_TITLE: 'window__title', + WINDOW_CLOSE: 'window__close', + WINDOW_BODY: 'window__body', + + // Buttons + BTN: 'btn', + BTN_PRIMARY: 'btn--primary', + BTN_SECONDARY: 'btn--secondary', + BTN_SUCCESS: 'btn--success', + BTN_DANGER: 'btn--danger', + BTN_GITHUB: 'btn--github', + + // Forms + INPUT: 'input', + INPUT_GROUP: 'input-group', + + // Messages + MESSAGE: 'message', + MESSAGE_SUCCESS: 'message--success', + MESSAGE_ERROR: 'message--error', + MESSAGE_INFO: 'message--info', + + // Chat + CHAT: 'chat', + CHAT_OUTPUT: 'chat__output', + CHAT_INPUT: 'chat__input', + CHAT_CONTROLS: 'chat__controls', + CHAT_MESSAGE: 'chat__message', + CHAT_SYSTEM: 'chat__system', + + // Avatar + AVATAR: 'avatar', + AVATAR_PREVIEW: 'avatar__preview', + AVATAR_CONTROLS: 'avatar__controls', + AVATAR_USERNAME: 'avatar__username', + + // Friends + FRIENDS: 'friends', + FRIENDS_TABS: 'friends__tabs', + FRIENDS_TAB: 'friends__tab', + FRIENDS_TAB_ACTIVE: 'friends__tab--active', + FRIENDS_CONTENT: 'friends__content', + FRIENDS_LIST: 'friends__list', + FRIENDS_ITEM: 'friends__item', + FRIENDS_AVATAR: 'friends__avatar', + FRIENDS_NAME: 'friends__name', + FRIENDS_ACTIONS: 'friends__actions', + FRIENDS_SEARCH: 'friends__search', + 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) +export const COLORS = { + PRIMARY: '#0066cc', + SUCCESS: '#3cff01', + ERROR: '#ff4d4d', + GITHUB: '#24292e', + BACKGROUND: '#000', + SURFACE: '#222', + TEXT: '#fff' +}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/duel.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/duel.js new file mode 100755 index 0000000..1df4822 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/duel.js @@ -0,0 +1,148 @@ +// ───────────────────────────────────────────── +// 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.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) { + if (!this.isReady) return; + this.socket.emit('tetris:game-over', { score }); + } + + // ─── 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': + this._showOpponentOverlay('YOU WIN', action.score); + 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 }); + }); + + this.socket.on('tetris:start-duel', () => { + if (this.onStart) this.onStart(); + }); + } + + // ─── 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/Transcendance-Test/Transcendence/srcs/frontend/src/element.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/element.js new file mode 100755 index 0000000..e46e637 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/element.js @@ -0,0 +1,114 @@ +/** + * DOM element utilities + * This module provides helper functions for creating elements + * without depending on specific HTML IDs + */ + +/** + * Creates a DOM element with options + * @param {string} tag - HTML tag + * @param {Object} options - Configuration options + * @param {string|string[]} [options.classes] - CSS classes + * @param {string} [options.text] - Element text + * @param {string} [options.html] - Inner HTML + * @param {Object} [options.attrs] - Additional attributes + * @param {Object} [options.style] - Inline styles (avoid using) + * @param {Object} [options.events] - Events to attach + * @param {HTMLElement[]} [options.children] - Child elements + * @returns {HTMLElement} + */ +export function createElement(tag, options = {}) { + const element = document.createElement(tag); + + // Classes + if (options.classes) { + const classes = Array.isArray(options.classes) ? options.classes : [options.classes]; + element.className = classes.filter(Boolean).join(' '); + } + + // Text + if (options.text) { + element.textContent = options.text; + } + + // HTML + if (options.html) { + element.innerHTML = options.html; + } + + // Attributes + if (options.attrs) { + Object.entries(options.attrs).forEach(([key, value]) => { + element.setAttribute(key, value); + }); + } + + // Styles (use sparingly) + if (options.style) { + Object.assign(element.style, options.style); + } + + // Events + if (options.events) { + Object.entries(options.events).forEach(([event, handler]) => { + element.addEventListener(event, handler); + }); + } + + // Children + if (options.children) { + options.children.forEach(child => { + if (child) element.appendChild(child); + }); + } + + return element; +} + +/** + * Selects an element by its data-attribute + * @param {string} attr - Attribute name (without 'data-') + * @param {string} value - Value to search for + * @param {HTMLElement} [parent=document] - Parent element + * @returns {HTMLElement|null} + */ +export function findByData(attr, value, parent = document) { + return parent.querySelector(`[data-${attr}="${value}"]`); +} + +/** + * Selects all elements by their data-attribute + * @param {string} attr - Attribute name (without 'data-') + * @param {string} [value] - Value to search for (optional) + * @param {HTMLElement} [parent=document] - Parent element + * @returns {HTMLElement[]} + */ +export function findAllByData(attr, value, parent = document) { + const selector = value ? `[data-${attr}="${value}"]` : `[data-${attr}]`; + return Array.from(parent.querySelectorAll(selector)); +} + +/** + * Adds or removes a class based on a condition + * @param {HTMLElement} element + * @param {string} className + * @param {boolean} condition + */ +export function toggleClass(element, className, condition) { + if (condition) { + element.classList.add(className); + } else { + element.classList.remove(className); + } +} + +/** + * Escapes HTML to prevent XSS + * @param {string} text + * @returns {string} + */ +export function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/events.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/events.js new file mode 100755 index 0000000..e82be3c --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/events.js @@ -0,0 +1,98 @@ +/** + * EventBus - Centralized event system + * Enables communication between modules without circular dependencies + */ +class EventBus { + constructor() { + this.listeners = new Map(); + } + + /** + * Subscribe to an event + * @param {string} event - Event name + * @param {Function} callback - Function to call + * @returns {Function} Unsubscribe function + */ + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event).add(callback); + + return () => this.off(event, callback); + } + + /** + * Subscribe to an event once + * @param {string} event - Event name + * @param {Function} callback - Function to call + */ + once(event, callback) { + const wrapper = (data) => { + this.off(event, wrapper); + callback(data); + }; + this.on(event, wrapper); + } + + /** + * Unsubscribe from an event + * @param {string} event - Event name + * @param {Function} callback - Function to remove + */ + off(event, callback) { + if (this.listeners.has(event)) { + this.listeners.get(event).delete(callback); + } + } + + /** + * Emit an event + * @param {string} event - Event name + * @param {*} data - Data to transmit + */ + emit(event, data) { + console.log(`EventBus: Emitting event "${event}"`, data); + if (this.listeners.has(event)) { + const listeners = this.listeners.get(event); + console.log(`EventBus: "${event}" has ${listeners.size} listener(s)`); + this.listeners.get(event).forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`Error in listener for "${event}":`, error); + } + }); + } else { + console.warn(`EventBus: No listeners for event "${event}"`); + } + } +} + +// Exported singleton instance +export const eventBus = new EventBus(); + +// Available events (for documentation and autocompletion) +export const Events = { + // Authentication + USER_LOGGED_IN: 'user:logged-in', + USER_LOGGED_OUT: 'user:logged-out', + USER_REGISTERED: 'user:registered', + + // Windows + WINDOW_OPENED: 'window:opened', + WINDOW_CLOSED: 'window:closed', + + // Avatar + AVATAR_UPDATED: 'avatar:updated', + + // Chat + CHAT_CONNECTED: 'chat:connected', + CHAT_DISCONNECTED: 'chat:disconnected', + CHAT_MESSAGE_RECEIVED: 'chat:message-received', + + // Game Rooms + ROOM_JOINED: 'room:joined', + ROOM_LEFT: 'room:left', + ROOM_CREATED: 'room:created' +}; diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/friends.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/friends.js new file mode 100755 index 0000000..19d042e --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/friends.js @@ -0,0 +1,453 @@ +import { Window } from './windows.js'; +import { API, STORAGE_KEYS, CSS } from './config.js'; +import { eventBus, Events } from './events.js'; + +/** + * Friends management window + * Allows viewing friends, requests, and searching users + */ +export class FriendsWindow extends Window { + constructor() { + super({ + name: 'friends', + title: 'Amis', + cssClasses: ['friends-window'] + }); + + this.currentTab = 'friends'; + this.buildUI(); + this.bindEvents(); + + eventBus.on(Events.USER_LOGGED_IN, () => this.loadCurrentTab()); + } + + /** + * Builds the user interface + */ + buildUI() { + // Tabs + this.tabs = this.createElement('div', CSS.FRIENDS_TABS); + + this.friendsTab = this.createElement('button', [CSS.FRIENDS_TAB, CSS.FRIENDS_TAB_ACTIVE], { + text: 'Amis' + }); + this.friendsTab.dataset.tab = 'friends'; + + this.requestsTab = this.createElement('button', CSS.FRIENDS_TAB, { + text: 'Demandes' + }); + this.requestsTab.dataset.tab = 'requests'; + + this.searchTab = this.createElement('button', CSS.FRIENDS_TAB, { + text: 'Rechercher' + }); + this.searchTab.dataset.tab = 'search'; + + this.tabs.append(this.friendsTab, this.requestsTab, this.searchTab); + + // Content area + this.content = this.createElement('div', CSS.FRIENDS_CONTENT); + + // Search input (hidden by default) + this.searchContainer = this.createElement('div', CSS.FRIENDS_SEARCH); + this.searchInput = this.createElement('input', CSS.INPUT, { + type: 'text', + placeholder: 'Rechercher un utilisateur...' + }); + this.searchBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Chercher' + }); + this.searchContainer.append(this.searchInput, this.searchBtn); + this.searchContainer.style.display = 'none'; + + // List container + this.list = this.createElement('div', CSS.FRIENDS_LIST); + + // Message + this.message = this.createElement('div', CSS.MESSAGE); + + this.content.append(this.searchContainer, this.list, this.message); + + // Assembly + this.body.append(this.tabs, this.content); + } + + /** + * Attaches event handlers + */ + bindEvents() { + this.tabs.addEventListener('click', (e) => { + const tab = e.target.closest(`.${CSS.FRIENDS_TAB}`); + if (tab) { + this.switchTab(tab.dataset.tab); + } + }); + + this.searchBtn.addEventListener('click', () => this.searchUsers()); + this.searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.searchUsers(); + }); + } + + /** + * Switches between tabs + */ + switchTab(tabName) { + this.currentTab = tabName; + + // Update tab styles + [this.friendsTab, this.requestsTab, this.searchTab].forEach(tab => { + tab.classList.toggle(CSS.FRIENDS_TAB_ACTIVE, tab.dataset.tab === tabName); + }); + + // Show/hide search + this.searchContainer.style.display = tabName === 'search' ? 'flex' : 'none'; + + this.loadCurrentTab(); + } + + /** + * Loads data for current tab + */ + loadCurrentTab() { + switch (this.currentTab) { + case 'friends': + this.loadFriends(); + break; + case 'requests': + this.loadRequests(); + break; + case 'search': + this.list.innerHTML = ''; + this.showMessage('Entrez un nom pour rechercher', 'info'); + break; + } + } + + /** + * Gets auth headers + */ + getHeaders() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; + } + + /** + * Loads friends list + */ + async loadFriends() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour voir vos amis', 'info'); + return; + } + + try { + const response = await fetch(API.FRIENDS.LIST, { + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.renderFriendsList(data.friends || []); + } catch (error) { + console.error('Load friends error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + /** + * Loads pending requests + */ + async loadRequests() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour voir les demandes', 'info'); + return; + } + + try { + const response = await fetch(API.FRIENDS.REQUESTS, { + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.renderRequestsList(data.requests || []); + } catch (error) { + console.error('Load requests error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + /** + * Searches users + */ + async searchUsers() { + const query = this.searchInput.value.trim(); + if (!query) { + this.showMessage('Entrez un nom pour rechercher', 'info'); + return; + } + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour rechercher', 'info'); + return; + } + + try { + const response = await fetch(`${API.FRIENDS.SEARCH}?q=${encodeURIComponent(query)}`, { + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.renderSearchResults(data.users || []); + } catch (error) { + console.error('Search error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + /** + * Renders friends list + */ + renderFriendsList(friends) { + this.list.innerHTML = ''; + this.message.textContent = ''; + + if (friends.length === 0) { + this.showMessage('Aucun ami pour le moment', 'info'); + return; + } + + friends.forEach(friend => { + const item = this.createFriendItem(friend, 'friend'); + this.list.appendChild(item); + }); + } + + /** + * Renders requests list + */ + renderRequestsList(requests) { + this.list.innerHTML = ''; + this.message.textContent = ''; + + if (requests.length === 0) { + this.showMessage('Aucune demande en attente', 'info'); + return; + } + + requests.forEach(request => { + const item = this.createFriendItem(request, 'request'); + this.list.appendChild(item); + }); + } + + /** + * Renders search results + */ + renderSearchResults(users) { + this.list.innerHTML = ''; + this.message.textContent = ''; + + if (users.length === 0) { + this.showMessage('Aucun utilisateur trouve', 'info'); + return; + } + + users.forEach(user => { + const item = this.createFriendItem(user, 'search'); + this.list.appendChild(item); + }); + } + + /** + * Creates a friend/user item + */ + createFriendItem(user, type) { + const item = this.createElement('div', CSS.FRIENDS_ITEM); + + const avatar = this.createElement('img', CSS.FRIENDS_AVATAR, { + alt: user.username + }); + 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 removeBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { + text: 'Retirer' + }); + removeBtn.addEventListener('click', () => this.removeFriend(user.id)); + actions.appendChild(removeBtn); + } else if (type === 'request') { + const acceptBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], { + text: 'Accepter' + }); + acceptBtn.addEventListener('click', () => this.acceptRequest(user.id)); + + const declineBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { + text: 'Refuser' + }); + declineBtn.addEventListener('click', () => this.declineRequest(user.id)); + + actions.append(acceptBtn, declineBtn); + } else if (type === 'search') { + const addBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Ajouter' + }); + addBtn.addEventListener('click', () => this.sendRequest(user.id, addBtn)); + actions.appendChild(addBtn); + } + + item.append(avatar, infoContainer, actions); + return item; + } + + /** + * Sends a friend request + */ + async sendRequest(userId, button) { + try { + const response = await fetch(`${API.FRIENDS.REQUEST}/${userId}`, { + method: 'POST', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + button.textContent = 'Envoye'; + button.disabled = true; + this.showMessage('Demande envoyee', 'success'); + } catch (error) { + console.error('Send request error:', error); + this.showMessage('Erreur', 'error'); + } + } + + /** + * Accepts a friend request + */ + async acceptRequest(userId) { + try { + const response = await fetch(`${API.FRIENDS.ACCEPT}/${userId}`, { + method: 'POST', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.showMessage('Ami ajoute', 'success'); + this.loadRequests(); + } catch (error) { + console.error('Accept request error:', error); + this.showMessage('Erreur', 'error'); + } + } + + /** + * Declines a friend request + */ + async declineRequest(userId) { + try { + const response = await fetch(`${API.FRIENDS.DECLINE}/${userId}`, { + method: 'POST', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.showMessage('Demande refusee', 'success'); + this.loadRequests(); + } catch (error) { + console.error('Decline request error:', error); + this.showMessage('Erreur', 'error'); + } + } + + /** + * Removes a friend + */ + async removeFriend(userId) { + try { + const response = await fetch(`${API.FRIENDS.LIST}/${userId}`, { + method: 'DELETE', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.showMessage('Ami retire', 'success'); + this.loadFriends(); + } catch (error) { + console.error('Remove friend error:', error); + this.showMessage('Erreur', 'error'); + } + } + + /** + * Shows a message + */ + showMessage(text, type = 'info') { + this.message.textContent = text; + this.message.className = CSS.MESSAGE; + + if (type === 'success') { + this.message.classList.add(CSS.MESSAGE_SUCCESS); + } else if (type === 'error') { + this.message.classList.add(CSS.MESSAGE_ERROR); + } else { + this.message.classList.add(CSS.MESSAGE_INFO); + } + } +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.css b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.css new file mode 100755 index 0000000..b40f7ff --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.css @@ -0,0 +1,1021 @@ +:root { + --color-primary: #0066cc; + --color-primary-hover: #0052a3; + --color-success: #3cff01; + --color-success-dark: #28a745; + --color-error: #ff4d4d; + --color-warning: #ffc107; + --color-github: #24292e; + + --color-bg: #000; + + --app-background-base: radial-gradient( + circle at top, + #1b2735, + #090a0f + ); + + /* --app-background-image: url("./assets/background.png"); */ + + --color-surface: #222; + --color-surface-light: #333; + --color-text: #fff; + --color-text-muted: #aaa; + + --font-size-base: 10px; + --font-size-sm: 1.2rem; + --font-size-md: 1.4rem; + --font-size-lg: 1.6rem; + --font-size-xl: 3rem; + + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 24px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 12px; + --radius-full: 50%; + + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5); + + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + + --z-menu: 2; + --z-window: 100; + --z-modal: 200; +} + +/* ============================================ + RESET & BASE + ============================================ */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + height: 100%; + background-image: + var(--app-background-base); + + background-size: + contain, + cover; + + background-position: + center, + center; + + background-repeat: + no-repeat, + no-repeat; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--color-text); + line-height: 1.5; +} + +.app { + position: relative; + width: 70%; + min-width: 800px; + margin: 0 auto; +} + + +/* ============================================ + TYPOGRAPHY + ============================================ */ +.title { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + font-size: var(--font-size-xl); + text-align: center; + text-shadow: 2px 2px 10px black; + z-index: 1; + font-family: "Cinzel Decorative", cursive; + color: var(--color-success); + margin: 0; + padding: var(--spacing-md); +} + +/* ============================================ + MENU + ============================================ */ + +.menu { + position: fixed; + top: 0; + left: 50px; + padding: 0; + margin: 0; + z-index: var(--z-menu); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.menu__item { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; +} + +.menu__item:hover { + background: var(--color-surface-light); + font-size: var(--font-size-lg); +} + +.menu__item--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* ============================================ + GAME + ============================================ */ + +.game { + position: fixed; + top: 0; + right: 50px; + padding: 0; + margin: 0; + z-index: var(--z-menu); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.game__item { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + cursor: pointer; + transition: all var(--transition-fast); + text-align: right; +} + +.game__item:hover { + background: var(--color-surface-light); + font-size: var(--font-size-lg); +} + +.game__item--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* ============================================ + PAGES + ============================================ */ + +.page { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + + z-index: var(--z-menu); +} + +.page__item { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + cursor: pointer; + transition: all var(--transition-fast); + text-align: right; +} + +.page__item:hover { + background: var(--color-surface-light); + font-size: var(--font-size-lg); +} + +.page__item--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* ============================================ + BUTTONS + ============================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + font-weight: 500; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; +} + +.btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn--primary { + background: var(--color-primary); + color: var(--color-text); +} + +.btn--primary:hover { + background: var(--color-primary-hover); +} + +.btn--secondary { + background: var(--color-surface-light); + color: var(--color-text); +} + +.btn--success { + background: var(--color-success-dark); + color: var(--color-text); +} + +.btn--danger { + background: var(--color-error); + color: var(--color-text); +} + +.btn--github { + background: var(--color-github); + color: var(--color-text); +} + +.btn--ghost { + background: transparent; + color: var(--color-text); + border: 1px solid var(--color-surface-light); +} + +/* ============================================ + INPUTS + ============================================ */ +.input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); +} + +.input::placeholder { + color: var(--color-text-muted); +} + +.input-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +/* ============================================ + WINDOWS + ============================================ */ +.window { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--color-bg); + border: 2px ridge var(--color-text); + color: var(--color-text); + z-index: var(--z-window); + display: none; + flex-direction: column; + min-width: 280px; + box-shadow: var(--shadow-lg); +} + +.window--visible { + display: flex; +} + +.window--left { + left: 25%; +} + +.window--right { + left: 75%; +} + +.window__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-surface); + cursor: move; + user-select: none; +} + +.window__title { + font-weight: 500; + font-size: var(--font-size-md); +} + +.window__close { + cursor: pointer; + font-size: var(--font-size-lg); + opacity: 0.8; + transition: opacity var(--transition-fast); + background: none; + border: none; + color: var(--color-text); + padding: 0; + line-height: 1; +} + +.window__close:hover { + opacity: 1; +} + +.window__body { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + flex: 1; + overflow: auto; +} + +/* ============================================ + MESSAGES + ============================================ */ +.message { + font-size: var(--font-size-sm); + padding: var(--spacing-xs); + border-radius: var(--radius-sm); +} + +.message--success { + color: var(--color-success); +} + +.message--error { + color: var(--color-error); +} + +.message--info { + color: var(--color-text-muted); +} + +/* ============================================ + LOGIN WINDOW + ============================================ */ +.login { + width: 320px; +} + +.login__form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.login__actions { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-xs); +} + +.login__divider { + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-text-muted); + font-size: var(--font-size-sm); + margin: var(--spacing-sm) 0; +} + +.login__divider::before, +.login__divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-surface-light); +} + +/* ============================================ + CHAT WINDOW + ============================================ */ +.chat { + width: 380px; + height: 400px; +} + +.chat__output { + flex: 1; + overflow-y: auto; + padding: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-height: 150px; +} + +.chat__message { + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-surface-light); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); +} + +.chat__message--own { + background: var(--color-primary); + align-self: flex-end; +} + +.chat__friend-indicator { + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--color-success); + border-radius: 50%; + margin-right: var(--spacing-xs); + vertical-align: middle; +} + +.chat__system { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-style: italic; + text-align: center; +} + +.chat__system--error { + color: var(--color-error); +} + +.chat__system--success { + color: var(--color-success); +} + +.chat__input-container { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +.chat__input { + flex: 1; +} + +.chat__controls { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +/* ============================================ + AVATAR WINDOW + ============================================ */ +.avatar-window { + width: 360px; +} + +.avatar__preview { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: var(--radius-full); + border: 3px solid var(--color-text); + box-shadow: var(--shadow-md); + background: var(--color-surface); + align-self: center; +} + +.avatar__username { + font-size: var(--font-size-lg); + font-weight: 600; + text-align: center; + color: var(--color-text); + margin-top: var(--spacing-sm); +} + +.avatar__controls { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + align-items: center; +} + +.avatar__file-input { + display: none; +} + +/* ============================================ + EASTER EGG BUTTON + ============================================ */ +/* .easter-egg { + position: absolute; + top: 20%; + left: 50%; + transform: translateX(-50%); + z-index: 1; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + font-size: var(--font-size-md); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.easter-egg:hover { + background: var(--color-error); + border-color: var(--color-error); +} */ + +/* ============================================ + UTILITIES + ============================================ */ +.hidden { + display: none !important; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.text-center { + text-align: center; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +/* ============================================ + FRIENDS WINDOW + ============================================ */ +.friends-window { + width: 400px; + height: 450px; +} + +.friends__tabs { + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + +.friends__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); +} + +.friends__tab:hover { + background: var(--color-surface-light); +} + +.friends__tab--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +.friends__content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.friends__search { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.friends__search .input { + flex: 1; +} + +.friends__list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.friends__item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); +} + +.friends__avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + object-fit: cover; + border: 2px solid var(--color-surface-light); +} + +.friends__name { + flex: 1; + font-size: var(--font-size-md); + font-weight: 500; +} + +.friends__actions { + display: flex; + gap: var(--spacing-xs); +} + +.friends__actions .btn { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-sm); +} + +.friends__empty { + text-align: center; + 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/Transcendance-Test/Transcendence/srcs/frontend/src/game.html b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.html new file mode 100755 index 0000000..51aef37 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game.html @@ -0,0 +1,34 @@ + + + + + + Lobby + + + + + + +

Lobby

+ + + + + +
+ +
+ + + + + \ No newline at end of file diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game_room.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game_room.js new file mode 100755 index 0000000..d5881e9 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/game_room.js @@ -0,0 +1,1580 @@ +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: {} + }; + + 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'); + console.log(`${data.username} joined the room`); + + 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); + } + + if (this.gameState.drawer === data.username) + { + this.showMessage('Le dessinateur a quitté la partie, fin du jeu', 'info'); + } + + 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`); + }); + } + + 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; + } + + 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 createRoom() { + const name = this.roomNameInput.value.trim(); + if (!name) { + this.showMessage('Entrez un nom pour le salon', 'error'); + return; + } + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour creer un salon', 'info'); + return; + } + + if (this.currentRoom) { + this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error'); + return; + } + + try { + const currentResponse = await fetch(API.ROOMS.CURRENT, { + headers: this.getHeaders() + }); + if (currentResponse.ok && currentResponse.status !== 204) { + const currentData = await currentResponse.json(); + if (currentData && currentData.id) { + this.currentRoom = currentData; + this.enterLobby(currentData); + this.showMessage('Vous etes deja dans un salon', 'error'); + return; + } + } + } catch (e) { + // Continue + } + + try { + const listResponse = await fetch(API.ROOMS.LIST, { + headers: this.getHeaders() + }); + if (listResponse.ok) { + this.roomsList = await listResponse.json() || []; + } + } catch (e) { + // Continue + } + + if (this.roomNameExists(name)) { + this.showMessage('Un salon avec ce nom existe deja', 'error'); + return; + } + + try { + const response = await fetch(API.ROOMS.CREATE, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ name }) + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + this.roomNameInput.value = ''; + this.currentRoom = data; + this.showMessage('Salon cree', 'success'); + eventBus.emit(Events.ROOM_CREATED, data); + this.enterLobby(data); + } catch (error) { + console.error('Create room error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + async joinRoom(roomId) { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) { + this.showMessage('Connectez-vous pour rejoindre', 'info'); + return; + } + + if (this.currentRoom) { + this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error'); + return; + } + + try { + const currentResponse = await fetch(API.ROOMS.CURRENT, { + headers: this.getHeaders() + }); + if (currentResponse.ok && currentResponse.status !== 204) { + const currentData = await currentResponse.json(); + if (currentData && currentData.id) { + this.currentRoom = currentData; + this.enterLobby(currentData); + this.showMessage('Vous etes deja dans un salon', 'error'); + return; + } + } + } catch (e) { + // Continue + } + + try { + const response = await fetch(API.ROOMS.JOIN(roomId), { + method: 'POST', + headers: this.getHeaders() + }); + const data = await response.json(); + + if (!response.ok) { + this.showMessage(data.error || 'Erreur', 'error'); + return; + } + + const roomResponse = await fetch(API.ROOMS.GET(roomId), { + headers: this.getHeaders() + }); + const roomData = await roomResponse.json(); + + this.currentRoom = roomData; + eventBus.emit(Events.ROOM_JOINED, roomData); + this.enterLobby(roomData); + } catch (error) { + console.error('Join room error:', error); + this.showMessage('Erreur de connexion', 'error'); + } + } + + enterLobby(room) { + this.currentRoom = room; + this.lobbyTab.style.display = 'block'; + this.lobbyTitle.textContent = room.name; + this.switchTab('lobby'); + + // Connect to WebSocket for real-time sync + this.connectToGameSocket(); + } + + async loadLobby() { + if (!this.currentRoom) return; + + 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}" - Bon ${typeText}!${pointsText}`; + } else { + item.textContent = `${username}: "${guess}" - Mauvais ${typeText}${pointsText}`; + } + + this.guessHistory.appendChild(item); + this.guessHistory.scrollTop = this.guessHistory.scrollHeight; + } + + updateScoresDisplay(scores) { + if (!scores) return; + this.gameState.scores = scores; + + // Update scores display in game UI + if (this.scoresDisplay) { + const sortedScores = Object.entries(scores) + .sort((a, b) => b[1] - a[1]) + .map(([name, score]) => `${name}: ${score}`) + .join(' | '); + this.scoresDisplay.textContent = sortedScores; + } + + // Update player list with scores if visible + const playerItems = this.playerList.querySelectorAll('.gameroom__player'); + playerItems.forEach(item => { + const nameEl = item.querySelector('.gameroom__player-name'); + const scoreEl = item.querySelector('.gameroom__player-score'); + if (nameEl && scoreEl) { + const playerName = nameEl.textContent; + const score = scores[playerName] || 0; + scoreEl.textContent = `${score} pts`; + } + }); + } + + wordFound(word, winner, drawerBonus = 0) { + let message = `${winner} a trouve le mot: ${word}!`; + if (drawerBonus > 0 && this.gameState.drawer) { + message += ` (${this.gameState.drawer} +${drawerBonus} pts)`; + } + this.currentDrawerInfo.textContent = message; + this.currentDrawerInfo.classList.add('gameroom__drawer-info--winner'); + + this.guessContainer.style.display = 'none'; + this.drawTools.style.display = 'none'; + + // Reveal full word + this.wordDisplay.textContent = word.split('').join(' '); + + // Auto next round after delay + setTimeout(() => { + if (this.gameState.isPlaying) { + this.nextRound(); + } + }, 3000); + } + + nextRound() { + // Move to next player + this.gameState.currentPlayerIndex = (this.gameState.currentPlayerIndex + 1) % this.gameState.players.length; + const nextDrawer = this.gameState.players[this.gameState.currentPlayerIndex]; + + if (this.socket?.connected) { + this.socket.emit('game-next-round', { drawer: nextDrawer }); + } else { + this.gameState.drawer = nextDrawer; + this.setupRound(); + } + } + + backToLobby() { + 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/Transcendance-Test/Transcendence/srcs/frontend/src/global_chat.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/global_chat.js new file mode 100755 index 0000000..2936321 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/global_chat.js @@ -0,0 +1,320 @@ +import { Window } from './windows.js'; +import { STORAGE_KEYS, CSS } from './config.js'; +import { eventBus, Events } from './events.js'; + +/** + * Global chat window + * Uses Socket.IO for real-time communication + */ +export class GlobalChat extends Window { + constructor() { + super({ + name: 'chat', + title: 'Global Chat', + cssClasses: ['chat'] + }); + + this.socket = null; + this.connected = false; + this.friendIds = new Set(); + this.currentUserId = null; + this.currentUsername = null; + + this.buildUI(); + this.bindEvents(); + } + + /** + * Builds the user interface + */ + buildUI() { + // Message display area + this.output = this.createElement('div', CSS.CHAT_OUTPUT); + + // Input container + this.inputContainer = this.createElement('div', 'chat__input-container'); + + this.input = this.createElement('input', [CSS.INPUT, CSS.CHAT_INPUT], { + type: 'text', + placeholder: 'Type your message...' + }); + + this.sendBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Send' + }); + + this.inputContainer.append(this.input, this.sendBtn); + + // Connection controls + this.controls = this.createElement('div', CSS.CHAT_CONTROLS); + + this.connectBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], { + text: 'Connect' + }); + + this.reconnectBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Reconnect' + }); + + this.controls.append(this.connectBtn, this.reconnectBtn); + + // Assembly + this.body.append(this.output, this.inputContainer, this.controls); + } + + /** + * Attaches event handlers + */ + bindEvents() { + this.sendBtn.addEventListener('click', () => this.sendMessage()); + this.connectBtn.addEventListener('click', () => this.connect()); + this.reconnectBtn.addEventListener('click', () => this.reconnect()); + + // Send with Enter + this.input.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + } + + /** + * Displays a system message + * @param {string} text - Message text + * @param {'info'|'error'|'success'} type - Message type + */ + addSystemMessage(text, type = 'info') { + const msg = this.createElement('div', CSS.CHAT_SYSTEM); + + if (type === 'error') { + msg.classList.add('chat__system--error'); + } else if (type === 'success') { + msg.classList.add('chat__system--success'); + } + + msg.textContent = text; + this.output.appendChild(msg); + this.scrollToBottom(); + } + + /** + * Displays a chat message + * @param {string} username - User name + * @param {string} content - Message content + * @param {boolean} isOwn - Is this the current user's message + * @param {boolean} isFriend - Is this user a friend + */ + addChatMessage(username, content, isOwn = false, isFriend = false) { + const msg = this.createElement('div', CSS.CHAT_MESSAGE); + + if (isOwn) { + msg.classList.add('chat__message--own'); + } + + const friendIndicator = isFriend ? '' : ''; + msg.innerHTML = `${friendIndicator}${this.escapeHtml(username)}: ${this.escapeHtml(content)}`; + this.output.appendChild(msg); + this.scrollToBottom(); + } + + /** + * Escapes HTML to prevent XSS + * @param {string} text + * @returns {string} + */ + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Scrolls the message area to the bottom + */ + scrollToBottom() { + this.output.scrollTop = this.output.scrollHeight; + } + + /** + * Sends a message + */ + sendMessage() { + const content = this.input.value.trim(); + if (!content) return; + + if (!this.socket?.connected) { + this.addSystemMessage('Error: you are not connected to the global chat', 'error'); + return; + } + + this.socket.emit('chat-message', { content }); + this.addChatMessage('Me', content, true); + this.input.value = ''; + } + + /** + * Reconnects to the server + */ + async reconnect() { + if (this.socket) { + try { + this.socket.close(); + } catch (e) { + // Ignore + } + this.socket = null; + } + + this.connected = false; + this.addSystemMessage('Reconnecting...'); + 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 + */ + async connect() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + + if (!token) { + this.addSystemMessage('Error: you must be logged in to use the global chat', 'error'); + 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; + } + + // Load Socket.IO if needed + await this.loadSocketIO(); + + const ioConfig = { + auth: { token }, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + transports: ['websocket', 'polling'] + }; + + // Optional alternative port + 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.setupSocketListeners(); + } + + /** + * Loads the Socket.IO script if needed + */ + 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'); + resolve(); + }; + + script.onerror = () => { + console.error('Failed to load Socket.IO'); + reject(new Error('Socket.IO load failed')); + }; + + document.head.appendChild(script); + }); + } + + /** + * Sets up Socket.IO listeners + */ + setupSocketListeners() { + 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 }); + }); + + this.socket.on('connect_error', (err) => { + console.error('Socket connection error:', err.message); + this.addSystemMessage(`Connection error: ${err.message}`, 'error'); + }); + + this.socket.on('disconnect', (reason) => { + console.log('Socket disconnected:', reason); + this.connected = false; + this.addSystemMessage(`Disconnected (${reason})`); + eventBus.emit(Events.CHAT_DISCONNECTED, { reason }); + }); + + this.socket.on('chat-init', (data) => { + this.friendIds = new Set(data.friendIds || []); + + // Display recent messages + data.messages.forEach(msg => { + 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/Transcendance-Test/Transcendence/srcs/frontend/src/index.css b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.css new file mode 100755 index 0000000..3e260a9 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.css @@ -0,0 +1,682 @@ +/* ============================================ + TRANSCENDENCE - Main Stylesheet + Convention: BEM (Block__Element--Modifier) + ============================================ */ + +/* ============================================ + CSS VARIABLES + ============================================ */ +:root { + --color-primary: #0066cc; + --color-primary-hover: #0052a3; + --color-success: #3cff01; + --color-success-dark: #28a745; + --color-error: #ff4d4d; + --color-warning: #ffc107; + --color-github: #24292e; + + --color-bg: #a3a3a3; + + --app-background-base: radial-gradient( + circle at top, + #000000, + #4d4d4d + ); + + --app-background-image: url("./assets/background.png"); + + --color-surface: #222; + --color-surface-light: #333; + --color-text: #fff; + --color-text-muted: #aaa; + + --font-size-base: 10px; + --font-size-sm: 1.2rem; + --font-size-md: 1.4rem; + --font-size-lg: 1.6rem; + --font-size-xl: 3rem; + + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 24px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 12px; + --radius-full: 50%; + + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5); + + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + + --z-menu: 2; + --z-window: 100; + --z-modal: 200; +} + +/* ============================================ + RESET & BASE + ============================================ */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + height: 100%; + background-image: + var(--app-background-image), + var(--app-background-base); + + background-size: + contain, + cover; + + background-position: + center, + center; + + background-repeat: + no-repeat, + no-repeat; +} + +body { + margin: 0; + width: 70%; + min-width: 800px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--color-text); + line-height: 1.5; +} + +/* ============================================ + TYPOGRAPHY + ============================================ */ + +.title { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + text-transform: uppercase; + + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + + font-size: var(--font-size-xl); + text-align: center; + text-shadow: 2px 2px 10px black; + z-index: 1; + font-family: "Cinzel Decorative", cursive; + color: rgba(248, 252, 2, 0.6); + + margin: 0; + padding: var(--spacing-md); + + /* Rectangle + rounded corners */ + background-color: rgba(247, 7, 67, 0.6); + border: 2px solid rgba(0, 0, 0, 0.6); + border-radius: 15px; +} + + +/* ============================================ + MENU + ============================================ */ + +.menu { + position: fixed; + top: 0; + left: 50px; + padding: 0; + margin: 0; + z-index: var(--z-menu); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.menu__item { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; +} + +.menu__item:hover { + background: var(--color-surface-light); + font-size: var(--font-size-lg); +} + +.menu__item--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* ============================================ + GAME + ============================================ */ + +.game { + position: fixed; + top: 0; + right: 50px; + padding: 0; + margin: 0; + z-index: var(--z-menu); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.game__item { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + cursor: pointer; + transition: all var(--transition-fast); + text-align: right; +} + +.game__item:hover { + background: var(--color-surface-light); + font-size: var(--font-size-lg); +} + +.game__item--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* ============================================ + BUTTONS + ============================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + font-weight: 500; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; +} + +.btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn--primary { + background: var(--color-primary); + color: var(--color-text); +} + +.btn--primary:hover { + background: var(--color-primary-hover); +} + +.btn--secondary { + background: var(--color-surface-light); + color: var(--color-text); +} + +.btn--success { + background: var(--color-success-dark); + color: var(--color-text); +} + +.btn--danger { + background: var(--color-error); + color: var(--color-text); +} + +.btn--github { + background: var(--color-github); + color: var(--color-text); +} + +.btn--ghost { + background: transparent; + color: var(--color-text); + border: 1px solid var(--color-surface-light); +} + +/* ============================================ + INPUTS + ============================================ */ +.input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-md); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); +} + +.input::placeholder { + color: var(--color-text-muted); +} + +.input-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +/* ============================================ + WINDOWS + ============================================ */ +.window { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--color-bg); + border: 2px ridge var(--color-text); + color: var(--color-text); + z-index: var(--z-window); + display: none; + flex-direction: column; + min-width: 280px; + box-shadow: var(--shadow-lg); +} + +.window--visible { + display: flex; +} + +.window--left { + left: 25%; +} + +.window--right { + left: 75%; +} + +.window__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-surface); + cursor: move; + user-select: none; +} + +.window__title { + font-weight: 500; + font-size: var(--font-size-md); +} + +.window__close { + cursor: pointer; + font-size: var(--font-size-lg); + opacity: 0.8; + transition: opacity var(--transition-fast); + background: none; + border: none; + color: var(--color-text); + padding: 0; + line-height: 1; +} + +.window__close:hover { + opacity: 1; +} + +.window__body { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + flex: 1; + overflow: auto; +} + +/* ============================================ + MESSAGES + ============================================ */ +.message { + font-size: var(--font-size-sm); + padding: var(--spacing-xs); + border-radius: var(--radius-sm); +} + +.message--success { + color: var(--color-success); +} + +.message--error { + color: var(--color-error); +} + +.message--info { + color: var(--color-text-muted); +} + +/* ============================================ + LOGIN WINDOW + ============================================ */ +.login { + width: 320px; +} + +.login__form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.login__actions { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-xs); +} + +.login__divider { + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-text-muted); + font-size: var(--font-size-sm); + margin: var(--spacing-sm) 0; +} + +.login__divider::before, +.login__divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-surface-light); +} + +/* ============================================ + CHAT WINDOW + ============================================ */ +.chat { + width: 380px; + height: 400px; +} + +.chat__output { + flex: 1; + overflow-y: auto; + padding: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-height: 150px; +} + +.chat__message { + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-surface-light); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); +} + +.chat__message--own { + background: var(--color-primary); + align-self: flex-end; +} + +.chat__friend-indicator { + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--color-success); + border-radius: 50%; + margin-right: var(--spacing-xs); + vertical-align: middle; +} + +.chat__system { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-style: italic; + text-align: center; +} + +.chat__system--error { + color: var(--color-error); +} + +.chat__system--success { + color: var(--color-success); +} + +.chat__input-container { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +.chat__input { + flex: 1; +} + +.chat__controls { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +/* ============================================ + AVATAR WINDOW + ============================================ */ +.avatar-window { + width: 360px; +} + +.avatar__preview { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: var(--radius-full); + border: 3px solid var(--color-text); + box-shadow: var(--shadow-md); + background: var(--color-surface); + align-self: center; +} + +.avatar__username { + font-size: var(--font-size-lg); + font-weight: 600; + text-align: center; + color: var(--color-text); + margin-top: var(--spacing-sm); +} + +.avatar__controls { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + align-items: center; +} + +.avatar__file-input { + display: none; +} + +/* ============================================ + EASTER EGG BUTTON + ============================================ */ +/* .easter-egg { + position: absolute; + top: 20%; + left: 50%; + transform: translateX(-50%); + z-index: 1; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-surface-light); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + font-size: var(--font-size-md); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.easter-egg:hover { + background: var(--color-error); + border-color: var(--color-error); +} */ + +/* ============================================ + UTILITIES + ============================================ */ +.hidden { + display: none !important; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.text-center { + text-align: center; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +/* ============================================ + FRIENDS WINDOW + ============================================ */ +.friends-window { + width: 400px; + height: 450px; +} + +.friends__tabs { + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + +.friends__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); +} + +.friends__tab:hover { + background: var(--color-surface-light); +} + +.friends__tab--active { + background: var(--color-primary); + border-color: var(--color-primary); +} + +.friends__content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.friends__search { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.friends__search .input { + flex: 1; +} + +.friends__list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.friends__item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--color-surface); + border-radius: var(--radius-md); +} + +.friends__avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + object-fit: cover; + border: 2px solid var(--color-surface-light); +} + +.friends__name { + flex: 1; + font-size: var(--font-size-md); + font-weight: 500; +} + +.friends__actions { + display: flex; + gap: var(--spacing-xs); +} + +.friends__actions .btn { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-sm); +} + +.friends__empty { + text-align: center; + color: var(--color-text-muted); + padding: var(--spacing-lg); +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.html b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.html new file mode 100755 index 0000000..ee45125 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/index.html @@ -0,0 +1,31 @@ + + + + + + Transcendence.io + + + + + + +

Transcendence.io

+ + + + + + + + diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/login.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/login.js new file mode 100755 index 0000000..f83dfec --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/login.js @@ -0,0 +1,239 @@ +import { Window } from './windows.js'; +import { API, STORAGE_KEYS, CSS } from './config.js'; +import { eventBus, Events } from './events.js'; + +/** + * Login and registration window + * Emits events instead of directly importing other windows + */ +export class LoginWindow extends Window { + constructor() { + super({ + name: 'login', + title: 'Login', + cssClasses: ['login'] + }); + + this.buildUI(); + this.bindEvents(); + this.checkIfAlreadyLoggedIn(); + } + + /** + * Builds the user interface + */ + buildUI() { + // Main form + this.form = this.createElement('div', 'login__form'); + + // Username field + this.usernameInput = this.createElement('input', CSS.INPUT, { + type: 'text', + placeholder: 'Username' + }); + + // Password field + this.passwordInput = this.createElement('input', CSS.INPUT, { + type: 'password', + placeholder: 'Password' + }); + + // Action buttons + this.actions = this.createElement('div', 'login__actions'); + + this.loginBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Sign in' + }); + + this.registerBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Register' + }); + + this.actions.append(this.loginBtn, this.registerBtn); + + // Feedback message + this.message = this.createElement('div', CSS.MESSAGE); + + // Divider + this.divider = this.createElement('div', 'login__divider', { + text: 'or' + }); + + // GitHub button + this.githubBtn = this.createElement('button', [CSS.BTN, CSS.BTN_GITHUB], { + text: 'Sign in with GitHub' + }); + + // Assembly + this.form.append( + this.usernameInput, + this.passwordInput, + this.actions, + this.message, + this.divider, + this.githubBtn + ); + + this.body.appendChild(this.form); + } + + /** + * Attaches event handlers + */ + bindEvents() { + this.loginBtn.addEventListener('click', () => this.handleLogin()); + this.registerBtn.addEventListener('click', () => this.handleRegister()); + this.githubBtn.addEventListener('click', () => this.handleGitHubLogin()); + + // Login with Enter + this.passwordInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.handleLogin(); + } + }); + } + + /** + * Checks if user is already logged in + */ + checkIfAlreadyLoggedIn() { + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (token) { + this.showMessage('You are already logged in!', 'success'); + } + } + + /** + * Handles login + */ + async handleLogin() { + const username = this.usernameInput.value.trim(); + const password = this.passwordInput.value; + + if (!username || !password) { + this.showMessage('Please fill in all fields', 'error'); + return; + } + + this.showMessage('Signing in...', 'info'); + + try { + const response = await fetch(API.AUTH.LOGIN, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (response.ok && data.token) { + console.log('Login successful, storing token'); + localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token); + this.showMessage('Login successful! Welcome.', 'success'); + + console.log('Emitting USER_LOGGED_IN event'); + // Emit login event + eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token }); + + console.log('Token stored:', !!localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN)); + + // Close window after delay + setTimeout(() => this.hide(), 1500); + } else { + const errorMsg = data?.message || 'Login failed'; + this.showMessage(errorMsg, 'error'); + } + } catch (error) { + console.error('Login error:', error); + this.showMessage('Server connection error', 'error'); + } + } + + /** + * Handles registration + */ + async handleRegister() { + const username = this.usernameInput.value.trim(); + const password = this.passwordInput.value; + + if (!username || !password) { + this.showMessage('Please fill in all fields', 'error'); + return; + } + + this.showMessage('Registering...', 'info'); + + try { + const response = await fetch(API.AUTH.REGISTER, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (response.ok) { + this.showMessage('Registration successful! You can now sign in.', 'success'); + eventBus.emit(Events.USER_REGISTERED, { username }); + } else { + const errorMsg = data?.message || 'Registration failed'; + this.showMessage(errorMsg, 'error'); + } + } catch (error) { + console.error('Registration error:', error); + this.showMessage('Server connection error', 'error'); + } + } + + /** + * Handles GitHub OAuth login + */ + handleGitHubLogin() { + const width = 600; + const height = 700; + const left = (screen.width - width) / 2; + const top = (screen.height - height) / 2; + + const popup = window.open( + API.AUTH.GITHUB, + 'githubOAuth', + `width=${width},height=${height},left=${left},top=${top}` + ); + + const handleMessage = (event) => { + if (event.data?.token) { + localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token); + this.showMessage('GitHub login successful! Welcome.', 'success'); + + // Emit login event + eventBus.emit(Events.USER_LOGGED_IN, { + provider: 'github', + token: event.data.token + }); + + window.removeEventListener('message', handleMessage); + if (popup) popup.close(); + } + }; + + window.addEventListener('message', handleMessage, { once: true }); + } + + /** + * Displays a feedback message + * @param {string} text - Message text + * @param {'success'|'error'|'info'} type - Message type + */ + showMessage(text, type = 'info') { + this.message.textContent = text; + this.message.className = CSS.MESSAGE; + + if (type === 'success') { + this.message.classList.add(CSS.MESSAGE_SUCCESS); + } else if (type === 'error') { + this.message.classList.add(CSS.MESSAGE_ERROR); + } else { + this.message.classList.add(CSS.MESSAGE_INFO); + } + } +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/pieces.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/pieces.js new file mode 100755 index 0000000..9af9cad --- /dev/null +++ b/Transcendence/Transcendance-Test/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/Transcendance-Test/Transcendence/srcs/frontend/src/renderer.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/renderer.js new file mode 100755 index 0000000..f024216 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/renderer.js @@ -0,0 +1,126 @@ +// ───────────────────────────────────────────── +// RENDU +// ───────────────────────────────────────────── + +const CELL = 30; +const COLORS = ['#070712','#a855f7','#f97316','#3b82f6','#06b6d4','#ef4444','#22c55e','#eab308','#555577']; + +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; + ctx.fillStyle = COLORS[colorIndex]; + ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2); + // Highlight + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(x * size + p, y * size + p, size - p * 2, 3); + ctx.fillRect(x * size + p, y * size + p, 3, size - p * 2); + // Ombre + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(x * size + p, (y + 1) * size - p - 3, size - p * 2, 3); + ctx.fillRect((x + 1) * size - p - 3, y * size + p, 3, size - p * 2); +} + +function clearCanvas(ctx, w, h) { + ctx.fillStyle = '#070712'; + ctx.fillRect(0, 0, w, h); +} + +function drawGridLines(ctx, cols, rows, size) { + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + 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 >= grid.length || grid[ny][nx] !== 0) valid = false; + } + if (!valid) { ghost.y--; break; } + } + + if (ghost.y === piece.getPosition().y) return; + + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + 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 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); + } + + // Panneaux miniatures + drawMiniPiece(ctxNext, game.nextPiece, 100, 80); + drawMiniPiece(ctxHold, game.storedPiece, 100, 80); + + // Score + document.getElementById('score-display').textContent = game.score; +} + +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); +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.css b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.css new file mode 100755 index 0000000..9d29537 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.css @@ -0,0 +1,355 @@ +:root { + --bg: #070712; + --panel: #0d0d1f; + --border: #1a1a3e; + --accent: #00ffe7; + --accent2:#ff00aa; + --dim: #3a3a6a; + --text: #c0c0e0; +} + +* { 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: center; + overflow: hidden; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(0,255,231,0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,255,231,0.03) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; +} + +h1 { + font-family: 'Orbitron', monospace; + font-weight: 900; + font-size: 2.2rem; + letter-spacing: 0.4em; + color: var(--accent); + text-shadow: 0 0 20px var(--accent), 0 0 40px var(--accent); + margin-bottom: 20px; + position: relative; + z-index: 1; +} + +/* ── Zone de jeu globale ── */ +#game-area { + display: flex; + gap: 32px; + align-items: flex-start; + position: relative; + z-index: 1; +} + +/* ── Section locale (légèrement décalée à gauche par le flex naturel) ── */ +#local-section { + display: flex; + flex-direction: column; + align-items: center; +} + +#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: 6px; + padding: 14px; + width: 130px; + box-shadow: 0 0 20px rgba(0,255,231,0.05); +} + +.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: 4px; } + +#canvas-main { + border: 1px solid var(--border); + box-shadow: 0 0 30px rgba(0,255,231,0.08), inset 0 0 30px rgba(0,0,0,0.5); +} + +#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 30px rgba(255,0,170,0.08), 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; +} + +button { + font-family: 'Orbitron', monospace; + font-size: 0.55rem; + letter-spacing: 0.15em; + font-weight: 700; + text-transform: uppercase; + padding: 10px 8px; + border: 1px solid; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + 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-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(7,7,18,0.88); + border-radius: 4px; + 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: 'Orbitron', monospace; + font-size: 1.4rem; + font-weight: 900; + letter-spacing: 0.2em; + color: var(--accent2); + text-shadow: 0 0 20px var(--accent2); +} + +#overlay-score { + font-family: 'Orbitron', monospace; + font-size: 0.9rem; + color: var(--accent); +} + +#overlay-opponent-title { + font-family: 'Orbitron', 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: 'Orbitron', monospace; + font-size: 0.9rem; + color: var(--accent2); +} + +/* ── Panneau duel ── */ +#duel-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 20px; + margin-bottom: 14px; + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 0 20px rgba(255,0,170,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); } + +/* ── Settings Panel ── */ +#settings-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 14px 20px; + margin-top: -250px; + margin-left: -600px; + box-shadow: 0 0 20px rgba(0,255,231,0.05); + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 10px; + width: fit-content; +} + +.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; + align-items: center; + justify-content: space-between; + gap: 16px; + font-size: 0.6rem; + color: var(--dim); + letter-spacing: 0.05em; +} + +#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: 80px; + 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; +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.html b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.html new file mode 100755 index 0000000..ea0a17c --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.html @@ -0,0 +1,121 @@ + + + + + + TETRIS + + + + + +

TETRIS

+ + +
+ Duel +
+ + + +
+
+
+ +
+ + +
+
+ + +
+
Hold
+ + +
+
Score
+
0
+
+ +
+ + + +
+
+ + +
+ +
+
GAME OVER
+
+
+
+ + +
+
Next
+ + +
+
← → Déplacer
+
Descendre
+
Q Rot. gauche
+
W Rot. droite
+
Espace Drop
+
C Hold
+
+
+ +
+
+ + +
+
+
Adversaire
+
+
Score
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+ + +
+
Paramètres
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.js new file mode 100755 index 0000000..6032065 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/tetris.js @@ -0,0 +1,366 @@ +// ───────────────────────────────────────────── +// LOGIQUE TETRIS +// ───────────────────────────────────────────── + +class Tetris { + constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) { + this.onRender = onRender; + this.onGameOver = onGameOver; + this.onBlockPlaced = onBlockPlaced; + this.onLinesCleared = onLinesCleared; + + 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; + + 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._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); + } + + 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; + + 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(); + } + } + + _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; + } + + this.onRender(); + } + + _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(); + } + + _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 (this.onLinesCleared && cleared > 0) + 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 >= 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) { + 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) { + 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) + 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.isRunning || !lines.length) return; + this.grid.splice(0, lines.length); + for (const line of lines) this.grid.push([...line]); + if (!this._isValidPosition()) this._gameOver(); + } + + _gameOver() { + this.stop(); + this.onGameOver(this.score); + } +} diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ui.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ui.js new file mode 100755 index 0000000..d738e54 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/ui.js @@ -0,0 +1,162 @@ +// ───────────────────────────────────────────── +// 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'); + +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(); +} + +btnJoinDuel.addEventListener('click', () => { + const code = inputRoomCode.value.trim().toUpperCase(); + if (!code) return; + if (duel) { duel.leave(); } + 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); +}); + +// ───────────────────────────────────────────── +// INIT +// ───────────────────────────────────────────── + +const game = new Tetris( + // onRender + () => { + if (duel) duel.synchronize_game(); + render(); + updateButtons(); + }, + // onGameOver + (score) => { + if (duel) duel.onLocalGameOver(score); + render(); + updateButtons(); + showOverlay('GAME OVER', score); + }, + // onBlockPlaced — relay duel + (grid) => { + if (duel) duel.onLocalBlockPlaced(grid, game.score); + }, + // onLinesCleared — relay duel + (count, holeCol) => { + if (duel) duel.onLocalLinesCleared(count, holeCol); + } +); + +btnStart.addEventListener('click', () => { + if (duel && duel.isReady) { + duel.startDuel(); // déclenche les deux parties via le serveur + } else { + startLocalGame(); // solo + } +}); + +btnPause.addEventListener('click', () => { + game.pause(); + updateButtons(); + if (game.isPaused) showOverlay('PAUSE'); + else hideOverlay(); +}); + +btnStop.addEventListener('click', () => { + game.stop(); + updateButtons(); + render(); + showOverlay('STOPPED'); +}); + +function applySettings() { + game.configure({ + timeToDown: parseInt(inputTTD.value, 10), + hardening: parseInt(inputHardening.value, 10), + decrementTTD: parseInt(inputDecrement.value, 10), + }); +} + +inputTTD.addEventListener('change', applySettings); +inputHardening.addEventListener('change', applySettings); +inputDecrement.addEventListener('change', applySettings); + +render(); +updateButtons(); diff --git a/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/windows.js b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/windows.js new file mode 100755 index 0000000..229a229 --- /dev/null +++ b/Transcendence/Transcendance-Test/Transcendence/srcs/frontend/src/windows.js @@ -0,0 +1,234 @@ +import { CSS } from './config.js'; +import { eventBus, Events } from './events.js'; + +/** + * Centralized window registry + * Manages window visibility and positioning + */ +class WindowRegistry { + constructor() { + this.windows = new Map(); + } + + /** + * Registers a window in the registry + * @param {string} name - Unique window name + * @param {Window} window - Window instance + */ + register(name, window) { + this.windows.set(name, window); + } + + /** + * Gets a window by its name + * @param {string} name - Window name + * @returns {Window|undefined} + */ + get(name) { + return this.windows.get(name); + } + + /** + * Returns all visible windows + * @returns {Window[]} + */ + getVisible() { + return Array.from(this.windows.values()).filter(w => w.isVisible()); + } + + /** + * Reorganizes visible windows + */ + reorganize() { + const visible = this.getVisible(); + + if (visible.length === 0) return; + + if (visible.length === 1) { + visible[0].setPosition('center'); + } else if (visible.length === 2) { + visible[0].setPosition('left'); + visible[1].setPosition('right'); + } else { + visible.forEach(w => w.setPosition('center')); + } + } + + /** + * Shows a window and reorganizes + * @param {string} name - Window name + */ + show(name) { + const window = this.get(name); + if (window) { + window.show(); + } + } + + /** + * Hides a window and reorganizes + * @param {string} name - Window name + */ + hide(name) { + const window = this.get(name); + if (window) { + window.hide(); + } + } + + /** + * Toggles window visibility + * @param {string} name - Window name + */ + toggle(name) { + const window = this.get(name); + if (window) { + window.toggle(); + } + } +} + +// Singleton registry instance +export const windowRegistry = new WindowRegistry(); + +/** + * Base class for all windows + * Uses CSS classes instead of inline styles + */ +export class Window { + /** + * @param {Object} options - Configuration options + * @param {string} options.name - Unique name for the registry + * @param {string} options.title - Title displayed in the header + * @param {string[]} [options.cssClasses] - Additional CSS classes + */ + constructor({ name, title, cssClasses = [] }) { + this.name = name; + + // Create main container + this.element = document.createElement('div'); + this.element.className = [CSS.WINDOW, ...cssClasses].join(' '); + + // Header + this.header = document.createElement('div'); + this.header.className = CSS.WINDOW_HEADER; + + this.titleElement = document.createElement('span'); + this.titleElement.className = CSS.WINDOW_TITLE; + this.titleElement.textContent = title; + + this.closeBtn = document.createElement('button'); + this.closeBtn.className = CSS.WINDOW_CLOSE; + this.closeBtn.textContent = '✖'; + this.closeBtn.setAttribute('aria-label', 'Close'); + this.closeBtn.addEventListener('click', () => this.hide()); + + this.header.append(this.titleElement, this.closeBtn); + + // Body + this.body = document.createElement('div'); + this.body.className = CSS.WINDOW_BODY; + + // Assembly + this.element.append(this.header, this.body); + document.body.appendChild(this.element); + + // Register in the registry + windowRegistry.register(name, this); + } + + /** + * Checks if the window is visible + * @returns {boolean} + */ + isVisible() { + return this.element.classList.contains(CSS.WINDOW_VISIBLE); + } + + /** + * Sets the window position + * @param {'center'|'left'|'right'} position + */ + setPosition(position) { + this.element.classList.remove('window--left', 'window--right'); + + if (position === 'left') { + this.element.classList.add('window--left'); + } else if (position === 'right') { + this.element.classList.add('window--right'); + } + } + + /** + * Shows the window + */ + show() { + const wasHidden = !this.isVisible(); + this.element.classList.add(CSS.WINDOW_VISIBLE); + + if (wasHidden) { + windowRegistry.reorganize(); + eventBus.emit(Events.WINDOW_OPENED, { name: this.name }); + } + } + + /** + * Hides the window + */ + hide() { + const wasVisible = this.isVisible(); + this.element.classList.remove(CSS.WINDOW_VISIBLE); + + if (wasVisible) { + windowRegistry.reorganize(); + eventBus.emit(Events.WINDOW_CLOSED, { name: this.name }); + } + } + + /** + * Toggles visibility + */ + toggle() { + if (this.isVisible()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Updates the window title + * @param {string} title + */ + setTitle(title) { + this.titleElement.textContent = title; + } + + /** + * Creates an element with CSS classes + * @param {string} tag - HTML tag + * @param {string|string[]} classes - CSS classes + * @param {Object} [attrs] - Additional attributes + * @returns {HTMLElement} + */ + createElement(tag, classes, attrs = {}) { + const element = document.createElement(tag); + const classList = Array.isArray(classes) ? classes : [classes]; + element.className = classList.filter(Boolean).join(' '); + + Object.entries(attrs).forEach(([key, value]) => { + if (key === 'text') { + element.textContent = value; + } else if (key === 'html') { + element.innerHTML = value; + } else { + element.setAttribute(key, value); + } + }); + + return element; + } +} + +// Export old class name for compatibility (alias) +export { Window as fenetre }; diff --git a/Transcendence/srcs/backend/routes/game_room.js b/Transcendence/srcs/backend/routes/game_room.js index 0d19d24..d3f722b 100644 --- a/Transcendence/srcs/backend/routes/game_room.js +++ b/Transcendence/srcs/backend/routes/game_room.js @@ -18,6 +18,22 @@ 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) => { @@ -134,4 +150,39 @@ router.post('/:roomId/leave', authenticateToken, async(req, res) => } }); -export default router; \ No newline at end of file + +// 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; diff --git a/Transcendence/srcs/backend/services/game_room.js b/Transcendence/srcs/backend/services/game_room.js index e2fd1f6..f59e7e8 100644 --- a/Transcendence/srcs/backend/services/game_room.js +++ b/Transcendence/srcs/backend/services/game_room.js @@ -44,6 +44,71 @@ 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); @@ -123,13 +188,68 @@ async function getCurrentRoom(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, - getCurrentRoom -}; \ No newline at end of file + getCurrentRoom, + updateRoomStatus, + resetRoomScores, + cleanupEndedRooms +}; diff --git a/Transcendence/srcs/backend/services/socket.js b/Transcendence/srcs/backend/services/socket.js index 019e424..aede582 100644 --- a/Transcendence/srcs/backend/services/socket.js +++ b/Transcendence/srcs/backend/services/socket.js @@ -27,6 +27,42 @@ async function broadcastRoomsList(io) { } } +// 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)) { @@ -185,16 +221,94 @@ function setupSocketIO(io) 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', (data) => { + 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 @@ -209,6 +323,33 @@ function setupSocketIO(io) 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); @@ -233,6 +374,9 @@ function setupSocketIO(io) 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 @@ -269,6 +413,12 @@ function setupSocketIO(io) 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, @@ -285,6 +435,9 @@ function setupSocketIO(io) const roomId = socket.gameRoomId; if (!roomId) return; + // Spectators cannot clear canvas + if (socket.isSpectator) return; + socket.to(roomId).emit('game-clear-canvas'); }); @@ -293,6 +446,13 @@ function setupSocketIO(io) 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; @@ -416,13 +576,71 @@ function setupSocketIO(io) }); }); + 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) + { + gameState.players = gameState.players.filter(p => p !== username); + delete gameState.scores[username]; + + io.to(roomId).emit('scores-updated', gameState.scores); + } + + 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', () => { + 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); }); // ============================================ @@ -540,11 +758,39 @@ function setupSocketIO(io) console.log(`User disconnected: ${socket.user.username}`); - // Notify game room if player was in one + // 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); + + + socket.to(roomId).emit('game-player-left', { username: socket.user.username, userId: socket.user.userId @@ -596,4 +842,4 @@ function _tetrisRelayToOpponent(socket, event, data) { } export { broadcastRoomsList }; -export default setupSocketIO; \ No newline at end of file +export default setupSocketIO; diff --git a/Transcendence/srcs/frontend/src/game_room.js b/Transcendence/srcs/frontend/src/game_room.js index 4ae0040..c19993d 100644 --- a/Transcendence/srcs/frontend/src/game_room.js +++ b/Transcendence/srcs/frontend/src/game_room.js @@ -14,9 +14,17 @@ export class GameRoomWindow extends Window { 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();