diff --git a/README.md b/README.md index 8ca88e7..f98ab61 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,76 @@ -# regicidechess -A [libGDX](https://libgdx.com/) project generated with [gdx-liftoff](https://github.com/libgdx/gdx-liftoff). +# Regicide Chess -This project was generated with a template including simple application launchers and an `ApplicationAdapter` extension that draws libGDX logo. +Regicide Chess is a hybrid chess game for Android that combines classic chess rules with a regicide twist. Built with **LibGDX** and **Firebase**, it supports real-time multiplayer matches, letting players challenge friends or opponents online. -## Platforms +## Project Structure -- `core`: Main module with the application logic shared by all platforms. -- `lwjgl3`: Primary desktop platform using LWJGL3; was called 'desktop' in older docs. -- `android`: Android mobile platform. Needs Android SDK. +The project follows the standard LibGDX multi-module layout: -## Gradle +``` +RegicideChess/ +├── android/ # Android-specific module +│ └── .../firebase/ +│ └── AndroidFirebase.java # Main Firebase interface +├── core/ # Platform-independent game logic +│ ├── database/ # Firebase data handling +│ ├── input/ # Input processing +│ ├── model/ # Game models +│ ├── network/ # Networking utilities +│ ├── screens/ # Game screens +│ │ ├── Lobby/ # Lobby creation/joining +│ │ ├── Game/ # Main gameplay +│ │ ├── MainMenu/ # Main menu +│ │ └── Setup/ # Setup pieces +│ ├── states/ # Game state management +│ └── utils/ # Helper classes +├── assets/ # Sprites, fonts +└── ... # Gradle build files, etc. +``` -This project uses [Gradle](https://gradle.org/) to manage dependencies. -The Gradle wrapper was included, so you can run Gradle tasks using `gradlew.bat` or `./gradlew` commands. -Useful Gradle tasks and flags: +- **android/**: Contains Android-specific code, notably `AndroidFirebase.java` which acts as the central hub for all Firebase operations. +- **core/**: All shared game logic, UI screens, input handling, and networking abstractions. This module is independent of Android and can be reused on other platforms. +- **assets/**: Images (piece sprites) -- `--continue`: when using this flag, errors will not stop the tasks from running. -- `--daemon`: thanks to this flag, Gradle daemon will be used to run chosen tasks. -- `--offline`: when using this flag, cached dependency archives will be used. -- `--refresh-dependencies`: this flag forces validation of all dependencies. Useful for snapshot versions. -- `android:lint`: performs Android project validation. -- `build`: builds sources and archives of every project. -- `cleanEclipse`: removes Eclipse project data. -- `cleanIdea`: removes IntelliJ project data. -- `clean`: removes `build` folders, which store compiled classes and built archives. -- `eclipse`: generates Eclipse project data. -- `idea`: generates IntelliJ project data. -- `lwjgl3:jar`: builds application's runnable jar, which can be found at `lwjgl3/build/libs`. -- `lwjgl3:run`: starts the application. -- `test`: runs unit tests (if any). +## Compilation and Execution -Note that most tasks that are not specific to a single project can be run with `name:` prefix, where the `name` should be replaced with the ID of a specific project. -For example, `core:clean` removes `build` folder only from the `core` project. +### For End Users (Play Store / APK) + +1. Download the `RegicideChess.apk` from the [Releases Page](https://git.ntnu.no/TDT4240-14/TDT4240/-/releases) +2. Copy it to your Android device and install it. You may need to enable **Install from unknown sources** in your device settings. +3. Tap the app icon to launch. + +### For Developers (Build from Source) + +#### Prerequisites +- [Android Studio](https://developer.android.com/studio) (with Android SDK and build tools) +- Java 11 or higher + +#### Steps +1. Clone the repository: + ```bash + git clone https://git.ntnu.no/TDT4240-14/TDT4240.git + ``` +2. Open the project in **Android Studio**. +3. Wait for Gradle sync to complete (it will automatically download all dependencies, including LibGDX and Firebase SDKs). +4. Build the APK: + - Go to the top menu: `Build` → `Build Bundle(s) / APK(s)` → `Build APK(s)`. +5. The debug APK will be generated at: + ``` + app/build/outputs/apk/debug/ + ``` + +#### Running on an Emulator or Device +- Connect your Android device via USB or start an emulator. +- Click the **Run** button in Android Studio. + +## Testing Multiplayer with BlueStacks + +Since multiplayer requires two devices, we recommend using [BlueStacks](https://www.bluestacks.com) (a free Android emulator for PC) to run multiple instances. + +1. Install BlueStacks and open the **Multi‑Instance Manager**. +2. Create two separate instances (e.g., `Instance 1` and `Instance 2`). +3. Launch both instances. +4. Drag the `RegicideChess.apk` file into each BlueStacks window to install the game. +5. On one instance, create a new lobby. On the other, join using the displayed **Game ID**. +6. Play a full match – this simulates two real devices and helps catch multiplayer bugs. diff --git a/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java b/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java index 0fc9053..eb0aaf3 100644 --- a/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java +++ b/android/src/main/java/com/group14/regicidechess/android/FirebaseSetupManager.java @@ -94,18 +94,27 @@ public void getOpponentBoard(String gameId, boolean localIsWhite, FirebaseAPI.Ca .addOnFailureListener(e -> onBoard.call(new int[0][0])); } + private ValueEventListener bothReadyListener; + public void listenForBothSetupReady(String gameId, Runnable onBothReady) { DatabaseReference ref = db.child("games").child(gameId).child("bothReady"); - ValueEventListener listener = new ValueEventListener() { + + // Remove existing listener if it exists to prevent duplicates + if (bothReadyListener != null) { + ref.removeEventListener(bothReadyListener); + } + + bothReadyListener = new ValueEventListener() { @Override public void onDataChange(DataSnapshot s) { Boolean bothReady = s.getValue(Boolean.class); if (bothReady != null && bothReady) { ref.removeEventListener(this); + bothReadyListener = null; // Clear reference onBothReady.run(); } } @Override public void onCancelled(DatabaseError e) {} }; - ref.addValueEventListener(listener); + ref.addValueEventListener(bothReadyListener); } } 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 7acd266..1bc7494 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 @@ -110,14 +110,31 @@ public interface Listener { private boolean selfConnected = true; // mirrors .info/connected private boolean forfeitWritten = false; // prevent double-write to gameOver + /** + * @param selfConnectedAtStart Pass the connection state inherited from SetupNetworkHandler + * (via isSelfConnected()). This seeds selfConnected correctly so + * the initial .info/connected = true callback — which Firebase + * always fires immediately on listener attach — is not mistaken + * for a reconnect after a disconnect. + * Pass {@code true} for a normal start; pass the actual state + * when transitioning from SetupScreen. + */ public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, - Image connectionIcon, Label connectionLabel, FirebaseAPI api) { + Image connectionIcon, Label connectionLabel, + FirebaseAPI api, boolean selfConnectedAtStart) { this.gameId = gameId; this.localPlayer = localPlayer; this.listener = listener; this.connectionIcon = connectionIcon; this.connectionLabel = connectionLabel; this.api = api; + this.selfConnected = selfConnectedAtStart; + } + + /** Convenience overload for cases where we know the device is connected (e.g. tests). */ + public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, + Image connectionIcon, Label connectionLabel, FirebaseAPI api) { + this(gameId, localPlayer, listener, connectionIcon, connectionLabel, api, true); } // ───────────────────────────────────────────────────────────────────────── @@ -241,8 +258,12 @@ private void startOpponentMoveListener() { } private void startGameOverListener() { - api.listenForGameOver(gameId, reason -> - Gdx.app.postRunnable(() -> listener.onGameOver(reason))); + api.listenForGameOver(gameId, reason -> { + // "cancel:..." is written by SetupNetworkHandler during the setup phase. + // Ignore it — GameScreen only handles "forfeit:..." and "disconnect:...". + if (reason == null || reason.startsWith("cancel:")) return; + Gdx.app.postRunnable(() -> listener.onGameOver(reason)); + }); } private void startHeartbeatAndLatency() { 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 ebbca5b..7d90927 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 @@ -106,27 +106,35 @@ public void showOpponentDisconnected() { overlayWrapper.setVisible(true); } + /** + * Called when a forfeit/disconnect reason arrives from Firebase. + * reason format: "forfeit:{loserColor}" | "disconnect:{droppedColor}" + */ public void showForfeitReceived(String reason) { - // 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) { + if (reason.equals("disconnect:" + myColor)) { + // I was the one who dropped and couldn't reconnect in time overlayTitle.setText("You Lost"); - overlayBody.setText("You disconnected."); - } else if (opponentDisconnected) { + overlayBody.setText("You disconnected and could not reconnect in time."); + } else if (reason.equals("disconnect:" + opponentColor)) { + // Opponent dropped and didn't reconnect overlayTitle.setText("You Win!"); overlayBody.setText("Opponent disconnected."); - } else if (opponentForfeited) { + } else if (reason.equals("forfeit:" + opponentColor)) { + // Opponent pressed Forfeit overlayTitle.setText("You Win!"); - overlayBody.setText("Opponent forfeited!"); - } else { - // "forfeit:myColor" — I voluntarily forfeited + overlayBody.setText("Opponent forfeited."); + } else if (reason.equals("forfeit:" + myColor)) { + // My own forfeit echoed back from Firebase — isGameOver guard in GameScreen + // should prevent this path, but handle it defensively. overlayTitle.setText("You Lost"); overlayBody.setText("You forfeited."); + } else { + // Unrecognised reason (e.g. stale "cancel:" from setup — should be filtered + // in GameNetworkHandler, but guard here too). + return; } overlayConfirmBtn.setText("Back to Menu"); overlayCancelBtn.setVisible(false); @@ -134,6 +142,16 @@ public void showForfeitReceived(String reason) { overlayWrapper.setVisible(true); } + /** Shown immediately to the local player after they confirm the forfeit dialog. */ + public void showSelfForfeited() { + overlayTitle.setText("You Lost"); + overlayBody.setText("You forfeited."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + public void showPromotion() { promotionOverlay.setVisible(true); } 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 bffcd46..ce50254 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 @@ -113,9 +113,18 @@ public class GameScreen implements Screen, // Constructor // ───────────────────────────────────────────────────────────────────────── + /** + * @param selfConnectedAtStart Connection state at the moment of transition, taken from + * SetupNetworkHandler.isSelfConnected(). Seeding this correctly + * prevents the initial .info/connected = true callback that + * Firebase fires on listener attach from being misread as a + * reconnect after a disconnect, which would cancel the grace timer. + * Use the single-arg overload (defaults to true) when constructing + * GameScreen outside of a setup→game transition (e.g. in tests). + */ public GameScreen(Game game, SpriteBatch batch, Board board, Player localPlayer, int boardSize, String gameId, - FirebaseAPI api) { + FirebaseAPI api, boolean selfConnectedAtStart) { this.game = game; this.batch = batch; this.localPlayer = localPlayer; @@ -146,7 +155,8 @@ public GameScreen(Game game, SpriteBatch batch, overlayManager = new GameOverlayManager(stage, skin, localPlayer, this); networkHandler = new GameNetworkHandler(gameId, localPlayer, this, - connectionIcon, connectionLabel, api); + connectionIcon, connectionLabel, api, + selfConnectedAtStart); boardRenderer = new GameBoardRenderer(batch, localPlayer, boardSize); boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT); @@ -390,7 +400,9 @@ public void onForfeitConfirmed() { stopHeartbeatTimer(); forfeitBtn.setVisible(false); networkHandler.signalForfeit(); - overlayManager.showGameOver(false); + // Show "You forfeited" immediately — the Firebase echo of our own + // forfeit signal must NOT trigger onGameOver again (isGameOver guards it). + overlayManager.showSelfForfeited(); } @Override diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java index 01b59fa..b109c1e 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java @@ -1,44 +1,135 @@ // File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java package com.group14.regicidechess.screens.setup; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.scenes.scene2d.ui.Image; import com.badlogic.gdx.scenes.scene2d.ui.Label; import com.badlogic.gdx.scenes.scene2d.ui.Skin; import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.utils.Align; import com.group14.regicidechess.model.Player; /** * SetupHeaderWidget — builds and manages the header UI. + * + * Layout (mirrors GameScreen top bar): + * + * [ SETUP (title) ] [ Budget: X / Y ] [ ● ms / Opp: -- ms ] + * left center right (connection group) + * + * The connection group contains: + * • connectionIcon — colour-coded dot (green/orange/red) + * • connectionLabel — own RTT in ms, or "Lost" + * • opponentLatencyLabel — opponent's published RTT */ public class SetupHeaderWidget { - private final Skin skin; + private final Skin skin; private final Player player; + + // Budget label — refreshed when pieces are placed/removed. private Label budgetLabel; + // Latency labels — updated by SetupScreen from SetupNetworkHandler callbacks. + private Label myLatencyLabel; + private Label opponentLatencyLabel; + + // Connection icon — colour updated alongside myLatencyLabel. + private Image connectionIcon; + private Label connectionLabel; + public SetupHeaderWidget(Skin skin, Player player) { - this.skin = skin; + this.skin = skin; this.player = player; } + // ───────────────────────────────────────────────────────────────────────── + // Build + // ───────────────────────────────────────────────────────────────────────── + public Table build() { Table header = new Table(); header.setBackground(skin.getDrawable("primary-pixel")); header.pad(12); + // Left — screen title Label titleLabel = new Label("SETUP", skin, "title"); + + // Centre — budget remaining budgetLabel = new Label(getBudgetText(), skin); + budgetLabel.setAlignment(Align.center); + + // Right — connection group (icon + own RTT, then opponent RTT below) + connectionIcon = new Image(skin.getDrawable("white-pixel")); + connectionIcon.setColor(Color.GRAY); + connectionLabel = new Label("Connecting", skin, "small"); + + opponentLatencyLabel = new Label("Opp: --", skin, "small"); + opponentLatencyLabel.setAlignment(Align.right); + + myLatencyLabel = new Label("Ping: --", skin, "small"); + myLatencyLabel.setAlignment(Align.right); + + // Connection sub-table (icon + "Connecting" / RTT on first row, + // opponent latency on second row) + Table connGroup = new Table(); + connGroup.add(connectionIcon).size(12, 12).padRight(6); + connGroup.add(connectionLabel).right(); + connGroup.row(); + connGroup.add(opponentLatencyLabel).colspan(2).right(); header.add(titleLabel).expandX().left(); - header.add(budgetLabel).expandX().right(); + header.add(budgetLabel).expandX().center(); + header.add(connGroup).width(120).right().padRight(4); return header; } + // ───────────────────────────────────────────────────────────────────────── + // Refresh helpers (called from SetupScreen) + // ───────────────────────────────────────────────────────────────────────── + public void refreshBudget() { if (budgetLabel != null) { budgetLabel.setText(getBudgetText()); } } + /** + * Update own ping display. + * @param latencyMs measured RTT in ms, or -1 to show "Lost". + */ + public void setMyLatency(long latencyMs) { + if (connectionLabel == null || connectionIcon == null) return; + if (latencyMs < 0) { + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + if (myLatencyLabel != null) myLatencyLabel.setText("Ping: Lost"); + } else { + if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); + else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE); + else connectionIcon.setColor(Color.RED); + connectionLabel.setText(latencyMs + " ms"); + if (myLatencyLabel != null) myLatencyLabel.setText("Ping: " + latencyMs + " ms"); + } + } + + /** + * Update opponent ping display. + * @param latencyMs opponent's published RTT in ms, or -1 to show "Lost". + */ + public void setOpponentLatency(long latencyMs) { + if (opponentLatencyLabel == null) return; + if (latencyMs < 0) { + opponentLatencyLabel.setText("Opp: Lost"); + } else { + opponentLatencyLabel.setText("Opp: " + latencyMs + " ms"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Internal + // ───────────────────────────────────────────────────────────────────────── + private String getBudgetText() { return "Budget: " + player.getBudgetRemaining() + " / " + player.getBudget(); } diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java new file mode 100644 index 0000000..300a758 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java @@ -0,0 +1,388 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupNetworkHandler.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +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.database.FirebaseAPI; +import com.group14.regicidechess.model.Player; + +/** + * SetupNetworkHandler — owns all Firebase subscriptions during the setup phase. + * + * Disconnect / reconnect grace-period design (mirrors GameNetworkHandler) + * ──────────────────────────────────────────────────────────────────────── + * When a player loses their Firebase connection the server writes a timestamp + * to games/{gameId}/disconnectedAt/{color} via a pre-registered onDisconnect + * hook. + * + * Disconnected player — local (client-side) selfCancelTask. + * After RECONNECT_GRACE_MS with no reconnect, fires + * onGameCancelled locally. Cannot use Firebase while + * offline, so this must be purely client-side. + * + * Connected player — server-side-triggered (client-side) autoCancelTask. + * Started when disconnectedAt/{opponentColor} appears + * in Firebase. After RECONNECT_GRACE_MS writes + * "cancel:{loserColor}" to games/{gameId}/gameOver + * (first-writer-wins transaction) and fires + * onGameCancelled locally so the cancelled screen is + * immediate. + * + * Reconnect path + * ────────────── + * 1. selfConnected → true → selfCancelTask cancelled. + * 2. api.signalReconnected() clears disconnectedAt/{color} and writes + * reconnected/{color} = true. + * 3. Connected player's listenForOpponentReconnected fires → autoCancelTask + * cancelled → setup resumes. + * + * Latency / heartbeat + * ──────────────────── + * Heartbeats are sent by SetupScreen on a fixed timer (same as GameScreen). + * This handler wires the listeners so both players can see each other's ping + * in the header, identical to the GameScreen top bar. + */ +public class SetupNetworkHandler { + + public interface Listener { + /** My own measured RTT (shown in the header). */ + void onMyLatency(long latencyMs); + + /** Opponent's published latency (shown in the header). */ + void onOpponentLatency(long latencyMs); + + /** I lost my own Firebase connection. */ + void onSelfDisconnected(); + + /** I regained my own Firebase connection. */ + void onSelfReconnected(); + + /** Opponent's disconnectedAt timestamp appeared — they dropped. */ + void onOpponentDisconnected(); + + /** Opponent came back within the grace window — cancel UI countdown. */ + void onOpponentReconnected(); + + /** + * Called when the disconnect grace period expires for either player + * and the game can no longer continue. Both players receive this. + */ + void onGameCancelled(); + } + + // Heartbeat timeout before the connected player starts the grace timer. + private static final long HEARTBEAT_TIMEOUT_MS = 5_000L; + // Grace period before cancellation (same as GameScreen). + private static final long RECONNECT_GRACE_MS = 10_000L; + + private final String gameId; + private final Player localPlayer; + private final Listener listener; + private final FirebaseAPI api; + + // Visual connection indicator (identical widgets to GameScreen top bar). + private final Image connectionIcon; + private final Label connectionLabel; + + // Connected player's auto-cancel timer. + private Timer.Task autoCancelTask = null; + + // Disconnected player's local cancel timer. + private Timer.Task selfCancelTask = null; + + private boolean opponentDisconnected = false; + private boolean selfConnected = true; + private boolean cancelWritten = false; // prevent double-write + + public SetupNetworkHandler(String gameId, Player localPlayer, Listener listener, + Image connectionIcon, Label connectionLabel, + FirebaseAPI api) { + this.gameId = gameId; + this.localPlayer = localPlayer; + this.listener = listener; + this.connectionIcon = connectionIcon; + this.connectionLabel = connectionLabel; + this.api = api; + } + + // ───────────────────────────────────────────────────────────────────────── + // Start / stop + // ───────────────────────────────────────────────────────────────────────── + + public void start() { + registerDisconnectSignal(); + startSelfConnectionListener(); + startGameCancelledListener(); // listens for gameOver written by the connected player + startHeartbeatAndLatency(); + startOpponentDisconnectedAtListener(); + startOpponentReconnectListener(); + } + + /** + * Full stop: cancels all timers AND removes all Firebase listeners. + * Call this when setup ends abnormally (game cancelled, back to menu). + */ + public void stop() { + cancelAutoCancelTimer(); + cancelSelfCancelTimer(); + api.removeAllListeners(); + } + + /** + * Transition stop: cancels local timers but does NOT remove Firebase listeners. + * + * Call this instead of stop() when handing off to GameScreen so the underlying + * Firebase socket and .info/connected subscription remain alive across the + * screen switch. Tearing down and re-registering listeners during the transition + * causes Firebase to emit .info/connected = false momentarily (listener detach/ + * re-attach gap), which both handlers interpret as a disconnect and immediately + * start their 10-second grace timers — cancelling the game before it begins. + * + * GameNetworkHandler.start() will add its own fresh listeners on top of the + * still-live socket without any interruption. + */ + public void stopForTransition() { + cancelAutoCancelTimer(); + cancelSelfCancelTimer(); + // Intentionally NOT calling api.removeAllListeners() — the Firebase socket + // stays connected. GameNetworkHandler will add its own listeners on the same + // FirebaseAPI instance and call removeAllListeners() when the game ends. + } + + /** + * Returns whether this device currently has a live Firebase connection. + * Passed to GameNetworkHandler so it can seed selfConnected correctly and + * not misread the initial .info/connected = true callback as a "reconnect". + */ + public boolean isSelfConnected() { + return selfConnected; + } + + // ───────────────────────────────────────────────────────────────────────── + // Outgoing + // ───────────────────────────────────────────────────────────────────────── + + public void sendHeartbeat() { + api.sendHeartbeat(gameId, localPlayer.isWhite()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Server-side disconnect hook + // ───────────────────────────────────────────────────────────────────────── + + private void registerDisconnectSignal() { + String myColor = localPlayer.isWhite() ? "white" : "black"; + // Writes disconnectedAt/{color} on drop — NOT gameOver. + // The connected opponent starts their grace timer from this signal. + api.registerDisconnectGameOver(gameId, "disconnect:" + myColor); + } + + // ───────────────────────────────────────────────────────────────────────── + // Own connection — .info/connected + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfConnectionListener() { + api.listenForMyConnection( + // onConnected + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) { + selfConnected = true; + Gdx.app.log("SetupNetworkHandler", "Self reconnected to Firebase"); + cancelSelfCancelTimer(); + // Clear disconnectedAt/{myColor} and write reconnected/{myColor}. + api.signalReconnected(gameId, localPlayer.isWhite()); + listener.onSelfReconnected(); + } + }), + // onDisconnected + () -> Gdx.app.postRunnable(() -> { + if (selfConnected) { + selfConnected = false; + Gdx.app.log("SetupNetworkHandler", "Self disconnected from Firebase"); + cancelAutoCancelTimer(); // can't be the connected player while offline + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + startSelfCancelTimer(); + listener.onSelfDisconnected(); + } + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Incoming: gameOver listener (re-uses the same Firebase path as GameScreen) + // ───────────────────────────────────────────────────────────────────────── + + private void startGameCancelledListener() { + // The connected player writes "cancel:{color}" via autoCancelTask. + // Both players see it here and show the cancellation screen. + api.listenForGameOver(gameId, reason -> + Gdx.app.postRunnable(() -> { + if (reason != null && reason.startsWith("cancel:")) { + listener.onGameCancelled(); + } + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Heartbeat & latency + // ───────────────────────────────────────────────────────────────────────── + + private void startHeartbeatAndLatency() { + // Each player listens for the *other* player's heartbeat. + boolean watchOpponent = !localPlayer.isWhite(); + + api.listenForHeartbeat( + gameId, + watchOpponent, + HEARTBEAT_TIMEOUT_MS, + // ── Heartbeat received ──────────────────────────────────────── + latency -> Gdx.app.postRunnable(() -> { + boolean wasDisconnected = opponentDisconnected; + opponentDisconnected = false; + cancelAutoCancelTimer(); + + updateMyConnectionUI(latency); + listener.onMyLatency(latency); + // Publish our own measured RTT so the opponent can display it. + api.sendLatency(gameId, localPlayer.isWhite(), latency); + + if (wasDisconnected) { + listener.onOpponentReconnected(); + } + }), + // ── Timeout: no heartbeat from opponent ─────────────────────── + () -> Gdx.app.postRunnable(() -> { + if (!selfConnected) return; // we're the offline one + if (opponentDisconnected) return; // grace timer already running + + opponentDisconnected = true; + Gdx.app.log("SetupNetworkHandler", + "Heartbeat timeout — opponent disconnected, starting 10s grace timer"); + listener.onOpponentDisconnected(); + startAutoCancelTimer(); + }) + ); + + api.listenForOpponentLatency(gameId, watchOpponent, + latency -> Gdx.app.postRunnable(() -> listener.onOpponentLatency(latency))); + } + + // ───────────────────────────────────────────────────────────────────────── + // disconnectedAt listener — secondary fallback (connected player only) + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentDisconnectedAtListener() { + String opponentColor = localPlayer.isWhite() ? "black" : "white"; + api.listenForOpponentDisconnectedAt(gameId, opponentColor, () -> + Gdx.app.postRunnable(() -> { + if (opponentDisconnected) return; // heartbeat already started timer + if (!selfConnected) return; // we're the offline one + + opponentDisconnected = true; + Gdx.app.log("SetupNetworkHandler", + "disconnectedAt signal — starting grace timer (fallback path)"); + listener.onOpponentDisconnected(); + startAutoCancelTimer(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Opponent reconnect listener (connected player only) + // ───────────────────────────────────────────────────────────────────────── + + private void startOpponentReconnectListener() { + boolean watchWhite = !localPlayer.isWhite(); + api.listenForOpponentReconnected(gameId, watchWhite, () -> + Gdx.app.postRunnable(() -> { + Gdx.app.log("SetupNetworkHandler", + "Opponent signalled reconnect — cancelling cancel timer"); + cancelAutoCancelTimer(); + opponentDisconnected = false; + listener.onOpponentReconnected(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Connected player's auto-cancel timer + // + // On expiry: writes "cancel:{droppedColor}" to gameOver (first-writer-wins) + // and fires onGameCancelled locally for immediate UI response. + // ───────────────────────────────────────────────────────────────────────── + + private void startAutoCancelTimer() { + cancelAutoCancelTimer(); + autoCancelTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (!opponentDisconnected || cancelWritten || !selfConnected) return; + cancelWritten = true; + String droppedColor = localPlayer.isWhite() ? "black" : "white"; + Gdx.app.log("SetupNetworkHandler", + "Grace period expired — writing cancel:" + droppedColor); + api.signalGameOver(gameId, "cancel:" + droppedColor); + // Show cancellation screen immediately without waiting for + // Firebase to echo the write back. + listener.onGameCancelled(); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelAutoCancelTimer() { + if (autoCancelTask != null) { + autoCancelTask.cancel(); + autoCancelTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Disconnected player's local cancel timer + // + // Started when .info/connected goes false. + // Cancelled if .info/connected returns true within the grace window. + // On expiry: fires onGameCancelled locally (cannot write Firebase offline). + // The server-side onDisconnect hook ensures the connected player's + // autoCancelTimer eventually fires and writes the gameOver reason. + // ───────────────────────────────────────────────────────────────────────── + + private void startSelfCancelTimer() { + cancelSelfCancelTimer(); + selfCancelTask = Timer.schedule(new Timer.Task() { + @Override + public void run() { + Gdx.app.postRunnable(() -> { + if (selfConnected) return; // reconnected in time + Gdx.app.log("SetupNetworkHandler", + "Self reconnect grace expired — showing cancellation locally"); + listener.onGameCancelled(); + }); + } + }, RECONNECT_GRACE_MS / 1000f); + } + + private void cancelSelfCancelTimer() { + if (selfCancelTask != null) { + selfCancelTask.cancel(); + selfCancelTask = null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Connection indicator (identical to GameScreen) + // ───────────────────────────────────────────────────────────────────────── + + private void updateMyConnectionUI(long latencyMs) { + if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); + else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE); + 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/setup/SetupScreen.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java index e490d1c..e07452a 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java @@ -1,34 +1,52 @@ -// SetupScreen.java - Updated with exception handling +// SetupScreen.java - with heartbeat, latency display, and disconnect/cancel logic package com.group14.regicidechess.screens.setup; import com.badlogic.gdx.Game; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; import com.badlogic.gdx.graphics.g2d.SpriteBatch; import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.Actor; import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; import com.badlogic.gdx.scenes.scene2d.ui.Skin; import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.badlogic.gdx.utils.Align; import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.database.FirebaseAPI; import com.group14.regicidechess.input.ScreenInputHandler; import com.group14.regicidechess.model.Player; import com.group14.regicidechess.model.pieces.ChessPiece; import com.group14.regicidechess.screens.game.GameScreen; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; import com.group14.regicidechess.states.SetupState; import com.group14.regicidechess.utils.ResourceManager; +import java.util.Timer; +import java.util.TimerTask; -public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - // LibGDX +public class SetupScreen implements Screen, + ScreenInputHandler.ScreenInputObserver, + SetupNetworkHandler.Listener { + + // ── Heartbeat ───────────────────────────────────────────────────────────── + private static final long HEARTBEAT_INTERVAL_MS = 2_000L; + + // ── LibGDX ──────────────────────────────────────────────────────────────── private final Game game; private final SpriteBatch batch; private final Stage stage; private final Skin skin; private final ScreenInputHandler inputHandler; - // Game state + // ── Game state ──────────────────────────────────────────────────────────── private final SetupState setupState; private final Player localPlayer; private final String gameId; @@ -36,23 +54,48 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv private final boolean isHost; private boolean isConfirmed = false; - // UI Widgets + // ── UI Widgets ──────────────────────────────────────────────────────────── private final SetupHeaderWidget headerWidget; private final SetupFooterWidget footerWidget; private final SetupPaletteWidget palette; - // Helpers + // ── Helpers ─────────────────────────────────────────────────────────────── private final SetupBoardRenderer boardRenderer; private SetupBoardInputHandler boardInputHandler; private SetupFlowController flowController; + private SetupNetworkHandler networkHandler; + + // ── Heartbeat timer ─────────────────────────────────────────────────────── + private Timer heartbeatTimer; + private boolean isHeartbeatRunning = false; + private boolean isScreenActive = true; + + // ── Reconnect countdown (shown to the disconnected player) ──────────────── + private Timer reconnectCountdownTimer; + private int reconnectSecondsLeft = 0; + + // ── Game-cancelled overlay ──────────────────────────────────────────────── + private Table cancelledOverlay; + private boolean gameCancelledShown = false; + + // ── Transition guard ────────────────────────────────────────────────────── + // Set to true by onOpponentBoardFetched() after stopForTransition() is called. + // hide() checks this flag to avoid calling stop() (which calls removeAllListeners()) + // AFTER GameNetworkHandler.start() has already added its own listeners on the same + // FirebaseAPI instance — that would tear down the game's listeners immediately. + private boolean transitioningToGame = false; @SuppressWarnings("unused") private Table root; + // ───────────────────────────────────────────────────────────────────────── + // Constructor + // ───────────────────────────────────────────────────────────────────────── + public SetupScreen(Game game, SpriteBatch batch, String gameId, int boardSize, int budget, boolean isHost) { - this.game = game; - this.batch = batch; + this.game = game; + this.batch = batch; this.gameId = gameId; this.isHost = isHost; @@ -60,30 +103,26 @@ public SetupScreen(Game game, SpriteBatch batch, // Host = white (player1), joiner = black (player2) this.localPlayer = new Player(isHost ? "player1" : "player2", isHost, budget); - // Setup state this.setupState = new SetupState(); setupState.setBoardSize(boardSize); setupState.setBudget(budget); setupState.setPlayer(localPlayer); setupState.enter(); - // LibGDX setup this.stage = new Stage(new FitViewport( SetupScreenConfig.VIEWPORT_WIDTH, - SetupScreenConfig.VIEWPORT_HEIGHT), - batch); - this.skin = ResourceManager.getInstance().getSkin(); + SetupScreenConfig.VIEWPORT_HEIGHT), batch); + this.skin = ResourceManager.getInstance().getSkin(); this.inputHandler = new ScreenInputHandler(); inputHandler.addObserver(this); - // Create widgets and helpers this.palette = new SetupPaletteWidget(skin, this::onPaletteSelectionChanged, true); BoardCoordinateMapper coordinateMapper = new BoardCoordinateMapper(localPlayer, boardSize); this.boardRenderer = new SetupBoardRenderer(batch, coordinateMapper); - this.headerWidget = new SetupHeaderWidget(skin, localPlayer); - this.footerWidget = new SetupFooterWidget(skin, createFooterListener()); + this.headerWidget = new SetupHeaderWidget(skin, localPlayer); + this.footerWidget = new SetupFooterWidget(skin, createFooterListener()); this.boardInputHandler = new SetupBoardInputHandler( setupState, boardRenderer, palette, localPlayer, createBoardActionListener(), false); @@ -91,6 +130,21 @@ public SetupScreen(Game game, SpriteBatch batch, this.flowController = new SetupFlowController( gameId, localPlayer, setupState, createFlowListener()); + // Network handler — connection icon/label live inside SetupHeaderWidget + // but we need them before build(), so we create temporary widgets here + // that the header will display once build() is called in buildUI(). + // The simplest approach: let SetupHeaderWidget own them and expose + // setMyLatency / setOpponentLatency helpers (see SetupHeaderWidget.java). + // We pass dummy Image/Label here because SetupNetworkHandler uses them + // only for the colour-coded dot; the header's own labels handle text. + Image connIcon = new Image(skin.getDrawable("white-pixel")); + Label connLabel = new Label("Connecting", skin, "small"); + connIcon.setColor(Color.GRAY); + + FirebaseAPI api = setupState.getFirebaseApi(); + this.networkHandler = new SetupNetworkHandler( + gameId, localPlayer, this, connIcon, connLabel, api); + buildUI(); boardRenderer.computeGeometry( SetupScreenConfig.VIEWPORT_WIDTH, @@ -99,22 +153,20 @@ public SetupScreen(Game game, SpriteBatch batch, SetupScreenConfig.PALETTE_HEIGHT, SetupScreenConfig.FOOTER_HEIGHT, boardSize); - - Gdx.app.log("SetupScreen", "Initialized with board size: " + boardSize + ", budget: " + budget); + + Gdx.app.log("SetupScreen", + "Initialized with board size: " + boardSize + ", budget: " + budget); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error initializing SetupScreen: " + e.getMessage(), e); showErrorAndExit("Failed to initialize setup: " + e.getMessage()); throw new RuntimeException("SetupScreen initialization failed", e); } } - + private void showErrorAndExit(String message) { Gdx.app.error("SetupScreen", message); - // Try to go back to main menu try { - com.group14.regicidechess.screens.mainmenu.MainMenuScreen mainMenu = - new com.group14.regicidechess.screens.mainmenu.MainMenuScreen(game, batch); - game.setScreen(mainMenu); + game.setScreen(new MainMenuScreen(game, batch)); } catch (Exception e) { Gdx.app.error("SetupScreen", "Could not return to main menu", e); } @@ -131,37 +183,219 @@ private void buildUI() { root.top(); stage.addActor(root); - // Header root.add(headerWidget.build()) .expandX().fillX() .height(SetupScreenConfig.HEADER_HEIGHT) .row(); - // Spacer for board area (drawn by boardRenderer) - root.add().expandX().expandY().row(); + root.add().expandX().expandY().row(); // board spacer - // Palette root.add(palette.buildWidget()) .expandX().fillX() .height(SetupScreenConfig.PALETTE_HEIGHT) .row(); - // Footer root.add(footerWidget.build()) .expandX().fillX() .height(SetupScreenConfig.FOOTER_HEIGHT) .row(); - // Waiting label (initially hidden) - root.add(footerWidget.getWaitingLabel()) - .expandX().padTop(8).row(); + // NOTE: do NOT add footerWidget.getWaitingLabel() here. + // SetupFooterWidget.showWaitingMode() embeds the waiting label + // inside the footer table itself. Adding it here as a second + // actor would re-parent it out of the footer (Scene2D silently + // removes an actor from its current parent when it is added to + // another), breaking the waiting-mode layout. + buildCancelledOverlay(); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error building UI: " + e.getMessage(), e); } } + /** + * Builds the "Game Cancelled" overlay. It starts invisible and is shown + * on top of all other UI via stage.addActor() when onGameCancelled() fires. + * The overlay dims the background and shows a centred card with a message + * and a "Back to Menu" button — identical in structure to GameScreen's + * game-over overlay. + */ + private void buildCancelledOverlay() { + cancelledOverlay = new Table(); + cancelledOverlay.setFillParent(true); + cancelledOverlay.setVisible(false); + + // Semi-transparent dark card + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(32); + + Label title = new Label("Game Cancelled", skin, "title"); + title.setAlignment(Align.center); + + Label msg = new Label( + "A player disconnected.\nReturning to the main menu.", + skin, "small"); + msg.setAlignment(Align.center); + msg.setWrap(true); + + TextButton backBtn = new TextButton("Back to Menu", skin); + backBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + navigateToMainMenu(); + } + }); + + card.add(title).expandX().center().padBottom(16).row(); + card.add(msg).width(320).center().padBottom(24).row(); + card.add(backBtn).width(200).height(56).center().row(); + + cancelledOverlay.add(card).width(360).center(); + + stage.addActor(cancelledOverlay); + } + + // ───────────────────────────────────────────────────────────────────────── + // Heartbeat timer management (mirrors GameScreen exactly) + // ───────────────────────────────────────────────────────────────────────── + + private void startHeartbeatTimer() { + if (isHeartbeatRunning) return; + heartbeatTimer = new Timer(true); + heartbeatTimer.scheduleAtFixedRate(new TimerTask() { + @Override public void run() { + if (isScreenActive) { + Gdx.app.postRunnable(() -> networkHandler.sendHeartbeat()); + } + } + }, 0, HEARTBEAT_INTERVAL_MS); + isHeartbeatRunning = true; + Gdx.app.log("SetupScreen", "Heartbeat timer started"); + } + + private void stopHeartbeatTimer() { + if (heartbeatTimer != null) { + heartbeatTimer.cancel(); + heartbeatTimer = null; + } + isHeartbeatRunning = false; + } + // ───────────────────────────────────────────────────────────────────────── - // Listeners + // Reconnect countdown (shown to the disconnected player) + // Mirrors GameScreen.startReconnectCountdown() exactly. + // ───────────────────────────────────────────────────────────────────────── + + 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"); + } + } + }, 1_000L, 1_000L); + } + + private void stopReconnectCountdown() { + if (reconnectCountdownTimer != null) { + reconnectCountdownTimer.cancel(); + reconnectCountdownTimer = null; + } + reconnectSecondsLeft = 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // SetupNetworkHandler.Listener implementation + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onMyLatency(long latencyMs) { + Gdx.app.postRunnable(() -> headerWidget.setMyLatency(latencyMs)); + } + + @Override + public void onOpponentLatency(long latencyMs) { + Gdx.app.postRunnable(() -> headerWidget.setOpponentLatency(latencyMs)); + } + + @Override + public void onSelfDisconnected() { + stopHeartbeatTimer(); + Gdx.app.postRunnable(() -> { + headerWidget.setMyLatency(-1); // shows "Lost" + startReconnectCountdown(); + }); + } + + @Override + public void onSelfReconnected() { + // stopReconnectCountdown / startHeartbeatTimer are thread-safe (Timer cancel + // and schedule), but sendHeartbeat() and all UI updates must run on the GDX + // render thread. + stopReconnectCountdown(); + startHeartbeatTimer(); + Gdx.app.postRunnable(() -> { + networkHandler.sendHeartbeat(); + headerWidget.setMyLatency(0); // will be updated on next heartbeat cycle + showStatus("Reconnected!"); + }); + } + + @Override + public void onOpponentDisconnected() { + Gdx.app.postRunnable(() -> { + headerWidget.setOpponentLatency(-1); // shows "Opp: Lost" + showStatus("Opponent disconnected. Waiting for reconnection... (10s)"); + }); + } + + @Override + public void onOpponentReconnected() { + Gdx.app.postRunnable(() -> showStatus( + isConfirmed + ? "Opponent reconnected. Waiting for both to confirm..." + : "Opponent reconnected. Place your pieces.")); + } + + @Override + public void onGameCancelled() { + Gdx.app.postRunnable(() -> { + if (gameCancelledShown) return; + gameCancelledShown = true; + stopHeartbeatTimer(); + stopReconnectCountdown(); + showCancelledOverlay(); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Game-cancelled overlay + // ───────────────────────────────────────────────────────────────────────── + + private void showCancelledOverlay() { + if (cancelledOverlay != null) { + cancelledOverlay.setVisible(true); + // Bring to front so it draws over everything + cancelledOverlay.toFront(); + } + } + + private void navigateToMainMenu() { + stopHeartbeatTimer(); + stopReconnectCountdown(); + networkHandler.stop(); + game.setScreen(new MainMenuScreen(game, batch)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Listeners (footer / board / flow) // ───────────────────────────────────────────────────────────────────────── private SetupFooterWidget.FooterListener createFooterListener() { @@ -173,7 +407,6 @@ public void onClear() { showStatus("Cannot clear board while confirmed. Press Unconfirm first."); return; } - Gdx.app.log("SetupScreen", "Clear button pressed"); setupState.clearBoard(); palette.clearSelection(); refreshUI(); @@ -186,18 +419,16 @@ public void onClear() { @Override public void onConfirm() { try { - Gdx.app.log("SetupScreen", "Confirm button pressed"); flowController.confirm(); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error confirming setup: " + e.getMessage(), e); showStatus("Error confirming: " + e.getMessage()); } } - + @Override public void onUnconfirm() { try { - Gdx.app.log("SetupScreen", "Unconfirm button pressed"); flowController.unconfirm(); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error unconfirming setup: " + e.getMessage(), e); @@ -209,35 +440,11 @@ public void onUnconfirm() { private SetupBoardInputHandler.BoardActionListener createBoardActionListener() { return new SetupBoardInputHandler.BoardActionListener() { - @Override - public void onPiecePlaced(ChessPiece piece, int col, int row) { - Gdx.app.log("SetupScreen", "Piece placed: " + piece.getTypeName() + " at (" + col + ", " + row + ")"); - refreshUI(); - } - - @Override - public void onPieceRemoved(int col, int row) { - Gdx.app.log("SetupScreen", "Piece removed at (" + col + ", " + row + ")"); - refreshUI(); - } - - @Override - public void onPieceReplaced(ChessPiece oldPiece, ChessPiece newPiece, int col, int row) { - Gdx.app.log("SetupScreen", "Piece replaced: " + oldPiece.getTypeName() + - " -> " + newPiece.getTypeName() + " at (" + col + ", " + row + ")"); - refreshUI(); - } - - @Override - public void onInvalidPlacement(String reason) { - Gdx.app.log("SetupScreen", "Invalid placement: " + reason); - showStatus(reason); - } - - @Override - public void onStateChanged() { - refreshUI(); - } + @Override public void onPiecePlaced(ChessPiece piece, int col, int row) { refreshUI(); } + @Override public void onPieceRemoved(int col, int row) { refreshUI(); } + @Override public void onPieceReplaced(ChessPiece oldPiece, ChessPiece newPiece, int col, int row) { refreshUI(); } + @Override public void onInvalidPlacement(String reason) { showStatus(reason); } + @Override public void onStateChanged() { refreshUI(); } }; } @@ -247,26 +454,22 @@ private SetupFlowController.FlowListener createFlowListener() { public void onUploadComplete() { Gdx.app.postRunnable(() -> { try { - Gdx.app.log("SetupScreen", "Upload complete, waiting for opponent"); isConfirmed = true; - footerWidget.setConfirmed(true); footerWidget.setConfirmEnabled(true); - footerWidget.setWaitingMode(true); + footerWidget.showWaitingMode(); // single call — no redundant setConfirmed/setWaitingMode updateBoardInputLock(true); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error in onUploadComplete: " + e.getMessage(), e); } }); } - + @Override public void onUnconfirmSuccess() { Gdx.app.postRunnable(() -> { try { - Gdx.app.log("SetupScreen", "Unconfirm successful"); isConfirmed = false; - footerWidget.setConfirmed(false); - footerWidget.setWaitingMode(false); + footerWidget.showSetupMode(); // single call footerWidget.setConfirmEnabled(setupState.isReadyForConfirm()); updateBoardInputLock(false); showStatus("Setup unlocked. You can now make changes."); @@ -275,17 +478,10 @@ public void onUnconfirmSuccess() { } }); } - + @Override public void onUnconfirmError(String message) { - Gdx.app.postRunnable(() -> { - try { - Gdx.app.log("SetupScreen", "Unconfirm error: " + message); - showStatus("Failed to unconfirm: " + message); - } catch (Exception e) { - Gdx.app.error("SetupScreen", "Error in onUnconfirmError: " + e.getMessage(), e); - } - }); + Gdx.app.postRunnable(() -> showStatus("Failed to unconfirm: " + message)); } @Override @@ -294,21 +490,31 @@ public void onBothReady() { } @Override - public void onOpponentBoardFetched(com.group14.regicidechess.model.Board finalBoard, - Player opponent) { + public void onOpponentBoardFetched(com.group14.regicidechess.model.Board finalBoard, Player opponent) { Gdx.app.postRunnable(() -> { + if (transitioningToGame) return; + try { - Gdx.app.log("SetupScreen", "Opponent board fetched, navigating to GameScreen"); + isScreenActive = false; + stopReconnectCountdown(); + stopHeartbeatTimer(); + boolean connectedNow = networkHandler.isSelfConnected(); + + transitioningToGame = true; + networkHandler.stopForTransition(); + game.setScreen(new GameScreen( - game, batch, + game, + batch, finalBoard, localPlayer, setupState.getBoardSize(), gameId, - setupState.getFirebaseApi())); + setupState.getFirebaseApi(), + connectedNow + )); } catch (Exception e) { - Gdx.app.error("SetupScreen", "Error navigating to GameScreen: " + e.getMessage(), e); - showStatus("Error starting game: " + e.getMessage()); + Gdx.app.error("SetupScreen", "Error transitioning to GameScreen: " + e.getMessage(), e); } }); } @@ -316,22 +522,16 @@ public void onOpponentBoardFetched(com.group14.regicidechess.model.Board finalBo @Override public void onError(String message) { Gdx.app.postRunnable(() -> { - try { - Gdx.app.log("SetupScreen", "Error: " + message); - showStatus(message); - isConfirmed = false; - footerWidget.setConfirmed(false); - footerWidget.setConfirmEnabled(true); - footerWidget.setWaitingMode(false); - updateBoardInputLock(false); - } catch (Exception e) { - Gdx.app.error("SetupScreen", "Error in onError: " + e.getMessage(), e); - } + showStatus(message); + isConfirmed = false; + footerWidget.showSetupMode(); // single call + footerWidget.setConfirmEnabled(true); + updateBoardInputLock(false); }); } }; } - + private void updateBoardInputLock(boolean locked) { try { this.boardInputHandler = new SetupBoardInputHandler( @@ -360,8 +560,24 @@ public void render(float delta) { boardRenderer.drawBoard(stage.getCamera().combined, setupState); stage.act(delta); - stage.draw(); + // Draw dimming layer behind the overlay when it's visible. + if (gameCancelledShown) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + ShapeRenderer sr = new ShapeRenderer(); + sr.setProjectionMatrix(stage.getCamera().combined); + sr.begin(ShapeRenderer.ShapeType.Filled); + sr.setColor(0f, 0f, 0f, 0.65f); + sr.rect(0, 0, + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT); + sr.end(); + sr.dispose(); + Gdx.gl.glDisable(GL20.GL_BLEND); + } + + stage.draw(); boardRenderer.drawPaletteSprites(stage.getCamera().combined, palette.getButtons(), SetupPaletteWidget.PIECE_NAMES); } catch (Exception e) { @@ -375,6 +591,7 @@ public void render(float delta) { @Override public void onTap(int screenX, int screenY, int pointer, int button) { + if (gameCancelledShown) return; // block board taps when cancelled overlay is up try { Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); boardInputHandler.handleTap(world.x, world.y); @@ -383,14 +600,9 @@ public void onTap(int screenX, int screenY, int pointer, int button) { } } - @Override - public void onDrag(int x, int y, int pointer) {} - - @Override - public void onRelease(int x, int y, int pointer, int button) {} - - @Override - public void onKeyDown(int keycode) {} + @Override public void onDrag(int x, int y, int pointer) {} + @Override public void onRelease(int x, int y, int pointer, int button) {} + @Override public void onKeyDown(int keycode) {} // ───────────────────────────────────────────────────────────────────────── // UI refresh @@ -399,20 +611,8 @@ public void onKeyDown(int keycode) {} private void refreshUI() { try { headerWidget.refreshBudget(); - boolean kingPlaced = setupState.isReadyForConfirm(); - if (!isConfirmed) { - footerWidget.setConfirmEnabled(kingPlaced); - } - - int pieceCount = setupState.getBoard().getPieces().size(); - int playerPieces = setupState.getBoard().getPieces(setupState.getPlayer()).size(); - Gdx.app.log("SetupScreen", "Refresh UI - King placed: " + kingPlaced + - ", Confirmed: " + isConfirmed + - ", Total pieces: " + pieceCount + - ", Player pieces: " + playerPieces + - ", Budget remaining: " + localPlayer.getBudgetRemaining()); - + if (!isConfirmed) footerWidget.setConfirmEnabled(kingPlaced); showStatus(kingPlaced ? (isConfirmed ? "Setup confirmed! Press Unconfirm to make changes." : "Ready! Press Confirm when done.") : "Place your King to continue."); @@ -436,9 +636,11 @@ private void showStatus(String msg) { @Override public void show() { try { - Gdx.app.log("SetupScreen", "Screen shown"); + isScreenActive = true; Gdx.input.setInputProcessor( new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + networkHandler.start(); + startHeartbeatTimer(); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error in show: " + e.getMessage(), e); } @@ -461,16 +663,32 @@ public void resize(int width, int height) { } @Override - public void pause() {} + public void pause() { + isScreenActive = false; + } @Override - public void resume() {} + public void resume() { + isScreenActive = true; + if (!gameCancelledShown) networkHandler.sendHeartbeat(); + } @Override public void hide() { try { - Gdx.app.log("SetupScreen", "Screen hidden"); + isScreenActive = false; inputHandler.clearObservers(); + stopReconnectCountdown(); + stopHeartbeatTimer(); + // If we're transitioning to GameScreen, stopForTransition() was already + // called and GameNetworkHandler.start() has already registered its own + // listeners on the same FirebaseAPI instance. Calling stop() here would + // invoke removeAllListeners() and immediately tear down those game + // listeners, causing both devices to see a disconnect and start their + // 10-second grace timers before the game even begins. + if (!transitioningToGame) { + networkHandler.stop(); + } setupState.exit(); } catch (Exception e) { Gdx.app.error("SetupScreen", "Error in hide: " + e.getMessage(), e); @@ -480,7 +698,8 @@ public void hide() { @Override public void dispose() { try { - Gdx.app.log("SetupScreen", "Screen disposed"); + stopReconnectCountdown(); + stopHeartbeatTimer(); stage.dispose(); boardRenderer.dispose(); } catch (Exception e) {