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 c6fe312..116485d 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java @@ -73,6 +73,11 @@ public void removePlayerFromSession( sessionRepository.removePlayerFromSession(sessionId, playerId, callback); } + @Override + public void cancelSession(String sessionId, String endReason, SimpleCallback callback) { + sessionRepository.cancelSession(sessionId, endReason, callback); + } + @Override public void deleteSession(String sessionId, DeleteSessionCallback callback) { sessionRepository.deleteSession(sessionId, callback); @@ -98,6 +103,11 @@ public void listenToSessionState(String sessionId, SessionStateListener listener sessionRepository.listenToSessionState(sessionId, listener); } + @Override + public void stopListeningToSessionState(String sessionId) { + sessionRepository.stopListeningToSessionState(); + } + @Override public void updatePlayerScore(String sessionId, String playerId, int score, SimpleCallback callback) { sessionRepository.updatePlayerScore(sessionId, playerId, score, 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 3722644..6a7f376 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java @@ -23,6 +23,7 @@ public class FirestoreSessionRepository { private static final String QUESTIONS = "Questions"; private final FirebaseFirestore firestore; + private ListenerRegistration sessionStateRegistration; public FirestoreSessionRepository(FirebaseFirestore firestore) { this.firestore = firestore; @@ -32,9 +33,7 @@ public void gamePinExists(String gamePin, FirebaseGateway.GamePinExistsCallback firestore.collection(SESSIONS) .whereEqualTo("gamePin", gamePin) .get() - .addOnSuccessListener(snapshot -> { - callback.onResult(!snapshot.isEmpty()); - }) + .addOnSuccessListener(snapshot -> callback.onResult(!snapshot.isEmpty())) .addOnFailureListener(callback::onFailure); } @@ -52,6 +51,7 @@ public void createSession( session.put("state", state); session.put("currentRound", currentRound); session.put("totalRounds", totalRounds); + session.put("endReason", FirebaseGateway.END_REASON_NONE); session.put("createdAt", Timestamp.now()); firestore.collection(SESSIONS) @@ -208,6 +208,22 @@ public void removePlayerFromSession( .addOnFailureListener(callback::onFailure); } + public void cancelSession( + String sessionId, + String endReason, + FirebaseGateway.SimpleCallback callback + ) { + Map update = new HashMap<>(); + update.put("state", FirebaseGateway.STATE_CANCELLED); + update.put("endReason", endReason); + + firestore.collection(SESSIONS) + .document(sessionId) + .update(update) + .addOnSuccessListener(unused -> callback.onSuccess()) + .addOnFailureListener(callback::onFailure); + } + public void deleteSession( String sessionId, FirebaseGateway.DeleteSessionCallback callback @@ -307,7 +323,16 @@ public void getQuestions( String songArtist = doc.getString("songArtist"); String previewUrl = doc.getString("previewUrl"); String albumArtUrl = doc.getString("albumArtUrl"); - List options = (List) doc.get("options"); + Object rawOptions = doc.get("options"); + List options = new ArrayList<>(); + + if (rawOptions instanceof List) { + for (Object item : (List) rawOptions) { + if (item instanceof String) { + options.add((String) item); + } + } + } Long roundIndexLong = doc.getLong("roundIndex"); int roundIndex = roundIndexLong != null ? roundIndexLong.intValue() : 0; @@ -335,23 +360,42 @@ public void updateSessionState( .addOnFailureListener(callback::onFailure); } + public void stopListeningToSessionState() { + if (sessionStateRegistration != null) { + sessionStateRegistration.remove(); + sessionStateRegistration = null; + } + } + public void listenToSessionState( String sessionId, FirebaseGateway.SessionStateListener listener ) { - firestore.collection(SESSIONS) + sessionStateRegistration = firestore.collection(SESSIONS) .document(sessionId) .addSnapshotListener((snapshot, error) -> { if (error != null) { listener.onFailure(error); return; } - if (snapshot == null || !snapshot.exists()) return; + + if (snapshot == null || !snapshot.exists()) { + listener.onFailure(new IllegalStateException("Session not found")); + return; + } + String state = snapshot.getString("state"); Long roundLong = snapshot.getLong("currentRound"); + String endReason = snapshot.getString("endReason"); + int currentRound = roundLong != null ? roundLong.intValue() : 0; + if (state != null) { - listener.onStateChanged(state, currentRound); + listener.onStateChanged( + state, + currentRound, + endReason != null ? endReason : "" + ); } }); } diff --git a/core/src/main/java/group07/beatbattle/BeatBattle.java b/core/src/main/java/group07/beatbattle/BeatBattle.java index 9f2eb24..ae02204 100644 --- a/core/src/main/java/group07/beatbattle/BeatBattle.java +++ b/core/src/main/java/group07/beatbattle/BeatBattle.java @@ -60,6 +60,11 @@ public void setLocalSession(String sessionId, boolean isHost) { this.localIsHost = isHost; } + public void clearLocalSession() { + this.localSessionId = null; + this.localIsHost = false; + } + public String getLocalSessionId() { return localSessionId; } public boolean isLocalHost() { return localIsHost; } diff --git a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java index 74a3607..bc749cb 100644 --- a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java +++ b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java @@ -2,11 +2,14 @@ import com.badlogic.gdx.Gdx; +import java.util.List; + import group07.beatbattle.BeatBattle; import group07.beatbattle.firebase.FirebaseGateway; import group07.beatbattle.model.GameSession; import group07.beatbattle.model.Leaderboard; import group07.beatbattle.model.LeaderboardEntry; +import group07.beatbattle.model.Player; import group07.beatbattle.states.GameOverState; import group07.beatbattle.states.InRoundState; import group07.beatbattle.states.StartState; @@ -16,16 +19,51 @@ public class LeaderboardController { private final BeatBattle game; private final GameSession session; - private final Leaderboard leaderboard; - public LeaderboardController(BeatBattle game, GameSession session, Leaderboard leaderboard) { + private boolean playerListenerActive = false; + private Runnable onPlayersChangedCallback; + + public LeaderboardController(BeatBattle game, GameSession session) { this.game = game; this.session = session; - this.leaderboard = leaderboard; } public Leaderboard getLeaderboard() { - return leaderboard; + return new Leaderboard(session.getPlayers()); + } + + public void startPlayerListener(Runnable onChanged) { + this.playerListenerActive = true; + this.onPlayersChangedCallback = onChanged; + String sessionId = game.getLocalSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToPlayers( + sessionId, + new FirebaseGateway.PlayersListenerCallback() { + @Override + public void onPlayersChanged(List updatedPlayers) { + if (!playerListenerActive) return; + Gdx.app.postRunnable(() -> { + if (!playerListenerActive) return; + session.syncPlayers(updatedPlayers); + if (onPlayersChangedCallback != null) { + onPlayersChangedCallback.run(); + } + }); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.error("LeaderboardController", "Player listener failed: " + e.getMessage()); + } + } + ); + } + + public void stopPlayerListener() { + playerListenerActive = false; + onPlayersChangedCallback = null; } /** Returns the leaderboard entry for the local player, or null if not found. */ @@ -35,7 +73,7 @@ public LeaderboardEntry getLocalPlayerEntry() { localId = session.getPlayers().get(0).getId(); } if (localId == null) return null; - for (LeaderboardEntry entry : leaderboard.getEntries()) { + for (LeaderboardEntry entry : getLeaderboard().getEntries()) { if (entry.getPlayerId().equals(localId)) return entry; } return null; @@ -65,7 +103,7 @@ public void onNextRound() { final int newRound = session.getCurrentRoundIndex(); game.getFirebaseGateway().updateRoundState( sessionId, - "in_round", + FirebaseGateway.STATE_IN_ROUND, newRound, new FirebaseGateway.SimpleCallback() { @Override @@ -89,16 +127,81 @@ private void launchNextRound() { StateManager.getInstance().setState(new InRoundState(game, roundController)); } - public void onBackToMenu() { + public void onSessionCancelled() { StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); } + public String getSessionId() { + return game.getLocalSessionId(); + } + + public void onBackToMenu() { + String sessionId = game.getLocalSessionId(); + String playerId = game.getLocalPlayerId(); + + if (sessionId == null || sessionId.isBlank()) { + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); + return; + } + + if (game.isLocalHost()) { + game.getFirebaseGateway().cancelSession( + sessionId, + FirebaseGateway.END_REASON_HOST_LEFT, + new FirebaseGateway.SimpleCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); + } + } + ); + } else { + if (playerId == null || playerId.isBlank()) { + game.clearLocalSession(); + game.getFirebaseGateway().stopListeningToSessionState(sessionId); + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); + return; + } + + game.clearLocalSession(); + game.getFirebaseGateway().stopListeningToSessionState(sessionId); + game.getFirebaseGateway().removePlayerFromSession( + sessionId, + playerId, + new FirebaseGateway.RemovePlayerCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); + } + } + ); + } + } + public void onGameOver() { String sessionId = game.getLocalSessionId(); if (game.isLocalHost() && sessionId != null && !sessionId.isBlank()) { game.getFirebaseGateway().updateSessionState( sessionId, - "game_over", + FirebaseGateway.STATE_GAME_OVER, new FirebaseGateway.SimpleCallback() { @Override public void onSuccess() { diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 6986237..7a3390f 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -17,7 +17,6 @@ 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; @@ -47,6 +46,10 @@ public void onJoinGame() { StateManager.getInstance().setState(new JoinCreateState(game, GameMode.JOIN, this)); } + public void onHostCancelledSession(GameMode mode) { + StateManager.getInstance().setState(new JoinCreateState(game, mode, this)); + } + public void onReady( GameMode mode, String playerName, @@ -265,25 +268,34 @@ public void onGameStarted( 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"}; + final String[] lastHandled = {FirebaseGateway.STATE_IN_ROUND + ":0:" + FirebaseGateway.END_REASON_NONE}; game.getFirebaseGateway().listenToSessionState( sessionId, new FirebaseGateway.SessionStateListener() { @Override - public void onStateChanged(String state, int currentRound) { - String key = state + ":" + currentRound; + public void onStateChanged(String state, int currentRound, String endReason) { + String key = state + ":" + currentRound + ":" + endReason; if (key.equals(lastHandled[0])) return; lastHandled[0] = key; - if ("leaderboard".equals(state)) { + if (game.getLocalSessionId() == null) return; + + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); + return; + } + + if (FirebaseGateway.STATE_LEADERBOARD.equals(state)) { joinerTransitionToLeaderboard(sessionId, session); - } else if ("in_round".equals(state)) { + } else if (FirebaseGateway.STATE_IN_ROUND.equals(state)) { joinerTransitionToRound(session, currentRound); - } else if ("game_over".equals(state)) { + } else if (FirebaseGateway.STATE_GAME_OVER.equals(state)) { Gdx.app.postRunnable(() -> { - Leaderboard lb = new Leaderboard(session.getPlayers()); - LeaderboardController lc = new LeaderboardController(game, session, lb); + if (game.getLocalSessionId() == null) return; + LeaderboardController lc = new LeaderboardController(game, session); StateManager.getInstance().setState(new GameOverState(game, lc)); }); } @@ -305,13 +317,9 @@ private void joinerTransitionToLeaderboard(String sessionId, GameSession session @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); + if (game.getLocalSessionId() == null) return; + session.syncPlayers(updated); + LeaderboardController lc = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, lc)); }); } @@ -319,8 +327,8 @@ public void onPlayersChanged(List updated) { @Override public void onFailure(Exception e) { Gdx.app.postRunnable(() -> { - Leaderboard lb = new Leaderboard(session.getPlayers()); - LeaderboardController lc = new LeaderboardController(game, session, lb); + if (game.getLocalSessionId() == null) return; + LeaderboardController lc = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, lc)); }); } @@ -331,6 +339,7 @@ public void onFailure(Exception e) { private void joinerTransitionToRound(GameSession session, int roundIndex) { if (roundIndex >= session.getQuestions().size()) return; Gdx.app.postRunnable(() -> { + if (game.getLocalSessionId() == null) return; session.setCurrentRoundIndex(roundIndex); Question question = session.getCurrentQuestion(); if (question == null) return; diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index 2614fc3..7403955 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -12,7 +12,6 @@ import group07.beatbattle.ecs.systems.AudioSystem; import group07.beatbattle.firebase.FirebaseGateway; import group07.beatbattle.model.GameSession; -import group07.beatbattle.model.Leaderboard; import group07.beatbattle.model.Player; import group07.beatbattle.model.Question; import group07.beatbattle.model.score.ScoreCalculator; @@ -28,11 +27,14 @@ public class RoundController { private final Entity roundEntity; private boolean playerAnswered = false; private boolean transitioned = false; + private int activePlayerCount; + private int currentAnswerCount = 0; public RoundController(BeatBattle game, Question question, GameSession session) { this.game = game; this.question = question; this.session = session; + this.activePlayerCount = session.getPlayers().size(); this.roundEntity = RoundFactory.getInstance().createRound( question.getId(), question.getSong().getPreviewUrl() @@ -42,6 +44,7 @@ public RoundController(BeatBattle game, Question question, GameSession session) if (game.isLocalHost()) { startAllAnsweredListener(); + startActivePlayersListener(); } } @@ -86,12 +89,86 @@ public void onRoundExpired() { transitionToLeaderboard(); } - public void onLeaveSession() { + public void onSessionCancelled() { + leaveToStart(); + } + + public String getSessionId() { + return game.getLocalSessionId(); + } + + public boolean isHost() { + return game.isLocalHost(); + } + + private void leaveToStart() { AudioSystem.getInstance().stop(roundEntity); Engine.getInstance().removeEntity(roundEntity); + String sid = game.getLocalSessionId(); + game.clearLocalSession(); + game.getFirebaseGateway().stopListeningToSessionState(sid); StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); } + public void onLeaveSession() { + String sessionId = game.getLocalSessionId(); + String playerId = game.getLocalPlayerId(); + + if (sessionId == null || sessionId.isBlank()) { + leaveToStart(); + return; + } + + if (game.isLocalHost()) { + game.getFirebaseGateway().cancelSession( + sessionId, + FirebaseGateway.END_REASON_HOST_LEFT, + new FirebaseGateway.SimpleCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(RoundController.this::leaveToStart); + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.postRunnable(RoundController.this::leaveToStart); + } + } + ); + } else { + if (playerId == null || playerId.isBlank()) { + leaveToStart(); + return; + } + + game.clearLocalSession(); + game.getFirebaseGateway().stopListeningToSessionState(sessionId); + game.getFirebaseGateway().removePlayerFromSession( + sessionId, + playerId, + new FirebaseGateway.RemovePlayerCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(() -> { + AudioSystem.getInstance().stop(roundEntity); + Engine.getInstance().removeEntity(roundEntity); + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); + }); + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.postRunnable(() -> { + AudioSystem.getInstance().stop(roundEntity); + Engine.getInstance().removeEntity(roundEntity); + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); + }); + } + } + ); + } + } + // --- Private helpers --- /** @@ -102,18 +179,14 @@ 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()); - } + currentAnswerCount = count; + maybeAdvanceToLeaderboard(); } @Override @@ -125,6 +198,37 @@ public void onFailure(Exception exception) { ); } + private void startActivePlayersListener() { + String sessionId = game.getLocalSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToPlayers( + sessionId, + new FirebaseGateway.PlayersListenerCallback() { + @Override + public void onPlayersChanged(List players) { + activePlayerCount = players != null ? players.size() : 0; + maybeAdvanceToLeaderboard(); + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("RoundController", + "Players listener failed: " + exception.getMessage()); + } + } + ); + } + + private void maybeAdvanceToLeaderboard() { + if (transitioned) return; + if (activePlayerCount <= 0) return; + + if (currentAnswerCount >= activePlayerCount) { + Gdx.app.postRunnable(this::transitionToLeaderboard); + } + } + private void recordAnswerInFirebase() { String sessionId = game.getLocalSessionId(); String playerId = game.getLocalPlayerId(); @@ -183,7 +287,7 @@ private void transitionToLeaderboard() { if (game.isLocalHost() && sessionId != null && !sessionId.isBlank()) { game.getFirebaseGateway().updateSessionState( sessionId, - "leaderboard", + FirebaseGateway.STATE_LEADERBOARD, new FirebaseGateway.SimpleCallback() { @Override public void onSuccess() { fetchAndLaunch(sessionId); } @Override public void onFailure(Exception e) { fetchAndLaunch(sessionId); } @@ -202,13 +306,7 @@ private void fetchAndLaunch(String sessionId) { @Override public void onPlayersChanged(List updatedPlayers) { Gdx.app.postRunnable(() -> { - for (Player updated : updatedPlayers) { - for (Player local : session.getPlayers()) { - if (local.getId().equals(updated.getId())) { - local.setScore(updated.getScore()); - } - } - } + session.syncPlayers(updatedPlayers); launchLeaderboard(); }); } @@ -227,8 +325,7 @@ public void onFailure(Exception exception) { private void launchLeaderboard() { AudioSystem.getInstance().stop(roundEntity); Engine.getInstance().removeEntity(roundEntity); - Leaderboard leaderboard = new Leaderboard(session.getPlayers()); - LeaderboardController leaderboardController = new LeaderboardController(game, session, leaderboard); + LeaderboardController leaderboardController = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, leaderboardController)); } } diff --git a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java index d539696..98acf0f 100644 --- a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java @@ -6,6 +6,15 @@ public interface FirebaseGateway { + String STATE_LOBBY = "lobby"; + String STATE_IN_ROUND = "in_round"; + String STATE_LEADERBOARD = "leaderboard"; + String STATE_GAME_OVER = "game_over"; + String STATE_CANCELLED = "cancelled"; + + String END_REASON_NONE = ""; + String END_REASON_HOST_LEFT = "host_left"; + void gamePinExists(String gamePin, GamePinExistsCallback callback); void createSession( @@ -49,6 +58,8 @@ void removePlayerFromSession( RemovePlayerCallback callback ); + void cancelSession(String sessionId, String endReason, SimpleCallback callback); + void deleteSession( String sessionId, DeleteSessionCallback callback @@ -67,6 +78,8 @@ void deleteSession( void listenToSessionState(String sessionId, SessionStateListener listener); + void stopListeningToSessionState(String sessionId); + void updatePlayerScore(String sessionId, String playerId, int score, SimpleCallback callback); /** Records that a player has submitted an answer for the given round. */ @@ -130,7 +143,7 @@ interface GetQuestionsCallback { interface SessionStateListener { /** @param state current session state string * @param currentRound current round index stored in Firebase */ - void onStateChanged(String state, int currentRound); + void onStateChanged(String state, int currentRound, String endReason); 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 aee9965..29d8a11 100644 --- a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java @@ -65,6 +65,11 @@ public void removePlayerFromSession( callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); } + @Override + public void cancelSession(String sessionId, String endReason, SimpleCallback callback) { + callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); + } + @Override public void deleteSession( String sessionId, @@ -93,6 +98,11 @@ public void listenToSessionState(String sessionId, SessionStateListener listener listener.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); } + @Override + public void stopListeningToSessionState(String sessionId) { + // No-op: no listener was registered + } + @Override public void updatePlayerScore(String sessionId, String playerId, int score, SimpleCallback 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 cb65132..f152f41 100644 --- a/core/src/main/java/group07/beatbattle/model/GameSession.java +++ b/core/src/main/java/group07/beatbattle/model/GameSession.java @@ -42,6 +42,23 @@ public void removePlayer(String playerId) { players.removeIf(p -> p.getId().equals(playerId)); } + /** Sync local player list to match Firebase: remove departed players, update scores. */ + public void syncPlayers(List updated) { + players.removeIf(local -> { + for (Player u : updated) { + if (u.getId().equals(local.getId())) return false; + } + return true; + }); + for (Player u : updated) { + for (Player local : players) { + if (local.getId().equals(u.getId())) { + local.setScore(u.getScore()); + } + } + } + } + public void addQuestion(Question question) { questions.add(question); } public Question getCurrentQuestion() { diff --git a/core/src/main/java/group07/beatbattle/model/services/LobbyService.java b/core/src/main/java/group07/beatbattle/model/services/LobbyService.java index c1cbbf2..96025f0 100644 --- a/core/src/main/java/group07/beatbattle/model/services/LobbyService.java +++ b/core/src/main/java/group07/beatbattle/model/services/LobbyService.java @@ -34,7 +34,7 @@ public interface LeaveLobbyCallback { private static final String PIN_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final int DEFAULT_TOTAL_ROUNDS = 20; private static final int INITIAL_ROUND = 0; - private static final String INITIAL_SESSION_STATE = "lobby"; + private static final String INITIAL_SESSION_STATE = FirebaseGateway.STATE_LOBBY; private final FirebaseGateway firebaseGateway; private final Random random; @@ -183,9 +183,10 @@ public void leaveLobby( } if (isHost) { - firebaseGateway.deleteSession( + firebaseGateway.cancelSession( sessionId, - new FirebaseGateway.DeleteSessionCallback() { + FirebaseGateway.END_REASON_HOST_LEFT, + new FirebaseGateway.SimpleCallback() { @Override public void onSuccess() { callback.onSuccess(); diff --git a/core/src/main/java/group07/beatbattle/ui/dialog/AlertDialogs.java b/core/src/main/java/group07/beatbattle/ui/dialog/AlertDialogs.java index e28221c..0c93d10 100644 --- a/core/src/main/java/group07/beatbattle/ui/dialog/AlertDialogs.java +++ b/core/src/main/java/group07/beatbattle/ui/dialog/AlertDialogs.java @@ -64,6 +64,42 @@ public static void showInfo( centerDialog(dialog, stage, dialogWidth); } + public static void showInfo( + Stage stage, + BitmapFont font, + String title, + String message, + Runnable onDismiss + ) { + float dialogWidth = getDialogWidth(stage); + float contentWidth = dialogWidth - 140f; + + Skin skin = createSkin(font, BACKGROUND_DARK, BACKGROUND_DARK, dialogWidth); + + Dialog dialog = new Dialog(title, skin) { + @Override + protected void result(Object object) { + if (onDismiss != null) { + onDismiss.run(); + } + } + }; + + Label contentLabel = createContentLabel(message, skin); + + dialog.getContentTable() + .add(contentLabel) + .width(contentWidth) + .padTop(10) + .padBottom(10); + + dialog.button("OK", true); + + styleDialog(dialog); + dialog.show(stage); + centerDialog(dialog, stage, dialogWidth); + } + public static void showWarning( Stage stage, BitmapFont font, diff --git a/core/src/main/java/group07/beatbattle/view/GameOverView.java b/core/src/main/java/group07/beatbattle/view/GameOverView.java index eef29cd..925bb1c 100644 --- a/core/src/main/java/group07/beatbattle/view/GameOverView.java +++ b/core/src/main/java/group07/beatbattle/view/GameOverView.java @@ -21,6 +21,8 @@ import group07.beatbattle.i18n.Strings; import group07.beatbattle.model.LeaderboardEntry; import group07.beatbattle.ui.components.BackButton; +import group07.beatbattle.ui.dialog.AlertDialogs; +import group07.beatbattle.firebase.FirebaseGateway; public class GameOverView extends ScreenAdapter { @@ -34,6 +36,8 @@ public class GameOverView extends ScreenAdapter { private final Stage stage; private final Texture logoTexture; + private boolean sessionCancelledHandled = false; + public GameOverView(BeatBattle game, LeaderboardController controller) { this.game = game; this.controller = controller; @@ -86,12 +90,19 @@ public GameOverView(BeatBattle game, LeaderboardController controller) { backButton.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - controller.onBackToMenu(); + AlertDialogs.showConfirmation( + stage, + game.getMontserratFont(), + Strings.leaveSession(), + controller.isHost() ? Strings.leaveHostMsg() : Strings.leavePlayerMsg(), + controller::onBackToMenu + ); } }); root.add(backButton).width(600f).height(130f).row(); stage.addActor(root); + startListeningToSessionState(); } private Table buildRow(LeaderboardEntry entry, boolean isLocal) { @@ -145,4 +156,42 @@ public void dispose() { stage.dispose(); logoTexture.dispose(); } + + private void startListeningToSessionState() { + String sessionId = controller.getSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound, String endReason) { + if (sessionCancelledHandled) return; + + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + sessionCancelledHandled = true; + Gdx.app.postRunnable(() -> { + if (FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason)) { + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + controller::onSessionCancelled + ); + } else { + controller.onSessionCancelled(); + } + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("GameOverView", + "Session state listener failed: " + exception.getMessage()); + } + } + ); + } } diff --git a/core/src/main/java/group07/beatbattle/view/GameRoundView.java b/core/src/main/java/group07/beatbattle/view/GameRoundView.java index dff9e9a..9dc0324 100644 --- a/core/src/main/java/group07/beatbattle/view/GameRoundView.java +++ b/core/src/main/java/group07/beatbattle/view/GameRoundView.java @@ -19,6 +19,8 @@ import com.badlogic.gdx.utils.Align; import com.badlogic.gdx.utils.Scaling; import com.badlogic.gdx.utils.viewport.ScreenViewport; +import group07.beatbattle.firebase.FirebaseGateway; +import group07.beatbattle.ui.dialog.AlertDialogs; import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.RoundController; @@ -65,6 +67,7 @@ public class GameRoundView extends ScreenAdapter { private Texture iconSoundTex; private Texture iconMuteTex; private Texture logoTexture; + private boolean sessionCancelledHandled = false; public GameRoundView(BeatBattle game, RoundController controller) { this.game = game; @@ -108,7 +111,13 @@ public GameRoundView(BeatBattle game, RoundController controller) { leaveButton.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - controller.onLeaveSession(); + AlertDialogs.showConfirmation( + stage, + game.getMontserratFont(), + Strings.leaveSession(), + controller.isHost() ? Strings.leaveHostMsg() : Strings.leavePlayerMsg(), + controller::onLeaveSession + ); } }); @@ -191,6 +200,7 @@ public void changed(ChangeEvent event, Actor actor) { root.add(grid).expand().center().row(); stage.addActor(root); + startListeningToSessionState(); } @Override @@ -346,4 +356,43 @@ private Texture solidTexture(Color color) { pixmap.dispose(); return tex; } + + private void startListeningToSessionState() { + String sessionId = controller.getSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound, String endReason) { + if (sessionCancelledHandled) return; + + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + sessionCancelledHandled = true; + + Gdx.app.postRunnable(() -> { + if (FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason)) { + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + controller::onSessionCancelled + ); + } else { + controller.onSessionCancelled(); + } + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("GameRoundView", + "Session state listener failed: " + exception.getMessage()); + } + } + ); + } } diff --git a/core/src/main/java/group07/beatbattle/view/LeaderboardView.java b/core/src/main/java/group07/beatbattle/view/LeaderboardView.java index adc3e6f..fda0b8b 100644 --- a/core/src/main/java/group07/beatbattle/view/LeaderboardView.java +++ b/core/src/main/java/group07/beatbattle/view/LeaderboardView.java @@ -20,6 +20,8 @@ import group07.beatbattle.model.LeaderboardEntry; import group07.beatbattle.ui.components.BackButton; import group07.beatbattle.ui.components.JoinCreateButton; +import group07.beatbattle.ui.dialog.AlertDialogs; +import group07.beatbattle.firebase.FirebaseGateway; public class LeaderboardView extends ScreenAdapter { @@ -32,6 +34,8 @@ public class LeaderboardView extends ScreenAdapter { private Label countdownLabel; private float timeLeft = AUTO_ADVANCE_DELAY; private boolean advanced = false; + private boolean sessionCancelledHandled = false; + private Table playersTable; public LeaderboardView(BeatBattle game, LeaderboardController controller) { this.game = game; @@ -75,19 +79,10 @@ public LeaderboardView(BeatBattle game, LeaderboardController controller) { header.add(styledLabel(Strings.scoreCol(), Color.LIGHT_GRAY)).width(200f).right(); root.add(header).fillX().padBottom(20f).row(); - // --- Top 3 rows --- - java.util.List entries = controller.getLeaderboard().getEntries(); - int topCount = Math.min(3, entries.size()); - for (int i = 0; i < topCount; i++) { - root.add(buildPlayerRow(entries.get(i))).fillX().padBottom(16f).row(); - } - - // --- Local player row (if outside top 3) --- - LeaderboardEntry local = controller.getLocalPlayerEntry(); - if (local != null && local.getRank() > 3) { - root.add(styledLabel("· · ·", Color.DARK_GRAY)).padTop(8f).padBottom(8f).row(); - root.add(buildPlayerRow(local)).fillX().padBottom(16f).row(); - } + // --- Player rows (rebuilt live when roster changes) --- + playersTable = new Table(); + buildPlayerRows(); + root.add(playersTable).fillX().row(); root.add().expandY().row(); @@ -101,12 +96,20 @@ public LeaderboardView(BeatBattle game, LeaderboardController controller) { leaveButton.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { - controller.onBackToMenu(); + AlertDialogs.showConfirmation( + stage, + game.getMontserratFont(), + Strings.leaveSession(), + controller.isHost() ? Strings.leaveHostMsg() : Strings.leavePlayerMsg(), + controller::onBackToMenu + ); } }); root.add(leaveButton).width(500f).height(120f).row(); stage.addActor(root); + controller.startPlayerListener(this::refreshLeaderboard); + startListeningToSessionState(); } @Override @@ -146,10 +149,29 @@ public void resize(int width, int height) { @Override public void dispose() { + controller.stopPlayerListener(); stage.dispose(); logoTexture.dispose(); } + private void buildPlayerRows() { + playersTable.clearChildren(); + java.util.List entries = controller.getLeaderboard().getEntries(); + int topCount = Math.min(3, entries.size()); + for (int i = 0; i < topCount; i++) { + playersTable.add(buildPlayerRow(entries.get(i))).fillX().padBottom(16f).row(); + } + LeaderboardEntry local = controller.getLocalPlayerEntry(); + if (local != null && local.getRank() > 3) { + playersTable.add(styledLabel("· · ·", Color.DARK_GRAY)).padTop(8f).padBottom(8f).row(); + playersTable.add(buildPlayerRow(local)).fillX().padBottom(16f).row(); + } + } + + private void refreshLeaderboard() { + buildPlayerRows(); + } + private Table buildPlayerRow(LeaderboardEntry entry) { Table row = new Table(); row.add(styledLabel(entry.getRank() + ".", Color.WHITE)).width(80f).left(); @@ -186,4 +208,42 @@ private Label.LabelStyle subtitleStyle() { style.fontColor = Color.LIGHT_GRAY; return style; } + + private void startListeningToSessionState() { + String sessionId = controller.getSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound, String endReason) { + if (sessionCancelledHandled) return; + + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + sessionCancelledHandled = true; + Gdx.app.postRunnable(() -> { + if (FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason)) { + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + controller::onSessionCancelled + ); + } else { + controller.onSessionCancelled(); + } + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("LeaderboardView", + "Session state listener failed: " + exception.getMessage()); + } + } + ); + } } diff --git a/core/src/main/java/group07/beatbattle/view/LobbyView.java b/core/src/main/java/group07/beatbattle/view/LobbyView.java index d00b23b..0818133 100644 --- a/core/src/main/java/group07/beatbattle/view/LobbyView.java +++ b/core/src/main/java/group07/beatbattle/view/LobbyView.java @@ -140,8 +140,25 @@ private void startListeningToSessionState() { sessionId, new FirebaseGateway.SessionStateListener() { @Override - public void onStateChanged(String state, int currentRound) { - if ("in_round".equals(state) && !gameStarted && !leavingLobby) { + public void onStateChanged(String state, int currentRound, String endReason) { + if (FirebaseGateway.STATE_CANCELLED.equals(state) && !leavingLobby) { + Gdx.app.postRunnable(() -> { + if (FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason)) { + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + () -> controller.onHostCancelledSession(mode) + ); + } else { + controller.onHostCancelledSession(mode); + } + }); + return; + } + + if (FirebaseGateway.STATE_IN_ROUND.equals(state) && !gameStarted && !leavingLobby) { gameStarted = true; setStatusMessage(Strings.gameStarting()); game.getFirebaseGateway().getQuestions(