diff --git a/android/ic_launcher-web.png b/android/ic_launcher-web.png index fd910c5..aa62d80 100644 Binary files a/android/ic_launcher-web.png and b/android/ic_launcher-web.png differ diff --git a/android/regicidechess.png b/android/regicidechess.png new file mode 100644 index 0000000..c891e47 Binary files /dev/null and b/android/regicidechess.png differ diff --git a/android/res/drawable-hdpi/ic_launcher.png b/android/res/drawable-hdpi/ic_launcher.png index 46b36d4..5257281 100644 Binary files a/android/res/drawable-hdpi/ic_launcher.png and b/android/res/drawable-hdpi/ic_launcher.png differ diff --git a/android/res/drawable-mdpi/ic_launcher.png b/android/res/drawable-mdpi/ic_launcher.png index c162dec..a2f07d6 100644 Binary files a/android/res/drawable-mdpi/ic_launcher.png and b/android/res/drawable-mdpi/ic_launcher.png differ diff --git a/android/res/drawable-xhdpi/ic_launcher.png b/android/res/drawable-xhdpi/ic_launcher.png index 8693030..611377b 100644 Binary files a/android/res/drawable-xhdpi/ic_launcher.png and b/android/res/drawable-xhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxhdpi/ic_launcher.png b/android/res/drawable-xxhdpi/ic_launcher.png index fcc0814..fcb2409 100644 Binary files a/android/res/drawable-xxhdpi/ic_launcher.png and b/android/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxxhdpi/ic_launcher.png b/android/res/drawable-xxxhdpi/ic_launcher.png index 9c26815..8f44e34 100644 Binary files a/android/res/drawable-xxxhdpi/ic_launcher.png and b/android/res/drawable-xxxhdpi/ic_launcher.png differ diff --git a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java index 4e311f6..0509f51 100644 --- a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java @@ -1,392 +1,120 @@ package com.group14.regicidechess.android; -import android.os.Handler; -import android.os.Looper; - -import com.google.firebase.database.ChildEventListener; -import com.google.firebase.database.DataSnapshot; -import com.google.firebase.database.DatabaseError; import com.google.firebase.database.DatabaseReference; import com.google.firebase.database.FirebaseDatabase; -import com.google.firebase.database.ValueEventListener; import com.group14.regicidechess.API; import com.group14.regicidechess.database.FirebaseAPI; import com.group14.regicidechess.model.Lobby; import com.group14.regicidechess.model.Move; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - public class AndroidFirebase implements FirebaseAPI, API { + private final FirebaseUtils utils = new FirebaseUtils(); private final DatabaseReference db = FirebaseDatabase.getInstance().getReference(); - private final List cleanupActions = new ArrayList<>(); - private ValueEventListener trackValue(DatabaseReference ref, ValueEventListener listener) { - cleanupActions.add(() -> ref.removeEventListener(listener)); - return listener; - } + private final FirebaseLobbyManager lobbyManager; + private final FirebaseSetupManager setupManager; + private final FirebaseMoveManager moveManager; + private final FirebaseHeartbeatManager heartbeatManager; + private final FirebaseConnectionManager connectionManager; + private final FirebaseGameOverManager gameOverManager; - private ChildEventListener trackChild(DatabaseReference ref, ChildEventListener listener) { - cleanupActions.add(() -> ref.removeEventListener(listener)); - return listener; + public AndroidFirebase() { + utils.fetchServerTimeOffset(db); + lobbyManager = new FirebaseLobbyManager(db, utils); + setupManager = new FirebaseSetupManager(db, utils); + moveManager = new FirebaseMoveManager(db, utils); + heartbeatManager = new FirebaseHeartbeatManager(db, utils); + connectionManager = new FirebaseConnectionManager(db, utils); + gameOverManager = new FirebaseGameOverManager(db, utils); } - // ── API (Legacy) ────────────────────────────────────────────────────────── - - @Override - public void createLobby() { - db.child("lobbies").push().setValue(1); + // Lobby + @Override public void createLobby(Lobby lobby, Callback onSuccess, Callback onError) { + lobbyManager.createLobby(lobby, onSuccess, onError); } - - // ── Lobby ───────────────────────────────────────────────────────────────── - - @Override - public void createLobby(Lobby lobby, Callback onSuccess, Callback onError) { - String gameId = lobby.getGameId(); - Map data = new HashMap<>(); - data.put("boardSize", lobby.getBoardSize()); - data.put("budget", lobby.getBudget()); - data.put("status", "waiting"); - - db.child("lobbies").child(gameId).setValue(data) - .addOnSuccessListener(v -> onSuccess.call(gameId)) - .addOnFailureListener(e -> onError.call(e.getMessage())); + @Override public void fetchLobby(String gameId, Callback onSuccess, Callback onError) { + lobbyManager.fetchLobby(gameId, onSuccess, onError); } - - @Override - public void fetchLobby(String gameId, Callback onSuccess, Callback onError) { - db.child("lobbies").child(gameId).get() - .addOnSuccessListener(snapshot -> { - if (!snapshot.exists()) { onError.call("Lobby not found"); return; } - int boardSize = getInt(snapshot, "boardSize"); - int budget = getInt(snapshot, "budget"); - onSuccess.call(new Lobby(gameId, boardSize, budget, - System.currentTimeMillis() + 30 * 60 * 1000L)); - }) - .addOnFailureListener(e -> onError.call(e.getMessage())); + @Override public void joinLobby(String gameId, Callback onSuccess, Callback onError) { + lobbyManager.joinLobby(gameId, onSuccess, onError); } - - @Override - public void joinLobby(String gameId, Callback onSuccess, Callback onError) { - db.child("lobbies").child(gameId).get() - .addOnSuccessListener(snapshot -> { - if (!snapshot.exists()) { onError.call("Lobby not found"); return; } - int boardSize = getInt(snapshot, "boardSize"); - int budget = getInt(snapshot, "budget"); - db.child("lobbies").child(gameId).child("status").setValue("joined"); - onSuccess.call(new Lobby(gameId, boardSize, budget, - System.currentTimeMillis() + 30 * 60 * 1000L)); - }) - .addOnFailureListener(e -> onError.call(e.getMessage())); + @Override public void listenForOpponentReady(String gameId, Runnable onReady) { + lobbyManager.listenForOpponentReady(gameId, onReady); } - - @Override - public void listenForOpponentReady(String gameId, Runnable onReady) { - DatabaseReference statusRef = db.child("lobbies").child(gameId).child("status"); - statusRef.addValueEventListener(trackValue(statusRef, new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - if ("joined".equals(snapshot.getValue(String.class))) { - snapshot.getRef().removeEventListener(this); - onReady.run(); - } - } - @Override public void onCancelled(DatabaseError e) {} - })); + @Override public void startGame(String gameId) { lobbyManager.startGame(gameId); } + @Override public void listenForGameStart(String gameId, Runnable onStart) { + lobbyManager.listenForGameStart(gameId, onStart); } - @Override - public void startGame(String gameId) { - db.child("lobbies").child(gameId).child("status").setValue("started"); + // Setup + @Override public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess, Callback onError) { + setupManager.confirmSetup(gameId, isWhite, board, onSuccess, onError); } - - @Override - public void listenForGameStart(String gameId, Runnable onStart) { - DatabaseReference statusRef = db.child("lobbies").child(gameId).child("status"); - statusRef.addValueEventListener(trackValue(statusRef, new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - if ("started".equals(snapshot.getValue(String.class))) { - snapshot.getRef().removeEventListener(this); - onStart.run(); - } - } - @Override public void onCancelled(DatabaseError e) {} - })); + @Override public void unconfirmSetup(String gameId, boolean isWhite, Runnable onSuccess, Callback onError) { + setupManager.unconfirmSetup(gameId, isWhite, onSuccess, onError); } - - // ── Setup ───────────────────────────────────────────────────────────────── - - @Override - public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess, Callback onError) { - String player = isWhite ? "white" : "black"; - DatabaseReference gameRef = db.child("games").child(gameId); - - List> pieces = new ArrayList<>(); - for (int col = 0; col < board.length; col++) { - for (int row = 0; row < board[col].length; row++) { - if (board[col][row] != 0) { - Map entry = new HashMap<>(); - entry.put("col", col); - entry.put("row", row); - entry.put("piece", board[col][row]); - pieces.add(entry); - } - } - } - - gameRef.child("boards").child(player).setValue(pieces) - .addOnSuccessListener(v1 -> - gameRef.child("setup").child(player).setValue("ready") - .addOnSuccessListener(v2 -> - gameRef.child("setup").addListenerForSingleValueEvent(new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - boolean whiteReady = "ready".equals(snapshot.child("white").getValue(String.class)); - boolean blackReady = "ready".equals(snapshot.child("black").getValue(String.class)); - if (whiteReady && blackReady) { - gameRef.child("bothReady").setValue(true); - } - onSuccess.run(); - } - @Override public void onCancelled(DatabaseError e) { onError.call(e.getMessage()); } - }) - ) - .addOnFailureListener(e -> onError.call(e.getMessage())) - ) - .addOnFailureListener(e -> onError.call(e.getMessage())); + @Override public void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard) { + setupManager.getOpponentBoard(gameId, localIsWhite, onBoard); } - - @Override - public void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard) { - String opponentKey = localIsWhite ? "black" : "white"; - db.child("games").child(gameId).child("boards").child(opponentKey).get() - .addOnSuccessListener(snapshot -> { - if (!snapshot.exists()) { onBoard.call(new int[0][0]); return; } - int maxCol = 0, maxRow = 0; - for (DataSnapshot entry : snapshot.getChildren()) { - int c = getInt(entry, "col"), r = getInt(entry, "row"); - if (c > maxCol) maxCol = c; - if (r > maxRow) maxRow = r; - } - int size = Math.max(maxCol, maxRow) + 1; - int[][] b = new int[size][size]; - for (DataSnapshot entry : snapshot.getChildren()) { - b[getInt(entry, "col")][getInt(entry, "row")] = getInt(entry, "piece"); - } - onBoard.call(b); - }) - .addOnFailureListener(e -> onBoard.call(new int[0][0])); + @Override public void listenForBothSetupReady(String gameId, Runnable onBothReady) { + setupManager.listenForBothSetupReady(gameId, onBothReady); } - @Override - public void listenForBothSetupReady(String gameId, Runnable onBothReady) { - DatabaseReference bothReadyRef = db.child("games").child(gameId).child("bothReady"); - bothReadyRef.addValueEventListener(trackValue(bothReadyRef, new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - Boolean bothReady = snapshot.getValue(Boolean.class); - if (bothReady != null && bothReady) { - snapshot.getRef().removeEventListener(this); - onBothReady.run(); - } - } - @Override public void onCancelled(DatabaseError e) {} - })); + // Moves + @Override public void saveMove(String gameId, Move move, Runnable onSuccess) { + moveManager.saveMove(gameId, move, onSuccess); } - - // ── Moves ───────────────────────────────────────────────────────────────── - - @Override - public void saveMove(String gameId, Move move, Runnable onSuccess) { - Map data = new HashMap<>(); - data.put("fromCol", (int) move.getFrom().x); - data.put("fromRow", (int) move.getFrom().y); - data.put("toCol", (int) move.getTo().x); - data.put("toRow", (int) move.getTo().y); - data.put("player", move.getPlayer().isWhite() ? "white" : "black"); - data.put("timestamp", System.currentTimeMillis()); - if (move.getPromotion() != null) { - data.put("promotion", move.getPromotion()); - } - - db.child("games").child(gameId).child("moves").push() - .setValue(data) - .addOnSuccessListener(v -> onSuccess.run()); + @Override public void listenForOpponentMove(String gameId, Callback onMove) { + moveManager.listenForOpponentMove(gameId, onMove); } - @Override - public void listenForOpponentMove(String gameId, Callback onMove) { - // Only listen for moves added AFTER we attach the listener. - // We do this by first reading the last known key, then attaching - // a child listener that skips everything up to and including that key. - db.child("games").child(gameId).child("moves") - .orderByKey().limitToLast(1) - .get() - .addOnSuccessListener(snapshot -> { - // Collect the key of the last existing move (may be null if none) - final String[] lastExistingKey = { null }; - for (DataSnapshot child : snapshot.getChildren()) { - lastExistingKey[0] = child.getKey(); - } - - DatabaseReference movesRef = db.child("games").child(gameId).child("moves"); - movesRef.addChildEventListener(trackChild(movesRef, new ChildEventListener() { - private boolean seenStart = (lastExistingKey[0] == null); - - @Override - public void onChildAdded(DataSnapshot snapshot, String prev) { - // Skip all pre-existing moves until we pass the last known key - if (!seenStart) { - if (snapshot.getKey().equals(lastExistingKey[0])) { - seenStart = true; - } - return; - } - - int fromCol = getInt(snapshot, "fromCol"); - int fromRow = getInt(snapshot, "fromRow"); - int toCol = getInt(snapshot, "toCol"); - int toRow = getInt(snapshot, "toRow"); - String mover = snapshot.child("player").getValue(String.class); - int isWhite = "white".equals(mover) ? 1 : 0; - - String promo = snapshot.child("promotion").getValue(String.class); - int promoCode = 0; - if (promo != null) { - if ("Queen".equals(promo)) promoCode = 2; - else if ("Rook".equals(promo)) promoCode = 3; - else if ("Bishop".equals(promo)) promoCode = 4; - else if ("Knight".equals(promo)) promoCode = 5; - } - - onMove.call(new int[]{fromCol, fromRow, toCol, toRow, isWhite, promoCode}); - } - @Override public void onChildChanged(DataSnapshot s, String p) {} - @Override public void onChildRemoved(DataSnapshot s) {} - @Override public void onChildMoved (DataSnapshot s, String p) {} - @Override public void onCancelled (DatabaseError e) {} - })); - }) - .addOnFailureListener(e -> { - // Fallback: attach listener without filtering (original behaviour) - DatabaseReference movesRef = db.child("games").child(gameId).child("moves"); - movesRef.addChildEventListener(trackChild(movesRef, new ChildEventListener() { - @Override - public void onChildAdded(DataSnapshot snapshot, String prev) { - int fromCol = getInt(snapshot, "fromCol"); - int fromRow = getInt(snapshot, "fromRow"); - int toCol = getInt(snapshot, "toCol"); - int toRow = getInt(snapshot, "toRow"); - String mover = snapshot.child("player").getValue(String.class); - int isWhite = "white".equals(mover) ? 1 : 0; - onMove.call(new int[]{fromCol, fromRow, toCol, toRow, isWhite, 0}); - } - @Override public void onChildChanged(DataSnapshot s, String p) {} - @Override public void onChildRemoved(DataSnapshot s) {} - @Override public void onChildMoved (DataSnapshot s, String p) {} - @Override public void onCancelled (DatabaseError e) {} - })); - }); + // Heartbeat & latency + @Override public void sendLatency(String gameId, boolean isWhite, long latencyMs) { + heartbeatManager.sendLatency(gameId, isWhite, latencyMs); } - - // ── Heartbeat ───────────────────────────────────────────────────────────── - - @Override - public void sendHeartbeat(String gameId, boolean isWhite) { - db.child("games").child(gameId).child("heartbeat") - .child(isWhite ? "white" : "black") - .setValue(System.currentTimeMillis()); + @Override public void listenForOpponentLatency(String gameId, boolean listenForWhite, Callback onLatency) { + heartbeatManager.listenForOpponentLatency(gameId, listenForWhite, onLatency); } - - @Override - public void listenForHeartbeat(String gameId, boolean listenForWhite, - long timeoutMs, Callback onHeartbeat, Runnable onTimeout) { - String player = listenForWhite ? "white" : "black"; - Handler handler = new Handler(Looper.getMainLooper()); - Runnable timeoutRunnable = onTimeout::run; - cleanupActions.add(() -> handler.removeCallbacks(timeoutRunnable)); - - // Start timeout timer — reset each time a valid heartbeat arrives - handler.postDelayed(timeoutRunnable, timeoutMs); - - DatabaseReference heartbeatRef = db.child("games").child(gameId).child("heartbeat").child(player); - heartbeatRef.addValueEventListener(trackValue(heartbeatRef, new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - Long timestamp = snapshot.getValue(Long.class); - if (timestamp == null) return; // No heartbeat yet — let timer run - - long now = System.currentTimeMillis(); - long latency = now - timestamp; - - // Ignore stale timestamps (e.g. from a previous game session) - if (latency < 0 || latency > timeoutMs) return; - - // Valid heartbeat — reset the timeout and report latency - handler.removeCallbacks(timeoutRunnable); - handler.postDelayed(timeoutRunnable, timeoutMs); - onHeartbeat.call(latency); - } - @Override public void onCancelled(DatabaseError e) {} - })); + @Override public void sendHeartbeat(String gameId, boolean isWhite) { + heartbeatManager.sendHeartbeat(gameId, isWhite); } - - // ── Game over ───────────────────────────────────────────────────────────── - - @Override - public void signalGameOver(String gameId, String reason) { - db.child("games").child(gameId).child("gameOver").setValue(reason); + @Override public void listenForHeartbeat(String gameId, boolean listenForWhite, long timeoutMs, Callback onHeartbeat, Runnable onTimeout) { + heartbeatManager.listenForHeartbeat(gameId, listenForWhite, timeoutMs, onHeartbeat, onTimeout); } - @Override - public void listenForGameOver(String gameId, Callback onGameOver) { - DatabaseReference gameOverRef = db.child("games").child(gameId).child("gameOver"); - gameOverRef.addValueEventListener(trackValue(gameOverRef, new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - String reason = snapshot.getValue(String.class); - if (reason != null) { - snapshot.getRef().removeEventListener(this); - onGameOver.call(reason); - } - } - @Override public void onCancelled(DatabaseError e) {} - })); + // Connection + @Override public void listenForMyConnection(Runnable onConnected, Runnable onDisconnected) { + connectionManager.listenForMyConnection(onConnected, onDisconnected); + } + @Override public void signalReconnected(String gameId, boolean isWhite) { + connectionManager.signalReconnected(gameId, isWhite); + } + @Override public void listenForOpponentReconnected(String gameId, boolean listenForWhite, Runnable onReconnected) { + connectionManager.listenForOpponentReconnected(gameId, listenForWhite, onReconnected); } - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - private int getInt(DataSnapshot snapshot, String key) { - Object val = snapshot.child(key).getValue(); - if (val instanceof Long) return ((Long) val).intValue(); - if (val instanceof Integer) return (Integer) val; - return 0; + // Game over & disconnect hooks + @Override public void signalGameOver(String gameId, String reason) { + gameOverManager.signalGameOver(gameId, reason); + } + @Override public void registerDisconnectGameOver(String gameId, String reason) { + gameOverManager.registerDisconnectGameOver(gameId, reason); + } + @Override public void listenForGameOver(String gameId, Callback onGameOver) { + gameOverManager.listenForGameOver(gameId, onGameOver); + } + @Override public void listenForOpponentDisconnectedAt(String gameId, String opponentColor, Runnable onDisconnected) { + gameOverManager.listenForOpponentDisconnectedAt(gameId, opponentColor, onDisconnected); } - @Override - public void unconfirmSetup(String gameId, boolean isWhite, Runnable onSuccess, Callback onError) { - String player = isWhite ? "white" : "black"; - DatabaseReference gameRef = db.child("games").child(gameId); - - // Clear the ready flag for this player and reset bothReady - gameRef.child("setup").child(player).removeValue() - .addOnSuccessListener(v -> { - gameRef.child("bothReady").removeValue() - .addOnSuccessListener(v2 -> onSuccess.run()) - .addOnFailureListener(e -> onError.call(e.getMessage())); - }) - .addOnFailureListener(e -> onError.call(e.getMessage())); + // Utilities + @Override public void removeAllListeners() { + utils.removeAllListeners(); } - @Override - public void removeAllListeners() { - for (Runnable cleanup : cleanupActions) cleanup.run(); - cleanupActions.clear(); + // Legacy API method + @Override public void createLobby() { + db.child("lobbies").push().setValue(1); } -} +} \ No newline at end of file diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseConnectionManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseConnectionManager.java new file mode 100644 index 0000000..4fabe92 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseConnectionManager.java @@ -0,0 +1,48 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; + +public class FirebaseConnectionManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseConnectionManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void listenForMyConnection(Runnable onConnected, Runnable onDisconnected) { + DatabaseReference connRef = db.child(".info/connected"); + ValueEventListener listener = utils.trackValue(connRef, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Boolean connected = s.getValue(Boolean.class); + if (Boolean.TRUE.equals(connected)) onConnected.run(); + else onDisconnected.run(); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public void signalReconnected(String gameId, boolean isWhite) { + String color = isWhite ? "white" : "black"; + db.child("games").child(gameId).child("disconnectedAt").child(color).onDisconnect().cancel(); + db.child("games").child(gameId).child("disconnectedAt").child(color).removeValue(); + db.child("games").child(gameId).child("reconnected").child(color).setValue(true); + } + + public void listenForOpponentReconnected(String gameId, boolean listenForWhite, Runnable onReconnected) { + String color = listenForWhite ? "white" : "black"; + DatabaseReference ref = db.child("games").child(gameId).child("reconnected").child(color); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Boolean val = s.getValue(Boolean.class); + if (Boolean.TRUE.equals(val)) onReconnected.run(); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseGameOverManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseGameOverManager.java new file mode 100644 index 0000000..32107b2 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseGameOverManager.java @@ -0,0 +1,69 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.MutableData; +import com.google.firebase.database.ServerValue; +import com.google.firebase.database.Transaction; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; + +public class FirebaseGameOverManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseGameOverManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void signalGameOver(String gameId, String reason) { + DatabaseReference ref = db.child("games").child(gameId).child("gameOver"); + ref.onDisconnect().cancel(); + ref.runTransaction(new Transaction.Handler() { + @Override public Transaction.Result doTransaction(MutableData currentData) { + if (currentData.getValue() != null) return Transaction.abort(); + currentData.setValue(reason); + return Transaction.success(currentData); + } + @Override public void onComplete(DatabaseError error, boolean committed, DataSnapshot snapshot) {} + }); + } + + public void registerDisconnectGameOver(String gameId, String reason) { + String color = reason.startsWith("disconnect:") ? reason.substring("disconnect:".length()) : reason; + DatabaseReference ref = db.child("games").child(gameId).child("disconnectedAt").child(color); + ref.onDisconnect().setValue(ServerValue.TIMESTAMP); + } + + public void listenForGameOver(String gameId, FirebaseAPI.Callback onGameOver) { + DatabaseReference ref = db.child("games").child(gameId).child("gameOver"); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + String reason = s.getValue(String.class); + if (reason != null) { + ref.removeEventListener(this); + onGameOver.call(reason); + } + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public void listenForOpponentDisconnectedAt(String gameId, String opponentColor, Runnable onDisconnected) { + DatabaseReference ref = db.child("games").child(gameId).child("disconnectedAt").child(opponentColor); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + if (s.getValue() != null) { + ref.removeEventListener(this); + utils.untrackValue(this); + onDisconnected.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + utils.trackValue(ref, listener); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseHeartbeatManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseHeartbeatManager.java new file mode 100644 index 0000000..ae8b786 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseHeartbeatManager.java @@ -0,0 +1,97 @@ +package com.group14.regicidechess.android; + +import android.os.Handler; +import android.os.Looper; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ServerValue; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class FirebaseHeartbeatManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseHeartbeatManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void sendLatency(String gameId, boolean isWhite, long latencyMs) { + String player = isWhite ? "white" : "black"; + db.child("games").child(gameId).child("latency").child(player).setValue(latencyMs); + } + + public void listenForOpponentLatency(String gameId, boolean listenForWhite, FirebaseAPI.Callback onLatency) { + String player = listenForWhite ? "white" : "black"; + DatabaseReference ref = db.child("games").child(gameId).child("latency").child(player); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Long latency = s.getValue(Long.class); + if (latency != null) onLatency.call(latency); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public void sendHeartbeat(String gameId, boolean isWhite) { + String playerKey = isWhite ? "white" : "black"; + long correctedSendTime = utils.serverNow(); + Map data = new HashMap<>(); + data.put("timestamp", ServerValue.TIMESTAMP); + data.put("serverCorrectedSendTime", correctedSendTime); + data.put("sender", playerKey); + db.child("games").child(gameId).child("heartbeat").child(playerKey).setValue(data); + } + + public void listenForHeartbeat(String gameId, boolean listenForWhite, + long timeoutMs, FirebaseAPI.Callback onHeartbeat, Runnable onTimeout) { + String player = listenForWhite ? "white" : "black"; + Handler handler = new Handler(Looper.getMainLooper()); + AtomicBoolean currentlyTimedOut = new AtomicBoolean(false); + final long[] lastHeartbeatTime = {utils.serverNow()}; + + Runnable timeoutRunnable = new Runnable() { + @Override public void run() { + long now = utils.serverNow(); + long timeSinceLast = now - lastHeartbeatTime[0]; + if (timeSinceLast >= timeoutMs) { + if (!currentlyTimedOut.getAndSet(true)) onTimeout.run(); + } else { + currentlyTimedOut.set(false); + } + handler.postDelayed(this, timeoutMs / 3); + } + }; + handler.postDelayed(timeoutRunnable, timeoutMs); + + DatabaseReference ref = db.child("games").child(gameId).child("heartbeat").child(player); + ValueEventListener listener = utils.trackValue(ref, new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Object value = s.getValue(); + if (value == null) return; + lastHeartbeatTime[0] = utils.serverNow(); + currentlyTimedOut.set(false); + long rtt = 0; + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) value; + Object sendTime = map.get("serverCorrectedSendTime"); + if (sendTime instanceof Long) { + rtt = utils.serverNow() - (Long) sendTime; + if (rtt < 0) rtt = 0; + } + } + onHeartbeat.call(rtt); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseLobbyManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseLobbyManager.java new file mode 100644 index 0000000..c472c4d --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseLobbyManager.java @@ -0,0 +1,92 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Lobby; + +import java.util.HashMap; +import java.util.Map; + +public class FirebaseLobbyManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseLobbyManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void createLobby(Lobby lobby, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + String gameId = lobby.getGameId(); + Map data = new HashMap<>(); + data.put("boardSize", lobby.getBoardSize()); + data.put("budget", lobby.getBudget()); + data.put("status", "waiting"); + + db.child("lobbies").child(gameId).setValue(data) + .addOnSuccessListener(v -> onSuccess.call(gameId)) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void fetchLobby(String gameId, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + db.child("lobbies").child(gameId).get() + .addOnSuccessListener(snapshot -> { + if (!snapshot.exists()) { onError.call("Lobby not found"); return; } + int boardSize = utils.getInt(snapshot, "boardSize"); + int budget = utils.getInt(snapshot, "budget"); + onSuccess.call(new Lobby(gameId, boardSize, budget, + System.currentTimeMillis() + 30 * 60 * 1000L)); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void joinLobby(String gameId, FirebaseAPI.Callback onSuccess, FirebaseAPI.Callback onError) { + db.child("lobbies").child(gameId).get() + .addOnSuccessListener(snapshot -> { + if (!snapshot.exists()) { onError.call("Lobby not found"); return; } + int boardSize = utils.getInt(snapshot, "boardSize"); + int budget = utils.getInt(snapshot, "budget"); + db.child("lobbies").child(gameId).child("status").setValue("joined"); + onSuccess.call(new Lobby(gameId, boardSize, budget, + System.currentTimeMillis() + 30 * 60 * 1000L)); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void listenForOpponentReady(String gameId, Runnable onReady) { + DatabaseReference ref = db.child("lobbies").child(gameId).child("status"); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + if ("joined".equals(s.getValue(String.class))) { + ref.removeEventListener(this); + onReady.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + ref.addValueEventListener(listener); + // cleanup will be handled by utils if needed, but we don't track here for simplicity + } + + public void startGame(String gameId) { + db.child("lobbies").child(gameId).child("status").setValue("started"); + } + + public void listenForGameStart(String gameId, Runnable onStart) { + DatabaseReference ref = db.child("lobbies").child(gameId).child("status"); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + if ("started".equals(s.getValue(String.class))) { + ref.removeEventListener(this); + onStart.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + ref.addValueEventListener(listener); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseMoveManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseMoveManager.java new file mode 100644 index 0000000..7cbaee0 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseMoveManager.java @@ -0,0 +1,73 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.ChildEventListener; +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ServerValue; +import com.group14.regicidechess.database.FirebaseAPI; +import com.group14.regicidechess.model.Move; + +import java.util.HashMap; +import java.util.Map; + +public class FirebaseMoveManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseMoveManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void saveMove(String gameId, Move move, Runnable onSuccess) { + Map data = new HashMap<>(); + data.put("fromCol", (int) move.getFrom().x); + data.put("fromRow", (int) move.getFrom().y); + data.put("toCol", (int) move.getTo().x); + data.put("toRow", (int) move.getTo().y); + data.put("player", move.getPlayer().isWhite() ? "white" : "black"); + data.put("timestamp", ServerValue.TIMESTAMP); + if (move.getPromotion() != null) { + data.put("promotion", move.getPromotion()); + } + + db.child("games").child(gameId).child("moves").push() + .setValue(data) + .addOnSuccessListener(v -> onSuccess.run()); + } + + public void listenForOpponentMove(String gameId, FirebaseAPI.Callback onMove) { + long startTime = System.currentTimeMillis(); + DatabaseReference movesRef = db.child("games").child(gameId).child("moves"); + + movesRef.orderByChild("timestamp").startAt(startTime) + .addChildEventListener(utils.trackChild(movesRef, new ChildEventListener() { + @Override public void onChildAdded(DataSnapshot s, String prev) { + int fromCol = utils.getInt(s, "fromCol"); + int fromRow = utils.getInt(s, "fromRow"); + int toCol = utils.getInt(s, "toCol"); + int toRow = utils.getInt(s, "toRow"); + String mover = s.child("player").getValue(String.class); + int isWhite = "white".equals(mover) ? 1 : 0; + + String promo = s.child("promotion").getValue(String.class); + int promoCode = 0; + if (promo != null) { + switch (promo) { + case "Queen": promoCode = 2; break; + case "Rook": promoCode = 3; break; + case "Bishop": promoCode = 4; break; + case "Knight": promoCode = 5; break; + } + } + onMove.call(new int[]{fromCol, fromRow, toCol, toRow, isWhite, promoCode}); + } + @Override public void onChildChanged(DataSnapshot s, String p) {} + @Override public void onChildRemoved(DataSnapshot s) {} + @Override public void onChildMoved(DataSnapshot s, String p) {} + @Override public void onCancelled(DatabaseError e) {} + })); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java new file mode 100644 index 0000000..0fc9053 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java @@ -0,0 +1,111 @@ +package com.group14.regicidechess.android; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; +import com.group14.regicidechess.database.FirebaseAPI; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FirebaseSetupManager { + + private final DatabaseReference db; + private final FirebaseUtils utils; + + public FirebaseSetupManager(DatabaseReference db, FirebaseUtils utils) { + this.db = db; + this.utils = utils; + } + + public void confirmSetup(String gameId, boolean isWhite, int[][] board, + Runnable onSuccess, FirebaseAPI.Callback onError) { + String player = isWhite ? "white" : "black"; + DatabaseReference gameRef = db.child("games").child(gameId); + + List> pieces = new ArrayList<>(); + for (int col = 0; col < board.length; col++) { + for (int row = 0; row < board[col].length; row++) { + if (board[col][row] != 0) { + Map entry = new HashMap<>(); + entry.put("col", col); + entry.put("row", row); + entry.put("piece", board[col][row]); + pieces.add(entry); + } + } + } + + gameRef.child("boards").child(player).setValue(pieces) + .addOnSuccessListener(v1 -> + gameRef.child("setup").child(player).setValue("ready") + .addOnSuccessListener(v2 -> + gameRef.child("setup").addListenerForSingleValueEvent(new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + boolean whiteReady = "ready".equals(s.child("white").getValue(String.class)); + boolean blackReady = "ready".equals(s.child("black").getValue(String.class)); + if (whiteReady && blackReady) { + gameRef.child("bothReady").setValue(true); + } + onSuccess.run(); + } + @Override public void onCancelled(DatabaseError e) { onError.call(e.getMessage()); } + }) + ) + .addOnFailureListener(e -> onError.call(e.getMessage())) + ) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void unconfirmSetup(String gameId, boolean isWhite, + Runnable onSuccess, FirebaseAPI.Callback onError) { + String player = isWhite ? "white" : "black"; + DatabaseReference gameRef = db.child("games").child(gameId); + gameRef.child("setup").child(player).removeValue() + .addOnSuccessListener(v -> { + gameRef.child("bothReady").removeValue() + .addOnSuccessListener(v2 -> onSuccess.run()) + .addOnFailureListener(e -> onError.call(e.getMessage())); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + public void getOpponentBoard(String gameId, boolean localIsWhite, FirebaseAPI.Callback onBoard) { + String opponentKey = localIsWhite ? "black" : "white"; + db.child("games").child(gameId).child("boards").child(opponentKey).get() + .addOnSuccessListener(snapshot -> { + if (!snapshot.exists()) { onBoard.call(new int[0][0]); return; } + int maxCol = 0, maxRow = 0; + for (DataSnapshot entry : snapshot.getChildren()) { + int c = utils.getInt(entry, "col"), r = utils.getInt(entry, "row"); + if (c > maxCol) maxCol = c; + if (r > maxRow) maxRow = r; + } + int size = Math.max(maxCol, maxRow) + 1; + int[][] b = new int[size][size]; + for (DataSnapshot entry : snapshot.getChildren()) { + b[utils.getInt(entry, "col")][utils.getInt(entry, "row")] = utils.getInt(entry, "piece"); + } + onBoard.call(b); + }) + .addOnFailureListener(e -> onBoard.call(new int[0][0])); + } + + public void listenForBothSetupReady(String gameId, Runnable onBothReady) { + DatabaseReference ref = db.child("games").child(gameId).child("bothReady"); + ValueEventListener listener = new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Boolean bothReady = s.getValue(Boolean.class); + if (bothReady != null && bothReady) { + ref.removeEventListener(this); + onBothReady.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }; + ref.addValueEventListener(listener); + } +} diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseUtils.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseUtils.java new file mode 100644 index 0000000..6fedc72 --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseUtils.java @@ -0,0 +1,79 @@ +package com.group14.regicidechess.android; + +import android.util.Log; + +import com.google.firebase.database.ChildEventListener; +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.ValueEventListener; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FirebaseUtils { + + private final List cleanupActions = new ArrayList<>(); + private final Map valueCleanups = new HashMap<>(); + private final Map childCleanups = new HashMap<>(); + + private volatile long serverTimeOffset = 0L; + + public void fetchServerTimeOffset(DatabaseReference db) { + db.child(".info/serverTimeOffset").addListenerForSingleValueEvent(new ValueEventListener() { + @Override public void onDataChange(DataSnapshot s) { + Object val = s.getValue(); + if (val instanceof Long) serverTimeOffset = (Long) val; + else if (val instanceof Double) serverTimeOffset = ((Double) val).longValue(); + Log.d("FirebaseUtils", "serverTimeOffset=" + serverTimeOffset); + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + public long serverNow() { + return System.currentTimeMillis() + serverTimeOffset; + } + + public ValueEventListener trackValue(DatabaseReference ref, ValueEventListener listener) { + Runnable cleanup = () -> ref.removeEventListener(listener); + cleanupActions.add(cleanup); + valueCleanups.put(listener, cleanup); + ref.addValueEventListener(listener); + return listener; + } + + public void untrackValue(ValueEventListener listener) { + Runnable cleanup = valueCleanups.remove(listener); + if (cleanup != null) cleanupActions.remove(cleanup); + } + + public ChildEventListener trackChild(DatabaseReference ref, ChildEventListener listener) { + Runnable cleanup = () -> ref.removeEventListener(listener); + cleanupActions.add(cleanup); + childCleanups.put(listener, cleanup); + ref.addChildEventListener(listener); + return listener; + } + + public void untrackChild(ChildEventListener listener) { + Runnable cleanup = childCleanups.remove(listener); + if (cleanup != null) cleanupActions.remove(cleanup); + } + + public void removeAllListeners() { + for (Runnable cleanup : cleanupActions) cleanup.run(); + cleanupActions.clear(); + valueCleanups.clear(); + childCleanups.clear(); + } + + public int getInt(DataSnapshot snapshot, String key) { + Object val = snapshot.child(key).getValue(); + if (val instanceof Long) return ((Long) val).intValue(); + if (val instanceof Integer) return (Integer) val; + return 0; + } +} diff --git a/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java index 212ba4d..7247ead 100644 --- a/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java +++ b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java @@ -1,4 +1,4 @@ -// FirebaseAPI.java - Fix unconfirmSetup signature +// FirebaseAPI.java package com.group14.regicidechess.database; import com.group14.regicidechess.model.Lobby; @@ -76,10 +76,78 @@ public interface FirebaseAPI { void listenForHeartbeat(String gameId, boolean listenForWhite, long timeoutMs, Callback onHeartbeat, Runnable onTimeout); + /** + * Listens to Firebase's own .info/connected node. + * onConnected fires with true when this device has a live Firebase connection, + * false when it goes offline. Fires immediately with the current state. + */ + void listenForMyConnection(Runnable onConnected, Runnable onDisconnected); + + /** Publishes this player's measured latency so the opponent can display it. */ + void sendLatency(String gameId, boolean isWhite, long latencyMs); + + /** Listens for the opponent's published latency. listenForWhite = true → watch white's value. */ + void listenForOpponentLatency(String gameId, boolean listenForWhite, Callback onLatency); + /** e.g. "forfeit:white", "forfeit:black" */ void signalGameOver(String gameId, String reason); + + /** + * Registers a server-side Firebase onDisconnect hook so that if this + * client drops unexpectedly, the server writes a timestamp to + * games/{gameId}/disconnectedAt/{color}. + * + * IMPORTANT: This does NOT write to gameOver directly. The still-connected + * opponent listens to disconnectedAt via listenForOpponentDisconnectedAt and + * starts a RECONNECT_GRACE_MS countdown. Only after that grace period expires + * (and the opponent hasn't called signalReconnected) does the connected player + * write to gameOver. This guarantees both sides have the full grace window + * before any win/loss screen is shown. + * + * The hook is cancelled by signalReconnected() when the player comes back, + * and by signalGameOver() for clean match endings. + * + * Typical call: api.registerDisconnectGameOver(gameId, "disconnect:white") + */ + void registerDisconnectGameOver(String gameId, String reason); + void listenForGameOver(String gameId, Callback onGameOver); + /** + * Fires once when games/{gameId}/disconnectedAt/{opponentColor} becomes + * non-null. This is written by the server-side onDisconnect hook registered + * in registerDisconnectGameOver() and is the authoritative signal that the + * opponent's Firebase connection dropped. + * + * The connected player uses this to start the RECONNECT_GRACE_MS countdown. + * Unlike heartbeat timeout (which can be noisy on a slow network), this + * signal comes from Firebase's own infrastructure and is reliable. + * + * @param gameId the active game ID + * @param opponentColor "white" or "black" + * @param onDisconnected called once when the timestamp appears + */ + void listenForOpponentDisconnectedAt(String gameId, String opponentColor, + Runnable onDisconnected); + + /** + * Writes games/{gameId}/reconnected/{color} = true and clears + * games/{gameId}/disconnectedAt/{color} so the still-connected opponent + * knows this player came back within the grace window and cancels their + * auto-forfeit timer. + * + * Also cancels the server-side onDisconnect hook so it never fires again + * for this session. + */ + void signalReconnected(String gameId, boolean isWhite); + + /** + * Fires once if games/{gameId}/reconnected/{opponentColor} becomes true + * within the grace window. Used by the connected player to cancel their + * auto-forfeit timer when the opponent comes back online. + */ + void listenForOpponentReconnected(String gameId, boolean listenForWhite, Runnable onReconnected); + void removeAllListeners(); // ───────────────────────────────────────────────────────────────────────── @@ -87,4 +155,4 @@ void listenForHeartbeat(String gameId, boolean listenForWhite, long timeoutMs, interface Callback { void call(T value); } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/Board.java b/core/src/main/java/com/group14/regicidechess/model/Board.java index 67e2cc8..05aa754 100644 --- a/core/src/main/java/com/group14/regicidechess/model/Board.java +++ b/core/src/main/java/com/group14/regicidechess/model/Board.java @@ -1,37 +1,27 @@ package com.group14.regicidechess.model; import com.badlogic.gdx.math.Vector2; -import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.*; import java.util.ArrayList; import java.util.List; -/** - * Board — Owns and manages all ChessPiece instances on the grid. - * - * Placement: core/src/main/java/com/group14/regicidechess/model/Board.java - * - * The board is column-major: pieces[col][row]. - * col 0 = left (file A), row 0 = bottom (rank 1). - */ public class Board { - private final int size; - private final ChessPiece[][] pieces; + private final int size; + private final ChessPiece[][] pieces; + private Vector2 enPassantTarget; public Board(int size) { - this.size = size; + this.size = size; this.pieces = new ChessPiece[size][size]; + this.enPassantTarget = null; } // ------------------------------------------------------------------------- // Placement // ------------------------------------------------------------------------- - /** - * Places a piece on the board and injects the board reference into it. - * Any piece already at that position is silently replaced. - */ public void placePiece(ChessPiece piece, Vector2 pos) { placePiece(piece, (int) pos.x, (int) pos.y); } @@ -45,9 +35,6 @@ public void placePiece(ChessPiece piece, int col, int row) { } } - /** - * Removes and returns the piece at (col, row), or null if empty. - */ public ChessPiece removePiece(Vector2 pos) { return removePiece((int) pos.x, (int) pos.y); } @@ -59,25 +46,86 @@ public ChessPiece removePiece(int col, int row) { return piece; } - /** - * Moves the piece from one square to another. - * Returns the captured piece (or null if the destination was empty). - */ public ChessPiece movePiece(Vector2 from, Vector2 to) { return movePiece((int) from.x, (int) from.y, (int) to.x, (int) to.y); } public ChessPiece movePiece(int fromCol, int fromRow, int toCol, int toRow) { - ChessPiece moving = removePiece(fromCol, fromRow); - ChessPiece captured = pieces[toCol][toRow]; // may be null - placePiece(moving, toCol, toRow); - // Notify pawn it has moved so the two-square rule is disabled next turn - if (moving instanceof com.group14.regicidechess.model.pieces.Pawn) { - ((com.group14.regicidechess.model.pieces.Pawn) moving).markMoved(); + ChessPiece moving = getPieceAt(fromCol, fromRow); + if (moving == null) return null; + + // Castling + if (moving instanceof King && Math.abs(toCol - fromCol) == 2 && fromRow == toRow) { + return handleCastling((King) moving, fromCol, fromRow, toCol, toRow); + } + + // En passant capture + if (moving instanceof Pawn && enPassantTarget != null + && toCol == (int) enPassantTarget.x && toRow == (int) enPassantTarget.y) { + int capturedRow = (moving.getOwner().isWhite() ? toRow - 1 : toRow + 1); + ChessPiece capturedPawn = removePiece(toCol, capturedRow); + placePiece(removePiece(fromCol, fromRow), toCol, toRow); + ((Pawn) moving).markMoved(); + enPassantTarget = null; + return capturedPawn; + } + + // Normal move + ChessPiece captured = removePiece(toCol, toRow); + placePiece(removePiece(fromCol, fromRow), toCol, toRow); + + moving.setHasMoved(true); + if (moving instanceof Pawn) ((Pawn) moving).markMoved(); + + enPassantTarget = null; + if (moving instanceof Pawn && Math.abs(toRow - fromRow) == 2) { + int midRow = (fromRow + toRow) / 2; + enPassantTarget = new Vector2(toCol, midRow); } + return captured; } + private ChessPiece handleCastling(King king, int fromCol, int fromRow, int toCol, int toRow) { + int direction = (toCol > fromCol) ? 1 : -1; + int rookCol = -1; + for (int c = fromCol + direction; c >= 0 && c < size; c += direction) { + ChessPiece piece = getPieceAt(c, fromRow); + if (piece instanceof Rook && piece.getOwner() == king.getOwner()) { + rookCol = c; + break; + } + } + if (rookCol == -1) return null; + + ChessPiece rook = removePiece(rookCol, fromRow); + removePiece(fromCol, fromRow); + + placePiece(king, toCol, toRow); + int newRookCol = toCol - direction; + placePiece(rook, newRookCol, toRow); + + king.setHasMoved(true); + rook.setHasMoved(true); + return null; + } + + // ------------------------------------------------------------------------- + // Attack detection – uses attacksSquare, no recursion + // ------------------------------------------------------------------------- + + public boolean isSquareAttacked(int col, int row, Player owner) { + for (int c = 0; c < size; c++) { + for (int r = 0; r < size; r++) { + ChessPiece piece = pieces[c][r]; + if (piece != null && piece.getOwner() != owner) { + if (piece.attacksSquare(col, row)) return true; + } + } + } + return false; + } + // ------------------------------------------------------------------------- // Queries // ------------------------------------------------------------------------- @@ -91,7 +139,6 @@ public ChessPiece getPieceAt(int col, int row) { return pieces[col][row]; } - /** Returns all pieces currently on the board. */ public List getPieces() { List result = new ArrayList<>(); for (int c = 0; c < size; c++) @@ -100,7 +147,6 @@ public List getPieces() { return result; } - /** Returns all pieces belonging to a specific player. */ public List getPieces(Player player) { List result = new ArrayList<>(); for (ChessPiece p : getPieces()) @@ -114,10 +160,18 @@ public boolean inBounds(int col, int row) { public int getSize() { return size; } - /** Clears all pieces from the board. */ public void clear() { for (int c = 0; c < size; c++) for (int r = 0; r < size; r++) pieces[c][r] = null; + enPassantTarget = null; + } + + public Vector2 getEnPassantTarget() { + return enPassantTarget == null ? null : enPassantTarget.cpy(); + } + + public void clearEnPassantTarget() { + enPassantTarget = null; } } \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java index 4a21f87..f0914a6 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java @@ -6,7 +6,6 @@ import java.util.ArrayList; import java.util.List; -/** Placement: core/src/main/java/com/group14/regicidechess/model/pieces/Bishop.java */ public class Bishop extends ChessPiece { public Bishop(Player owner) { @@ -21,5 +20,21 @@ public List validMoves() { return moves; } + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + if (Math.abs(col - c) != Math.abs(row - r)) return false; + int stepX = (col > c) ? 1 : -1; + int stepY = (row > r) ? 1 : -1; + int x = c + stepX, y = r + stepY; + while (x != col) { + if (board.getPieceAt(x, y) != null) return false; + x += stepX; + y += stepY; + } + return true; + } + @Override public String getTypeName() { return "Bishop"; } } \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java b/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java index 9cda40c..a4e2e58 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/ChessPiece.java @@ -23,13 +23,15 @@ public abstract class ChessPiece { /** Reference to the board — set by Board.placePiece(). */ protected Board board; + protected boolean hasMoved = false; protected ChessPiece(Player owner, int pointCost) { this.owner = owner; this.pointCost = pointCost; this.position = new Vector2(-1, -1); // unplaced sentinel } - + public boolean hasMoved() { return hasMoved; } + public void setHasMoved(boolean moved) { this.hasMoved = moved; } // ------------------------------------------------------------------------- // Abstract // ------------------------------------------------------------------------- @@ -40,6 +42,14 @@ protected ChessPiece(Player owner, int pointCost) { */ public abstract List validMoves(); + /** + * Returns true if this piece can attack the given square (col, row) + * regardless of whether moving there would leave its own king in check. + * Used for isSquareAttacked() to avoid recursion and correctly handle + * pawn attacks (diagonal only) vs pawn moves (forward). + */ + public abstract boolean attacksSquare(int col, int row); + // ------------------------------------------------------------------------- // Shared sliding helper — used by Rook, Bishop, Queen // ------------------------------------------------------------------------- diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/King.java b/core/src/main/java/com/group14/regicidechess/model/pieces/King.java index 83de5ea..0da2b5e 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/King.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/King.java @@ -6,22 +6,67 @@ import java.util.ArrayList; import java.util.List; -/** Placement: core/src/main/java/com/group14/regicidechess/model/pieces/King.java */ public class King extends ChessPiece { public King(Player owner) { - super(owner, 0); // free and mandatory (FR7.6) + super(owner, 0); } @Override public List validMoves() { List moves = new ArrayList<>(); + // normal one-step moves for (int dc = -1; dc <= 1; dc++) for (int dr = -1; dr <= 1; dr++) if (dc != 0 || dr != 0) step(moves, dc, dr); + + // Castling + if (hasMoved) return moves; + if (board.isSquareAttacked((int) position.x, (int) position.y, owner)) + return moves; + + int kingCol = (int) position.x; + int kingRow = (int) position.y; + + for (int c = 0; c < board.getSize(); c++) { + ChessPiece piece = board.getPieceAt(c, kingRow); + if (piece instanceof Rook && piece.getOwner() == owner && !piece.hasMoved()) { + int step = (c > kingCol) ? 1 : -1; + boolean pathClear = true; + for (int x = kingCol + step; x != c; x += step) { + if (board.getPieceAt(x, kingRow) != null) { + pathClear = false; + break; + } + } + if (!pathClear) continue; + if (Math.abs(c - kingCol) < 2) continue; // must move two squares + int destCol = kingCol + 2 * step; + if (destCol < 0 || destCol >= board.getSize()) continue; + + boolean safe = true; + for (int x = Math.min(kingCol, destCol); x <= Math.max(kingCol, destCol); x++) { + if (board.isSquareAttacked(x, kingRow, owner)) { + safe = false; + break; + } + } + if (safe) { + moves.add(new Vector2(destCol, kingRow)); + } + } + } return moves; } - @Override public String getTypeName() { return "King"; } + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + return Math.abs(col - c) <= 1 && Math.abs(row - r) <= 1 && !(col == c && row == r); + } + + @Override + public String getTypeName() { return "King"; } } \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java index 7d81a67..c676e70 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java @@ -6,7 +6,6 @@ import java.util.ArrayList; import java.util.List; -/** Placement: core/src/main/java/com/group14/regicidechess/model/pieces/Knight.java */ public class Knight extends ChessPiece { public Knight(Player owner) { @@ -21,5 +20,14 @@ public List validMoves() { return moves; } + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + int dx = Math.abs(col - c); + int dy = Math.abs(row - r); + return (dx == 2 && dy == 1) || (dx == 1 && dy == 2); + } + @Override public String getTypeName() { return "Knight"; } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java index cab9443..6170400 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java @@ -24,8 +24,7 @@ public List validMoves() { // One square forward if (board.inBounds(c, r + dir) && board.getPieceAt(c, r + dir) == null) { moves.add(new Vector2(c, r + dir)); - - // Two squares forward on first move (FR1.2) + // Two squares forward on first move if (!hasMoved && board.inBounds(c, r + 2 * dir) && board.getPieceAt(c, r + 2 * dir) == null) { moves.add(new Vector2(c, r + 2 * dir)); @@ -42,14 +41,30 @@ public List validMoves() { } } } + + // En passant + Vector2 enPassantTarget = board.getEnPassantTarget(); + if (enPassantTarget != null && (int) enPassantTarget.y == r + dir) { + for (int dc : new int[]{ -1, 1 }) { + int tc = c + dc; + if (tc == (int) enPassantTarget.x) { + moves.add(new Vector2(tc, r + dir)); + } + } + } return moves; } - /** Called by Board.movePiece() after the pawn is moved. */ - public void markMoved() { - hasMoved = true; + @Override + public boolean attacksSquare(int col, int row) { + int dir = owner.isWhite() ? 1 : -1; + int c = (int) position.x; + int r = (int) position.y; + // Pawn attacks one square diagonally forward + return (Math.abs(col - c) == 1 && row == r + dir); } + public void markMoved() { hasMoved = true; } public boolean hasMoved() { return hasMoved; } @Override public String getTypeName() { return "Pawn"; } diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java index a97672c..19a5db0 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java @@ -6,7 +6,6 @@ import java.util.ArrayList; import java.util.List; -/** Placement: core/src/main/java/com/group14/regicidechess/model/pieces/Queen.java */ public class Queen extends ChessPiece { public Queen(Player owner) { @@ -16,14 +15,44 @@ public Queen(Player owner) { @Override public List validMoves() { List moves = new ArrayList<>(); - // Rook directions slide(moves, 1, 0); slide(moves, -1, 0); slide(moves, 0, 1); slide(moves, 0, -1); - // Bishop directions slide(moves, 1, 1); slide(moves, 1, -1); slide(moves, -1, 1); slide(moves, -1, -1); return moves; } + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + // Rook-like + if (c == col) { + int step = (row > r) ? 1 : -1; + for (int y = r + step; y != row; y += step) + if (board.getPieceAt(c, y) != null) return false; + return true; + } + if (r == row) { + int step = (col > c) ? 1 : -1; + for (int x = c + step; x != col; x += step) + if (board.getPieceAt(x, r) != null) return false; + return true; + } + // Bishop-like + if (Math.abs(col - c) == Math.abs(row - r)) { + int stepX = (col > c) ? 1 : -1; + int stepY = (row > r) ? 1 : -1; + int x = c + stepX, y = r + stepY; + while (x != col) { + if (board.getPieceAt(x, y) != null) return false; + x += stepX; + y += stepY; + } + return true; + } + return false; + } + @Override public String getTypeName() { return "Queen"; } } \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java b/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java index f335f30..39da117 100644 --- a/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java +++ b/core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java @@ -6,7 +6,6 @@ import java.util.ArrayList; import java.util.List; -/** Placement: core/src/main/java/com/group14/regicidechess/model/pieces/Rook.java */ public class Rook extends ChessPiece { public Rook(Player owner) { @@ -21,5 +20,24 @@ public List validMoves() { return moves; } + @Override + public boolean attacksSquare(int col, int row) { + int c = (int) position.x; + int r = (int) position.y; + if (c == col) { + int step = (row > r) ? 1 : -1; + for (int y = r + step; y != row; y += step) + if (board.getPieceAt(c, y) != null) return false; + return true; + } + if (r == row) { + int step = (col > c) ? 1 : -1; + for (int x = c + step; x != col; x += step) + if (board.getPieceAt(x, r) != null) return false; + return true; + } + return false; + } + @Override public String getTypeName() { return "Rook"; } } \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java index a30b012..7acd266 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java @@ -5,7 +5,9 @@ import com.badlogic.gdx.math.Vector2; import com.badlogic.gdx.scenes.scene2d.ui.Image; import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.utils.Timer; import com.group14.regicidechess.network.CircuitBreaker; +import com.group14.regicidechess.database.DatabaseManager; import com.group14.regicidechess.database.FirebaseAPI; import com.group14.regicidechess.model.Move; import com.group14.regicidechess.model.Player; @@ -13,45 +15,101 @@ /** * GameNetworkHandler — owns all Firebase subscriptions for an active game. * - * Placement: core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java + * Disconnect / reconnect grace-period design + * ────────────────────────────────────────── + * When a player loses their Firebase connection the server writes a timestamp + * to games/{gameId}/disconnectedAt/{color} via a pre-registered onDisconnect + * hook (see AndroidFirebase.registerDisconnectGameOver). * - * Receives raw Firebase data and forwards parsed, typed events to the Listener. - * Knows nothing about rendering or game logic — it only translates network events. + * From that moment BOTH sides start an independent 10-second countdown: + * + * Disconnected player — local (client-side) selfLossTask. + * If we haven't reconnected within RECONNECT_GRACE_MS + * we show "You Lost" locally. We cannot receive a + * Firebase message while offline, so this must be + * purely local. + * + * Connected player — server-side-triggered (client-side) autoForfeitTask. + * Started when disconnectedAt/{opponentColor} appears + * in Firebase (not just when the heartbeat times out, + * which could be noisy). After RECONNECT_GRACE_MS + * this player writes "disconnect:{loserColor}" to + * games/{gameId}/gameOver via a first-writer-wins + * transaction, then fires onGameOver locally so the + * win screen appears immediately without waiting for + * Firebase to echo it back. + * + * Reconnect path + * ────────────── + * When the disconnected player's Firebase connection comes back: + * 1. selfConnected → true → selfLossTask cancelled. + * 2. api.signalReconnected() clears disconnectedAt/{color} and writes + * reconnected/{color} = true. + * 3. Connected player's listenForOpponentReconnected fires → autoForfeitTask + * cancelled → game resumes. + * + * First-writer-wins + * ───────────────── + * signalGameOver uses a Firebase transaction so only the first reason lands, + * preventing a reconnect race where both timers fire near-simultaneously and + * the second write overwrites the first. */ public class GameNetworkHandler { - /** Callbacks forwarded to GameScreen. All are already on the LibGDX GL thread. */ public interface Listener { - /** - * Called when the opponent makes a move. - * @param from logic-space origin - * @param to logic-space destination - * @param promoCode 0 = no promotion; 2=Queen 3=Rook 4=Bishop 5=Knight - */ void onOpponentMove(Vector2 from, Vector2 to, int promoCode); - - /** Called when the opponent forfeits or the game ends via signalGameOver. */ void onGameOver(String reason); - /** Called periodically with the measured latency to the opponent in ms. */ - void onHeartbeatLatency(long latencyMs); + /** My own measured RTT to Firebase (shown at bottom of screen). */ + void onMyLatency(long latencyMs); + + /** Opponent's published latency (shown at top of screen). */ + void onOpponentLatency(long latencyMs); + + /** I lost my own Firebase connection. */ + void onSelfDisconnected(); + + /** I regained my own Firebase connection. */ + void onSelfReconnected(); - /** Called when no heartbeat has arrived within the timeout window. */ + /** Opponent's disconnectedAt timestamp appeared in Firebase (they dropped). */ void onOpponentDisconnected(); + + /** Opponent heartbeat resumed / reconnected flag set — grace timer cancelled. */ + void onOpponentReconnected(); } - private static final long HEARTBEAT_TIMEOUT_MS = 15_000L; + // Heartbeat sent every 2 s. After HEARTBEAT_TIMEOUT_MS with no heartbeat + // the opponent is considered disconnected at the client level. + // The authoritative disconnect signal for the grace timer is the server-side + // disconnectedAt timestamp written by the onDisconnect hook. + private static final long HEARTBEAT_TIMEOUT_MS = 5_000L; + private static final long RECONNECT_GRACE_MS = 10_000L; - private final String gameId; - private final Player localPlayer; - private final Listener listener; + private final String gameId; + private final Player localPlayer; + private final Listener listener; private final FirebaseAPI api; - // Connection UI refs — updated directly here to keep GameScreen clean + // Reflects YOUR OWN connection quality — updated only from successful RTT measurements. private final Image connectionIcon; private final Label connectionLabel; + + // Circuit breaker for move writes only. private final CircuitBreaker circuitBreaker = new CircuitBreaker(3, 10_000L); + // Connected player's grace timer: fires after RECONNECT_GRACE_MS if + // disconnectedAt/{opponentColor} is set and hasn't been cleared. + private Timer.Task autoForfeitTask = null; + + // Disconnected player's local grace timer: fires after RECONNECT_GRACE_MS + // if we haven't regained a Firebase connection. + private Timer.Task selfLossTask = null; + + private boolean opponentDisconnected = false; // true while opponent's disconnectedAt is set + private boolean selfConnected = true; // mirrors .info/connected + private boolean forfeitWritten = false; // prevent double-write to gameOver + public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, Image connectionIcon, Label connectionLabel, FirebaseAPI api) { this.gameId = gameId; @@ -63,17 +121,25 @@ public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, } // ───────────────────────────────────────────────────────────────────────── - // Start all listeners + // Start / stop // ───────────────────────────────────────────────────────────────────────── public void start() { + // Server-side safety net: writes disconnectedAt/{myColor} if we drop. + // This is what starts the connected opponent's grace timer — NOT gameOver. + registerDisconnectSignal(); + startSelfConnectionListener(); startOpponentMoveListener(); startGameOverListener(); - startHeartbeat(); + startHeartbeatAndLatency(); + startOpponentDisconnectedAtListener(); // listens for disconnectedAt/{opponentColor} + startOpponentReconnectListener(); // listens for reconnected/{opponentColor} } public void stop() { - DatabaseManager.getInstance().getApi().removeAllListeners(); + cancelAutoForfeitTimer(); + cancelSelfLossTimer(); + api.removeAllListeners(); } // ───────────────────────────────────────────────────────────────────────── @@ -82,43 +148,96 @@ public void stop() { public void saveMove(Move move) { if (!circuitBreaker.allowRequest()) { - Gdx.app.log("GameNetworkHandler", "Circuit breaker OPEN — move send blocked"); + Gdx.app.log("GameNetworkHandler", "Circuit breaker OPEN — move blocked"); return; } - api.saveMove(gameId, move, () -> {}); + api.saveMove(gameId, move, () -> circuitBreaker.recordSuccess()); } public void sendHeartbeat() { - if (!circuitBreaker.allowRequest()) return; api.sendHeartbeat(gameId, localPlayer.isWhite()); } - /** Signals that this player has forfeited. */ public void signalForfeit() { + if (forfeitWritten) return; + forfeitWritten = true; String loser = localPlayer.isWhite() ? "white" : "black"; api.signalGameOver(gameId, "forfeit:" + loser); } // ───────────────────────────────────────────────────────────────────────── - // Incoming listeners + // Server-side disconnect signal (Firebase onDisconnect hook) // ───────────────────────────────────────────────────────────────────────── /** - * Listens for opponent moves. - * Firebase sends int[]{fromCol, fromRow, toCol, toRow, isWhite(1/0), promoCode}. - * Own echoed moves are filtered out via coords[4]. + * Registers a Firebase onDisconnect write to + * games/{gameId}/disconnectedAt/{myColor}. + * + * This is the authoritative signal that tells the connected opponent to + * start their RECONNECT_GRACE_MS countdown. It does NOT write to gameOver — + * only the connected player's autoForfeitTimer does that, after the full + * grace window has elapsed without a reconnect. + * + * The hook is cancelled in signalReconnected() if we come back online, + * and in signalGameOver() for clean game endings. */ - private void startOpponentMoveListener() { - api.listenForOpponentMove(gameId, coords -> { - boolean moveIsWhite = coords.length > 4 && coords[4] == 1; - if (moveIsWhite == localPlayer.isWhite()) return; // own echo + private void registerDisconnectSignal() { + String myColor = localPlayer.isWhite() ? "white" : "black"; + // We pass "disconnect:{myColor}" as the reason string; AndroidFirebase + // parses the color and writes it to disconnectedAt/{color} rather than + // gameOver, so there is no instant loss. + api.registerDisconnectGameOver(gameId, "disconnect:" + myColor); + } - Vector2 from = new Vector2(coords[0], coords[1]); - Vector2 to = new Vector2(coords[2], coords[3]); - int promoCode = coords.length > 5 ? coords[5] : 0; + // ───────────────────────────────────────────────────────────────────────── + // Own connection — .info/connected + // ───────────────────────────────────────────────────────────────────────── - Gdx.app.postRunnable(() -> listener.onOpponentMove(from, to, promoCode)); - }); + private void startSelfConnectionListener() { + api.listenForMyConnection( + // onConnected + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) { + selfConnected = true; + Gdx.app.log("GameNetworkHandler", "Self reconnected to Firebase"); + // Cancel local loss countdown — we made it back in time + cancelSelfLossTimer(); + // Tell Firebase (and the opponent) that we're back. + // AndroidFirebase.signalReconnected also cancels the onDisconnect + // hook and clears disconnectedAt so the opponent's timer stops. + api.signalReconnected(gameId, localPlayer.isWhite()); + listener.onSelfReconnected(); + } + }), + // onDisconnected + () -> Gdx.app.postRunnable(() -> { + if (selfConnected) { + selfConnected = false; + Gdx.app.log("GameNetworkHandler", "Self disconnected from Firebase"); + cancelAutoForfeitTimer(); // we can't be the connected player while offline + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + // Start local countdown — if we don't reconnect in time, show "You Lost" + startSelfLossTimer(); + listener.onSelfDisconnected(); + } + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Incoming listeners + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentMoveListener() { + api.listenForOpponentMove(gameId, coords -> { + boolean moveIsWhite = coords.length > 4 && coords[4] == 1; + if (moveIsWhite == localPlayer.isWhite()) return; + Vector2 from = new Vector2(coords[0], coords[1]); + Vector2 to = new Vector2(coords[2], coords[3]); + int promoCode = coords.length > 5 ? coords[5] : 0; + Gdx.app.postRunnable(() -> listener.onOpponentMove(from, to, promoCode)); + }); } private void startGameOverListener() { @@ -126,40 +245,193 @@ private void startGameOverListener() { Gdx.app.postRunnable(() -> listener.onGameOver(reason))); } - private void startHeartbeat() { - sendHeartbeat(); + private void startHeartbeatAndLatency() { + boolean watchOpponent = !localPlayer.isWhite(); + api.listenForHeartbeat( - gameId, - !localPlayer.isWhite(), - HEARTBEAT_TIMEOUT_MS, - latency -> Gdx.app.postRunnable(() -> { - circuitBreaker.recordSuccess(); - updateConnectionUI(latency); - listener.onHeartbeatLatency(latency); - }), - () -> Gdx.app.postRunnable(() -> { - circuitBreaker.recordFailure(); - connectionIcon.setColor(Color.RED); - connectionLabel.setText("Lost"); - listener.onOpponentDisconnected(); - }) - ); + gameId, + watchOpponent, + HEARTBEAT_TIMEOUT_MS, + // ── Heartbeat received ──────────────────────────────────────── + latency -> Gdx.app.postRunnable(() -> { + boolean wasDisconnected = opponentDisconnected; + opponentDisconnected = false; + cancelAutoForfeitTimer(); + circuitBreaker.recordSuccess(); + + updateMyConnectionUI(latency); + listener.onMyLatency(latency); + api.sendLatency(gameId, localPlayer.isWhite(), latency); + + if (wasDisconnected) { + // Heartbeat resumed before grace timer fired — opponent is back. + // listenForOpponentReconnected will also fire via signalReconnected, + // but handling it here too keeps the UI snappy. + listener.onOpponentReconnected(); + } + }), + // ── Timeout: no heartbeat from opponent for HEARTBEAT_TIMEOUT_MS ─ + // This is the PRIMARY trigger for the connected player's grace timer. + // Firebase's onDisconnect hook (disconnectedAt) is a secondary fallback + // but can take 60+ seconds on mobile — far too slow to be the main signal. + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) { + Gdx.app.log("GameNetworkHandler", + "Heartbeat timeout ignored — self is offline"); + return; + } + if (opponentDisconnected) return; // grace timer already running + + opponentDisconnected = true; + circuitBreaker.recordFailure(); + Gdx.app.log("GameNetworkHandler", + "Heartbeat timeout — opponent disconnected, starting 10s grace timer"); + listener.onOpponentDisconnected(); + startAutoForfeitTimer(); + }) + ); + + api.listenForOpponentLatency(gameId, watchOpponent, + latency -> Gdx.app.postRunnable(() -> listener.onOpponentLatency(latency))); + } + + // ───────────────────────────────────────────────────────────────────────── + // disconnectedAt listener — secondary fallback (connected player only) + // + // Fires when games/{gameId}/disconnectedAt/{opponentColor} is written by + // the server's onDisconnect hook. Firebase may take 60+ seconds to detect + // a dropped TCP connection on mobile, so the heartbeat timeout above is the + // PRIMARY trigger for the grace timer. This listener catches edge cases where + // the heartbeat timeout misfires or the opponent's app is killed without the + // TCP stack notifying Firebase cleanly. + // + // Both paths guard with `if (opponentDisconnected) return` so only the first + // one starts the timer. + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentDisconnectedAtListener() { + String opponentColor = localPlayer.isWhite() ? "black" : "white"; + api.listenForOpponentDisconnectedAt(gameId, opponentColor, () -> + Gdx.app.postRunnable(() -> { + if (opponentDisconnected) return; // heartbeat already started the timer + if (!selfConnected) return; // we're the ones offline + opponentDisconnected = true; + Gdx.app.log("GameNetworkHandler", + "disconnectedAt signal received — starting grace timer (fallback path)"); + listener.onOpponentDisconnected(); + startAutoForfeitTimer(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Opponent reconnect listener (connected player only) + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentReconnectListener() { + boolean watchWhite = !localPlayer.isWhite(); + api.listenForOpponentReconnected(gameId, watchWhite, () -> + Gdx.app.postRunnable(() -> { + Gdx.app.log("GameNetworkHandler", + "Opponent signalled reconnect — cancelling forfeit timer"); + cancelAutoForfeitTimer(); + opponentDisconnected = false; + // Only notify the listener if the timer was actually running, + // i.e. the heartbeat timeout or disconnectedAt signal had fired. + // The heartbeat callback above also calls onOpponentReconnected + // when it resumes, so both paths are covered. + listener.onOpponentReconnected(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Connected player's auto-forfeit timer + // + // Started when disconnectedAt/{opponentColor} appears (server confirms drop). + // Cancelled if reconnected/{opponentColor} appears within the grace window. + // On expiry: writes "disconnect:{loserColor}" to gameOver (first-writer-wins + // transaction) and fires onGameOver locally so the win screen is immediate. + // ───────────────────────────────────────────────────────────────────────── + + private void startAutoForfeitTimer() { + cancelAutoForfeitTimer(); + autoForfeitTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (!opponentDisconnected || forfeitWritten || !selfConnected) return; + forfeitWritten = true; + String disconnectedColor = localPlayer.isWhite() ? "black" : "white"; + Gdx.app.log("GameNetworkHandler", + "Grace period expired — writing disconnect:" + disconnectedColor); + api.signalGameOver(gameId, "disconnect:" + disconnectedColor); + // Deliver locally so the connected player sees "You Win" immediately + // without waiting for Firebase to echo it back. + listener.onGameOver("disconnect:" + disconnectedColor); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelAutoForfeitTimer() { + if (autoForfeitTask != null) { + autoForfeitTask.cancel(); + autoForfeitTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Disconnected player's local loss timer + // + // Started when .info/connected goes false (we lost Firebase). + // Cancelled if .info/connected returns true within the grace window. + // On expiry: shows "You Lost" locally — no Firebase write (we're offline). + // The server-side onDisconnect hook ensures the opponent sees the result + // via their own autoForfeitTimer once the grace period passes. + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfLossTimer() { + cancelSelfLossTimer(); + selfLossTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (selfConnected) return; // reconnected in time — do nothing + Gdx.app.log("GameNetworkHandler", + "Self reconnect grace expired — showing loss locally"); + // Show loss locally without any Firebase write — we're offline. + // The server-side onDisconnect hook has already written + // disconnectedAt/{myColor}, which will trigger the connected + // player's autoForfeitTimer to write the gameOver reason. + listener.onGameOver( + "disconnect:" + (localPlayer.isWhite() ? "white" : "black")); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelSelfLossTimer() { + if (selfLossTask != null) { + selfLossTask.cancel(); + selfLossTask = null; + } } // ───────────────────────────────────────────────────────────────────────── - // Connection UI + // YOUR OWN connection indicator // ───────────────────────────────────────────────────────────────────────── - private void updateConnectionUI(long latencyMs) { - CircuitBreaker.State cbState = circuitBreaker.getState(); - if (cbState == CircuitBreaker.State.OPEN) { + private void updateMyConnectionUI(long latencyMs) { + CircuitBreaker.State state = circuitBreaker.getState(); + if (state == CircuitBreaker.State.OPEN) { connectionIcon.setColor(Color.RED); - connectionLabel.setText("Disconnected"); + connectionLabel.setText("Error"); return; } - if (cbState == CircuitBreaker.State.HALF_OPEN) { + if (state == CircuitBreaker.State.HALF_OPEN) { connectionIcon.setColor(Color.ORANGE); - connectionLabel.setText("Reconnecting..."); + connectionLabel.setText("Retrying"); return; } if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); @@ -167,4 +439,4 @@ private void updateConnectionUI(long latencyMs) { else connectionIcon.setColor(Color.RED); connectionLabel.setText(latencyMs + " ms"); } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java index d979d62..ebbca5b 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java @@ -97,11 +97,37 @@ public void showGameOver(boolean localWon) { overlayWrapper.setVisible(true); } + public void showOpponentDisconnected() { + overlayTitle.setText("You Win!"); + overlayBody.setText("Opponent disconnected."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + public void showForfeitReceived(String reason) { - // reason = "forfeit:{loserColor}" - boolean opponentLost = reason.endsWith(localPlayer.isWhite() ? "black" : "white"); - overlayTitle.setText(opponentLost ? "You Win!" : "Game Over"); - overlayBody.setText(opponentLost ? "Opponent forfeited!" : "You forfeited."); + // reason = "forfeit:{loserColor}" or "disconnect:{disconnectedColor}" + String myColor = localPlayer.isWhite() ? "white" : "black"; + String opponentColor = localPlayer.isWhite() ? "black" : "white"; + boolean iDisconnected = reason.equals("disconnect:" + myColor); + boolean opponentDisconnected = reason.equals("disconnect:" + opponentColor); + boolean opponentForfeited = reason.equals("forfeit:" + opponentColor); + + if (iDisconnected) { + overlayTitle.setText("You Lost"); + overlayBody.setText("You disconnected."); + } else if (opponentDisconnected) { + overlayTitle.setText("You Win!"); + overlayBody.setText("Opponent disconnected."); + } else if (opponentForfeited) { + overlayTitle.setText("You Win!"); + overlayBody.setText("Opponent forfeited!"); + } else { + // "forfeit:myColor" — I voluntarily forfeited + overlayTitle.setText("You Lost"); + overlayBody.setText("You forfeited."); + } overlayConfirmBtn.setText("Back to Menu"); overlayCancelBtn.setVisible(false); onOverlayConfirm = listener::onGameOverBack; diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java index 5bce68e..bffcd46 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java @@ -36,6 +36,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Timer; +import java.util.TimerTask; /** * GameScreen — thin coordinator for an active chess match. @@ -61,7 +63,7 @@ public class GameScreen implements Screen, private static final float V_HEIGHT = 854f; private static final float TOP_BAR_HEIGHT = 70f; private static final float STATUS_BAR_HEIGHT = 60f; - private static final float HEARTBEAT_INTERVAL = 5f; + private static final long HEARTBEAT_INTERVAL_MS = 2000L; // 2 seconds // ── LibGDX ──────────────────────────────────────────────────────────────── private final Game game; @@ -89,9 +91,23 @@ public class GameScreen implements Screen, private final GameNetworkHandler networkHandler; // ── Widgets ─────────────────────────────────────────────────────────────── + private TextButton forfeitBtn; private Label turnLabel; private Label statusLabel; - private float heartbeatTimer = 0f; + private Label myLatencyLabel; + private Label opponentLatencyLabel; + + // #8: Fixed - Use Timer instead of render-loop delta for heartbeats + private Timer heartbeatTimer; + private boolean isHeartbeatRunning = false; + private boolean isScreenActive = true; + + // Countdown shown to the disconnected player during the reconnect grace window + private Timer reconnectCountdownTimer; + private int reconnectSecondsLeft = 0; + + // Track if game is over to prevent multiple triggers + private boolean isGameOver = false; // ───────────────────────────────────────────────────────────────────────── // Constructor @@ -122,7 +138,6 @@ public GameScreen(Game game, SpriteBatch batch, inputHandler = new ScreenInputHandler(); inputHandler.addObserver(this); - // Connection UI elements are owned here but passed to the network handler Image connectionIcon = new Image(skin.getDrawable("white-pixel")); Label connectionLabel = new Label("Connecting", skin, "small"); connectionIcon.setColor(Color.GRAY); @@ -149,12 +164,11 @@ private void buildUI(Image connectionIcon, Label connectionLabel) { root.top(); stage.addActor(root); - // Top bar Table topBar = new Table(); topBar.setBackground(skin.getDrawable("primary-pixel")); topBar.pad(10); - TextButton forfeitBtn = new TextButton("Forfeit", skin, "danger"); + forfeitBtn = new TextButton("Forfeit", skin, "danger"); forfeitBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { overlayManager.showForfeitConfirm(); @@ -164,27 +178,69 @@ private void buildUI(Image connectionIcon, Label connectionLabel) { turnLabel = new Label("", skin, "title"); turnLabel.setAlignment(Align.center); + topBar.add(forfeitBtn).width(100).height(50).left(); + topBar.add(turnLabel).expandX().center(); + + opponentLatencyLabel = new Label("Opp: --", skin, "small"); + opponentLatencyLabel.setAlignment(Align.right); + Table connGroup = new Table(); connGroup.add(connectionIcon).size(12, 12).padRight(6); connGroup.add(connectionLabel).right(); + connGroup.row(); + connGroup.add(opponentLatencyLabel).colspan(2).right(); - topBar.add(forfeitBtn).width(100).height(50).left(); - topBar.add(turnLabel).expandX().center(); topBar.add(connGroup).width(110).right().padRight(8); root.add(topBar).expandX().fillX().height(TOP_BAR_HEIGHT).row(); root.add().expandX().expandY().row(); // spacer for board area - // Status bar Table statusBar = new Table(); statusBar.setBackground(skin.getDrawable("surface-pixel")); statusBar.pad(10); statusLabel = new Label("Select a piece to move.", skin, "small"); statusLabel.setAlignment(Align.center); + myLatencyLabel = new Label("Ping: --", skin, "small"); + myLatencyLabel.setAlignment(Align.right); statusBar.add(statusLabel).expandX(); + statusBar.add(myLatencyLabel).width(80).right(); root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); } + // ───────────────────────────────────────────────────────────────────────── + // #8: Fixed - Heartbeat timer management + // ───────────────────────────────────────────────────────────────────────── + + private void startHeartbeatTimer() { + if (isHeartbeatRunning) return; + + heartbeatTimer = new Timer(true); + heartbeatTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + if (isScreenActive && !isGameOver) { + Gdx.app.postRunnable(() -> { + if (!isGameOver) { + networkHandler.sendHeartbeat(); + } + }); + } + } + }, 0, HEARTBEAT_INTERVAL_MS); + + isHeartbeatRunning = true; + Gdx.app.log("GameScreen", "Heartbeat timer started with interval: " + HEARTBEAT_INTERVAL_MS + "ms"); + } + + private void stopHeartbeatTimer() { + if (heartbeatTimer != null) { + heartbeatTimer.cancel(); + heartbeatTimer = null; + } + isHeartbeatRunning = false; + Gdx.app.log("GameScreen", "Heartbeat timer stopped"); + } + // ───────────────────────────────────────────────────────────────────────── // Rendering // ───────────────────────────────────────────────────────────────────────── @@ -195,12 +251,6 @@ public void render(float delta) { Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); inMatchState.update(delta); - heartbeatTimer += delta; - if (heartbeatTimer >= HEARTBEAT_INTERVAL) { - heartbeatTimer = 0f; - networkHandler.sendHeartbeat(); - } - boardRenderer.draw(stage.getCamera().combined, inMatchState.getBoard(), selectedCell, validMoves); @@ -302,7 +352,7 @@ private void executeMove(Vector2 from, Vector2 to) { if (captured instanceof King) { networkHandler.saveMove(new Move(from, to, movingPiece, localPlayer)); refreshTurnLabel(); - overlayManager.showGameOver(true); + triggerGameOver(true); } else if (isPawnPromotion(movingPiece, to)) { pendingPromotionMove = new Move(from, to, movingPiece, localPlayer); @@ -320,6 +370,14 @@ private boolean isPawnPromotion(ChessPiece piece, Vector2 to) { int rank = piece.getOwner().isWhite() ? inMatchState.getBoard().getSize() - 1 : 0; return (int) to.y == rank; } + + private void triggerGameOver(boolean localWon) { + if (isGameOver) return; + isGameOver = true; + stopHeartbeatTimer(); + forfeitBtn.setVisible(false); + overlayManager.showGameOver(localWon); + } // ───────────────────────────────────────────────────────────────────────── // GameOverlayManager.Listener @@ -327,12 +385,18 @@ private boolean isPawnPromotion(ChessPiece piece, Vector2 to) { @Override public void onForfeitConfirmed() { + if (isGameOver) return; + isGameOver = true; + stopHeartbeatTimer(); + forfeitBtn.setVisible(false); networkHandler.signalForfeit(); overlayManager.showGameOver(false); } @Override public void onGameOverBack() { + stopHeartbeatTimer(); + networkHandler.stop(); game.setScreen(new MainMenuScreen(game, batch)); } @@ -384,22 +448,55 @@ public void onOpponentMove(Vector2 from, Vector2 to, int promoCode) { deselect(); refreshTurnLabel(); - if (captured instanceof King) overlayManager.showGameOver(false); + if (captured instanceof King) triggerGameOver(false); else showStatus("Your turn!"); } @Override public void onGameOver(String reason) { - if (!overlayManager.isGeneralOverlayVisible()) { - overlayManager.showForfeitReceived(reason); - } + if (isGameOver) return; + isGameOver = true; + stopHeartbeatTimer(); + networkHandler.stop(); + forfeitBtn.setVisible(false); + overlayManager.showForfeitReceived(reason); + } + + @Override + public void onMyLatency(long latencyMs) { + Gdx.app.postRunnable(() -> myLatencyLabel.setText("Ping: " + latencyMs + " ms")); } - @Override public void onHeartbeatLatency(long latencyMs) { /* handled by GameNetworkHandler */ } + @Override + public void onOpponentLatency(long latencyMs) { + Gdx.app.postRunnable(() -> opponentLatencyLabel.setText("Opp: " + latencyMs + " ms")); + } + + @Override + public void onSelfDisconnected() { + stopHeartbeatTimer(); + Gdx.app.postRunnable(() -> myLatencyLabel.setText("Ping: Lost")); + startReconnectCountdown(); + } + + @Override + public void onSelfReconnected() { + stopReconnectCountdown(); + startHeartbeatTimer(); + networkHandler.sendHeartbeat(); + Gdx.app.postRunnable(() -> myLatencyLabel.setText("Ping: --")); + showStatus("Reconnected!"); + } @Override public void onOpponentDisconnected() { - showStatus("Opponent disconnected."); + Gdx.app.postRunnable(() -> opponentLatencyLabel.setText("Opp: Lost")); + showStatus("Opponent disconnected. Waiting for reconnection..."); + } + + @Override + public void onOpponentReconnected() { + showStatus("Opponent reconnected."); } // ───────────────────────────────────────────────────────────────────────── @@ -410,7 +507,9 @@ private void refreshTurnLabel() { turnLabel.setText(inMatchState.isMyTurn(localPlayer) ? "Your turn" : "Opponent's turn"); } - private void showStatus(String msg) { statusLabel.setText(msg); } + private void showStatus(String msg) { + Gdx.app.postRunnable(() -> statusLabel.setText(msg)); + } private boolean containsVector(List list, Vector2 v) { for (Vector2 item : list) @@ -418,14 +517,47 @@ private boolean containsVector(List list, Vector2 v) { return false; } + // ───────────────────────────────────────────────────────────────────────── + // Reconnect countdown (shown to the disconnected player) + // ───────────────────────────────────────────────────────────────────────── + + private void startReconnectCountdown() { + stopReconnectCountdown(); + reconnectSecondsLeft = 10; + showStatus("You are disconnected! Reconnecting... " + reconnectSecondsLeft + "s"); + reconnectCountdownTimer = new Timer(true); + reconnectCountdownTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + reconnectSecondsLeft--; + if (reconnectSecondsLeft <= 0) { + Gdx.app.postRunnable(() -> showStatus("Disconnected.")); + stopReconnectCountdown(); + } else { + showStatus("You are disconnected! Reconnecting... " + reconnectSecondsLeft + "s"); + } + } + }, 1000L, 1000L); + } + + private void stopReconnectCountdown() { + if (reconnectCountdownTimer != null) { + reconnectCountdownTimer.cancel(); + reconnectCountdownTimer = null; + } + reconnectSecondsLeft = 0; + } + // ───────────────────────────────────────────────────────────────────────── // Screen lifecycle // ───────────────────────────────────────────────────────────────────────── @Override public void show() { + isScreenActive = true; Gdx.input.setInputProcessor( new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + startHeartbeatTimer(); } @Override @@ -434,20 +566,32 @@ public void resize(int width, int height) { boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT); } - @Override public void pause() {} - @Override public void resume() {} + @Override + public void pause() { + isScreenActive = false; + } + + @Override + public void resume() { + isScreenActive = true; + networkHandler.sendHeartbeat(); + } @Override public void hide() { + isScreenActive = false; inputHandler.clearObservers(); + stopReconnectCountdown(); + stopHeartbeatTimer(); networkHandler.stop(); inMatchState.exit(); } @Override public void dispose() { - networkHandler.stop(); + stopReconnectCountdown(); + stopHeartbeatTimer(); stage.dispose(); boardRenderer.dispose(); } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java index 4d9e018..dcdc9b6 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java @@ -68,8 +68,7 @@ public void confirm() { gameId, localPlayer.isWhite(), encoded, - this::listenForBothReady, - error -> Gdx.app.postRunnable(() -> listener.onError("Setup failed: " + error)) + this::listenForBothReady ); } @@ -144,4 +143,4 @@ private void fetchOpponentBoard() { public boolean isConfirmed() { return isConfirmed; } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/states/SetupState.java b/core/src/main/java/com/group14/regicidechess/states/SetupState.java index c48f641..d02ab09 100644 --- a/core/src/main/java/com/group14/regicidechess/states/SetupState.java +++ b/core/src/main/java/com/group14/regicidechess/states/SetupState.java @@ -162,7 +162,7 @@ public FirebaseAPI getFirebaseApi() { } public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess) { - DatabaseManager.getInstance().getApi().confirmSetup(gameId, isWhite, board, onSuccess); + DatabaseManager.getInstance().getApi().confirmSetup(gameId, isWhite, board, onSuccess, null); } public void unconfirmSetup(String gameId, boolean isWhite, Runnable onSuccess, FirebaseAPI.Callback onError) {