// 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<BOARD_SIZE; i++) { System.arraycopy(this.board[i], 0, copy[i], 0, BOARD_SIZE); } return copy; }

/** * (Ré)Initialise le plateau de jeu pour une nouvelle partie. * Remplit le plateau de zéros, réinitialise le score et les états `gameWon`/`gameOver`, * puis ajoute deux tuiles initiales. */ private void initializeNewBoard() { this.board = new int[BOARD_SIZE][BOARD_SIZE]; // Crée une nouvelle matrice vide this.currentScore = 0; this.gameWon = false; this.gameOver = false; addNewTile(); // Ajoute la première tuile addNewTile(); // Ajoute la seconde tuile }

// --- Logique du Jeu ---

/** * Ajoute une nouvelle tuile (2, 4, 8, etc., selon probabilités) sur une case vide aléatoire. * Si le plateau est plein, cette méthode ne fait rien. */ public void addNewTile() { List<int[]> 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<int[]> findEmptyCells() { List<int[]> 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<BOARD_SIZE; r++) for (int c=0; c<BOARD_SIZE; c++) if (getCellValue(r,c) >= 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; r<BOARD_SIZE; r++) for (int c=0; c<BOARD_SIZE; c++) { int current = getCellValue(r,c); if ((r>0 && getCellValue(r-1,c)==current) || (r<BOARD_SIZE-1 && getCellValue(r+1,c)==current) || (c>0 && getCellValue(r,c-1)==current) || (c<BOARD_SIZE-1 && getCellValue(r,c+1)==current)) { setGameOver(false); return; // Fusion possible -> 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<BOARD_SIZE; r++) for (int c=0; c<BOARD_SIZE; c++) if (getCellValue(r,c)==0) return true; return false; }

/** * Retourne la valeur de la plus haute tuile présente sur le plateau. * @return La valeur maximale trouvée, ou 0 si le plateau est vide. */ public int getHighestTileValue() { int maxTile = 0; for (int r=0; r<BOARD_SIZE; r++) for (int c=0; c<BOARD_SIZE; c++) if (board[r][c] > maxTile) maxTile = board[r][c]; return maxTile; } }// 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<Animator> animations = new ArrayList<>(); for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { TextView currentView = tileViews[r][c]; // Vue à la position finale (après syncBoardView) if (currentView == null) continue; // Pas de vue à animer ici int valueAfterAdd = boardAfterAdd[r][c]; int valueAfterPush = boardAfterPush[r][c]; // Valeur avant l'ajout int valueBeforePush = boardBeforePush[r][c]; // Valeur tout au début // 1. Animation d'Apparition // Si la case était vide après le push, mais a une valeur maintenant (c'est la nouvelle tuile) if (valueAfterPush == 0 && valueAfterAdd > 0) { //Log.d("AnimationDebug", "Animating APPEAR at ["+r+","+c+"]"); currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f); Animator appear = createAppearAnimation(currentView); animations.add(appear); } // 2. Animation de Fusion // Si la valeur a changé PENDANT le push (valeur après push > valeur avant push) // ET que la case n'était pas vide avant (ce n'est pas un simple déplacement) else if (valueAfterPush > valueBeforePush && valueBeforePush != 0) { //Log.d("AnimationDebug", "Animating MERGE at ["+r+","+c+"]"); Animator merge = createMergeAnimation(currentView); animations.add(merge); } // Note : les tuiles qui ont simplement bougé ne sont pas animées ici. // Les tuiles qui ont disparu (fusionnées vers une autre case) sont gérées par syncBoardView qui les supprime. } } if (!animations.isEmpty()) { AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(animations); animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { // finalizeMove n'est plus responsable du déblocage, mais vérifie la fin checkEndGameConditions(); } }); animatorSet.start(); } else { // Si aucune animation n'a été générée (ex: mouvement sans fusion ni nouvelle tuile possible) checkEndGameConditions(); // Vérifie quand même la fin de partie } } /** Crée une animation d'apparition (scale + alpha). */ private Animator createAppearAnimation(View view) { ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f); ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f); ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f); AnimatorSet set = new AnimatorSet(); set.playTogether(scaleX, scaleY, alpha); set.setDuration(150); // Durée apparition return set; } /** Crée une animation de 'pulse' pour une fusion. */ private Animator createMergeAnimation(View view) { ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); AnimatorSet set = new AnimatorSet(); set.playTogether(scaleX, scaleY); set.setDuration(120); // Durée pulse fusion return set; } /** * Vérifie si le jeu est gagné ou perdu et affiche le dialogue approprié. * Doit être appelé après la fin des animations potentielles. */ private void checkEndGameConditions() { if (game == null || currentGameState == GameFlowState.GAME_OVER) return; if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { currentGameState = GameFlowState.WON_DIALOG_SHOWN; long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); gameStats.recordWin(timeTaken); showGameWonKeepPlayingDialog(); // La notif est déjà envoyée dans handleSwipe si on la veut immédiate } else if (game.isGameOver()) { currentGameState = GameFlowState.GAME_OVER; long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); gameStats.recordLoss(); gameStats.endGame(timeTaken); showGameOverDialog(); } } /** Énumération pour les directions de swipe. */ private enum Direction { UP, DOWN, LEFT, RIGHT } // --- Dialogues --- /** * Affiche la boîte de dialogue demandant confirmation avant de redémarrer. */ private void showRestartConfirmationDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_restart_confirm, null); builder.setView(dialogView); Button cancelButton = dialogView.findViewById(R.id.dialogCancelButton); Button confirmButton = dialogView.findViewById(R.id.dialogConfirmButton); final AlertDialog dialog = builder.create(); cancelButton.setOnClickListener(v -> dialog.dismiss()); confirmButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); }); dialog.show(); } /** * Démarre une nouvelle partie logiquement et rafraîchit l'UI. * Réinitialise les stats de la partie en cours via `gameStats.startGame()`. * Ferme le panneau de statistiques s'il est ouvert. */ private void startNewGame() { if (gameStats == null) gameStats = new GameStats(this); // Précaution si initialisation a échoué avant gameStats.startGame(); // Réinitialise stats de partie (temps, mouvements, etc.) game = new Game(); // Crée un nouveau jeu logique game.setHighestScore(gameStats.getOverallHighScore()); // Applique le meilleur score global au nouveau jeu currentGameState = GameFlowState.PLAYING; // Définit l'état à JOUER // Ferme le panneau de statistiques s'il était ouvert if (statisticsVisible) { toggleStatistics(); // Utilise la méthode existante pour masquer proprement } syncBoardView(); updateUI(); // Met à jour l'affichage (plateau, scores) } /** * Affiche la boîte de dialogue quand 2048 est atteint, en utilisant un layout personnalisé. * Propose de continuer à jouer ou de commencer une nouvelle partie. */ private void showGameWonKeepPlayingDialog() { playSound(soundWinId); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_game_won, null); builder.setView(dialogView); builder.setCancelable(false); Button keepPlayingButton = dialogView.findViewById(R.id.dialogKeepPlayingButton); Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonWon); final AlertDialog dialog = builder.create(); keepPlayingButton.setOnClickListener(v -> { // L'état est déjà WON_DIALOG_SHOWN, on ne fait rien de spécial, le jeu continue. dialog.dismiss(); }); newGameButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); }); dialog.show(); } /** * Affiche la boîte de dialogue de fin de partie (plus de mouvements), en utilisant un layout personnalisé. * Propose Nouvelle Partie ou Quitter. */ private void showGameOverDialog() { playSound(soundGameOverId); AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_game_over, null); builder.setView(dialogView); builder.setCancelable(false); TextView messageTextView = dialogView.findViewById(R.id.dialogMessageGameOver); Button newGameButton = dialogView.findViewById(R.id.dialogNewGameButtonGameOver); Button quitButton = dialogView.findViewById(R.id.dialogQuitButtonGameOver); messageTextView.setText(getString(R.string.game_over_message, game.getCurrentScore())); final AlertDialog dialog = builder.create(); newGameButton.setOnClickListener(v -> { dialog.dismiss(); startNewGame(); }); quitButton.setOnClickListener(v -> { dialog.dismiss(); finish(); // Ferme l'application }); dialog.show(); } // --- Menu Principal --- /** * Affiche la boîte de dialogue du menu principal en utilisant un layout personnalisé. * Attache les listeners aux boutons pour déclencher les actions correspondantes. */ private void showMenu() { AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_main_menu, null); // Gonfle le layout personnalisé builder.setView(dialogView); builder.setCancelable(true); // Récupère les boutons du layout personnalisé Button howToPlayButton = dialogView.findViewById(R.id.menuButtonHowToPlay); Button settingsButton = dialogView.findViewById(R.id.menuButtonSettings); Button aboutButton = dialogView.findViewById(R.id.menuButtonAbout); Button returnButton = dialogView.findViewById(R.id.menuButtonReturn); final AlertDialog dialog = builder.create(); // Attache les listeners aux boutons howToPlayButton.setOnClickListener(v -> { dialog.dismiss(); // Ferme le menu showHowToPlayDialog(); // Ouvre la dialogue "Comment Jouer" }); settingsButton.setOnClickListener(v -> { dialog.dismiss(); // Ferme le menu showSettingsDialog(); // Ouvre la dialogue placeholder "Paramètres" }); aboutButton.setOnClickListener(v -> { dialog.dismiss(); // Ferme le menu showAboutDialog(); // Ouvre la dialogue "À Propos" }); returnButton.setOnClickListener(v -> { dialog.dismiss(); // Ferme simplement le menu }); dialog.show(); // Affiche la boîte de dialogue } /** * Affiche une boîte de dialogue expliquant les règles du jeu, * en utilisant un layout personnalisé pour une meilleure présentation. */ private void showHowToPlayDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_how_to_play, null); // Gonfle le layout builder.setView(dialogView); builder.setCancelable(true); // Permet de fermer en cliquant à côté // Récupère le bouton OK DANS la vue gonflée Button okButton = dialogView.findViewById(R.id.dialogOkButtonHowToPlay); final AlertDialog dialog = builder.create(); okButton.setOnClickListener(v -> dialog.dismiss()); // Ferme simplement dialog.show(); } /** * Affiche la boîte de dialogue des paramètres en utilisant un layout personnalisé. * Gère les interactions avec les différentes options. */ private void showSettingsDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_settings, null); builder.setView(dialogView).setCancelable(true); // Vues SwitchMaterial switchSound = dialogView.findViewById(R.id.switchSound); SwitchMaterial switchNotifications = dialogView.findViewById(R.id.switchNotifications); // Activé Button permissionsButton = dialogView.findViewById(R.id.buttonManagePermissions); Button shareStatsButton = dialogView.findViewById(R.id.buttonShareStats); Button resetStatsButton = dialogView.findViewById(R.id.buttonResetStats); Button quitAppButton = dialogView.findViewById(R.id.buttonQuitApp); Button closeButton = dialogView.findViewById(R.id.buttonCloseSettings); // Ajout boutons de test (optionnel, pour débugger) Button testNotifHS = new Button(this); // Crée programmatiquement testNotifHS.setText(R.string.settings_test_notif_highscore); Button testNotifInactiv = new Button(this); testNotifInactiv.setText(R.string.settings_test_notif_inactivity); // Ajouter ces boutons au layout 'dialogView' si nécessaire (ex: ((LinearLayout)dialogView).addView(...) ) final AlertDialog dialog = builder.create(); // Config Son (MAINTENANT ACTIF) switchSound.setEnabled(true); // Activé switchSound.setChecked(soundEnabled); // État actuel chargé switchSound.setOnCheckedChangeListener((buttonView, isChecked) -> { soundEnabled = isChecked; // Met à jour l'état saveSoundPreference(isChecked); // Sauvegarde la préférence Toast.makeText(this, isChecked ? R.string.sound_enabled : R.string.sound_disabled, Toast.LENGTH_SHORT).show(); }); // Config Notifications (Activé + Gestion Permission) switchNotifications.setEnabled(true); // Activé switchNotifications.setChecked(notificationsEnabled); // État actuel switchNotifications.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { // L'utilisateur VEUT activer les notifications requestNotificationPermission(); // Demande la permission si nécessaire } else { // L'utilisateur désactive les notifications notificationsEnabled = false; saveNotificationPreference(false); Toast.makeText(this, R.string.notifications_disabled, Toast.LENGTH_SHORT).show(); // Ici, annuler les éventuelles notifications planifiées (WorkManager/AlarmManager) } }); // Listeners autres boutons (Permissions, Share, Reset, Quit, Close) permissionsButton.setOnClickListener(v -> { openAppSettings(); dialog.dismiss(); }); shareStatsButton.setOnClickListener(v -> { shareStats(); dialog.dismiss(); }); resetStatsButton.setOnClickListener(v -> { dialog.dismiss(); showResetStatsConfirmationDialog(); }); quitAppButton.setOnClickListener(v -> { dialog.dismiss(); finishAffinity(); }); closeButton.setOnClickListener(v -> dialog.dismiss()); // Listeners boutons de test (si ajoutés) testNotifHS.setOnClickListener(v -> { showHighScoreNotification(gameStats.getOverallHighScore()); }); testNotifInactiv.setOnClickListener(v -> { showInactivityNotification(); }); dialog.show(); } /** Met à jour l'état du switch notification (utile si permission refusée). */ private void updateNotificationSwitchState(boolean isEnabled) { // Si la vue des paramètres est actuellement affichée, met à jour le switch View settingsDialogView = getLayoutInflater().inflate(R.layout.dialog_settings, null); // Attention, regonfler n'est pas idéal // Mieux: Garder une référence à la vue ou au switch si la dialog est affichée. // Pour la simplicité ici, on suppose qu'il faut rouvrir les paramètres pour voir le changement. } /** Sauvegarde la préférence d'activation des notifications. */ private void saveNotificationPreference(boolean enabled) { if (preferences != null) { preferences.edit().putBoolean("notifications_enabled", enabled).apply(); } } /** Charge la préférence d'activation des notifications. */ private void loadNotificationPreference() { if (preferences != null) { // Change le défaut de true à false notificationsEnabled = preferences.getBoolean("notifications_enabled", false); } else { notificationsEnabled = false; // Assure une valeur par défaut si prefs est null } } /** Ouvre les paramètres système de l'application. */ private void openAppSettings() { Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", getPackageName(), null); intent.setData(uri); try { startActivity(intent); } catch (ActivityNotFoundException e) { Toast.makeText(this, "Impossible d'ouvrir les paramètres.", Toast.LENGTH_LONG).show(); } } // --- Gestion des Permissions (Notifications) --- /** Demande la permission POST_NOTIFICATIONS si nécessaire (Android 13+). */ private void requestNotificationPermission() { // Vérifie si on est sur Android 13+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Vérifie si la permission n'est PAS déjà accordée if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { // Demande la permission requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); // Le résultat sera géré dans le callback du requestPermissionLauncher } else { // La permission est déjà accordée, on peut activer directement notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); // Planifier les notifications ici si ce n'est pas déjà fait } } else { // Sur les versions antérieures à Android 13, pas besoin de demander la permission notificationsEnabled = true; saveNotificationPreference(true); Toast.makeText(this, R.string.notifications_enabled, Toast.LENGTH_SHORT).show(); // Planifier les notifications ici } } // --- Logique de Notification --- /** * Crée l'Intent qui sera lancé au clic sur une notification (ouvre MainActivity). * @return PendingIntent configuré. */ private PendingIntent createNotificationTapIntent() { Intent intent = new Intent(this, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); // Ouvre l'app ou la ramène devant // FLAG_IMMUTABLE est requis pour Android 12+ int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; return PendingIntent.getActivity(this, 0, intent, flags); } /** * Construit et affiche une notification simple. * @param context Contexte. * @param title Titre de la notification. * @param message Corps du message de la notification. * @param notificationId ID unique pour cette notification. */ private void showNotification(Context context, String title, String message, int notificationId) { // Vérifie si les notifications sont activées et si la permission est accordée (pour Android 13+) if (!notificationsEnabled || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)) { // Ne pas envoyer la notification si désactivé ou permission manquante return; } NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône .setContentTitle(title) .setContentText(message) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(createNotificationTapIntent()) // Action au clic .setAutoCancel(true); // Ferme la notif après clic NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); // L'ID notificationId est utilisé pour mettre à jour une notif existante ou en afficher une nouvelle notificationManager.notify(notificationId, builder.build()); } /** Démarre le NotificationService s'il n'est pas déjà lancé. */ private void startNotificationService() { Intent serviceIntent = new Intent(this, NotificationService.class); // Utiliser startForegroundService pour API 26+ si le service doit faire qqch rapidement // mais pour une tâche périodique simple startService suffit. startService(serviceIntent); } /** Arrête le NotificationService. */ private void stopNotificationService() { Intent serviceIntent = new Intent(this, NotificationService.class); stopService(serviceIntent); } /** Affiche la notification d'accomplissement via le NotificationHelper. */ private void showAchievementNotification(int tileValue) { // Vérifie l'état global avant d'envoyer (au cas où désactivé entre-temps) if (!notificationsEnabled) return; String title = getString(R.string.notification_title_achievement); String message = getString(R.string.notification_text_achievement, tileValue); NotificationHelper.showNotification(this, title, message, 1); // ID 1 pour achievement } /** Affiche la notification de rappel du meilleur score (pour test). */ private void showHighScoreNotification(int highScore) { String title = getString(R.string.notification_title_highscore); String message = getString(R.string.notification_text_highscore, highScore); // Utiliser un ID spécifique (ex: 2) showNotification(this, title, message, 2); // NOTE: La planification réelle utiliserait WorkManager/AlarmManager } /** Affiche la notification de rappel d'inactivité (pour test). */ private void showInactivityNotification() { String title = getString(R.string.notification_title_inactivity); String message = getString(R.string.notification_text_inactivity); // Utiliser un ID spécifique (ex: 3) showNotification(this, title, message, 3); // NOTE: La planification réelle utiliserait WorkManager/AlarmManager et suivi du temps } /** * Crée et lance une Intent pour partager les statistiques du joueur. */ private void shareStats() { if (gameStats == null) return; // Construit le message à partager String shareBody = getString(R.string.share_stats_body, gameStats.getOverallHighScore(), gameStats.getHighestTile(), gameStats.getNumberOfTimesObjectiveReached(), gameStats.getTotalGamesStarted(), // Ou totalGamesPlayed ? GameStats.formatTime(gameStats.getTotalPlayTimeMs()), gameStats.getTotalMoves() ); Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_stats_subject)); shareIntent.putExtra(Intent.EXTRA_TEXT, shareBody); try { startActivity(Intent.createChooser(shareIntent, getString(R.string.share_stats_title))); } catch (ActivityNotFoundException e) { Toast.makeText(this, "Aucune application de partage disponible.", Toast.LENGTH_SHORT).show(); } } /** * Affiche une boîte de dialogue pour confirmer la réinitialisation des statistiques. */ private void showResetStatsConfirmationDialog() { new AlertDialog.Builder(this) .setTitle(R.string.reset_stats_confirm_title) .setMessage(R.string.reset_stats_confirm_message) .setPositiveButton(R.string.confirm, (dialog, which) -> { resetStatistics(); // Appelle la méthode de réinitialisation dialog.dismiss(); }) .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) .setIcon(android.R.drawable.ic_dialog_alert) // Icône d'avertissement .show(); } /** * Réinitialise toutes les statistiques via GameStats et sauvegarde les changements. * Affiche une confirmation à l'utilisateur. */ private void resetStatistics() { if (gameStats != null) { gameStats.resetStats(); // Réinitialise les stats dans l'objet gameStats.saveStats(); // Sauvegarde les stats réinitialisées // Met aussi à jour le highScore de l'objet Game courant (si une partie est en cours) if(game != null){ game.setHighestScore(gameStats.getOverallHighScore()); // Le HS est aussi reset dans GameStats updateScores(); // Rafraichit l'affichage du HS si visible } Toast.makeText(this, R.string.stats_reset_confirmation, Toast.LENGTH_SHORT).show(); // Si les stats étaient visibles, on pourrait vouloir les rafraîchir if (statisticsVisible && inflatedStatsView != null) { updateStatisticsTextViews(); } } } /** * Affiche la boîte de dialogue "À Propos" en utilisant un layout personnalisé, * incluant des informations sur l'application et un lien cliquable vers le site web. */ private void showAboutDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); LayoutInflater inflater = getLayoutInflater(); View dialogView = inflater.inflate(R.layout.dialog_about, null); // Gonfle le layout builder.setView(dialogView); builder.setCancelable(true); // Permet de fermer // Récupère les vues du layout TextView websiteLinkTextView = dialogView.findViewById(R.id.websiteLinkTextView); Button okButton = dialogView.findViewById(R.id.dialogOkButtonAbout); final AlertDialog dialog = builder.create(); // Rend le lien cliquable pour ouvrir le navigateur websiteLinkTextView.setOnClickListener(v -> { Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_website_url))); try { startActivity(browserIntent); } catch (ActivityNotFoundException e) { // Gère le cas où aucun navigateur n'est installé Toast.makeText(this, "Aucun navigateur web trouvé.", Toast.LENGTH_SHORT).show(); } }); // Bouton OK pour fermer la dialogue okButton.setOnClickListener(v -> dialog.dismiss()); dialog.show(); } // --- Gestion Stats UI --- /** * Affiche ou masque le panneau de statistiques. * Gonfle le layout via ViewStub si c'est la première fois. */ private void toggleStatistics() { statisticsVisible = !statisticsVisible; if (statisticsVisible) { if (inflatedStatsView == null) { // Gonfle si pas encore fait inflatedStatsView = statisticsViewStub.inflate(); // Attache listener au bouton Back une fois la vue gonflée Button backButton = inflatedStatsView.findViewById(R.id.backButton); backButton.setOnClickListener(v -> toggleStatistics()); // Recliquer sur Back re-appelle toggle } updateStatisticsTextViews(); // Remplit les champs avec les données actuelles inflatedStatsView.setVisibility(View.VISIBLE); // Affiche multiplayerButton.setVisibility(View.GONE); // Masque bouton multi } else { if (inflatedStatsView != null) { // Masque si la vue existe inflatedStatsView.setVisibility(View.GONE); } multiplayerButton.setVisibility(View.VISIBLE); // Réaffiche bouton multi } } /** * Remplit les TextViews du panneau de statistiques avec les données de l'objet GameStats. * Doit être appelé seulement après que `inflatedStatsView` a été initialisé. */ private void updateStatisticsTextViews() { if (inflatedStatsView == null || gameStats == null) return; // Récupération des TextViews dans la vue gonflée TextView highScoreStatsLabel = inflatedStatsView.findViewById(R.id.high_score_stats_label); TextView totalGamesPlayedLabel = inflatedStatsView.findViewById(R.id.total_games_played_label); TextView totalGamesStartedLabel = inflatedStatsView.findViewById(R.id.total_games_started_label); TextView winPercentageLabel = inflatedStatsView.findViewById(R.id.win_percentage_label); TextView totalPlayTimeLabel = inflatedStatsView.findViewById(R.id.total_play_time_label); TextView totalMovesLabel = inflatedStatsView.findViewById(R.id.total_moves_label); TextView currentMovesLabel = inflatedStatsView.findViewById(R.id.current_moves_label); TextView currentGameTimeLabel = inflatedStatsView.findViewById(R.id.current_game_time_label); TextView averageGameTimeLabel = inflatedStatsView.findViewById(R.id.average_game_time_label); TextView bestWinningTimeLabel = inflatedStatsView.findViewById(R.id.best_winning_time_label); TextView worstWinningTimeLabel = inflatedStatsView.findViewById(R.id.worst_winning_time_label); TextView totalMergesLabel = inflatedStatsView.findViewById(R.id.total_merges_label); TextView highestTileLabel = inflatedStatsView.findViewById(R.id.highest_tile_label); TextView objectiveReachedLabel = inflatedStatsView.findViewById(R.id.number_of_time_objective_reached_label); TextView perfectGameLabel = inflatedStatsView.findViewById(R.id.perfect_game_label); TextView multiplayerGamesWonLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_won_label); TextView multiplayerGamesPlayedLabel = inflatedStatsView.findViewById(R.id.multiplayer_games_played_label); TextView multiplayerWinRateLabel = inflatedStatsView.findViewById(R.id.multiplayer_win_rate_label); TextView multiplayerBestWinningStreakLabel = inflatedStatsView.findViewById(R.id.multiplayer_best_winning_streak_label); TextView multiplayerAverageScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_average_score_label); TextView averageTimePerGameMultiLabel = inflatedStatsView.findViewById(R.id.average_time_per_game_label); // Potentiel ID dupliqué dans layout? TextView totalMultiplayerLossesLabel = inflatedStatsView.findViewById(R.id.total_multiplayer_losses_label); TextView multiplayerHighScoreLabel = inflatedStatsView.findViewById(R.id.multiplayer_high_score_label); TextView mergesThisGameLabel = inflatedStatsView.findViewById(R.id.merges_this_game); // MAJ textes avec getters de gameStats highScoreStatsLabel.setText(getString(R.string.high_score_stats, gameStats.getOverallHighScore())); totalGamesPlayedLabel.setText(getString(R.string.total_games_played, gameStats.getTotalGamesPlayed())); totalGamesStartedLabel.setText(getString(R.string.total_games_started, gameStats.getTotalGamesStarted())); totalMovesLabel.setText(getString(R.string.total_moves, gameStats.getTotalMoves())); currentMovesLabel.setText(getString(R.string.current_moves, gameStats.getCurrentMoves())); mergesThisGameLabel.setText(getString(R.string.merges_this_game_label, gameStats.getMergesThisGame())); totalMergesLabel.setText(getString(R.string.total_merges, gameStats.getTotalMerges())); highestTileLabel.setText(getString(R.string.highest_tile, gameStats.getHighestTile())); objectiveReachedLabel.setText(getString(R.string.number_of_time_objective_reached, gameStats.getNumberOfTimesObjectiveReached())); perfectGameLabel.setText(getString(R.string.perfect_games, gameStats.getPerfectGames())); multiplayerGamesWonLabel.setText(getString(R.string.multiplayer_games_won, gameStats.getMultiplayerGamesWon())); multiplayerGamesPlayedLabel.setText(getString(R.string.multiplayer_games_played, gameStats.getMultiplayerGamesPlayed())); multiplayerBestWinningStreakLabel.setText(getString(R.string.multiplayer_best_winning_streak, gameStats.getMultiplayerBestWinningStreak())); multiplayerAverageScoreLabel.setText(getString(R.string.multiplayer_average_score, gameStats.getMultiplayerAverageScore())); totalMultiplayerLossesLabel.setText(getString(R.string.total_multiplayer_losses, gameStats.getTotalMultiplayerLosses())); multiplayerHighScoreLabel.setText(getString(R.string.multiplayer_high_score, gameStats.getMultiplayerHighestScore())); // Calculs Pourcentages String winPercentage = (gameStats.getTotalGamesStarted() > 0) ? String.format("%.2f%%", ((double) gameStats.getNumberOfTimesObjectiveReached() / gameStats.getTotalGamesStarted()) * 100) : "N/A"; winPercentageLabel.setText(getString(R.string.win_percentage, winPercentage)); String multiplayerWinRate = (gameStats.getMultiplayerGamesPlayed() > 0) ? String.format("%.2f%%", ((double) gameStats.getMultiplayerGamesWon() / gameStats.getMultiplayerGamesPlayed()) * 100) : "N/A"; multiplayerWinRateLabel.setText(getString(R.string.multiplayer_win_rate, multiplayerWinRate)); // Calculs Temps totalPlayTimeLabel.setText(getString(R.string.total_play_time, GameStats.formatTime(gameStats.getTotalPlayTimeMs()))); // Calcule le temps de la partie en cours seulement si elle n'est pas finie long currentDurationMs = 0; if (game != null && gameStats != null && currentGameState == GameFlowState.PLAYING && gameStats.getCurrentGameStartTimeMs() > 0) { currentDurationMs = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); } currentGameTimeLabel.setText(getString(R.string.current_game_time, GameStats.formatTime(currentDurationMs))); averageGameTimeLabel.setText(getString(R.string.average_time_per_game, GameStats.formatTime(gameStats.getAverageGameTimeMs()))); averageTimePerGameMultiLabel.setText(getString(R.string.average_time_per_game_label, GameStats.formatTime(gameStats.getMultiplayerAverageTimeMs()))); // Assurez-vous que l'ID R.string.average_time_per_game_label est correct bestWinningTimeLabel.setText(getString(R.string.best_winning_time, (gameStats.getBestWinningTimeMs() != Long.MAX_VALUE) ? GameStats.formatTime(gameStats.getBestWinningTimeMs()) : "N/A")); worstWinningTimeLabel.setText(getString(R.string.worst_winning_time, (gameStats.getWorstWinningTimeMs() != 0) ? GameStats.formatTime(gameStats.getWorstWinningTimeMs()) : "N/A")); } // --- Placeholders Multi --- /** Affiche un dialogue placeholder pour le multijoueur. */ private void showMultiplayerScreen() { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("Multijoueur").setMessage("Fonctionnalité multijoueur à venir !").setPositiveButton("OK", null); builder.create().show(); } // --- Sauvegarde / Chargement --- /** Sauvegarde l'état du jeu et le meilleur score via SharedPreferences. */ private void saveGame() { SharedPreferences.Editor editor = preferences.edit(); if (game != null) { editor.putString(GAME_STATE_KEY, game.toString()); // Sérialise Game (plateau + score courant) // Le meilleur score est géré et sauvegardé par GameStats, mais on le sauve aussi ici pour la synchro au chargement editor.putInt(HIGH_SCORE_KEY, game.getHighestScore()); } else { editor.remove(GAME_STATE_KEY); // Optionnel: nettoyer si pas de jeu } editor.apply(); // Utilise apply() pour une sauvegarde asynchrone } /** Charge l'état du jeu depuis SharedPreferences et synchronise le meilleur score. */ private void loadGame() { String gameStateString = preferences.getString(GAME_STATE_KEY, null); // Charge le meilleur score depuis les préférences (sera aussi chargé par GameStats mais on l'utilise ici pour Game) int savedHighScore = preferences.getInt(HIGH_SCORE_KEY, 0); // Assure que GameStats charge son état (y compris le HS global) if (gameStats == null) { gameStats = new GameStats(this); } // Précaution gameStats.loadStats(); // Charge explicitement les stats (ce qui devrait inclure le HS global) // S'assure que le HS chargé par gameStats est cohérent avec celui des prefs directes if (savedHighScore > gameStats.getOverallHighScore()) { gameStats.setHighestScore(savedHighScore); // Assure que GameStats a au moins le HS trouvé ici } else { savedHighScore = gameStats.getOverallHighScore(); // Utilise le HS de GameStats s'il est plus grand } Game loadedGame = null; if (gameStateString != null) { loadedGame = Game.deserialize(gameStateString); } if (loadedGame != null) { game = loadedGame; game.setHighestScore(savedHighScore); // Applique le HS synchronisé // Détermine l'état basé sur le jeu chargé if (game.isGameOver()) { currentGameState = GameFlowState.GAME_OVER; } else if (game.isGameWon()) { // Si on charge une partie déjà gagnée, on considère qu'on a déjà vu la dialog currentGameState = GameFlowState.WON_DIALOG_SHOWN; } else { currentGameState = GameFlowState.PLAYING; // Le timer sera (re)démarré dans onResume si l'état est PLAYING } } else { // Pas de sauvegarde valide ou erreur de désérialisation -> Commence une nouvelle partie implicitement game = null; // Sera géré par l'appel à startNewGame dans initializeGameAndStats } } } // Fin MainActivity// Fichier NotificationHelper.java package legion.muyue.best2048; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; // Pour checkSelfPermission /** * Classe utilitaire pour simplifier la création et l'affichage des notifications * et la gestion du canal de notification pour l'application Best 2048. */ public class NotificationHelper { /** Identifiant unique du canal de notification pour cette application. */ public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant /** * Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur. * Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif). * Doit être appelée avant d'afficher la première notification sur API 26+. * * @param context Contexte applicatif. */ public static void createNotificationChannel(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { CharSequence name = context.getString(R.string.notification_channel_name); String description = context.getString(R.string.notification_channel_description); int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); channel.setDescription(description); // Enregistre le canal auprès du système. NotificationManager notificationManager = context.getSystemService(NotificationManager.class); if (notificationManager != null) { notificationManager.createNotificationChannel(channel); } } } /** * Construit et affiche une notification. * Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher. * * @param context Contexte (peut être une Activity ou un Service). * @param title Titre de la notification. * @param message Contenu texte de la notification. * @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler). */ public static void showNotification(Context context, String title, String message, int notificationId) { // Vérification de la permission pour Android 13+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { // Si la permission n'est pas accordée, ne pas tenter d'afficher la notification. // L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode // si elle sait que l'utilisateur a activé les notifications dans les paramètres. System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée."); return; } } // Intent pour ouvrir MainActivity au clic Intent intent = new Intent(context, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags); // Construction de la notification via NotificationCompat pour la compatibilité NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification .setContentTitle(title) .setContentText(message) .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale .setContentIntent(pendingIntent) // Action au clic .setAutoCancel(true); // Ferme la notification après le clic // Affichage de la notification NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); try { notificationManager.notify(notificationId, builder.build()); } catch (SecurityException e){ // Gérer l'exception de sécurité qui peut survenir même avec la vérification ci-dessus dans certains cas limites System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage()); } } }// Fichier NotificationService.java package legion.muyue.best2048; import android.app.Service; import android.content.Intent; import android.content.SharedPreferences; import android.os.Handler; import android.os.IBinder; import android.os.Looper; // Important pour créer un Handler sur le Main Thread import androidx.annotation.Nullable; import java.util.concurrent.TimeUnit; /** * Service exécuté en arrière-plan pour envoyer des notifications périodiques * (rappel de meilleur score, rappel d'inactivité). * Utilise un Handler pour planifier les tâches répétitives. * NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée), * WorkManager serait préférable en production. */ public class NotificationService extends Service { private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs private static final int NOTIFICATION_ID_INACTIVITY = 3; // Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité) private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1); private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3); private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent private Handler handler; private Runnable periodicTaskRunnable; // Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs) private static final String PREFS_NAME = "Best2048_Prefs"; private static final String HIGH_SCORE_KEY = "high_score"; private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé @Override public void onCreate() { super.onCreate(); // Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue) // Pour des tâches plus lourdes, utiliser HandlerThread handler = new Handler(Looper.getMainLooper()); // Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage // NotificationHelper.createNotificationChannel(this); periodicTaskRunnable = new Runnable() { @Override public void run() { // Vérifie périodiquement s'il faut envoyer une notification checkAndSendNotifications(); // Replanifie la tâche handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures } }; } @Override public int onStartCommand(Intent intent, int flags, int startId) { // Lance la tâche périodique lors du démarrage du service handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification // START_STICKY : Le système essaiera de recréer le service s'il est tué. return START_STICKY; } @Override public void onDestroy() { super.onDestroy(); // Arrête la planification des tâches lorsque le service est détruit if (handler != null && periodicTaskRunnable != null) { handler.removeCallbacks(periodicTaskRunnable); } } @Nullable @Override public IBinder onBind(Intent intent) { // Service non lié (Started Service) return null; } /** * Vérifie les conditions et envoie les notifications périodiques si nécessaire. */ private void checkAndSendNotifications() { SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé if (!notificationsEnabled) { // Si désactivé dans les prefs, on arrête potentiellement le service? // Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer. // stopSelf(); // Arrêterait le service return; } // --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) --- // Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici) // Pour une vraie app, il faudrait une logique pour ne pas spammer. // Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ? int highScore = prefs.getInt(HIGH_SCORE_KEY, 0); // Temporairement on l'envoie à chaque check pour test (à modifier!) // if (shouldSendHighScoreNotification()) { showHighScoreNotificationNow(highScore); // } // --- Notification d'Inactivité --- long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0); if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) { // Si l'inactivité dépasse le seuil showInactivityNotificationNow(); // Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ? // Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause. } } /** Affiche la notification High Score */ private void showHighScoreNotificationNow(int highScore) { String title = getString(R.string.notification_title_highscore); String message = getString(R.string.notification_text_highscore, highScore); NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_HIGHSCORE); } /** Affiche la notification d'Inactivité */ private void showInactivityNotificationNow() { String title = getString(R.string.notification_title_inactivity); String message = getString(R.string.notification_text_inactivity); NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_INACTIVITY); } // Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques // private boolean shouldSendHighScoreNotification() { ... } }// Fichier OnSwipeTouchListener.java /** * Listener de vue personnalisé qui détecte les gestes de balayage (swipe) * dans les quatre directions cardinales et notifie un listener externe. * Utilise {@link GestureDetector} pour l'analyse des gestes. */ package legion.muyue.best2048; import android.annotation.SuppressLint; import android.content.Context; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; public class OnSwipeTouchListener implements View.OnTouchListener { /** Détecteur de gestes standard d'Android. */ private final GestureDetector gestureDetector; /** Listener externe à notifier lors de la détection d'un swipe. */ private final SwipeListener listener; /** * Interface à implémenter par les classes souhaitant réagir aux événements de swipe. */ public interface SwipeListener { /** Appelée lorsqu'un swipe vers le haut est détecté. */ void onSwipeTop(); /** Appelée lorsqu'un swipe vers le bas est détecté. */ void onSwipeBottom(); /** Appelée lorsqu'un swipe vers la gauche est détecté. */ void onSwipeLeft(); /** Appelée lorsqu'un swipe vers la droite est détecté. */ void onSwipeRight(); } /** * Constructeur. * @param context Contexte applicatif, nécessaire pour `GestureDetector`. * @param listener Instance qui recevra les notifications de swipe. Ne doit pas être null. */ public OnSwipeTouchListener(Context context, @NonNull SwipeListener listener) { this.gestureDetector = new GestureDetector(context, new GestureListener()); this.listener = listener; } /** * Intercepte les événements tactiles sur la vue associée et les délègue * au {@link GestureDetector} pour analyse. * @param v La vue touchée. * @param event L'événement tactile. * @return true si le geste a été consommé par le détecteur, false sinon. */ @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouch(View v, MotionEvent event) { // Passe l'événement au GestureDetector. Si ce dernier le gère (ex: détecte un onFling), // il retournera true, et l'événement ne sera pas propagé davantage. return gestureDetector.onTouchEvent(event); } /** * Classe interne implémentant l'écouteur de gestes pour détecter le 'fling' (balayage rapide). */ private final class GestureListener extends GestureDetector.SimpleOnGestureListener { /** Distance minimale (en pixels) pour qu'un mouvement soit considéré comme un swipe. */ private static final int SWIPE_THRESHOLD = 100; /** Vitesse minimale (en pixels/sec) pour qu'un mouvement soit considéré comme un swipe. */ private static final int SWIPE_VELOCITY_THRESHOLD = 100; /** * Toujours retourner true pour onDown garantit que les événements suivants * (comme onFling) seront bien reçus par ce listener. */ @Override public boolean onDown(@NonNull MotionEvent e) { return true; } /** * Appelée quand un geste de 'fling' (balayage rapide) est détecté. * Analyse la direction et la vitesse pour déterminer s'il s'agit d'un swipe valide * et notifie le {@link SwipeListener} externe. */ @Override public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { if (e1 == null) return false; // Point de départ est nécessaire boolean result = false; try { float diffY = e2.getY() - e1.getY(); float diffX = e2.getX() - e1.getX(); // Priorité au mouvement le plus ample (horizontal ou vertical) if (Math.abs(diffX) > Math.abs(diffY)) { // Mouvement principalement horizontal if (Math.abs(diffX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { if (diffX > 0) { listener.onSwipeRight(); } else { listener.onSwipeLeft(); } result = true; // Geste horizontal traité } } else { // Mouvement principalement vertical if (Math.abs(diffY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { if (diffY > 0) { listener.onSwipeBottom(); } else { listener.onSwipeTop(); } result = true; // Geste vertical traité } } } catch (Exception exception) { // En cas d'erreur inattendue, on logue discrètement. System.err.println("Erreur dans OnSwipeTouchListener.onFling: " + exception.getMessage()); // Ne pas crasher l'application pour une erreur de détection de geste. } return result; } } } // Fin OnSwipeTouchListener #FF000000 #FFFFFFFF #776e65 #f9f6f2 #faf8ef #bbada0 #f65e3b #edc22e #8f7a66 #f9f6f2 #cdc1b4 #eee4da #ede0c8 #f2b179 #f59563 #f67c5f #f65e3b #edcf72 #edcc61 #edc850 #edc53f #edc22e #3c3a32 #6200ee #3700b3 #3700b3 #03dac6 #b0bec5 #875932 16dp 8dp 48sp 18sp 6dp 4dp 4dp 24sp 20sp 16sp Best 2048 2048 Score : Score :\n%d High Score :\n%d High Score : Restart Stats Menu Multiplayer Restart ? Are you sure you want to restart the game ? Cancel Confirm High Score: %d Total Games Played: %d Total Games Started: %d Win Percentage: %s Total Play Time: %s Total Moves: %d Current Moves: %d Current Game Time: %s Merges: %d Average Game Time: %s Best Winning Time: %s Worst Winning Time: %s Total Merges: %d Highest Tile: %d Number of time objective reached: %d Perfect game : %d Multiplayer game won : %d Multiplayer game played : %d Multiplayer win rate : %s Best winning streak : %d Multiplayer Average Score: %d Average time per game: %s Total Multiplayer losses: %d Multiplayer High Score: %d Stats Statistics General Current Game Single Player Multiplayer Back You won! Congratulations, you\'ve reached 2048! Continue New Part Game over! No move possible.\nFinal score: %d To leave Main Menu How to Play Settings About Back How to Play Swipe the screen (Up, Down, Left, Right) to move all the tiles.\n\nWhen two tiles with the same number touch, they merge into one!\n\nReach the 2048 tile to win.\n\nThe game is over if the board is full and no moves are possible. About Best 2048 Version: 1.0 (University Project)\nDeveloped by: La Legion de Muyue\n(Leader: Muyue, Members: 2 others)\n\nBased on the popular game 2048. Website : legion-muyue.fr https://legion-muyue.fr OK Settings Sound Notifications Manage Permissions Share my Statistics Reset Statistics Quit Application Close Reset Stats? Are you sure you want to erase all your saved statistics? This action is irreversible. Share my stats via… My 2048 Statistics Here are my stats on Best 2048:\n- Best Score: %d\n- Highest Tile: %d\n- Games Won: %d / %d\n- Total Time: %s\n- Total Moves: %d Statistics reset. Game Updates Notifications related to the 2048 game Congratulations! You reached the %d tile! New Challenge! Your best score is %d. Can you do better? We miss you! How about a quick game of 2048 to relax? Permission Required To receive notifications (reminders, achievements), please allow the application to send notifications in the settings. Go to Settings Notifications enabled. Notifications disabled. Test High Score Notif Test Inactivity Notif Sound effects enabled. Sound effects disabled. @font/nunito_family match_parent 0dp 1 @color/score_label_background center 8dp @color/white @dimen/text_size_score_label @font/nunito_family 0dp wrap_content @color/button_background @color/white @dimen/padding_general @dimen/padding_general @dimen/padding_general @dimen/padding_general ?android:attr/buttonBarButtonStyle @font/nunito_family 0dp wrap_content @dimen/padding_general @dimen/padding_general ?android:attr/buttonBarButtonStyle @dimen/padding_general @dimen/padding_general @drawable/button_multiplayer_background @color/white true bold 20sp @font/nunito_family 18sp bold @color/text_tile_low 4dp @font/nunito_family match_parent wrap_content vertical @drawable/dialog_background 12dp 16dp @font/nunito_family match_parent wrap_content @color/text_tile_low 16sp 4dp true @null