Refactor: Nettoyage code, documentation et raffinements finaux

- **Cleanup:** Suppression logs de débogage superflus, commentaires obsolètes. Standardisation logging.
- **Documentation:** Ajout/Amélioration significative des commentaires JavaDoc (Game, GameStats, MainActivity, etc.).
- **Game Logic:** Refactoring des méthodes pushX en processMove. Ajout validation et helpers (isIndexValid). Amélioration robustesse (deserialize, win/loss checks). Classe rendue totalement indépendante du contexte/prefs.
- **Stats:** Suppression stat 'perfectGames'. Amélioration formatage temps/moyennes.
- **Notifications:** Logique de cooldown/intervalle affinée dans NotificationService. Utilisation centralisée de NotificationHelper. Gestion permission robuste.
- **MainActivity:** Refactoring affichage plateau (syncBoardView avec fonds + tuiles). Animation simplifiée (apparition/fusion seulement). Gestion améliorée des états de jeu (PLAYING, WON, GAME_OVER). Logique onResume/onPause/load/save affinée. Placeholders pour Menu/Multi clarifiés.
- **UI:** Layouts de dialogues standardisés avec LinearLayout. Permissions AndroidManifest nettoyées. Ajustements mineurs (marges, couleurs, strings, style police).
This commit is contained in:
Augustin ROUX 2025-04-04 18:40:08 +02:00
parent 76d0976575
commit bee192e335
7 changed files with 5888 additions and 1072 deletions

View File

@ -4,16 +4,6 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -22,7 +12,9 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Best2048">
android:theme="@style/Theme.Best2048"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
@ -32,6 +24,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".NotificationService"
android:enabled="true"

View File

@ -1,14 +1,15 @@
// 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).
* Représente la logique métier du jeu 2048.
* Gère l'état du plateau de jeu, les déplacements des tuiles, les fusions,
* le score de la partie en cours, ainsi que les conditions de victoire ou de défaite.
* Cette classe est conçue pour être indépendante du framework Android, ne contenant
* aucune dépendance au Contexte Android ou aux SharedPreferences.
*/
package legion.muyue.best2048;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting; // Pour les méthodes de test éventuelles
import androidx.annotation.Nullable; // Ajout pour le retour de deserialize
import androidx.annotation.VisibleForTesting;
import java.util.ArrayList;
import java.util.List;
@ -16,24 +17,26 @@ import java.util.Random;
public class Game {
/** La taille du plateau de jeu (nombre de lignes/colonnes, typiquement 4x4). */
private static final int BOARD_SIZE = 4;
/** 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). */
/** Meilleur score global connu par cette instance (généralement défini depuis l'extérieur). */
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. */
/** Indicateur si la condition de victoire (une tuile >= 2048) a été atteinte. */
private boolean gameWon = false;
/** Indicateur si la partie est terminée (plus de mouvements possibles). */
/** Indicateur si la partie est terminée (plus de mouvements ou de fusions possibles). */
private boolean gameOver = false;
/**
* Constructeur pour démarrer une nouvelle partie.
* Initialise un plateau vide, le score à 0, et ajoute deux tuiles initiales.
* Initialise un plateau vide, met le score courant à 0, réinitialise les états
* de victoire/défaite, et ajoute deux tuiles initiales aléatoirement.
*/
public Game() {
this.randomNumberGenerator = new Random();
@ -43,81 +46,145 @@ public class Game {
/**
* Constructeur pour restaurer une partie à partir d'un état sauvegardé.
* Le meilleur score (`highestScore`) doit être défini séparément via {@link #setHighestScore(int)}.
* Recalcule les états `gameWon` et `gameOver` en fonction du plateau fourni.
* Recalcule les états `gameWon` et `gameOver` en fonction du plateau et du score fournis.
* Lance une exception si le plateau fourni n'a pas les bonnes dimensions.
*
* @param board Le plateau de jeu restauré.
* @param board Le plateau de jeu restauré. Doit être de dimensions BOARD_SIZE x BOARD_SIZE.
* @param score Le score courant restauré.
* @throws IllegalArgumentException si les dimensions du plateau fourni sont incorrectes.
*/
public Game(int[][] board, int score) {
// Valider les dimensions du plateau fourni ? Pourrait être ajouté.
this.board = board;
public Game(@NonNull int[][] board, int score) {
// Validation des dimensions du plateau
if (board == null || board.length != BOARD_SIZE || board[0].length != BOARD_SIZE) {
throw new IllegalArgumentException("Le plateau fourni n'a pas les dimensions attendues (" + BOARD_SIZE + "x" + BOARD_SIZE + ").");
}
this.board = board; // Attention: shallow copy si board est modifié ailleurs après coup. getBoard() est plus sûr.
// Pour la restauration, on suppose que le tableau fourni est destiné à cet usage unique.
this.currentScore = score;
this.randomNumberGenerator = new Random();
// Recalculer l'état de victoire/défaite basé sur le plateau chargé
checkWinCondition();
checkGameOverCondition();
// Vérifier si la partie chargée est déjà terminée
checkGameOverCondition(); // Important si on charge une partie déjà terminée
}
// --- Getters / Setters ---
/**
* Retourne la valeur de la tuile aux coordonnées spécifiées.
* @param row Ligne (0 à BOARD_SIZE-1).
* @param column Colonne (0 à BOARD_SIZE-1).
* @return Valeur de la tuile, ou 0 si les coordonnées sont invalides.
* Retourne la valeur de la tuile aux coordonnées spécifiées (ligne, colonne).
* Les indices sont basés sur 0.
*
* @param row Ligne de la cellule (0 à BOARD_SIZE-1).
* @param column Colonne de la cellule (0 à BOARD_SIZE-1).
* @return La valeur de la tuile, ou 0 si la cellule est vide ou les coordonnées sont invalides.
*/
public int getCellValue(int row, int column) {
if (isIndexValid(row, column)) {
return this.board[row][column];
}
return 0; // Retourne 0 pour indice invalide
// Log ou gestion d'erreur pourrait être ajouté si un accès invalide est critique
return 0; // Retourne 0 pour indice invalide comme convention
}
/**
* Définit la valeur d'une tuile aux coordonnées spécifiées.
* Ne fait rien si les coordonnées sont invalides.
* @param row Ligne (0 à BOARD_SIZE-1).
* @param col Colonne (0 à BOARD_SIZE-1).
* @param value Nouvelle valeur de la tuile.
* Utilisé principalement en interne ou pour les tests. Ne fait rien si
* les coordonnées (ligne, colonne) sont en dehors des limites du plateau.
*
* @param row Ligne de la cellule (0 à BOARD_SIZE-1).
* @param col Colonne de la cellule (0 à BOARD_SIZE-1).
* @param value Nouvelle valeur entière pour la tuile.
*/
public void setCellValue(int row, int col, int value) {
@VisibleForTesting // Indique que cette méthode est surtout pour les tests
void setCellValue(int row, int col, int value) {
if (isIndexValid(row, col)) {
this.board[row][col] = value;
}
}
/** @return Le score actuel de la partie. */
public int getCurrentScore() { return currentScore; }
/**
* Retourne le score actuel de la partie en cours.
* Le score augmente lors de la fusion de tuiles.
*
* @return Le score entier actuel.
*/
public int getCurrentScore() {
return currentScore;
}
/** @return Le meilleur score connu par cet objet (défini via setHighestScore). */
public int getHighestScore() { return highestScore; }
/**
* Retourne le meilleur score connu par cette instance de jeu.
* Cette valeur est généralement chargée depuis une sauvegarde externe et mise à jour via {@link #setHighestScore(int)}.
*
* @return Le meilleur score entier connu.
*/
public int getHighestScore() {
return highestScore;
}
/**
* Met à jour la valeur du meilleur score stockée dans cet objet Game.
* Typiquement appelé par la classe gérant la persistance (MainActivity).
* @param highScore Le meilleur score global à stocker.
* Cette méthode est typiquement appelée par la classe gérant la persistance
* (par exemple, MainActivity) après avoir chargé le meilleur score global.
*
* @param highScore Le nouveau meilleur score global à stocker dans cette instance.
*/
public void setHighestScore(int highScore) { this.highestScore = highScore; }
/** @return true si une tuile 2048 (ou plus) a été atteinte, false sinon. */
public boolean isGameWon() { return gameWon; }
/** @return true si aucune case n'est vide ET aucun mouvement/fusion n'est possible, false sinon. */
public boolean isGameOver() { return gameOver; }
/** Met à jour la valeur de gameWon si partie gagné **/
private void setGameWon(boolean won) {this.gameWon = won;}
/** Met à jour la valeur de gameWon si partie gagné **/
private void setGameOver(boolean over) {this.gameOver = over;}
public void setHighestScore(int highScore) {
this.highestScore = highScore;
}
/**
* Retourne une copie profonde du plateau de jeu actuel.
* Utile pour la sérialisation ou pour éviter des modifications externes non désirées.
* @return Une nouvelle matrice 2D représentant l'état actuel du plateau.
* Vérifie si la condition de victoire a été atteinte (au moins une tuile avec une valeur >= 2048).
*
* @return true si le jeu est considéré comme gagné, false sinon.
*/
public boolean isGameWon() {
return gameWon;
}
/**
* Vérifie si la partie est terminée.
* La partie est terminée si aucune case n'est vide ET aucun mouvement ou fusion n'est possible
* dans aucune des quatre directions.
*
* @return true si la partie est terminée (game over), false sinon.
*/
public boolean isGameOver() {
return gameOver;
}
/**
* Met à jour l'indicateur interne de victoire du jeu.
* Utilisé après une fusion ou lors de la restauration d'une partie.
*
* @param won true si la condition de victoire est atteinte, false sinon.
*/
private void setGameWon(boolean won) {
this.gameWon = won;
}
/**
* Met à jour l'indicateur interne de fin de partie (game over).
* Utilisé après une tentative de mouvement infructueuse ou lors de la restauration.
*
* @param over true si la condition de fin de partie est atteinte, false sinon.
*/
private void setGameOver(boolean over) {
this.gameOver = over;
}
/**
* Retourne une copie profonde (deep copy) du plateau de jeu actuel.
* Ceci est utile pour obtenir l'état du plateau sans risquer de le modifier
* accidentellement de l'extérieur, ou pour la sérialisation.
*
* @return Une nouvelle matrice 2D (`int[BOARD_SIZE][BOARD_SIZE]`) représentant l'état actuel du plateau.
*/
@NonNull
public int[][] getBoard() {
int[][] copy = new int[BOARD_SIZE][BOARD_SIZE];
for(int i=0; i<BOARD_SIZE; i++) {
for (int i = 0; i < BOARD_SIZE; i++) {
// System.arraycopy est efficace pour copier des tableaux primitifs
System.arraycopy(this.board[i], 0, copy[i], 0, BOARD_SIZE);
}
return copy;
@ -125,37 +192,48 @@ public class Game {
/**
* ()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.
* Crée une nouvelle matrice vide (remplie de 0), réinitialise le score courant
* et les indicateurs `gameWon`/`gameOver`, puis appelle {@link #addNewTile()}
* deux fois pour placer les deux premières tuiles aléatoires.
*/
private void initializeNewBoard() {
this.board = new int[BOARD_SIZE][BOARD_SIZE]; // Crée une nouvelle matrice vide
this.board = new int[BOARD_SIZE][BOARD_SIZE]; // Crée une nouvelle matrice remplie de zéros par défaut
this.currentScore = 0;
this.gameWon = false;
this.gameOver = false;
addNewTile(); // Ajoute la première tuile
addNewTile(); // Ajoute la seconde tuile
// Ajoute les deux premières tuiles requises pour démarrer une partie
addNewTile();
addNewTile();
}
// --- 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.
* Ajoute une nouvelle tuile (2, 4, 8, etc., selon les probabilités définies
* dans {@link #generateRandomTileValue()}) sur une case vide choisie aléatoirement.
* Si le plateau est plein (aucune case vide), cette méthode ne fait rien.
*/
public void addNewTile() {
List<int[]> emptyCells = findEmptyCells();
if (!emptyCells.isEmpty()) {
// Choisit une cellule vide au hasard parmi celles disponibles
int[] randomCell = emptyCells.get(randomNumberGenerator.nextInt(emptyCells.size()));
int value = generateRandomTileValue();
// Place la nouvelle tuile sur le plateau logique
// Utilise setCellValue pour la cohérence, même si l'accès direct serait possible ici
setCellValue(randomCell[0], randomCell[1], value);
}
// Si emptyCells est vide, le plateau est plein, on ne peut rien ajouter.
// La condition de Game Over sera vérifiée après la tentative de mouvement.
}
/**
* Trouve toutes les cellules vides sur le plateau.
* @return Une liste de tableaux d'entiers `[row, col]` pour chaque cellule vide.
* Recherche et retourne les coordonnées de toutes les cellules vides (valeur 0) sur le plateau.
*
* @return Une {@link List} de tableaux d'entiers `[row, col]` pour chaque cellule vide trouvée.
* Retourne une liste vide si le plateau est plein.
*/
@NonNull
private List<int[]> findEmptyCells() {
List<int[]> emptyCells = new ArrayList<>();
for (int row = 0; row < BOARD_SIZE; row++) {
@ -169,211 +247,368 @@ public class Game {
}
/**
* Génère la valeur pour une nouvelle tuile en utilisant des probabilités prédéfinies.
* @return La valeur (2, 4, 8, ...).
* Génère la valeur pour une nouvelle tuile (2, 4, 8, ...) en utilisant une distribution
* de probabilités prédéfinie. Les probabilités sont ajustées pour rendre l'apparition
* des tuiles de faible valeur plus fréquente.
*
* Probabilités actuelles (approximatives) :
* - 2: 85.40%
* - 4: 12.00%
* - 8: 2.00%
* - 16: 0.50%
* - 32: 0.05%
* - 64: 0.03%
* - 128: 0.01%
* - 256: 0.01%
*
* @return La valeur entière (2, 4, 8, ...) de la tuile générée.
*/
private int generateRandomTileValue() {
int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour pourcentages fins
if (randomValue < 8540) return 2; // 85.40%
if (randomValue < 9740) return 4; // 12.00%
if (randomValue < 9940) return 8; // 2.00%
if (randomValue < 9990) return 16; // 0.50%
if (randomValue < 9995) return 32; // 0.05%
if (randomValue < 9998) return 64; // 0.03%
if (randomValue < 9999) return 128;// 0.01%
return 256; // 0.01%
int randomValue = randomNumberGenerator.nextInt(10000); // Base 10000 pour une granularité fine des pourcentages
// Les seuils définissent les probabilités cumulées
if (randomValue < 8540) return 2; // 0 <= randomValue < 8540 (85.40%)
if (randomValue < 9740) return 4; // 8540 <= randomValue < 9740 (12.00%)
if (randomValue < 9940) return 8; // 9740 <= randomValue < 9940 (2.00%)
if (randomValue < 9990) return 16; // 9940 <= randomValue < 9990 (0.50%)
if (randomValue < 9995) return 32; // 9990 <= randomValue < 9995 (0.05%)
if (randomValue < 9998) return 64; // 9995 <= randomValue < 9998 (0.03%)
if (randomValue < 9999) return 128; // 9998 <= randomValue < 9999 (0.01%)
return 256; // 9999 <= randomValue < 10000 (0.01%)
}
/**
* Tente de déplacer et fusionner les tuiles vers le HAUT.
* Met à jour le score interne et vérifie les états win/gameOver.
* @return true si le plateau a été modifié, false sinon.
* Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
*
* @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
public boolean pushUp() { return processMove(MoveDirection.UP); }
public boolean pushUp() {
return processMove(MoveDirection.UP);
}
/**
* Tente de déplacer et fusionner les tuiles vers le BAS.
* @return true si le plateau a été modifié, false sinon.
* Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
*
* @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
public boolean pushDown() { return processMove(MoveDirection.DOWN); }
public boolean pushDown() {
return processMove(MoveDirection.DOWN);
}
/**
* Tente de déplacer et fusionner les tuiles vers la GAUCHE.
* @return true si le plateau a été modifié, false sinon.
* Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
*
* @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
public boolean pushLeft() { return processMove(MoveDirection.LEFT); }
public boolean pushLeft() {
return processMove(MoveDirection.LEFT);
}
/**
* Tente de déplacer et fusionner les tuiles vers la DROITE.
* @return true si le plateau a été modifié, false sinon.
* Met à jour le plateau, le score interne, et vérifie les états de victoire/défaite.
*
* @return true si au moins une tuile a bougé ou fusionné, false si le plateau n'a pas changé.
*/
public boolean pushRight() { return processMove(MoveDirection.RIGHT); }
public boolean pushRight() {
return processMove(MoveDirection.RIGHT);
}
/** Énumération interne pour clarifier le traitement des mouvements. */
/** Énumération interne pour clarifier la direction du mouvement dans {@link #processMove}. */
private enum MoveDirection { UP, DOWN, LEFT, RIGHT }
/**
* Méthode générique pour traiter un mouvement (déplacement et fusion) dans une direction donnée.
* Contient la logique de base partagée par les méthodes pushX.
* @param direction La direction du mouvement.
* @return true si le plateau a été modifié, false sinon.
* C'est le cœur de la logique de déplacement du jeu. Elle parcourt le plateau, déplace
* les tuiles jusqu'au bout dans la direction demandée, puis gère les fusions éventuelles.
*
* @param direction La direction du mouvement ({@link MoveDirection}).
* @return true si le plateau a été modifié (au moins un déplacement ou une fusion), false sinon.
*/
private boolean processMove(MoveDirection direction) {
boolean boardChanged = false;
// Itère sur l'axe perpendiculaire au mouvement
// Itérer sur l'axe perpendiculaire au mouvement (colonnes pour UP/DOWN, lignes pour LEFT/RIGHT)
for (int i = 0; i < BOARD_SIZE; i++) {
boolean[] hasMerged = new boolean[BOARD_SIZE]; // Pour éviter double fusion sur l'axe de mouvement
// Itère sur l'axe du mouvement, dans le bon sens
// Tableau pour suivre si une tuile sur la ligne/colonne cible a déjà résulté d'une fusion
// pendant CE mouvement, pour éviter les fusions en chaîne (ex: 2-2-4 -> 4-4 -> 8 en un seul swipe)
boolean[] hasMerged = new boolean[BOARD_SIZE];
// Itérer sur l'axe du mouvement. Le sens de parcours est crucial :
// - Pour UP/LEFT: du début vers la fin (index 1 à N-1) car les tuiles fusionnent vers les petits indices.
// - Pour DOWN/RIGHT: de la fin vers le début (index N-2 à 0) car les tuiles fusionnent vers les grands indices.
int start = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? BOARD_SIZE - 2 : 1;
int end = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : BOARD_SIZE;
int step = (direction == MoveDirection.DOWN || direction == MoveDirection.RIGHT) ? -1 : 1;
for (int j = start; j != end; j += step) {
int row = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? j : i;
int col = (direction == MoveDirection.LEFT || direction == MoveDirection.RIGHT) ? j : i;
int row, col;
// Déterminer les coordonnées (row, col) de la tuile courante basée sur l'axe i et l'index j
if (direction == MoveDirection.UP || direction == MoveDirection.DOWN) {
row = j; col = i; // Mouvement vertical, i est la colonne, j est la ligne
} else {
row = i; col = j; // Mouvement horizontal, i est la ligne, j est la colonne
}
if (getCellValue(row, col) != 0) {
int currentValue = getCellValue(row, col);
int currentValue = getCellValue(row, col);
// Si la case n'est pas vide, on essaie de la déplacer/fusionner
if (currentValue != 0) {
int currentRow = row;
int currentCol = col;
// Calcule la position cible après déplacement dans les cases vides
int targetRow = currentRow;
int targetRow = currentRow; // Position cible initiale
int targetCol = currentCol;
// 1. Déplacement : Trouver la case la plus éloignée atteignable dans la direction du mouvement
int nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
int nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
// Tant que la case suivante est valide et vide, on continue de "glisser"
while (isIndexValid(nextRow, nextCol) && getCellValue(nextRow, nextCol) == 0) {
targetRow = nextRow;
targetCol = nextCol;
// Calculer la case suivante pour la prochaine itération de la boucle while
nextRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
nextCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
}
// Déplace la tuile si sa position cible est différente
if (targetRow != currentRow || targetCol != currentCol) {
setCellValue(targetRow, targetCol, currentValue);
setCellValue(currentRow, currentCol, 0);
boardChanged = true;
}
// Vérifie la fusion potentielle avec la case suivante dans la direction du mouvement
int mergeTargetRow = targetRow + ((direction == MoveDirection.UP) ? -1 : (direction == MoveDirection.DOWN) ? 1 : 0);
int mergeTargetCol = targetCol + ((direction == MoveDirection.LEFT) ? -1 : (direction == MoveDirection.RIGHT) ? 1 : 0);
// 2. Fusion : Vérifier la case immédiatement après la position cible (targetRow, targetCol)
// La case à vérifier pour fusion est `nextRow`, `nextCol` (calculée juste avant ou après la sortie du while)
int mergeTargetRow = nextRow;
int mergeTargetCol = nextCol;
// Index utilisé pour le tableau `hasMerged` (soit la ligne soit la colonne cible de la fusion)
int mergeIndex = (direction == MoveDirection.UP || direction == MoveDirection.DOWN) ? mergeTargetRow : mergeTargetCol;
boolean merged = false;
// Vérifier si la case de fusion potentielle est valide, a la même valeur, et n'a pas déjà fusionné
if (isIndexValid(mergeTargetRow, mergeTargetCol) &&
getCellValue(mergeTargetRow, mergeTargetCol) == currentValue &&
!hasMerged[mergeIndex])
!hasMerged[mergeIndex]) // Crucial: empêche double fusion
{
// Fusion !
int newValue = currentValue * 2;
setCellValue(mergeTargetRow, mergeTargetCol, newValue);
setCellValue(targetRow, targetCol, 0); // La tuile qui fusionne disparaît
currentScore += newValue;
hasMerged[mergeIndex] = true;
boardChanged = true;
setCellValue(mergeTargetRow, mergeTargetCol, newValue); // Met à jour la case cible de la fusion
setCellValue(currentRow, currentCol, 0); // Vide la case d'origine de la tuile qui a bougé ET fusionné
currentScore += newValue; // Ajoute la *nouvelle* valeur au score
hasMerged[mergeIndex] = true; // Marque la case cible comme ayant fusionné
boardChanged = true; // Le plateau a changé
merged = true;
// Vérifier immédiatement si cette fusion a créé une tuile >= 2048
if (newValue >= 2048) {
setGameWon(true);
}
}
}
}
}
// Vérifie les conditions de fin après chaque type de mouvement complet
checkWinCondition();
checkGameOverCondition();
// 3. Déplacement final (si pas de fusion OU si la tuile a bougé avant de potentiellement fusionner)
// Si la tuile n'a pas fusionné (elle reste elle est ou se déplace seulement)
// ET que sa position cible (targetRow, targetCol) est différente de sa position initiale
if (!merged && (targetRow != currentRow || targetCol != currentCol)) {
setCellValue(targetRow, targetCol, currentValue); // Déplace la valeur vers la case cible
setCellValue(currentRow, currentCol, 0); // Vide la case d'origine
boardChanged = true; // Le plateau a changé
}
} // Fin if (currentValue != 0)
} // Fin boucle interne (j)
} // Fin boucle externe (i)
// Après avoir traité toutes les lignes/colonnes, vérifier l'état global
// Note: checkWinCondition est déjà appelé lors d'une fusion >= 2048, mais on le refait ici par sécurité.
checkWinCondition(); // Vérifie si 2048 a été atteint (peut être déjà fait lors d'une fusion)
checkGameOverCondition(); // Vérifie si plus aucun mouvement n'est possible
return boardChanged;
}
/**
* Vérifie si les indices de ligne et colonne sont valides pour le plateau.
* @param row Ligne.
* @param col Colonne.
* @return true si les indices sont dans les limites [0, BOARD_SIZE-1].
* Vérifie si les indices de ligne et colonne fournis sont valides pour le plateau de jeu actuel.
*
* @param row L'indice de ligne à vérifier.
* @param col L'indice de colonne à vérifier.
* @return true si 0 <= row < BOARD_SIZE et 0 <= col < BOARD_SIZE, false sinon.
*/
private boolean isIndexValid(int row, int col) {
return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
/**
* Sérialise l'état essentiel du jeu (plateau et score courant) pour sauvegarde.
* Format: "val,val,...,val,score"
* @return Chaîne représentant l'état du jeu.
* Sérialise l'état essentiel du jeu (plateau et score courant) en une chaîne de caractères.
* Le format actuel est simple : toutes les valeurs du plateau séparées par des virgules,
* suivies par le score courant, également séparé par une virgule.
* Exemple pour un plateau 2x2 : "2,0,4,8,12" ( 12 est le score).
* <p>
* NOTE : Ce format est simple mais peut être fragile si la structure du jeu
* ou les données à sauvegarder évoluent. Un format comme JSON pourrait offrir plus de flexibilité.
* </p>
*
* @return Une chaîne non nulle représentant l'état sérialisé du jeu.
*/
@NonNull
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int row = 0; row < BOARD_SIZE; row++) {
for (int col = 0; col < BOARD_SIZE; col++) { sb.append(board[row][col]).append(","); }
for (int col = 0; col < BOARD_SIZE; col++) {
sb.append(board[row][col]).append(",");
}
}
// Ajoute le score à la fin, séparé par une virgule
sb.append(currentScore);
return sb.toString();
}
/**
* Crée un objet Game à partir de sa représentation sérialisée (plateau + score).
* Le meilleur score doit être défini séparément après la création.
* @param serializedState La chaîne issue de {@link #toString()}.
* @return Une nouvelle instance de Game, ou null en cas d'erreur de format.
* Crée (désérialise) un objet {@code Game} à partir de sa représentation sous forme de chaîne,
* telle que générée par {@link #toString()}.
* Le meilleur score (`highestScore`) n'est pas inclus dans la chaîne et doit être
* défini séparément sur l'objet retourné via {@link #setHighestScore(int)}.
* <p>
* NOTE : Cette méthode dépend du format spécifique généré par {@code toString()}.
* </p>
*
* @param serializedState La chaîne représentant l'état du jeu (plateau + score courant).
* @return Une nouvelle instance de {@code Game} si la désérialisation réussit,
* ou {@code null} si la chaîne est invalide, vide, ou a un format incorrect
* (mauvais nombre d'éléments, valeur non entière).
*/
public static Game deserialize(String serializedState) {
if (serializedState == null || serializedState.isEmpty()) return null;
@Nullable
public static Game deserialize(@Nullable String serializedState) {
if (serializedState == null || serializedState.isEmpty()) {
return null;
}
String[] values = serializedState.split(",");
if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) return null; // +1 pour le score
int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE]; int index = 0;
try {
for (int row = 0; row < BOARD_SIZE; row++) {
for (int col = 0; col < BOARD_SIZE; col++) { newBoard[row][col] = Integer.parseInt(values[index++]); }
}
int score = Integer.parseInt(values[index]); // Le dernier élément est le score
return new Game(newBoard, score);
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return null; }
}
// Vérifie si le nombre d'éléments correspond à la taille du plateau + 1 (pour le score)
if (values.length != (BOARD_SIZE * BOARD_SIZE + 1)) {
return null;
}
/**
* Vérifie si la condition de victoire (une tuile >= 2048) est atteinte.
* Met à jour l'état interne `gameWon`.
*/
private void checkWinCondition() {
if (!gameWon) {
for (int r=0; r<BOARD_SIZE; r++) for (int c=0; c<BOARD_SIZE; c++) if (getCellValue(r,c) >= 2048) {
setGameWon(true); return;
int[][] newBoard = new int[BOARD_SIZE][BOARD_SIZE];
int index = 0;
try {
// Remplit le plateau à partir des valeurs de la chaîne
for (int row = 0; row < BOARD_SIZE; row++) {
for (int col = 0; col < BOARD_SIZE; col++) {
newBoard[row][col] = Integer.parseInt(values[index++]);
}
}
// Le dernier élément est le score
int score = Integer.parseInt(values[index]);
// Crée une nouvelle instance de Game avec les données désérialisées
// Le constructeur recalculera gameWon et gameOver.
return new Game(newBoard, score);
} catch (ArrayIndexOutOfBoundsException | IllegalArgumentException e) {
// En cas d'erreur de format (valeur non entière, problème d'indice, ou dimension plateau incorrecte dans constructeur)
// Log l'erreur pourrait être utile pour le débogage
System.err.println("Erreur lors de la désérialisation de l'état du jeu: " + e.getMessage());
return null;
}
}
/**
* Vérifie si la condition de fin de partie est atteinte (plateau plein ET aucun mouvement possible).
* Vérifie si la condition de victoire (au moins une tuile avec une valeur >= 2048)
* est actuellement atteinte sur le plateau. Met à jour l'état interne `gameWon`.
* Cette vérification s'arrête dès qu'une tuile gagnante est trouvée ou si le jeu
* est déjà marqué comme gagné.
*/
private void checkWinCondition() {
// Si le jeu est déjà marqué comme gagné, pas besoin de revérifier
if (gameWon) {
return;
}
// Parcours le plateau à la recherche d'une tuile >= 2048
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
if (getCellValue(r, c) >= 2048) {
setGameWon(true); // Met à jour l'état interne
return; // Sort dès qu'une condition de victoire est trouvée
}
}
}
// Si la boucle se termine sans trouver de tuile >= 2048, gameWon reste false (ou son état précédent)
}
/**
* Vérifie si la condition de fin de partie (Game Over) est atteinte.
* Cela se produit si le plateau est plein (pas de case vide) ET si aucune
* fusion n'est possible entre des tuiles adjacentes (horizontalement ou verticalement).
* Met à jour l'état interne `gameOver`.
*/
private void checkGameOverCondition() {
if (hasEmptyCell()) { setGameOver(false); return; } // Pas game over si case vide
// Vérifie s'il existe au moins une fusion possible
for (int r=0; 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
// Si le jeu est déjà terminé, pas besoin de revérifier
if (gameOver) {
return;
}
// Si il y a au moins une case vide, le jeu ne peut pas être terminé
if (hasEmptyCell()) {
setGameOver(false);
return;
}
// Le plateau est plein. Vérifier s'il existe au moins une fusion possible.
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
int current = getCellValue(r, c);
// Vérifie le voisin du HAUT (si existe)
if (r > 0 && getCellValue(r - 1, c) == current) {
setGameOver(false); return; // Fusion possible vers le haut
}
// Vérifie le voisin du BAS (si existe)
if (r < BOARD_SIZE - 1 && getCellValue(r + 1, c) == current) {
setGameOver(false); return; // Fusion possible vers le bas
}
// Vérifie le voisin de GAUCHE (si existe)
if (c > 0 && getCellValue(r, c - 1) == current) {
setGameOver(false); return; // Fusion possible vers la gauche
}
// Vérifie le voisin de DROITE (si existe)
if (c < BOARD_SIZE - 1 && getCellValue(r, c + 1) == current) {
setGameOver(false); return; // Fusion possible vers la droite
}
}
}
setGameOver(true); // Aucune case vide et aucune fusion -> game over
// Si on arrive ici, le plateau est plein ET aucune fusion adjacente n'est possible.
setGameOver(true);
}
/**
* Vérifie si le plateau contient au moins une case vide (valeur 0).
* @return true si une case vide existe, false sinon.
* Vérifie rapidement si le plateau contient au moins une case vide (valeur 0).
* Utilisé principalement par {@link #checkGameOverCondition()}.
*
* @return true s'il existe au moins une case vide, false si le plateau est plein.
*/
private boolean hasEmptyCell() {
for (int r=0; r<BOARD_SIZE; r++) for (int c=0; c<BOARD_SIZE; c++) if (getCellValue(r,c)==0) return true;
return false;
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
if (getCellValue(r, c) == 0) {
return true; // Sort dès qu'une case vide est trouvée
}
}
}
return false; // Aucune case vide trouvée après avoir parcouru tout le plateau
}
/**
* 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.
* Retourne la valeur de la plus haute tuile (la plus grande valeur numérique)
* actuellement présente sur le plateau de jeu.
*
* @return La valeur maximale trouvée sur le plateau, 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];
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;
}
}

View File

@ -1,7 +1,8 @@
// Fichier GameStats.java
/**
* Gère la collecte, la persistance (via SharedPreferences) et l'accès
* aux statistiques du jeu 2048, pour les modes solo et multijoueur (si applicable).
* Gère la collecte, la persistance (via {@link SharedPreferences}) et l'accès
* aux statistiques du jeu 2048.
* Inclut des statistiques générales, solo, et des placeholders pour le multijoueur.
* Charge et sauvegarde automatiquement les statistiques via SharedPreferences.
*/
package legion.muyue.best2048;
@ -13,9 +14,17 @@ import java.util.concurrent.TimeUnit;
public class GameStats {
// --- Constantes pour SharedPreferences ---
/** Nom du fichier de préférences partagées utilisé pour stocker les statistiques et l'état du jeu. */
private static final String PREFS_NAME = "Best2048_Prefs";
private static final String HIGH_SCORE_KEY = "high_score"; // Clé partagée avec Game/MainActivity
// Clés spécifiques aux statistiques
/**
* Clé pour le meilleur score global.
* NOTE : Cette clé est également utilisée directement par MainActivity/Game pour la sauvegarde/chargement
* de l'état du jeu. La synchronisation est gérée entre les classes.
*/
private static final String HIGH_SCORE_KEY = "high_score";
// Clés spécifiques aux statistiques persistantes
private static final String STATS_TOTAL_GAMES_PLAYED = "totalGamesPlayed";
private static final String STATS_TOTAL_GAMES_STARTED = "totalGamesStarted";
private static final String STATS_TOTAL_MOVES = "totalMoves";
@ -23,10 +32,11 @@ public class GameStats {
private static final String STATS_TOTAL_MERGES = "totalMerges";
private static final String STATS_HIGHEST_TILE = "highestTile";
private static final String STATS_OBJECTIVE_REACHED_COUNT = "numberOfTimesObjectiveReached";
private static final String STATS_PERFECT_GAMES = "perfectGames";
// private static final String STATS_PERFECT_GAMES = "perfectGames"; // Clé supprimée car concept non défini
private static final String STATS_BEST_WINNING_TIME_MS = "bestWinningTimeMs";
private static final String STATS_WORST_WINNING_TIME_MS = "worstWinningTimeMs";
// ... (autres clés stats) ...
// Clés pour les statistiques multijoueur (fonctionnalité future)
private static final String STATS_MP_GAMES_WON = "multiplayerGamesWon";
private static final String STATS_MP_GAMES_PLAYED = "multiplayerGamesPlayed";
private static final String STATS_MP_BEST_WINNING_STREAK = "multiplayerBestWinningStreak";
@ -37,94 +47,133 @@ public class GameStats {
// --- Champs de Statistiques ---
// Générales & Solo
private int totalGamesPlayed;
private int totalGamesStarted;
private int totalMoves;
private long totalPlayTimeMs;
private int totalMerges;
private int highestTile;
private int numberOfTimesObjectiveReached; // Nombre de victoires (>= 2048)
private int perfectGames; // Concept non défini ici
private long bestWinningTimeMs;
private long worstWinningTimeMs;
private int overallHighScore; // Meilleur score global
// Partie en cours (non persistées telles quelles)
// Générales & Solo
/** Nombre total de parties terminées (victoire ou défaite). */
private int totalGamesPlayed;
/** Nombre total de parties démarrées (via bouton "Nouvelle Partie"). */
private int totalGamesStarted;
/** Nombre total de mouvements (swipes valides) effectués dans toutes les parties. */
private int totalMoves;
/** Temps total passé à jouer (en millisecondes) sur toutes les parties. */
private long totalPlayTimeMs;
/** Nombre total de fusions de tuiles effectuées dans toutes les parties. */
private int totalMerges;
/** Valeur de la plus haute tuile atteinte globalement dans toutes les parties. */
private int highestTile;
/** Nombre de fois où l'objectif (atteindre 2048 ou plus) a été atteint. */
private int numberOfTimesObjectiveReached;
// private int perfectGames; // Champ supprimé car concept non défini
/** Meilleur temps (le plus court, en ms) pour atteindre l'objectif (>= 2048). */
private long bestWinningTimeMs;
/** Pire temps (le plus long, en ms) pour atteindre l'objectif (>= 2048). */
private long worstWinningTimeMs;
/**
* Meilleur score global atteint. Persisté via HIGH_SCORE_KEY.
* Synchronisé avec MainActivity/Game lors du chargement/sauvegarde.
*/
private int overallHighScore;
// Partie en cours (non persistées telles quelles, utilisées pour calculs en temps réel)
/** Nombre de mouvements effectués dans la partie actuelle. */
private int currentMoves;
/** Timestamp (en ms) du début de la partie actuelle. Réinitialisé à chaque nouvelle partie ou reprise. */
private long currentGameStartTimeMs;
/** Nombre de fusions effectuées dans la partie actuelle. */
private int mergesThisGame;
// Multijoueur
// Multijoueur (placeholders pour fonctionnalité future)
/** Nombre de parties multijoueur gagnées. */
private int multiplayerGamesWon;
/** Nombre de parties multijoueur jouées (terminées). */
private int multiplayerGamesPlayed;
/** Plus longue série de victoires consécutives en multijoueur. */
private int multiplayerBestWinningStreak;
/** Score total accumulé dans toutes les parties multijoueur. */
private long multiplayerTotalScore;
/** Temps total passé dans les parties multijoueur (en ms). */
private long multiplayerTotalTimeMs;
/** Nombre total de défaites en multijoueur. */
private int totalMultiplayerLosses;
/** Meilleur score atteint dans une seule partie multijoueur. */
private int multiplayerHighestScore;
/** Contexte nécessaire pour accéder aux SharedPreferences. */
/** Contexte applicatif nécessaire pour accéder aux SharedPreferences. */
private final Context context;
/**
* Constructeur. Initialise l'objet et charge immédiatement les statistiques
* depuis les SharedPreferences.
* @param context Contexte de l'application.
* Constructeur de GameStats.
* Initialise l'objet et charge immédiatement les statistiques persistantes
* depuis les SharedPreferences via {@link #loadStats()}.
*
* @param context Contexte de l'application (Activity ou Application). Utilise `getApplicationContext()` pour éviter les fuites de mémoire.
*/
public GameStats(Context context) {
this.context = context.getApplicationContext(); // Utilise le contexte applicatif
loadStats();
this.context = context.getApplicationContext(); // Important: utiliser le contexte applicatif
loadStats(); // Charge les statistiques dès l'initialisation
}
// --- Persistance (SharedPreferences) ---
/**
* Charge toutes les statistiques persistantes depuis les SharedPreferences.
* Appelé par le constructeur.
* Charge toutes les statistiques persistantes depuis le fichier SharedPreferences défini par {@link #PREFS_NAME}.
* Si une clé n'existe pas, une valeur par défaut est utilisée (0, Long.MAX_VALUE pour best time, etc.).
* Appelé automatiquement par le constructeur.
*/
public void loadStats() {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
// Charge le high score (clé partagée)
overallHighScore = prefs.getInt(HIGH_SCORE_KEY, 0);
// Charge les statistiques générales et solo
totalGamesPlayed = prefs.getInt(STATS_TOTAL_GAMES_PLAYED, 0);
totalGamesStarted = prefs.getInt(STATS_TOTAL_GAMES_STARTED, 0);
// ... (chargement de toutes les autres clés persistantes) ...
totalMoves = prefs.getInt(STATS_TOTAL_MOVES, 0);
totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0);
totalPlayTimeMs = prefs.getLong(STATS_TOTAL_PLAY_TIME_MS, 0L);
totalMerges = prefs.getInt(STATS_TOTAL_MERGES, 0);
highestTile = prefs.getInt(STATS_HIGHEST_TILE, 0);
numberOfTimesObjectiveReached = prefs.getInt(STATS_OBJECTIVE_REACHED_COUNT, 0);
perfectGames = prefs.getInt(STATS_PERFECT_GAMES, 0);
bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme défaut pour 'best'
worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0);
// perfectGames = prefs.getInt(STATS_PERFECT_GAMES, 0); // Supprimé
bestWinningTimeMs = prefs.getLong(STATS_BEST_WINNING_TIME_MS, Long.MAX_VALUE); // MAX_VALUE comme indicateur "pas encore de temps enregistré"
worstWinningTimeMs = prefs.getLong(STATS_WORST_WINNING_TIME_MS, 0L);
// Charge les statistiques multijoueur (placeholders)
multiplayerGamesWon = prefs.getInt(STATS_MP_GAMES_WON, 0);
multiplayerGamesPlayed = prefs.getInt(STATS_MP_GAMES_PLAYED, 0);
multiplayerBestWinningStreak = prefs.getInt(STATS_MP_BEST_WINNING_STREAK, 0);
multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0);
multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0);
multiplayerTotalScore = prefs.getLong(STATS_MP_TOTAL_SCORE, 0L);
multiplayerTotalTimeMs = prefs.getLong(STATS_MP_TOTAL_TIME_MS, 0L);
totalMultiplayerLosses = prefs.getInt(STATS_MP_LOSSES, 0);
multiplayerHighestScore = prefs.getInt(STATS_MP_HIGH_SCORE, 0);
}
/**
* Sauvegarde toutes les statistiques persistantes dans les SharedPreferences.
* Appelé typiquement dans `onPause` de l'activité.
* Sauvegarde toutes les statistiques persistantes actuelles dans le fichier SharedPreferences.
* Utilise `apply()` pour une sauvegarde asynchrone et non bloquante.
* Devrait être appelée lorsque l'application se met en pause ou est détruite,
* ou après une mise à jour significative des statistiques (ex: reset).
*/
public void saveStats() {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putInt(HIGH_SCORE_KEY, overallHighScore); // Sauvegarde le HS global
// Sauvegarde le high score (clé partagée)
editor.putInt(HIGH_SCORE_KEY, overallHighScore);
// Sauvegarde les statistiques générales et solo
editor.putInt(STATS_TOTAL_GAMES_PLAYED, totalGamesPlayed);
editor.putInt(STATS_TOTAL_GAMES_STARTED, totalGamesStarted);
// ... (sauvegarde de toutes les autres clés persistantes) ...
editor.putInt(STATS_TOTAL_MOVES, totalMoves);
editor.putLong(STATS_TOTAL_PLAY_TIME_MS, totalPlayTimeMs);
editor.putInt(STATS_TOTAL_MERGES, totalMerges);
editor.putInt(STATS_HIGHEST_TILE, highestTile);
editor.putInt(STATS_OBJECTIVE_REACHED_COUNT, numberOfTimesObjectiveReached);
editor.putInt(STATS_PERFECT_GAMES, perfectGames);
// editor.putInt(STATS_PERFECT_GAMES, perfectGames); // Supprimé
editor.putLong(STATS_BEST_WINNING_TIME_MS, bestWinningTimeMs);
editor.putLong(STATS_WORST_WINNING_TIME_MS, worstWinningTimeMs);
// Sauvegarde les statistiques multijoueur (placeholders)
editor.putInt(STATS_MP_GAMES_WON, multiplayerGamesWon);
editor.putInt(STATS_MP_GAMES_PLAYED, multiplayerGamesPlayed);
editor.putInt(STATS_MP_BEST_WINNING_STREAK, multiplayerBestWinningStreak);
@ -133,24 +182,28 @@ public class GameStats {
editor.putInt(STATS_MP_LOSSES, totalMultiplayerLosses);
editor.putInt(STATS_MP_HIGH_SCORE, multiplayerHighestScore);
editor.apply(); // Applique les changements de manière asynchrone
// Applique les changements de manière asynchrone
editor.apply();
}
// --- Méthodes de Mise à Jour des Statistiques ---
/**
* Doit être appelée au début de chaque nouvelle partie.
* Incrémente le compteur de parties démarrées et réinitialise les stats de la partie en cours.
* Doit être appelée au début de chaque nouvelle partie (ex: clic sur "Nouvelle Partie").
* Incrémente le compteur de parties démarrées (`totalGamesStarted`) et réinitialise
* les statistiques spécifiques à la partie en cours (`currentMoves`, `mergesThisGame`, `currentGameStartTimeMs`).
*/
public void startGame() {
totalGamesStarted++;
currentMoves = 0;
mergesThisGame = 0;
currentGameStartTimeMs = System.currentTimeMillis();
currentGameStartTimeMs = System.currentTimeMillis(); // Enregistre le temps de début
}
/**
* Enregistre un mouvement réussi (qui a modifié le plateau).
* Enregistre un mouvement réussi (un swipe qui a modifié l'état du plateau).
* Incrémente le compteur de mouvements pour la partie en cours (`currentMoves`)
* et le compteur total de mouvements (`totalMoves`).
*/
public void recordMove() {
currentMoves++;
@ -159,7 +212,10 @@ public class GameStats {
/**
* Enregistre une ou plusieurs fusions survenues lors d'un mouvement.
* @param numberOfMerges Le nombre de fusions (si connu, sinon 1 par défaut).
* Incrémente le compteur de fusions pour la partie en cours (`mergesThisGame`)
* et le compteur total de fusions (`totalMerges`).
*
* @param numberOfMerges Le nombre de fusions distinctes réalisées lors du dernier mouvement (typiquement 1 ou plus).
*/
public void recordMerge(int numberOfMerges) {
if (numberOfMerges > 0) {
@ -169,47 +225,71 @@ public class GameStats {
}
/**
* Met à jour la statistique de la plus haute tuile atteinte globalement.
* @param tileValue La valeur de la tuile la plus haute de la partie en cours.
* Met à jour la statistique de la plus haute tuile atteinte globalement (`highestTile`),
* si la valeur fournie est supérieure à la valeur actuelle enregistrée.
* Devrait être appelée après chaque mouvement qui pourrait créer une nouvelle tuile max.
*
* @param currentHighestTileValue La valeur de la tuile la plus haute sur le plateau à l'instant T.
*/
public void updateHighestTile(int tileValue) {
if (tileValue > this.highestTile) {
this.highestTile = tileValue;
public void updateHighestTile(int currentHighestTileValue) {
if (currentHighestTileValue > this.highestTile) {
this.highestTile = currentHighestTileValue;
// La sauvegarde se fera via saveStats() globalement
}
}
/**
* Enregistre une victoire et met à jour les temps associés.
* @param timeTakenMs Temps écoulé pour cette partie gagnante.
* Enregistre une victoire (atteinte de l'objectif >= 2048).
* Incrémente le compteur de victoires (`numberOfTimesObjectiveReached`) et met à jour
* les statistiques de meilleur/pire temps de victoire si nécessaire.
* Appelle également {@link #endGame(long)} pour finaliser les statistiques générales de la partie.
*
* @param timeTakenMs Temps écoulé (en millisecondes) pour terminer cette partie gagnante.
*/
public void recordWin(long timeTakenMs) {
numberOfTimesObjectiveReached++;
if (timeTakenMs < bestWinningTimeMs) { bestWinningTimeMs = timeTakenMs; }
if (timeTakenMs > worstWinningTimeMs) { worstWinningTimeMs = timeTakenMs; }
endGame(timeTakenMs); // Finalise aussi le temps total et parties jouées
if (timeTakenMs < bestWinningTimeMs) {
bestWinningTimeMs = timeTakenMs;
}
// Utiliser >= pour le pire temps permet de l'enregistrer même si c'est le premier
if (timeTakenMs >= worstWinningTimeMs) {
worstWinningTimeMs = timeTakenMs;
}
endGame(timeTakenMs); // Finalise aussi le temps total et le compteur de parties jouées
}
/**
* Enregistre une défaite.
* Enregistre une défaite (Game Over - plateau plein sans mouvement possible).
* Calcule le temps écoulé pour la partie et appelle {@link #endGame(long)}
* pour finaliser les statistiques générales.
*/
public void recordLoss() {
// Calcule le temps écoulé avant de finaliser
endGame(System.currentTimeMillis() - currentGameStartTimeMs);
// Calcule le temps écoulé pour cette partie avant de la finaliser
long timeTakenMs = System.currentTimeMillis() - currentGameStartTimeMs;
endGame(timeTakenMs);
}
/**
* Finalise les statistiques générales à la fin d'une partie (victoire ou défaite).
* @param timeTakenMs Temps total de la partie terminée.
* Incrémente le compteur de parties jouées (`totalGamesPlayed`) et ajoute le temps
* de la partie terminée au temps de jeu total (`totalPlayTimeMs`).
*
* @param timeTakenMs Temps total (en millisecondes) de la partie qui vient de se terminer.
*/
public void endGame(long timeTakenMs) {
private void endGame(long timeTakenMs) {
totalGamesPlayed++;
addPlayTime(timeTakenMs);
addPlayTime(timeTakenMs); // Ajoute la durée de la partie terminée au total
// Ne pas réinitialiser currentGameStartTimeMs ici, car une nouvelle partie
// pourrait ne pas commencer immédiatement (ex: affichage dialogue Game Over).
// startGame() s'en chargera.
}
/**
* Ajoute une durée (en ms) au temps de jeu total enregistré.
* Typiquement appelé dans `onPause`.
* @param durationMs Durée à ajouter.
* Ajoute une durée (en millisecondes) au temps de jeu total enregistré (`totalPlayTimeMs`).
* Typiquement appelé dans `onPause` de l'activité pour enregistrer le temps écoulé
* depuis `onResume` ou `startGame`.
*
* @param durationMs Durée positive (en millisecondes) à ajouter au temps total.
*/
public void addPlayTime(long durationMs) {
if (durationMs > 0) {
@ -218,8 +298,10 @@ public class GameStats {
}
/**
* Réinitialise toutes les statistiques (solo, multijoueur, high score global)
* à leurs valeurs par défaut.
* Réinitialise toutes les statistiques (générales, solo, multijoueur, y compris le high score global)
* à leurs valeurs par défaut (0 ou équivalent).
* Après la réinitialisation, les nouvelles valeurs sont immédiatement sauvegardées
* dans les SharedPreferences via {@link #saveStats()}.
*/
public void resetStats() {
// Réinitialise toutes les variables membres à 0 ou valeur initiale
@ -227,80 +309,158 @@ public class GameStats {
totalGamesPlayed = 0;
totalGamesStarted = 0;
totalMoves = 0;
totalPlayTimeMs = 0;
totalPlayTimeMs = 0L;
totalMerges = 0;
highestTile = 0;
numberOfTimesObjectiveReached = 0;
perfectGames = 0;
// perfectGames = 0; // Supprimé
bestWinningTimeMs = Long.MAX_VALUE;
worstWinningTimeMs = 0;
worstWinningTimeMs = 0L;
// Réinitialise les stats multijoueur (placeholders)
multiplayerGamesWon = 0;
multiplayerGamesPlayed = 0;
multiplayerBestWinningStreak = 0;
multiplayerTotalScore = 0;
multiplayerTotalTimeMs = 0;
multiplayerTotalScore = 0L;
multiplayerTotalTimeMs = 0L;
totalMultiplayerLosses = 0;
multiplayerHighestScore = 0;
// Réinitialise les stats de la partie en cours (au cas )
currentMoves = 0;
mergesThisGame = 0;
currentGameStartTimeMs = 0L; // Ou System.currentTimeMillis() si on considère que reset démarre une nouvelle session? Non, 0 est plus sûr.
// Sauvegarde immédiatement les statistiques réinitialisées
saveStats();
}
// --- Getters pour l'affichage ---
/** @return Le nombre total de parties terminées (victoire ou défaite). */
public int getTotalGamesPlayed() { return totalGamesPlayed; }
/** @return Le nombre total de parties démarrées. */
public int getTotalGamesStarted() { return totalGamesStarted; }
/** @return Le nombre total de mouvements valides effectués. */
public int getTotalMoves() { return totalMoves; }
/** @return Le nombre de mouvements dans la partie en cours. */
public int getCurrentMoves() { return currentMoves; }
/** @return Le temps total de jeu accumulé (en ms). */
public long getTotalPlayTimeMs() { return totalPlayTimeMs; }
public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; } // Utile pour calcul durée en cours
/** @return Le timestamp (en ms) du début de la partie en cours. Utile pour calculer la durée en temps réel. */
public long getCurrentGameStartTimeMs() { return currentGameStartTimeMs; }
/** @return Le nombre de fusions dans la partie en cours. */
public int getMergesThisGame() { return mergesThisGame; }
/** @return Le nombre total de fusions effectuées. */
public int getTotalMerges() { return totalMerges; }
/** @return La valeur de la plus haute tuile atteinte globalement. */
public int getHighestTile() { return highestTile; }
/** @return Le nombre de fois où l'objectif (>= 2048) a été atteint. */
public int getNumberOfTimesObjectiveReached() { return numberOfTimesObjectiveReached; }
public int getPerfectGames() { return perfectGames; }
// public int getPerfectGames() { return perfectGames; } // Supprimé
/** @return Le meilleur temps (le plus court, en ms) pour gagner une partie, ou Long.MAX_VALUE si aucune victoire. */
public long getBestWinningTimeMs() { return bestWinningTimeMs; }
/** @return Le pire temps (le plus long, en ms) pour gagner une partie, ou 0 si aucune victoire. */
public long getWorstWinningTimeMs() { return worstWinningTimeMs; }
public int getOverallHighScore() { return overallHighScore; } // Getter pour HS global
// Getters Multiplayer
/** @return Le meilleur score global enregistré. */
public int getOverallHighScore() { return overallHighScore; }
// Getters Multiplayer (fonctionnalité future)
/** @return Nombre de parties multijoueur gagnées. */
public int getMultiplayerGamesWon() { return multiplayerGamesWon; }
/** @return Nombre de parties multijoueur jouées. */
public int getMultiplayerGamesPlayed() { return multiplayerGamesPlayed; }
/** @return Meilleure série de victoires consécutives en multijoueur. */
public int getMultiplayerBestWinningStreak() { return multiplayerBestWinningStreak; }
/** @return Score total accumulé en multijoueur. */
public long getMultiplayerTotalScore() { return multiplayerTotalScore; }
/** @return Temps total passé en multijoueur (en ms). */
public long getMultiplayerTotalTimeMs() { return multiplayerTotalTimeMs; }
/** @return Nombre total de défaites en multijoueur. */
public int getTotalMultiplayerLosses() { return totalMultiplayerLosses; }
/** @return Meilleur score atteint dans une partie multijoueur. */
public int getMultiplayerHighestScore() { return multiplayerHighestScore; }
// --- Setters ---
/** Met à jour la valeur interne du high score global. */
public void setHighestScore(int highScore) {
// Met à jour si la nouvelle valeur est meilleure
if (highScore > this.overallHighScore) {
this.overallHighScore = highScore;
// La sauvegarde se fait via saveStats() globalement
}
}
/** Définit le timestamp de début de la partie en cours. */
public void setCurrentGameStartTimeMs(long timeMs) { this.currentGameStartTimeMs = timeMs; }
// --- Méthodes Calculées ---
/** @return Temps moyen par partie terminée en millisecondes. */
public long getAverageGameTimeMs() { return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0; }
/** @return Score moyen par partie multijoueur terminée. */
public int getMultiplayerAverageScore() { return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0; }
/** @return Temps moyen par partie multijoueur terminée en millisecondes. */
public long getMultiplayerAverageTimeMs() { return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0; }
/**
* Formate une durée en millisecondes en chaîne "hh:mm:ss" ou "mm:ss".
* @param milliseconds Durée en millisecondes.
* @return Chaîne de temps formatée.
* Met à jour la valeur interne du meilleur score global (`overallHighScore`).
* La mise à jour n'a lieu que si le score fourni est strictement supérieur
* au meilleur score actuel. La sauvegarde effective dans SharedPreferences
* se fait via un appel ultérieur à {@link #saveStats()}.
*
* @param highScore Le nouveau score à potentiellement définir comme meilleur score.
*/
@SuppressLint("DefaultLocale")
public void setHighestScore(int highScore) {
// Met à jour seulement si la nouvelle valeur est meilleure
if (highScore > this.overallHighScore) {
this.overallHighScore = highScore;
// La sauvegarde se fait via saveStats() globalement, pas ici directement.
}
}
/**
* Définit le timestamp (en ms) du début de la partie en cours.
* Typiquement utilisé par MainActivity dans `onResume` pour reprendre le chronomètre.
*
* @param timeMs Le timestamp en millisecondes.
*/
public void setCurrentGameStartTimeMs(long timeMs) {
this.currentGameStartTimeMs = timeMs;
}
// --- Méthodes Calculées ---
/**
* Calcule le temps moyen passé par partie terminée (solo).
*
* @return Le temps moyen par partie en millisecondes, ou 0 si aucune partie n'a été terminée.
*/
public long getAverageGameTimeMs() {
return (totalGamesPlayed > 0) ? totalPlayTimeMs / totalGamesPlayed : 0L;
}
/**
* Calcule le score moyen par partie multijoueur terminée.
* (Basé sur les statistiques multijoueur placeholder).
*
* @return Le score moyen entier par partie multijoueur, ou 0 si aucune partie multijoueur n'a été jouée.
*/
public int getMultiplayerAverageScore() {
return (multiplayerGamesPlayed > 0) ? (int)(multiplayerTotalScore / multiplayerGamesPlayed) : 0;
}
/**
* Calcule le temps moyen passé par partie multijoueur terminée.
* (Basé sur les statistiques multijoueur placeholder).
*
* @return Le temps moyen par partie multijoueur en millisecondes, ou 0 si aucune partie multijoueur n'a été jouée.
*/
public long getMultiplayerAverageTimeMs() {
return (multiplayerGamesPlayed > 0) ? multiplayerTotalTimeMs / multiplayerGamesPlayed : 0L;
}
/**
* Formate une durée donnée en millisecondes en une chaîne de caractères lisible.
* Le format est "hh:mm:ss" si la durée est d'une heure ou plus, sinon "mm:ss".
*
* @param milliseconds Durée totale en millisecondes.
* @return Une chaîne formatée représentant la durée.
*/
@SuppressLint("DefaultLocale") // Justifié car le format hh:mm:ss est standard et non dépendant de la locale
public static String formatTime(long milliseconds) {
if (milliseconds < 0) { milliseconds = 0; } // Gère les cas négatifs
long hours = TimeUnit.MILLISECONDS.toHours(milliseconds);
long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60;
long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60;
if (hours > 0) { return String.format("%02d:%02d:%02d", hours, minutes, seconds); }
else { return String.format("%02d:%02d", minutes, seconds); }
long minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60; // Minutes restantes après les heures
long seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60; // Secondes restantes après les minutes
if (hours > 0) {
// Format avec heures si nécessaire
return String.format("%02d:%02d:%02d", hours, minutes, seconds);
} else {
// Format minutes:secondes sinon
return String.format("%02d:%02d", minutes, seconds);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,9 @@
// Fichier NotificationHelper.java
/**
* Classe utilitaire centralisant la logique de création et d'affichage des notifications
* pour l'application Best 2048.
* Gère également la création du canal de notification nécessaire pour Android 8.0 (API 26) et supérieur.
* Utilise {@link NotificationCompat} pour assurer la compatibilité avec les anciennes versions d'Android.
*/
package legion.muyue.best2048;
import android.app.NotificationChannel;
@ -6,86 +11,143 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager; // Import pour PackageManager.PERMISSION_GRANTED
import android.os.Build;
import android.util.Log; // Pour le logging des erreurs/warnings
import androidx.annotation.NonNull; // Pour marquer les paramètres non-null
import androidx.core.app.ActivityCompat; // Pour checkSelfPermission (remplace ContextCompat ici)
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat; // Pour checkSelfPermission
// import androidx.core.content.ContextCompat; // Peut être utilisé mais ActivityCompat est plus direct pour la permission ici
/**
* Classe utilitaire pour simplifier la création et l'affichage des notifications
* et la gestion du canal de notification pour l'application Best 2048.
*/
public class NotificationHelper {
/** Identifiant unique du canal de notification pour cette application. */
public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant
/**
* Identifiant unique et constant pour le canal de notification de cette application.
* Ce même ID doit être utilisé lors de la création de la notification via {@link NotificationCompat.Builder}.
* Il est visible par l'utilisateur dans les paramètres de notification de l'application sur Android 8.0+.
*/
public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé dans MainActivity/Service
/** Tag pour les logs spécifiques à ce helper. */
private static final String TAG = "NotificationHelper";
/**
* Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur.
* Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif).
* Doit être appelée avant d'afficher la première notification sur API 26+.
* Crée le canal de notification requis pour afficher des notifications sur Android 8.0 (API 26) et supérieur.
* Si le canal existe déjà, cette méthode ne fait rien (elle est idempotente).
* Doit être appelée une fois, idéalement au démarrage de l'application (par exemple dans `Application.onCreate`
* ou dans `MainActivity.onCreate`), avant d'afficher la toute première notification sur un appareil API 26+.
*
* @param context Contexte applicatif.
* @param context Le contexte applicatif ({@code Context}) utilisé pour accéder aux services système.
*/
public static void createNotificationChannel(Context context) {
public static void createNotificationChannel(@NonNull Context context) {
// La création de canal n'est nécessaire que sur Android Oreo (API 26) et versions ultérieures
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Récupère les chaînes de caractères pour le nom et la description du canal depuis les ressources
CharSequence name = context.getString(R.string.notification_channel_name);
String description = context.getString(R.string.notification_channel_description);
int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut
// Définit l'importance du canal (affecte comment la notification est présentée à l'utilisateur)
// IMPORTANCE_DEFAULT est un bon point de départ pour la plupart des notifications.
int importance = NotificationManager.IMPORTANCE_DEFAULT;
// Crée l'objet NotificationChannel
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
channel.setDescription(description);
// Enregistre le canal auprès du système.
// Optionnel : Configurer d'autres propriétés du canal ici (vibration, lumière, son par défaut, etc.)
// channel.enableLights(true);
// channel.setLightColor(Color.RED);
// channel.enableVibration(true);
// channel.setVibrationPattern(new long[]{100, 200, 300, 400, 500, 400, 300, 200, 400});
// Récupère le NotificationManager système
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
// Enregistre le canal auprès du système. Si le canal existe déjà, aucune opération n'est effectuée.
if (notificationManager != null) {
Log.d(TAG, "Création ou mise à jour du canal de notification: " + CHANNEL_ID);
notificationManager.createNotificationChannel(channel);
} else {
Log.e(TAG, "NotificationManager non disponible, impossible de créer le canal.");
}
} else {
Log.d(TAG, "API < 26, pas besoin de créer de canal de notification.");
}
}
/**
* Construit et affiche une notification.
* Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher.
* Construit et affiche une notification standard pour l'application.
* Vérifie la permission {@code POST_NOTIFICATIONS} sur Android 13 (API 33) et supérieur
* avant de tenter d'afficher la notification. Si la permission est manquante,
* un message d'erreur est logué et la méthode se termine sans afficher de notification.
*
* @param context Contexte (peut être une Activity ou un Service).
* @param title Titre de la notification.
* @param message Contenu texte de la notification.
* @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler).
* @param context Le contexte ({@code Context}, peut être une Activity, un Service, etc.)
* utilisé pour construire la notification et accéder aux ressources.
* @param title Le titre à afficher dans la notification.
* @param message Le contenu textuel principal de la notification.
* @param notificationId Un identifiant entier unique pour cette notification. Permet de mettre à jour
* ou d'annuler cette notification spécifique plus tard en utilisant le même ID.
*/
public static void showNotification(Context context, String title, String message, int notificationId) {
// Vérification de la permission pour Android 13+
public static void showNotification(@NonNull Context context, @NonNull String title, @NonNull String message, int notificationId) {
Log.d(TAG, "Tentative d'affichage de la notification ID: " + notificationId + " Titre: " + title);
// --- Vérification de la permission pour Android 13 (API 33) et supérieur ---
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
// Si la permission n'est pas accordée, ne pas tenter d'afficher la notification.
// L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode
// si elle sait que l'utilisateur a activé les notifications dans les paramètres.
System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée.");
// Utilise ActivityCompat.checkSelfPermission pour vérifier la permission
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
// Si la permission n'est pas accordée, loguer un avertissement et ne pas continuer.
// L'application appelante (ex: MainActivity, Service) est responsable de demander la permission
// si l'utilisateur a indiqué vouloir recevoir des notifications.
Log.w(TAG, "Permission POST_NOTIFICATIONS manquante pour API 33+. Notification ID " + notificationId + " non affichée.");
// Optionnel : Envoyer un broadcast ou utiliser une autre méthode pour signaler l'échec ? Non, simple log suffit ici.
return;
} else {
Log.d(TAG, "Permission POST_NOTIFICATIONS accordée sur API 33+.");
}
} else {
Log.d(TAG, "API < 33, permission POST_NOTIFICATIONS non requise pour afficher.");
}
// Intent pour ouvrir MainActivity au clic
// --- Création de l'Intent pour le clic sur la notification ---
// Ouvre MainActivity lorsque l'utilisateur clique sur la notification.
Intent intent = new Intent(context, MainActivity.class);
// Flags pour gérer correctement la pile d'activités (ramène l'instance existante ou en crée une nouvelle)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT;
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags);
// Flags pour le PendingIntent (requis : IMMUTABLE sur API 31+)
int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE;
}
// Crée le PendingIntent qui enveloppe l'Intent
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, pendingIntentFlags);
// Construction de la notification via NotificationCompat pour la compatibilité
// --- Construction de la notification via NotificationCompat ---
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale
.setContentIntent(pendingIntent) // Action au clic
.setAutoCancel(true); // Ferme la notification après le clic
.setSmallIcon(R.drawable.ic_stat_notification_2048) // Icône obligatoire (doit être monochrome/blanche avec transparence)
// .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher)) // Icône optionnelle plus grande
.setContentTitle(title) // Titre de la notification
.setContentText(message) // Texte principal
.setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité (affecte l'affichage head-up, etc.)
.setContentIntent(pendingIntent) // Action à exécuter au clic
.setAutoCancel(true) // Ferme automatiquement la notification après le clic
.setDefaults(NotificationCompat.DEFAULT_SOUND | NotificationCompat.DEFAULT_VIBRATE) // Utilise son/vibration par défaut du système (si autorisé)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); // Visible sur l'écran de verrouillage
// Affichage de la notification
// --- Affichage de la notification ---
// Utilise NotificationManagerCompat pour la compatibilité
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
try {
// Affiche la notification. Si une notification avec le même ID existe déjà, elle est mise à jour.
notificationManager.notify(notificationId, builder.build());
Log.i(TAG, "Notification ID " + notificationId + " affichée avec succès.");
} catch (SecurityException e){
// Gérer l'exception de sécurité qui peut survenir même avec la vérification ci-dessus dans certains cas limites
System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage());
// Gérer l'exception de sécurité qui peut (rarement) survenir même avec la vérification ci-dessus.
Log.e(TAG, "Erreur de sécurité lors de l'affichage de la notification ID " + notificationId, e);
// Afficher un Toast ou logguer est généralement suffisant ici.
} catch (Exception ex) {
// Capturer d'autres exceptions potentielles
Log.e(TAG, "Erreur inattendue lors de l'affichage de la notification ID " + notificationId, ex);
}
}
}

View File

@ -1,4 +1,22 @@
// Fichier NotificationService.java
/**
* Service exécuté en arrière-plan pour tenter d'envoyer des notifications périodiques,
* comme un rappel du meilleur score ou un rappel d'inactivité.
*
* <p><strong>AVERTISSEMENT IMPORTANT :</strong> Ce service utilise un {@link android.os.Handler}
* pour planifier les tâches répétitives. Cette approche est simple mais <strong>NON FIABLE</strong>
* pour les tâches en arrière-plan sur les versions modernes d'Android. Le système peut tuer
* le service à tout moment pour économiser les ressources, et le redémarrage via
* {@code START_STICKY} n'est pas garanti d'être rapide ou même de se produire sur tous les appareils
* ou dans toutes les conditions (ex: mode Doze, restrictions fabricant). </p>
*
* <p><strong>Pour une solution robuste et recommandée par Android pour les tâches périodiques
* en arrière-plan, il est FORTEMENT conseillé d'utiliser {@link androidx.work.WorkManager}.</strong>
* WorkManager gère les contraintes (réseau, charge), la persistance des tâches et
* garantit leur exécution même si l'application ou l'appareil redémarre.</p>
*
* <p>Ce code est fourni à titre d'exemple de la logique de notification, mais le mécanisme
* de planification devrait être remplacé par WorkManager en production.</p>
*/
package legion.muyue.best2048;
import android.app.Service;
@ -7,129 +25,227 @@ import android.content.SharedPreferences;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper; // Important pour créer un Handler sur le Main Thread
import android.util.Log;
import androidx.annotation.Nullable;
import java.util.concurrent.TimeUnit;
/**
* Service exécuté en arrière-plan pour envoyer des notifications périodiques
* (rappel de meilleur score, rappel d'inactivité).
* Utilise un Handler pour planifier les tâches répétitives.
* NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée),
* WorkManager serait préférable en production.
*/
public class NotificationService extends Service {
private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs
private static final int NOTIFICATION_ID_INACTIVITY = 3;
// Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité)
private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1);
private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3);
private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent
/** Tag pour les logs spécifiques à ce service. */
private static final String TAG = "NotificationService";
// --- Constantes de Notification et Intervalles ---
/** ID unique pour la notification de rappel du meilleur score. */
private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs (ex: 1 pour achievement)
/** ID unique pour la notification de rappel d'inactivité. */
private static final int NOTIFICATION_ID_INACTIVITY = 3;
/** Intervalle minimum souhaité entre deux notifications de rappel du meilleur score (ex: 1 jour). */
private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1);
/** Seuil d'inactivité : durée après laquelle une notification d'inactivité peut être envoyée (ex: 3 jours). */
private static final long INACTIVITY_THRESHOLD_MS = TimeUnit.DAYS.toMillis(3);
/** Intervalle minimum souhaité entre deux notifications de rappel d'inactivité (ex: 1 jour, pour éviter spam si toujours inactif). */
private static final long INACTIVITY_NOTIFICATION_COOLDOWN_MS = TimeUnit.DAYS.toMillis(1);
/** Intervalle auquel le Handler vérifie s'il faut envoyer une notification (ex: toutes les 6 heures). */
// NOTE: Avec WorkManager, cette vérification fréquente serait inutile, on planifierait directement la tâche au bon moment.
private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6);
/** Handler pour poster les tâches Runnable périodiques. Fonctionne sur le thread principal. */
private Handler handler;
/** Runnable contenant la logique de vérification et d'envoi des notifications. */
private Runnable periodicTaskRunnable;
// Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs)
// --- Constantes SharedPreferences ---
// (Doivent correspondre à celles utilisées dans MainActivity et GameStats)
/** Nom du fichier de préférences partagées. */
private static final String PREFS_NAME = "Best2048_Prefs";
/** Clé pour lire le meilleur score global. */
private static final String HIGH_SCORE_KEY = "high_score";
private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé
/** Clé pour lire le timestamp de la dernière partie jouée. */
private static final String LAST_PLAYED_TIME_KEY = "last_played_time";
/** Clé pour vérifier si les notifications sont activées par l'utilisateur. */
private static final String NOTIFICATIONS_ENABLED_KEY = "notifications_enabled";
/** Clé pour stocker le timestamp du dernier envoi de la notification High Score. */
private static final String LAST_HS_NOTIFICATION_TIME = "lastHsNotificationTime";
/** Clé pour stocker le timestamp du dernier envoi de la notification d'Inactivité. */
private static final String LAST_INACTIVITY_NOTIFICATION_TIME = "lastInactivityNotificationTime";
/**
* Appelée par le système lors de la première création du service.
* Initialise le Handler et le Runnable pour la tâche périodique.
* Ne crée PAS le canal de notification ici, car MainActivity le fait déjà.
*/
@Override
public void onCreate() {
super.onCreate();
// Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue)
// Pour des tâches plus lourdes, utiliser HandlerThread
Log.i(TAG, "onCreate: Service de notification en cours de création.");
// Utilise le Looper principal pour le Handler. Simple, mais bloque le thread principal
// si checkAndSendNotifications() devient une tâche longue.
// Pour des tâches potentiellement longues, utiliser un HandlerThread serait mieux.
handler = new Handler(Looper.getMainLooper());
// Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage
// NotificationHelper.createNotificationChannel(this);
// Définit le Runnable qui sera exécuté périodiquement
periodicTaskRunnable = new Runnable() {
@Override
public void run() {
// Vérifie périodiquement s'il faut envoyer une notification
Log.d(TAG, "Exécution de la tâche périodique de vérification des notifications.");
// Vérifie s'il faut envoyer des notifications
checkAndSendNotifications();
// Replanifie la tâche
handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures
// Replanifie la prochaine exécution de cette même tâche
// ATTENTION: Ce mécanisme n'est pas fiable en arrière-plan.
handler.postDelayed(this, CHECK_INTERVAL_MS);
Log.d(TAG, "Prochaine vérification des notifications planifiée dans " + CHECK_INTERVAL_MS + " ms.");
}
};
}
/**
* Appelée lorsque le service est démarré, soit par `startService()` soit après un redémarrage du système
* (si START_STICKY est utilisé et que le système choisit de le redémarrer).
* Lance la tâche périodique de vérification des notifications.
*
* @param intent L'Intent fourni à startService(), peut être null si redémarré par le système.
* @param flags Indicateurs supplémentaires sur la demande de démarrage.
* @param startId Un identifiant unique représentant cette demande de démarrage spécifique.
* @return La valeur de retour indique comment le système doit gérer le service s'il est tué.
* {@code START_STICKY} : Le système essaiera de recréer le service après l'avoir tué,
* mais l'Intent ne sera pas redélivré (il sera null).
* <strong>NOTE: START_STICKY n'est pas une garantie d'exécution fiable pour les tâches
* périodiques en arrière-plan. Utilisez WorkManager.</strong>
*/
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// Lance la tâche périodique lors du démarrage du service
handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon
handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification
Log.i(TAG, "onStartCommand: Service démarré (ou redémarré). StartId: " + startId);
// START_STICKY : Le système essaiera de recréer le service s'il est tué.
// S'assure qu'il n'y a pas d'anciennes tâches en double avant de poster la nouvelle
handler.removeCallbacks(periodicTaskRunnable);
// Lance la première exécution de la tâche immédiatement (ou presque)
handler.post(periodicTaskRunnable);
Log.d(TAG, "Première vérification des notifications postée.");
// Utiliser START_STICKY pour que le système essaie de redémarrer le service s'il est tué.
// C'est un pis-aller par rapport à WorkManager.
return START_STICKY;
}
/**
* Appelée lorsque le service est sur le point d'être détruit.
* Nettoie les ressources, en particulier, arrête la planification des tâches
* périodiques via le Handler pour éviter les fuites ou les exécutions non désirées.
*/
@Override
public void onDestroy() {
super.onDestroy();
Log.i(TAG, "onDestroy: Service de notification en cours de destruction.");
// Arrête la planification des tâches lorsque le service est détruit
if (handler != null && periodicTaskRunnable != null) {
Log.d(TAG, "Annulation des tâches périodiques planifiées.");
handler.removeCallbacks(periodicTaskRunnable);
}
// Autres nettoyages si nécessaire
}
/**
* Retourne l'interface de communication au client.
* Comme c'est un service démarré (Started Service) et non lié (Bound Service),
* cette méthode retourne {@code null}.
*
* @param intent L'Intent qui a été utilisé pour se lier au service (non pertinent ici).
* @return null car ce service ne permet pas le binding.
*/
@Nullable
@Override
public IBinder onBind(Intent intent) {
// Service non lié (Started Service)
// Ce service n'est pas conçu pour être lié.
return null;
}
/**
* Vérifie les conditions et envoie les notifications périodiques si nécessaire.
* Vérifie les conditions pour envoyer les notifications périodiques (High Score, Inactivité).
* Lit les préférences utilisateur (notifications activées?) et les timestamps nécessaires.
* Appelle les méthodes d'affichage de notification appropriées si les conditions et
* les délais depuis le dernier envoi sont respectés.
* Met à jour le timestamp du dernier envoi après avoir tenté d'envoyer une notification.
*/
private void checkAndSendNotifications() {
Log.d(TAG, "checkAndSendNotifications: Début de la vérification.");
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé
// 1. Vérifier si les notifications sont globalement activées par l'utilisateur
boolean notificationsEnabled = prefs.getBoolean(NOTIFICATIONS_ENABLED_KEY, false); // Lire la préférence
if (!notificationsEnabled) {
// Si désactivé dans les prefs, on arrête potentiellement le service?
// Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer.
// stopSelf(); // Arrêterait le service
Log.i(TAG, "checkAndSendNotifications: Notifications désactivées dans les préférences, arrêt de la vérification.");
// Si les notifications sont désactivées, on pourrait arrêter le service pour économiser des ressources.
// stopSelf(); // Arrêterait le service lui-même.
return;
}
// --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) ---
// Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici)
// Pour une vraie app, il faudrait une logique pour ne pas spammer.
// Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ?
int highScore = prefs.getInt(HIGH_SCORE_KEY, 0);
// Temporairement on l'envoie à chaque check pour test (à modifier!)
// if (shouldSendHighScoreNotification()) {
showHighScoreNotificationNow(highScore);
// }
long currentTime = System.currentTimeMillis();
// --- Notification d'Inactivité ---
long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0);
if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) {
// Si l'inactivité dépasse le seuil
showInactivityNotificationNow();
// Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ?
// Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause.
// 2. Vérification pour la notification High Score
long lastHsNotificationTime = prefs.getLong(LAST_HS_NOTIFICATION_TIME, 0);
if (currentTime - lastHsNotificationTime > HIGHSCORE_INTERVAL_MS) {
Log.d(TAG, "checkAndSendNotifications: Intervalle pour notification High Score écoulé.");
int highScore = prefs.getInt(HIGH_SCORE_KEY, 0);
if (highScore > 0) { // N'envoie pas si le high score est 0
Log.i(TAG, "checkAndSendNotifications: Envoi de la notification High Score.");
showHighScoreNotificationNow(highScore);
// Met à jour le timestamp du dernier envoi APRÈS avoir tenté d'envoyer
prefs.edit().putLong(LAST_HS_NOTIFICATION_TIME, currentTime).apply();
} else {
Log.d(TAG, "checkAndSendNotifications: High Score est 0, notification non envoyée.");
}
} else {
Log.d(TAG, "checkAndSendNotifications: Intervalle pour notification High Score non écoulé.");
}
// 3. Vérification pour la notification d'Inactivité
long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0);
long lastInactivityNotificationTime = prefs.getLong(LAST_INACTIVITY_NOTIFICATION_TIME, 0);
// Condition 1: Temps depuis la dernière partie > Seuil d'inactivité
boolean isInactivityThresholdMet = (lastPlayedTime > 0 && currentTime - lastPlayedTime > INACTIVITY_THRESHOLD_MS);
// Condition 2: Temps depuis la dernière notification d'inactivité > Cooldown
boolean isCooldownMet = (currentTime - lastInactivityNotificationTime > INACTIVITY_NOTIFICATION_COOLDOWN_MS);
if (isInactivityThresholdMet) {
Log.d(TAG, "checkAndSendNotifications: Seuil d'inactivité atteint.");
if (isCooldownMet) {
Log.i(TAG, "checkAndSendNotifications: Cooldown pour notification d'inactivité respecté. Envoi...");
showInactivityNotificationNow();
// Met à jour le timestamp du dernier envoi APRÈS avoir tenté d'envoyer
prefs.edit().putLong(LAST_INACTIVITY_NOTIFICATION_TIME, currentTime).apply();
} else {
Log.d(TAG, "checkAndSendNotifications: Cooldown pour notification d'inactivité non écoulé.");
}
} else {
Log.d(TAG, "checkAndSendNotifications: Seuil d'inactivité non atteint.");
}
Log.d(TAG, "checkAndSendNotifications: Fin de la vérification.");
}
/** Affiche la notification High Score */
/**
* Prépare et affiche immédiatement la notification de rappel du meilleur score.
* Utilise {@link NotificationHelper} pour l'affichage effectif.
*
* @param highScore Le meilleur score à afficher dans la notification.
*/
private void showHighScoreNotificationNow(int highScore) {
String title = getString(R.string.notification_title_highscore);
String message = getString(R.string.notification_text_highscore, highScore);
NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_HIGHSCORE);
}
/** Affiche la notification d'Inactivité */
/**
* Prépare et affiche immédiatement la notification de rappel d'inactivité.
* Utilise {@link NotificationHelper} pour l'affichage effectif.
*/
private void showInactivityNotificationNow() {
String title = getString(R.string.notification_title_inactivity);
String message = getString(R.string.notification_text_inactivity);
NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_INACTIVITY);
}
// Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques
// private boolean shouldSendHighScoreNotification() { ... }
}

3570
sortie.txt Normal file

File diff suppressed because it is too large Load Diff