diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 60b9b3d..d58a97a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,16 +4,6 @@
-
-
-
-
-
-
-
-
-
-
+ android:theme="@style/Theme.Best2048"
+ tools:targetApi="31">
+
+
= 2048) a été atteinte. */
+ /** Indicateur si la condition de victoire (une tuile >= 2048) a été atteinte. */
private boolean gameWon = false;
- /** Indicateur si la partie est terminée (plus de mouvements possibles). */
+ /** Indicateur si la partie est terminée (plus de mouvements ou de fusions possibles). */
private boolean gameOver = false;
/**
* Constructeur pour démarrer une nouvelle partie.
- * Initialise un plateau vide, le score à 0, et ajoute deux tuiles initiales.
+ * Initialise un plateau vide, met le score courant à 0, réinitialise les états
+ * de victoire/défaite, et ajoute deux tuiles initiales aléatoirement.
*/
public Game() {
this.randomNumberGenerator = new Random();
@@ -43,81 +46,145 @@ 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 fourni.
+ * 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.
*
- * @param board Le plateau de jeu restauré.
+ * @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.
*/
- public Game(int[][] board, int score) {
- // Valider les dimensions du plateau fourni ? Pourrait être ajouté.
- this.board = board;
+ 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.
this.currentScore = score;
this.randomNumberGenerator = new Random();
+ // Recalculer l'état de victoire/défaite basé sur le plateau chargé
checkWinCondition();
- checkGameOverCondition();
+ // Vérifier si la partie chargée est déjà terminée
+ checkGameOverCondition(); // Important si on charge une partie déjà terminée
}
// --- Getters / Setters ---
/**
- * Retourne la valeur de la tuile aux coordonnées spécifiées.
- * @param row Ligne (0 à BOARD_SIZE-1).
- * @param column Colonne (0 à BOARD_SIZE-1).
- * @return Valeur de la tuile, ou 0 si les coordonnées sont invalides.
+ * Retourne la valeur de la tuile aux coordonnées spécifiées (ligne, colonne).
+ * Les indices sont basés sur 0.
+ *
+ * @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.
*/
public int getCellValue(int row, int column) {
if (isIndexValid(row, column)) {
return this.board[row][column];
}
- return 0; // Retourne 0 pour indice invalide
+ // Log ou gestion d'erreur pourrait être ajouté si un accès invalide est critique
+ return 0; // Retourne 0 pour indice invalide comme convention
}
/**
* Définit la valeur d'une tuile aux coordonnées spécifiées.
- * Ne fait rien si les coordonnées sont invalides.
- * @param row Ligne (0 à BOARD_SIZE-1).
- * @param col Colonne (0 à BOARD_SIZE-1).
- * @param value Nouvelle valeur de la tuile.
+ * Utilisé principalement en interne ou pour les tests. Ne fait rien si
+ * les coordonnées (ligne, colonne) sont en dehors des limites du plateau.
+ *
+ * @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.
*/
- public void setCellValue(int row, int col, int value) {
+ @VisibleForTesting // Indique que cette méthode est surtout pour les tests
+ void setCellValue(int row, int col, int value) {
if (isIndexValid(row, col)) {
this.board[row][col] = value;
}
}
- /** @return Le score actuel de la partie. */
- public int getCurrentScore() { return currentScore; }
+ /**
+ * Retourne le score actuel de la partie en cours.
+ * Le score augmente lors de la fusion de tuiles.
+ *
+ * @return Le score entier actuel.
+ */
+ public int getCurrentScore() {
+ return currentScore;
+ }
- /** @return Le meilleur score connu par cet objet (défini via setHighestScore). */
- public int getHighestScore() { return highestScore; }
+ /**
+ * 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)}.
+ *
+ * @return Le meilleur score entier connu.
+ */
+ public int getHighestScore() {
+ return highestScore;
+ }
/**
* Met à jour la valeur du meilleur score stockée dans cet objet Game.
- * Typiquement appelé par la classe gérant la persistance (MainActivity).
- * @param highScore Le meilleur score global à stocker.
+ * 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.
+ *
+ * @param highScore Le nouveau meilleur score global à stocker dans cette instance.
*/
- public void setHighestScore(int highScore) { this.highestScore = highScore; }
-
- /** @return true si une tuile 2048 (ou plus) a été atteinte, false sinon. */
- public boolean isGameWon() { return gameWon; }
-
- /** @return true si aucune case n'est vide ET aucun mouvement/fusion n'est possible, false sinon. */
- public boolean isGameOver() { return gameOver; }
-
- /** Met à jour la valeur de gameWon si partie gagné **/
- private void setGameWon(boolean won) {this.gameWon = won;}
-
- /** Met à jour la valeur de gameWon si partie gagné **/
- private void setGameOver(boolean over) {this.gameOver = over;}
+ public void setHighestScore(int highScore) {
+ this.highestScore = highScore;
+ }
/**
- * Retourne une copie profonde du plateau de jeu actuel.
- * Utile pour la sérialisation ou pour éviter des modifications externes non désirées.
- * @return Une nouvelle matrice 2D représentant l'état actuel du plateau.
+ * Vérifie si la condition de victoire a été atteinte (au moins une tuile avec une valeur >= 2048).
+ *
+ * @return true si le jeu est considéré comme gagné, 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.
+ *
+ * @return true si la partie est terminée (game over), 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.
+ */
+ 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.
+ */
+ 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.
+ *
+ * @return Une nouvelle matrice 2D (`int[BOARD_SIZE][BOARD_SIZE]`) représentant l'état actuel du plateau.
+ */
+ @NonNull
public int[][] getBoard() {
int[][] copy = new int[BOARD_SIZE][BOARD_SIZE];
- for(int i=0; i emptyCells = findEmptyCells();
if (!emptyCells.isEmpty()) {
+ // Choisit une cellule vide au hasard parmi celles disponibles
int[] randomCell = emptyCells.get(randomNumberGenerator.nextInt(emptyCells.size()));
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
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.
}
/**
- * Trouve toutes les cellules vides sur le plateau.
- * @return Une liste de tableaux d'entiers `[row, col]` pour chaque cellule vide.
+ * Recherche et retourne les coordonnées de 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.
*/
+ @NonNull
private List findEmptyCells() {
List emptyCells = new ArrayList<>();
for (int row = 0; row < BOARD_SIZE; row++) {
@@ -169,211 +247,368 @@ public class Game {
}
/**
- * Génère la valeur pour une nouvelle tuile en utilisant des probabilités prédéfinies.
- * @return La valeur (2, 4, 8, ...).
+ * 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.
+ *
+ * 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.
*/
private int generateRandomTileValue() {
- int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour pourcentages fins
- if (randomValue < 8540) return 2; // 85.40%
- if (randomValue < 9740) return 4; // 12.00%
- if (randomValue < 9940) return 8; // 2.00%
- if (randomValue < 9990) return 16; // 0.50%
- if (randomValue < 9995) return 32; // 0.05%
- if (randomValue < 9998) return 64; // 0.03%
- if (randomValue < 9999) return 128;// 0.01%
- return 256; // 0.01%
+ 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%)
}
/**
* Tente de déplacer et fusionner les tuiles vers le HAUT.
- * Met à jour le score interne et vérifie les états win/gameOver.
- * @return true si le plateau a été modifié, false sinon.
+ * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
+ *
+ * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
- public boolean pushUp() { return processMove(MoveDirection.UP); }
+ public boolean pushUp() {
+ return processMove(MoveDirection.UP);
+ }
/**
* Tente de déplacer et fusionner les tuiles vers le BAS.
- * @return true si le plateau a été modifié, false sinon.
+ * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
+ *
+ * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
- public boolean pushDown() { return processMove(MoveDirection.DOWN); }
+ public boolean pushDown() {
+ return processMove(MoveDirection.DOWN);
+ }
/**
* Tente de déplacer et fusionner les tuiles vers la GAUCHE.
- * @return true si le plateau a été modifié, false sinon.
+ * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
+ *
+ * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
- public boolean pushLeft() { return processMove(MoveDirection.LEFT); }
+ public boolean pushLeft() {
+ return processMove(MoveDirection.LEFT);
+ }
/**
* Tente de déplacer et fusionner les tuiles vers la DROITE.
- * @return true si le plateau a été modifié, false sinon.
+ * Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
+ *
+ * @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
- public boolean pushRight() { return processMove(MoveDirection.RIGHT); }
+ public boolean pushRight() {
+ return processMove(MoveDirection.RIGHT);
+ }
- /** Énumération interne pour clarifier le traitement des mouvements. */
+ /** Énumération interne pour clarifier la direction du mouvement dans {@link #processMove}. */
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.
- * Contient la logique de base partagée par les méthodes pushX.
- * @param direction La direction du mouvement.
- * @return true si le plateau a été modifié, false sinon.
+ * 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.
+ *
+ * @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.
*/
private boolean processMove(MoveDirection direction) {
boolean boardChanged = false;
- // Itère sur l'axe perpendiculaire au mouvement
+
+ // Itérer sur l'axe perpendiculaire au mouvement (colonnes pour UP/DOWN, lignes pour LEFT/RIGHT)
for (int i = 0; i < BOARD_SIZE; i++) {
- boolean[] hasMerged = new boolean[BOARD_SIZE]; // Pour éviter double fusion sur l'axe de mouvement
- // Itère sur l'axe du mouvement, dans le bon sens
+ // 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)
+ 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.
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;
for (int j = start; j != end; j += step) {
- int row = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? j : i;
- int col = (direction == MoveDirection.LEFT || direction == MoveDirection.RIGHT) ? j : i;
+ int row, col;
+ // Déterminer les coordonnées (row, col) de la tuile courante basée sur l'axe i et l'index j
+ if (direction == MoveDirection.UP || direction == MoveDirection.DOWN) {
+ row = j; col = i; // Mouvement vertical, i est la colonne, j est la ligne
+ } else {
+ row = i; col = j; // Mouvement horizontal, i est la ligne, j est la colonne
+ }
- if (getCellValue(row, col) != 0) {
- int currentValue = getCellValue(row, col);
+ int currentValue = getCellValue(row, col);
+
+ // Si la case n'est pas vide, on essaie de la déplacer/fusionner
+ if (currentValue != 0) {
int currentRow = row;
int currentCol = col;
-
- // Calcule la position cible après déplacement dans les cases vides
- int targetRow = currentRow;
+ int targetRow = currentRow; // Position cible initiale
int targetCol = currentCol;
+
+ // 1. Déplacement : Trouver la case la plus éloignée atteignable 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"
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
nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
}
- // Déplace la tuile si sa position cible est différente
- if (targetRow != currentRow || targetCol != currentCol) {
- setCellValue(targetRow, targetCol, currentValue);
- setCellValue(currentRow, currentCol, 0);
- boardChanged = true;
- }
-
- // Vérifie la fusion potentielle avec la case suivante dans la direction du mouvement
- int mergeTargetRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
- int mergeTargetCol = 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;
+ int mergeTargetCol = nextCol;
+ // Index utilisé pour le tableau `hasMerged` (soit la ligne soit la colonne cible de la fusion)
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])
+ !hasMerged[mergeIndex]) // Crucial: empêche double fusion
{
+ // Fusion !
int newValue = currentValue * 2;
- setCellValue(mergeTargetRow, mergeTargetCol, newValue);
- setCellValue(targetRow, targetCol, 0); // La tuile qui fusionne disparaît
- currentScore += newValue;
- hasMerged[mergeIndex] = true;
- boardChanged = true;
+ 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;
+
+ // Vérifier immédiatement si cette fusion a créé une tuile >= 2048
+ if (newValue >= 2048) {
+ setGameWon(true);
+ }
}
- }
- }
- }
- // Vérifie les conditions de fin après chaque type de mouvement complet
- checkWinCondition();
- checkGameOverCondition();
+
+ // 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
+ if (!merged && (targetRow != currentRow || targetCol != currentCol)) {
+ 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é
+ }
+ } // Fin if (currentValue != 0)
+ } // Fin boucle interne (j)
+ } // Fin boucle 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
+
return boardChanged;
}
+
/**
- * Vérifie si les indices de ligne et colonne sont valides pour le plateau.
- * @param row Ligne.
- * @param col Colonne.
- * @return true si les indices sont dans les limites [0, BOARD_SIZE-1].
+ * Vérifie si les indices de ligne et colonne fournis sont valides pour le plateau de jeu actuel.
+ *
+ * @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.
*/
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) pour sauvegarde.
- * Format: "val,val,...,val,score"
- * @return Chaîne représentant l'état du jeu.
+ * 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é.
+ *
+ *
+ * @return Une chaîne non nulle représentant l'état sérialisé du jeu.
*/
@NonNull
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int row = 0; row < BOARD_SIZE; row++) {
- for (int col = 0; col < BOARD_SIZE; col++) { sb.append(board[row][col]).append(","); }
+ for (int col = 0; col < BOARD_SIZE; col++) {
+ sb.append(board[row][col]).append(",");
+ }
}
+ // Ajoute le score à la fin, séparé par une virgule
sb.append(currentScore);
return sb.toString();
}
/**
- * Crée un objet Game à partir de sa représentation sérialisée (plateau + score).
- * Le meilleur score doit être défini séparément après la création.
- * @param serializedState La chaîne issue de {@link #toString()}.
- * @return Une nouvelle instance de Game, ou null en cas d'erreur de format.
+ * 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()}.
+ *
+ *
+ * @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).
*/
- public static Game deserialize(String serializedState) {
- if (serializedState == null || serializedState.isEmpty()) return null;
+ @Nullable
+ public static Game deserialize(@Nullable String serializedState) {
+ if (serializedState == null || serializedState.isEmpty()) {
+ return null;
+ }
String[] values = serializedState.split(",");
- if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) return null; // +1 pour le score
- int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; int index = 0;
- try {
- for (int row = 0; row < BOARD_SIZE; row++) {
- for (int col = 0; col < BOARD_SIZE; col++) { newBoard[row][col] = Integer.parseInt(values[index++]); }
- }
- int score = Integer.parseInt(values[index]); // Le dernier élément est le score
- return new Game(newBoard, score);
- } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return null; }
- }
+ // Vérifie si le nombre d'éléments correspond à la taille du plateau + 1 (pour le score)
+ if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) {
+ return null;
+ }
- /**
- * Vérifie si la condition de victoire (une tuile >= 2048) est atteinte.
- * Met à jour l'état interne `gameWon`.
- */
- private void checkWinCondition() {
- if (!gameWon) {
- for (int r=0; r= 2048) {
- setGameWon(true); return;
+ int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE];
+ int index = 0;
+ try {
+ // Remplit le plateau à partir des valeurs de la chaîne
+ 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.
+ 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;
}
}
/**
- * Vérifie si la condition de fin de partie est atteinte (plateau plein ET aucun mouvement possible).
+ * 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é.
+ */
+ private void checkWinCondition() {
+ // Si le jeu est déjà marqué comme gagné, pas besoin de revérifier
+ if (gameWon) {
+ return;
+ }
+ // Parcours 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
+ }
+ }
+ }
+ // Si la boucle se termine sans trouver de tuile >= 2048, gameWon reste false (ou son état précédent)
+ }
+
+ /**
+ * 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`.
*/
private void checkGameOverCondition() {
- if (hasEmptyCell()) { setGameOver(false); return; } // Pas game over si case vide
- // Vérifie s'il existe au moins une fusion possible
- for (int r=0; r0 && getCellValue(r-1,c)==current) || (r0 && getCellValue(r,c-1)==current) || (c pas game over
+ // Si le jeu est 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é
+ if (hasEmptyCell()) {
+ setGameOver(false);
+ return;
+ }
+
+ // Le plateau est plein. Vérifier s'il existe au moins une fusion possible.
+ 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)
+ if (r > 0 && getCellValue(r - 1, c) == current) {
+ setGameOver(false); return; // Fusion possible vers le haut
+ }
+ // Vérifie le voisin du BAS (si existe)
+ if (r < BOARD_SIZE - 1 && getCellValue(r + 1, c) == current) {
+ setGameOver(false); return; // Fusion possible vers le bas
+ }
+ // Vérifie le voisin de GAUCHE (si existe)
+ if (c > 0 && getCellValue(r, c - 1) == current) {
+ setGameOver(false); return; // Fusion possible vers la gauche
+ }
+ // Vérifie le voisin de DROITE (si existe)
+ if (c < BOARD_SIZE - 1 && getCellValue(r, c + 1) == current) {
+ setGameOver(false); return; // Fusion possible vers la droite
+ }
}
}
- setGameOver(true); // Aucune case vide et aucune fusion -> game over
+
+ // Si on arrive ici, le plateau est plein ET aucune fusion adjacente n'est possible.
+ setGameOver(true);
}
/**
- * Vérifie si le plateau contient au moins une case vide (valeur 0).
- * @return true si une case vide existe, false sinon.
+ * Vérifie rapidement si le plateau contient au moins une case vide (valeur 0).
+ * Utilisé principalement par {@link #checkGameOverCondition()}.
+ *
+ * @return true s'il existe au moins une case vide, false si le plateau est plein.
*/
private boolean hasEmptyCell() {
- for (int r=0; r maxTile) maxTile = board[r][c];
+ for (int r = 0; r < BOARD_SIZE; r++) {
+ for (int c = 0; c < BOARD_SIZE; c++) {
+ if (board[r][c] > maxTile) {
+ maxTile = board[r][c];
+ }
+ }
+ }
return maxTile;
}
}
\ No newline at end of file
diff --git a/app/src/main/java/legion/muyue/best2048/GameStats.java b/app/src/main/java/legion/muyue/best2048/GameStats.java
index b74ca24..843e251 100644
--- a/app/src/main/java/legion/muyue/best2048/GameStats.java
+++ b/app/src/main/java/legion/muyue/best2048/GameStats.java
@@ -1,7 +1,8 @@
-// Fichier GameStats.java
/**
- * Gère la collecte, la persistance (via SharedPreferences) et l'accès
- * aux statistiques du jeu 2048, pour les modes solo et multijoueur (si applicable).
+ * 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;
@@ -13,9 +14,17 @@ import java.util.concurrent.TimeUnit;
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. */
private static final String PREFS_NAME = "Best2048_Prefs";
- private static final String HIGH_SCORE_KEY = "high_score"; // Clé partagée avec Game/MainActivity
- // Clés spécifiques aux statistiques
+ /**
+ * 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.
+ */
+ private static final String HIGH_SCORE_KEY = "high_score";
+
+ // Clés spécifiques aux statistiques persistantes
private static final String STATS_TOTAL_GAMES_PLAYED = "totalGamesPlayed";
private static final String STATS_TOTAL_GAMES_STARTED = "totalGamesStarted";
private static final String STATS_TOTAL_MOVES = "totalMoves";
@@ -23,10 +32,11 @@ public class GameStats {
private static final String STATS_TOTAL_MERGES = "totalMerges";
private static final String STATS_HIGHEST_TILE = "highestTile";
private static final String STATS_OBJECTIVE_REACHED_COUNT = "numberOfTimesObjectiveReached";
- private static final String STATS_PERFECT_GAMES = "perfectGames";
+ // private static final String STATS_PERFECT_GAMES = "perfectGames"; // Clé supprimée car concept non défini
private static final String STATS_BEST_WINNING_TIME_MS = "bestWinningTimeMs";
private static final String STATS_WORST_WINNING_TIME_MS = "worstWinningTimeMs";
- // ... (autres clés stats) ...
+
+ // Clés pour les statistiques multijoueur (fonctionnalité future)
private static final String STATS_MP_GAMES_WON = "multiplayerGamesWon";
private static final String STATS_MP_GAMES_PLAYED = "multiplayerGamesPlayed";
private static final String STATS_MP_BEST_WINNING_STREAK = "multiplayerBestWinningStreak";
@@ -37,94 +47,133 @@ public class GameStats {
// --- Champs de Statistiques ---
- // Générales & Solo
- private int totalGamesPlayed;
- private int totalGamesStarted;
- private int totalMoves;
- private long totalPlayTimeMs;
- private int totalMerges;
- private int highestTile;
- private int numberOfTimesObjectiveReached; // Nombre de victoires (>= 2048)
- private int perfectGames; // Concept non défini ici
- private long bestWinningTimeMs;
- private long worstWinningTimeMs;
- private int overallHighScore; // Meilleur score global
- // Partie en cours (non persistées telles quelles)
+ // Générales & Solo
+ /** Nombre total de parties terminées (victoire ou défaite). */
+ private int totalGamesPlayed;
+ /** Nombre total de parties démarrées (via bouton "Nouvelle Partie"). */
+ private int totalGamesStarted;
+ /** Nombre total de mouvements (swipes valides) effectués dans toutes les parties. */
+ private int totalMoves;
+ /** Temps total passé à jouer (en millisecondes) sur toutes les parties. */
+ private long totalPlayTimeMs;
+ /** Nombre total de fusions de tuiles effectuées dans toutes les parties. */
+ private int totalMerges;
+ /** Valeur de la plus haute tuile atteinte globalement dans toutes les parties. */
+ private int highestTile;
+ /** Nombre de fois où l'objectif (atteindre 2048 ou plus) a été atteint. */
+ 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). */
+ private long bestWinningTimeMs;
+ /** Pire temps (le plus long, en ms) pour atteindre l'objectif (>= 2048). */
+ private long worstWinningTimeMs;
+ /**
+ * Meilleur score global atteint. Persisté via HIGH_SCORE_KEY.
+ * Synchronisé avec MainActivity/Game lors du chargement/sauvegarde.
+ */
+ 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. */
private int currentMoves;
+ /** Timestamp (en ms) du début de la partie actuelle. Réinitialisé à chaque nouvelle partie ou reprise. */
private long currentGameStartTimeMs;
+ /** Nombre de fusions effectuées dans la partie actuelle. */
private int mergesThisGame;
- // Multijoueur
+ // Multijoueur (placeholders pour fonctionnalité future)
+ /** Nombre de parties multijoueur gagnées. */
private int multiplayerGamesWon;
+ /** Nombre de parties multijoueur jouées (terminées). */
private int multiplayerGamesPlayed;
+ /** Plus longue série de victoires consécutives en multijoueur. */
private int multiplayerBestWinningStreak;
+ /** Score total accumulé dans toutes les parties multijoueur. */
private long multiplayerTotalScore;
+ /** Temps total passé dans les parties multijoueur (en ms). */
private long multiplayerTotalTimeMs;
+ /** Nombre total de défaites en multijoueur. */
private int totalMultiplayerLosses;
+ /** Meilleur score atteint dans une seule partie multijoueur. */
private int multiplayerHighestScore;
- /** Contexte nécessaire pour accéder aux SharedPreferences. */
+ /** Contexte applicatif nécessaire pour accéder aux SharedPreferences. */
private final Context context;
/**
- * Constructeur. Initialise l'objet et charge immédiatement les statistiques
- * depuis les SharedPreferences.
- * @param context Contexte de l'application.
+ * Constructeur de GameStats.
+ * Initialise l'objet et charge immédiatement les statistiques persistantes
+ * depuis les SharedPreferences via {@link #loadStats()}.
+ *
+ * @param context Contexte de l'application (Activity ou Application). Utilise `getApplicationContext()` pour éviter les fuites de mémoire.
*/
public GameStats(Context context) {
- this.context = context.getApplicationContext(); // Utilise le contexte applicatif
- loadStats();
+ this.context = context.getApplicationContext(); // Important: utiliser le contexte applicatif
+ loadStats(); // Charge les statistiques dès l'initialisation
}
// --- Persistance (SharedPreferences) ---
/**
- * Charge toutes les statistiques persistantes depuis les SharedPreferences.
- * Appelé par le constructeur.
+ * 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.
*/
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);
- // ... (chargement de toutes les autres clés persistantes) ...
totalMoves = prefs.getInt(STATS_TOTAL_MOVES, 0);
- totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0);
+ totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0L);
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);
- bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme défaut pour 'best'
- worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 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)
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);
- multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0);
- multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0);
+ multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0L);
+ multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0L);
totalMultiplayerLosses = prefs.getInt(STATS_MP_LOSSES, 0);
multiplayerHighestScore = prefs.getInt(STATS_MP_HIGH_SCORE, 0);
}
/**
- * Sauvegarde toutes les statistiques persistantes dans les SharedPreferences.
- * Appelé typiquement dans `onPause` de l'activité.
+ * 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).
*/
public void saveStats() {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
- editor.putInt(HIGH_SCORE_KEY, overallHighScore); // Sauvegarde le HS global
+
+ // 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);
- // ... (sauvegarde de toutes les autres clés persistantes) ...
editor.putInt(STATS_TOTAL_MOVES, totalMoves);
editor.putLong(STATS_TOTAL_PLAY_TIME_MS, totalPlayTimeMs);
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);
+ // 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)
editor.putInt(STATS_MP_GAMES_WON, multiplayerGamesWon);
editor.putInt(STATS_MP_GAMES_PLAYED, multiplayerGamesPlayed);
editor.putInt(STATS_MP_BEST_WINNING_STREAK, multiplayerBestWinningStreak);
@@ -133,24 +182,28 @@ public class GameStats {
editor.putInt(STATS_MP_LOSSES, totalMultiplayerLosses);
editor.putInt(STATS_MP_HIGH_SCORE, multiplayerHighestScore);
- editor.apply(); // Applique les changements de manière asynchrone
+ // Applique les changements de manière asynchrone
+ editor.apply();
}
// --- Méthodes de Mise à Jour des Statistiques ---
/**
- * Doit être appelée au début de chaque nouvelle partie.
- * Incrémente le compteur de parties démarrées et réinitialise les stats de la partie en cours.
+ * 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`).
*/
public void startGame() {
totalGamesStarted++;
currentMoves = 0;
mergesThisGame = 0;
- currentGameStartTimeMs = System.currentTimeMillis();
+ currentGameStartTimeMs = System.currentTimeMillis(); // Enregistre le temps de début
}
/**
- * Enregistre un mouvement réussi (qui a modifié le plateau).
+ * 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`).
*/
public void recordMove() {
currentMoves++;
@@ -159,7 +212,10 @@ public class GameStats {
/**
* Enregistre une ou plusieurs fusions survenues lors d'un mouvement.
- * @param numberOfMerges Le nombre de fusions (si connu, sinon 1 par défaut).
+ * Incrémente le compteur de fusions pour la partie en cours (`mergesThisGame`)
+ * et le compteur total de fusions (`totalMerges`).
+ *
+ * @param numberOfMerges Le nombre de fusions distinctes réalisées lors du dernier mouvement (typiquement 1 ou plus).
*/
public void recordMerge(int numberOfMerges) {
if (numberOfMerges > 0) {
@@ -169,47 +225,71 @@ public class GameStats {
}
/**
- * Met à jour la statistique de la plus haute tuile atteinte globalement.
- * @param tileValue La valeur de la tuile la plus haute de la partie en cours.
+ * 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.
+ *
+ * @param currentHighestTileValue La valeur de la tuile la plus haute sur le plateau à l'instant T.
*/
- public void updateHighestTile(int tileValue) {
- if (tileValue > this.highestTile) {
- this.highestTile = tileValue;
+ public void updateHighestTile(int currentHighestTileValue) {
+ if (currentHighestTileValue > this.highestTile) {
+ this.highestTile = currentHighestTileValue;
+ // La sauvegarde se fera via saveStats() globalement
}
}
/**
- * Enregistre une victoire et met à jour les temps associés.
- * @param timeTakenMs Temps écoulé pour cette partie gagnante.
+ * 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.
+ *
+ * @param timeTakenMs Temps écoulé (en millisecondes) pour terminer cette partie gagnante.
*/
public void recordWin(long timeTakenMs) {
numberOfTimesObjectiveReached++;
- if (timeTakenMs < bestWinningTimeMs) { bestWinningTimeMs = timeTakenMs; }
- if (timeTakenMs > worstWinningTimeMs) { worstWinningTimeMs = timeTakenMs; }
- endGame(timeTakenMs); // Finalise aussi le temps total et parties jouées
+ if (timeTakenMs < bestWinningTimeMs) {
+ bestWinningTimeMs = timeTakenMs;
+ }
+ // Utiliser >= pour le pire temps permet de l'enregistrer même si c'est le premier
+ if (timeTakenMs >= worstWinningTimeMs) {
+ worstWinningTimeMs = timeTakenMs;
+ }
+ endGame(timeTakenMs); // Finalise aussi le temps total et le compteur de parties jouées
}
/**
- * Enregistre une défaite.
+ * 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.
*/
public void recordLoss() {
- // Calcule le temps écoulé avant de finaliser
- endGame(System.currentTimeMillis() - currentGameStartTimeMs);
+ // Calcule le temps écoulé pour cette partie avant de la finaliser
+ long timeTakenMs = System.currentTimeMillis() - currentGameStartTimeMs;
+ endGame(timeTakenMs);
}
/**
* Finalise les statistiques générales à la fin d'une partie (victoire ou défaite).
- * @param timeTakenMs Temps total de la partie terminée.
+ * Incrémente le compteur de parties jouées (`totalGamesPlayed`) et ajoute le temps
+ * de la partie terminée au temps de jeu total (`totalPlayTimeMs`).
+ *
+ * @param timeTakenMs Temps total (en millisecondes) de la partie qui vient de se terminer.
*/
- public void endGame(long timeTakenMs) {
+ private void endGame(long timeTakenMs) {
totalGamesPlayed++;
- addPlayTime(timeTakenMs);
+ 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.
}
/**
- * Ajoute une durée (en ms) au temps de jeu total enregistré.
- * Typiquement appelé dans `onPause`.
- * @param durationMs Durée à ajouter.
+ * 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`.
+ *
+ * @param durationMs Durée positive (en millisecondes) à ajouter au temps total.
*/
public void addPlayTime(long durationMs) {
if (durationMs > 0) {
@@ -218,8 +298,10 @@ public class GameStats {
}
/**
- * Réinitialise toutes les statistiques (solo, multijoueur, high score global)
- * à leurs valeurs par défaut.
+ * 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()}.
*/
public void resetStats() {
// Réinitialise toutes les variables membres à 0 ou valeur initiale
@@ -227,80 +309,158 @@ public class GameStats {
totalGamesPlayed = 0;
totalGamesStarted = 0;
totalMoves = 0;
- totalPlayTimeMs = 0;
+ totalPlayTimeMs = 0L;
totalMerges = 0;
highestTile = 0;
numberOfTimesObjectiveReached = 0;
- perfectGames = 0;
+ // perfectGames = 0; // Supprimé
bestWinningTimeMs = Long.MAX_VALUE;
- worstWinningTimeMs = 0;
+ worstWinningTimeMs = 0L;
+ // Réinitialise les stats multijoueur (placeholders)
multiplayerGamesWon = 0;
multiplayerGamesPlayed = 0;
multiplayerBestWinningStreak = 0;
- multiplayerTotalScore = 0;
- multiplayerTotalTimeMs = 0;
+ multiplayerTotalScore = 0L;
+ multiplayerTotalTimeMs = 0L;
totalMultiplayerLosses = 0;
multiplayerHighestScore = 0;
+ // Réinitialise les stats de la partie en cours (au cas où)
+ 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.
+
+ // Sauvegarde immédiatement les statistiques réinitialisées
saveStats();
}
// --- Getters pour l'affichage ---
+
+ /** @return Le nombre total de parties terminées (victoire ou défaite). */
public int getTotalGamesPlayed() { return totalGamesPlayed; }
+ /** @return Le nombre total de parties démarrées. */
public int getTotalGamesStarted() { return totalGamesStarted; }
+ /** @return Le nombre total de mouvements valides effectués. */
public int getTotalMoves() { return totalMoves; }
+ /** @return Le nombre de mouvements dans la partie en cours. */
public int getCurrentMoves() { return currentMoves; }
+ /** @return Le temps total de jeu accumulé (en ms). */
public long getTotalPlayTimeMs() { return totalPlayTimeMs; }
- public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } // Utile pour calcul durée en cours
+ /** @return Le timestamp (en ms) du début de la partie en cours. Utile pour calculer la durée en temps réel. */
+ public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; }
+ /** @return Le nombre de fusions dans la partie en cours. */
public int getMergesThisGame() { return mergesThisGame; }
+ /** @return Le nombre total de fusions effectuées. */
public int getTotalMerges() { return totalMerges; }
+ /** @return La valeur de la plus haute tuile atteinte globalement. */
public int getHighestTile() { return highestTile; }
+ /** @return Le nombre de fois où l'objectif (>= 2048) a été atteint. */
public int getNumberOfTimesObjectiveReached() { return numberOfTimesObjectiveReached; }
- public int getPerfectGames() { return perfectGames; }
+ // 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. */
public long getBestWinningTimeMs() { return bestWinningTimeMs; }
+ /** @return Le pire temps (le plus long, en ms) pour gagner une partie, ou 0 si aucune victoire. */
public long getWorstWinningTimeMs() { return worstWinningTimeMs; }
- public int getOverallHighScore() { return overallHighScore; } // Getter pour HS global
- // Getters Multiplayer
+ /** @return Le meilleur score global enregistré. */
+ public int getOverallHighScore() { return overallHighScore; }
+
+ // Getters Multiplayer (fonctionnalité future)
+ /** @return Nombre de parties multijoueur gagnées. */
public int getMultiplayerGamesWon() { return multiplayerGamesWon; }
+ /** @return Nombre de parties multijoueur jouées. */
public int getMultiplayerGamesPlayed() { return multiplayerGamesPlayed; }
+ /** @return Meilleure série de victoires consécutives en multijoueur. */
public int getMultiplayerBestWinningStreak() { return multiplayerBestWinningStreak; }
+ /** @return Score total accumulé en multijoueur. */
public long getMultiplayerTotalScore() { return multiplayerTotalScore; }
+ /** @return Temps total passé en multijoueur (en ms). */
public long getMultiplayerTotalTimeMs() { return multiplayerTotalTimeMs; }
+ /** @return Nombre total de défaites en multijoueur. */
public int getTotalMultiplayerLosses() { return totalMultiplayerLosses; }
+ /** @return Meilleur score atteint dans une partie multijoueur. */
public int getMultiplayerHighestScore() { return multiplayerHighestScore; }
// --- Setters ---
- /** Met à jour la valeur interne du high score global. */
- public void setHighestScore(int highScore) {
- // Met à jour si la nouvelle valeur est meilleure
- if (highScore > this.overallHighScore) {
- this.overallHighScore = highScore;
- // La sauvegarde se fait via saveStats() globalement
- }
- }
- /** Définit le timestamp de début de la partie en cours. */
- public void setCurrentGameStartTimeMs(long timeMs) { this.currentGameStartTimeMs = timeMs; }
-
- // --- Méthodes Calculées ---
- /** @return Temps moyen par partie terminée en millisecondes. */
- public long getAverageGameTimeMs() { return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0; }
- /** @return Score moyen par partie multijoueur terminée. */
- public int getMultiplayerAverageScore() { return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; }
- /** @return Temps moyen par partie multijoueur terminée en millisecondes. */
- public long getMultiplayerAverageTimeMs() { return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0; }
/**
- * Formate une durée en millisecondes en chaîne "hh:mm:ss" ou "mm:ss".
- * @param milliseconds Durée en millisecondes.
- * @return Chaîne de temps formatée.
+ * 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()}.
+ *
+ * @param highScore Le nouveau score à potentiellement définir comme meilleur score.
*/
- @SuppressLint("DefaultLocale")
+ 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.
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @param timeMs Le timestamp en millisecondes.
+ */
+ public void setCurrentGameStartTimeMs(long timeMs) {
+ this.currentGameStartTimeMs = timeMs;
+ }
+
+ // --- Méthodes Calculées ---
+
+ /**
+ * Calcule le temps moyen passé par partie terminée (solo).
+ *
+ * @return Le temps moyen par partie en millisecondes, ou 0 si aucune partie n'a été terminée.
+ */
+ public long getAverageGameTimeMs() {
+ return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0L;
+ }
+
+ /**
+ * 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.
+ */
+ 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).
+ *
+ * @return Le temps moyen par partie multijoueur 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".
+ *
+ * @param milliseconds Durée totale en millisecondes.
+ * @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;
- long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60;
- if (hours > 0) { return String.format("%02d:%02d:%02d", hours, minutes, seconds); }
- else { return String.format("%02d:%02d", minutes, seconds); }
+ 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
+
+ if (hours > 0) {
+ // Format avec heures si nécessaire
+ return String.format("%02d:%02d:%02d", hours, minutes, seconds);
+ } else {
+ // Format minutes:secondes sinon
+ return String.format("%02d:%02d", minutes, seconds);
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/legion/muyue/best2048/MainActivity.java b/app/src/main/java/legion/muyue/best2048/MainActivity.java
index 2f9915d..065b53e 100644
--- a/app/src/main/java/legion/muyue/best2048/MainActivity.java
+++ b/app/src/main/java/legion/muyue/best2048/MainActivity.java
@@ -1,17 +1,18 @@
-// Fichier MainActivity.java
/**
- * Activité principale de l'application 2048.
- * Gère l'interface utilisateur (plateau, scores, boutons), coordonne les interactions
- * avec la logique du jeu (classe Game) et la gestion des statistiques (classe GameStats).
- * Gère également le cycle de vie de l'application et la persistance de l'état du jeu.
+ * 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 android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.Context;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
+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;
@@ -22,6 +23,7 @@ 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;
@@ -32,10 +34,11 @@ import android.widget.TextView;
import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
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;
-import androidx.core.app.NotificationManagerCompat;
+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;
@@ -45,8 +48,8 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.view.ViewTreeObserver;
-import android.media.AudioAttributes; // Pour SoundPool
-import android.media.SoundPool; // Pour SoundPool
+import android.media.AudioAttributes;
+import android.media.SoundPool;
import java.util.ArrayList;
import java.util.List;
@@ -54,134 +57,454 @@ import java.util.List;
public class MainActivity extends AppCompatActivity {
- // --- UI Elements ---
- private GridLayout boardGridLayout;
- private TextView currentScoreTextView;
- private TextView highestScoreTextView;
- private Button newGameButton;
- private Button multiplayerButton;
- private Button statisticsButton;
- private Button menuButton;
- private ViewStub statisticsViewStub;
- private View inflatedStatsView;
-
- // --- Game Logic & Stats ---
- private Game game;
- private GameStats gameStats;
+ // --- 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. */
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";
- private boolean notificationsEnabled = false;
+ /** Nom du fichier de préférences partagées. Doit correspondre à GameStats.PREFS_NAME. */
+ private static final String PREFS_NAME = "Best2048_Prefs";
+ /** Clé pour sauvegarder/charger le meilleur score global. Doit correspondre à GameStats.HIGH_SCORE_KEY. */
+ private static final String HIGH_SCORE_KEY = "high_score";
+ /** Clé pour sauvegarder/charger l'état sérialisé du jeu (plateau + score courant). */
+ 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é). */
private static final String LAST_PLAYED_TIME_KEY = "last_played_time";
+ /** Clé pour sauvegarder/charger la préférence d'activation du son. */
+ private static final String SOUND_ENABLED_KEY = "sound_enabled";
+ /** Clé pour sauvegarder/charger la préférence d'activation des notifications. */
+ private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled";
- // --- State Management ---
- private boolean statisticsVisible = false;
- private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER }
- private GameFlowState currentGameState = GameFlowState.PLAYING;
-
- /** Références aux TextViews des tuiles actuellement affichées. */
+ // --- UI Elements ---
+ /** Layout de grille affichant les tuiles du jeu. */
+ private GridLayout boardGridLayout;
+ /** TextView affichant le score de la partie en cours. */
+ private TextView currentScoreTextView;
+ /** TextView affichant le meilleur score global. */
+ private TextView highestScoreTextView;
+ /** Bouton pour démarrer une nouvelle partie (après confirmation). */
+ private Button newGameButton;
+ /** Bouton pour accéder aux fonctionnalités multijoueur (placeholder actuel). */
+ private Button multiplayerButton;
+ /** Bouton pour afficher/masquer le panneau des statistiques. */
+ private Button statisticsButton;
+ /** Bouton pour ouvrir le menu principal (dialogue). */
+ private Button menuButton;
+ /** ViewStub pour charger paresseusement le layout des statistiques. */
+ private ViewStub statisticsViewStub;
+ /** Référence au layout des statistiques une fois qu'il est gonflé par le ViewStub. */
+ private View inflatedStatsView;
+ /** Références aux TextViews représentant les tuiles actuellement affichées 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). */
+ private Game game;
+ /** Instance de la classe GameStats gérant la collecte et la persistance des statistiques. */
+ private GameStats gameStats;
+
+ // --- State Management ---
+ /** Indique 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). */
+ 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+). */
+ 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. */
private SharedPreferences preferences;
- private static final String PREFS_NAME = "Best2048_Prefs";
- private static final String HIGH_SCORE_KEY = "high_score";
- private static final String GAME_STATE_KEY = "game_state";
- // --- Champs Son ---
+ // --- Sound ---
+ /** Pool pour gérer et jouer les effets sonores courts. */
private SoundPool soundPool;
- private int soundMoveId = -1; // Initialise à -1 pour savoir s'ils sont chargés
+ /** ID du son chargé pour un mouvement de tuile. */
+ private int soundMoveId = -1;
+ /** ID du son chargé pour une fusion de tuiles. */
private int soundMergeId = -1;
+ /** ID du son chargé pour la victoire (atteinte de 2048). */
private int soundWinId = -1;
+ /** ID du son chargé pour la fin de partie (Game Over). */
private int soundGameOverId = -1;
- private boolean soundPoolLoaded = false; // Flag pour savoir si les sons sont prêts
- private boolean soundEnabled = true; // Son activé par défaut
-
- // --- Activity Lifecycle ---
+ /** 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).
+ */
private final ActivityResultLauncher requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (isGranted) {
- // La permission est accordée. On peut activer/planifier les notifications.
+ // Permission accordée !
+ Log.i(TAG, "Permission POST_NOTIFICATIONS accordée.");
notificationsEnabled = true;
saveNotificationPreference(true);
Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
- // Ici, on pourrait (re)planifier les notifications périodiques avec WorkManager/AlarmManager
+ startNotificationService(); // Démarrer le service maintenant que la permission est OK
} else {
- // La permission est refusée. L'utilisateur ne recevra pas de notifications.
+ // Permission refusée.
+ Log.w(TAG, "Permission POST_NOTIFICATIONS refusée.");
notificationsEnabled = false;
saveNotificationPreference(false);
- // Désactive le switch dans les paramètres si l'utilisateur vient de refuser
- updateNotificationSwitchState(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
Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show();
- // Afficher une explication si nécessaire
+ stopNotificationService(); // Arrêter le service si la permission est refusée
+ // Optionnel : Afficher une explication pourquoi la notif est utile.
// showNotificationPermissionRationale();
}
});
+ // --- 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.
+ *
+ * @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.
+ */
@Override
protected void onCreate(Bundle savedInstanceState) {
- EdgeToEdge.enable(this);
+ EdgeToEdge.enable(this); // Active l'affichage bord à bord si le thème le supporte
super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
- NotificationHelper.createNotificationChannel(this);
- findViews();
- initializeSoundPool();
- initializeGameAndStats();
- setupListeners();
+ setContentView(R.layout.activity_main); // Définit le layout de l'activité
+
+ Log.d(TAG, "onCreate: Initialisation de l'activité.");
+
+ // 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)
if (notificationsEnabled) {
- startNotificationService();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ startNotificationService();
+ } 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();
+ }
}
}
/**
- * Synchronise COMPLETEMENT le GridLayout avec l'état actuel de 'game.board'.
- * Étape 1: Ajoute 16 vues de fond pour "fixer" la structure de la grille.
- * Étape 2: Ajoute les TextViews des tuiles réelles (valeur > 0) par-dessus.
- * Stocke les références des tuiles réelles dans tileViews.
+ * 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.
+ */
+ @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
+ 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
+ if (statisticsVisible) {
+ if (inflatedStatsView != null) { // Si déjà gonflé
+ 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é.");
+ } 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
+ toggleStatistics();
+ }
+ } else {
+ if (inflatedStatsView != null) {
+ inflatedStatsView.setVisibility(View.GONE); // Assure qu'il est masqué si non visible
+ }
+ multiplayerButton.setVisibility(View.VISIBLE); // Assure que le bouton multi est 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é.
+ */
+ @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
+ 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");
+ }
+ 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.");
+ }
+ }
+
+ /**
+ * Appelée juste avant que l'activité ne soit détruite.
+ * Libère les ressources critiques, notamment le 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
+ 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.
+ */
+ 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);
+ newGameButton = findViewById(R.id.restartButton);
+ statisticsButton = findViewById(R.id.statsButton);
+ 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.
+ */
+ private void initializeGameAndStats() {
+ Log.d(TAG, "initializeGameAndStats: Initialisation du jeu et des statistiques.");
+ preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
+
+ // Charger les préférences utilisateur avant d'initialiser les objets qui en dépendent
+ loadSoundPreference();
+ loadNotificationPreference();
+
+ // Initialiser GameStats (qui charge ses propres données depuis SharedPreferences)
+ gameStats = new GameStats(this);
+
+ // Charger l'état du jeu (Game) s'il existe
+ loadGame(); // Tente de charger 'game' depuis SharedPreferences
+
+ // Si loadGame n'a pas réussi à charger une partie valide (ou s'il n'y avait pas de sauvegarde)
+ 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
+ } 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
+ game.setHighestScore(gameStats.getOverallHighScore());
+ updateUI(); // Met à jour l'UI avec le jeu chargé
+ }
+ // 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.
+ */
+ private void initializeSoundPool() {
+ Log.d(TAG, "initializeSoundPool: Initialisation du SoundPool.");
+ // Configuration pour les effets sonores de jeu
+ AudioAttributes attributes = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_GAME) // Type d'usage approprié
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) // Type de contenu
+ .build();
+
+ // Crée le SoundPool avec un nombre maximum de flux simultanés
+ soundPool = new SoundPool.Builder()
+ .setMaxStreams(3) // Permet de jouer jusqu'à 3 sons en même temps
+ .setAudioAttributes(attributes)
+ .build();
+
+ // Listener pour détecter la fin du chargement des sons
+ 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 (soundMoveId > 0 && soundMergeId > 0 && soundWinId > 0 && soundGameOverId > 0) {
+ Log.i(TAG, "SoundPool: Tous les sons sont chargés.");
+ soundPoolLoaded = true;
+ }
+ } 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
+ }
+ });
+
+ // 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).
+ 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)
+ if (soundMoveId == 0 || soundMergeId == 0 || soundWinId == 0 || soundGameOverId == 0) {
+ throw new RuntimeException("SoundPool.load a retourné 0, échec du chargement d'un son.");
+ }
+ } 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
+ soundEnabled = false;
+ saveSoundPreference(false);
+ Toast.makeText(this, "Erreur au chargement des sons, son désactivé.", 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}).
+ */
+ private void setupListeners() {
+ Log.d(TAG, "setupListeners: Configuration des listeners.");
+ // Listener pour le bouton Nouvelle Partie
+ newGameButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press)); // Effet visuel
+ showRestartConfirmationDialog(); // Affiche dialogue de confirmation
+ });
+
+ // Listener pour le bouton Statistiques
+ statisticsButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ toggleStatistics(); // Affiche/Masque le panneau
+ });
+
+ // Listener pour le bouton Menu
+ menuButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ showMenu(); // Affiche le dialogue du menu principal
+ });
+
+ // Listener pour le bouton Multijoueur (placeholder)
+ multiplayerButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ showMultiplayerScreen(); // Affiche dialogue placeholder
+ });
+
+ // Attache le listener de swipe au GridLayout du 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()}.
+ */
+ 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é
+ }
+ // 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
+ 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()}.
*/
private void syncBoardView() {
// Vérifications de sécurité
if (game == null || boardGridLayout == null) {
- System.err.println("syncBoardView: Game ou GridLayout est null !");
+ Log.e(TAG, "syncBoardView: Impossible de synchroniser, Game ou GridLayout est null !");
return;
}
- // Log.d("SyncDebug", "Syncing board view (Background + Tiles)...");
+ // 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 (pas les fonds)
+ // Réinitialise le tableau qui stocke les références aux *vraies* 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 une seule fois
+ // 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é
// --- Étape 1: Ajouter les 16 vues de fond pour définir la grille ---
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
- // Utiliser un simple View pour le fond est suffisant
- View backgroundCell = new View(this);
+ // Utiliser un simple View pour le fond
+ View backgroundCell = new View(context);
- // Appliquer le style d'une cellule vide
- // Utilise le même drawable que les tuiles pour les coins arrondis, mais avec la couleur de fond vide
+ // Appliquer le style d'une cellule vide (fond + coins arrondis)
backgroundCell.setBackgroundResource(R.drawable.tile_background);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- // Utiliser setTintList pour la compatibilité et éviter de recréer des drawables
- backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty));
- } else {
- // Pour les versions plus anciennes, une approche différente pourrait être nécessaire si setTintList n'est pas dispo
- // ou utiliser un drawable spécifique pour le fond. Ici on suppose API 21+ pour setTintList.
- // Alternative simple mais moins propre : backgroundCell.setBackgroundColor(ContextCompat.getColor(this, R.color.tile_empty)); (perd les coins arrondis)
- }
+ backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(context, R.color.tile_empty));
- // Définir les LayoutParams pour positionner cette vue de fond dans la grille
+ // Définir les LayoutParams pour positionner cette vue de fond
GridLayout.LayoutParams params = new GridLayout.LayoutParams();
- params.width = 0;
- params.height = 0;
+ 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);
@@ -198,50 +521,52 @@ public class MainActivity extends AppCompatActivity {
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
+ // Si la case logique contient une tuile réelle (valeur > 0)
if (value > 0) {
- // Crée la TextView stylisée pour cette tuile via notre méthode helper
- // createTileTextView utilise les mêmes LayoutParams (row, col, span 1, weight 1, margins)
+ // Crée la TextView stylisée pour cette tuile via la méthode helper
TextView tileTextView = createTileTextView(value, r, c);
- // Stocke la référence à cette TextView de tuile (pas la vue de fond)
+ // Stocke la référence à cette TextView de tuile
tileViews[r][c] = tileTextView;
- // Ajoute la TextView de la tuile au GridLayout.
- // Comme elle est ajoutée après la vue de fond pour la même cellule (r,c)
- // et utilise les mêmes paramètres de positionnement, elle s'affichera par-dessus.
+ // Ajoute la TextView de la tuile au GridLayout. Elle se superposera au fond.
boardGridLayout.addView(tileTextView);
- // Log.d("SyncDebug", "Added TILE view at ["+r+","+c+"] with value "+value);
+ // Log.d(TAG, "syncBoardView: Ajout de la tuile [" + r + "," + c + "] valeur " + value);
}
}
}
- // Log.d("SyncDebug", "Board sync finished.");
+ // Log.d(TAG, "syncBoardView: Synchronisation terminée.");
- // Met à jour l'affichage textuel des scores
+ // Met à jour l'affichage textuel des scores après avoir redessiné le plateau
updateScores();
}
/**
- * Crée et configure une TextView pour une tuile.
- * @param value Valeur de la tuile.
- * @param row Ligne.
- * @param col Colonne.
- * @return La TextView configurée.
+ * 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.
+ *
+ * @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.
*/
+ @NonNull
private TextView createTileTextView(int value, int row, int col) {
TextView tileTextView = new TextView(this);
- setTileStyle(tileTextView, value); // Applique le style visuel
+ setTileStyle(tileTextView, value); // Applique le style visuel (couleur, taille texte)
+ // Configure les LayoutParams pour le positionnement dans le GridLayout
GridLayout.LayoutParams params = new GridLayout.LayoutParams();
- params.width = 0; // Largeur gérée par GridLayout basé sur la colonne/poids
- params.height = 0; // Hauteur gérée par GridLayout basé sur la ligne/poids
-
- // --- Modification : Spécifier explicitement le span (taille) à 1 ---
- // Utilisation de spec(start, size, weight)
- params.rowSpec = GridLayout.spec(row, 1, 1f); // Commence à 'row', occupe 1 ligne, poids 1
- params.columnSpec = GridLayout.spec(col, 1, 1f); // Commence à 'col', occupe 1 colonne, poids 1
- // --- Fin Modification ---
+ 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.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);
@@ -249,269 +574,64 @@ public class MainActivity extends AppCompatActivity {
return tileTextView;
}
- @Override
- protected void onResume() {
- super.onResume();
- // Redémarre le timer seulement si le jeu est en cours (pas gagné/perdu)
- if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) {
- gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis());
- }
- // Gère le réaffichage potentiel des stats si l'activité reprend
- if (statisticsVisible) {
- if (inflatedStatsView != null) { // Si déjà gonflé
- updateStatisticsTextViews(); // Met à jour les données affichées
- inflatedStatsView.setVisibility(View.VISIBLE);
- multiplayerButton.setVisibility(View.GONE);
- } else {
- // Si pas encore gonflé (cas rare mais possible), on le fait afficher
- toggleStatistics();
- }
- }
- }
-
- @Override
- protected void onPause() {
- super.onPause();
- // Sauvegarde l'état et les stats si le jeu existe
- if (game != null && gameStats != null) {
- // Met à jour le temps total SI la partie était en cours
- if (currentGameState == GameFlowState.PLAYING) {
- gameStats.addPlayTime(System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs()); // Utilise méthode GameStats
- }
- saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS
- gameStats.saveStats(); // Sauvegarde toutes les stats via GameStats
- }
- }
-
- /** Sauvegarde le timestamp actuel comme dernier moment joué. */
- private void saveLastPlayedTime() {
- if (preferences != null) {
- preferences.edit().putLong(LAST_PLAYED_TIME_KEY, System.currentTimeMillis()).apply();
- }
- }
-
- @Override
- protected void onDestroy() { // Important de libérer SoundPool
- super.onDestroy();
- if (soundPool != null) {
- soundPool.release();
- soundPool = null;
- }
- }
-
- // --- Initialisation ---
/**
- * Récupère les références des vues du layout principal via leur ID.
- */
- private void findViews() {
- boardGridLayout = findViewById(R.id.gameBoard);
- currentScoreTextView = findViewById(R.id.scoreLabel);
- highestScoreTextView = findViewById(R.id.highScoreLabel);
- newGameButton = findViewById(R.id.restartButton);
- statisticsButton = findViewById(R.id.statsButton);
- menuButton = findViewById(R.id.menuButton);
- multiplayerButton = findViewById(R.id.multiplayerButton);
- statisticsViewStub = findViewById(R.id.statsViewStub);
- }
-
- /**
- * Initialise les objets Game et GameStats.
- * Charge l'état du jeu sauvegardé (s'il existe) et le meilleur score.
- * Met à jour l'interface utilisateur initiale.
- */
- private void initializeGameAndStats() {
- preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
- gameStats = new GameStats(this);
- loadNotificationPreference();
- loadSoundPreference();
- loadGame(); // Charge jeu et met à jour high score
- updateUI();
- if (game == null) {
- // Si loadGame échoue ou aucune sauvegarde, startNewGame gère l'initialisation
- startNewGame();
- }
- // L'état (currentGameState) est défini dans loadGame ou startNewGame
- }
-
- /** Initialise le SoundPool et charge les effets sonores. */
- private void initializeSoundPool() {
- // Configuration pour les effets sonores de jeu
- AudioAttributes attributes = new AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_GAME)
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build();
-
- // Crée le SoundPool
- soundPool = new SoundPool.Builder()
- .setMaxStreams(3) // Nombre max de sons joués simultanément
- .setAudioAttributes(attributes)
- .build();
-
- // Listener pour savoir quand les sons sont chargés
- soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> {
- if (status == 0) {
- // Vérifie si TOUS les sons sont chargés (ou gère individuellement)
- // Ici, on met juste un flag général pour simplifier
- soundPoolLoaded = true;
- }
- });
-
- // Charge les sons depuis res/raw
- // Le 3ème argument (priority) est 1 (priorité normale)
- try {
- 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);
- } catch (Exception e) {
- // Gérer l'erreur, peut-être désactiver le son
- soundEnabled = false;
- }
- }
-
- // --- Préférences Son ---
-
- /** Sauvegarde la préférence d'activation du son. */
- private void saveSoundPreference(boolean enabled) {
- if (preferences != null) {
- preferences.edit().putBoolean("sound_enabled", enabled).apply();
- }
- }
-
- /** Charge la préférence d'activation du son. */
- private void loadSoundPreference() {
- if (preferences != null) {
- soundEnabled = preferences.getBoolean("sound_enabled", true); // Son activé par défaut
- } else {
- soundEnabled = true;
- }
- }
-
- // --- Lecture Son ---
-
- /**
- * Joue un effet sonore si le SoundPool est chargé et si le son est activé.
- * @param soundId L'ID du son retourné par soundPool.load().
- */
- private void playSound(int soundId) {
- if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) {
- // Arguments: soundID, leftVolume, rightVolume, priority, loop, rate
- soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f);
- }
- }
-
- /** Crée le canal de notification nécessaire pour Android 8.0+. */
- private void createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- CharSequence name = getString(R.string.notification_channel_name);
- String description = getString(R.string.notification_channel_description);
- int importance = NotificationManager.IMPORTANCE_DEFAULT;
- NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance);
- channel.setDescription(description);
- // Enregistre le canal avec le système; ne peut pas être changé après ça
- NotificationManager notificationManager = getSystemService(NotificationManager.class);
- if (notificationManager != null) {
- notificationManager.createNotificationChannel(channel);
- }
- }
- }
-
- /**
- * Configure les listeners pour les boutons et le plateau de jeu (swipes).
- * Mise à jour pour le bouton Menu.
- */
- private void setupListeners() {
- newGameButton.setOnClickListener(v -> {
- v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
- showRestartConfirmationDialog();
- });
- statisticsButton.setOnClickListener(v -> {
- v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
- toggleStatistics();
- });
- // Modifié pour appeler la nouvelle méthode showMenu()
- menuButton.setOnClickListener(v -> {
- v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
- showMenu(); // Appelle la méthode du menu
- });
- multiplayerButton.setOnClickListener(v -> {
- v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
- showMultiplayerScreen(); // Affiche dialogue placeholder
- });
- setupSwipeListener();
- }
-
- // --- Mise à jour UI ---
-
- /**
- * Met à jour complètement l'interface utilisateur (plateau et scores).
- */
- private void updateUI() {
- if (game == null) return;
- updateBoard();
- updateScores();
- }
-
- /**
- * Redessine le plateau de jeu en créant/mettant à jour les TextViews des tuiles.
- */
- private void updateBoard() {
- boardGridLayout.removeAllViews();
- for (int row = 0; row < BOARD_SIZE; row++) {
- for (int col = 0; col < BOARD_SIZE; col++) {
- TextView tileTextView = new TextView(this);
- int value = game.getCellValue(row, col);
- setTileStyle(tileTextView, value);
- // Définit les LayoutParams pour que la tuile remplisse la cellule du GridLayout
- GridLayout.LayoutParams params = new GridLayout.LayoutParams();
- params.width = 0; params.height = 0; // Poids gère la taille
- params.rowSpec = GridLayout.spec(row, 1f); // Prend 1 fraction de l'espace en hauteur
- params.columnSpec = GridLayout.spec(col, 1f); // Prend 1 fraction de l'espace en largeur
- int margin = (int) getResources().getDimension(R.dimen.tile_margin);
- params.setMargins(margin, margin, margin, margin);
- tileTextView.setLayoutParams(params);
- boardGridLayout.addView(tileTextView);
- }
- }
- }
-
- /**
- * Met à jour les TextViews affichant le score courant et le meilleur score.
+ * Met à jour les TextViews affichant le score courant et le meilleur score global.
+ * Utilise les valeurs actuelles de l'objet {@link Game}.
*/
private void updateScores() {
- currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore()));
- highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore()));
+ 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 (fond, texte, taille) à une TextView représentant une tuile.
- * @param tileTextView La TextView de la tuile.
- * @param value La valeur numérique de la tuile (0 pour vide).
+ * 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).
+ *
+ * @param tileTextView La TextView de la tuile à styliser.
+ * @param value La valeur numérique de la tuile (0 pour une case vide).
*/
- private void setTileStyle(TextView tileTextView, int value) {
+ 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.setGravity(Gravity.CENTER);
- tileTextView.setTypeface(null, android.graphics.Typeface.BOLD);
- int backgroundColorId; int textColorId; int textSizeId;
+ tileTextView.setGravity(Gravity.CENTER); // Centre le texte
+ tileTextView.setTypeface(null, android.graphics.Typeface.BOLD); // Texte en gras
+
+ int backgroundColorId;
+ int textColorId;
+ int textSizeId;
+
+ // Sélectionne les couleurs et la taille de texte 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 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;
+ 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;
+ 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;
+ default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break; // 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));
}
@@ -519,34 +639,59 @@ public class MainActivity extends AppCompatActivity {
// --- Gestion des Actions Utilisateur ---
/**
- * Configure le listener pour détecter les swipes sur le plateau de jeu.
+ * 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}.
*/
- @SuppressLint("ClickableViewAccessibility")
+ @SuppressLint("ClickableViewAccessibility") // Nécessaire car setOnTouchListener est utilisé
private void setupSwipeListener() {
+ if (boardGridLayout == null) {
+ Log.e(TAG, "setupSwipeListener: boardGridLayout est null, impossible d'attacher le listener.");
+ return;
+ }
+ Log.d(TAG, "setupSwipeListener: Configuration du listener de swipe sur le plateau.");
boardGridLayout.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() {
- @Override public void onSwipeTop() { handleSwipe(Direction.UP); }
- @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); }
- @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); }
- @Override public void onSwipeRight() { handleSwipe(Direction.RIGHT); }
+ @Override public void onSwipeTop() { handleSwipe(Direction.UP); }
+ @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); }
+ @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); }
+ @Override public void onSwipeRight() { handleSwipe(Direction.RIGHT); }
}));
}
/**
- * Traite un swipe : met à jour la logique, synchronise l'affichage instantanément,
- * puis lance des animations locales d'apparition/fusion sur les tuiles concernées.
- * @param direction Direction du swipe.
+ * 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.
+ *
+ * @param direction La direction du swipe détecté ({@link Direction}).
*/
private void handleSwipe(Direction direction) {
- // Bloque si jeu terminé
+ Log.d(TAG, "handleSwipe: Geste détecté -> " + direction);
+
+ // Bloque les mouvements si le jeu n'est pas initialisé ou est terminé
if (game == null || gameStats == null || currentGameState == GameFlowState.GAME_OVER) {
+ Log.d(TAG, "handleSwipe: Mouvement ignoré (jeu non prêt ou terminé). State: " + currentGameState);
return;
}
- // Note: On ne bloque plus sur 'isAnimating' pour cette approche
+ // 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(); // État avant le push
+ int[][] boardBeforePush = game.getBoard(); // Copie profonde de l'état avant
- boolean boardChanged = false;
+ boolean boardChanged = false; // Indicateur si le mouvement a modifié le plateau logique
+
+ // Appelle la méthode logique correspondante dans Game
switch (direction) {
case UP: boardChanged = game.pushUp(); break;
case DOWN: boardChanged = game.pushDown(); break;
@@ -554,261 +699,454 @@ public class MainActivity extends AppCompatActivity {
case RIGHT: boardChanged = game.pushRight(); break;
}
+ // Si le mouvement a effectivement modifié quelque chose sur le plateau logique...
if (boardChanged) {
- playSound(soundMoveId);
- // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile
+ Log.d(TAG, "handleSwipe: Le plateau a changé.");
+ playSound(soundMoveId); // Joue le son de mouvement
+
+ // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile (utile pour animer les fusions)
int[][] boardAfterPush = game.getBoard();
- // Met à jour les stats générales
- gameStats.recordMove();
+ // --- Mise à jour des statistiques ---
+ gameStats.recordMove(); // Enregistre le mouvement
int scoreAfter = game.getCurrentScore();
- if (scoreAfter > scoreBefore) {
- playSound(soundMergeId);
- gameStats.recordMerge(1); // Simplifié
+ if (scoreAfter > scoreBefore) { // Si le score a augmenté, une fusion a eu lieu
+ Log.d(TAG, "handleSwipe: Fusion détectée (score " + scoreBefore + " -> " + scoreAfter + ")");
+ 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
if (scoreAfter > game.getHighestScore()) {
- game.setHighestScore(scoreAfter);
- gameStats.setHighestScore(scoreAfter);
+ 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);
}
}
+ // Met à jour la stat de la plus haute tuile atteinte globalement
gameStats.updateHighestTile(game.getHighestTileValue());
- // updateScores(); // Le score sera mis à jour par syncBoardView
- // Ajoute la nouvelle tuile dans la logique du jeu
+ // --- Ajout de la nouvelle tuile ---
+ Log.d(TAG, "handleSwipe: Ajout d'une nouvelle tuile...");
game.addNewTile();
- int[][] boardAfterAdd = game.getBoard(); // État final logique
+ int[][] boardAfterAdd = game.getBoard(); // État logique final après ajout
- // *** Synchronise l'affichage avec l'état final logique ***
- syncBoardView();
+ // --- Mise à jour VISUELLE du plateau ---
+ Log.d(TAG, "handleSwipe: Synchronisation de la vue du plateau...");
+ syncBoardView(); // Redessine TOUT le plateau basé sur boardAfterAdd
- // *** Lance les animations locales sur les vues mises à jour ***
- animateChanges(boardBeforePush, boardAfterPush, 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 est maintenant dans animateChanges->finalizeMove
- // pour s'assurer qu'elle est faite APRÈS les animations.
+ // La vérification de fin de partie (checkEndGameConditions) est maintenant appelée
+ // à la fin des animations dans animateChanges.
} else {
- // Mouvement invalide, on vérifie quand même si c'est la fin du jeu
+ // 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.
if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) {
- 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();
- gameStats.recordLoss(); gameStats.endGame(timeTaken);
- showGameOverDialog();
+ gameStats.recordLoss(); // Enregistre la défaite
+ // gameStats.endGame(timeTaken); // endGame est appelé par recordLoss/recordWin
+ showGameOverDialog(); // Affiche le dialogue de fin
}
}
}
/**
- * Identifie les tuiles qui ont fusionné ou sont apparues et lance des animations
- * simples (scale/alpha) sur les vues correspondantes DÉJÀ positionnées par syncBoardView.
- * @param boardBeforePush État avant le déplacement/fusion.
- * @param boardAfterPush État après déplacement/fusion, avant ajout nouvelle tuile.
- * @param boardAfterAdd État final après ajout nouvelle tuile.
+ * 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.
+ *
+ * @param soundId L'ID entier du son à jouer, tel que retourné par {@code soundPool.load()}.
+ * Doit être supérieur à 0 pour être valide.
*/
- private void animateChanges(int[][] boardBeforePush, int[][] boardAfterPush, int[][] boardAfterAdd) {
- List animations = new ArrayList<>();
+ private void playSound(int soundId) {
+ // Vérifie toutes les conditions avant de tenter de jouer le son
+ 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));
+ */
+ }
+ }
+ /**
+ * 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()}.
+ *
+ * @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 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
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
- TextView currentView = tileViews[r][c]; // Vue à la position finale (après syncBoardView)
- if (currentView == null) continue; // Pas de vue à animer ici
+ TextView currentView = tileViews[r][c]; // Récupère la vue TextView à la position finale [r][c]
- int valueAfterAdd = boardAfterAdd[r][c];
- int valueAfterPush = boardAfterPush[r][c]; // Valeur avant l'ajout
- int valueBeforePush = boardBeforePush[r][c]; // Valeur tout au début
+ // Si pas de vue à cette position (case vide dans l'état final), on passe
+ if (currentView == null) continue;
- // 1. Animation d'Apparition
- // Si la case était vide après le push, mais a une valeur maintenant (c'est la nouvelle tuile)
+ 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.
if (valueAfterPush == 0 && valueAfterAdd > 0) {
- //Log.d("AnimationDebug", "Animating APPEAR at ["+r+","+c+"]");
- currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f);
- Animator appear = createAppearAnimation(currentView);
- animations.add(appear);
+ // Log.d(TAG, "animateChanges: Animation APPARITION pour [" + r + "," + c + "]");
+ // Prépare la vue pour l'animation (petite et transparente)
+ 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 a changé PENDANT le push (valeur après push > valeur avant push)
- // ET que la case n'était pas vide avant (ce n'est pas un simple déplacement)
- else if (valueAfterPush > valueBeforePush && valueBeforePush != 0) {
- //Log.d("AnimationDebug", "Animating MERGE at ["+r+","+c+"]");
- Animator merge = createMergeAnimation(currentView);
- animations.add(merge);
+ // 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 :
+ 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;
}
- // Note : les tuiles qui ont simplement bougé ne sont pas animées ici.
- // Les tuiles qui ont disparu (fusionnées vers une autre case) sont gérées par syncBoardView qui les supprime.
+ // 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)
+ 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
if (!animations.isEmpty()) {
+ Log.d(TAG, "animateChanges: Démarrage de " + animations.size() + " animations.");
AnimatorSet animatorSet = new AnimatorSet();
- animatorSet.playTogether(animations);
+ animatorSet.playTogether(animations); // Joue toutes les animations en parallèle
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
- // finalizeMove n'est plus responsable du déblocage, mais vérifie la fin
+ // 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();
}
});
- animatorSet.start();
+ animatorSet.start(); // Démarre l'ensemble des animations
} else {
- // Si aucune animation n'a été générée (ex: mouvement sans fusion ni nouvelle tuile possible)
- checkEndGameConditions(); // Vérifie quand même la fin de partie
+ // 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
+ checkEndGameConditions();
}
}
- /** Crée une animation d'apparition (scale + alpha). */
- private Animator createAppearAnimation(View view) {
+ /**
+ * 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).
+ *
+ * @param view La vue (TextView de la tuile) à animer.
+ * @return Un objet {@link Animator} représentant l'animation d'apparition.
+ */
+ 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 apparition
- return set;
- }
-
- /** Crée une animation de 'pulse' pour une fusion. */
- private Animator createMergeAnimation(View view) {
- ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f);
- ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f);
- AnimatorSet set = new AnimatorSet();
- set.playTogether(scaleX, scaleY);
- set.setDuration(120); // Durée pulse fusion
+ set.setDuration(150); // Durée courte pour l'apparition (en ms)
return set;
}
/**
- * Vérifie si le jeu est gagné ou perdu et affiche le dialogue approprié.
- * Doit être appelé après la fin des animations potentielles.
+ * Crée une animation de "pulse" (léger grossissement puis retour à la normale)
+ * pour une vue (typiquement une tuile résultant d'une fusion).
+ *
+ * @param view La vue (TextView de la tuile fusionnée) à animer.
+ * @return Un objet {@link Animator} représentant l'animation de fusion.
*/
- private void checkEndGameConditions() {
- if (game == null || currentGameState == GameFlowState.GAME_OVER) return;
+ 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 scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f);
- if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) {
- currentGameState = GameFlowState.WON_DIALOG_SHOWN;
- long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
- gameStats.recordWin(timeTaken);
- showGameWonKeepPlayingDialog();
- // La notif est déjà envoyée dans handleSwipe si on la veut immédiate
- } else if (game.isGameOver()) {
- currentGameState = GameFlowState.GAME_OVER;
- long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
- gameStats.recordLoss();
- gameStats.endGame(timeTaken);
- showGameOverDialog();
- }
+ // 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)
+ return set;
}
- /** Énumération pour les directions de swipe. */
+ /**
+ * 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.
+ */
+ 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;
+ }
+
+ // 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
+ 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.");
+ }
+ // 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. */
private enum Direction { UP, DOWN, LEFT, RIGHT }
// --- Dialogues ---
/**
- * Affiche la boîte de dialogue demandant confirmation avant de redémarrer.
+ * 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`.
*/
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);
+ View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); // Gonfle le layout personnalisé
builder.setView(dialogView);
+ // Empêche de fermer en cliquant à l'extérieur
+ builder.setCancelable(false);
+
Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton);
Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton);
- final AlertDialog dialog = builder.create();
- cancelButton.setOnClickListener(v -> dialog.dismiss());
- confirmButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); });
- dialog.show();
+
+ final AlertDialog dialog = builder.create(); // Crée la dialogue mais ne l'affiche pas encore
+
+ // 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
+ confirmButton.setOnClickListener(v -> {
+ Log.d(TAG, "Dialogue redémarrage: Confirmé.");
+ dialog.dismiss(); // Ferme la dialogue
+ startNewGame(); // Lance une nouvelle partie
+ });
+
+ dialog.show(); // Affiche la dialogue
}
/**
- * Démarre une nouvelle partie logiquement et rafraîchit l'UI.
- * Réinitialise les stats de la partie en cours via `gameStats.startGame()`.
- * Ferme le panneau de statistiques s'il est ouvert.
+ * 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.
*/
private void startNewGame() {
- if (gameStats == null) gameStats = new GameStats(this); // Précaution si initialisation a échoué avant
- gameStats.startGame(); // Réinitialise stats de partie (temps, mouvements, etc.)
- game = new Game(); // Crée un nouveau jeu logique
- game.setHighestScore(gameStats.getOverallHighScore()); // Applique le meilleur score global au nouveau jeu
- currentGameState = GameFlowState.PLAYING; // Définit l'état à JOUER
+ 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
+ }
+ // Informe GameStats du début de la partie (reset timers/compteurs de partie)
+ gameStats.startGame();
+
+ // Crée une nouvelle instance de la logique du jeu
+ game = new Game();
+ // Applique le meilleur score global connu au nouvel objet Game
+ game.setHighestScore(gameStats.getOverallHighScore());
+
+ // Définit l'état du flux de jeu
+ currentGameState = GameFlowState.PLAYING;
// Ferme le panneau de statistiques s'il était ouvert
if (statisticsVisible) {
+ Log.d(TAG, "startNewGame: Fermeture du panneau de statistiques.");
toggleStatistics(); // Utilise la méthode existante pour masquer proprement
}
- syncBoardView();
-
- updateUI(); // Met à jour l'affichage (plateau, scores)
+ // Met à jour l'interface utilisateur pour afficher le nouveau plateau et les scores (0)
+ updateUI(); // Appellera syncBoardView et updateScores
}
/**
- * Affiche la boîte de dialogue quand 2048 est atteint, en utilisant un layout personnalisé.
- * Propose de continuer à jouer ou de commencer une 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.
*/
private void showGameWonKeepPlayingDialog() {
- playSound(soundWinId);
+ 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);
+ View dialogView = inflater.inflate(R.layout.dialog_game_won, null); // Gonfle le layout
builder.setView(dialogView);
- builder.setCancelable(false);
+ builder.setCancelable(false); // Empêche de fermer sans choisir une option
Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton);
- Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonWon);
+ Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonWon); // Renommé pour éviter conflit
+
final AlertDialog dialog = builder.create();
+ // Listener pour "Continuer à jouer"
keepPlayingButton.setOnClickListener(v -> {
- // L'état est déjà WON_DIALOG_SHOWN, on ne fait rien de spécial, le jeu continue.
+ 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();
});
- newGameButton.setOnClickListener(v -> {
+
+ // Listener pour "Nouvelle Partie"
+ newGameButtonDialog.setOnClickListener(v -> {
+ Log.d(TAG, "Dialogue victoire: Nouvelle partie.");
dialog.dismiss();
- startNewGame();
+ startNewGame(); // Démarre une nouvelle partie
});
+
dialog.show();
}
/**
- * Affiche la boîte de dialogue de fin de partie (plus de mouvements), en utilisant un layout personnalisé.
- * Propose Nouvelle Partie ou Quitter.
+ * 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.
*/
private void showGameOverDialog() {
- playSound(soundGameOverId);
+ Log.i(TAG, "Affichage dialogue Game Over. Score final: " + (game != null ? game.getCurrentScore() : "N/A"));
+ 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);
+ View dialogView = inflater.inflate(R.layout.dialog_game_over, null); // Gonfle le layout
builder.setView(dialogView);
- builder.setCancelable(false);
+ builder.setCancelable(false); // Empêche de fermer sans choisir
TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver);
- Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonGameOver);
+ Button newGameButtonDialog = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); // Renommé
Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver);
- messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore()));
+
+ // Affiche le message avec le score final
+ 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
+ }
+
final AlertDialog dialog = builder.create();
- newGameButton.setOnClickListener(v -> {
+ // Listener pour "Nouvelle Partie"
+ newGameButtonDialog.setOnClickListener(v -> {
+ Log.d(TAG, "Dialogue Game Over: Nouvelle partie.");
dialog.dismiss();
startNewGame();
});
+
+ // Listener pour "Quitter"
quitButton.setOnClickListener(v -> {
+ Log.d(TAG, "Dialogue Game Over: Quitter.");
dialog.dismiss();
- finish(); // Ferme l'application
+ finish(); // Ferme l'activité actuelle (et l'application si c'est la seule activité)
});
+
dialog.show();
}
// --- Menu Principal ---
/**
- * Affiche la boîte de dialogue du menu principal en utilisant un layout personnalisé.
- * Attache les listeners aux boutons pour déclencher les actions correspondantes.
+ * 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".
*/
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é
builder.setView(dialogView);
- builder.setCancelable(true);
+ builder.setCancelable(true); // Permet de fermer en cliquant à côté
// Récupère les boutons du layout personnalisé
Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay);
@@ -818,34 +1156,39 @@ public class MainActivity extends AppCompatActivity {
final AlertDialog dialog = builder.create();
- // Attache les listeners aux boutons
+ // 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 placeholder "Paramètres"
+ 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
+ dialog.show(); // Affiche la boîte de dialogue du menu
}
/**
- * Affiche une boîte de dialogue expliquant les règles du jeu,
- * en utilisant un layout personnalisé pour une meilleure présentation.
+ * 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
@@ -857,303 +1200,497 @@ public class MainActivity extends AppCompatActivity {
final AlertDialog dialog = builder.create();
- okButton.setOnClickListener(v -> dialog.dismiss()); // Ferme simplement
+ okButton.setOnClickListener(v -> {
+ Log.d(TAG, "Dialogue Comment Jouer: OK cliqué.");
+ dialog.dismiss(); // Ferme simplement
+ });
dialog.show();
}
/**
- * Affiche la boîte de dialogue des paramètres en utilisant un layout personnalisé.
- * Gère les interactions avec les différentes options.
+ * 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.
*/
private void showSettingsDialog() {
+ Log.d(TAG, "Affichage dialogue Paramètres.");
AlertDialog.Builder builder = new AlertDialog.Builder(this);
LayoutInflater inflater = getLayoutInflater();
- View dialogView = inflater.inflate(R.layout.dialog_settings, null);
- builder.setView(dialogView).setCancelable(true);
+ View dialogView = inflater.inflate(R.layout.dialog_settings, null); // Gonfle le layout
+ builder.setView(dialogView).setCancelable(true); // Permet de fermer en cliquant à côté
- // Vues
+ // Récupération des vues dans la dialogue
SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound);
- SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); // Activé
+ SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications);
Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions);
Button shareStatsButton = dialogView.findViewById(R.id.buttonShareStats);
Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats);
Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp);
Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings);
- // Ajout boutons de test (optionnel, pour débugger)
- Button testNotifHS = new Button(this); // Crée programmatiquement
- testNotifHS.setText(R.string.settings_test_notif_highscore);
- Button testNotifInactiv = new Button(this);
- testNotifInactiv.setText(R.string.settings_test_notif_inactivity);
- // Ajouter ces boutons au layout 'dialogView' si nécessaire (ex: ((LinearLayout)dialogView).addView(...) )
+ // Les boutons de test ont été retirés car non ajoutés au layout
final AlertDialog dialog = builder.create();
- // Config Son (MAINTENANT ACTIF)
- switchSound.setEnabled(true); // Activé
- switchSound.setChecked(soundEnabled); // État actuel chargé
+ // --- Configuration du Switch Son ---
+ switchSound.setChecked(soundEnabled); // Initialise avec l'état actuel
switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> {
- soundEnabled = isChecked; // Met à jour l'état
+ Log.d(TAG, "Paramètres: Switch Son changé à " + isChecked);
+ soundEnabled = isChecked; // Met à jour l'état interne
saveSoundPreference(isChecked); // Sauvegarde la préférence
Toast.makeText(this,
isChecked ? R.string.sound_enabled : R.string.sound_disabled,
Toast.LENGTH_SHORT).show();
+ // Jouer un petit son de test ici ? Optionnel.
});
- // Config Notifications (Activé + Gestion Permission)
- switchNotifications.setEnabled(true); // Activé
- switchNotifications.setChecked(notificationsEnabled); // État actuel
+ // --- Configuration du Switch Notifications ---
+ switchNotifications.setChecked(notificationsEnabled); // Initialise avec l'état actuel
switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ Log.d(TAG, "Paramètres: Switch Notifications changé à " + isChecked);
if (isChecked) {
// L'utilisateur VEUT activer les notifications
- requestNotificationPermission(); // Demande la permission si nécessaire
+ 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)
} else {
// L'utilisateur désactive les notifications
+ Log.d(TAG, "Paramètres: Désactivation des notifications.");
notificationsEnabled = false;
saveNotificationPreference(false);
Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show();
- // Ici, annuler les éventuelles notifications planifiées (WorkManager/AlarmManager)
+ // Arrêter le service de notification si l'utilisateur désactive
+ stopNotificationService();
+ // Annuler les éventuelles notifications planifiées (via WorkManager/AlarmManager si utilisé)
}
+ // 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 autres boutons (Permissions, Share, Reset, Quit, Close)
- permissionsButton.setOnClickListener(v -> { openAppSettings(); dialog.dismiss(); });
- shareStatsButton.setOnClickListener(v -> { shareStats(); dialog.dismiss(); });
- resetStatsButton.setOnClickListener(v -> { dialog.dismiss(); showResetStatsConfirmationDialog(); });
- quitAppButton.setOnClickListener(v -> { dialog.dismiss(); finishAffinity(); });
- closeButton.setOnClickListener(v -> dialog.dismiss());
+ // --- 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
+ });
- // Listeners boutons de test (si ajoutés)
- testNotifHS.setOnClickListener(v -> { showHighScoreNotification(gameStats.getOverallHighScore()); });
- testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); });
+ // 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();
}
- /** Met à jour l'état du switch notification (utile si permission refusée). */
+ /**
+ * 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.
+ *
+ * @param isEnabled L'état souhaité pour l'interrupteur (généralement false si permission refusée).
+ */
private void updateNotificationSwitchState(boolean isEnabled) {
- // Si la vue des paramètres est actuellement affichée, met à jour le switch
- View settingsDialogView = getLayoutInflater().inflate(R.layout.dialog_settings, null); // Attention, regonfler n'est pas idéal
- // Mieux: Garder une référence à la vue ou au switch si la dialog est affichée.
- // Pour la simplicité ici, on suppose qu'il faut rouvrir les paramètres pour voir le changement.
+ // 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);
+ }
}
- /** Sauvegarde la préférence d'activation des notifications. */
+ /**
+ * Sauvegarde la préférence utilisateur pour l'activation/désactivation des notifications
+ * dans les SharedPreferences.
+ *
+ * @param enabled true pour activer, false pour désactiver.
+ */
private void saveNotificationPreference(boolean enabled) {
if (preferences != null) {
- preferences.edit().putBoolean("notifications_enabled", enabled).apply();
+ 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 d'activation des notifications. */
+ /**
+ * Charge la préférence utilisateur pour l'activation/désactivation des notifications
+ * depuis les SharedPreferences. Par défaut à false si non définie.
+ */
private void loadNotificationPreference() {
if (preferences != null) {
- // Change le défaut de true à false
- notificationsEnabled = preferences.getBoolean("notifications_enabled", false);
+ // 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);
} 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.");
}
}
+ /**
+ * Sauvegarde la préférence utilisateur pour l'activation/désactivation des effets sonores
+ * dans les SharedPreferences.
+ *
+ * @param enabled true pour activer, false pour désactiver.
+ */
+ 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.");
+ }
+ }
- /** Ouvre les paramètres système de l'application. */
+ /**
+ * 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.
+ */
+ 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);
+ } 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.");
+ }
+ }
+
+ /**
+ * 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}.
+ */
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);
+ 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();
}
}
// --- Gestion des Permissions (Notifications) ---
- /** Demande la permission POST_NOTIFICATIONS si nécessaire (Android 13+). */
+ /**
+ * 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.
+ */
private void requestNotificationPermission() {
- // Vérifie si on est sur Android 13+
+ // Vérifie si on est sur Android 13 (TIRAMISU) ou plus
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 (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
- // Demande la permission
+ 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, on peut activer directement
+ // 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)
+ if (!notificationsEnabled) {
+ notificationsEnabled = true;
+ saveNotificationPreference(true);
+ Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
+ startNotificationService(); // Démarrer le service
+ }
+ }
+ } 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.
+ if (!notificationsEnabled) {
notificationsEnabled = true;
saveNotificationPreference(true);
Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
- // Planifier les notifications ici si ce n'est pas déjà fait
+ startNotificationService(); // Démarrer le service
}
- } else {
- // Sur les versions antérieures à Android 13, pas besoin de demander la permission
- notificationsEnabled = true;
- saveNotificationPreference(true);
- Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
- // Planifier les notifications ici
}
}
- // --- Logique de Notification ---
+ // --- Logique de Notification (Affichage principalement délégué à NotificationHelper) ---
/**
- * Crée l'Intent qui sera lancé au clic sur une notification (ouvre MainActivity).
- * @return PendingIntent configuré.
+ * 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é.
+ *
+ * @return Le {@link PendingIntent} configuré pour être attaché à la notification.
*/
private PendingIntent createNotificationTapIntent() {
Intent intent = new Intent(this, MainActivity.class);
- intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); // Ouvre l'app ou la ramène devant
- // FLAG_IMMUTABLE est requis pour Android 12+
- int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT;
+ // 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.
- * @param context Contexte.
- * @param title Titre de la notification.
- * @param message Corps du message de la notification.
- * @param notificationId ID unique pour cette notification.
+ * 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).
*/
private void showNotification(Context context, String title, String message, int notificationId) {
- // Vérifie si les notifications sont activées et si la permission est accordée (pour Android 13+)
- if (!notificationsEnabled || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
- ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)) {
- // Ne pas envoyer la notification si désactivé ou permission manquante
+ // Vérification globale avant d'appeler le Helper
+ if (!notificationsEnabled) {
+ Log.w(TAG, "showNotification: Notifications désactivées, notification [" + notificationId + "] non envoyée.");
+ return;
+ }
+ // Le Helper vérifiera aussi la permission pour API 33+
+ Log.d(TAG, "showNotification: Tentative d'affichage de la notification ID " + notificationId);
+ 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.
+ */
+ 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;
}
- NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône
- .setContentTitle(title)
- .setContentText(message)
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
- .setContentIntent(createNotificationTapIntent()) // Action au clic
- .setAutoCancel(true); // Ferme la notif après clic
-
- NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
- // L'ID notificationId est utilisé pour mettre à jour une notif existante ou en afficher une nouvelle
- notificationManager.notify(notificationId, builder.build());
- }
-
- /** Démarre le NotificationService s'il n'est pas déjà lancé. */
- private void startNotificationService() {
+ Log.i(TAG, "Démarrage du NotificationService.");
Intent serviceIntent = new Intent(this, NotificationService.class);
- // Utiliser startForegroundService pour API 26+ si le service doit faire qqch rapidement
- // mais pour une tâche périodique simple startService suffit.
- startService(serviceIntent);
+ 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);
+ }
}
- /** Arrête le NotificationService. */
+ /**
+ * Arrête le {@link NotificationService} s'il est en cours d'exécution.
+ */
private void stopNotificationService() {
+ Log.i(TAG, "Arrêt du NotificationService.");
Intent serviceIntent = new Intent(this, NotificationService.class);
stopService(serviceIntent);
}
- /** Affiche la notification d'accomplissement via le NotificationHelper. */
+ /**
+ * 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.
+ *
+ * @param tileValue La valeur de la tuile atteinte (ex: 2048).
+ */
private void showAchievementNotification(int tileValue) {
- // Vérifie l'état global avant d'envoyer (au cas où désactivé entre-temps)
- if (!notificationsEnabled) return;
+ if (!notificationsEnabled) return; // Vérification globale
+ Log.i(TAG, "Affichage notification d'accomplissement pour la tuile " + tileValue);
String title = getString(R.string.notification_title_achievement);
String message = getString(R.string.notification_text_achievement, tileValue);
- NotificationHelper.showNotification(this, title, message, 1); // ID 1 pour achievement
- }
-
- /** Affiche la notification de rappel du meilleur score (pour test). */
- private void showHighScoreNotification(int highScore) {
- String title = getString(R.string.notification_title_highscore);
- String message = getString(R.string.notification_text_highscore, highScore);
- // Utiliser un ID spécifique (ex: 2)
- showNotification(this, title, message, 2);
- // NOTE: La planification réelle utiliserait WorkManager/AlarmManager
- }
-
- /** Affiche la notification de rappel d'inactivité (pour test). */
- private void showInactivityNotification() {
- String title = getString(R.string.notification_title_inactivity);
- String message = getString(R.string.notification_text_inactivity);
- // Utiliser un ID spécifique (ex: 3)
- showNotification(this, title, message, 3);
- // NOTE: La planification réelle utiliserait WorkManager/AlarmManager et suivi du temps
+ // Utilise ID 1 (par convention définie dans NotificationService ou ici)
+ showNotification(this, title, message, 1);
}
/**
- * Crée et lance une Intent pour partager les statistiques du joueur.
+ * 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.
*/
private void shareStats() {
- if (gameStats == null) return;
+ if (gameStats == null) {
+ Log.w(TAG, "shareStats: Impossible de partager, GameStats est null.");
+ Toast.makeText(this, "Statistiques non disponibles.", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Log.i(TAG, "Préparation du partage des statistiques.");
- // Construit le message à partager
+ // Construit le message texte à partager
String shareBody = getString(R.string.share_stats_body,
gameStats.getOverallHighScore(),
gameStats.getHighestTile(),
gameStats.getNumberOfTimesObjectiveReached(),
- gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ?
+ gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? Utilisons started pour cohérence avec win %
GameStats.formatTime(gameStats.getTotalPlayTimeMs()),
gameStats.getTotalMoves()
);
+ // Crée l'Intent de partage
Intent shareIntent = new Intent(Intent.ACTION_SEND);
- shareIntent.setType("text/plain");
- shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject));
- shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody);
+ shareIntent.setType("text/plain"); // Type MIME pour du texte simple
+ 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
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();
}
}
/**
- * Affiche une boîte de dialogue pour confirmer la réinitialisation des statistiques.
+ * 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.
*/
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.");
resetStatistics(); // Appelle la méthode de réinitialisation
dialog.dismiss();
})
- .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss())
- .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement
+ // Bouton Négatif (Annuler)
+ .setNegativeButton(R.string.cancel, (dialog, which) -> {
+ Log.d(TAG, "Réinitialisation des statistiques annulée.");
+ dialog.dismiss();
+ })
+ .setCancelable(false) // Empêche de fermer sans choisir
.show();
}
/**
- * Réinitialise toutes les statistiques via GameStats et sauvegarde les changements.
- * Affiche une confirmation à l'utilisateur.
+ * 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.
*/
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
- gameStats.saveStats(); // Sauvegarde les stats réinitialisées
- // Met aussi à jour le highScore de l'objet Game courant (si une partie est en cours)
+ 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)
if(game != null){
- game.setHighestScore(gameStats.getOverallHighScore()); // Le HS est aussi reset dans GameStats
- updateScores(); // Rafraichit l'affichage du HS si visible
+ game.setHighestScore(gameStats.getOverallHighScore()); // Met à jour l'instance Game
+ updateScores(); // Rafraîchit l'affichage du HS si visible
}
+
Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show();
- // Si les stats étaient visibles, on pourrait vouloir les rafraîchir
+
+ // Si les statistiques étaient visibles, on les rafraîchit pour montrer les zéros
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" en utilisant un layout personnalisé,
- * incluant des informations sur l'application et un lien cliquable vers le site web.
+ * 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.
*/
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
builder.setView(dialogView);
- builder.setCancelable(true); // Permet de fermer
+ builder.setCancelable(true); // Permet de fermer en cliquant à côté
// Récupère les vues du layout
TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView);
@@ -1161,19 +1698,25 @@ public class MainActivity extends AppCompatActivity {
final AlertDialog dialog = builder.create();
- // Rend le lien cliquable pour ouvrir le navigateur
+ // Rend le lien du site web cliquable pour ouvrir le navigateur
websiteLinkTextView.setOnClickListener(v -> {
- Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_website_url)));
+ String url = getString(R.string.about_website_url);
+ Log.d(TAG, "Dialogue À Propos: Clic sur le lien du site web -> " + url);
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
try {
startActivity(browserIntent);
} 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();
}
});
// Bouton OK pour fermer la dialogue
- okButton.setOnClickListener(v -> dialog.dismiss());
+ okButton.setOnClickListener(v -> {
+ Log.d(TAG, "Dialogue À Propos: OK cliqué.");
+ dialog.dismiss();
+ });
dialog.show();
}
@@ -1182,37 +1725,67 @@ public class MainActivity extends AppCompatActivity {
// --- Gestion Stats UI ---
/**
- * Affiche ou masque le panneau de statistiques.
- * Gonfle le layout via ViewStub si c'est la première fois.
+ * 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.
*/
private void toggleStatistics() {
- statisticsVisible = !statisticsVisible;
+ statisticsVisible = !statisticsVisible; // Inverse l'état de visibilité
+ Log.d(TAG, "toggleStatistics: Visibilité changée à " + statisticsVisible);
+
if (statisticsVisible) {
- if (inflatedStatsView == null) { // Gonfle si pas encore fait
- inflatedStatsView = statisticsViewStub.inflate();
- // Attache listener au bouton Back une fois la vue gonflée
- Button backButton = inflatedStatsView.findViewById(R.id.backButton);
- backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back re-appelle toggle
+ // 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.");
+ 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é
+ 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 !");
+ }
+ } 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();
+ return;
+ }
}
- updateStatisticsTextViews(); // Remplit les champs avec les données actuelles
- inflatedStatsView.setVisibility(View.VISIBLE); // Affiche
- multiplayerButton.setVisibility(View.GONE); // Masque bouton multi
+ // Assure que la vue est visible et met à jour les données
+ if (inflatedStatsView != null) {
+ updateStatisticsTextViews(); // Remplit les champs avec les données actuelles
+ inflatedStatsView.setVisibility(View.VISIBLE); // Affiche le panneau
+ }
+ multiplayerButton.setVisibility(View.GONE); // Masque le bouton multijoueur quand les stats sont visibles
+
} else {
- if (inflatedStatsView != null) { // Masque si la vue existe
+ // Si on veut masquer les stats
+ if (inflatedStatsView != null) { // Masque le panneau s'il existe
inflatedStatsView.setVisibility(View.GONE);
}
- multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche bouton multi
+ multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche le bouton multijoueur
}
}
/**
- * Remplit les TextViews du panneau de statistiques avec les données de l'objet GameStats.
- * Doit être appelé seulement après que `inflatedStatsView` a été initialisé.
+ * 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.
*/
+ @SuppressLint("StringFormatInvalid") // Pour R.string.average_time_per_game_label potentiellement mal utilisé
private void updateStatisticsTextViews() {
- if (inflatedStatsView == null || gameStats == null) return;
+ 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
+ }
+ Log.d(TAG, "updateStatisticsTextViews: Mise à jour des labels de statistiques.");
- // Récupération des TextViews dans la vue gonflée
+ // Récupération des TextViews DANS la vue gonflée
+ // Utiliser findViewByID sur inflatedStatsView, pas sur l'activité !
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);
@@ -1227,120 +1800,227 @@ 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);
+ // 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); // Potentiel ID dupliqué dans layout?
+ TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // *** ATTENTION: ID POTENTIELLEMENT DUPLIQUÉ DANS LE LAYOUT XML ***
TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label);
TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label);
- TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game);
- // MAJ textes avec getters de gameStats
- highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore()));
- totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed()));
- totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted()));
- totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves()));
- currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves()));
- mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame()));
- totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges()));
- highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile()));
- objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached()));
- perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames()));
- multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon()));
- multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed()));
- multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak()));
- multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore()));
- totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses()));
- multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore()));
- // Calculs Pourcentages
- String winPercentage = (gameStats.getTotalGamesStarted() > 0) ? String.format("%.2f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) : "N/A";
- winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage));
- String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) ? String.format("%.2f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) : "N/A";
- multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate));
+ // --- Vérifier la nullité de chaque TextView avant de l'utiliser ---
+ // (Alternativement, utiliser un View Binding ou Data Binding pour plus de sécurité)
- // Calculs Temps
- totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs())));
- // Calcule le temps de la partie en cours seulement si elle n'est pas finie
- long currentDurationMs = 0;
- if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) {
- currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
+ // Mise à jour des textes avec les getters de gameStats + formatage
+ 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()));
+ if (totalMovesLabel != null) totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves()));
+ if (currentMovesLabel != null) currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves()));
+ if (mergesThisGameLabel != null) mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame()));
+ 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)
+ if (winPercentageLabel != null) {
+ String winPercentage = (gameStats.getTotalGamesStarted() > 0)
+ ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100)
+ : "N/A";
+ winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage));
+ }
+ if (multiplayerWinRateLabel != null) {
+ String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0)
+ ? String.format(java.util.Locale.US, "%.1f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100)
+ : "N/A";
+ multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate));
+ }
+
+ // Calculs et formatage des Temps
+ 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;
+ 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
+ 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.
+ 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
+ }
+ } else {
+ Log.w(TAG, "TextView pour R.id.average_time_per_game_label (multi) non trouvée.");
}
- currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs)));
- averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs())));
- averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); // Assurez-vous que l'ID R.string.average_time_per_game_label est correct
- bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A"));
- worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A"));
}
// --- Placeholders Multi ---
- /** Affiche un dialogue placeholder pour le multijoueur. */
+ /**
+ * Affiche une simple boîte de dialogue indiquant que la fonctionnalité multijoueur
+ * n'est pas encore disponible. Sert de placeholder pour le bouton "Multijoueur".
+ */
private void showMultiplayerScreen() {
+ Log.d(TAG, "Affichage dialogue placeholder Multijoueur.");
AlertDialog.Builder builder = new AlertDialog.Builder(this);
- builder.setTitle("Multijoueur").setMessage("Fonctionnalité multijoueur à venir !").setPositiveButton("OK", null);
+ builder.setTitle(R.string.multiplayer)
+ .setMessage("Fonctionnalité multijoueur à venir !")
+ .setPositiveButton(R.string.ok, null); // Bouton OK qui ferme simplement
builder.create().show();
}
// --- Sauvegarde / Chargement ---
- /** Sauvegarde l'état du jeu et le meilleur score via SharedPreferences. */
+ /**
+ * 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`.
+ */
private void saveGame() {
+ if (preferences == null) {
+ Log.e(TAG, "saveGame: SharedPreferences non initialisé, sauvegarde annulée.");
+ return;
+ }
SharedPreferences.Editor editor = preferences.edit();
if (game != null) {
- editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise Game (plateau + score courant)
- // Le meilleur score est géré et sauvegardé par GameStats, mais on le sauve aussi ici pour la synchro au chargement
+ 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.
editor.putInt(HIGH_SCORE_KEY, game.getHighestScore());
} else {
- editor.remove(GAME_STATE_KEY); // Optionnel: nettoyer si pas de jeu
+ Log.w(TAG, "saveGame: Instance 'game' est null, suppression de l'état sauvegardé.");
+ // Optionnel: nettoyer la clé si pas de jeu à sauvegarder
+ editor.remove(GAME_STATE_KEY);
}
- editor.apply(); // Utilise apply() pour une sauvegarde asynchrone
+ editor.apply(); // Sauvegarde asynchrone
}
- /** Charge l'état du jeu depuis SharedPreferences et synchronise le meilleur score. */
+ /**
+ * 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`.
+ */
private void loadGame() {
- String gameStateString = preferences.getString(GAME_STATE_KEY, null);
- // Charge le meilleur score depuis les préférences (sera aussi chargé par GameStats mais on l'utilise ici pour Game)
- int savedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0);
-
- // Assure que GameStats charge son état (y compris le HS global)
- if (gameStats == null) { gameStats = new GameStats(this); } // Précaution
- gameStats.loadStats(); // Charge explicitement les stats (ce qui devrait inclure le HS global)
- // S'assure que le HS chargé par gameStats est cohérent avec celui des prefs directes
- if (savedHighScore > gameStats.getOverallHighScore()) {
- gameStats.setHighestScore(savedHighScore); // Assure que GameStats a au moins le HS trouvé ici
- } else {
- savedHighScore = gameStats.getOverallHighScore(); // Utilise le HS de GameStats s'il est plus grand
+ if (preferences == null) {
+ Log.e(TAG, "loadGame: SharedPreferences non initialisé, chargement annulé.");
+ game = null;
+ return;
}
+ Log.d(TAG, "loadGame: Tentative de chargement de l'état du jeu.");
+ 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
+ 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é
+ }
+ // Prend le max entre le HS chargé directement et celui chargé par GameStats
+ int authoritativeHighScore = Math.max(loadedHighScore, gameStats.getOverallHighScore());
+ // S'assure que GameStats a bien la valeur la plus élevée
+ 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;
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é
if (loadedGame != null) {
+ Log.i(TAG, "loadGame: Désérialisation réussie.");
game = loadedGame;
- game.setHighestScore(savedHighScore); // Applique le HS synchronisé
- // Détermine l'état basé sur le jeu chargé
+ game.setHighestScore(authoritativeHighScore); // Applique le HS synchronisé
+
+ // Détermine l'état de jeu basé sur le plateau 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 dialog
+ // 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)");
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 -> Commence une nouvelle partie implicitement
- game = null; // Sera géré par l'appel à startNewGame dans initializeGameAndStats
+ // 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
}
}
-} // Fin MainActivity
\ No newline at end of file
+ /**
+ * Sauvegarde le timestamp actuel dans les SharedPreferences.
+ * Utilisé par {@link NotificationService} pour déterminer l'inactivité.
+ * Appelée dans `onPause`.
+ */
+ 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.");
+ }
+ }
+
+}
\ 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 7805e67..4f00892 100644
--- a/app/src/main/java/legion/muyue/best2048/NotificationHelper.java
+++ b/app/src/main/java/legion/muyue/best2048/NotificationHelper.java
@@ -1,4 +1,9 @@
-// Fichier NotificationHelper.java
+/**
+ * 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;
@@ -6,86 +11,143 @@ 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.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.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
-import androidx.core.content.ContextCompat; // Pour checkSelfPermission
+// import androidx.core.content.ContextCompat; // Peut être utilisé mais ActivityCompat est plus direct pour la permission ici
-/**
- * Classe utilitaire pour simplifier la création et l'affichage des notifications
- * et la gestion du canal de notification pour l'application Best 2048.
- */
public class NotificationHelper {
- /** Identifiant unique du canal de notification pour cette application. */
- public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant
+ /**
+ * 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+.
+ */
+ 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";
/**
- * Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur.
- * Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif).
- * Doit être appelée avant d'afficher la première notification sur API 26+.
+ * 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+.
*
- * @param context Contexte applicatif.
+ * @param context Le contexte applicatif ({@code Context}) utilisé pour accéder aux services système.
*/
- public static void createNotificationChannel(Context context) {
+ 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);
- int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut
+ // 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;
+ // Crée l'objet NotificationChannel
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
channel.setDescription(description);
- // Enregistre le canal auprès du système.
+ // 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.
- * Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher.
+ * 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.
*
- * @param context Contexte (peut être une Activity ou un Service).
- * @param title Titre de la notification.
- * @param message Contenu texte de la notification.
- * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler).
+ * @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.
*/
- public static void showNotification(Context context, String title, String message, int notificationId) {
- // Vérification de la permission pour Android 13+
+ 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) {
- if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
- // Si la permission n'est pas accordée, ne pas tenter d'afficher la notification.
- // L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode
- // si elle sait que l'utilisateur a activé les notifications dans les paramètres.
- System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée.");
+ // Utilise ActivityCompat.checkSelfPermission pour vérifier la permission
+ 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+.");
}
+ } else {
+ Log.d(TAG, "API < 33, permission POST_NOTIFICATIONS non requise pour afficher.");
}
- // Intent pour ouvrir MainActivity au clic
+ // --- Création de l'Intent pour le clic sur la notification ---
+ // Ouvre MainActivity lorsque l'utilisateur clique sur la notification.
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)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
- int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT;
- PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags);
+ // Flags pour le PendingIntent (requis : IMMUTABLE sur API 31+)
+ 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 pour la compatibilité
+ // --- Construction de la notification via NotificationCompat ---
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification
- .setContentTitle(title)
- .setContentText(message)
- .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale
- .setContentIntent(pendingIntent) // Action au clic
- .setAutoCancel(true); // Ferme la notification après le clic
+ .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é)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC); // Visible sur l'écran de verrouillage
- // Affichage de la notification
+ // --- Affichage de la notification ---
+ // Utilise NotificationManagerCompat pour la compatibilité
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 survenir même avec la vérification ci-dessus dans certains cas limites
- System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage());
+ // 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
index 897e4f7..26bdba2 100644
--- a/app/src/main/java/legion/muyue/best2048/NotificationService.java
+++ b/app/src/main/java/legion/muyue/best2048/NotificationService.java
@@ -1,4 +1,22 @@
-// Fichier NotificationService.java
+/**
+ * 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;
@@ -7,129 +25,227 @@ 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;
-/**
- * Service exécuté en arrière-plan pour envoyer des notifications périodiques
- * (rappel de meilleur score, rappel d'inactivité).
- * Utilise un Handler pour planifier les tâches répétitives.
- * NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée),
- * WorkManager serait préférable en production.
- */
public class NotificationService extends Service {
- private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs
- private static final int NOTIFICATION_ID_INACTIVITY = 3;
- // Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité)
- private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1);
- private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3);
- private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent
+ /** 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;
- // Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs)
+ // --- 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";
- private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé
+ /** 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();
- // Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue)
- // Pour des tâches plus lourdes, utiliser HandlerThread
+ 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());
- // Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage
- // NotificationHelper.createNotificationChannel(this);
+ // Définit le Runnable qui sera exécuté périodiquement
periodicTaskRunnable = new Runnable() {
@Override
public void run() {
- // Vérifie périodiquement s'il faut envoyer une notification
+ 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 tâche
- handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures
+ // 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) {
- // Lance la tâche périodique lors du démarrage du service
- handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon
- handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification
+ Log.i(TAG, "onStartCommand: Service démarré (ou redémarré). StartId: " + startId);
- // START_STICKY : Le système essaiera de recréer le service s'il est tué.
+ // 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) {
- // Service non lié (Started Service)
+ // Ce service n'est pas conçu pour être lié.
return null;
}
/**
- * Vérifie les conditions et envoie les notifications périodiques si nécessaire.
+ * 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);
- boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé
+ // 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) {
- // Si désactivé dans les prefs, on arrête potentiellement le service?
- // Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer.
- // stopSelf(); // Arrêterait le service
+ 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;
}
- // --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) ---
- // Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici)
- // Pour une vraie app, il faudrait une logique pour ne pas spammer.
- // Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ?
- int highScore = prefs.getInt(HIGH_SCORE_KEY, 0);
- // Temporairement on l'envoie à chaque check pour test (à modifier!)
- // if (shouldSendHighScoreNotification()) {
- showHighScoreNotificationNow(highScore);
- // }
+ long currentTime = System.currentTimeMillis();
-
- // --- Notification d'Inactivité ---
- long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0);
- if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) {
- // Si l'inactivité dépasse le seuil
- showInactivityNotificationNow();
- // Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ?
- // Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause.
+ // 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.");
}
- /** Affiche la notification High Score */
+ /**
+ * 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);
}
- /** Affiche la notification d'Inactivité */
+ /**
+ * 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);
}
- // Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques
- // private boolean shouldSendHighScoreNotification() { ... }
-
}
\ No newline at end of file
diff --git a/sortie.txt b/sortie.txt
new file mode 100644
index 0000000..f9f9922
--- /dev/null
+++ b/sortie.txt
@@ -0,0 +1,3570 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// Fichier Game.java
+/**
+ * Représente la logique métier du jeu 2048. Gère l'état du plateau de jeu,
+ * les déplacements, les fusions, le score de la partie en cours, et les conditions
+ * de victoire ou de défaite. Cette classe est conçue pour être indépendante du
+ * framework Android (pas de dépendance au Contexte ou aux SharedPreferences).
+ */
+package legion.muyue.best2048;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting; // Pour les méthodes de test éventuelles
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+public class Game {
+
+ /** Le plateau de jeu, une matrice 2D d'entiers. 0 représente une case vide. */
+ private int[][] board;
+ /** Générateur de nombres aléatoires pour l'ajout de nouvelles tuiles. */
+ private final Random randomNumberGenerator;
+ /** Score de la partie actuellement en cours. */
+ private int currentScore = 0;
+ /** Meilleur score global (reçu et stocké, mais non géré logiquement ici). */
+ private int highestScore = 0;
+ /** Taille du plateau de jeu (nombre de lignes/colonnes). */
+ private static final int BOARD_SIZE = 4;
+ /** Indicateur si la condition de victoire (>= 2048) a été atteinte. */
+ private boolean gameWon = false;
+ /** Indicateur si la partie est terminée (plus de mouvements possibles). */
+ private boolean gameOver = false;
+
+ /**
+ * Constructeur pour démarrer une nouvelle partie.
+ * Initialise un plateau vide, le score à 0, et ajoute deux tuiles initiales.
+ */
+ public Game() {
+ this.randomNumberGenerator = new Random();
+ initializeNewBoard();
+ }
+
+ /**
+ * 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 fourni.
+ *
+ * @param board Le plateau de jeu restauré.
+ * @param score Le score courant restauré.
+ */
+ public Game(int[][] board, int score) {
+ // Valider les dimensions du plateau fourni ? Pourrait être ajouté.
+ this.board = board;
+ this.currentScore = score;
+ this.randomNumberGenerator = new Random();
+ checkWinCondition();
+ checkGameOverCondition();
+ }
+
+ // --- Getters / Setters ---
+
+ /**
+ * Retourne la valeur de la tuile aux coordonnées spécifiées.
+ * @param row Ligne (0 à BOARD_SIZE-1).
+ * @param column Colonne (0 à BOARD_SIZE-1).
+ * @return Valeur de la tuile, ou 0 si les coordonnées sont invalides.
+ */
+ public int getCellValue(int row, int column) {
+ if (isIndexValid(row, column)) {
+ return this.board[row][column];
+ }
+ return 0; // Retourne 0 pour indice invalide
+ }
+
+ /**
+ * Définit la valeur d'une tuile aux coordonnées spécifiées.
+ * Ne fait rien si les coordonnées sont invalides.
+ * @param row Ligne (0 à BOARD_SIZE-1).
+ * @param col Colonne (0 à BOARD_SIZE-1).
+ * @param value Nouvelle valeur de la tuile.
+ */
+ public void setCellValue(int row, int col, int value) {
+ if (isIndexValid(row, col)) {
+ this.board[row][col] = value;
+ }
+ }
+
+ /** @return Le score actuel de la partie. */
+ public int getCurrentScore() { return currentScore; }
+
+ /** @return Le meilleur score connu par cet objet (défini via setHighestScore). */
+ public int getHighestScore() { return highestScore; }
+
+ /**
+ * Met à jour la valeur du meilleur score stockée dans cet objet Game.
+ * Typiquement appelé par la classe gérant la persistance (MainActivity).
+ * @param highScore Le meilleur score global à stocker.
+ */
+ public void setHighestScore(int highScore) { this.highestScore = highScore; }
+
+ /** @return true si une tuile 2048 (ou plus) a été atteinte, false sinon. */
+ public boolean isGameWon() { return gameWon; }
+
+ /** @return true si aucune case n'est vide ET aucun mouvement/fusion n'est possible, false sinon. */
+ public boolean isGameOver() { return gameOver; }
+
+ /** Met à jour la valeur de gameWon si partie gagné **/
+ private void setGameWon(boolean won) {this.gameWon = won;}
+
+ /** Met à jour la valeur de gameWon si partie gagné **/
+ private void setGameOver(boolean over) {this.gameOver = over;}
+
+ /**
+ * Retourne une copie profonde du plateau de jeu actuel.
+ * Utile pour la sérialisation ou pour éviter des modifications externes non désirées.
+ * @return Une nouvelle matrice 2D représentant l'état actuel du plateau.
+ */
+ public int[][] getBoard() {
+ int[][] copy = new int[BOARD_SIZE][BOARD_SIZE];
+ for(int i=0; i emptyCells = findEmptyCells();
+ if (!emptyCells.isEmpty()) {
+ int[] randomCell = emptyCells.get(randomNumberGenerator.nextInt(emptyCells.size()));
+ int value = generateRandomTileValue();
+ setCellValue(randomCell[0], randomCell[1], value);
+ }
+ }
+
+ /**
+ * Trouve toutes les cellules vides sur le plateau.
+ * @return Une liste de tableaux d'entiers `[row, col]` pour chaque cellule vide.
+ */
+ private List findEmptyCells() {
+ List emptyCells = new ArrayList<>();
+ for (int row = 0; row < BOARD_SIZE; row++) {
+ for (int col = 0; col < BOARD_SIZE; col++) {
+ if (board[row][col] == 0) {
+ emptyCells.add(new int[]{row, col});
+ }
+ }
+ }
+ return emptyCells;
+ }
+
+ /**
+ * Génère la valeur pour une nouvelle tuile en utilisant des probabilités prédéfinies.
+ * @return La valeur (2, 4, 8, ...).
+ */
+ private int generateRandomTileValue() {
+ int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour pourcentages fins
+ if (randomValue < 8540) return 2; // 85.40%
+ if (randomValue < 9740) return 4; // 12.00%
+ if (randomValue < 9940) return 8; // 2.00%
+ if (randomValue < 9990) return 16; // 0.50%
+ if (randomValue < 9995) return 32; // 0.05%
+ if (randomValue < 9998) return 64; // 0.03%
+ if (randomValue < 9999) return 128;// 0.01%
+ return 256; // 0.01%
+ }
+
+ /**
+ * Tente de déplacer et fusionner les tuiles vers le HAUT.
+ * Met à jour le score interne et vérifie les états win/gameOver.
+ * @return true si le plateau a été modifié, false sinon.
+ */
+ public boolean pushUp() { return processMove(MoveDirection.UP); }
+
+ /**
+ * Tente de déplacer et fusionner les tuiles vers le BAS.
+ * @return true si le plateau a été modifié, false sinon.
+ */
+ public boolean pushDown() { return processMove(MoveDirection.DOWN); }
+
+ /**
+ * Tente de déplacer et fusionner les tuiles vers la GAUCHE.
+ * @return true si le plateau a été modifié, false sinon.
+ */
+ public boolean pushLeft() { return processMove(MoveDirection.LEFT); }
+
+ /**
+ * Tente de déplacer et fusionner les tuiles vers la DROITE.
+ * @return true si le plateau a été modifié, false sinon.
+ */
+ public boolean pushRight() { return processMove(MoveDirection.RIGHT); }
+
+ /** Énumération interne pour clarifier le traitement des mouvements. */
+ 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.
+ * Contient la logique de base partagée par les méthodes pushX.
+ * @param direction La direction du mouvement.
+ * @return true si le plateau a été modifié, false sinon.
+ */
+ private boolean processMove(MoveDirection direction) {
+ boolean boardChanged = false;
+ // Itère sur l'axe perpendiculaire au mouvement
+ for (int i = 0; i < BOARD_SIZE; i++) {
+ boolean[] hasMerged = new boolean[BOARD_SIZE]; // Pour éviter double fusion sur l'axe de mouvement
+ // Itère sur l'axe du mouvement, dans le bon sens
+ 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;
+
+ for (int j = start; j != end; j += step) {
+ int row = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? j : i;
+ int col = (direction == MoveDirection.LEFT || direction == MoveDirection.RIGHT) ? j : i;
+
+ if (getCellValue(row, col) != 0) {
+ int currentValue = getCellValue(row, col);
+ int currentRow = row;
+ int currentCol = col;
+
+ // Calcule la position cible après déplacement dans les cases vides
+ int targetRow = currentRow;
+ int targetCol = currentCol;
+ int nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
+ int nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
+
+ while (isIndexValid(nextRow, nextCol) && getCellValue(nextRow, nextCol) == 0) {
+ targetRow = nextRow;
+ targetCol = nextCol;
+ nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
+ nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
+ }
+
+ // Déplace la tuile si sa position cible est différente
+ if (targetRow != currentRow || targetCol != currentCol) {
+ setCellValue(targetRow, targetCol, currentValue);
+ setCellValue(currentRow, currentCol, 0);
+ boardChanged = true;
+ }
+
+ // Vérifie la fusion potentielle avec la case suivante dans la direction du mouvement
+ int mergeTargetRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
+ int mergeTargetCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
+ int mergeIndex = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? mergeTargetRow : mergeTargetCol;
+
+ if (isIndexValid(mergeTargetRow, mergeTargetCol) &&
+ getCellValue(mergeTargetRow, mergeTargetCol) == currentValue &&
+ !hasMerged[mergeIndex])
+ {
+ int newValue = currentValue * 2;
+ setCellValue(mergeTargetRow, mergeTargetCol, newValue);
+ setCellValue(targetRow, targetCol, 0); // La tuile qui fusionne disparaît
+ currentScore += newValue;
+ hasMerged[mergeIndex] = true;
+ boardChanged = true;
+ }
+ }
+ }
+ }
+ // Vérifie les conditions de fin après chaque type de mouvement complet
+ checkWinCondition();
+ checkGameOverCondition();
+ return boardChanged;
+ }
+
+ /**
+ * Vérifie si les indices de ligne et colonne sont valides pour le plateau.
+ * @param row Ligne.
+ * @param col Colonne.
+ * @return true si les indices sont dans les limites [0, BOARD_SIZE-1].
+ */
+ 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) pour sauvegarde.
+ * Format: "val,val,...,val,score"
+ * @return Chaîne représentant l'état du jeu.
+ */
+ @NonNull
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ for (int row = 0; row < BOARD_SIZE; row++) {
+ for (int col = 0; col < BOARD_SIZE; col++) { sb.append(board[row][col]).append(","); }
+ }
+ sb.append(currentScore);
+ return sb.toString();
+ }
+
+ /**
+ * Crée un objet Game à partir de sa représentation sérialisée (plateau + score).
+ * Le meilleur score doit être défini séparément après la création.
+ * @param serializedState La chaîne issue de {@link #toString()}.
+ * @return Une nouvelle instance de Game, ou null en cas d'erreur de format.
+ */
+ public static Game deserialize(String serializedState) {
+ if (serializedState == null || serializedState.isEmpty()) return null;
+ String[] values = serializedState.split(",");
+ if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) return null; // +1 pour le score
+ int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; int index = 0;
+ try {
+ for (int row = 0; row < BOARD_SIZE; row++) {
+ for (int col = 0; col < BOARD_SIZE; col++) { newBoard[row][col] = Integer.parseInt(values[index++]); }
+ }
+ int score = Integer.parseInt(values[index]); // Le dernier élément est le score
+ return new Game(newBoard, score);
+ } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return null; }
+ }
+
+ /**
+ * Vérifie si la condition de victoire (une tuile >= 2048) est atteinte.
+ * Met à jour l'état interne `gameWon`.
+ */
+ private void checkWinCondition() {
+ if (!gameWon) {
+ for (int r=0; r= 2048) {
+ setGameWon(true); return;
+ }
+ }
+ }
+
+ /**
+ * Vérifie si la condition de fin de partie est atteinte (plateau plein ET aucun mouvement possible).
+ * Met à jour l'état interne `gameOver`.
+ */
+ private void checkGameOverCondition() {
+ if (hasEmptyCell()) { setGameOver(false); return; } // Pas game over si case vide
+ // Vérifie s'il existe au moins une fusion possible
+ for (int r=0; r0 && getCellValue(r-1,c)==current) || (r0 && getCellValue(r,c-1)==current) || (c pas game over
+ }
+ }
+ setGameOver(true); // Aucune case vide et aucune fusion -> game over
+ }
+
+ /**
+ * Vérifie si le plateau contient au moins une case vide (valeur 0).
+ * @return true si une case vide existe, false sinon.
+ */
+ private boolean hasEmptyCell() {
+ for (int r=0; r maxTile) maxTile = board[r][c];
+ return maxTile;
+ }
+}// Fichier GameStats.java
+/**
+ * Gère la collecte, la persistance (via SharedPreferences) et l'accès
+ * aux statistiques du jeu 2048, pour les modes solo et multijoueur (si applicable).
+ */
+package legion.muyue.best2048;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.SharedPreferences;
+import java.util.concurrent.TimeUnit;
+
+public class GameStats {
+
+ // --- Constantes pour SharedPreferences ---
+ private static final String PREFS_NAME = "Best2048_Prefs";
+ private static final String HIGH_SCORE_KEY = "high_score"; // Clé partagée avec Game/MainActivity
+ // Clés spécifiques aux statistiques
+ private static final String STATS_TOTAL_GAMES_PLAYED = "totalGamesPlayed";
+ private static final String STATS_TOTAL_GAMES_STARTED = "totalGamesStarted";
+ private static final String STATS_TOTAL_MOVES = "totalMoves";
+ private static final String STATS_TOTAL_PLAY_TIME_MS = "totalPlayTimeMs";
+ private static final String STATS_TOTAL_MERGES = "totalMerges";
+ private static final String STATS_HIGHEST_TILE = "highestTile";
+ private static final String STATS_OBJECTIVE_REACHED_COUNT = "numberOfTimesObjectiveReached";
+ private static final String STATS_PERFECT_GAMES = "perfectGames";
+ private static final String STATS_BEST_WINNING_TIME_MS = "bestWinningTimeMs";
+ private static final String STATS_WORST_WINNING_TIME_MS = "worstWinningTimeMs";
+ // ... (autres clés stats) ...
+ private static final String STATS_MP_GAMES_WON = "multiplayerGamesWon";
+ private static final String STATS_MP_GAMES_PLAYED = "multiplayerGamesPlayed";
+ private static final String STATS_MP_BEST_WINNING_STREAK = "multiplayerBestWinningStreak";
+ private static final String STATS_MP_TOTAL_SCORE = "multiplayerTotalScore";
+ private static final String STATS_MP_TOTAL_TIME_MS = "multiplayerTotalTimeMs";
+ private static final String STATS_MP_LOSSES = "totalMultiplayerLosses";
+ private static final String STATS_MP_HIGH_SCORE = "multiplayerHighScore";
+
+
+ // --- Champs de Statistiques ---
+ // Générales & Solo
+ private int totalGamesPlayed;
+ private int totalGamesStarted;
+ private int totalMoves;
+ private long totalPlayTimeMs;
+ private int totalMerges;
+ private int highestTile;
+ private int numberOfTimesObjectiveReached; // Nombre de victoires (>= 2048)
+ private int perfectGames; // Concept non défini ici
+ private long bestWinningTimeMs;
+ private long worstWinningTimeMs;
+ private int overallHighScore; // Meilleur score global
+
+ // Partie en cours (non persistées telles quelles)
+ private int currentMoves;
+ private long currentGameStartTimeMs;
+ private int mergesThisGame;
+
+ // Multijoueur
+ private int multiplayerGamesWon;
+ private int multiplayerGamesPlayed;
+ private int multiplayerBestWinningStreak;
+ private long multiplayerTotalScore;
+ private long multiplayerTotalTimeMs;
+ private int totalMultiplayerLosses;
+ private int multiplayerHighestScore;
+
+ /** Contexte nécessaire pour accéder aux SharedPreferences. */
+ private final Context context;
+
+ /**
+ * Constructeur. Initialise l'objet et charge immédiatement les statistiques
+ * depuis les SharedPreferences.
+ * @param context Contexte de l'application.
+ */
+ public GameStats(Context context) {
+ this.context = context.getApplicationContext(); // Utilise le contexte applicatif
+ loadStats();
+ }
+
+ // --- Persistance (SharedPreferences) ---
+
+ /**
+ * Charge toutes les statistiques persistantes depuis les SharedPreferences.
+ * Appelé par le constructeur.
+ */
+ public void loadStats() {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ overallHighScore = prefs.getInt(HIGH_SCORE_KEY, 0);
+ totalGamesPlayed = prefs.getInt(STATS_TOTAL_GAMES_PLAYED, 0);
+ totalGamesStarted = prefs.getInt(STATS_TOTAL_GAMES_STARTED, 0);
+ // ... (chargement de toutes les autres clés persistantes) ...
+ totalMoves = prefs.getInt(STATS_TOTAL_MOVES, 0);
+ totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0);
+ 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);
+ bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme défaut pour 'best'
+ worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0);
+ 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);
+ multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0);
+ multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0);
+ totalMultiplayerLosses = prefs.getInt(STATS_MP_LOSSES, 0);
+ multiplayerHighestScore = prefs.getInt(STATS_MP_HIGH_SCORE, 0);
+ }
+
+ /**
+ * Sauvegarde toutes les statistiques persistantes dans les SharedPreferences.
+ * Appelé typiquement dans `onPause` de l'activité.
+ */
+ public void saveStats() {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putInt(HIGH_SCORE_KEY, overallHighScore); // Sauvegarde le HS global
+ editor.putInt(STATS_TOTAL_GAMES_PLAYED, totalGamesPlayed);
+ editor.putInt(STATS_TOTAL_GAMES_STARTED, totalGamesStarted);
+ // ... (sauvegarde de toutes les autres clés persistantes) ...
+ editor.putInt(STATS_TOTAL_MOVES, totalMoves);
+ editor.putLong(STATS_TOTAL_PLAY_TIME_MS, totalPlayTimeMs);
+ 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);
+ editor.putLong(STATS_BEST_WINNING_TIME_MS, bestWinningTimeMs);
+ editor.putLong(STATS_WORST_WINNING_TIME_MS, worstWinningTimeMs);
+ editor.putInt(STATS_MP_GAMES_WON, multiplayerGamesWon);
+ editor.putInt(STATS_MP_GAMES_PLAYED, multiplayerGamesPlayed);
+ editor.putInt(STATS_MP_BEST_WINNING_STREAK, multiplayerBestWinningStreak);
+ editor.putLong(STATS_MP_TOTAL_SCORE, multiplayerTotalScore);
+ editor.putLong(STATS_MP_TOTAL_TIME_MS, multiplayerTotalTimeMs);
+ editor.putInt(STATS_MP_LOSSES, totalMultiplayerLosses);
+ editor.putInt(STATS_MP_HIGH_SCORE, multiplayerHighestScore);
+
+ editor.apply(); // Applique les changements de manière asynchrone
+ }
+
+ // --- Méthodes de Mise à Jour des Statistiques ---
+
+ /**
+ * Doit être appelée au début de chaque nouvelle partie.
+ * Incrémente le compteur de parties démarrées et réinitialise les stats de la partie en cours.
+ */
+ public void startGame() {
+ totalGamesStarted++;
+ currentMoves = 0;
+ mergesThisGame = 0;
+ currentGameStartTimeMs = System.currentTimeMillis();
+ }
+
+ /**
+ * Enregistre un mouvement réussi (qui a modifié le plateau).
+ */
+ public void recordMove() {
+ currentMoves++;
+ totalMoves++;
+ }
+
+ /**
+ * Enregistre une ou plusieurs fusions survenues lors d'un mouvement.
+ * @param numberOfMerges Le nombre de fusions (si connu, sinon 1 par défaut).
+ */
+ public void recordMerge(int numberOfMerges) {
+ if (numberOfMerges > 0) {
+ mergesThisGame += numberOfMerges;
+ totalMerges += numberOfMerges;
+ }
+ }
+
+ /**
+ * Met à jour la statistique de la plus haute tuile atteinte globalement.
+ * @param tileValue La valeur de la tuile la plus haute de la partie en cours.
+ */
+ public void updateHighestTile(int tileValue) {
+ if (tileValue > this.highestTile) {
+ this.highestTile = tileValue;
+ }
+ }
+
+ /**
+ * Enregistre une victoire et met à jour les temps associés.
+ * @param timeTakenMs Temps écoulé pour cette partie gagnante.
+ */
+ public void recordWin(long timeTakenMs) {
+ numberOfTimesObjectiveReached++;
+ if (timeTakenMs < bestWinningTimeMs) { bestWinningTimeMs = timeTakenMs; }
+ if (timeTakenMs > worstWinningTimeMs) { worstWinningTimeMs = timeTakenMs; }
+ endGame(timeTakenMs); // Finalise aussi le temps total et parties jouées
+ }
+
+ /**
+ * Enregistre une défaite.
+ */
+ public void recordLoss() {
+ // Calcule le temps écoulé avant de finaliser
+ endGame(System.currentTimeMillis() - currentGameStartTimeMs);
+ }
+
+ /**
+ * Finalise les statistiques générales à la fin d'une partie (victoire ou défaite).
+ * @param timeTakenMs Temps total de la partie terminée.
+ */
+ public void endGame(long timeTakenMs) {
+ totalGamesPlayed++;
+ addPlayTime(timeTakenMs);
+ }
+
+ /**
+ * Ajoute une durée (en ms) au temps de jeu total enregistré.
+ * Typiquement appelé dans `onPause`.
+ * @param durationMs Durée à ajouter.
+ */
+ public void addPlayTime(long durationMs) {
+ if (durationMs > 0) {
+ this.totalPlayTimeMs += durationMs;
+ }
+ }
+
+ /**
+ * Réinitialise toutes les statistiques (solo, multijoueur, high score global)
+ * à leurs valeurs par défaut.
+ */
+ public void resetStats() {
+ // Réinitialise toutes les variables membres à 0 ou valeur initiale
+ overallHighScore = 0;
+ totalGamesPlayed = 0;
+ totalGamesStarted = 0;
+ totalMoves = 0;
+ totalPlayTimeMs = 0;
+ totalMerges = 0;
+ highestTile = 0;
+ numberOfTimesObjectiveReached = 0;
+ perfectGames = 0;
+ bestWinningTimeMs = Long.MAX_VALUE;
+ worstWinningTimeMs = 0;
+
+ multiplayerGamesWon = 0;
+ multiplayerGamesPlayed = 0;
+ multiplayerBestWinningStreak = 0;
+ multiplayerTotalScore = 0;
+ multiplayerTotalTimeMs = 0;
+ totalMultiplayerLosses = 0;
+ multiplayerHighestScore = 0;
+
+ saveStats();
+ }
+
+ // --- Getters pour l'affichage ---
+ public int getTotalGamesPlayed() { return totalGamesPlayed; }
+ public int getTotalGamesStarted() { return totalGamesStarted; }
+ public int getTotalMoves() { return totalMoves; }
+ public int getCurrentMoves() { return currentMoves; }
+ public long getTotalPlayTimeMs() { return totalPlayTimeMs; }
+ public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } // Utile pour calcul durée en cours
+ public int getMergesThisGame() { return mergesThisGame; }
+ public int getTotalMerges() { return totalMerges; }
+ public int getHighestTile() { return highestTile; }
+ public int getNumberOfTimesObjectiveReached() { return numberOfTimesObjectiveReached; }
+ public int getPerfectGames() { return perfectGames; }
+ public long getBestWinningTimeMs() { return bestWinningTimeMs; }
+ public long getWorstWinningTimeMs() { return worstWinningTimeMs; }
+ public int getOverallHighScore() { return overallHighScore; } // Getter pour HS global
+ // Getters Multiplayer
+ public int getMultiplayerGamesWon() { return multiplayerGamesWon; }
+ public int getMultiplayerGamesPlayed() { return multiplayerGamesPlayed; }
+ public int getMultiplayerBestWinningStreak() { return multiplayerBestWinningStreak; }
+ public long getMultiplayerTotalScore() { return multiplayerTotalScore; }
+ public long getMultiplayerTotalTimeMs() { return multiplayerTotalTimeMs; }
+ public int getTotalMultiplayerLosses() { return totalMultiplayerLosses; }
+ public int getMultiplayerHighestScore() { return multiplayerHighestScore; }
+
+ // --- Setters ---
+ /** Met à jour la valeur interne du high score global. */
+ public void setHighestScore(int highScore) {
+ // Met à jour si la nouvelle valeur est meilleure
+ if (highScore > this.overallHighScore) {
+ this.overallHighScore = highScore;
+ // La sauvegarde se fait via saveStats() globalement
+ }
+ }
+ /** Définit le timestamp de début de la partie en cours. */
+ public void setCurrentGameStartTimeMs(long timeMs) { this.currentGameStartTimeMs = timeMs; }
+
+ // --- Méthodes Calculées ---
+ /** @return Temps moyen par partie terminée en millisecondes. */
+ public long getAverageGameTimeMs() { return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0; }
+ /** @return Score moyen par partie multijoueur terminée. */
+ public int getMultiplayerAverageScore() { return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; }
+ /** @return Temps moyen par partie multijoueur terminée en millisecondes. */
+ public long getMultiplayerAverageTimeMs() { return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0; }
+
+ /**
+ * Formate une durée en millisecondes en chaîne "hh:mm:ss" ou "mm:ss".
+ * @param milliseconds Durée en millisecondes.
+ * @return Chaîne de temps formatée.
+ */
+ @SuppressLint("DefaultLocale")
+ public static String formatTime(long milliseconds) {
+ long hours = TimeUnit.MILLISECONDS.toHours(milliseconds);
+ long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60;
+ long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60;
+ if (hours > 0) { return String.format("%02d:%02d:%02d", hours, minutes, seconds); }
+ else { return String.format("%02d:%02d", minutes, seconds); }
+ }
+}// Fichier MainActivity.java
+/**
+ * Activité principale de l'application 2048.
+ * Gère l'interface utilisateur (plateau, scores, boutons), coordonne les interactions
+ * avec la logique du jeu (classe Game) et la gestion des statistiques (classe GameStats).
+ * Gère également le cycle de vie de l'application et la persistance de l'état du jeu.
+ */
+package legion.muyue.best2048;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ActivityNotFoundException;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.provider.Settings;
+import com.google.android.material.switchmaterial.SwitchMaterial;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewStub;
+import android.view.animation.AnimationUtils;
+import android.widget.TextView;
+import androidx.activity.EdgeToEdge;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+import androidx.gridlayout.widget.GridLayout;
+import android.widget.Button;
+import android.widget.Toast;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.view.ViewTreeObserver;
+import android.media.AudioAttributes; // Pour SoundPool
+import android.media.SoundPool; // Pour SoundPool
+
+import java.util.ArrayList;
+import java.util.List;
+
+
+public class MainActivity extends AppCompatActivity {
+
+ // --- UI Elements ---
+ private GridLayout boardGridLayout;
+ private TextView currentScoreTextView;
+ private TextView highestScoreTextView;
+ private Button newGameButton;
+ private Button multiplayerButton;
+ private Button statisticsButton;
+ private Button menuButton;
+ private ViewStub statisticsViewStub;
+ private View inflatedStatsView;
+
+ // --- Game Logic & Stats ---
+ private Game game;
+ private GameStats gameStats;
+ private static final int BOARD_SIZE = 4;
+ private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL";
+ private boolean notificationsEnabled = false;
+ private static final String LAST_PLAYED_TIME_KEY = "last_played_time";
+
+ // --- State Management ---
+ private boolean statisticsVisible = false;
+ private enum GameFlowState { PLAYING, WON_DIALOG_SHOWN, GAME_OVER }
+ private GameFlowState currentGameState = GameFlowState.PLAYING;
+
+ /** Références aux TextViews des tuiles actuellement affichées. */
+ private TextView[][] tileViews = new TextView[BOARD_SIZE][BOARD_SIZE];
+
+ // --- Preferences ---
+ private SharedPreferences preferences;
+ private static final String PREFS_NAME = "Best2048_Prefs";
+ private static final String HIGH_SCORE_KEY = "high_score";
+ private static final String GAME_STATE_KEY = "game_state";
+
+ // --- Champs Son ---
+ private SoundPool soundPool;
+ private int soundMoveId = -1; // Initialise à -1 pour savoir s'ils sont chargés
+ private int soundMergeId = -1;
+ private int soundWinId = -1;
+ private int soundGameOverId = -1;
+ private boolean soundPoolLoaded = false; // Flag pour savoir si les sons sont prêts
+ private boolean soundEnabled = true; // Son activé par défaut
+
+ // --- Activity Lifecycle ---
+
+ private final ActivityResultLauncher requestPermissionLauncher =
+ registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+ if (isGranted) {
+ // La permission est accordée. On peut activer/planifier les notifications.
+ notificationsEnabled = true;
+ saveNotificationPreference(true);
+ Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
+ // Ici, on pourrait (re)planifier les notifications périodiques avec WorkManager/AlarmManager
+ } else {
+ // La permission est refusée. L'utilisateur ne recevra pas de notifications.
+ notificationsEnabled = false;
+ saveNotificationPreference(false);
+ // Désactive le switch dans les paramètres si l'utilisateur vient de refuser
+ updateNotificationSwitchState(false);
+ Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show();
+ // Afficher une explication si nécessaire
+ // showNotificationPermissionRationale();
+ }
+ });
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ EdgeToEdge.enable(this);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+ NotificationHelper.createNotificationChannel(this);
+ findViews();
+ initializeSoundPool();
+ initializeGameAndStats();
+ setupListeners();
+ if (notificationsEnabled) {
+ startNotificationService();
+ }
+ }
+
+ /**
+ * Synchronise COMPLETEMENT le GridLayout avec l'état actuel de 'game.board'.
+ * Étape 1: Ajoute 16 vues de fond pour "fixer" la structure de la grille.
+ * Étape 2: Ajoute les TextViews des tuiles réelles (valeur > 0) par-dessus.
+ * Stocke les références des tuiles réelles dans tileViews.
+ */
+ private void syncBoardView() {
+ // Vérifications de sécurité
+ if (game == null || boardGridLayout == null) {
+ System.err.println("syncBoardView: Game ou GridLayout est null !");
+ return;
+ }
+ // Log.d("SyncDebug", "Syncing board view (Background + Tiles)...");
+
+ // --- Réinitialisation ---
+ boardGridLayout.removeAllViews(); // Vide complètement le GridLayout visuel
+ // Réinitialise le tableau qui stocke les références aux *vraies* tuiles (pas les fonds)
+ 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 une seule fois
+ int gridMargin = (int) getResources().getDimension(R.dimen.tile_margin);
+
+ // --- Étape 1: Ajouter les 16 vues de fond pour définir la grille ---
+ for (int r = 0; r < BOARD_SIZE; r++) {
+ for (int c = 0; c < BOARD_SIZE; c++) {
+ // Utiliser un simple View pour le fond est suffisant
+ View backgroundCell = new View(this);
+
+ // Appliquer le style d'une cellule vide
+ // Utilise le même drawable que les tuiles pour les coins arrondis, mais avec la couleur de fond vide
+ backgroundCell.setBackgroundResource(R.drawable.tile_background);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ // Utiliser setTintList pour la compatibilité et éviter de recréer des drawables
+ backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty));
+ } else {
+ // Pour les versions plus anciennes, une approche différente pourrait être nécessaire si setTintList n'est pas dispo
+ // ou utiliser un drawable spécifique pour le fond. Ici on suppose API 21+ pour setTintList.
+ // Alternative simple mais moins propre : backgroundCell.setBackgroundColor(ContextCompat.getColor(this, R.color.tile_empty)); (perd les coins arrondis)
+ }
+
+
+ // Définir les LayoutParams pour positionner cette vue de fond dans la grille
+ GridLayout.LayoutParams params = new GridLayout.LayoutParams();
+ params.width = 0;
+ params.height = 0;
+ // 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);
+ backgroundCell.setLayoutParams(params);
+
+ // Ajouter cette vue de fond au GridLayout
+ boardGridLayout.addView(backgroundCell);
+ }
+ }
+
+ // --- Étape 2: Ajouter les TextViews des tuiles réelles (par-dessus les fonds) ---
+ 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
+ if (value > 0) {
+ // Crée la TextView stylisée pour cette tuile via notre méthode helper
+ // createTileTextView utilise les mêmes LayoutParams (row, col, span 1, weight 1, margins)
+ TextView tileTextView = createTileTextView(value, r, c);
+
+ // Stocke la référence à cette TextView de tuile (pas la vue de fond)
+ tileViews[r][c] = tileTextView;
+
+ // Ajoute la TextView de la tuile au GridLayout.
+ // Comme elle est ajoutée après la vue de fond pour la même cellule (r,c)
+ // et utilise les mêmes paramètres de positionnement, elle s'affichera par-dessus.
+ boardGridLayout.addView(tileTextView);
+ // Log.d("SyncDebug", "Added TILE view at ["+r+","+c+"] with value "+value);
+ }
+ }
+ }
+ // Log.d("SyncDebug", "Board sync finished.");
+
+ // Met à jour l'affichage textuel des scores
+ updateScores();
+ }
+
+ /**
+ * Crée et configure une TextView pour une tuile.
+ * @param value Valeur de la tuile.
+ * @param row Ligne.
+ * @param col Colonne.
+ * @return La TextView configurée.
+ */
+ private TextView createTileTextView(int value, int row, int col) {
+ TextView tileTextView = new TextView(this);
+ setTileStyle(tileTextView, value); // Applique le style visuel
+
+ GridLayout.LayoutParams params = new GridLayout.LayoutParams();
+ params.width = 0; // Largeur gérée par GridLayout basé sur la colonne/poids
+ params.height = 0; // Hauteur gérée par GridLayout basé sur la ligne/poids
+
+ // --- Modification : Spécifier explicitement le span (taille) à 1 ---
+ // Utilisation de spec(start, size, weight)
+ params.rowSpec = GridLayout.spec(row, 1, 1f); // Commence à 'row', occupe 1 ligne, poids 1
+ params.columnSpec = GridLayout.spec(col, 1, 1f); // Commence à 'col', occupe 1 colonne, poids 1
+ // --- Fin Modification ---
+
+ int margin = (int) getResources().getDimension(R.dimen.tile_margin);
+ params.setMargins(margin, margin, margin, margin);
+ tileTextView.setLayoutParams(params);
+
+ return tileTextView;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ // Redémarre le timer seulement si le jeu est en cours (pas gagné/perdu)
+ if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING) {
+ gameStats.setCurrentGameStartTimeMs(System.currentTimeMillis());
+ }
+ // Gère le réaffichage potentiel des stats si l'activité reprend
+ if (statisticsVisible) {
+ if (inflatedStatsView != null) { // Si déjà gonflé
+ updateStatisticsTextViews(); // Met à jour les données affichées
+ inflatedStatsView.setVisibility(View.VISIBLE);
+ multiplayerButton.setVisibility(View.GONE);
+ } else {
+ // Si pas encore gonflé (cas rare mais possible), on le fait afficher
+ toggleStatistics();
+ }
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ // Sauvegarde l'état et les stats si le jeu existe
+ if (game != null && gameStats != null) {
+ // Met à jour le temps total SI la partie était en cours
+ if (currentGameState == GameFlowState.PLAYING) {
+ gameStats.addPlayTime(System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs()); // Utilise méthode GameStats
+ }
+ saveGame(); // Sauvegarde l'état du jeu (plateau + score courant) et le HS
+ gameStats.saveStats(); // Sauvegarde toutes les stats via GameStats
+ }
+ }
+
+ /** Sauvegarde le timestamp actuel comme dernier moment joué. */
+ private void saveLastPlayedTime() {
+ if (preferences != null) {
+ preferences.edit().putLong(LAST_PLAYED_TIME_KEY, System.currentTimeMillis()).apply();
+ }
+ }
+
+ @Override
+ protected void onDestroy() { // Important de libérer SoundPool
+ super.onDestroy();
+ if (soundPool != null) {
+ soundPool.release();
+ soundPool = null;
+ }
+ }
+
+ // --- Initialisation ---
+
+ /**
+ * Récupère les références des vues du layout principal via leur ID.
+ */
+ private void findViews() {
+ boardGridLayout = findViewById(R.id.gameBoard);
+ currentScoreTextView = findViewById(R.id.scoreLabel);
+ highestScoreTextView = findViewById(R.id.highScoreLabel);
+ newGameButton = findViewById(R.id.restartButton);
+ statisticsButton = findViewById(R.id.statsButton);
+ menuButton = findViewById(R.id.menuButton);
+ multiplayerButton = findViewById(R.id.multiplayerButton);
+ statisticsViewStub = findViewById(R.id.statsViewStub);
+ }
+
+ /**
+ * Initialise les objets Game et GameStats.
+ * Charge l'état du jeu sauvegardé (s'il existe) et le meilleur score.
+ * Met à jour l'interface utilisateur initiale.
+ */
+ private void initializeGameAndStats() {
+ preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
+ gameStats = new GameStats(this);
+ loadNotificationPreference();
+ loadSoundPreference();
+ loadGame(); // Charge jeu et met à jour high score
+ updateUI();
+ if (game == null) {
+ // Si loadGame échoue ou aucune sauvegarde, startNewGame gère l'initialisation
+ startNewGame();
+ }
+ // L'état (currentGameState) est défini dans loadGame ou startNewGame
+ }
+
+ /** Initialise le SoundPool et charge les effets sonores. */
+ private void initializeSoundPool() {
+ // Configuration pour les effets sonores de jeu
+ AudioAttributes attributes = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_GAME)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build();
+
+ // Crée le SoundPool
+ soundPool = new SoundPool.Builder()
+ .setMaxStreams(3) // Nombre max de sons joués simultanément
+ .setAudioAttributes(attributes)
+ .build();
+
+ // Listener pour savoir quand les sons sont chargés
+ soundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> {
+ if (status == 0) {
+ // Vérifie si TOUS les sons sont chargés (ou gère individuellement)
+ // Ici, on met juste un flag général pour simplifier
+ soundPoolLoaded = true;
+ }
+ });
+
+ // Charge les sons depuis res/raw
+ // Le 3ème argument (priority) est 1 (priorité normale)
+ try {
+ 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);
+ } catch (Exception e) {
+ // Gérer l'erreur, peut-être désactiver le son
+ soundEnabled = false;
+ }
+ }
+
+ // --- Préférences Son ---
+
+ /** Sauvegarde la préférence d'activation du son. */
+ private void saveSoundPreference(boolean enabled) {
+ if (preferences != null) {
+ preferences.edit().putBoolean("sound_enabled", enabled).apply();
+ }
+ }
+
+ /** Charge la préférence d'activation du son. */
+ private void loadSoundPreference() {
+ if (preferences != null) {
+ soundEnabled = preferences.getBoolean("sound_enabled", true); // Son activé par défaut
+ } else {
+ soundEnabled = true;
+ }
+ }
+
+ // --- Lecture Son ---
+
+ /**
+ * Joue un effet sonore si le SoundPool est chargé et si le son est activé.
+ * @param soundId L'ID du son retourné par soundPool.load().
+ */
+ private void playSound(int soundId) {
+ if (soundPoolLoaded && soundEnabled && soundPool != null && soundId > 0) {
+ // Arguments: soundID, leftVolume, rightVolume, priority, loop, rate
+ soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f);
+ }
+ }
+
+ /** Crée le canal de notification nécessaire pour Android 8.0+. */
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ CharSequence name = getString(R.string.notification_channel_name);
+ String description = getString(R.string.notification_channel_description);
+ int importance = NotificationManager.IMPORTANCE_DEFAULT;
+ NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance);
+ channel.setDescription(description);
+ // Enregistre le canal avec le système; ne peut pas être changé après ça
+ NotificationManager notificationManager = getSystemService(NotificationManager.class);
+ if (notificationManager != null) {
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+ }
+
+ /**
+ * Configure les listeners pour les boutons et le plateau de jeu (swipes).
+ * Mise à jour pour le bouton Menu.
+ */
+ private void setupListeners() {
+ newGameButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ showRestartConfirmationDialog();
+ });
+ statisticsButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ toggleStatistics();
+ });
+ // Modifié pour appeler la nouvelle méthode showMenu()
+ menuButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ showMenu(); // Appelle la méthode du menu
+ });
+ multiplayerButton.setOnClickListener(v -> {
+ v.startAnimation(AnimationUtils.loadAnimation(this, R.anim.button_press));
+ showMultiplayerScreen(); // Affiche dialogue placeholder
+ });
+ setupSwipeListener();
+ }
+
+ // --- Mise à jour UI ---
+
+ /**
+ * Met à jour complètement l'interface utilisateur (plateau et scores).
+ */
+ private void updateUI() {
+ if (game == null) return;
+ updateBoard();
+ updateScores();
+ }
+
+ /**
+ * Redessine le plateau de jeu en créant/mettant à jour les TextViews des tuiles.
+ */
+ private void updateBoard() {
+ boardGridLayout.removeAllViews();
+ for (int row = 0; row < BOARD_SIZE; row++) {
+ for (int col = 0; col < BOARD_SIZE; col++) {
+ TextView tileTextView = new TextView(this);
+ int value = game.getCellValue(row, col);
+ setTileStyle(tileTextView, value);
+ // Définit les LayoutParams pour que la tuile remplisse la cellule du GridLayout
+ GridLayout.LayoutParams params = new GridLayout.LayoutParams();
+ params.width = 0; params.height = 0; // Poids gère la taille
+ params.rowSpec = GridLayout.spec(row, 1f); // Prend 1 fraction de l'espace en hauteur
+ params.columnSpec = GridLayout.spec(col, 1f); // Prend 1 fraction de l'espace en largeur
+ int margin = (int) getResources().getDimension(R.dimen.tile_margin);
+ params.setMargins(margin, margin, margin, margin);
+ tileTextView.setLayoutParams(params);
+ boardGridLayout.addView(tileTextView);
+ }
+ }
+ }
+
+ /**
+ * Met à jour les TextViews affichant le score courant et le meilleur score.
+ */
+ private void updateScores() {
+ currentScoreTextView.setText(getString(R.string.score_placeholder, game.getCurrentScore()));
+ highestScoreTextView.setText(getString(R.string.high_score_placeholder, game.getHighestScore()));
+ }
+
+ /**
+ * Applique le style visuel (fond, texte, taille) à une TextView représentant une tuile.
+ * @param tileTextView La TextView de la tuile.
+ * @param value La valeur numérique de la tuile (0 pour vide).
+ */
+ private void setTileStyle(TextView tileTextView, int value) {
+ tileTextView.setText(value > 0 ? String.valueOf(value) : "");
+ tileTextView.setGravity(Gravity.CENTER);
+ tileTextView.setTypeface(null, android.graphics.Typeface.BOLD);
+ int backgroundColorId; int textColorId; int textSizeId;
+ 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;
+ }
+ 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));
+ }
+
+
+ // --- Gestion des Actions Utilisateur ---
+
+ /**
+ * Configure le listener pour détecter les swipes sur le plateau de jeu.
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ private void setupSwipeListener() {
+ boardGridLayout.setOnTouchListener(new OnSwipeTouchListener(this, new OnSwipeTouchListener.SwipeListener() {
+ @Override public void onSwipeTop() { handleSwipe(Direction.UP); }
+ @Override public void onSwipeBottom() { handleSwipe(Direction.DOWN); }
+ @Override public void onSwipeLeft() { handleSwipe(Direction.LEFT); }
+ @Override public void onSwipeRight() { handleSwipe(Direction.RIGHT); }
+ }));
+ }
+
+ /**
+ * Traite un swipe : met à jour la logique, synchronise l'affichage instantanément,
+ * puis lance des animations locales d'apparition/fusion sur les tuiles concernées.
+ * @param direction Direction du swipe.
+ */
+ private void handleSwipe(Direction direction) {
+ // Bloque si jeu terminé
+ if (game == null || gameStats == null || currentGameState == GameFlowState.GAME_OVER) {
+ return;
+ }
+ // Note: On ne bloque plus sur 'isAnimating' pour cette approche
+
+ int scoreBefore = game.getCurrentScore();
+ int[][] boardBeforePush = game.getBoard(); // État avant le push
+
+ boolean boardChanged = false;
+ switch (direction) {
+ case UP: boardChanged = game.pushUp(); break;
+ case DOWN: boardChanged = game.pushDown(); break;
+ case LEFT: boardChanged = game.pushLeft(); break;
+ case RIGHT: boardChanged = game.pushRight(); break;
+ }
+
+ if (boardChanged) {
+ playSound(soundMoveId);
+ // Capture l'état APRÈS le push mais AVANT l'ajout de la nouvelle tuile
+ int[][] boardAfterPush = game.getBoard();
+
+ // Met à jour les stats générales
+ gameStats.recordMove();
+ int scoreAfter = game.getCurrentScore();
+ if (scoreAfter > scoreBefore) {
+ playSound(soundMergeId);
+ gameStats.recordMerge(1); // Simplifié
+ if (scoreAfter > game.getHighestScore()) {
+ game.setHighestScore(scoreAfter);
+ gameStats.setHighestScore(scoreAfter);
+ }
+ }
+ gameStats.updateHighestTile(game.getHighestTileValue());
+ // updateScores(); // Le score sera mis à jour par syncBoardView
+
+ // Ajoute la nouvelle tuile dans la logique du jeu
+ game.addNewTile();
+ int[][] boardAfterAdd = game.getBoard(); // État final logique
+
+ // *** Synchronise l'affichage avec l'état final logique ***
+ syncBoardView();
+
+ // *** Lance les animations locales sur les vues mises à jour ***
+ animateChanges(boardBeforePush, boardAfterPush, boardAfterAdd);
+
+ // La vérification de fin de partie est maintenant dans animateChanges->finalizeMove
+ // pour s'assurer qu'elle est faite APRÈS les animations.
+
+ } else {
+ // Mouvement invalide, on vérifie quand même si c'est la fin du jeu
+ if (game.isGameOver() && currentGameState != GameFlowState.GAME_OVER) {
+ currentGameState = GameFlowState.GAME_OVER;
+ long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
+ gameStats.recordLoss(); gameStats.endGame(timeTaken);
+ showGameOverDialog();
+ }
+ }
+ }
+
+ /**
+ * Identifie les tuiles qui ont fusionné ou sont apparues et lance des animations
+ * simples (scale/alpha) sur les vues correspondantes DÉJÀ positionnées par syncBoardView.
+ * @param boardBeforePush État avant le déplacement/fusion.
+ * @param boardAfterPush État après déplacement/fusion, avant ajout nouvelle tuile.
+ * @param boardAfterAdd État final après ajout nouvelle tuile.
+ */
+ private void animateChanges(int[][] boardBeforePush, int[][] boardAfterPush, int[][] boardAfterAdd) {
+ List animations = new ArrayList<>();
+
+ for (int r = 0; r < BOARD_SIZE; r++) {
+ for (int c = 0; c < BOARD_SIZE; c++) {
+ TextView currentView = tileViews[r][c]; // Vue à la position finale (après syncBoardView)
+ if (currentView == null) continue; // Pas de vue à animer ici
+
+ int valueAfterAdd = boardAfterAdd[r][c];
+ int valueAfterPush = boardAfterPush[r][c]; // Valeur avant l'ajout
+ int valueBeforePush = boardBeforePush[r][c]; // Valeur tout au début
+
+ // 1. Animation d'Apparition
+ // Si la case était vide après le push, mais a une valeur maintenant (c'est la nouvelle tuile)
+ if (valueAfterPush == 0 && valueAfterAdd > 0) {
+ //Log.d("AnimationDebug", "Animating APPEAR at ["+r+","+c+"]");
+ currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f);
+ Animator appear = createAppearAnimation(currentView);
+ animations.add(appear);
+ }
+ // 2. Animation de Fusion
+ // Si la valeur a changé PENDANT le push (valeur après push > valeur avant push)
+ // ET que la case n'était pas vide avant (ce n'est pas un simple déplacement)
+ else if (valueAfterPush > valueBeforePush && valueBeforePush != 0) {
+ //Log.d("AnimationDebug", "Animating MERGE at ["+r+","+c+"]");
+ Animator merge = createMergeAnimation(currentView);
+ animations.add(merge);
+ }
+ // Note : les tuiles qui ont simplement bougé ne sont pas animées ici.
+ // Les tuiles qui ont disparu (fusionnées vers une autre case) sont gérées par syncBoardView qui les supprime.
+ }
+ }
+
+ if (!animations.isEmpty()) {
+ AnimatorSet animatorSet = new AnimatorSet();
+ animatorSet.playTogether(animations);
+ animatorSet.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ // finalizeMove n'est plus responsable du déblocage, mais vérifie la fin
+ checkEndGameConditions();
+ }
+ });
+ animatorSet.start();
+ } else {
+ // Si aucune animation n'a été générée (ex: mouvement sans fusion ni nouvelle tuile possible)
+ checkEndGameConditions(); // Vérifie quand même la fin de partie
+ }
+ }
+
+ /** Crée une animation d'apparition (scale + alpha). */
+ private Animator createAppearAnimation(View view) {
+ 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);
+ AnimatorSet set = new AnimatorSet();
+ set.playTogether(scaleX, scaleY, alpha);
+ set.setDuration(150); // Durée apparition
+ return set;
+ }
+
+ /** Crée une animation de 'pulse' pour une fusion. */
+ private Animator createMergeAnimation(View view) {
+ ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f);
+ ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f);
+ AnimatorSet set = new AnimatorSet();
+ set.playTogether(scaleX, scaleY);
+ set.setDuration(120); // Durée pulse fusion
+ return set;
+ }
+
+ /**
+ * Vérifie si le jeu est gagné ou perdu et affiche le dialogue approprié.
+ * Doit être appelé après la fin des animations potentielles.
+ */
+ private void checkEndGameConditions() {
+ if (game == null || currentGameState == GameFlowState.GAME_OVER) return;
+
+ if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) {
+ currentGameState = GameFlowState.WON_DIALOG_SHOWN;
+ long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
+ gameStats.recordWin(timeTaken);
+ showGameWonKeepPlayingDialog();
+ // La notif est déjà envoyée dans handleSwipe si on la veut immédiate
+ } else if (game.isGameOver()) {
+ currentGameState = GameFlowState.GAME_OVER;
+ long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
+ gameStats.recordLoss();
+ gameStats.endGame(timeTaken);
+ showGameOverDialog();
+ }
+ }
+
+ /** Énumération pour les directions de swipe. */
+ private enum Direction { UP, DOWN, LEFT, RIGHT }
+
+ // --- Dialogues ---
+
+ /**
+ * Affiche la boîte de dialogue demandant confirmation avant de redémarrer.
+ */
+ private void showRestartConfirmationDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ LayoutInflater inflater = getLayoutInflater();
+ View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null);
+ builder.setView(dialogView);
+ Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton);
+ Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton);
+ final AlertDialog dialog = builder.create();
+ cancelButton.setOnClickListener(v -> dialog.dismiss());
+ confirmButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); });
+ dialog.show();
+ }
+
+ /**
+ * Démarre une nouvelle partie logiquement et rafraîchit l'UI.
+ * Réinitialise les stats de la partie en cours via `gameStats.startGame()`.
+ * Ferme le panneau de statistiques s'il est ouvert.
+ */
+ private void startNewGame() {
+ if (gameStats == null) gameStats = new GameStats(this); // Précaution si initialisation a échoué avant
+ gameStats.startGame(); // Réinitialise stats de partie (temps, mouvements, etc.)
+ game = new Game(); // Crée un nouveau jeu logique
+ game.setHighestScore(gameStats.getOverallHighScore()); // Applique le meilleur score global au nouveau jeu
+ currentGameState = GameFlowState.PLAYING; // Définit l'état à JOUER
+
+ // Ferme le panneau de statistiques s'il était ouvert
+ if (statisticsVisible) {
+ toggleStatistics(); // Utilise la méthode existante pour masquer proprement
+ }
+
+ syncBoardView();
+
+ updateUI(); // Met à jour l'affichage (plateau, scores)
+ }
+
+
+ /**
+ * Affiche la boîte de dialogue quand 2048 est atteint, en utilisant un layout personnalisé.
+ * Propose de continuer à jouer ou de commencer une nouvelle partie.
+ */
+ private void showGameWonKeepPlayingDialog() {
+ playSound(soundWinId);
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ LayoutInflater inflater = getLayoutInflater();
+ View dialogView = inflater.inflate(R.layout.dialog_game_won, null);
+ builder.setView(dialogView);
+ builder.setCancelable(false);
+
+ Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton);
+ Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonWon);
+ final AlertDialog dialog = builder.create();
+
+ keepPlayingButton.setOnClickListener(v -> {
+ // L'état est déjà WON_DIALOG_SHOWN, on ne fait rien de spécial, le jeu continue.
+ dialog.dismiss();
+ });
+ newGameButton.setOnClickListener(v -> {
+ dialog.dismiss();
+ startNewGame();
+ });
+ dialog.show();
+ }
+
+ /**
+ * Affiche la boîte de dialogue de fin de partie (plus de mouvements), en utilisant un layout personnalisé.
+ * Propose Nouvelle Partie ou Quitter.
+ */
+ private void showGameOverDialog() {
+ playSound(soundGameOverId);
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ LayoutInflater inflater = getLayoutInflater();
+ View dialogView = inflater.inflate(R.layout.dialog_game_over, null);
+ builder.setView(dialogView);
+ builder.setCancelable(false);
+
+ TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver);
+ Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonGameOver);
+ Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver);
+ messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore()));
+ final AlertDialog dialog = builder.create();
+
+ newGameButton.setOnClickListener(v -> {
+ dialog.dismiss();
+ startNewGame();
+ });
+ quitButton.setOnClickListener(v -> {
+ dialog.dismiss();
+ finish(); // Ferme l'application
+ });
+ dialog.show();
+ }
+
+ // --- Menu Principal ---
+
+ /**
+ * Affiche la boîte de dialogue du menu principal en utilisant un layout personnalisé.
+ * Attache les listeners aux boutons pour déclencher les actions correspondantes.
+ */
+ private void showMenu() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ LayoutInflater inflater = getLayoutInflater();
+ View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); // Gonfle le layout personnalisé
+ builder.setView(dialogView);
+ builder.setCancelable(true);
+
+ // Récupère les boutons du layout personnalisé
+ Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay);
+ Button settingsButton = dialogView.findViewById(R.id.menuButtonSettings);
+ Button aboutButton = dialogView.findViewById(R.id.menuButtonAbout);
+ Button returnButton = dialogView.findViewById(R.id.menuButtonReturn);
+
+ final AlertDialog dialog = builder.create();
+
+ // Attache les listeners aux boutons
+ howToPlayButton.setOnClickListener(v -> {
+ dialog.dismiss(); // Ferme le menu
+ showHowToPlayDialog(); // Ouvre la dialogue "Comment Jouer"
+ });
+
+ settingsButton.setOnClickListener(v -> {
+ dialog.dismiss(); // Ferme le menu
+ showSettingsDialog(); // Ouvre la dialogue placeholder "Paramètres"
+ });
+
+ aboutButton.setOnClickListener(v -> {
+ dialog.dismiss(); // Ferme le menu
+ showAboutDialog(); // Ouvre la dialogue "À Propos"
+ });
+
+ returnButton.setOnClickListener(v -> {
+ dialog.dismiss(); // Ferme simplement le menu
+ });
+
+ dialog.show(); // Affiche la boîte de dialogue
+ }
+
+ /**
+ * Affiche une boîte de dialogue expliquant les règles du jeu,
+ * en utilisant un layout personnalisé pour une meilleure présentation.
+ */
+ private void showHowToPlayDialog() {
+ 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 -> dialog.dismiss()); // Ferme simplement
+
+ dialog.show();
+ }
+
+ /**
+ * Affiche la boîte de dialogue des paramètres en utilisant un layout personnalisé.
+ * Gère les interactions avec les différentes options.
+ */
+ 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);
+
+ // Vues
+ SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound);
+ SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); // Activé
+ Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions);
+ Button shareStatsButton = dialogView.findViewById(R.id.buttonShareStats);
+ Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats);
+ Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp);
+ Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings);
+ // Ajout boutons de test (optionnel, pour débugger)
+ Button testNotifHS = new Button(this); // Crée programmatiquement
+ testNotifHS.setText(R.string.settings_test_notif_highscore);
+ Button testNotifInactiv = new Button(this);
+ testNotifInactiv.setText(R.string.settings_test_notif_inactivity);
+ // Ajouter ces boutons au layout 'dialogView' si nécessaire (ex: ((LinearLayout)dialogView).addView(...) )
+
+ final AlertDialog dialog = builder.create();
+
+ // Config Son (MAINTENANT ACTIF)
+ switchSound.setEnabled(true); // Activé
+ switchSound.setChecked(soundEnabled); // État actuel chargé
+ switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ soundEnabled = isChecked; // Met à jour l'état
+ saveSoundPreference(isChecked); // Sauvegarde la préférence
+ Toast.makeText(this,
+ isChecked ? R.string.sound_enabled : R.string.sound_disabled,
+ Toast.LENGTH_SHORT).show();
+ });
+
+ // Config Notifications (Activé + Gestion Permission)
+ switchNotifications.setEnabled(true); // Activé
+ switchNotifications.setChecked(notificationsEnabled); // État actuel
+ switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (isChecked) {
+ // L'utilisateur VEUT activer les notifications
+ requestNotificationPermission(); // Demande la permission si nécessaire
+ } else {
+ // L'utilisateur désactive les notifications
+ notificationsEnabled = false;
+ saveNotificationPreference(false);
+ Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show();
+ // Ici, annuler les éventuelles notifications planifiées (WorkManager/AlarmManager)
+ }
+ });
+
+ // Listeners autres boutons (Permissions, Share, Reset, Quit, Close)
+ permissionsButton.setOnClickListener(v -> { openAppSettings(); dialog.dismiss(); });
+ shareStatsButton.setOnClickListener(v -> { shareStats(); dialog.dismiss(); });
+ resetStatsButton.setOnClickListener(v -> { dialog.dismiss(); showResetStatsConfirmationDialog(); });
+ quitAppButton.setOnClickListener(v -> { dialog.dismiss(); finishAffinity(); });
+ closeButton.setOnClickListener(v -> dialog.dismiss());
+
+ // Listeners boutons de test (si ajoutés)
+ testNotifHS.setOnClickListener(v -> { showHighScoreNotification(gameStats.getOverallHighScore()); });
+ testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); });
+
+
+ dialog.show();
+ }
+
+ /** Met à jour l'état du switch notification (utile si permission refusée). */
+ private void updateNotificationSwitchState(boolean isEnabled) {
+ // Si la vue des paramètres est actuellement affichée, met à jour le switch
+ View settingsDialogView = getLayoutInflater().inflate(R.layout.dialog_settings, null); // Attention, regonfler n'est pas idéal
+ // Mieux: Garder une référence à la vue ou au switch si la dialog est affichée.
+ // Pour la simplicité ici, on suppose qu'il faut rouvrir les paramètres pour voir le changement.
+ }
+
+ /** Sauvegarde la préférence d'activation des notifications. */
+ private void saveNotificationPreference(boolean enabled) {
+ if (preferences != null) {
+ preferences.edit().putBoolean("notifications_enabled", enabled).apply();
+ }
+ }
+
+ /** Charge la préférence d'activation des notifications. */
+ private void loadNotificationPreference() {
+ if (preferences != null) {
+ // Change le défaut de true à false
+ notificationsEnabled = preferences.getBoolean("notifications_enabled", false);
+ } else {
+ notificationsEnabled = false; // Assure une valeur par défaut si prefs est null
+ }
+ }
+
+
+ /** Ouvre les paramètres système de l'application. */
+ private void openAppSettings() {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ Uri uri = Uri.fromParts("package", getPackageName(), null);
+ intent.setData(uri);
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(this, "Impossible d'ouvrir les paramètres.", Toast.LENGTH_LONG).show();
+ }
+ }
+
+ // --- Gestion des Permissions (Notifications) ---
+
+ /** Demande la permission POST_NOTIFICATIONS si nécessaire (Android 13+). */
+ private void requestNotificationPermission() {
+ // Vérifie si on est sur Android 13+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // Vérifie si la permission n'est PAS déjà accordée
+ if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ // Demande la permission
+ 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, on peut activer directement
+ notificationsEnabled = true;
+ saveNotificationPreference(true);
+ Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
+ // Planifier les notifications ici si ce n'est pas déjà fait
+ }
+ } else {
+ // Sur les versions antérieures à Android 13, pas besoin de demander la permission
+ notificationsEnabled = true;
+ saveNotificationPreference(true);
+ Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show();
+ // Planifier les notifications ici
+ }
+ }
+
+ // --- Logique de Notification ---
+
+ /**
+ * Crée l'Intent qui sera lancé au clic sur une notification (ouvre MainActivity).
+ * @return PendingIntent configuré.
+ */
+ private PendingIntent createNotificationTapIntent() {
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); // Ouvre l'app ou la ramène devant
+ // FLAG_IMMUTABLE est requis pour Android 12+
+ int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT;
+ return PendingIntent.getActivity(this, 0, intent, flags);
+ }
+
+ /**
+ * Construit et affiche une notification simple.
+ * @param context Contexte.
+ * @param title Titre de la notification.
+ * @param message Corps du message de la notification.
+ * @param notificationId ID unique pour cette notification.
+ */
+ private void showNotification(Context context, String title, String message, int notificationId) {
+ // Vérifie si les notifications sont activées et si la permission est accordée (pour Android 13+)
+ if (!notificationsEnabled || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)) {
+ // Ne pas envoyer la notification si désactivé ou permission manquante
+ return;
+ }
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône
+ .setContentTitle(title)
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(createNotificationTapIntent()) // Action au clic
+ .setAutoCancel(true); // Ferme la notif après clic
+
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+ // L'ID notificationId est utilisé pour mettre à jour une notif existante ou en afficher une nouvelle
+ notificationManager.notify(notificationId, builder.build());
+ }
+
+ /** Démarre le NotificationService s'il n'est pas déjà lancé. */
+ private void startNotificationService() {
+ Intent serviceIntent = new Intent(this, NotificationService.class);
+ // Utiliser startForegroundService pour API 26+ si le service doit faire qqch rapidement
+ // mais pour une tâche périodique simple startService suffit.
+ startService(serviceIntent);
+ }
+
+ /** Arrête le NotificationService. */
+ private void stopNotificationService() {
+ Intent serviceIntent = new Intent(this, NotificationService.class);
+ stopService(serviceIntent);
+ }
+
+ /** Affiche la notification d'accomplissement via le NotificationHelper. */
+ private void showAchievementNotification(int tileValue) {
+ // Vérifie l'état global avant d'envoyer (au cas où désactivé entre-temps)
+ if (!notificationsEnabled) return;
+ String title = getString(R.string.notification_title_achievement);
+ String message = getString(R.string.notification_text_achievement, tileValue);
+ NotificationHelper.showNotification(this, title, message, 1); // ID 1 pour achievement
+ }
+
+ /** Affiche la notification de rappel du meilleur score (pour test). */
+ private void showHighScoreNotification(int highScore) {
+ String title = getString(R.string.notification_title_highscore);
+ String message = getString(R.string.notification_text_highscore, highScore);
+ // Utiliser un ID spécifique (ex: 2)
+ showNotification(this, title, message, 2);
+ // NOTE: La planification réelle utiliserait WorkManager/AlarmManager
+ }
+
+ /** Affiche la notification de rappel d'inactivité (pour test). */
+ private void showInactivityNotification() {
+ String title = getString(R.string.notification_title_inactivity);
+ String message = getString(R.string.notification_text_inactivity);
+ // Utiliser un ID spécifique (ex: 3)
+ showNotification(this, title, message, 3);
+ // NOTE: La planification réelle utiliserait WorkManager/AlarmManager et suivi du temps
+ }
+
+ /**
+ * Crée et lance une Intent pour partager les statistiques du joueur.
+ */
+ private void shareStats() {
+ if (gameStats == null) return;
+
+ // Construit le message à partager
+ String shareBody = getString(R.string.share_stats_body,
+ gameStats.getOverallHighScore(),
+ gameStats.getHighestTile(),
+ gameStats.getNumberOfTimesObjectiveReached(),
+ gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ?
+ GameStats.formatTime(gameStats.getTotalPlayTimeMs()),
+ gameStats.getTotalMoves()
+ );
+
+ Intent shareIntent = new Intent(Intent.ACTION_SEND);
+ shareIntent.setType("text/plain");
+ shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject));
+ shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody);
+
+ try {
+ startActivity(Intent.createChooser(shareIntent, getString(R.string.share_stats_title)));
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(this, "Aucune application de partage disponible.", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Affiche une boîte de dialogue pour confirmer la réinitialisation des statistiques.
+ */
+ private void showResetStatsConfirmationDialog() {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.reset_stats_confirm_title)
+ .setMessage(R.string.reset_stats_confirm_message)
+ .setPositiveButton(R.string.confirm, (dialog, which) -> {
+ resetStatistics(); // Appelle la méthode de réinitialisation
+ dialog.dismiss();
+ })
+ .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss())
+ .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement
+ .show();
+ }
+
+ /**
+ * Réinitialise toutes les statistiques via GameStats et sauvegarde les changements.
+ * Affiche une confirmation à l'utilisateur.
+ */
+ private void resetStatistics() {
+ if (gameStats != null) {
+ gameStats.resetStats(); // Réinitialise les stats dans l'objet
+ gameStats.saveStats(); // Sauvegarde les stats réinitialisées
+ // Met aussi à jour le highScore de l'objet Game courant (si une partie est en cours)
+ if(game != null){
+ game.setHighestScore(gameStats.getOverallHighScore()); // Le HS est aussi reset dans GameStats
+ updateScores(); // Rafraichit l'affichage du HS si visible
+ }
+ Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show();
+ // Si les stats étaient visibles, on pourrait vouloir les rafraîchir
+ if (statisticsVisible && inflatedStatsView != null) {
+ updateStatisticsTextViews();
+ }
+ }
+ }
+
+ /**
+ * Affiche la boîte de dialogue "À Propos" en utilisant un layout personnalisé,
+ * incluant des informations sur l'application et un lien cliquable vers le site web.
+ */
+ private void showAboutDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ LayoutInflater inflater = getLayoutInflater();
+ View dialogView = inflater.inflate(R.layout.dialog_about, null); // Gonfle le layout
+ builder.setView(dialogView);
+ builder.setCancelable(true); // Permet de fermer
+
+ // Récupère les vues du layout
+ TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView);
+ Button okButton = dialogView.findViewById(R.id.dialogOkButtonAbout);
+
+ final AlertDialog dialog = builder.create();
+
+ // Rend le lien cliquable pour ouvrir le navigateur
+ websiteLinkTextView.setOnClickListener(v -> {
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_website_url)));
+ try {
+ startActivity(browserIntent);
+ } catch (ActivityNotFoundException e) {
+ // Gère le cas où aucun navigateur n'est installé
+ Toast.makeText(this, "Aucun navigateur web trouvé.", Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ // Bouton OK pour fermer la dialogue
+ okButton.setOnClickListener(v -> dialog.dismiss());
+
+ dialog.show();
+ }
+
+
+ // --- Gestion Stats UI ---
+
+ /**
+ * Affiche ou masque le panneau de statistiques.
+ * Gonfle le layout via ViewStub si c'est la première fois.
+ */
+ private void toggleStatistics() {
+ statisticsVisible = !statisticsVisible;
+ if (statisticsVisible) {
+ if (inflatedStatsView == null) { // Gonfle si pas encore fait
+ inflatedStatsView = statisticsViewStub.inflate();
+ // Attache listener au bouton Back une fois la vue gonflée
+ Button backButton = inflatedStatsView.findViewById(R.id.backButton);
+ backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back re-appelle toggle
+ }
+ updateStatisticsTextViews(); // Remplit les champs avec les données actuelles
+ inflatedStatsView.setVisibility(View.VISIBLE); // Affiche
+ multiplayerButton.setVisibility(View.GONE); // Masque bouton multi
+ } else {
+ if (inflatedStatsView != null) { // Masque si la vue existe
+ inflatedStatsView.setVisibility(View.GONE);
+ }
+ multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche bouton multi
+ }
+ }
+
+ /**
+ * Remplit les TextViews du panneau de statistiques avec les données de l'objet GameStats.
+ * Doit être appelé seulement après que `inflatedStatsView` a été initialisé.
+ */
+ private void updateStatisticsTextViews() {
+ if (inflatedStatsView == null || gameStats == null) return;
+
+ // Récupération des TextViews dans la vue gonflée
+ 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);
+ TextView winPercentageLabel = inflatedStatsView.findViewById(R.id.win_percentage_label);
+ TextView totalPlayTimeLabel = inflatedStatsView.findViewById(R.id.total_play_time_label);
+ TextView totalMovesLabel = inflatedStatsView.findViewById(R.id.total_moves_label);
+ TextView currentMovesLabel = inflatedStatsView.findViewById(R.id.current_moves_label);
+ TextView currentGameTimeLabel = inflatedStatsView.findViewById(R.id.current_game_time_label);
+ TextView averageGameTimeLabel = inflatedStatsView.findViewById(R.id.average_game_time_label);
+ TextView bestWinningTimeLabel = inflatedStatsView.findViewById(R.id.best_winning_time_label);
+ TextView worstWinningTimeLabel = inflatedStatsView.findViewById(R.id.worst_winning_time_label);
+ 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);
+ 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); // Potentiel ID dupliqué dans layout?
+ TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label);
+ TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label);
+ TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game);
+
+ // MAJ textes avec getters de gameStats
+ highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore()));
+ totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed()));
+ totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted()));
+ totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves()));
+ currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves()));
+ mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame()));
+ totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges()));
+ highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile()));
+ objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached()));
+ perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames()));
+ multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon()));
+ multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed()));
+ multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak()));
+ multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore()));
+ totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses()));
+ multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore()));
+
+ // Calculs Pourcentages
+ String winPercentage = (gameStats.getTotalGamesStarted() > 0) ? String.format("%.2f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) : "N/A";
+ winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage));
+ String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) ? String.format("%.2f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) : "N/A";
+ multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate));
+
+ // Calculs Temps
+ totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs())));
+ // Calcule le temps de la partie en cours seulement si elle n'est pas finie
+ long currentDurationMs = 0;
+ if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) {
+ currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs();
+ }
+ currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs)));
+
+ averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs())));
+ averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); // Assurez-vous que l'ID R.string.average_time_per_game_label est correct
+ bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A"));
+ worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A"));
+ }
+
+
+ // --- Placeholders Multi ---
+
+ /** Affiche un dialogue placeholder pour le multijoueur. */
+ private void showMultiplayerScreen() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle("Multijoueur").setMessage("Fonctionnalité multijoueur à venir !").setPositiveButton("OK", null);
+ builder.create().show();
+ }
+
+ // --- Sauvegarde / Chargement ---
+
+ /** Sauvegarde l'état du jeu et le meilleur score via SharedPreferences. */
+ private void saveGame() {
+ SharedPreferences.Editor editor = preferences.edit();
+ if (game != null) {
+ editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise Game (plateau + score courant)
+ // Le meilleur score est géré et sauvegardé par GameStats, mais on le sauve aussi ici pour la synchro au chargement
+ editor.putInt(HIGH_SCORE_KEY, game.getHighestScore());
+ } else {
+ editor.remove(GAME_STATE_KEY); // Optionnel: nettoyer si pas de jeu
+ }
+ editor.apply(); // Utilise apply() pour une sauvegarde asynchrone
+ }
+
+ /** Charge l'état du jeu depuis SharedPreferences et synchronise le meilleur score. */
+ private void loadGame() {
+ String gameStateString = preferences.getString(GAME_STATE_KEY, null);
+ // Charge le meilleur score depuis les préférences (sera aussi chargé par GameStats mais on l'utilise ici pour Game)
+ int savedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0);
+
+ // Assure que GameStats charge son état (y compris le HS global)
+ if (gameStats == null) { gameStats = new GameStats(this); } // Précaution
+ gameStats.loadStats(); // Charge explicitement les stats (ce qui devrait inclure le HS global)
+ // S'assure que le HS chargé par gameStats est cohérent avec celui des prefs directes
+ if (savedHighScore > gameStats.getOverallHighScore()) {
+ gameStats.setHighestScore(savedHighScore); // Assure que GameStats a au moins le HS trouvé ici
+ } else {
+ savedHighScore = gameStats.getOverallHighScore(); // Utilise le HS de GameStats s'il est plus grand
+ }
+
+
+ Game loadedGame = null;
+ if (gameStateString != null) {
+ loadedGame = Game.deserialize(gameStateString);
+ }
+
+ if (loadedGame != null) {
+ game = loadedGame;
+ game.setHighestScore(savedHighScore); // Applique le HS synchronisé
+ // Détermine l'état basé sur le jeu chargé
+ if (game.isGameOver()) {
+ 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 dialog
+ currentGameState = GameFlowState.WON_DIALOG_SHOWN;
+ } else {
+ 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 -> Commence une nouvelle partie implicitement
+ game = null; // Sera géré par l'appel à startNewGame dans initializeGameAndStats
+ }
+ }
+
+} // Fin MainActivity// Fichier NotificationHelper.java
+package legion.muyue.best2048;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat; // Pour checkSelfPermission
+
+/**
+ * Classe utilitaire pour simplifier la création et l'affichage des notifications
+ * et la gestion du canal de notification pour l'application Best 2048.
+ */
+public class NotificationHelper {
+
+ /** Identifiant unique du canal de notification pour cette application. */
+ public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant
+
+ /**
+ * Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur.
+ * Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif).
+ * Doit être appelée avant d'afficher la première notification sur API 26+.
+ *
+ * @param context Contexte applicatif.
+ */
+ public static void createNotificationChannel(Context context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ CharSequence name = context.getString(R.string.notification_channel_name);
+ String description = context.getString(R.string.notification_channel_description);
+ int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut
+
+ NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
+ channel.setDescription(description);
+ // Enregistre le canal auprès du système.
+ NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
+ if (notificationManager != null) {
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+ }
+
+ /**
+ * Construit et affiche une notification.
+ * Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher.
+ *
+ * @param context Contexte (peut être une Activity ou un Service).
+ * @param title Titre de la notification.
+ * @param message Contenu texte de la notification.
+ * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler).
+ */
+ public static void showNotification(Context context, String title, String message, int notificationId) {
+ // Vérification de la permission pour Android 13+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ // Si la permission n'est pas accordée, ne pas tenter d'afficher la notification.
+ // L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode
+ // si elle sait que l'utilisateur a activé les notifications dans les paramètres.
+ System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée.");
+ return;
+ }
+ }
+
+ // Intent pour ouvrir MainActivity au clic
+ Intent intent = new Intent(context, MainActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT;
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags);
+
+ // Construction de la notification via NotificationCompat pour la compatibilité
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification
+ .setContentTitle(title)
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale
+ .setContentIntent(pendingIntent) // Action au clic
+ .setAutoCancel(true); // Ferme la notification après le clic
+
+ // Affichage de la notification
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+ try {
+ notificationManager.notify(notificationId, builder.build());
+ } catch (SecurityException e){
+ // Gérer l'exception de sécurité qui peut survenir même avec la vérification ci-dessus dans certains cas limites
+ System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage());
+ }
+ }
+}// Fichier NotificationService.java
+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 androidx.annotation.Nullable;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Service exécuté en arrière-plan pour envoyer des notifications périodiques
+ * (rappel de meilleur score, rappel d'inactivité).
+ * Utilise un Handler pour planifier les tâches répétitives.
+ * NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée),
+ * WorkManager serait préférable en production.
+ */
+public class NotificationService extends Service {
+
+ private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs
+ private static final int NOTIFICATION_ID_INACTIVITY = 3;
+ // Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité)
+ private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1);
+ private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3);
+ private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent
+
+ private Handler handler;
+ private Runnable periodicTaskRunnable;
+
+ // Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs)
+ private static final String PREFS_NAME = "Best2048_Prefs";
+ private static final String HIGH_SCORE_KEY = "high_score";
+ private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ // Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue)
+ // Pour des tâches plus lourdes, utiliser HandlerThread
+ handler = new Handler(Looper.getMainLooper());
+ // Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage
+ // NotificationHelper.createNotificationChannel(this);
+
+ periodicTaskRunnable = new Runnable() {
+ @Override
+ public void run() {
+ // Vérifie périodiquement s'il faut envoyer une notification
+ checkAndSendNotifications();
+ // Replanifie la tâche
+ handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures
+ }
+ };
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ // Lance la tâche périodique lors du démarrage du service
+ handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon
+ handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification
+
+ // START_STICKY : Le système essaiera de recréer le service s'il est tué.
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ // Arrête la planification des tâches lorsque le service est détruit
+ if (handler != null && periodicTaskRunnable != null) {
+ handler.removeCallbacks(periodicTaskRunnable);
+ }
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ // Service non lié (Started Service)
+ return null;
+ }
+
+ /**
+ * Vérifie les conditions et envoie les notifications périodiques si nécessaire.
+ */
+ private void checkAndSendNotifications() {
+ SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
+ boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé
+
+ if (!notificationsEnabled) {
+ // Si désactivé dans les prefs, on arrête potentiellement le service?
+ // Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer.
+ // stopSelf(); // Arrêterait le service
+ return;
+ }
+
+ // --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) ---
+ // Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici)
+ // Pour une vraie app, il faudrait une logique pour ne pas spammer.
+ // Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ?
+ int highScore = prefs.getInt(HIGH_SCORE_KEY, 0);
+ // Temporairement on l'envoie à chaque check pour test (à modifier!)
+ // if (shouldSendHighScoreNotification()) {
+ showHighScoreNotificationNow(highScore);
+ // }
+
+
+ // --- Notification d'Inactivité ---
+ long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0);
+ if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) {
+ // Si l'inactivité dépasse le seuil
+ showInactivityNotificationNow();
+ // Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ?
+ // Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause.
+ }
+ }
+
+ /** Affiche la notification High Score */
+ 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);
+ }
+
+ /** Affiche la notification d'Inactivité */
+ 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);
+ }
+
+ // Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques
+ // private boolean shouldSendHighScoreNotification() { ... }
+
+}// 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;
+import android.content.Context;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.View;
+import androidx.annotation.NonNull;
+
+public class OnSwipeTouchListener implements View.OnTouchListener {
+
+ /** Détecteur de gestes standard d'Android. */
+ private final GestureDetector gestureDetector;
+ /** Listener externe à notifier lors de la détection d'un swipe. */
+ private final SwipeListener listener;
+
+ /**
+ * Interface à implémenter par les classes souhaitant réagir aux événements de swipe.
+ */
+ public interface SwipeListener {
+ /** Appelée lorsqu'un swipe vers le haut est détecté. */
+ void onSwipeTop();
+ /** Appelée lorsqu'un swipe vers le bas est détecté. */
+ void onSwipeBottom();
+ /** Appelée lorsqu'un swipe vers la gauche est détecté. */
+ void onSwipeLeft();
+ /** Appelée lorsqu'un swipe 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.
+ */
+ public OnSwipeTouchListener(Context context, @NonNull SwipeListener listener) {
+ this.gestureDetector = new GestureDetector(context, new GestureListener());
+ 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.
+ */
+ @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.
+ return gestureDetector.onTouchEvent(event);
+ }
+
+ /**
+ * Classe interne implémentant l'écouteur de gestes pour détecter le 'fling' (balayage rapide).
+ */
+ private final class GestureListener extends GestureDetector.SimpleOnGestureListener {
+
+ /** Distance minimale (en pixels) pour qu'un mouvement soit considéré comme un swipe. */
+ private static final int SWIPE_THRESHOLD = 100;
+ /** Vitesse minimale (en pixels/sec) pour qu'un mouvement soit considéré comme un swipe. */
+ 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.
+ */
+ @Override
+ public boolean onDown(@NonNull MotionEvent e) {
+ return true;
+ }
+
+ /**
+ * 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.
+ */
+ @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
+
+ boolean result = false;
+ try {
+ float diffY = e2.getY() - e1.getY();
+ float diffX = e2.getX() - e1.getX();
+
+ // Priorité au mouvement le plus ample (horizontal ou vertical)
+ if (Math.abs(diffX) > Math.abs(diffY)) {
+ // Mouvement principalement horizontal
+ if (Math.abs(diffX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) {
+ if (diffX > 0) {
+ listener.onSwipeRight();
+ } else {
+ listener.onSwipeLeft();
+ }
+ result = true; // Geste horizontal traité
+ }
+ } else {
+ // Mouvement principalement vertical
+ if (Math.abs(diffY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) {
+ if (diffY > 0) {
+ listener.onSwipeBottom();
+ } else {
+ listener.onSwipeTop();
+ }
+ result = true; // Geste vertical 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.
+ }
+ return result;
+ }
+ }
+} // Fin OnSwipeTouchListener
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #FF000000
+ #FFFFFFFF
+
+ #776e65
+ #f9f6f2
+
+ #faf8ef
+ #bbada0
+ #f65e3b
+ #edc22e
+ #8f7a66
+ #f9f6f2
+ #cdc1b4
+ #eee4da
+ #ede0c8
+ #f2b179
+ #f59563
+ #f67c5f
+ #f65e3b
+ #edcf72
+ #edcc61
+ #edc850
+ #edc53f
+ #edc22e
+ #3c3a32
+ #6200ee
+ #3700b3
+ #3700b3
+ #03dac6
+ #b0bec5
+
+ #875932
+
+
+ 16dp
+ 8dp
+ 48sp
+ 18sp
+ 6dp
+ 4dp
+ 4dp24sp
+ 20sp
+ 16sp
+
+ Best 2048
+ 2048
+ Score :
+ Score :\n%d
+ High Score :\n%d
+ High Score :
+ Restart
+ Stats
+ Menu
+ Multiplayer
+ Restart ?
+ Are you sure you want to restart the game ?
+ Cancel
+ Confirm
+ High Score: %d
+ Total Games Played: %d
+ Total Games Started: %d
+ Win Percentage: %s
+ Total Play Time: %s
+ Total Moves: %d
+ Current Moves: %d
+ Current Game Time: %s
+ Merges: %d
+ Average Game Time: %s
+ Best Winning Time: %s
+ 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
+ Multiplayer Average Score: %d
+ Average time per game: %s
+ Total Multiplayer losses: %d
+ Multiplayer High Score: %d
+ Stats
+ Statistics
+ General
+ Current Game
+ Single Player
+ Multiplayer
+ Back
+ You won!
+ Congratulations, you\'ve reached 2048!
+ Continue
+ New Part
+ Game over!
+ No move possible.\nFinal score: %d
+ To leave
+ Main Menu
+ How to Play
+ Settings
+ About
+ Back
+ How to Play
+ 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.frhttps://legion-muyue.fr
+ OK
+ Settings
+ Sound
+ Notifications
+ Manage Permissions
+ Share my 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…
+ 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.
+ Game Updates
+ Notifications related to the 2048 game
+ Congratulations!
+ You reached the %d tile!
+ New Challenge!
+ Your best score is %d. Can you do better?
+ We miss you!
+ How about a quick game of 2048 to relax?
+ Permission Required
+ To receive notifications (reminders, achievements), please allow the application to send notifications in the settings.
+ Go to Settings
+ Notifications enabled.
+ Notifications disabled.
+ Test High Score Notif
+ Test Inactivity Notif
+ Sound effects enabled.
+ Sound effects disabled.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file