// 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