Passe le classement Memory en ligne (ASP.NET + SQL Server)

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é.
This commit is contained in:
2026-07-15 22:35:24 +02:00
parent 44be41b2b2
commit 2a85ced1e6
8 changed files with 580 additions and 305 deletions

View File

@@ -93,6 +93,13 @@
overflow: hidden;
}
/* Ligne du joueur courant dans le classement */
.rank-item.is-me {
border-left-color: var(--amber-dark);
background: linear-gradient(135deg, var(--amber-light), var(--white));
box-shadow: 0 2px 8px rgba(186, 117, 23, 0.15);
}
.rank-top {
display: flex;
align-items: center;

View File

@@ -1,90 +1,114 @@
/* ============================================
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 {
static KEY_LEADERBOARD = 'nextgn_leaderboard';
static MAX_SCORES = 100;
// 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 = {};
/**
* Ajouter un score au classement
* 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 addScore(gameData) {
const scores = this.getAllScores();
const newScore = {
sessionId: generateUUID(),
static async addScore(gameData) {
const record = {
playerName: getPlayerName(),
userId: getUserId(),
score: gameData.score,
moves: gameData.moves,
timeSeconds: gameData.duration,
difficulty: gameData.difficulty,
date: new Date().toISOString(),
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
won: gameData.won !== false
won: gameData.won !== false,
date: new Date().toISOString()
};
scores.push(newScore);
// Limiter à MAX_SCORES (garder les plus récents)
if (scores.length > this.MAX_SCORES) {
scores.sort((a, b) => new Date(b.date) - new Date(a.date));
scores.splice(this.MAX_SCORES);
}
localStorage.setItem(this.KEY_LEADERBOARD, JSON.stringify(scores));
DEBUG.log('Score added:', newScore);
return newScore;
}
/**
* Obtenir tous les scores
*/
static getAllScores() {
// 1) Historique local (statistiques personnelles)
this._recordLocalHistory(record);
// 2) Classement en ligne
try {
const scores = JSON.parse(localStorage.getItem(this.KEY_LEADERBOARD)) || [];
return scores;
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) {
console.error('Leaderboard error:', e);
return [];
}
}
/**
* Obtenir le classement (top X)
* Récupérer le classement en ligne (top X) pour un mode donné.
* @returns {Promise<Array>}
*/
static getLeaderboard(difficulty = 'all', limit = 10) {
let scores = this.getAllScores();
// Filtrer par difficulté
if (difficulty !== 'all') {
scores = scores.filter(s => s.difficulty === difficulty);
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');
}
// Trier par score (décroissant)
scores.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
// En cas d'égalité, par temps
return a.timeSeconds - b.timeSeconds;
});
return scores.slice(0, limit);
this._cache[difficulty] = data.scores;
return data.scores;
}
/**
* Obtenir les stats d'un joueur
* Statistiques du joueur, calculées à partir de son historique LOCAL.
*/
static getPlayerStats(userId) {
const scores = this.getAllScores().filter(s => s.userId === userId);
static getPlayerStats() {
const scores = this._getLocalHistory();
if (scores.length === 0) {
return {
totalGames: 0,
@@ -96,14 +120,14 @@ class LeaderboardManager {
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,
@@ -114,63 +138,37 @@ class LeaderboardManager {
perfectGames: perfectGames
};
}
/**
* Obtenir le rang d'un score
* Mettre à jour l'affichage du classement (asynchrone).
*/
static getPlayerRank(userId, difficulty = 'all') {
const leaderboard = this.getLeaderboard(difficulty, Infinity);
const rank = leaderboard.findIndex(s => s.userId === userId) + 1;
return rank > 0 ? rank : null;
}
/**
* Vérifier si c'est un nouveau record personnel
*/
static isPersonalRecord(userId, newScore) {
const stats = this.getPlayerStats(userId);
return newScore > stats.bestScore;
}
/**
* Obtenir les statistiques globales
*/
static getGlobalStats() {
const scores = this.getAllScores();
if (scores.length === 0) {
return {
totalGames: 0,
totalPlayers: 0,
averageScore: 0,
highestScore: 0,
fastestCompletion: Infinity
};
}
const uniquePlayers = new Set(scores.map(s => s.userId));
const totalScore = scores.reduce((a, b) => a + b.score, 0);
const highestScore = Math.max(...scores.map(s => s.score), 0);
const fastestCompletion = Math.min(...scores.map(s => s.timeSeconds), Infinity);
return {
totalGames: scores.length,
totalPlayers: uniquePlayers.size,
averageScore: Math.round(totalScore / scores.length),
highestScore: highestScore,
fastestCompletion: fastestCompletion === Infinity ? 0 : fastestCompletion
};
}
/**
* Mettre à jour l'affichage du classement
*/
static updateDisplay(difficulty = 'all') {
const leaderboard = this.getLeaderboard(difficulty, 10);
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">
@@ -181,16 +179,20 @@ class LeaderboardManager {
`;
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>
@@ -205,96 +207,53 @@ class LeaderboardManager {
</div>
</div>
`;
leaderboardList.appendChild(rankItem);
});
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
if (typeof analyticsManager !== 'undefined') {
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
}
}
/**
* Mettre à jour les statistiques du joueur
* Mettre à jour les statistiques du joueur (locales).
*/
static updatePlayerStats() {
const userId = getUserId();
const stats = this.getPlayerStats(userId);
// Remplir les éléments HTML
document.getElementById('total-games').textContent = stats.totalGames;
document.getElementById('best-score').textContent = formatNumber(stats.bestScore);
document.getElementById('best-time').textContent = stats.bestTime === Infinity ? '--' : formatTime(stats.bestTime);
document.getElementById('win-rate').textContent = `${stats.winRate}%`;
// Ajouter des classes pour animation si valeurs élevées
if (stats.bestScore > 5000) {
document.getElementById('best-score').parentElement.classList.add('high-value');
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 (stats.winRate >= 50) {
document.getElementById('win-rate').parentElement.classList.add('high-value');
if (winRateEl && stats.winRate >= 50) {
winRateEl.parentElement.classList.add('high-value');
}
}
/**
* Afficher le rang du joueur
* Effacer l'historique local du joueur (DEBUG).
* N'affecte PAS le classement en ligne partagé.
*/
static updatePlayerRank(difficulty = 'all') {
const userId = getUserId();
const rank = this.getPlayerRank(userId, difficulty);
if (rank) {
const rankDisplay = document.getElementById('player-rank');
if (rankDisplay) {
rankDisplay.textContent = `Vous êtes #{rank}`;
}
}
}
/**
* Afficher les statistiques globales
*/
static displayGlobalStats() {
const globalStats = this.getGlobalStats();
DEBUG.log('Global Statistics:', globalStats);
return globalStats;
}
/**
* Nettoyer les scores (DEBUG)
*/
static clearAll() {
if (confirm('Êtes-vous ABSOLUMENT certain de vouloir supprimer TOUS les scores?')) {
localStorage.removeItem(this.KEY_LEADERBOARD);
this.updateDisplay();
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('✓ Tous les scores ont été supprimés');
DEBUG.log('✓ Historique local effacé');
return true;
}
return false;
}
/**
* Exporter les scores en CSV
*/
static exportCSV() {
const scores = this.getAllScores();
let csv = 'Rang,Joueur,Score,Difficulté,Temps (s),Mouvements,Parfait,Date\n';
scores.forEach((score, index) => {
csv += `${index + 1},"${score.playerName}",${score.score},"${score.difficulty}",${score.timeSeconds},${score.moves},${score.isPerfect ? 'Oui' : 'Non'},"${score.date}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `leaderboard-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
/**
* Obtenir l'affichage du rang (numéro ou médaille)
*/
@@ -302,7 +261,7 @@ class LeaderboardManager {
const medals = ['🥇', '🥈', '🥉'];
return medals[index] || `${index + 1}`;
}
/**
* Échapper le HTML (sécurité)
*/
@@ -313,78 +272,11 @@ class LeaderboardManager {
}
}
/**
* Statistiques avancées
*/
class AdvancedStats {
/**
* Obtenir la distribution des scores par difficulté
*/
static getDistributionByDifficulty() {
const scores = LeaderboardManager.getAllScores();
const distribution = {};
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
distribution[difficulty] = scores.filter(s => s.difficulty === difficulty).length;
});
return distribution;
}
/**
* Obtenir la tendance temporelle
*/
static getTimelineTrend(days = 7) {
const scores = LeaderboardManager.getAllScores();
const now = new Date();
const timeline = {};
for (let i = 0; i < days; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
timeline[dateStr] = scores.filter(s => s.date.slice(0, 10) === dateStr).length;
}
return timeline;
}
/**
* Obtenir la moyenne de temps par difficulté
*/
static getAverageTimeByDifficulty() {
const scores = LeaderboardManager.getAllScores();
const averages = {};
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
const diffScores = scores.filter(s => s.difficulty === difficulty);
if (diffScores.length > 0) {
const totalTime = diffScores.reduce((a, b) => a + b.timeSeconds, 0);
averages[difficulty] = Math.round(totalTime / diffScores.length);
}
});
return averages;
}
/**
* Taux de réussite global
*/
static getGlobalSuccessRate() {
const scores = LeaderboardManager.getAllScores();
if (scores.length === 0) return 0;
const perfectGames = scores.filter(s => s.isPerfect).length;
return Math.round((perfectGames / scores.length) * 100);
}
}
// ============================================
// INITIALISATION
// ============================================
// Exporter pour utilisation globale
window.LeaderboardManager = LeaderboardManager;
window.AdvancedStats = AdvancedStats;
DEBUG.log('Leaderboard Manager initialized');
DEBUG.log('Leaderboard Manager initialized (online mode)');

View File

@@ -347,12 +347,16 @@ class MemoryGameEngine {
achievements: achievements.map(a => a.id)
});
// Sauvegarder score
// Sauvegarder le score (classement en ligne) puis rafraîchir l'affichage
LeaderboardManager.addScore({
...gameData,
score: finalScore.totalScore
}).then(() => {
const activeFilter = document.querySelector('.tab-btn.active')?.getAttribute('data-filter') || 'all';
LeaderboardManager.updateDisplay(activeFilter);
LeaderboardManager.updatePlayerStats();
});
// Afficher modal victoire
this.showVictoryModal(finalScore, achievements, isWon);
}

View File

@@ -292,10 +292,10 @@ function getRank(index) {
}
/**
* Vérifier si le score est dans le top 10
* Vérifier si le score est dans le top 10 (classement en ligne, asynchrone)
*/
function isTopScore(score, difficulty = 'all') {
const scores = LeaderboardManager.getLeaderboard(difficulty, 10);
async function isTopScore(score, difficulty = 'all') {
const scores = await LeaderboardManager.getLeaderboard(difficulty, 10);
return scores.some(s => s.score === score.score && s.userId === score.userId);
}
@@ -318,57 +318,25 @@ function formatDate(isoDate) {
}
/**
* Obtenir les statistiques d'un joueur
* Obtenir les statistiques locales du joueur
*/
function getPlayerStatistics(userId) {
const scores = LeaderboardManager.getAllScores().filter(s => s.userId === userId);
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
};
function getPlayerStatistics() {
return LeaderboardManager.getPlayerStats();
}
/**
* Déboguer : afficher tous les scores en console
* Déboguer : afficher l'historique local du joueur en console
*/
function debugScores() {
console.table(LeaderboardManager.getAllScores());
console.table(LeaderboardManager._getLocalHistory());
}
/**
* Déboguer : nettoyer tous les scores
* Déboguer : effacer les statistiques locales du joueur
* (le classement en ligne partagé n'est pas affecté)
*/
function clearAllScores() {
if (confirm('Êtes-vous sûr de vouloir supprimer tous les scores?')) {
localStorage.removeItem('nextgn_leaderboard');
console.log('✓ Tous les scores ont été supprimés');
location.reload();
}
LeaderboardManager.clearLocalHistory();
}
/**