"""
Interface graphique Tkinter avancée pour MasterMind
"""
import tkinter as tk
from tkinter import ttk, messagebox
from typing import List, Tuple
from game_logic import MasterMindGame, guess_to_colors, feedback_to_string
from config import (
COLORS,
CIRCLE_DIAMETER,
CIRCLE_SPACING,
FEEDBACK_DIAMETER,
FEEDBACK_SPACING,
GRID_PADDING,
ROW_HEIGHT,
BG_COLOR,
BUTTON_COLOR,
SELECTED_BORDER,
DEFAULT_BORDER,
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):
"""Dessine un octogone (polygone à 8 côtés)"""
# Coins d'un rectangle
left, top, right, bottom = x1, y1, x2, y2
width = right - left
height = bottom - top
cut = min(width, height) // 4 # Taille des coins coupés
# Points de l'octogone (sens horaire)
points = [
left + cut,
top, # haut-gauche (début coupe)
right - cut,
top, # haut-droit (début coupe)
right,
top + cut, # droite-haut (début coupe)
right,
bottom - cut, # droite-bas (début coupe)
right - cut,
bottom, # bas-droit (début coupe)
left + cut,
bottom, # bas-gauche (début coupe)
left,
bottom - cut, # gauche-bas (début coupe)
left,
top + cut, # gauche-haut (début coupe)
]
return canvas.create_polygon(points, **kwargs)
class MasterMindGUI:
"""Interface Tkinter pour MasterMind"""
def __init__(self):
self.root = tk.Tk()
self.root.title(WINDOW_TITLE)
self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
self.root.configure(bg=BG_COLOR)
# 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
# Style ttk
self.style = ttk.Style()
self.style.theme_use("clam")
# ICI: configuration de la taille de police des boutons
# Modifie 'font' pour changer la taille (9 par défaut, 8 pour plus petit)
self.style.configure(
"TButton", padding=3, background=BUTTON_COLOR, font=("Helvetica", 8)
)
self.style.configure("Title.TLabel", font=("Helvetica", 16, "bold"))
# Layout principal en grille
self.root.columnconfigure(1, weight=1) # Colonne principale extensible
self.root.rowconfigure(2, weight=1) # Ligne historique extensible
# Construction de l'interface
self._build_palette() # Colonne 0 (avec boutons de contrôle)
self._build_main_area() # Colonne 1
# Initialiser l'affichage
self._update_guess_display()
def _build_palette(self):
"""Palette verticale de boutons octogonaux (côté gauche) + boutons de contrôle"""
palette_frame = ttk.Frame(self.root, padding=10)
palette_frame.grid(row=0, column=0, rowspan=4, sticky="ns", padx=(10, 5))
ttk.Label(palette_frame, text="Couleurs", font=("Helvetica", 12, "bold")).pack(
pady=(0, 10)
)
self.palette_canvas = tk.Canvas(
palette_frame, width=70, height=350, bg=BG_COLOR, highlightthickness=0
)
self.palette_canvas.pack()
# Créer les octogones pour chaque couleur
self.color_buttons = []
button_size = 45
spacing = 55
start_y = 15
for idx, (name, color) in enumerate(COLORS):
x1 = 15
y1 = start_y + idx * spacing
x2 = x1 + button_size
y2 = y1 + button_size
# Octogone
oct_id = create_octagon(
self.palette_canvas,
x1,
y1,
x2,
y2,
fill=color,
outline="black",
width=2,
tags=f"color_{idx}",
)
self.palette_canvas.tag_bind(
oct_id, "<Button-1>", lambda e, c=idx: self._select_color(c)
)
# Texte (nom de la couleur)
text_id = self.palette_canvas.create_text(
(x1 + x2) / 2,
(y1 + y2) / 2,
text=name[:3].upper(),
font=("Helvetica", 8, "bold"),
fill="white" if self._is_dark(color) else "black",
tags=f"text_{idx}",
)
self.palette_canvas.tag_bind(
text_id, "<Button-1>", lambda e, c=idx: self._select_color(c)
)
self.color_buttons.append(oct_id)
# Boutons de contrôle sous la palette
control_frame = ttk.Frame(palette_frame)
control_frame.pack(pady=(20, 0))
self.validate_btn = ttk.Button(
control_frame, text="Valider", command=self._on_validate, width=8
)
self.validate_btn.pack(pady=3)
self.new_game_btn = ttk.Button(
control_frame, text="Rejouer", command=self._on_new_game, width=8
)
self.new_game_btn.pack(pady=3)
self.reveal_btn = ttk.Button(
control_frame, text="Solution", command=self._on_reveal, width=8
)
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)
main_frame.grid(row=0, column=1, rowspan=4, sticky="nsew", padx=(5, 10))
# Configuration de la grille interne
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(2, weight=1) # Historique extensible
# Titre
title = ttk.Label(main_frame, text="MASTERMIND", style="Title.TLabel")
title.grid(row=0, column=0, pady=(0, 10))
# Zone de sélection du guess (votre combinaison)
guess_frame = ttk.LabelFrame(main_frame, text="Votre combinaison", padding=10)
guess_frame.grid(row=1, column=0, pady=(0, 10), sticky="ew")
guess_frame.columnconfigure(tuple(range(4)), weight=1)
# Cercles cliquables pour le guess
self.guess_buttons = []
for i in range(4):
canvas = tk.Canvas(
guess_frame,
width=CIRCLE_DIAMETER + 4,
height=CIRCLE_DIAMETER + 4,
bg=BG_COLOR,
highlightthickness=0,
)
canvas.grid(row=0, column=i, padx=5, pady=5)
canvas.create_oval(
2,
2,
CIRCLE_DIAMETER + 2,
CIRCLE_DIAMETER + 2,
fill=COLORS[0][1],
outline=SELECTED_BORDER,
width=2,
tags=f"circle_{i}",
)
canvas.bind("<Button-1>", lambda e, idx=i: self._on_guess_circle_click(idx))
self.guess_buttons.append(canvas)
# Historique
history_frame = ttk.LabelFrame(main_frame, text="Historique", padding=5)
history_frame.grid(row=2, column=0, sticky="nsew", pady=(10, 0))
history_frame.columnconfigure(0, weight=1)
history_frame.rowconfigure(0, weight=1)
# Canvas scrollable pour l'historique
self.history_canvas = tk.Canvas(history_frame, bg=BG_COLOR, height=250)
self.history_canvas.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(
history_frame, orient=tk.VERTICAL, command=self.history_canvas.yview
)
scrollbar.grid(row=0, column=1, sticky="ns")
self.history_canvas.configure(yscrollcommand=scrollbar.set)
# Frame conteneur
self.history_frame = ttk.Frame(self.history_canvas)
self.history_canvas.create_window(
(0, 0), window=self.history_frame, anchor="nw"
)
self.history_frame.bind(
"<Configure>",
lambda e: self.history_canvas.configure(
scrollregion=self.history_canvas.bbox("all")
),
)
# Barre de statut (en bas de la fenêtre principale)
self.status_label = ttk.Label(
self.root, text="Prêt à jouer!", relief=tk.SUNKEN, anchor=tk.W
)
self.status_label.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(5, 0))
def _is_dark(self, hex_color: str) -> bool:
"""Détermine si une couleur hexadécimale est sombre (pour texte blanc/noir)"""
hex_color = hex_color.lstrip("#")
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
def _on_guess_circle_click(self, position: int):
"""Clic sur un cercle de guess -> change sa couleur"""
self.selected_colors[position] = (self.selected_colors[position] + 1) % len(
COLORS
)
self._update_guess_display()
def _select_color(self, color_idx: int):
"""Sélection d'une couleur dans la palette -> affecte toutes les positions"""
for i in range(4):
self.selected_colors[i] = color_idx
self._update_guess_display()
def _update_guess_display(self):
"""Met à jour l'affichage des cercles de guess"""
for i, color_idx in enumerate(self.selected_colors):
color_hex = COLORS[color_idx][1]
canvas = self.guess_buttons[i]
canvas.itemconfig(f"circle_{i}", fill=color_hex)
def _on_validate(self):
"""Valide le guess actuel"""
try:
won, feedback = self.game.make_guess(self.selected_colors.copy())
self._add_history_row(self.selected_colors.copy(), feedback, won)
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(secret)}"
)
self.validate_btn.config(state="disabled")
else:
# Reset selection
self.selected_colors = [0] * 4
self._update_guess_display()
except ValueError as e:
self.status_label.config(text=str(e))
def _on_new_game(self):
"""Nouvelle partie"""
self.game.reset()
self._clear_history()
self.selected_colors = [0] * 4
self._update_guess_display()
self.validate_btn.config(state="normal")
self.status_label.config(text="Nouvelle partie!")
def _on_reveal(self):
"""Révèle le code secret (triche)"""
secret = self.game.reveal_secret()
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 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
remaining = self.game.get_remaining_attempts()
if won:
msg = f"Bravo! Trouvé en {len(self.game.attempts)} tentatives!"
else:
msg = f"Feedback: {black} noir(s), {white} blanc(s) - {remaining} essais restants"
self.status_label.config(text=msg)
def _clear_history(self):
"""Efface l'historique visuel"""
for widget in self.history_frame.winfo_children():
widget.destroy()
def _add_history_row(self, guess: List[int], feedback: Tuple[int, int], won: bool):
"""Ajoute une ligne dans l'historique"""
row = len(self.game.attempts) - 1
frame = ttk.Frame(self.history_frame)
frame.grid(row=row, column=0, pady=5, sticky="ew")
# Numéro de tentative
ttk.Label(frame, text=f"#{row+1}", width=5).grid(row=0, column=0, padx=5)
# Cercles de couleurs du guess
guess_frame = ttk.Frame(frame)
guess_frame.grid(row=0, column=1, padx=10)
for i, color_idx in enumerate(guess):
canvas = tk.Canvas(
guess_frame,
width=CIRCLE_DIAMETER,
height=CIRCLE_DIAMETER,
bg=BG_COLOR,
highlightthickness=0,
)
canvas.grid(row=0, column=i, padx=2)
canvas.create_oval(
2,
2,
CIRCLE_DIAMETER - 2,
CIRCLE_DIAMETER - 2,
fill=COLORS[color_idx][1],
outline="black",
width=1,
)
# Feedback (petits cercles)
feedback_frame = ttk.Frame(frame)
feedback_frame.grid(row=0, column=2, padx=10)
black, white = feedback
# D'abord les noirs (ordre important)
for i in range(black):
self._draw_feedback_circle(feedback_frame, i, "black")
# Puis les blancs
for i in range(white):
self._draw_feedback_circle(feedback_frame, black + i, "white")
# Indicateur gagné
if won:
ttk.Label(
frame, text="✓", foreground="green", font=("Helvetica", 16, "bold")
).grid(row=0, column=3, padx=10)
def _draw_feedback_circle(self, parent: ttk.Frame, index: int, color: str):
"""Dessine un petit cercle de feedback (noir ou blanc)"""
canvas = tk.Canvas(
parent,
width=FEEDBACK_DIAMETER,
height=FEEDBACK_DIAMETER,
bg=BG_COLOR,
highlightthickness=0,
)
col = index % 3
row = index // 3
canvas.grid(row=row, column=col, padx=1, pady=1)
canvas.create_oval(
1,
1,
FEEDBACK_DIAMETER - 1,
FEEDBACK_DIAMETER - 1,
fill=color,
outline="gray",
width=1,
)
def run(self):
"""Lance la boucle principale de l'application"""
self.root.mainloop()