diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e24fdf9..318784a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,8 +8,8 @@ android { defaultConfig { applicationId = "legion.muyue.best2048" - minSdk = 33 - targetSdk = 35 + minSdk = 28 + targetSdk = 33 versionCode = 1 versionName = "1.0" @@ -18,7 +18,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -46,4 +47,5 @@ dependencies { implementation(libs.logging.interceptor) implementation(libs.gson) implementation(libs.okhttp) + implementation(libs.work.runtime) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b5e750c..b87f135 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,11 +31,6 @@ android:exported="false" android:screenOrientation="portrait" android:theme="@style/Theme.Best2048" /> - - \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/Game.java b/app/src/main/java/legion/muyue/best2048/Game.java index 3ada607..205a967 100644 --- a/app/src/main/java/legion/muyue/best2048/Game.java +++ b/app/src/main/java/legion/muyue/best2048/Game.java @@ -1,42 +1,65 @@ -/** - * Représente la logique métier du jeu 2048. - * Gère l'état du plateau de jeu, les déplacements des tuiles, les fusions, - * le score de la partie en cours, ainsi que les conditions de victoire ou de défaite. - * Cette classe est conçue pour être indépendante du framework Android, ne contenant - * aucune dépendance au Contexte Android ou aux SharedPreferences. - */ package legion.muyue.best2048; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; // Ajout pour le retour de deserialize +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import java.util.ArrayList; import java.util.List; import java.util.Random; +/** + * Représente la logique et l'état d'une partie du jeu 2048. + * Gère le plateau de jeu, le score, la génération de nouvelles tuiles, + * le traitement des mouvements du joueur, la détection des conditions de victoire et de fin de partie, + * ainsi que la sérialisation et la désérialisation de base de l'état du jeu. + * La taille du plateau est définie par {@link #BOARD_SIZE}. + */ public class Game { - /** La taille du plateau de jeu (nombre de lignes/colonnes, typiquement 4x4). */ + /** + * La taille (nombre de lignes et de colonnes) du plateau de jeu. + * Fixé à 4 pour un jeu 2048 standard. + */ private static final int BOARD_SIZE = 4; - /** Le plateau de jeu, une matrice 2D d'entiers. 0 représente une case vide. */ + /** + * Le plateau de jeu, représenté par une grille 2D d'entiers. + * Chaque entier correspond à la valeur d'une tuile (0 pour une case vide). + */ private int[][] board; - /** Générateur de nombres aléatoires pour l'ajout de nouvelles tuiles. */ + + /** + * Générateur de nombres aléatoires utilisé pour placer de nouvelles tuiles + * et déterminer leur valeur initiale (2, 4, 8, etc.). + */ private final Random randomNumberGenerator; - /** Score de la partie actuellement en cours. */ + + /** + * Le score actuel de la partie en cours. Augmente lors de la fusion de tuiles. + */ private int currentScore = 0; - /** Meilleur score global connu par cette instance (généralement défini depuis l'extérieur). */ + + /** + * Le meilleur score enregistré. Peut être chargé ou défini de l'extérieur. + * Note : La persistance de ce score n'est pas gérée par cette classe. + */ private int highestScore = 0; - /** Indicateur si la condition de victoire (une tuile >= 2048) a été atteinte. */ + + /** + * Indicateur de victoire. Passe à {@code true} lorsqu'une tuile >= 2048 est créée. + * Le jeu peut continuer après la victoire. + */ private boolean gameWon = false; - /** Indicateur si la partie est terminée (plus de mouvements ou de fusions possibles). */ + + /** + * Indicateur de fin de partie. Passe à {@code true} lorsqu'aucun mouvement valide n'est plus possible. + */ private boolean gameOver = false; /** - * Constructeur pour démarrer une nouvelle partie. - * Initialise un plateau vide, met le score courant à 0, réinitialise les états - * de victoire/défaite, et ajoute deux tuiles initiales aléatoirement. + * Constructeur par défaut pour démarrer une nouvelle partie. + * Initialise un plateau vide, met le score à zéro, et ajoute deux tuiles initiales aléatoires. */ public Game() { this.randomNumberGenerator = new Random(); @@ -44,58 +67,54 @@ public class Game { } /** - * Constructeur pour restaurer une partie à partir d'un état sauvegardé. - * Le meilleur score (`highestScore`) doit être défini séparément via {@link #setHighestScore(int)}. - * Recalcule les états `gameWon` et `gameOver` en fonction du plateau et du score fournis. - * Lance une exception si le plateau fourni n'a pas les bonnes dimensions. + * Constructeur pour créer une instance de jeu à partir d'un état existant. + * Utile pour charger une partie sauvegardée ou pour les tests. + * Vérifie si les conditions de victoire ou de fin de partie sont déjà remplies. * - * @param board Le plateau de jeu restauré. Doit être de dimensions BOARD_SIZE x BOARD_SIZE. - * @param score Le score courant restauré. - * @throws IllegalArgumentException si les dimensions du plateau fourni sont incorrectes. + * @param board Le plateau de jeu existant. Doit être non null et de taille {@code BOARD_SIZE}x{@code BOARD_SIZE}. + * @param score Le score associé à l'état du plateau fourni. + * @throws IllegalArgumentException si le plateau fourni est null ou n'a pas les dimensions correctes. */ public Game(@NonNull int[][] board, int score) { - // Validation des dimensions du plateau if (board == null || board.length != BOARD_SIZE || board[0].length != BOARD_SIZE) { throw new IllegalArgumentException("Le plateau fourni n'a pas les dimensions attendues (" + BOARD_SIZE + "x" + BOARD_SIZE + ")."); } - this.board = board; // Attention: shallow copy si board est modifié ailleurs après coup. getBoard() est plus sûr. - // Pour la restauration, on suppose que le tableau fourni est destiné à cet usage unique. + // Crée une copie défensive du tableau pour éviter les modifications externes + this.board = new int[BOARD_SIZE][BOARD_SIZE]; + for (int i = 0; i < BOARD_SIZE; i++) { + System.arraycopy(board[i], 0, this.board[i], 0, BOARD_SIZE); + } this.currentScore = score; this.randomNumberGenerator = new Random(); - // Recalculer l'état de victoire/défaite basé sur le plateau chargé + // Recalcule l'état win/over basé sur le plateau fourni checkWinCondition(); - // Vérifier si la partie chargée est déjà terminée - checkGameOverCondition(); // Important si on charge une partie déjà terminée + checkGameOverCondition(); } - // --- Getters / Setters --- - /** - * Retourne la valeur de la tuile aux coordonnées spécifiées (ligne, colonne). - * Les indices sont basés sur 0. + * Récupère la valeur de la tuile à la position spécifiée. * - * @param row Ligne de la cellule (0 à BOARD_SIZE-1). - * @param column Colonne de la cellule (0 à BOARD_SIZE-1). - * @return La valeur de la tuile, ou 0 si la cellule est vide ou les coordonnées sont invalides. + * @param row L'indice de la ligne (0 à BOARD_SIZE - 1). + * @param column L'indice de la colonne (0 à BOARD_SIZE - 1). + * @return La valeur de la tuile à la position (row, column), ou 0 si les indices sont invalides ou la case est vide. */ public int getCellValue(int row, int column) { if (isIndexValid(row, column)) { return this.board[row][column]; } - // Log ou gestion d'erreur pourrait être ajouté si un accès invalide est critique - return 0; // Retourne 0 pour indice invalide comme convention + return 0; // Retourne 0 si l'index est hors limites } /** - * Définit la valeur d'une tuile aux coordonnées spécifiées. - * Utilisé principalement en interne ou pour les tests. Ne fait rien si - * les coordonnées (ligne, colonne) sont en dehors des limites du plateau. + * Définit la valeur d'une cellule spécifique sur le plateau. + * Principalement destiné aux tests unitaires pour configurer des scénarios spécifiques. + * Utiliser avec prudence car cela modifie directement l'état interne du jeu. * - * @param row Ligne de la cellule (0 à BOARD_SIZE-1). - * @param col Colonne de la cellule (0 à BOARD_SIZE-1). - * @param value Nouvelle valeur entière pour la tuile. + * @param row L'indice de la ligne (0 à BOARD_SIZE - 1). + * @param col L'indice de la colonne (0 à BOARD_SIZE - 1). + * @param value La nouvelle valeur pour la cellule. */ - @VisibleForTesting // Indique que cette méthode est surtout pour les tests + @VisibleForTesting void setCellValue(int row, int col, int value) { if (isIndexValid(row, col)) { this.board[row][col] = value; @@ -103,135 +122,121 @@ public class Game { } /** - * Retourne le score actuel de la partie en cours. - * Le score augmente lors de la fusion de tuiles. + * Récupère le score actuel de la partie. * - * @return Le score entier actuel. + * @return Le score actuel. */ public int getCurrentScore() { return currentScore; } /** - * Retourne le meilleur score connu par cette instance de jeu. - * Cette valeur est généralement chargée depuis une sauvegarde externe et mise à jour via {@link #setHighestScore(int)}. + * Récupère le meilleur score enregistré (peut nécessiter une gestion externe). * - * @return Le meilleur score entier connu. + * @return Le meilleur score connu. */ public int getHighestScore() { return highestScore; } /** - * Met à jour la valeur du meilleur score stockée dans cet objet Game. - * Cette méthode est typiquement appelée par la classe gérant la persistance - * (par exemple, MainActivity) après avoir chargé le meilleur score global. + * Définit le meilleur score. Utile pour charger un meilleur score sauvegardé. * - * @param highScore Le nouveau meilleur score global à stocker dans cette instance. + * @param highScore Le meilleur score à définir. */ public void setHighestScore(int highScore) { this.highestScore = highScore; } /** - * Vérifie si la condition de victoire a été atteinte (au moins une tuile avec une valeur >= 2048). + * Vérifie si la condition de victoire (atteindre une tuile >= 2048) a été remplie. * - * @return true si le jeu est considéré comme gagné, false sinon. + * @return {@code true} si le jeu est gagné, {@code false} sinon. */ public boolean isGameWon() { return gameWon; } /** - * Vérifie si la partie est terminée. - * La partie est terminée si aucune case n'est vide ET aucun mouvement ou fusion n'est possible - * dans aucune des quatre directions. + * Vérifie si la condition de fin de partie (aucun mouvement possible) a été remplie. * - * @return true si la partie est terminée (game over), false sinon. + * @return {@code true} si le jeu est terminé, {@code false} sinon. */ public boolean isGameOver() { return gameOver; } /** - * Met à jour l'indicateur interne de victoire du jeu. - * Utilisé après une fusion ou lors de la restauration d'une partie. - * - * @param won true si la condition de victoire est atteinte, false sinon. + * Définit l'état de victoire du jeu. Méthode privée utilisée en interne. + * @param won Nouvel état de victoire. */ private void setGameWon(boolean won) { this.gameWon = won; } /** - * Met à jour l'indicateur interne de fin de partie (game over). - * Utilisé après une tentative de mouvement infructueuse ou lors de la restauration. - * - * @param over true si la condition de fin de partie est atteinte, false sinon. + * Définit l'état de fin de partie du jeu. Méthode privée utilisée en interne. + * @param over Nouvel état de fin de partie. */ private void setGameOver(boolean over) { this.gameOver = over; } /** - * Retourne une copie profonde (deep copy) du plateau de jeu actuel. - * Ceci est utile pour obtenir l'état du plateau sans risquer de le modifier - * accidentellement de l'extérieur, ou pour la sérialisation. + * Retourne une copie du plateau de jeu actuel. + * Utiliser cette méthode pour obtenir l'état du plateau sans risquer de + * modifier l'état interne du jeu par inadvertance. * - * @return Une nouvelle matrice 2D (`int[BOARD_SIZE][BOARD_SIZE]`) représentant l'état actuel du plateau. + * @return Une copie 2D du tableau {@code board}. Ne sera jamais null. */ @NonNull public int[][] getBoard() { int[][] copy = new int[BOARD_SIZE][BOARD_SIZE]; for (int i = 0; i < BOARD_SIZE; i++) { - // System.arraycopy est efficace pour copier des tableaux primitifs + // Utilise arraycopy pour une copie efficace de chaque ligne System.arraycopy(this.board[i], 0, copy[i], 0, BOARD_SIZE); } return copy; } /** - * (Ré)Initialise le plateau de jeu pour une nouvelle partie. - * Crée une nouvelle matrice vide (remplie de 0), réinitialise le score courant - * et les indicateurs `gameWon`/`gameOver`, puis appelle {@link #addNewTile()} - * deux fois pour placer les deux premières tuiles aléatoires. + * Initialise ou réinitialise le plateau pour une nouvelle partie. + * Met toutes les cellules à 0, réinitialise le score et les indicateurs + * de victoire/fin de partie, puis ajoute deux nouvelles tuiles. */ private void initializeNewBoard() { - this.board = new int[BOARD_SIZE][BOARD_SIZE]; // Crée une nouvelle matrice remplie de zéros par défaut + this.board = new int[BOARD_SIZE][BOARD_SIZE]; // Crée un nouveau tableau vide this.currentScore = 0; this.gameWon = false; this.gameOver = false; - // Ajoute les deux premières tuiles requises pour démarrer une partie - addNewTile(); - addNewTile(); + addNewTile(); // Ajoute la première tuile + addNewTile(); // Ajoute la deuxième tuile } - // --- Logique du Jeu --- - /** - * Ajoute une nouvelle tuile (2, 4, 8, etc., selon les probabilités définies - * dans {@link #generateRandomTileValue()}) sur une case vide choisie aléatoirement. - * Si le plateau est plein (aucune case vide), cette méthode ne fait rien. + * Ajoute une nouvelle tuile aléatoire (2, 4, 8, ...) sur une case vide aléatoire du plateau. + * Si aucune case n'est vide, cette méthode n'a aucun effet. + * La valeur de la nouvelle tuile est déterminée par {@link #generateRandomTileValue()}. */ public void addNewTile() { List emptyCells = findEmptyCells(); if (!emptyCells.isEmpty()) { - // Choisit une cellule vide au hasard parmi celles disponibles + // Choisit une cellule vide au hasard int[] randomCell = emptyCells.get(randomNumberGenerator.nextInt(emptyCells.size())); + // Génère la valeur de la nouvelle tuile (2, 4, 8, ...) int value = generateRandomTileValue(); - // Place la nouvelle tuile sur le plateau logique - // Utilise setCellValue pour la cohérence, même si l'accès direct serait possible ici + // Place la nouvelle tuile sur le plateau setCellValue(randomCell[0], randomCell[1], value); } - // Si emptyCells est vide, le plateau est plein, on ne peut rien ajouter. - // La condition de Game Over sera vérifiée après la tentative de mouvement. + // Après ajout, revérifie si le jeu est terminé (au cas où l'ajout bloquerait tout) + checkGameOverCondition(); } /** - * Recherche et retourne les coordonnées de toutes les cellules vides (valeur 0) sur le plateau. + * Trouve toutes les cellules vides (valeur 0) sur le plateau. * - * @return Une {@link List} de tableaux d'entiers `[row, col]` pour chaque cellule vide trouvée. - * Retourne une liste vide si le plateau est plein. + * @return Une liste de tableaux d'entiers {@code [row, col]}, où chaque tableau représente + * les coordonnées d'une cellule vide. Retourne une liste vide s'il n'y a pas de cellules vides. Ne sera jamais null. */ @NonNull private List findEmptyCells() { @@ -247,205 +252,193 @@ public class Game { } /** - * Génère la valeur pour une nouvelle tuile (2, 4, 8, ...) en utilisant une distribution - * de probabilités prédéfinie. Les probabilités sont ajustées pour rendre l'apparition - * des tuiles de faible valeur plus fréquente. + * Génère la valeur pour une nouvelle tuile ajoutée au plateau. + * Cette implémentation spécifique a des probabilités pour 2, 4, 8, 16, 32, 64, 128, 256. + * ~85.4% de chance pour 2, ~12% pour 4, ~2% pour 8, ~0.5% pour 16, etc. * - * Probabilités actuelles (approximatives) : - * - 2: 85.40% - * - 4: 12.00% - * - 8: 2.00% - * - 16: 0.50% - * - 32: 0.05% - * - 64: 0.03% - * - 128: 0.01% - * - 256: 0.01% - * - * @return La valeur entière (2, 4, 8, ...) de la tuile générée. + * @return La valeur de la nouvelle tuile (2, 4, 8, ... , 256). */ private int generateRandomTileValue() { - int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour une granularité fine des pourcentages - - // Les seuils définissent les probabilités cumulées - if (randomValue < 8540) return 2; // 0 <= randomValue < 8540 (85.40%) - if (randomValue < 9740) return 4; // 8540 <= randomValue < 9740 (12.00%) - if (randomValue < 9940) return 8; // 9740 <= randomValue < 9940 (2.00%) - if (randomValue < 9990) return 16; // 9940 <= randomValue < 9990 (0.50%) - if (randomValue < 9995) return 32; // 9990 <= randomValue < 9995 (0.05%) - if (randomValue < 9998) return 64; // 9995 <= randomValue < 9998 (0.03%) - if (randomValue < 9999) return 128; // 9998 <= randomValue < 9999 (0.01%) - return 256; // 9999 <= randomValue < 10000 (0.01%) + // Note: Les probabilités ici diffèrent du 2048 standard (90% 2, 10% 4). + int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour les pourcentages + if (randomValue < 8540) return 2; // ~85.4% + if (randomValue < 9740) return 4; // ~12.0% (9740 - 8540) + if (randomValue < 9940) return 8; // ~ 2.0% (9940 - 9740) + if (randomValue < 9990) return 16; // ~ 0.5% (9990 - 9940) + if (randomValue < 9995) return 32; // ~0.05% (9995 - 9990) + if (randomValue < 9998) return 64; // ~0.03% (9998 - 9995) + if (randomValue < 9999) return 128;// ~0.01% (9999 - 9998) + return 256; // ~0.01% } /** - * Tente de déplacer et fusionner les tuiles vers le HAUT. - * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * Tente d'effectuer un mouvement vers le haut. + * Déplace et fusionne les tuiles vers le haut. * - * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. + * @return {@code true} si le plateau a été modifié par le mouvement, {@code false} sinon. */ public boolean pushUp() { return processMove(MoveDirection.UP); } /** - * Tente de déplacer et fusionner les tuiles vers le BAS. - * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * Tente d'effectuer un mouvement vers le bas. + * Déplace et fusionne les tuiles vers le bas. * - * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. + * @return {@code true} si le plateau a été modifié par le mouvement, {@code false} sinon. */ public boolean pushDown() { return processMove(MoveDirection.DOWN); } /** - * Tente de déplacer et fusionner les tuiles vers la GAUCHE. - * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * Tente d'effectuer un mouvement vers la gauche. + * Déplace et fusionne les tuiles vers la gauche. * - * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. + * @return {@code true} si le plateau a été modifié par le mouvement, {@code false} sinon. */ public boolean pushLeft() { return processMove(MoveDirection.LEFT); } /** - * Tente de déplacer et fusionner les tuiles vers la DROITE. - * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite. + * Tente d'effectuer un mouvement vers la droite. + * Déplace et fusionne les tuiles vers la droite. * - * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé. + * @return {@code true} si le plateau a été modifié par le mouvement, {@code false} sinon. */ public boolean pushRight() { return processMove(MoveDirection.RIGHT); } - /** Énumération interne pour clarifier la direction du mouvement dans {@link #processMove}. */ + /** + * Énumération interne représentant les quatre directions de mouvement possibles. + */ private enum MoveDirection { UP, DOWN, LEFT, RIGHT } /** - * Méthode générique pour traiter un mouvement (déplacement et fusion) dans une direction donnée. - * C'est le cœur de la logique de déplacement du jeu. Elle parcourt le plateau, déplace - * les tuiles jusqu'au bout dans la direction demandée, puis gère les fusions éventuelles. + * Traite un mouvement dans la direction spécifiée. + * Parcourt le plateau ligne par ligne ou colonne par colonne, déplace les tuiles, + * effectue les fusions, met à jour le score, et vérifie les conditions de victoire/fin de partie. * - * @param direction La direction du mouvement ({@link MoveDirection}). - * @return true si le plateau a été modifié (au moins un déplacement ou une fusion), false sinon. + * @param direction La direction {@link MoveDirection} du mouvement à traiter. + * @return {@code true} si au moins une tuile a bougé ou fusionné, {@code false} sinon. */ private boolean processMove(MoveDirection direction) { - boolean boardChanged = false; + boolean boardChanged = false; // Indicateur si le plateau a été modifié - // Itérer sur l'axe perpendiculaire au mouvement (colonnes pour UP/DOWN, lignes pour LEFT/RIGHT) + // Itère sur les lignes (pour GAUCHE/DROITE) ou les colonnes (pour HAUT/BAS) for (int i = 0; i < BOARD_SIZE; i++) { - // Tableau pour suivre si une tuile sur la ligne/colonne cible a déjà résulté d'une fusion - // pendant CE mouvement, pour éviter les fusions en chaîne (ex: 2-2-4 -> 4-4 -> 8 en un seul swipe) + // Tableau pour suivre si une cellule de la ligne/colonne cible a déjà fusionné dans ce mouvement boolean[] hasMerged = new boolean[BOARD_SIZE]; - // Itérer sur l'axe du mouvement. Le sens de parcours est crucial : - // - Pour UP/LEFT: du début vers la fin (index 1 à N-1) car les tuiles fusionnent vers les petits indices. - // - Pour DOWN/RIGHT: de la fin vers le début (index N-2 à 0) car les tuiles fusionnent vers les grands indices. + // Détermine l'ordre de parcours des cellules dans la ligne/colonne + // Pour HAUT et GAUCHE : on part du début (index 1) vers la fin + // Pour BAS et DROITE : on part de l'avant-dernière (index BOARD_SIZE - 2) vers le début int start = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? BOARD_SIZE - 2 : 1; int end = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : BOARD_SIZE; int step = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : 1; + // Parcourt les cellules de la ligne/colonne for (int j = start; j != end; j += step) { - int row, col; - // Déterminer les coordonnées (row, col) de la tuile courante basée sur l'axe i et l'index j + int row, col; // Coordonnées de la cellule actuelle (j) if (direction == MoveDirection.UP || direction == MoveDirection.DOWN) { - row = j; col = i; // Mouvement vertical, i est la colonne, j est la ligne + // Pour HAUT/BAS, 'i' est la colonne, 'j' est la ligne + row = j; col = i; } else { - row = i; col = j; // Mouvement horizontal, i est la ligne, j est la colonne + // Pour GAUCHE/DROITE, 'i' est la ligne, 'j' est la colonne + row = i; col = j; } - int currentValue = getCellValue(row, col); + int currentValue = getCellValue(row, col); // Valeur de la tuile actuelle - // Si la case n'est pas vide, on essaie de la déplacer/fusionner + // Si la cellule n'est pas vide if (currentValue != 0) { - int currentRow = row; + int currentRow = row; // Position initiale de la tuile int currentCol = col; - int targetRow = currentRow; // Position cible initiale + int targetRow = currentRow; // Position cible après déplacement (sans fusion) int targetCol = currentCol; - // 1. Déplacement : Trouver la case la plus éloignée atteignable dans la direction du mouvement + // Calcule la position de la case voisine dans la direction du mouvement int nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); int nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); - // Tant que la case suivante est valide et vide, on continue de "glisser" + // Tant que la case voisine est valide et vide, on déplace la cible while (isIndexValid(nextRow, nextCol) && getCellValue(nextRow, nextCol) == 0) { targetRow = nextRow; targetCol = nextCol; - // Calculer la case suivante pour la prochaine itération de la boucle while + // Calcule la case suivante pour continuer le déplacement nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0); nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0); } - // 2. Fusion : Vérifier la case immédiatement après la position cible (targetRow, targetCol) - // La case à vérifier pour fusion est `nextRow`, `nextCol` (calculée juste avant ou après la sortie du while) - int mergeTargetRow = nextRow; + // nextRow/nextCol contient maintenant la première case non vide rencontrée ou une case invalide + int mergeTargetRow = nextRow; // Case potentielle pour la fusion int mergeTargetCol = nextCol; - // Index utilisé pour le tableau `hasMerged` (soit la ligne soit la colonne cible de la fusion) + // Index utilisé pour vérifier si la case cible a déjà fusionné int mergeIndex = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? mergeTargetRow : mergeTargetCol; - boolean merged = false; - // Vérifier si la case de fusion potentielle est valide, a la même valeur, et n'a pas déjà fusionné - if (isIndexValid(mergeTargetRow, mergeTargetCol) && - getCellValue(mergeTargetRow, mergeTargetCol) == currentValue && - !hasMerged[mergeIndex]) // Crucial: empêche double fusion + boolean merged = false; // Indicateur si une fusion a eu lieu pour cette tuile + // Vérifie si une fusion est possible + if (isIndexValid(mergeTargetRow, mergeTargetCol) && // La case cible est valide + getCellValue(mergeTargetRow, mergeTargetCol) == currentValue && // Elle a la même valeur + !hasMerged[mergeIndex]) // Elle n'a pas déjà fusionné ce tour-ci { - // Fusion ! - int newValue = currentValue * 2; - setCellValue(mergeTargetRow, mergeTargetCol, newValue); // Met à jour la case cible de la fusion - setCellValue(currentRow, currentCol, 0); // Vide la case d'origine de la tuile qui a bougé ET fusionné - currentScore += newValue; // Ajoute la *nouvelle* valeur au score - hasMerged[mergeIndex] = true; // Marque la case cible comme ayant fusionné - boardChanged = true; // Le plateau a changé - merged = true; + // --- Fusion --- + int newValue = currentValue * 2; // Nouvelle valeur après fusion + setCellValue(mergeTargetRow, mergeTargetCol, newValue); // Met à jour la case cible + setCellValue(currentRow, currentCol, 0); // Vide la case d'origine + currentScore += newValue; // Augmente le score + hasMerged[mergeIndex] = true; // Marque la case cible comme ayant fusionné + boardChanged = true; // Le plateau a changé + merged = true; // La tuile a fusionné - // Vérifier immédiatement si cette fusion a créé une tuile >= 2048 - if (newValue >= 2048) { + // Vérifie la condition de victoire après la fusion + if (newValue >= 2048) { // Utiliser une constante WINNING_TILE serait mieux setGameWon(true); } } - // 3. Déplacement final (si pas de fusion OU si la tuile a bougé avant de potentiellement fusionner) - // Si la tuile n'a pas fusionné (elle reste où elle est ou se déplace seulement) - // ET que sa position cible (targetRow, targetCol) est différente de sa position initiale + // Si aucune fusion n'a eu lieu mais que la tuile doit bouger if (!merged && (targetRow != currentRow || targetCol != currentCol)) { + // --- Déplacement simple --- setCellValue(targetRow, targetCol, currentValue); // Déplace la valeur vers la case cible - setCellValue(currentRow, currentCol, 0); // Vide la case d'origine - boardChanged = true; // Le plateau a changé + setCellValue(currentRow, currentCol, 0); // Vide la case d'origine + boardChanged = true; // Le plateau a changé } } // Fin if (currentValue != 0) - } // Fin boucle interne (j) - } // Fin boucle externe (i) + } // Fin boucle for interne (j) + } // Fin boucle for externe (i) - // Après avoir traité toutes les lignes/colonnes, vérifier l'état global - // Note: checkWinCondition est déjà appelé lors d'une fusion >= 2048, mais on le refait ici par sécurité. - checkWinCondition(); // Vérifie si 2048 a été atteint (peut être déjà fait lors d'une fusion) - checkGameOverCondition(); // Vérifie si plus aucun mouvement n'est possible + // Après avoir traité toutes les lignes/colonnes, revérifie les conditions + // (la condition de victoire peut avoir été atteinte pendant la fusion) + checkWinCondition(); + // La condition de fin de partie doit être vérifiée après le mouvement + checkGameOverCondition(); + // Retourne si le plateau a été modifié return boardChanged; } - /** - * Vérifie si les indices de ligne et colonne fournis sont valides pour le plateau de jeu actuel. + * Vérifie si les coordonnées fournies (ligne, colonne) sont valides + * par rapport à la taille du plateau ({@link #BOARD_SIZE}). * - * @param row L'indice de ligne à vérifier. - * @param col L'indice de colonne à vérifier. - * @return true si 0 <= row < BOARD_SIZE et 0 <= col < BOARD_SIZE, false sinon. + * @param row L'indice de la ligne à vérifier. + * @param col L'indice de la colonne à vérifier. + * @return {@code true} si 0 <= row < BOARD_SIZE et 0 <= col < BOARD_SIZE, {@code false} sinon. */ private boolean isIndexValid(int row, int col) { return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; } /** - * Sérialise l'état essentiel du jeu (plateau et score courant) en une chaîne de caractères. - * Le format actuel est simple : toutes les valeurs du plateau séparées par des virgules, - * suivies par le score courant, également séparé par une virgule. - * Exemple pour un plateau 2x2 : "2,0,4,8,12" (où 12 est le score). - *

- * NOTE : Ce format est simple mais peut être fragile si la structure du jeu - * ou les données à sauvegarder évoluent. Un format comme JSON pourrait offrir plus de flexibilité. - *

+ * Fournit une représentation textuelle simple de l'état du jeu (plateau + score). + * Le format est une chaîne de valeurs séparées par des virgules : + * toutes les valeurs du plateau (ligne par ligne), suivies du score actuel. + * Utilisé pour la sérialisation simple via {@link #deserialize(String)}. * - * @return Une chaîne non nulle représentant l'état sérialisé du jeu. + * @return Une chaîne représentant l'état du jeu. Ne sera jamais null. */ @NonNull @Override @@ -456,149 +449,139 @@ public class Game { sb.append(board[row][col]).append(","); } } - // Ajoute le score à la fin, séparé par une virgule + // Ajoute le score à la fin, sans virgule après sb.append(currentScore); return sb.toString(); } /** - * Crée (désérialise) un objet {@code Game} à partir de sa représentation sous forme de chaîne, - * telle que générée par {@link #toString()}. - * Le meilleur score (`highestScore`) n'est pas inclus dans la chaîne et doit être - * défini séparément sur l'objet retourné via {@link #setHighestScore(int)}. - *

- * NOTE : Cette méthode dépend du format spécifique généré par {@code toString()}. - *

+ * Crée une instance de {@link Game} à partir d'une chaîne sérialisée. + * La chaîne doit correspondre au format généré par {@link #toString()}. * - * @param serializedState La chaîne représentant l'état du jeu (plateau + score courant). - * @return Une nouvelle instance de {@code Game} si la désérialisation réussit, - * ou {@code null} si la chaîne est invalide, vide, ou a un format incorrect - * (mauvais nombre d'éléments, valeur non entière). + * @param serializedState La chaîne contenant l'état du jeu sérialisé. Peut être null ou vide. + * @return Une nouvelle instance de {@link Game} initialisée avec l'état désérialisé, + * ou {@code null} si la chaîne est invalide, vide, null, ou ne correspond pas au format attendu. */ @Nullable public static Game deserialize(@Nullable String serializedState) { if (serializedState == null || serializedState.isEmpty()) { - return null; + return null; // Chaîne vide ou nulle } String[] values = serializedState.split(","); - // Vérifie si le nombre d'éléments correspond à la taille du plateau + 1 (pour le score) + // Vérifie si le nombre de valeurs correspond à (taille*taille + 1 score) if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) { - return null; + return null; // Nombre incorrect de valeurs } int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; int index = 0; try { - // Remplit le plateau à partir des valeurs de la chaîne + // Remplit le nouveau plateau for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { newBoard[row][col] = Integer.parseInt(values[index++]); } } - // Le dernier élément est le score - int score = Integer.parseInt(values[index]); - - // Crée une nouvelle instance de Game avec les données désérialisées - // Le constructeur recalculera gameWon et gameOver. + // Récupère le score + int score = Integer.parseInt(values[index]); // La dernière valeur est le score + // Crée et retourne le nouvel objet Game en utilisant le constructeur approprié return new Game(newBoard, score); - } catch (ArrayIndexOutOfBoundsException | IllegalArgumentException e) { - // En cas d'erreur de format (valeur non entière, problème d'indice, ou dimension plateau incorrecte dans constructeur) - // Log l'erreur pourrait être utile pour le débogage - System.err.println("Erreur lors de la désérialisation de l'état du jeu: " + e.getMessage()); - return null; + return null; // Échec de la désérialisation } } /** - * Vérifie si la condition de victoire (au moins une tuile avec une valeur >= 2048) - * est actuellement atteinte sur le plateau. Met à jour l'état interne `gameWon`. - * Cette vérification s'arrête dès qu'une tuile gagnante est trouvée ou si le jeu - * est déjà marqué comme gagné. + * Vérifie si la condition de victoire (une tuile >= 2048) est atteinte. + * Met à jour le flag {@link #gameWon} si nécessaire. + * Ne fait rien si le jeu est déjà marqué comme gagné. */ private void checkWinCondition() { - // Si le jeu est déjà marqué comme gagné, pas besoin de revérifier + // Si déjà gagné, pas besoin de revérifier if (gameWon) { return; } - // Parcours le plateau à la recherche d'une tuile >= 2048 + // Parcourt le plateau à la recherche d'une tuile >= 2048 for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { - if (getCellValue(r, c) >= 2048) { - setGameWon(true); // Met à jour l'état interne - return; // Sort dès qu'une condition de victoire est trouvée + // Utiliser getCellValue pour la cohérence (bien que l'accès direct soit possible ici) + if (getCellValue(r, c) >= 2048) { // Utiliser une constante WINNING_TILE = 2048 + setGameWon(true); + // Pas besoin de continuer à chercher une fois la condition atteinte + return; } } } - // Si la boucle se termine sans trouver de tuile >= 2048, gameWon reste false (ou son état précédent) + // Si on arrive ici, aucune tuile >= 2048 n'a été trouvée } /** - * Vérifie si la condition de fin de partie (Game Over) est atteinte. - * Cela se produit si le plateau est plein (pas de case vide) ET si aucune - * fusion n'est possible entre des tuiles adjacentes (horizontalement ou verticalement). - * Met à jour l'état interne `gameOver`. + * Vérifie si la condition de fin de partie est atteinte (plus aucun mouvement possible). + * Un jeu est terminé s'il n'y a plus de cellules vides ET aucune paire de tuiles + * adjacentes identiques (horizontalement ou verticalement). + * Met à jour le flag {@link #gameOver} si nécessaire. + * Ne fait rien si le jeu est déjà marqué comme terminé. */ private void checkGameOverCondition() { - // Si le jeu est déjà terminé, pas besoin de revérifier + // Si déjà terminé, pas besoin de revérifier if (gameOver) { return; } - // Si il y a au moins une case vide, le jeu ne peut pas être terminé + + // S'il y a au moins une cellule vide, le jeu n'est pas terminé if (hasEmptyCell()) { - setGameOver(false); + setGameOver(false); // Assure que le flag est bien à false return; } - // Le plateau est plein. Vérifier s'il existe au moins une fusion possible. + // S'il n'y a pas de cellules vides, vérifie les fusions possibles + // Parcourt toutes les cellules for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { int current = getCellValue(r, c); - // Vérifie le voisin du HAUT (si existe) + // Vérifie la fusion possible vers le haut (si pas sur la première ligne) if (r > 0 && getCellValue(r - 1, c) == current) { - setGameOver(false); return; // Fusion possible vers le haut + setGameOver(false); return; // Mouvement possible trouvé } - // Vérifie le voisin du BAS (si existe) + // Vérifie la fusion possible vers le bas (si pas sur la dernière ligne) if (r < BOARD_SIZE - 1 && getCellValue(r + 1, c) == current) { - setGameOver(false); return; // Fusion possible vers le bas + setGameOver(false); return; // Mouvement possible trouvé } - // Vérifie le voisin de GAUCHE (si existe) + // Vérifie la fusion possible vers la gauche (si pas sur la première colonne) if (c > 0 && getCellValue(r, c - 1) == current) { - setGameOver(false); return; // Fusion possible vers la gauche + setGameOver(false); return; // Mouvement possible trouvé } - // Vérifie le voisin de DROITE (si existe) + // Vérifie la fusion possible vers la droite (si pas sur la dernière colonne) if (c < BOARD_SIZE - 1 && getCellValue(r, c + 1) == current) { - setGameOver(false); return; // Fusion possible vers la droite + setGameOver(false); return; // Mouvement possible trouvé } } } - // Si on arrive ici, le plateau est plein ET aucune fusion adjacente n'est possible. + // Si on arrive ici, il n'y a pas de cellules vides ET aucune fusion possible setGameOver(true); } /** - * Vérifie rapidement si le plateau contient au moins une case vide (valeur 0). - * Utilisé principalement par {@link #checkGameOverCondition()}. + * Vérifie rapidement s'il existe au moins une cellule vide sur le plateau. * - * @return true s'il existe au moins une case vide, false si le plateau est plein. + * @return {@code true} s'il y a au moins une cellule avec la valeur 0, {@code false} sinon. */ private boolean hasEmptyCell() { for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (getCellValue(r, c) == 0) { - return true; // Sort dès qu'une case vide est trouvée + return true; // Trouvé une cellule vide } } } - return false; // Aucune case vide trouvée après avoir parcouru tout le plateau + return false; // Aucune cellule vide trouvée } /** - * Retourne la valeur de la plus haute tuile (la plus grande valeur numérique) - * actuellement présente sur le plateau de jeu. + * Calcule et retourne la valeur de la plus haute tuile actuellement sur le plateau. * - * @return La valeur maximale trouvée sur le plateau, ou 0 si le plateau est vide. + * @return La valeur maximale parmi toutes les tuiles du plateau. Retourne 0 si le plateau est vide. */ public int getHighestTileValue() { int maxTile = 0; diff --git a/app/src/main/java/legion/muyue/best2048/GameStats.java b/app/src/main/java/legion/muyue/best2048/GameStats.java index 843e251..4363d54 100644 --- a/app/src/main/java/legion/muyue/best2048/GameStats.java +++ b/app/src/main/java/legion/muyue/best2048/GameStats.java @@ -1,9 +1,3 @@ -/** - * Gère la collecte, la persistance (via {@link SharedPreferences}) et l'accès - * aux statistiques du jeu 2048. - * Inclut des statistiques générales, solo, et des placeholders pour le multijoueur. - * Charge et sauvegarde automatiquement les statistiques via SharedPreferences. - */ package legion.muyue.best2048; import android.annotation.SuppressLint; @@ -11,122 +5,130 @@ import android.content.Context; import android.content.SharedPreferences; import java.util.concurrent.TimeUnit; +/** + * Gère le suivi, le stockage et la récupération des statistiques de jeu + * pour les modes solo et multijoueur du jeu Best2048. + * Utilise {@link SharedPreferences} pour la persistance des données. + * Nécessite un {@link Context} Android pour l'initialisation. + * + * Cette classe suit diverses métriques telles que le nombre de parties jouées, + * les scores, le temps de jeu, les mouvements, les fusions, les séries de victoires, etc. + */ public class GameStats { // --- Constantes pour SharedPreferences --- - - /** Nom du fichier de préférences partagées utilisé pour stocker les statistiques et l'état du jeu. */ + /** Nom du fichier de préférences partagées utilisé pour stocker les statistiques. */ private static final String PREFS_NAME = "Best2048_Prefs"; - /** - * Clé pour le meilleur score global. - * NOTE : Cette clé est également utilisée directement par MainActivity/Game pour la sauvegarde/chargement - * de l'état du jeu. La synchronisation est gérée entre les classes. - */ + /** Clé pour stocker le meilleur score global (principalement solo). */ private static final String HIGH_SCORE_KEY = "high_score"; - - // Clés spécifiques aux statistiques persistantes + // Clés pour les statistiques Solo + /** Clé pour le nombre total de parties solo terminées (victoire ou défaite). */ private static final String STATS_TOTAL_GAMES_PLAYED = "totalGamesPlayed"; + /** Clé pour le nombre total de parties solo démarrées. */ private static final String STATS_TOTAL_GAMES_STARTED = "totalGamesStarted"; + /** Clé pour le nombre total de mouvements effectués en mode solo. */ private static final String STATS_TOTAL_MOVES = "totalMoves"; + /** Clé pour le temps de jeu total cumulé en mode solo (en millisecondes). */ private static final String STATS_TOTAL_PLAY_TIME_MS = "totalPlayTimeMs"; + /** Clé pour le nombre total de fusions de tuiles en mode solo. */ private static final String STATS_TOTAL_MERGES = "totalMerges"; + /** Clé pour la valeur de la plus haute tuile jamais atteinte en mode solo. */ private static final String STATS_HIGHEST_TILE = "highestTile"; + /** Clé pour le nombre de fois où l'objectif (ex: tuile 2048) a été atteint en mode solo. */ private static final String STATS_OBJECTIVE_REACHED_COUNT = "numberOfTimesObjectiveReached"; - // private static final String STATS_PERFECT_GAMES = "perfectGames"; // Clé supprimée car concept non défini + /** Clé pour le meilleur temps pour atteindre l'objectif en mode solo (en millisecondes). */ private static final String STATS_BEST_WINNING_TIME_MS = "bestWinningTimeMs"; + /** Clé pour le pire (plus long) temps pour atteindre l'objectif en mode solo (en millisecondes). */ private static final String STATS_WORST_WINNING_TIME_MS = "worstWinningTimeMs"; - - // Clés pour les statistiques multijoueur (fonctionnalité future) + // Clés pour les statistiques Multijoueur (persistées) + /** Clé pour le nombre total de parties multijoueur gagnées. */ private static final String STATS_MP_GAMES_WON = "multiplayerGamesWon"; + /** Clé pour le nombre total de parties multijoueur jouées (terminées). */ private static final String STATS_MP_GAMES_PLAYED = "multiplayerGamesPlayed"; + /** Clé pour la meilleure série de victoires consécutives en multijoueur. */ private static final String STATS_MP_BEST_WINNING_STREAK = "multiplayerBestWinningStreak"; + /** Clé pour le score total cumulé en multijoueur. */ private static final String STATS_MP_TOTAL_SCORE = "multiplayerTotalScore"; + /** Clé pour le temps de jeu total cumulé en multijoueur (en millisecondes). */ private static final String STATS_MP_TOTAL_TIME_MS = "multiplayerTotalTimeMs"; + /** Clé pour le nombre total de parties multijoueur perdues. */ private static final String STATS_MP_LOSSES = "totalMultiplayerLosses"; + /** Clé pour le meilleur score obtenu dans une seule partie multijoueur. */ private static final String STATS_MP_HIGH_SCORE = "multiplayerHighScore"; - - // --- Champs de Statistiques --- - - // Générales & Solo - /** Nombre total de parties terminées (victoire ou défaite). */ + // --- Champs Solo & Général (persistés via SharedPreferences) --- + /** Nombre total de parties solo terminées (victoires + défaites). Persisté. */ private int totalGamesPlayed; - /** Nombre total de parties démarrées (via bouton "Nouvelle Partie"). */ + /** Nombre total de parties solo commencées. Persisté. */ private int totalGamesStarted; - /** Nombre total de mouvements (swipes valides) effectués dans toutes les parties. */ + /** Nombre total de mouvements effectués dans toutes les parties solo. Persisté. */ private int totalMoves; - /** Temps total passé à jouer (en millisecondes) sur toutes les parties. */ + /** Temps de jeu total cumulé pour toutes les parties solo (en millisecondes). Persisté. */ private long totalPlayTimeMs; - /** Nombre total de fusions de tuiles effectuées dans toutes les parties. */ + /** Nombre total de fusions effectuées dans toutes les parties solo. Persisté. */ private int totalMerges; - /** Valeur de la plus haute tuile atteinte globalement dans toutes les parties. */ + /** Valeur de la plus haute tuile jamais atteinte en mode solo. Persisté. */ private int highestTile; - /** Nombre de fois où l'objectif (atteindre 2048 ou plus) a été atteint. */ + /** Nombre de fois où l'objectif (ex: 2048) a été atteint en solo. Persisté. */ private int numberOfTimesObjectiveReached; - // private int perfectGames; // Champ supprimé car concept non défini - /** Meilleur temps (le plus court, en ms) pour atteindre l'objectif (>= 2048). */ + /** Meilleur temps (le plus court) pour atteindre l'objectif en solo (en millisecondes). Persisté. */ private long bestWinningTimeMs; - /** Pire temps (le plus long, en ms) pour atteindre l'objectif (>= 2048). */ + /** Pire temps (le plus long) pour atteindre l'objectif en solo (en millisecondes). Persisté. */ private long worstWinningTimeMs; - /** - * Meilleur score global atteint. Persisté via HIGH_SCORE_KEY. - * Synchronisé avec MainActivity/Game lors du chargement/sauvegarde. - */ + /** Meilleur score global atteint (principalement en mode solo). Persisté. */ private int overallHighScore; - // Partie en cours (non persistées telles quelles, utilisées pour calculs en temps réel) - /** Nombre de mouvements effectués dans la partie actuelle. */ + // --- Champs Partie en cours (Solo - transitoires, non persistés) --- + /** Nombre de mouvements effectués dans la partie solo actuelle. Non persisté. */ private int currentMoves; - /** Timestamp (en ms) du début de la partie actuelle. Réinitialisé à chaque nouvelle partie ou reprise. */ + /** Timestamp (millisecondes) du début de la partie solo actuelle. Non persisté. */ private long currentGameStartTimeMs; - /** Nombre de fusions effectuées dans la partie actuelle. */ + /** Nombre de fusions effectuées dans la partie solo actuelle. Non persisté. */ private int mergesThisGame; - // Multijoueur (placeholders pour fonctionnalité future) - /** Nombre de parties multijoueur gagnées. */ + // --- Champs Multijoueur (persistés via SharedPreferences) --- + /** Nombre total de parties multijoueur gagnées. Persisté. */ private int multiplayerGamesWon; - /** Nombre de parties multijoueur jouées (terminées). */ + /** Nombre total de parties multijoueur jouées (terminées). Persisté. */ private int multiplayerGamesPlayed; - /** Plus longue série de victoires consécutives en multijoueur. */ + /** Meilleure série de victoires consécutives en multijoueur jamais atteinte. Persisté. */ private int multiplayerBestWinningStreak; - /** Score total accumulé dans toutes les parties multijoueur. */ + /** Score total cumulé sur toutes les parties multijoueur. Persisté. */ private long multiplayerTotalScore; - /** Temps total passé dans les parties multijoueur (en ms). */ + /** Temps de jeu total cumulé pour toutes les parties multijoueur (en millisecondes). Persisté. */ private long multiplayerTotalTimeMs; - /** Nombre total de défaites en multijoueur. */ + /** Nombre total de parties multijoueur perdues. Persisté. */ private int totalMultiplayerLosses; - /** Meilleur score atteint dans une seule partie multijoueur. */ + /** Meilleur score obtenu dans une seule partie multijoueur. Persisté. */ private int multiplayerHighestScore; - /** Contexte applicatif nécessaire pour accéder aux SharedPreferences. */ + // --- Champs Transitoires (non persistés) --- + /** Série de victoires consécutives actuelle en multijoueur. Non persisté. */ + private int currentMultiplayerWinningStreak = 0; + + /** Contexte Android nécessaire pour accéder aux SharedPreferences. */ private final Context context; + // --- Constructeur & Persistance --- + /** - * Constructeur de GameStats. - * Initialise l'objet et charge immédiatement les statistiques persistantes - * depuis les SharedPreferences via {@link #loadStats()}. + * Construit une nouvelle instance de GameStats. + * Charge immédiatement les statistiques persistées depuis les SharedPreferences. * - * @param context Contexte de l'application (Activity ou Application). Utilise `getApplicationContext()` pour éviter les fuites de mémoire. + * @param context Le contexte de l'application Android, nécessaire pour accéder aux SharedPreferences. Ne doit pas être null. */ public GameStats(Context context) { - this.context = context.getApplicationContext(); // Important: utiliser le contexte applicatif - loadStats(); // Charge les statistiques dès l'initialisation + this.context = context.getApplicationContext(); // Utilise le contexte d'application pour éviter les fuites de mémoire + loadStats(); // Charge les statistiques au moment de la création } - // --- Persistance (SharedPreferences) --- - /** - * Charge toutes les statistiques persistantes depuis le fichier SharedPreferences défini par {@link #PREFS_NAME}. - * Si une clé n'existe pas, une valeur par défaut est utilisée (0, Long.MAX_VALUE pour best time, etc.). - * Appelé automatiquement par le constructeur. + * Charge toutes les statistiques persistées depuis le fichier SharedPreferences défini par {@link #PREFS_NAME}. + * Si une clé n'est pas trouvée, une valeur par défaut est utilisée (généralement 0 ou Long.MAX_VALUE pour best time). */ public void loadStats() { SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - - // Charge le high score (clé partagée) overallHighScore = prefs.getInt(HIGH_SCORE_KEY, 0); - - // Charge les statistiques générales et solo totalGamesPlayed = prefs.getInt(STATS_TOTAL_GAMES_PLAYED, 0); totalGamesStarted = prefs.getInt(STATS_TOTAL_GAMES_STARTED, 0); totalMoves = prefs.getInt(STATS_TOTAL_MOVES, 0); @@ -134,11 +136,9 @@ public class GameStats { totalMerges = prefs.getInt(STATS_TOTAL_MERGES, 0); highestTile = prefs.getInt(STATS_HIGHEST_TILE, 0); numberOfTimesObjectiveReached = prefs.getInt(STATS_OBJECTIVE_REACHED_COUNT, 0); - // perfectGames = prefs.getInt(STATS_PERFECT_GAMES, 0); // Supprimé - bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme indicateur "pas encore de temps enregistré" - worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0L); - - // Charge les statistiques multijoueur (placeholders) + bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); + worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0L); // 0 est ok pour le pire temps + // Chargement des stats Multi multiplayerGamesWon = prefs.getInt(STATS_MP_GAMES_WON, 0); multiplayerGamesPlayed = prefs.getInt(STATS_MP_GAMES_PLAYED, 0); multiplayerBestWinningStreak = prefs.getInt(STATS_MP_BEST_WINNING_STREAK, 0); @@ -149,19 +149,14 @@ public class GameStats { } /** - * Sauvegarde toutes les statistiques persistantes actuelles dans le fichier SharedPreferences. - * Utilise `apply()` pour une sauvegarde asynchrone et non bloquante. - * Devrait être appelée lorsque l'application se met en pause ou est détruite, - * ou après une mise à jour significative des statistiques (ex: reset). + * Sauvegarde l'état actuel de toutes les statistiques persistables dans le fichier SharedPreferences. + * Utilise {@link SharedPreferences.Editor#apply()} pour une sauvegarde asynchrone en arrière-plan. + * Les statistiques transitoires (partie en cours) ne sont pas sauvegardées. */ public void saveStats() { SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); - - // Sauvegarde le high score (clé partagée) editor.putInt(HIGH_SCORE_KEY, overallHighScore); - - // Sauvegarde les statistiques générales et solo editor.putInt(STATS_TOTAL_GAMES_PLAYED, totalGamesPlayed); editor.putInt(STATS_TOTAL_GAMES_STARTED, totalGamesStarted); editor.putInt(STATS_TOTAL_MOVES, totalMoves); @@ -169,11 +164,9 @@ public class GameStats { editor.putInt(STATS_TOTAL_MERGES, totalMerges); editor.putInt(STATS_HIGHEST_TILE, highestTile); editor.putInt(STATS_OBJECTIVE_REACHED_COUNT, numberOfTimesObjectiveReached); - // editor.putInt(STATS_PERFECT_GAMES, perfectGames); // Supprimé editor.putLong(STATS_BEST_WINNING_TIME_MS, bestWinningTimeMs); editor.putLong(STATS_WORST_WINNING_TIME_MS, worstWinningTimeMs); - - // Sauvegarde les statistiques multijoueur (placeholders) + // Sauvegarde des stats Multi editor.putInt(STATS_MP_GAMES_WON, multiplayerGamesWon); editor.putInt(STATS_MP_GAMES_PLAYED, multiplayerGamesPlayed); editor.putInt(STATS_MP_BEST_WINNING_STREAK, multiplayerBestWinningStreak); @@ -181,29 +174,26 @@ public class GameStats { editor.putLong(STATS_MP_TOTAL_TIME_MS, multiplayerTotalTimeMs); editor.putInt(STATS_MP_LOSSES, totalMultiplayerLosses); editor.putInt(STATS_MP_HIGH_SCORE, multiplayerHighestScore); - - // Applique les changements de manière asynchrone - editor.apply(); + editor.apply(); // Sauvegarde asynchrone } - // --- Méthodes de Mise à Jour des Statistiques --- + // --- Méthodes de suivi pour le mode Solo --- /** - * Doit être appelée au début de chaque nouvelle partie (ex: clic sur "Nouvelle Partie"). - * Incrémente le compteur de parties démarrées (`totalGamesStarted`) et réinitialise - * les statistiques spécifiques à la partie en cours (`currentMoves`, `mergesThisGame`, `currentGameStartTimeMs`). + * Enregistre le début d'une nouvelle partie solo. + * Incrémente le compteur de parties démarrées, réinitialise les compteurs de la partie actuelle + * (mouvements, fusions) et enregistre l'heure de début. */ public void startGame() { totalGamesStarted++; currentMoves = 0; mergesThisGame = 0; - currentGameStartTimeMs = System.currentTimeMillis(); // Enregistre le temps de début + currentGameStartTimeMs = System.currentTimeMillis(); // Enregistre l'heure de début } /** - * Enregistre un mouvement réussi (un swipe qui a modifié l'état du plateau). - * Incrémente le compteur de mouvements pour la partie en cours (`currentMoves`) - * et le compteur total de mouvements (`totalMoves`). + * Enregistre un mouvement effectué dans la partie solo actuelle. + * Incrémente le compteur de mouvements pour la partie en cours et le compteur total de mouvements. */ public void recordMove() { currentMoves++; @@ -211,11 +201,10 @@ public class GameStats { } /** - * Enregistre une ou plusieurs fusions survenues lors d'un mouvement. - * Incrémente le compteur de fusions pour la partie en cours (`mergesThisGame`) - * et le compteur total de fusions (`totalMerges`). + * Enregistre une ou plusieurs fusions effectuées lors d'un mouvement en solo. + * Incrémente le compteur de fusions pour la partie en cours et le compteur total de fusions. * - * @param numberOfMerges Le nombre de fusions distinctes réalisées lors du dernier mouvement (typiquement 1 ou plus). + * @param numberOfMerges Le nombre de fusions à ajouter (doit être > 0). */ public void recordMerge(int numberOfMerges) { if (numberOfMerges > 0) { @@ -225,86 +214,144 @@ public class GameStats { } /** - * Met à jour la statistique de la plus haute tuile atteinte globalement (`highestTile`), - * si la valeur fournie est supérieure à la valeur actuelle enregistrée. - * Devrait être appelée après chaque mouvement qui pourrait créer une nouvelle tuile max. + * Met à jour la valeur de la plus haute tuile jamais atteinte si la nouvelle valeur est supérieure. * - * @param currentHighestTileValue La valeur de la tuile la plus haute sur le plateau à l'instant T. + * @param tileValue La valeur de la tuile potentiellement la plus haute. */ - public void updateHighestTile(int currentHighestTileValue) { - if (currentHighestTileValue > this.highestTile) { - this.highestTile = currentHighestTileValue; - // La sauvegarde se fera via saveStats() globalement + public void updateHighestTile(int tileValue) { + if (tileValue > highestTile) { + highestTile = tileValue; + saveStats(); } } /** - * Enregistre une victoire (atteinte de l'objectif >= 2048). - * Incrémente le compteur de victoires (`numberOfTimesObjectiveReached`) et met à jour - * les statistiques de meilleur/pire temps de victoire si nécessaire. - * Appelle également {@link #endGame(long)} pour finaliser les statistiques générales de la partie. + * Enregistre une victoire en mode solo (atteinte de l'objectif). + * Incrémente le compteur de victoires, met à jour les meilleurs/pires temps de victoire si applicable, + * et appelle {@link #endGame(long)} pour finaliser les statistiques de la partie. * - * @param timeTakenMs Temps écoulé (en millisecondes) pour terminer cette partie gagnante. + * @param timeMs Le temps pris pour gagner cette partie (en millisecondes). */ - public void recordWin(long timeTakenMs) { + public void recordWin(long timeMs) { numberOfTimesObjectiveReached++; - if (timeTakenMs < bestWinningTimeMs) { - bestWinningTimeMs = timeTakenMs; + if (timeMs < bestWinningTimeMs) { + bestWinningTimeMs = timeMs; } - // Utiliser >= pour le pire temps permet de l'enregistrer même si c'est le premier - if (timeTakenMs >= worstWinningTimeMs) { - worstWinningTimeMs = timeTakenMs; + // Utilise >= pour worstWinningTimeMs pour s'assurer qu'il est mis à jour même si le premier temps est 0 + if (timeMs >= worstWinningTimeMs) { + worstWinningTimeMs = timeMs; } - endGame(timeTakenMs); // Finalise aussi le temps total et le compteur de parties jouées + endGame(timeMs); // Finalise la partie avec le temps enregistré } /** - * Enregistre une défaite (Game Over - plateau plein sans mouvement possible). - * Calcule le temps écoulé pour la partie et appelle {@link #endGame(long)} - * pour finaliser les statistiques générales. + * Enregistre une défaite en mode solo. + * Calcule le temps de jeu pour cette partie et appelle {@link #endGame(long)}. */ public void recordLoss() { - // Calcule le temps écoulé pour cette partie avant de la finaliser - long timeTakenMs = System.currentTimeMillis() - currentGameStartTimeMs; - endGame(timeTakenMs); + long timeMs = (currentGameStartTimeMs > 0) ? System.currentTimeMillis() - currentGameStartTimeMs : 0; + endGame(timeMs); // Finalise la partie } /** - * Finalise les statistiques générales à la fin d'une partie (victoire ou défaite). - * Incrémente le compteur de parties jouées (`totalGamesPlayed`) et ajoute le temps - * de la partie terminée au temps de jeu total (`totalPlayTimeMs`). + * Méthode privée pour finaliser les statistiques communes à la fin d'une partie solo (victoire ou défaite). + * Incrémente le compteur total de parties jouées et ajoute le temps de jeu de cette partie. * - * @param timeTakenMs Temps total (en millisecondes) de la partie qui vient de se terminer. + * @param timeMs Le temps de jeu pour la partie qui vient de se terminer (en millisecondes). */ - private void endGame(long timeTakenMs) { + private void endGame(long timeMs) { totalGamesPlayed++; - addPlayTime(timeTakenMs); // Ajoute la durée de la partie terminée au total - // Ne pas réinitialiser currentGameStartTimeMs ici, car une nouvelle partie - // pourrait ne pas commencer immédiatement (ex: affichage dialogue Game Over). - // startGame() s'en chargera. + addPlayTime(timeMs); + currentGameStartTimeMs = 0; + saveStats(); } /** - * Ajoute une durée (en millisecondes) au temps de jeu total enregistré (`totalPlayTimeMs`). - * Typiquement appelé dans `onPause` de l'activité pour enregistrer le temps écoulé - * depuis `onResume` ou `startGame`. + * Ajoute une durée au temps de jeu total cumulé en mode solo. * - * @param durationMs Durée positive (en millisecondes) à ajouter au temps total. + * @param durationMs La durée à ajouter (en millisecondes, doit être > 0). */ public void addPlayTime(long durationMs) { if (durationMs > 0) { - this.totalPlayTimeMs += durationMs; + totalPlayTimeMs += durationMs; } } + // --- Méthodes de suivi pour le mode Multijoueur --- + /** - * Réinitialise toutes les statistiques (générales, solo, multijoueur, y compris le high score global) - * à leurs valeurs par défaut (0 ou équivalent). - * Après la réinitialisation, les nouvelles valeurs sont immédiatement sauvegardées - * dans les SharedPreferences via {@link #saveStats()}. + * Méthode privée pour mettre à jour les statistiques communes à la fin d'une partie multijoueur. + * Met à jour le nombre de parties jouées, le score total, le temps total et le meilleur score multi. + * + * @param score Le score obtenu dans la partie multijoueur terminée. + * @param durationMs La durée de la partie multijoueur (en millisecondes). + */ + private void endMultiplayerGame(int score, long durationMs) { + multiplayerGamesPlayed++; + multiplayerTotalScore += score; + if (durationMs > 0) { + multiplayerTotalTimeMs += durationMs; + } + if (score > multiplayerHighestScore) { + multiplayerHighestScore = score; + } + // Sauvegarde souvent après une partie multi pour ne pas perdre le résultat + saveStats(); + } + + /** + * Enregistre une victoire en multijoueur. + * Incrémente le compteur de victoires multi, met à jour la série de victoires (actuelle et meilleure), + * et appelle {@link #endMultiplayerGame(int, long)}. + * + * @param score Le score obtenu dans la partie gagnée. + * @param durationMs La durée de la partie gagnée (en millisecondes). + */ + public void recordMultiplayerWin(int score, long durationMs) { + multiplayerGamesWon++; + currentMultiplayerWinningStreak++; // Incrémente la série actuelle + if (currentMultiplayerWinningStreak > multiplayerBestWinningStreak) { + multiplayerBestWinningStreak = currentMultiplayerWinningStreak; // Met à jour la meilleure série si nécessaire + } + endMultiplayerGame(score, durationMs); // Finalise les stats communes + } + + /** + * Enregistre une défaite en multijoueur. + * Incrémente le compteur de défaites multi, réinitialise la série de victoires actuelle, + * et appelle {@link #endMultiplayerGame(int, long)}. + * + * @param score Le score obtenu dans la partie perdue. + * @param durationMs La durée de la partie perdue (en millisecondes). + */ + public void recordMultiplayerLoss(int score, long durationMs) { + totalMultiplayerLosses++; + currentMultiplayerWinningStreak = 0; // Réinitialise la série de victoires + endMultiplayerGame(score, durationMs); // Finalise les stats communes + } + + /** + * Enregistre un match nul en multijoueur. + * Réinitialise la série de victoires actuelle et appelle {@link #endMultiplayerGame(int, long)}. + * Note: Un match nul compte comme une partie jouée mais n'affecte pas les victoires/défaites. + * + * @param score Le score obtenu dans le match nul. + * @param durationMs La durée du match nul (en millisecondes). + */ + public void recordMultiplayerDraw(int score, long durationMs) { + currentMultiplayerWinningStreak = 0; // Réinitialise la série de victoires (convention) + endMultiplayerGame(score, durationMs); // Finalise les stats communes + } + + // --- Réinitialisation --- + + /** + * Réinitialise toutes les statistiques (solo et multijoueur, persistées et transitoires) + * à leurs valeurs par défaut. + * Sauvegarde immédiatement l'état réinitialisé dans les SharedPreferences. */ public void resetStats() { - // Réinitialise toutes les variables membres à 0 ou valeur initiale + // Réinitialisation Solo & Général overallHighScore = 0; totalGamesPlayed = 0; totalGamesStarted = 0; @@ -313,11 +360,9 @@ public class GameStats { totalMerges = 0; highestTile = 0; numberOfTimesObjectiveReached = 0; - // perfectGames = 0; // Supprimé bestWinningTimeMs = Long.MAX_VALUE; worstWinningTimeMs = 0L; - - // Réinitialise les stats multijoueur (placeholders) + // Réinitialisation Multi multiplayerGamesWon = 0; multiplayerGamesPlayed = 0; multiplayerBestWinningStreak = 0; @@ -325,96 +370,94 @@ public class GameStats { multiplayerTotalTimeMs = 0L; totalMultiplayerLosses = 0; multiplayerHighestScore = 0; - - // Réinitialise les stats de la partie en cours (au cas où) + // Réinitialisation Transitoire currentMoves = 0; mergesThisGame = 0; - currentGameStartTimeMs = 0L; // Ou System.currentTimeMillis() si on considère que reset démarre une nouvelle session? Non, 0 est plus sûr. + currentGameStartTimeMs = 0L; + currentMultiplayerWinningStreak = 0; - // Sauvegarde immédiatement les statistiques réinitialisées - saveStats(); + saveStats(); // Sauvegarde l'état réinitialisé } - // --- Getters pour l'affichage --- + // --- Getters (Accesseurs) --- - /** @return Le nombre total de parties terminées (victoire ou défaite). */ + /** @return Le nombre total de parties solo terminées. */ public int getTotalGamesPlayed() { return totalGamesPlayed; } - /** @return Le nombre total de parties démarrées. */ + /** @return Le nombre total de parties solo démarrées. */ public int getTotalGamesStarted() { return totalGamesStarted; } - /** @return Le nombre total de mouvements valides effectués. */ + /** @return Le nombre total de mouvements effectués en solo. */ public int getTotalMoves() { return totalMoves; } - /** @return Le nombre de mouvements dans la partie en cours. */ + /** @return Le nombre de mouvements dans la partie solo actuelle. */ public int getCurrentMoves() { return currentMoves; } - /** @return Le temps total de jeu accumulé (en ms). */ + /** @return Le temps de jeu total cumulé en solo (millisecondes). */ public long getTotalPlayTimeMs() { return totalPlayTimeMs; } - /** @return Le timestamp (en ms) du début de la partie en cours. Utile pour calculer la durée en temps réel. */ + /** @return Le timestamp de début de la partie solo actuelle (millisecondes). */ public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } - /** @return Le nombre de fusions dans la partie en cours. */ + /** @return Le nombre de fusions dans la partie solo actuelle. */ public int getMergesThisGame() { return mergesThisGame; } - /** @return Le nombre total de fusions effectuées. */ + /** @return Le nombre total de fusions effectuées en solo. */ public int getTotalMerges() { return totalMerges; } - /** @return La valeur de la plus haute tuile atteinte globalement. */ + /** @return La valeur de la plus haute tuile jamais atteinte en solo. */ public int getHighestTile() { return highestTile; } - /** @return Le nombre de fois où l'objectif (>= 2048) a été atteint. */ + /** @return Le nombre de fois où l'objectif a été atteint en solo. */ public int getNumberOfTimesObjectiveReached() { return numberOfTimesObjectiveReached; } - // public int getPerfectGames() { return perfectGames; } // Supprimé - /** @return Le meilleur temps (le plus court, en ms) pour gagner une partie, ou Long.MAX_VALUE si aucune victoire. */ + /** @return Le meilleur temps pour atteindre l'objectif en solo (millisecondes), ou Long.MAX_VALUE si jamais atteint. */ public long getBestWinningTimeMs() { return bestWinningTimeMs; } - /** @return Le pire temps (le plus long, en ms) pour gagner une partie, ou 0 si aucune victoire. */ + /** @return Le pire temps pour atteindre l'objectif en solo (millisecondes). */ public long getWorstWinningTimeMs() { return worstWinningTimeMs; } - /** @return Le meilleur score global enregistré. */ + /** @return Le meilleur score global atteint (principalement solo). */ public int getOverallHighScore() { return overallHighScore; } - // Getters Multiplayer (fonctionnalité future) - /** @return Nombre de parties multijoueur gagnées. */ + // --- Getters Multijoueur --- + /** @return Le nombre total de parties multijoueur gagnées. */ public int getMultiplayerGamesWon() { return multiplayerGamesWon; } - /** @return Nombre de parties multijoueur jouées. */ + /** @return Le nombre total de parties multijoueur jouées. */ public int getMultiplayerGamesPlayed() { return multiplayerGamesPlayed; } - /** @return Meilleure série de victoires consécutives en multijoueur. */ + /** @return La meilleure série de victoires consécutives en multijoueur. */ public int getMultiplayerBestWinningStreak() { return multiplayerBestWinningStreak; } - /** @return Score total accumulé en multijoueur. */ + /** @return Le score total cumulé en multijoueur. */ public long getMultiplayerTotalScore() { return multiplayerTotalScore; } - /** @return Temps total passé en multijoueur (en ms). */ + /** @return Le temps de jeu total cumulé en multijoueur (millisecondes). */ public long getMultiplayerTotalTimeMs() { return multiplayerTotalTimeMs; } - /** @return Nombre total de défaites en multijoueur. */ + /** @return Le nombre total de parties multijoueur perdues. */ public int getTotalMultiplayerLosses() { return totalMultiplayerLosses; } - /** @return Meilleur score atteint dans une partie multijoueur. */ + /** @return Le meilleur score obtenu dans une seule partie multijoueur. */ public int getMultiplayerHighestScore() { return multiplayerHighestScore; } + /** @return La série de victoires consécutives actuelle en multijoueur (non persisté). */ + public int getCurrentMultiplayerWinningStreak() { return currentMultiplayerWinningStreak; } - // --- Setters --- + + // --- Setters (Mutateurs) --- /** - * Met à jour la valeur interne du meilleur score global (`overallHighScore`). - * La mise à jour n'a lieu que si le score fourni est strictement supérieur - * au meilleur score actuel. La sauvegarde effective dans SharedPreferences - * se fait via un appel ultérieur à {@link #saveStats()}. + * Met à jour le meilleur score global si le score fourni est supérieur. + * Utile pour enregistrer un nouveau record après une partie solo. + * Ne sauvegarde pas automatiquement, appeler {@link #saveStats()} si nécessaire. * - * @param highScore Le nouveau score à potentiellement définir comme meilleur score. + * @param score Le nouveau score potentiellement plus élevé. */ - public void setHighestScore(int highScore) { - // Met à jour seulement si la nouvelle valeur est meilleure - if (highScore > this.overallHighScore) { - this.overallHighScore = highScore; - // La sauvegarde se fait via saveStats() globalement, pas ici directement. + public void setHighestScore(int score) { + if (score > overallHighScore) { + overallHighScore = score; } } /** - * Définit le timestamp (en ms) du début de la partie en cours. - * Typiquement utilisé par MainActivity dans `onResume` pour reprendre le chronomètre. + * Définit manuellement l'heure de début de la partie en cours. + * Peut être utile si l'état du jeu est restauré d'une source externe. * - * @param timeMs Le timestamp en millisecondes. + * @param timeMs Le timestamp de début en millisecondes. */ public void setCurrentGameStartTimeMs(long timeMs) { - this.currentGameStartTimeMs = timeMs; + currentGameStartTimeMs = timeMs; } // --- Méthodes Calculées --- /** - * Calcule le temps moyen passé par partie terminée (solo). + * Calcule le temps de jeu moyen par partie solo terminée. * - * @return Le temps moyen par partie en millisecondes, ou 0 si aucune partie n'a été terminée. + * @return Le temps moyen en millisecondes, ou 0 si aucune partie n'a été jouée. */ public long getAverageGameTimeMs() { return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0L; @@ -422,44 +465,44 @@ public class GameStats { /** * Calcule le score moyen par partie multijoueur terminée. - * (Basé sur les statistiques multijoueur placeholder). * - * @return Le score moyen entier par partie multijoueur, ou 0 si aucune partie multijoueur n'a été jouée. + * @return Le score moyen, ou 0 si aucune partie multijoueur n'a été jouée. */ public int getMultiplayerAverageScore() { return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; } /** - * Calcule le temps moyen passé par partie multijoueur terminée. - * (Basé sur les statistiques multijoueur placeholder). + * Calcule le temps de jeu moyen par partie multijoueur terminée. * - * @return Le temps moyen par partie multijoueur en millisecondes, ou 0 si aucune partie multijoueur n'a été jouée. + * @return Le temps moyen en millisecondes, ou 0 si aucune partie multijoueur n'a été jouée. */ public long getMultiplayerAverageTimeMs() { return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0L; } /** - * Formate une durée donnée en millisecondes en une chaîne de caractères lisible. - * Le format est "hh:mm:ss" si la durée est d'une heure ou plus, sinon "mm:ss". + * Formate une durée en millisecondes en une chaîne lisible "HH:MM:SS" ou "MM:SS". + * Gère les durées négatives en les traitant comme 0. + * Supprime le lint warning pour DefaultLocale car le format numérique est indépendant de la locale. * - * @param milliseconds Durée totale en millisecondes. + * @param ms La durée en millisecondes à formater. * @return Une chaîne formatée représentant la durée. */ - @SuppressLint("DefaultLocale") // Justifié car le format hh:mm:ss est standard et non dépendant de la locale - public static String formatTime(long milliseconds) { - if (milliseconds < 0) { milliseconds = 0; } // Gère les cas négatifs - - long hours = TimeUnit.MILLISECONDS.toHours(milliseconds); - long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60; // Minutes restantes après les heures - long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60; // Secondes restantes après les minutes + @SuppressLint("DefaultLocale") // Le format %02d est indépendant de la locale + public static String formatTime(long ms) { + if (ms < 0) { + ms = 0; // Traite les durées négatives comme 0 + } + long hours = TimeUnit.MILLISECONDS.toHours(ms); + long minutes = TimeUnit.MILLISECONDS.toMinutes(ms) % 60; // Minutes restantes après les heures + long seconds = TimeUnit.MILLISECONDS.toSeconds(ms) % 60; // Secondes restantes après les minutes if (hours > 0) { - // Format avec heures si nécessaire + // Format avec heures si la durée est >= 1 heure return String.format("%02d:%02d:%02d", hours, minutes, seconds); } else { - // Format minutes:secondes sinon + // Format sans heures si la durée est < 1 heure return String.format("%02d:%02d", minutes, seconds); } } diff --git a/app/src/main/java/legion/muyue/best2048/MainActivity.java b/app/src/main/java/legion/muyue/best2048/MainActivity.java index b493708..951a89a 100644 --- a/app/src/main/java/legion/muyue/best2048/MainActivity.java +++ b/app/src/main/java/legion/muyue/best2048/MainActivity.java @@ -1,11 +1,3 @@ -/** - * Activité principale de l'application Best 2048. - * Gère l'interface utilisateur (plateau de jeu, affichage des scores, boutons), - * coordonne les interactions de l'utilisateur (swipes, clics) avec la logique du jeu - * (classe {@link Game}) et la gestion des statistiques (classe {@link GameStats}). - * Prend également en charge le cycle de vie de l'application, la persistance de l'état du jeu - * via SharedPreferences, les effets sonores via SoundPool, et les notifications. - */ package legion.muyue.best2048; import static androidx.core.content.ContextCompat.startActivity; @@ -13,10 +5,6 @@ import static androidx.core.content.ContextCompat.startActivity; import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.Context; -import android.app.NotificationChannel; // Conservé pour référence mais createNotificationChannel est dans Helper -import android.app.NotificationManager; // Conservé pour référence mais createNotificationChannel est dans Helper -import android.app.PendingIntent; -import android.content.ActivityNotFoundException; import android.content.pm.PackageManager; import android.os.Build; import android.provider.Settings; @@ -25,7 +13,6 @@ import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; -import android.util.Log; // Utiliser Log pour le débogage import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; @@ -39,8 +26,6 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; -import androidx.core.app.NotificationCompat; // Conservé pour référence, mais showNotification est dans Helper -import androidx.core.app.NotificationManagerCompat; // Conservé pour référence, mais showNotification est dans Helper import androidx.core.content.ContextCompat; import androidx.gridlayout.widget.GridLayout; import android.widget.Button; @@ -49,253 +34,228 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; -import android.view.ViewTreeObserver; +import android.content.ActivityNotFoundException; import android.media.AudioAttributes; import android.media.SoundPool; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; + import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; +/** + * Activité principale de l'application, gérant le mode de jeu solo 2048. + * Responsable de l'affichage du plateau de jeu, des scores, de la gestion des entrées utilisateur (swipes, clics sur boutons), + * de l'intégration avec la logique du jeu (classe {@link Game}), de la gestion des statistiques (classe {@link GameStats}), + * des effets sonores ({@link SoundPool}), des paramètres (son, notifications), de la sauvegarde et du chargement de l'état du jeu + * via {@link SharedPreferences}, et de la planification des tâches de notification en arrière-plan avec {@link WorkManager}. + */ public class MainActivity extends AppCompatActivity { - // --- Constantes --- - /** Tag pour les logs de débogage spécifiques à cette activité. */ - private static final String TAG = "MainActivity"; - /** Taille du plateau de jeu (nombre de lignes/colonnes). Doit correspondre à Game.BOARD_SIZE. */ + /** Taille du plateau de jeu (4x4). */ private static final int BOARD_SIZE = 4; - /** Identifiant unique du canal de notification. Doit correspondre à NotificationHelper.CHANNEL_ID. */ - private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL"; - /** Nom du fichier de préférences partagées. Doit correspondre à GameStats.PREFS_NAME. */ + /** Nom du fichier SharedPreferences pour stocker les préférences et l'état du jeu. */ private static final String PREFS_NAME = "Best2048_Prefs"; - /** Clé pour sauvegarder/charger le meilleur score global. Doit correspondre à GameStats.HIGH_SCORE_KEY. */ + /** Clé SharedPreferences pour le meilleur score. */ private static final String HIGH_SCORE_KEY = "high_score"; - /** Clé pour sauvegarder/charger l'état sérialisé du jeu (plateau + score courant). */ + /** Clé SharedPreferences pour l'état sérialisé du jeu solo. */ private static final String GAME_STATE_KEY = "game_state"; - /** Clé pour sauvegarder/charger le timestamp de la dernière partie jouée (pour les notifications d'inactivité). */ + /** Clé SharedPreferences pour le timestamp de la dernière partie jouée. */ private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; - /** Clé pour sauvegarder/charger la préférence d'activation du son. */ + /** Clé SharedPreferences pour l'état d'activation des sons. */ private static final String SOUND_ENABLED_KEY = "sound_enabled"; - /** Clé pour sauvegarder/charger la préférence d'activation des notifications. */ + /** Clé SharedPreferences pour l'état d'activation des notifications. */ private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"; + /** Tag unique pour identifier le travail périodique de notification dans WorkManager. */ + private static final String PERIODIC_NOTIFICATION_WORK_TAG = "periodic-notification-work"; + /** Intervalle de répétition (en heures) pour le worker de notification. */ + private static final long WORKER_REPEAT_INTERVAL_HOURS = 12; // --- UI Elements --- - /** Layout de grille affichant les tuiles du jeu. */ + /** Layout de grille pour le plateau de jeu. */ private GridLayout boardGridLayout; - /** TextView affichant le score de la partie en cours. */ + /** TextView affichant le score actuel. */ private TextView currentScoreTextView; - /** TextView affichant le meilleur score global. */ + /** TextView affichant le meilleur score. */ private TextView highestScoreTextView; - /** Bouton pour démarrer une nouvelle partie (après confirmation). */ + /** Bouton pour démarrer une nouvelle partie. */ private Button newGameButton; - /** Bouton pour accéder aux fonctionnalités multijoueur (placeholder actuel). */ + /** Bouton pour accéder au mode multijoueur. */ private Button multiplayerButton; - /** Bouton pour afficher/masquer le panneau des statistiques. */ + /** Bouton pour afficher/masquer les statistiques. */ private Button statisticsButton; - /** Bouton pour ouvrir le menu principal (dialogue). */ + /** Bouton pour ouvrir le menu principal. */ private Button menuButton; - /** ViewStub pour charger paresseusement le layout des statistiques. */ + /** ViewStub pour charger paresseusement le panneau des statistiques. */ private ViewStub statisticsViewStub; - /** Référence au layout des statistiques une fois qu'il est gonflé par le ViewStub. */ + /** Référence vers la vue des statistiques une fois chargée. */ private View inflatedStatsView; - /** Références aux TextViews représentant les tuiles actuellement affichées sur le plateau. */ + /** Tableau 2D de TextViews représentant les tuiles sur le plateau. */ private TextView[][] tileViews = new TextView[BOARD_SIZE][BOARD_SIZE]; - // --- Game Logic & Stats --- - /** Instance de la classe Game contenant la logique métier du jeu (plateau, mouvements, score). */ + // --- Game Logic & State --- + /** Instance de la logique du jeu 2048. */ private Game game; - /** Instance de la classe GameStats gérant la collecte et la persistance des statistiques. */ + /** Instance pour gérer les statistiques de jeu. */ private GameStats gameStats; - - // --- State Management --- - /** Indique si le panneau des statistiques est actuellement visible. */ + /** Indicateur si le panneau des statistiques est actuellement visible. */ private boolean statisticsVisible = false; - /** Énumération pour suivre l'état actuel du flux de jeu (en cours, gagné, perdu). */ + /** Énumération pour suivre l'état global du flux de jeu (en cours, gagné, perdu). */ private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER } /** État actuel du flux de jeu. */ private GameFlowState currentGameState = GameFlowState.PLAYING; - /** Indique si les notifications sont activées par l'utilisateur et si la permission est accordée (sur API 33+). */ + /** Indicateur si les notifications sont activées dans les préférences. */ private boolean notificationsEnabled = false; - /** Indique si les effets sonores sont activés par l'utilisateur. */ - private boolean soundEnabled = true; // Son activé par défaut - - // --- Preferences --- - /** Instance de SharedPreferences pour la persistance des données. */ + /** Indicateur si les effets sonores sont activés dans les préférences. */ + private boolean soundEnabled = true; + /** Instance des SharedPreferences. */ private SharedPreferences preferences; // --- Sound --- - /** Pool pour gérer et jouer les effets sonores courts. */ + /** Pool pour gérer et jouer les effets sonores. */ private SoundPool soundPool; - /** ID du son chargé pour un mouvement de tuile. */ + /** ID du son pour un mouvement de tuile. */ private int soundMoveId = -1; - /** ID du son chargé pour une fusion de tuiles. */ + /** ID du son pour une fusion de tuiles. */ private int soundMergeId = -1; - /** ID du son chargé pour la victoire (atteinte de 2048). */ + /** ID du son pour la victoire (atteinte de 2048). */ private int soundWinId = -1; - /** ID du son chargé pour la fin de partie (Game Over). */ + /** ID du son pour la fin de partie (game over). */ private int soundGameOverId = -1; /** Indicateur si tous les sons ont été chargés avec succès dans le SoundPool. */ private boolean soundPoolLoaded = false; /** - * Lanceur d'activité pour demander la permission POST_NOTIFICATIONS (Android 13+). - * Gère la réponse de l'utilisateur (accordée ou refusée). + * Launcher pour gérer le résultat de la demande de permission de notification + * (requise à partir d'Android 13). */ private final ActivityResultLauncher requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { if (isGranted) { - // Permission accordée ! - Log.i(TAG, "Permission POST_NOTIFICATIONS accordée."); + // Permission accordée notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); - startNotificationService(); // Démarrer le service maintenant que la permission est OK + schedulePeriodicNotifications(); // Planifie le worker } else { - // Permission refusée. - Log.w(TAG, "Permission POST_NOTIFICATIONS refusée."); + // Permission refusée notificationsEnabled = false; saveNotificationPreference(false); - // Met à jour l'interrupteur dans les paramètres si l'utilisateur vient de refuser - updateNotificationSwitchState(false); // Informe l'utilisateur et met à jour l'UI potentielle + updateNotificationSwitchState(false); // Met à jour le switch dans les paramètres si visible Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); - stopNotificationService(); // Arrêter le service si la permission est refusée - // Optionnel : Afficher une explication pourquoi la notif est utile. - // showNotificationPermissionRationale(); + cancelPeriodicNotifications(); // Annule le worker } }); - // --- Activity Lifecycle --- - /** - * Appelée lors de la création initiale de l'activité. - * Configure la vue, initialise les composants (jeu, stats, son, listeners), - * et charge l'état sauvegardé si disponible. + * Méthode appelée lors de la création de l'activité. + * Initialise les vues, le canal de notification, le SoundPool, le jeu et les statistiques (en chargeant l'état sauvegardé si existant), + * configure les listeners pour les boutons et les swipes, et planifie le worker de notification si nécessaire. * - * @param savedInstanceState Si l'activité est recréée après avoir été détruite, - * ce Bundle contient les données qu'elle a fournies le plus récemment - * dans onSaveInstanceState(Bundle). Sinon, il est null. + * @param savedInstanceState État précédemment sauvegardé (non utilisé directement ici, le chargement se fait via SharedPreferences). */ @Override protected void onCreate(Bundle savedInstanceState) { - EdgeToEdge.enable(this); // Active l'affichage bord à bord si le thème le supporte + EdgeToEdge.enable(this); // Active l'affichage bord à bord super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // Définit le layout de l'activité - Log.d(TAG, "onCreate: Initialisation de l'activité."); + findViews(); // Récupère les références des vues + NotificationHelper.createNotificationChannel(this); // Crée le canal de notification (requis pour Android O+) + initializeSoundPool(); // Initialise les effets sonores + initializeGameAndStats(); // Charge/Initialise le jeu et les statistiques + setupListeners(); // Configure les listeners des boutons et des swipes - // Initialisation des composants - findViews(); // Récupère les références des vues du layout - NotificationHelper.createNotificationChannel(this); // Crée le canal (nécessaire pour API 26+) - initializeSoundPool(); // Charge les effets sonores - initializeGameAndStats(); // Initialise Game, GameStats et charge les données sauvegardées - setupListeners(); // Attache les listeners aux boutons et au plateau - - // Démarrer le service de notification SEULEMENT si les notifications sont activées ET la permission est OK (implicite si < API 33) + // Vérifie et planifie le worker de notification au démarrage si les notifications sont activées et la permission accordée (ou non nécessaire) if (notificationsEnabled) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - startNotificationService(); + schedulePeriodicNotifications(); } else { - Log.w(TAG, "Notifications activées dans les prefs, mais permission manquante sur API 33+. Service non démarré."); - // On pourrait demander la permission ici si on veut être proactif - // requestNotificationPermission(); + // Cas où les notifications étaient activées mais la permission a été révoquée entre temps + notificationsEnabled = false; + saveNotificationPreference(false); + cancelPeriodicNotifications(); } } } /** - * Appelée lorsque l'activité devient visible à l'utilisateur. - * Redémarre le chronomètre de la partie si elle était en cours. - * S'assure que le panneau de statistiques est affiché correctement s'il était visible - * avant la mise en pause. + * Méthode appelée lorsque l'activité revient au premier plan. + * Réinitialise l'heure de début de la partie pour le suivi du temps de jeu de la session actuelle. + * Gère la visibilité du panneau des statistiques s'il était ouvert avant la pause. */ @Override protected void onResume() { super.onResume(); - Log.d(TAG, "onResume: Reprise de l'activité."); - // Redémarre le timer seulement si une partie est activement en cours + // Réinitialise le timer de la partie en cours si le jeu est en état 'PLAYING' if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) { - // Utilise le temps sauvegardé lors du dernier onPause s'il existe, - // ou redémarre simplement le chrono. Ici, on redémarre simplement. gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis()); - Log.d(TAG, "onResume: Chronomètre de partie redémarré."); } - // Gère le réaffichage potentiel des statistiques si l'activité reprend + // Gère la ré-affichage du panneau des statistiques s'il était visible if (statisticsVisible) { - if (inflatedStatsView != null) { // Si déjà gonflé + if (inflatedStatsView != null) { updateStatisticsTextViews(); // Met à jour les données affichées - inflatedStatsView.setVisibility(View.VISIBLE); // Assure la visibilité - multiplayerButton.setVisibility(View.GONE); // Assure que le bouton multi est masqué - Log.d(TAG, "onResume: Panneau de statistiques ré-affiché."); + inflatedStatsView.setVisibility(View.VISIBLE); // Le rend visible + multiplayerButton.setVisibility(View.GONE); // Cache le bouton multijoueur } else { - // Si pas encore gonflé (cas rare mais possible), on le fait afficher - // Normalement, toggleStatistics devrait être appelé, mais on force ici pour couvrir le cas. - Log.w(TAG, "onResume: Panneau stats visible mais vue non gonflée. Tentative d'affichage via toggleStatistics."); - statisticsVisible = false; // Force à re-basculer pour gonfler + // Cas où la vue a été détruite (peu probable mais possible), referme le panneau + statisticsVisible = false; toggleStatistics(); } } else { + // S'assure que le panneau des stats est caché et le bouton multijoueur visible if (inflatedStatsView != null) { - inflatedStatsView.setVisibility(View.GONE); // Assure qu'il est masqué si non visible + inflatedStatsView.setVisibility(View.GONE); } - multiplayerButton.setVisibility(View.VISIBLE); // Assure que le bouton multi est visible + multiplayerButton.setVisibility(View.VISIBLE); } } /** - * Appelée lorsque l'activité est sur le point de passer en arrière-plan. - * Sauvegarde l'état actuel du jeu, les statistiques, et met à jour le temps de jeu total. - * Enregistre également le timestamp actuel comme dernier moment d'activité. + * Méthode appelée lorsque l'activité passe en arrière-plan. + * Enregistre le temps de jeu de la session en cours, sauvegarde l'état du jeu, + * les statistiques et le timestamp de la dernière partie jouée dans les SharedPreferences. */ @Override protected void onPause() { super.onPause(); - Log.d(TAG, "onPause: Mise en pause de l'activité."); - // Sauvegarde l'état et les stats si le jeu existe if (game != null && gameStats != null) { - Log.d(TAG, "onPause: Sauvegarde de l'état du jeu et des statistiques."); - // Met à jour le temps total SI la partie était activement en cours + // Si le jeu était en cours, enregistre la durée de la session if (currentGameState == GameFlowState.PLAYING) { long sessionDuration = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.addPlayTime(sessionDuration); // Ajoute le temps de la session au total - Log.d(TAG, "onPause: Temps de jeu ajouté: " + sessionDuration + "ms"); + gameStats.addPlayTime(sessionDuration); } - saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS via prefs - gameStats.saveStats(); // Sauvegarde toutes les statistiques via GameStats (y compris HS) - saveLastPlayedTime(); // Enregistre le moment actuel - } else { - Log.w(TAG, "onPause: Jeu ou GameStats est null, sauvegarde annulée."); + saveGame(); // Sauvegarde l'état du plateau et le score actuel + gameStats.saveStats(); // Sauvegarde toutes les statistiques persistantes + saveLastPlayedTime(); // Enregistre l'heure actuelle comme dernière heure jouée } } /** - * Appelée juste avant que l'activité ne soit détruite. - * Libère les ressources critiques, notamment le SoundPool. + * Méthode appelée juste avant la destruction de l'activité. + * Libère les ressources du SoundPool. */ @Override protected void onDestroy() { super.onDestroy(); - Log.d(TAG, "onDestroy: Destruction de l'activité."); - // Libère les ressources du SoundPool pour éviter les fuites de mémoire + // Libère le SoundPool pour éviter les fuites de mémoire if (soundPool != null) { - Log.d(TAG, "onDestroy: Libération du SoundPool."); soundPool.release(); soundPool = null; } - // Optionnel: Arrêter le service ici aussi, bien que ce soit moins courant - // stopNotificationService(); } - // --- Initialisation --- - /** - * Récupère les références des vues principales du layout (activity_main.xml) via leur ID. - * Doit être appelée dans onCreate après setContentView. + * Récupère les références des vues principales de l'activité depuis le layout XML. */ private void findViews() { - Log.d(TAG, "findViews: Récupération des références des vues."); boardGridLayout = findViewById(R.id.gameBoard); currentScoreTextView = findViewById(R.id.scoreLabel); highestScoreTextView = findViewById(R.id.highScoreLabel); @@ -304,271 +264,206 @@ public class MainActivity extends AppCompatActivity { menuButton = findViewById(R.id.menuButton); multiplayerButton = findViewById(R.id.multiplayerButton); statisticsViewStub = findViewById(R.id.statsViewStub); - // inflatedStatsView sera initialisé lorsque le ViewStub est gonflé } /** - * Initialise les objets Game et GameStats. - * Charge les préférences utilisateur (son, notifications). - * Charge l'état du jeu sauvegardé (s'il existe) et synchronise le meilleur score. - * Met à jour l'interface utilisateur initiale. Si aucun jeu n'est chargé, démarre une nouvelle partie. + * Initialise les SharedPreferences, charge les préférences de son et de notification, + * initialise l'instance de GameStats, et charge l'état du jeu sauvegardé ou démarre une nouvelle partie. */ private void initializeGameAndStats() { - Log.d(TAG, "initializeGameAndStats: Initialisation du jeu et des statistiques."); + // Initialise les SharedPreferences preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - // Charger les préférences utilisateur avant d'initialiser les objets qui en dépendent + // Charge les préférences utilisateur loadSoundPreference(); loadNotificationPreference(); - // Initialiser GameStats (qui charge ses propres données depuis SharedPreferences) - gameStats = new GameStats(this); + // Initialise le gestionnaire de statistiques + gameStats = new GameStats(this); // Charge automatiquement les stats persistées - // Charger l'état du jeu (Game) s'il existe - loadGame(); // Tente de charger 'game' depuis SharedPreferences + // Charge l'état du jeu sauvegardé + loadGame(); - // Si loadGame n'a pas réussi à charger une partie valide (ou s'il n'y avait pas de sauvegarde) + // Si aucun jeu n'a été chargé (ou échec), démarre une nouvelle partie if (game == null) { - Log.i(TAG, "initializeGameAndStats: Aucun état de jeu valide trouvé, démarrage d'une nouvelle partie."); - startNewGame(); // Crée une nouvelle instance de 'game' et initialise l'UI + startNewGame(); } else { - Log.i(TAG, "initializeGameAndStats: État de jeu chargé avec succès."); - // Assure que le meilleur score dans l'objet Game chargé est synchronisé avec GameStats + // Si un jeu a été chargé, met à jour son meilleur score avec celui des stats globales game.setHighestScore(gameStats.getOverallHighScore()); - updateUI(); // Met à jour l'UI avec le jeu chargé + updateUI(); // Met à jour l'affichage initial } - // L'état 'currentGameState' est défini dans loadGame ou startNewGame - Log.d(TAG, "initializeGameAndStats: État initial du jeu: " + currentGameState); } /** - * Initialise le {@link SoundPool} et charge les différents effets sonores - * utilisés dans le jeu depuis les ressources raw. - * Met en place un listener pour savoir quand les sons sont prêts à être joués. + * Initialise le SoundPool et charge les différents effets sonores utilisés dans le jeu. + * Gère les erreurs de chargement et met à jour l'état d'activation du son si nécessaire. */ private void initializeSoundPool() { - Log.d(TAG, "initializeSoundPool: Initialisation du SoundPool."); - // Configuration pour les effets sonores de jeu + // Configure les attributs audio pour les jeux AudioAttributes attributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_GAME) // Type d'usage approprié - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) // Type de contenu + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build(); - // Crée le SoundPool avec un nombre maximum de flux simultanés + // Crée le SoundPool soundPool = new SoundPool.Builder() - .setMaxStreams(3) // Permet de jouer jusqu'à 3 sons en même temps + .setMaxStreams(3) // Nombre maximum de sons joués simultanément .setAudioAttributes(attributes) .build(); - // Listener pour détecter la fin du chargement des sons + // Définit un listener pour savoir quand les sons sont chargés soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> { - if (status == 0) { - // Succès du chargement pour 'sampleId' - Log.d(TAG, "SoundPool: Son ID " + sampleId + " chargé avec succès."); - // Pourrait vérifier si tous les sons sont chargés ici, mais un flag global est plus simple - // Vérifier si tous les ID attendus sont > 0 avant de mettre le flag + if (status == 0) { // 0 signifie succès + // Vérifie si tous les sons attendus sont chargés if (soundMoveId > 0 && soundMergeId > 0 && soundWinId > 0 && soundGameOverId > 0) { - Log.i(TAG, "SoundPool: Tous les sons sont chargés."); - soundPoolLoaded = true; + soundPoolLoaded = true; // Tous les sons sont prêts } - } else { - // Échec du chargement - Log.e(TAG, "SoundPool: Échec du chargement du son ID " + sampleId + ", status: " + status); - // On pourrait désactiver le son ou juste ignorer ce son spécifique - soundEnabled = false; // Désactiver globalement par sécurité en cas d'échec - saveSoundPreference(false); // Sauvegarde la désactivation + } else { // Échec du chargement d'un son + soundEnabled = false; // Désactive les sons + saveSoundPreference(false); // Sauvegarde la préférence } }); - // Charge les sons depuis res/raw. Les IDs retournés sont stockés. - // Le 3ème argument (priority) est généralement 1 (priorité normale). + // Tente de charger chaque son depuis les ressources raw try { - Log.d(TAG, "SoundPool: Chargement des sons..."); soundMoveId = soundPool.load(this, R.raw.move, 1); soundMergeId = soundPool.load(this, R.raw.merge, 1); soundWinId = soundPool.load(this, R.raw.win, 1); soundGameOverId = soundPool.load(this, R.raw.game_over, 1); - // Vérifier immédiatement si les ID sont valides (load retourne 0 en cas d'erreur interne) + // Vérifie si load a retourné 0 (erreur) if (soundMoveId == 0 || soundMergeId == 0 || soundWinId == 0 || soundGameOverId == 0) { - throw new RuntimeException("SoundPool.load a retourné 0, échec du chargement d'un son."); + throw new RuntimeException("SoundPool.load returned 0"); } } catch (Exception e) { - Log.e(TAG, "SoundPool: Erreur lors du chargement des sons.", e); - // Gérer l'erreur, par exemple désactiver complètement le son + // En cas d'erreur (fichier manquant, etc.), désactive les sons soundEnabled = false; saveSoundPreference(false); - Toast.makeText(this, "Erreur au chargement des sons, son désactivé.", Toast.LENGTH_LONG).show(); + Toast.makeText(this, "Error loading sounds, sound disabled.", Toast.LENGTH_LONG).show(); } } /** - * Configure les listeners pour les éléments interactifs : - * - Boutons (Nouvelle Partie, Stats, Menu, Multijoueur) - * - Plateau de jeu (détection des swipes via {@link OnSwipeTouchListener}). + * Configure les OnClickListeners pour les boutons de l'interface utilisateur + * (Nouvelle Partie, Statistiques, Menu, Multijoueur) et configure le listener de swipe. */ private void setupListeners() { - Log.d(TAG, "setupListeners: Configuration des listeners."); - // Listener pour le bouton Nouvelle Partie + // Bouton Nouvelle Partie -> Affiche dialogue de confirmation newGameButton.setOnClickListener(v -> { - v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); // Effet visuel - showRestartConfirmationDialog(); // Affiche dialogue de confirmation + v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); + showRestartConfirmationDialog(); }); - // Listener pour le bouton Statistiques + // Bouton Statistiques -> Affiche/Masque le panneau des stats statisticsButton.setOnClickListener(v -> { v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - toggleStatistics(); // Affiche/Masque le panneau + toggleStatistics(); }); - // Listener pour le bouton Menu + // Bouton Menu -> Affiche le dialogue du menu principal menuButton.setOnClickListener(v -> { v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - showMenu(); // Affiche le dialogue du menu principal + showMenu(); }); - // Listener pour le bouton Multijoueur (placeholder) + // Bouton Multijoueur -> Lance l'activité MultiplayerActivity multiplayerButton.setOnClickListener(v -> { v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); - showMultiplayerScreen(); // Affiche dialogue placeholder + showMultiplayerScreen(); }); - // Attache le listener de swipe au GridLayout du plateau + // Configure la détection des swipes sur le plateau setupSwipeListener(); } - // --- Mise à jour UI --- - /** - * Met à jour l'ensemble de l'interface utilisateur pour refléter l'état actuel - * du jeu et des statistiques. Appelle {@link #syncBoardView()} et {@link #updateScores()}. + * Met à jour l'ensemble de l'interface utilisateur principale (plateau, scores). + * Actualise également le panneau des statistiques s'il est visible. */ private void updateUI() { - Log.d(TAG, "updateUI: Mise à jour de l'interface utilisateur."); if (game == null) { - Log.w(TAG, "updateUI: Tentative de mise à jour avec 'game' null."); - return; // Ne rien faire si le jeu n'est pas initialisé + return; // Ne fait rien si le jeu n'est pas initialisé } - // Utilise syncBoardView pour redessiner le plateau de manière robuste - syncBoardView(); - // Met à jour les labels de score - updateScores(); - // Si les statistiques sont visibles, les mettre à jour aussi + syncBoardView(); // Redessine le plateau de jeu + updateScores(); // Met à jour les scores affichés + // Met à jour les stats si le panneau est visible if (statisticsVisible && inflatedStatsView != null) { updateStatisticsTextViews(); } } /** - * Synchronise COMPLÈTEMENT le {@link GridLayout} (boardGridLayout) avec l'état actuel - * du plateau logique dans l'objet {@link Game} (game.board). - * Cette méthode est conçue pour être appelée après chaque modification logique du plateau. - * Étapes : - * 1. Supprime toutes les vues existantes du GridLayout. - * 2. Réinitialise le tableau `tileViews` qui stocke les références aux TextViews des tuiles. - * 3. Ajoute 16 vues de fond ({@code View}) pour structurer la grille visuellement. - * 4. Ajoute les {@code TextView} pour chaque tuile réelle (valeur > 0) par-dessus les fonds, - * en utilisant {@link #createTileTextView(int, int, int)}. - * 5. Stocke les références des TextViews des tuiles réelles dans `tileViews`. - * 6. Met à jour l'affichage textuel des scores via {@link #updateScores()}. + * Synchronise l'affichage du {@link GridLayout} (plateau) avec l'état actuel du jeu. + * Supprime toutes les vues précédentes et recrée les cellules de fond et les TextViews des tuiles + * en fonction des valeurs du plateau de l'objet {@link Game}. */ private void syncBoardView() { - // Vérifications de sécurité if (game == null || boardGridLayout == null) { - Log.e(TAG, "syncBoardView: Impossible de synchroniser, Game ou GridLayout est null !"); - return; + return; // Vérifications préliminaires } - // Log.d(TAG, "syncBoardView: Synchronisation du plateau..."); - - // --- Réinitialisation --- - boardGridLayout.removeAllViews(); // Vide complètement le GridLayout visuel - // Réinitialise le tableau qui stocke les références aux *vraies* tuiles + boardGridLayout.removeAllViews(); // Efface le contenu actuel de la grille + // Réinitialise le tableau des références aux vues des tuiles for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { tileViews[r][c] = null; } } - // Récupère la marge et le contexte une seule fois pour l'optimisation - int gridMargin = (int) getResources().getDimension(R.dimen.tile_margin); - Context context = this; // Contexte de l'activité + int gridMargin = (int) getResources().getDimension(R.dimen.tile_margin); // Marge entre les tuiles + Context context = this; - // --- Étape 1: Ajouter les 16 vues de fond pour définir la grille --- + // Crée et ajoute les cellules de fond pour chaque position for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { - // Utiliser un simple View pour le fond - View backgroundCell = new View(context); + View backgroundCell = new View(context); // Crée une vue simple + backgroundCell.setBackgroundResource(R.drawable.tile_background); // Applique le fond arrondi + backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(context, R.color.tile_empty)); // Couleur de fond par défaut - // Appliquer le style d'une cellule vide (fond + coins arrondis) - backgroundCell.setBackgroundResource(R.drawable.tile_background); - backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(context, R.color.tile_empty)); - - - // Définir les LayoutParams pour positionner cette vue de fond + // Configure les paramètres de layout pour la grille (poids égal pour toutes les cellules) GridLayout.LayoutParams params = new GridLayout.LayoutParams(); - params.width = 0; // Largeur gérée par poids - params.height = 0; // Hauteur gérée par poids - // Spécifier ligne, colonne, span=1, et poids=1 pour occuper la cellule - params.rowSpec = GridLayout.spec(r, 1, 1f); - params.columnSpec = GridLayout.spec(c, 1, 1f); - params.setMargins(gridMargin, gridMargin, gridMargin, gridMargin); + params.width = 0; params.height = 0; // Taille déterminée par le poids + params.rowSpec = GridLayout.spec(r, 1, 1f); // Poids 1 en ligne + params.columnSpec = GridLayout.spec(c, 1, 1f); // Poids 1 en colonne + params.setMargins(gridMargin, gridMargin, gridMargin, gridMargin); // Applique les marges backgroundCell.setLayoutParams(params); - - // Ajouter cette vue de fond au GridLayout - boardGridLayout.addView(backgroundCell); + boardGridLayout.addView(backgroundCell); // Ajoute la cellule de fond à la grille } } - // --- Étape 2: Ajouter les TextViews des tuiles réelles (par-dessus les fonds) --- + // Crée et ajoute les TextViews pour les tuiles ayant une valeur > 0 for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { - int value = game.getCellValue(r, c); // Récupère la valeur logique - - // Si la case logique contient une tuile réelle (valeur > 0) + int value = game.getCellValue(r, c); // Récupère la valeur de la tuile if (value > 0) { - // Crée la TextView stylisée pour cette tuile via la méthode helper + // Crée le TextView pour la tuile TextView tileTextView = createTileTextView(value, r, c); - - // Stocke la référence à cette TextView de tuile - tileViews[r][c] = tileTextView; - - // Ajoute la TextView de la tuile au GridLayout. Elle se superposera au fond. - boardGridLayout.addView(tileTextView); - // Log.d(TAG, "syncBoardView: Ajout de la tuile [" + r + "," + c + "] valeur " + value); + tileViews[r][c] = tileTextView; // Stocke la référence + boardGridLayout.addView(tileTextView); // Ajoute la tuile à la grille (par-dessus le fond) } } } - // Log.d(TAG, "syncBoardView: Synchronisation terminée."); - - // Met à jour l'affichage textuel des scores après avoir redessiné le plateau - updateScores(); + updateScores(); // Met à jour les scores après avoir redessiné le plateau } /** - * Crée et configure une {@link TextView} pour représenter une tuile du jeu. - * Applique le style visuel approprié (couleur, taille de texte) basé sur la valeur - * de la tuile via {@link #setTileStyle(TextView, int)}. - * Configure les {@link GridLayout.LayoutParams} pour positionner correctement la tuile - * dans la grille avec les marges appropriées. + * Crée une instance de {@link TextView} pour représenter une tuile du jeu. + * Applique le style approprié et configure les paramètres de layout pour le {@link GridLayout}. * - * @param value La valeur numérique de la tuile (doit être > 0). - * @param row L'indice de la ligne (0 à BOARD_SIZE-1) où la tuile doit être placée. - * @param col L'indice de la colonne (0 à BOARD_SIZE-1) où la tuile doit être placée. - * @return La {@code TextView} configurée et prête à être ajoutée au GridLayout. + * @param value La valeur de la tuile. + * @param row La ligne de la tuile. + * @param col La colonne de la tuile. + * @return Le {@link TextView} configuré pour la tuile. */ @NonNull private TextView createTileTextView(int value, int row, int col) { TextView tileTextView = new TextView(this); - setTileStyle(tileTextView, value); // Applique le style visuel (couleur, taille texte) + setTileStyle(tileTextView, value); // Applique le style visuel - // Configure les LayoutParams pour le positionnement dans le GridLayout + // Configure les paramètres de layout (identiques à ceux de la cellule de fond) GridLayout.LayoutParams params = new GridLayout.LayoutParams(); - params.width = 0; // Largeur gérée par poids - params.height = 0; // Hauteur gérée par poids - // Spécifie la position (ligne, colonne), le span (1x1) et le poids (1f) + params.width = 0; + params.height = 0; params.rowSpec = GridLayout.spec(row, 1, 1f); params.columnSpec = GridLayout.spec(col, 1, 1f); - - // Applique les marges externes int margin = (int) getResources().getDimension(R.dimen.tile_margin); params.setMargins(margin, margin, margin, margin); tileTextView.setLayoutParams(params); @@ -576,83 +471,69 @@ public class MainActivity extends AppCompatActivity { return tileTextView; } - /** - * Met à jour les TextViews affichant le score courant et le meilleur score global. - * Utilise les valeurs actuelles de l'objet {@link Game}. + * Met à jour les TextViews affichant le score actuel et le meilleur score. */ private void updateScores() { if (game != null && currentScoreTextView != null && highestScoreTextView != null) { - // Log.d(TAG, "updateScores: Score=" + game.getCurrentScore() + ", HighScore=" + game.getHighestScore()); currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore())); highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore())); - } else { - Log.w(TAG, "updateScores: Impossible de mettre à jour les scores (game ou TextViews null)."); } } /** - * Applique le style visuel (couleur de fond, couleur de texte, taille de texte) - * à une {@link TextView} représentant une tuile, en fonction de sa valeur numérique. - * Gère également le cas des cases vides (valeur 0). + * Applique le style visuel (couleur de fond, couleur et taille du texte) à un TextView de tuile + * en fonction de sa valeur numérique. * - * @param tileTextView La TextView de la tuile à styliser. - * @param value La valeur numérique de la tuile (0 pour une case vide). + * @param tileTextView Le TextView à styliser. + * @param value La valeur de la tuile. */ private void setTileStyle(@NonNull TextView tileTextView, int value) { - // Définit le texte (valeur numérique ou chaîne vide pour 0) - tileTextView.setText(value > 0 ? String.valueOf(value) : ""); + tileTextView.setText(value > 0 ? String.valueOf(value) : ""); // Affiche la valeur ou vide tileTextView.setGravity(Gravity.CENTER); // Centre le texte - tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); // Texte en gras + tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); // Met en gras + // Variables pour stocker les ID des ressources de style int backgroundColorId; int textColorId; int textSizeId; - // Sélectionne les couleurs et la taille de texte en fonction de la valeur de la tuile + // Sélectionne les styles en fonction de la valeur de la tuile switch (value) { - case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; // Case vide - case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; - case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; - case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; // Taille texte réduite pour 3 chiffres - case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; - case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; - case 1024: backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // Taille texte encore réduite pour 4 chiffres - case 2048: backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; - default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // Tuiles > 2048 + case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; + case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; + case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; + case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 1024:backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + case 2048:backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // Pour les tuiles > 2048 } - // Applique le fond (drawable avec coins arrondis) et la teinte - tileTextView.setBackgroundResource(R.drawable.tile_background); - tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId)); - - // Applique la couleur du texte - tileTextView.setTextColor(ContextCompat.getColor(this, textColorId)); - - // Applique la taille du texte (convertie de sp/dp en pixels) - tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId)); + // Applique les styles sélectionnés + tileTextView.setBackgroundResource(R.drawable.tile_background); // Applique le drawable de fond + tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId)); // Définit la couleur de fond + tileTextView.setTextColor(ContextCompat.getColor(this, textColorId)); // Définit la couleur du texte + tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId)); // Définit la taille du texte } - - // --- Gestion des Actions Utilisateur --- - /** - * Configure le listener pour détecter les gestes de balayage (swipe) sur le - * {@link GridLayout} représentant le plateau de jeu. Utilise une instance - * de {@link OnSwipeTouchListener}. + * Configure le listener pour détecter les gestes de balayage (swipe) sur le plateau de jeu. + * Utilise la classe {@link OnSwipeTouchListener}. */ - @SuppressLint("ClickableViewAccessibility") // Nécessaire car setOnTouchListener est utilisé + @SuppressLint("ClickableViewAccessibility") // Requis pour setOnTouchListener private void setupSwipeListener() { if (boardGridLayout == null) { - Log.e(TAG, "setupSwipeListener: boardGridLayout est null, impossible d'attacher le listener."); - return; + return; // Vérifie si la grille existe } - Log.d(TAG, "setupSwipeListener: Configuration du listener de swipe sur le plateau."); + // Attache le listener à la grille boardGridLayout.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() { + // Appelle handleSwipe avec la direction correspondante pour chaque type de swipe détecté @Override public void onSwipeTop() { handleSwipe(Direction.UP); } @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); } @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); } @@ -661,39 +542,25 @@ public class MainActivity extends AppCompatActivity { } /** - * Gère un geste de swipe détecté sur le plateau. - * 1. Vérifie si le jeu est dans un état où un mouvement est autorisé. - * 2. Appelle la méthode de logique de jeu appropriée (`game.pushX()`). - * 3. Si le plateau a changé : - * a. Joue les sons appropriés (mouvement, fusion). - * b. Enregistre les statistiques (mouvement, fusion, tuile max, score). - * c. Ajoute une nouvelle tuile logique via `game.addNewTile()`. - * d. Met à jour l'affichage COMPLET du plateau via `syncBoardView()`. - * e. Lance les animations locales (apparition, fusion) sur les vues mises à jour via `animateChanges()`. - * 4. Si le plateau n'a pas changé, vérifie quand même si c'est un état de Game Over. - * 5. La vérification finale des conditions de victoire/défaite est déléguée à `checkEndGameConditions()`, - * appelée après la fin des animations. + * Gère un geste de balayage (swipe) détecté. + * Appelle la méthode correspondante dans l'objet {@link Game} pour effectuer le mouvement. + * Si le plateau change, met à jour les statistiques, joue les sons appropriés, ajoute une nouvelle tuile, + * met à jour l'UI et lance les animations. + * Vérifie ensuite les conditions de fin de partie (victoire ou game over). * - * @param direction La direction du swipe détecté ({@link Direction}). + * @param direction La direction du balayage. */ private void handleSwipe(Direction direction) { - Log.d(TAG, "handleSwipe: Geste détecté -> " + direction); - - // Bloque les mouvements si le jeu n'est pas initialisé ou est terminé + // Ne fait rien si le jeu n'est pas initialisé, pas de stats, ou si la partie est déjà terminée if (game == null || gameStats == null || currentGameState == GameFlowState.GAME_OVER) { - Log.d(TAG, "handleSwipe: Mouvement ignoré (jeu non prêt ou terminé). State: " + currentGameState); return; } - // On pourrait aussi bloquer pendant une animation si nécessaire, mais l'approche actuelle - // met à jour l'UI puis anime, ce qui rend un blocage moins critique. - // Sauvegarde l'état avant le mouvement pour les animations et la détection de changement - int scoreBefore = game.getCurrentScore(); - int[][] boardBeforePush = game.getBoard(); // Copie profonde de l'état avant + int scoreBefore = game.getCurrentScore(); // Score avant le mouvement + int[][] boardBeforePush = game.getBoard(); // État du plateau avant le mouvement (pour animations) + boolean boardChanged = false; // Indicateur si le mouvement a modifié le plateau - boolean boardChanged = false; // Indicateur si le mouvement a modifié le plateau logique - - // Appelle la méthode logique correspondante dans Game + // Appelle la méthode de mouvement correspondante dans l'objet Game switch (direction) { case UP: boardChanged = game.pushUp(); break; case DOWN: boardChanged = game.pushDown(); break; @@ -701,456 +568,310 @@ public class MainActivity extends AppCompatActivity { case RIGHT: boardChanged = game.pushRight(); break; } - // Si le mouvement a effectivement modifié quelque chose sur le plateau logique... + // Si le mouvement a modifié le plateau if (boardChanged) { - Log.d(TAG, "handleSwipe: Le plateau a changé."); playSound(soundMoveId); // Joue le son de mouvement + int[][] boardAfterPush = game.getBoard(); // État du plateau après le push/merge - // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile (utile pour animer les fusions) - int[][] boardAfterPush = game.getBoard(); - - // --- Mise à jour des statistiques --- + // Met à jour les statistiques gameStats.recordMove(); // Enregistre le mouvement int scoreAfter = game.getCurrentScore(); - if (scoreAfter > scoreBefore) { // Si le score a augmenté, une fusion a eu lieu - Log.d(TAG, "handleSwipe: Fusion détectée (score " + scoreBefore + " -> " + scoreAfter + ")"); + if (scoreAfter > scoreBefore) { // Si le score a augmenté (fusion) playSound(soundMergeId); // Joue le son de fusion - // Simplifié : on compte 1 pour chaque mouvement entraînant une augmentation de score. - // Pour être précis, il faudrait que game.pushX retourne le nombre de fusions. - gameStats.recordMerge(1); // TODO: Améliorer pour compter précisément les fusions si nécessaire - - // Vérifie et met à jour le meilleur score si nécessaire + gameStats.recordMerge(1); // Enregistre la fusion (suppose 1 par coup pour simplifier) + // Met à jour le meilleur score si nécessaire if (scoreAfter > game.getHighestScore()) { - Log.i(TAG, "handleSwipe: Nouveau High Score! " + scoreAfter); - game.setHighestScore(scoreAfter); // Met à jour dans l'objet Game - gameStats.setHighestScore(scoreAfter); // Met à jour dans GameStats (sera sauvegardé via saveStats) - // On pourrait envoyer une notif de nouveau HS ici si désiré et permis - // showNotification(this, "Nouveau Record !", "Meilleur score : " + scoreAfter, 4); + game.setHighestScore(scoreAfter); + gameStats.setHighestScore(scoreAfter); } } - // Met à jour la stat de la plus haute tuile atteinte globalement - gameStats.updateHighestTile(game.getHighestTileValue()); + gameStats.updateHighestTile(game.getHighestTileValue()); // Met à jour la plus haute tuile atteinte - // --- Ajout de la nouvelle tuile --- - Log.d(TAG, "handleSwipe: Ajout d'une nouvelle tuile..."); - game.addNewTile(); - int[][] boardAfterAdd = game.getBoard(); // État logique final après ajout + game.addNewTile(); // Ajoute une nouvelle tuile aléatoire + int[][] boardAfterAdd = game.getBoard(); // État final du plateau après ajout - // --- Mise à jour VISUELLE du plateau --- - Log.d(TAG, "handleSwipe: Synchronisation de la vue du plateau..."); - syncBoardView(); // Redessine TOUT le plateau basé sur boardAfterAdd - - // --- Lancement des ANIMATIONS locales --- - Log.d(TAG, "handleSwipe: Lancement des animations..."); - animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd); // Anime apparition/fusion - - // La vérification de fin de partie (checkEndGameConditions) est maintenant appelée - // à la fin des animations dans animateChanges. + syncBoardView(); // Met à jour l'affichage du plateau (sans animation ici, car animateChanges le fera) + animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd); // Lance les animations + // Les conditions de fin de partie sont vérifiées à la fin des animations } else { - // Le mouvement n'a rien changé sur le plateau logique. - Log.d(TAG, "handleSwipe: Mouvement invalide (aucune tuile n'a bougé)."); - // Vérifier si c'est un état de Game Over (plateau plein et bloqué) - // Note: game.pushX() a déjà appelé checkGameOverCondition, on vérifie juste le flag. + // Si le plateau n'a pas changé, vérifie immédiatement si c'est un game over if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) { - Log.i(TAG, "handleSwipe: Mouvement invalide ET état Game Over détecté."); - currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état - long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + currentGameState = GameFlowState.GAME_OVER; gameStats.recordLoss(); // Enregistre la défaite - // gameStats.endGame(timeTaken); // endGame est appelé par recordLoss/recordWin - showGameOverDialog(); // Affiche le dialogue de fin + showGameOverDialog(); // Affiche le dialogue de fin de partie } } } /** - * Joue un effet sonore via le SoundPool, si celui-ci est chargé, - * si le son est globalement activé par l'utilisateur (`soundEnabled`), - * si le SoundPool lui-même est initialisé et si l'ID du son est valide. - * Ne fait rien si une de ces conditions n'est pas remplie. + * Joue un effet sonore si les sons sont activés et chargés. * - * @param soundId L'ID entier du son à jouer, tel que retourné par {@code soundPool.load()}. - * Doit être supérieur à 0 pour être valide. + * @param soundId L'ID du son à jouer (obtenu via {@link SoundPool#load}). */ private void playSound(int soundId) { - // Vérifie toutes les conditions avant de tenter de jouer le son + // Vérifie si le pool est chargé, les sons activés, le pool existe et l'ID est valide if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) { - // Arguments de soundPool.play: - // soundID: L'ID du son chargé. - // leftVolume: Volume pour le canal gauche (0.0 à 1.0). - // rightVolume: Volume pour le canal droit (0.0 à 1.0). - // priority: Priorité du flux (0 = la plus basse). Non utilisé pour la dépréemption. - // loop: Mode de boucle (-1 = boucle infinie, 0 = ne pas boucler, >0 = nombre de répétitions). - // rate: Vitesse de lecture (1.0 = vitesse normale, 0.5 = demi-vitesse, 2.0 = double vitesse). - soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f); - // Log.d(TAG, "playSound: Joué son ID " + soundId); // Décommenter pour débogage - } else { - // Log pour déboguer pourquoi le son n'est pas joué - /* - Log.d(TAG, "playSound: Son ID " + soundId + " non joué. Conditions: " + - "soundPoolLoaded=" + soundPoolLoaded + - ", soundEnabled=" + soundEnabled + - ", soundPool != null=" + (soundPool != null) + - ", soundId > 0=" + (soundId > 0)); - */ + // Joue le son + soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f); // volume G/D, priorité, loop, rate } } /** - * Analyse les différences entre l'état du plateau avant et après un mouvement - * pour identifier les tuiles qui sont apparues ou qui ont fusionné. - * Lance des animations locales (scale, alpha) sur les {@link TextView} correspondantes - * qui ont DÉJÀ été mises à jour et positionnées par {@link #syncBoardView()}. - * À la fin de l'ensemble des animations, appelle {@link #checkEndGameConditions()}. + * Anime les changements survenus sur le plateau après un mouvement. + * Détecte les nouvelles tuiles apparues et les tuiles fusionnées et lance les animations correspondantes. + * Vérifie les conditions de fin de partie une fois les animations terminées. * - * @param boardBeforePush État du plateau (valeurs) avant le début du mouvement/fusion. - * @param boardAfterPush État du plateau après le mouvement/fusion mais AVANT l'ajout de la nouvelle tuile. + * @param boardBeforePush État du plateau avant le mouvement (push/merge). + * @param boardAfterPush État du plateau après le mouvement mais avant l'ajout de la nouvelle tuile. * @param boardAfterAdd État final du plateau après l'ajout de la nouvelle tuile. */ private void animateChanges(@NonNull int[][] boardBeforePush, @NonNull int[][] boardAfterPush, @NonNull int[][] boardAfterAdd) { List animations = new ArrayList<>(); // Liste pour regrouper les animations - // Parcourt le plateau final + // Parcours le plateau final for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { - TextView currentView = tileViews[r][c]; // Récupère la vue TextView à la position finale [r][c] + TextView currentView = tileViews[r][c]; // Vue à cette position + if (currentView == null) continue; // Passe si pas de vue (cellule vide après coup) - // Si pas de vue à cette position (case vide dans l'état final), on passe - if (currentView == null) continue; + int valueAfterAdd = boardAfterAdd[r][c]; // Valeur finale + int valueAfterPush = boardAfterPush[r][c]; // Valeur après push/merge - int valueAfterAdd = boardAfterAdd[r][c]; // Valeur finale - int valueAfterPush = boardAfterPush[r][c]; // Valeur intermédiaire (après fusion, avant ajout) - // int valueBeforePush = boardBeforePush[r][c]; // Valeur initiale à [r][c] (pas toujours utile ici) - - // Trouver la ou les tuiles d'origine qui ont mené à cette position - // C'est complexe. Simplifions : animons basé sur ce qui s'est passé à la position [r][c] - - // 1. Animation d'Apparition : - // Si la case [r][c] était vide après le push, mais a une valeur maintenant (après add), - // c'est la NOUVELLE tuile qui vient d'apparaître à cette position. + // Détecte l'apparition d'une nouvelle tuile (était vide après push, mais a une valeur après add) if (valueAfterPush == 0 && valueAfterAdd > 0) { - // Log.d(TAG, "animateChanges: Animation APPARITION pour [" + r + "," + c + "]"); - // Prépare la vue pour l'animation (petite et transparente) + // Prépare l'animation d'apparition currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f); - // Crée et ajoute l'animation d'apparition animations.add(createAppearAnimation(currentView)); } - // 2. Animation de Fusion : - // Si la valeur à [r][c] APRÈS le push est plus grande que la valeur AVANT le push - // à CETTE MÊME position [r][c] (cela signifie qu'une fusion s'est produite ICI). - // Note: Cette condition est approximative. Une tuile peut s'être déplacée PUIS avoir fusionné ailleurs. - // Ou une tuile peut avoir fusionné DANS cette case. - // Pour une animation de "pulse" simple sur la tuile résultante de la fusion : + + // Détecte une fusion (valeur après push > valeur avant push, et n'était pas 0 avant) boolean fusionOccurredAtThisPosition = false; - // Vérifier si la valeur a augmenté à cette position pendant la phase de push/merge if (valueAfterPush > boardBeforePush[r][c] && boardBeforePush[r][c] != 0) { fusionOccurredAtThisPosition = true; } - // Vérifier aussi si une tuile s'est déplacée vers ici et a fusionné (valeur avant=0, valeur après push > 0 ET différente de la valeur après add si c'était la nouvelle tuile) - // C'est complexe. L'approche la plus simple est d'animer si la valeur a augmenté par rapport à l'état AVANT le push. - else { - // Chercher si une tuile adjacente de même valeur a disparu lors du push - // ... logique complexe ... - } - // Si on détecte une fusion à cette position [r][c] (par augmentation de valeur par ex) - // ET que ce n'est pas la nouvelle tuile qui vient d'apparaître (géré au-dessus) + // Ajoute l'animation de fusion si fusion a eu lieu ET si ce n'est pas la tuile qui vient juste d'apparaître if (fusionOccurredAtThisPosition && !(valueAfterPush == 0 && valueAfterAdd > 0)) { - // Log.d(TAG, "animateChanges: Animation FUSION pour [" + r + "," + c + "]"); - // Crée et ajoute l'animation de fusion (pulse) animations.add(createMergeAnimation(currentView)); } - - // Note : Les tuiles qui ont simplement bougé ne sont pas explicitement animées ici. - // L'effet visuel vient du fait que syncBoardView les a placées à leur nouvelle position. } } - // Si des animations ont été créées, on les joue ensemble + // Si des animations sont à jouer if (!animations.isEmpty()) { - Log.d(TAG, "animateChanges: Démarrage de " + animations.size() + " animations."); AnimatorSet animatorSet = new AnimatorSet(); - animatorSet.playTogether(animations); // Joue toutes les animations en parallèle + animatorSet.playTogether(animations); // Joue toutes les animations ensemble + // Ajoute un listener pour vérifier la fin de partie APRÈS les animations animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { - // Appelée quand TOUTES les animations de l'AnimatorSet sont terminées - Log.d(TAG, "animateChanges: Fin des animations."); - // Vérifie maintenant les conditions de fin de partie - checkEndGameConditions(); + checkEndGameConditions(); // Vérifie victoire/défaite } }); - animatorSet.start(); // Démarre l'ensemble des animations + animatorSet.start(); // Démarre les animations } else { - // Si aucune animation n'a été générée (ex: mouvement simple sans fusion ni nouvelle tuile) - Log.d(TAG, "animateChanges: Aucune animation à jouer."); - // Vérifie quand même immédiatement les conditions de fin de partie + // S'il n'y a pas d'animations (ex: mouvement sans changement), vérifie quand même la fin de partie checkEndGameConditions(); } } /** - * Crée une animation d'apparition pour une vue (typiquement une nouvelle tuile). - * L'animation combine une augmentation de taille (scale) et une apparition en fondu (alpha). + * Crée un {@link Animator} pour l'animation d'apparition d'une tuile (scale + alpha). * - * @param view La vue (TextView de la tuile) à animer. - * @return Un objet {@link Animator} représentant l'animation d'apparition. + * @param view La vue de la tuile à animer. + * @return L'Animator configuré. */ private Animator createAppearAnimation(@NonNull View view) { - // Animation de 0.3 à 1.0 pour la largeur et la hauteur ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f); ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f); - // Animation de 0.0 (transparent) à 1.0 (opaque) pour l'alpha ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f); - - // Regroupe les animations pour les jouer en même temps AnimatorSet set = new AnimatorSet(); set.playTogether(scaleX, scaleY, alpha); - set.setDuration(150); // Durée courte pour l'apparition (en ms) + set.setDuration(150); // Durée en ms return set; } /** - * Crée une animation de "pulse" (léger grossissement puis retour à la normale) - * pour une vue (typiquement une tuile résultant d'une fusion). + * Crée un {@link Animator} pour l'animation de fusion d'une tuile (effet "pop"). * - * @param view La vue (TextView de la tuile fusionnée) à animer. - * @return Un objet {@link Animator} représentant l'animation de fusion. + * @param view La vue de la tuile à animer. + * @return L'Animator configuré. */ private Animator createMergeAnimation(@NonNull View view) { - // Animation de 1.0 à 1.2 puis retour à 1.0 pour la largeur et la hauteur - ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); // Grossit puis revient ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); - - // Regroupe les animations AnimatorSet set = new AnimatorSet(); set.playTogether(scaleX, scaleY); - set.setDuration(120); // Durée très courte pour l'effet "pulse" (en ms) + set.setDuration(120); // Durée en ms return set; } /** - * Vérifie les conditions de fin de partie (Victoire ou Game Over) après qu'un - * mouvement (et ses animations éventuelles) a été complété. - * Met à jour l'état du jeu (`currentGameState`) et affiche les dialogues appropriés. - * Enregistre également les statistiques de victoire/défaite. + * Vérifie si les conditions de fin de partie (victoire ou game over) sont remplies + * et met à jour l'état du jeu ({@link #currentGameState}) et les statistiques en conséquence. + * Affiche les dialogues appropriés. */ private void checkEndGameConditions() { - Log.d(TAG, "checkEndGameConditions: Vérification de l'état de fin de partie."); if (game == null || gameStats == null) { - Log.w(TAG, "checkEndGameConditions: Jeu ou Stats non initialisé."); - return; + return; // Vérifications } - // Vérifier la condition de VICTOIRE - // On vérifie si le jeu est gagné ET si on n'a pas déjà montré le dialogue de victoire + // Vérifie la victoire (si pas déjà dans un état final) if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { - Log.i(TAG, "checkEndGameConditions: Victoire détectée !"); - currentGameState = GameFlowState.WON_DIALOG_SHOWN; // Marque que le dialogue va être montré - long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.recordWin(timeTaken); // Enregistre la victoire et met à jour les temps - showGameWonKeepPlayingDialog(); // Affiche le dialogue "Gagné / Continuer ?" - // Si une notification d'accomplissement est souhaitée : - showAchievementNotification(game.getHighestTileValue()); - - // Vérifier la condition de GAME OVER (si pas déjà gagné ou terminé) - } else if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) { - Log.i(TAG, "checkEndGameConditions: Game Over détecté !"); - currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état - long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.recordLoss(); // Enregistre la défaite - // gameStats.endGame(timeTaken); // Est appelé par recordLoss - showGameOverDialog(); // Affiche le dialogue "Game Over" - - // Sinon, le jeu continue normalement - } else { - Log.d(TAG, "checkEndGameConditions: Le jeu continue."); + currentGameState = GameFlowState.WON_DIALOG_SHOWN; // Met à jour l'état + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); // Calcule le temps + gameStats.recordWin(timeTaken); // Enregistre la victoire dans les stats + showGameWonKeepPlayingDialog(); // Affiche le dialogue de victoire + showAchievementNotification(game.getHighestTileValue()); // Affiche une notification de succès + } + // Vérifie le game over (si pas déjà dans un état final) + else if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) { + currentGameState = GameFlowState.GAME_OVER; // Met à jour l'état + gameStats.recordLoss(); // Enregistre la défaite dans les stats + showGameOverDialog(); // Affiche le dialogue de game over } - // Sauvegarder l'état après chaque vérification pourrait être utile - // saveGame(); - // gameStats.saveStats(); } - /** Énumération interne simple pour représenter les directions de swipe. */ + /** + * Énumération privée pour représenter les directions de swipe. + */ private enum Direction { UP, DOWN, LEFT, RIGHT } - // --- Dialogues --- - /** - * Affiche une boîte de dialogue demandant à l'utilisateur de confirmer - * s'il souhaite réellement redémarrer la partie en cours. - * Utilise le layout personnalisé `dialog_restart_confirm.xml`. + * Affiche un dialogue demandant confirmation avant de redémarrer une nouvelle partie. */ private void showRestartConfirmationDialog() { - Log.d(TAG, "Affichage dialogue confirmation redémarrage."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); // Gonfle le layout personnalisé + // Charge le layout personnalisé du dialogue + View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); builder.setView(dialogView); - // Empêche de fermer en cliquant à l'extérieur - builder.setCancelable(false); + builder.setCancelable(false); // Empêche de fermer en cliquant à l'extérieur + // Récupère les boutons du dialogue Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton); Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton); - final AlertDialog dialog = builder.create(); // Crée la dialogue mais ne l'affiche pas encore + final AlertDialog dialog = builder.create(); // Crée le dialogue - // Listener pour le bouton Annuler - cancelButton.setOnClickListener(v -> { - Log.d(TAG, "Dialogue redémarrage: Annulé."); - dialog.dismiss(); // Ferme la dialogue - }); - - // Listener pour le bouton Confirmer + // Actions des boutons + cancelButton.setOnClickListener(v -> dialog.dismiss()); // Ferme le dialogue confirmButton.setOnClickListener(v -> { - Log.d(TAG, "Dialogue redémarrage: Confirmé."); - dialog.dismiss(); // Ferme la dialogue + dialog.dismiss(); // Ferme le dialogue startNewGame(); // Lance une nouvelle partie }); - - dialog.show(); // Affiche la dialogue + dialog.show(); // Affiche le dialogue } /** * Démarre une nouvelle partie. - * 1. Informe `GameStats` que la nouvelle partie commence (pour réinitialiser les stats de partie). - * 2. Crée une nouvelle instance de `Game`. - * 3. Applique le meilleur score global (connu par `GameStats`) à cette nouvelle partie. - * 4. Met l'état du jeu (`currentGameState`) à `PLAYING`. - * 5. Ferme le panneau de statistiques s'il était ouvert. - * 6. Met à jour l'interface utilisateur (plateau, scores) pour refléter la nouvelle partie. + * Réinitialise l'objet {@link Game}, met à jour les statistiques de début de partie, + * réinitialise l'état du flux de jeu, ferme le panneau des statistiques s'il est ouvert, + * et met à jour l'interface utilisateur. */ private void startNewGame() { - Log.i(TAG, "startNewGame: Démarrage d'une nouvelle partie."); - if (gameStats == null) { - Log.e(TAG, "startNewGame: GameStats est null! Tentative de réinitialisation."); - gameStats = new GameStats(this); // Précaution + if (gameStats == null) { // S'assure que gameStats est initialisé + gameStats = new GameStats(this); } - // Informe GameStats du début de la partie (reset timers/compteurs de partie) - gameStats.startGame(); + gameStats.startGame(); // Notifie GameStats du début d'une nouvelle partie - // Crée une nouvelle instance de la logique du jeu - game = new Game(); - // Applique le meilleur score global connu au nouvel objet Game + game = new Game(); // Crée une nouvelle instance de jeu + // Met à jour le meilleur score du jeu avec celui des stats globales game.setHighestScore(gameStats.getOverallHighScore()); + currentGameState = GameFlowState.PLAYING; // Réinitialise l'état du jeu - // Définit l'état du flux de jeu - currentGameState = GameFlowState.PLAYING; - - // Ferme le panneau de statistiques s'il était ouvert + // Ferme les statistiques si elles sont ouvertes if (statisticsVisible) { - Log.d(TAG, "startNewGame: Fermeture du panneau de statistiques."); - toggleStatistics(); // Utilise la méthode existante pour masquer proprement + toggleStatistics(); } - - // Met à jour l'interface utilisateur pour afficher le nouveau plateau et les scores (0) - updateUI(); // Appellera syncBoardView et updateScores + updateUI(); // Met à jour l'affichage pour la nouvelle partie } - /** - * Affiche la boîte de dialogue signalant que le joueur a gagné (atteint 2048). - * Utilise le layout personnalisé `dialog_game_won.xml`. - * Propose de continuer à jouer ("Keep Playing") ou de commencer une nouvelle partie. - * Joue le son de victoire. + * Affiche le dialogue indiquant que le joueur a gagné (atteint 2048 ou plus) + * et lui propose de continuer à jouer ou de commencer une nouvelle partie. + * Joue également le son de victoire. */ private void showGameWonKeepPlayingDialog() { - Log.i(TAG, "Affichage dialogue de victoire."); playSound(soundWinId); // Joue le son de victoire AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_game_won, null); // Gonfle le layout + // Charge le layout personnalisé + View dialogView = inflater.inflate(R.layout.dialog_game_won, null); builder.setView(dialogView); - builder.setCancelable(false); // Empêche de fermer sans choisir une option + builder.setCancelable(false); // Ne peut pas être fermé en cliquant à l'extérieur + // Récupère les boutons Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton); - Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonWon); // Renommé pour éviter conflit + Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonWon); final AlertDialog dialog = builder.create(); - // Listener pour "Continuer à jouer" - keepPlayingButton.setOnClickListener(v -> { - Log.d(TAG, "Dialogue victoire: Continué."); - // L'état est déjà WON_DIALOG_SHOWN, le jeu peut continuer si l'utilisateur swipe. - // On ferme juste la dialogue. - dialog.dismiss(); - }); - - // Listener pour "Nouvelle Partie" + // Actions des boutons + keepPlayingButton.setOnClickListener(v -> dialog.dismiss()); // Ferme juste le dialogue, le jeu continue newGameButtonDialog.setOnClickListener(v -> { - Log.d(TAG, "Dialogue victoire: Nouvelle partie."); - dialog.dismiss(); - startNewGame(); // Démarre une nouvelle partie + dialog.dismiss(); // Ferme le dialogue + startNewGame(); // Commence une nouvelle partie }); - - dialog.show(); + dialog.show(); // Affiche le dialogue } /** - * Affiche la boîte de dialogue signalant la fin de partie (Game Over). - * Utilise le layout personnalisé `dialog_game_over.xml`. - * Affiche le score final et propose de commencer une Nouvelle Partie ou de Quitter l'application. - * Joue le son de fin de partie. + * Affiche le dialogue indiquant que la partie est terminée (game over). + * Affiche le score final et propose de commencer une nouvelle partie ou de quitter. + * Joue également le son de fin de partie. */ private void showGameOverDialog() { - Log.i(TAG, "Affichage dialogue Game Over. Score final: " + (game != null ? game.getCurrentScore() : "N/A")); - playSound(soundGameOverId); // Joue le son de Game Over + playSound(soundGameOverId); // Joue le son de game over AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_game_over, null); // Gonfle le layout + // Charge le layout personnalisé + View dialogView = inflater.inflate(R.layout.dialog_game_over, null); builder.setView(dialogView); - builder.setCancelable(false); // Empêche de fermer sans choisir + builder.setCancelable(false); // Non annulable + // Récupère les vues du dialogue TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver); - Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); // Renommé + Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver); - // Affiche le message avec le score final + // Affiche le score final dans le message if (game != null) { messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore())); } else { - messageTextView.setText(getString(R.string.game_over_title)); // Message générique si game est null + messageTextView.setText(getString(R.string.game_over_title)); // Message générique si jeu null } final AlertDialog dialog = builder.create(); - // Listener pour "Nouvelle Partie" + // Actions des boutons newGameButtonDialog.setOnClickListener(v -> { - Log.d(TAG, "Dialogue Game Over: Nouvelle partie."); dialog.dismiss(); - startNewGame(); + startNewGame(); // Nouvelle partie }); - - // Listener pour "Quitter" quitButton.setOnClickListener(v -> { - Log.d(TAG, "Dialogue Game Over: Quitter."); dialog.dismiss(); - finish(); // Ferme l'activité actuelle (et l'application si c'est la seule activité) + finish(); // Ferme l'activité (quitte l'application si c'est la seule activité) }); - - dialog.show(); + dialog.show(); // Affiche le dialogue } - // --- Menu Principal --- - /** - * Affiche la boîte de dialogue du menu principal. - * Utilise le layout personnalisé `dialog_main_menu.xml`. - * Contient des boutons pour "Comment Jouer", "Paramètres", "À Propos", et "Retour". + * Affiche le dialogue du menu principal avec les options : Comment Jouer, Paramètres, À Propos, Retour. */ private void showMenu() { - Log.d(TAG, "Affichage dialogue du menu principal."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); // Gonfle le layout personnalisé + View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); builder.setView(dialogView); - builder.setCancelable(true); // Permet de fermer en cliquant à côté + builder.setCancelable(true); // Peut être fermé en cliquant à l'extérieur - // Récupère les boutons du layout personnalisé + // Récupère les boutons du menu Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay); Button settingsButton = dialogView.findViewById(R.id.menuButtonSettings); Button aboutButton = dialogView.findViewById(R.id.menuButtonAbout); @@ -1158,77 +879,41 @@ public class MainActivity extends AppCompatActivity { final AlertDialog dialog = builder.create(); - // Attache les listeners aux boutons du menu - howToPlayButton.setOnClickListener(v -> { - Log.d(TAG, "Menu: Clic sur Comment Jouer."); - dialog.dismiss(); // Ferme le menu - showHowToPlayDialog(); // Ouvre la dialogue "Comment Jouer" - }); - - settingsButton.setOnClickListener(v -> { - Log.d(TAG, "Menu: Clic sur Paramètres."); - dialog.dismiss(); // Ferme le menu - showSettingsDialog(); // Ouvre la dialogue "Paramètres" - }); - - aboutButton.setOnClickListener(v -> { - Log.d(TAG, "Menu: Clic sur À Propos."); - dialog.dismiss(); // Ferme le menu - showAboutDialog(); // Ouvre la dialogue "À Propos" - }); - - returnButton.setOnClickListener(v -> { - Log.d(TAG, "Menu: Clic sur Retour."); - dialog.dismiss(); // Ferme simplement le menu - }); - - dialog.show(); // Affiche la boîte de dialogue du menu - } - - /** - * Affiche une boîte de dialogue expliquant les règles du jeu 2048. - * Utilise le layout personnalisé `dialog_how_to_play.xml`. - */ - private void showHowToPlayDialog() { - Log.d(TAG, "Affichage dialogue Comment Jouer."); - AlertDialog.Builder builder = new AlertDialog.Builder(this); - LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_how_to_play, null); // Gonfle le layout - builder.setView(dialogView); - builder.setCancelable(true); // Permet de fermer en cliquant à côté - - // Récupère le bouton OK DANS la vue gonflée - Button okButton = dialogView.findViewById(R.id.dialogOkButtonHowToPlay); - - final AlertDialog dialog = builder.create(); - - okButton.setOnClickListener(v -> { - Log.d(TAG, "Dialogue Comment Jouer: OK cliqué."); - dialog.dismiss(); // Ferme simplement - }); - + // Actions des boutons du menu + howToPlayButton.setOnClickListener(v -> { dialog.dismiss(); showHowToPlayDialog(); }); + settingsButton.setOnClickListener(v -> { dialog.dismiss(); showSettingsDialog(); }); + aboutButton.setOnClickListener(v -> { dialog.dismiss(); showAboutDialog(); }); + returnButton.setOnClickListener(v -> dialog.dismiss()); // Ferme simplement le menu dialog.show(); } /** - * Affiche la boîte de dialogue des paramètres de l'application. - * Utilise le layout personnalisé `dialog_settings.xml`. - * Permet à l'utilisateur de gérer : - * - Activation/Désactivation des effets sonores. - * - Activation/Désactivation des notifications (avec demande de permission si nécessaire). - * - Accès aux paramètres système de l'application (pour gérer les permissions manuellement). - * - Partage des statistiques. - * - Réinitialisation des statistiques (avec confirmation). - * - Quitter l'application. + * Affiche un dialogue simple expliquant les règles du jeu. */ - private void showSettingsDialog() { - Log.d(TAG, "Affichage dialogue Paramètres."); + private void showHowToPlayDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_settings, null); // Gonfle le layout - builder.setView(dialogView).setCancelable(true); // Permet de fermer en cliquant à côté + View dialogView = inflater.inflate(R.layout.dialog_how_to_play, null); + builder.setView(dialogView); + builder.setCancelable(true); // Annulable + Button okButton = dialogView.findViewById(R.id.dialogOkButtonHowToPlay); // Bouton OK pour fermer + final AlertDialog dialog = builder.create(); + okButton.setOnClickListener(v -> dialog.dismiss()); + dialog.show(); + } - // Récupération des vues dans la dialogue + /** + * Affiche le dialogue des paramètres, permettant à l'utilisateur d'activer/désactiver + * le son et les notifications, de gérer les permissions, de partager ou réinitialiser + * les statistiques, et de quitter l'application. + */ + private void showSettingsDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LayoutInflater inflater = getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_settings, null); + builder.setView(dialogView).setCancelable(true); // Annulable + + // Récupère les vues des paramètres SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound); SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions); @@ -1236,558 +921,372 @@ public class MainActivity extends AppCompatActivity { Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats); Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp); Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings); - // Les boutons de test ont été retirés car non ajoutés au layout final AlertDialog dialog = builder.create(); - // --- Configuration du Switch Son --- - switchSound.setChecked(soundEnabled); // Initialise avec l'état actuel + // Configure le switch Son + switchSound.setChecked(soundEnabled); // État initial switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> { - Log.d(TAG, "Paramètres: Switch Son changé à " + isChecked); - soundEnabled = isChecked; // Met à jour l'état interne + soundEnabled = isChecked; // Met à jour l'état local saveSoundPreference(isChecked); // Sauvegarde la préférence + // Affiche un Toast de confirmation Toast.makeText(this, isChecked ? R.string.sound_enabled : R.string.sound_disabled, Toast.LENGTH_SHORT).show(); - // Jouer un petit son de test ici ? Optionnel. }); - // --- Configuration du Switch Notifications --- - switchNotifications.setChecked(notificationsEnabled); // Initialise avec l'état actuel + // Configure le switch Notifications + switchNotifications.setChecked(notificationsEnabled); // État initial switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> { - Log.d(TAG, "Paramètres: Switch Notifications changé à " + isChecked); if (isChecked) { - // L'utilisateur VEUT activer les notifications - Log.d(TAG, "Paramètres: Demande d'activation des notifications."); - requestNotificationPermission(); // Demande la permission si nécessaire (gère aussi l'activation si déjà accordée) + // Si activé, demande la permission (gère la logique interne) + requestNotificationPermission(); } else { - // L'utilisateur désactive les notifications - Log.d(TAG, "Paramètres: Désactivation des notifications."); + // Si désactivé notificationsEnabled = false; - saveNotificationPreference(false); + saveNotificationPreference(false); // Sauvegarde la préférence Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); - // Arrêter le service de notification si l'utilisateur désactive - stopNotificationService(); - // Annuler les éventuelles notifications planifiées (via WorkManager/AlarmManager si utilisé) + cancelPeriodicNotifications(); // Annule le worker de notification } - // Note: L'état 'notificationsEnabled' est mis à jour dans le callback de requestPermissionLauncher si la permission est accordée/refusée. - // Le switch peut se désynchroniser brièvement si l'utilisateur refuse la permission. updateNotificationSwitchState est appelé dans le callback. }); - // --- Listeners pour les autres boutons --- - // Bouton Gérer Permissions -> Ouvre les paramètres système de l'app - permissionsButton.setOnClickListener(v -> { - Log.d(TAG, "Paramètres: Clic sur Gérer Permissions."); - openAppSettings(); - dialog.dismiss(); // Ferme les paramètres de l'app après avoir ouvert ceux du système - }); + // Configure les boutons + permissionsButton.setOnClickListener(v -> { openAppSettings(); dialog.dismiss(); }); // Ouvre les paramètres système de l'app + shareStatsButton.setOnClickListener(v -> { shareStats(); dialog.dismiss(); }); // Partage les statistiques + resetStatsButton.setOnClickListener(v -> { dialog.dismiss(); showResetStatsConfirmationDialog(); }); // Demande confirmation avant reset + quitAppButton.setOnClickListener(v -> { dialog.dismiss(); finishAffinity(); }); // Quitte complètement l'application + closeButton.setOnClickListener(v -> dialog.dismiss()); // Ferme le dialogue des paramètres - // Bouton Partager Stats -> Lance une Intent de partage - shareStatsButton.setOnClickListener(v -> { - Log.d(TAG, "Paramètres: Clic sur Partager Stats."); - shareStats(); - dialog.dismiss(); - }); - - // Bouton Réinitialiser Stats -> Affiche dialogue de confirmation - resetStatsButton.setOnClickListener(v -> { - Log.d(TAG, "Paramètres: Clic sur Réinitialiser Stats."); - dialog.dismiss(); // Ferme les paramètres AVANT d'ouvrir la confirmation - showResetStatsConfirmationDialog(); - }); - - // Bouton Quitter l'App -> Ferme toutes les activités de l'application - quitAppButton.setOnClickListener(v -> { - Log.d(TAG, "Paramètres: Clic sur Quitter App."); - dialog.dismiss(); - finishAffinity(); // Ferme l'application complètement - }); - - // Bouton Fermer -> Ferme simplement la dialogue des paramètres - closeButton.setOnClickListener(v -> { - Log.d(TAG, "Paramètres: Clic sur Fermer."); - dialog.dismiss(); - }); - - // // Emplacement pour ajouter les listeners des boutons de test si besoin - // testNotifHS.setOnClickListener(v -> { if(gameStats!=null) showHighScoreNotification(gameStats.getOverallHighScore()); }); - // testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); }); - - dialog.show(); + dialog.show(); // Affiche le dialogue } /** - * Met à jour l'état visuel de l'interrupteur de notifications. - * Actuellement, cette méthode est un placeholder car mettre à jour une vue - * dans une dialogue non affichée ou après sa fermeture est complexe. - * L'approche la plus simple est que l'utilisateur doive rouvrir les paramètres - * pour voir l'état mis à jour si la permission a été refusée. + * Met à jour l'état local de `notificationsEnabled` et sauvegarde la préférence + * si la valeur est mise à `false`. Utilisé principalement après un refus de permission. * - * @param isEnabled L'état souhaité pour l'interrupteur (généralement false si permission refusée). + * @param isEnabled L'état actuel (généralement `false` lors de l'appel). */ private void updateNotificationSwitchState(boolean isEnabled) { - // Idéalement, si la dialogue des paramètres est encore ouverte (AlertDialog dialog), - // on récupérerait le switch et on ferait dialogView.findViewById(R.id.switchNotifications).setChecked(isEnabled); - // Mais gérer la référence à la dialogue/vue de manière fiable est complexe. - Log.d(TAG, "updateNotificationSwitchState: État notif demandé: " + isEnabled + " (Mise à jour visuelle du switch non implémentée si dialogue fermée)"); - // Si l'utilisateur vient de refuser, on s'assure que notre état interne est correct. if (!isEnabled) { notificationsEnabled = false; saveNotificationPreference(false); } + // Pas d'action si isEnabled est true ici, car la logique est dans requestNotificationPermission } /** - * Sauvegarde la préférence utilisateur pour l'activation/désactivation des notifications - * dans les SharedPreferences. + * Sauvegarde la préférence d'activation des notifications dans les SharedPreferences. * - * @param enabled true pour activer, false pour désactiver. + * @param enabled {@code true} si les notifications doivent être activées, {@code false} sinon. */ private void saveNotificationPreference(boolean enabled) { if (preferences != null) { - Log.d(TAG, "Sauvegarde préférence notifications: " + enabled); preferences.edit().putBoolean(NOTIFICATIONS_ENABLED_KEY, enabled).apply(); - } else { - Log.e(TAG, "Impossible de sauvegarder préférence notifications, SharedPreferences est null."); } } /** - * Charge la préférence utilisateur pour l'activation/désactivation des notifications - * depuis les SharedPreferences. Par défaut à false si non définie. + * Charge la préférence d'activation des notifications depuis les SharedPreferences. + * Met à jour la variable membre `notificationsEnabled`. */ private void loadNotificationPreference() { if (preferences != null) { - // Défaut à false - l'utilisateur doit activement les activer. - notificationsEnabled = preferences.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); - Log.d(TAG, "Chargement préférence notifications: " + notificationsEnabled); + notificationsEnabled = preferences.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); // Désactivé par défaut } else { - notificationsEnabled = false; // Assure une valeur par défaut si prefs est null - Log.e(TAG, "Impossible de charger préférence notifications, SharedPreferences est null. Défaut: false."); + notificationsEnabled = false; } } /** - * Sauvegarde la préférence utilisateur pour l'activation/désactivation des effets sonores - * dans les SharedPreferences. + * Sauvegarde la préférence d'activation du son dans les SharedPreferences. * - * @param enabled true pour activer, false pour désactiver. + * @param enabled {@code true} si le son doit être activé, {@code false} sinon. */ private void saveSoundPreference(boolean enabled) { if (preferences != null) { - Log.d(TAG, "Sauvegarde préférence son: " + enabled); preferences.edit().putBoolean(SOUND_ENABLED_KEY, enabled).apply(); - } else { - Log.e(TAG, "Impossible de sauvegarder préférence son, SharedPreferences est null."); } } /** - * Charge la préférence utilisateur pour l'activation/désactivation des effets sonores - * depuis les SharedPreferences. Par défaut à true si non définie. + * Charge la préférence d'activation du son depuis les SharedPreferences. + * Met à jour la variable membre `soundEnabled`. */ private void loadSoundPreference() { if (preferences != null) { - soundEnabled = preferences.getBoolean(SOUND_ENABLED_KEY, true); // Son activé par défaut - Log.d(TAG, "Chargement préférence son: " + soundEnabled); + soundEnabled = preferences.getBoolean(SOUND_ENABLED_KEY, true); // Activé par défaut } else { - soundEnabled = true; // Assure une valeur par défaut si prefs est null - Log.e(TAG, "Impossible de charger préférence son, SharedPreferences est null. Défaut: true."); + soundEnabled = true; } } /** - * Ouvre l'écran des paramètres système spécifiques à cette application, - * permettant à l'utilisateur de gérer manuellement les permissions accordées. - * Utilise une Intent {@code Settings.ACTION_APPLICATION_DETAILS_SETTINGS}. + * Ouvre l'écran des paramètres système de l'application pour permettre à l'utilisateur + * de gérer manuellement les permissions. */ private void openAppSettings() { - Log.d(TAG, "Ouverture des paramètres système de l'application."); Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - // Crée l'URI pointant vers les détails de notre package Uri uri = Uri.fromParts("package", getPackageName(), null); intent.setData(uri); try { - startActivity(intent); // Lance l'Intent + startActivity(intent); // Lance l'intent } catch (ActivityNotFoundException e) { - // Gère le cas (très improbable) où l'écran des paramètres ne peut être ouvert - Log.e(TAG, "Impossible d'ouvrir les paramètres système de l'application.", e); - Toast.makeText(this, "Impossible d'ouvrir les paramètres.", Toast.LENGTH_LONG).show(); + // Gère le cas où l'intent ne peut pas être résolu + Toast.makeText(this, "Cannot open settings.", Toast.LENGTH_LONG).show(); } } - // --- Gestion des Permissions (Notifications) --- - /** - * Demande la permission {@code POST_NOTIFICATIONS} si l'application tourne - * sur Android 13 (API 33) ou supérieur et si la permission n'a pas déjà été accordée. - * Si la permission est déjà accordée ou si l'API est inférieure à 33, active - * directement les notifications (met à jour l'état interne et sauvegarde la préférence). - * Utilise le {@code requestPermissionLauncher} pour gérer la réponse asynchrone. + * Demande la permission d'afficher des notifications si nécessaire (Android 13+). + * Si la permission est déjà accordée ou non requise (versions antérieures), active + * les notifications si elles ne l'étaient pas déjà. */ private void requestNotificationPermission() { - // Vérifie si on est sur Android 13 (TIRAMISU) ou plus + // Si Android 13 (Tiramisu) ou supérieur if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Log.d(TAG, "Vérification permission POST_NOTIFICATIONS sur API 33+"); // Vérifie si la permission n'est PAS déjà accordée if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // La permission n'est pas accordée, il faut la demander. - Log.i(TAG, "Permission POST_NOTIFICATIONS non accordée. Demande en cours..."); // Lance la demande de permission via le launcher enregistré requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); - // Le résultat sera géré dans le callback du requestPermissionLauncher } else { - // La permission est DÉJÀ accordée. - Log.i(TAG, "Permission POST_NOTIFICATIONS déjà accordée."); - // On peut activer directement les notifications (si ce n'est pas déjà fait) + // Permission déjà accordée, active les notifications si elles étaient désactivées if (!notificationsEnabled) { notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); - startNotificationService(); // Démarrer le service + schedulePeriodicNotifications(); } } } else { - // Sur les versions antérieures à Android 13, pas besoin de demander la permission runtime. - Log.i(TAG, "API < 33, permission POST_NOTIFICATIONS non requise explicitement."); - // On considère la permission comme accordée, on active les notifications. + // Pour les versions antérieures à Android 13, la permission n'est pas requise + // Active simplement les notifications si elles étaient désactivées if (!notificationsEnabled) { notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); - startNotificationService(); // Démarrer le service + schedulePeriodicNotifications(); } } } - // --- Logique de Notification (Affichage principalement délégué à NotificationHelper) --- - /** - * Crée l'Intent qui sera lancé lorsque l'utilisateur clique sur une notification. - * Dans ce cas, l'intent ouvre (ou ramène au premier plan) la {@code MainActivity}. - * Utilise les flags appropriés pour la gestion de la pile d'activités et l'immutabilité. + * Affiche une notification en utilisant {@link NotificationHelper}. + * Ne fait rien si les notifications sont désactivées globalement. * - * @return Le {@link PendingIntent} configuré pour être attaché à la notification. - */ - private PendingIntent createNotificationTapIntent() { - Intent intent = new Intent(this, MainActivity.class); - // FLAG_ACTIVITY_NEW_TASK: Démarre l'activité dans une nouvelle tâche si nécessaire. - // FLAG_ACTIVITY_CLEAR_TASK: Efface la tâche existante avant de démarrer l'activité (si NEW_TASK est aussi défini). - // Résultat : ramène l'instance existante au premier plan ou en crée une nouvelle. - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - - // FLAG_IMMUTABLE est requis pour Android 12+ (API 31) pour les PendingIntents utilisés par le système. - // FLAG_UPDATE_CURRENT: Si un PendingIntent avec le même requestCode existe déjà, met à jour son Intent extra data. - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // FLAG_IMMUTABLE existe depuis API 23, mais requis système depuis 31 - flags |= PendingIntent.FLAG_IMMUTABLE; - } - return PendingIntent.getActivity(this, 0, intent, flags); - } - - /** - * Construit et affiche une notification simple en utilisant {@link NotificationHelper}. - * Vérifie d'abord si les notifications sont activées et si la permission est accordée. - * - * @param context Contexte (typiquement 'this' depuis l'activité). - * @param title Titre de la notification. - * @param message Corps du message de la notification. - * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler). + * @param context Contexte. + * @param title Titre de la notification. + * @param message Message de la notification. + * @param notificationId ID unique de la notification. */ private void showNotification(Context context, String title, String message, int notificationId) { - // Vérification globale avant d'appeler le Helper if (!notificationsEnabled) { - Log.w(TAG, "showNotification: Notifications désactivées, notification [" + notificationId + "] non envoyée."); - return; + return; // Ne pas afficher si désactivé } - // Le Helper vérifiera aussi la permission pour API 33+ - Log.d(TAG, "showNotification: Tentative d'affichage de la notification ID " + notificationId); + // Délègue l'affichage à NotificationHelper NotificationHelper.showNotification(context, title, message, notificationId); } /** - * Démarre le {@link NotificationService} en arrière-plan s'il n'est pas déjà lancé. - * Ce service est responsable de l'envoi des notifications périodiques (rappel HS, inactivité). - * Ne fait rien si les notifications ne sont pas activées ou si la permission manque. + * Planifie une tâche périodique en arrière-plan ({@link NotificationWorker}) + * en utilisant {@link WorkManager} pour vérifier et afficher des notifications. + * Utilise une politique {@code KEEP} pour éviter de replanifier si un travail identique est déjà programmé. */ - private void startNotificationService() { - if (!notificationsEnabled) { - Log.w(TAG, "startNotificationService: Notifications désactivées, service non démarré."); - return; - } - // Sur API 33+, revérifier la permission avant de démarrer - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "startNotificationService: Permission POST_NOTIFICATIONS manquante sur API 33+, service non démarré."); - return; - } - - Log.i(TAG, "Démarrage du NotificationService."); - Intent serviceIntent = new Intent(this, NotificationService.class); - try { - // Utiliser startService. Si le service a besoin d'être foreground (API 26+), - // il doit appeler startForeground() lui-même rapidement. - startService(serviceIntent); - } catch (IllegalStateException e) { - // Peut arriver sur API 26+ si l'app est en arrière-plan et tente de démarrer un service - // sans le passer en foreground rapidement. NotificationService devrait gérer startForeground si besoin. - Log.e(TAG, "Impossible de démarrer NotificationService (peut-être en arrière-plan sur API 26+ ?)", e); - } catch (SecurityException se) { - Log.e(TAG, "Erreur de sécurité au démarrage de NotificationService.", se); - } + private void schedulePeriodicNotifications() { + // Crée la requête de travail périodique + PeriodicWorkRequest periodicWorkRequest = + new PeriodicWorkRequest.Builder(NotificationWorker.class, + WORKER_REPEAT_INTERVAL_HOURS, TimeUnit.HOURS) // Répète toutes les X heures + .build(); + // Met en file d'attente le travail avec un tag unique et une politique KEEP + WorkManager.getInstance(getApplicationContext()).enqueueUniquePeriodicWork( + PERIODIC_NOTIFICATION_WORK_TAG, // Tag pour identifier/annuler le travail + ExistingPeriodicWorkPolicy.KEEP, // Garde le travail existant s'il y en a un + periodicWorkRequest + ); } /** - * Arrête le {@link NotificationService} s'il est en cours d'exécution. + * Annule la tâche périodique de notification en arrière-plan si elle était planifiée. */ - private void stopNotificationService() { - Log.i(TAG, "Arrêt du NotificationService."); - Intent serviceIntent = new Intent(this, NotificationService.class); - stopService(serviceIntent); + private void cancelPeriodicNotifications() { + // Annule le travail en utilisant son tag unique + WorkManager.getInstance(getApplicationContext()).cancelUniqueWork(PERIODIC_NOTIFICATION_WORK_TAG); } /** - * Affiche la notification d'accomplissement (ex: atteinte d'une tuile spécifique) - * via le {@link NotificationHelper}. - * Vérifie si les notifications sont activées avant d'envoyer. + * Affiche une notification de "succès" lorsqu'une tuile significative (ex: 2048) est atteinte. * - * @param tileValue La valeur de la tuile atteinte (ex: 2048). + * @param tileValue La valeur de la tuile atteinte. */ private void showAchievementNotification(int tileValue) { - if (!notificationsEnabled) return; // Vérification globale - Log.i(TAG, "Affichage notification d'accomplissement pour la tuile " + tileValue); + if (!notificationsEnabled) return; // Vérifie si les notifications sont activées String title = getString(R.string.notification_title_achievement); String message = getString(R.string.notification_text_achievement, tileValue); - // Utilise ID 1 (par convention définie dans NotificationService ou ici) - showNotification(this, title, message, 1); + // Affiche la notification via la méthode wrapper + showNotification(this, title, message, 1); // Utilise ID 1 pour ce type de notif } /** - * Affiche la notification de rappel du meilleur score (utilisé pour test/démo). - * La planification réelle devrait être gérée par {@link NotificationService} ou WorkManager. - * - * @param highScore Le meilleur score actuel à afficher. - */ - private void showHighScoreNotification(int highScore) { - Log.d(TAG, "Affichage notification de test High Score: " + highScore); - String title = getString(R.string.notification_title_highscore); - String message = getString(R.string.notification_text_highscore, highScore); - // Utilise ID 2 (par convention définie dans NotificationService) - showNotification(this, title, message, 2); - } - - /** - * Affiche la notification de rappel d'inactivité (utilisé pour test/démo). - * La planification et la logique réelle devraient être dans {@link NotificationService} ou WorkManager. - */ - private void showInactivityNotification() { - Log.d(TAG, "Affichage notification de test Inactivité."); - String title = getString(R.string.notification_title_inactivity); - String message = getString(R.string.notification_text_inactivity); - // Utilise ID 3 (par convention définie dans NotificationService) - showNotification(this, title, message, 3); - } - - /** - * Crée et lance une Intent {@code Intent.ACTION_SEND} pour permettre à l'utilisateur - * de partager ses statistiques de jeu via d'autres applications (email, réseaux sociaux, etc.). - * Formate un message texte contenant les statistiques clés. + * Prépare et lance une intention pour partager les statistiques de jeu actuelles + * via les applications disponibles sur l'appareil (email, réseaux sociaux, etc.). */ private void shareStats() { if (gameStats == null) { - Log.w(TAG, "shareStats: Impossible de partager, GameStats est null."); - Toast.makeText(this, "Statistiques non disponibles.", Toast.LENGTH_SHORT).show(); + Toast.makeText(this, "Stats not available.", Toast.LENGTH_SHORT).show(); return; } - Log.i(TAG, "Préparation du partage des statistiques."); - // Construit le message texte à partager + // Construit le message à partager avec les statistiques formatées String shareBody = getString(R.string.share_stats_body, gameStats.getOverallHighScore(), gameStats.getHighestTile(), gameStats.getNumberOfTimesObjectiveReached(), - gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? Utilisons started pour cohérence avec win % - GameStats.formatTime(gameStats.getTotalPlayTimeMs()), + gameStats.getTotalGamesStarted(), + GameStats.formatTime(gameStats.getTotalPlayTimeMs()), // Formate le temps gameStats.getTotalMoves() ); - // Crée l'Intent de partage + // Crée l'intention de partage Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); // Type MIME pour du texte simple + shareIntent.setType("text/plain"); // Type de contenu texte shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject)); // Sujet (pour email, etc.) shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody); // Corps du message - // Lance le sélecteur d'applications pour le partage + // Lance le sélecteur d'applications de partage try { startActivity(Intent.createChooser(shareIntent, getString(R.string.share_stats_title))); } catch (ActivityNotFoundException e) { - // Gère le cas où aucune application ne peut gérer l'Intent de partage - Log.e(TAG, "Aucune application de partage disponible.", e); - Toast.makeText(this, "Aucune application de partage disponible.", Toast.LENGTH_SHORT).show(); + // Gère le cas où aucune application de partage n'est installée + Toast.makeText(this, "No sharing app available.", Toast.LENGTH_SHORT).show(); } } /** - * Affiche une boîte de dialogue de confirmation avant de réinitialiser - * toutes les statistiques du joueur. Prévient l'utilisateur que l'action est irréversible. + * Affiche un dialogue demandant confirmation avant de réinitialiser toutes les statistiques de jeu. */ private void showResetStatsConfirmationDialog() { - Log.w(TAG, "Affichage dialogue confirmation réinitialisation stats."); new AlertDialog.Builder(this) .setTitle(R.string.reset_stats_confirm_title) .setMessage(R.string.reset_stats_confirm_message) .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement standard - // Bouton Positif (Confirmer) - .setPositiveButton(R.string.confirm, (dialog, which) -> { - Log.i(TAG, "Réinitialisation des statistiques confirmée."); + .setPositiveButton(R.string.confirm, (dialog, which) -> { // Bouton Confirmer resetStatistics(); // Appelle la méthode de réinitialisation dialog.dismiss(); }) - // Bouton Négatif (Annuler) - .setNegativeButton(R.string.cancel, (dialog, which) -> { - Log.d(TAG, "Réinitialisation des statistiques annulée."); - dialog.dismiss(); + .setNegativeButton(R.string.cancel, (dialog, which) -> { // Bouton Annuler + dialog.dismiss(); // Ferme simplement le dialogue }) - .setCancelable(false) // Empêche de fermer sans choisir + .setCancelable(false) // Non annulable en cliquant à l'extérieur .show(); } /** - * Réinitialise toutes les statistiques stockées via l'objet {@link GameStats}. - * Met également à jour le meilleur score affiché dans l'interface si une partie est en cours. - * Affiche une confirmation à l'utilisateur via un Toast. + * Réinitialise toutes les statistiques de jeu en appelant {@link GameStats#resetStats()}. + * Met également à jour le meilleur score affiché dans le jeu en cours et actualise + * le panneau des statistiques s'il est visible. Affiche un message de confirmation. */ private void resetStatistics() { - Log.i(TAG, "resetStatistics: Réinitialisation des statistiques demandée."); if (gameStats != null) { - gameStats.resetStats(); // Réinitialise les stats dans l'objet et sauvegarde via SharedPreferences - - // Met aussi à jour le highScore de l'objet Game courant (qui est maintenant 0) + gameStats.resetStats(); // Réinitialise les données dans GameStats (et sauvegarde) + // Met à jour le meilleur score dans l'instance 'game' actuelle if(game != null){ - game.setHighestScore(gameStats.getOverallHighScore()); // Met à jour l'instance Game - updateScores(); // Rafraîchit l'affichage du HS si visible + game.setHighestScore(gameStats.getOverallHighScore()); // Le high score est maintenant 0 + updateScores(); // Met à jour l'affichage des scores } - - Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show(); - - // Si les statistiques étaient visibles, on les rafraîchit pour montrer les zéros + Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show(); // Confirmation + // Met à jour le panneau des stats s'il est affiché if (statisticsVisible && inflatedStatsView != null) { - Log.d(TAG, "resetStatistics: Rafraîchissement du panneau de statistiques."); updateStatisticsTextViews(); } - } else { - Log.e(TAG, "resetStatistics: GameStats est null, impossible de réinitialiser."); } } /** - * Affiche la boîte de dialogue "À Propos". - * Utilise le layout personnalisé `dialog_about.xml`. - * Affiche des informations sur l'application (version, développeur) et - * inclut un lien cliquable vers un site web. + * Affiche un dialogue "À Propos" contenant des informations sur l'application + * et un lien cliquable vers un site web. */ private void showAboutDialog() { - Log.d(TAG, "Affichage dialogue À Propos."); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); - View dialogView = inflater.inflate(R.layout.dialog_about, null); // Gonfle le layout + View dialogView = inflater.inflate(R.layout.dialog_about, null); // Charge le layout builder.setView(dialogView); - builder.setCancelable(true); // Permet de fermer en cliquant à côté + builder.setCancelable(true); // Annulable - // Récupère les vues du layout - TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView); - Button okButton = dialogView.findViewById(R.id.dialogOkButtonAbout); + TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView); // Lien texte + Button okButton = dialogView.findViewById(R.id.dialogOkButtonAbout); // Bouton OK final AlertDialog dialog = builder.create(); - // Rend le lien du site web cliquable pour ouvrir le navigateur + // Rend le lien cliquable pour ouvrir un navigateur web websiteLinkTextView.setOnClickListener(v -> { - String url = getString(R.string.about_website_url); - Log.d(TAG, "Dialogue À Propos: Clic sur le lien du site web -> " + url); + String url = getString(R.string.about_website_url); // URL depuis les ressources Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); try { - startActivity(browserIntent); + startActivity(browserIntent); // Tente d'ouvrir le navigateur } catch (ActivityNotFoundException e) { - // Gère le cas où aucun navigateur n'est installé - Log.e(TAG, "Aucun navigateur web trouvé pour ouvrir l'URL.", e); - Toast.makeText(this, "Aucun navigateur web trouvé.", Toast.LENGTH_SHORT).show(); + Toast.makeText(this, "No web browser found.", Toast.LENGTH_SHORT).show(); // Erreur si pas de navigateur } }); - - // Bouton OK pour fermer la dialogue - okButton.setOnClickListener(v -> { - Log.d(TAG, "Dialogue À Propos: OK cliqué."); - dialog.dismiss(); - }); - - dialog.show(); + // Bouton OK pour fermer le dialogue + okButton.setOnClickListener(v -> dialog.dismiss()); + dialog.show(); // Affiche le dialogue } - - // --- Gestion Stats UI --- - /** * Affiche ou masque le panneau des statistiques. - * Utilise un {@link ViewStub} pour gonfler le layout (`stats_layout.xml`) uniquement - * lors de la première demande d'affichage (optimisation des performances). - * Gère la visibilité du panneau et du bouton multijoueur. + * Charge la vue à partir du {@link ViewStub} lors de la première ouverture. + * Met à jour le contenu des TextViews de statistiques lorsque le panneau est affiché. + * Gère également la visibilité du bouton Multijoueur pour éviter les superpositions. */ private void toggleStatistics() { statisticsVisible = !statisticsVisible; // Inverse l'état de visibilité - Log.d(TAG, "toggleStatistics: Visibilité changée à " + statisticsVisible); if (statisticsVisible) { // Si on veut afficher les stats - if (inflatedStatsView == null) { // Gonfle le layout si ce n'est pas déjà fait - Log.i(TAG, "toggleStatistics: Gonflage du ViewStub des statistiques."); + if (inflatedStatsView == null) { + // Si la vue n'a pas encore été chargée (première fois) try { - inflatedStatsView = statisticsViewStub.inflate(); // Gonfle le layout défini dans android:layout="@layout/stats_layout" - // Attache listener au bouton Retour DANS le layout des stats, une fois gonflé + inflatedStatsView = statisticsViewStub.inflate(); // Charge la vue depuis le ViewStub + // Trouve le bouton Retour dans la vue chargée et configure son listener Button backButton = inflatedStatsView.findViewById(R.id.backButton); if (backButton != null) { - backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back appelle à nouveau toggle - } else { - Log.e(TAG, "toggleStatistics: Bouton Retour (R.id.backButton) non trouvé dans stats_layout !"); + backButton.setOnClickListener(v -> toggleStatistics()); // Le bouton Retour rappelle cette même méthode } } catch (Exception e) { - Log.e(TAG, "Erreur lors du gonflage du ViewStub des statistiques", e); - statisticsVisible = false; // Annule le changement d'état si erreur - Toast.makeText(this, "Erreur lors de l'affichage des stats.", Toast.LENGTH_SHORT).show(); + // Gère une erreur potentielle lors du chargement de la vue + statisticsVisible = false; // Revient à l'état masqué + Toast.makeText(this, "Error displaying stats.", Toast.LENGTH_SHORT).show(); return; } } - // Assure que la vue est visible et met à jour les données + // Si la vue est chargée (ou vient de l'être) if (inflatedStatsView != null) { - updateStatisticsTextViews(); // Remplit les champs avec les données actuelles - inflatedStatsView.setVisibility(View.VISIBLE); // Affiche le panneau + updateStatisticsTextViews(); // Met à jour les données affichées + inflatedStatsView.setVisibility(View.VISIBLE); // Rend la vue visible } - multiplayerButton.setVisibility(View.GONE); // Masque le bouton multijoueur quand les stats sont visibles - + multiplayerButton.setVisibility(View.GONE); // Masque le bouton Multijoueur } else { // Si on veut masquer les stats - if (inflatedStatsView != null) { // Masque le panneau s'il existe - inflatedStatsView.setVisibility(View.GONE); + if (inflatedStatsView != null) { + inflatedStatsView.setVisibility(View.GONE); // Masque la vue des stats } - multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche le bouton multijoueur + multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche le bouton Multijoueur } } /** - * Remplit les {@link TextView} du panneau de statistiques (contenu dans `inflatedStatsView`) - * avec les données actuelles provenant de l'objet {@link GameStats}. - * Doit être appelé seulement après que `inflatedStatsView` a été initialisé (gonflé) - * et seulement si `gameStats` n'est pas null. + * Met à jour le contenu textuel de tous les TextViews dans le panneau des statistiques + * en utilisant les données actuelles de l'instance {@link GameStats}. + * Formate les pourcentages et les durées de manière appropriée. */ - @SuppressLint("StringFormatInvalid") // Pour R.string.average_time_per_game_label potentiellement mal utilisé + @SuppressLint("StringFormatInvalid") // Supprime le warning pour le format de temps multi, même si corrigé. private void updateStatisticsTextViews() { + // Vérifie si la vue et les stats sont disponibles if (inflatedStatsView == null || gameStats == null) { - Log.w(TAG, "updateStatisticsTextViews: Vue des stats non gonflée ou GameStats null."); - return; // Ne rien faire si la vue n'existe pas ou les stats ne sont pas prêtes + return; } - Log.d(TAG, "updateStatisticsTextViews: Mise à jour des labels de statistiques."); - // Récupération des TextViews DANS la vue gonflée - // Utiliser findViewByID sur inflatedStatsView, pas sur l'activité ! + // Récupère les références de tous les TextViews de statistiques (peut être optimisé) TextView highScoreStatsLabel = inflatedStatsView.findViewById(R.id.high_score_stats_label); TextView totalGamesPlayedLabel = inflatedStatsView.findViewById(R.id.total_games_played_label); TextView totalGamesStartedLabel = inflatedStatsView.findViewById(R.id.total_games_started_label); @@ -1802,24 +1301,17 @@ public class MainActivity extends AppCompatActivity { TextView totalMergesLabel = inflatedStatsView.findViewById(R.id.total_merges_label); TextView highestTileLabel = inflatedStatsView.findViewById(R.id.highest_tile_label); TextView objectiveReachedLabel = inflatedStatsView.findViewById(R.id.number_of_time_objective_reached_label); - // TextView perfectGameLabel = inflatedStatsView.findViewById(R.id.perfect_game_label); // Supprimé TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game); - - // Multiplayer stats TextViews TextView multiplayerGamesWonLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_won_label); TextView multiplayerGamesPlayedLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_played_label); TextView multiplayerWinRateLabel = inflatedStatsView.findViewById(R.id.multiplayer_win_rate_label); TextView multiplayerBestWinningStreakLabel = inflatedStatsView.findViewById(R.id.multiplayer_best_winning_streak_label); TextView multiplayerAverageScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_average_score_label); - TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // *** ATTENTION: ID POTENTIELLEMENT DUPLIQUÉ DANS LE LAYOUT XML *** + TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_multi_label); // Corrigé TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label); TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label); - - // --- Vérifier la nullité de chaque TextView avant de l'utiliser --- - // (Alternativement, utiliser un View Binding ou Data Binding pour plus de sécurité) - - // Mise à jour des textes avec les getters de gameStats + formatage + // Met à jour le texte de chaque TextView avec les données formatées if (highScoreStatsLabel != null) highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore())); if (totalGamesPlayedLabel != null) totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed())); if (totalGamesStartedLabel != null) totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted())); @@ -1829,202 +1321,148 @@ public class MainActivity extends AppCompatActivity { if (totalMergesLabel != null) totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges())); if (highestTileLabel != null) highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile())); if (objectiveReachedLabel != null) objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached())); - // if (perfectGameLabel != null) perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames())); // Supprimé - // Calculs Pourcentages (avec vérification division par zéro) + // Calcule et formate le pourcentage de victoires solo if (winPercentageLabel != null) { String winPercentage = (gameStats.getTotalGamesStarted() > 0) ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) - : "N/A"; + : "N/A"; // Affiche N/A si aucune partie démarrée winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage)); } + // Calcule et formate le pourcentage de victoires multi if (multiplayerWinRateLabel != null) { String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) - : "N/A"; + : "N/A"; // Affiche N/A si aucune partie jouée multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate)); } - // Calculs et formatage des Temps + // Met à jour les labels de temps (formatés par GameStats.formatTime) if (totalPlayTimeLabel != null) totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs()))); - - // Calcule le temps de la partie en cours seulement si elle est active if (currentGameTimeLabel != null) { long currentDurationMs = 0; + // Calcule la durée de la partie en cours si elle est active if (game != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) { currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); } currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs))); } - if (averageGameTimeLabel != null) averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs()))); if (bestWinningTimeLabel != null) bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A")); if (worstWinningTimeLabel != null) worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A")); - // MAJ Stats Multi + // Met à jour les statistiques multijoueur if (multiplayerGamesWonLabel != null) multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon())); if (multiplayerGamesPlayedLabel != null) multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed())); if (multiplayerBestWinningStreakLabel != null) multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak())); if (multiplayerAverageScoreLabel != null) multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore())); if (totalMultiplayerLossesLabel != null) totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses())); if (multiplayerHighScoreLabel != null) multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore())); - - // *** ATTENTION : ID potentiellement dupliqué *** - // L'ID R.id.average_time_per_game_label semble être utilisé pour averageGameTimeLabel ET averageTimePerGameMultiLabel. - // Cela signifie qu'une seule des deux TextViews (probablement la dernière dans le XML) sera trouvée et mise à jour. - // Vous DEVEZ donner des IDs uniques à ces deux TextViews dans stats_layout.xml et mettre à jour les findViewById ici. if (averageTimePerGameMultiLabel != null) { - // Cette ligne mettra probablement à jour la même TextView que averageGameTimeLabel si l'ID est dupliqué ! - // Assurez-vous que R.id.average_time_per_game_label pointe vers la bonne TextView pour le multi. + // Utilise le bon ID de string pour le temps moyen multi (supposons R.string.average_time_per_game_label) try { averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); } catch (Exception e) { - // Le format de la string R.string.average_time_per_game_label attend peut-être un autre type d'argument - Log.e(TAG, "Erreur de formatage pour average_time_per_game_label (multi). Vérifier la string resource.", e); - // Afficher une valeur par défaut ou un message d'erreur - averageTimePerGameMultiLabel.setText(R.string.average_time_per_game_label); // Affiche le label brut + // Fallback au cas où l'ID de string serait incorrect ou manquant + averageTimePerGameMultiLabel.setText(R.string.average_time_per_game_label); } - } else { - Log.w(TAG, "TextView pour R.id.average_time_per_game_label (multi) non trouvée."); } - } - - // --- Placeholders Multi --- - - /** Affiche l'écran du mode multijoueur (lance MultiplayerActivity). */ - private void showMultiplayerScreen() { - Log.d(TAG, "Affichage écran Multijoueur."); - Intent intent = new Intent(this, MultiplayerActivity.class); - // TODO: Passer des informations utiles à l'activité multi (ex: ID Joueur) - // intent.putExtra("playerId", myPlayerId); - try { - startActivity(intent); - } catch (ActivityNotFoundException e) { - Log.e(TAG,"MultiplayerActivity non trouvée ! Vérifier AndroidManifest.xml", e); - Toast.makeText(this, "Erreur: Fonctionnalité multijoueur non disponible.", Toast.LENGTH_SHORT).show(); - } - // AlertDialog retiré - } - - // --- Sauvegarde / Chargement --- - /** - * Sauvegarde l'état essentiel du jeu actuel (plateau + score courant via {@code game.toString()}) - * et le meilleur score global (depuis `game.getHighestScore()`) dans les SharedPreferences. - * Utilise `apply()` pour une sauvegarde asynchrone. - * Appelée typiquement dans `onPause`. + * Lance l'activité {@link MultiplayerActivity} pour démarrer ou rejoindre une partie multijoueur. + */ + private void showMultiplayerScreen() { + Intent intent = new Intent(this, MultiplayerActivity.class); // Crée l'intent + try { + startActivity(intent); // Lance l'activité + } catch (ActivityNotFoundException e) { + // Gère le cas où l'activité ne peut pas être démarrée + Toast.makeText(this, "Error: Multiplayer feature unavailable.", Toast.LENGTH_SHORT).show(); + } + } + + /** + * Sauvegarde l'état actuel du jeu (plateau et score) et le meilleur score + * dans les SharedPreferences. */ private void saveGame() { if (preferences == null) { - Log.e(TAG, "saveGame: SharedPreferences non initialisé, sauvegarde annulée."); - return; + return; // Ne peut pas sauvegarder sans SharedPreferences } - SharedPreferences.Editor editor = preferences.edit(); + SharedPreferences.Editor editor = preferences.edit(); // Obtient l'éditeur if (game != null) { - Log.d(TAG, "saveGame: Sauvegarde de l'état du jeu et du high score."); - editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise l'objet Game - // Le meilleur score est aussi géré par GameStats, mais on le sauve ici aussi - // pour assurer la cohérence lors du chargement direct de l'objet Game. + // Sérialise l'état du jeu (en utilisant game.toString()) et le meilleur score + editor.putString(GAME_STATE_KEY, game.toString()); editor.putInt(HIGH_SCORE_KEY, game.getHighestScore()); } else { - Log.w(TAG, "saveGame: Instance 'game' est null, suppression de l'état sauvegardé."); - // Optionnel: nettoyer la clé si pas de jeu à sauvegarder + // S'il n'y a pas de jeu en cours, supprime l'état sauvegardé editor.remove(GAME_STATE_KEY); } - editor.apply(); // Sauvegarde asynchrone + editor.apply(); // Applique les changements de manière asynchrone } /** - * Charge l'état du jeu depuis les SharedPreferences. - * 1. Tente de lire la chaîne sérialisée de l'état du jeu (`GAME_STATE_KEY`). - * 2. Charge le meilleur score global (`HIGH_SCORE_KEY`) depuis les préférences (cette valeur - * a aussi été chargée par `gameStats`, on s'assure de la cohérence). - * 3. Tente de désérialiser la chaîne d'état en un objet `Game`. - * 4. Si la désérialisation réussit : - * a. Assigne l'objet chargé à la variable `game`. - * b. Applique le meilleur score chargé à l'objet `game`. - * c. Détermine l'état `currentGameState` (PLAYING, WON_DIALOG_SHOWN, GAME_OVER) basé sur le jeu chargé. - * 5. Si la désérialisation échoue ou si aucune sauvegarde n'existe, `game` reste `null` (ou devient `null`), - * ce qui déclenchera la création d'une nouvelle partie dans `initializeGameAndStats`. + * Charge l'état du jeu sauvegardé depuis les SharedPreferences. + * Désérialise l'état du plateau et récupère le meilleur score. + * Met à jour l'instance {@link #game} et {@link #currentGameState} en conséquence. + * S'assure que le meilleur score est synchronisé entre l'état chargé et les statistiques globales. */ private void loadGame() { if (preferences == null) { - Log.e(TAG, "loadGame: SharedPreferences non initialisé, chargement annulé."); - game = null; + game = null; // Ne peut pas charger sans SharedPreferences return; } - Log.d(TAG, "loadGame: Tentative de chargement de l'état du jeu."); + // Récupère l'état sérialisé et le high score depuis les prefs String gameStateString = preferences.getString(GAME_STATE_KEY, null); - - // Charge le meilleur score depuis les préférences directes. int loadedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0); - Log.d(TAG, "loadGame: High score chargé depuis les prefs directes: " + loadedHighScore); - // Assure que GameStats (qui a aussi chargé les prefs) est la source de vérité pour le HS + // S'assure que gameStats est initialisé pour récupérer le high score global if (gameStats == null) { - Log.w(TAG, "loadGame: GameStats est null lors du chargement! Tentative d'init."); - gameStats = new GameStats(this); // Devrait déjà être initialisé, mais sécurité + gameStats = new GameStats(this); } - // Prend le max entre le HS chargé directement et celui chargé par GameStats + // Prend le maximum entre le high score chargé et celui des stats globales int authoritativeHighScore = Math.max(loadedHighScore, gameStats.getOverallHighScore()); - // S'assure que GameStats a bien la valeur la plus élevée + // Met à jour les stats globales si le high score chargé était plus élevé if (authoritativeHighScore > gameStats.getOverallHighScore()) { gameStats.setHighestScore(authoritativeHighScore); - // Pas besoin de saveStats() ici, sera fait dans onPause. } - Log.d(TAG, "loadGame: High score définitif après synchro avec GameStats: " + authoritativeHighScore); - Game loadedGame = null; + // Tente de désérialiser l'état du jeu si une chaîne a été trouvée if (gameStateString != null) { - Log.d(TAG, "loadGame: État de jeu trouvé dans les prefs, tentative de désérialisation."); loadedGame = Game.deserialize(gameStateString); - } else { - Log.d(TAG, "loadGame: Aucun état de jeu trouvé dans les prefs."); } - // Si un jeu a été correctement désérialisé + // Si le chargement/désérialisation a réussi if (loadedGame != null) { - Log.i(TAG, "loadGame: Désérialisation réussie."); - game = loadedGame; - game.setHighestScore(authoritativeHighScore); // Applique le HS synchronisé - - // Détermine l'état de jeu basé sur le plateau chargé + game = loadedGame; // Utilise le jeu chargé + // Définit le meilleur score correct + game.setHighestScore(authoritativeHighScore); + // Détermine l'état du flux de jeu en fonction de l'état chargé if (game.isGameOver()) { - Log.d(TAG, "loadGame: État chargé -> GAME_OVER"); currentGameState = GameFlowState.GAME_OVER; } else if (game.isGameWon()) { - // Si on charge une partie déjà gagnée, on considère qu'on a déjà vu la dialogue - Log.d(TAG, "loadGame: État chargé -> WON_DIALOG_SHOWN (partie déjà gagnée)"); + // Si gagné, on met l'état comme si le dialogue avait déjà été montré pour éviter de le remontrer au chargement currentGameState = GameFlowState.WON_DIALOG_SHOWN; } else { - Log.d(TAG, "loadGame: État chargé -> PLAYING"); currentGameState = GameFlowState.PLAYING; - // Le timer sera (re)démarré dans onResume si l'état est PLAYING } } else { - // Pas de sauvegarde valide ou erreur de désérialisation - Log.w(TAG, "loadGame: Échec de la désérialisation ou pas de sauvegarde. 'game' mis à null."); - game = null; // Force à null pour déclencher startNewGame() dans initializeGameAndStats - currentGameState = GameFlowState.PLAYING; // Nouvel état par défaut + // Si échec du chargement, initialise à null (startNewGame sera appelé) + game = null; + currentGameState = GameFlowState.PLAYING; } } /** - * Sauvegarde le timestamp actuel dans les SharedPreferences. - * Utilisé par {@link NotificationService} pour déterminer l'inactivité. - * Appelée dans `onPause`. + * Sauvegarde le timestamp actuel dans les SharedPreferences comme 'dernière fois jouée'. + * Utilisé par le {@link NotificationWorker} pour détecter l'inactivité. */ private void saveLastPlayedTime() { if (preferences != null) { - long now = System.currentTimeMillis(); - Log.d(TAG, "Enregistrement du dernier temps joué: " + now); - preferences.edit().putLong(LAST_PLAYED_TIME_KEY, now).apply(); - } else { - Log.e(TAG, "Impossible d'enregistrer le dernier temps joué, SharedPreferences est null."); + long now = System.currentTimeMillis(); // Heure actuelle + preferences.edit().putLong(LAST_PLAYED_TIME_KEY, now).apply(); // Sauvegarde } } - } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/MultiplayerActivity.java b/app/src/main/java/legion/muyue/best2048/MultiplayerActivity.java index 3f19463..2cb563e 100644 --- a/app/src/main/java/legion/muyue/best2048/MultiplayerActivity.java +++ b/app/src/main/java/legion/muyue/best2048/MultiplayerActivity.java @@ -1,141 +1,211 @@ package legion.muyue.best2048; -// Imports Android standard et UI +// --- Imports --- +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.annotation.SuppressLint; +import android.app.AlertDialog; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.util.Log; import android.util.TypedValue; import android.view.Gravity; import android.view.View; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import androidx.activity.EdgeToEdge; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.gridlayout.widget.GridLayout; - -// Imports Retrofit (pour les appels REST initiaux) +// Retrofit import retrofit2.Call; import retrofit2.Callback; -import retrofit2.Response; // <--- IMPORT IMPORTANT POUR RETROFIT CALLBACK ! - -// Imports OkHttp (pour WebSocket) +import retrofit2.Response; +// OkHttp import okhttp3.OkHttpClient; import okhttp3.Request; -// import okhttp3.Response; // Aussi nécessaire pour WebSocketListener.onFailure, garder les deux imports import okhttp3.WebSocket; import okhttp3.WebSocketListener; import okio.ByteString; - -// Imports Gson (pour JSON) +// Gson import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; - -// Import Timeout pour OkHttpClient +// Util +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; - -// Imports des classes Data et Network (existantes) +import java.util.concurrent.atomic.AtomicBoolean; +// App specific import legion.muyue.best2048.data.GameInfo; import legion.muyue.best2048.data.GameStateResponse; -import legion.muyue.best2048.data.MoveRequest; // Utilisé pour construire le JSON du move via WebSocket -import legion.muyue.best2048.data.PlayerIdRequest; // Pour createOrJoinGame +import legion.muyue.best2048.data.MoveRequest; +import legion.muyue.best2048.data.PlayerIdRequest; import legion.muyue.best2048.network.ApiClient; import legion.muyue.best2048.network.ApiService; +import legion.muyue.best2048.GameStats; - +/** + * Activité gérant l'interface utilisateur et la logique du mode multijoueur en temps réel du jeu 2048. + * Cette activité communique avec un serveur backend via une API REST (Retrofit) pour la gestion des parties + * (création/rejoindre) et une connexion WebSocket (OkHttp) pour la réception des mises à jour de l'état du jeu + * en temps réel et l'envoi des mouvements du joueur. + * Elle gère également l'affichage du plateau de jeu, des scores, de l'indicateur de tour, + * l'état de la connexion, les animations des tuiles, et la détection des gestes de balayage (swipe). + */ public class MultiplayerActivity extends AppCompatActivity { - private static final String TAG = "MultiplayerActivity"; + /** Taille du plateau de jeu (4x4). */ private static final int BOARD_SIZE = 4; + /** URL du serveur WebSocket pour la communication en temps réel. */ + private static final String WEBSOCKET_URL = "wss://best2048.legion-muyue.fr/ws"; + /** Nombre maximum de tentatives de reconnexion WebSocket. */ + private static final int MAX_RECONNECT_ATTEMPTS = 5; + /** Délai (en millisecondes) entre les tentatives de reconnexion WebSocket. */ + private static final long RECONNECT_DELAY_MS = 5000; - // UI + // --- UI Elements --- + /** Layout de grille pour afficher le plateau de jeu. */ private GridLayout boardGridLayoutMulti; + /** TextView affichant le score du joueur local. */ private TextView myScoreLabelMulti; + /** TextView affichant le score de l'adversaire. */ private TextView opponentScoreLabelMulti; + /** TextView indiquant à qui est le tour de jouer. */ private TextView turnIndicatorMulti; + /** TextView affichant le statut actuel de la partie multijoueur (recherche, connexion, attente, terminé...). */ private TextView statusTextMulti; + /** Indicateur de chargement (ProgressBar). */ private ProgressBar loadingIndicatorMulti; + /** Tableau 2D de TextViews représentant les tuiles sur le plateau. */ private TextView[][] tileViewsMulti = new TextView[BOARD_SIZE][BOARD_SIZE]; - // Network - REST (pour appels initiaux) + // --- Network Components --- + /** Service Retrofit pour les appels API REST. */ private ApiService apiService; - - // Network - WebSocket (pour temps réel) + /** Client OkHttp utilisé pour la connexion WebSocket. */ private OkHttpClient wsClient; + /** Instance active de la connexion WebSocket. */ private WebSocket webSocket; + /** Listener pour gérer les événements WebSocket. */ private MyWebSocketListener webSocketListener; - private static final String WEBSOCKET_URL = "wss://best2048.legion-muyue.fr/ws"; - private boolean isWebSocketConnected = false; + /** Indicateur de l'état actuel de la connexion WebSocket. */ + private volatile boolean isWebSocketConnected = false; + /** Compteur des tentatives de reconnexion actuelles. */ + private int reconnectAttempts = 0; + /** Flag atomique pour éviter les tentatives de reconnexion multiples simultanées. */ + private AtomicBoolean isReconnecting = new AtomicBoolean(false); - // Game State + // --- Game State --- + /** ID de la partie multijoueur en cours. */ private String currentGameId = null; + /** ID unique du joueur local. */ private String myPlayerId = null; + /** ID de l'adversaire (déduit des informations de la partie). */ private String opponentPlayerId = null; + /** Dernier état de jeu reçu du serveur. */ private GameStateResponse currentGameState = null; + /** État du plateau précédent, utilisé pour calculer les animations. */ + private int[][] previousBoardState = null; + /** Indicateur si la partie est terminée (localement ou via le serveur). */ + private volatile boolean gameHasEnded = false; - // JSON Parser + // --- Stats & Utilities --- + /** Instance pour gérer les statistiques de jeu. */ + private GameStats gameStats; + /** Timestamp du début de la partie multijoueur (pour calculer la durée). */ + private long multiplayerGameStartTimeMs = 0L; + /** Instance de Gson pour sérialiser/désérialiser les messages WebSocket. */ private Gson gson; - - // Handler pour UI Thread + /** Handler pour exécuter des tâches sur le thread UI. */ private Handler uiHandler; + /** Runnable pour déclencher les tentatives de reconnexion. */ + private Runnable reconnectRunnable; - // --- Activity Lifecycle --- - + /** + * Méthode appelée lors de la création de l'activité. + * Initialise l'interface utilisateur, les composants réseau (Retrofit, OkHttpClient, WebSocketListener), + * les utilitaires (Gson, Handler, GameStats), configure le listener de swipe et lance le processus + * d'initialisation de la partie multijoueur. + * Active également le mode EdgeToEdge pour l'affichage. + * + * @param savedInstanceState État précédemment sauvegardé de l'activité (non utilisé ici). + */ @Override protected void onCreate(Bundle savedInstanceState) { + EdgeToEdge.enable(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_multiplayer); - Log.d(TAG, "onCreate"); - findViewsMulti(); - apiService = ApiClient.getApiService(); // Instance Retrofit - wsClient = new OkHttpClient.Builder() - .pingInterval(30, TimeUnit.SECONDS) // Maintenir connexion WS active - .build(); - webSocketListener = new MyWebSocketListener(); - gson = new Gson(); - uiHandler = new Handler(Looper.getMainLooper()); + findViewsMulti(); // Récupère les références des vues + apiService = ApiClient.getApiService(); // Obtient le service API Retrofit + // Configure le client OkHttp pour le WebSocket avec un ping interval + wsClient = new OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build(); + webSocketListener = new MyWebSocketListener(); // Crée le listener WebSocket + gson = new Gson(); // Initialise Gson + gameStats = new GameStats(getApplicationContext()); // Initialise les statistiques + uiHandler = new Handler(Looper.getMainLooper()); // Initialise le Handler UI + reconnectRunnable = this::tryReconnect; // Définit le Runnable de reconnexion - setupSwipeListenerMulti(); - initMultiplayerGame(); // Démarrer le processus + setupSwipeListenerMulti(); // Configure la détection de swipe + initMultiplayerGame(); // Lance la recherche/création de partie } + /** + * Méthode appelée lorsque l'activité passe en arrière-plan. + * Sauvegarde les statistiques, annule les tentatives de reconnexion programmées + * et ferme la connexion WebSocket pour économiser les ressources. + */ @Override protected void onPause() { super.onPause(); - Log.d(TAG, "onPause"); - closeWebSocket(); // Fermer WS en quittant l'écran + uiHandler.removeCallbacks(reconnectRunnable); // Annule les reconnexions programmées + isReconnecting.set(false); // Réinitialise le flag de reconnexion + closeWebSocket(); // Ferme la connexion WebSocket + if (gameStats != null) { gameStats.saveStats(); } // Sauvegarde les statistiques } + /** + * Méthode appelée lorsque l'activité revient au premier plan. + * Tente de se reconnecter au WebSocket si nécessaire (si une partie était en cours et non terminée) + * ou rafraîchit l'état du jeu si déjà connecté. + */ @Override protected void onResume() { super.onResume(); - Log.d(TAG, "onResume"); - // Tenter de reconnecter si besoin - if (currentGameId != null && !isWebSocketConnected && currentGameState != null && !currentGameState.isGameOver()) { - Log.i(TAG, "Tentative de reconnexion WebSocket..."); - connectWebSocket(); - } else if (currentGameId != null && isWebSocketConnected) { - Log.d(TAG, "WebSocket déjà connecté, rafraîchissement état via REST..."); - fetchGameState(); // Resynchroniser via REST au cas où + // Si une partie était en cours, non terminée, et qu'on n'est pas connecté ou en reconnexion + if (currentGameId != null && !isWebSocketConnected && !gameHasEnded && !isReconnecting.get()) { + resetReconnectionAttempts(); // Réinitialise les tentatives + tryReconnect(); // Lance la reconnexion + } + // Si connecté et partie en cours, rafraîchir l'état au cas où + else if (currentGameId != null && isWebSocketConnected && !gameHasEnded) { + fetchGameState(); } } + /** + * Méthode appelée avant la destruction de l'activité. + * Marque la partie comme terminée, annule toutes les tâches du Handler + * et ferme définitivement la connexion WebSocket. + */ @Override protected void onDestroy() { super.onDestroy(); - Log.d(TAG, "onDestroy"); - closeWebSocket(); // Assurer la fermeture - if (uiHandler != null) { - uiHandler.removeCallbacksAndMessages(null); - } + gameHasEnded = true; // Marque la partie comme terminée + uiHandler.removeCallbacks(reconnectRunnable); // Annule les reconnexions + closeWebSocket(); // Ferme le WebSocket + // Supprime tous les messages/callbacks pour éviter les fuites + if (uiHandler != null) { uiHandler.removeCallbacksAndMessages(null); } } - // --- UI Setup --- + /** + * Trouve et assigne les vues de l'interface utilisateur à leurs variables membres correspondantes. + */ private void findViewsMulti() { boardGridLayoutMulti = findViewById(R.id.gameBoardMulti); myScoreLabelMulti = findViewById(R.id.myScoreLabelMulti); @@ -145,315 +215,457 @@ public class MultiplayerActivity extends AppCompatActivity { loadingIndicatorMulti = findViewById(R.id.loadingIndicatorMulti); } - // --- Game Initialization & REST Call --- - - /** Lance l'appel REST pour créer ou rejoindre une partie. */ + /** + * Initialise le processus de démarrage d'une partie multijoueur. + * Affiche l'indicateur de chargement, génère un ID de joueur local, et appelle l'API + * pour créer une nouvelle partie ou rejoindre une partie existante en attente. + * En cas de succès, stocke les informations de la partie et lance la connexion WebSocket. + * Gère les erreurs réseau potentielles. + */ private void initMultiplayerGame() { - showLoading(true); - statusTextMulti.setText(R.string.multiplayer_status_searching); + gameHasEnded = false; // Assure que le flag est bien à false + showLoading(true); // Affiche l'indicateur de chargement + statusTextMulti.setText(R.string.multiplayer_status_searching); // Met à jour le statut + myPlayerId = java.util.UUID.randomUUID().toString(); // Génère un ID joueur unique - // Générer/Récupérer Player ID (UUID temporaire pour test) - myPlayerId = java.util.UUID.randomUUID().toString(); - Log.i(TAG, "initMultiplayerGame: Utilisation du Player ID: " + myPlayerId); - - PlayerIdRequest requestBody = new PlayerIdRequest(myPlayerId); - - // Appel Retrofit - apiService.createOrJoinGame(requestBody).enqueue(new Callback() { + // Appel API asynchrone pour créer/rejoindre une partie + apiService.createOrJoinGame(new PlayerIdRequest(myPlayerId)).enqueue(new Callback() { + /** Appelée en cas de réponse réussie de l'API. */ @Override - // Utilise bien retrofit2.Response ici public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful() && response.body() != null) { + // Succès : stocke les infos de la partie et lance le WebSocket GameInfo gameInfo = response.body(); currentGameId = gameInfo.getGameId(); - - // Détermine opponentPlayerId - if (myPlayerId.equals(gameInfo.getPlayer1Id())) { - opponentPlayerId = gameInfo.getPlayer2Id(); - } else { - opponentPlayerId = gameInfo.getPlayer1Id(); - } - - Log.i(TAG, "Partie rejointe/créée via REST: ID=" + currentGameId + ", Moi=" + myPlayerId + ", Adversaire=" + opponentPlayerId); + // Détermine l'ID de l'adversaire + opponentPlayerId = myPlayerId.equals(gameInfo.getPlayer1Id()) ? gameInfo.getPlayer2Id() : gameInfo.getPlayer1Id(); statusTextMulti.setText(getString(R.string.multiplayer_status_found, currentGameId.substring(0, 8))); - - // Succès de l'appel REST -> On lance la connexion WebSocket - connectWebSocket(); - + resetReconnectionAttempts(); // Prépare pour une nouvelle connexion + connectWebSocket(); // Établit la connexion WebSocket } else { - Log.e(TAG, "Erreur création/rejoindre partie: " + response.code()); - handleNetworkError(getString(R.string.error_join_create_game, response.code())); + // Erreur de l'API (partie non trouvée, serveur plein, etc.) + handleNetworkError(getString(R.string.error_join_create_game, response.code()), true); showLoading(false); } } - + /** Appelée en cas d'échec de la communication avec l'API. */ @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.e(TAG, "Échec création/rejoindre partie", t); - handleNetworkError(getString(R.string.error_server_connection)); + // Erreur réseau ou de connexion au serveur + handleNetworkError(getString(R.string.error_server_connection), true); showLoading(false); } }); } - /** Récupère l'état actuel via REST (pour synchro). */ + /** + * Récupère l'état actuel du jeu depuis le serveur via une requête REST API. + * Utilisé principalement lors de la reprise de l'activité pour resynchroniser l'état. + */ private void fetchGameState() { - if (currentGameId == null) return; - Log.d(TAG, "fetchGameState via REST pour Game ID: " + currentGameId); - + if (currentGameId == null || gameHasEnded) return; // Ne fait rien si pas de partie ou partie terminée apiService.getGameState(currentGameId).enqueue(new Callback() { @Override - // Utilise bien retrofit2.Response ici public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null) { - Log.d(TAG, "État du jeu récupéré via REST."); - currentGameState = response.body(); - // Mettre à jour l'UI sur le thread principal + if (response.isSuccessful() && response.body() != null && !gameHasEnded) { + currentGameState = response.body(); // Met à jour l'état local + // Met à jour l'UI sur le thread principal runOnUiThread(() -> updateMultiplayerUI(currentGameState)); } else { - Log.e(TAG, "Erreur récupération état partie via REST: " + response.code()); + // Gérer l'erreur de récupération ou si la partie s'est terminée entre temps } } - @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.e(TAG, "Échec récupération état partie via REST", t); + // Gérer l'échec de la récupération si nécessaire (ex: afficher un message) } }); } - // --- WebSocket Management --- - - /** Initialise et lance la connexion WebSocket. */ + /** + * Tente d'établir une connexion WebSocket avec le serveur. + * S'assure qu'aucune connexion n'est déjà active et que la partie n'est pas terminée. + * Ferme toute connexion précédente avant d'en ouvrir une nouvelle. + * Met à jour l'UI pour indiquer l'état de connexion. + */ private void connectWebSocket() { - if (currentGameId == null || myPlayerId == null) { - Log.e(TAG, "connectWebSocket: Impossible de connecter, gameId ou playerId est null."); + // Conditions pour ne pas établir de connexion + if (currentGameId == null || myPlayerId == null || isWebSocketConnected || gameHasEnded) { return; } - if (webSocket != null) { - Log.w(TAG, "connectWebSocket: Tentative de connexion alors qu'un WebSocket existe déjà. Fermeture de l'ancien."); - closeWebSocket(); - } + // Ferme l'ancien socket s'il existe + if (webSocket != null) { closeWebSocket(); } - Log.i(TAG, "Tentative de connexion WebSocket à: " + WEBSOCKET_URL); - statusTextMulti.setText(R.string.multiplayer_status_connecting); - showLoading(true); + // Met à jour l'UI sur le thread principal + runOnUiThread(() -> { + statusTextMulti.setText(R.string.multiplayer_status_connecting); + showLoading(true); + disableSwipe(); // Désactive les swipes pendant la connexion + }); + // Crée la requête WebSocket et lance la connexion Request request = new Request.Builder().url(WEBSOCKET_URL).build(); - webSocket = wsClient.newWebSocket(request, webSocketListener); // Lance la connexion + webSocket = wsClient.newWebSocket(request, webSocketListener); } - /** Ferme la connexion WebSocket proprement. */ + /** + * Ferme proprement la connexion WebSocket si elle est active. + * Réinitialise les indicateurs d'état de connexion et de reconnexion. + * Annule les tentatives de reconnexion programmées. + */ private void closeWebSocket() { if (webSocket != null) { - Log.i(TAG, "Fermeture de la connexion WebSocket."); - webSocket.close(1000, "Activity closed"); - webSocket = null; - isWebSocketConnected = false; + webSocket.close(1000, "Client closing"); // Code 1000: fermeture normale + webSocket = null; // Libère la référence } + isWebSocketConnected = false; // Met à jour le flag + isReconnecting.set(false); // Arrête le processus de reconnexion + uiHandler.removeCallbacks(reconnectRunnable); // Annule les tâches de reconnexion } - /** Méthode pour envoyer un message texte via le WebSocket. */ + /** + * Envoie un message (sérialisé en JSON) via la connexion WebSocket active. + * Ne fait rien si le WebSocket n'est pas connecté ou si la partie est terminée. + * + * @param jsonMessage Le message au format JSON à envoyer. + */ private void sendWebSocketMessage(String jsonMessage) { - if (webSocket != null && isWebSocketConnected) { - Log.d(TAG, "Envoi WS message: " + jsonMessage); + if (webSocket != null && isWebSocketConnected && !gameHasEnded) { webSocket.send(jsonMessage); - } else { - Log.e(TAG, "sendWebSocketMessage: Tentative d'envoi alors que le WebSocket est fermé ou non connecté."); - Toast.makeText(this, R.string.error_websocket_disconnected, Toast.LENGTH_SHORT).show(); - // On pourrait tenter une reconnexion ici si désiré - // connectWebSocket(); } } + /** + * Réinitialise le compteur de tentatives de reconnexion et le flag atomique associé. + * Annule également toute tâche de reconnexion programmée dans le Handler. + */ + private void resetReconnectionAttempts() { + reconnectAttempts = 0; + isReconnecting.set(false); + uiHandler.removeCallbacks(reconnectRunnable); + } - // --- WebSocket Listener Implementation --- + /** + * Tente de se reconnecter au WebSocket si la connexion est perdue et que le nombre maximal + * de tentatives n'a pas été atteint. + * Met à jour l'UI pour indiquer la tentative en cours. + * Si le nombre maximal est atteint, gère l'erreur comme fatale. + */ + private void tryReconnect() { + // Si non connecté, partie non terminée et tentatives restantes + if (!isWebSocketConnected && !gameHasEnded && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + isReconnecting.set(true); // Marque comme en cours de reconnexion + reconnectAttempts++; + runOnUiThread(()-> statusTextMulti.setText("Disconnected. Reconnecting attempt " + reconnectAttempts + "...")); + connectWebSocket(); // Lance une nouvelle tentative de connexion + // La prochaine tentative sera programmée par onFailure si celle-ci échoue + } else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + // Nombre maximal de tentatives atteint + isReconnecting.set(false); + handleNetworkError("Reconnection failed. Please check connection and restart.", true); + } else { + // Cas où la connexion a été rétablie entre temps ou la partie s'est terminée + isReconnecting.set(false); + } + } + + /** + * Listener interne pour gérer les événements du cycle de vie et les messages de la connexion WebSocket. + */ private class MyWebSocketListener extends WebSocketListener { + /** Appelée lorsque la connexion WebSocket est établie avec succès. */ @Override - public void onOpen(@NonNull WebSocket ws, @NonNull okhttp3.Response response) { // okhttp3.Response ici ! + public void onOpen(@NonNull WebSocket ws, @NonNull okhttp3.Response response) { super.onOpen(ws, response); - isWebSocketConnected = true; - Log.i(TAG, "WebSocket onOpen: Connexion établie !"); + isWebSocketConnected = true; // Met à jour le flag + resetReconnectionAttempts(); // Réinitialise les tentatives car connecté - // Envoie le message d'enregistrement + // Envoie un message d'enregistrement au serveur avec l'ID du joueur et de la partie RegisterMessage registerMsg = new RegisterMessage("register", myPlayerId, currentGameId); - String registerJson = gson.toJson(registerMsg); - ws.send(registerJson); // Envoie l'enregistrement + ws.send(gson.toJson(registerMsg)); // Met à jour l'UI sur le thread principal runOnUiThread(() -> { - showLoading(false); + showLoading(false); // Cache l'indicateur de chargement statusTextMulti.setText(R.string.multiplayer_status_waiting_state); Toast.makeText(MultiplayerActivity.this, R.string.websocket_connected, Toast.LENGTH_SHORT).show(); + disableSwipe(); // Désactive les swipes en attendant le premier état du jeu }); } + /** Appelée lorsqu'un message texte est reçu via WebSocket. */ @Override public void onMessage(@NonNull WebSocket ws, @NonNull String text) { super.onMessage(ws, text); - Log.d(TAG, "WebSocket onMessage: Reçu texte: " + text); - try { + if (gameHasEnded) return; // Ignore les messages si la partie est déjà marquée comme terminée localement + + // Désérialise le message de base pour déterminer son type BaseMessage baseMessage = gson.fromJson(text, BaseMessage.class); if ("gameStateUpdate".equals(baseMessage.type)) { + // Si c'est une mise à jour de l'état du jeu + if (currentGameState != null) { + // Sauvegarde l'état précédent pour les animations + previousBoardState = currentGameState.getBoard(); + } + // Désérialise l'état complet du jeu currentGameState = gson.fromJson(text, GameStateResponse.class); - Log.i(TAG, "WebSocket onMessage: GameStateUpdate reçu. Tour: " + currentGameState.getCurrentPlayerId()); + // Si c'est le premier état reçu et que la partie n'est pas finie, enregistre l'heure de début + if (multiplayerGameStartTimeMs == 0L && !currentGameState.isGameOver()) { + multiplayerGameStartTimeMs = System.currentTimeMillis(); + } + + // Met à jour l'UI sur le thread principal runOnUiThread(() -> { - showLoading(false); - updateMultiplayerUI(currentGameState); + showLoading(false); // S'assure que le chargement est caché + updateMultiplayerUI(currentGameState); // Met à jour l'affichage (scores, plateau...) + + // Anime les changements sur le plateau + if (previousBoardState != null) { + animateMultiplayerChanges(previousBoardState, currentGameState.getBoard()); + } else { + // Anime l'apparition initiale + animateMultiplayerChanges(new int[BOARD_SIZE][BOARD_SIZE], currentGameState.getBoard()); + } + + // Active/désactive le swipe en fonction du tour + updateSwipeListenerState(currentGameState); + + // Vérifie si l'état reçu indique la fin de partie if (currentGameState.isGameOver()) { - handleGameOverUI(currentGameState); + handleMultiplayerGameOver(currentGameState); } }); } else if ("error".equals(baseMessage.type)) { + // Si c'est un message d'erreur du serveur ErrorMessage errorMsg = gson.fromJson(text, ErrorMessage.class); - Log.e(TAG, "WebSocket onMessage: Erreur serveur reçue: " + errorMsg.message); - runOnUiThread(() -> { - Toast.makeText(MultiplayerActivity.this, getString(R.string.server_error_prefix, errorMsg.message), Toast.LENGTH_LONG).show(); - statusTextMulti.setText(getString(R.string.server_error_prefix, errorMsg.message)); - }); + handleServerError(errorMsg.message); // Gère l'erreur } else if ("info".equals(baseMessage.type)) { + // Si c'est un message d'information du serveur InfoMessage infoMsg = gson.fromJson(text, InfoMessage.class); - Log.i(TAG, "WebSocket onMessage: Info serveur reçue: " + infoMsg.message); + // Affiche l'info dans un Toast runOnUiThread(() -> Toast.makeText(MultiplayerActivity.this, "Info: " + infoMsg.message, Toast.LENGTH_SHORT).show()); - } else { - Log.w(TAG, "WebSocket onMessage: Type de message inconnu reçu: " + baseMessage.type); } } catch (JsonSyntaxException e) { - Log.e(TAG, "WebSocket onMessage: Erreur parsing JSON", e); + // Gère une erreur de désérialisation JSON } } - @Override - public void onMessage(@NonNull WebSocket ws, @NonNull ByteString bytes) { - super.onMessage(ws, bytes); - Log.w(TAG, "WebSocket onMessage: Reçu message binaire (non géré)"); - } - + /** Appelée lorsque le serveur initie la fermeture de la connexion. */ @Override public void onClosing(@NonNull WebSocket ws, int code, @NonNull String reason) { super.onClosing(ws, code, reason); - Log.i(TAG, "WebSocket onClosing: Fermeture demandée par le serveur. Code=" + code + ", Raison=" + reason); - isWebSocketConnected = false; - ws.close(1000, null); - runOnUiThread(() -> { - statusTextMulti.setText(getString(R.string.websocket_closing)); - showLoading(false); - }); + isWebSocketConnected = false; // Marque comme déconnecté } + /** Appelée lorsque la connexion WebSocket est complètement fermée. */ @Override public void onClosed(@NonNull WebSocket ws, int code, @NonNull String reason) { super.onClosed(ws, code, reason); - Log.i(TAG, "WebSocket onClosed: Connexion fermée. Code=" + code + ", Raison=" + reason); - isWebSocketConnected = false; - webSocket = null; - runOnUiThread(() -> { - statusTextMulti.setText(getString(R.string.websocket_closed)); - showLoading(false); - // Gérer la reconnexion si nécessaire - }); + isWebSocketConnected = false; // Marque comme déconnecté + webSocket = null; // Libère la référence + // Si la partie n'est pas censée être terminée, tente de se reconnecter + if (!gameHasEnded) { + runOnUiThread(() -> statusTextMulti.setText(getString(R.string.websocket_closed) + " Reconnecting...")); + // Lance le processus de reconnexion si pas déjà en cours + if (!isReconnecting.getAndSet(true)) { + resetReconnectionAttempts(); + // Programme la première tentative après un court délai + uiHandler.postDelayed(reconnectRunnable, 1000); + } + } else { + // Si la partie est terminée, met juste à jour le statut + runOnUiThread(() -> { + statusTextMulti.setText(getString(R.string.websocket_closed)); + disableSwipe(); + showLoading(false); + }); + } } + /** Appelée en cas d'échec de la connexion ou d'erreur de communication. */ @Override - public void onFailure(@NonNull WebSocket ws, @NonNull Throwable t, @Nullable okhttp3.Response response) { // okhttp3.Response ici ! + public void onFailure(@NonNull WebSocket ws, @NonNull Throwable t, @Nullable okhttp3.Response response) { super.onFailure(ws, t, response); - Log.e(TAG, "WebSocket onFailure: Erreur de connexion.", t); - isWebSocketConnected = false; - webSocket = null; - runOnUiThread(() -> { - handleNetworkError(getString(R.string.error_websocket_connection)); - showLoading(false); - // Gérer la reconnexion si nécessaire - }); - } - } - - // --- Data Classes internes pour WebSocket Messages --- - private static class BaseMessage { String type; } - private static class ErrorMessage extends BaseMessage { String message; } - private static class InfoMessage extends BaseMessage { String message; } - private static class RegisterMessage extends BaseMessage { String playerId; String gameId; - RegisterMessage(String type, String pId, String gId){ this.type=type; this.playerId=pId; this.gameId=gId; } - } - private static class MoveMessage extends BaseMessage { String direction; String playerId; - MoveMessage(String type, String dir, String pId) {this.type=type; this.direction=dir; this.playerId=pId;} - } - - // --- UI Update --- - /** Met à jour l'interface multijoueur. DOIT être appelée sur le thread UI. */ - private void updateMultiplayerUI(GameStateResponse state) { - if (state == null) return; - if (Looper.myLooper() != Looper.getMainLooper()) { runOnUiThread(() -> updateMultiplayerUI(state)); return; } - Log.d(TAG, "updateMultiplayerUI: Mise à jour sur thread UI."); - - myScoreLabelMulti.setText(getString(R.string.multiplayer_my_score, state.getMyScore(myPlayerId))); - opponentScoreLabelMulti.setText(getString(R.string.multiplayer_opponent_score, state.getOpponentScore(myPlayerId))); - - boolean myTurn = myPlayerId != null && myPlayerId.equals(state.getCurrentPlayerId()); - turnIndicatorMulti.setText(myTurn ? R.string.multiplayer_turn_yours : R.string.multiplayer_turn_opponent); - turnIndicatorMulti.setTextColor(ContextCompat.getColor(this, myTurn ? R.color.tile_16 : R.color.text_tile_low)); - - syncBoardViewMulti(state.getBoard()); - - if (!state.isGameOver()) { - if (!myTurn) { statusTextMulti.setText(R.string.multiplayer_status_waiting_opponent); } - else { statusTextMulti.setText(""); } - } - // Le message de fin est géré par handleGameOverUI - } - - /** Gère l'affichage de fin de partie. DOIT être appelée sur le thread UI. */ - private void handleGameOverUI(GameStateResponse finalState) { - if (finalState == null || !finalState.isGameOver()) return; - if (Looper.myLooper() != Looper.getMainLooper()) { runOnUiThread(() -> handleGameOverUI(finalState)); return; } - - Log.i(TAG, "handleGameOverUI: Partie terminée. Winner: " + finalState.getWinnerId()); - String endMessage; - if ("DRAW".equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_draw); } - else if (myPlayerId != null && myPlayerId.equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_won); } - else if (opponentPlayerId != null && opponentPlayerId.equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_lost); } - else { endMessage = getString(R.string.game_over_generic); } - - statusTextMulti.setText(endMessage); - turnIndicatorMulti.setText(""); - closeWebSocket(); // Fermer le WebSocket à la fin de la partie - } - - - // --- Board Sync (inchangé) --- - /** Synchronise la vue de la grille multijoueur avec un état de plateau donné. */ - private void syncBoardViewMulti(int[][] boardState) { - if (boardState == null || boardState.length != BOARD_SIZE || boardState[0].length != BOARD_SIZE) { Log.e(TAG, "syncBoardViewMulti: État du plateau invalide."); return; } - boardGridLayoutMulti.removeAllViews(); - tileViewsMulti = new TextView[BOARD_SIZE][BOARD_SIZE]; - for (int r = 0; r < BOARD_SIZE; r++) { - for (int c = 0; c < BOARD_SIZE; c++) { - View backgroundCell = new View(this); - backgroundCell.setBackgroundResource(R.drawable.tile_background); - backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty)); - GridLayout.LayoutParams bgParams = new GridLayout.LayoutParams(GridLayout.spec(r, 1f), GridLayout.spec(c, 1f)); - bgParams.width = 0; bgParams.height = 0; - int margin = (int) getResources().getDimension(R.dimen.tile_margin); - bgParams.setMargins(margin, margin, margin, margin); - backgroundCell.setLayoutParams(bgParams); - boardGridLayoutMulti.addView(backgroundCell); - int value = boardState[r][c]; - if (value > 0) { - TextView tileView = createTileTextViewMulti(value, r, c); - tileViewsMulti[r][c] = tileView; - boardGridLayoutMulti.addView(tileView); - } else { tileViewsMulti[r][c] = null; } + isWebSocketConnected = false; // Marque comme déconnecté + webSocket = null; // Libère la référence + // Si la partie n'est pas terminée, lance ou continue le processus de reconnexion + if (!gameHasEnded) { + if (!isReconnecting.get()) { // Si ce n'est pas déjà en cours de reconnexion + isReconnecting.set(true); // Marque comme en cours + runOnUiThread(() -> { + statusTextMulti.setText("Connection failed. Retrying..."); + disableSwipe(); + tryReconnect(); // Lance immédiatement la première tentative + }); + } + // Programme la PROCHAINE tentative après le délai configuré + uiHandler.postDelayed(reconnectRunnable, RECONNECT_DELAY_MS); + } else { + // Si la partie est terminée, gère comme une erreur réseau non fatale + handleNetworkError("WebSocket connection failed.", false); } } } - /** Crée une TextView pour une tuile multijoueur. */ + + // --- Classes internes pour les messages WebSocket --- + /** Classe de base pour les messages WebSocket, contenant uniquement le type. */ + private static class BaseMessage { String type; } + /** Message WebSocket pour signaler une erreur du serveur. */ + private static class ErrorMessage extends BaseMessage { String message; } + /** Message WebSocket pour envoyer une information textuelle. */ + private static class InfoMessage extends BaseMessage { String message; } + /** Message WebSocket envoyé par le client pour s'enregistrer auprès du serveur. */ + private static class RegisterMessage extends BaseMessage { String playerId; String gameId; RegisterMessage(String t, String p, String g){type=t; playerId=p; gameId=g;} } + /** Message WebSocket envoyé par le client pour soumettre un mouvement. */ + private static class MoveMessage extends BaseMessage { String direction; String playerId; MoveMessage(String t, String d, String p){type=t; direction=d; playerId=p;} } + + /** + * Met à jour l'interface utilisateur globale (scores, indicateur de tour, plateau) + * en fonction de l'état du jeu fourni. + * S'assure d'être exécutée sur le thread UI. + * + * @param state L'état actuel du jeu {@link GameStateResponse}. + */ + private void updateMultiplayerUI(GameStateResponse state) { + if (state == null) return; // Ne fait rien si l'état est null + // Vérifie si on est sur le thread UI, sinon repost sur le thread UI + if (Looper.myLooper() != Looper.getMainLooper()) { runOnUiThread(() -> updateMultiplayerUI(state)); return; } + + // Met à jour les labels de score + myScoreLabelMulti.setText(getString(R.string.multiplayer_my_score, state.getMyScore(myPlayerId))); + opponentScoreLabelMulti.setText(getString(R.string.multiplayer_opponent_score, state.getOpponentScore(myPlayerId))); + + // Met à jour l'indicateur de tour + boolean myTurn = myPlayerId != null && myPlayerId.equals(state.getCurrentPlayerId()); + turnIndicatorMulti.setText(myTurn ? R.string.multiplayer_turn_yours : R.string.multiplayer_turn_opponent); + // Change la couleur du texte en fonction du tour + turnIndicatorMulti.setTextColor(ContextCompat.getColor(this, myTurn ? R.color.tile_16 : R.color.text_tile_low)); + + // Synchronise l'affichage du plateau de jeu + syncBoardViewMulti(state.getBoard()); + + // Met à jour le texte de statut si la partie n'est pas terminée + if (!gameHasEnded && !state.isGameOver()) { + updateStatusBasedOnTurn(state); + } + } + + /** + * Gère la fin d'une partie multijoueur. + * Marque la partie comme terminée, enregistre les statistiques finales (victoire, défaite, nul), + * met à jour l'UI pour afficher le message de fin de partie, désactive les swipes et ferme le WebSocket. + * S'assure que cela ne se produit qu'une seule fois. + * + * @param finalState L'état final du jeu {@link GameStateResponse} reçu du serveur. + */ + private void handleMultiplayerGameOver(GameStateResponse finalState) { + // Vérifie si la fin de partie n'a pas déjà été gérée + if (!gameHasEnded) { + gameHasEnded = true; // Marque comme terminée + // Enregistre les statistiques si la partie avait démarré + if (gameStats != null && multiplayerGameStartTimeMs > 0) { + long durationMs = System.currentTimeMillis() - multiplayerGameStartTimeMs; + int myFinalScore = finalState.getMyScore(myPlayerId); + // Détermine le résultat et enregistre les stats + if ("DRAW".equals(finalState.getWinnerId())) { gameStats.recordMultiplayerDraw(myFinalScore, durationMs); } + else if (myPlayerId != null && myPlayerId.equals(finalState.getWinnerId())) { gameStats.recordMultiplayerWin(myFinalScore, durationMs); } + else { gameStats.recordMultiplayerLoss(myFinalScore, durationMs); } + } + // Met à jour l'UI sur le thread principal + runOnUiThread(() -> { + showGameOverMessage(finalState); // Affiche le message de fin + disableSwipe(); // Désactive les swipes + closeWebSocket(); // Ferme la connexion + }); + } + } + + /** + * Affiche le message approprié dans le TextView de statut en fonction du résultat de la partie. + * Gère les cas de victoire, défaite, match nul, et victoire par forfait (heuristique). + * + * @param finalState L'état final du jeu {@link GameStateResponse}. + */ + private void showGameOverMessage(GameStateResponse finalState) { + String endMessage; + boolean wonByForfeit = false; + // Heuristique pour détecter une victoire par forfait (si on gagne sans atteindre l'objectif) + // Cela nécessite que le serveur attribue correctement le gagnant en cas de déconnexion adverse. + if (myPlayerId != null && myPlayerId.equals(finalState.getWinnerId()) && finalState.getMyScore(myPlayerId) < finalState.getTargetScore()) { + wonByForfeit = true; + } + + // Choisit le message en fonction du résultat + if ("DRAW".equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_draw); } + else if (wonByForfeit) { endMessage = "Opponent disconnected - You Win!"; } + else if (myPlayerId != null && myPlayerId.equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_won); } + else if (opponentPlayerId != null && opponentPlayerId.equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_lost); } + else { endMessage = getString(R.string.game_over_generic); } // Cas générique + + // Affiche le message et efface l'indicateur de tour + statusTextMulti.setText(endMessage); + turnIndicatorMulti.setText(""); + } + + /** + * Synchronise la vue du plateau de jeu ({@link #boardGridLayoutMulti}) avec l'état + * du plateau fourni. + * Supprime toutes les vues existantes et recrée les cellules de fond et les TextViews des tuiles + * en fonction des valeurs dans {@code boardState}. + * + * @param boardState Le tableau 2D représentant l'état actuel du plateau. + */ + private void syncBoardViewMulti(int[][] boardState) { + if (boardState == null || boardState.length != BOARD_SIZE || boardState[0].length != BOARD_SIZE) { return; } + boardGridLayoutMulti.removeAllViews(); // Efface le contenu précédent de la grille + tileViewsMulti = new TextView[BOARD_SIZE][BOARD_SIZE]; // Réinitialise le tableau de références des vues de tuiles + // Parcours chaque cellule du plateau + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + // Crée et ajoute la cellule de fond (toujours présente) + View backgroundCell = new View(this); + backgroundCell.setBackgroundResource(R.drawable.tile_background); + backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty)); + // Configure les paramètres de layout pour la grille (poids égal pour toutes les cellules) + GridLayout.LayoutParams bgParams = new GridLayout.LayoutParams(GridLayout.spec(r, 1f), GridLayout.spec(c, 1f)); + bgParams.width = 0; bgParams.height = 0; // Utilise le poids pour déterminer la taille + int margin = (int) getResources().getDimension(R.dimen.tile_margin); // Marge autour des cellules + bgParams.setMargins(margin, margin, margin, margin); + backgroundCell.setLayoutParams(bgParams); + boardGridLayoutMulti.addView(backgroundCell); + + int value = boardState[r][c]; // Valeur de la tuile à cette position + if (value > 0) { + // Si la cellule contient une tuile, crée le TextView correspondant + TextView tileView = createTileTextViewMulti(value, r, c); + tileViewsMulti[r][c] = tileView; // Stocke la référence + boardGridLayoutMulti.addView(tileView); // Ajoute la vue de la tuile à la grille + } else { + // S'il n'y a pas de tuile, stocke null dans le tableau de références + tileViewsMulti[r][c] = null; + } + } + } + } + + /** + * Crée et configure une instance de {@link TextView} pour représenter une tuile du jeu. + * Applique le style approprié (couleur, taille de texte) en fonction de la valeur de la tuile + * et configure ses paramètres de layout pour le {@link GridLayout}. + * + * @param value La valeur de la tuile à afficher. + * @param row La ligne de la tuile dans la grille. + * @param col La colonne de la tuile dans la grille. + * @return Le {@link TextView} configuré représentant la tuile. + */ private TextView createTileTextViewMulti(int value, int row, int col) { - TextView tileTextView = new TextView(this); - setTileStyleMulti(tileTextView, value); + TextView tileTextView = new TextView(this); // Crée une nouvelle instance + setTileStyleMulti(tileTextView, value); // Applique le style (couleur, texte, taille) + // Configure les paramètres de layout pour la grille (identiques à la cellule de fond) GridLayout.LayoutParams params = new GridLayout.LayoutParams(GridLayout.spec(row, 1f), GridLayout.spec(col, 1f)); params.width = 0; params.height = 0; int margin = (int) getResources().getDimension(R.dimen.tile_margin); @@ -461,38 +673,126 @@ public class MultiplayerActivity extends AppCompatActivity { tileTextView.setLayoutParams(params); return tileTextView; } - /** Applique le style à une tuile. */ + + /** + * Applique le style visuel (couleur de fond, couleur de texte, taille de texte) + * à un {@link TextView} représentant une tuile, en fonction de sa valeur numérique. + * Utilise des ressources de couleur et de dimension définies dans les fichiers XML. + * + * @param tileTextView Le {@link TextView} de la tuile à styliser. + * @param value La valeur numérique de la tuile. + */ private void setTileStyleMulti(TextView tileTextView, int value) { - tileTextView.setText(value > 0 ? String.valueOf(value) : ""); - tileTextView.setGravity(Gravity.CENTER); - tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); + tileTextView.setText(value > 0 ? String.valueOf(value) : ""); // Affiche la valeur ou rien si 0 + tileTextView.setGravity(Gravity.CENTER); // Centre le texte + tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); // Met le texte en gras + int backgroundColorId; int textColorId; int textSizeId; + // Sélectionne les ressources de style en fonction de la valeur switch (value) { - case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; - case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; - case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; - case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; - case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; - case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; - case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; - case 1024: backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; - case 2048: backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; - default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break; + case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; + case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break; + case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break; + case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break; + case 1024:backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + case 2048:backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; + default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // Pour > 2048 } - tileTextView.setBackgroundResource(R.drawable.tile_background); - tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId)); - tileTextView.setTextColor(ContextCompat.getColor(this, textColorId)); - tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId)); + // Applique les styles + tileTextView.setBackgroundResource(R.drawable.tile_background); // Définit le drawable de fond (peut-être arrondi) + tileTextView.getBackground().setTint(ContextCompat.getColor(this, backgroundColorId)); // Applique la couleur de fond + tileTextView.setTextColor(ContextCompat.getColor(this, textColorId)); // Applique la couleur du texte + tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId)); // Applique la taille du texte } - // --- Swipe Handling (Modifié pour envoyer via WebSocket) --- + /** + * Détecte les changements entre deux états du plateau (avant et après un mouvement) + * et lance les animations correspondantes (apparition de nouvelles tuiles, fusion de tuiles). + * + * @param boardBefore L'état du plateau avant le mouvement. + * @param boardAfter L'état du plateau après le mouvement. + */ + private void animateMultiplayerChanges(@NonNull int[][] boardBefore, @NonNull int[][] boardAfter) { + List animations = new ArrayList<>(); // Liste pour collecter les animations à jouer simultanément + // Parcours toutes les cellules du plateau après le mouvement + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + TextView currentView = tileViewsMulti[r][c]; // Vue de la tuile à cette position (peut être null) + if (currentView == null) continue; // Ignore si pas de tuile après le mouvement - /** Configure le listener de swipe pour le plateau multijoueur. */ - @SuppressLint("ClickableViewAccessibility") + int valueAfter = boardAfter[r][c]; // Valeur après + // Récupère la valeur avant (gère les cas où boardBefore pourrait être mal formé ou null) + int valueBefore = (boardBefore != null && boardBefore.length > r && boardBefore[r].length > c) ? boardBefore[r][c] : 0; + + // Détecte une nouvelle tuile apparue (était 0 avant, > 0 après) + if (valueBefore == 0 && valueAfter > 0) { + // Prépare la vue pour l'animation d'apparition (petite échelle, transparente) + currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f); + animations.add(createAppearAnimation(currentView)); // Ajoute l'animation d'apparition + } + // Détecte une fusion (valeur après > valeur avant, et n'était pas 0 avant) + else if (valueAfter > valueBefore && valueBefore != 0) { + animations.add(createMergeAnimation(currentView)); // Ajoute l'animation de fusion + } + } + } + // Si des animations ont été trouvées, les joue toutes ensemble + if (!animations.isEmpty()) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animations); // Joue toutes les animations simultanément + animatorSet.start(); // Démarre l'ensemble d'animations + } + } + + /** + * Crée un {@link Animator} pour l'animation d'apparition d'une tuile (scale + alpha). + * + * @param view La {@link View} (TextView de la tuile) à animer. + * @return L'Animator configuré pour l'apparition. + */ + private Animator createAppearAnimation(@NonNull View view) { + // Animations de changement d'échelle et d'opacité + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f); + ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f); + // Groupe les animations pour les jouer ensemble + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY, alpha); + set.setDuration(150); // Durée de l'animation en ms + return set; + } + + /** + * Crée un {@link Animator} pour l'animation de fusion d'une tuile (effet de "pop"). + * + * @param view La {@link View} (TextView de la tuile) à animer. + * @return L'Animator configuré pour la fusion. + */ + private Animator createMergeAnimation(@NonNull View view) { + // Animation de changement d'échelle (1 -> 1.2 -> 1) + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); + // Groupe les animations + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY); + set.setDuration(120); // Durée de l'animation en ms + return set; + } + + /** + * Configure le listener de détection de swipe sur la grille du plateau de jeu. + * Utilise {@link OnSwipeTouchListener}. + */ + @SuppressLint("ClickableViewAccessibility") // Nécessaire car on attache un OnTouchListener private void setupSwipeListenerMulti() { + if (boardGridLayoutMulti == null) return; // Vérifie si la grille existe + // Crée et attache une nouvelle instance de OnSwipeTouchListener boardGridLayoutMulti.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() { @Override public void onSwipeTop() { handleMultiplayerSwipe(Direction.UP); } @Override public void onSwipeBottom() { handleMultiplayerSwipe(Direction.DOWN); } @@ -501,33 +801,176 @@ public class MultiplayerActivity extends AppCompatActivity { })); } - /** Gère un swipe : envoie un message WebSocket 'move'. */ + /** + * Gère un geste de balayage (swipe) détecté sur le plateau de jeu. + * Vérifie si c'est le tour du joueur local et si la partie est en cours. + * Si les conditions sont remplies, met à jour le statut, désactive temporairement les swipes, + * et envoie le mouvement au serveur via WebSocket. + * Affiche un message si ce n'est pas le tour du joueur. + * + * @param direction La direction du balayage détecté. + */ private void handleMultiplayerSwipe(Direction direction) { - if (currentGameState == null || currentGameId == null || currentGameState.isGameOver() || myPlayerId == null) { Log.d(TAG, "Swipe ignoré (état invalide)."); return; } - if (!myPlayerId.equals(currentGameState.getCurrentPlayerId())) { Toast.makeText(this, R.string.error_not_your_turn, Toast.LENGTH_SHORT).show(); return; } - if (webSocket == null || !isWebSocketConnected) { Toast.makeText(this, R.string.error_websocket_disconnected, Toast.LENGTH_SHORT).show(); connectWebSocket(); return; } + // Vérifications préliminaires + if (!isWebSocketConnected || gameHasEnded || currentGameState == null || currentGameState.isGameOver() || myPlayerId == null) return; + // Vérifie si c'est le tour du joueur local + if (!myPlayerId.equals(currentGameState.getCurrentPlayerId())) { + Toast.makeText(this, R.string.error_not_your_turn, Toast.LENGTH_SHORT).show(); + return; + } - Log.d(TAG, "Swipe détecté: " + direction + ". Envoi du mouvement via WebSocket..."); - statusTextMulti.setText(R.string.multiplayer_status_sending_move); + // C'est le tour du joueur : prépare l'envoi du mouvement + statusTextMulti.setText(R.string.multiplayer_status_sending_move); // Met à jour le statut + disableSwipe(); // Désactive les swipes en attendant la réponse + // Crée le message de mouvement et l'envoie via WebSocket MoveMessage moveMsg = new MoveMessage("move", direction.name(), myPlayerId); - String moveJson = gson.toJson(moveMsg); - sendWebSocketMessage(moveJson); - // L'UI sera mise à jour à la réception du gameStateUpdate + sendWebSocketMessage(gson.toJson(moveMsg)); } + /** + * Gère les messages d'erreur reçus du serveur via WebSocket. + * Détermine si l'erreur est fatale (nécessite d'arrêter la partie) ou non. + * Affiche un message approprié à l'utilisateur (Toast ou AlertDialog). + * + * @param message Le message d'erreur reçu du serveur. + */ + private void handleServerError(String message) { + boolean isFatal = false; // Indicateur si l'erreur bloque la partie + String userMessage = getString(R.string.server_error_prefix, message); // Message par défaut - // --- Utility Methods --- + // Détermine si l'erreur est fatale en fonction de son contenu + if (message.contains("Game not found") || message.contains("Player ID mismatch") || message.contains("Registration failed")) { + isFatal = true; + userMessage = "Critical Error: " + message + ". Cannot continue game."; + } else if (message.contains("Game not currently playing") || message.contains("Game is already over")) { + // Erreur indiquant que la partie est terminée côté serveur + isFatal = true; + userMessage = "Info: " + message; + gameHasEnded = true; // Marque la partie comme terminée localement aussi + } + + if (isFatal) { + // Gère l'erreur fatale (affiche un dialogue bloquant) + handleNetworkError(userMessage, true); + } else { + // Pour les erreurs non fatales, affiche un Toast et remet le statut UI + String finalUserMessage = userMessage; + runOnUiThread(() -> { + Toast.makeText(this, finalUserMessage, Toast.LENGTH_SHORT).show(); + // Si l'erreur est survenue après l'envoi d'un coup, remet le statut "en attente" ou "votre tour" + if (statusTextMulti.getText().toString().equals(getString(R.string.multiplayer_status_sending_move))) { + updateStatusBasedOnTurn(currentGameState); // Remet le statut correct + } + // Réactive le swipe si c'était le tour du joueur (au cas où l'erreur était temporaire) + updateSwipeListenerState(currentGameState); + }); + } + } + + /** + * Active ou désactive la détection des swipes sur le plateau de jeu en fonction + * de l'état actuel du jeu (si la partie est en cours et si c'est le tour du joueur local). + * + * @param state L'état actuel du jeu {@link GameStateResponse}. + */ + private void updateSwipeListenerState(GameStateResponse state) { + // Désactive si état null, partie terminée ou finie + if (state == null || gameHasEnded || state.isGameOver()) { + disableSwipe(); + return; + } + // Active seulement si c'est le tour du joueur local + boolean myTurn = myPlayerId != null && myPlayerId.equals(state.getCurrentPlayerId()); + if (myTurn) { + enableSwipe(); + } else { + disableSwipe(); + } + } + + /** + * Active la détection des swipes en attachant le {@link OnSwipeTouchListener} à la grille. + */ + @SuppressLint("ClickableViewAccessibility") // Nécessaire pour setOnTouchListener + private void enableSwipe() { + if (boardGridLayoutMulti != null) { + // Réattache le listener existant ou en crée un nouveau si nécessaire + setupSwipeListenerMulti(); + } + } + + /** + * Désactive la détection des swipes en détachant le listener (mettant OnTouchListener à null). + */ + private void disableSwipe() { + if (boardGridLayoutMulti != null) { + boardGridLayoutMulti.setOnTouchListener(null); // Détache le listener + } + } + + /** + * Affiche ou masque l'indicateur de chargement (ProgressBar). + * + * @param show {@code true} pour afficher l'indicateur, {@code false} pour le masquer. + */ private void showLoading(boolean show) { - if (loadingIndicatorMulti != null) { loadingIndicatorMulti.setVisibility(show ? View.VISIBLE : View.GONE); } + if (loadingIndicatorMulti != null) { + loadingIndicatorMulti.setVisibility(show ? View.VISIBLE : View.GONE); + } } - private void handleNetworkError(String message) { + + /** + * Gère l'affichage des erreurs réseau ou serveur. + * Pour les erreurs fatales, affiche un {@link AlertDialog} bloquant qui ferme l'activité. + * Pour les erreurs non fatales, affiche un {@link Toast} et met à jour le texte de statut. + * Masque toujours l'indicateur de chargement. + * S'assure d'être exécutée sur le thread UI. + * + * @param message Le message d'erreur à afficher. + * @param isFatal {@code true} si l'erreur empêche la continuation de la partie. + */ + private void handleNetworkError(String message, boolean isFatal) { runOnUiThread(() -> { - if (statusTextMulti != null) { statusTextMulti.setText(message); } - Toast.makeText(MultiplayerActivity.this, message, Toast.LENGTH_LONG).show(); - showLoading(false); + // Si l'erreur est fatale et l'activité n'est pas en train de se fermer + if(isFatal && !isFinishing()) { + // Affiche un dialogue d'erreur bloquant + new AlertDialog.Builder(this) + .setTitle("Error") + .setMessage(message) + .setPositiveButton(android.R.string.ok, (dialog, which) -> finish()) // Ferme l'activité au clic sur OK + .setCancelable(false) // Empêche de fermer le dialogue autrement + .show(); + gameHasEnded = true; // Marque la partie comme terminée + disableSwipe(); // Désactive les interactions + } else if (!isFinishing()){ // Si erreur non fatale et activité active + // Met à jour le texte de statut et affiche un Toast long + if (statusTextMulti != null) { statusTextMulti.setText(message); } + Toast.makeText(MultiplayerActivity.this, message, Toast.LENGTH_LONG).show(); + } + showLoading(false); // Masque l'indicateur de chargement dans tous les cas d'erreur gérés }); } + + /** + * Met à jour le texte de statut principal en fonction de l'état actuel du jeu, + * typiquement pour indiquer si le joueur attend le coup de l'adversaire. + * Ne fait rien si la partie est terminée. + * + * @param state L'état actuel du jeu {@link GameStateResponse}. + */ + private void updateStatusBasedOnTurn(GameStateResponse state) { + // Ne met pas à jour si l'état est null, la partie terminée localement ou via l'état + if (state == null || state.isGameOver() || gameHasEnded) return; + // Détermine si c'est le tour du joueur local + boolean myTurn = myPlayerId != null && myPlayerId.equals(state.getCurrentPlayerId()); + // Affiche "En attente de l'adversaire" si ce n'est pas notre tour, sinon efface le statut (ou le laisse tel quel) + statusTextMulti.setText(myTurn ? "" : getString(R.string.multiplayer_status_waiting_opponent)); + } + + /** + * Énumération privée représentant les quatre directions de mouvement possibles. + */ private enum Direction { UP, DOWN, LEFT, RIGHT } -} // Fin MultiplayerActivity \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/NotificationHelper.java b/app/src/main/java/legion/muyue/best2048/NotificationHelper.java index 4f00892..6616b91 100644 --- a/app/src/main/java/legion/muyue/best2048/NotificationHelper.java +++ b/app/src/main/java/legion/muyue/best2048/NotificationHelper.java @@ -1,9 +1,3 @@ -/** - * Classe utilitaire centralisant la logique de création et d'affichage des notifications - * pour l'application Best 2048. - * Gère également la création du canal de notification nécessaire pour Android 8.0 (API 26) et supérieur. - * Utilise {@link NotificationCompat} pour assurer la compatibilité avec les anciennes versions d'Android. - */ package legion.muyue.best2048; import android.app.NotificationChannel; @@ -11,143 +5,128 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; // Import pour PackageManager.PERMISSION_GRANTED +import android.content.pm.PackageManager; import android.os.Build; -import android.util.Log; // Pour le logging des erreurs/warnings -import androidx.annotation.NonNull; // Pour marquer les paramètres non-null -import androidx.core.app.ActivityCompat; // Pour checkSelfPermission (remplace ContextCompat ici) +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; -// import androidx.core.content.ContextCompat; // Peut être utilisé mais ActivityCompat est plus direct pour la permission ici +/** + * Classe utilitaire pour aider à la création et à l'affichage des notifications Android + * pour l'application Best2048. + * Gère la création du canal de notification requis à partir d'Android 8 (Oreo) + * et fournit une méthode standardisée pour afficher les notifications, + * en incluant la vérification des permissions pour Android 13+ et la configuration + * d'un {@link PendingIntent} pour ouvrir l'application. + * + *

Il est recommandé d'appeler {@link #createNotificationChannel(Context)} une fois + * au démarrage de l'application (par exemple, dans la méthode {@code onCreate} de la classe Application) + * pour s'assurer que le canal est disponible avant d'envoyer des notifications sur les appareils + * Android 8.0+.

+ */ public class NotificationHelper { /** - * Identifiant unique et constant pour le canal de notification de cette application. - * Ce même ID doit être utilisé lors de la création de la notification via {@link NotificationCompat.Builder}. - * Il est visible par l'utilisateur dans les paramètres de notification de l'application sur Android 8.0+. + * Identifiant unique et constant pour le canal de notification de l'application. + * Requis pour Android 8.0 (API niveau 26) et supérieur. */ - public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé dans MainActivity/Service - - /** Tag pour les logs spécifiques à ce helper. */ - private static final String TAG = "NotificationHelper"; + public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; /** - * Crée le canal de notification requis pour afficher des notifications sur Android 8.0 (API 26) et supérieur. - * Si le canal existe déjà, cette méthode ne fait rien (elle est idempotente). - * Doit être appelée une fois, idéalement au démarrage de l'application (par exemple dans `Application.onCreate` - * ou dans `MainActivity.onCreate`), avant d'afficher la toute première notification sur un appareil API 26+. + * Constructeur privé pour empêcher l'instanciation de cette classe utilitaire. + */ + private NotificationHelper() { + // Classe utilitaire, ne doit pas être instanciée. + } + + /** + * Crée le canal de notification nécessaire pour afficher des notifications sur + * Android 8.0 (Oreo, API niveau 26) et supérieur. + * Si l'application cible une version d'Android inférieure ou si le canal existe déjà, + * cette méthode n'a aucun effet ou n'est pas nécessaire. + * Doit être appelée avant de tenter d'afficher une notification sur les appareils concernés. + * Utilise des ressources string ({@code R.string}) pour le nom et la description du canal. * - * @param context Le contexte applicatif ({@code Context}) utilisé pour accéder aux services système. + * @param context Le contexte de l'application, nécessaire pour accéder aux services système + * et aux ressources. Ne doit pas être null. */ public static void createNotificationChannel(@NonNull Context context) { - // La création de canal n'est nécessaire que sur Android Oreo (API 26) et versions ultérieures if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Récupère les chaînes de caractères pour le nom et la description du canal depuis les ressources CharSequence name = context.getString(R.string.notification_channel_name); String description = context.getString(R.string.notification_channel_description); - // Définit l'importance du canal (affecte comment la notification est présentée à l'utilisateur) - // IMPORTANCE_DEFAULT est un bon point de départ pour la plupart des notifications. - int importance = NotificationManager.IMPORTANCE_DEFAULT; + // Définit l'importance du canal (affecte comment la notification est présentée) + int importance = NotificationManager.IMPORTANCE_DEFAULT; // Ou IMPORTANCE_HIGH, etc. // Crée l'objet NotificationChannel NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); channel.setDescription(description); - // Optionnel : Configurer d'autres propriétés du canal ici (vibration, lumière, son par défaut, etc.) - // channel.enableLights(true); - // channel.setLightColor(Color.RED); - // channel.enableVibration(true); - // channel.setVibrationPattern(new long[]{100, 200, 300, 400, 500, 400, 300, 200, 400}); // Récupère le NotificationManager système NotificationManager notificationManager = context.getSystemService(NotificationManager.class); // Enregistre le canal auprès du système. Si le canal existe déjà, aucune opération n'est effectuée. if (notificationManager != null) { - Log.d(TAG, "Création ou mise à jour du canal de notification: " + CHANNEL_ID); notificationManager.createNotificationChannel(channel); } else { - Log.e(TAG, "NotificationManager non disponible, impossible de créer le canal."); } - } else { - Log.d(TAG, "API < 26, pas besoin de créer de canal de notification."); } } /** * Construit et affiche une notification standard pour l'application. - * Vérifie la permission {@code POST_NOTIFICATIONS} sur Android 13 (API 33) et supérieur - * avant de tenter d'afficher la notification. Si la permission est manquante, - * un message d'erreur est logué et la méthode se termine sans afficher de notification. + * Gère la vérification de la permission {@code POST_NOTIFICATIONS} pour Android 13+ (Tiramisu). + * Configure un {@link PendingIntent} pour ouvrir {@link MainActivity} lorsque l'utilisateur + * appuie sur la notification. + * Utilise {@link NotificationCompat} pour la compatibilité avec les anciennes versions d'Android. * - * @param context Le contexte ({@code Context}, peut être une Activity, un Service, etc.) - * utilisé pour construire la notification et accéder aux ressources. - * @param title Le titre à afficher dans la notification. - * @param message Le contenu textuel principal de la notification. - * @param notificationId Un identifiant entier unique pour cette notification. Permet de mettre à jour - * ou d'annuler cette notification spécifique plus tard en utilisant le même ID. + * @param context Le contexte de l'application. Ne doit pas être null. + * @param title Le titre de la notification. Ne doit pas être null. + * @param message Le message principal (corps) de la notification. Ne doit pas être null. + * @param notificationId Un identifiant entier unique pour cette notification. Si une notification + * avec le même ID existe déjà, elle sera mise à jour. */ public static void showNotification(@NonNull Context context, @NonNull String title, @NonNull String message, int notificationId) { - Log.d(TAG, "Tentative d'affichage de la notification ID: " + notificationId + " Titre: " + title); - - // --- Vérification de la permission pour Android 13 (API 33) et supérieur --- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // Utilise ActivityCompat.checkSelfPermission pour vérifier la permission + // Vérifie si la permission d'envoyer des notifications est accordée if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // Si la permission n'est pas accordée, loguer un avertissement et ne pas continuer. - // L'application appelante (ex: MainActivity, Service) est responsable de demander la permission - // si l'utilisateur a indiqué vouloir recevoir des notifications. - Log.w(TAG, "Permission POST_NOTIFICATIONS manquante pour API 33+. Notification ID " + notificationId + " non affichée."); - // Optionnel : Envoyer un broadcast ou utiliser une autre méthode pour signaler l'échec ? Non, simple log suffit ici. - return; - } else { - Log.d(TAG, "Permission POST_NOTIFICATIONS accordée sur API 33+."); + // Si la permission n'est pas accordée, on ne peut pas afficher la notification. + // L'application devrait demander la permission à l'utilisateur à un moment approprié. + return; // Arrête l'exécution de la méthode } - } else { - Log.d(TAG, "API < 33, permission POST_NOTIFICATIONS non requise pour afficher."); } - // --- Création de l'Intent pour le clic sur la notification --- - // Ouvre MainActivity lorsque l'utilisateur clique sur la notification. + // Crée un Intent pour ouvrir MainActivity lorsque la notification est cliquée Intent intent = new Intent(context, MainActivity.class); - // Flags pour gérer correctement la pile d'activités (ramène l'instance existante ou en crée une nouvelle) + // Flags pour gérer le comportement de la pile d'activités intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - // Flags pour le PendingIntent (requis : IMMUTABLE sur API 31+) + + // Crée un PendingIntent qui enveloppe l'Intent int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE; } - // Crée le PendingIntent qui enveloppe l'Intent PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, pendingIntentFlags); - // --- Construction de la notification via NotificationCompat --- + // Construit la notification en utilisant NotificationCompat.Builder pour la compatibilité NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_notification_2048) // Icône obligatoire (doit être monochrome/blanche avec transparence) - // .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher)) // Icône optionnelle plus grande - .setContentTitle(title) // Titre de la notification - .setContentText(message) // Texte principal - .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité (affecte l'affichage head-up, etc.) - .setContentIntent(pendingIntent) // Action à exécuter au clic - .setAutoCancel(true) // Ferme automatiquement la notification après le clic - .setDefaults(NotificationCompat.DEFAULT_SOUND | NotificationCompat.DEFAULT_VIBRATE) // Utilise son/vibration par défaut du système (si autorisé) + .setSmallIcon(R.drawable.ic_stat_notification_2048) // Icône obligatoire + .setContentTitle(title) // Titre de la notification + .setContentText(message) // Corps du message + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité (affecte l'affichage) + .setContentIntent(pendingIntent) // Action lorsque l'utilisateur clique sur la notification + .setAutoCancel(true) // Ferme la notification après le clic + .setDefaults(NotificationCompat.DEFAULT_SOUND | NotificationCompat.DEFAULT_VIBRATE) // Son et vibration par défaut .setVisibility(NotificationCompat.VISIBILITY_PUBLIC); // Visible sur l'écran de verrouillage - // --- Affichage de la notification --- - // Utilise NotificationManagerCompat pour la compatibilité + // Récupère NotificationManagerCompat pour envoyer la notification NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); try { - // Affiche la notification. Si une notification avec le même ID existe déjà, elle est mise à jour. notificationManager.notify(notificationId, builder.build()); - Log.i(TAG, "Notification ID " + notificationId + " affichée avec succès."); } catch (SecurityException e){ - // Gérer l'exception de sécurité qui peut (rarement) survenir même avec la vérification ci-dessus. - Log.e(TAG, "Erreur de sécurité lors de l'affichage de la notification ID " + notificationId, e); - // Afficher un Toast ou logguer est généralement suffisant ici. } catch (Exception ex) { - // Capturer d'autres exceptions potentielles - Log.e(TAG, "Erreur inattendue lors de l'affichage de la notification ID " + notificationId, ex); } } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/NotificationService.java b/app/src/main/java/legion/muyue/best2048/NotificationService.java deleted file mode 100644 index 26bdba2..0000000 --- a/app/src/main/java/legion/muyue/best2048/NotificationService.java +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Service exécuté en arrière-plan pour tenter d'envoyer des notifications périodiques, - * comme un rappel du meilleur score ou un rappel d'inactivité. - * - *

AVERTISSEMENT IMPORTANT : Ce service utilise un {@link android.os.Handler} - * pour planifier les tâches répétitives. Cette approche est simple mais NON FIABLE - * pour les tâches en arrière-plan sur les versions modernes d'Android. Le système peut tuer - * le service à tout moment pour économiser les ressources, et le redémarrage via - * {@code START_STICKY} n'est pas garanti d'être rapide ou même de se produire sur tous les appareils - * ou dans toutes les conditions (ex: mode Doze, restrictions fabricant).

- * - *

Pour une solution robuste et recommandée par Android pour les tâches périodiques - * en arrière-plan, il est FORTEMENT conseillé d'utiliser {@link androidx.work.WorkManager}. - * WorkManager gère les contraintes (réseau, charge), la persistance des tâches et - * garantit leur exécution même si l'application ou l'appareil redémarre.

- * - *

Ce code est fourni à titre d'exemple de la logique de notification, mais le mécanisme - * de planification devrait être remplacé par WorkManager en production.

- */ -package legion.muyue.best2048; - -import android.app.Service; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Handler; -import android.os.IBinder; -import android.os.Looper; // Important pour créer un Handler sur le Main Thread -import android.util.Log; - -import androidx.annotation.Nullable; -import java.util.concurrent.TimeUnit; - -public class NotificationService extends Service { - - /** Tag pour les logs spécifiques à ce service. */ - private static final String TAG = "NotificationService"; - - // --- Constantes de Notification et Intervalles --- - /** ID unique pour la notification de rappel du meilleur score. */ - private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs (ex: 1 pour achievement) - /** ID unique pour la notification de rappel d'inactivité. */ - private static final int NOTIFICATION_ID_INACTIVITY = 3; - - /** Intervalle minimum souhaité entre deux notifications de rappel du meilleur score (ex: 1 jour). */ - private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1); - /** Seuil d'inactivité : durée après laquelle une notification d'inactivité peut être envoyée (ex: 3 jours). */ - private static final long INACTIVITY_THRESHOLD_MS = TimeUnit.DAYS.toMillis(3); - /** Intervalle minimum souhaité entre deux notifications de rappel d'inactivité (ex: 1 jour, pour éviter spam si toujours inactif). */ - private static final long INACTIVITY_NOTIFICATION_COOLDOWN_MS = TimeUnit.DAYS.toMillis(1); - - /** Intervalle auquel le Handler vérifie s'il faut envoyer une notification (ex: toutes les 6 heures). */ - // NOTE: Avec WorkManager, cette vérification fréquente serait inutile, on planifierait directement la tâche au bon moment. - private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); - - /** Handler pour poster les tâches Runnable périodiques. Fonctionne sur le thread principal. */ - private Handler handler; - /** Runnable contenant la logique de vérification et d'envoi des notifications. */ - private Runnable periodicTaskRunnable; - - // --- Constantes SharedPreferences --- - // (Doivent correspondre à celles utilisées dans MainActivity et GameStats) - /** Nom du fichier de préférences partagées. */ - private static final String PREFS_NAME = "Best2048_Prefs"; - /** Clé pour lire le meilleur score global. */ - private static final String HIGH_SCORE_KEY = "high_score"; - /** Clé pour lire le timestamp de la dernière partie jouée. */ - private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; - /** Clé pour vérifier si les notifications sont activées par l'utilisateur. */ - private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"; - /** Clé pour stocker le timestamp du dernier envoi de la notification High Score. */ - private static final String LAST_HS_NOTIFICATION_TIME = "lastHsNotificationTime"; - /** Clé pour stocker le timestamp du dernier envoi de la notification d'Inactivité. */ - private static final String LAST_INACTIVITY_NOTIFICATION_TIME = "lastInactivityNotificationTime"; - - /** - * Appelée par le système lors de la première création du service. - * Initialise le Handler et le Runnable pour la tâche périodique. - * Ne crée PAS le canal de notification ici, car MainActivity le fait déjà. - */ - @Override - public void onCreate() { - super.onCreate(); - Log.i(TAG, "onCreate: Service de notification en cours de création."); - // Utilise le Looper principal pour le Handler. Simple, mais bloque le thread principal - // si checkAndSendNotifications() devient une tâche longue. - // Pour des tâches potentiellement longues, utiliser un HandlerThread serait mieux. - handler = new Handler(Looper.getMainLooper()); - - // Définit le Runnable qui sera exécuté périodiquement - periodicTaskRunnable = new Runnable() { - @Override - public void run() { - Log.d(TAG, "Exécution de la tâche périodique de vérification des notifications."); - // Vérifie s'il faut envoyer des notifications - checkAndSendNotifications(); - // Replanifie la prochaine exécution de cette même tâche - // ATTENTION: Ce mécanisme n'est pas fiable en arrière-plan. - handler.postDelayed(this, CHECK_INTERVAL_MS); - Log.d(TAG, "Prochaine vérification des notifications planifiée dans " + CHECK_INTERVAL_MS + " ms."); - } - }; - } - - /** - * Appelée lorsque le service est démarré, soit par `startService()` soit après un redémarrage du système - * (si START_STICKY est utilisé et que le système choisit de le redémarrer). - * Lance la tâche périodique de vérification des notifications. - * - * @param intent L'Intent fourni à startService(), peut être null si redémarré par le système. - * @param flags Indicateurs supplémentaires sur la demande de démarrage. - * @param startId Un identifiant unique représentant cette demande de démarrage spécifique. - * @return La valeur de retour indique comment le système doit gérer le service s'il est tué. - * {@code START_STICKY} : Le système essaiera de recréer le service après l'avoir tué, - * mais l'Intent ne sera pas redélivré (il sera null). - * NOTE: START_STICKY n'est pas une garantie d'exécution fiable pour les tâches - * périodiques en arrière-plan. Utilisez WorkManager. - */ - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.i(TAG, "onStartCommand: Service démarré (ou redémarré). StartId: " + startId); - - // S'assure qu'il n'y a pas d'anciennes tâches en double avant de poster la nouvelle - handler.removeCallbacks(periodicTaskRunnable); - // Lance la première exécution de la tâche immédiatement (ou presque) - handler.post(periodicTaskRunnable); - Log.d(TAG, "Première vérification des notifications postée."); - - // Utiliser START_STICKY pour que le système essaie de redémarrer le service s'il est tué. - // C'est un pis-aller par rapport à WorkManager. - return START_STICKY; - } - - /** - * Appelée lorsque le service est sur le point d'être détruit. - * Nettoie les ressources, en particulier, arrête la planification des tâches - * périodiques via le Handler pour éviter les fuites ou les exécutions non désirées. - */ - @Override - public void onDestroy() { - super.onDestroy(); - Log.i(TAG, "onDestroy: Service de notification en cours de destruction."); - // Arrête la planification des tâches lorsque le service est détruit - if (handler != null && periodicTaskRunnable != null) { - Log.d(TAG, "Annulation des tâches périodiques planifiées."); - handler.removeCallbacks(periodicTaskRunnable); - } - // Autres nettoyages si nécessaire - } - - /** - * Retourne l'interface de communication au client. - * Comme c'est un service démarré (Started Service) et non lié (Bound Service), - * cette méthode retourne {@code null}. - * - * @param intent L'Intent qui a été utilisé pour se lier au service (non pertinent ici). - * @return null car ce service ne permet pas le binding. - */ - @Nullable - @Override - public IBinder onBind(Intent intent) { - // Ce service n'est pas conçu pour être lié. - return null; - } - - /** - * Vérifie les conditions pour envoyer les notifications périodiques (High Score, Inactivité). - * Lit les préférences utilisateur (notifications activées?) et les timestamps nécessaires. - * Appelle les méthodes d'affichage de notification appropriées si les conditions et - * les délais depuis le dernier envoi sont respectés. - * Met à jour le timestamp du dernier envoi après avoir tenté d'envoyer une notification. - */ - private void checkAndSendNotifications() { - Log.d(TAG, "checkAndSendNotifications: Début de la vérification."); - SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - - // 1. Vérifier si les notifications sont globalement activées par l'utilisateur - boolean notificationsEnabled = prefs.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); // Lire la préférence - if (!notificationsEnabled) { - Log.i(TAG, "checkAndSendNotifications: Notifications désactivées dans les préférences, arrêt de la vérification."); - // Si les notifications sont désactivées, on pourrait arrêter le service pour économiser des ressources. - // stopSelf(); // Arrêterait le service lui-même. - return; - } - - long currentTime = System.currentTimeMillis(); - - // 2. Vérification pour la notification High Score - long lastHsNotificationTime = prefs.getLong(LAST_HS_NOTIFICATION_TIME, 0); - if (currentTime - lastHsNotificationTime > HIGHSCORE_INTERVAL_MS) { - Log.d(TAG, "checkAndSendNotifications: Intervalle pour notification High Score écoulé."); - int highScore = prefs.getInt(HIGH_SCORE_KEY, 0); - if (highScore > 0) { // N'envoie pas si le high score est 0 - Log.i(TAG, "checkAndSendNotifications: Envoi de la notification High Score."); - showHighScoreNotificationNow(highScore); - // Met à jour le timestamp du dernier envoi APRÈS avoir tenté d'envoyer - prefs.edit().putLong(LAST_HS_NOTIFICATION_TIME, currentTime).apply(); - } else { - Log.d(TAG, "checkAndSendNotifications: High Score est 0, notification non envoyée."); - } - } else { - Log.d(TAG, "checkAndSendNotifications: Intervalle pour notification High Score non écoulé."); - } - - // 3. Vérification pour la notification d'Inactivité - long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0); - long lastInactivityNotificationTime = prefs.getLong(LAST_INACTIVITY_NOTIFICATION_TIME, 0); - - // Condition 1: Temps depuis la dernière partie > Seuil d'inactivité - boolean isInactivityThresholdMet = (lastPlayedTime > 0 && currentTime - lastPlayedTime > INACTIVITY_THRESHOLD_MS); - // Condition 2: Temps depuis la dernière notification d'inactivité > Cooldown - boolean isCooldownMet = (currentTime - lastInactivityNotificationTime > INACTIVITY_NOTIFICATION_COOLDOWN_MS); - - if (isInactivityThresholdMet) { - Log.d(TAG, "checkAndSendNotifications: Seuil d'inactivité atteint."); - if (isCooldownMet) { - Log.i(TAG, "checkAndSendNotifications: Cooldown pour notification d'inactivité respecté. Envoi..."); - showInactivityNotificationNow(); - // Met à jour le timestamp du dernier envoi APRÈS avoir tenté d'envoyer - prefs.edit().putLong(LAST_INACTIVITY_NOTIFICATION_TIME, currentTime).apply(); - } else { - Log.d(TAG, "checkAndSendNotifications: Cooldown pour notification d'inactivité non écoulé."); - } - } else { - Log.d(TAG, "checkAndSendNotifications: Seuil d'inactivité non atteint."); - } - Log.d(TAG, "checkAndSendNotifications: Fin de la vérification."); - } - - /** - * Prépare et affiche immédiatement la notification de rappel du meilleur score. - * Utilise {@link NotificationHelper} pour l'affichage effectif. - * - * @param highScore Le meilleur score à afficher dans la notification. - */ - private void showHighScoreNotificationNow(int highScore) { - String title = getString(R.string.notification_title_highscore); - String message = getString(R.string.notification_text_highscore, highScore); - NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_HIGHSCORE); - } - - /** - * Prépare et affiche immédiatement la notification de rappel d'inactivité. - * Utilise {@link NotificationHelper} pour l'affichage effectif. - */ - private void showInactivityNotificationNow() { - String title = getString(R.string.notification_title_inactivity); - String message = getString(R.string.notification_text_inactivity); - NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_INACTIVITY); - } - -} \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/NotificationWorker.java b/app/src/main/java/legion/muyue/best2048/NotificationWorker.java new file mode 100644 index 0000000..c5d4ceb --- /dev/null +++ b/app/src/main/java/legion/muyue/best2048/NotificationWorker.java @@ -0,0 +1,148 @@ +package legion.muyue.best2048; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.util.concurrent.TimeUnit; + +/** + * Un {@link Worker} Android conçu pour s'exécuter périodiquement en arrière-plan + * via {@link androidx.work.WorkManager}. + * Sa tâche est de vérifier si certaines conditions sont remplies pour envoyer + * des notifications à l'utilisateur, telles qu'un rappel de son meilleur score + * ou une notification d'inactivité pour l'encourager à rejouer. + * Les conditions, seuils et états (comme la dernière fois jouée ou notifiée) + * sont gérés via {@link SharedPreferences}. + */ +public class NotificationWorker extends Worker { + + // --- Constantes --- + + /** Identifiant unique pour la notification de rappel du meilleur score. */ + private static final int NOTIFICATION_ID_HIGHSCORE = 2; + /** Identifiant unique pour la notification d'inactivité. */ + private static final int NOTIFICATION_ID_INACTIVITY = 3; + + /** Intervalle minimum (en millisecondes) entre deux notifications de rappel du meilleur score. (1 jour) */ + private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1); + /** Seuil d'inactivité (en millisecondes) : durée depuis la dernière partie jouée au-delà de laquelle une notification peut être envoyée. (3 jours) */ + private static final long INACTIVITY_THRESHOLD_MS = TimeUnit.DAYS.toMillis(3); + /** Temps de recharge minimum (en millisecondes) entre deux notifications d'inactivité. (1 jour) */ + private static final long INACTIVITY_NOTIFICATION_COOLDOWN_MS = TimeUnit.DAYS.toMillis(1); + + /** Nom du fichier SharedPreferences utilisé par cette classe et potentiellement d'autres (ex: GameStats). */ + private static final String PREFS_NAME = "Best2048_Prefs"; + /** Clé SharedPreferences pour le meilleur score (utilisé aussi par GameStats). */ + private static final String HIGH_SCORE_KEY = "high_score"; + /** Clé SharedPreferences pour stocker le timestamp de la dernière fois où l'utilisateur a joué. */ + private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; + /** Clé SharedPreferences pour l'activation globale des notifications par l'utilisateur. */ + private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"; + /** Clé SharedPreferences pour stocker le timestamp de la dernière notification de meilleur score envoyée. */ + private static final String LAST_HS_NOTIFICATION_TIME = "lastHsNotificationTime"; + /** Clé SharedPreferences pour stocker le timestamp de la dernière notification d'inactivité envoyée. */ + private static final String LAST_INACTIVITY_NOTIFICATION_TIME = "lastInactivityNotificationTime"; + + /** + * Constructeur standard pour un {@link Worker}. + * + * @param context Le contexte de l'application. + * @param workerParams Paramètres pour le Worker, fournis par WorkManager. + */ + public NotificationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + /** + * Exécute la tâche principale du Worker en arrière-plan. + * Vérifie si les notifications sont activées, puis évalue les conditions pour + * envoyer une notification de meilleur score ou une notification d'inactivité. + * Met à jour les timestamps des dernières notifications envoyées si nécessaire. + * Cette méthode est appelée par WorkManager sur un thread d'arrière-plan. + * + * @return {@link Result#success()} si le travail s'est terminé (qu'une notification ait été envoyée ou non). + * Retourne {@link Result#failure()} ou {@link Result#retry()} en cas d'erreur non récupérable ou si le travail doit être retenté. + * Ici, on retourne toujours success() même en cas d'exception interne pour ne pas bloquer les exécutions futures, + * mais un logging serait approprié. + */ + @NonNull + @Override + public Result doWork() { + Context context = getApplicationContext(); + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + + // 1. Vérifier si les notifications sont activées globalement + boolean notificationsEnabled = prefs.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); + if (!notificationsEnabled) { + return Result.success(); // Travail terminé avec succès (rien à faire) + } + + long currentTime = System.currentTimeMillis(); + + // 2. Vérification pour la notification du meilleur score + try { + long lastHsNotificationTime = prefs.getLong(LAST_HS_NOTIFICATION_TIME, 0); + // Vérifier si l'intervalle depuis la dernière notification de high score est dépassé + if (currentTime - lastHsNotificationTime > HIGHSCORE_INTERVAL_MS) { + int highScore = prefs.getInt(HIGH_SCORE_KEY, 0); + // Vérifier s'il y a un high score à afficher (> 0) + if (highScore > 0) { + showHighScoreNotificationNow(context, highScore); + // Mettre à jour le timestamp de la dernière notification de high score + prefs.edit().putLong(LAST_HS_NOTIFICATION_TIME, currentTime).apply(); + } + } + } catch (Exception e) { + } + + // 3. Vérification pour la notification d'inactivité + try { + long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0); + long lastInactivityNotificationTime = prefs.getLong(LAST_INACTIVITY_NOTIFICATION_TIME, 0); + + // Condition 1: L'utilisateur n'a pas joué depuis un certain temps (INACTIVITY_THRESHOLD_MS) + boolean isInactivityThresholdMet = (lastPlayedTime > 0 && currentTime - lastPlayedTime > INACTIVITY_THRESHOLD_MS); + // Condition 2: Assez de temps s'est écoulé depuis la dernière notification d'inactivité (cooldown) + boolean isCooldownMet = (currentTime - lastInactivityNotificationTime > INACTIVITY_NOTIFICATION_COOLDOWN_MS); + + if (isInactivityThresholdMet && isCooldownMet) { + showInactivityNotificationNow(context); + // Mettre à jour le timestamp de la dernière notification d'inactivité + prefs.edit().putLong(LAST_INACTIVITY_NOTIFICATION_TIME, currentTime).apply(); + } + } catch (Exception e) { + } + return Result.success(); + } + + /** + * Construit et affiche immédiatement la notification de rappel du meilleur score. + * Utilise une classe helper {@code NotificationHelper} (non fournie ici) pour la logique d'affichage réelle. + * Récupère les textes depuis les ressources de chaînes Android (R.string). + * + * @param context Le contexte applicatif. + * @param highScore Le meilleur score actuel de l'utilisateur à afficher. + */ + private void showHighScoreNotificationNow(Context context, int highScore) { + String title = context.getString(R.string.notification_title_highscore); + String message = context.getString(R.string.notification_text_highscore, highScore); + NotificationHelper.showNotification(context, title, message, NOTIFICATION_ID_HIGHSCORE); + } + + /** + * Construit et affiche immédiatement la notification d'inactivité. + * Utilise une classe helper {@code NotificationHelper} (non fournie ici) pour la logique d'affichage réelle. + * Récupère les textes depuis les ressources de chaînes Android (R.string). + * + * @param context Le contexte applicatif. + */ + private void showInactivityNotificationNow(Context context) { + String title = context.getString(R.string.notification_title_inactivity); + String message = context.getString(R.string.notification_text_inactivity); + NotificationHelper.showNotification(context, title, message, NOTIFICATION_ID_INACTIVITY); + } +} \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/OnSwipeTouchListener.java b/app/src/main/java/legion/muyue/best2048/OnSwipeTouchListener.java index a530780..e57e7a4 100644 --- a/app/src/main/java/legion/muyue/best2048/OnSwipeTouchListener.java +++ b/app/src/main/java/legion/muyue/best2048/OnSwipeTouchListener.java @@ -1,9 +1,3 @@ -// Fichier OnSwipeTouchListener.java -/** - * Listener de vue personnalisé qui détecte les gestes de balayage (swipe) - * dans les quatre directions cardinales et notifie un listener externe. - * Utilise {@link GestureDetector} pour l'analyse des gestes. - */ package legion.muyue.best2048; import android.annotation.SuppressLint; @@ -12,66 +6,85 @@ import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +/** + * Un {@link View.OnTouchListener} qui détecte les gestes de balayage (swipe) + * dans quatre directions (haut, bas, gauche, droite) sur une {@link View} associée. + * Utilise un {@link GestureDetector} pour interpréter les événements tactiles. + * Notifie un {@link SwipeListener} lorsqu'un balayage valide est détecté. + * + * Pour l'utiliser, créez une instance de cette classe en fournissant un {@link Context} + * et une implémentation de {@link SwipeListener}, puis attachez-la à la View souhaitée + * via {@link View#setOnTouchListener(View.OnTouchListener)}. + */ public class OnSwipeTouchListener implements View.OnTouchListener { - /** Détecteur de gestes standard d'Android. */ + /** Détecteur de gestes Android utilisé pour interpréter les événements tactiles. */ private final GestureDetector gestureDetector; - /** Listener externe à notifier lors de la détection d'un swipe. */ + /** L'écouteur qui sera notifié des événements de balayage détectés. */ private final SwipeListener listener; /** - * Interface à implémenter par les classes souhaitant réagir aux événements de swipe. + * Interface de callback à implémenter par les classes qui souhaitent être notifiées + * des événements de balayage détectés par {@link OnSwipeTouchListener}. */ public interface SwipeListener { - /** Appelée lorsqu'un swipe vers le haut est détecté. */ + /** Appelé lorsqu'un balayage vers le haut est détecté. */ void onSwipeTop(); - /** Appelée lorsqu'un swipe vers le bas est détecté. */ + /** Appelé lorsqu'un balayage vers le bas est détecté. */ void onSwipeBottom(); - /** Appelée lorsqu'un swipe vers la gauche est détecté. */ + /** Appelé lorsqu'un balayage vers la gauche est détecté. */ void onSwipeLeft(); - /** Appelée lorsqu'un swipe vers la droite est détecté. */ + /** Appelé lorsqu'un balayage vers la droite est détecté. */ void onSwipeRight(); } /** - * Constructeur. - * @param context Contexte applicatif, nécessaire pour `GestureDetector`. - * @param listener Instance qui recevra les notifications de swipe. Ne doit pas être null. + * Construit une nouvelle instance de l'écouteur de balayage. + * + * @param context Le contexte de l'application ou de l'activité, nécessaire pour initialiser le {@link GestureDetector}. + * @param listener L'instance de {@link SwipeListener} qui recevra les notifications de balayage. Ne doit pas être null. */ public OnSwipeTouchListener(Context context, @NonNull SwipeListener listener) { this.gestureDetector = new GestureDetector(context, new GestureListener()); + // Stocke la référence vers l'écouteur fourni this.listener = listener; } /** - * Intercepte les événements tactiles sur la vue associée et les délègue - * au {@link GestureDetector} pour analyse. - * @param v La vue touchée. - * @param event L'événement tactile. - * @return true si le geste a été consommé par le détecteur, false sinon. + * Méthode appelée lorsque la {@link View} associée reçoit un événement tactile. + * Délègue le traitement de l'événement au {@link GestureDetector}. + * + * @param v La {@link View} qui a reçu l'événement tactile. + * @param event L'objet {@link MotionEvent} décrivant l'événement tactile. + * @return {@code true} si l'événement a été consommé par le {@link GestureDetector}, {@code false} sinon. */ @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouch(View v, MotionEvent event) { - // Passe l'événement au GestureDetector. Si ce dernier le gère (ex: détecte un onFling), - // il retournera true, et l'événement ne sera pas propagé davantage. + // Passe l'événement tactile au GestureDetector pour analyse return gestureDetector.onTouchEvent(event); } /** - * Classe interne implémentant l'écouteur de gestes pour détecter le 'fling' (balayage rapide). + * Classe interne qui étend {@link GestureDetector.SimpleOnGestureListener} pour + * implémenter la logique de détection de balayage (spécifiquement dans {@code onFling}). */ private final class GestureListener extends GestureDetector.SimpleOnGestureListener { - /** Distance minimale (en pixels) pour qu'un mouvement soit considéré comme un swipe. */ + /** Distance minimale (en pixels) qu'un doigt doit parcourir pour qu'un mouvement soit considéré comme un balayage. */ private static final int SWIPE_THRESHOLD = 100; - /** Vitesse minimale (en pixels/sec) pour qu'un mouvement soit considéré comme un swipe. */ + /** Vitesse minimale (en pixels par seconde) requise pour qu'un mouvement soit considéré comme un balayage (fling). */ private static final int SWIPE_VELOCITY_THRESHOLD = 100; /** - * Toujours retourner true pour onDown garantit que les événements suivants - * (comme onFling) seront bien reçus par ce listener. + * Appelée lorsque l'événement {@link MotionEvent#ACTION_DOWN} se produit. + * Doit retourner {@code true} pour indiquer que ce listener est intéressé + * par la séquence complète des événements tactiles (move, up, fling). + * + * @param e L'événement MotionEvent initial (ACTION_DOWN). + * @return Toujours {@code true}. */ @Override public boolean onDown(@NonNull MotionEvent e) { @@ -79,47 +92,61 @@ public class OnSwipeTouchListener implements View.OnTouchListener { } /** - * Appelée quand un geste de 'fling' (balayage rapide) est détecté. - * Analyse la direction et la vitesse pour déterminer s'il s'agit d'un swipe valide - * et notifie le {@link SwipeListener} externe. + * Appelée lorsque le {@link GestureDetector} détecte un mouvement de "fling" + * (un glissement rapide suivi d'un relâchement). C'est ici que la logique + * de détection de balayage est implémentée. + * + * @param e1 L'événement {@link MotionEvent} initial (ACTION_DOWN) où le fling a commencé. Peut être null dans certains cas rares. + * @param e2 L'événement {@link MotionEvent} final (ACTION_UP) où le fling s'est terminé. + * @param velocityX La vélocité du fling sur l'axe X (pixels par seconde). + * @param velocityY La vélocité du fling sur l'axe Y (pixels par seconde). + * @return {@code true} si un balayage valide a été détecté et géré (c'est-à-dire qu'une méthode du listener a été appelée), {@code false} sinon. */ @Override - public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { - if (e1 == null) return false; // Point de départ est nécessaire + public boolean onFling(@Nullable MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { + // Vérifie si l'événement initial est null (précaution) + if (e1 == null) { + return false; + } - boolean result = false; + boolean result = false; // Indique si un swipe a été détecté et traité try { + // Calcule la différence de position entre le début et la fin du mouvement float diffY = e2.getY() - e1.getY(); float diffX = e2.getX() - e1.getX(); - // Priorité au mouvement le plus ample (horizontal ou vertical) + // Détermine si le mouvement est principalement horizontal ou vertical if (Math.abs(diffX) > Math.abs(diffY)) { // Mouvement principalement horizontal + // Vérifie si la distance et la vitesse dépassent les seuils if (Math.abs(diffX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { if (diffX > 0) { + // Balayage vers la droite listener.onSwipeRight(); } else { + // Balayage vers la gauche listener.onSwipeLeft(); } - result = true; // Geste horizontal traité + result = true; // Un balayage horizontal a été traité } } else { // Mouvement principalement vertical + // Vérifie si la distance et la vitesse dépassent les seuils if (Math.abs(diffY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { if (diffY > 0) { + // Balayage vers le bas listener.onSwipeBottom(); } else { + // Balayage vers le haut listener.onSwipeTop(); } - result = true; // Geste vertical traité + result = true; // Un balayage vertical a été traité } } } catch (Exception exception) { - // En cas d'erreur inattendue, on logue discrètement. - System.err.println("Erreur dans OnSwipeTouchListener.onFling: " + exception.getMessage()); - // Ne pas crasher l'application pour une erreur de détection de geste. } + // Retourne true si un swipe a été détecté et traité, false sinon return result; } } -} // Fin OnSwipeTouchListener \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/data/GameInfo.java b/app/src/main/java/legion/muyue/best2048/data/GameInfo.java index 4d8b4a0..8459ddc 100644 --- a/app/src/main/java/legion/muyue/best2048/data/GameInfo.java +++ b/app/src/main/java/legion/muyue/best2048/data/GameInfo.java @@ -1,20 +1,86 @@ -package legion.muyue.best2048.data; // Créez un sous-package data si vous voulez +package legion.muyue.best2048.data; import com.google.gson.annotations.SerializedName; +/** + * Représente les informations de base d'une partie du jeu 2048. + * Cette classe est utilisée pour stocker et transférer les données relatives + * à une session de jeu spécifique, notamment via la sérialisation/désérialisation JSON avec Gson. + */ public class GameInfo { - @SerializedName("gameId") // Correspond au nom du champ JSON + + /** + * L'identifiant unique de la partie. + * Utilisé par Gson pour la sérialisation avec le nom "gameId". + */ + @SerializedName("gameId") private String gameId; - @SerializedName("status") // Ex: WAITING, PLAYING, FINISHED + + /** + * Le statut actuel de la partie (par exemple, "en attente", "en cours", "terminée"). + * Utilisé par Gson pour la sérialisation avec le nom "status". + */ + @SerializedName("status") private String status; + + /** + * L'identifiant du premier joueur. + * Peut être null si le joueur n'a pas encore rejoint. + * Utilisé par Gson pour la sérialisation avec le nom "player1Id". + */ @SerializedName("player1Id") private String player1Id; - @SerializedName("player2Id") - private String player2Id; // Peut être null si en attente - // --- Getters (et Setters si nécessaire) --- - public String getGameId() { return gameId; } - public String getStatus() { return status; } - public String getPlayer1Id() { return player1Id; } - public String getPlayer2Id() { return player2Id; } + /** + * L'identifiant du deuxième joueur. + * Peut être null si le joueur n'a pas encore rejoint ou s'il s'agit d'une partie solo. + * Utilisé par Gson pour la sérialisation avec le nom "player2Id". + */ + @SerializedName("player2Id") + private String player2Id; + + /** + * Constructeur par défaut. + * Nécessaire pour certaines bibliothèques de sérialisation/désérialisation comme Gson. + */ + public GameInfo() { + // Constructeur par défaut explicite pour la clarté et la compatibilité + } + + + /** + * Récupère l'identifiant unique de la partie. + * + * @return L'identifiant de la partie. + */ + public String getGameId() { + return gameId; + } + + /** + * Récupère le statut actuel de la partie. + * + * @return Le statut de la partie (ex: "waiting", "playing", "finished"). + */ + public String getStatus() { + return status; + } + + /** + * Récupère l'identifiant du premier joueur. + * + * @return L'identifiant du joueur 1, ou null s'il n'est pas défini. + */ + public String getPlayer1Id() { + return player1Id; + } + + /** + * Récupère l'identifiant du deuxième joueur. + * + * @return L'identifiant du joueur 2, ou null s'il n'est pas défini. + */ + public String getPlayer2Id() { + return player2Id; + } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java b/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java index 7b3a527..7de65f6 100644 --- a/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java +++ b/app/src/main/java/legion/muyue/best2048/data/GameStateResponse.java @@ -1,64 +1,218 @@ package legion.muyue.best2048.data; import com.google.gson.annotations.SerializedName; +import java.util.Arrays; +/** + * Représente l'état complet d'une partie du jeu 2048 à un instant T. + * Cette classe est typiquement utilisée pour encapsuler les données reçues + * en réponse à une requête d'état de jeu, souvent via JSON (Gson). + * Elle contient les informations sur le plateau, les scores, le joueur courant, etc. + */ public class GameStateResponse { + + /** + * L'identifiant unique de la partie. + * Utilisé par Gson pour la sérialisation avec le nom "gameId". + */ @SerializedName("gameId") private String gameId; + + /** + * La grille de jeu actuelle, représentée par un tableau 2D d'entiers. + * Chaque entier représente la valeur d'une tuile (0 pour une case vide). + * Utilisé par Gson pour la sérialisation avec le nom "board". + */ @SerializedName("board") - private int[][] board; // Plateau de jeu actuel + private int[][] board; + + /** + * Le score actuel du joueur 1. + * Utilisé par Gson pour la sérialisation avec le nom "player1Score". + */ @SerializedName("player1Score") private int player1Score; + + /** + * Le score actuel du joueur 2. + * Utilisé par Gson pour la sérialisation avec le nom "player2Score". + */ @SerializedName("player2Score") private int player2Score; - @SerializedName("currentPlayerId") // ID du joueur dont c'est le tour + + /** + * L'identifiant du joueur dont c'est actuellement le tour de jouer. + * Utilisé par Gson pour la sérialisation avec le nom "currentPlayerId". + */ + @SerializedName("currentPlayerId") private String currentPlayerId; + + /** + * Indicateur booléen signalant si la partie est terminée. + * Utilisé par Gson pour la sérialisation avec le nom "isGameOver". + */ @SerializedName("isGameOver") private boolean isGameOver; - @SerializedName("winnerId") // ID du gagnant si terminé, null sinon + + /** + * L'identifiant du joueur gagnant, si la partie est terminée et qu'il y a un gagnant. + * Peut être null si la partie n'est pas terminée ou s'il y a égalité. + * Utilisé par Gson pour la sérialisation avec le nom "winnerId". + */ + @SerializedName("winnerId") private String winnerId; + + /** + * Le statut global de la partie (par exemple, "en cours", "terminée"). + * Utilisé par Gson pour la sérialisation avec le nom "status". + */ @SerializedName("status") private String status; - @SerializedName("player1Id") // Ajoute ce champ s'il manque + + /** + * L'identifiant du joueur 1. + * Utilisé par Gson pour la sérialisation avec le nom "player1Id". + */ + @SerializedName("player1Id") private String player1Id; - @SerializedName("player2Id") // Ajoute ce champ s'il manque + + /** + * L'identifiant du joueur 2. + * Utilisé par Gson pour la sérialisation avec le nom "player2Id". + */ + @SerializedName("player2Id") private String player2Id; - @SerializedName("targetScore") // Ajoute ce champ + + /** + * Le score cible à atteindre pour gagner la partie (par exemple, 2048). + * Utilisé par Gson pour la sérialisation avec le nom "targetScore". + */ + @SerializedName("targetScore") private int targetScore; - // --- Getters --- + + /** + * Constructeur par défaut. + * Nécessaire pour certaines bibliothèques de sérialisation/désérialisation comme Gson. + */ + public GameStateResponse() { + // Constructeur par défaut explicite + } + + /** + * Récupère l'identifiant unique de la partie. + * + * @return L'identifiant de la partie. + */ public String getGameId() { return gameId; } + + /** + * Récupère la grille de jeu actuelle. + * Le tableau retourné est une référence directe à l'état interne ; + * pour éviter des modifications accidentelles, envisagez de retourner une copie. + * Exemple de copie : {@code return Arrays.stream(board).map(int[]::clone).toArray(int[][]::new);} + * + * @return Un tableau 2D d'entiers représentant le plateau de jeu. + */ public int[][] getBoard() { return board; } + + /** + * Récupère le score du joueur 1. + * + * @return Le score du joueur 1. + */ public int getPlayer1Score() { return player1Score; } + + /** + * Récupère le score du joueur 2. + * + * @return Le score du joueur 2. + */ public int getPlayer2Score() { return player2Score; } + + /** + * Récupère l'identifiant du joueur dont c'est le tour. + * + * @return L'identifiant du joueur courant. + */ public String getCurrentPlayerId() { return currentPlayerId; } + + /** + * Vérifie si la partie est terminée. + * + * @return true si la partie est terminée, false sinon. + */ public boolean isGameOver() { return isGameOver; } + + /** + * Récupère l'identifiant du joueur gagnant. + * + * @return L'identifiant du gagnant, ou null si la partie n'est pas terminée ou s'il n'y a pas de gagnant unique. + */ public String getWinnerId() { return winnerId; } + + /** + * Récupère le statut actuel de la partie. + * + * @return Le statut de la partie (ex: "playing", "finished"). + */ public String getStatus() { return status; } + + /** + * Récupère l'identifiant du joueur 1. + * + * @return L'identifiant du joueur 1. + */ public String getPlayer1Id() { return player1Id; } + + /** + * Récupère l'identifiant du joueur 2. + * + * @return L'identifiant du joueur 2. + */ public String getPlayer2Id() { return player2Id; } + + /** + * Récupère le score cible à atteindre pour gagner. + * + * @return Le score cible (par exemple, 2048). + */ public int getTargetScore() { return targetScore; } - // --- Méthode utilitaire pour obtenir le score de l'adversaire --- + + /** + * Calcule et retourne le score de l'adversaire par rapport à l'identifiant du joueur fourni. + * + * @param myActualPlayerId L'identifiant du joueur pour lequel on veut connaître le score de l'adversaire. + * @return Le score de l'adversaire si {@code myActualPlayerId} correspond à l'un des joueurs, sinon 0. Retourne 0 si {@code myActualPlayerId} est null. + */ public int getOpponentScore(String myActualPlayerId) { - if (myActualPlayerId == null) return 0; + if (myActualPlayerId == null) { + return 0; + } if (myActualPlayerId.equals(player1Id)) { - // Si je suis P1, le score de l'adversaire est P2 return player2Score; } else if (myActualPlayerId.equals(player2Id)) { - // Si je suis P2, le score de l'adversaire est P1 return player1Score; } - return 0; // Mon ID ne correspond à aucun joueur ? + return 0; } + /** + * Calcule et retourne le score du joueur correspondant à l'identifiant fourni. + * + * @param myActualPlayerId L'identifiant du joueur dont on veut connaître le score. + * @return Le score du joueur si {@code myActualPlayerId} correspond à l'un des joueurs, sinon 0. Retourne 0 si {@code myActualPlayerId} est null. + */ public int getMyScore(String myActualPlayerId) { - if (myActualPlayerId == null) return 0; + if (myActualPlayerId == null) { + return 0; + } if (myActualPlayerId.equals(player1Id)) { return player1Score; } else if (myActualPlayerId.equals(player2Id)) { return player2Score; } - return 0; // Mon ID ne correspond à aucun joueur ? + return 0; } } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/data/MoveRequest.java b/app/src/main/java/legion/muyue/best2048/data/MoveRequest.java index 8cc5e3c..b277ff1 100644 --- a/app/src/main/java/legion/muyue/best2048/data/MoveRequest.java +++ b/app/src/main/java/legion/muyue/best2048/data/MoveRequest.java @@ -1,12 +1,39 @@ package legion.muyue.best2048.data; +/** + * Représente une requête pour effectuer un mouvement dans le jeu 2048. + * Cette classe est typiquement utilisée comme un objet de transfert de données (DTO) + * pour envoyer l'action d'un joueur (la direction du mouvement) au serveur ou + * au moteur de jeu. + * Elle contient l'identifiant du joueur effectuant le mouvement et la direction choisie. + */ public class MoveRequest { - private String direction; // "UP", "DOWN", "LEFT", "RIGHT" - private String playerId; // ID du joueur qui fait le mouvement + /** + * La direction du mouvement demandé. + * Par exemple : "UP", "DOWN", "LEFT", "RIGHT". + * La casse et les valeurs exactes dépendent de la convention utilisée par le système. + * Ce champ est destiné à être sérialisé (par exemple en JSON) pour la communication. + */ + private String direction; + + /** + * L'identifiant unique du joueur qui effectue le mouvement. + * Permet au système de savoir quel joueur est à l'origine de la requête. + * Ce champ est destiné à être sérialisé (par exemple en JSON) pour la communication. + */ + private String playerId; + + /** + * Construit une nouvelle requête de mouvement. + * + * @param direction La direction souhaitée pour le mouvement (ex: "UP", "DOWN", "LEFT", "RIGHT"). + * Ne doit généralement pas être null ou vide. + * @param playerId L'identifiant du joueur effectuant la requête. + * Ne doit généralement pas être null ou vide. + */ public MoveRequest(String direction, String playerId) { this.direction = direction; this.playerId = playerId; } - // Pas besoin de getters si seulement utilisé pour l'envoi avec Gson } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java b/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java index d27d2b4..20087e2 100644 --- a/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java +++ b/app/src/main/java/legion/muyue/best2048/data/PlayerIdRequest.java @@ -1,10 +1,27 @@ package legion.muyue.best2048.data; +/** + * Représente une requête contenant uniquement l'identifiant d'un joueur. + * Cette classe est typiquement utilisée comme un objet de transfert de données (DTO) + * simple pour les opérations où seul l'identifiant du joueur est nécessaire, + * par exemple, pour rejoindre une partie, demander des informations spécifiques + * au joueur, ou s'identifier auprès d'un service. + */ public class PlayerIdRequest { + + /** + * L'identifiant unique du joueur concerné par la requête. + * Ce champ est destiné à être sérialisé (par exemple en JSON) pour la communication. + */ private String playerId; + /** + * Construit une nouvelle requête contenant un identifiant de joueur. + * + * @param playerId L'identifiant unique du joueur. + * Ne doit généralement pas être null ou vide. + */ public PlayerIdRequest(String playerId) { this.playerId = playerId; } - // Pas besoin de getters si seulement utilisé pour l'envoi avec Gson } \ No newline at end of file diff --git a/app/src/main/java/legion/muyue/best2048/network/ApiClient.java b/app/src/main/java/legion/muyue/best2048/network/ApiClient.java index 1baedc6..8a36b63 100644 --- a/app/src/main/java/legion/muyue/best2048/network/ApiClient.java +++ b/app/src/main/java/legion/muyue/best2048/network/ApiClient.java @@ -5,43 +5,78 @@ import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; +/** + * Fournit un client Retrofit configuré pour interagir avec l'API du jeu Best2048. + * Cette classe utilise un modèle Singleton (paresseux et non strictement thread-safe dans sa forme actuelle) + * pour l'instance Retrofit, garantissant qu'une seule instance est créée et réutilisée + * pour toutes les requêtes réseau. + * La configuration inclut l'URL de base de l'API, un intercepteur pour logger les requêtes/réponses + * (niveau BODY), et un convertisseur Gson pour la sérialisation/désérialisation JSON. + */ public class ApiClient { - // URL de base de votre API serveur + /** + * L'URL de base pour l'API du jeu Best2048. + * Toutes les requêtes définies dans {@link ApiService} seront relatives à cette URL. + */ private static final String BASE_URL = "https://best2048.legion-muyue.fr/api/"; + /** + * L'instance Singleton de Retrofit. + * Initialisée paresseusement lors du premier appel à {@link #getClient()}. + * Note: L'initialisation paresseuse ici n'est pas garantie comme étant thread-safe + * dans des scénarios de haute concurrence sans synchronisation externe. + */ private static Retrofit retrofit = null; /** - * Crée et retourne une instance singleton de Retrofit configurée. - * Inclut un intercepteur pour logger les requêtes/réponses HTTP (utile pour le debug). + * Constructeur privé pour empêcher l'instanciation directe de cette classe utilitaire. + */ + private ApiClient() { + // Classe utilitaire, ne doit pas être instanciée. + } + + /** + * Récupère l'instance Singleton de Retrofit. + * Si l'instance n'existe pas encore, elle est créée et configurée avec : + *
    + *
  • L'URL de base ({@link #BASE_URL}).
  • + *
  • Un {@link OkHttpClient} personnalisé incluant un {@link HttpLoggingInterceptor} + * (niveau BODY) pour le débogage réseau.
  • + *
  • Un {@link GsonConverterFactory} pour gérer la conversion JSON.
  • + *
* - * @return L'instance configurée de Retrofit. + * @return L'instance Retrofit configurée et prête à l'emploi. + * @see #getApiService() pour obtenir directement une instance de service API. */ public static Retrofit getClient() { if (retrofit == null) { - // Intercepteur pour voir les logs HTTP dans Logcat (Niveau BODY pour tout voir) HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); - // Client OkHttp avec l'intercepteur + // Construit le client OkHttp en ajoutant l'intercepteur OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(logging) .build(); - // Construction de l'instance Retrofit + // Construit l'instance Retrofit retrofit = new Retrofit.Builder() .baseUrl(BASE_URL) - .client(client) // Utilise le client OkHttp configuré - .addConverterFactory(GsonConverterFactory.create()) // Utilise Gson pour parser le JSON + .client(client) + .addConverterFactory(GsonConverterFactory.create()) .build(); } return retrofit; } /** - * Fournit une instance de l'interface ApiService. - * @return Instance de ApiService. + * Crée et retourne une instance de l'interface {@link ApiService}. + * Utilise l'instance Retrofit obtenue via {@link #getClient()} pour générer + * l'implémentation du service API. + * + * @return Une instance prête à l'emploi de {@link ApiService} pour effectuer des appels API. + * Retourne une nouvelle instance de service à chaque appel, mais basée sur + * le même client Retrofit/OkHttp sous-jacent. */ public static ApiService getApiService() { return getClient().create(ApiService.class); diff --git a/app/src/main/java/legion/muyue/best2048/network/ApiService.java b/app/src/main/java/legion/muyue/best2048/network/ApiService.java index ed12f9e..ef37494 100644 --- a/app/src/main/java/legion/muyue/best2048/network/ApiService.java +++ b/app/src/main/java/legion/muyue/best2048/network/ApiService.java @@ -1,40 +1,65 @@ -package legion.muyue.best2048.network; // Créez un sous-package network +package legion.muyue.best2048.network; import legion.muyue.best2048.data.GameInfo; import legion.muyue.best2048.data.GameStateResponse; import legion.muyue.best2048.data.MoveRequest; +import legion.muyue.best2048.data.PlayerIdRequest; import retrofit2.Call; import retrofit2.http.Body; import retrofit2.http.GET; import retrofit2.http.POST; import retrofit2.http.Path; -import retrofit2.http.Query; // Pour éventuels paramètres de création -import legion.muyue.best2048.data.PlayerIdRequest; +/** + * Définit les points de terminaison (endpoints) de l'API REST pour le jeu Best2048. + * Cette interface est utilisée par Retrofit pour générer une implémentation + * capable d'effectuer des appels réseau vers le serveur du jeu. + * + * Les appels retournent des objets {@link Call} qui permettent une exécution + * asynchrone (ou synchrone) des requêtes HTTP. + * + * @see ApiClient#getApiService() pour obtenir une instance implémentant cette interface. + */ public interface ApiService { /** - * Crée une nouvelle partie ou rejoint une partie en attente (matchmaking simple). - * TODO: Définir les paramètres nécessaires (ex: ID du joueur). - * @return Informations sur la partie créée/rejointe. + * Crée une nouvelle partie ou rejoint une partie existante en attente pour le joueur spécifié. + * Effectue une requête POST vers {@code /api/games}. + * Le corps de la requête contient l'identifiant du joueur. + * + * @param playerIdRequest Un objet {@link PlayerIdRequest} contenant l'identifiant unique + * du joueur qui souhaite créer ou rejoindre une partie. + * @return Un objet {@link Call} qui, en cas de succès, encapsule les informations + * de la partie créée ou rejointe ({@link GameInfo}). */ - @POST("games") // Endpoint: /api/games (POST) + @POST("games") Call createOrJoinGame(@Body PlayerIdRequest playerIdRequest); /** * Récupère l'état actuel complet d'une partie spécifique. - * @param gameId L'identifiant unique de la partie. - * @return L'état actuel du jeu. + * Effectue une requête GET vers {@code /api/games/{gameId}}. + * + * @param gameId L'identifiant unique de la partie dont l'état doit être récupéré. + * Ce paramètre est inséré dans le chemin de l'URL. + * @return Un objet {@link Call} qui, en cas de succès, encapsule l'état complet + * de la partie demandée ({@link GameStateResponse}). */ @GET("games/{gameId}") Call getGameState(@Path("gameId") String gameId); /** - * Soumet le mouvement d'un joueur pour une partie spécifique. - * Le serveur validera si c'est bien le tour de ce joueur. - * @param gameId L'identifiant unique de la partie. - * @param moveRequest L'objet contenant la direction du mouvement et l'ID du joueur. - * @return Le nouvel état du jeu après application du mouvement (ou un message d'erreur). + * Soumet un mouvement effectué par un joueur dans une partie spécifique. + * Effectue une requête POST vers {@code /api/games/{gameId}/moves}. + * Le corps de la requête contient les détails du mouvement (direction et identifiant du joueur). + * + * @param gameId L'identifiant unique de la partie dans laquelle le mouvement est effectué. + * Ce paramètre est inséré dans le chemin de l'URL. + * @param moveRequest Un objet {@link MoveRequest} contenant la direction du mouvement + * et l'identifiant du joueur effectuant le mouvement. + * @return Un objet {@link Call} qui, en cas de succès, encapsule le nouvel état + * de la partie après l'application du mouvement ({@link GameStateResponse}). + * La réponse peut indiquer si le mouvement était valide, l'état mis à jour du plateau, + * les scores, etc. */ @POST("games/{gameId}/moves") Call makeMove(@Path("gameId") String gameId, @Body MoveRequest moveRequest); diff --git a/app/src/main/res/layout/stats_layout.xml b/app/src/main/res/layout/stats_layout.xml index fb05101..9238313 100644 --- a/app/src/main/res/layout/stats_layout.xml +++ b/app/src/main/res/layout/stats_layout.xml @@ -42,53 +42,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/general_section" /> - - - - - - - - - - - - - + + + + + + - - - - - - - + + + - - - - - - - - - - - - - - - + + + + + + - - + + + + + - - - - - - - - - - - - - - + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5666e4..39370a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,16 +1,17 @@ + Best 2048 2048 - Score : - Score :\n%d - High Score :\n%d - High Score : + Score: + Score:\n%d + High Score:\n%d + High Score: Restart Stats Menu Multiplayer - Restart ? - Are you sure you want to restart the game ? + Restart? + Are you sure you want to restart the game? Cancel Confirm High Score: %d @@ -27,15 +28,15 @@ Worst Winning Time: %s Total Merges: %d Highest Tile: %d - Number of time objective reached: %d - Perfect game : %d - Multiplayer game won : %d - Multiplayer game played : %d - Multiplayer win rate : %s - Best winning streak : %d + Times Objective Reached: %d + Perfect Games: %d + Multiplayer Games Won: %d + Multiplayer Games Played: %d + Multiplayer Win Rate: %s + Best Winning Streak: %d Multiplayer Average Score: %d - Average time per game: %s - Total Multiplayer losses: %d + Average Time Per Game: %s + Total Multiplayer Losses: %d Multiplayer High Score: %d Stats Statistics @@ -44,13 +45,13 @@ Single Player Multiplayer Back - You won! + You Won! Congratulations, you\'ve reached 2048! - Continue - New Part - Game over! - No move possible.\nFinal score: %d - To leave + Keep Playing + New Game + Game Over! + No more moves possible.\nFinal Score: %d + Quit Main Menu How to Play Settings @@ -60,19 +61,20 @@ Swipe the screen (Up, Down, Left, Right) to move all the tiles.\n\nWhen two tiles with the same number touch, they merge into one!\n\nReach the 2048 tile to win.\n\nThe game is over if the board is full and no moves are possible. About Best 2048 Version: 1.0 (University Project)\nDeveloped by: La Legion de Muyue\n(Leader: Muyue, Members: 2 others)\n\nBased on the popular game 2048. - Website : legion-muyue.fr https://legion-muyue.fr + Website: legion-muyue.fr + https://legion-muyue.fr OK Settings Sound Notifications Manage Permissions - Share my Statistics + Share Statistics Reset Statistics Quit Application Close Reset Stats? Are you sure you want to erase all your saved statistics? This action is irreversible. - Share my stats via… + Share stats via… My 2048 Statistics Here are my stats on Best 2048:\n- Best Score: %d\n- Highest Tile: %d\n- Games Won: %d / %d\n- Total Time: %s\n- Total Moves: %d Statistics reset. @@ -94,29 +96,30 @@ Sound effects enabled. Sound effects disabled. - Recherche d\'une partie… - Partie trouvée ! ID: %s… Connexion au serveur… - Connecté. En attente de l\'état du jeu… - En attente du coup adverse… - Envoi du mouvement… + Searching for a game… + Game found! ID: %s… + Connecting to server… + Connected. Waiting for game state… + Waiting for opponent\'s move… + Sending move… - À Votre Tour - Tour Adversaire + Your Turn + Opponent\'s Turn - Moi :\n%d - Autre :\n%d - Impossible de créer ou rejoindre (Code: %d) - Échec de connexion au serveur. - Erreur de connexion WebSocket. - WebSocket déconnecté. Tentative de reconnexion… - Ce n\'est pas votre tour. - Erreur Serveur: %s - Connecté au serveur de jeu ! - Fermeture de la connexion… - Connexion fermée. + You:\n%d + Opponent:\n%d + Could not create or join game (Code: %d) + Failed to connect to server. + WebSocket connection error. + WebSocket disconnected. Attempting to reconnect… + It\'s not your turn. + Server Error: %s + Connected to game server! + Closing connection… + Connection closed. - Égalité ! - Vous avez Gagné ! - Vous avez Perdu. - Partie Terminée ! + It\'s a Draw! + You Won! + You Lost. + Game Over! \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd2a02b..4fd576c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ activity = "1.8.0" constraintlayout = "2.1.4" gridlayout = "1.0.0" retrofit = "2.9.0" +workRuntime = "2.10.0" [libraries] activity-v190 = { module = "androidx.activity:activity", version.ref = "androidxActivity" } @@ -30,6 +31,7 @@ constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayo gridlayout = { group = "androidx.gridlayout", name = "gridlayout", version.ref = "gridlayout" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "loggingInterceptor" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }