Le classement du jeu Memory était stocké en localStorage (visible d'un seul joueur, sur un seul poste). Il est désormais partagé en ligne via une API ASP.NET/SQL Server adaptée à l'hébergement bsite.net. - API scores.ashx (C#, compilée au vol par IIS) : lecture GET et enregistrement POST, création automatique de la table, ne conserve que le top 10 de chaque mode (facile / normal / difficile). - leaderboard.js : le classement passe par fetch vers l'API (états chargement/erreur/vide, surbrillance de la ligne du joueur). Les statistiques personnelles restent stockées localement. - memory-game.js : rafraîchit le classement après l'enregistrement (async). - Style .is-me pour la ligne du joueur courant. - web.config.example fourni ; le web.config réel (mot de passe) est ignoré.
283 lines
9.9 KiB
JavaScript
283 lines
9.9 KiB
JavaScript
/* ============================================
|
|
LEADERBOARD & PLAYER STATISTICS
|
|
============================================
|
|
Le CLASSEMENT est désormais partagé EN LIGNE : tous les joueurs
|
|
enregistrent leurs scores via l'API PHP (api/scores.php) et voient le
|
|
même top 10 par mode de jeu.
|
|
|
|
Les STATISTIQUES PERSONNELLES (« Vos statistiques ») restent stockées
|
|
localement dans le navigateur du joueur, car le serveur ne conserve que
|
|
le top 10 de chaque mode (et non l'historique complet de chacun).
|
|
============================================ */
|
|
|
|
/**
|
|
* Manager du classement
|
|
*/
|
|
class LeaderboardManager {
|
|
// Point d'entrée de l'API (relatif à memory.html) — ASP.NET + SQL Server
|
|
static API_URL = 'api/scores.ashx';
|
|
// Historique local des parties du joueur (pour ses stats perso)
|
|
static KEY_HISTORY = 'nextgn_player_history';
|
|
static MAX_HISTORY = 500;
|
|
|
|
// Cache mémoire du dernier classement récupéré (par difficulté)
|
|
static _cache = {};
|
|
|
|
/**
|
|
* Enregistrer une partie :
|
|
* - envoie le score au classement en ligne
|
|
* - conserve la partie dans l'historique local (stats perso)
|
|
* @returns {Promise<Object>} résultat de l'API ({ ok, isTop, rank } ou { ok:false })
|
|
*/
|
|
static async addScore(gameData) {
|
|
const record = {
|
|
playerName: getPlayerName(),
|
|
userId: getUserId(),
|
|
score: gameData.score,
|
|
moves: gameData.moves,
|
|
timeSeconds: gameData.duration,
|
|
difficulty: gameData.difficulty,
|
|
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
|
|
won: gameData.won !== false,
|
|
date: new Date().toISOString()
|
|
};
|
|
|
|
// 1) Historique local (statistiques personnelles)
|
|
this._recordLocalHistory(record);
|
|
|
|
// 2) Classement en ligne
|
|
try {
|
|
const res = await fetch(this.API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(record)
|
|
});
|
|
const data = await res.json();
|
|
DEBUG.log('Score submitted:', data);
|
|
return data;
|
|
} catch (e) {
|
|
console.error('Leaderboard submit error:', e);
|
|
return { ok: false, error: 'network' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ajouter la partie à l'historique local du joueur
|
|
*/
|
|
static _recordLocalHistory(record) {
|
|
const history = this._getLocalHistory();
|
|
history.push(record);
|
|
if (history.length > this.MAX_HISTORY) {
|
|
history.splice(0, history.length - this.MAX_HISTORY);
|
|
}
|
|
try {
|
|
localStorage.setItem(this.KEY_HISTORY, JSON.stringify(history));
|
|
} catch (e) {
|
|
console.error('History save error:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Historique local des parties du joueur
|
|
*/
|
|
static _getLocalHistory() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(this.KEY_HISTORY)) || [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupérer le classement en ligne (top X) pour un mode donné.
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
static async getLeaderboard(difficulty = 'all', limit = 10) {
|
|
const url = `${this.API_URL}?difficulty=${encodeURIComponent(difficulty)}&limit=${limit}`;
|
|
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
|
const data = await res.json();
|
|
if (!data || data.ok !== true || !Array.isArray(data.scores)) {
|
|
throw new Error('Réponse invalide du serveur');
|
|
}
|
|
this._cache[difficulty] = data.scores;
|
|
return data.scores;
|
|
}
|
|
|
|
/**
|
|
* Statistiques du joueur, calculées à partir de son historique LOCAL.
|
|
*/
|
|
static getPlayerStats() {
|
|
const scores = this._getLocalHistory();
|
|
|
|
if (scores.length === 0) {
|
|
return {
|
|
totalGames: 0,
|
|
bestScore: 0,
|
|
averageScore: 0,
|
|
averageTime: 0,
|
|
bestTime: Infinity,
|
|
winRate: 0,
|
|
perfectGames: 0
|
|
};
|
|
}
|
|
|
|
const bestScore = Math.max(...scores.map(s => s.score), 0);
|
|
const totalScore = scores.reduce((a, b) => a + b.score, 0);
|
|
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
|
|
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
|
|
const perfectGames = scores.filter(s => s.isPerfect).length;
|
|
const winRate = (perfectGames / scores.length) * 100;
|
|
|
|
return {
|
|
totalGames: scores.length,
|
|
bestScore: bestScore,
|
|
averageScore: Math.round(totalScore / scores.length),
|
|
averageTime: Math.round(totalTime / scores.length),
|
|
bestTime: bestTime === Infinity ? 0 : bestTime,
|
|
winRate: Math.round(winRate),
|
|
perfectGames: perfectGames
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour l'affichage du classement (asynchrone).
|
|
*/
|
|
static async updateDisplay(difficulty = 'all') {
|
|
const leaderboardList = document.getElementById('leaderboard-list');
|
|
if (!leaderboardList) return;
|
|
|
|
// État de chargement
|
|
leaderboardList.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⏳</div>
|
|
<div class="empty-state-text">Chargement du classement…</div>
|
|
</div>
|
|
`;
|
|
|
|
let leaderboard;
|
|
try {
|
|
leaderboard = await this.getLeaderboard(difficulty, 10);
|
|
} catch (e) {
|
|
console.error('Leaderboard load error:', e);
|
|
leaderboardList.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⚠️</div>
|
|
<div class="empty-state-text">Classement indisponible</div>
|
|
<div class="empty-state-hint">Vérifiez votre connexion et réessayez.</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
if (leaderboard.length === 0) {
|
|
leaderboardList.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🎮</div>
|
|
<div class="empty-state-text">Aucun score pour le moment</div>
|
|
<div class="empty-state-hint">Jouez une partie pour apparaître au classement!</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const myUserId = getUserId();
|
|
leaderboardList.innerHTML = '';
|
|
|
|
leaderboard.forEach((score, index) => {
|
|
const rankItem = document.createElement('div');
|
|
rankItem.className = 'rank-item';
|
|
if (score.userId === myUserId) {
|
|
rankItem.classList.add('is-me');
|
|
}
|
|
|
|
const rankNumber = this.getRankDisplay(index);
|
|
const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"';
|
|
|
|
rankItem.innerHTML = `
|
|
<div class="rank-top">
|
|
<div ${medal}>${rankNumber}</div>
|
|
<div class="rank-score">${formatNumber(score.score)}</div>
|
|
</div>
|
|
<div class="rank-info">
|
|
<div class="player-name">${this.escapeHTML(score.playerName)}</div>
|
|
<div class="rank-meta">
|
|
<span>⏱ ${formatTime(score.timeSeconds)}</span>
|
|
<span>🎯 ${score.moves}</span>
|
|
${score.isPerfect ? '<span>✨</span>' : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
leaderboardList.appendChild(rankItem);
|
|
});
|
|
|
|
if (typeof analyticsManager !== 'undefined') {
|
|
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour les statistiques du joueur (locales).
|
|
*/
|
|
static updatePlayerStats() {
|
|
const stats = this.getPlayerStats();
|
|
|
|
const totalGamesEl = document.getElementById('total-games');
|
|
const bestScoreEl = document.getElementById('best-score');
|
|
const bestTimeEl = document.getElementById('best-time');
|
|
const winRateEl = document.getElementById('win-rate');
|
|
|
|
if (totalGamesEl) totalGamesEl.textContent = stats.totalGames;
|
|
if (bestScoreEl) bestScoreEl.textContent = formatNumber(stats.bestScore);
|
|
if (bestTimeEl) bestTimeEl.textContent = stats.bestTime === Infinity || stats.bestTime === 0 ? '--' : formatTime(stats.bestTime);
|
|
if (winRateEl) winRateEl.textContent = `${stats.winRate}%`;
|
|
|
|
if (bestScoreEl && stats.bestScore > 5000) {
|
|
bestScoreEl.parentElement.classList.add('high-value');
|
|
}
|
|
if (winRateEl && stats.winRate >= 50) {
|
|
winRateEl.parentElement.classList.add('high-value');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Effacer l'historique local du joueur (DEBUG).
|
|
* N'affecte PAS le classement en ligne partagé.
|
|
*/
|
|
static clearLocalHistory() {
|
|
if (confirm('Effacer VOS statistiques locales ? (le classement en ligne n\'est pas touché)')) {
|
|
localStorage.removeItem(this.KEY_HISTORY);
|
|
this.updatePlayerStats();
|
|
DEBUG.log('✓ Historique local effacé');
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Obtenir l'affichage du rang (numéro ou médaille)
|
|
*/
|
|
static getRankDisplay(index) {
|
|
const medals = ['🥇', '🥈', '🥉'];
|
|
return medals[index] || `${index + 1}`;
|
|
}
|
|
|
|
/**
|
|
* Échapper le HTML (sécurité)
|
|
*/
|
|
static escapeHTML(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// INITIALISATION
|
|
// ============================================
|
|
|
|
// Exporter pour utilisation globale
|
|
window.LeaderboardManager = LeaderboardManager;
|
|
|
|
DEBUG.log('Leaderboard Manager initialized (online mode)');
|