diff --git a/app/src/main/java/legion/muyue/best2048/MainActivity.java b/app/src/main/java/legion/muyue/best2048/MainActivity.java index a67f88e..e73060a 100644 --- a/app/src/main/java/legion/muyue/best2048/MainActivity.java +++ b/app/src/main/java/legion/muyue/best2048/MainActivity.java @@ -40,6 +40,14 @@ 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 java.util.ArrayList; +import java.util.List; public class MainActivity extends AppCompatActivity { @@ -68,6 +76,9 @@ public class MainActivity extends AppCompatActivity { 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"; @@ -109,6 +120,74 @@ public class MainActivity extends AppCompatActivity { if (notificationsEnabled) { startNotificationService(); } + syncBoardView(); + } + + /** + * Synchronise COMPLETEMENT le GridLayout avec l'état actuel de 'game.board'. + * Crée/Met à jour/Supprime les TextViews nécessaires et les stocke dans tileViews. + * C'est la méthode qui assure que l'affichage correspond à la logique à un instant T. + */ + private void syncBoardView() { + if (game == null) return; + // Log.d("SyncDebug", "Syncing board view..."); + + // Pas besoin de removeAllViews si on gère correctement add/remove/update + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + int value = game.getCellValue(r, c); + TextView currentView = tileViews[r][c]; + + if (currentView == null && value > 0) { + // Une nouvelle tuile doit apparaître là où il n'y en avait pas + currentView = createTileTextView(value, r, c); + tileViews[r][c] = currentView; + boardGridLayout.addView(currentView); + // Log.d("SyncDebug", "Added view at ["+r+","+c+"]"); + } else if (currentView != null && value == 0) { + // Une tuile doit disparaître + boardGridLayout.removeView(currentView); + tileViews[r][c] = null; + // Log.d("SyncDebug", "Removed view at ["+r+","+c+"]"); + } else if (currentView != null && value > 0) { + // Une tuile existe déjà, on met juste à jour son style/texte + // Vérifie si la valeur a changé (fusion) pour potentielle animation future + // if (!currentView.getText().toString().equals(String.valueOf(value))) { + // Log.d("SyncDebug", "Updating view at ["+r+","+c+"] to "+value); + // } + setTileStyle(currentView, value); // Applique toujours le style correct + } + // Si currentView == null && value == 0 -> rien à faire + } + } + // Log.d("SyncDebug", "Board sync finished."); + updateScores(); // Assure que les scores sont aussi à jour + } + + /** + * Crée et configure une TextView pour une tuile. (Peut être simplifié) + * @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 + + // Les LayoutParams sont nécessaires pour GridLayout + GridLayout.LayoutParams params = new GridLayout.LayoutParams(); + // Taille à 0dp pour que le poids (spec) fonctionne + params.width = 0; + params.height = 0; + // Position initiale basée sur row/col, poids pour remplir la cellule + params.rowSpec = GridLayout.spec(row, 1f); + params.columnSpec = GridLayout.spec(col, 1f); + int margin = (int) getResources().getDimension(R.dimen.tile_margin); + params.setMargins(margin, margin, margin, margin); + tileTextView.setLayoutParams(params); + + return tileTextView; } @Override @@ -317,63 +396,169 @@ public class MainActivity extends AppCompatActivity { } /** - * Traite un geste de swipe de l'utilisateur sur le plateau de jeu. - * Met à jour le jeu, les statistiques et l'UI, et vérifie les conditions de fin de partie. - * @param direction La direction du swipe détecté. + * 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; // Ignore swipe si jeu terminé ou non initialisé + return; } + // Note: On ne bloque plus sur 'isAnimating' pour cette approche int scoreBefore = game.getCurrentScore(); - boolean boardChanged = false; + int[][] boardBeforePush = game.getBoard(); // État avant le push - // Tente d'effectuer le mouvement dans l'objet Game + boolean boardChanged = false; switch (direction) { - case UP: boardChanged = game.pushUp(); break; - case DOWN: boardChanged = game.pushDown(); break; - case LEFT: boardChanged = game.pushLeft(); break; + case UP: boardChanged = game.pushUp(); break; + case DOWN: boardChanged = game.pushDown(); break; + case LEFT: boardChanged = game.pushLeft(); break; case RIGHT: boardChanged = game.pushRight(); break; } - // Si le mouvement a modifié le plateau if (boardChanged) { + // 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(); - int scoreDelta = scoreAfter - scoreBefore; - if (scoreDelta > 0) { - gameStats.recordMerge(1); // Simplification: compte comme 1 fusion si score augmente + if (scoreAfter > scoreBefore) { + gameStats.recordMerge(1); // Simplifié if (scoreAfter > game.getHighestScore()) { game.setHighestScore(scoreAfter); - gameStats.setHighestScore(scoreAfter); // Met à jour aussi dans GameStats pour sauvegarde + gameStats.setHighestScore(scoreAfter); } } gameStats.updateHighestTile(game.getHighestTileValue()); - game.addNewTile(); // Ajoute une nouvelle tuile - updateUI(); // Rafraîchit l'affichage - } + // updateScores(); // Le score sera mis à jour par syncBoardView - // Vérifie l'état final après le mouvement (même si boardChanged est false) - if (currentGameState != GameFlowState.GAME_OVER) { - if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { - currentGameState = GameFlowState.WON_DIALOG_SHOWN; - long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); - gameStats.recordWin(timeTaken); - showAchievementNotification(2048); - showGameWonKeepPlayingDialog(); - } else if (game.isGameOver()) { + // 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); // Finalise temps, etc. + gameStats.recordLoss(); gameStats.endGame(timeTaken); showGameOverDialog(); - // Met à jour l'UI pour afficher le score final si Game Over atteint sans mouvement (rare) - if (!boardChanged) updateUI(); } } } + /** + * Identifie les tuiles qui ont fusionné ou sont apparues et lance des animations + * simples (scale/alpha) sur les vues correspondantes DÉJÀ positionnées par syncBoardView. + * @param boardBeforePush État avant le déplacement/fusion. + * @param boardAfterPush État après déplacement/fusion, avant ajout nouvelle tuile. + * @param boardAfterAdd État final après ajout nouvelle tuile. + */ + private void animateChanges(int[][] boardBeforePush, int[][] boardAfterPush, int[][] boardAfterAdd) { + List animations = new ArrayList<>(); + + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + TextView currentView = tileViews[r][c]; // Vue à la position finale (après syncBoardView) + if (currentView == null) continue; // Pas de vue à animer ici + + int valueAfterAdd = boardAfterAdd[r][c]; + int valueAfterPush = boardAfterPush[r][c]; // Valeur avant l'ajout + int valueBeforePush = boardBeforePush[r][c]; // Valeur tout au début + + // 1. Animation d'Apparition + // Si la case était vide après le push, mais a une valeur maintenant (c'est la nouvelle tuile) + if (valueAfterPush == 0 && valueAfterAdd > 0) { + //Log.d("AnimationDebug", "Animating APPEAR at ["+r+","+c+"]"); + currentView.setScaleX(0.3f); currentView.setScaleY(0.3f); currentView.setAlpha(0f); + Animator appear = createAppearAnimation(currentView); + animations.add(appear); + } + // 2. Animation de Fusion + // Si la valeur a changé PENDANT le push (valeur après push > valeur avant push) + // ET que la case n'était pas vide avant (ce n'est pas un simple déplacement) + else if (valueAfterPush > valueBeforePush && valueBeforePush != 0) { + //Log.d("AnimationDebug", "Animating MERGE at ["+r+","+c+"]"); + Animator merge = createMergeAnimation(currentView); + animations.add(merge); + } + // Note : les tuiles qui ont simplement bougé ne sont pas animées ici. + // Les tuiles qui ont disparu (fusionnées vers une autre case) sont gérées par syncBoardView qui les supprime. + } + } + + if (!animations.isEmpty()) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animations); + animatorSet.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // finalizeMove n'est plus responsable du déblocage, mais vérifie la fin + checkEndGameConditions(); + } + }); + animatorSet.start(); + } else { + // Si aucune animation n'a été générée (ex: mouvement sans fusion ni nouvelle tuile possible) + checkEndGameConditions(); // Vérifie quand même la fin de partie + } + } + + /** Crée une animation d'apparition (scale + alpha). */ + private Animator createAppearAnimation(View view) { + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0.3f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0.3f, 1f); + ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f); + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY, alpha); + set.setDuration(150); // Durée apparition + return set; + } + + /** Crée une animation de 'pulse' pour une fusion. */ + private Animator createMergeAnimation(View view) { + ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f); + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY); + set.setDuration(120); // Durée pulse fusion + return set; + } + + /** + * Vérifie si le jeu est gagné ou perdu et affiche le dialogue approprié. + * Doit être appelé après la fin des animations potentielles. + */ + private void checkEndGameConditions() { + if (game == null || currentGameState == GameFlowState.GAME_OVER) return; + + if (game.isGameWon() && currentGameState == GameFlowState.PLAYING) { + currentGameState = GameFlowState.WON_DIALOG_SHOWN; + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordWin(timeTaken); + showGameWonKeepPlayingDialog(); + // La notif est déjà envoyée dans handleSwipe si on la veut immédiate + } else if (game.isGameOver()) { + currentGameState = GameFlowState.GAME_OVER; + long timeTaken = System.currentTimeMillis() - gameStats.getCurrentGameStartTimeMs(); + gameStats.recordLoss(); + gameStats.endGame(timeTaken); + showGameOverDialog(); + } + } + /** Énumération pour les directions de swipe. */ private enum Direction { UP, DOWN, LEFT, RIGHT }