Compare commits
2 Commits
44be41b2b2
...
317f98b711
| Author | SHA1 | Date | |
|---|---|---|---|
| 317f98b711 | |||
| 2a85ced1e6 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,6 @@
|
|||||||
|
|
||||||
.DS_Store
|
.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
|
||||||
|
|||||||
55
exercices/memory-pictogrammes/api/README.md
Normal file
55
exercices/memory-pictogrammes/api/README.md
Normal file
@@ -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).
|
||||||
329
exercices/memory-pictogrammes/api/scores.ashx
Normal file
329
exercices/memory-pictogrammes/api/scores.ashx
Normal file
@@ -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<string, object> {
|
||||||
|
{ "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<Dictionary<string, object>>();
|
||||||
|
|
||||||
|
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<string, object> { { "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<string, object> data = null;
|
||||||
|
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(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<string, object> {
|
||||||
|
{ "ok", true },
|
||||||
|
{ "saved", true },
|
||||||
|
{ "isTop", rank.HasValue },
|
||||||
|
{ "rank", rank.HasValue ? (object)rank.Value : null }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accès données -------------------------------------------------------
|
||||||
|
List<Dictionary<string, object>> FetchTop(SqlConnection conn, string difficulty, int limit)
|
||||||
|
{
|
||||||
|
var result = new List<Dictionary<string, object>>();
|
||||||
|
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<string, object> {
|
||||||
|
{ "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<string, object> a, Dictionary<string, object> 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<string, object>;
|
||||||
|
if (dict != null && dict.ContainsKey("scores"))
|
||||||
|
{
|
||||||
|
var list = dict["scores"] as List<Dictionary<string, object>>;
|
||||||
|
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<string, object> { { "ok", false }, { "error", message } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Str(Dictionary<string, object> d, string key)
|
||||||
|
{
|
||||||
|
object v; return (d.TryGetValue(key, out v) && v != null) ? v.ToString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Int(Dictionary<string, object> 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<string, object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
exercices/memory-pictogrammes/api/web.config.example
Normal file
16
exercices/memory-pictogrammes/api/web.config.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<!--
|
||||||
|
MODÈLE de configuration. Sur le serveur bsite.net :
|
||||||
|
1. Copiez ce fichier en « web.config ».
|
||||||
|
2. Renseignez le mot de passe fourni par le panneau « Manage Databases ».
|
||||||
|
|
||||||
|
Le vrai « web.config » n'est PAS versionné (voir .gitignore) afin de ne
|
||||||
|
jamais exposer le mot de passe dans l'historique Git.
|
||||||
|
-->
|
||||||
|
<connectionStrings>
|
||||||
|
<add name="MemoryDb"
|
||||||
|
connectionString="Server=sql.bsite.net\MSSQL2016;Database=nextgnformation_reponses_exercices;User Id=nextgnformation_reponses_exercices;Password=VOTRE_MOT_DE_PASSE;"
|
||||||
|
providerName="System.Data.SqlClient" />
|
||||||
|
</connectionStrings>
|
||||||
|
</configuration>
|
||||||
@@ -93,6 +93,13 @@
|
|||||||
overflow: hidden;
|
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 {
|
.rank-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,90 +1,114 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
LEADERBOARD & PLAYER STATISTICS
|
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
|
* Manager du classement
|
||||||
*/
|
*/
|
||||||
class LeaderboardManager {
|
class LeaderboardManager {
|
||||||
static KEY_LEADERBOARD = 'nextgn_leaderboard';
|
// Point d'entrée de l'API (relatif à memory.html) — ASP.NET + SQL Server
|
||||||
static MAX_SCORES = 100;
|
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) {
|
static async addScore(gameData) {
|
||||||
const scores = this.getAllScores();
|
const record = {
|
||||||
|
|
||||||
const newScore = {
|
|
||||||
sessionId: generateUUID(),
|
|
||||||
playerName: getPlayerName(),
|
playerName: getPlayerName(),
|
||||||
userId: getUserId(),
|
userId: getUserId(),
|
||||||
score: gameData.score,
|
score: gameData.score,
|
||||||
moves: gameData.moves,
|
moves: gameData.moves,
|
||||||
timeSeconds: gameData.duration,
|
timeSeconds: gameData.duration,
|
||||||
difficulty: gameData.difficulty,
|
difficulty: gameData.difficulty,
|
||||||
date: new Date().toISOString(),
|
|
||||||
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
|
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
|
||||||
won: gameData.won !== false
|
won: gameData.won !== false,
|
||||||
|
date: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
scores.push(newScore);
|
// 1) Historique local (statistiques personnelles)
|
||||||
|
this._recordLocalHistory(record);
|
||||||
// Limiter à MAX_SCORES (garder les plus récents)
|
|
||||||
if (scores.length > this.MAX_SCORES) {
|
// 2) Classement en ligne
|
||||||
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() {
|
|
||||||
try {
|
try {
|
||||||
const scores = JSON.parse(localStorage.getItem(this.KEY_LEADERBOARD)) || [];
|
const res = await fetch(this.API_URL, {
|
||||||
return scores;
|
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) {
|
} catch (e) {
|
||||||
console.error('Leaderboard error:', e);
|
|
||||||
return [];
|
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) {
|
static async getLeaderboard(difficulty = 'all', limit = 10) {
|
||||||
let scores = this.getAllScores();
|
const url = `${this.API_URL}?difficulty=${encodeURIComponent(difficulty)}&limit=${limit}`;
|
||||||
|
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||||
// Filtrer par difficulté
|
const data = await res.json();
|
||||||
if (difficulty !== 'all') {
|
if (!data || data.ok !== true || !Array.isArray(data.scores)) {
|
||||||
scores = scores.filter(s => s.difficulty === difficulty);
|
throw new Error('Réponse invalide du serveur');
|
||||||
}
|
}
|
||||||
|
this._cache[difficulty] = data.scores;
|
||||||
// Trier par score (décroissant)
|
return data.scores;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtenir les stats d'un joueur
|
* Statistiques du joueur, calculées à partir de son historique LOCAL.
|
||||||
*/
|
*/
|
||||||
static getPlayerStats(userId) {
|
static getPlayerStats() {
|
||||||
const scores = this.getAllScores().filter(s => s.userId === userId);
|
const scores = this._getLocalHistory();
|
||||||
|
|
||||||
if (scores.length === 0) {
|
if (scores.length === 0) {
|
||||||
return {
|
return {
|
||||||
totalGames: 0,
|
totalGames: 0,
|
||||||
@@ -96,14 +120,14 @@ class LeaderboardManager {
|
|||||||
perfectGames: 0
|
perfectGames: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bestScore = Math.max(...scores.map(s => s.score), 0);
|
const bestScore = Math.max(...scores.map(s => s.score), 0);
|
||||||
const totalScore = scores.reduce((a, b) => a + b.score, 0);
|
const totalScore = scores.reduce((a, b) => a + b.score, 0);
|
||||||
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
|
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
|
||||||
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
|
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
|
||||||
const perfectGames = scores.filter(s => s.isPerfect).length;
|
const perfectGames = scores.filter(s => s.isPerfect).length;
|
||||||
const winRate = (perfectGames / scores.length) * 100;
|
const winRate = (perfectGames / scores.length) * 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalGames: scores.length,
|
totalGames: scores.length,
|
||||||
bestScore: bestScore,
|
bestScore: bestScore,
|
||||||
@@ -114,63 +138,37 @@ class LeaderboardManager {
|
|||||||
perfectGames: perfectGames
|
perfectGames: perfectGames
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtenir le rang d'un score
|
* Mettre à jour l'affichage du classement (asynchrone).
|
||||||
*/
|
*/
|
||||||
static getPlayerRank(userId, difficulty = 'all') {
|
static async updateDisplay(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);
|
|
||||||
const leaderboardList = document.getElementById('leaderboard-list');
|
const leaderboardList = document.getElementById('leaderboard-list');
|
||||||
|
|
||||||
if (!leaderboardList) return;
|
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) {
|
if (leaderboard.length === 0) {
|
||||||
leaderboardList.innerHTML = `
|
leaderboardList.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -181,16 +179,20 @@ class LeaderboardManager {
|
|||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const myUserId = getUserId();
|
||||||
leaderboardList.innerHTML = '';
|
leaderboardList.innerHTML = '';
|
||||||
|
|
||||||
leaderboard.forEach((score, index) => {
|
leaderboard.forEach((score, index) => {
|
||||||
const rankItem = document.createElement('div');
|
const rankItem = document.createElement('div');
|
||||||
rankItem.className = 'rank-item';
|
rankItem.className = 'rank-item';
|
||||||
|
if (score.userId === myUserId) {
|
||||||
|
rankItem.classList.add('is-me');
|
||||||
|
}
|
||||||
|
|
||||||
const rankNumber = this.getRankDisplay(index);
|
const rankNumber = this.getRankDisplay(index);
|
||||||
const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"';
|
const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"';
|
||||||
|
|
||||||
rankItem.innerHTML = `
|
rankItem.innerHTML = `
|
||||||
<div class="rank-top">
|
<div class="rank-top">
|
||||||
<div ${medal}>${rankNumber}</div>
|
<div ${medal}>${rankNumber}</div>
|
||||||
@@ -205,96 +207,53 @@ class LeaderboardManager {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
leaderboardList.appendChild(rankItem);
|
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() {
|
static updatePlayerStats() {
|
||||||
const userId = getUserId();
|
const stats = this.getPlayerStats();
|
||||||
const stats = this.getPlayerStats(userId);
|
|
||||||
|
const totalGamesEl = document.getElementById('total-games');
|
||||||
// Remplir les éléments HTML
|
const bestScoreEl = document.getElementById('best-score');
|
||||||
document.getElementById('total-games').textContent = stats.totalGames;
|
const bestTimeEl = document.getElementById('best-time');
|
||||||
document.getElementById('best-score').textContent = formatNumber(stats.bestScore);
|
const winRateEl = document.getElementById('win-rate');
|
||||||
document.getElementById('best-time').textContent = stats.bestTime === Infinity ? '--' : formatTime(stats.bestTime);
|
|
||||||
document.getElementById('win-rate').textContent = `${stats.winRate}%`;
|
if (totalGamesEl) totalGamesEl.textContent = stats.totalGames;
|
||||||
|
if (bestScoreEl) bestScoreEl.textContent = formatNumber(stats.bestScore);
|
||||||
// Ajouter des classes pour animation si valeurs élevées
|
if (bestTimeEl) bestTimeEl.textContent = stats.bestTime === Infinity || stats.bestTime === 0 ? '--' : formatTime(stats.bestTime);
|
||||||
if (stats.bestScore > 5000) {
|
if (winRateEl) winRateEl.textContent = `${stats.winRate}%`;
|
||||||
document.getElementById('best-score').parentElement.classList.add('high-value');
|
|
||||||
|
if (bestScoreEl && stats.bestScore > 5000) {
|
||||||
|
bestScoreEl.parentElement.classList.add('high-value');
|
||||||
}
|
}
|
||||||
|
if (winRateEl && stats.winRate >= 50) {
|
||||||
if (stats.winRate >= 50) {
|
winRateEl.parentElement.classList.add('high-value');
|
||||||
document.getElementById('win-rate').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') {
|
static clearLocalHistory() {
|
||||||
const userId = getUserId();
|
if (confirm('Effacer VOS statistiques locales ? (le classement en ligne n\'est pas touché)')) {
|
||||||
const rank = this.getPlayerRank(userId, difficulty);
|
localStorage.removeItem(this.KEY_HISTORY);
|
||||||
|
|
||||||
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();
|
|
||||||
this.updatePlayerStats();
|
this.updatePlayerStats();
|
||||||
DEBUG.log('✓ Tous les scores ont été supprimés');
|
DEBUG.log('✓ Historique local effacé');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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)
|
* Obtenir l'affichage du rang (numéro ou médaille)
|
||||||
*/
|
*/
|
||||||
@@ -302,7 +261,7 @@ class LeaderboardManager {
|
|||||||
const medals = ['🥇', '🥈', '🥉'];
|
const medals = ['🥇', '🥈', '🥉'];
|
||||||
return medals[index] || `${index + 1}`;
|
return medals[index] || `${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Échapper le HTML (sécurité)
|
* É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
|
// INITIALISATION
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Exporter pour utilisation globale
|
// Exporter pour utilisation globale
|
||||||
window.LeaderboardManager = LeaderboardManager;
|
window.LeaderboardManager = LeaderboardManager;
|
||||||
window.AdvancedStats = AdvancedStats;
|
|
||||||
|
|
||||||
DEBUG.log('Leaderboard Manager initialized');
|
DEBUG.log('Leaderboard Manager initialized (online mode)');
|
||||||
|
|||||||
@@ -347,12 +347,16 @@ class MemoryGameEngine {
|
|||||||
achievements: achievements.map(a => a.id)
|
achievements: achievements.map(a => a.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sauvegarder score
|
// Sauvegarder le score (classement en ligne) puis rafraîchir l'affichage
|
||||||
LeaderboardManager.addScore({
|
LeaderboardManager.addScore({
|
||||||
...gameData,
|
...gameData,
|
||||||
score: finalScore.totalScore
|
score: finalScore.totalScore
|
||||||
|
}).then(() => {
|
||||||
|
const activeFilter = document.querySelector('.tab-btn.active')?.getAttribute('data-filter') || 'all';
|
||||||
|
LeaderboardManager.updateDisplay(activeFilter);
|
||||||
|
LeaderboardManager.updatePlayerStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Afficher modal victoire
|
// Afficher modal victoire
|
||||||
this.showVictoryModal(finalScore, achievements, isWon);
|
this.showVictoryModal(finalScore, achievements, isWon);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
async function isTopScore(score, difficulty = 'all') {
|
||||||
const scores = LeaderboardManager.getLeaderboard(difficulty, 10);
|
const scores = await LeaderboardManager.getLeaderboard(difficulty, 10);
|
||||||
return scores.some(s => s.score === score.score && s.userId === score.userId);
|
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) {
|
function getPlayerStatistics() {
|
||||||
const scores = LeaderboardManager.getAllScores().filter(s => s.userId === userId);
|
return LeaderboardManager.getPlayerStats();
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Déboguer : afficher tous les scores en console
|
* Déboguer : afficher l'historique local du joueur en console
|
||||||
*/
|
*/
|
||||||
function debugScores() {
|
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() {
|
function clearAllScores() {
|
||||||
if (confirm('Êtes-vous sûr de vouloir supprimer tous les scores?')) {
|
LeaderboardManager.clearLocalHistory();
|
||||||
localStorage.removeItem('nextgn_leaderboard');
|
|
||||||
console.log('✓ Tous les scores ont été supprimés');
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user