From 29afd29ee0744dabaf03e394a49f2a78c2e05f16 Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:32:16 +0100 Subject: [PATCH 01/14] feat(AndroidFirebase): Complete Firebase multiplayer implementation - Implement lobby creation/joining with Firebase - Add confirmSetup with board encoding and bothReady synchronization - Implement saveMove and listenForOpponentMove with player filtering - Add getOpponentBoard for retrieving enemy piece positions - Add listenForGameStart for synchronized game transition - Store boards as flat list of {col, row, piece} entries --- .../android/AndroidFirebase.java | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java diff --git a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java new file mode 100644 index 0000000..0c6ac9f --- /dev/null +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java @@ -0,0 +1,256 @@ +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.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 DatabaseReference db = FirebaseDatabase.getInstance().getReference(); + + // ── API (Legacy/General) ────────────────────────────────────────────────── + + @Override + public void createLobby() { + db.child("lobbies").push().setValue(1); + } + + // ── 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 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 = snapshot.child("boardSize").getValue(Integer.class); + int budget = snapshot.child("budget").getValue(Integer.class); + + // Mark player two as joined — this is what listenForOpponentReady() watches + db.child("lobbies").child(gameId).child("status").setValue("ready"); + + onSuccess.call(new Lobby(gameId, boardSize, budget, + System.currentTimeMillis() + 30 * 60 * 1000L)); + }) + .addOnFailureListener(e -> onError.call(e.getMessage())); + } + + /** + * Host calls this to detect when the second player has joined the lobby. + * Fires once when lobby status changes to "ready". + * NOTE: This is for LobbyScreen only. SetupScreen uses listenForGameStart(). + */ + @Override + public void listenForOpponentReady(String gameId, Runnable onReady) { + db.child("lobbies").child(gameId).child("status") + .addValueEventListener(new ValueEventListener() { + @Override + public void onDataChange(DataSnapshot snapshot) { + String status = snapshot.getValue(String.class); + if ("ready".equals(status)) { + snapshot.getRef().removeEventListener(this); + onReady.run(); + } + } + @Override public void onCancelled(DatabaseError e) {} + }); + } + + // ── Setup ───────────────────────────────────────────────────────────────── + + /** + * Saves this player's board layout to Firebase and marks them as ready. + * When both players are ready, sets games/{gameId}/bothReady = true, + * which triggers listenForGameStart() on both clients. + * + * Board is stored as a flat list of maps: [{col, row, pieceCode}, ...] + */ + @Override + public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess) { + String player = isWhite ? "white" : "black"; + DatabaseReference gameRef = db.child("games").child(gameId); + + // Encode board as a list of {col, row, piece} maps for easy Firebase storage + 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); + } + } + } + + // Save board layout for this player + gameRef.child("boards").child(player).setValue(pieces) + .addOnSuccessListener(v1 -> { + // Mark this player as ready + gameRef.child("setup").child(player).setValue("ready") + .addOnSuccessListener(v2 -> { + // Check if both players are now ready + 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) { + // Both ready — signal game start for both clients + gameRef.child("bothReady").setValue(true); + } + onSuccess.run(); + } + @Override + public void onCancelled(DatabaseError error) { + onSuccess.run(); + } + }); + }); + }) + .addOnFailureListener(e -> onSuccess.run()); // still proceed even if save fails + } + + /** + * Fetches the opponent's stored board layout from Firebase. + * Returns int[boardSize][boardSize] decoded from the stored piece list. + * Called by GameScreen after both players confirm setup. + */ + @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; + } + + // Find board dimensions from the data + int maxCol = 0, maxRow = 0; + for (DataSnapshot entry : snapshot.getChildren()) { + int col = getInt(entry, "col"); + int row = getInt(entry, "row"); + if (col > maxCol) maxCol = col; + if (row > maxRow) maxRow = row; + } + int size = Math.max(maxCol, maxRow) + 1; + int[][] board = new int[size][size]; + + for (DataSnapshot entry : snapshot.getChildren()) { + int col = getInt(entry, "col"); + int row = getInt(entry, "row"); + int piece = getInt(entry, "piece"); + board[col][row] = piece; + } + + onBoard.call(board); + }) + .addOnFailureListener(e -> onBoard.call(new int[0][0])); + } + + // ── Moves ───────────────────────────────────────────────────────────────── + + /** + * Saves a move to Firebase. Includes the "player" field ("white"/"black") + * so the opponent's listener can filter out its own echoed 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()); + + db.child("games").child(gameId).child("moves").push() + .setValue(data) + .addOnSuccessListener(v -> onSuccess.run()); + } + + /** + * Listens for new moves from Firebase. + * Returns int[]{fromCol, fromRow, toCol, toRow, isWhite(1=white / 0=black)} + * so the caller can filter out its own moves by comparing isWhite to localPlayer.isWhite(). + */ + @Override + public void listenForOpponentMove(String gameId, Callback onMove) { + db.child("games").child(gameId).child("moves") + .addChildEventListener(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}); + } + @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) {} + }); + } + + /** + * Fires once when games/{gameId}/bothReady == true. + * Used by BOTH players in SetupScreen to navigate to GameScreen simultaneously. + */ + @Override + public void listenForGameStart(String gameId, Runnable onStart) { + db.child("games").child(gameId).child("bothReady") + .addValueEventListener(new ValueEventListener() { + @Override + public void onDataChange(DataSnapshot snapshot) { + Boolean bothReady = snapshot.getValue(Boolean.class); + if (bothReady != null && bothReady) { + snapshot.getRef().removeEventListener(this); + onStart.run(); + } + } + @Override public void onCancelled(DatabaseError error) {} + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // 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; + } +} \ No newline at end of file From 90475b111296038423db65077c73db965aad6a86 Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:32:41 +0100 Subject: [PATCH 02/14] feat(AndroidLauncher): Initialize Firebase bridge on app launch - Create AndroidFirebase instance and inject into DatabaseManager singleton - Pass Firebase implementation to Main game instance for multiplayer support - Enable immersive mode for full-screen gameplay --- .../regicidechess/android/AndroidLauncher.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java b/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java index 8a8371a..6a9fcde 100644 --- a/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidLauncher.java @@ -5,15 +5,20 @@ import com.badlogic.gdx.backends.android.AndroidApplication; import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration; import com.group14.regicidechess.Main; +import com.group14.regicidechess.database.DatabaseManager; /** Launches the Android application. */ public class AndroidLauncher extends AndroidApplication { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - AndroidApplicationConfiguration configuration = new AndroidApplicationConfiguration(); - configuration.useImmersiveMode = true; // Recommended, but not required. - initialize(new Main(new AndroidAPI()), configuration); + // Initialise Firebase bridge before starting the game + AndroidFirebase firebase = new AndroidFirebase(); + DatabaseManager.getInstance().init(firebase); + + AndroidApplicationConfiguration configuration = new AndroidApplicationConfiguration(); + configuration.useImmersiveMode = true; + initialize(new Main(firebase), configuration); } -} +} \ No newline at end of file From f86d6b56d916f81f98c6b4fd283df5f36dfc2be0 Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:33:48 +0100 Subject: [PATCH 03/14] feat(DatabaseManager): Add singleton manager for Firebase API access - Implement thread-safe singleton pattern for database management - Provide init method for FirebaseAPI injection from AndroidLauncher - Add getApi method for accessing Firebase functionality across screens --- .../database/DatabaseManager.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java diff --git a/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java b/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java new file mode 100644 index 0000000..d7f4b7c --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java @@ -0,0 +1,23 @@ +// core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java +package com.group14.regicidechess.database; + +public class DatabaseManager { + + private static DatabaseManager instance; + private FirebaseAPI api; + + private DatabaseManager() {} + + public static DatabaseManager getInstance() { + if (instance == null) instance = new DatabaseManager(); + return instance; + } + + public void init(FirebaseAPI api) { + this.api = api; + } + + public FirebaseAPI getApi() { + return api; + } +} \ No newline at end of file From 48203380f8cd60ae03d913a43d4f63c3245a2ee0 Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:34:24 +0100 Subject: [PATCH 04/14] feat(FirebaseAPI): Define complete Firebase interface for multiplayer - Add confirmSetup with board encoding for setup phase synchronization - Add getOpponentBoard for retrieving opponent piece positions - Add saveMove with player identification for turn tracking - Add listenForOpponentMove with isWhite field for move filtering - Add listenForOpponentReady for lobby synchronization - Add listenForGameStart for simultaneous game transition - Define generic Callback interface for async operations --- .../regicidechess/database/FirebaseAPI.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java diff --git a/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java new file mode 100644 index 0000000..affbcac --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java @@ -0,0 +1,45 @@ +package com.group14.regicidechess.database; + +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.model.Move; + +public interface FirebaseAPI { + void createLobby(Lobby lobby, Callback onSuccess, Callback onError); + void joinLobby(String gameId, Callback onSuccess, Callback onError); + + /** + * Saves this player's board layout and marks them as ready. + * When both players are ready, sets games/{gameId}/bothReady = true. + */ + void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess); + + /** + * Fetches the opponent's board layout from Firebase. + * Returns a 2D int array encoded with getPieceTypeCode values. + */ + void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard); + + void saveMove(String gameId, Move move, Runnable onSuccess); + + /** + * Listens for new moves. Calls onMove with int[]{fromCol, fromRow, toCol, toRow, isWhite(1/0)}. + * The isWhite field (index 4) lets the caller filter out its own moves. + */ + void listenForOpponentMove(String gameId, Callback onMove); + + /** + * Fires once when lobby status changes to "ready" (second player joined). + * Used by the HOST in LobbyScreen to detect the joiner. + */ + void listenForOpponentReady(String gameId, Runnable onReady); + + /** + * Fires once when games/{gameId}/bothReady == true. + * Used by BOTH players in SetupScreen to navigate to GameScreen together. + */ + void listenForGameStart(String gameId, Runnable onStart); + + interface Callback { + void call(T value); + } +} \ No newline at end of file From b1d5e99bda9e5c09404f42882e179d92aae261bf Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:35:35 +0100 Subject: [PATCH 05/14] Update GameScreen to implement firebase and multiplayer support --- .../regicidechess/screens/GameScreen.java | 95 +++++++++++++++---- 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java index 70866fe..d167fcc 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java @@ -18,8 +18,10 @@ 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.DatabaseManager; import com.group14.regicidechess.input.ScreenInputHandler; import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Move; import com.group14.regicidechess.model.Player; import com.group14.regicidechess.model.pieces.ChessPiece; import com.group14.regicidechess.model.pieces.King; @@ -48,6 +50,7 @@ public class GameScreen implements Screen, ScreenInputHandler.ScreenInputObserve // ── Game Logic layer references ─────────────────────────────────────────── private final InMatchState inMatchState; private final Player localPlayer; + private final String gameId; // ── Selection FSM ───────────────────────────────────────────────────────── private Vector2 selectedCell = null; @@ -63,26 +66,27 @@ public class GameScreen implements Screen, ScreenInputHandler.ScreenInputObserve private Label statusLabel; // ── Overlay (forfeit confirm / game over) — built once, shown/hidden ────── + private Table overlayWrapper; private Table overlayTable; - private Table overlayWrapper; // wraps dimmer + card, hidden by default private Label overlayTitle; private Label overlayBody; private TextButton overlayConfirmBtn; private TextButton overlayCancelBtn; - - // What the confirm button should do — set before showing overlay - private Runnable onOverlayConfirm; + private Runnable onOverlayConfirm; // ───────────────────────────────────────────────────────────────────────── // Constructor // ───────────────────────────────────────────────────────────────────────── public GameScreen(Game game, SpriteBatch batch, - Board board, Player localPlayer, int boardSize) { + Board board, Player localPlayer, int boardSize, String gameId) { this.game = game; this.batch = batch; this.localPlayer = localPlayer; + this.gameId = gameId; + // Opponent is derived from localPlayer — board already contains both sides' + // pieces (merged in SetupScreen.navigateToGame before this screen is created) Player opponent = new Player( localPlayer.isWhite() ? "player2" : "player1", !localPlayer.isWhite(), @@ -94,6 +98,9 @@ public GameScreen(Game game, SpriteBatch batch, localPlayer.isWhite() ? opponent : localPlayer); inMatchState.enter(); + // Start listening for opponent moves BEFORE building UI so no events are missed + startOpponentMoveListener(); + stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); skin = ResourceManager.getInstance().getSkin(); shapeRenderer = new ShapeRenderer(); @@ -148,19 +155,15 @@ private void buildUI() { root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); - // ── Overlay (shared by forfeit confirm + game over) ─────────────────── buildOverlay(); } private void buildOverlay() { - // overlayWrapper covers the full screen — centres the card. - // No background here; dimmer is drawn via ShapeRenderer in render(). overlayWrapper = new Table(); overlayWrapper.setFillParent(true); overlayWrapper.setVisible(false); stage.addActor(overlayWrapper); - // Centred card inside the wrapper overlayTable = new Table(); overlayTable.setBackground(skin.getDrawable("surface-pixel")); overlayTable.pad(32); @@ -183,10 +186,8 @@ private void buildOverlay() { btnRow.add(overlayConfirmBtn).width(130).height(55); overlayTable.add(btnRow).row(); - // Centre the card inside the full-screen wrapper overlayWrapper.add(overlayTable).center(); - // Wire listeners once — behaviour set via onOverlayConfirm overlayConfirmBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { overlayWrapper.setVisible(false); @@ -249,7 +250,6 @@ public void render(float delta) { stage.act(delta); stage.draw(); - // Draw semi-transparent dimmer on top of board but below the overlay card if (overlayWrapper.isVisible()) { Gdx.gl.glEnable(GL20.GL_BLEND); Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); @@ -259,7 +259,6 @@ public void render(float delta) { shapeRenderer.rect(0, 0, V_WIDTH, V_HEIGHT); shapeRenderer.end(); Gdx.gl.glDisable(GL20.GL_BLEND); - // Redraw stage so the overlay card appears on top of the dimmer stage.draw(); } } @@ -342,7 +341,6 @@ private void drawBoard() { @Override public void onTap(int screenX, int screenY, int pointer, int button) { - // Ignore board taps when an overlay is visible if (overlayWrapper.isVisible()) return; Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); handleBoardTap(world.x, world.y); @@ -407,28 +405,87 @@ private void deselect() { } private void executeMove(Vector2 from, Vector2 to) { + if (!inMatchState.isMyTurn(localPlayer)) { + showStatus("Not your turn!"); + deselect(); + return; + } + + // Capture the moving piece reference BEFORE executing the move, + // because executeMove() updates the board and the piece's position. + ChessPiece movingPiece = inMatchState.getBoard().getPieceAt(from); + if (movingPiece == null) { + deselect(); + return; + } + ChessPiece captured = inMatchState.executeMove(from, to); deselect(); refreshTurnLabel(); - // TODO: DatabaseManager.getInstance().saveMove(...) + // Save to Firebase using the piece we captured before the board update + DatabaseManager.getInstance().getApi() + .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> { + // Move saved — no action needed + }); if (captured instanceof King) { showGameOverOverlay(true); } else { - showStatus(inMatchState.isMyTurn(localPlayer) - ? "Your turn!" - : "Waiting for opponent..."); + showStatus("Waiting for opponent..."); } } + // ───────────────────────────────────────────────────────────────────────── + // Opponent move listener + // ───────────────────────────────────────────────────────────────────────── + + /** + * Subscribes to Firebase move events. + * + * AndroidFirebase now sends int[]{fromCol, fromRow, toCol, toRow, isWhite(1/0)}. + * We filter by the isWhite flag (index 4) to ignore our own echoed moves. + * This is much more reliable than filtering by isMyTurn(), which could be + * wrong if events arrive out of order or when the listener first attaches + * and replays existing children. + */ + private void startOpponentMoveListener() { + DatabaseManager.getInstance().getApi() + .listenForOpponentMove(gameId, coords -> { + boolean moveIsWhite = (coords.length > 4) && (coords[4] == 1); + + // Ignore moves that belong to us + if (moveIsWhite == localPlayer.isWhite()) return; + + Gdx.app.postRunnable(() -> { + Vector2 from = new Vector2(coords[0], coords[1]); + Vector2 to = new Vector2(coords[2], coords[3]); + + // Safety check: the piece at 'from' must belong to the opponent + ChessPiece piece = inMatchState.getBoard().getPieceAt(from); + if (piece == null || piece.getOwner() == localPlayer) return; + + ChessPiece captured = inMatchState.executeMove(from, to); + deselect(); + refreshTurnLabel(); + + if (captured instanceof King) { + showGameOverOverlay(false); + } else { + showStatus("Your turn!"); + } + }); + }); + } + // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private void refreshTurnLabel() { Player current = inMatchState.getCurrentTurn(); - turnLabel.setText("Turn: " + (current.isWhite() ? "White" : "Black")); + boolean myTurn = current == localPlayer; + turnLabel.setText(myTurn ? "Your turn" : "Opponent's turn"); } private void showStatus(String msg) { statusLabel.setText(msg); } From 6ddcfb99aa9932f6e2b7b580e6423086e21cfb2a Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:35:42 +0100 Subject: [PATCH 06/14] Update LobbyScreen to implement firebase and multiplayer support --- .../regicidechess/screens/LobbyScreen.java | 176 +++++++----------- 1 file changed, 72 insertions(+), 104 deletions(-) diff --git a/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java b/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java index 87dd412..9e6af43 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java @@ -19,14 +19,6 @@ import com.group14.regicidechess.states.LobbyState; import com.group14.regicidechess.utils.ResourceManager; -/** - * LobbyScreen — GUI layer for lobby creation and joining. - * - * Placement: core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java - * - * Owns: rendering, sliders, button listeners. - * Delegates: Game ID generation, player creation, lobby config → LobbyState. - */ public class LobbyScreen implements Screen, ScreenInputHandler.ScreenInputObserver { public enum Mode { HOST, JOIN } @@ -43,31 +35,23 @@ public enum Mode { HOST, JOIN } private static final int BUDGET_DEFAULT = 25; private static final int BUDGET_STEP = 1; - // ── LibGDX / GUI fields ─────────────────────────────────────────────────── private final Game game; private final SpriteBatch batch; private final Mode mode; - private final String incomingGameId; // non-null in JOIN mode + private final String incomingGameId; private final Stage stage; private final Skin skin; private final ScreenInputHandler inputHandler; + private final LobbyState lobbyState; - // ── Game Logic layer reference ──────────────────────────────────────────── - private final LobbyState lobbyState; - - // ── Slider values (HOST mode — read into LobbyState on confirm) ────────── private int boardSize = BOARD_DEFAULT; private int budget = BUDGET_DEFAULT; - // ── Scene2D labels that update at runtime ───────────────────────────────── - private Label boardSizeValueLabel; - private Label budgetValueLabel; - private Label statusLabel; - - // ───────────────────────────────────────────────────────────────────────── - // Constructor - // ───────────────────────────────────────────────────────────────────────── + private Label boardSizeValueLabel; + private Label budgetValueLabel; + private Label statusLabel; + private TextButton confirmBtn; // kept so we can disable it while waiting public LobbyScreen(Game game, SpriteBatch batch, Mode mode, String gameId) { this.game = game; @@ -75,7 +59,6 @@ public LobbyScreen(Game game, SpriteBatch batch, Mode mode, String gameId) { this.mode = mode; this.incomingGameId = gameId; - // Initialise Game Logic state lobbyState = new LobbyState(); lobbyState.enter(); @@ -99,16 +82,13 @@ private void buildUI() { root.top().pad(32); stage.addActor(root); - String titleText = (mode == Mode.HOST) ? "Create Lobby" : "Join Lobby"; - Label titleLabel = new Label(titleText, skin, "title"); + Label titleLabel = new Label(mode == Mode.HOST ? "Create Lobby" : "Join Lobby", + skin, "title"); titleLabel.setAlignment(Align.center); root.add(titleLabel).expandX().padBottom(32).row(); - if (mode == Mode.HOST) { - buildHostUI(root); - } else { - buildJoinUI(root); - } + if (mode == Mode.HOST) buildHostUI(root); + else buildJoinUI(root); TextButton backBtn = new TextButton("Back", skin, "default"); backBtn.addListener(new ChangeListener() { @@ -119,10 +99,7 @@ private void buildUI() { root.add(backBtn).width(200).height(50).padTop(24).row(); } - // ── Host UI ─────────────────────────────────────────────────────────────── - private void buildHostUI(Table root) { - // Board size slider Label boardSizeLabel = new Label("Board Size", skin); boardSizeValueLabel = new Label(boardSize + " x " + boardSize, skin); @@ -135,7 +112,6 @@ private void buildHostUI(Table root) { } }); - // Budget slider Label budgetLabel = new Label("Starting Budget", skin); budgetValueLabel = new Label(String.valueOf(budget), skin); @@ -148,23 +124,24 @@ private void buildHostUI(Table root) { } }); - // Confirm — delegates to LobbyState, then navigates - TextButton confirmBtn = new TextButton("Start Game", skin, "accent"); + confirmBtn = new TextButton("Start Game", skin, "accent"); confirmBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - onHostConfirm(); + if (!confirmBtn.isDisabled()) onHostConfirm(); } }); + statusLabel = new Label("", skin, "small"); + statusLabel.setAlignment(Align.center); + root.add(buildRow(boardSizeLabel, boardSizeValueLabel)).expandX().fillX().padBottom(8).row(); root.add(boardSlider).width(380).height(40).padBottom(24).row(); root.add(buildRow(budgetLabel, budgetValueLabel)).expandX().fillX().padBottom(8).row(); root.add(budgetSlider).width(380).height(40).padBottom(32).row(); - root.add(confirmBtn).width(280).height(60).padBottom(24).row(); + root.add(confirmBtn).width(280).height(60).padBottom(16).row(); + root.add(statusLabel).expandX().row(); } - // ── Join UI ─────────────────────────────────────────────────────────────── - private void buildJoinUI(Table root) { Label enteredLabel = new Label("Game ID: " + incomingGameId, skin, "title"); enteredLabel.setAlignment(Align.center); @@ -178,83 +155,85 @@ private void buildJoinUI(Table root) { statusLabel = new Label("", skin, "small"); statusLabel.setAlignment(Align.center); - TextButton joinBtn = new TextButton("Join Match", skin, "accent"); - joinBtn.addListener(new ChangeListener() { + confirmBtn = new TextButton("Join Match", skin, "accent"); + confirmBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - onJoinConfirm(); + if (!confirmBtn.isDisabled()) onJoinConfirm(); } }); - root.add(joinBtn).width(280).height(60).padBottom(16).row(); + root.add(confirmBtn).width(280).height(60).padBottom(16).row(); root.add(statusLabel).expandX().row(); } // ───────────────────────────────────────────────────────────────────────── - // Actions — delegate to LobbyState + // Actions // ───────────────────────────────────────────────────────────────────────── - /** - * HOST: asks LobbyState to generate the lobby (Game ID + Player One), - * then navigates to SetupScreen with the real lobby config. - */ private void onHostConfirm() { - // LobbyState creates the Lobby and Player One - String generatedId = lobbyState.generateGameId(boardSize, budget); - Gdx.app.log("LobbyScreen", "Lobby created: " + generatedId - + " size=" + boardSize + " budget=" + budget); - - // TODO: DatabaseManager.getInstance().saveLobby(lobbyState.getLobby(), () -> { - // navigateToSetup(true); - // }); - - navigateToSetup(true); + confirmBtn.setDisabled(true); + setStatus("Creating lobby..."); + + lobbyState.createLobby(boardSize, budget, + // onSuccess — runs on LibGDX thread + () -> { + String id = lobbyState.getLobby().getGameId(); + setStatus("Lobby created! ID: " + id + "\nWaiting for opponent..."); + listenForOpponent(id); + }, + // onError + () -> { + setStatus("Failed to create lobby. Try again."); + confirmBtn.setDisabled(false); + }); } - /** - * JOIN: asks LobbyState to fetch the lobby from the server, - * then navigates to SetupScreen as Player Two. - */ private void onJoinConfirm() { - if (statusLabel != null) statusLabel.setText("Connecting..."); - - // LobbyState resolves lobby settings from Firebase (stub for now) - lobbyState.joinByGameId(incomingGameId); - - // TODO: wait for async fetch; for now navigate with stub defaults - // The real flow: lobbyState.joinByGameId fetches from DatabaseManager, - // updates lobbyState.getLobby(), then we call navigateToSetup(false). - navigateToSetup(false); + confirmBtn.setDisabled(true); + setStatus("Connecting..."); + + lobbyState.joinLobby(incomingGameId, + // onSuccess + () -> { + boardSizeValueLabel.setText("Board: " + lobbyState.getLobby().getBoardSize() + + " x " + lobbyState.getLobby().getBoardSize()); + budgetValueLabel.setText("Budget: " + lobbyState.getLobby().getBudget()); + navigateToSetup(false); + }, + // onError + () -> { + setStatus("Lobby not found. Check the Game ID."); + confirmBtn.setDisabled(false); + }); } /** - * Navigates to SetupScreen, reading board config from LobbyState.getLobby(). - * Falls back to slider values if the lobby isn't populated yet (stub path). + * Listens for a second player joining the lobby, then navigates to setup. + * Firebase listener fires on the LibGDX thread via postRunnable in LobbyState. */ + private void listenForOpponent(String gameId) { + com.group14.regicidechess.database.DatabaseManager.getInstance().getApi() + .listenForOpponentReady(gameId, () -> + Gdx.app.postRunnable(() -> navigateToSetup(true))); + } + private void navigateToSetup(boolean isHost) { - int size; - int bud; - - if (lobbyState.getLobby() != null) { - // Game Logic layer has the authoritative values - size = lobbyState.getLobby().getBoardSize(); - bud = lobbyState.getLobby().getBudget(); - } else { - // Fallback for join stub (lobby not yet fetched) - size = BOARD_DEFAULT; - bud = BUDGET_DEFAULT; - } - - String gameId = lobbyState.getLobby() != null - ? lobbyState.getLobby().getGameId() - : (incomingGameId != null ? incomingGameId : "LOCAL"); - - game.setScreen(new SetupScreen(game, batch, gameId, size, bud, isHost)); + int size = lobbyState.getLobby() != null ? lobbyState.getLobby().getBoardSize() : BOARD_DEFAULT; + int bud = lobbyState.getLobby() != null ? lobbyState.getLobby().getBudget() : BUDGET_DEFAULT; + String id = lobbyState.getLobby() != null ? lobbyState.getLobby().getGameId() + : (incomingGameId != null ? incomingGameId : "LOCAL"); + + game.setScreen(new SetupScreen(game, batch, id, size, bud, isHost)); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── + private void setStatus(String msg) { + if (statusLabel != null) statusLabel.setText(msg); + } + private Table buildRow(Label left, Label right) { Table row = new Table(); row.add(left).expandX().left(); @@ -269,19 +248,11 @@ private Slider.SliderStyle buildSliderStyle() { return style; } - // ───────────────────────────────────────────────────────────────────────── - // ScreenInputObserver — all input handled by Scene2D widgets on this screen - // ───────────────────────────────────────────────────────────────────────── - @Override public void onTap (int x, int y, 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) {} - // ───────────────────────────────────────────────────────────────────────── - // Screen lifecycle - // ───────────────────────────────────────────────────────────────────────── - @Override public void show() { com.badlogic.gdx.InputMultiplexer mx = @@ -303,8 +274,5 @@ public void render(float delta) { @Override public void resume() {} @Override public void hide() { inputHandler.clearObservers(); lobbyState.exit(); } - @Override - public void dispose() { - stage.dispose(); - } + @Override public void dispose() { stage.dispose(); } } \ No newline at end of file From e2b03180b6117a4e2c67d3fd39a2024abe386b9b Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:35:51 +0100 Subject: [PATCH 07/14] Update SetupScreen to implement firebase and multiplayer support --- .../regicidechess/screens/SetupScreen.java | 166 +++++++++++++++--- 1 file changed, 142 insertions(+), 24 deletions(-) diff --git a/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java b/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java index e759a24..c8c423b 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java @@ -19,6 +19,7 @@ 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.DatabaseManager; import com.group14.regicidechess.input.ScreenInputHandler; import com.group14.regicidechess.model.Player; import com.group14.regicidechess.model.pieces.Bishop; @@ -33,6 +34,15 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + // ── Piece type codes (used for Firebase serialisation) ──────────────────── + // Must match decodePiece() in GameScreen + public static final int CODE_KING = 1; + public static final int CODE_QUEEN = 2; + public static final int CODE_ROOK = 3; + public static final int CODE_BISHOP = 4; + public static final int CODE_KNIGHT = 5; + public static final int CODE_PAWN = 6; + // ── Layout constants ────────────────────────────────────────────────────── private static final float V_WIDTH = 480f; private static final float V_HEIGHT = 854f; @@ -41,8 +51,8 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv private static final float FOOTER_HEIGHT = 70f; // ── Piece display data ──────────────────────────────────────────────────── - private static final String[] PIECE_NAMES = { "King", "Queen", "Rook", "Bishop", "Knight", "Pawn" }; - private static final int[] PIECE_COSTS = { 0, 9, 5, 3, 3, 1 }; + private static final String[] PIECE_NAMES = { "King", "Queen", "Rook", "Bishop", "Knight", "Pawn" }; + private static final int[] PIECE_COSTS = { 0, 9, 5, 3, 3, 1 }; // ── LibGDX / GUI fields ─────────────────────────────────────────────────── private final Game game; @@ -55,6 +65,8 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv // ── Game Logic layer reference ──────────────────────────────────────────── private final SetupState setupState; private final Player localPlayer; + private final String gameId; + private final boolean isHost; // ── Palette selection (GUI-only state) ──────────────────────────────────── private int selectedPieceIndex = -1; @@ -69,6 +81,7 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv private Label budgetLabel; private Label statusLabel; private TextButton confirmBtn; + private Label waitingLabel; // ───────────────────────────────────────────────────────────────────────── // Constructor @@ -76,8 +89,10 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv 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; localPlayer = new Player(isHost ? "player1" : "player2", isHost, budget); @@ -131,7 +146,6 @@ private void buildUI() { for (int i = 0; i < PIECE_NAMES.length; i++) { final int idx = i; - // Two blank lines at top — sprite will be drawn there by drawPaletteSprites() String btnLabel = "\n\n" + PIECE_NAMES[i] + "\n[" + (PIECE_COSTS[i] == 0 ? "free" : PIECE_COSTS[i]) + "]"; TextButton btn = new TextButton(btnLabel, skin, "default"); @@ -162,6 +176,11 @@ private void buildUI() { statusLabel = new Label("Place your King to continue", skin, "small"); statusLabel.setAlignment(Align.center); + // Waiting label — shown after confirm while we wait for the other player + waitingLabel = new Label("Waiting for opponent...", skin, "small"); + waitingLabel.setAlignment(Align.center); + waitingLabel.setVisible(false); + clearBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { onClear(); } }); @@ -175,6 +194,9 @@ private void buildUI() { footer.add(statusLabel).expandX(); footer.add(confirmBtn).width(140).height(50).expandX().right(); root.add(footer).expandX().fillX().height(FOOTER_HEIGHT).row(); + + // Waiting label sits below the footer + root.add(waitingLabel).expandX().padTop(8).row(); } // ───────────────────────────────────────────────────────────────────────── @@ -185,7 +207,6 @@ private void computeBoardGeometry(float screenW, float screenH) { float available = screenH - HEADER_HEIGHT - PALETTE_HEIGHT - FOOTER_HEIGHT - 16f; float maxW = screenW - 16f; int size = setupState.getBoardSize(); - cellSize = Math.min(maxW / size, available / size); boardLeft = (screenW - cellSize * size) / 2f; boardBottom = FOOTER_HEIGHT + PALETTE_HEIGHT + (available - cellSize * size) / 2f + 8f; @@ -203,7 +224,7 @@ public void render(float delta) { drawBoard(); stage.act(delta); stage.draw(); - drawPaletteSprites(); // drawn after stage so sprites appear on top of buttons + drawPaletteSprites(); } private void drawBoard() { @@ -277,12 +298,6 @@ private void drawBoard() { batch.end(); } - /** - * Draws piece sprites on top of the palette buttons. - * Called after stage.draw() so sprites appear above the button backgrounds. - * Uses localToStageCoordinates() to correctly resolve nested actor positions - * (buttons live inside ScrollPane -> Table -> Table). - */ private void drawPaletteSprites() { String color = localPlayer.isWhite() ? "white" : "black"; @@ -297,15 +312,13 @@ private void drawPaletteSprites() { float btnW = btn.getWidth(); float btnH = btn.getHeight(); - // Resolve button origin to stage coordinates Vector2 stagePos = btn.localToStageCoordinates(new Vector2(0, 0)); float btnX = stagePos.x; float btnY = stagePos.y; - // Sprite sits in the top portion of the button, above the text float spriteSize = Math.min(btnW, btnH) * 0.42f; float spriteX = btnX + (btnW - spriteSize) / 2f; - float spriteY = btnY + btnH - spriteSize - 4f; // 4px padding from top edge + float spriteY = btnY + btnH - spriteSize - 4f; batch.draw(tex, spriteX, spriteY, spriteSize, spriteSize); } @@ -343,7 +356,6 @@ private void handleBoardTap(float worldX, float worldY) { ChessPiece piece = createPiece(selectedPieceIndex); boolean ok = setupState.placePiece(piece, col, row); if (!ok) { - // Give specific feedback for king limit vs other failures if (piece instanceof King && kingIsOnBoard()) { showStatus("You can only place one King!"); } else { @@ -395,16 +407,122 @@ private void onConfirm() { showStatus("Place your King before confirming."); return; } - Gdx.app.log("SetupScreen", "Setup confirmed."); - // TODO: DatabaseManager.getInstance().confirmSetup(...) - navigateToGame(); + + // Lock UI while waiting + confirmBtn.setDisabled(true); + confirmBtn.setVisible(false); + waitingLabel.setVisible(true); + showStatus("Waiting for opponent to finish setup..."); + + // Serialize our board and send to Firebase + int[][] boardState = convertBoardToArray(); + DatabaseManager.getInstance().getApi().confirmSetup( + gameId, + localPlayer.isWhite(), + boardState, + () -> Gdx.app.postRunnable(() -> { + // Both players now use listenForGameStart — it fires when Firebase + // sets bothReady=true, which only happens once BOTH have confirmed. + listenForGameStart(); + }) + ); + } + + /** + * Both host and joiner call this after confirming setup. + * Navigates to GameScreen when Firebase signals bothReady = true. + */ + private void listenForGameStart() { + DatabaseManager.getInstance().getApi().listenForGameStart( + gameId, + () -> Gdx.app.postRunnable(this::navigateToGame) + ); } private void navigateToGame() { - game.setScreen(new GameScreen(game, batch, - setupState.getBoard(), - localPlayer, - setupState.getBoardSize())); + // Fetch opponent's board from Firebase, then merge and navigate + DatabaseManager.getInstance().getApi().getOpponentBoard( + gameId, + localPlayer.isWhite(), + opponentBoard -> Gdx.app.postRunnable(() -> { + // Place opponent's pieces onto our local board instance + mergeOpponentBoard(opponentBoard); + game.setScreen(new GameScreen( + game, batch, + setupState.getBoard(), + localPlayer, + setupState.getBoardSize(), + gameId)); + }) + ); + } + + /** + * Decodes the opponent's board (int[][]) and places their pieces onto + * setupState.getBoard() so GameScreen has a fully populated board. + */ + private void mergeOpponentBoard(int[][] opponentBoard) { + if (opponentBoard == null || opponentBoard.length == 0) return; + + // The opponent is always the other color + Player opponentPlayer = new Player( + localPlayer.isWhite() ? "player2" : "player1", + !localPlayer.isWhite(), + localPlayer.getBudget()); + + for (int col = 0; col < opponentBoard.length; col++) { + for (int row = 0; row < opponentBoard[col].length; row++) { + int code = opponentBoard[col][row]; + if (code == 0) continue; + ChessPiece piece = decodePiece(code, opponentPlayer); + if (piece != null) { + setupState.getBoard().placePiece(piece, col, row); + } + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Board serialisation helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Converts the current board to a 2D int array using piece type codes. + * 0 = empty, 1 = King, 2 = Queen, 3 = Rook, 4 = Bishop, 5 = Knight, 6 = Pawn + */ + private int[][] convertBoardToArray() { + int size = setupState.getBoardSize(); + int[][] boardState = new int[size][size]; + for (ChessPiece piece : setupState.getBoard().getPieces()) { + int col = (int) piece.getPosition().x; + int row = (int) piece.getPosition().y; + boardState[col][row] = getPieceTypeCode(piece); + } + return boardState; + } + + /** Maps a piece instance to its int code. */ + private int getPieceTypeCode(ChessPiece piece) { + if (piece instanceof King) return CODE_KING; + if (piece instanceof Queen) return CODE_QUEEN; + if (piece instanceof Rook) return CODE_ROOK; + if (piece instanceof Bishop) return CODE_BISHOP; + if (piece instanceof Knight) return CODE_KNIGHT; + if (piece instanceof Pawn) return CODE_PAWN; + return 0; + } + + /** Decodes an int code back into a ChessPiece for the given owner. */ + public static ChessPiece decodePiece(int code, Player owner) { + switch (code) { + case CODE_KING: return new King(owner); + case CODE_QUEEN: return new Queen(owner); + case CODE_ROOK: return new Rook(owner); + case CODE_BISHOP: return new Bishop(owner); + case CODE_KNIGHT: return new Knight(owner); + case CODE_PAWN: return new Pawn(owner); + default: return null; + } } // ───────────────────────────────────────────────────────────────────────── From 590d6b561454ada3cb17f27e62e4973d61909caf Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 14:36:01 +0100 Subject: [PATCH 08/14] Update LobbyState to implement firebase and multiplayer support --- .../regicidechess/states/LobbyState.java | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/com/group14/regicidechess/states/LobbyState.java b/core/src/main/java/com/group14/regicidechess/states/LobbyState.java index d56ea7e..5168bde 100644 --- a/core/src/main/java/com/group14/regicidechess/states/LobbyState.java +++ b/core/src/main/java/com/group14/regicidechess/states/LobbyState.java @@ -1,9 +1,9 @@ package com.group14.regicidechess.states; +import com.group14.regicidechess.database.DatabaseManager; import com.group14.regicidechess.model.Lobby; import com.group14.regicidechess.model.Player; -/** Placement: core/src/main/java/com/group14/regicidechess/states/LobbyState.java */ public class LobbyState extends GameState { private Lobby lobby; @@ -14,19 +14,50 @@ public class LobbyState extends GameState { @Override public void update(float delta) {} @Override public void exit() {} - public String generateGameId(int boardSize, int budget) { + /** + * Creates a lobby locally and persists it to Firebase. + * onSuccess is called on the LibGDX thread with the generated Game ID. + */ + public void createLobby(int boardSize, int budget, + Runnable onSuccess, Runnable onError) { lobby = new Lobby(boardSize, budget); playerOne = new Player("player1", true, budget); - return lobby.getGameId(); + + DatabaseManager.getInstance().getApi().createLobby(lobby, + gameId -> com.badlogic.gdx.Gdx.app.postRunnable(onSuccess), + err -> { + com.badlogic.gdx.Gdx.app.log("LobbyState", "createLobby error: " + err); + com.badlogic.gdx.Gdx.app.postRunnable(onError); + }); + } + + /** + * Fetches an existing lobby from Firebase by Game ID. + * onSuccess is called with the populated Lobby on the LibGDX thread. + */ + public void joinLobby(String gameId, + Runnable onSuccess, Runnable onError) { + DatabaseManager.getInstance().getApi().joinLobby(gameId, + fetchedLobby -> com.badlogic.gdx.Gdx.app.postRunnable(() -> { + lobby = fetchedLobby; + playerTwo = new Player("player2", false, fetchedLobby.getBudget()); + onSuccess.run(); + }), + err -> { + com.badlogic.gdx.Gdx.app.log("LobbyState", "joinLobby error: " + err); + com.badlogic.gdx.Gdx.app.postRunnable(onError); + }); } - public void joinByGameId(String id) { - // TODO: lobby = DatabaseManager.getInstance().fetchLobby(id); - // playerTwo = new Player("player2", false, lobby.getBudget()); + // Keep old stub method name for backwards compatibility (local-only testing) + public String generateGameId(int boardSize, int budget) { + lobby = new Lobby(boardSize, budget); + playerOne = new Player("player1", true, budget); + return lobby.getGameId(); } - public Lobby getLobby() { return lobby; } - public Player getPlayerOne() { return playerOne; } - public Player getPlayerTwo() { return playerTwo; } - public void setPlayerTwo(Player p) { this.playerTwo = p; } + public Lobby getLobby() { return lobby; } + public Player getPlayerOne() { return playerOne; } + public Player getPlayerTwo() { return playerTwo; } + public void setPlayerTwo(Player p) { this.playerTwo = p; } } \ No newline at end of file From e80ee8c1a9df61c2aa7227a872091d3cc153857c Mon Sep 17 00:00:00 2001 From: benjamls Date: Mon, 23 Mar 2026 22:16:35 +0100 Subject: [PATCH 09/14] Add Heartbeat and fix game logic --- .../android/AndroidFirebase.java | 235 +++++++++++----- .../database/DatabaseManager.java | 12 +- .../regicidechess/database/FirebaseAPI.java | 69 +++-- .../regicidechess/screens/GameScreen.java | 260 +++++++++--------- .../regicidechess/screens/LobbyScreen.java | 203 +++++++++++--- .../regicidechess/screens/MainMenuScreen.java | 125 +++------ .../regicidechess/screens/SetupScreen.java | 212 +++++++------- .../regicidechess/states/LobbyState.java | 8 + 8 files changed, 655 insertions(+), 469 deletions(-) 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 0c6ac9f..f6a3602 100644 --- a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java @@ -1,5 +1,8 @@ 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; @@ -20,7 +23,7 @@ public class AndroidFirebase implements FirebaseAPI, API { private final DatabaseReference db = FirebaseDatabase.getInstance().getReference(); - // ── API (Legacy/General) ────────────────────────────────────────────────── + // ── API (Legacy) ────────────────────────────────────────────────────────── @Override public void createLobby() { @@ -42,16 +45,37 @@ public void createLobby(Lobby lobby, Callback onSuccess, Callback onError.call(e.getMessage())); } + /** + * Reads lobby data with NO side effects. + * Used by MainMenuScreen to validate a Game ID before navigating. + */ + @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())); + } + + /** + * Reads lobby data AND marks the joiner as present (status = "joined"). + * The HOST's listenForOpponentReady() watches for this value. + */ @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 = snapshot.child("boardSize").getValue(Integer.class); - int budget = snapshot.child("budget").getValue(Integer.class); + int boardSize = getInt(snapshot, "boardSize"); + int budget = getInt(snapshot, "budget"); - // Mark player two as joined — this is what listenForOpponentReady() watches - db.child("lobbies").child(gameId).child("status").setValue("ready"); + // "joined" = joiner is present; does NOT start the game. + db.child("lobbies").child(gameId).child("status").setValue("joined"); onSuccess.call(new Lobby(gameId, boardSize, budget, System.currentTimeMillis() + 30 * 60 * 1000L)); @@ -60,9 +84,9 @@ public void joinLobby(String gameId, Callback onSuccess, Callback } /** - * Host calls this to detect when the second player has joined the lobby. - * Fires once when lobby status changes to "ready". - * NOTE: This is for LobbyScreen only. SetupScreen uses listenForGameStart(). + * Fires once when lobbies/{gameId}/status == "joined". + * HOST calls this — it means a second player is waiting in the lobby. + * Does NOT trigger game navigation, only enables the "Start Game" button. */ @Override public void listenForOpponentReady(String gameId, Runnable onReady) { @@ -70,8 +94,7 @@ public void listenForOpponentReady(String gameId, Runnable onReady) { .addValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot snapshot) { - String status = snapshot.getValue(String.class); - if ("ready".equals(status)) { + if ("joined".equals(snapshot.getValue(String.class))) { snapshot.getRef().removeEventListener(this); onReady.run(); } @@ -80,21 +103,48 @@ public void onDataChange(DataSnapshot snapshot) { }); } + /** + * Sets lobbies/{gameId}/status = "started". + * HOST calls this when pressing "Start Game". + * The joiner's listenForGameStart() watches for this value. + */ + @Override + public void startGame(String gameId) { + db.child("lobbies").child(gameId).child("status").setValue("started"); + } + + /** + * Fires once when lobbies/{gameId}/status == "started". + * JOINER calls this to know when the host has pressed "Start Game". + */ + @Override + public void listenForGameStart(String gameId, Runnable onStart) { + db.child("lobbies").child(gameId).child("status") + .addValueEventListener(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) {} + }); + } + // ── Setup ───────────────────────────────────────────────────────────────── /** - * Saves this player's board layout to Firebase and marks them as ready. - * When both players are ready, sets games/{gameId}/bothReady = true, - * which triggers listenForGameStart() on both clients. - * - * Board is stored as a flat list of maps: [{col, row, pieceCode}, ...] + * Saves this player's board layout and marks them as setup-ready. + * Board is stored as a list of {col, row, piece} objects. + * Once BOTH players are "ready", sets games/{gameId}/bothReady = true. + * This is watched by listenForBothSetupReady() — separate from the lobby path. */ @Override public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess) { String player = isWhite ? "white" : "black"; DatabaseReference gameRef = db.child("games").child(gameId); - // Encode board as a list of {col, row, piece} maps for easy Firebase storage List> pieces = new ArrayList<>(); for (int col = 0; col < board.length; col++) { for (int row = 0; row < board[col].length; row++) { @@ -108,79 +158,86 @@ public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable } } - // Save board layout for this player + // Write board first, then mark ready gameRef.child("boards").child(player).setValue(pieces) - .addOnSuccessListener(v1 -> { - // Mark this player as ready + .addOnSuccessListener(v1 -> gameRef.child("setup").child(player).setValue("ready") - .addOnSuccessListener(v2 -> { - // Check if both players are now 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) { - // Both ready — signal game start for both clients + // Both boards are written AND both players are ready gameRef.child("bothReady").setValue(true); } onSuccess.run(); } - @Override - public void onCancelled(DatabaseError error) { - onSuccess.run(); - } - }); - }); - }) - .addOnFailureListener(e -> onSuccess.run()); // still proceed even if save fails + @Override public void onCancelled(DatabaseError e) { onSuccess.run(); } + }) + ) + ) + .addOnFailureListener(e -> onSuccess.run()); } /** - * Fetches the opponent's stored board layout from Firebase. - * Returns int[boardSize][boardSize] decoded from the stored piece list. - * Called by GameScreen after both players confirm setup. + * Fetches the opponent's board from games/{gameId}/boards/{opponentColor}. + * Returns int[boardSize][boardSize] with piece type codes. */ @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() + db.child("games").child(gameId).child("boards").child(opponentKey).get() .addOnSuccessListener(snapshot -> { - if (!snapshot.exists()) { - onBoard.call(new int[0][0]); - return; - } - - // Find board dimensions from the data + if (!snapshot.exists()) { onBoard.call(new int[0][0]); return; } int maxCol = 0, maxRow = 0; for (DataSnapshot entry : snapshot.getChildren()) { - int col = getInt(entry, "col"); - int row = getInt(entry, "row"); - if (col > maxCol) maxCol = col; - if (row > maxRow) maxRow = row; + 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[][] board = new int[size][size]; - + int size = Math.max(maxCol, maxRow) + 1; + int[][] b = new int[size][size]; for (DataSnapshot entry : snapshot.getChildren()) { - int col = getInt(entry, "col"); - int row = getInt(entry, "row"); - int piece = getInt(entry, "piece"); - board[col][row] = piece; + b[getInt(entry, "col")][getInt(entry, "row")] = getInt(entry, "piece"); } - - onBoard.call(board); + onBoard.call(b); }) .addOnFailureListener(e -> onBoard.call(new int[0][0])); } + /** + * Fires once when games/{gameId}/bothReady == true. + * BOTH players call this in SetupScreen to navigate to GameScreen together. + * + * This listens on the GAMES node — completely separate from listenForGameStart() + * which listens on the LOBBIES node. The two are for different phases: + * listenForGameStart() → lobby phase (host pressed "Start Game") + * listenForBothSetupReady() → setup phase (both players confirmed boards) + */ + @Override + public void listenForBothSetupReady(String gameId, Runnable onBothReady) { + db.child("games").child(gameId).child("bothReady") + .addValueEventListener(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 ───────────────────────────────────────────────────────────────── /** - * Saves a move to Firebase. Includes the "player" field ("white"/"black") - * so the opponent's listener can filter out its own echoed moves. + * Saves a move to Firebase. The "player" field ("white"/"black") lets the + * opponent's listener filter out echoed moves. + * Coordinates are in logic space (same for both clients). */ @Override public void saveMove(String gameId, Move move, Runnable onSuccess) { @@ -198,9 +255,9 @@ public void saveMove(String gameId, Move move, Runnable onSuccess) { } /** - * Listens for new moves from Firebase. - * Returns int[]{fromCol, fromRow, toCol, toRow, isWhite(1=white / 0=black)} - * so the caller can filter out its own moves by comparing isWhite to localPlayer.isWhite(). + * Fires for every move child added to games/{gameId}/moves. + * Returns int[]{fromCol, fromRow, toCol, toRow, isWhite(1=white/0=black)}. + * All coordinates are in logic space. */ @Override public void listenForOpponentMove(String gameId, Callback onMove) { @@ -223,23 +280,59 @@ public void onChildAdded(DataSnapshot snapshot, String prev) { }); } - /** - * Fires once when games/{gameId}/bothReady == true. - * Used by BOTH players in SetupScreen to navigate to GameScreen simultaneously. - */ + // ── Heartbeat ───────────────────────────────────────────────────────────── + @Override - public void listenForGameStart(String gameId, Runnable onStart) { - db.child("games").child(gameId).child("bothReady") + public void sendHeartbeat(String gameId, boolean isWhite) { + db.child("games").child(gameId).child("heartbeat") + .child(isWhite ? "white" : "black") + .setValue(System.currentTimeMillis()); + } + + @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; + + db.child("games").child(gameId).child("heartbeat").child(player) .addValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot snapshot) { - Boolean bothReady = snapshot.getValue(Boolean.class); - if (bothReady != null && bothReady) { + Long timestamp = snapshot.getValue(Long.class); + if (timestamp != null) { + long latency = System.currentTimeMillis() - timestamp; + onHeartbeat.call(latency); + } + handler.removeCallbacks(timeoutRunnable); + handler.postDelayed(timeoutRunnable, timeoutMs); + } + @Override public void onCancelled(DatabaseError e) {} + }); + handler.postDelayed(timeoutRunnable, timeoutMs); + } + + // ── Game over ───────────────────────────────────────────────────────────── + + @Override + public void signalGameOver(String gameId, String reason) { + db.child("games").child(gameId).child("gameOver").setValue(reason); + } + + @Override + public void listenForGameOver(String gameId, Callback onGameOver) { + db.child("games").child(gameId).child("gameOver") + .addValueEventListener(new ValueEventListener() { + @Override + public void onDataChange(DataSnapshot snapshot) { + String reason = snapshot.getValue(String.class); + if (reason != null) { snapshot.getRef().removeEventListener(this); - onStart.run(); + onGameOver.call(reason); } } - @Override public void onCancelled(DatabaseError error) {} + @Override public void onCancelled(DatabaseError e) {} }); } @@ -253,4 +346,4 @@ private int getInt(DataSnapshot snapshot, String key) { if (val instanceof Integer) return (Integer) val; return 0; } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java b/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java index d7f4b7c..9a84a45 100644 --- a/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java +++ b/core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java @@ -1,4 +1,3 @@ -// core/src/main/java/com/group14/regicidechess/database/DatabaseManager.java package com.group14.regicidechess.database; public class DatabaseManager { @@ -17,7 +16,18 @@ public void init(FirebaseAPI api) { this.api = api; } + /** + * Returns the FirebaseAPI implementation. + * Throws a clear IllegalStateException if init() was never called, + * so the cause is obvious instead of a confusing NullPointerException. + */ public FirebaseAPI getApi() { + if (api == null) { + throw new IllegalStateException( + "DatabaseManager not initialised! " + + "Call DatabaseManager.getInstance().init(firebase) " + + "in your launcher before starting the game."); + } return api; } } \ No newline at end of file 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 affbcac..140946c 100644 --- a/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java +++ b/core/src/main/java/com/group14/regicidechess/database/FirebaseAPI.java @@ -4,42 +4,79 @@ import com.group14.regicidechess.model.Move; public interface FirebaseAPI { + + // ── Lobby ───────────────────────────────────────────────────────────────── + void createLobby(Lobby lobby, Callback onSuccess, Callback onError); void joinLobby(String gameId, Callback onSuccess, Callback onError); + /** Reads lobby settings without any side effects. Used by MainMenuScreen to validate. */ + void fetchLobby(String gameId, Callback onSuccess, Callback onError); + /** - * Saves this player's board layout and marks them as ready. - * When both players are ready, sets games/{gameId}/bothReady = true. + * Fires once when lobbies/{gameId}/status == "joined". + * HOST calls this to know a second player has entered the lobby. */ - void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess); + void listenForOpponentReady(String gameId, Runnable onReady); + + /** + * Sets lobbies/{gameId}/status = "started". + * HOST calls this when pressing "Start Game" so the joiner navigates to SetupScreen. + */ + void startGame(String gameId); /** - * Fetches the opponent's board layout from Firebase. - * Returns a 2D int array encoded with getPieceTypeCode values. + * Fires once when lobbies/{gameId}/status == "started". + * JOINER calls this to know the host has pressed "Start Game". */ + void listenForGameStart(String gameId, Runnable onStart); + + // ── Setup ───────────────────────────────────────────────────────────────── + + /** + * Saves this player's board layout and marks them as setup-ready. + * Sets games/{gameId}/bothReady = true once BOTH players have called this. + */ + void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess); + + /** Fetches the opponent's stored board layout. */ void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard); + /** + * Fires once when games/{gameId}/bothReady == true. + * BOTH players call this in SetupScreen after confirming their boards. + * NOTE: Completely separate from listenForGameStart() which is lobby-phase only. + */ + void listenForBothSetupReady(String gameId, Runnable onBothReady); + + // ── In-match ────────────────────────────────────────────────────────────── + void saveMove(String gameId, Move move, Runnable onSuccess); /** - * Listens for new moves. Calls onMove with int[]{fromCol, fromRow, toCol, toRow, isWhite(1/0)}. - * The isWhite field (index 4) lets the caller filter out its own moves. + * Fires for every move added to games/{gameId}/moves. + * Returns int[]{fromCol, fromRow, toCol, toRow, isWhite(1=white/0=black)}. + * Caller filters out own moves by comparing isWhite to localPlayer.isWhite(). */ void listenForOpponentMove(String gameId, Callback onMove); - /** - * Fires once when lobby status changes to "ready" (second player joined). - * Used by the HOST in LobbyScreen to detect the joiner. - */ - void listenForOpponentReady(String gameId, Runnable onReady); + void sendHeartbeat(String gameId, boolean isWhite); /** - * Fires once when games/{gameId}/bothReady == true. - * Used by BOTH players in SetupScreen to navigate to GameScreen together. + * Listens for the opponent's heartbeat. + * @param onHeartbeat called with the latency (delay) in ms when a heartbeat arrives. + * @param onTimeout called if no heartbeat is received within timeoutMs. */ - void listenForGameStart(String gameId, Runnable onStart); + void listenForHeartbeat(String gameId, boolean listenForWhite, long timeoutMs, + Callback onHeartbeat, Runnable onTimeout); + + /** e.g. "forfeit:white", "forfeit:black" */ + void signalGameOver(String gameId, String reason); + void listenForGameOver(String gameId, Callback onGameOver); + + // ───────────────────────────────────────────────────────────────────────── interface Callback { void call(T value); } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java index d167fcc..b6dfe52 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java @@ -33,13 +33,11 @@ public class GameScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - // ── Layout constants ────────────────────────────────────────────────────── private static final float V_WIDTH = 480f; private static final float V_HEIGHT = 854f; private static final float TOP_BAR_HEIGHT = 70f; private static final float STATUS_BAR_HEIGHT = 60f; - // ── LibGDX / GUI fields ─────────────────────────────────────────────────── private final Game game; private final SpriteBatch batch; private final Stage stage; @@ -47,27 +45,37 @@ public class GameScreen implements Screen, ScreenInputHandler.ScreenInputObserve private final ScreenInputHandler inputHandler; private final ShapeRenderer shapeRenderer; - // ── Game Logic layer references ─────────────────────────────────────────── private final InMatchState inMatchState; private final Player localPlayer; private final String gameId; + private final int boardSize; - // ── Selection FSM ───────────────────────────────────────────────────────── - private Vector2 selectedCell = null; - private List validMoves = new ArrayList<>(); + // ── Selection ───────────────────────────────────────────────────────────── + private Vector2 selectedCell = null; // logic coordinates + private List validMoves = new ArrayList<>(); // logic coordinates // ── Board geometry ──────────────────────────────────────────────────────── private float boardLeft; private float boardBottom; private float cellSize; - // ── Scene2D widgets ─────────────────────────────────────────────────────── + // ── Board flip helpers ──────────────────────────────────────────────────── + private int toDisplayRow(int logicRow) { + return localPlayer.isWhite() ? logicRow : (boardSize - 1 - logicRow); + } + private int toLogicRow(int displayRow) { + return localPlayer.isWhite() ? displayRow : (boardSize - 1 - displayRow); + } + + // ── Widgets ─────────────────────────────────────────────────────────────── private Label turnLabel; private Label statusLabel; + private Label connectionLabel; + private float heartbeatTimer = 0f; + private static final float HEARTBEAT_INTERVAL = 5f; - // ── Overlay (forfeit confirm / game over) — built once, shown/hidden ────── + // ── Overlay ─────────────────────────────────────────────────────────────── private Table overlayWrapper; - private Table overlayTable; private Label overlayTitle; private Label overlayBody; private TextButton overlayConfirmBtn; @@ -84,9 +92,8 @@ public GameScreen(Game game, SpriteBatch batch, this.batch = batch; this.localPlayer = localPlayer; this.gameId = gameId; + this.boardSize = boardSize; - // Opponent is derived from localPlayer — board already contains both sides' - // pieces (merged in SetupScreen.navigateToGame before this screen is created) Player opponent = new Player( localPlayer.isWhite() ? "player2" : "player1", !localPlayer.isWhite(), @@ -98,8 +105,7 @@ public GameScreen(Game game, SpriteBatch batch, localPlayer.isWhite() ? opponent : localPlayer); inMatchState.enter(); - // Start listening for opponent moves BEFORE building UI so no events are missed - startOpponentMoveListener(); + startListeners(); stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); skin = ResourceManager.getInstance().getSkin(); @@ -112,47 +118,43 @@ public GameScreen(Game game, SpriteBatch batch, refreshTurnLabel(); } - // ───────────────────────────────────────────────────────────────────────── - // UI construction - // ───────────────────────────────────────────────────────────────────────── - private void buildUI() { Table root = new Table(); root.setFillParent(true); 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.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - showForfeitOverlay(); - } + @Override public void changed(ChangeEvent event, Actor actor) { showForfeitOverlay(); } }); turnLabel = new Label("", skin, "title"); turnLabel.setAlignment(Align.center); - topBar.add(forfeitBtn).width(120).height(50).left(); + // Heartbeat / Connection UI + Table connGroup = new Table(); + connectionLabel = new Label("?", skin, "small"); // "wifi" or similar icon placeholder + connectionLabel.setAlignment(Align.right); + connGroup.add(connectionLabel).expandX().right().row(); + + topBar.add(forfeitBtn).width(100).height(50).left(); topBar.add(turnLabel).expandX().center(); - topBar.add().width(120); + topBar.add(connGroup).width(80).right().padRight(8); root.add(topBar).expandX().fillX().height(TOP_BAR_HEIGHT).row(); root.add().expandX().expandY().row(); - // ── 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); statusBar.add(statusLabel).expandX(); - root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); buildOverlay(); @@ -164,14 +166,13 @@ private void buildOverlay() { overlayWrapper.setVisible(false); stage.addActor(overlayWrapper); - overlayTable = new Table(); + Table overlayTable = new Table(); overlayTable.setBackground(skin.getDrawable("surface-pixel")); overlayTable.pad(32); overlayTitle = new Label("", skin, "title"); overlayTitle.setAlignment(Align.center); - - overlayBody = new Label("", skin, "default"); + overlayBody = new Label("", skin, "default"); overlayBody.setAlignment(Align.center); overlayBody.setWrap(true); @@ -201,33 +202,37 @@ private void buildOverlay() { }); } - // ───────────────────────────────────────────────────────────────────────── - // Overlay helpers - // ───────────────────────────────────────────────────────────────────────── - private void showForfeitOverlay() { overlayTitle.setText("Forfeit?"); overlayBody.setText("Are you sure you want to give up?"); overlayConfirmBtn.setText("Yes, forfeit"); overlayCancelBtn.setVisible(true); - onOverlayConfirm = () -> showGameOverOverlay(false); + onOverlayConfirm = () -> { + String loser = localPlayer.isWhite() ? "white" : "black"; + DatabaseManager.getInstance().getApi().signalGameOver(gameId, "forfeit:" + loser); + showGameOverOverlay(false); + }; overlayWrapper.setVisible(true); } private void showGameOverOverlay(boolean localWon) { overlayTitle.setText(localWon ? "You Win!" : "Game Over"); - overlayBody.setText(localWon - ? "You captured the opponent's King!" - : "Your King was captured."); + overlayBody.setText(localWon ? "You captured the opponent's King!" : "Your King was captured."); overlayConfirmBtn.setText("Back to Menu"); overlayCancelBtn.setVisible(false); onOverlayConfirm = () -> game.setScreen(new MainMenuScreen(game, batch)); overlayWrapper.setVisible(true); } - // ───────────────────────────────────────────────────────────────────────── - // Board geometry - // ───────────────────────────────────────────────────────────────────────── + private void showForfeitReceivedOverlay(String reason) { + boolean opponentLost = reason.endsWith(localPlayer.isWhite() ? "black" : "white"); + overlayTitle.setText(opponentLost ? "You Win!" : "Game Over"); + overlayBody.setText(opponentLost ? "Opponent forfeited!" : "You forfeited."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = () -> game.setScreen(new MainMenuScreen(game, batch)); + overlayWrapper.setVisible(true); + } private void computeBoardGeometry(float screenW, float screenH, int size) { float available = screenH - TOP_BAR_HEIGHT - STATUS_BAR_HEIGHT - 16f; @@ -237,15 +242,18 @@ private void computeBoardGeometry(float screenW, float screenH, int size) { boardBottom = STATUS_BAR_HEIGHT + (available - cellSize * size) / 2f + 8f; } - // ───────────────────────────────────────────────────────────────────────── - // Rendering - // ───────────────────────────────────────────────────────────────────────── - @Override public void render(float delta) { Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); inMatchState.update(delta); + + heartbeatTimer += delta; + if (heartbeatTimer >= HEARTBEAT_INTERVAL) { + heartbeatTimer = 0f; + DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); + } + drawBoard(); stage.act(delta); stage.draw(); @@ -266,16 +274,15 @@ public void render(float delta) { private void drawBoard() { Board board = inMatchState.getBoard(); int size = board.getSize(); - shapeRenderer.setProjectionMatrix(stage.getCamera().combined); - // ── Tiles ───────────────────────────────────────────────────────────── shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); for (int col = 0; col < size; col++) { - for (int row = 0; row < size; row++) { - float x = boardLeft + col * cellSize; - float y = boardBottom + row * cellSize; - boolean light = (col + row) % 2 == 0; + for (int logicRow = 0; logicRow < size; logicRow++) { + int dispRow = toDisplayRow(logicRow); + float x = boardLeft + col * cellSize; + float y = boardBottom + dispRow * cellSize; + boolean light = (col + logicRow) % 2 == 0; shapeRenderer.setColor(light ? new Color(0.93f, 0.85f, 0.72f, 1f) : new Color(0.55f, 0.38f, 0.24f, 1f)); @@ -284,26 +291,26 @@ private void drawBoard() { } shapeRenderer.end(); - // ── Valid-move highlights ───────────────────────────────────────────── if (!validMoves.isEmpty()) { shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); shapeRenderer.setColor(new Color(0.35f, 0.75f, 0.35f, 0.55f)); - for (Vector2 m : validMoves) + for (Vector2 m : validMoves) { + int dispRow = toDisplayRow((int) m.y); shapeRenderer.rect(boardLeft + m.x * cellSize, - boardBottom + m.y * cellSize, cellSize, cellSize); + boardBottom + dispRow * cellSize, cellSize, cellSize); + } shapeRenderer.end(); } - // ── Selected cell highlight ─────────────────────────────────────────── if (selectedCell != null) { + int dispRow = toDisplayRow((int) selectedCell.y); shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); shapeRenderer.setColor(new Color(0.85f, 0.65f, 0.13f, 0.60f)); shapeRenderer.rect(boardLeft + selectedCell.x * cellSize, - boardBottom + selectedCell.y * cellSize, cellSize, cellSize); + boardBottom + dispRow * cellSize, cellSize, cellSize); shapeRenderer.end(); } - // ── Grid lines ──────────────────────────────────────────────────────── shapeRenderer.begin(ShapeRenderer.ShapeType.Line); shapeRenderer.setColor(new Color(0f, 0f, 0f, 0.25f)); for (int i = 0; i <= size; i++) { @@ -314,38 +321,29 @@ private void drawBoard() { } shapeRenderer.end(); - // ── Pieces ──────────────────────────────────────────────────────────── batch.setProjectionMatrix(stage.getCamera().combined); batch.begin(); - for (ChessPiece piece : board.getPieces()) { - Vector2 pos = piece.getPosition(); - String color = piece.getOwner().isWhite() ? "white" : "black"; - String type = piece.getTypeName().toLowerCase(); - Texture tex = ResourceManager.getInstance().getPieceTexture(color, type); - + Vector2 pos = piece.getPosition(); + int dispRow = toDisplayRow((int) pos.y); + String color = piece.getOwner().isWhite() ? "white" : "black"; + Texture tex = ResourceManager.getInstance().getPieceTexture(color, piece.getTypeName().toLowerCase()); float pieceSize = cellSize * 0.8f; float offset = (cellSize - pieceSize) / 2f; - float px = boardLeft + pos.x * cellSize + offset; - float py = boardBottom + pos.y * cellSize + offset; - - batch.draw(tex, px, py, pieceSize, pieceSize); + batch.draw(tex, + boardLeft + pos.x * cellSize + offset, + boardBottom + dispRow * cellSize + offset, + pieceSize, pieceSize); } - batch.end(); } - // ───────────────────────────────────────────────────────────────────────── - // Input — ScreenInputObserver - // ───────────────────────────────────────────────────────────────────────── - @Override public void onTap(int screenX, int screenY, int pointer, int button) { if (overlayWrapper.isVisible()) return; Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); handleBoardTap(world.x, world.y); } - @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) {} @@ -362,10 +360,11 @@ private void handleBoardTap(float worldX, float worldY) { if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return; - int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); - int row = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); + int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); + int logicRow = toLogicRow(dispRow); - Vector2 tapped = new Vector2(col, row); + Vector2 tapped = new Vector2(col, logicRow); if (selectedCell == null) { trySelect(tapped); @@ -373,7 +372,7 @@ private void handleBoardTap(float worldX, float worldY) { if (containsVector(validMoves, tapped)) { executeMove(selectedCell, tapped); } else { - ChessPiece occupant = board.getPieceAt(col, row); + ChessPiece occupant = board.getPieceAt(col, logicRow); if (occupant != null && occupant.getOwner() == localPlayer) { trySelect(tapped); } else { @@ -384,9 +383,7 @@ private void handleBoardTap(float worldX, float worldY) { } private void trySelect(Vector2 cell) { - Board board = inMatchState.getBoard(); - ChessPiece piece = board.getPieceAt((int) cell.x, (int) cell.y); - + ChessPiece piece = inMatchState.getBoard().getPieceAt((int) cell.x, (int) cell.y); if (piece == null || piece.getOwner() != localPlayer) { showStatus("Select one of your own pieces."); deselect(); @@ -394,8 +391,7 @@ private void trySelect(Vector2 cell) { } selectedCell = cell; validMoves = piece.validMoves(); - showStatus("Selected: " + piece.getTypeName() - + " — " + validMoves.size() + " move(s) available."); + showStatus("Selected: " + piece.getTypeName() + " — " + validMoves.size() + " move(s)."); } private void deselect() { @@ -411,57 +407,35 @@ private void executeMove(Vector2 from, Vector2 to) { return; } - // Capture the moving piece reference BEFORE executing the move, - // because executeMove() updates the board and the piece's position. ChessPiece movingPiece = inMatchState.getBoard().getPieceAt(from); - if (movingPiece == null) { - deselect(); - return; - } + if (movingPiece == null) { deselect(); return; } ChessPiece captured = inMatchState.executeMove(from, to); deselect(); refreshTurnLabel(); - // Save to Firebase using the piece we captured before the board update DatabaseManager.getInstance().getApi() - .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> { - // Move saved — no action needed - }); + .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> {}); - if (captured instanceof King) { - showGameOverOverlay(true); - } else { - showStatus("Waiting for opponent..."); - } + if (captured instanceof King) showGameOverOverlay(true); + else showStatus("Waiting for opponent..."); } - // ───────────────────────────────────────────────────────────────────────── - // Opponent move listener - // ───────────────────────────────────────────────────────────────────────── + private void startListeners() { + startOpponentMoveListener(); + startGameOverListener(); + startHeartbeat(); + } - /** - * Subscribes to Firebase move events. - * - * AndroidFirebase now sends int[]{fromCol, fromRow, toCol, toRow, isWhite(1/0)}. - * We filter by the isWhite flag (index 4) to ignore our own echoed moves. - * This is much more reliable than filtering by isMyTurn(), which could be - * wrong if events arrive out of order or when the listener first attaches - * and replays existing children. - */ private void startOpponentMoveListener() { DatabaseManager.getInstance().getApi() .listenForOpponentMove(gameId, coords -> { - boolean moveIsWhite = (coords.length > 4) && (coords[4] == 1); - - // Ignore moves that belong to us + boolean moveIsWhite = coords.length > 4 && coords[4] == 1; if (moveIsWhite == localPlayer.isWhite()) return; Gdx.app.postRunnable(() -> { Vector2 from = new Vector2(coords[0], coords[1]); Vector2 to = new Vector2(coords[2], coords[3]); - - // Safety check: the piece at 'from' must belong to the opponent ChessPiece piece = inMatchState.getBoard().getPieceAt(from); if (piece == null || piece.getOwner() == localPlayer) return; @@ -469,22 +443,47 @@ private void startOpponentMoveListener() { deselect(); refreshTurnLabel(); - if (captured instanceof King) { - showGameOverOverlay(false); - } else { - showStatus("Your turn!"); - } + if (captured instanceof King) showGameOverOverlay(false); + else showStatus("Your turn!"); }); }); } - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── + private void startGameOverListener() { + DatabaseManager.getInstance().getApi() + .listenForGameOver(gameId, reason -> Gdx.app.postRunnable(() -> { + if (!overlayWrapper.isVisible()) showForfeitReceivedOverlay(reason); + })); + } + + private void startHeartbeat() { + DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); + DatabaseManager.getInstance().getApi() + .listenForHeartbeat(gameId, !localPlayer.isWhite(), 15000, + latency -> Gdx.app.postRunnable(() -> { + updateConnectionUI(latency); + }), + () -> Gdx.app.postRunnable(() -> { + connectionLabel.setColor(Color.RED); + connectionLabel.setText("✕ Lost"); + showStatus("Opponent disconnected."); + })); + } + + private void updateConnectionUI(long latency) { + String icon = "📶"; // Standard signal icon + if (latency < 150) { + connectionLabel.setColor(Color.GREEN); + } else if (latency < 500) { + connectionLabel.setColor(Color.ORANGE); + } else { + connectionLabel.setColor(Color.RED); + } + connectionLabel.setText(icon + " " + latency + "ms"); + } private void refreshTurnLabel() { - Player current = inMatchState.getCurrentTurn(); - boolean myTurn = current == localPlayer; + boolean myTurn = inMatchState.isMyTurn(localPlayer); turnLabel.setText(myTurn ? "Your turn" : "Opponent's turn"); } @@ -496,10 +495,6 @@ private boolean containsVector(List list, Vector2 v) { return false; } - // ───────────────────────────────────────────────────────────────────────── - // Screen lifecycle - // ───────────────────────────────────────────────────────────────────────── - @Override public void show() { com.badlogic.gdx.InputMultiplexer mx = @@ -516,10 +511,5 @@ public void resize(int width, int height) { @Override public void pause() {} @Override public void resume() {} @Override public void hide() { inputHandler.clearObservers(); inMatchState.exit(); } - - @Override - public void dispose() { - stage.dispose(); - shapeRenderer.dispose(); - } -} \ No newline at end of file + @Override public void dispose() { stage.dispose(); shapeRenderer.dispose(); } +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java b/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java index 9e6af43..e9a8e3e 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java @@ -15,10 +15,33 @@ 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.DatabaseManager; import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Lobby; import com.group14.regicidechess.states.LobbyState; import com.group14.regicidechess.utils.ResourceManager; +/** + * LobbyScreen handles two modes: + * + * HOST flow: + * 1. Host adjusts sliders and presses "Create Lobby" + * → lobby created in Firebase, screen shows the Game ID + * → "Start Game" button appears but is DISABLED + * 2. Joiner enters the lobby (status = "joined") + * → "Start Game" becomes ENABLED + * 3. Host presses "Start Game" + * → Firebase status set to "started" + * → Host navigates to SetupScreen + * → Joiner's listener fires and also navigates to SetupScreen + * + * JOIN flow: + * 1. Joiner presses "Join Match" + * → Firebase status set to "joined" (signals host a player is present) + * → Joiner waits, showing "Waiting for host to start..." + * 2. Host presses "Start Game" (status becomes "started") + * → Joiner's listener fires → navigates to SetupScreen + */ public class LobbyScreen implements Screen, ScreenInputHandler.ScreenInputObserver { public enum Mode { HOST, JOIN } @@ -51,15 +74,29 @@ public enum Mode { HOST, JOIN } private Label boardSizeValueLabel; private Label budgetValueLabel; private Label statusLabel; - private TextButton confirmBtn; // kept so we can disable it while waiting - public LobbyScreen(Game game, SpriteBatch batch, Mode mode, String gameId) { + // HOST buttons — createBtn shown first; startBtn shown after lobby is created + private TextButton createBtn; + private TextButton startBtn; + + // JOIN button + private TextButton joinBtn; + + public LobbyScreen(Game game, SpriteBatch batch, Mode mode, Lobby lobby) { this.game = game; this.batch = batch; this.mode = mode; - this.incomingGameId = gameId; + this.lobbyState = new LobbyState(); + + if (lobby != null) { + this.incomingGameId = lobby.getGameId(); + this.lobbyState.setPrefetchedLobby(lobby); + this.boardSize = lobby.getBoardSize(); + this.budget = lobby.getBudget(); + } else { + this.incomingGameId = null; + } - lobbyState = new LobbyState(); lobbyState.enter(); stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); @@ -100,6 +137,7 @@ private void buildUI() { } private void buildHostUI(Table root) { + // ── Sliders ─────────────────────────────────────────────────────────── Label boardSizeLabel = new Label("Board Size", skin); boardSizeValueLabel = new Label(boardSize + " x " + boardSize, skin); @@ -124,21 +162,34 @@ private void buildHostUI(Table root) { } }); - confirmBtn = new TextButton("Start Game", skin, "accent"); - confirmBtn.addListener(new ChangeListener() { + // ── Status label ────────────────────────────────────────────────────── + statusLabel = new Label("", skin, "small"); + statusLabel.setAlignment(Align.center); + + // ── Create Lobby button ─────────────────────────────────────────────── + createBtn = new TextButton("Create Lobby", skin, "accent"); + createBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - if (!confirmBtn.isDisabled()) onHostConfirm(); + if (!createBtn.isDisabled()) onHostCreateLobby(); } }); - statusLabel = new Label("", skin, "small"); - statusLabel.setAlignment(Align.center); + // ── Start Game button — hidden until a joiner arrives ───────────────── + startBtn = new TextButton("Start Game", skin, "accent"); + startBtn.setVisible(false); // hidden initially + startBtn.setDisabled(true); // also disabled as safety + startBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + if (!startBtn.isDisabled()) onHostStartGame(); + } + }); root.add(buildRow(boardSizeLabel, boardSizeValueLabel)).expandX().fillX().padBottom(8).row(); root.add(boardSlider).width(380).height(40).padBottom(24).row(); root.add(buildRow(budgetLabel, budgetValueLabel)).expandX().fillX().padBottom(8).row(); root.add(budgetSlider).width(380).height(40).padBottom(32).row(); - root.add(confirmBtn).width(280).height(60).padBottom(16).row(); + root.add(createBtn).width(280).height(60).padBottom(8).row(); + root.add(startBtn).width(280).height(60).padBottom(16).row(); root.add(statusLabel).expandX().row(); } @@ -147,83 +198,146 @@ private void buildJoinUI(Table root) { enteredLabel.setAlignment(Align.center); root.add(enteredLabel).expandX().padBottom(24).row(); - boardSizeValueLabel = new Label("Board: fetching...", skin, "small"); - budgetValueLabel = new Label("Budget: fetching...", skin, "small"); + // If we have prefetched lobby data, show it immediately + String boardText = (lobbyState.getLobby() != null) + ? "Board: " + boardSize + " x " + boardSize + : "Board: fetching..."; + String budgetText = (lobbyState.getLobby() != null) + ? "Budget: " + budget + : "Budget: fetching..."; + + boardSizeValueLabel = new Label(boardText, skin, "small"); + budgetValueLabel = new Label(budgetText, skin, "small"); root.add(boardSizeValueLabel).expandX().padBottom(4).row(); root.add(budgetValueLabel).expandX().padBottom(32).row(); statusLabel = new Label("", skin, "small"); statusLabel.setAlignment(Align.center); - confirmBtn = new TextButton("Join Match", skin, "accent"); - confirmBtn.addListener(new ChangeListener() { + joinBtn = new TextButton("Join Match", skin, "accent"); + joinBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - if (!confirmBtn.isDisabled()) onJoinConfirm(); + if (!joinBtn.isDisabled()) onJoinConfirm(); } }); - root.add(confirmBtn).width(280).height(60).padBottom(16).row(); + root.add(joinBtn).width(280).height(60).padBottom(16).row(); root.add(statusLabel).expandX().row(); } // ───────────────────────────────────────────────────────────────────────── - // Actions + // HOST actions // ───────────────────────────────────────────────────────────────────────── - private void onHostConfirm() { - confirmBtn.setDisabled(true); + /** + * HOST STEP 1: Creates the lobby in Firebase. + * Shows the Game ID and waits passively for a joiner. + * The "Start Game" button remains hidden/disabled until a joiner is detected. + */ + private void onHostCreateLobby() { + createBtn.setDisabled(true); setStatus("Creating lobby..."); lobbyState.createLobby(boardSize, budget, - // onSuccess — runs on LibGDX thread () -> { String id = lobbyState.getLobby().getGameId(); - setStatus("Lobby created! ID: " + id + "\nWaiting for opponent..."); - listenForOpponent(id); + // Hide the create button, show the (still disabled) start button + createBtn.setVisible(false); + startBtn.setVisible(true); + startBtn.setDisabled(true); + setStatus("Lobby created!\nGame ID: " + id + + "\n\nWaiting for opponent to join..."); + listenForJoiner(id); }, - // onError () -> { setStatus("Failed to create lobby. Try again."); - confirmBtn.setDisabled(false); + createBtn.setDisabled(false); }); } + /** + * HOST STEP 2 (callback): A joiner has entered the lobby. + * Enables the "Start Game" button — host decides when to proceed. + */ + private void onJoinerArrived() { + setStatus("Opponent joined! Press Start Game when you are ready."); + startBtn.setDisabled(false); + } + + /** + * HOST STEP 3: Host explicitly presses "Start Game". + * Writes status = "started" to Firebase so the joiner navigates to SetupScreen. + * Host also navigates immediately after. + */ + private void onHostStartGame() { + startBtn.setDisabled(true); + setStatus("Starting..."); + + String id = lobbyState.getLobby().getGameId(); + int size = lobbyState.getLobby().getBoardSize(); + int bud = lobbyState.getLobby().getBudget(); + + // Signal joiner to navigate + DatabaseManager.getInstance().getApi().startGame(id); + + // Host navigates right away + game.setScreen(new SetupScreen(game, batch, id, size, bud, true)); + } + + // ───────────────────────────────────────────────────────────────────────── + // JOINER actions + // ───────────────────────────────────────────────────────────────────────── + + /** + * JOINER STEP 1: Confirms joining. + * Calls joinLobby() which sets status = "joined" in Firebase (host sees this). + * Then listens for status = "started" (host pressed "Start Game"). + */ private void onJoinConfirm() { - confirmBtn.setDisabled(true); + joinBtn.setDisabled(true); setStatus("Connecting..."); lobbyState.joinLobby(incomingGameId, - // onSuccess () -> { - boardSizeValueLabel.setText("Board: " + lobbyState.getLobby().getBoardSize() + boardSizeValueLabel.setText("Board: " + + lobbyState.getLobby().getBoardSize() + " x " + lobbyState.getLobby().getBoardSize()); budgetValueLabel.setText("Budget: " + lobbyState.getLobby().getBudget()); - navigateToSetup(false); + setStatus("Joined!\nWaiting for host to start the game..."); + listenForHostStart(incomingGameId); }, - // onError () -> { setStatus("Lobby not found. Check the Game ID."); - confirmBtn.setDisabled(false); + joinBtn.setDisabled(false); }); } + // ───────────────────────────────────────────────────────────────────────── + // Firebase listeners + // ───────────────────────────────────────────────────────────────────────── + /** - * Listens for a second player joining the lobby, then navigates to setup. - * Firebase listener fires on the LibGDX thread via postRunnable in LobbyState. + * HOST: Listens for status = "joined". + * Does NOT navigate — just enables the Start Game button via onJoinerArrived(). */ - private void listenForOpponent(String gameId) { - com.group14.regicidechess.database.DatabaseManager.getInstance().getApi() - .listenForOpponentReady(gameId, () -> - Gdx.app.postRunnable(() -> navigateToSetup(true))); + private void listenForJoiner(String gameId) { + DatabaseManager.getInstance().getApi() + .listenForOpponentReady(gameId, + () -> Gdx.app.postRunnable(this::onJoinerArrived)); } - private void navigateToSetup(boolean isHost) { - int size = lobbyState.getLobby() != null ? lobbyState.getLobby().getBoardSize() : BOARD_DEFAULT; - int bud = lobbyState.getLobby() != null ? lobbyState.getLobby().getBudget() : BUDGET_DEFAULT; - String id = lobbyState.getLobby() != null ? lobbyState.getLobby().getGameId() - : (incomingGameId != null ? incomingGameId : "LOCAL"); - - game.setScreen(new SetupScreen(game, batch, id, size, bud, isHost)); + /** + * JOINER: Listens for status = "started" (set by host pressing Start Game). + * Navigates to SetupScreen when it fires. + */ + private void listenForHostStart(String gameId) { + DatabaseManager.getInstance().getApi() + .listenForGameStart(gameId, + () -> Gdx.app.postRunnable(() -> { + int size = lobbyState.getLobby().getBoardSize(); + int bud = lobbyState.getLobby().getBudget(); + game.setScreen(new SetupScreen(game, batch, gameId, size, bud, false)); + })); } // ───────────────────────────────────────────────────────────────────────── @@ -273,6 +387,5 @@ public void render(float delta) { @Override public void pause() {} @Override public void resume() {} @Override public void hide() { inputHandler.clearObservers(); lobbyState.exit(); } - @Override public void dispose() { stage.dispose(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java b/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java index bb0fa77..959777f 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java @@ -15,43 +15,27 @@ 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.DatabaseManager; import com.group14.regicidechess.input.ScreenInputHandler; import com.group14.regicidechess.states.MainMenuState; import com.group14.regicidechess.utils.ResourceManager; -/** - * MainMenuScreen — GUI layer for the main menu. - * - * Placement: core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java - * - * Owns: rendering, button listeners, join-panel visibility. - * Delegates: player identity → MainMenuState (when Firebase auth is added). - * - * No game logic lives here — this screen only routes to LobbyScreen. - */ public class MainMenuScreen implements Screen, ScreenInputHandler.ScreenInputObserver { private static final float V_WIDTH = 480f; private static final float V_HEIGHT = 854f; - // ── LibGDX / GUI fields ─────────────────────────────────────────────────── private final Game game; private final SpriteBatch batch; private final Stage stage; private final Skin skin; private final ScreenInputHandler inputHandler; + private final MainMenuState mainMenuState; - // ── Game Logic layer reference ──────────────────────────────────────────── - private final MainMenuState mainMenuState; - - // ── Join panel widgets ──────────────────────────────────────────────────── private Table joinPanel; private TextField gameIdField; private Label errorLabel; - - // ───────────────────────────────────────────────────────────────────────── - // Constructor - // ───────────────────────────────────────────────────────────────────────── + private TextButton joinConfirmBtn; public MainMenuScreen(Game game, SpriteBatch batch) { this.game = game; @@ -69,39 +53,30 @@ public MainMenuScreen(Game game, SpriteBatch batch) { buildUI(); } - // ───────────────────────────────────────────────────────────────────────── - // UI construction - // ───────────────────────────────────────────────────────────────────────── - private void buildUI() { Table root = new Table(); root.setFillParent(true); root.setBackground(skin.getDrawable("surface-pixel")); stage.addActor(root); - // ── Title ───────────────────────────────────────────────────────────── Label titleLabel = new Label("REGICIDE\nCHESS", skin, "title"); titleLabel.setAlignment(Align.center); Label subtitleLabel = new Label("online strategy chess", skin, "small"); subtitleLabel.setAlignment(Align.center); - // ── Buttons ─────────────────────────────────────────────────────────── TextButton createBtn = new TextButton("Create Lobby", skin, "accent"); TextButton joinBtn = new TextButton("Join Lobby", skin, "default"); createBtn.pad(12); joinBtn.pad(12); - // ── Join panel (hidden until "Join Lobby" is tapped) ────────────────── joinPanel = buildJoinPanel(); joinPanel.setVisible(false); - // ── Error label (visible only on validation failure) ────────────────── errorLabel = new Label("", skin, "small"); errorLabel.setColor(com.badlogic.gdx.graphics.Color.RED); errorLabel.setAlignment(Align.center); - // ── Layout ──────────────────────────────────────────────────────────── root.add(titleLabel).expandX().padTop(120).padBottom(8).row(); root.add(subtitleLabel).expandX().padBottom(80).row(); root.add(createBtn).width(300).height(60).padBottom(20).row(); @@ -109,16 +84,17 @@ private void buildUI() { root.add(errorLabel).expandX().padBottom(8).row(); root.add(joinPanel).width(320).row(); - // ── Listeners ───────────────────────────────────────────────────────── createBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - onCreateLobby(); + clearError(); + game.setScreen(new LobbyScreen(game, batch, LobbyScreen.Mode.HOST, null)); } }); joinBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - toggleJoinPanel(); + clearError(); + joinPanel.setVisible(!joinPanel.isVisible()); } }); } @@ -133,17 +109,18 @@ private Table buildJoinPanel() { gameIdField.setMessageText("e.g. ABC123"); gameIdField.setMaxLength(10); - TextButton confirmBtn = new TextButton("Join", skin, "accent"); - TextButton backBtn = new TextButton("Back", skin, "default"); + joinConfirmBtn = new TextButton("Join", skin, "accent"); + TextButton backBtn = new TextButton("Back", skin, "default"); panel.add(hint).colspan(2).padBottom(8).row(); panel.add(gameIdField).width(200).height(50).padRight(8); - panel.add(confirmBtn).width(80).height(50).row(); + panel.add(joinConfirmBtn).width(80).height(50).row(); panel.add(backBtn).colspan(2).width(290).height(48).padTop(8).row(); - confirmBtn.addListener(new ChangeListener() { + // Validate lobby exists BEFORE navigating to LobbyScreen + joinConfirmBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - onJoinLobby(gameIdField.getText().trim()); + onJoinPressed(); } }); @@ -157,61 +134,50 @@ private Table buildJoinPanel() { return panel; } - // ───────────────────────────────────────────────────────────────────────── - // Actions - // ───────────────────────────────────────────────────────────────────────── - - /** Navigate to LobbyScreen in HOST mode. */ - private void onCreateLobby() { - clearError(); - game.setScreen(new LobbyScreen(game, batch, LobbyScreen.Mode.HOST, null)); - } - - /** Toggle the Game-ID input panel. */ - private void toggleJoinPanel() { - clearError(); - joinPanel.setVisible(!joinPanel.isVisible()); - } - /** - * Client-side sanity check (FR3): Game ID must not be empty. - * LobbyScreen will ask the server to validate and fetch the lobby settings. + * Validates the Game ID against Firebase before navigating. + * Shows an error inline if not found — user never leaves the main menu. */ - private void onJoinLobby(String gameId) { + private void onJoinPressed() { + String gameId = gameIdField.getText().trim(); if (gameId.isEmpty()) { showError("Please enter a Game ID."); return; } - clearError(); - game.setScreen(new LobbyScreen(game, batch, LobbyScreen.Mode.JOIN, gameId)); - } - // ───────────────────────────────────────────────────────────────────────── - // Error display (visible in UI — replaces Gdx.app.log stub) - // ───────────────────────────────────────────────────────────────────────── + joinConfirmBtn.setDisabled(true); + showError("Checking..."); - private void showError(String message) { - errorLabel.setText(message); - Gdx.app.log("MainMenuScreen", message); + DatabaseManager.getInstance().getApi().fetchLobby(gameId, + // Lobby found — navigate to LobbyScreen in JOIN mode with pre-fetched lobby + fetchedLobby -> Gdx.app.postRunnable(() -> { + joinConfirmBtn.setDisabled(false); + clearError(); + // Pass the already-fetched lobby data straight to LobbyScreen + game.setScreen(new LobbyScreen(game, batch, LobbyScreen.Mode.JOIN, + fetchedLobby)); + }), + // Lobby not found + err -> Gdx.app.postRunnable(() -> { + joinConfirmBtn.setDisabled(false); + showError("Lobby not found. Check the Game ID."); + }) + ); + } + + private void showError(String msg) { + errorLabel.setText(msg); } private void clearError() { errorLabel.setText(""); } - // ───────────────────────────────────────────────────────────────────────── - // ScreenInputObserver — Scene2D handles all widget input on this screen - // ───────────────────────────────────────────────────────────────────────── - @Override public void onTap (int x, int y, 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) {} - // ───────────────────────────────────────────────────────────────────────── - // Screen lifecycle - // ───────────────────────────────────────────────────────────────────────── - @Override public void show() { com.badlogic.gdx.InputMultiplexer mx = @@ -231,15 +197,6 @@ public void render(float delta) { @Override public void resize(int width, int height) { stage.getViewport().update(width, height, true); } @Override public void pause() {} @Override public void resume() {} - - @Override - public void hide() { - inputHandler.clearObservers(); - mainMenuState.exit(); - } - - @Override - public void dispose() { - stage.dispose(); - } -} \ No newline at end of file + @Override public void hide() { inputHandler.clearObservers(); mainMenuState.exit(); } + @Override public void dispose() { stage.dispose(); } +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java b/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java index c8c423b..a63885e 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java @@ -34,8 +34,7 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - // ── Piece type codes (used for Firebase serialisation) ──────────────────── - // Must match decodePiece() in GameScreen + // ── Piece type codes (must match decodePiece) ───────────────────────────── public static final int CODE_KING = 1; public static final int CODE_QUEEN = 2; public static final int CODE_ROOK = 3; @@ -50,11 +49,10 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv private static final float PALETTE_HEIGHT = 130f; private static final float FOOTER_HEIGHT = 70f; - // ── Piece display data ──────────────────────────────────────────────────── private static final String[] PIECE_NAMES = { "King", "Queen", "Rook", "Bishop", "Knight", "Pawn" }; private static final int[] PIECE_COSTS = { 0, 9, 5, 3, 3, 1 }; - // ── LibGDX / GUI fields ─────────────────────────────────────────────────── + // ── LibGDX / GUI ────────────────────────────────────────────────────────── private final Game game; private final SpriteBatch batch; private final Stage stage; @@ -62,13 +60,13 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv private final ScreenInputHandler inputHandler; private final ShapeRenderer shapeRenderer; - // ── Game Logic layer reference ──────────────────────────────────────────── + // ── State ───────────────────────────────────────────────────────────────── private final SetupState setupState; private final Player localPlayer; private final String gameId; private final boolean isHost; - // ── Palette selection (GUI-only state) ──────────────────────────────────── + // ── Palette ─────────────────────────────────────────────────────────────── private int selectedPieceIndex = -1; private TextButton[] paletteButtons; @@ -77,12 +75,26 @@ public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserv private float boardBottom; private float cellSize; - // ── Scene2D widgets that update at runtime ──────────────────────────────── + // ── Board flip helpers ──────────────────────────────────────────────────── + // White sees row 0 at the bottom (normal). Black is flipped: row (size-1) at bottom. + private int toDisplayRow(int logicRow, int size) { + return localPlayer.isWhite() ? logicRow : (size - 1 - logicRow); + } + private int toLogicRow(int displayRow, int size) { + return localPlayer.isWhite() ? displayRow : (size - 1 - displayRow); + } + + // ── Widgets ─────────────────────────────────────────────────────────────── private Label budgetLabel; private Label statusLabel; private TextButton confirmBtn; private Label waitingLabel; + // ── Retry state for board fetch ─────────────────────────────────────────── + private static final int BOARD_FETCH_MAX_RETRIES = 5; + private static final long BOARD_FETCH_RETRY_MS = 600; + private int boardFetchRetries = 0; + // ───────────────────────────────────────────────────────────────────────── // Constructor // ───────────────────────────────────────────────────────────────────────── @@ -94,6 +106,7 @@ public SetupScreen(Game game, SpriteBatch batch, this.gameId = gameId; this.isHost = isHost; + // Host = white (player1), joiner = black (player2) localPlayer = new Player(isHost ? "player1" : "player2", isHost, budget); setupState = new SetupState(); @@ -122,28 +135,22 @@ private void buildUI() { root.top(); stage.addActor(root); - // ── Header ──────────────────────────────────────────────────────────── Table header = new Table(); header.setBackground(skin.getDrawable("primary-pixel")); header.pad(12); - Label titleLabel = new Label("SETUP", skin, "title"); budgetLabel = new Label(budgetText(), skin); header.add(titleLabel).expandX().left(); header.add(budgetLabel).expandX().right(); root.add(header).expandX().fillX().height(HEADER_HEIGHT).row(); - // ── Board spacer ────────────────────────────────────────────────────── root.add().expandX().expandY().row(); - // ── Piece palette ───────────────────────────────────────────────────── Table paletteWrapper = new Table(); paletteWrapper.setBackground(skin.getDrawable("primary-dark-pixel")); paletteWrapper.pad(8); - Table palette = new Table(); paletteButtons = new TextButton[PIECE_NAMES.length]; - for (int i = 0; i < PIECE_NAMES.length; i++) { final int idx = i; String btnLabel = "\n\n" + PIECE_NAMES[i] @@ -152,35 +159,26 @@ private void buildUI() { btn.getLabel().setAlignment(Align.center); paletteButtons[i] = btn; btn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - selectPalette(idx); - } + @Override public void changed(ChangeEvent event, Actor actor) { selectPalette(idx); } }); palette.add(btn).width(68).height(90).pad(4); } - ScrollPane scroll = new ScrollPane(palette, skin); scroll.setScrollingDisabled(false, true); paletteWrapper.add(scroll).expandX().fillX().height(100); root.add(paletteWrapper).expandX().fillX().height(PALETTE_HEIGHT).row(); - // ── Footer ──────────────────────────────────────────────────────────── Table footer = new Table(); footer.setBackground(skin.getDrawable("surface-pixel")); footer.pad(10); - TextButton clearBtn = new TextButton("Clear", skin, "danger"); confirmBtn = new TextButton("Confirm", skin, "accent"); confirmBtn.setDisabled(true); - statusLabel = new Label("Place your King to continue", skin, "small"); statusLabel.setAlignment(Align.center); - - // Waiting label — shown after confirm while we wait for the other player waitingLabel = new Label("Waiting for opponent...", skin, "small"); waitingLabel.setAlignment(Align.center); waitingLabel.setVisible(false); - clearBtn.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { onClear(); } }); @@ -189,13 +187,10 @@ private void buildUI() { if (!confirmBtn.isDisabled()) onConfirm(); } }); - footer.add(clearBtn).width(140).height(50).expandX().left(); footer.add(statusLabel).expandX(); footer.add(confirmBtn).width(140).height(50).expandX().right(); root.add(footer).expandX().fillX().height(FOOTER_HEIGHT).row(); - - // Waiting label sits below the footer root.add(waitingLabel).expandX().padTop(8).row(); } @@ -231,16 +226,16 @@ private void drawBoard() { int size = setupState.getBoardSize(); shapeRenderer.setProjectionMatrix(stage.getCamera().combined); - // ── Tiles ───────────────────────────────────────────────────────────── + // Tiles shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); for (int col = 0; col < size; col++) { - for (int row = 0; row < size; row++) { - float x = boardLeft + col * cellSize; - float y = boardBottom + row * cellSize; - boolean light = (col + row) % 2 == 0; - boolean home = row >= setupState.getHomeRowMin() - && row <= setupState.getHomeRowMax(); - + for (int logicRow = 0; logicRow < size; logicRow++) { + int dispRow = toDisplayRow(logicRow, size); + float x = boardLeft + col * cellSize; + float y = boardBottom + dispRow * cellSize; + boolean light = (col + logicRow) % 2 == 0; + boolean home = logicRow >= setupState.getHomeRowMin() + && logicRow <= setupState.getHomeRowMax(); if (home) { shapeRenderer.setColor(light ? new Color(0.93f, 0.90f, 0.75f, 1f) @@ -255,7 +250,7 @@ private void drawBoard() { } shapeRenderer.end(); - // ── Grid lines ──────────────────────────────────────────────────────── + // Grid lines shapeRenderer.begin(ShapeRenderer.ShapeType.Line); shapeRenderer.setColor(new Color(0f, 0f, 0f, 0.3f)); for (int i = 0; i <= size; i++) { @@ -266,68 +261,54 @@ private void drawBoard() { } shapeRenderer.end(); - // ── Home zone border ────────────────────────────────────────────────── + // Home zone border shapeRenderer.begin(ShapeRenderer.ShapeType.Line); shapeRenderer.setColor(new Color(0.4f, 0.85f, 0.4f, 0.9f)); Gdx.gl.glLineWidth(3f); - float zoneBottom = boardBottom + setupState.getHomeRowMin() * cellSize; - float zoneTop = boardBottom + (setupState.getHomeRowMax() + 1) * cellSize; + int homeDispMin = toDisplayRow(setupState.getHomeRowMin(), size); + int homeDispMax = toDisplayRow(setupState.getHomeRowMax(), size); + float zoneBottom = boardBottom + Math.min(homeDispMin, homeDispMax) * cellSize; + float zoneTop = boardBottom + (Math.max(homeDispMin, homeDispMax) + 1) * cellSize; shapeRenderer.rect(boardLeft, zoneBottom, size * cellSize, zoneTop - zoneBottom); Gdx.gl.glLineWidth(1f); shapeRenderer.end(); - // ── Placed pieces ───────────────────────────────────────────────────── + // Placed pieces batch.setProjectionMatrix(stage.getCamera().combined); batch.begin(); - for (ChessPiece piece : setupState.getBoard().getPieces()) { - Vector2 pos = piece.getPosition(); - int col = (int) pos.x, row = (int) pos.y; - String color = localPlayer.isWhite() ? "white" : "black"; - String type = piece.getTypeName().toLowerCase(); - Texture tex = ResourceManager.getInstance().getPieceTexture(color, type); - - float pieceSize = cellSize * 0.8f; - float offset = (cellSize - pieceSize) / 2f; - float x = boardLeft + col * cellSize + offset; - float y = boardBottom + row * cellSize + offset; - - batch.draw(tex, x, y, pieceSize, pieceSize); + Vector2 pos = piece.getPosition(); + int col = (int) pos.x; + int dispRow = toDisplayRow((int) pos.y, size); + String color = localPlayer.isWhite() ? "white" : "black"; + Texture tex = ResourceManager.getInstance().getPieceTexture(color, piece.getTypeName().toLowerCase()); + float pieceSize = cellSize * 0.8f; + float offset = (cellSize - pieceSize) / 2f; + batch.draw(tex, + boardLeft + col * cellSize + offset, + boardBottom + dispRow * cellSize + offset, + pieceSize, pieceSize); } - batch.end(); } private void drawPaletteSprites() { String color = localPlayer.isWhite() ? "white" : "black"; - batch.setProjectionMatrix(stage.getCamera().combined); batch.begin(); - for (int i = 0; i < paletteButtons.length; i++) { TextButton btn = paletteButtons[i]; - String type = PIECE_NAMES[i].toLowerCase(); - Texture tex = ResourceManager.getInstance().getPieceTexture(color, type); - - float btnW = btn.getWidth(); - float btnH = btn.getHeight(); - - Vector2 stagePos = btn.localToStageCoordinates(new Vector2(0, 0)); - float btnX = stagePos.x; - float btnY = stagePos.y; - - float spriteSize = Math.min(btnW, btnH) * 0.42f; - float spriteX = btnX + (btnW - spriteSize) / 2f; - float spriteY = btnY + btnH - spriteSize - 4f; - - batch.draw(tex, spriteX, spriteY, spriteSize, spriteSize); + Texture tex = ResourceManager.getInstance().getPieceTexture(color, PIECE_NAMES[i].toLowerCase()); + float btnW = btn.getWidth(), btnH = btn.getHeight(); + Vector2 sp = btn.localToStageCoordinates(new Vector2(0, 0)); + float s = Math.min(btnW, btnH) * 0.42f; + batch.draw(tex, sp.x + (btnW - s) / 2f, sp.y + btnH - s - 4f, s, s); } - batch.end(); } // ───────────────────────────────────────────────────────────────────────── - // Input — ScreenInputObserver + // Input // ───────────────────────────────────────────────────────────────────────── @Override @@ -335,7 +316,6 @@ public void onTap(int screenX, int screenY, int pointer, int button) { Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); handleBoardTap(world.x, world.y); } - @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) {} @@ -345,25 +325,22 @@ private void handleBoardTap(float worldX, float worldY) { if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return; - int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); - int row = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); + int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); + int row = toLogicRow(dispRow, size); // display row → logic row ChessPiece existing = setupState.getBoard().getPieceAt(col, row); - if (existing != null) { setupState.removePiece(col, row); } else if (selectedPieceIndex >= 0) { ChessPiece piece = createPiece(selectedPieceIndex); boolean ok = setupState.placePiece(piece, col, row); if (!ok) { - if (piece instanceof King && kingIsOnBoard()) { - showStatus("You can only place one King!"); - } else { - showStatus("Cannot place here — check home zone and budget."); - } + showStatus(piece instanceof King && kingIsOnBoard() + ? "You can only place one King!" + : "Cannot place here — check home zone and budget."); } } - refreshUI(); } @@ -402,50 +379,64 @@ private void onClear() { } private void onConfirm() { - boolean ready = setupState.setReady(); - if (!ready) { + if (!setupState.setReady()) { showStatus("Place your King before confirming."); return; } - // Lock UI while waiting confirmBtn.setDisabled(true); confirmBtn.setVisible(false); waitingLabel.setVisible(true); showStatus("Waiting for opponent to finish setup..."); - // Serialize our board and send to Firebase int[][] boardState = convertBoardToArray(); + + // Step 1: Write our board to Firebase and mark ourselves ready. + // confirmSetup() sets bothReady=true once BOTH players have called it. DatabaseManager.getInstance().getApi().confirmSetup( gameId, localPlayer.isWhite(), boardState, - () -> Gdx.app.postRunnable(() -> { - // Both players now use listenForGameStart — it fires when Firebase - // sets bothReady=true, which only happens once BOTH have confirmed. - listenForGameStart(); - }) + () -> Gdx.app.postRunnable(this::listenForBothReady) ); } /** - * Both host and joiner call this after confirming setup. - * Navigates to GameScreen when Firebase signals bothReady = true. + * Step 2: Listen for games/{gameId}/bothReady == true. + * This only becomes true after BOTH players have written their boards. + * Uses the dedicated listenForBothSetupReady() so it is completely separate + * from the lobby-phase listenForGameStart() (which watches lobbies/{id}/status). */ - private void listenForGameStart() { - DatabaseManager.getInstance().getApi().listenForGameStart( + private void listenForBothReady() { + DatabaseManager.getInstance().getApi().listenForBothSetupReady( gameId, - () -> Gdx.app.postRunnable(this::navigateToGame) + () -> Gdx.app.postRunnable(this::fetchOpponentBoardAndNavigate) ); } - private void navigateToGame() { - // Fetch opponent's board from Firebase, then merge and navigate + /** + * Step 3: Fetch opponent's board, then navigate. + * + * There is a small race: bothReady fires as soon as the second player writes + * "ready", but their board write may not yet have propagated to our local + * Firebase cache. We retry a few times if the board comes back empty. + */ + private void fetchOpponentBoardAndNavigate() { DatabaseManager.getInstance().getApi().getOpponentBoard( gameId, localPlayer.isWhite(), opponentBoard -> Gdx.app.postRunnable(() -> { - // Place opponent's pieces onto our local board instance + boolean boardEmpty = opponentBoard == null || opponentBoard.length == 0; + if (boardEmpty && boardFetchRetries < BOARD_FETCH_MAX_RETRIES) { + boardFetchRetries++; + new java.util.Timer().schedule(new java.util.TimerTask() { + @Override public void run() { + Gdx.app.postRunnable(() -> fetchOpponentBoardAndNavigate()); + } + }, BOARD_FETCH_RETRY_MS); + return; + } + mergeOpponentBoard(opponentBoard); game.setScreen(new GameScreen( game, batch, @@ -458,13 +449,12 @@ private void navigateToGame() { } /** - * Decodes the opponent's board (int[][]) and places their pieces onto - * setupState.getBoard() so GameScreen has a fully populated board. + * Decodes the opponent's board array and places their pieces onto the shared board. + * Coordinates are already in logic space (as stored by the opponent's convertBoardToArray). */ private void mergeOpponentBoard(int[][] opponentBoard) { if (opponentBoard == null || opponentBoard.length == 0) return; - // The opponent is always the other color Player opponentPlayer = new Player( localPlayer.isWhite() ? "player2" : "player1", !localPlayer.isWhite(), @@ -475,21 +465,15 @@ private void mergeOpponentBoard(int[][] opponentBoard) { int code = opponentBoard[col][row]; if (code == 0) continue; ChessPiece piece = decodePiece(code, opponentPlayer); - if (piece != null) { - setupState.getBoard().placePiece(piece, col, row); - } + if (piece != null) setupState.getBoard().placePiece(piece, col, row); } } } // ───────────────────────────────────────────────────────────────────────── - // Board serialisation helpers + // Serialisation helpers // ───────────────────────────────────────────────────────────────────────── - /** - * Converts the current board to a 2D int array using piece type codes. - * 0 = empty, 1 = King, 2 = Queen, 3 = Rook, 4 = Bishop, 5 = Knight, 6 = Pawn - */ private int[][] convertBoardToArray() { int size = setupState.getBoardSize(); int[][] boardState = new int[size][size]; @@ -501,7 +485,6 @@ private int[][] convertBoardToArray() { return boardState; } - /** Maps a piece instance to its int code. */ private int getPieceTypeCode(ChessPiece piece) { if (piece instanceof King) return CODE_KING; if (piece instanceof Queen) return CODE_QUEEN; @@ -512,7 +495,6 @@ private int getPieceTypeCode(ChessPiece piece) { return 0; } - /** Decodes an int code back into a ChessPiece for the given owner. */ public static ChessPiece decodePiece(int code, Player owner) { switch (code) { case CODE_KING: return new King(owner); @@ -532,11 +514,7 @@ public static ChessPiece decodePiece(int code, Player owner) { private void refreshUI() { budgetLabel.setText(budgetText()); confirmBtn.setDisabled(!kingIsOnBoard()); - if (!kingIsOnBoard()) { - showStatus("Place your King to continue."); - } else { - showStatus("Ready! Press Confirm when done."); - } + showStatus(kingIsOnBoard() ? "Ready! Press Confirm when done." : "Place your King to continue."); } private boolean kingIsOnBoard() { diff --git a/core/src/main/java/com/group14/regicidechess/states/LobbyState.java b/core/src/main/java/com/group14/regicidechess/states/LobbyState.java index 5168bde..660e7d5 100644 --- a/core/src/main/java/com/group14/regicidechess/states/LobbyState.java +++ b/core/src/main/java/com/group14/regicidechess/states/LobbyState.java @@ -56,6 +56,14 @@ public String generateGameId(int boardSize, int budget) { return lobby.getGameId(); } + /** Called when MainMenuScreen has already fetched the lobby. */ + public void setPrefetchedLobby(Lobby prefetched) { + this.lobby = prefetched; + if (prefetched != null) { + playerTwo = new Player("player2", false, prefetched.getBudget()); + } + } + public Lobby getLobby() { return lobby; } public Player getPlayerOne() { return playerOne; } public Player getPlayerTwo() { return playerTwo; } From ac24380a6e049319836186fac01893c1321a5df8 Mon Sep 17 00:00:00 2001 From: benjamls Date: Tue, 24 Mar 2026 11:54:12 +0100 Subject: [PATCH 10/14] feat: Implement pawn promotion and 2 tile move --- .../android/AndroidFirebase.java | 74 ++--- .../group14/regicidechess/model/Board.java | 4 + .../com/group14/regicidechess/model/Move.java | 16 +- .../regicidechess/model/pieces/Pawn.java | 20 +- .../regicidechess/screens/GameScreen.java | 277 +++++++++++++++--- 5 files changed, 287 insertions(+), 104 deletions(-) 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 f6a3602..cf62235 100644 --- a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java @@ -45,10 +45,6 @@ public void createLobby(Lobby lobby, Callback onSuccess, Callback onError.call(e.getMessage())); } - /** - * Reads lobby data with NO side effects. - * Used by MainMenuScreen to validate a Game ID before navigating. - */ @Override public void fetchLobby(String gameId, Callback onSuccess, Callback onError) { db.child("lobbies").child(gameId).get() @@ -62,10 +58,6 @@ public void fetchLobby(String gameId, Callback onSuccess, Callback onError.call(e.getMessage())); } - /** - * Reads lobby data AND marks the joiner as present (status = "joined"). - * The HOST's listenForOpponentReady() watches for this value. - */ @Override public void joinLobby(String gameId, Callback onSuccess, Callback onError) { db.child("lobbies").child(gameId).get() @@ -73,21 +65,13 @@ public void joinLobby(String gameId, Callback onSuccess, Callback if (!snapshot.exists()) { onError.call("Lobby not found"); return; } int boardSize = getInt(snapshot, "boardSize"); int budget = getInt(snapshot, "budget"); - - // "joined" = joiner is present; does NOT start the game. 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())); } - /** - * Fires once when lobbies/{gameId}/status == "joined". - * HOST calls this — it means a second player is waiting in the lobby. - * Does NOT trigger game navigation, only enables the "Start Game" button. - */ @Override public void listenForOpponentReady(String gameId, Runnable onReady) { db.child("lobbies").child(gameId).child("status") @@ -103,20 +87,11 @@ public void onDataChange(DataSnapshot snapshot) { }); } - /** - * Sets lobbies/{gameId}/status = "started". - * HOST calls this when pressing "Start Game". - * The joiner's listenForGameStart() watches for this value. - */ @Override public void startGame(String gameId) { db.child("lobbies").child(gameId).child("status").setValue("started"); } - /** - * Fires once when lobbies/{gameId}/status == "started". - * JOINER calls this to know when the host has pressed "Start Game". - */ @Override public void listenForGameStart(String gameId, Runnable onStart) { db.child("lobbies").child(gameId).child("status") @@ -134,12 +109,6 @@ public void onDataChange(DataSnapshot snapshot) { // ── Setup ───────────────────────────────────────────────────────────────── - /** - * Saves this player's board layout and marks them as setup-ready. - * Board is stored as a list of {col, row, piece} objects. - * Once BOTH players are "ready", sets games/{gameId}/bothReady = true. - * This is watched by listenForBothSetupReady() — separate from the lobby path. - */ @Override public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable onSuccess) { String player = isWhite ? "white" : "black"; @@ -158,7 +127,6 @@ public void confirmSetup(String gameId, boolean isWhite, int[][] board, Runnable } } - // Write board first, then mark ready gameRef.child("boards").child(player).setValue(pieces) .addOnSuccessListener(v1 -> gameRef.child("setup").child(player).setValue("ready") @@ -169,7 +137,6 @@ 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) { - // Both boards are written AND both players are ready gameRef.child("bothReady").setValue(true); } onSuccess.run(); @@ -181,10 +148,6 @@ public void onDataChange(DataSnapshot snapshot) { .addOnFailureListener(e -> onSuccess.run()); } - /** - * Fetches the opponent's board from games/{gameId}/boards/{opponentColor}. - * Returns int[boardSize][boardSize] with piece type codes. - */ @Override public void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard) { String opponentKey = localIsWhite ? "black" : "white"; @@ -207,15 +170,6 @@ public void getOpponentBoard(String gameId, boolean localIsWhite, Callback onBoard.call(new int[0][0])); } - /** - * Fires once when games/{gameId}/bothReady == true. - * BOTH players call this in SetupScreen to navigate to GameScreen together. - * - * This listens on the GAMES node — completely separate from listenForGameStart() - * which listens on the LOBBIES node. The two are for different phases: - * listenForGameStart() → lobby phase (host pressed "Start Game") - * listenForBothSetupReady() → setup phase (both players confirmed boards) - */ @Override public void listenForBothSetupReady(String gameId, Runnable onBothReady) { db.child("games").child(gameId).child("bothReady") @@ -234,11 +188,6 @@ public void onDataChange(DataSnapshot snapshot) { // ── Moves ───────────────────────────────────────────────────────────────── - /** - * Saves a move to Firebase. The "player" field ("white"/"black") lets the - * opponent's listener filter out echoed moves. - * Coordinates are in logic space (same for both clients). - */ @Override public void saveMove(String gameId, Move move, Runnable onSuccess) { Map data = new HashMap<>(); @@ -248,17 +197,15 @@ public void saveMove(String gameId, Move move, Runnable onSuccess) { 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()); } - /** - * Fires for every move child added to games/{gameId}/moves. - * Returns int[]{fromCol, fromRow, toCol, toRow, isWhite(1=white/0=black)}. - * All coordinates are in logic space. - */ @Override public void listenForOpponentMove(String gameId, Callback onMove) { db.child("games").child(gameId).child("moves") @@ -271,7 +218,20 @@ public void onChildAdded(DataSnapshot snapshot, String prev) { 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}); + + // We encode promotion into the array. + // Let's use a longer array if promotion exists. + // coords[5] = promotion type code if present. + 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) {} 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 06bb92c..67e2cc8 100644 --- a/core/src/main/java/com/group14/regicidechess/model/Board.java +++ b/core/src/main/java/com/group14/regicidechess/model/Board.java @@ -71,6 +71,10 @@ 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(); + } return captured; } diff --git a/core/src/main/java/com/group14/regicidechess/model/Move.java b/core/src/main/java/com/group14/regicidechess/model/Move.java index 5dc919c..1c6522b 100644 --- a/core/src/main/java/com/group14/regicidechess/model/Move.java +++ b/core/src/main/java/com/group14/regicidechess/model/Move.java @@ -10,6 +10,7 @@ public class Move { private final Vector2 to; private final ChessPiece piece; private final Player player; + private String promotion; // e.g. "Queen", "Rook", etc. public Move(Vector2 from, Vector2 to, ChessPiece piece, Player player) { this.from = from.cpy(); @@ -18,8 +19,15 @@ public Move(Vector2 from, Vector2 to, ChessPiece piece, Player player) { this.player = player; } - public Vector2 getFrom() { return from.cpy(); } - public Vector2 getTo() { return to.cpy(); } - public ChessPiece getPiece() { return piece; } - public Player getPlayer() { return player; } + public Move(Vector2 from, Vector2 to, ChessPiece piece, Player player, String promotion) { + this(from, to, piece, player); + this.promotion = promotion; + } + + public Vector2 getFrom() { return from.cpy(); } + public Vector2 getTo() { return to.cpy(); } + public ChessPiece getPiece() { return piece; } + public Player getPlayer() { return player; } + public String getPromotion() { return promotion; } + public void setPromotion(String promotion) { this.promotion = promotion; } } 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 207ee3e..cab9443 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 @@ -6,9 +6,10 @@ import java.util.ArrayList; import java.util.List; -/** Placement: core/src/main/java/com/group14/regicidechess/model/pieces/Pawn.java */ public class Pawn extends ChessPiece { + private boolean hasMoved = false; + public Pawn(Player owner) { super(owner, 1); } @@ -16,14 +17,19 @@ public Pawn(Player owner) { @Override public List validMoves() { List moves = new ArrayList<>(); - // White moves up (+row), black moves down (-row) int dir = owner.isWhite() ? 1 : -1; int c = (int) position.x; int r = (int) position.y; - // Forward — only if square is empty (FR1.1) + // 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) + if (!hasMoved && board.inBounds(c, r + 2 * dir) + && board.getPieceAt(c, r + 2 * dir) == null) { + moves.add(new Vector2(c, r + 2 * dir)); + } } // Diagonal captures @@ -36,9 +42,15 @@ public List validMoves() { } } } - // No en passant (§2.1.3) return moves; } + /** Called by Board.movePiece() after the pawn is moved. */ + public void markMoved() { + hasMoved = true; + } + + public boolean hasMoved() { return hasMoved; } + @Override public String getTypeName() { return "Pawn"; } } \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java index b6dfe52..f47e5b1 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java +++ b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java @@ -10,12 +10,15 @@ import com.badlogic.gdx.graphics.glutils.ShapeRenderer; import com.badlogic.gdx.math.Vector2; import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.InputEvent; 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.scenes.scene2d.utils.ClickListener; import com.badlogic.gdx.utils.Align; import com.badlogic.gdx.utils.viewport.FitViewport; import com.group14.regicidechess.database.DatabaseManager; @@ -23,8 +26,13 @@ import com.group14.regicidechess.model.Board; import com.group14.regicidechess.model.Move; import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; import com.group14.regicidechess.model.pieces.ChessPiece; import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; import com.group14.regicidechess.states.InMatchState; import com.group14.regicidechess.utils.ResourceManager; @@ -51,8 +59,8 @@ public class GameScreen implements Screen, ScreenInputHandler.ScreenInputObserve private final int boardSize; // ── Selection ───────────────────────────────────────────────────────────── - private Vector2 selectedCell = null; // logic coordinates - private List validMoves = new ArrayList<>(); // logic coordinates + private Vector2 selectedCell = null; + private List validMoves = new ArrayList<>(); // ── Board geometry ──────────────────────────────────────────────────────── private float boardLeft; @@ -71,10 +79,21 @@ private int toLogicRow(int displayRow) { private Label turnLabel; private Label statusLabel; private Label connectionLabel; + private Image connectionIcon; private float heartbeatTimer = 0f; private static final float HEARTBEAT_INTERVAL = 5f; - // ── Overlay ─────────────────────────────────────────────────────────────── + // ── Promotion ───────────────────────────────────────────────────────────── + private Table promotionOverlay; + + /** + * Stores the full pending promotion move (from + to + pawn) so onPromotionChosen() + * has everything it needs to save the correct move to Firebase. + * Set just before showing the promotion overlay; cleared after the player chooses. + */ + private Move pendingPromotionMove = null; + + // ── General overlay ─────────────────────────────────────────────────────── private Table overlayWrapper; private Label overlayTitle; private Label overlayBody; @@ -118,6 +137,10 @@ public GameScreen(Game game, SpriteBatch batch, refreshTurnLabel(); } + // ───────────────────────────────────────────────────────────────────────── + // UI + // ───────────────────────────────────────────────────────────────────────── + private void buildUI() { Table root = new Table(); root.setFillParent(true); @@ -136,15 +159,16 @@ private void buildUI() { turnLabel = new Label("", skin, "title"); turnLabel.setAlignment(Align.center); - // Heartbeat / Connection UI Table connGroup = new Table(); - connectionLabel = new Label("?", skin, "small"); // "wifi" or similar icon placeholder - connectionLabel.setAlignment(Align.right); - connGroup.add(connectionLabel).expandX().right().row(); + connectionIcon = new Image(skin.getDrawable("white-pixel")); + connectionIcon.setColor(Color.GRAY); + connectionLabel = new Label("Connecting", skin, "small"); + connGroup.add(connectionIcon).size(12, 12).padRight(6); + connGroup.add(connectionLabel).right(); topBar.add(forfeitBtn).width(100).height(50).left(); topBar.add(turnLabel).expandX().center(); - topBar.add(connGroup).width(80).right().padRight(8); + topBar.add(connGroup).width(110).right().padRight(8); root.add(topBar).expandX().fillX().height(TOP_BAR_HEIGHT).row(); root.add().expandX().expandY().row(); @@ -158,6 +182,7 @@ private void buildUI() { root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); buildOverlay(); + buildPromotionOverlay(); } private void buildOverlay() { @@ -202,6 +227,47 @@ private void buildOverlay() { }); } + private void buildPromotionOverlay() { + promotionOverlay = new Table(); + promotionOverlay.setFillParent(true); + promotionOverlay.setVisible(false); + stage.addActor(promotionOverlay); + + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(24); + + Label title = new Label("Promote Pawn", skin, "title"); + title.setAlignment(Align.center); + card.add(title).colspan(4).padBottom(20).row(); + + String[] names = { "Queen", "Rook", "Bishop", "Knight" }; + String color = localPlayer.isWhite() ? "white" : "black"; + + for (String pieceName : names) { + Table btn = new Table(); + btn.setBackground(skin.getDrawable("primary-pixel")); + btn.pad(8); + Texture tex = ResourceManager.getInstance().getPieceTexture(color, pieceName.toLowerCase()); + Image img = new Image(tex); + btn.add(img).size(60).row(); + btn.add(new Label(pieceName, skin, "small")).row(); + btn.setTouchable(com.badlogic.gdx.scenes.scene2d.Touchable.enabled); + btn.addListener(new ClickListener() { + @Override public void clicked(InputEvent event, float x, float y) { + onPromotionChosen(pieceName); + } + }); + card.add(btn).size(100).pad(8); + } + + promotionOverlay.add(card).center(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Overlay helpers + // ───────────────────────────────────────────────────────────────────────── + private void showForfeitOverlay() { overlayTitle.setText("Forfeit?"); overlayBody.setText("Are you sure you want to give up?"); @@ -234,6 +300,10 @@ private void showForfeitReceivedOverlay(String reason) { overlayWrapper.setVisible(true); } + // ───────────────────────────────────────────────────────────────────────── + // Board geometry + // ───────────────────────────────────────────────────────────────────────── + private void computeBoardGeometry(float screenW, float screenH, int size) { float available = screenH - TOP_BAR_HEIGHT - STATUS_BAR_HEIGHT - 16f; float maxW = screenW - 16f; @@ -242,6 +312,10 @@ private void computeBoardGeometry(float screenW, float screenH, int size) { boardBottom = STATUS_BAR_HEIGHT + (available - cellSize * size) / 2f + 8f; } + // ───────────────────────────────────────────────────────────────────────── + // Rendering + // ───────────────────────────────────────────────────────────────────────── + @Override public void render(float delta) { Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); @@ -258,7 +332,7 @@ public void render(float delta) { stage.act(delta); stage.draw(); - if (overlayWrapper.isVisible()) { + if (overlayWrapper.isVisible() || promotionOverlay.isVisible()) { Gdx.gl.glEnable(GL20.GL_BLEND); Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); shapeRenderer.setProjectionMatrix(stage.getCamera().combined); @@ -295,19 +369,17 @@ private void drawBoard() { shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); shapeRenderer.setColor(new Color(0.35f, 0.75f, 0.35f, 0.55f)); for (Vector2 m : validMoves) { - int dispRow = toDisplayRow((int) m.y); shapeRenderer.rect(boardLeft + m.x * cellSize, - boardBottom + dispRow * cellSize, cellSize, cellSize); + boardBottom + toDisplayRow((int) m.y) * cellSize, cellSize, cellSize); } shapeRenderer.end(); } if (selectedCell != null) { - int dispRow = toDisplayRow((int) selectedCell.y); shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); shapeRenderer.setColor(new Color(0.85f, 0.65f, 0.13f, 0.60f)); shapeRenderer.rect(boardLeft + selectedCell.x * cellSize, - boardBottom + dispRow * cellSize, cellSize, cellSize); + boardBottom + toDisplayRow((int) selectedCell.y) * cellSize, cellSize, cellSize); shapeRenderer.end(); } @@ -338,9 +410,13 @@ private void drawBoard() { batch.end(); } + // ───────────────────────────────────────────────────────────────────────── + // Input + // ───────────────────────────────────────────────────────────────────────── + @Override public void onTap(int screenX, int screenY, int pointer, int button) { - if (overlayWrapper.isVisible()) return; + if (overlayWrapper.isVisible() || promotionOverlay.isVisible()) return; Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); handleBoardTap(world.x, world.y); } @@ -360,8 +436,8 @@ private void handleBoardTap(float worldX, float worldY) { if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return; - int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); - int dispRow = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); + int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); int logicRow = toLogicRow(dispRow); Vector2 tapped = new Vector2(col, logicRow); @@ -400,6 +476,10 @@ private void deselect() { showStatus("Select a piece to move."); } + // ───────────────────────────────────────────────────────────────────────── + // Move execution + // ───────────────────────────────────────────────────────────────────────── + private void executeMove(Vector2 from, Vector2 to) { if (!inMatchState.isMyTurn(localPlayer)) { showStatus("Not your turn!"); @@ -410,36 +490,131 @@ private void executeMove(Vector2 from, Vector2 to) { ChessPiece movingPiece = inMatchState.getBoard().getPieceAt(from); if (movingPiece == null) { deselect(); return; } + // Execute the move locally — this switches the turn inside InMatchState. ChessPiece captured = inMatchState.executeMove(from, to); deselect(); - refreshTurnLabel(); - DatabaseManager.getInstance().getApi() - .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> {}); + if (captured instanceof King) { + // King captured: save move and end game immediately. + DatabaseManager.getInstance().getApi() + .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> {}); + refreshTurnLabel(); + showGameOverOverlay(true); + + } else if (isPawnPromotion(movingPiece, to)) { + // Promotion: do NOT save to Firebase yet — wait for the player to pick a piece. + // Store the full move details so onPromotionChosen() can send them correctly. + pendingPromotionMove = new Move(from, to, movingPiece, localPlayer); + // NOTE: turn has already been switched inside inMatchState.executeMove(). + // refreshTurnLabel() is called in onPromotionChosen() after saving. + showPromotionOverlay(); + + } else { + // Normal move. + DatabaseManager.getInstance().getApi() + .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> {}); + refreshTurnLabel(); + showStatus("Waiting for opponent..."); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Promotion + // ───────────────────────────────────────────────────────────────────────── + + private boolean isPawnPromotion(ChessPiece piece, Vector2 to) { + if (!(piece instanceof Pawn)) return false; + int promotionRank = piece.getOwner().isWhite() + ? inMatchState.getBoard().getSize() - 1 + : 0; + return (int) to.y == promotionRank; + } + + private void showPromotionOverlay() { + promotionOverlay.setVisible(true); + } - if (captured instanceof King) showGameOverOverlay(true); - else showStatus("Waiting for opponent..."); + /** + * Called when the local player selects a promotion piece. + * + * Flow: + * 1. The pawn has already moved to the promotion square (executeMove ran). + * 2. We replace the pawn with the chosen piece on the local board. + * 3. We save ONE complete move to Firebase with the promotion field set. + * from/to come from pendingPromotionMove so the coords are correct. + * 4. We refresh the turn label — the turn was already switched in step 1. + */ + private void onPromotionChosen(String pieceName) { + promotionOverlay.setVisible(false); + if (pendingPromotionMove == null) return; + + // Replace pawn with promoted piece on the local board + ChessPiece promotedPiece = buildPiece(pieceName, localPlayer); + Vector2 toCell = pendingPromotionMove.getTo(); + inMatchState.getBoard().placePiece(promotedPiece, (int) toCell.x, (int) toCell.y); + + // Save the move with promotion info — real from/to, not (-1,-1) + Move moveToSave = new Move( + pendingPromotionMove.getFrom(), + pendingPromotionMove.getTo(), + pendingPromotionMove.getPiece(), // original pawn + localPlayer, + pieceName // "Queen", "Rook", "Bishop", or "Knight" + ); + DatabaseManager.getInstance().getApi().saveMove(gameId, moveToSave, () -> {}); + + // Turn was already switched by executeMove — just refresh the label now + refreshTurnLabel(); + showStatus("Waiting for opponent..."); + pendingPromotionMove = null; } + // ───────────────────────────────────────────────────────────────────────── + // Firebase listeners + // ───────────────────────────────────────────────────────────────────────── + private void startListeners() { startOpponentMoveListener(); startGameOverListener(); startHeartbeat(); } + /** + * Opponent moves arrive as int[]{fromCol, fromRow, toCol, toRow, isWhite, promoCode}. + * isWhite : 1 = white's move, 0 = black's move + * promoCode: 0 = no promotion, 2 = Queen, 3 = Rook, 4 = Bishop, 5 = Knight + * + * All coordinates are logic-space (same on both clients). + * Own echoed moves are filtered out via coords[4]. + */ private void startOpponentMoveListener() { DatabaseManager.getInstance().getApi() .listenForOpponentMove(gameId, coords -> { boolean moveIsWhite = coords.length > 4 && coords[4] == 1; - if (moveIsWhite == localPlayer.isWhite()) return; + if (moveIsWhite == localPlayer.isWhite()) return; // own echo Gdx.app.postRunnable(() -> { Vector2 from = new Vector2(coords[0], coords[1]); Vector2 to = new Vector2(coords[2], coords[3]); + + // Safety: piece at 'from' must be opponent's ChessPiece piece = inMatchState.getBoard().getPieceAt(from); if (piece == null || piece.getOwner() == localPlayer) return; + Player opponent = piece.getOwner(); + + // Apply the move (switches turn in InMatchState) ChessPiece captured = inMatchState.executeMove(from, to); + + // Apply promotion if present + int promoCode = coords.length > 5 ? coords[5] : 0; + if (promoCode != 0) { + ChessPiece promoted = buildPieceFromCode(promoCode, opponent); + if (promoted != null) { + inMatchState.getBoard().placePiece(promoted, (int) to.x, (int) to.y); + } + } + deselect(); refreshTurnLabel(); @@ -460,31 +635,51 @@ private void startHeartbeat() { DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); DatabaseManager.getInstance().getApi() .listenForHeartbeat(gameId, !localPlayer.isWhite(), 15000, - latency -> Gdx.app.postRunnable(() -> { - updateConnectionUI(latency); - }), - () -> Gdx.app.postRunnable(() -> { - connectionLabel.setColor(Color.RED); - connectionLabel.setText("✕ Lost"); + latency -> Gdx.app.postRunnable(() -> updateConnectionUI(latency)), + () -> Gdx.app.postRunnable(() -> { + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); showStatus("Opponent disconnected."); })); } private void updateConnectionUI(long latency) { - String icon = "📶"; // Standard signal icon - if (latency < 150) { - connectionLabel.setColor(Color.GREEN); - } else if (latency < 500) { - connectionLabel.setColor(Color.ORANGE); - } else { - connectionLabel.setColor(Color.RED); + if (latency < 150) connectionIcon.setColor(Color.GREEN); + else if (latency < 500) connectionIcon.setColor(Color.ORANGE); + else connectionIcon.setColor(Color.RED); + connectionLabel.setText(latency + " ms"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Piece factory helpers + // ───────────────────────────────────────────────────────────────────────── + + private ChessPiece buildPiece(String name, Player owner) { + switch (name) { + case "Queen": return new Queen(owner); + case "Rook": return new Rook(owner); + case "Bishop": return new Bishop(owner); + default: return new Knight(owner); } - connectionLabel.setText(icon + " " + latency + "ms"); } + /** Decodes a promotion code from Firebase. 2=Queen, 3=Rook, 4=Bishop, 5=Knight. */ + private ChessPiece buildPieceFromCode(int code, Player owner) { + switch (code) { + case 2: return new Queen(owner); + case 3: return new Rook(owner); + case 4: return new Bishop(owner); + case 5: return new Knight(owner); + default: return null; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + private void refreshTurnLabel() { - boolean myTurn = inMatchState.isMyTurn(localPlayer); - turnLabel.setText(myTurn ? "Your turn" : "Opponent's turn"); + turnLabel.setText(inMatchState.isMyTurn(localPlayer) ? "Your turn" : "Opponent's turn"); } private void showStatus(String msg) { statusLabel.setText(msg); } @@ -495,6 +690,10 @@ private boolean containsVector(List list, Vector2 v) { return false; } + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + @Override public void show() { com.badlogic.gdx.InputMultiplexer mx = @@ -512,4 +711,4 @@ public void resize(int width, int height) { @Override public void resume() {} @Override public void hide() { inputHandler.clearObservers(); inMatchState.exit(); } @Override public void dispose() { stage.dispose(); shapeRenderer.dispose(); } -} +} \ No newline at end of file From 51d142d6e112d860d239d53e380987f5b2508b03 Mon Sep 17 00:00:00 2001 From: benjamls Date: Tue, 24 Mar 2026 17:43:33 +0100 Subject: [PATCH 11/14] Improve modularity for gamescreen --- .../android/AndroidFirebase.java | 115 ++- .../java/com/group14/regicidechess/Main.java | 2 +- .../regicidechess/screens/GameScreen.java | 714 ------------------ .../screens/game/GameBoardRenderer.java | 178 +++++ .../screens/game/GameNetworkHandler.java | 151 ++++ .../screens/game/GameOverlayManager.java | 200 +++++ .../screens/game/GameScreen.java | 452 +++++++++++ .../screens/game/PieceFactory.java | 59 ++ 8 files changed, 1123 insertions(+), 748 deletions(-) delete mode 100644 core/src/main/java/com/group14/regicidechess/screens/GameScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java 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 cf62235..efd296d 100644 --- a/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java +++ b/android/src/main/java/com/group14/regicidechess/android/AndroidFirebase.java @@ -208,35 +208,76 @@ public void saveMove(String gameId, Move move, Runnable onSuccess) { @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") - .addChildEventListener(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; - - // We encode promotion into the array. - // Let's use a longer array if promotion exists. - // coords[5] = promotion type code if present. - 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}); + .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(); } - @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) {} + + db.child("games").child(gameId).child("moves") + .addChildEventListener(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) + db.child("games").child(gameId).child("moves") + .addChildEventListener(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) {} + }); }); } @@ -256,21 +297,29 @@ public void listenForHeartbeat(String gameId, boolean listenForWhite, Handler handler = new Handler(Looper.getMainLooper()); Runnable timeoutRunnable = onTimeout::run; + // Start timeout timer — reset each time a valid heartbeat arrives + handler.postDelayed(timeoutRunnable, timeoutMs); + db.child("games").child(gameId).child("heartbeat").child(player) .addValueEventListener(new ValueEventListener() { @Override public void onDataChange(DataSnapshot snapshot) { Long timestamp = snapshot.getValue(Long.class); - if (timestamp != null) { - long latency = System.currentTimeMillis() - timestamp; - onHeartbeat.call(latency); - } + 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) {} }); - handler.postDelayed(timeoutRunnable, timeoutMs); } // ── Game over ───────────────────────────────────────────────────────────── @@ -306,4 +355,4 @@ private int getInt(DataSnapshot snapshot, String key) { if (val instanceof Integer) return (Integer) val; return 0; } -} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/Main.java b/core/src/main/java/com/group14/regicidechess/Main.java index 6d2d775..7482b99 100644 --- a/core/src/main/java/com/group14/regicidechess/Main.java +++ b/core/src/main/java/com/group14/regicidechess/Main.java @@ -2,7 +2,7 @@ import com.badlogic.gdx.Game; import com.badlogic.gdx.graphics.g2d.SpriteBatch; -import com.group14.regicidechess.screens.MainMenuScreen; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; import com.group14.regicidechess.utils.ResourceManager; public class Main extends Game { diff --git a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java b/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java deleted file mode 100644 index f47e5b1..0000000 --- a/core/src/main/java/com/group14/regicidechess/screens/GameScreen.java +++ /dev/null @@ -1,714 +0,0 @@ -package com.group14.regicidechess.screens; - -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.Texture; -import com.badlogic.gdx.graphics.g2d.SpriteBatch; -import com.badlogic.gdx.graphics.glutils.ShapeRenderer; -import com.badlogic.gdx.math.Vector2; -import com.badlogic.gdx.scenes.scene2d.Actor; -import com.badlogic.gdx.scenes.scene2d.InputEvent; -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.scenes.scene2d.utils.ClickListener; -import com.badlogic.gdx.utils.Align; -import com.badlogic.gdx.utils.viewport.FitViewport; -import com.group14.regicidechess.database.DatabaseManager; -import com.group14.regicidechess.input.ScreenInputHandler; -import com.group14.regicidechess.model.Board; -import com.group14.regicidechess.model.Move; -import com.group14.regicidechess.model.Player; -import com.group14.regicidechess.model.pieces.Bishop; -import com.group14.regicidechess.model.pieces.ChessPiece; -import com.group14.regicidechess.model.pieces.King; -import com.group14.regicidechess.model.pieces.Knight; -import com.group14.regicidechess.model.pieces.Pawn; -import com.group14.regicidechess.model.pieces.Queen; -import com.group14.regicidechess.model.pieces.Rook; -import com.group14.regicidechess.states.InMatchState; -import com.group14.regicidechess.utils.ResourceManager; - -import java.util.ArrayList; -import java.util.List; - -public class GameScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - - private static final float V_WIDTH = 480f; - private static final float V_HEIGHT = 854f; - private static final float TOP_BAR_HEIGHT = 70f; - private static final float STATUS_BAR_HEIGHT = 60f; - - private final Game game; - private final SpriteBatch batch; - private final Stage stage; - private final Skin skin; - private final ScreenInputHandler inputHandler; - private final ShapeRenderer shapeRenderer; - - private final InMatchState inMatchState; - private final Player localPlayer; - private final String gameId; - private final int boardSize; - - // ── Selection ───────────────────────────────────────────────────────────── - private Vector2 selectedCell = null; - private List validMoves = new ArrayList<>(); - - // ── Board geometry ──────────────────────────────────────────────────────── - private float boardLeft; - private float boardBottom; - private float cellSize; - - // ── Board flip helpers ──────────────────────────────────────────────────── - private int toDisplayRow(int logicRow) { - return localPlayer.isWhite() ? logicRow : (boardSize - 1 - logicRow); - } - private int toLogicRow(int displayRow) { - return localPlayer.isWhite() ? displayRow : (boardSize - 1 - displayRow); - } - - // ── Widgets ─────────────────────────────────────────────────────────────── - private Label turnLabel; - private Label statusLabel; - private Label connectionLabel; - private Image connectionIcon; - private float heartbeatTimer = 0f; - private static final float HEARTBEAT_INTERVAL = 5f; - - // ── Promotion ───────────────────────────────────────────────────────────── - private Table promotionOverlay; - - /** - * Stores the full pending promotion move (from + to + pawn) so onPromotionChosen() - * has everything it needs to save the correct move to Firebase. - * Set just before showing the promotion overlay; cleared after the player chooses. - */ - private Move pendingPromotionMove = null; - - // ── General overlay ─────────────────────────────────────────────────────── - private Table overlayWrapper; - private Label overlayTitle; - private Label overlayBody; - private TextButton overlayConfirmBtn; - private TextButton overlayCancelBtn; - private Runnable onOverlayConfirm; - - // ───────────────────────────────────────────────────────────────────────── - // Constructor - // ───────────────────────────────────────────────────────────────────────── - - public GameScreen(Game game, SpriteBatch batch, - Board board, Player localPlayer, int boardSize, String gameId) { - this.game = game; - this.batch = batch; - this.localPlayer = localPlayer; - this.gameId = gameId; - this.boardSize = boardSize; - - Player opponent = new Player( - localPlayer.isWhite() ? "player2" : "player1", - !localPlayer.isWhite(), - localPlayer.getBudget()); - - inMatchState = new InMatchState(); - inMatchState.init(board, - localPlayer.isWhite() ? localPlayer : opponent, - localPlayer.isWhite() ? opponent : localPlayer); - inMatchState.enter(); - - startListeners(); - - stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); - skin = ResourceManager.getInstance().getSkin(); - shapeRenderer = new ShapeRenderer(); - inputHandler = new ScreenInputHandler(); - inputHandler.addObserver(this); - - buildUI(); - computeBoardGeometry(V_WIDTH, V_HEIGHT, boardSize); - refreshTurnLabel(); - } - - // ───────────────────────────────────────────────────────────────────────── - // UI - // ───────────────────────────────────────────────────────────────────────── - - private void buildUI() { - Table root = new Table(); - root.setFillParent(true); - root.top(); - stage.addActor(root); - - Table topBar = new Table(); - topBar.setBackground(skin.getDrawable("primary-pixel")); - topBar.pad(10); - - TextButton forfeitBtn = new TextButton("Forfeit", skin, "danger"); - forfeitBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { showForfeitOverlay(); } - }); - - turnLabel = new Label("", skin, "title"); - turnLabel.setAlignment(Align.center); - - Table connGroup = new Table(); - connectionIcon = new Image(skin.getDrawable("white-pixel")); - connectionIcon.setColor(Color.GRAY); - connectionLabel = new Label("Connecting", skin, "small"); - connGroup.add(connectionIcon).size(12, 12).padRight(6); - connGroup.add(connectionLabel).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(); - - 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); - statusBar.add(statusLabel).expandX(); - root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); - - buildOverlay(); - buildPromotionOverlay(); - } - - private void buildOverlay() { - overlayWrapper = new Table(); - overlayWrapper.setFillParent(true); - overlayWrapper.setVisible(false); - stage.addActor(overlayWrapper); - - Table overlayTable = new Table(); - overlayTable.setBackground(skin.getDrawable("surface-pixel")); - overlayTable.pad(32); - - overlayTitle = new Label("", skin, "title"); - overlayTitle.setAlignment(Align.center); - overlayBody = new Label("", skin, "default"); - overlayBody.setAlignment(Align.center); - overlayBody.setWrap(true); - - overlayConfirmBtn = new TextButton("", skin, "accent"); - overlayCancelBtn = new TextButton("Cancel", skin, "default"); - - overlayTable.add(overlayTitle).expandX().padBottom(16).row(); - overlayTable.add(overlayBody).width(300).padBottom(32).row(); - - Table btnRow = new Table(); - btnRow.add(overlayCancelBtn).width(130).height(55).padRight(12); - btnRow.add(overlayConfirmBtn).width(130).height(55); - overlayTable.add(btnRow).row(); - - overlayWrapper.add(overlayTable).center(); - - overlayConfirmBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - overlayWrapper.setVisible(false); - if (onOverlayConfirm != null) onOverlayConfirm.run(); - } - }); - overlayCancelBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - overlayWrapper.setVisible(false); - } - }); - } - - private void buildPromotionOverlay() { - promotionOverlay = new Table(); - promotionOverlay.setFillParent(true); - promotionOverlay.setVisible(false); - stage.addActor(promotionOverlay); - - Table card = new Table(); - card.setBackground(skin.getDrawable("surface-pixel")); - card.pad(24); - - Label title = new Label("Promote Pawn", skin, "title"); - title.setAlignment(Align.center); - card.add(title).colspan(4).padBottom(20).row(); - - String[] names = { "Queen", "Rook", "Bishop", "Knight" }; - String color = localPlayer.isWhite() ? "white" : "black"; - - for (String pieceName : names) { - Table btn = new Table(); - btn.setBackground(skin.getDrawable("primary-pixel")); - btn.pad(8); - Texture tex = ResourceManager.getInstance().getPieceTexture(color, pieceName.toLowerCase()); - Image img = new Image(tex); - btn.add(img).size(60).row(); - btn.add(new Label(pieceName, skin, "small")).row(); - btn.setTouchable(com.badlogic.gdx.scenes.scene2d.Touchable.enabled); - btn.addListener(new ClickListener() { - @Override public void clicked(InputEvent event, float x, float y) { - onPromotionChosen(pieceName); - } - }); - card.add(btn).size(100).pad(8); - } - - promotionOverlay.add(card).center(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Overlay helpers - // ───────────────────────────────────────────────────────────────────────── - - private void showForfeitOverlay() { - overlayTitle.setText("Forfeit?"); - overlayBody.setText("Are you sure you want to give up?"); - overlayConfirmBtn.setText("Yes, forfeit"); - overlayCancelBtn.setVisible(true); - onOverlayConfirm = () -> { - String loser = localPlayer.isWhite() ? "white" : "black"; - DatabaseManager.getInstance().getApi().signalGameOver(gameId, "forfeit:" + loser); - showGameOverOverlay(false); - }; - overlayWrapper.setVisible(true); - } - - private void showGameOverOverlay(boolean localWon) { - overlayTitle.setText(localWon ? "You Win!" : "Game Over"); - overlayBody.setText(localWon ? "You captured the opponent's King!" : "Your King was captured."); - overlayConfirmBtn.setText("Back to Menu"); - overlayCancelBtn.setVisible(false); - onOverlayConfirm = () -> game.setScreen(new MainMenuScreen(game, batch)); - overlayWrapper.setVisible(true); - } - - private void showForfeitReceivedOverlay(String reason) { - boolean opponentLost = reason.endsWith(localPlayer.isWhite() ? "black" : "white"); - overlayTitle.setText(opponentLost ? "You Win!" : "Game Over"); - overlayBody.setText(opponentLost ? "Opponent forfeited!" : "You forfeited."); - overlayConfirmBtn.setText("Back to Menu"); - overlayCancelBtn.setVisible(false); - onOverlayConfirm = () -> game.setScreen(new MainMenuScreen(game, batch)); - overlayWrapper.setVisible(true); - } - - // ───────────────────────────────────────────────────────────────────────── - // Board geometry - // ───────────────────────────────────────────────────────────────────────── - - private void computeBoardGeometry(float screenW, float screenH, int size) { - float available = screenH - TOP_BAR_HEIGHT - STATUS_BAR_HEIGHT - 16f; - float maxW = screenW - 16f; - cellSize = Math.min(maxW / size, available / size); - boardLeft = (screenW - cellSize * size) / 2f; - boardBottom = STATUS_BAR_HEIGHT + (available - cellSize * size) / 2f + 8f; - } - - // ───────────────────────────────────────────────────────────────────────── - // Rendering - // ───────────────────────────────────────────────────────────────────────── - - @Override - public void render(float delta) { - Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); - Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); - inMatchState.update(delta); - - heartbeatTimer += delta; - if (heartbeatTimer >= HEARTBEAT_INTERVAL) { - heartbeatTimer = 0f; - DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); - } - - drawBoard(); - stage.act(delta); - stage.draw(); - - if (overlayWrapper.isVisible() || promotionOverlay.isVisible()) { - Gdx.gl.glEnable(GL20.GL_BLEND); - Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); - shapeRenderer.setProjectionMatrix(stage.getCamera().combined); - shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); - shapeRenderer.setColor(0f, 0f, 0f, 0.65f); - shapeRenderer.rect(0, 0, V_WIDTH, V_HEIGHT); - shapeRenderer.end(); - Gdx.gl.glDisable(GL20.GL_BLEND); - stage.draw(); - } - } - - private void drawBoard() { - Board board = inMatchState.getBoard(); - int size = board.getSize(); - shapeRenderer.setProjectionMatrix(stage.getCamera().combined); - - shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); - for (int col = 0; col < size; col++) { - for (int logicRow = 0; logicRow < size; logicRow++) { - int dispRow = toDisplayRow(logicRow); - float x = boardLeft + col * cellSize; - float y = boardBottom + dispRow * cellSize; - boolean light = (col + logicRow) % 2 == 0; - shapeRenderer.setColor(light - ? new Color(0.93f, 0.85f, 0.72f, 1f) - : new Color(0.55f, 0.38f, 0.24f, 1f)); - shapeRenderer.rect(x, y, cellSize, cellSize); - } - } - shapeRenderer.end(); - - if (!validMoves.isEmpty()) { - shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); - shapeRenderer.setColor(new Color(0.35f, 0.75f, 0.35f, 0.55f)); - for (Vector2 m : validMoves) { - shapeRenderer.rect(boardLeft + m.x * cellSize, - boardBottom + toDisplayRow((int) m.y) * cellSize, cellSize, cellSize); - } - shapeRenderer.end(); - } - - if (selectedCell != null) { - shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); - shapeRenderer.setColor(new Color(0.85f, 0.65f, 0.13f, 0.60f)); - shapeRenderer.rect(boardLeft + selectedCell.x * cellSize, - boardBottom + toDisplayRow((int) selectedCell.y) * cellSize, cellSize, cellSize); - shapeRenderer.end(); - } - - shapeRenderer.begin(ShapeRenderer.ShapeType.Line); - shapeRenderer.setColor(new Color(0f, 0f, 0f, 0.25f)); - for (int i = 0; i <= size; i++) { - float x = boardLeft + i * cellSize; - float y = boardBottom + i * cellSize; - shapeRenderer.line(x, boardBottom, x, boardBottom + size * cellSize); - shapeRenderer.line(boardLeft, y, boardLeft + size * cellSize, y); - } - shapeRenderer.end(); - - batch.setProjectionMatrix(stage.getCamera().combined); - batch.begin(); - for (ChessPiece piece : board.getPieces()) { - Vector2 pos = piece.getPosition(); - int dispRow = toDisplayRow((int) pos.y); - String color = piece.getOwner().isWhite() ? "white" : "black"; - Texture tex = ResourceManager.getInstance().getPieceTexture(color, piece.getTypeName().toLowerCase()); - float pieceSize = cellSize * 0.8f; - float offset = (cellSize - pieceSize) / 2f; - batch.draw(tex, - boardLeft + pos.x * cellSize + offset, - boardBottom + dispRow * cellSize + offset, - pieceSize, pieceSize); - } - batch.end(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Input - // ───────────────────────────────────────────────────────────────────────── - - @Override - public void onTap(int screenX, int screenY, int pointer, int button) { - if (overlayWrapper.isVisible() || promotionOverlay.isVisible()) return; - Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); - handleBoardTap(world.x, world.y); - } - @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) {} - - private void handleBoardTap(float worldX, float worldY) { - if (!inMatchState.isMyTurn(localPlayer)) { - showStatus("Not your turn."); - return; - } - - Board board = inMatchState.getBoard(); - int size = board.getSize(); - - if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; - if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return; - - int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); - int dispRow = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); - int logicRow = toLogicRow(dispRow); - - Vector2 tapped = new Vector2(col, logicRow); - - if (selectedCell == null) { - trySelect(tapped); - } else { - if (containsVector(validMoves, tapped)) { - executeMove(selectedCell, tapped); - } else { - ChessPiece occupant = board.getPieceAt(col, logicRow); - if (occupant != null && occupant.getOwner() == localPlayer) { - trySelect(tapped); - } else { - deselect(); - } - } - } - } - - private void trySelect(Vector2 cell) { - ChessPiece piece = inMatchState.getBoard().getPieceAt((int) cell.x, (int) cell.y); - if (piece == null || piece.getOwner() != localPlayer) { - showStatus("Select one of your own pieces."); - deselect(); - return; - } - selectedCell = cell; - validMoves = piece.validMoves(); - showStatus("Selected: " + piece.getTypeName() + " — " + validMoves.size() + " move(s)."); - } - - private void deselect() { - selectedCell = null; - validMoves.clear(); - showStatus("Select a piece to move."); - } - - // ───────────────────────────────────────────────────────────────────────── - // Move execution - // ───────────────────────────────────────────────────────────────────────── - - private void executeMove(Vector2 from, Vector2 to) { - if (!inMatchState.isMyTurn(localPlayer)) { - showStatus("Not your turn!"); - deselect(); - return; - } - - ChessPiece movingPiece = inMatchState.getBoard().getPieceAt(from); - if (movingPiece == null) { deselect(); return; } - - // Execute the move locally — this switches the turn inside InMatchState. - ChessPiece captured = inMatchState.executeMove(from, to); - deselect(); - - if (captured instanceof King) { - // King captured: save move and end game immediately. - DatabaseManager.getInstance().getApi() - .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> {}); - refreshTurnLabel(); - showGameOverOverlay(true); - - } else if (isPawnPromotion(movingPiece, to)) { - // Promotion: do NOT save to Firebase yet — wait for the player to pick a piece. - // Store the full move details so onPromotionChosen() can send them correctly. - pendingPromotionMove = new Move(from, to, movingPiece, localPlayer); - // NOTE: turn has already been switched inside inMatchState.executeMove(). - // refreshTurnLabel() is called in onPromotionChosen() after saving. - showPromotionOverlay(); - - } else { - // Normal move. - DatabaseManager.getInstance().getApi() - .saveMove(gameId, new Move(from, to, movingPiece, localPlayer), () -> {}); - refreshTurnLabel(); - showStatus("Waiting for opponent..."); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Promotion - // ───────────────────────────────────────────────────────────────────────── - - private boolean isPawnPromotion(ChessPiece piece, Vector2 to) { - if (!(piece instanceof Pawn)) return false; - int promotionRank = piece.getOwner().isWhite() - ? inMatchState.getBoard().getSize() - 1 - : 0; - return (int) to.y == promotionRank; - } - - private void showPromotionOverlay() { - promotionOverlay.setVisible(true); - } - - /** - * Called when the local player selects a promotion piece. - * - * Flow: - * 1. The pawn has already moved to the promotion square (executeMove ran). - * 2. We replace the pawn with the chosen piece on the local board. - * 3. We save ONE complete move to Firebase with the promotion field set. - * from/to come from pendingPromotionMove so the coords are correct. - * 4. We refresh the turn label — the turn was already switched in step 1. - */ - private void onPromotionChosen(String pieceName) { - promotionOverlay.setVisible(false); - if (pendingPromotionMove == null) return; - - // Replace pawn with promoted piece on the local board - ChessPiece promotedPiece = buildPiece(pieceName, localPlayer); - Vector2 toCell = pendingPromotionMove.getTo(); - inMatchState.getBoard().placePiece(promotedPiece, (int) toCell.x, (int) toCell.y); - - // Save the move with promotion info — real from/to, not (-1,-1) - Move moveToSave = new Move( - pendingPromotionMove.getFrom(), - pendingPromotionMove.getTo(), - pendingPromotionMove.getPiece(), // original pawn - localPlayer, - pieceName // "Queen", "Rook", "Bishop", or "Knight" - ); - DatabaseManager.getInstance().getApi().saveMove(gameId, moveToSave, () -> {}); - - // Turn was already switched by executeMove — just refresh the label now - refreshTurnLabel(); - showStatus("Waiting for opponent..."); - pendingPromotionMove = null; - } - - // ───────────────────────────────────────────────────────────────────────── - // Firebase listeners - // ───────────────────────────────────────────────────────────────────────── - - private void startListeners() { - startOpponentMoveListener(); - startGameOverListener(); - startHeartbeat(); - } - - /** - * Opponent moves arrive as int[]{fromCol, fromRow, toCol, toRow, isWhite, promoCode}. - * isWhite : 1 = white's move, 0 = black's move - * promoCode: 0 = no promotion, 2 = Queen, 3 = Rook, 4 = Bishop, 5 = Knight - * - * All coordinates are logic-space (same on both clients). - * Own echoed moves are filtered out via coords[4]. - */ - private void startOpponentMoveListener() { - DatabaseManager.getInstance().getApi() - .listenForOpponentMove(gameId, coords -> { - boolean moveIsWhite = coords.length > 4 && coords[4] == 1; - if (moveIsWhite == localPlayer.isWhite()) return; // own echo - - Gdx.app.postRunnable(() -> { - Vector2 from = new Vector2(coords[0], coords[1]); - Vector2 to = new Vector2(coords[2], coords[3]); - - // Safety: piece at 'from' must be opponent's - ChessPiece piece = inMatchState.getBoard().getPieceAt(from); - if (piece == null || piece.getOwner() == localPlayer) return; - - Player opponent = piece.getOwner(); - - // Apply the move (switches turn in InMatchState) - ChessPiece captured = inMatchState.executeMove(from, to); - - // Apply promotion if present - int promoCode = coords.length > 5 ? coords[5] : 0; - if (promoCode != 0) { - ChessPiece promoted = buildPieceFromCode(promoCode, opponent); - if (promoted != null) { - inMatchState.getBoard().placePiece(promoted, (int) to.x, (int) to.y); - } - } - - deselect(); - refreshTurnLabel(); - - if (captured instanceof King) showGameOverOverlay(false); - else showStatus("Your turn!"); - }); - }); - } - - private void startGameOverListener() { - DatabaseManager.getInstance().getApi() - .listenForGameOver(gameId, reason -> Gdx.app.postRunnable(() -> { - if (!overlayWrapper.isVisible()) showForfeitReceivedOverlay(reason); - })); - } - - private void startHeartbeat() { - DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); - DatabaseManager.getInstance().getApi() - .listenForHeartbeat(gameId, !localPlayer.isWhite(), 15000, - latency -> Gdx.app.postRunnable(() -> updateConnectionUI(latency)), - () -> Gdx.app.postRunnable(() -> { - connectionIcon.setColor(Color.RED); - connectionLabel.setText("Lost"); - showStatus("Opponent disconnected."); - })); - } - - private void updateConnectionUI(long latency) { - if (latency < 150) connectionIcon.setColor(Color.GREEN); - else if (latency < 500) connectionIcon.setColor(Color.ORANGE); - else connectionIcon.setColor(Color.RED); - connectionLabel.setText(latency + " ms"); - } - - // ───────────────────────────────────────────────────────────────────────── - // Piece factory helpers - // ───────────────────────────────────────────────────────────────────────── - - private ChessPiece buildPiece(String name, Player owner) { - switch (name) { - case "Queen": return new Queen(owner); - case "Rook": return new Rook(owner); - case "Bishop": return new Bishop(owner); - default: return new Knight(owner); - } - } - - /** Decodes a promotion code from Firebase. 2=Queen, 3=Rook, 4=Bishop, 5=Knight. */ - private ChessPiece buildPieceFromCode(int code, Player owner) { - switch (code) { - case 2: return new Queen(owner); - case 3: return new Rook(owner); - case 4: return new Bishop(owner); - case 5: return new Knight(owner); - default: return null; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - private void refreshTurnLabel() { - turnLabel.setText(inMatchState.isMyTurn(localPlayer) ? "Your turn" : "Opponent's turn"); - } - - private void showStatus(String msg) { statusLabel.setText(msg); } - - private boolean containsVector(List list, Vector2 v) { - for (Vector2 item : list) - if ((int) item.x == (int) v.x && (int) item.y == (int) v.y) return true; - return false; - } - - // ───────────────────────────────────────────────────────────────────────── - // Screen lifecycle - // ───────────────────────────────────────────────────────────────────────── - - @Override - public void show() { - com.badlogic.gdx.InputMultiplexer mx = - new com.badlogic.gdx.InputMultiplexer(stage, inputHandler); - Gdx.input.setInputProcessor(mx); - } - - @Override - public void resize(int width, int height) { - stage.getViewport().update(width, height, true); - computeBoardGeometry(V_WIDTH, V_HEIGHT, inMatchState.getBoard().getSize()); - } - - @Override public void pause() {} - @Override public void resume() {} - @Override public void hide() { inputHandler.clearObservers(); inMatchState.exit(); } - @Override public void dispose() { stage.dispose(); shapeRenderer.dispose(); } -} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java new file mode 100644 index 0000000..a80347f --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java @@ -0,0 +1,178 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Matrix4; +import com.badlogic.gdx.math.Vector2; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.utils.ResourceManager; + +import java.util.List; + +/** + * GameBoardRenderer — draws the chess board, pieces, and selection highlights. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/GameBoardRenderer.java + * + * Owns the ShapeRenderer. Knows nothing about Firebase, screens, or game logic — + * it is given what to draw via method parameters and the shared board reference. + * + * Coordinate convention: + * Logic space — col/row as used by Board and InMatchState (row 0 = white's back rank). + * Display space — row after optional flip; row 0 is always at the bottom of the screen. + * White: display == logic. Black: display = (size - 1 - logic) so their pieces appear at the bottom. + */ +public class GameBoardRenderer { + + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final Player localPlayer; + private final int boardSize; + + private float boardLeft; + private float boardBottom; + private float cellSize; + + // Tile colours + private static final Color LIGHT_TILE = new Color(0.93f, 0.85f, 0.72f, 1f); + private static final Color DARK_TILE = new Color(0.55f, 0.38f, 0.24f, 1f); + + public GameBoardRenderer(SpriteBatch batch, Player localPlayer, int boardSize) { + this.batch = batch; + this.localPlayer = localPlayer; + this.boardSize = boardSize; + this.shapeRenderer = new ShapeRenderer(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Geometry + // ───────────────────────────────────────────────────────────────────────── + + public void computeGeometry(float screenW, float screenH, + float topBarHeight, float statusBarHeight) { + float available = screenH - topBarHeight - statusBarHeight - 16f; + float maxW = screenW - 16f; + cellSize = Math.min(maxW / boardSize, available / boardSize); + boardLeft = (screenW - cellSize * boardSize) / 2f; + boardBottom = statusBarHeight + (available - cellSize * boardSize) / 2f + 8f; + } + + /** Converts a logic row to a display row for this player's perspective. */ + public int toDisplayRow(int logicRow) { + return localPlayer.isWhite() ? logicRow : (boardSize - 1 - logicRow); + } + + /** Converts a display row back to a logic row. */ + public int toLogicRow(int displayRow) { + return localPlayer.isWhite() ? displayRow : (boardSize - 1 - displayRow); + } + + /** Returns the cell size in pixels (useful for tap detection in GameScreen). */ + public float getCellSize() { return cellSize; } + public float getBoardLeft() { return boardLeft; } + public float getBoardBottom(){ return boardBottom; } + + // ───────────────────────────────────────────────────────────────────────── + // Drawing + // ───────────────────────────────────────────────────────────────────────── + + /** + * Draws the full board: tiles, highlights, grid, and pieces. + * + * @param projMatrix camera combined matrix from the Stage + * @param board current game board + * @param selectedCell logic coords of the selected cell, or null + * @param validMoves logic coords of valid move targets, may be empty + */ + public void draw(Matrix4 projMatrix, Board board, + Vector2 selectedCell, List validMoves) { + shapeRenderer.setProjectionMatrix(projMatrix); + drawTiles(); + drawValidMoveHighlights(validMoves); + drawSelectedHighlight(selectedCell); + drawGrid(); + drawPieces(projMatrix, board); + } + + private void drawTiles() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + for (int col = 0; col < boardSize; col++) { + for (int logicRow = 0; logicRow < boardSize; logicRow++) { + int dispRow = toDisplayRow(logicRow); + float x = boardLeft + col * cellSize; + float y = boardBottom + dispRow * cellSize; + boolean light = (col + logicRow) % 2 == 0; + shapeRenderer.setColor(light ? LIGHT_TILE : DARK_TILE); + shapeRenderer.rect(x, y, cellSize, cellSize); + } + } + shapeRenderer.end(); + } + + private void drawValidMoveHighlights(List validMoves) { + if (validMoves == null || validMoves.isEmpty()) return; + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + shapeRenderer.setColor(new Color(0.35f, 0.75f, 0.35f, 0.55f)); + for (Vector2 m : validMoves) { + shapeRenderer.rect( + boardLeft + m.x * cellSize, + boardBottom + toDisplayRow((int) m.y) * cellSize, + cellSize, cellSize); + } + shapeRenderer.end(); + } + + private void drawSelectedHighlight(Vector2 selectedCell) { + if (selectedCell == null) return; + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + shapeRenderer.setColor(new Color(0.85f, 0.65f, 0.13f, 0.60f)); + shapeRenderer.rect( + boardLeft + selectedCell.x * cellSize, + boardBottom + toDisplayRow((int) selectedCell.y) * cellSize, + cellSize, cellSize); + shapeRenderer.end(); + } + + private void drawGrid() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(new Color(0f, 0f, 0f, 0.25f)); + for (int i = 0; i <= boardSize; i++) { + float x = boardLeft + i * cellSize; + float y = boardBottom + i * cellSize; + shapeRenderer.line(x, boardBottom, x, boardBottom + boardSize * cellSize); + shapeRenderer.line(boardLeft, y, boardLeft + boardSize * cellSize, y); + } + shapeRenderer.end(); + } + + private void drawPieces(Matrix4 projMatrix, Board board) { + batch.setProjectionMatrix(projMatrix); + batch.begin(); + for (ChessPiece piece : board.getPieces()) { + Vector2 pos = piece.getPosition(); + int dispRow = toDisplayRow((int) pos.y); + String color = piece.getOwner().isWhite() ? "white" : "black"; + Texture tex = ResourceManager.getInstance() + .getPieceTexture(color, piece.getTypeName().toLowerCase()); + float pieceSize = cellSize * 0.8f; + float offset = (cellSize - pieceSize) / 2f; + batch.draw(tex, + boardLeft + pos.x * cellSize + offset, + boardBottom + dispRow * cellSize + offset, + pieceSize, pieceSize); + } + batch.end(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────────── + + public void dispose() { + shapeRenderer.dispose(); + } +} \ 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 new file mode 100644 index 0000000..4a70618 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java @@ -0,0 +1,151 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.group14.regicidechess.database.DatabaseManager; +import com.group14.regicidechess.model.Move; +import com.group14.regicidechess.model.Player; + +/** + * GameNetworkHandler — owns all Firebase subscriptions for an active game. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java + * + * Receives raw Firebase data and forwards parsed, typed events to the Listener. + * Knows nothing about rendering or game logic — it only translates network events. + */ +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); + + /** Called when no heartbeat has arrived within the timeout window. */ + void onOpponentDisconnected(); + } + + private static final long HEARTBEAT_TIMEOUT_MS = 15_000L; + + private final String gameId; + private final Player localPlayer; + private final Listener listener; + + // Connection UI refs — updated directly here to keep GameScreen clean + private final Image connectionIcon; + private final Label connectionLabel; + + public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, + Image connectionIcon, Label connectionLabel) { + this.gameId = gameId; + this.localPlayer = localPlayer; + this.listener = listener; + this.connectionIcon = connectionIcon; + this.connectionLabel = connectionLabel; + } + + // ───────────────────────────────────────────────────────────────────────── + // Start all listeners + // ───────────────────────────────────────────────────────────────────────── + + public void start() { + startOpponentMoveListener(); + startGameOverListener(); + startHeartbeat(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Outgoing + // ───────────────────────────────────────────────────────────────────────── + + /** Saves a completed move (with optional promotion) to Firebase. */ + public void saveMove(Move move) { + DatabaseManager.getInstance().getApi().saveMove(gameId, move, () -> {}); + } + + /** Sends a heartbeat pulse. Call every HEARTBEAT_INTERVAL seconds from render(). */ + public void sendHeartbeat() { + DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); + } + + /** Signals that this player has forfeited. */ + public void signalForfeit() { + String loser = localPlayer.isWhite() ? "white" : "black"; + DatabaseManager.getInstance().getApi().signalGameOver(gameId, "forfeit:" + loser); + } + + // ───────────────────────────────────────────────────────────────────────── + // Incoming listeners + // ───────────────────────────────────────────────────────────────────────── + + /** + * Listens for opponent moves. + * Firebase sends int[]{fromCol, fromRow, toCol, toRow, isWhite(1/0), promoCode}. + * Own echoed moves are filtered out via coords[4]. + */ + private void startOpponentMoveListener() { + DatabaseManager.getInstance().getApi() + .listenForOpponentMove(gameId, coords -> { + boolean moveIsWhite = coords.length > 4 && coords[4] == 1; + if (moveIsWhite == localPlayer.isWhite()) return; // own echo + + 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() { + DatabaseManager.getInstance().getApi() + .listenForGameOver(gameId, reason -> + Gdx.app.postRunnable(() -> listener.onGameOver(reason))); + } + + private void startHeartbeat() { + // Send our first beat immediately + DatabaseManager.getInstance().getApi().sendHeartbeat(gameId, localPlayer.isWhite()); + + DatabaseManager.getInstance().getApi() + .listenForHeartbeat( + gameId, + !localPlayer.isWhite(), // watch opponent's heartbeat + HEARTBEAT_TIMEOUT_MS, + latency -> Gdx.app.postRunnable(() -> { + updateConnectionUI(latency); + listener.onHeartbeatLatency(latency); + }), + () -> Gdx.app.postRunnable(() -> { + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Lost"); + listener.onOpponentDisconnected(); + }) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Connection UI + // ───────────────────────────────────────────────────────────────────────── + + private void updateConnectionUI(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/game/GameOverlayManager.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java new file mode 100644 index 0000000..d979d62 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java @@ -0,0 +1,200 @@ +package com.group14.regicidechess.screens.game; + +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.scenes.scene2d.InputEvent; +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.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; +import com.badlogic.gdx.utils.Align; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * GameOverlayManager — builds and controls all modal overlays in the game screen. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/GameOverlayManager.java + * + * Manages three overlays: + * 1. General overlay — forfeit confirmation and game-over summary. + * 2. Promotion overlay — piece selection when a pawn reaches the back rank. + * + * Callers supply callbacks; this class owns no game logic. + */ +public class GameOverlayManager { + + /** Callbacks surfaced to GameScreen. */ + public interface Listener { + void onForfeitConfirmed(); + void onGameOverBack(); + void onPromotionChosen(String pieceName); + } + + private final Stage stage; + private final Skin skin; + private final Player localPlayer; + private final Listener listener; + + // ── General overlay widgets ─────────────────────────────────────────────── + private Table overlayWrapper; + private Label overlayTitle; + private Label overlayBody; + private TextButton overlayConfirmBtn; + private TextButton overlayCancelBtn; + private Runnable onOverlayConfirm; + + // ── Promotion overlay ───────────────────────────────────────────────────── + private Table promotionOverlay; + + public GameOverlayManager(Stage stage, Skin skin, Player localPlayer, Listener listener) { + this.stage = stage; + this.skin = skin; + this.localPlayer = localPlayer; + this.listener = listener; + + buildGeneralOverlay(); + buildPromotionOverlay(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Visibility queries (used by GameScreen to block board taps) + // ───────────────────────────────────────────────────────────────────────── + + public boolean isAnyOverlayVisible() { + return overlayWrapper.isVisible() || promotionOverlay.isVisible(); + } + + public boolean isGeneralOverlayVisible() { + return overlayWrapper.isVisible(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Show helpers + // ───────────────────────────────────────────────────────────────────────── + + public void showForfeitConfirm() { + overlayTitle.setText("Forfeit?"); + overlayBody.setText("Are you sure you want to give up?"); + overlayConfirmBtn.setText("Yes, forfeit"); + overlayCancelBtn.setVisible(true); + onOverlayConfirm = listener::onForfeitConfirmed; + overlayWrapper.setVisible(true); + } + + public void showGameOver(boolean localWon) { + overlayTitle.setText(localWon ? "You Win!" : "Game Over"); + overlayBody.setText(localWon + ? "You captured the opponent's King!" + : "Your King was captured."); + 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."); + overlayConfirmBtn.setText("Back to Menu"); + overlayCancelBtn.setVisible(false); + onOverlayConfirm = listener::onGameOverBack; + overlayWrapper.setVisible(true); + } + + public void showPromotion() { + promotionOverlay.setVisible(true); + } + + // ───────────────────────────────────────────────────────────────────────── + // Construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildGeneralOverlay() { + overlayWrapper = new Table(); + overlayWrapper.setFillParent(true); + overlayWrapper.setVisible(false); + stage.addActor(overlayWrapper); + + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(32); + + overlayTitle = new Label("", skin, "title"); + overlayTitle.setAlignment(Align.center); + overlayBody = new Label("", skin, "default"); + overlayBody.setAlignment(Align.center); + overlayBody.setWrap(true); + + overlayConfirmBtn = new TextButton("", skin, "accent"); + overlayCancelBtn = new TextButton("Cancel", skin, "default"); + + card.add(overlayTitle).expandX().padBottom(16).row(); + card.add(overlayBody).width(300).padBottom(32).row(); + + Table btnRow = new Table(); + btnRow.add(overlayCancelBtn).width(130).height(55).padRight(12); + btnRow.add(overlayConfirmBtn).width(130).height(55); + card.add(btnRow).row(); + + overlayWrapper.add(card).center(); + + overlayConfirmBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + overlayWrapper.setVisible(false); + if (onOverlayConfirm != null) onOverlayConfirm.run(); + } + }); + overlayCancelBtn.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + overlayWrapper.setVisible(false); + } + }); + } + + private void buildPromotionOverlay() { + promotionOverlay = new Table(); + promotionOverlay.setFillParent(true); + promotionOverlay.setVisible(false); + stage.addActor(promotionOverlay); + + Table card = new Table(); + card.setBackground(skin.getDrawable("surface-pixel")); + card.pad(24); + + Label title = new Label("Promote Pawn", skin, "title"); + title.setAlignment(Align.center); + card.add(title).colspan(4).padBottom(20).row(); + + String color = localPlayer.isWhite() ? "white" : "black"; + String[] names = { "Queen", "Rook", "Bishop", "Knight" }; + + for (String pieceName : names) { + Table btn = new Table(); + btn.setBackground(skin.getDrawable("primary-pixel")); + btn.pad(8); + + Texture tex = ResourceManager.getInstance() + .getPieceTexture(color, pieceName.toLowerCase()); + btn.add(new Image(tex)).size(60).row(); + btn.add(new Label(pieceName, skin, "small")).row(); + + btn.setTouchable(com.badlogic.gdx.scenes.scene2d.Touchable.enabled); + btn.addListener(new ClickListener() { + @Override public void clicked(InputEvent event, float x, float y) { + promotionOverlay.setVisible(false); + listener.onPromotionChosen(pieceName); + } + }); + card.add(btn).size(100).pad(8); + } + + promotionOverlay.add(card).center(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..eecfd4e --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameScreen.java @@ -0,0 +1,452 @@ +package com.group14.regicidechess.screens.game; + +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.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +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.input.ScreenInputHandler; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Move; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.screens.game.GameBoardRenderer; +import com.group14.regicidechess.screens.game.GameNetworkHandler; +import com.group14.regicidechess.screens.game.GameOverlayManager; +import com.group14.regicidechess.screens.game.PieceFactory; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; +import com.group14.regicidechess.states.InMatchState; +import com.group14.regicidechess.utils.ResourceManager; + +import java.util.ArrayList; +import java.util.List; + +/** + * GameScreen — thin coordinator for an active chess match. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/GameScreen.java + * + * Responsibilities: + * - LibGDX Screen lifecycle (show, render, resize, dispose) + * - Building top-bar and status-bar UI + * - Board tap input and selection FSM + * - Delegating rendering → GameBoardRenderer + * - Delegating overlays → GameOverlayManager + * - Delegating Firebase I/O → GameNetworkHandler + * - Piece promotion logic → PieceFactory + */ +public class GameScreen implements Screen, + ScreenInputHandler.ScreenInputObserver, + GameOverlayManager.Listener, + GameNetworkHandler.Listener { + + // ── Layout ──────────────────────────────────────────────────────────────── + private static final float V_WIDTH = 480f; + 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; + + // ── LibGDX ──────────────────────────────────────────────────────────────── + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // ── Game state ──────────────────────────────────────────────────────────── + private final InMatchState inMatchState; + private final Player localPlayer; + private final String gameId; + private final int boardSize; + + // ── Selection FSM (logic coordinates) ──────────────────────────────────── + private Vector2 selectedCell = null; + private List validMoves = new ArrayList<>(); + + // ── Pending promotion ───────────────────────────────────────────────────── + private Move pendingPromotionMove = null; + + // ── Helpers ─────────────────────────────────────────────────────────────── + private final GameBoardRenderer boardRenderer; + private final GameOverlayManager overlayManager; + private final GameNetworkHandler networkHandler; + + // ── Widgets ─────────────────────────────────────────────────────────────── + private Label turnLabel; + private Label statusLabel; + private float heartbeatTimer = 0f; + + // ───────────────────────────────────────────────────────────────────────── + // Constructor + // ───────────────────────────────────────────────────────────────────────── + + public GameScreen(Game game, SpriteBatch batch, + Board board, Player localPlayer, int boardSize, String gameId) { + this.game = game; + this.batch = batch; + this.localPlayer = localPlayer; + this.gameId = gameId; + this.boardSize = boardSize; + + Player opponent = new Player( + localPlayer.isWhite() ? "player2" : "player1", + !localPlayer.isWhite(), + localPlayer.getBudget()); + + inMatchState = new InMatchState(); + inMatchState.init(board, + localPlayer.isWhite() ? localPlayer : opponent, + localPlayer.isWhite() ? opponent : localPlayer); + inMatchState.enter(); + + stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); + skin = ResourceManager.getInstance().getSkin(); + 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); + + buildUI(connectionIcon, connectionLabel); + + overlayManager = new GameOverlayManager(stage, skin, localPlayer, this); + networkHandler = new GameNetworkHandler(gameId, localPlayer, this, + connectionIcon, connectionLabel); + boardRenderer = new GameBoardRenderer(batch, localPlayer, boardSize); + boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT); + + networkHandler.start(); + refreshTurnLabel(); + } + + // ───────────────────────────────────────────────────────────────────────── + // UI construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildUI(Image connectionIcon, Label connectionLabel) { + Table root = new Table(); + root.setFillParent(true); + 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.addListener(new ChangeListener() { + @Override public void changed(ChangeEvent event, Actor actor) { + overlayManager.showForfeitConfirm(); + } + }); + + turnLabel = new Label("", skin, "title"); + turnLabel.setAlignment(Align.center); + + Table connGroup = new Table(); + connGroup.add(connectionIcon).size(12, 12).padRight(6); + connGroup.add(connectionLabel).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); + statusBar.add(statusLabel).expandX(); + root.add(statusBar).expandX().fillX().height(STATUS_BAR_HEIGHT).row(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Rendering + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void render(float delta) { + Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); + 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); + + stage.act(delta); + stage.draw(); + + // Draw a semi-transparent dimmer behind any visible overlay + if (overlayManager.isAnyOverlayVisible()) { + 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, V_WIDTH, V_HEIGHT); + sr.end(); + sr.dispose(); + Gdx.gl.glDisable(GL20.GL_BLEND); + stage.draw(); // redraw so overlay card appears above dimmer + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Input — ScreenInputObserver + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int screenX, int screenY, int pointer, int button) { + if (overlayManager.isAnyOverlayVisible()) return; + Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); + handleBoardTap(world.x, world.y); + } + @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) {} + + private void handleBoardTap(float worldX, float worldY) { + if (!inMatchState.isMyTurn(localPlayer)) { + showStatus("Not your turn."); + return; + } + + float cellSize = boardRenderer.getCellSize(); + float boardLeft = boardRenderer.getBoardLeft(); + float boardBot = boardRenderer.getBoardBottom(); + int size = inMatchState.getBoard().getSize(); + + if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; + if (worldY < boardBot || worldY > boardBot + size * cellSize) return; + + int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int)((worldY - boardBot) / cellSize), size - 1)); + int logicRow = boardRenderer.toLogicRow(dispRow); + + Vector2 tapped = new Vector2(col, logicRow); + + if (selectedCell == null) { + trySelect(tapped); + } else if (containsVector(validMoves, tapped)) { + executeMove(selectedCell, tapped); + } else { + ChessPiece occupant = inMatchState.getBoard().getPieceAt(col, logicRow); + if (occupant != null && occupant.getOwner() == localPlayer) { + trySelect(tapped); + } else { + deselect(); + } + } + } + + private void trySelect(Vector2 cell) { + ChessPiece piece = inMatchState.getBoard().getPieceAt((int) cell.x, (int) cell.y); + if (piece == null || piece.getOwner() != localPlayer) { + showStatus("Select one of your own pieces."); + deselect(); + return; + } + selectedCell = cell; + validMoves = piece.validMoves(); + showStatus("Selected: " + piece.getTypeName() + " — " + validMoves.size() + " move(s)."); + } + + private void deselect() { + selectedCell = null; + validMoves.clear(); + showStatus("Select a piece to move."); + } + + // ───────────────────────────────────────────────────────────────────────── + // Move execution + // ───────────────────────────────────────────────────────────────────────── + + private void executeMove(Vector2 from, Vector2 to) { + ChessPiece movingPiece = inMatchState.getBoard().getPieceAt(from); + if (movingPiece == null) { deselect(); return; } + + // Execute locally — this also switches the turn inside InMatchState + ChessPiece captured = inMatchState.executeMove(from, to); + deselect(); + + if (captured instanceof King) { + networkHandler.saveMove(new Move(from, to, movingPiece, localPlayer)); + refreshTurnLabel(); + overlayManager.showGameOver(true); + + } else if (isPawnPromotion(movingPiece, to)) { + // Do NOT save yet — wait for the player to pick a promotion piece + pendingPromotionMove = new Move(from, to, movingPiece, localPlayer); + overlayManager.showPromotion(); + + } else { + networkHandler.saveMove(new Move(from, to, movingPiece, localPlayer)); + refreshTurnLabel(); + showStatus("Waiting for opponent..."); + } + } + + private boolean isPawnPromotion(ChessPiece piece, Vector2 to) { + if (!(piece instanceof Pawn)) return false; + int rank = piece.getOwner().isWhite() ? inMatchState.getBoard().getSize() - 1 : 0; + return (int) to.y == rank; + } + + // ───────────────────────────────────────────────────────────────────────── + // GameOverlayManager.Listener + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onForfeitConfirmed() { + networkHandler.signalForfeit(); + overlayManager.showGameOver(false); + } + + @Override + public void onGameOverBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + + /** + * Promotion piece chosen by the local player. + * Replaces the pawn on the board and sends the complete move to Firebase. + * Turn was already switched by executeMove(); just refresh the label. + */ + @Override + public void onPromotionChosen(String pieceName) { + if (pendingPromotionMove == null) return; + + ChessPiece promoted = PieceFactory.fromName(pieceName, localPlayer); + Vector2 toCell = pendingPromotionMove.getTo(); + inMatchState.getBoard().placePiece(promoted, (int) toCell.x, (int) toCell.y); + + networkHandler.saveMove(new Move( + pendingPromotionMove.getFrom(), + pendingPromotionMove.getTo(), + pendingPromotionMove.getPiece(), + localPlayer, + pieceName + )); + + refreshTurnLabel(); + showStatus("Waiting for opponent..."); + pendingPromotionMove = null; + } + + // ───────────────────────────────────────────────────────────────────────── + // GameNetworkHandler.Listener + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onOpponentMove(Vector2 from, Vector2 to, int promoCode) { + ChessPiece piece = inMatchState.getBoard().getPieceAt(from); + if (piece == null || piece.getOwner() == localPlayer) return; + + Player opponent = piece.getOwner(); + ChessPiece captured = inMatchState.executeMove(from, to); + + if (promoCode != 0) { + ChessPiece promoted = PieceFactory.fromCode(promoCode, opponent); + if (promoted != null) { + inMatchState.getBoard().placePiece(promoted, (int) to.x, (int) to.y); + } + } + + deselect(); + refreshTurnLabel(); + + if (captured instanceof King) overlayManager.showGameOver(false); + else showStatus("Your turn!"); + } + + @Override + public void onGameOver(String reason) { + if (!overlayManager.isGeneralOverlayVisible()) { + overlayManager.showForfeitReceived(reason); + } + } + + @Override public void onHeartbeatLatency(long latencyMs) { /* handled by GameNetworkHandler */ } + + @Override + public void onOpponentDisconnected() { + showStatus("Opponent disconnected."); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private void refreshTurnLabel() { + turnLabel.setText(inMatchState.isMyTurn(localPlayer) ? "Your turn" : "Opponent's turn"); + } + + private void showStatus(String msg) { statusLabel.setText(msg); } + + private boolean containsVector(List list, Vector2 v) { + for (Vector2 item : list) + if ((int) item.x == (int) v.x && (int) item.y == (int) v.y) return true; + return false; + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT); + } + + @Override public void pause() {} + @Override public void resume() {} + + @Override + public void hide() { + inputHandler.clearObservers(); + inMatchState.exit(); + } + + @Override + public void dispose() { + stage.dispose(); + boardRenderer.dispose(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java b/core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java new file mode 100644 index 0000000..b8418e5 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java @@ -0,0 +1,59 @@ +package com.group14.regicidechess.screens.game; + +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; + +/** + * PieceFactory — stateless helpers for building promotion pieces. + * + * Placement: core/src/main/java/com/group14/regicidechess/screens/game/PieceFactory.java + * + * Promotion codes (shared between GameScreen and AndroidFirebase): + * 2 = Queen, 3 = Rook, 4 = Bishop, 5 = Knight + */ +public final class PieceFactory { + + private PieceFactory() {} + + /** + * Builds a promotion piece from a display name. + * Falls back to Knight for unrecognised names. + */ + public static ChessPiece fromName(String name, Player owner) { + switch (name) { + case "Queen": return new Queen(owner); + case "Rook": return new Rook(owner); + case "Bishop": return new Bishop(owner); + default: return new Knight(owner); + } + } + + /** + * Builds a promotion piece from a Firebase promotion code. + * Returns null for code 0 (no promotion). + */ + public static ChessPiece fromCode(int code, Player owner) { + switch (code) { + case 2: return new Queen(owner); + case 3: return new Rook(owner); + case 4: return new Bishop(owner); + case 5: return new Knight(owner); + default: return null; + } + } + + /** Returns the Firebase promotion code for a piece name. */ + public static int toCode(String name) { + switch (name) { + case "Queen": return 2; + case "Rook": return 3; + case "Bishop": return 4; + case "Knight": return 5; + default: return 0; + } + } +} \ No newline at end of file From 26118d5ba9cd541ce0f3f5e8695782dbf6c59c81 Mon Sep 17 00:00:00 2001 From: benjamls Date: Tue, 24 Mar 2026 17:44:14 +0100 Subject: [PATCH 12/14] Improve modularity for setupscreen --- .../regicidechess/screens/SetupScreen.java | 558 ------------------ .../screens/setup/BoardCoordinateMapper.java | 43 ++ .../screens/setup/BoardFetchRetryPolicy.java | 45 ++ .../screens/setup/SetupBoardCodec.java | 73 +++ .../screens/setup/SetupBoardInputHandler.java | 90 +++ .../screens/setup/SetupBoardRenderer.java | 150 +++++ .../screens/setup/SetupFlowController.java | 109 ++++ .../screens/setup/SetupFooterWidget.java | 92 +++ .../screens/setup/SetupHeaderWidget.java | 45 ++ .../screens/setup/SetupPaletteWidget.java | 150 +++++ .../screens/setup/SetupScreen.java | 360 +++++++++++ .../screens/setup/SetupScreenConfig.java | 22 + .../regicidechess/states/SetupState.java | 103 +++- 13 files changed, 1269 insertions(+), 571 deletions(-) delete mode 100644 core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java diff --git a/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java b/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java deleted file mode 100644 index a63885e..0000000 --- a/core/src/main/java/com/group14/regicidechess/screens/SetupScreen.java +++ /dev/null @@ -1,558 +0,0 @@ -package com.group14.regicidechess.screens; - -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.Texture; -import com.badlogic.gdx.graphics.g2d.SpriteBatch; -import com.badlogic.gdx.graphics.glutils.ShapeRenderer; -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.Label; -import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane; -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.DatabaseManager; -import com.group14.regicidechess.input.ScreenInputHandler; -import com.group14.regicidechess.model.Player; -import com.group14.regicidechess.model.pieces.Bishop; -import com.group14.regicidechess.model.pieces.ChessPiece; -import com.group14.regicidechess.model.pieces.King; -import com.group14.regicidechess.model.pieces.Knight; -import com.group14.regicidechess.model.pieces.Pawn; -import com.group14.regicidechess.model.pieces.Queen; -import com.group14.regicidechess.model.pieces.Rook; -import com.group14.regicidechess.states.SetupState; -import com.group14.regicidechess.utils.ResourceManager; - -public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - - // ── Piece type codes (must match decodePiece) ───────────────────────────── - public static final int CODE_KING = 1; - public static final int CODE_QUEEN = 2; - public static final int CODE_ROOK = 3; - public static final int CODE_BISHOP = 4; - public static final int CODE_KNIGHT = 5; - public static final int CODE_PAWN = 6; - - // ── Layout constants ────────────────────────────────────────────────────── - private static final float V_WIDTH = 480f; - private static final float V_HEIGHT = 854f; - private static final float HEADER_HEIGHT = 80f; - private static final float PALETTE_HEIGHT = 130f; - private static final float FOOTER_HEIGHT = 70f; - - private static final String[] PIECE_NAMES = { "King", "Queen", "Rook", "Bishop", "Knight", "Pawn" }; - private static final int[] PIECE_COSTS = { 0, 9, 5, 3, 3, 1 }; - - // ── LibGDX / GUI ────────────────────────────────────────────────────────── - private final Game game; - private final SpriteBatch batch; - private final Stage stage; - private final Skin skin; - private final ScreenInputHandler inputHandler; - private final ShapeRenderer shapeRenderer; - - // ── State ───────────────────────────────────────────────────────────────── - private final SetupState setupState; - private final Player localPlayer; - private final String gameId; - private final boolean isHost; - - // ── Palette ─────────────────────────────────────────────────────────────── - private int selectedPieceIndex = -1; - private TextButton[] paletteButtons; - - // ── Board geometry ──────────────────────────────────────────────────────── - private float boardLeft; - private float boardBottom; - private float cellSize; - - // ── Board flip helpers ──────────────────────────────────────────────────── - // White sees row 0 at the bottom (normal). Black is flipped: row (size-1) at bottom. - private int toDisplayRow(int logicRow, int size) { - return localPlayer.isWhite() ? logicRow : (size - 1 - logicRow); - } - private int toLogicRow(int displayRow, int size) { - return localPlayer.isWhite() ? displayRow : (size - 1 - displayRow); - } - - // ── Widgets ─────────────────────────────────────────────────────────────── - private Label budgetLabel; - private Label statusLabel; - private TextButton confirmBtn; - private Label waitingLabel; - - // ── Retry state for board fetch ─────────────────────────────────────────── - private static final int BOARD_FETCH_MAX_RETRIES = 5; - private static final long BOARD_FETCH_RETRY_MS = 600; - private int boardFetchRetries = 0; - - // ───────────────────────────────────────────────────────────────────────── - // Constructor - // ───────────────────────────────────────────────────────────────────────── - - public SetupScreen(Game game, SpriteBatch batch, - String gameId, int boardSize, int budget, boolean isHost) { - this.game = game; - this.batch = batch; - this.gameId = gameId; - this.isHost = isHost; - - // Host = white (player1), joiner = black (player2) - localPlayer = new Player(isHost ? "player1" : "player2", isHost, budget); - - setupState = new SetupState(); - setupState.setBoardSize(boardSize); - setupState.setBudget(budget); - setupState.setPlayer(localPlayer); - setupState.enter(); - - stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); - skin = ResourceManager.getInstance().getSkin(); - shapeRenderer = new ShapeRenderer(); - inputHandler = new ScreenInputHandler(); - inputHandler.addObserver(this); - - buildUI(); - computeBoardGeometry(V_WIDTH, V_HEIGHT); - } - - // ───────────────────────────────────────────────────────────────────────── - // UI construction - // ───────────────────────────────────────────────────────────────────────── - - private void buildUI() { - Table root = new Table(); - root.setFillParent(true); - root.top(); - stage.addActor(root); - - Table header = new Table(); - header.setBackground(skin.getDrawable("primary-pixel")); - header.pad(12); - Label titleLabel = new Label("SETUP", skin, "title"); - budgetLabel = new Label(budgetText(), skin); - header.add(titleLabel).expandX().left(); - header.add(budgetLabel).expandX().right(); - root.add(header).expandX().fillX().height(HEADER_HEIGHT).row(); - - root.add().expandX().expandY().row(); - - Table paletteWrapper = new Table(); - paletteWrapper.setBackground(skin.getDrawable("primary-dark-pixel")); - paletteWrapper.pad(8); - Table palette = new Table(); - paletteButtons = new TextButton[PIECE_NAMES.length]; - for (int i = 0; i < PIECE_NAMES.length; i++) { - final int idx = i; - String btnLabel = "\n\n" + PIECE_NAMES[i] - + "\n[" + (PIECE_COSTS[i] == 0 ? "free" : PIECE_COSTS[i]) + "]"; - TextButton btn = new TextButton(btnLabel, skin, "default"); - btn.getLabel().setAlignment(Align.center); - paletteButtons[i] = btn; - btn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { selectPalette(idx); } - }); - palette.add(btn).width(68).height(90).pad(4); - } - ScrollPane scroll = new ScrollPane(palette, skin); - scroll.setScrollingDisabled(false, true); - paletteWrapper.add(scroll).expandX().fillX().height(100); - root.add(paletteWrapper).expandX().fillX().height(PALETTE_HEIGHT).row(); - - Table footer = new Table(); - footer.setBackground(skin.getDrawable("surface-pixel")); - footer.pad(10); - TextButton clearBtn = new TextButton("Clear", skin, "danger"); - confirmBtn = new TextButton("Confirm", skin, "accent"); - confirmBtn.setDisabled(true); - statusLabel = new Label("Place your King to continue", skin, "small"); - statusLabel.setAlignment(Align.center); - waitingLabel = new Label("Waiting for opponent...", skin, "small"); - waitingLabel.setAlignment(Align.center); - waitingLabel.setVisible(false); - clearBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { onClear(); } - }); - confirmBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - if (!confirmBtn.isDisabled()) onConfirm(); - } - }); - footer.add(clearBtn).width(140).height(50).expandX().left(); - footer.add(statusLabel).expandX(); - footer.add(confirmBtn).width(140).height(50).expandX().right(); - root.add(footer).expandX().fillX().height(FOOTER_HEIGHT).row(); - root.add(waitingLabel).expandX().padTop(8).row(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Board geometry - // ───────────────────────────────────────────────────────────────────────── - - private void computeBoardGeometry(float screenW, float screenH) { - float available = screenH - HEADER_HEIGHT - PALETTE_HEIGHT - FOOTER_HEIGHT - 16f; - float maxW = screenW - 16f; - int size = setupState.getBoardSize(); - cellSize = Math.min(maxW / size, available / size); - boardLeft = (screenW - cellSize * size) / 2f; - boardBottom = FOOTER_HEIGHT + PALETTE_HEIGHT + (available - cellSize * size) / 2f + 8f; - } - - // ───────────────────────────────────────────────────────────────────────── - // Rendering - // ───────────────────────────────────────────────────────────────────────── - - @Override - public void render(float delta) { - Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); - Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); - setupState.update(delta); - drawBoard(); - stage.act(delta); - stage.draw(); - drawPaletteSprites(); - } - - private void drawBoard() { - int size = setupState.getBoardSize(); - shapeRenderer.setProjectionMatrix(stage.getCamera().combined); - - // Tiles - shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); - for (int col = 0; col < size; col++) { - for (int logicRow = 0; logicRow < size; logicRow++) { - int dispRow = toDisplayRow(logicRow, size); - float x = boardLeft + col * cellSize; - float y = boardBottom + dispRow * cellSize; - boolean light = (col + logicRow) % 2 == 0; - boolean home = logicRow >= setupState.getHomeRowMin() - && logicRow <= setupState.getHomeRowMax(); - if (home) { - shapeRenderer.setColor(light - ? new Color(0.93f, 0.90f, 0.75f, 1f) - : new Color(0.45f, 0.62f, 0.32f, 1f)); - } else { - shapeRenderer.setColor(light - ? new Color(0.93f, 0.85f, 0.72f, 1f) - : new Color(0.55f, 0.38f, 0.24f, 1f)); - } - shapeRenderer.rect(x, y, cellSize, cellSize); - } - } - shapeRenderer.end(); - - // Grid lines - shapeRenderer.begin(ShapeRenderer.ShapeType.Line); - shapeRenderer.setColor(new Color(0f, 0f, 0f, 0.3f)); - for (int i = 0; i <= size; i++) { - float x = boardLeft + i * cellSize; - float y = boardBottom + i * cellSize; - shapeRenderer.line(x, boardBottom, x, boardBottom + size * cellSize); - shapeRenderer.line(boardLeft, y, boardLeft + size * cellSize, y); - } - shapeRenderer.end(); - - // Home zone border - shapeRenderer.begin(ShapeRenderer.ShapeType.Line); - shapeRenderer.setColor(new Color(0.4f, 0.85f, 0.4f, 0.9f)); - Gdx.gl.glLineWidth(3f); - int homeDispMin = toDisplayRow(setupState.getHomeRowMin(), size); - int homeDispMax = toDisplayRow(setupState.getHomeRowMax(), size); - float zoneBottom = boardBottom + Math.min(homeDispMin, homeDispMax) * cellSize; - float zoneTop = boardBottom + (Math.max(homeDispMin, homeDispMax) + 1) * cellSize; - shapeRenderer.rect(boardLeft, zoneBottom, size * cellSize, zoneTop - zoneBottom); - Gdx.gl.glLineWidth(1f); - shapeRenderer.end(); - - // Placed pieces - batch.setProjectionMatrix(stage.getCamera().combined); - batch.begin(); - for (ChessPiece piece : setupState.getBoard().getPieces()) { - Vector2 pos = piece.getPosition(); - int col = (int) pos.x; - int dispRow = toDisplayRow((int) pos.y, size); - String color = localPlayer.isWhite() ? "white" : "black"; - Texture tex = ResourceManager.getInstance().getPieceTexture(color, piece.getTypeName().toLowerCase()); - float pieceSize = cellSize * 0.8f; - float offset = (cellSize - pieceSize) / 2f; - batch.draw(tex, - boardLeft + col * cellSize + offset, - boardBottom + dispRow * cellSize + offset, - pieceSize, pieceSize); - } - batch.end(); - } - - private void drawPaletteSprites() { - String color = localPlayer.isWhite() ? "white" : "black"; - batch.setProjectionMatrix(stage.getCamera().combined); - batch.begin(); - for (int i = 0; i < paletteButtons.length; i++) { - TextButton btn = paletteButtons[i]; - Texture tex = ResourceManager.getInstance().getPieceTexture(color, PIECE_NAMES[i].toLowerCase()); - float btnW = btn.getWidth(), btnH = btn.getHeight(); - Vector2 sp = btn.localToStageCoordinates(new Vector2(0, 0)); - float s = Math.min(btnW, btnH) * 0.42f; - batch.draw(tex, sp.x + (btnW - s) / 2f, sp.y + btnH - s - 4f, s, s); - } - batch.end(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Input - // ───────────────────────────────────────────────────────────────────────── - - @Override - public void onTap(int screenX, int screenY, int pointer, int button) { - Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); - handleBoardTap(world.x, world.y); - } - @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) {} - - private void handleBoardTap(float worldX, float worldY) { - int size = setupState.getBoardSize(); - if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return; - if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return; - - int col = Math.max(0, Math.min((int)((worldX - boardLeft) / cellSize), size - 1)); - int dispRow = Math.max(0, Math.min((int)((worldY - boardBottom) / cellSize), size - 1)); - int row = toLogicRow(dispRow, size); // display row → logic row - - ChessPiece existing = setupState.getBoard().getPieceAt(col, row); - if (existing != null) { - setupState.removePiece(col, row); - } else if (selectedPieceIndex >= 0) { - ChessPiece piece = createPiece(selectedPieceIndex); - boolean ok = setupState.placePiece(piece, col, row); - if (!ok) { - showStatus(piece instanceof King && kingIsOnBoard() - ? "You can only place one King!" - : "Cannot place here — check home zone and budget."); - } - } - refreshUI(); - } - - private ChessPiece createPiece(int idx) { - switch (idx) { - case 0: return new King(localPlayer); - case 1: return new Queen(localPlayer); - case 2: return new Rook(localPlayer); - case 3: return new Bishop(localPlayer); - case 4: return new Knight(localPlayer); - case 5: return new Pawn(localPlayer); - default: throw new IllegalArgumentException("Unknown piece index: " + idx); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Palette - // ───────────────────────────────────────────────────────────────────────── - - private void selectPalette(int idx) { - selectedPieceIndex = (selectedPieceIndex == idx) ? -1 : idx; - for (int i = 0; i < paletteButtons.length; i++) { - paletteButtons[i].setStyle(skin.get( - i == selectedPieceIndex ? "accent" : "default", - TextButton.TextButtonStyle.class)); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Actions - // ───────────────────────────────────────────────────────────────────────── - - private void onClear() { - setupState.clearBoard(); - refreshUI(); - } - - private void onConfirm() { - if (!setupState.setReady()) { - showStatus("Place your King before confirming."); - return; - } - - confirmBtn.setDisabled(true); - confirmBtn.setVisible(false); - waitingLabel.setVisible(true); - showStatus("Waiting for opponent to finish setup..."); - - int[][] boardState = convertBoardToArray(); - - // Step 1: Write our board to Firebase and mark ourselves ready. - // confirmSetup() sets bothReady=true once BOTH players have called it. - DatabaseManager.getInstance().getApi().confirmSetup( - gameId, - localPlayer.isWhite(), - boardState, - () -> Gdx.app.postRunnable(this::listenForBothReady) - ); - } - - /** - * Step 2: Listen for games/{gameId}/bothReady == true. - * This only becomes true after BOTH players have written their boards. - * Uses the dedicated listenForBothSetupReady() so it is completely separate - * from the lobby-phase listenForGameStart() (which watches lobbies/{id}/status). - */ - private void listenForBothReady() { - DatabaseManager.getInstance().getApi().listenForBothSetupReady( - gameId, - () -> Gdx.app.postRunnable(this::fetchOpponentBoardAndNavigate) - ); - } - - /** - * Step 3: Fetch opponent's board, then navigate. - * - * There is a small race: bothReady fires as soon as the second player writes - * "ready", but their board write may not yet have propagated to our local - * Firebase cache. We retry a few times if the board comes back empty. - */ - private void fetchOpponentBoardAndNavigate() { - DatabaseManager.getInstance().getApi().getOpponentBoard( - gameId, - localPlayer.isWhite(), - opponentBoard -> Gdx.app.postRunnable(() -> { - boolean boardEmpty = opponentBoard == null || opponentBoard.length == 0; - if (boardEmpty && boardFetchRetries < BOARD_FETCH_MAX_RETRIES) { - boardFetchRetries++; - new java.util.Timer().schedule(new java.util.TimerTask() { - @Override public void run() { - Gdx.app.postRunnable(() -> fetchOpponentBoardAndNavigate()); - } - }, BOARD_FETCH_RETRY_MS); - return; - } - - mergeOpponentBoard(opponentBoard); - game.setScreen(new GameScreen( - game, batch, - setupState.getBoard(), - localPlayer, - setupState.getBoardSize(), - gameId)); - }) - ); - } - - /** - * Decodes the opponent's board array and places their pieces onto the shared board. - * Coordinates are already in logic space (as stored by the opponent's convertBoardToArray). - */ - private void mergeOpponentBoard(int[][] opponentBoard) { - if (opponentBoard == null || opponentBoard.length == 0) return; - - Player opponentPlayer = new Player( - localPlayer.isWhite() ? "player2" : "player1", - !localPlayer.isWhite(), - localPlayer.getBudget()); - - for (int col = 0; col < opponentBoard.length; col++) { - for (int row = 0; row < opponentBoard[col].length; row++) { - int code = opponentBoard[col][row]; - if (code == 0) continue; - ChessPiece piece = decodePiece(code, opponentPlayer); - if (piece != null) setupState.getBoard().placePiece(piece, col, row); - } - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Serialisation helpers - // ───────────────────────────────────────────────────────────────────────── - - private int[][] convertBoardToArray() { - int size = setupState.getBoardSize(); - int[][] boardState = new int[size][size]; - for (ChessPiece piece : setupState.getBoard().getPieces()) { - int col = (int) piece.getPosition().x; - int row = (int) piece.getPosition().y; - boardState[col][row] = getPieceTypeCode(piece); - } - return boardState; - } - - private int getPieceTypeCode(ChessPiece piece) { - if (piece instanceof King) return CODE_KING; - if (piece instanceof Queen) return CODE_QUEEN; - if (piece instanceof Rook) return CODE_ROOK; - if (piece instanceof Bishop) return CODE_BISHOP; - if (piece instanceof Knight) return CODE_KNIGHT; - if (piece instanceof Pawn) return CODE_PAWN; - return 0; - } - - public static ChessPiece decodePiece(int code, Player owner) { - switch (code) { - case CODE_KING: return new King(owner); - case CODE_QUEEN: return new Queen(owner); - case CODE_ROOK: return new Rook(owner); - case CODE_BISHOP: return new Bishop(owner); - case CODE_KNIGHT: return new Knight(owner); - case CODE_PAWN: return new Pawn(owner); - default: return null; - } - } - - // ───────────────────────────────────────────────────────────────────────── - // UI refresh - // ───────────────────────────────────────────────────────────────────────── - - private void refreshUI() { - budgetLabel.setText(budgetText()); - confirmBtn.setDisabled(!kingIsOnBoard()); - showStatus(kingIsOnBoard() ? "Ready! Press Confirm when done." : "Place your King to continue."); - } - - private boolean kingIsOnBoard() { - for (ChessPiece p : setupState.getBoard().getPieces()) - if (p instanceof King) return true; - return false; - } - - private String budgetText() { - return "Budget: " + localPlayer.getBudgetRemaining() + " / " + localPlayer.getBudget(); - } - - private void showStatus(String msg) { statusLabel.setText(msg); } - - // ───────────────────────────────────────────────────────────────────────── - // Screen lifecycle - // ───────────────────────────────────────────────────────────────────────── - - @Override - public void show() { - com.badlogic.gdx.InputMultiplexer mx = - new com.badlogic.gdx.InputMultiplexer(stage, inputHandler); - Gdx.input.setInputProcessor(mx); - } - - @Override - public void resize(int width, int height) { - stage.getViewport().update(width, height, true); - computeBoardGeometry(V_WIDTH, V_HEIGHT); - } - - @Override public void pause() {} - @Override public void resume() {} - @Override public void hide() { inputHandler.clearObservers(); setupState.exit(); } - - @Override - public void dispose() { - stage.dispose(); - shapeRenderer.dispose(); - } -} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java new file mode 100644 index 0000000..e2cbb8a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java @@ -0,0 +1,43 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/BoardCoordinateMapper.java +package com.group14.regicidechess.screens.setup; + +import com.group14.regicidechess.model.Player; + +/** + * Handles conversion between logic coordinates and display coordinates. + * White: display = logic + * Black: display = (size - 1 - logic) + */ +public class BoardCoordinateMapper { + private final Player localPlayer; + private final int boardSize; + + public BoardCoordinateMapper(Player localPlayer, int boardSize) { + this.localPlayer = localPlayer; + this.boardSize = boardSize; + } + + public int toDisplayRow(int logicRow) { + return localPlayer.isWhite() ? logicRow : (boardSize - 1 - logicRow); + } + + public int toLogicRow(int displayRow) { + return localPlayer.isWhite() ? displayRow : (boardSize - 1 - displayRow); + } + + public int getHomeDisplayRowMin(int homeRowMin) { + return toDisplayRow(homeRowMin); + } + + public int getHomeDisplayRowMax(int homeRowMax) { + return toDisplayRow(homeRowMax); + } + + public Player getLocalPlayer() { + return localPlayer; + } + + public int getBoardSize() { + return boardSize; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java new file mode 100644 index 0000000..2fa38be --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java @@ -0,0 +1,45 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/BoardFetchRetryPolicy.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * Encapsulates retry logic for fetching opponent's board. + */ +public class BoardFetchRetryPolicy { + private final int maxRetries; + private final long retryDelayMs; + + public BoardFetchRetryPolicy() { + this(SetupScreenConfig.BOARD_FETCH_MAX_RETRIES, SetupScreenConfig.BOARD_FETCH_RETRY_MS); + } + + public BoardFetchRetryPolicy(int maxRetries, long retryDelayMs) { + this.maxRetries = maxRetries; + this.retryDelayMs = retryDelayMs; + } + + public boolean shouldRetry(int currentRetryCount) { + return currentRetryCount < maxRetries; + } + + public void scheduleRetry(Runnable retryAction, int retryNumber) { + new Timer().schedule(new TimerTask() { + @Override + public void run() { + Gdx.app.postRunnable(retryAction); + } + }, retryDelayMs); + } + + public int getMaxRetries() { + return maxRetries; + } + + public long getRetryDelayMs() { + return retryDelayMs; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java new file mode 100644 index 0000000..2b54411 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java @@ -0,0 +1,73 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardCodec.java +package com.group14.regicidechess.screens.setup; + +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; + +/** + * SetupBoardCodec — converts between a Board's pieces and the int[][] format + * used by Firebase. + */ +public final class SetupBoardCodec { + + public static final int CODE_KING = 1; + public static final int CODE_QUEEN = 2; + public static final int CODE_ROOK = 3; + public static final int CODE_BISHOP = 4; + public static final int CODE_KNIGHT = 5; + public static final int CODE_PAWN = 6; + + private SetupBoardCodec() {} + + public static int[][] encode(Board board) { + int size = board.getSize(); + int[][] state = new int[size][size]; + for (ChessPiece piece : board.getPieces()) { + int col = (int) piece.getPosition().x; + int row = (int) piece.getPosition().y; + state[col][row] = toCode(piece); + } + return state; + } + + public static void mergeInto(int[][] encoded, Player owner, Board board) { + if (encoded == null || encoded.length == 0) return; + for (int col = 0; col < encoded.length; col++) { + for (int row = 0; row < encoded[col].length; row++) { + int code = encoded[col][row]; + if (code == 0) continue; + ChessPiece piece = decode(code, owner); + if (piece != null) board.placePiece(piece, col, row); + } + } + } + + public static int toCode(ChessPiece piece) { + if (piece instanceof King) return CODE_KING; + if (piece instanceof Queen) return CODE_QUEEN; + if (piece instanceof Rook) return CODE_ROOK; + if (piece instanceof Bishop) return CODE_BISHOP; + if (piece instanceof Knight) return CODE_KNIGHT; + if (piece instanceof Pawn) return CODE_PAWN; + return 0; + } + + public static ChessPiece decode(int code, Player owner) { + switch (code) { + case CODE_KING: return new King(owner); + case CODE_QUEEN: return new Queen(owner); + case CODE_ROOK: return new Rook(owner); + case CODE_BISHOP: return new Bishop(owner); + case CODE_KNIGHT: return new Knight(owner); + case CODE_PAWN: return new Pawn(owner); + default: return null; + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java new file mode 100644 index 0000000..bb8b67a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java @@ -0,0 +1,90 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardInputHandler.java +package com.group14.regicidechess.screens.setup; + +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.states.SetupState; + +/** + * Handles board tap input for the setup screen. + */ +public class SetupBoardInputHandler { + + public interface BoardActionListener { + void onPiecePlaced(ChessPiece piece, int col, int row); + void onPieceRemoved(int col, int row); + void onInvalidPlacement(String reason); + void onStateChanged(); + } + + private final SetupState setupState; + private final SetupBoardRenderer renderer; + private final SetupPaletteWidget palette; + private final Player localPlayer; + private final BoardActionListener listener; + + public SetupBoardInputHandler(SetupState setupState, SetupBoardRenderer renderer, + SetupPaletteWidget palette, Player localPlayer, + BoardActionListener listener) { + this.setupState = setupState; + this.renderer = renderer; + this.palette = palette; + this.localPlayer = localPlayer; + this.listener = listener; + } + + public boolean handleTap(float worldX, float worldY) { + float cellSize = renderer.getCellSize(); + float boardLeft = renderer.getBoardLeft(); + float boardBottom = renderer.getBoardBottom(); + int size = setupState.getBoardSize(); + + if (worldX < boardLeft || worldX > boardLeft + size * cellSize) return false; + if (worldY < boardBottom || worldY > boardBottom + size * cellSize) return false; + + int col = Math.max(0, Math.min((int) ((worldX - boardLeft) / cellSize), size - 1)); + int dispRow = Math.max(0, Math.min((int) ((worldY - boardBottom) / cellSize), size - 1)); + int row = renderer.getCoordinateMapper().toLogicRow(dispRow); + + ChessPiece existing = setupState.getBoard().getPieceAt(col, row); + + if (existing != null) { + // Remove existing piece + setupState.removePiece(col, row); + listener.onPieceRemoved(col, row); + listener.onStateChanged(); + } else if (palette.hasSelection()) { + ChessPiece piece = palette.createSelectedPiece(localPlayer); + boolean success = setupState.placePiece(piece, col, row); + + if (success) { + // IMPORTANT: Do NOT clear selection - keep it so player can place more + // palette.afterPiecePlaced() is called but we don't clear selection + // The palette's keepSelectionAfterPlace flag is true by default + palette.afterPiecePlaced(); // This won't clear if keepSelectionAfterPlace is true + listener.onPiecePlaced(piece, col, row); + listener.onStateChanged(); + } else { + String reason = getPlacementFailureReason(piece); + listener.onInvalidPlacement(reason); + } + } + + return true; + } + + private String getPlacementFailureReason(ChessPiece piece) { + if (piece instanceof King && kingIsOnBoard()) { + return "You can only place one King!"; + } + return "Cannot place here — check home zone and budget."; + } + + private boolean kingIsOnBoard() { + for (ChessPiece p : setupState.getBoard().getPieces()) { + if (p instanceof King) return true; + } + return false; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java new file mode 100644 index 0000000..787ab24 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java @@ -0,0 +1,150 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupBoardRenderer.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Matrix4; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.states.SetupState; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * SetupBoardRenderer — draws the setup-phase board and palette piece sprites. + */ +public class SetupBoardRenderer { + + private static final Color HOME_LIGHT = new Color(0.93f, 0.90f, 0.75f, 1f); + private static final Color HOME_DARK = new Color(0.45f, 0.62f, 0.32f, 1f); + private static final Color PLAIN_LIGHT = new Color(0.93f, 0.85f, 0.72f, 1f); + private static final Color PLAIN_DARK = new Color(0.55f, 0.38f, 0.24f, 1f); + private static final Color HOME_BORDER = new Color(0.4f, 0.85f, 0.4f, 0.9f); + private static final Color GRID_COLOR = new Color(0f, 0f, 0f, 0.3f); + + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final BoardCoordinateMapper coordinateMapper; + + private float boardLeft; + private float boardBottom; + private float cellSize; + + public SetupBoardRenderer(SpriteBatch batch, BoardCoordinateMapper coordinateMapper) { + this.batch = batch; + this.coordinateMapper = coordinateMapper; + this.shapeRenderer = new ShapeRenderer(); + } + + public void computeGeometry(float screenW, float screenH, + float headerH, float paletteH, float footerH, + int boardSize) { + float available = screenH - headerH - paletteH - footerH - 16f; + float maxW = screenW - 16f; + cellSize = Math.min(maxW / boardSize, available / boardSize); + boardLeft = (screenW - cellSize * boardSize) / 2f; + boardBottom = footerH + paletteH + (available - cellSize * boardSize) / 2f + 8f; + } + + public void drawBoard(Matrix4 projMatrix, SetupState state) { + int size = coordinateMapper.getBoardSize(); + shapeRenderer.setProjectionMatrix(projMatrix); + + drawTiles(state, size); + drawGrid(size); + drawHomeZoneBorder(state, size); + drawPlacedPieces(projMatrix, state, size); + } + + public void drawPaletteSprites(Matrix4 projMatrix, TextButton[] paletteButtons, String[] pieceNames) { + String color = coordinateMapper.getLocalPlayer().isWhite() ? "white" : "black"; + batch.setProjectionMatrix(projMatrix); + batch.begin(); + for (int i = 0; i < paletteButtons.length; i++) { + TextButton btn = paletteButtons[i]; + Texture tex = ResourceManager.getInstance().getPieceTexture(color, pieceNames[i].toLowerCase()); + float btnW = btn.getWidth(); + float btnH = btn.getHeight(); + Vector2 sp = btn.localToStageCoordinates(new Vector2(0, 0)); + float s = Math.min(btnW, btnH) * 0.42f; + batch.draw(tex, sp.x + (btnW - s) / 2f, sp.y + btnH - s - 4f, s, s); + } + batch.end(); + } + + private void drawTiles(SetupState state, int size) { + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + for (int col = 0; col < size; col++) { + for (int logicRow = 0; logicRow < size; logicRow++) { + int dispRow = coordinateMapper.toDisplayRow(logicRow); + float x = boardLeft + col * cellSize; + float y = boardBottom + dispRow * cellSize; + boolean light = (col + logicRow) % 2 == 0; + boolean home = logicRow >= state.getHomeRowMin() && logicRow <= state.getHomeRowMax(); + + if (home) { + shapeRenderer.setColor(light ? HOME_LIGHT : HOME_DARK); + } else { + shapeRenderer.setColor(light ? PLAIN_LIGHT : PLAIN_DARK); + } + shapeRenderer.rect(x, y, cellSize, cellSize); + } + } + shapeRenderer.end(); + } + + private void drawGrid(int size) { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(GRID_COLOR); + for (int i = 0; i <= size; i++) { + float x = boardLeft + i * cellSize; + float y = boardBottom + i * cellSize; + shapeRenderer.line(x, boardBottom, x, boardBottom + size * cellSize); + shapeRenderer.line(boardLeft, y, boardLeft + size * cellSize, y); + } + shapeRenderer.end(); + } + + private void drawHomeZoneBorder(SetupState state, int size) { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(HOME_BORDER); + Gdx.gl.glLineWidth(3f); + int homeDispMin = coordinateMapper.toDisplayRow(state.getHomeRowMin()); + int homeDispMax = coordinateMapper.toDisplayRow(state.getHomeRowMax()); + float zoneBottom = boardBottom + Math.min(homeDispMin, homeDispMax) * cellSize; + float zoneTop = boardBottom + (Math.max(homeDispMin, homeDispMax) + 1) * cellSize; + shapeRenderer.rect(boardLeft, zoneBottom, size * cellSize, zoneTop - zoneBottom); + Gdx.gl.glLineWidth(1f); + shapeRenderer.end(); + } + + private void drawPlacedPieces(Matrix4 projMatrix, SetupState state, int size) { + String color = coordinateMapper.getLocalPlayer().isWhite() ? "white" : "black"; + batch.setProjectionMatrix(projMatrix); + batch.begin(); + for (ChessPiece piece : state.getBoard().getPieces()) { + int col = (int) piece.getPosition().x; + int dispRow = coordinateMapper.toDisplayRow((int) piece.getPosition().y); + Texture tex = ResourceManager.getInstance().getPieceTexture(color, piece.getTypeName().toLowerCase()); + float pieceSize = cellSize * 0.8f; + float offset = (cellSize - pieceSize) / 2f; + batch.draw(tex, + boardLeft + col * cellSize + offset, + boardBottom + dispRow * cellSize + offset, + pieceSize, pieceSize); + } + batch.end(); + } + + public float getCellSize() { return cellSize; } + public float getBoardLeft() { return boardLeft; } + public float getBoardBottom() { return boardBottom; } + public BoardCoordinateMapper getCoordinateMapper() { return coordinateMapper; } + + public void dispose() { + shapeRenderer.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 new file mode 100644 index 0000000..860bcfb --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java @@ -0,0 +1,109 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupFlowController.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.Gdx; +import com.group14.regicidechess.database.DatabaseManager; +import com.group14.regicidechess.model.Board; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.states.SetupState; + +/** + * Manages the 3-step Firebase flow for setup confirmation. + */ +public class SetupFlowController { + + public interface FlowListener { + void onUploadComplete(); + void onBothReady(); + void onOpponentBoardFetched(Board finalBoard, Player opponent); + void onError(String message); + } + + private final String gameId; + private final Player localPlayer; + private final SetupState setupState; + private final FlowListener listener; + private final BoardFetchRetryPolicy retryPolicy; + + private int boardFetchRetries = 0; + + public SetupFlowController(String gameId, Player localPlayer, SetupState setupState, FlowListener listener) { + this(gameId, localPlayer, setupState, listener, new BoardFetchRetryPolicy()); + } + + public SetupFlowController(String gameId, Player localPlayer, SetupState setupState, + FlowListener listener, BoardFetchRetryPolicy retryPolicy) { + this.gameId = gameId; + this.localPlayer = localPlayer; + this.setupState = setupState; + this.listener = listener; + this.retryPolicy = retryPolicy; + } + + public void confirm() { + // Check UI readiness (King placed) + if (!setupState.isReadyForConfirm()) { + listener.onError("Place your King before confirming."); + return; + } + + // Now set the player as ready for Firebase + if (!setupState.setReady()) { + listener.onError("Failed to set ready state."); + return; + } + + listener.onUploadComplete(); + + int[][] encoded = SetupBoardCodec.encode(setupState.getBoard()); + DatabaseManager.getInstance().getApi().confirmSetup( + gameId, + localPlayer.isWhite(), + encoded, + this::listenForBothReady + ); + } + + private void listenForBothReady() { + DatabaseManager.getInstance().getApi().listenForBothSetupReady( + gameId, + () -> Gdx.app.postRunnable(() -> { + listener.onBothReady(); + fetchOpponentBoardWithRetry(); + }) + ); + } + + private void fetchOpponentBoardWithRetry() { + fetchOpponentBoard(); + } + + private void fetchOpponentBoard() { + DatabaseManager.getInstance().getApi().getOpponentBoard( + gameId, + localPlayer.isWhite(), + opponentBoard -> Gdx.app.postRunnable(() -> { + boolean empty = opponentBoard == null || opponentBoard.length == 0; + + if (empty && retryPolicy.shouldRetry(boardFetchRetries)) { + boardFetchRetries++; + retryPolicy.scheduleRetry(this::fetchOpponentBoard, boardFetchRetries); + return; + } + + if (empty) { + listener.onError("Failed to fetch opponent's board after multiple attempts."); + return; + } + + Player opponentPlayer = new Player( + localPlayer.isWhite() ? "player2" : "player1", + !localPlayer.isWhite(), + localPlayer.getBudget()); + SetupBoardCodec.mergeInto(opponentBoard, opponentPlayer, setupState.getBoard()); + + listener.onOpponentBoardFetched(setupState.getBoard(), opponentPlayer); + }) + ); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java new file mode 100644 index 0000000..b46d36e --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java @@ -0,0 +1,92 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupFooterWidget.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.scenes.scene2d.Actor; +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; + +/** + * SetupFooterWidget — builds and manages the footer UI. + */ +public class SetupFooterWidget { + + public interface FooterListener { + void onClear(); + void onConfirm(); + } + + private final Skin skin; + private final FooterListener listener; + + private TextButton confirmBtn; + private Label statusLabel; + private Label waitingLabel; + + public SetupFooterWidget(Skin skin, FooterListener listener) { + this.skin = skin; + this.listener = listener; + } + + public Table build() { + Table footer = new Table(); + footer.setBackground(skin.getDrawable("surface-pixel")); + footer.pad(10); + + TextButton clearBtn = new TextButton("Clear", skin, "danger"); + confirmBtn = new TextButton("Confirm", skin, "accent"); + confirmBtn.setDisabled(true); + + statusLabel = new Label("Place your King to continue", skin, "small"); + statusLabel.setAlignment(Align.center); + + waitingLabel = new Label("Waiting for opponent...", skin, "small"); + waitingLabel.setAlignment(Align.center); + waitingLabel.setVisible(false); + + clearBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + listener.onClear(); + } + }); + + confirmBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!confirmBtn.isDisabled()) { + listener.onConfirm(); + } + } + }); + + footer.add(clearBtn).width(140).height(50).expandX().left(); + footer.add(statusLabel).expandX(); + footer.add(confirmBtn).width(140).height(50).expandX().right(); + + return footer; + } + + public void setConfirmEnabled(boolean enabled) { + confirmBtn.setDisabled(!enabled); + } + + public void setStatusMessage(String message) { + statusLabel.setText(message); + } + + public void setWaitingMode(boolean waiting) { + confirmBtn.setVisible(!waiting); + waitingLabel.setVisible(waiting); + if (waiting) { + setStatusMessage("Waiting for opponent to finish setup..."); + } + } + + public Label getWaitingLabel() { + return waitingLabel; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..01b59fa --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java @@ -0,0 +1,45 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupHeaderWidget.java +package com.group14.regicidechess.screens.setup; + +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.group14.regicidechess.model.Player; + +/** + * SetupHeaderWidget — builds and manages the header UI. + */ +public class SetupHeaderWidget { + private final Skin skin; + private final Player player; + private Label budgetLabel; + + public SetupHeaderWidget(Skin skin, Player player) { + this.skin = skin; + this.player = player; + } + + public Table build() { + Table header = new Table(); + header.setBackground(skin.getDrawable("primary-pixel")); + header.pad(12); + + Label titleLabel = new Label("SETUP", skin, "title"); + budgetLabel = new Label(getBudgetText(), skin); + + header.add(titleLabel).expandX().left(); + header.add(budgetLabel).expandX().right(); + + return header; + } + + public void refreshBudget() { + if (budgetLabel != null) { + budgetLabel.setText(getBudgetText()); + } + } + + private String getBudgetText() { + return "Budget: " + player.getBudgetRemaining() + " / " + player.getBudget(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java new file mode 100644 index 0000000..a32028c --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java @@ -0,0 +1,150 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupPaletteWidget.java +package com.group14.regicidechess.screens.setup; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane; +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.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.Bishop; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.model.pieces.Knight; +import com.group14.regicidechess.model.pieces.Pawn; +import com.group14.regicidechess.model.pieces.Queen; +import com.group14.regicidechess.model.pieces.Rook; + +/** + * SetupPaletteWidget — builds the scrollable piece-selection palette. + */ +public class SetupPaletteWidget { + + public static final String[] PIECE_NAMES = { + "King", "Queen", "Rook", "Bishop", "Knight", "Pawn" + }; + + public static final int[] PIECE_COSTS = { 0, 9, 5, 3, 3, 1 }; + + public interface Listener { + void onSelectionChanged(int selectedIndex); + } + + private final Skin skin; + private final Listener listener; + private final TextButton[] buttons; + private int selectedIndex = -1; + + // Option to keep selection after placing a piece + private boolean keepSelectionAfterPlace = true; + + public SetupPaletteWidget(Skin skin, Listener listener) { + this.skin = skin; + this.listener = listener; + this.buttons = new TextButton[PIECE_NAMES.length]; + } + + public SetupPaletteWidget(Skin skin, Listener listener, boolean keepSelectionAfterPlace) { + this(skin, listener); + this.keepSelectionAfterPlace = keepSelectionAfterPlace; + } + + public Table buildWidget() { + Table paletteWrapper = new Table(); + paletteWrapper.setBackground(skin.getDrawable("primary-dark-pixel")); + paletteWrapper.pad(8); + + Table palette = new Table(); + + for (int i = 0; i < PIECE_NAMES.length; i++) { + final int idx = i; + String cost = PIECE_COSTS[i] == 0 ? "free" : String.valueOf(PIECE_COSTS[i]); + String label = "\n\n" + PIECE_NAMES[i] + "\n[" + cost + "]"; + + TextButton btn = new TextButton(label, skin, "default"); + btn.getLabel().setAlignment(Align.center); + buttons[i] = btn; + + btn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + toggle(idx); + } + }); + palette.add(btn).width(68).height(90).pad(4); + } + + ScrollPane scroll = new ScrollPane(palette, skin); + scroll.setScrollingDisabled(false, true); + paletteWrapper.add(scroll).expandX().fillX().height(100); + return paletteWrapper; + } + + private void toggle(int idx) { + selectedIndex = (selectedIndex == idx) ? -1 : idx; + refreshButtonStyles(); + if (listener != null) { + listener.onSelectionChanged(selectedIndex); + } + } + + /** + * Clears the selection (use this when you want to force deselection). + */ + public void clearSelection() { + selectedIndex = -1; + refreshButtonStyles(); + } + + /** + * Called after a piece is placed. If keepSelectionAfterPlace is true, + * selection remains; otherwise it's cleared. + */ + public void afterPiecePlaced() { + if (!keepSelectionAfterPlace) { + clearSelection(); + } + } + + private void refreshButtonStyles() { + for (int i = 0; i < buttons.length; i++) { + buttons[i].setStyle(skin.get( + i == selectedIndex ? "accent" : "default", + TextButton.TextButtonStyle.class)); + } + } + + public ChessPiece createSelectedPiece(Player owner) { + return createPiece(selectedIndex, owner); + } + + public static ChessPiece createPiece(int idx, Player owner) { + switch (idx) { + case 0: return new King(owner); + case 1: return new Queen(owner); + case 2: return new Rook(owner); + case 3: return new Bishop(owner); + case 4: return new Knight(owner); + case 5: return new Pawn(owner); + default: return null; + } + } + + public TextButton[] getButtons() { + return buttons; + } + + public int getSelectedIndex() { + return selectedIndex; + } + + public boolean hasSelection() { + return selectedIndex >= 0; + } + + public void setKeepSelectionAfterPlace(boolean keep) { + this.keepSelectionAfterPlace = keep; + } +} \ 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 new file mode 100644 index 0000000..3ec4893 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java @@ -0,0 +1,360 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreen.java +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.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Player; +import com.group14.regicidechess.model.pieces.ChessPiece; +import com.group14.regicidechess.model.pieces.King; +import com.group14.regicidechess.screens.game.GameScreen; +import com.group14.regicidechess.states.SetupState; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * SetupScreen — thin coordinator for the piece-placement phase. + * + * Refactored version with improved modularity: + * - UI components extracted to separate widgets + * - Firebase flow extracted to SetupFlowController + * - Board input logic extracted to SetupBoardInputHandler + * - Coordinate mapping extracted to BoardCoordinateMapper + * - Palette keeps selection after placing pieces for faster placement + */ +public class SetupScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + + // LibGDX + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // Game state + private final SetupState setupState; + private final Player localPlayer; + private final String gameId; + @SuppressWarnings("unused") + private final boolean isHost; + + // UI Widgets + private final SetupHeaderWidget headerWidget; + private final SetupFooterWidget footerWidget; + private final SetupPaletteWidget palette; + + // Helpers + private final SetupBoardRenderer boardRenderer; + private final SetupBoardInputHandler boardInputHandler; + private final SetupFlowController flowController; + + @SuppressWarnings("unused") + private Table root; + + public SetupScreen(Game game, SpriteBatch batch, + String gameId, int boardSize, int budget, boolean isHost) { + this.game = game; + this.batch = batch; + this.gameId = gameId; + this.isHost = isHost; + + // 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(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + // Create widgets and helpers + // IMPORTANT: Pass true to keep selection after placing pieces + 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.boardInputHandler = new SetupBoardInputHandler( + setupState, boardRenderer, palette, localPlayer, createBoardActionListener()); + + this.flowController = new SetupFlowController( + gameId, localPlayer, setupState, createFlowListener()); + + buildUI(); + boardRenderer.computeGeometry( + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT, + SetupScreenConfig.HEADER_HEIGHT, + SetupScreenConfig.PALETTE_HEIGHT, + SetupScreenConfig.FOOTER_HEIGHT, + boardSize); + + // Log initial state for debugging + Gdx.app.log("SetupScreen", "Initialized with board size: " + boardSize + ", budget: " + budget); + } + + // ───────────────────────────────────────────────────────────────────────── + // UI construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildUI() { + root = new Table(); + root.setFillParent(true); + 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(); + + // 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(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Listeners + // ───────────────────────────────────────────────────────────────────────── + + private SetupFooterWidget.FooterListener createFooterListener() { + return new SetupFooterWidget.FooterListener() { + @Override + public void onClear() { + Gdx.app.log("SetupScreen", "Clear button pressed"); + setupState.clearBoard(); + palette.clearSelection(); + refreshUI(); + } + + @Override + public void onConfirm() { + Gdx.app.log("SetupScreen", "Confirm button pressed"); + flowController.confirm(); + } + }; + } + + 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 onInvalidPlacement(String reason) { + Gdx.app.log("SetupScreen", "Invalid placement: " + reason); + showStatus(reason); + } + + @Override + public void onStateChanged() { + refreshUI(); + } + }; + } + + private SetupFlowController.FlowListener createFlowListener() { + return new SetupFlowController.FlowListener() { + @Override + public void onUploadComplete() { + Gdx.app.postRunnable(() -> { + Gdx.app.log("SetupScreen", "Upload complete, waiting for opponent"); + footerWidget.setConfirmEnabled(false); + footerWidget.setWaitingMode(true); + }); + } + + @Override + public void onBothReady() { + Gdx.app.log("SetupScreen", "Both players ready"); + // Nothing to do here, waiting for board fetch + } + + @Override + public void onOpponentBoardFetched(com.group14.regicidechess.model.Board finalBoard, + Player opponent) { + Gdx.app.postRunnable(() -> { + Gdx.app.log("SetupScreen", "Opponent board fetched, navigating to GameScreen"); + game.setScreen(new GameScreen( + game, batch, + finalBoard, + localPlayer, + setupState.getBoardSize(), + gameId)); + }); + } + + @Override + public void onError(String message) { + Gdx.app.postRunnable(() -> { + Gdx.app.log("SetupScreen", "Error: " + message); + showStatus(message); + footerWidget.setConfirmEnabled(true); + footerWidget.setWaitingMode(false); + }); + } + }; + } + + private void onPaletteSelectionChanged(int selectedIndex) { + if (selectedIndex >= 0) { + Gdx.app.log("SetupScreen", "Selected piece: " + SetupPaletteWidget.PIECE_NAMES[selectedIndex]); + } + // Widget handles its own button styles, no action needed + } + + // ───────────────────────────────────────────────────────────────────────── + // Rendering + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void render(float delta) { + Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + setupState.update(delta); + + // Board is drawn before the stage so pieces sit below Scene2D actors + boardRenderer.drawBoard(stage.getCamera().combined, setupState); + stage.act(delta); + stage.draw(); + + // Palette sprites are drawn after the stage so they appear on top of buttons + boardRenderer.drawPaletteSprites(stage.getCamera().combined, + palette.getButtons(), SetupPaletteWidget.PIECE_NAMES); + } + + // ───────────────────────────────────────────────────────────────────────── + // Input + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int screenX, int screenY, int pointer, int button) { + Vector2 world = stage.getViewport().unproject(new Vector2(screenX, screenY)); + boardInputHandler.handleTap(world.x, world.y); + } + + @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 + // ───────────────────────────────────────────────────────────────────────── + + private void refreshUI() { + headerWidget.refreshBudget(); + + // Use the correct method for UI readiness + boolean kingPlaced = setupState.isReadyForConfirm(); + footerWidget.setConfirmEnabled(kingPlaced); + + // Debug logging + int pieceCount = setupState.getBoard().getPieces().size(); + int playerPieces = setupState.getBoard().getPieces(setupState.getPlayer()).size(); + Gdx.app.log("SetupScreen", "Refresh UI - King placed: " + kingPlaced + + ", Total pieces: " + pieceCount + + ", Player pieces: " + playerPieces + + ", Budget remaining: " + localPlayer.getBudgetRemaining()); + + showStatus(kingPlaced + ? "Ready! Press Confirm when done." + : "Place your King to continue."); + } + + private void showStatus(String msg) { + footerWidget.setStatusMessage(msg); + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + Gdx.app.log("SetupScreen", "Screen shown"); + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + boardRenderer.computeGeometry( + SetupScreenConfig.VIEWPORT_WIDTH, + SetupScreenConfig.VIEWPORT_HEIGHT, + SetupScreenConfig.HEADER_HEIGHT, + SetupScreenConfig.PALETTE_HEIGHT, + SetupScreenConfig.FOOTER_HEIGHT, + setupState.getBoardSize()); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + Gdx.app.log("SetupScreen", "Screen hidden"); + inputHandler.clearObservers(); + setupState.exit(); + } + + @Override + public void dispose() { + Gdx.app.log("SetupScreen", "Screen disposed"); + stage.dispose(); + boardRenderer.dispose(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java new file mode 100644 index 0000000..16fc784 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java @@ -0,0 +1,22 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/setup/SetupScreenConfig.java +package com.group14.regicidechess.screens.setup; + +/** + * Configuration constants for the setup screen. + */ +public final class SetupScreenConfig { + private SetupScreenConfig() {} // Prevent instantiation + + // Viewport dimensions + public static final float VIEWPORT_WIDTH = 480f; + public static final float VIEWPORT_HEIGHT = 854f; + + // Layout heights + public static final float HEADER_HEIGHT = 80f; + public static final float PALETTE_HEIGHT = 130f; + public static final float FOOTER_HEIGHT = 70f; + + // Board fetch retry configuration + public static final int BOARD_FETCH_MAX_RETRIES = 5; + public static final long BOARD_FETCH_RETRY_MS = 600L; +} \ 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 5a2c060..46485ea 100644 --- a/core/src/main/java/com/group14/regicidechess/states/SetupState.java +++ b/core/src/main/java/com/group14/regicidechess/states/SetupState.java @@ -1,10 +1,17 @@ +// File: core/src/main/java/com/group14/regicidechess/states/SetupState.java package com.group14.regicidechess.states; + import com.group14.regicidechess.model.Board; import com.group14.regicidechess.model.Player; import com.group14.regicidechess.model.pieces.ChessPiece; import com.group14.regicidechess.model.pieces.King; -/** Placement: core/src/main/java/com/group14/regicidechess/states/SetupState.java */ +/** + * SetupState — manages the piece placement phase. + * + * Important: isReady() returns true if King is placed, NOT player.isReady(). + * The player.isReady() flag is used for the Firebase flow, not for UI readiness. + */ public class SetupState extends GameState { private Board board; @@ -14,21 +21,33 @@ public class SetupState extends GameState { private int homeRowMin; private int homeRowMax; - @Override public void enter() {} - @Override public void update(float delta) {} - @Override public void exit() {} + @Override + public void enter() { + com.badlogic.gdx.Gdx.app.log("SetupState", "Enter"); + } + + @Override + public void update(float delta) {} + + @Override + public void exit() { + com.badlogic.gdx.Gdx.app.log("SetupState", "Exit"); + } public void setBoardSize(int size) { this.boardSize = size; this.board = new Board(size); + com.badlogic.gdx.Gdx.app.log("SetupState", "Board size set to: " + size); } public void setBudget(int budget) { this.budget = budget; if (player != null) player.resetBudget(); + com.badlogic.gdx.Gdx.app.log("SetupState", "Budget set to: " + budget); } private static final int HOME_ROWS = 2; + public void setPlayer(Player player) { this.player = player; if (player.isWhite()) { @@ -38,39 +57,90 @@ public void setPlayer(Player player) { homeRowMin = boardSize - HOME_ROWS; // rows (size-2)–(size-1) homeRowMax = boardSize - 1; } + com.badlogic.gdx.Gdx.app.log("SetupState", "Player set: " + player.getPlayerId() + + ", home rows: " + homeRowMin + "-" + homeRowMax); } public boolean placePiece(ChessPiece piece, int col, int row) { - if (!isInHomeZone(row)) return false; + if (!isInHomeZone(row)) { + com.badlogic.gdx.Gdx.app.log("SetupState", "Placement failed: not in home zone (row " + row + ")"); + return false; + } - if (piece instanceof King && kingIsPlaced()) return false; + if (piece instanceof King && kingIsPlaced()) { + com.badlogic.gdx.Gdx.app.log("SetupState", "Placement failed: King already placed"); + return false; + } - if (!player.spendBudget(piece.getPointCost())) return false; + if (!player.spendBudget(piece.getPointCost())) { + com.badlogic.gdx.Gdx.app.log("SetupState", "Placement failed: insufficient budget"); + return false; + } + ChessPiece existing = board.getPieceAt(col, row); - if (existing != null) player.refundBudget(existing.getPointCost()); + if (existing != null) { + player.refundBudget(existing.getPointCost()); + } + board.placePiece(piece, col, row); + com.badlogic.gdx.Gdx.app.log("SetupState", "Piece placed: " + piece.getTypeName() + + " at (" + col + ", " + row + "), budget remaining: " + + player.getBudgetRemaining()); return true; } public void removePiece(int col, int row) { ChessPiece removed = board.removePiece(col, row); - if (removed != null) player.refundBudget(removed.getPointCost()); + if (removed != null) { + player.refundBudget(removed.getPointCost()); + com.badlogic.gdx.Gdx.app.log("SetupState", "Piece removed: " + removed.getTypeName() + + " at (" + col + ", " + row + "), budget remaining: " + + player.getBudgetRemaining()); + } } public void clearBoard() { board.clear(); player.resetBudget(); + com.badlogic.gdx.Gdx.app.log("SetupState", "Board cleared, budget reset"); } + /** + * Sets the player as ready for Firebase flow. + * This should ONLY be called when confirming the setup. + */ public boolean setReady() { - if (!kingIsPlaced()) return false; + if (!kingIsPlaced()) { + com.badlogic.gdx.Gdx.app.log("SetupState", "setReady() failed: King not placed"); + return false; + } player.setReady(); + com.badlogic.gdx.Gdx.app.log("SetupState", "setReady() success, player ready flag set"); return true; } + /** + * Checks if the player is ready for the Firebase flow. + * This is different from UI readiness (which only requires King). + */ + public boolean isPlayerReady() { + return player != null && player.isReady(); + } + + /** + * UI readiness: King must be placed. + * This is what the confirm button should check. + */ + public boolean isReadyForConfirm() { + return kingIsPlaced(); + } + private boolean kingIsPlaced() { - for (ChessPiece p : board.getPieces(player)) - if (p instanceof King) return true; + for (ChessPiece p : board.getPieces(player)) { + if (p instanceof King) { + return true; + } + } return false; } @@ -84,5 +154,12 @@ private boolean isInHomeZone(int row) { public int getBudget() { return budget; } public int getHomeRowMin() { return homeRowMin; } public int getHomeRowMax() { return homeRowMax; } - public boolean isReady() { return player != null && player.isReady(); } + + /** + * @deprecated Use isReadyForConfirm() for UI readiness, or isPlayerReady() for Firebase readiness. + */ + @Deprecated + public boolean isReady() { + return isReadyForConfirm(); + } } \ No newline at end of file From 2d691f4ebed625bfac2fe9a770eb997461b04581 Mon Sep 17 00:00:00 2001 From: benjamls Date: Tue, 24 Mar 2026 17:44:49 +0100 Subject: [PATCH 13/14] Improve modularity for lobbyscreen --- .../regicidechess/screens/LobbyScreen.java | 391 ------------------ .../screens/lobby/LobbyFlowController.java | 67 +++ .../screens/lobby/LobbyHostUI.java | 196 +++++++++ .../screens/lobby/LobbyJoinUI.java | 117 ++++++ .../screens/lobby/LobbyMode.java | 10 + .../screens/lobby/LobbyScreen.java | 301 ++++++++++++++ .../screens/lobby/LobbyScreenConfig.java | 32 ++ .../screens/lobby/LobbyStateManager.java | 123 ++++++ 8 files changed, 846 insertions(+), 391 deletions(-) delete mode 100644 core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java diff --git a/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java b/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java deleted file mode 100644 index e9a8e3e..0000000 --- a/core/src/main/java/com/group14/regicidechess/screens/LobbyScreen.java +++ /dev/null @@ -1,391 +0,0 @@ -package com.group14.regicidechess.screens; - -import com.badlogic.gdx.Game; -import com.badlogic.gdx.Gdx; -import com.badlogic.gdx.Screen; -import com.badlogic.gdx.graphics.GL20; -import com.badlogic.gdx.graphics.g2d.SpriteBatch; -import com.badlogic.gdx.scenes.scene2d.Actor; -import com.badlogic.gdx.scenes.scene2d.Stage; -import com.badlogic.gdx.scenes.scene2d.ui.Label; -import com.badlogic.gdx.scenes.scene2d.ui.Skin; -import com.badlogic.gdx.scenes.scene2d.ui.Slider; -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.DatabaseManager; -import com.group14.regicidechess.input.ScreenInputHandler; -import com.group14.regicidechess.model.Lobby; -import com.group14.regicidechess.states.LobbyState; -import com.group14.regicidechess.utils.ResourceManager; - -/** - * LobbyScreen handles two modes: - * - * HOST flow: - * 1. Host adjusts sliders and presses "Create Lobby" - * → lobby created in Firebase, screen shows the Game ID - * → "Start Game" button appears but is DISABLED - * 2. Joiner enters the lobby (status = "joined") - * → "Start Game" becomes ENABLED - * 3. Host presses "Start Game" - * → Firebase status set to "started" - * → Host navigates to SetupScreen - * → Joiner's listener fires and also navigates to SetupScreen - * - * JOIN flow: - * 1. Joiner presses "Join Match" - * → Firebase status set to "joined" (signals host a player is present) - * → Joiner waits, showing "Waiting for host to start..." - * 2. Host presses "Start Game" (status becomes "started") - * → Joiner's listener fires → navigates to SetupScreen - */ -public class LobbyScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - - public enum Mode { HOST, JOIN } - - private static final float V_WIDTH = 480f; - private static final float V_HEIGHT = 854f; - - private static final int BOARD_MIN = 5; - private static final int BOARD_MAX = 8; - private static final int BOARD_DEFAULT = 8; - - private static final int BUDGET_MIN = 5; - private static final int BUDGET_MAX = 50; - private static final int BUDGET_DEFAULT = 25; - private static final int BUDGET_STEP = 1; - - private final Game game; - private final SpriteBatch batch; - private final Mode mode; - private final String incomingGameId; - - private final Stage stage; - private final Skin skin; - private final ScreenInputHandler inputHandler; - private final LobbyState lobbyState; - - private int boardSize = BOARD_DEFAULT; - private int budget = BUDGET_DEFAULT; - - private Label boardSizeValueLabel; - private Label budgetValueLabel; - private Label statusLabel; - - // HOST buttons — createBtn shown first; startBtn shown after lobby is created - private TextButton createBtn; - private TextButton startBtn; - - // JOIN button - private TextButton joinBtn; - - public LobbyScreen(Game game, SpriteBatch batch, Mode mode, Lobby lobby) { - this.game = game; - this.batch = batch; - this.mode = mode; - this.lobbyState = new LobbyState(); - - if (lobby != null) { - this.incomingGameId = lobby.getGameId(); - this.lobbyState.setPrefetchedLobby(lobby); - this.boardSize = lobby.getBoardSize(); - this.budget = lobby.getBudget(); - } else { - this.incomingGameId = null; - } - - lobbyState.enter(); - - stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); - skin = ResourceManager.getInstance().getSkin(); - - inputHandler = new ScreenInputHandler(); - inputHandler.addObserver(this); - - buildUI(); - } - - // ───────────────────────────────────────────────────────────────────────── - // UI construction - // ───────────────────────────────────────────────────────────────────────── - - private void buildUI() { - Table root = new Table(); - root.setFillParent(true); - root.setBackground(skin.getDrawable("surface-pixel")); - root.top().pad(32); - stage.addActor(root); - - Label titleLabel = new Label(mode == Mode.HOST ? "Create Lobby" : "Join Lobby", - skin, "title"); - titleLabel.setAlignment(Align.center); - root.add(titleLabel).expandX().padBottom(32).row(); - - if (mode == Mode.HOST) buildHostUI(root); - else buildJoinUI(root); - - TextButton backBtn = new TextButton("Back", skin, "default"); - backBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - game.setScreen(new MainMenuScreen(game, batch)); - } - }); - root.add(backBtn).width(200).height(50).padTop(24).row(); - } - - private void buildHostUI(Table root) { - // ── Sliders ─────────────────────────────────────────────────────────── - Label boardSizeLabel = new Label("Board Size", skin); - boardSizeValueLabel = new Label(boardSize + " x " + boardSize, skin); - - Slider boardSlider = new Slider(BOARD_MIN, BOARD_MAX, 1, false, buildSliderStyle()); - boardSlider.setValue(BOARD_DEFAULT); - boardSlider.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - boardSize = (int) boardSlider.getValue(); - boardSizeValueLabel.setText(boardSize + " x " + boardSize); - } - }); - - Label budgetLabel = new Label("Starting Budget", skin); - budgetValueLabel = new Label(String.valueOf(budget), skin); - - Slider budgetSlider = new Slider(BUDGET_MIN, BUDGET_MAX, BUDGET_STEP, false, buildSliderStyle()); - budgetSlider.setValue(BUDGET_DEFAULT); - budgetSlider.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - budget = (int) budgetSlider.getValue(); - budgetValueLabel.setText(String.valueOf(budget)); - } - }); - - // ── Status label ────────────────────────────────────────────────────── - statusLabel = new Label("", skin, "small"); - statusLabel.setAlignment(Align.center); - - // ── Create Lobby button ─────────────────────────────────────────────── - createBtn = new TextButton("Create Lobby", skin, "accent"); - createBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - if (!createBtn.isDisabled()) onHostCreateLobby(); - } - }); - - // ── Start Game button — hidden until a joiner arrives ───────────────── - startBtn = new TextButton("Start Game", skin, "accent"); - startBtn.setVisible(false); // hidden initially - startBtn.setDisabled(true); // also disabled as safety - startBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - if (!startBtn.isDisabled()) onHostStartGame(); - } - }); - - root.add(buildRow(boardSizeLabel, boardSizeValueLabel)).expandX().fillX().padBottom(8).row(); - root.add(boardSlider).width(380).height(40).padBottom(24).row(); - root.add(buildRow(budgetLabel, budgetValueLabel)).expandX().fillX().padBottom(8).row(); - root.add(budgetSlider).width(380).height(40).padBottom(32).row(); - root.add(createBtn).width(280).height(60).padBottom(8).row(); - root.add(startBtn).width(280).height(60).padBottom(16).row(); - root.add(statusLabel).expandX().row(); - } - - private void buildJoinUI(Table root) { - Label enteredLabel = new Label("Game ID: " + incomingGameId, skin, "title"); - enteredLabel.setAlignment(Align.center); - root.add(enteredLabel).expandX().padBottom(24).row(); - - // If we have prefetched lobby data, show it immediately - String boardText = (lobbyState.getLobby() != null) - ? "Board: " + boardSize + " x " + boardSize - : "Board: fetching..."; - String budgetText = (lobbyState.getLobby() != null) - ? "Budget: " + budget - : "Budget: fetching..."; - - boardSizeValueLabel = new Label(boardText, skin, "small"); - budgetValueLabel = new Label(budgetText, skin, "small"); - root.add(boardSizeValueLabel).expandX().padBottom(4).row(); - root.add(budgetValueLabel).expandX().padBottom(32).row(); - - statusLabel = new Label("", skin, "small"); - statusLabel.setAlignment(Align.center); - - joinBtn = new TextButton("Join Match", skin, "accent"); - joinBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - if (!joinBtn.isDisabled()) onJoinConfirm(); - } - }); - - root.add(joinBtn).width(280).height(60).padBottom(16).row(); - root.add(statusLabel).expandX().row(); - } - - // ───────────────────────────────────────────────────────────────────────── - // HOST actions - // ───────────────────────────────────────────────────────────────────────── - - /** - * HOST STEP 1: Creates the lobby in Firebase. - * Shows the Game ID and waits passively for a joiner. - * The "Start Game" button remains hidden/disabled until a joiner is detected. - */ - private void onHostCreateLobby() { - createBtn.setDisabled(true); - setStatus("Creating lobby..."); - - lobbyState.createLobby(boardSize, budget, - () -> { - String id = lobbyState.getLobby().getGameId(); - // Hide the create button, show the (still disabled) start button - createBtn.setVisible(false); - startBtn.setVisible(true); - startBtn.setDisabled(true); - setStatus("Lobby created!\nGame ID: " + id - + "\n\nWaiting for opponent to join..."); - listenForJoiner(id); - }, - () -> { - setStatus("Failed to create lobby. Try again."); - createBtn.setDisabled(false); - }); - } - - /** - * HOST STEP 2 (callback): A joiner has entered the lobby. - * Enables the "Start Game" button — host decides when to proceed. - */ - private void onJoinerArrived() { - setStatus("Opponent joined! Press Start Game when you are ready."); - startBtn.setDisabled(false); - } - - /** - * HOST STEP 3: Host explicitly presses "Start Game". - * Writes status = "started" to Firebase so the joiner navigates to SetupScreen. - * Host also navigates immediately after. - */ - private void onHostStartGame() { - startBtn.setDisabled(true); - setStatus("Starting..."); - - String id = lobbyState.getLobby().getGameId(); - int size = lobbyState.getLobby().getBoardSize(); - int bud = lobbyState.getLobby().getBudget(); - - // Signal joiner to navigate - DatabaseManager.getInstance().getApi().startGame(id); - - // Host navigates right away - game.setScreen(new SetupScreen(game, batch, id, size, bud, true)); - } - - // ───────────────────────────────────────────────────────────────────────── - // JOINER actions - // ───────────────────────────────────────────────────────────────────────── - - /** - * JOINER STEP 1: Confirms joining. - * Calls joinLobby() which sets status = "joined" in Firebase (host sees this). - * Then listens for status = "started" (host pressed "Start Game"). - */ - private void onJoinConfirm() { - joinBtn.setDisabled(true); - setStatus("Connecting..."); - - lobbyState.joinLobby(incomingGameId, - () -> { - boardSizeValueLabel.setText("Board: " - + lobbyState.getLobby().getBoardSize() - + " x " + lobbyState.getLobby().getBoardSize()); - budgetValueLabel.setText("Budget: " + lobbyState.getLobby().getBudget()); - setStatus("Joined!\nWaiting for host to start the game..."); - listenForHostStart(incomingGameId); - }, - () -> { - setStatus("Lobby not found. Check the Game ID."); - joinBtn.setDisabled(false); - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Firebase listeners - // ───────────────────────────────────────────────────────────────────────── - - /** - * HOST: Listens for status = "joined". - * Does NOT navigate — just enables the Start Game button via onJoinerArrived(). - */ - private void listenForJoiner(String gameId) { - DatabaseManager.getInstance().getApi() - .listenForOpponentReady(gameId, - () -> Gdx.app.postRunnable(this::onJoinerArrived)); - } - - /** - * JOINER: Listens for status = "started" (set by host pressing Start Game). - * Navigates to SetupScreen when it fires. - */ - private void listenForHostStart(String gameId) { - DatabaseManager.getInstance().getApi() - .listenForGameStart(gameId, - () -> Gdx.app.postRunnable(() -> { - int size = lobbyState.getLobby().getBoardSize(); - int bud = lobbyState.getLobby().getBudget(); - game.setScreen(new SetupScreen(game, batch, gameId, size, bud, false)); - })); - } - - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - private void setStatus(String msg) { - if (statusLabel != null) statusLabel.setText(msg); - } - - private Table buildRow(Label left, Label right) { - Table row = new Table(); - row.add(left).expandX().left(); - row.add(right).expandX().right(); - return row; - } - - private Slider.SliderStyle buildSliderStyle() { - Slider.SliderStyle style = new Slider.SliderStyle(); - style.background = skin.getDrawable("primary-pixel"); - style.knob = skin.getDrawable("accent-pixel"); - return style; - } - - @Override public void onTap (int x, int y, 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 show() { - com.badlogic.gdx.InputMultiplexer mx = - new com.badlogic.gdx.InputMultiplexer(stage, inputHandler); - Gdx.input.setInputProcessor(mx); - } - - @Override - public void render(float delta) { - Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); - Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); - lobbyState.update(delta); - stage.act(delta); - stage.draw(); - } - - @Override public void resize(int width, int height) { stage.getViewport().update(width, height, true); } - @Override public void pause() {} - @Override public void resume() {} - @Override public void hide() { inputHandler.clearObservers(); lobbyState.exit(); } - @Override public void dispose() { stage.dispose(); } -} diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java new file mode 100644 index 0000000..93d7d20 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java @@ -0,0 +1,67 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyFlowController.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.Gdx; +import com.group14.regicidechess.database.DatabaseManager; + +/** + * Manages Firebase listeners and flow between host and joiner. + * Encapsulates all Firebase communication logic. + */ +public class LobbyFlowController { + + public interface FlowListener { + void onJoinerArrived(); // Host: someone joined the lobby + void onGameStarted(); // Joiner: host started the game + void onError(String message); + } + + private final FlowListener listener; + private String activeGameId; + + public LobbyFlowController(FlowListener listener) { + this.listener = listener; + } + + /** + * Host: Listens for a joiner to enter the lobby. + * When status becomes "joined", calls onJoinerArrived(). + */ + public void listenForJoiner(String gameId) { + this.activeGameId = gameId; + DatabaseManager.getInstance().getApi() + .listenForOpponentReady(gameId, + () -> Gdx.app.postRunnable(() -> { + if (listener != null) { + listener.onJoinerArrived(); + } + })); + } + + /** + * Host: Signals the game to start. + * Writes status = "started" to Firebase. + */ + public void signalGameStart(String gameId) { + DatabaseManager.getInstance().getApi().startGame(gameId); + } + + /** + * Joiner: Listens for host to start the game. + * When status becomes "started", calls onGameStarted(). + */ + public void listenForGameStart(String gameId) { + this.activeGameId = gameId; + DatabaseManager.getInstance().getApi() + .listenForGameStart(gameId, + () -> Gdx.app.postRunnable(() -> { + if (listener != null) { + listener.onGameStarted(); + } + })); + } + + public String getActiveGameId() { + return activeGameId; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java new file mode 100644 index 0000000..24fb3d6 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java @@ -0,0 +1,196 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyHostUI.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Slider; +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; + +/** + * Builds and manages the host-specific UI for lobby creation. + * Contains board size slider, budget slider, create button, and start button. + */ +public class LobbyHostUI { + + public interface HostUIListener { + void onCreateLobby(int boardSize, int budget); + void onStartGame(); + void onBack(); + } + + private final Skin skin; + private final HostUIListener listener; + + private int boardSize = LobbyScreenConfig.BOARD_DEFAULT; + private int budget = LobbyScreenConfig.BUDGET_DEFAULT; + + private Label boardSizeValueLabel; + private Label budgetValueLabel; + private Label statusLabel; + private TextButton createBtn; + private TextButton startBtn; + + public LobbyHostUI(Skin skin, HostUIListener listener) { + this.skin = skin; + this.listener = listener; + } + + public Table build() { + Table container = new Table(); + container.top(); + + // Board size controls + Label boardSizeLabel = new Label("Board Size", skin); + boardSizeValueLabel = new Label(boardSize + " x " + boardSize, skin); + + Slider boardSlider = createBoardSlider(); + + // Budget controls + Label budgetLabel = new Label("Starting Budget", skin); + budgetValueLabel = new Label(String.valueOf(budget), skin); + + Slider budgetSlider = createBudgetSlider(); + + // Status label + statusLabel = new Label("", skin, "small"); + statusLabel.setAlignment(Align.center); + + // Buttons + createBtn = new TextButton("Create Lobby", skin, "accent"); + createBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!createBtn.isDisabled()) { + listener.onCreateLobby(boardSize, budget); + } + } + }); + + startBtn = new TextButton("Start Game", skin, "accent"); + startBtn.setVisible(false); + startBtn.setDisabled(true); + startBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!startBtn.isDisabled()) { + listener.onStartGame(); + } + } + }); + + // Layout + container.add(buildRow(boardSizeLabel, boardSizeValueLabel)) + .expandX().fillX().padBottom(8).row(); + container.add(boardSlider) + .width(LobbyScreenConfig.SLIDER_WIDTH) + .height(LobbyScreenConfig.SLIDER_HEIGHT) + .padBottom(24).row(); + container.add(buildRow(budgetLabel, budgetValueLabel)) + .expandX().fillX().padBottom(8).row(); + container.add(budgetSlider) + .width(LobbyScreenConfig.SLIDER_WIDTH) + .height(LobbyScreenConfig.SLIDER_HEIGHT) + .padBottom(32).row(); + container.add(createBtn) + .width(LobbyScreenConfig.BUTTON_WIDTH) + .height(LobbyScreenConfig.BUTTON_HEIGHT) + .padBottom(8).row(); + container.add(startBtn) + .width(LobbyScreenConfig.BUTTON_WIDTH) + .height(LobbyScreenConfig.BUTTON_HEIGHT) + .padBottom(16).row(); + container.add(statusLabel).expandX().row(); + + return container; + } + + private Slider createBoardSlider() { + Slider slider = new Slider( + LobbyScreenConfig.BOARD_MIN, + LobbyScreenConfig.BOARD_MAX, + 1, + false, + buildSliderStyle()); + slider.setValue(LobbyScreenConfig.BOARD_DEFAULT); + slider.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + boardSize = (int) slider.getValue(); + boardSizeValueLabel.setText(boardSize + " x " + boardSize); + } + }); + return slider; + } + + private Slider createBudgetSlider() { + Slider slider = new Slider( + LobbyScreenConfig.BUDGET_MIN, + LobbyScreenConfig.BUDGET_MAX, + LobbyScreenConfig.BUDGET_STEP, + false, + buildSliderStyle()); + slider.setValue(LobbyScreenConfig.BUDGET_DEFAULT); + slider.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + budget = (int) slider.getValue(); + budgetValueLabel.setText(String.valueOf(budget)); + } + }); + return slider; + } + + private Slider.SliderStyle buildSliderStyle() { + Slider.SliderStyle style = new Slider.SliderStyle(); + style.background = skin.getDrawable("primary-pixel"); + style.knob = skin.getDrawable("accent-pixel"); + return style; + } + + private Table buildRow(Label left, Label right) { + Table row = new Table(); + row.add(left).expandX().left(); + row.add(right).expandX().right(); + return row; + } + + // Public methods for updating UI state + + public void showCreatingState() { + createBtn.setDisabled(true); + setStatus("Creating lobby..."); + } + + public void showLobbyCreated(String gameId) { + createBtn.setVisible(false); + startBtn.setVisible(true); + startBtn.setDisabled(true); + setStatus("Lobby created!\nGame ID: " + gameId + + "\n\nWaiting for opponent to join..."); + } + + public void showJoinerArrived() { + setStatus("Opponent joined! Press Start Game when you are ready."); + startBtn.setDisabled(false); + } + + public void showStartingState() { + startBtn.setDisabled(true); + setStatus("Starting..."); + } + + public void showCreationFailed() { + setStatus("Failed to create lobby. Try again."); + createBtn.setDisabled(false); + } + + public void setStatus(String message) { + if (statusLabel != null) { + statusLabel.setText(message); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java new file mode 100644 index 0000000..97b470a --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java @@ -0,0 +1,117 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyJoinUI.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.scenes.scene2d.Actor; +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; + +/** + * Builds and manages the join-specific UI for joining an existing lobby. + * Contains game ID display, board/budget info, and join button. + */ +public class LobbyJoinUI { + + public interface JoinUIListener { + void onJoin(String gameId); + void onBack(); + } + + private final Skin skin; + private final JoinUIListener listener; + private final String gameId; + + private Label boardInfoLabel; + private Label budgetInfoLabel; + private Label statusLabel; + private TextButton joinBtn; + + private int boardSize = -1; + private int budget = -1; + + public LobbyJoinUI(Skin skin, String gameId, JoinUIListener listener) { + this.skin = skin; + this.gameId = gameId; + this.listener = listener; + } + + public Table build() { + Table container = new Table(); + container.top(); + + // Game ID display + Label gameIdLabel = new Label("Game ID: " + gameId, skin, "title"); + gameIdLabel.setAlignment(Align.center); + + // Board and budget info (initially fetching) + boardInfoLabel = new Label("Board: fetching...", skin, "small"); + budgetInfoLabel = new Label("Budget: fetching...", skin, "small"); + + // Status label + statusLabel = new Label("", skin, "small"); + statusLabel.setAlignment(Align.center); + + // Join button + joinBtn = new TextButton("Join Match", skin, "accent"); + joinBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + if (!joinBtn.isDisabled()) { + listener.onJoin(gameId); + } + } + }); + + // Layout + container.add(gameIdLabel).expandX().padBottom(24).row(); + container.add(boardInfoLabel).expandX().padBottom(4).row(); + container.add(budgetInfoLabel).expandX().padBottom(32).row(); + container.add(joinBtn) + .width(LobbyScreenConfig.BUTTON_WIDTH) + .height(LobbyScreenConfig.BUTTON_HEIGHT) + .padBottom(16).row(); + container.add(statusLabel).expandX().row(); + + return container; + } + + // Public methods for updating UI state + + public void updateLobbyInfo(int boardSize, int budget) { + this.boardSize = boardSize; + this.budget = budget; + boardInfoLabel.setText("Board: " + boardSize + " x " + boardSize); + budgetInfoLabel.setText("Budget: " + budget); + } + + public void showJoiningState() { + joinBtn.setDisabled(true); + setStatus("Connecting..."); + } + + public void showJoinedState() { + setStatus("Joined!\nWaiting for host to start the game..."); + } + + public void showJoinFailed() { + setStatus("Lobby not found. Check the Game ID."); + joinBtn.setDisabled(false); + } + + public void setStatus(String message) { + if (statusLabel != null) { + statusLabel.setText(message); + } + } + + public int getBoardSize() { + return boardSize; + } + + public int getBudget() { + return budget; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java new file mode 100644 index 0000000..8b4a249 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java @@ -0,0 +1,10 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyMode.java +package com.group14.regicidechess.screens.lobby; + +/** + * Defines the two modes for the lobby screen. + */ +public enum LobbyMode { + HOST, // Player creating a new lobby + JOIN // Player joining an existing lobby +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java new file mode 100644 index 0000000..8259b72 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java @@ -0,0 +1,301 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreen.java +package com.group14.regicidechess.screens.lobby; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.Stage; +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.viewport.FitViewport; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.screens.mainmenu.MainMenuScreen; +import com.group14.regicidechess.screens.setup.SetupScreen; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * LobbyScreen — thin coordinator for lobby creation and joining. + * + * Refactored version with improved modularity: + * - UI components extracted to LobbyHostUI and LobbyJoinUI + * - Firebase flow extracted to LobbyFlowController + * - State management extracted to LobbyStateManager + * - Screen now focuses purely on coordination between components + */ +public class LobbyScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + + // LibGDX + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // Mode and data + private final LobbyMode mode; + private final String incomingGameId; + + // Components + private final LobbyStateManager stateManager; + private final LobbyFlowController flowController; + private final LobbyHostUI hostUI; + private final LobbyJoinUI joinUI; + + // Shared UI + private Label titleLabel; + private Table root; + + public LobbyScreen(Game game, SpriteBatch batch, LobbyMode mode, Lobby lobby) { + this.game = game; + this.batch = batch; + this.mode = mode; + this.incomingGameId = lobby != null ? lobby.getGameId() : null; + + // Initialize components + this.stateManager = new LobbyStateManager(); + if (lobby != null) { + stateManager.setPrefetchedLobby(lobby); + } + + this.flowController = new LobbyFlowController(createFlowListener()); + this.stateManager.setListener(createStateListener()); + + // LibGDX setup + this.stage = new Stage(new FitViewport( + LobbyScreenConfig.VIEWPORT_WIDTH, + LobbyScreenConfig.VIEWPORT_HEIGHT), + batch); + this.skin = ResourceManager.getInstance().getSkin(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + // Create mode-specific UI + if (mode == LobbyMode.HOST) { + this.hostUI = new LobbyHostUI(skin, createHostUIListener()); + this.joinUI = null; + } else { + this.joinUI = new LobbyJoinUI(skin, incomingGameId, createJoinUIListener()); + this.hostUI = null; + } + + stateManager.enter(); + buildUI(); + + // If we have prefetched lobby data for joiner, update UI immediately + if (mode == LobbyMode.JOIN && lobby != null) { + joinUI.updateLobbyInfo(lobby.getBoardSize(), lobby.getBudget()); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // UI construction + // ───────────────────────────────────────────────────────────────────────── + + private void buildUI() { + root = new Table(); + root.setFillParent(true); + root.setBackground(skin.getDrawable("surface-pixel")); + root.top().pad(32); + stage.addActor(root); + + // Title + String titleText = mode == LobbyMode.HOST ? "Create Lobby" : "Join Lobby"; + titleLabel = new Label(titleText, skin, "title"); + titleLabel.setAlignment(com.badlogic.gdx.utils.Align.center); + root.add(titleLabel).expandX().padBottom(32).row(); + + // Mode-specific content + if (mode == LobbyMode.HOST) { + root.add(hostUI.build()).expandX().fillX().row(); + } else { + root.add(joinUI.build()).expandX().fillX().row(); + } + + // Back button (shared) + TextButton backBtn = new TextButton("Back", skin, "default"); + backBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onBack(); + } + }); + root.add(backBtn) + .width(LobbyScreenConfig.BACK_BUTTON_WIDTH) + .height(LobbyScreenConfig.BACK_BUTTON_HEIGHT) + .padTop(24).row(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Listeners + // ───────────────────────────────────────────────────────────────────────── + + private LobbyHostUI.HostUIListener createHostUIListener() { + return new LobbyHostUI.HostUIListener() { + @Override + public void onCreateLobby(int boardSize, int budget) { + hostUI.showCreatingState(); + stateManager.setBoardSize(boardSize); + stateManager.setBudget(budget); + stateManager.createLobby(); + } + + @Override + public void onStartGame() { + hostUI.showStartingState(); + String gameId = stateManager.getGameId(); + flowController.signalGameStart(gameId); + navigateToSetup(gameId, true); + } + + @Override + public void onBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + }; + } + + private LobbyJoinUI.JoinUIListener createJoinUIListener() { + return new LobbyJoinUI.JoinUIListener() { + @Override + public void onJoin(String gameId) { + joinUI.showJoiningState(); + stateManager.joinLobby(gameId); + } + + @Override + public void onBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + }; + } + + private LobbyStateManager.StateListener createStateListener() { + return new LobbyStateManager.StateListener() { + @Override + public void onLobbyCreated(String gameId) { + hostUI.showLobbyCreated(gameId); + flowController.listenForJoiner(gameId); + } + + @Override + public void onLobbyCreationFailed() { + hostUI.showCreationFailed(); + } + + @Override + public void onLobbyJoined() { + joinUI.updateLobbyInfo( + stateManager.getBoardSize(), + stateManager.getBudget()); + joinUI.showJoinedState(); + flowController.listenForGameStart(stateManager.getGameId()); + } + + @Override + public void onLobbyJoinFailed() { + joinUI.showJoinFailed(); + } + }; + } + + private LobbyFlowController.FlowListener createFlowListener() { + return new LobbyFlowController.FlowListener() { + @Override + public void onJoinerArrived() { + hostUI.showJoinerArrived(); + } + + @Override + public void onGameStarted() { + navigateToSetup(stateManager.getGameId(), false); + } + + @Override + public void onError(String message) { + if (mode == LobbyMode.HOST) { + hostUI.setStatus(message); + } else { + joinUI.setStatus(message); + } + } + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Navigation + // ───────────────────────────────────────────────────────────────────────── + + private void navigateToSetup(String gameId, boolean isHost) { + int boardSize = stateManager.getBoardSize(); + int budget = stateManager.getBudget(); + game.setScreen(new SetupScreen(game, batch, gameId, boardSize, budget, isHost)); + } + + private void onBack() { + game.setScreen(new MainMenuScreen(game, batch)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void render(float delta) { + Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + stateManager.update(delta); + stage.act(delta); + stage.draw(); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + inputHandler.clearObservers(); + stateManager.exit(); + } + + @Override + public void dispose() { + stage.dispose(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Input handling (unused but required by interface) + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int x, int y, 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) {} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java new file mode 100644 index 0000000..44e0d18 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java @@ -0,0 +1,32 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyScreenConfig.java +package com.group14.regicidechess.screens.lobby; + +/** + * Configuration constants for the lobby screen. + */ +public final class LobbyScreenConfig { + private LobbyScreenConfig() {} // Prevent instantiation + + // Viewport dimensions + public static final float VIEWPORT_WIDTH = 480f; + public static final float VIEWPORT_HEIGHT = 854f; + + // Board size limits + public static final int BOARD_MIN = 5; + public static final int BOARD_MAX = 8; + public static final int BOARD_DEFAULT = 8; + + // Budget limits + public static final int BUDGET_MIN = 5; + public static final int BUDGET_MAX = 50; + public static final int BUDGET_DEFAULT = 25; + public static final int BUDGET_STEP = 1; + + // UI dimensions + public static final int SLIDER_WIDTH = 380; + public static final int SLIDER_HEIGHT = 40; + public static final int BUTTON_WIDTH = 280; + public static final int BUTTON_HEIGHT = 60; + public static final int BACK_BUTTON_WIDTH = 200; + public static final int BACK_BUTTON_HEIGHT = 50; +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java new file mode 100644 index 0000000..ac37d51 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java @@ -0,0 +1,123 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/lobby/LobbyStateManager.java +package com.group14.regicidechess.screens.lobby; + +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.states.LobbyState; + +/** + * Manages lobby state and provides a clean interface for state operations. + * Encapsulates LobbyState and provides methods for host and join operations. + */ +public class LobbyStateManager { + + public interface StateListener { + void onLobbyCreated(String gameId); + void onLobbyCreationFailed(); + void onLobbyJoined(); + void onLobbyJoinFailed(); + } + + private final LobbyState lobbyState; + private StateListener listener; + + private int boardSize; + private int budget; + private String gameId; + + public LobbyStateManager() { + this.lobbyState = new LobbyState(); + this.boardSize = LobbyScreenConfig.BOARD_DEFAULT; + this.budget = LobbyScreenConfig.BUDGET_DEFAULT; + } + + public void setListener(StateListener listener) { + this.listener = listener; + } + + public void enter() { + lobbyState.enter(); + } + + public void exit() { + lobbyState.exit(); + } + + public void update(float delta) { + lobbyState.update(delta); + } + + // Host methods + + public void setBoardSize(int boardSize) { + this.boardSize = boardSize; + } + + public void setBudget(int budget) { + this.budget = budget; + } + + public void createLobby() { + lobbyState.createLobby(boardSize, budget, + () -> { + if (listener != null) { + listener.onLobbyCreated(lobbyState.getLobby().getGameId()); + } + }, + () -> { + if (listener != null) { + listener.onLobbyCreationFailed(); + } + }); + } + + // Join methods + + public void setPrefetchedLobby(Lobby lobby) { + if (lobby != null) { + lobbyState.setPrefetchedLobby(lobby); + this.boardSize = lobby.getBoardSize(); + this.budget = lobby.getBudget(); + this.gameId = lobby.getGameId(); + } + } + + public void joinLobby(String gameId) { + this.gameId = gameId; + lobbyState.joinLobby(gameId, + () -> { + if (listener != null) { + listener.onLobbyJoined(); + } + }, + () -> { + if (listener != null) { + listener.onLobbyJoinFailed(); + } + }); + } + + // Getters + + public Lobby getLobby() { + return lobbyState.getLobby(); + } + + public int getBoardSize() { + return lobbyState.getLobby() != null ? + lobbyState.getLobby().getBoardSize() : boardSize; + } + + public int getBudget() { + return lobbyState.getLobby() != null ? + lobbyState.getLobby().getBudget() : budget; + } + + public String getGameId() { + return lobbyState.getLobby() != null ? + lobbyState.getLobby().getGameId() : gameId; + } + + public LobbyState getLobbyState() { + return lobbyState; + } +} \ No newline at end of file From 2542fa57c8961adb41dc599dc1921c7617ed46d3 Mon Sep 17 00:00:00 2001 From: benjamls Date: Tue, 24 Mar 2026 17:45:09 +0100 Subject: [PATCH 14/14] Improve modularity for mainMenuScreen --- .../regicidechess/screens/MainMenuScreen.java | 202 ------------------ .../screens/mainmenu/JoinGamePanel.java | 186 ++++++++++++++++ .../screens/mainmenu/LobbyValidator.java | 76 +++++++ .../screens/mainmenu/MainMenuConfig.java | 39 ++++ .../screens/mainmenu/MainMenuScreen.java | 186 ++++++++++++++++ .../screens/mainmenu/MainMenuUI.java | 163 ++++++++++++++ 6 files changed, 650 insertions(+), 202 deletions(-) delete mode 100644 core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinGamePanel.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java create mode 100644 core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java diff --git a/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java b/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java deleted file mode 100644 index 959777f..0000000 --- a/core/src/main/java/com/group14/regicidechess/screens/MainMenuScreen.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.group14.regicidechess.screens; - -import com.badlogic.gdx.Game; -import com.badlogic.gdx.Gdx; -import com.badlogic.gdx.Screen; -import com.badlogic.gdx.graphics.GL20; -import com.badlogic.gdx.graphics.g2d.SpriteBatch; -import com.badlogic.gdx.scenes.scene2d.Actor; -import com.badlogic.gdx.scenes.scene2d.Stage; -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.ui.TextField; -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.DatabaseManager; -import com.group14.regicidechess.input.ScreenInputHandler; -import com.group14.regicidechess.states.MainMenuState; -import com.group14.regicidechess.utils.ResourceManager; - -public class MainMenuScreen implements Screen, ScreenInputHandler.ScreenInputObserver { - - private static final float V_WIDTH = 480f; - private static final float V_HEIGHT = 854f; - - private final Game game; - private final SpriteBatch batch; - private final Stage stage; - private final Skin skin; - private final ScreenInputHandler inputHandler; - private final MainMenuState mainMenuState; - - private Table joinPanel; - private TextField gameIdField; - private Label errorLabel; - private TextButton joinConfirmBtn; - - public MainMenuScreen(Game game, SpriteBatch batch) { - this.game = game; - this.batch = batch; - - mainMenuState = new MainMenuState(); - mainMenuState.enter(); - - stage = new Stage(new FitViewport(V_WIDTH, V_HEIGHT), batch); - skin = ResourceManager.getInstance().getSkin(); - - inputHandler = new ScreenInputHandler(); - inputHandler.addObserver(this); - - buildUI(); - } - - private void buildUI() { - Table root = new Table(); - root.setFillParent(true); - root.setBackground(skin.getDrawable("surface-pixel")); - stage.addActor(root); - - Label titleLabel = new Label("REGICIDE\nCHESS", skin, "title"); - titleLabel.setAlignment(Align.center); - - Label subtitleLabel = new Label("online strategy chess", skin, "small"); - subtitleLabel.setAlignment(Align.center); - - TextButton createBtn = new TextButton("Create Lobby", skin, "accent"); - TextButton joinBtn = new TextButton("Join Lobby", skin, "default"); - createBtn.pad(12); - joinBtn.pad(12); - - joinPanel = buildJoinPanel(); - joinPanel.setVisible(false); - - errorLabel = new Label("", skin, "small"); - errorLabel.setColor(com.badlogic.gdx.graphics.Color.RED); - errorLabel.setAlignment(Align.center); - - root.add(titleLabel).expandX().padTop(120).padBottom(8).row(); - root.add(subtitleLabel).expandX().padBottom(80).row(); - root.add(createBtn).width(300).height(60).padBottom(20).row(); - root.add(joinBtn).width(300).height(60).padBottom(16).row(); - root.add(errorLabel).expandX().padBottom(8).row(); - root.add(joinPanel).width(320).row(); - - createBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - clearError(); - game.setScreen(new LobbyScreen(game, batch, LobbyScreen.Mode.HOST, null)); - } - }); - - joinBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - clearError(); - joinPanel.setVisible(!joinPanel.isVisible()); - } - }); - } - - private Table buildJoinPanel() { - Table panel = new Table(); - panel.setBackground(skin.getDrawable("primary-pixel")); - panel.pad(16); - - Label hint = new Label("Enter Game ID", skin, "small"); - gameIdField = new TextField("", skin); - gameIdField.setMessageText("e.g. ABC123"); - gameIdField.setMaxLength(10); - - joinConfirmBtn = new TextButton("Join", skin, "accent"); - TextButton backBtn = new TextButton("Back", skin, "default"); - - panel.add(hint).colspan(2).padBottom(8).row(); - panel.add(gameIdField).width(200).height(50).padRight(8); - panel.add(joinConfirmBtn).width(80).height(50).row(); - panel.add(backBtn).colspan(2).width(290).height(48).padTop(8).row(); - - // Validate lobby exists BEFORE navigating to LobbyScreen - joinConfirmBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - onJoinPressed(); - } - }); - - backBtn.addListener(new ChangeListener() { - @Override public void changed(ChangeEvent event, Actor actor) { - joinPanel.setVisible(false); - clearError(); - } - }); - - return panel; - } - - /** - * Validates the Game ID against Firebase before navigating. - * Shows an error inline if not found — user never leaves the main menu. - */ - private void onJoinPressed() { - String gameId = gameIdField.getText().trim(); - if (gameId.isEmpty()) { - showError("Please enter a Game ID."); - return; - } - - joinConfirmBtn.setDisabled(true); - showError("Checking..."); - - DatabaseManager.getInstance().getApi().fetchLobby(gameId, - // Lobby found — navigate to LobbyScreen in JOIN mode with pre-fetched lobby - fetchedLobby -> Gdx.app.postRunnable(() -> { - joinConfirmBtn.setDisabled(false); - clearError(); - // Pass the already-fetched lobby data straight to LobbyScreen - game.setScreen(new LobbyScreen(game, batch, LobbyScreen.Mode.JOIN, - fetchedLobby)); - }), - // Lobby not found - err -> Gdx.app.postRunnable(() -> { - joinConfirmBtn.setDisabled(false); - showError("Lobby not found. Check the Game ID."); - }) - ); - } - - private void showError(String msg) { - errorLabel.setText(msg); - } - - private void clearError() { - errorLabel.setText(""); - } - - @Override public void onTap (int x, int y, 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 show() { - com.badlogic.gdx.InputMultiplexer mx = - new com.badlogic.gdx.InputMultiplexer(stage, inputHandler); - Gdx.input.setInputProcessor(mx); - } - - @Override - public void render(float delta) { - Gdx.gl.glClearColor(0.12f, 0.12f, 0.15f, 1f); - Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); - mainMenuState.update(delta); - stage.act(delta); - stage.draw(); - } - - @Override public void resize(int width, int height) { stage.getViewport().update(width, height, true); } - @Override public void pause() {} - @Override public void resume() {} - @Override public void hide() { inputHandler.clearObservers(); mainMenuState.exit(); } - @Override public void dispose() { stage.dispose(); } -} diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinGamePanel.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinGamePanel.java new file mode 100644 index 0000000..959ef26 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinGamePanel.java @@ -0,0 +1,186 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/JoinGamePanel.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.scenes.scene2d.Actor; +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.ui.TextField; +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener; +import com.group14.regicidechess.model.Lobby; + +/** + * Join game panel UI component. + * Handles the panel that appears when "Join Lobby" is pressed. + */ +public class JoinGamePanel { + + public interface JoinPanelListener { + void onJoin(String gameId); + void onCancel(); + void onError(String message); + } + + private final Skin skin; + private final JoinPanelListener listener; + private final LobbyValidator validator; + + private Table panel; + private TextField gameIdField; + private TextButton joinConfirmBtn; + private Label errorLabel; + + private boolean visible = false; + + public JoinGamePanel(Skin skin, JoinPanelListener listener) { + this.skin = skin; + this.listener = listener; + this.validator = new LobbyValidator(createValidationListener()); + buildPanel(); + } + + private void buildPanel() { + panel = new Table(); + panel.setBackground(skin.getDrawable("primary-pixel")); + panel.pad(MainMenuConfig.JOIN_PANEL_PAD); + + Label hint = new Label("Enter Game ID", skin, "small"); + gameIdField = new TextField("", skin); + gameIdField.setMessageText("e.g. ABC123"); + gameIdField.setMaxLength(MainMenuConfig.MAX_GAME_ID_LENGTH); + + joinConfirmBtn = new TextButton("Join", skin, "accent"); + TextButton backBtn = new TextButton("Back", skin, "default"); + + // Error label for inline validation messages + errorLabel = new Label("", skin, "small"); + errorLabel.setColor(com.badlogic.gdx.graphics.Color.RED); + + panel.add(hint).colspan(2).padBottom(8).row(); + panel.add(gameIdField) + .width(MainMenuConfig.GAME_ID_FIELD_WIDTH) + .height(MainMenuConfig.GAME_ID_FIELD_HEIGHT) + .padRight(8); + panel.add(joinConfirmBtn) + .width(MainMenuConfig.JOIN_BTN_WIDTH) + .height(MainMenuConfig.JOIN_BTN_HEIGHT) + .row(); + panel.add(backBtn) + .colspan(2) + .width(MainMenuConfig.BACK_BTN_WIDTH) + .height(MainMenuConfig.BACK_BTN_HEIGHT) + .padTop(8) + .row(); + panel.add(errorLabel) + .colspan(2) + .padTop(4) + .row(); + + // Button listeners + joinConfirmBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onJoinPressed(); + } + }); + + backBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onCancel(); + } + }); + + panel.setVisible(false); + } + + private void onJoinPressed() { + String gameId = gameIdField.getText().trim(); + if (gameId.isEmpty()) { + showError("Please enter a Game ID."); + return; + } + + joinConfirmBtn.setDisabled(true); + showError("Checking..."); + validator.validate(gameId); + } + + private void onCancel() { + validator.cancel(); + setVisible(false); + clearError(); + gameIdField.setText(""); + if (listener != null) { + listener.onCancel(); + } + } + + private LobbyValidator.ValidationListener createValidationListener() { + return new LobbyValidator.ValidationListener() { + @Override + public void onLobbyFound(Lobby lobby) { + joinConfirmBtn.setDisabled(false); + clearError(); + setVisible(false); + gameIdField.setText(""); + if (listener != null) { + listener.onJoin(lobby.getGameId()); + } + } + + @Override + public void onLobbyNotFound() { + joinConfirmBtn.setDisabled(false); + showError("Lobby not found. Check the Game ID."); + if (listener != null) { + listener.onError("Lobby not found"); + } + } + + @Override + public void onValidationError(String message) { + joinConfirmBtn.setDisabled(false); + showError(message); + if (listener != null) { + listener.onError(message); + } + } + }; + } + + public Table getPanel() { + return panel; + } + + public void setVisible(boolean visible) { + this.visible = visible; + panel.setVisible(visible); + if (!visible) { + validator.cancel(); + joinConfirmBtn.setDisabled(false); + clearError(); + } + } + + public boolean isVisible() { + return visible; + } + + public void showError(String message) { + errorLabel.setText(message); + } + + public void clearError() { + errorLabel.setText(""); + } + + public void setGameId(String gameId) { + gameIdField.setText(gameId); + } + + public String getGameId() { + return gameIdField.getText().trim(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java new file mode 100644 index 0000000..33d1d87 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java @@ -0,0 +1,76 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/LobbyValidator.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.Gdx; +import com.group14.regicidechess.database.DatabaseManager; +import com.group14.regicidechess.model.Lobby; + +/** + * Validates lobby existence in Firebase before navigating. + * Handles async validation and provides callbacks for success/failure. + */ +public class LobbyValidator { + + public interface ValidationListener { + void onLobbyFound(Lobby lobby); + void onLobbyNotFound(); + void onValidationError(String message); + } + + private final ValidationListener listener; + private boolean isValidating = false; + + public LobbyValidator(ValidationListener listener) { + this.listener = listener; + } + + /** + * Validates a Game ID by fetching it from Firebase. + * + * @param gameId the Game ID to validate + */ + public void validate(String gameId) { + if (isValidating) { + if (listener != null) { + listener.onValidationError("Already checking..."); + } + return; + } + + String trimmedId = gameId != null ? gameId.trim() : ""; + + if (trimmedId.isEmpty()) { + if (listener != null) { + listener.onValidationError("Please enter a Game ID."); + } + return; + } + + isValidating = true; + + DatabaseManager.getInstance().getApi().fetchLobby(trimmedId, + // Lobby found + lobby -> Gdx.app.postRunnable(() -> { + isValidating = false; + if (listener != null) { + listener.onLobbyFound(lobby); + } + }), + // Lobby not found + error -> Gdx.app.postRunnable(() -> { + isValidating = false; + if (listener != null) { + listener.onLobbyNotFound(); + } + }) + ); + } + + public boolean isValidating() { + return isValidating; + } + + public void cancel() { + isValidating = false; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java new file mode 100644 index 0000000..b755beb --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java @@ -0,0 +1,39 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuConfig.java +package com.group14.regicidechess.screens.mainmenu; + +/** + * Configuration constants for the main menu screen. + */ +public final class MainMenuConfig { + private MainMenuConfig() {} // Prevent instantiation + + // Viewport dimensions + public static final float VIEWPORT_WIDTH = 480f; + public static final float VIEWPORT_HEIGHT = 854f; + + // Layout dimensions + public static final int TITLE_PAD_TOP = 120; + public static final int TITLE_PAD_BOTTOM = 8; + public static final int SUBTITLE_PAD_BOTTOM = 80; + public static final int BUTTON_WIDTH = 300; + public static final int BUTTON_HEIGHT = 60; + public static final int BUTTON_PAD_BOTTOM = 20; + public static final int JOIN_BUTTON_PAD_BOTTOM = 16; + public static final int JOIN_PANEL_WIDTH = 320; + + // Join panel specific + public static final int GAME_ID_FIELD_WIDTH = 200; + public static final int GAME_ID_FIELD_HEIGHT = 50; + public static final int JOIN_BTN_WIDTH = 80; + public static final int JOIN_BTN_HEIGHT = 50; + public static final int BACK_BTN_WIDTH = 290; + public static final int BACK_BTN_HEIGHT = 48; + public static final int JOIN_PANEL_PAD = 16; + public static final int MAX_GAME_ID_LENGTH = 10; + + // Colors + public static final float BG_R = 0.12f; + public static final float BG_G = 0.12f; + public static final float BG_B = 0.15f; + public static final float BG_A = 1f; +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java new file mode 100644 index 0000000..dbe5e68 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java @@ -0,0 +1,186 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuScreen.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.utils.viewport.FitViewport; +import com.group14.regicidechess.input.ScreenInputHandler; +import com.group14.regicidechess.model.Lobby; +import com.group14.regicidechess.screens.lobby.LobbyMode; +import com.group14.regicidechess.screens.lobby.LobbyScreen; +import com.group14.regicidechess.states.MainMenuState; +import com.group14.regicidechess.utils.ResourceManager; + +/** + * MainMenuScreen — thin coordinator for the main menu. + * + * Refactored version with improved modularity: + * - UI extracted to MainMenuUI + * - Join panel logic extracted to JoinGamePanel + * - Lobby validation extracted to LobbyValidator + * - Screen focuses purely on coordination and navigation + */ +public class MainMenuScreen implements Screen, ScreenInputHandler.ScreenInputObserver { + + // LibGDX + private final Game game; + private final SpriteBatch batch; + private final Stage stage; + private final Skin skin; + private final ScreenInputHandler inputHandler; + + // State and UI + private final MainMenuState mainMenuState; + private final MainMenuUI mainMenuUI; + + public MainMenuScreen(Game game, SpriteBatch batch) { + this.game = game; + this.batch = batch; + + // Initialize state + this.mainMenuState = new MainMenuState(); + mainMenuState.enter(); + + // LibGDX setup + this.stage = new Stage(new FitViewport( + MainMenuConfig.VIEWPORT_WIDTH, + MainMenuConfig.VIEWPORT_HEIGHT), + batch); + this.skin = ResourceManager.getInstance().getSkin(); + this.inputHandler = new ScreenInputHandler(); + inputHandler.addObserver(this); + + // Build UI with listener + this.mainMenuUI = new MainMenuUI(skin, createUIListener()); + + // Add UI to stage + stage.addActor(mainMenuUI.build()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Listeners + // ───────────────────────────────────────────────────────────────────────── + + private MainMenuUI.MainMenuUIListener createUIListener() { + return new MainMenuUI.MainMenuUIListener() { + @Override + public void onCreateLobby() { + // Navigate to lobby screen in HOST mode + game.setScreen(new LobbyScreen(game, batch, LobbyMode.HOST, null)); + } + + @Override + public void onJoinLobby(String gameId) { + // Fetch lobby data before navigating + // Note: JoinGamePanel already validated existence, + // but we still need the lobby data for board size and budget + fetchLobbyAndNavigate(gameId); + } + + @Override + public void onBack() { + // Not used in main menu + } + }; + } + + /** + * Fetches lobby data after validation and navigates to LobbyScreen. + * Since JoinGamePanel already validated that the lobby exists, + * this fetch should succeed. + */ + private void fetchLobbyAndNavigate(String gameId) { + mainMenuUI.showError("Loading..."); + + com.group14.regicidechess.database.DatabaseManager.getInstance() + .getApi() + .fetchLobby(gameId, + // Success - lobby found + lobby -> Gdx.app.postRunnable(() -> { + mainMenuUI.clearError(); + navigateToLobbyScreen(lobby); + }), + // Failure - should not happen since validation passed, but handle gracefully + error -> Gdx.app.postRunnable(() -> { + mainMenuUI.showError("Failed to load lobby data. Please try again."); + }) + ); + } + + private void navigateToLobbyScreen(Lobby lobby) { + game.setScreen(new LobbyScreen(game, batch, LobbyMode.JOIN, lobby)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Screen lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void show() { + Gdx.input.setInputProcessor( + new com.badlogic.gdx.InputMultiplexer(stage, inputHandler)); + } + + @Override + public void render(float delta) { + // Clear screen + Gdx.gl.glClearColor( + MainMenuConfig.BG_R, + MainMenuConfig.BG_G, + MainMenuConfig.BG_B, + MainMenuConfig.BG_A); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + // Update state + mainMenuState.update(delta); + + // Draw UI + stage.act(delta); + stage.draw(); + } + + @Override + public void resize(int width, int height) { + stage.getViewport().update(width, height, true); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + inputHandler.clearObservers(); + mainMenuState.exit(); + mainMenuUI.clearError(); + mainMenuUI.getJoinPanel().setVisible(false); + } + + @Override + public void dispose() { + stage.dispose(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Input handling (unused but required by interface) + // ───────────────────────────────────────────────────────────────────────── + + @Override + public void onTap(int x, int y, 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) {} +} \ No newline at end of file diff --git a/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java new file mode 100644 index 0000000..4222be7 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java @@ -0,0 +1,163 @@ +// File: core/src/main/java/com/group14/regicidechess/screens/mainmenu/MainMenuUI.java +package com.group14.regicidechess.screens.mainmenu; + +import com.badlogic.gdx.scenes.scene2d.Actor; +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; + +/** + * Builds and manages the main menu UI. + * Contains title, subtitle, buttons, and the join panel. + */ +public class MainMenuUI { + + public interface MainMenuUIListener { + void onCreateLobby(); + void onJoinLobby(String gameId); + void onBack(); + } + + private final Skin skin; + private final MainMenuUIListener listener; + private final JoinGamePanel joinPanel; + + private Table root; + private TextButton createBtn; + private TextButton joinBtn; + private Label mainErrorLabel; + + public MainMenuUI(Skin skin, MainMenuUIListener listener) { + this.skin = skin; + this.listener = listener; + this.joinPanel = new JoinGamePanel(skin, createJoinPanelListener()); + } + + public Table build() { + root = new Table(); + root.setFillParent(true); + root.setBackground(skin.getDrawable("surface-pixel")); + + // Title + Label titleLabel = new Label("REGICIDE\nCHESS", skin, "title"); + titleLabel.setAlignment(Align.center); + + // Subtitle + Label subtitleLabel = new Label("online strategy chess", skin, "small"); + subtitleLabel.setAlignment(Align.center); + + // Buttons + createBtn = new TextButton("Create Lobby", skin, "accent"); + joinBtn = new TextButton("Join Lobby", skin, "default"); + createBtn.pad(12); + joinBtn.pad(12); + + // Main error label (for general errors) + mainErrorLabel = new Label("", skin, "small"); + mainErrorLabel.setColor(com.badlogic.gdx.graphics.Color.RED); + mainErrorLabel.setAlignment(Align.center); + + // Layout + root.add(titleLabel) + .expandX() + .padTop(MainMenuConfig.TITLE_PAD_TOP) + .padBottom(MainMenuConfig.TITLE_PAD_BOTTOM) + .row(); + root.add(subtitleLabel) + .expandX() + .padBottom(MainMenuConfig.SUBTITLE_PAD_BOTTOM) + .row(); + root.add(createBtn) + .width(MainMenuConfig.BUTTON_WIDTH) + .height(MainMenuConfig.BUTTON_HEIGHT) + .padBottom(MainMenuConfig.BUTTON_PAD_BOTTOM) + .row(); + root.add(joinBtn) + .width(MainMenuConfig.BUTTON_WIDTH) + .height(MainMenuConfig.BUTTON_HEIGHT) + .padBottom(MainMenuConfig.JOIN_BUTTON_PAD_BOTTOM) + .row(); + root.add(mainErrorLabel) + .expandX() + .padBottom(8) + .row(); + root.add(joinPanel.getPanel()) + .width(MainMenuConfig.JOIN_PANEL_WIDTH) + .row(); + + // Button listeners + createBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onCreatePressed(); + } + }); + + joinBtn.addListener(new ChangeListener() { + @Override + public void changed(ChangeEvent event, Actor actor) { + onJoinPressed(); + } + }); + + return root; + } + + private void onCreatePressed() { + clearError(); + if (listener != null) { + listener.onCreateLobby(); + } + } + + private void onJoinPressed() { + clearError(); + boolean isVisible = joinPanel.isVisible(); + joinPanel.setVisible(!isVisible); + if (isVisible) { + joinPanel.clearError(); + } + } + + private JoinGamePanel.JoinPanelListener createJoinPanelListener() { + return new JoinGamePanel.JoinPanelListener() { + @Override + public void onJoin(String gameId) { + if (listener != null) { + listener.onJoinLobby(gameId); + } + } + + @Override + public void onCancel() { + // No additional action needed + } + + @Override + public void onError(String message) { + // Show error in the main error label as well + showError(message); + } + }; + } + + public void showError(String message) { + mainErrorLabel.setText(message); + } + + public void clearError() { + mainErrorLabel.setText(""); + joinPanel.clearError(); + } + + public Table getRoot() { + return root; + } + + public JoinGamePanel getJoinPanel() { + return joinPanel; + } +} \ No newline at end of file