diff --git a/Transcendence/srcs/backend/db.js b/Transcendence/srcs/backend/db.js index 35ec2a2..28a95d5 100644 --- a/Transcendence/srcs/backend/db.js +++ b/Transcendence/srcs/backend/db.js @@ -45,6 +45,15 @@ async function runMigrations() IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_won') THEN ALTER TABLE users ADD COLUMN games_won INT DEFAULT 0; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_best_score') THEN + ALTER TABLE users ADD COLUMN tetris_best_score INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_wins') THEN + ALTER TABLE users ADD COLUMN tetris_wins INT DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_games_played') THEN + ALTER TABLE users ADD COLUMN tetris_games_played INT DEFAULT 0; + END IF; END $$; `); console.log('Migrations completed!'); diff --git a/Transcendence/srcs/backend/routes/player_stats.js b/Transcendence/srcs/backend/routes/player_stats.js index db9a06e..9a7dfe6 100644 --- a/Transcendence/srcs/backend/routes/player_stats.js +++ b/Transcendence/srcs/backend/routes/player_stats.js @@ -31,7 +31,7 @@ router.get('/user/:username', authenticateToken, async (req, res) => { } }); -// Get leaderboard +// Get general leaderboard router.get('/leaderboard', authenticateToken, async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit) || 10, 50); @@ -43,4 +43,66 @@ router.get('/leaderboard', authenticateToken, async (req, res) => { } }); +// Save tetris score (solo or duel) — updates best score if higher +router.post('/tetris/score', authenticateToken, async (req, res) => { + try { + const { score } = req.body; + if (typeof score !== 'number' || score < 0) { + return res.status(400).json({ error: 'Invalid score' }); + } + const bestScore = await playerStatsService.updateTetrisBestScore(req.user.userId, score); + await playerStatsService.incrementTetrisGamesPlayed(req.user.userId); + res.json({ bestScore }); + } catch (err) { + console.error('Error saving tetris score:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Tetris best score leaderboard +router.get('/tetris/leaderboard/score', authenticateToken, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 10, 50); + const leaderboard = await playerStatsService.getTetrisBestScoreLeaderboard(limit); + res.json(leaderboard); + } catch (err) { + console.error('Error getting tetris score leaderboard:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Tetris duel wins leaderboard +router.get('/tetris/leaderboard/wins', authenticateToken, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 10, 50); + const leaderboard = await playerStatsService.getTetrisDuelWinsLeaderboard(limit); + res.json(leaderboard); + } catch (err) { + console.error('Error getting tetris wins leaderboard:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Current user's rank by tetris best score +router.get('/tetris/rank/score', authenticateToken, async (req, res) => { + try { + const rank = await playerStatsService.getTetrisScoreRank(req.user.userId); + res.json({ rank }); + } catch (err) { + console.error('Error getting tetris score rank:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Current user's rank by tetris duel wins +router.get('/tetris/rank/wins', authenticateToken, async (req, res) => { + try { + const rank = await playerStatsService.getTetrisDuelWinsRank(req.user.userId); + res.json({ rank }); + } catch (err) { + console.error('Error getting tetris wins rank:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + export default router; diff --git a/Transcendence/srcs/backend/services/player_stats.js b/Transcendence/srcs/backend/services/player_stats.js index c6c7e71..4e3ed59 100644 --- a/Transcendence/srcs/backend/services/player_stats.js +++ b/Transcendence/srcs/backend/services/player_stats.js @@ -3,7 +3,8 @@ 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 + `SELECT id, username, avatar_url, total_points, games_played, games_won, + tetris_best_score, tetris_wins, tetris_games_played, created_at FROM users WHERE id = $1`, [userId] ); @@ -13,7 +14,8 @@ async function getStatsByUserId(userId) { // 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 + `SELECT id, username, avatar_url, total_points, games_played, games_won, + tetris_best_score, tetris_wins, tetris_games_played, created_at FROM users WHERE username = $1`, [username] ); @@ -76,6 +78,79 @@ async function getUserIdByUsername(username) { return result.rows[0]?.id || null; } +// Update tetris best score (only if new score is higher) +async function updateTetrisBestScore(userId, score) { + const result = await query( + `UPDATE users SET tetris_best_score = GREATEST(COALESCE(tetris_best_score, 0), $1) WHERE id = $2 RETURNING tetris_best_score`, + [score, userId] + ); + return result.rows[0]?.tetris_best_score || 0; +} + +// Increment tetris duel wins +async function incrementTetrisWins(userId) { + await query( + `UPDATE users SET tetris_wins = COALESCE(tetris_wins, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Increment tetris games played +async function incrementTetrisGamesPlayed(userId) { + await query( + `UPDATE users SET tetris_games_played = COALESCE(tetris_games_played, 0) + 1 WHERE id = $1`, + [userId] + ); +} + +// Leaderboard: best tetris scores +async function getTetrisBestScoreLeaderboard(limit = 10) { + const result = await query( + `SELECT id, username, avatar_url, tetris_best_score, tetris_wins, tetris_games_played + FROM users + WHERE tetris_best_score > 0 + ORDER BY tetris_best_score DESC + LIMIT $1`, + [limit] + ); + return result.rows; +} + +// Leaderboard: most tetris duel wins +async function getTetrisDuelWinsLeaderboard(limit = 10) { + const result = await query( + `SELECT id, username, avatar_url, tetris_wins, tetris_games_played, tetris_best_score + FROM users + WHERE tetris_wins > 0 + ORDER BY tetris_wins DESC + LIMIT $1`, + [limit] + ); + return result.rows; +} + +// Rank of a user by tetris best score (1 = best) +async function getTetrisScoreRank(userId) { + const result = await query( + `SELECT COUNT(*) + 1 AS rank + FROM users + WHERE tetris_best_score > COALESCE((SELECT tetris_best_score FROM users WHERE id = $1), 0)`, + [userId] + ); + return parseInt(result.rows[0]?.rank || 1); +} + +// Rank of a user by tetris duel wins (1 = best) +async function getTetrisDuelWinsRank(userId) { + const result = await query( + `SELECT COUNT(*) + 1 AS rank + FROM users + WHERE tetris_wins > COALESCE((SELECT tetris_wins FROM users WHERE id = $1), 0)`, + [userId] + ); + return parseInt(result.rows[0]?.rank || 1); +} + export default { getStatsByUserId, getStatsByUsername, @@ -84,5 +159,12 @@ export default { incrementGamesPlayed, incrementGamesWon, getLeaderboard, - getUserIdByUsername + getUserIdByUsername, + updateTetrisBestScore, + incrementTetrisWins, + incrementTetrisGamesPlayed, + getTetrisBestScoreLeaderboard, + getTetrisDuelWinsLeaderboard, + getTetrisScoreRank, + getTetrisDuelWinsRank }; diff --git a/Transcendence/srcs/backend/services/socket.js b/Transcendence/srcs/backend/services/socket.js index 79479ff..0feaa3d 100644 --- a/Transcendence/srcs/backend/services/socket.js +++ b/Transcendence/srcs/backend/services/socket.js @@ -10,6 +10,9 @@ const gameRooms = new Map(); // Store tetris duel rooms { roomCode → Map } const tetrisRooms = new Map(); +// Matchmaking queue for tetris +const tetrisMatchmakingQueue = []; + // Store io instance globally for use in routes let ioInstance = null; @@ -770,13 +773,65 @@ function setupSocketIO(io) } }); - // game-over → relayé en opponent-game-over chez l'adversaire - socket.on('tetris:game-over', (data) => { - _tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data); + // game-over → save stats + relay opponent-game-over + socket.on('tetris:game-over', async (data) => { + const loserId = socket.user.userId; + try { + await playerStatsService.updateTetrisBestScore(loserId, data.score || 0); + await playerStatsService.incrementTetrisGamesPlayed(loserId); + } catch (err) { + console.error('Error saving tetris loser stats:', err); + } + + const code = socket.tetrisRoomCode; + if (code) { + const room = tetrisRooms.get(code); + if (room) { + for (const [id, s] of room) { + if (id !== socket.id) { + s.emit('tetris:opponent-game-over', data); + try { + await playerStatsService.incrementTetrisWins(s.user.userId); + await playerStatsService.incrementTetrisGamesPlayed(s.user.userId); + } catch (err) { + console.error('Error saving tetris winner stats:', err); + } + } + } + } + } + }); + + // Matchmaking + socket.on('tetris:matchmaking-join', () => { + // Remove from queue if already there + const idx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id); + if (idx !== -1) tetrisMatchmakingQueue.splice(idx, 1); + + tetrisMatchmakingQueue.push(socket); + socket.emit('tetris:matchmaking-status', { status: 'searching', position: tetrisMatchmakingQueue.length }); + + if (tetrisMatchmakingQueue.length >= 2) { + const player1 = tetrisMatchmakingQueue.shift(); + const player2 = tetrisMatchmakingQueue.shift(); + const roomCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + player1.emit('tetris:matched', { roomCode, opponent: player2.user.username }); + player2.emit('tetris:matched', { roomCode, opponent: player1.user.username }); + } + }); + + socket.on('tetris:matchmaking-leave', () => { + const idx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id); + if (idx !== -1) tetrisMatchmakingQueue.splice(idx, 1); + socket.emit('tetris:matchmaking-status', { status: 'idle' }); }); socket.on('disconnect', async () => { + // Nettoyage matchmaking tetris + const mqIdx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id); + if (mqIdx !== -1) tetrisMatchmakingQueue.splice(mqIdx, 1); + // Nettoyage room tetris if (socket.tetrisRoomCode) { _tetrisLeave(socket); diff --git a/Transcendence/srcs/frontend/src/app.js b/Transcendence/srcs/frontend/src/app.js index b1d528c..b260673 100644 --- a/Transcendence/srcs/frontend/src/app.js +++ b/Transcendence/srcs/frontend/src/app.js @@ -8,6 +8,7 @@ import { GlobalChat } from './global_chat.js'; import { AvatarWindow } from './avatar.js'; import { FriendsWindow } from './friends.js'; import { GameRoomWindow } from './game_room.js'; +import { StatsWindow } from './stats.js'; /** * Main application class @@ -30,6 +31,7 @@ class App { new AvatarWindow(); new FriendsWindow(); new GameRoomWindow(); + new StatsWindow(); } /** diff --git a/Transcendence/srcs/frontend/src/avatar.js b/Transcendence/srcs/frontend/src/avatar.js index bf164e1..45e3103 100644 --- a/Transcendence/srcs/frontend/src/avatar.js +++ b/Transcendence/srcs/frontend/src/avatar.js @@ -1,4 +1,4 @@ -import { Window } from './windows.js'; +import { Window, windowRegistry } from './windows.js'; import { API, STORAGE_KEYS, CSS } from './config.js'; import { eventBus, Events } from './events.js'; @@ -52,6 +52,10 @@ export class AvatarWindow extends Window { // Controls this.controls = this.createElement('div', CSS.AVATAR_CONTROLS); + this.statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { + text: 'Mes statistiques' + }); + this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Choose image' }); @@ -64,7 +68,7 @@ export class AvatarWindow extends Window { text: 'Refresh' }); - this.controls.append(this.chooseBtn, this.saveBtn, this.refreshBtn); + this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn); // Feedback message this.message = this.createElement('div', CSS.MESSAGE); @@ -85,6 +89,7 @@ export class AvatarWindow extends Window { */ bindEvents() { this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); + this.statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showMe()); this.chooseBtn.addEventListener('click', () => this.fileInput.click()); this.saveBtn.addEventListener('click', () => this.uploadAvatar()); this.refreshBtn.addEventListener('click', () => this.loadAvatar()); diff --git a/Transcendence/srcs/frontend/src/config.js b/Transcendence/srcs/frontend/src/config.js index 34bab36..22fff46 100644 --- a/Transcendence/srcs/frontend/src/config.js +++ b/Transcendence/srcs/frontend/src/config.js @@ -36,7 +36,10 @@ export const API = { STATS: { ME: '/api/stats/me', USER: (username) => `/api/stats/user/${username}`, - LEADERBOARD: '/api/stats/leaderboard' + LEADERBOARD: '/api/stats/leaderboard', + TETRIS_SCORE: '/api/stats/tetris/score', + TETRIS_LEADERBOARD_SCORE: '/api/stats/tetris/leaderboard/score', + TETRIS_LEADERBOARD_WINS: '/api/stats/tetris/leaderboard/wins' } }; diff --git a/Transcendence/srcs/frontend/src/friends.js b/Transcendence/srcs/frontend/src/friends.js index cedb40f..fc8ca24 100644 --- a/Transcendence/srcs/frontend/src/friends.js +++ b/Transcendence/srcs/frontend/src/friends.js @@ -1,4 +1,4 @@ -import { Window } from './windows.js'; +import { Window, windowRegistry } from './windows.js'; import { API, STORAGE_KEYS, CSS } from './config.js'; import { eventBus, Events } from './events.js'; @@ -309,11 +309,16 @@ export class FriendsWindow extends Window { const actions = this.createElement('div', CSS.FRIENDS_ACTIONS); if (type === 'friend') { + const statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Stats' + }); + statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showUser(user.username)); + const removeBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { text: 'Retirer' }); removeBtn.addEventListener('click', () => this.removeFriend(user.id)); - actions.appendChild(removeBtn); + actions.append(statsBtn, removeBtn); } else if (type === 'request') { const acceptBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], { text: 'Accepter' @@ -327,11 +332,16 @@ export class FriendsWindow extends Window { actions.append(acceptBtn, declineBtn); } else if (type === 'search') { + const statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { + text: 'Stats' + }); + statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showUser(user.username)); + const addBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], { text: 'Ajouter' }); addBtn.addEventListener('click', () => this.sendRequest(user.id, addBtn)); - actions.appendChild(addBtn); + actions.append(statsBtn, addBtn); } item.append(avatar, infoContainer, actions); diff --git a/Transcendence/srcs/frontend/src/index.css b/Transcendence/srcs/frontend/src/index.css index b8c8788..fdcce5c 100644 --- a/Transcendence/srcs/frontend/src/index.css +++ b/Transcendence/srcs/frontend/src/index.css @@ -531,6 +531,76 @@ body { display: none; } +/* ============================================ + STATS WINDOW + ============================================ */ +.stats-window { + width: 320px; +} + +.stats__avatar { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: var(--radius-full); + border: 2px solid var(--color-text); + align-self: center; + display: block; + margin: 0 auto var(--spacing-xs); +} + +.stats__username { + font-size: var(--font-size-lg); + font-weight: 600; + text-align: center; + color: #000; + margin-bottom: var(--spacing-md); +} + +.stats__section { + margin-bottom: var(--spacing-md); +} + +.stats__section-title { + font-size: var(--font-size-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-primary); + border-bottom: 1px solid var(--color-surface-light); + padding-bottom: var(--spacing-xs); + margin-bottom: var(--spacing-xs); +} + +.stats__section-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.stats__row { + display: flex; + justify-content: space-between; + font-size: var(--font-size-sm); + padding: 3px 0; +} + +.stats__label { + color: #333; +} + +.stats__value { + font-weight: 600; + color: #000; +} + +.stats__loading { + font-size: var(--font-size-sm); + color: #333; + text-align: center; + padding: var(--spacing-sm) 0; +} + /* ============================================ EASTER EGG BUTTON ============================================ */ diff --git a/Transcendence/srcs/frontend/src/stats.js b/Transcendence/srcs/frontend/src/stats.js new file mode 100644 index 0000000..0a55cbe --- /dev/null +++ b/Transcendence/srcs/frontend/src/stats.js @@ -0,0 +1,122 @@ +import { Window } from './windows.js'; +import { API, STORAGE_KEYS } from './config.js'; + +/** + * Stats window — displays Scribble + Tetris stats for any user + * Usage: windowRegistry.get('stats').showUser(username) + */ +export class StatsWindow extends Window { + constructor() { + super({ + name: 'stats', + title: 'Statistiques', + cssClasses: ['stats-window'] + }); + + this.buildUI(); + } + + buildUI() { + this.avatarEl = this.createElement('img', 'stats__avatar', { alt: 'Avatar' }); + this.avatarEl.src = '/avatar/default.png'; + + this.usernameEl = this.createElement('div', 'stats__username'); + + // Scribble section + const scribbleSection = this.createElement('div', 'stats__section'); + const scribbleTitle = this.createElement('div', 'stats__section-title', { text: 'Scribble' }); + this.scribbleBody = this.createElement('div', 'stats__section-body'); + scribbleSection.append(scribbleTitle, this.scribbleBody); + + // Tetris section + const tetrisSection = this.createElement('div', 'stats__section'); + const tetrisTitle = this.createElement('div', 'stats__section-title', { text: 'Tetris' }); + this.tetrisBody = this.createElement('div', 'stats__section-body'); + tetrisSection.append(tetrisTitle, this.tetrisBody); + + this.body.append(this.avatarEl, this.usernameEl, scribbleSection, tetrisSection); + } + + async showUser(username) { + this.show(); + this.setTitle('Statistiques'); + this.usernameEl.textContent = username; + this.avatarEl.src = '/avatar/default.png'; + this.scribbleBody.innerHTML = '
Chargement…
'; + this.tetrisBody.innerHTML = ''; + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return; + + try { + const res = await fetch(API.STATS.USER(username), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) { + this.scribbleBody.innerHTML = '
Erreur
'; + return; + } + const data = await res.json(); + this.renderStats(data); + } catch (err) { + console.error('Stats load error:', err); + } + } + + async showMe() { + this.show(); + this.setTitle('Mes statistiques'); + this.scribbleBody.innerHTML = '
Chargement…
'; + this.tetrisBody.innerHTML = ''; + + const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); + if (!token) return; + + try { + const res = await fetch(API.STATS.ME, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) return; + const data = await res.json(); + this.renderStats(data); + } catch (err) { + console.error('Stats load error:', err); + } + } + + renderStats(data) { + this.setTitle(`Stats — ${data.username}`); + this.usernameEl.textContent = data.username; + this.avatarEl.src = data.avatar_url || '/avatar/default.png'; + + this.scribbleBody.innerHTML = ` +
+ Points + ${data.total_points || 0} +
+
+ Parties + ${data.games_played || 0} +
+
+ Victoires + ${data.games_won || 0} +
+ `; + + this.tetrisBody.innerHTML = ` +
+ Meilleur score + ${data.tetris_best_score || 0} +
+
+ Duels gagnés + ${data.tetris_wins || 0} +
+
+ Parties + ${data.tetris_games_played || 0} +
+ `; + } +} diff --git a/Transcendence/srcs/frontend/src/tetris.css b/Transcendence/srcs/frontend/src/tetris.css index 71c5182..37ae90e 100644 --- a/Transcendence/srcs/frontend/src/tetris.css +++ b/Transcendence/srcs/frontend/src/tetris.css @@ -383,3 +383,145 @@ button:disabled { opacity: 0.3; cursor: not-allowed; } opacity: 0.3; cursor: not-allowed; } + +/* ── Matchmaking ── */ +#btn-matchmaking, #btn-matchmaking-cancel { + background: transparent; + border: 1px solid var(--accent2); + border-radius: 4px; + color: var(--accent2); + font-family: 'Share Tech Mono', monospace; + font-size: 0.65rem; + padding: 5px 10px; + cursor: pointer; + transition: background 0.2s, box-shadow 0.2s; + flex: 1; +} + +#btn-matchmaking:hover:not(:disabled) { + background: rgba(255,0,170,0.15); + box-shadow: 0 0 8px rgba(255,0,170,0.3); +} + +#btn-matchmaking-cancel { + border-color: var(--dim); + color: var(--dim); +} + +#btn-matchmaking-cancel:not(:disabled) { + border-color: var(--accent2); + color: var(--accent2); +} + +#btn-matchmaking:disabled, #btn-matchmaking-cancel:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +#matchmaking-status { + font-size: 0.6rem; + min-height: 1rem; + text-align: center; + letter-spacing: 0.05em; +} + +#matchmaking-status.waiting { color: #ffcc00; } +#matchmaking-status.ready { color: var(--accent); } + +/* ── Leaderboards ── */ +#leaderboard-section { + position: relative; + z-index: 1; + width: 100%; + max-width: 620px; + margin: 20px auto 30px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.leaderboard-tabs { + display: flex; + border-bottom: 1px solid var(--border); +} + +.lb-tab { + flex: 1; + background: transparent; + border: none; + color: var(--dim); + font-family: 'Orbitron', monospace; + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 10px; + cursor: pointer; + transition: color 0.2s, background 0.2s; +} + +.lb-tab:hover { color: var(--text); } + +.lb-tab--active { + color: var(--accent); + background: rgba(0,255,231,0.05); + border-bottom: 2px solid var(--accent); +} + +.lb-content { display: none; } +.lb-content--active { display: block; } + +.lb-table { + width: 100%; + border-collapse: collapse; + font-size: 0.7rem; +} + +.lb-table th { + text-align: left; + padding: 8px 12px; + color: var(--accent); + font-family: 'Orbitron', monospace; + font-size: 0.55rem; + letter-spacing: 0.1em; + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} + +.lb-table td { + padding: 7px 12px; + border-bottom: 1px solid rgba(26,26,62,0.5); + color: var(--text); +} + +.lb-table tr:last-child td { border-bottom: none; } + +.lb-table tr:hover td { + background: rgba(0,255,231,0.03); +} + +.lb-table tr.lb-me td { + background: rgba(0,255,231,0.07); + color: var(--accent); +} + +.lb-you { + color: var(--dim); + font-size: 0.6rem; +} + +.lb-table tr.lb-separator td { + text-align: center; + color: var(--dim); + padding: 4px; + font-size: 0.6rem; + border-bottom: none; +} + +.lb-table td:first-child { + color: var(--dim); + font-size: 0.6rem; + width: 30px; +} + +body { overflow-y: auto; } diff --git a/Transcendence/srcs/frontend/src/tetris.html b/Transcendence/srcs/frontend/src/tetris.html index 0772ce5..9c4799c 100644 --- a/Transcendence/srcs/frontend/src/tetris.html +++ b/Transcendence/srcs/frontend/src/tetris.html @@ -24,6 +24,11 @@ +
+ + +
+
@@ -117,6 +122,36 @@ + +
+
+ + +
+ +
+ + + + + + + +
#JoueurMeilleur scoreParties
Chargement…
+
+ +
+ + + + + + + +
#JoueurVictoiresParties
Chargement…
+
+
+ diff --git a/Transcendence/srcs/frontend/src/tetris.js.backup b/Transcendence/srcs/frontend/src/tetris.js.backup deleted file mode 100644 index e3f6467..0000000 --- a/Transcendence/srcs/frontend/src/tetris.js.backup +++ /dev/null @@ -1,401 +0,0 @@ -// ───────────────────────────────────────────── -// 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(true); - } - } - - _handleKey(e) { - if (!this.isRunning || !this.currentPiece) return; - - switch (e.key) { - case 'ArrowLeft': - e.preventDefault(); - if (!this.isPaused && this._canMoveLeft()) this.currentPiece.moveLeft(); - break; - case 'ArrowRight': - e.preventDefault(); - if (!this.isPaused && this._canMoveRight()) this.currentPiece.moveRight(); - break; - case 'ArrowDown': - e.preventDefault(); - if (!this.isPaused && this._canMoveDown()) { - this.currentPiece.moveDown(); - this.score += 1; - this.accumulator = 0; - } - break; - case ' ': - e.preventDefault(); - if (!this.isPaused) this._hardDrop(); - break; - case 'q': case 'Q': - e.preventDefault(); - if (!this.isPaused) this._rotatePiece(-1); - break; - case 'w': case 'W': - e.preventDefault(); - if (!this.isPaused) this._rotatePiece(1); - break; - case 'c': case 'C': - e.preventDefault(); - if (!this.isPaused) this._storePiece(); - break; - } - - 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(true); - } - - _rotatePiece(direction) { - if (!this.currentPiece) return; - const originalPos = { ...this.currentPiece.getPosition() }; - - if (direction === -1) this.currentPiece.rotateLeft(); - else this.currentPiece.rotateRight(); - - if (!this._isValidPosition()) { - this.currentPiece.moveRight(); - if (this._isValidPosition()) return; - - this.currentPiece.moveLeft(); - this.currentPiece.moveLeft(); - if (this._isValidPosition()) return; - - this.currentPiece.moveLeft(); - if (this._isValidPosition()) return; - - this.currentPiece.moveRight(); - this.currentPiece.moveRight(); - this.currentPiece.position.y--; - if (this._isValidPosition()) return; - - this.currentPiece.position.y = originalPos.y; - this.currentPiece.position.x = originalPos.x; - if (direction === -1) this.currentPiece.rotateRight(); - else this.currentPiece.rotateLeft(); - } - } - - _storePiece() { - if (!this.canStore || !this.currentPiece) return; - - if (this.storedPiece === null) { - this.storedPiece = this.currentPiece; - this._spawnNewPiece(); - } else { - const temp = this.storedPiece; - this.storedPiece = this.currentPiece; - this.currentPiece = temp; - this.currentPiece.position.x = 3; - this.currentPiece.position.y = 0; - } - this.canStore = false; - this.accumulator = 0; - } - - _spawnNewPiece() { - this.currentPiece = this.nextPiece || this._createRandomPiece(); - this.nextPiece = this._createRandomPiece(); - this._updateBufferGrid(); - } - - _createRandomPiece() { - const types = [PieceT, PieceL, PieceReverseL, PieceI, PieceZ, PieceReverseZ, PieceO]; - return new types[Math.floor(Math.random() * types.length)](3, 0); - } - - _updateBufferGrid() { - this.bufferGrid = this._createGrid(10, 5); - if (!this.nextPiece) return; - const shape = this.nextPiece.getShape(); - const offsetX = Math.floor((10 - shape[0].length) / 2); - for (let y = 0; y < shape.length; y++) - for (let x = 0; x < shape[y].length; x++) - if (shape[y][x] !== 0) - this.bufferGrid[y + 1][x + offsetX] = this.nextPiece.getColor(); - } - - verifierLignes() { - let cleared = 0; - for (let y = this.grid.length - 1; y >= 0; y--) { - if (this.grid[y].every(c => c !== 0)) { - this.grid.splice(y, 1); - this.grid.unshift(Array(10).fill(0)); - cleared++; - y++; - } - } - const points = [0, 100, 300, 500, 800]; - this.score += points[cleared]; - this.count += points[cleared]; - if (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 < 0) continue; // encore au-dessus de la grille - if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false; - } - return true; - } - - _canMoveLeft() { - if (!this.currentPiece) return false; - const { x, y } = this.currentPiece.getPosition(); - const shape = this.currentPiece.getShape(); - for (let row = 0; row < shape.length; row++) - for (let col = 0; col < shape[row].length; col++) - if (shape[row][col] !== 0) { - if (y + row < 0) continue; // au-dessus de la grille - const nx = x + col - 1; - if (nx < 0 || this.grid[y + row][nx] !== 0) return false; - } - return true; - } - - _canMoveRight() { - if (!this.currentPiece) return false; - const { x, y } = this.currentPiece.getPosition(); - const shape = this.currentPiece.getShape(); - for (let row = 0; row < shape.length; row++) - for (let col = 0; col < shape[row].length; col++) - if (shape[row][col] !== 0) { - if (y + row < 0) continue; // au-dessus de la grille - const nx = x + col + 1; - if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false; - } - return true; - } - - _isValidPosition() { - if (!this.currentPiece) return false; - const { x, y } = this.currentPiece.getPosition(); - const shape = this.currentPiece.getShape(); - for (let row = 0; row < shape.length; row++) - for (let col = 0; col < shape[row].length; col++) - if (shape[row][col] !== 0) { - const gx = x + col; - const gy = y + row; - if (gx < 0 || gx >= this.grid[0].length || - gy < 0 || gy >= this.grid.length || - this.grid[gy][gx] !== 0) return false; - } - return true; - } - - _canSpawn() { return this._isValidPosition(); } - - _lockPiece() { - if (!this.currentPiece) return; - const { x, y } = this.currentPiece.getPosition(); - const shape = this.currentPiece.getShape(); - const color = this.currentPiece.getColor(); - for (let row = 0; row < shape.length; row++) - for (let col = 0; col < shape[row].length; col++) - if (shape[row][col] !== 0 && y + row >= 0) - this.grid[y + row][x + col] = color; - this.lastLandingCol = x + Math.floor(shape[0].length / 2); - if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r])); - } - - addGarbageLines(lines) { - if (!this.isRunning || !lines.length) return; - this.grid.splice(0, lines.length); - for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante - // La grille a remonté de lines.length lignes — on remonte la pièce du même décalage - // pour qu'elle reste dans la même position relative aux blocs verrouillés. - if (this.currentPiece) { - this.currentPiece.position.y -= lines.length; - } - if (this.grid[0].some(c => c !== 0)) { this._gameOver(false); return; } - if (!this._isValidPositionAllowTop()) this._gameOver(false); - } - - // Comme _isValidPosition mais tolère gy < 0 (zone tampon au-dessus de la grille après garbage) - _isValidPositionAllowTop() { - if (!this.currentPiece) return true; - const { x, y } = this.currentPiece.getPosition(); - const shape = this.currentPiece.getShape(); - for (let row = 0; row < shape.length; row++) - for (let col = 0; col < shape[row].length; col++) - if (shape[row][col] !== 0) { - const gy = y + row; - const gx = x + col; - if (gy < 0) continue; // au-dessus de la grille : OK - if (gx < 0 || gx >= this.grid[0].length || - gy >= this.grid.length || - this.grid[gy][gx] !== 0) return false; - } - return true; - } - - _gameOver(validBlock = false) { - } -} - _gameOver(validBlock = false) { - this.stop(); - this.onGameOver(this.score, validBlock); - } - - restart() { - this.stop(); - this.start(); - } -} diff --git a/Transcendence/srcs/frontend/src/ui.js b/Transcendence/srcs/frontend/src/ui.js index 47f9f4a..e284bc9 100644 --- a/Transcendence/srcs/frontend/src/ui.js +++ b/Transcendence/srcs/frontend/src/ui.js @@ -11,11 +11,16 @@ 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'); +const btnJoinDuel = document.getElementById('btn-join-duel'); +const btnLeaveDuel = document.getElementById('btn-leave-duel'); +const inputRoomCode = document.getElementById('input-room-code'); +const duelStatusEl = document.getElementById('duel-status'); +const opponentSection = document.getElementById('opponent-section'); + +// Matchmaking UI +const btnMatchmaking = document.getElementById('btn-matchmaking'); +const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel'); +const matchmakingStatusEl = document.getElementById('matchmaking-status'); function updateButtons() { btnStart.disabled = game.isRunning; @@ -76,6 +81,34 @@ function startLocalGame() { render(); } +// ───────────────────────────────────────────── +// SCORE SAVE (solo) +// ───────────────────────────────────────────── + +function saveTetrisScore(score) { + const token = localStorage.getItem('auth_token'); + if (!token) return; + fetch('/api/stats/tetris/score', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ score }) + }) + .then(r => r.json()) + .then(data => { + if (data.bestScore !== undefined) { + console.log('Meilleur score tetris:', data.bestScore); + } + }) + .catch(err => console.error('Erreur sauvegarde score tetris:', err)); +} + +// ───────────────────────────────────────────── +// DUEL BUTTONS +// ───────────────────────────────────────────── + btnJoinDuel.addEventListener('click', () => { const code = inputRoomCode.value.trim().toUpperCase(); if (!code) return; @@ -96,6 +129,56 @@ btnLeaveDuel.addEventListener('click', () => { updateDuelStatus(null, null); }); +// ───────────────────────────────────────────── +// MATCHMAKING +// ───────────────────────────────────────────── + +btnMatchmaking.addEventListener('click', () => { + socket.emit('tetris:matchmaking-join'); + btnMatchmaking.disabled = true; + btnMatchmakingCancel.disabled = false; + btnJoinDuel.disabled = true; + matchmakingStatusEl.textContent = 'Recherche en cours…'; + matchmakingStatusEl.className = 'waiting'; +}); + +btnMatchmakingCancel.addEventListener('click', () => { + socket.emit('tetris:matchmaking-leave'); + btnMatchmaking.disabled = false; + btnMatchmakingCancel.disabled = true; + btnJoinDuel.disabled = false; + matchmakingStatusEl.textContent = ''; +}); + +socket.on('tetris:matchmaking-status', (data) => { + if (data.status === 'searching') { + matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`; + } else if (data.status === 'idle') { + matchmakingStatusEl.textContent = ''; + btnMatchmaking.disabled = false; + btnMatchmakingCancel.disabled = true; + btnJoinDuel.disabled = false; + } +}); + +socket.on('tetris:matched', (data) => { + matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`; + matchmakingStatusEl.className = 'ready'; + btnMatchmaking.disabled = false; + btnMatchmakingCancel.disabled = true; + btnJoinDuel.disabled = false; + + // Auto-rejoindre la salle générée + if (duel) { duel.leave(); } + duel = new Duel(socket, game, updateDuelStatus, startLocalGame); + duel.join(data.roomCode); + inputRoomCode.value = data.roomCode; + btnJoinDuel.disabled = true; + btnLeaveDuel.disabled = false; + inputRoomCode.disabled = true; + updateDuelStatus('waiting', null); +}); + // ───────────────────────────────────────────── // INIT // ───────────────────────────────────────────── @@ -109,10 +192,16 @@ const game = new Tetris( }, // onGameOver (score, validBlock) => { - if (duel) duel.onLocalGameOver(score, validBlock); + const isDuel = duel && duel.isReady; + if (isDuel) { + duel.onLocalGameOver(score, validBlock); + } else { + saveTetrisScore(score); + } render(); updateButtons(); showOverlay('GAME OVER', score); + loadLeaderboards(); }, // onBlockPlaced — relay duel (grid) => { @@ -168,13 +257,100 @@ inputTTD.addEventListener('change', applySettings); inputHardening.addEventListener('change', applySettings); inputDecrement.addEventListener('change', applySettings); -btnRestart.addEventListener('click', () => { - if (duel && duel.isReady) { - // In duel mode, we don't restart from client side - let server handle it - return; - } else { +const btnRestart = document.getElementById('btn-restart'); +if (btnRestart) { + btnRestart.addEventListener('click', () => { + if (duel && duel.isReady) return; game.restart(); updateButtons(); render(); + }); +} + +// ───────────────────────────────────────────── +// LEADERBOARDS +// ───────────────────────────────────────────── + +async function loadLeaderboards() { + const token = localStorage.getItem('auth_token'); + if (!token) return; + + const headers = { 'Authorization': `Bearer ${token}` }; + + try { + const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([ + fetch('/api/stats/tetris/leaderboard/score', { headers }), + fetch('/api/stats/tetris/leaderboard/wins', { headers }), + fetch('/api/stats/me', { headers }), + fetch('/api/stats/tetris/rank/score', { headers }), + fetch('/api/stats/tetris/rank/wins', { headers }) + ]); + + const me = meRes.ok ? await meRes.json() : null; + const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null; + const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null; + + if (scoresRes.ok) { + const scores = await scoresRes.json(); + renderLeaderboard('lb-scores-body', scores, ['tetris_best_score', 'tetris_games_played'], me, rankScore); + } + + if (winsRes.ok) { + const wins = await winsRes.json(); + renderLeaderboard('lb-wins-body', wins, ['tetris_wins', 'tetris_games_played'], me, rankWins); + } + } catch (err) { + console.error('Erreur chargement leaderboards:', err); } +} + +function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) { + const tbody = document.getElementById(tbodyId); + if (!tbody) return; + if (!rows.length && !me) { + tbody.innerHTML = 'Aucun résultat'; + return; + } + + const myUsername = me?.username; + const inTop = rows.some(r => r.username === myUsername); + + let html = rows.map((r, i) => { + const isMe = r.username === myUsername; + return ` + ${i + 1} + ${escapeHtml(r.username)}${isMe ? ' (vous)' : ''} + ${r[col1] ?? 0} + ${r[col2] ?? 0} + `; + }).join(''); + + if (!inTop && me && myRank !== null) { + html += `· · ·`; + html += ` + ${myRank} + ${escapeHtml(myUsername)} (vous) + ${me[col1] ?? 0} + ${me[col2] ?? 0} + `; + } + + tbody.innerHTML = html || 'Aucun résultat'; +} + +function escapeHtml(str) { + return String(str).replace(/&/g,'&').replace(//g,'>'); +} + +// Tabs leaderboard +document.querySelectorAll('.lb-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active')); + document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active')); + tab.classList.add('lb-tab--active'); + document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active'); + }); }); + +// Chargement initial des leaderboards +loadLeaderboards();