diff --git a/.gitignore b/.gitignore index 39587cf..ae22356 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ # Theia .theia/ +data/statistics.json diff --git a/src/config.py b/src/config.py index 7bb41a3..236ced6 100644 --- a/src/config.py +++ b/src/config.py @@ -2,6 +2,14 @@ Configuration centralisée du jeu MasterMind """ +from pathlib import Path + +# --- Chemins --- +# Répertoire contenant ce fichier (src/) +CONFIG_DIR = Path(__file__).resolve().parent +# Répertoire racine du projet (un niveau au-dessus) +BASE_DIR = CONFIG_DIR.parent + # --- Règles du jeu --- CODE_LENGTH = 4 # Nombre de positions dans le code secret MAX_ATTEMPTS = 15 # Nombre maximum de tentatives @@ -40,4 +48,8 @@ # Fenêtre WINDOW_WIDTH = 450 WINDOW_HEIGHT = 800 -WINDOW_TITLE = "MasterMind" \ No newline at end of file +WINDOW_TITLE = "MasterMind" + +# --- Statistiques --- +# Chemin absolu vers le fichier de statistiques (basé sur l'emplacement du projet) +STATS_FILE = str(BASE_DIR / "data" / "statistics.json") \ No newline at end of file diff --git a/src/gui.py b/src/gui.py index 533ca8a..16fb316 100644 --- a/src/gui.py +++ b/src/gui.py @@ -3,7 +3,7 @@ """ import tkinter as tk -from tkinter import ttk +from tkinter import ttk, messagebox from typing import List, Tuple from game_logic import MasterMindGame, guess_to_colors, feedback_to_string from config import ( @@ -21,7 +21,9 @@ WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, + STATS_FILE, ) +from statistics import StatisticsManager, format_stat_summary def create_octagon(canvas: tk.Canvas, x1: int, y1: int, x2: int, y2: int, **kwargs): @@ -66,6 +68,9 @@ # Jeu self.game = MasterMindGame() + # Gestionnaire de statistiques + self.stats_manager = StatisticsManager(STATS_FILE) + # Variables pour la sélection de couleurs self.selected_colors = [0] * 4 # Indices des couleurs choisies self.guess_buttons = [] # Canvas des 4 positions à deviner @@ -167,6 +172,11 @@ ) self.reveal_btn.pack(pady=3) + self.stats_btn = ttk.Button( + control_frame, text="Statistiques", command=self._show_statistics, width=8 + ) + self.stats_btn.pack(pady=3) + def _build_main_area(self): """Zone principale (droite) avec titre, guess, historique""" main_frame = ttk.Frame(self.root, padding=10) @@ -278,11 +288,16 @@ self._update_status_after_guess(won, feedback) if self.game.game_over: + # Enregistrer les statistiques + attempts = len(self.game.attempts) + secret = self.game.reveal_secret() + self.stats_manager.record_game(won, attempts, secret) + if won: self.status_label.config(text="🎉 Félicitations! Vous avez gagné!") else: self.status_label.config( - text=f"💀 Perdu! Le code était: {guess_to_colors(self.game.reveal_secret())}" + text=f"💀 Perdu! Le code était: {guess_to_colors(secret)}" ) self.validate_btn.config(state="disabled") else: @@ -308,6 +323,95 @@ colors = guess_to_colors(secret) self.status_label.config(text=f"Code secret: {colors}") + def _show_statistics(self): + """Affiche la fenêtre des statistiques""" + stats_window = tk.Toplevel(self.root) + stats_window.title("📊 Statistiques MasterMind") + stats_window.geometry("500x600") + stats_window.configure(bg=BG_COLOR) + stats_window.transient(self.root) # Lie à la fenêtre principale + stats_window.grab_set() # Modal + + # Contenu + main_frame = ttk.Frame(stats_window, padding=15) + main_frame.pack(fill=tk.BOTH, expand=True) + + # Résumé global + summary = self.stats_manager.get_summary() + summary_text = format_stat_summary(summary) + summary_label = ttk.Label( + main_frame, + text=summary_text, + font=("Helvetica", 10), + justify=tk.LEFT, + background=BG_COLOR + ) + summary_label.pack(pady=(0, 15), anchor=tk.W) + + # Distribution des tentatives (victoires) + dist = self.stats_manager.get_attempt_distribution() + if dist: + dist_frame = ttk.LabelFrame(main_frame, text="Distribution des victoires", padding=10) + dist_frame.pack(fill=tk.X, pady=(0, 15)) + + for attempts in range(1, 16): + if attempts in dist: + count = dist[attempts] + bar = "█" * min(count * 2, 20) # Barre proportionnelle + ttk.Label( + dist_frame, + text=f" {attempts:2d} tentatives: {count:2d} {bar}", + font=("Courier", 9, "bold"), + background=BG_COLOR + ).pack(anchor=tk.W) + else: + ttk.Label(main_frame, text="(Aucune victoire enregistrée)", foreground="gray").pack(pady=(0, 15)) + + # Parties récentes + recent_frame = ttk.LabelFrame(main_frame, text="Dernières parties", padding=10) + recent_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 15)) + + recent_games = self.stats_manager.get_recent_games(10) + if recent_games: + # En-têtes + header_frame = ttk.Frame(recent_frame) + header_frame.pack(fill=tk.X, pady=(0, 5)) + ttk.Label(header_frame, text="Date", width=12).pack(side=tk.LEFT, padx=2) + ttk.Label(header_frame, text="Tentatives", width=10).pack(side=tk.LEFT, padx=2) + ttk.Label(header_frame, text="Résultat", width=8).pack(side=tk.LEFT, padx=2) + + for game in recent_games: + row_frame = ttk.Frame(recent_frame) + row_frame.pack(fill=tk.X, pady=1) + result_text = "✓ Gagné" if game['won'] else "✗ Perdu" + result_color = "green" if game['won'] else "red" + ttk.Label(row_frame, text=game['date'], width=12).pack(side=tk.LEFT, padx=2) + ttk.Label(row_frame, text=str(game['attempts']), width=10).pack(side=tk.LEFT, padx=2) + ttk.Label(row_frame, text=result_text, width=8, foreground=result_color).pack(side=tk.LEFT, padx=2) + else: + ttk.Label(recent_frame, text="(Aucune partie enregistrée)", foreground="gray").pack(pady=10) + + # Série actuelle + streak, streak_type = self.stats_manager.get_streak() + streak_text = f"Série actuelle: {streak} {streak_type}{'s' if streak > 1 else ''}" + ttk.Label(main_frame, text=streak_text, font=("Helvetica", 10, "italic")).pack(pady=5) + + # Boutons + btn_frame = ttk.Frame(main_frame) + btn_frame.pack(pady=10) + + def clear_stats(): + if tk.messagebox.askyesno("Confirmer", "Voulez-vous vraiment effacer toutes les statistiques?"): + self.stats_manager.clear_stats() + stats_window.destroy() + self.status_label.config(text="Statistiques effacées") + + ttk.Button(btn_frame, text="Effacer les stats", command=clear_stats).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Fermer", command=stats_window.destroy).pack(side=tk.LEFT, padx=5) + + # Focus sur la fenêtre + stats_window.focus_set() + def _update_status_after_guess(self, won: bool, feedback: Tuple[int, int]): """Met à jour le message de statut après un guess""" black, white = feedback diff --git a/src/statistics.py b/src/statistics.py new file mode 100644 index 0000000..d513326 --- /dev/null +++ b/src/statistics.py @@ -0,0 +1,214 @@ +""" +Module de gestion des statistiques du jeu MasterMind. +Collecte, analyse et persiste les données de jeu. +""" + +import json +import os +from datetime import datetime +from typing import List, Dict, Tuple, Optional +from dataclasses import dataclass, asdict +from pathlib import Path + + +@dataclass +class GameRecord: + """Enregistrement d'une partie jouée""" + timestamp: str + won: bool + attempts: int + secret: Optional[List[int]] = None # Code secret (optionnel pour vie privée) + + def to_dict(self) -> Dict: + """Convertit l'enregistrement en dictionnaire pour JSON""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict) -> 'GameRecord': + """Crée un enregistrement depuis un dictionnaire JSON""" + return cls(**data) + + +class StatisticsManager: + """Gestionnaire des statistiques du jeu""" + + def __init__(self, stats_file: str = "data/statistics.json"): + """ + Initialise le gestionnaire et charge les statistiques existantes. + + Args: + stats_file: Chemin vers le fichier de statistiques JSON + """ + self.stats_file = Path(stats_file) + self.games: List[GameRecord] = [] + self._load() + + def _load(self) -> None: + """Charge les statistiques depuis le fichier JSON""" + if self.stats_file.exists(): + try: + with open(self.stats_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.games = [GameRecord.from_dict(record) for record in data.get('games', [])] + except (json.JSONDecodeError, KeyError) as e: + print(f"⚠️ Erreur chargement statistiques: {e}. Initialisation vide.") + self.games = [] + else: + # Créer le dossier data si nécessaire + self.stats_file.parent.mkdir(parents=True, exist_ok=True) + self.games = [] + + def save(self) -> None: + """Sauvegarde les statistiques dans le fichier JSON""" + data = { + "total_games": len(self.games), + "games_won": sum(1 for g in self.games if g.won), + "last_updated": datetime.now().isoformat(), + "games": [game.to_dict() for game in self.games] + } + with open(self.stats_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def record_game(self, won: bool, attempts: int, secret: Optional[List[int]] = None) -> None: + """ + Enregistre une partie terminée. + + Args: + won: True si le joueur a gagné + attempts: Nombre de tentatives utilisées + secret: Code secret (optionnel, pour分析) + """ + record = GameRecord( + timestamp=datetime.now().isoformat(), + won=won, + attempts=attempts, + secret=secret + ) + self.games.append(record) + self.save() + + def get_summary(self) -> Dict[str, any]: + """ + Retourne un résumé des statistiques globales. + + Returns: + Dictionnaire avec métriques clés + """ + total = len(self.games) + if total == 0: + return { + "total_games": 0, + "games_won": 0, + "win_rate": 0.0, + "avg_attempts": 0.0, + "best_victory": None, + "recent_win_rate": 0.0 + } + + won_games = [g for g in self.games if g.won] + total_won = len(won_games) + + # Taux de victoire global + win_rate = total_won / total + + # Tentatives moyennes (victoires seulement) + avg_attempts = sum(g.attempts for g in won_games) / total_won if total_won > 0 else 0 + + # Meilleure victoire (moins de tentatives) + best_victory = min(g.attempts for g in won_games) if won_games else None + + # Win rate sur les 10 dernières parties (ou moins si moins de parties) + recent_games = self.games[-10:] if len(self.games) >= 10 else self.games + recent_won = sum(1 for g in recent_games if g.won) + recent_win_rate = recent_won / len(recent_games) + + return { + "total_games": total, + "games_won": total_won, + "win_rate": round(win_rate * 100, 1), + "avg_attempts": round(avg_attempts, 1), + "best_victory": best_victory, + "recent_win_rate": round(recent_win_rate * 100, 1), + "recent_games_count": len(recent_games) + } + + def get_attempt_distribution(self) -> Dict[int, int]: + """ + Retourne la distribution des tentatives pour les victoires. + + Returns: + Dictionnaire {tentatives: nombre} pour tentatives 1-15 + """ + won_games = [g for g in self.games if g.won] + distribution = {} + for attempts in range(1, 16): # 1 à 15 tentatives max + count = sum(1 for g in won_games if g.attempts == attempts) + if count > 0: + distribution[attempts] = count + return distribution + + def get_recent_games(self, n: int = 10) -> List[Dict]: + """ + Retourne les n dernières parties. + + Args: + n: Nombre de parties à retourner + + Returns: + Liste de dictionnaires avec données des parties + """ + recent = self.games[-n:] if n > 0 else [] + return [ + { + "timestamp": g.timestamp, + "won": g.won, + "attempts": g.attempts, + "date": datetime.fromisoformat(g.timestamp).strftime("%d/%m/%Y %H:%M") + } + for g in recent + ] + + def clear_stats(self) -> None: + """Réinitialise toutes les statistiques (avec confirmation)""" + self.games = [] + self.save() + + def get_streak(self) -> Tuple[int, str]: + """ + Calcule la série actuelle (victoires/défaites consécutives). + + Returns: + Tuple (longueur, type) où type est "victoire" ou "défaite" + """ + if not self.games: + return (0, "aucune") + + current_streak = 1 + current_type = "victoire" if self.games[-1].won else "défaite" + + for game in reversed(self.games[:-1]): + if (current_type == "victoire" and game.won) or (current_type == "défaite" and not game.won): + current_streak += 1 + else: + break + + return (current_streak, current_type) + + +def format_stat_summary(summary: Dict) -> str: + """Formate le résumé statistique en texte lisible""" + if summary["total_games"] == 0: + return "📊 Aucune partie enregistrée pour le moment." + + lines = [ + "📈 Statistiques MasterMind", + "=" * 40, + f" Parties jouées: {summary['total_games']}", + f" Victoires: {summary['games_won']}", + f" Taux de victoire: {summary['win_rate']}%", + f" Tentatives moyennes (victoires): {summary['avg_attempts']}", + f" Meilleure victoire: {summary['best_victory']} tentative(s)" if summary['best_victory'] else " Aucune victoire pour le moment", + f" Win rate (10 dernières): {summary['recent_win_rate']}%", + "=" * 40 + ] + return "\n".join(lines)