From 2a85ced1e68e76bcfd4be71277d6ac11194eacfc Mon Sep 17 00:00:00 2001 From: "gauthier.chombart" Date: Wed, 15 Jul 2026 22:35:24 +0200 Subject: [PATCH] Passe le classement Memory en ligne (ASP.NET + SQL Server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é. --- .gitignore | 4 + exercices/memory-pictogrammes/api/README.md | 55 +++ exercices/memory-pictogrammes/api/scores.ashx | 329 ++++++++++++++ .../api/web.config.example | 16 + .../assets/css/leaderboard.css | 7 + .../assets/js/leaderboard.js | 412 +++++++----------- .../assets/js/memory-game.js | 8 +- .../memory-pictogrammes/assets/js/utils.js | 54 +-- 8 files changed, 580 insertions(+), 305 deletions(-) create mode 100644 exercices/memory-pictogrammes/api/README.md create mode 100644 exercices/memory-pictogrammes/api/scores.ashx create mode 100644 exercices/memory-pictogrammes/api/web.config.example diff --git a/.gitignore b/.gitignore index 9bea433..c05a2ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .DS_Store + +# Contient le mot de passe de la base SQL Server — à créer sur le serveur +# à partir de web.config.example (ne jamais versionner) +exercices/memory-pictogrammes/api/web.config diff --git a/exercices/memory-pictogrammes/api/README.md b/exercices/memory-pictogrammes/api/README.md new file mode 100644 index 0000000..42944ce --- /dev/null +++ b/exercices/memory-pictogrammes/api/README.md @@ -0,0 +1,55 @@ +# API du classement Memory (ASP.NET + SQL Server) + +Petit service **ASP.NET (C#) + SQL Server** qui héberge en ligne le +**classement partagé** du jeu Memory. Tous les joueurs enregistrent leurs +scores au même endroit et voient le même top 10 par mode (facile / normal / +difficile). + +> Adapté à l'hébergement **FreeASPHosting.net / bsite.net** (Windows, IIS, +> base MSSQL 2016). Le fichier `scores.ashx` est compilé au vol par IIS : +> **aucune étape de build**, il suffit de le déposer. + +## Contenu + +| Fichier | Rôle | +|---|---| +| `scores.ashx` | Point d'entrée de l'API (GET pour lire, POST pour enregistrer). Crée la table automatiquement au premier appel. | +| `web.config.example` | Modèle de configuration à copier en `web.config`. | +| `web.config` | **À créer sur le serveur** avec le mot de passe SQL Server (clé `MemoryDb`). Non versionné. | +| `README.md` | Ce fichier. | + +## Installation sur bsite.net + +1. **Récupérer le mot de passe** de la base dans le panneau + *Manage Databases*. La base existe déjà : + - Serveur : `sql.bsite.net\MSSQL2016` + - Base / utilisateur : `nextgnformation_reponses_exercices` + +2. **Créer `web.config`** à partir du modèle (`cp web.config.example web.config`), + puis remplacer `VOTRE_MOT_DE_PASSE` par le mot de passe réel dans la clé + `MemoryDb`. + +3. **Déposer le dossier `api/`** via *My Files*, au même endroit que + `memory.html` (chemin attendu par le jeu : + `exercices/memory-pictogrammes/api/scores.ashx`). + +4. C'est prêt : au premier score enregistré (ou premier appel GET), la table + `memory_scores` est créée automatiquement. + +## Vérification rapide + +- Lecture du classement : + `GET https://nextgnformation.bsite.net/exercices/memory-pictogrammes/api/scores.ashx?difficulty=all` + doit renvoyer `{"ok":true,"scores":[...]}`. + +- Seuls les 10 meilleurs de chaque mode sont conservés : après chaque + enregistrement, les scores hors top 10 du mode concerné sont supprimés. + +## Points d'attention + +- **Ne poussez jamais `web.config` avec le vrai mot de passe dans un dépôt + public.** Éditez-le directement sur le serveur via *My Files*. +- Les statistiques personnelles (« Vos statistiques ») restent stockées + localement dans le navigateur de chaque joueur ; seul le classement est en ligne. +- Le score étant calculé côté navigateur, l'API valide les types et les bornes + mais ne garantit pas l'absence de triche (recalcul serveur possible plus tard). diff --git a/exercices/memory-pictogrammes/api/scores.ashx b/exercices/memory-pictogrammes/api/scores.ashx new file mode 100644 index 0000000..36c08a6 --- /dev/null +++ b/exercices/memory-pictogrammes/api/scores.ashx @@ -0,0 +1,329 @@ +<%@ WebHandler Language="C#" Class="ScoresHandler" %> +/* + * API du classement Memory (pictogrammes de sécurité) — ASP.NET + SQL Server. + * + * GET scores.ashx?difficulty=easy|normal|hard|all[&limit=10] + * -> renvoie le classement (top N) trié par score décroissant. + * + * POST scores.ashx (corps JSON) + * { playerName, userId, score, moves, timeSeconds, difficulty, isPerfect } + * -> enregistre le score, ne conserve que le top N par difficulté, + * renvoie { ok, saved, rank, isTop }. + * + * Seul le top N (défaut 10) de chaque mode est conservé : les scores plus + * faibles sont supprimés après chaque insertion. + * + * Fichier compilé au vol par IIS : il suffit de le déposer, aucune build. + * La chaîne de connexion est lue dans web.config (clé "MemoryDb"). + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.IO; +using System.Text; +using System.Web; +using System.Web.Configuration; +using System.Web.Script.Serialization; + +public class ScoresHandler : IHttpHandler +{ + static readonly string[] Difficulties = { "easy", "normal", "hard" }; + const int TopN = 10; + + public bool IsReusable { get { return false; } } + + public void ProcessRequest(HttpContext ctx) + { + ctx.Response.ContentType = "application/json; charset=utf-8"; + ctx.Response.AddHeader("X-Content-Type-Options", "nosniff"); + + var connString = WebConfigurationManager.ConnectionStrings["MemoryDb"] != null + ? WebConfigurationManager.ConnectionStrings["MemoryDb"].ConnectionString + : null; + + if (string.IsNullOrEmpty(connString)) + { + Fail(ctx, 500, "Configuration manquante (connectionString 'MemoryDb' dans web.config)."); + return; + } + + try + { + using (var conn = new SqlConnection(connString)) + { + conn.Open(); + EnsureTable(conn); + + if (ctx.Request.HttpMethod == "GET") + { + HandleGet(ctx, conn); + } + else if (ctx.Request.HttpMethod == "POST") + { + HandlePost(ctx, conn); + } + else + { + ctx.Response.StatusCode = 405; + ctx.Response.AddHeader("Allow", "GET, POST"); + Write(ctx, new Dictionary { + { "ok", false }, { "error", "Méthode non autorisée." } + }); + } + } + } + catch (Exception) + { + Fail(ctx, 500, "Erreur serveur."); + } + } + + // --- Création de la table si besoin ------------------------------------- + void EnsureTable(SqlConnection conn) + { + const string sql = + "IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'memory_scores') " + + "CREATE TABLE memory_scores (" + + " id INT IDENTITY(1,1) PRIMARY KEY," + + " player_name NVARCHAR(40) NOT NULL," + + " user_id NVARCHAR(64) NOT NULL," + + " score INT NOT NULL," + + " moves INT NOT NULL," + + " time_seconds INT NOT NULL," + + " difficulty NVARCHAR(16) NOT NULL," + + " is_perfect BIT NOT NULL DEFAULT 0," + + " created_at DATETIME NOT NULL" + + ");"; + using (var cmd = new SqlCommand(sql, conn)) { cmd.ExecuteNonQuery(); } + } + + // --- GET : renvoie le classement ---------------------------------------- + void HandleGet(HttpContext ctx, SqlConnection conn) + { + var difficulty = (ctx.Request.QueryString["difficulty"] ?? "all").Trim(); + int limit; + if (!int.TryParse(ctx.Request.QueryString["limit"], out limit)) limit = TopN; + if (limit < 1) limit = 1; + if (limit > TopN) limit = TopN; + + var rows = new List>(); + + if (difficulty == "all") + { + foreach (var diff in Difficulties) + rows.AddRange(FetchTop(conn, diff, TopN)); + + rows.Sort(CompareRows); + if (rows.Count > limit) rows = rows.GetRange(0, limit); + } + else + { + if (Array.IndexOf(Difficulties, difficulty) < 0) + { + Fail(ctx, 400, "Difficulté inconnue."); + return; + } + rows = FetchTop(conn, difficulty, limit); + } + + Write(ctx, new Dictionary { { "ok", true }, { "scores", rows } }); + } + + // --- POST : enregistre un score puis élague au top N -------------------- + void HandlePost(HttpContext ctx, SqlConnection conn) + { + string body; + using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) + body = reader.ReadToEnd(); + + Dictionary data = null; + try { data = new JavaScriptSerializer().Deserialize>(body); } + catch { data = null; } + + if (data == null) + { + Fail(ctx, 400, "Corps JSON invalide."); + return; + } + + var difficulty = Str(data, "difficulty"); + if (Array.IndexOf(Difficulties, difficulty) < 0) + { + Fail(ctx, 400, "Difficulté inconnue."); + return; + } + + // Nettoyage / bornage des entrées + var playerName = Str(data, "playerName").Trim(); + playerName = StripControlChars(playerName); + if (playerName.Length == 0) playerName = "Anonyme"; + if (playerName.Length > 40) playerName = playerName.Substring(0, 40); + + var userId = OnlyIdChars(Str(data, "userId")); + if (userId.Length > 64) userId = userId.Substring(0, 64); + if (userId.Length == 0) userId = "anon"; + + int score = Clamp(Int(data, "score"), 0, 100000000); + int moves = Clamp(Int(data, "moves"), 0, 100000); + int time = Clamp(Int(data, "timeSeconds"), 0, 1000000); + int isPerfect = Bool(data, "isPerfect") ? 1 : 0; + + int insertedId; + using (var cmd = new SqlCommand( + "INSERT INTO memory_scores " + + "(player_name, user_id, score, moves, time_seconds, difficulty, is_perfect, created_at) " + + "OUTPUT INSERTED.id " + + "VALUES (@name, @uid, @score, @moves, @time, @diff, @perfect, GETUTCDATE())", conn)) + { + cmd.Parameters.Add("@name", SqlDbType.NVarChar, 40).Value = playerName; + cmd.Parameters.Add("@uid", SqlDbType.NVarChar, 64).Value = userId; + cmd.Parameters.Add("@score", SqlDbType.Int).Value = score; + cmd.Parameters.Add("@moves", SqlDbType.Int).Value = moves; + cmd.Parameters.Add("@time", SqlDbType.Int).Value = time; + cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty; + cmd.Parameters.Add("@perfect", SqlDbType.Bit).Value = isPerfect; + insertedId = (int)cmd.ExecuteScalar(); + } + + // Ne conserver que le top N pour ce mode + using (var cmd = new SqlCommand( + "WITH ranked AS (" + + " SELECT id, ROW_NUMBER() OVER (" + + " PARTITION BY difficulty ORDER BY score DESC, time_seconds ASC, id ASC) AS rn" + + " FROM memory_scores WHERE difficulty = @diff" + + ") DELETE FROM memory_scores WHERE id IN (SELECT id FROM ranked WHERE rn > @topN)", conn)) + { + cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty; + cmd.Parameters.Add("@topN", SqlDbType.Int).Value = TopN; + cmd.ExecuteNonQuery(); + } + + // Le score inséré a-t-il survécu à l'élagage ? Quel rang ? + var top = FetchTop(conn, difficulty, TopN); + int? rank = null; + for (int i = 0; i < top.Count; i++) + { + if ((int)top[i]["id"] == insertedId) { rank = i + 1; break; } + } + + Write(ctx, new Dictionary { + { "ok", true }, + { "saved", true }, + { "isTop", rank.HasValue }, + { "rank", rank.HasValue ? (object)rank.Value : null } + }); + } + + // --- Accès données ------------------------------------------------------- + List> FetchTop(SqlConnection conn, string difficulty, int limit) + { + var result = new List>(); + using (var cmd = new SqlCommand( + "SELECT TOP (@limit) id, player_name, user_id, score, moves, time_seconds, " + + "difficulty, is_perfect, created_at " + + "FROM memory_scores WHERE difficulty = @diff " + + "ORDER BY score DESC, time_seconds ASC, id ASC", conn)) + { + cmd.Parameters.Add("@limit", SqlDbType.Int).Value = limit; + cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty; + using (var r = cmd.ExecuteReader()) + { + while (r.Read()) + { + result.Add(new Dictionary { + { "id", (int)r["id"] }, + { "playerName", r["player_name"].ToString() }, + { "userId", r["user_id"].ToString() }, + { "score", (int)r["score"] }, + { "moves", (int)r["moves"] }, + { "timeSeconds", (int)r["time_seconds"] }, + { "difficulty", r["difficulty"].ToString() }, + { "isPerfect", Convert.ToBoolean(r["is_perfect"]) }, + { "date", ((DateTime)r["created_at"]).ToString("yyyy-MM-ddTHH:mm:ss") + "Z" } + }); + } + } + } + return result; + } + + int CompareRows(Dictionary a, Dictionary b) + { + int sa = (int)a["score"], sb = (int)b["score"]; + if (sa != sb) return sb - sa; + return (int)a["timeSeconds"] - (int)b["timeSeconds"]; + } + + // --- Sérialisation / helpers -------------------------------------------- + void Write(HttpContext ctx, object payload) + { + // On retire la clé "id" (interne) des lignes avant l'envoi. + var scrubbed = Scrub(payload); + ctx.Response.Write(new JavaScriptSerializer().Serialize(scrubbed)); + } + + object Scrub(object payload) + { + var dict = payload as Dictionary; + if (dict != null && dict.ContainsKey("scores")) + { + var list = dict["scores"] as List>; + if (list != null) + foreach (var row in list) row.Remove("id"); + } + return payload; + } + + void Fail(HttpContext ctx, int status, string message) + { + ctx.Response.StatusCode = status; + ctx.Response.Write(new JavaScriptSerializer().Serialize( + new Dictionary { { "ok", false }, { "error", message } })); + } + + static string Str(Dictionary d, string key) + { + object v; return (d.TryGetValue(key, out v) && v != null) ? v.ToString() : ""; + } + + static int Int(Dictionary d, string key) + { + object v; int n; + if (d.TryGetValue(key, out v) && v != null && int.TryParse(v.ToString(), out n)) return n; + return 0; + } + + static bool Bool(Dictionary d, string key) + { + object v; + if (!d.TryGetValue(key, out v) || v == null) return false; + if (v is bool) return (bool)v; + var s = v.ToString().ToLowerInvariant(); + return s == "true" || s == "1"; + } + + static int Clamp(int value, int min, int max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } + + static string StripControlChars(string s) + { + var sb = new StringBuilder(s.Length); + foreach (var c in s) if (!char.IsControl(c)) sb.Append(c); + return sb.ToString(); + } + + static string OnlyIdChars(string s) + { + var sb = new StringBuilder(s.Length); + foreach (var c in s) + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') + sb.Append(c); + return sb.ToString(); + } +} diff --git a/exercices/memory-pictogrammes/api/web.config.example b/exercices/memory-pictogrammes/api/web.config.example new file mode 100644 index 0000000..4051293 --- /dev/null +++ b/exercices/memory-pictogrammes/api/web.config.example @@ -0,0 +1,16 @@ + + + + + + + diff --git a/exercices/memory-pictogrammes/assets/css/leaderboard.css b/exercices/memory-pictogrammes/assets/css/leaderboard.css index 1226051..bf83aa0 100644 --- a/exercices/memory-pictogrammes/assets/css/leaderboard.css +++ b/exercices/memory-pictogrammes/assets/css/leaderboard.css @@ -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; diff --git a/exercices/memory-pictogrammes/assets/js/leaderboard.js b/exercices/memory-pictogrammes/assets/js/leaderboard.js index ca5eb81..e4c866a 100644 --- a/exercices/memory-pictogrammes/assets/js/leaderboard.js +++ b/exercices/memory-pictogrammes/assets/js/leaderboard.js @@ -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} 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} */ - 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 = ` +
+
+
Chargement du classement…
+
+ `; + + let leaderboard; + try { + leaderboard = await this.getLeaderboard(difficulty, 10); + } catch (e) { + console.error('Leaderboard load error:', e); + leaderboardList.innerHTML = ` +
+
⚠️
+
Classement indisponible
+
Vérifiez votre connexion et réessayez.
+
+ `; + return; + } + if (leaderboard.length === 0) { leaderboardList.innerHTML = `
@@ -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 = `
${rankNumber}
@@ -205,96 +207,53 @@ class LeaderboardManager {
`; - + 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)'); diff --git a/exercices/memory-pictogrammes/assets/js/memory-game.js b/exercices/memory-pictogrammes/assets/js/memory-game.js index 14e404e..1086bdf 100644 --- a/exercices/memory-pictogrammes/assets/js/memory-game.js +++ b/exercices/memory-pictogrammes/assets/js/memory-game.js @@ -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); } diff --git a/exercices/memory-pictogrammes/assets/js/utils.js b/exercices/memory-pictogrammes/assets/js/utils.js index 8726646..bcd4e2b 100644 --- a/exercices/memory-pictogrammes/assets/js/utils.js +++ b/exercices/memory-pictogrammes/assets/js/utils.js @@ -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(); } /**