diff --git a/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java b/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java index 25143ab..c6fe312 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java @@ -102,4 +102,19 @@ public void listenToSessionState(String sessionId, SessionStateListener listener public void updatePlayerScore(String sessionId, String playerId, int score, SimpleCallback callback) { sessionRepository.updatePlayerScore(sessionId, playerId, score, callback); } + + @Override + public void updateRoundState(String sessionId, String state, int roundIndex, SimpleCallback callback) { + sessionRepository.updateRoundState(sessionId, state, roundIndex, callback); + } + + @Override + public void submitAnswer(String sessionId, int roundIndex, String playerId, SimpleCallback callback) { + sessionRepository.submitAnswer(sessionId, roundIndex, playerId, callback); + } + + @Override + public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCountCallback callback) { + sessionRepository.listenToRoundAnswerCount(sessionId, roundIndex, callback); + } } diff --git a/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java b/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java index 2459fc9..d3554d2 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java @@ -345,12 +345,69 @@ public void listenToSessionState( } if (snapshot == null || !snapshot.exists()) return; String state = snapshot.getString("state"); + Long roundLong = snapshot.getLong("currentRound"); + int currentRound = roundLong != null ? roundLong.intValue() : 0; if (state != null) { - listener.onStateChanged(state); + listener.onStateChanged(state, currentRound); } }); } + public void submitAnswer( + String sessionId, + int roundIndex, + String playerId, + FirebaseGateway.SimpleCallback callback + ) { + Map data = new HashMap<>(); + data.put("roundIndex", roundIndex); + data.put("playerId", playerId); + + firestore.collection(SESSIONS) + .document(sessionId) + .collection("Answers") + .document(roundIndex + "_" + playerId) + .set(data) + .addOnSuccessListener(unused -> callback.onSuccess()) + .addOnFailureListener(callback::onFailure); + } + + public void listenToRoundAnswerCount( + String sessionId, + int roundIndex, + FirebaseGateway.AnswerCountCallback callback + ) { + firestore.collection(SESSIONS) + .document(sessionId) + .collection("Answers") + .whereEqualTo("roundIndex", roundIndex) + .addSnapshotListener((snapshot, error) -> { + if (error != null) { + callback.onFailure(error); + return; + } + int count = snapshot != null ? snapshot.size() : 0; + callback.onCountChanged(count); + }); + } + + public void updateRoundState( + String sessionId, + String state, + int roundIndex, + FirebaseGateway.SimpleCallback callback + ) { + Map update = new HashMap<>(); + update.put("state", state); + update.put("currentRound", roundIndex); + + firestore.collection(SESSIONS) + .document(sessionId) + .update(update) + .addOnSuccessListener(unused -> callback.onSuccess()) + .addOnFailureListener(callback::onFailure); + } + public void updatePlayerScore( String sessionId, String playerId, diff --git a/core/src/main/java/group07/beatbattle/BeatBattle.java b/core/src/main/java/group07/beatbattle/BeatBattle.java index 973b6fa..597efe3 100644 --- a/core/src/main/java/group07/beatbattle/BeatBattle.java +++ b/core/src/main/java/group07/beatbattle/BeatBattle.java @@ -9,6 +9,7 @@ import group07.beatbattle.firebase.FirebaseGateway; import group07.beatbattle.controller.LobbyController; import group07.beatbattle.ecs.Engine; +import group07.beatbattle.model.GameSession; import group07.beatbattle.service.MusicService; import group07.beatbattle.states.StartState; import group07.beatbattle.states.StateManager; @@ -17,7 +18,6 @@ import group07.beatbattle.ui.components.BackButton; import group07.beatbattle.ui.components.JoinCreateButton; import group07.beatbattle.ui.style.InputFieldStyles; -import java.util.Random; public class BeatBattle extends Game { @@ -62,6 +62,10 @@ public void setLocalSession(String sessionId, boolean isHost) { public String getLocalSessionId() { return localSessionId; } public boolean isLocalHost() { return localIsHost; } + private GameSession currentSession; + public void setCurrentSession(GameSession session) { this.currentSession = session; } + public GameSession getCurrentSession() { return currentSession; } + @Override public void create() { montserratFont = loadFont("fonts/Montserrat-Regular.ttf", 54); diff --git a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java index 0ff6167..74a3607 100644 --- a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java +++ b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java @@ -1,6 +1,9 @@ package group07.beatbattle.controller; +import com.badlogic.gdx.Gdx; + import group07.beatbattle.BeatBattle; +import group07.beatbattle.firebase.FirebaseGateway; import group07.beatbattle.model.GameSession; import group07.beatbattle.model.Leaderboard; import group07.beatbattle.model.LeaderboardEntry; @@ -50,8 +53,38 @@ public boolean hasMoreRounds() { return session.hasMoreRounds(); } + public boolean isHost() { + return game.isLocalHost(); + } + public void onNextRound() { session.advanceRound(); + + String sessionId = game.getLocalSessionId(); + if (game.isLocalHost() && sessionId != null && !sessionId.isBlank()) { + final int newRound = session.getCurrentRoundIndex(); + game.getFirebaseGateway().updateRoundState( + sessionId, + "in_round", + newRound, + new FirebaseGateway.SimpleCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(LeaderboardController.this::launchNextRound); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.postRunnable(LeaderboardController.this::launchNextRound); + } + } + ); + } else { + launchNextRound(); + } + } + + private void launchNextRound() { RoundController roundController = new RoundController(game, session.getCurrentQuestion(), session); StateManager.getInstance().setState(new InRoundState(game, roundController)); } @@ -61,6 +94,29 @@ public void onBackToMenu() { } public void onGameOver() { - StateManager.getInstance().setState(new GameOverState(game, this)); + String sessionId = game.getLocalSessionId(); + if (game.isLocalHost() && sessionId != null && !sessionId.isBlank()) { + game.getFirebaseGateway().updateSessionState( + sessionId, + "game_over", + new FirebaseGateway.SimpleCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new GameOverState(game, LeaderboardController.this)) + ); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new GameOverState(game, LeaderboardController.this)) + ); + } + } + ); + } else { + StateManager.getInstance().setState(new GameOverState(game, this)); + } } } diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 0d4108a..e363076 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -17,8 +17,11 @@ import group07.beatbattle.model.Song; import group07.beatbattle.model.services.LobbyService; import group07.beatbattle.service.MusicServiceCallback; +import group07.beatbattle.model.Leaderboard; +import group07.beatbattle.states.GameOverState; import group07.beatbattle.states.InRoundState; import group07.beatbattle.states.JoinCreateState; +import group07.beatbattle.states.LeaderboardState; import group07.beatbattle.states.LobbyState; import group07.beatbattle.states.StartState; import group07.beatbattle.states.StateManager; @@ -247,10 +250,96 @@ public void onGameStarted( session.addQuestion(question); } + game.setCurrentSession(session); + + // Start persistent listener to follow host through all game states + startJoinerSyncListener(sessionId, session); + RoundController roundController = new RoundController(game, session.getCurrentQuestion(), session); StateManager.getInstance().setState(new InRoundState(game, roundController)); } + /** + * Joiners: single Firestore listener that routes through leaderboard → + * next round → game over, driven by what the host writes. + */ + private void startJoinerSyncListener(String sessionId, GameSession session) { + // Track the last state+round we handled to avoid duplicate transitions. + // Round 0 is already handled by onGameStarted, so start there. + final String[] lastHandled = {"in_round:0"}; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound) { + String key = state + ":" + currentRound; + if (key.equals(lastHandled[0])) return; + lastHandled[0] = key; + + if ("leaderboard".equals(state)) { + joinerTransitionToLeaderboard(sessionId, session); + } else if ("in_round".equals(state)) { + joinerTransitionToRound(session, currentRound); + } else if ("game_over".equals(state)) { + Gdx.app.postRunnable(() -> { + Leaderboard lb = new Leaderboard(session.getPlayers()); + LeaderboardController lc = new LeaderboardController(game, session, lb); + StateManager.getInstance().setState(new GameOverState(game, lc)); + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("LobbyController", + "Joiner sync error: " + exception.getMessage()); + } + } + ); + } + + private void joinerTransitionToLeaderboard(String sessionId, GameSession session) { + game.getFirebaseGateway().fetchPlayers( + sessionId, + new FirebaseGateway.PlayersListenerCallback() { + @Override + public void onPlayersChanged(List updated) { + Gdx.app.postRunnable(() -> { + for (Player u : updated) { + for (Player local : session.getPlayers()) { + if (local.getId().equals(u.getId())) local.setScore(u.getScore()); + } + } + Leaderboard lb = new Leaderboard(session.getPlayers()); + LeaderboardController lc = new LeaderboardController(game, session, lb); + StateManager.getInstance().setState(new LeaderboardState(game, lc)); + }); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.postRunnable(() -> { + Leaderboard lb = new Leaderboard(session.getPlayers()); + LeaderboardController lc = new LeaderboardController(game, session, lb); + StateManager.getInstance().setState(new LeaderboardState(game, lc)); + }); + } + } + ); + } + + private void joinerTransitionToRound(GameSession session, int roundIndex) { + if (roundIndex >= session.getQuestions().size()) return; + Gdx.app.postRunnable(() -> { + session.setCurrentRoundIndex(roundIndex); + Question question = session.getCurrentQuestion(); + if (question == null) return; + RoundController rc = new RoundController(game, question, session); + StateManager.getInstance().setState(new InRoundState(game, rc)); + }); + } + private void buildAndStartSession(String sessionId, List sessionPlayers, List songs) { GameSession session = new GameSession(sessionId, game.getLocalPlayerId(), numRounds); @@ -294,6 +383,8 @@ private void buildAndStartSession(String sessionId, List sessionPlayers, )); } + game.setCurrentSession(session); + // Store questions → update state → launch round game.getFirebaseGateway().storeQuestions( sessionId, diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index f79b451..2614fc3 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -27,6 +27,7 @@ public class RoundController { private final GameSession session; private final Entity roundEntity; private boolean playerAnswered = false; + private boolean transitioned = false; public RoundController(BeatBattle game, Question question, GameSession session) { this.game = game; @@ -38,6 +39,10 @@ public RoundController(BeatBattle game, Question question, GameSession session) ); Engine.getInstance().addEntity(roundEntity); AudioSystem.getInstance().play(roundEntity); + + if (game.isLocalHost()) { + startAllAnsweredListener(); + } } public Question getQuestion() { @@ -66,14 +71,17 @@ public void onAnswerSubmitted(String answer) { } playerAnswered = true; + recordAnswerInFirebase(); } + /** Called by GameRoundView when the timer runs out (host only). */ public void onRoundExpired() { if (!playerAnswered) { Player localPlayer = findLocalPlayer(); if (localPlayer != null) { submitScoreToFirebase(localPlayer.getId(), localPlayer.getScore()); } + recordAnswerInFirebase(); // count as answered so listener is consistent } transitionToLeaderboard(); } @@ -84,6 +92,57 @@ public void onLeaveSession() { StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); } + // --- Private helpers --- + + /** + * Host listens for the answer count for this round. + * When every player has answered, advance immediately without waiting for the timer. + */ + private void startAllAnsweredListener() { + String sessionId = game.getLocalSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + int totalPlayers = session.getPlayers().size(); + if (totalPlayers == 0) return; + + game.getFirebaseGateway().listenToRoundAnswerCount( + sessionId, + question.getRoundIndex(), + new FirebaseGateway.AnswerCountCallback() { + @Override + public void onCountChanged(int count) { + if (count >= totalPlayers) { + Gdx.app.postRunnable(() -> transitionToLeaderboard()); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("RoundController", + "Answer count listener failed: " + exception.getMessage()); + } + } + ); + } + + private void recordAnswerInFirebase() { + String sessionId = game.getLocalSessionId(); + String playerId = game.getLocalPlayerId(); + if (sessionId == null || sessionId.isBlank() || playerId == null) return; + + game.getFirebaseGateway().submitAnswer( + sessionId, + question.getRoundIndex(), + playerId, + new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { + Gdx.app.error("RoundController", "submitAnswer failed: " + e.getMessage()); + } + } + ); + } + private Player findLocalPlayer() { String localId = game.getLocalPlayerId(); if (localId != null) { @@ -106,11 +165,8 @@ private void submitScoreToFirebase(String playerId, int score) { playerId, score, new FirebaseGateway.SimpleCallback() { - @Override - public void onSuccess() {} - - @Override - public void onFailure(Exception exception) { + @Override public void onSuccess() {} + @Override public void onFailure(Exception exception) { Gdx.app.error("RoundController", "Failed to submit score: " + exception.getMessage()); } @@ -119,8 +175,26 @@ public void onFailure(Exception exception) { } private void transitionToLeaderboard() { + if (transitioned) return; + transitioned = true; + String sessionId = game.getLocalSessionId(); + if (game.isLocalHost() && sessionId != null && !sessionId.isBlank()) { + game.getFirebaseGateway().updateSessionState( + sessionId, + "leaderboard", + new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() { fetchAndLaunch(sessionId); } + @Override public void onFailure(Exception e) { fetchAndLaunch(sessionId); } + } + ); + } else { + fetchAndLaunch(sessionId); + } + } + + private void fetchAndLaunch(String sessionId) { if (sessionId != null && !sessionId.isBlank()) { game.getFirebaseGateway().fetchPlayers( sessionId, diff --git a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java index ffb6fe9..d539696 100644 --- a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java @@ -62,10 +62,19 @@ void deleteSession( void updateSessionState(String sessionId, String state, SimpleCallback callback); + /** Updates both state and currentRound atomically. */ + void updateRoundState(String sessionId, String state, int roundIndex, SimpleCallback callback); + void listenToSessionState(String sessionId, SessionStateListener listener); void updatePlayerScore(String sessionId, String playerId, int score, SimpleCallback callback); + /** Records that a player has submitted an answer for the given round. */ + void submitAnswer(String sessionId, int roundIndex, String playerId, SimpleCallback callback); + + /** Fires with the current answer count whenever a new answer is submitted for a round. */ + void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCountCallback callback); + // --- Callbacks --- interface GamePinExistsCallback { @@ -119,7 +128,14 @@ interface GetQuestionsCallback { } interface SessionStateListener { - void onStateChanged(String state); + /** @param state current session state string + * @param currentRound current round index stored in Firebase */ + void onStateChanged(String state, int currentRound); + void onFailure(Exception exception); + } + + interface AnswerCountCallback { + void onCountChanged(int count); void onFailure(Exception exception); } diff --git a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java index 873cdd4..aee9965 100644 --- a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java @@ -97,4 +97,19 @@ public void listenToSessionState(String sessionId, SessionStateListener listener public void updatePlayerScore(String sessionId, String playerId, int score, SimpleCallback callback) { callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); } + + @Override + public void updateRoundState(String sessionId, String state, int roundIndex, SimpleCallback callback) { + callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); + } + + @Override + public void submitAnswer(String sessionId, int roundIndex, String playerId, SimpleCallback callback) { + callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); + } + + @Override + public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCountCallback callback) { + callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); + } } diff --git a/core/src/main/java/group07/beatbattle/model/GameSession.java b/core/src/main/java/group07/beatbattle/model/GameSession.java index 5fd0fa0..cb65132 100644 --- a/core/src/main/java/group07/beatbattle/model/GameSession.java +++ b/core/src/main/java/group07/beatbattle/model/GameSession.java @@ -60,4 +60,8 @@ public boolean hasMoreRounds() { public void advanceRound() { currentRoundIndex++; } + + public void setCurrentRoundIndex(int index) { + currentRoundIndex = index; + } } diff --git a/core/src/main/java/group07/beatbattle/view/GameRoundView.java b/core/src/main/java/group07/beatbattle/view/GameRoundView.java index c273e8b..a617ea3 100644 --- a/core/src/main/java/group07/beatbattle/view/GameRoundView.java +++ b/core/src/main/java/group07/beatbattle/view/GameRoundView.java @@ -163,13 +163,16 @@ public void render(float delta) { int seconds = (int) Math.ceil(controller.getTimeRemaining()); timerLabel.setText(String.valueOf(seconds)); + // Reveal answers immediately when player answers (or timer runs out) if ((answered || controller.getTimeRemaining() <= 0) && !revealed) { revealed = true; answered = true; revealAnswers(); } - if (revealed) { + // Only start the transition countdown once the timer has fully run out. + // This ensures the host waits for everyone before advancing. + if (controller.getTimeRemaining() <= 0 && revealed && game.isLocalHost()) { resultDelay -= delta; if (resultDelay <= 0) { controller.onRoundExpired(); diff --git a/core/src/main/java/group07/beatbattle/view/LeaderboardView.java b/core/src/main/java/group07/beatbattle/view/LeaderboardView.java index fafaff4..5d0155e 100644 --- a/core/src/main/java/group07/beatbattle/view/LeaderboardView.java +++ b/core/src/main/java/group07/beatbattle/view/LeaderboardView.java @@ -87,9 +87,10 @@ public LeaderboardView(BeatBattle game, LeaderboardController controller) { root.add().expandY().row(); - // --- Countdown label --- + // --- Countdown label (only shown to host) --- String countdownPrefix = controller.hasMoreRounds() ? "Next round in " : "Final results in "; - countdownLabel = new Label(countdownPrefix + "3...", subtitleStyle()); + String initialText = controller.isHost() ? countdownPrefix + "3..." : "Waiting for host..."; + countdownLabel = new Label(initialText, subtitleStyle()); root.add(countdownLabel).padBottom(30f).row(); BackButton leaveButton = new BackButton("Leave Session", game.getMontserratFont()); @@ -114,7 +115,7 @@ public void render(float delta) { Gdx.gl.glClearColor(0.08f, 0.08f, 0.12f, 1f); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); - if (!advanced) { + if (!advanced && controller.isHost()) { timeLeft -= delta; int secs = Math.max(0, (int) Math.ceil(timeLeft)); String prefix = controller.hasMoreRounds() ? "Next round in " : "Final results in "; diff --git a/core/src/main/java/group07/beatbattle/view/LobbyView.java b/core/src/main/java/group07/beatbattle/view/LobbyView.java index 8ae4ecc..dc2884d 100644 --- a/core/src/main/java/group07/beatbattle/view/LobbyView.java +++ b/core/src/main/java/group07/beatbattle/view/LobbyView.java @@ -135,7 +135,7 @@ private void startListeningToSessionState() { sessionId, new FirebaseGateway.SessionStateListener() { @Override - public void onStateChanged(String state) { + public void onStateChanged(String state, int currentRound) { if ("in_round".equals(state) && !gameStarted && !leavingLobby) { gameStarted = true; setStatusMessage("Game starting...");