feat: Intégration WebSocket pour l'interaction multijoueur

Remplace le polling REST inefficace et les tentatives d'envoi de mouvements via REST par une communication WebSocket complète dans MultiplayerActivity.

Modifications clés :
- Ajout de la dépendance OkHttp pour le support WebSocket (action manuelle).
- Utilisation de OkHttp pour établir et gérer la connexion WebSocket (, ).
- Implémentation d'un  () pour gérer les événements :
    - Envoi du message 'register' à l'ouverture ().
    - Réception et traitement des messages 'gameStateUpdate', 'error', 'info' ().
    - Gestion de la fermeture et des erreurs (, , ).
- Mise à jour de l'interface utilisateur (, ) en temps réel basée sur les messages  reçus (en utilisant ).
- Modification de  pour créer et envoyer les messages 'move' via WebSocket ().
- Suppression complète du mécanisme de polling basé sur .
- Gestion du cycle de vie de la connexion WebSocket dans , , .
- Ajout de classes de données internes pour parser/créer les messages WebSocket JSON (, , , etc.).
This commit is contained in:
Augustin ROUX 2025-04-05 11:16:48 +02:00
parent 1dc439c186
commit be983a1107
4 changed files with 373 additions and 198 deletions

View File

@ -45,4 +45,5 @@ dependencies {
implementation(libs.converter.gson)
implementation(libs.logging.interceptor)
implementation(libs.gson)
implementation(libs.okhttp)
}

View File

@ -1,5 +1,6 @@
package legion.muyue.best2048;
// Imports Android standard et UI
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.os.Handler;
@ -11,29 +12,45 @@ import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.gridlayout.widget.GridLayout;
import java.util.Objects; // Pour la comparaison d'ID
import legion.muyue.best2048.data.GameInfo;
import legion.muyue.best2048.data.GameStateResponse;
import legion.muyue.best2048.data.MoveRequest;
import legion.muyue.best2048.data.PlayerIdRequest;
import legion.muyue.best2048.network.ApiClient;
import legion.muyue.best2048.network.ApiService;
// Imports Retrofit (pour les appels REST initiaux)
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Response; // <--- IMPORT IMPORTANT POUR RETROFIT CALLBACK !
// Imports OkHttp (pour WebSocket)
import okhttp3.OkHttpClient;
import okhttp3.Request;
// import okhttp3.Response; // Aussi nécessaire pour WebSocketListener.onFailure, garder les deux imports
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
// Imports Gson (pour JSON)
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
// Import Timeout pour OkHttpClient
import java.util.concurrent.TimeUnit;
// Imports des classes Data et Network (existantes)
import legion.muyue.best2048.data.GameInfo;
import legion.muyue.best2048.data.GameStateResponse;
import legion.muyue.best2048.data.MoveRequest; // Utilisé pour construire le JSON du move via WebSocket
import legion.muyue.best2048.data.PlayerIdRequest; // Pour createOrJoinGame
import legion.muyue.best2048.network.ApiClient;
import legion.muyue.best2048.network.ApiService;
public class MultiplayerActivity extends AppCompatActivity {
private static final String TAG = "MultiplayerActivity";
private static final int BOARD_SIZE = 4;
private static final long POLLING_INTERVAL_MS = 3000; // Intervalle de polling (3 sec) - Inefficace !
// UI
private GridLayout boardGridLayoutMulti;
@ -44,47 +61,81 @@ public class MultiplayerActivity extends AppCompatActivity {
private ProgressBar loadingIndicatorMulti;
private TextView[][] tileViewsMulti = new TextView[BOARD_SIZE][BOARD_SIZE];
// Network
// Network - REST (pour appels initiaux)
private ApiService apiService;
// Network - WebSocket (pour temps réel)
private OkHttpClient wsClient;
private WebSocket webSocket;
private MyWebSocketListener webSocketListener;
private static final String WEBSOCKET_URL = "wss://best2048.legion-muyue.fr/ws";
private boolean isWebSocketConnected = false;
// Game State
private String currentGameId = null;
private String myPlayerId = "Player1_Temp"; // TODO: Remplacer par une vraie identification
private String myPlayerId = null;
private String opponentPlayerId = null;
private GameStateResponse currentGameState = null;
// Polling Handler
private Handler pollingHandler;
private Runnable pollingRunnable;
private boolean isPollingActive = false;
// JSON Parser
private Gson gson;
// Handler pour UI Thread
private Handler uiHandler;
// --- Activity Lifecycle ---
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_multiplayer);
Log.d(TAG, "onCreate");
findViewsMulti();
apiService = ApiClient.getApiService();
pollingHandler = new Handler(Looper.getMainLooper());
apiService = ApiClient.getApiService(); // Instance Retrofit
wsClient = new OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS) // Maintenir connexion WS active
.build();
webSocketListener = new MyWebSocketListener();
gson = new Gson();
uiHandler = new Handler(Looper.getMainLooper());
setupSwipeListenerMulti();
initMultiplayerGame();
initMultiplayerGame(); // Démarrer le processus
}
@Override
protected void onPause() {
super.onPause();
stopPolling(); // Arrête le polling quand l'activité n'est plus visible
Log.d(TAG, "onPause");
closeWebSocket(); // Fermer WS en quittant l'écran
}
@Override
protected void onResume() {
super.onResume();
if (currentGameId != null && currentGameState != null && !currentGameState.isGameOver()) {
startPolling(); // Redémarre le polling si une partie est en cours
Log.d(TAG, "onResume");
// Tenter de reconnecter si besoin
if (currentGameId != null && !isWebSocketConnected && currentGameState != null && !currentGameState.isGameOver()) {
Log.i(TAG, "Tentative de reconnexion WebSocket...");
connectWebSocket();
} else if (currentGameId != null && isWebSocketConnected) {
Log.d(TAG, "WebSocket déjà connecté, rafraîchissement état via REST...");
fetchGameState(); // Resynchroniser via REST au cas
}
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.d(TAG, "onDestroy");
closeWebSocket(); // Assurer la fermeture
if (uiHandler != null) {
uiHandler.removeCallbacksAndMessages(null);
}
}
// --- UI Setup ---
private void findViewsMulti() {
boardGridLayoutMulti = findViewById(R.id.gameBoardMulti);
myScoreLabelMulti = findViewById(R.id.myScoreLabelMulti);
@ -94,160 +145,293 @@ public class MultiplayerActivity extends AppCompatActivity {
loadingIndicatorMulti = findViewById(R.id.loadingIndicatorMulti);
}
/** Tente de créer ou rejoindre une partie multijoueur. */
// --- Game Initialization & REST Call ---
/** Lance l'appel REST pour créer ou rejoindre une partie. */
private void initMultiplayerGame() {
showLoading(true);
statusTextMulti.setText("Recherche d'une partie...");
statusTextMulti.setText(R.string.multiplayer_status_searching);
// !!! IMPORTANT: Générer un ID unique ici !!!
// myPlayerId = "Player1_Temp"; // À REMPLACER
myPlayerId = java.util.UUID.randomUUID().toString(); // Exemple simple d'ID unique
Log.i(TAG, "Utilisation du Player ID: " + myPlayerId);
// Générer/Récupérer Player ID (UUID temporaire pour test)
myPlayerId = java.util.UUID.randomUUID().toString();
Log.i(TAG, "initMultiplayerGame: Utilisation du Player ID: " + myPlayerId);
// Crée l'objet pour le corps de la requête
PlayerIdRequest requestBody = new PlayerIdRequest(myPlayerId);
// Utilise requestBody dans l'appel API
// Appel Retrofit
apiService.createOrJoinGame(requestBody).enqueue(new Callback<GameInfo>() {
@Override
// Utilise bien retrofit2.Response ici
public void onResponse(@NonNull Call<GameInfo> call, @NonNull Response<GameInfo> response) {
if (response.isSuccessful() && response.body() != null) {
GameInfo gameInfo = response.body();
currentGameId = gameInfo.getGameId();
// Détermine correctement l'ID de l'adversaire
// Détermine opponentPlayerId
if (myPlayerId.equals(gameInfo.getPlayer1Id())) {
opponentPlayerId = gameInfo.getPlayer2Id();
} else {
opponentPlayerId = gameInfo.getPlayer1Id(); // Si je suis P2, l'autre est P1
opponentPlayerId = gameInfo.getPlayer1Id();
}
Log.i(TAG, "Partie rejointe/créée: ID=" + currentGameId + ", Moi=" + myPlayerId + ", Adversaire=" + opponentPlayerId);
statusTextMulti.setText("Partie trouvée ! ID: " + currentGameId.substring(0, 8) + "..."); // Affiche début ID
fetchGameState(); // Récupère l'état initial
Log.i(TAG, "Partie rejointe/créée via REST: ID=" + currentGameId + ", Moi=" + myPlayerId + ", Adversaire=" + opponentPlayerId);
statusTextMulti.setText(getString(R.string.multiplayer_status_found, currentGameId.substring(0, 8)));
// Succès de l'appel REST -> On lance la connexion WebSocket
connectWebSocket();
} else {
Log.e(TAG, "Erreur création/rejoindre partie: " + response.code());
handleNetworkError("Impossible de créer ou rejoindre une partie (Code: " + response.code() + ")");
handleNetworkError(getString(R.string.error_join_create_game, response.code()));
showLoading(false);
}
}
@Override
public void onFailure(@NonNull Call<GameInfo> call, @NonNull Throwable t) {
Log.e(TAG, "Échec création/rejoindre partie", t);
handleNetworkError("Échec de connexion au serveur.");
handleNetworkError(getString(R.string.error_server_connection));
showLoading(false);
}
});
}
/** Récupère l'état actuel du jeu depuis le serveur. */
/** Récupère l'état actuel via REST (pour synchro). */
private void fetchGameState() {
if (currentGameId == null) return;
// Ne montre le chargement que si pas déjà en polling (pour fluidité)
if(!isPollingActive) showLoading(true);
Log.d(TAG, "fetchGameState via REST pour Game ID: " + currentGameId);
apiService.getGameState(currentGameId).enqueue(new Callback<GameStateResponse>() {
@Override
// Utilise bien retrofit2.Response ici
public void onResponse(@NonNull Call<GameStateResponse> call, @NonNull Response<GameStateResponse> response) {
showLoading(false);
if (response.isSuccessful() && response.body() != null) {
Log.d(TAG, "État du jeu récupéré via REST.");
currentGameState = response.body();
Log.d(TAG, "État du jeu récupéré. Tour de: " + currentGameState.getCurrentPlayerId());
updateMultiplayerUI(currentGameState); // Met à jour l'affichage
if (!currentGameState.isGameOver()) {
startPolling(); // Commence ou continue le polling
// Mettre à jour l'UI sur le thread principal
runOnUiThread(() -> updateMultiplayerUI(currentGameState));
} else {
stopPolling(); // Arrête si la partie est finie
// Afficher message fin de partie
}
} else {
Log.e(TAG, "Erreur récupération état partie: " + response.code());
// Gérer l'erreur, peut-être réessayer ?
statusTextMulti.setText("Erreur récupération état...");
Log.e(TAG, "Erreur récupération état partie via REST: " + response.code());
}
}
@Override
public void onFailure(@NonNull Call<GameStateResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Échec récupération état partie", t);
showLoading(false);
handleNetworkError("Échec connexion pour état partie.");
// Arrêter le polling en cas d'échec réseau ?
// stopPolling();
Log.e(TAG, "Échec récupération état partie via REST", t);
}
});
}
/** Démarre le polling périodique pour récupérer l'état du jeu. */
private void startPolling() {
if (isPollingActive || currentGameId == null || currentGameState == null || currentGameState.isGameOver()) {
return; // Ne pas démarrer si déjà actif, pas de partie, ou partie finie
}
Log.d(TAG, "Démarrage du polling...");
isPollingActive = true;
pollingRunnable = new Runnable() {
@Override
public void run() {
if (!isPollingActive) return; // Vérifie si on doit s'arrêter
fetchGameState(); // Récupère l'état
// Replanifie après l'intervalle (même si fetchGameState est en cours)
// Pour éviter accumulation, on pourrait replanifier dans onResponse/onFailure
// Mais pour simple polling, c'est ok.
pollingHandler.postDelayed(this, POLLING_INTERVAL_MS);
}
};
pollingHandler.post(pollingRunnable); // Lance la première exécution
}
// --- WebSocket Management ---
/** Arrête le polling périodique. */
private void stopPolling() {
if (isPollingActive && pollingHandler != null && pollingRunnable != null) {
Log.d(TAG, "Arrêt du polling.");
isPollingActive = false;
pollingHandler.removeCallbacks(pollingRunnable);
}
}
/** Met à jour l'interface multijoueur basée sur l'état reçu. */
private void updateMultiplayerUI(GameStateResponse state) {
if (state == null) return;
// Met à jour les scores
// TODO: Adapter la logique getMyScore/getOpponentScore avec les vrais ID
myScoreLabelMulti.setText("Moi:\n" + state.getMyScore(myPlayerId));
opponentScoreLabelMulti.setText("Autre:\n" + state.getOpponentScore(myPlayerId));
// Met à jour l'indicateur de tour
boolean myTurn = myPlayerId.equals(state.getCurrentPlayerId());
turnIndicatorMulti.setText(myTurn ? "Votre Tour" : "Tour Adversaire");
turnIndicatorMulti.setTextColor(ContextCompat.getColor(this, myTurn ? R.color.tile_16 : R.color.text_tile_low)); // Exemple couleur
// Met à jour le plateau
syncBoardViewMulti(state.getBoard());
// Met à jour le statut
if(state.isGameOver()){
statusTextMulti.setText(state.getWinnerId() != null ? (state.getWinnerId().equals(myPlayerId) ? "Vous avez gagné !" : "Vous avez perdu.") : "Partie Terminée !");
} else if (!myTurn) {
statusTextMulti.setText("En attente du coup adverse...");
} else {
statusTextMulti.setText(""); // Vide si c'est notre tour et pas fini
}
}
/** Synchronise la vue de la grille multijoueur avec un état de plateau donné. */
private void syncBoardViewMulti(int[][] boardState) {
if (boardState == null || boardState.length != BOARD_SIZE || boardState[0].length != BOARD_SIZE) {
Log.e(TAG, "syncBoardViewMulti: État du plateau invalide.");
/** Initialise et lance la connexion WebSocket. */
private void connectWebSocket() {
if (currentGameId == null || myPlayerId == null) {
Log.e(TAG, "connectWebSocket: Impossible de connecter, gameId ou playerId est null.");
return;
}
// Logique similaire à syncBoardView de MainActivity
boardGridLayoutMulti.removeAllViews(); // Simplification par reset complet
tileViewsMulti = new TextView[BOARD_SIZE][BOARD_SIZE];
if (webSocket != null) {
Log.w(TAG, "connectWebSocket: Tentative de connexion alors qu'un WebSocket existe déjà. Fermeture de l'ancien.");
closeWebSocket();
}
Log.i(TAG, "Tentative de connexion WebSocket à: " + WEBSOCKET_URL);
statusTextMulti.setText(R.string.multiplayer_status_connecting);
showLoading(true);
Request request = new Request.Builder().url(WEBSOCKET_URL).build();
webSocket = wsClient.newWebSocket(request, webSocketListener); // Lance la connexion
}
/** Ferme la connexion WebSocket proprement. */
private void closeWebSocket() {
if (webSocket != null) {
Log.i(TAG, "Fermeture de la connexion WebSocket.");
webSocket.close(1000, "Activity closed");
webSocket = null;
isWebSocketConnected = false;
}
}
/** Méthode pour envoyer un message texte via le WebSocket. */
private void sendWebSocketMessage(String jsonMessage) {
if (webSocket != null && isWebSocketConnected) {
Log.d(TAG, "Envoi WS message: " + jsonMessage);
webSocket.send(jsonMessage);
} else {
Log.e(TAG, "sendWebSocketMessage: Tentative d'envoi alors que le WebSocket est fermé ou non connecté.");
Toast.makeText(this, R.string.error_websocket_disconnected, Toast.LENGTH_SHORT).show();
// On pourrait tenter une reconnexion ici si désiré
// connectWebSocket();
}
}
// --- WebSocket Listener Implementation ---
private class MyWebSocketListener extends WebSocketListener {
@Override
public void onOpen(@NonNull WebSocket ws, @NonNull okhttp3.Response response) { // okhttp3.Response ici !
super.onOpen(ws, response);
isWebSocketConnected = true;
Log.i(TAG, "WebSocket onOpen: Connexion établie !");
// Envoie le message d'enregistrement
RegisterMessage registerMsg = new RegisterMessage("register", myPlayerId, currentGameId);
String registerJson = gson.toJson(registerMsg);
ws.send(registerJson); // Envoie l'enregistrement
// Met à jour l'UI sur le thread principal
runOnUiThread(() -> {
showLoading(false);
statusTextMulti.setText(R.string.multiplayer_status_waiting_state);
Toast.makeText(MultiplayerActivity.this, R.string.websocket_connected, Toast.LENGTH_SHORT).show();
});
}
@Override
public void onMessage(@NonNull WebSocket ws, @NonNull String text) {
super.onMessage(ws, text);
Log.d(TAG, "WebSocket onMessage: Reçu texte: " + text);
try {
BaseMessage baseMessage = gson.fromJson(text, BaseMessage.class);
if ("gameStateUpdate".equals(baseMessage.type)) {
currentGameState = gson.fromJson(text, GameStateResponse.class);
Log.i(TAG, "WebSocket onMessage: GameStateUpdate reçu. Tour: " + currentGameState.getCurrentPlayerId());
runOnUiThread(() -> {
showLoading(false);
updateMultiplayerUI(currentGameState);
if (currentGameState.isGameOver()) {
handleGameOverUI(currentGameState);
}
});
} else if ("error".equals(baseMessage.type)) {
ErrorMessage errorMsg = gson.fromJson(text, ErrorMessage.class);
Log.e(TAG, "WebSocket onMessage: Erreur serveur reçue: " + errorMsg.message);
runOnUiThread(() -> {
Toast.makeText(MultiplayerActivity.this, getString(R.string.server_error_prefix, errorMsg.message), Toast.LENGTH_LONG).show();
statusTextMulti.setText(getString(R.string.server_error_prefix, errorMsg.message));
});
} else if ("info".equals(baseMessage.type)) {
InfoMessage infoMsg = gson.fromJson(text, InfoMessage.class);
Log.i(TAG, "WebSocket onMessage: Info serveur reçue: " + infoMsg.message);
runOnUiThread(() -> Toast.makeText(MultiplayerActivity.this, "Info: " + infoMsg.message, Toast.LENGTH_SHORT).show());
} else {
Log.w(TAG, "WebSocket onMessage: Type de message inconnu reçu: " + baseMessage.type);
}
} catch (JsonSyntaxException e) {
Log.e(TAG, "WebSocket onMessage: Erreur parsing JSON", e);
}
}
@Override
public void onMessage(@NonNull WebSocket ws, @NonNull ByteString bytes) {
super.onMessage(ws, bytes);
Log.w(TAG, "WebSocket onMessage: Reçu message binaire (non géré)");
}
@Override
public void onClosing(@NonNull WebSocket ws, int code, @NonNull String reason) {
super.onClosing(ws, code, reason);
Log.i(TAG, "WebSocket onClosing: Fermeture demandée par le serveur. Code=" + code + ", Raison=" + reason);
isWebSocketConnected = false;
ws.close(1000, null);
runOnUiThread(() -> {
statusTextMulti.setText(getString(R.string.websocket_closing));
showLoading(false);
});
}
@Override
public void onClosed(@NonNull WebSocket ws, int code, @NonNull String reason) {
super.onClosed(ws, code, reason);
Log.i(TAG, "WebSocket onClosed: Connexion fermée. Code=" + code + ", Raison=" + reason);
isWebSocketConnected = false;
webSocket = null;
runOnUiThread(() -> {
statusTextMulti.setText(getString(R.string.websocket_closed));
showLoading(false);
// Gérer la reconnexion si nécessaire
});
}
@Override
public void onFailure(@NonNull WebSocket ws, @NonNull Throwable t, @Nullable okhttp3.Response response) { // okhttp3.Response ici !
super.onFailure(ws, t, response);
Log.e(TAG, "WebSocket onFailure: Erreur de connexion.", t);
isWebSocketConnected = false;
webSocket = null;
runOnUiThread(() -> {
handleNetworkError(getString(R.string.error_websocket_connection));
showLoading(false);
// Gérer la reconnexion si nécessaire
});
}
}
// --- Data Classes internes pour WebSocket Messages ---
private static class BaseMessage { String type; }
private static class ErrorMessage extends BaseMessage { String message; }
private static class InfoMessage extends BaseMessage { String message; }
private static class RegisterMessage extends BaseMessage { String playerId; String gameId;
RegisterMessage(String type, String pId, String gId){ this.type=type; this.playerId=pId; this.gameId=gId; }
}
private static class MoveMessage extends BaseMessage { String direction; String playerId;
MoveMessage(String type, String dir, String pId) {this.type=type; this.direction=dir; this.playerId=pId;}
}
// --- UI Update ---
/** Met à jour l'interface multijoueur. DOIT être appelée sur le thread UI. */
private void updateMultiplayerUI(GameStateResponse state) {
if (state == null) return;
if (Looper.myLooper() != Looper.getMainLooper()) { runOnUiThread(() -> updateMultiplayerUI(state)); return; }
Log.d(TAG, "updateMultiplayerUI: Mise à jour sur thread UI.");
myScoreLabelMulti.setText(getString(R.string.multiplayer_my_score, state.getMyScore(myPlayerId)));
opponentScoreLabelMulti.setText(getString(R.string.multiplayer_opponent_score, state.getOpponentScore(myPlayerId)));
boolean myTurn = myPlayerId != null && myPlayerId.equals(state.getCurrentPlayerId());
turnIndicatorMulti.setText(myTurn ? R.string.multiplayer_turn_yours : R.string.multiplayer_turn_opponent);
turnIndicatorMulti.setTextColor(ContextCompat.getColor(this, myTurn ? R.color.tile_16 : R.color.text_tile_low));
syncBoardViewMulti(state.getBoard());
if (!state.isGameOver()) {
if (!myTurn) { statusTextMulti.setText(R.string.multiplayer_status_waiting_opponent); }
else { statusTextMulti.setText(""); }
}
// Le message de fin est géré par handleGameOverUI
}
/** Gère l'affichage de fin de partie. DOIT être appelée sur le thread UI. */
private void handleGameOverUI(GameStateResponse finalState) {
if (finalState == null || !finalState.isGameOver()) return;
if (Looper.myLooper() != Looper.getMainLooper()) { runOnUiThread(() -> handleGameOverUI(finalState)); return; }
Log.i(TAG, "handleGameOverUI: Partie terminée. Winner: " + finalState.getWinnerId());
String endMessage;
if ("DRAW".equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_draw); }
else if (myPlayerId != null && myPlayerId.equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_won); }
else if (opponentPlayerId != null && opponentPlayerId.equals(finalState.getWinnerId())) { endMessage = getString(R.string.game_over_lost); }
else { endMessage = getString(R.string.game_over_generic); }
statusTextMulti.setText(endMessage);
turnIndicatorMulti.setText("");
closeWebSocket(); // Fermer le WebSocket à la fin de la partie
}
// --- Board Sync (inchangé) ---
/** Synchronise la vue de la grille multijoueur avec un état de plateau donné. */
private void syncBoardViewMulti(int[][] boardState) {
if (boardState == null || boardState.length != BOARD_SIZE || boardState[0].length != BOARD_SIZE) { Log.e(TAG, "syncBoardViewMulti: État du plateau invalide."); return; }
boardGridLayoutMulti.removeAllViews();
tileViewsMulti = new TextView[BOARD_SIZE][BOARD_SIZE];
for (int r = 0; r < BOARD_SIZE; r++) {
for (int c = 0; c < BOARD_SIZE; c++) {
// Ajout fond (peut être optimisé si le fond ne change jamais)
View backgroundCell = new View(this);
backgroundCell.setBackgroundResource(R.drawable.tile_background);
backgroundCell.getBackground().setTintList(ContextCompat.getColorStateList(this, R.color.tile_empty));
@ -257,25 +441,19 @@ public class MultiplayerActivity extends AppCompatActivity {
bgParams.setMargins(margin, margin, margin, margin);
backgroundCell.setLayoutParams(bgParams);
boardGridLayoutMulti.addView(backgroundCell);
// Ajout tuile si valeur > 0
int value = boardState[r][c];
if (value > 0) {
TextView tileView = createTileTextViewMulti(value, r, c); // Utilise helper adapté
TextView tileView = createTileTextViewMulti(value, r, c);
tileViewsMulti[r][c] = tileView;
boardGridLayoutMulti.addView(tileView);
} else {
tileViewsMulti[r][c] = null;
} else { tileViewsMulti[r][c] = null; }
}
}
}
}
/** Crée une TextView pour une tuile multijoueur (similaire à MainActivity). */
/** Crée une TextView pour une tuile multijoueur. */
private TextView createTileTextViewMulti(int value, int row, int col) {
TextView tileTextView = new TextView(this);
setTileStyleMulti(tileTextView, value); // Utilise helper de style adapté
setTileStyleMulti(tileTextView, value);
GridLayout.LayoutParams params = new GridLayout.LayoutParams(GridLayout.spec(row, 1f), GridLayout.spec(col, 1f));
params.width = 0; params.height = 0;
int margin = (int) getResources().getDimension(R.dimen.tile_margin);
@ -283,10 +461,8 @@ public class MultiplayerActivity extends AppCompatActivity {
tileTextView.setLayoutParams(params);
return tileTextView;
}
/** Applique le style à une tuile (similaire à MainActivity). */
/** Applique le style à une tuile. */
private void setTileStyleMulti(TextView tileTextView, int value) {
// Copier/Adapter la logique de setTileStyle de MainActivity ici
tileTextView.setText(value > 0 ? String.valueOf(value) : "");
tileTextView.setGravity(Gravity.CENTER);
tileTextView.setTypeface(null, android.graphics.Typeface.BOLD);
@ -295,7 +471,15 @@ public class MultiplayerActivity extends AppCompatActivity {
case 0: backgroundColorId = R.color.tile_empty; textColorId = android.R.color.transparent; textSizeId = R.dimen.text_size_tile_small; break;
case 2: backgroundColorId = R.color.tile_2; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break;
case 4: backgroundColorId = R.color.tile_4; textColorId = R.color.text_tile_low; textSizeId = R.dimen.text_size_tile_small; break;
// ... autres cas ...
case 8: backgroundColorId = R.color.tile_8; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break;
case 16: backgroundColorId = R.color.tile_16; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break;
case 32: backgroundColorId = R.color.tile_32; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break;
case 64: backgroundColorId = R.color.tile_64; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_small; break;
case 128: backgroundColorId = R.color.tile_128; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break;
case 256: backgroundColorId = R.color.tile_256; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break;
case 512: backgroundColorId = R.color.tile_512; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_medium; break;
case 1024: backgroundColorId = R.color.tile_1024; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break;
case 2048: backgroundColorId = R.color.tile_2048; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break;
default: backgroundColorId = R.color.tile_super; textColorId = R.color.text_tile_high; textSizeId = R.dimen.text_size_tile_large; break;
}
tileTextView.setBackgroundResource(R.drawable.tile_background);
@ -304,6 +488,7 @@ public class MultiplayerActivity extends AppCompatActivity {
tileTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(textSizeId));
}
// --- Swipe Handling (Modifié pour envoyer via WebSocket) ---
/** Configure le listener de swipe pour le plateau multijoueur. */
@SuppressLint("ClickableViewAccessibility")
@ -316,71 +501,33 @@ public class MultiplayerActivity extends AppCompatActivity {
}));
}
/** Gère un swipe dans le contexte multijoueur. */
/** Gère un swipe : envoie un message WebSocket 'move'. */
private void handleMultiplayerSwipe(Direction direction) {
if (currentGameState == null || currentGameId == null || currentGameState.isGameOver()) {
Log.d(TAG, "Swipe ignoré (jeu non prêt ou terminé).");
return;
}
// Vérifie si c'est notre tour
if (!myPlayerId.equals(currentGameState.getCurrentPlayerId())) {
Log.d(TAG, "Swipe ignoré (pas notre tour).");
Toast.makeText(this, "Ce n'est pas votre tour.", Toast.LENGTH_SHORT).show();
return;
if (currentGameState == null || currentGameId == null || currentGameState.isGameOver() || myPlayerId == null) { Log.d(TAG, "Swipe ignoré (état invalide)."); return; }
if (!myPlayerId.equals(currentGameState.getCurrentPlayerId())) { Toast.makeText(this, R.string.error_not_your_turn, Toast.LENGTH_SHORT).show(); return; }
if (webSocket == null || !isWebSocketConnected) { Toast.makeText(this, R.string.error_websocket_disconnected, Toast.LENGTH_SHORT).show(); connectWebSocket(); return; }
Log.d(TAG, "Swipe détecté: " + direction + ". Envoi du mouvement via WebSocket...");
statusTextMulti.setText(R.string.multiplayer_status_sending_move);
MoveMessage moveMsg = new MoveMessage("move", direction.name(), myPlayerId);
String moveJson = gson.toJson(moveMsg);
sendWebSocketMessage(moveJson);
// L'UI sera mise à jour à la réception du gameStateUpdate
}
Log.d(TAG, "Swipe détecté: " + direction + ". Envoi du mouvement...");
showLoading(true); // Affiche indicateur pendant l'envoi
statusTextMulti.setText("Envoi du mouvement...");
stopPolling(); // Arrête le polling pendant qu'on joue notre coup
String directionString = direction.name(); // "UP", "DOWN", etc.
MoveRequest moveRequest = new MoveRequest(directionString, myPlayerId);
apiService.makeMove(currentGameId, moveRequest).enqueue(new Callback<GameStateResponse>() {
@Override
public void onResponse(@NonNull Call<GameStateResponse> call, @NonNull Response<GameStateResponse> response) {
// --- Utility Methods ---
private void showLoading(boolean show) {
if (loadingIndicatorMulti != null) { loadingIndicatorMulti.setVisibility(show ? View.VISIBLE : View.GONE); }
}
private void handleNetworkError(String message) {
runOnUiThread(() -> {
if (statusTextMulti != null) { statusTextMulti.setText(message); }
Toast.makeText(MultiplayerActivity.this, message, Toast.LENGTH_LONG).show();
showLoading(false);
if (response.isSuccessful() && response.body() != null) {
Log.i(TAG, "Mouvement envoyé avec succès.");
currentGameState = response.body();
updateMultiplayerUI(currentGameState); // Met à jour l'UI avec le nouvel état
if (!currentGameState.isGameOver()) {
startPolling(); // Redémarre le polling pour attendre l'adversaire
} else {
// Afficher message fin de partie (déjà fait dans updateUI)
}
} else {
Log.e(TAG, "Erreur envoi mouvement: " + response.code() + " - " + response.message());
handleNetworkError("Erreur lors de l'envoi du mouvement. Code: " + response.code());
// Si erreur, on devrait peut-être retenter de récupérer l'état ?
fetchGameState(); // Pour resynchroniser
}
}
@Override
public void onFailure(@NonNull Call<GameStateResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Échec envoi mouvement", t);
showLoading(false);
handleNetworkError("Échec connexion pour envoi mouvement.");
// Retenter de récupérer l'état ?
fetchGameState();
}
});
}
private void showLoading(boolean show) {
loadingIndicatorMulti.setVisibility(show ? View.VISIBLE : View.GONE);
}
private void handleNetworkError(String message) {
statusTextMulti.setText(message);
// Optionnel: Afficher un Toast aussi
Toast.makeText(this, message, Toast.LENGTH_LONG).show();
}
/** Énumération interne pour les directions (peut être partagée avec MainActivity). */
private enum Direction { UP, DOWN, LEFT, RIGHT }
} // Fin MultiplayerActivity

View File

@ -93,4 +93,30 @@
<string name="settings_test_notif_inactivity">Test Inactivity Notif</string>
<string name="sound_enabled">Sound effects enabled.</string>
<string name="sound_disabled">Sound effects disabled.</string>
<string name="multiplayer_status_searching">Recherche d\'une partie…</string>
<string name="multiplayer_status_found">Partie trouvée ! ID: %s…</string> <string name="multiplayer_status_connecting">Connexion au serveur…</string>
<string name="multiplayer_status_waiting_state">Connecté. En attente de l\'état du jeu…</string>
<string name="multiplayer_status_waiting_opponent">En attente du coup adverse…</string>
<string name="multiplayer_status_sending_move">Envoi du mouvement…</string>
<string name="multiplayer_turn_yours">À Votre Tour</string>
<string name="multiplayer_turn_opponent">Tour Adversaire</string>
<string name="multiplayer_my_score">Moi :\n%d</string>
<string name="multiplayer_opponent_score">Autre :\n%d</string>
<string name="error_join_create_game">Impossible de créer ou rejoindre (Code: %d)</string>
<string name="error_server_connection">Échec de connexion au serveur.</string>
<string name="error_websocket_connection">Erreur de connexion WebSocket.</string>
<string name="error_websocket_disconnected">WebSocket déconnecté. Tentative de reconnexion…</string>
<string name="error_not_your_turn">Ce n\'est pas votre tour.</string>
<string name="server_error_prefix">Erreur Serveur: %s</string>
<string name="websocket_connected">Connecté au serveur de jeu !</string>
<string name="websocket_closing">Fermeture de la connexion…</string>
<string name="websocket_closed">Connexion fermée.</string>
<string name="game_over_draw">Égalité !</string>
<string name="game_over_won">Vous avez Gagné !</string>
<string name="game_over_lost">Vous avez Perdu.</string>
<string name="game_over_generic">Partie Terminée !</string>
</resources>

View File

@ -28,6 +28,7 @@ material = { group = "com.google.android.material", name = "material", version.r
activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
gridlayout = { group = "androidx.gridlayout", name = "gridlayout", version.ref = "gridlayout" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "loggingInterceptor" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
[plugins]