From c46974fe0dd641177743cea44bc25067fd7081e3 Mon Sep 17 00:00:00 2001 From: odaakj Date: Sun, 19 Apr 2026 14:31:30 +0200 Subject: [PATCH 01/11] Update model package architecture --- .../beatbattle/model/{ => domain}/AnswerSubmission.java | 2 +- .../group07/beatbattle/model/{ => domain}/GameMode.java | 2 +- .../group07/beatbattle/model/{ => domain}/GameRules.java | 2 +- .../group07/beatbattle/model/{ => domain}/GameSession.java | 2 +- .../group07/beatbattle/model/{ => domain}/Leaderboard.java | 2 +- .../beatbattle/model/{ => domain}/LeaderboardEntry.java | 2 +- .../java/group07/beatbattle/model/{ => domain}/Player.java | 2 +- .../group07/beatbattle/model/{ => domain}/Question.java | 2 +- .../java/group07/beatbattle/model/{ => domain}/Song.java | 2 +- .../beatbattle/model/{ => dto}/SessionCreationResult.java | 2 +- .../beatbattle/model/{ => dto}/SessionJoinResult.java | 2 +- .../group07/beatbattle/model/score/ScoreCalculator.java | 2 +- .../group07/beatbattle/model/services/LobbyService.java | 6 +++--- .../beatbattle/model/services/MusicServiceCallback.java | 2 +- 14 files changed, 16 insertions(+), 16 deletions(-) rename core/src/main/java/group07/beatbattle/model/{ => domain}/AnswerSubmission.java (96%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/GameMode.java (76%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/GameRules.java (83%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/GameSession.java (98%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/Leaderboard.java (96%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/LeaderboardEntry.java (95%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/Player.java (96%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/Question.java (96%) rename core/src/main/java/group07/beatbattle/model/{ => domain}/Song.java (95%) rename core/src/main/java/group07/beatbattle/model/{ => dto}/SessionCreationResult.java (95%) rename core/src/main/java/group07/beatbattle/model/{ => dto}/SessionJoinResult.java (95%) diff --git a/core/src/main/java/group07/beatbattle/model/AnswerSubmission.java b/core/src/main/java/group07/beatbattle/model/domain/AnswerSubmission.java similarity index 96% rename from core/src/main/java/group07/beatbattle/model/AnswerSubmission.java rename to core/src/main/java/group07/beatbattle/model/domain/AnswerSubmission.java index 2b2220a..31a260b 100644 --- a/core/src/main/java/group07/beatbattle/model/AnswerSubmission.java +++ b/core/src/main/java/group07/beatbattle/model/domain/AnswerSubmission.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; /** * Immutable record of a player's answer for a single round. diff --git a/core/src/main/java/group07/beatbattle/model/GameMode.java b/core/src/main/java/group07/beatbattle/model/domain/GameMode.java similarity index 76% rename from core/src/main/java/group07/beatbattle/model/GameMode.java rename to core/src/main/java/group07/beatbattle/model/domain/GameMode.java index fe3a04b..19923d7 100644 --- a/core/src/main/java/group07/beatbattle/model/GameMode.java +++ b/core/src/main/java/group07/beatbattle/model/domain/GameMode.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; /** Whether the local player is creating a new session or joining an existing one. */ public enum GameMode { diff --git a/core/src/main/java/group07/beatbattle/model/GameRules.java b/core/src/main/java/group07/beatbattle/model/domain/GameRules.java similarity index 83% rename from core/src/main/java/group07/beatbattle/model/GameRules.java rename to core/src/main/java/group07/beatbattle/model/domain/GameRules.java index af178ee..76bf87b 100644 --- a/core/src/main/java/group07/beatbattle/model/GameRules.java +++ b/core/src/main/java/group07/beatbattle/model/domain/GameRules.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; /** Central place for game constants shared across systems (e.g. round duration). */ public final class GameRules { diff --git a/core/src/main/java/group07/beatbattle/model/GameSession.java b/core/src/main/java/group07/beatbattle/model/domain/GameSession.java similarity index 98% rename from core/src/main/java/group07/beatbattle/model/GameSession.java rename to core/src/main/java/group07/beatbattle/model/domain/GameSession.java index e6e582d..07891a1 100644 --- a/core/src/main/java/group07/beatbattle/model/GameSession.java +++ b/core/src/main/java/group07/beatbattle/model/domain/GameSession.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; import java.util.ArrayList; import java.util.Collections; diff --git a/core/src/main/java/group07/beatbattle/model/Leaderboard.java b/core/src/main/java/group07/beatbattle/model/domain/Leaderboard.java similarity index 96% rename from core/src/main/java/group07/beatbattle/model/Leaderboard.java rename to core/src/main/java/group07/beatbattle/model/domain/Leaderboard.java index 70b968a..50c1dfb 100644 --- a/core/src/main/java/group07/beatbattle/model/Leaderboard.java +++ b/core/src/main/java/group07/beatbattle/model/domain/Leaderboard.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; import java.util.ArrayList; import java.util.Collections; diff --git a/core/src/main/java/group07/beatbattle/model/LeaderboardEntry.java b/core/src/main/java/group07/beatbattle/model/domain/LeaderboardEntry.java similarity index 95% rename from core/src/main/java/group07/beatbattle/model/LeaderboardEntry.java rename to core/src/main/java/group07/beatbattle/model/domain/LeaderboardEntry.java index 31d444d..86a1c81 100644 --- a/core/src/main/java/group07/beatbattle/model/LeaderboardEntry.java +++ b/core/src/main/java/group07/beatbattle/model/domain/LeaderboardEntry.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; /** * A single row on the leaderboard. diff --git a/core/src/main/java/group07/beatbattle/model/Player.java b/core/src/main/java/group07/beatbattle/model/domain/Player.java similarity index 96% rename from core/src/main/java/group07/beatbattle/model/Player.java rename to core/src/main/java/group07/beatbattle/model/domain/Player.java index 7bb491d..74566ae 100644 --- a/core/src/main/java/group07/beatbattle/model/Player.java +++ b/core/src/main/java/group07/beatbattle/model/domain/Player.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; /** * Represents a player in a game session. diff --git a/core/src/main/java/group07/beatbattle/model/Question.java b/core/src/main/java/group07/beatbattle/model/domain/Question.java similarity index 96% rename from core/src/main/java/group07/beatbattle/model/Question.java rename to core/src/main/java/group07/beatbattle/model/domain/Question.java index d864841..b824945 100644 --- a/core/src/main/java/group07/beatbattle/model/Question.java +++ b/core/src/main/java/group07/beatbattle/model/domain/Question.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; import java.util.List; diff --git a/core/src/main/java/group07/beatbattle/model/Song.java b/core/src/main/java/group07/beatbattle/model/domain/Song.java similarity index 95% rename from core/src/main/java/group07/beatbattle/model/Song.java rename to core/src/main/java/group07/beatbattle/model/domain/Song.java index 9262a79..ddcd56f 100644 --- a/core/src/main/java/group07/beatbattle/model/Song.java +++ b/core/src/main/java/group07/beatbattle/model/domain/Song.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.domain; /** * Represents a song fetched from the Deezer API. diff --git a/core/src/main/java/group07/beatbattle/model/SessionCreationResult.java b/core/src/main/java/group07/beatbattle/model/dto/SessionCreationResult.java similarity index 95% rename from core/src/main/java/group07/beatbattle/model/SessionCreationResult.java rename to core/src/main/java/group07/beatbattle/model/dto/SessionCreationResult.java index ffbf73c..14a8784 100644 --- a/core/src/main/java/group07/beatbattle/model/SessionCreationResult.java +++ b/core/src/main/java/group07/beatbattle/model/dto/SessionCreationResult.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.dto; /** Result returned by LobbyService after successfully creating a session. */ public class SessionCreationResult { diff --git a/core/src/main/java/group07/beatbattle/model/SessionJoinResult.java b/core/src/main/java/group07/beatbattle/model/dto/SessionJoinResult.java similarity index 95% rename from core/src/main/java/group07/beatbattle/model/SessionJoinResult.java rename to core/src/main/java/group07/beatbattle/model/dto/SessionJoinResult.java index 75d9449..85d9e4e 100644 --- a/core/src/main/java/group07/beatbattle/model/SessionJoinResult.java +++ b/core/src/main/java/group07/beatbattle/model/dto/SessionJoinResult.java @@ -1,4 +1,4 @@ -package group07.beatbattle.model; +package group07.beatbattle.model.dto; /** Result returned by LobbyService after successfully joining a session. */ public class SessionJoinResult { diff --git a/core/src/main/java/group07/beatbattle/model/score/ScoreCalculator.java b/core/src/main/java/group07/beatbattle/model/score/ScoreCalculator.java index 2b96a8f..3a0308e 100644 --- a/core/src/main/java/group07/beatbattle/model/score/ScoreCalculator.java +++ b/core/src/main/java/group07/beatbattle/model/score/ScoreCalculator.java @@ -1,6 +1,6 @@ package group07.beatbattle.model.score; -import group07.beatbattle.model.GameRules; +import group07.beatbattle.model.domain.GameRules; /** * Calculates the score awarded for a round based on answer correctness and speed. 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 4f26012..5ff2a84 100644 --- a/core/src/main/java/group07/beatbattle/model/services/LobbyService.java +++ b/core/src/main/java/group07/beatbattle/model/services/LobbyService.java @@ -4,9 +4,9 @@ import java.util.List; import group07.beatbattle.firebase.FirebaseGateway; -import group07.beatbattle.model.SessionCreationResult; -import group07.beatbattle.model.SessionJoinResult; -import group07.beatbattle.model.Player; +import group07.beatbattle.model.dto.SessionCreationResult; +import group07.beatbattle.model.dto.SessionJoinResult; +import group07.beatbattle.model.domain.Player; /** * Handles creating, joining, and leaving lobby sessions. diff --git a/core/src/main/java/group07/beatbattle/model/services/MusicServiceCallback.java b/core/src/main/java/group07/beatbattle/model/services/MusicServiceCallback.java index d2b215c..1082c1f 100644 --- a/core/src/main/java/group07/beatbattle/model/services/MusicServiceCallback.java +++ b/core/src/main/java/group07/beatbattle/model/services/MusicServiceCallback.java @@ -1,6 +1,6 @@ package group07.beatbattle.model.services; -import group07.beatbattle.model.Song; +import group07.beatbattle.model.domain.Song; import java.util.List; public interface MusicServiceCallback { From a3e7b125b901c544ca0f38defdf2b7a32ba14e92 Mon Sep 17 00:00:00 2001 From: odaakj Date: Sun, 19 Apr 2026 14:39:03 +0200 Subject: [PATCH 02/11] Update project architecture --- .../android/DeezerMusicService.java | 2 +- .../firebase/AndroidFirebaseGateway.java | 5 + .../firebase/FirestoreSessionRepository.java | 21 +- .../java/group07/beatbattle/BeatBattle.java | 2 +- .../controller/LeaderboardController.java | 54 ++- .../controller/LobbyController.java | 328 ++++++------------ .../controller/QuestionBuilder.java | 128 +++++++ .../controller/RoundController.java | 103 ++++-- .../beatbattle/controller/RoundManager.java | 47 +++ .../controller/SessionSyncService.java | 127 +++++++ .../controller/SettingsController.java | 2 +- .../beatbattle/ecs/entities/RoundFactory.java | 2 +- .../beatbattle/firebase/FirebaseGateway.java | 9 +- .../firebase/NoOpFirebaseGateway.java | 5 + .../beatbattle/states/JoinCreateState.java | 2 +- .../group07/beatbattle/states/LobbyState.java | 2 +- .../beatbattle/states/SettingsState.java | 2 +- .../group07/beatbattle/view/GameOverView.java | 59 ++-- .../beatbattle/view/GameRoundView.java | 73 ++-- .../beatbattle/view/JoinCreateView.java | 2 +- .../beatbattle/view/LeaderboardView.java | 59 ++-- .../group07/beatbattle/view/LobbyView.java | 91 ++--- .../group07/beatbattle/view/SettingsView.java | 2 +- .../beatbattle/GameSimulationTest.java | 16 +- .../firebase/FakeFirebaseGateway.java | 2 +- .../beatbattle/lwjgl3/MockMusicService.java | 2 +- 26 files changed, 678 insertions(+), 469 deletions(-) create mode 100644 core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java create mode 100644 core/src/main/java/group07/beatbattle/controller/RoundManager.java create mode 100644 core/src/main/java/group07/beatbattle/controller/SessionSyncService.java diff --git a/android/src/main/java/group07/beatbattle/android/DeezerMusicService.java b/android/src/main/java/group07/beatbattle/android/DeezerMusicService.java index 0b04236..687cdc9 100644 --- a/android/src/main/java/group07/beatbattle/android/DeezerMusicService.java +++ b/android/src/main/java/group07/beatbattle/android/DeezerMusicService.java @@ -2,7 +2,7 @@ import com.badlogic.gdx.Gdx; -import group07.beatbattle.model.Song; +import group07.beatbattle.model.domain.Song; import group07.beatbattle.model.services.MusicService; import group07.beatbattle.model.services.MusicServiceCallback; 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 d0b14b8..8b75a9d 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java @@ -138,4 +138,9 @@ public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCou public void clearAnswers(String sessionId, SimpleCallback callback) { sessionRepository.clearAnswers(sessionId, callback); } + + @Override + public void stopAllListeners() { + sessionRepository.stopAllListeners(); + } } 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 5d858dd..9b3572f 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java @@ -15,7 +15,7 @@ import java.util.Map; import group07.beatbattle.firebase.FirebaseGateway; -import group07.beatbattle.model.Player; +import group07.beatbattle.model.domain.Player; /** * Direct Firestore data-access layer for all session-related operations. @@ -37,6 +37,7 @@ public class FirestoreSessionRepository { private final FirebaseFirestore firestore; private ListenerRegistration sessionStateRegistration; + private final List activeListeners = new ArrayList<>(); public FirestoreSessionRepository(FirebaseFirestore firestore) { this.firestore = firestore; @@ -144,7 +145,7 @@ public ListenerRegistration listenToPlayers( String sessionId, FirebaseGateway.PlayersListenerCallback callback ) { - return firestore.collection(SESSIONS) + ListenerRegistration reg = firestore.collection(SESSIONS) .document(sessionId) .collection(PLAYERS) .addSnapshotListener((snapshot, error) -> { @@ -180,6 +181,8 @@ public ListenerRegistration listenToPlayers( callback.onPlayersChanged(players); }); + activeListeners.add(reg); + return reg; } public void fetchPlayers( @@ -376,10 +379,19 @@ public void updateSessionState( public void stopListeningToSessionState() { if (sessionStateRegistration != null) { sessionStateRegistration.remove(); + activeListeners.remove(sessionStateRegistration); sessionStateRegistration = null; } } + public void stopAllListeners() { + for (ListenerRegistration reg : activeListeners) { + if (reg != null) reg.remove(); + } + activeListeners.clear(); + sessionStateRegistration = null; + } + public void listenToSessionState( String sessionId, FirebaseGateway.SessionStateListener listener @@ -388,6 +400,7 @@ public void listenToSessionState( // listeners from prior screens/rounds don't accumulate and fire stale events. if (sessionStateRegistration != null) { sessionStateRegistration.remove(); + activeListeners.remove(sessionStateRegistration); sessionStateRegistration = null; } sessionStateRegistration = firestore.collection(SESSIONS) @@ -417,6 +430,7 @@ public void listenToSessionState( ); } }); + activeListeners.add(sessionStateRegistration); } public void submitAnswer( @@ -443,7 +457,7 @@ public void listenToRoundAnswerCount( int roundIndex, FirebaseGateway.AnswerCountCallback callback ) { - firestore.collection(SESSIONS) + ListenerRegistration reg = firestore.collection(SESSIONS) .document(sessionId) .collection("Answers") .whereEqualTo("roundIndex", roundIndex) @@ -455,6 +469,7 @@ public void listenToRoundAnswerCount( int count = snapshot != null ? snapshot.size() : 0; callback.onCountChanged(count); }); + activeListeners.add(reg); } public void clearAnswers(String sessionId, FirebaseGateway.SimpleCallback callback) { diff --git a/core/src/main/java/group07/beatbattle/BeatBattle.java b/core/src/main/java/group07/beatbattle/BeatBattle.java index 6176dc2..78fb94e 100644 --- a/core/src/main/java/group07/beatbattle/BeatBattle.java +++ b/core/src/main/java/group07/beatbattle/BeatBattle.java @@ -10,7 +10,7 @@ import group07.beatbattle.controller.LobbyController; import group07.beatbattle.utils.Strings; import group07.beatbattle.ecs.Engine; -import group07.beatbattle.model.GameSession; +import group07.beatbattle.model.domain.GameSession; import group07.beatbattle.model.services.MusicService; import group07.beatbattle.states.StartState; import group07.beatbattle.states.StateManager; diff --git a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java index 6faadd0..a7b70c1 100644 --- a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java +++ b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java @@ -6,10 +6,10 @@ 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.model.domain.GameSession; +import group07.beatbattle.model.domain.Leaderboard; +import group07.beatbattle.model.domain.LeaderboardEntry; +import group07.beatbattle.model.domain.Player; import group07.beatbattle.states.GameOverState; import group07.beatbattle.states.InRoundState; import group07.beatbattle.states.StartState; @@ -17,6 +17,8 @@ /** * Controls the leaderboard screen shown between rounds and at game over. + * Views that need to react to session cancellation should call + * startSessionStateListener() rather than accessing FirebaseGateway directly. * * Host behaviour: onNextRound() writes the new round index to Firestore before * launching the next RoundController, so joiners are kept in sync. @@ -27,6 +29,12 @@ */ public class LeaderboardController { + /** Callback for views to react to session cancellation without importing FirebaseGateway. */ + public interface SessionCancelledCallback { + void onHostLeft(); + void onCancelled(); + } + private final BeatBattle game; private final GameSession session; @@ -137,6 +145,40 @@ private void launchNextRound() { StateManager.getInstance().setState(new InRoundState(game, roundController)); } + /** + * Registers a Firestore session-state listener on behalf of the view. + * Only registers for the host; joiners are already handled by SessionSyncService. + */ + public void startSessionStateListener(SessionCancelledCallback callback) { + if (!game.isLocalHost()) return; + String sessionId = game.getLocalSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound, String endReason) { + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + Gdx.app.postRunnable(() -> { + if (FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason)) { + callback.onHostLeft(); + } else { + callback.onCancelled(); + } + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("LeaderboardController", + "Session state listener failed: " + exception.getMessage()); + } + } + ); + } + public void onSessionCancelled() { StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); } @@ -177,13 +219,13 @@ public void onFailure(Exception exception) { } else { if (playerId == null || playerId.isBlank()) { game.clearLocalSession(); - game.getFirebaseGateway().stopListeningToSessionState(sessionId); + game.getFirebaseGateway().stopAllListeners(); StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); return; } game.clearLocalSession(); - game.getFirebaseGateway().stopListeningToSessionState(sessionId); + game.getFirebaseGateway().stopAllListeners(); game.getFirebaseGateway().removePlayerFromSession( sessionId, playerId, diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 90736fb..ed934b4 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -3,18 +3,17 @@ import com.badlogic.gdx.Gdx; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import group07.beatbattle.BeatBattle; import group07.beatbattle.firebase.FirebaseGateway; -import group07.beatbattle.model.GameMode; -import group07.beatbattle.model.GameSession; -import group07.beatbattle.model.Player; -import group07.beatbattle.model.Question; -import group07.beatbattle.model.SessionCreationResult; -import group07.beatbattle.model.SessionJoinResult; -import group07.beatbattle.model.Song; +import group07.beatbattle.model.domain.GameMode; +import group07.beatbattle.model.domain.GameSession; +import group07.beatbattle.model.domain.Player; +import group07.beatbattle.model.domain.Question; +import group07.beatbattle.model.domain.Song; +import group07.beatbattle.model.dto.SessionCreationResult; +import group07.beatbattle.model.dto.SessionJoinResult; import group07.beatbattle.model.services.LobbyService; import group07.beatbattle.model.services.MusicServiceCallback; import group07.beatbattle.states.GameOverState; @@ -30,20 +29,35 @@ /** * Orchestrates pre-game and inter-round navigation for both the host and joiners. * - * Host path: onStartGame() fetches tracks from Deezer, builds questions, stores them - * in Firestore, sets state to "in_round", then launches the first RoundController. + * Host path: onStartGame() fetches tracks from Deezer, delegates to QuestionBuilder + * which builds questions, stores them in Firestore, and sets state to "in_round". * - * Joiner path: onGameStarted() reads questions from Firestore and starts a persistent - * session-state listener that routes the joiner through leaderboard -> next round -> game over, - * driven entirely by what the host writes to Firestore. + * Joiner path: onGameStarted() reads questions from Firestore and delegates to + * SessionSyncService which starts a persistent listener routing the joiner through + * all game states driven by what the host writes to Firestore. */ public class LobbyController { - private static final int OPTIONS_PER_Q = 4; - private final BeatBattle game; private int numRounds = 5; + /** + * Callback interface that lets LobbyView react to session state changes + * without importing or knowing about FirebaseGateway. + */ + public interface LobbySessionCallback { + /** Returns the current player roster so the controller can pass it to onGameStarted. */ + List getCurrentPlayers(); + /** Called when the host cancelled the session. hostLeft=true means the host left explicitly. */ + void onHostCancelled(boolean hostLeft); + /** Called when STATE_IN_ROUND is detected and question fetching begins. */ + void onGameStarting(); + /** Called when question fetching fails; the listener resets so it can retry. */ + void onGameStartFailed(); + /** Called when the Firestore listener itself errors. */ + void onConnectionError(String error); + } + public LobbyController(BeatBattle game) { this.game = game; } @@ -217,14 +231,78 @@ public void onFailure(Exception exception) { ); } + /** + * Registers a Firestore session-state listener on behalf of LobbyView (joiner only). + * The view is notified through LobbySessionCallback without ever touching FirebaseGateway. + */ + public void startSessionStateListener(String sessionId, LobbySessionCallback callback) { + final boolean[] gameStarted = {false}; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound, String endReason) { + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + boolean hostLeft = FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason); + Gdx.app.postRunnable(() -> callback.onHostCancelled(hostLeft)); + return; + } + + if (FirebaseGateway.STATE_IN_ROUND.equals(state) && !gameStarted[0]) { + gameStarted[0] = true; + Gdx.app.postRunnable(callback::onGameStarting); + + game.getFirebaseGateway().getQuestions( + sessionId, + new FirebaseGateway.GetQuestionsCallback() { + @Override + public void onSuccess(List questions) { + List players = callback.getCurrentPlayers(); + onGameStarted(sessionId, questions, players); + } + + @Override + public void onFailure(Exception exception) { + gameStarted[0] = false; + Gdx.app.postRunnable(callback::onGameStartFailed); + } + } + ); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.postRunnable(() -> callback.onConnectionError(exception.getMessage())); + } + } + ); + } + /** Called by the host when they press Start Game. */ public void onStartGame(String sessionId, List players, int rounds) { numRounds = rounds; - int tracksNeeded = numRounds * OPTIONS_PER_Q; - game.getMusicService().fetchTracks(tracksNeeded, new MusicServiceCallback() { + game.getMusicService().fetchTracks(numRounds * QuestionBuilder.OPTIONS_PER_Q, new MusicServiceCallback() { @Override public void onSuccess(List songs) { - buildAndStartSession(sessionId, players, songs); + new QuestionBuilder(game).buildAndStore( + sessionId, players, songs, numRounds, + new QuestionBuilder.Callback() { + @Override + public void onReady(GameSession session) { + RoundController roundController = new RoundController( + game, session.getCurrentQuestion(), session + ); + StateManager.getInstance().setState(new InRoundState(game, roundController)); + } + + @Override + public void onFailure(String error) { + Gdx.app.error("LobbyController", "Failed to build session: " + error); + } + } + ); } @Override @@ -234,7 +312,7 @@ public void onFailure(String error) { }); } - /** Called by joiners when Firebase signals game has started. */ + /** Called by joiners when Firebase signals game has started (via startSessionStateListener). */ public void onGameStarted( String sessionId, List questionDataList, @@ -266,115 +344,12 @@ public void onGameStarted( game.setCurrentSession(session); - // Start persistent listener to follow host through all game states - startJoinerSyncListener(sessionId, session); + new SessionSyncService(game).startJoinerSync(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 = {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 endReason) { - String key = state + ":" + currentRound + ":" + endReason; - if (key.equals(lastHandled[0])) return; - lastHandled[0] = key; - - 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_LOBBY.equals(state)) { - Gdx.app.postRunnable(() -> { - if (game.getLocalSessionId() == null) return; - // Stop this sync listener before re-entering lobby so it - // doesn't race with LobbyView's fresh session state listener. - game.getFirebaseGateway().stopListeningToSessionState(sessionId); - StateManager.getInstance().setState(new LobbyState( - game, GameMode.JOIN, sessionId, - game.getLocalGamePin(), - game.getLocalPlayerId(), - game.getLocalPlayerName(), - false, - new LobbyController(game) - )); - }); - } else if (FirebaseGateway.STATE_LEADERBOARD.equals(state)) { - joinerTransitionToLeaderboard(sessionId, session); - } else if (FirebaseGateway.STATE_IN_ROUND.equals(state)) { - joinerTransitionToRound(session, currentRound); - } else if (FirebaseGateway.STATE_GAME_OVER.equals(state)) { - Gdx.app.postRunnable(() -> { - if (game.getLocalSessionId() == null) return; - LeaderboardController lc = new LeaderboardController(game, session); - 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(() -> { - if (game.getLocalSessionId() == null) return; - session.syncPlayers(updated); - LeaderboardController lc = new LeaderboardController(game, session); - StateManager.getInstance().setState(new LeaderboardState(game, lc)); - }); - } - - @Override - public void onFailure(Exception e) { - Gdx.app.postRunnable(() -> { - if (game.getLocalSessionId() == null) return; - LeaderboardController lc = new LeaderboardController(game, session); - StateManager.getInstance().setState(new LeaderboardState(game, lc)); - }); - } - } - ); - } - - 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; - RoundController rc = new RoundController(game, question, session); - StateManager.getInstance().setState(new InRoundState(game, rc)); - }); - } - public void playAgain(GameSession session) { String sessionId = game.getLocalSessionId(); if (sessionId == null) return; @@ -415,113 +390,10 @@ public void onFailure(Exception e) { for (Player p : players) { game.getFirebaseGateway().updatePlayerScore(sessionId, p.getId(), 0, new FirebaseGateway.SimpleCallback() { - @Override public void onSuccess() { if (--remaining[0] == 0) goToLobby.run(); } - @Override public void onFailure(Exception e) { if (--remaining[0] == 0) goToLobby.run(); } + @Override public void onSuccess() { if (--remaining[0] == 0) goToLobby.run(); } + @Override public void onFailure(Exception e) { if (--remaining[0] == 0) goToLobby.run(); } } ); } } - - private void buildAndStartSession(String sessionId, List sessionPlayers, List songs) { - GameSession session = new GameSession(sessionId, game.getLocalPlayerId(), numRounds); - - for (Player p : sessionPlayers) { - session.addPlayer(p); - } - - List decoyPool = new ArrayList<>(songs.subList( - Math.min(numRounds, songs.size()), songs.size() - )); - List questionDataList = new ArrayList<>(); - - for (int q = 0; q < numRounds && q < songs.size(); q++) { - Song correct = songs.get(q); - - List options = new ArrayList<>(); - options.add(correct.getTitle()); - - int decoysAdded = 0; - for (Song decoy : decoyPool) { - if (decoysAdded >= OPTIONS_PER_Q - 1) break; - String label = decoy.getTitle(); - if (!options.contains(label)) { - options.add(label); - decoysAdded++; - } - } - if (!decoyPool.isEmpty()) Collections.rotate(decoyPool, OPTIONS_PER_Q - 1); - - Collections.shuffle(options); - session.addQuestion(new Question("q" + (q + 1), correct, options, q)); - - questionDataList.add(new FirebaseGateway.QuestionData( - correct.getId(), - correct.getTitle(), - correct.getArtist(), - correct.getPreviewUrl(), - correct.getAlbumArtUrl(), - new ArrayList<>(options), - q - )); - } - - game.setCurrentSession(session); - - // Clear stale answers from any previous game, then store questions -> update state -> launch round - game.getFirebaseGateway().clearAnswers(sessionId, new FirebaseGateway.SimpleCallback() { - @Override public void onSuccess() { doStoreAndLaunch(sessionId, session, questionDataList); } - @Override public void onFailure(Exception e) { doStoreAndLaunch(sessionId, session, questionDataList); } - }); - } - - private void doStoreAndLaunch( - String sessionId, - GameSession session, - List questionDataList - ) { - game.getFirebaseGateway().storeQuestions( - sessionId, - questionDataList, - new FirebaseGateway.SimpleCallback() { - @Override - public void onSuccess() { - // Use updateRoundState with index 0 so currentRound is explicitly - // written to Firestore. On play-again, currentRound is stale (e.g. 4) - // from the previous game. If only "state" is updated, the joiner's - // persistent sync listener sees key "in_round:4:" which does NOT match - // lastHandled "in_round:0:" and triggers joinerTransitionToRound(4). - game.getFirebaseGateway().updateRoundState( - sessionId, - FirebaseGateway.STATE_IN_ROUND, - 0, - new FirebaseGateway.SimpleCallback() { - @Override - public void onSuccess() { - Gdx.app.postRunnable(() -> { - RoundController roundController = new RoundController( - game, session.getCurrentQuestion(), session - ); - StateManager.getInstance().setState( - new InRoundState(game, roundController) - ); - }); - } - - @Override - public void onFailure(Exception exception) { - Gdx.app.error("LobbyController", - "Failed to update session state: " + exception.getMessage()); - } - } - ); - } - - @Override - public void onFailure(Exception exception) { - Gdx.app.error("LobbyController", - "Failed to store questions: " + exception.getMessage()); - } - } - ); - } } diff --git a/core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java b/core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java new file mode 100644 index 0000000..61b79f6 --- /dev/null +++ b/core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java @@ -0,0 +1,128 @@ +package group07.beatbattle.controller; + +import com.badlogic.gdx.Gdx; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import group07.beatbattle.BeatBattle; +import group07.beatbattle.firebase.FirebaseGateway; +import group07.beatbattle.model.domain.GameSession; +import group07.beatbattle.model.domain.Player; +import group07.beatbattle.model.domain.Question; +import group07.beatbattle.model.domain.Song; + +/** + * Builds quiz questions from a list of songs and stores them in Firebase. + * Extracted from LobbyController to keep question-generation concerns separate. + * + * Flow: buildAndStore() -> clearAnswers -> storeQuestions -> updateRoundState -> callback.onReady() + */ +public class QuestionBuilder { + + static final int OPTIONS_PER_Q = 4; + + private final BeatBattle game; + + public interface Callback { + void onReady(GameSession session); + void onFailure(String error); + } + + public QuestionBuilder(BeatBattle game) { + this.game = game; + } + + public void buildAndStore( + String sessionId, + List players, + List songs, + int numRounds, + Callback callback + ) { + GameSession session = new GameSession(sessionId, game.getLocalPlayerId(), numRounds); + for (Player p : players) session.addPlayer(p); + + List decoyPool = new ArrayList<>(songs.subList( + Math.min(numRounds, songs.size()), songs.size() + )); + List questionDataList = new ArrayList<>(); + + for (int q = 0; q < numRounds && q < songs.size(); q++) { + Song correct = songs.get(q); + + List options = new ArrayList<>(); + options.add(correct.getTitle()); + + int decoysAdded = 0; + for (Song decoy : decoyPool) { + if (decoysAdded >= OPTIONS_PER_Q - 1) break; + String label = decoy.getTitle(); + if (!options.contains(label)) { + options.add(label); + decoysAdded++; + } + } + if (!decoyPool.isEmpty()) Collections.rotate(decoyPool, OPTIONS_PER_Q - 1); + + Collections.shuffle(options); + session.addQuestion(new Question("q" + (q + 1), correct, options, q)); + + questionDataList.add(new FirebaseGateway.QuestionData( + correct.getId(), + correct.getTitle(), + correct.getArtist(), + correct.getPreviewUrl(), + correct.getAlbumArtUrl(), + new ArrayList<>(options), + q + )); + } + + game.setCurrentSession(session); + + game.getFirebaseGateway().clearAnswers(sessionId, new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() { storeAndNotify(sessionId, session, questionDataList, callback); } + @Override public void onFailure(Exception e) { storeAndNotify(sessionId, session, questionDataList, callback); } + }); + } + + private void storeAndNotify( + String sessionId, + GameSession session, + List questionDataList, + Callback callback + ) { + game.getFirebaseGateway().storeQuestions(sessionId, questionDataList, new FirebaseGateway.SimpleCallback() { + @Override + public void onSuccess() { + game.getFirebaseGateway().updateRoundState( + sessionId, + FirebaseGateway.STATE_IN_ROUND, + 0, + new FirebaseGateway.SimpleCallback() { + @Override + public void onSuccess() { + Gdx.app.postRunnable(() -> callback.onReady(session)); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.postRunnable(() -> callback.onFailure( + "Failed to update round state: " + e.getMessage() + )); + } + } + ); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.postRunnable(() -> callback.onFailure( + "Failed to store questions: " + e.getMessage() + )); + } + }); + } +} diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index ef0ebd8..6b9b652 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -6,14 +6,10 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.ecs.Engine; -import group07.beatbattle.ecs.components.TimerComponent; -import group07.beatbattle.ecs.entities.Entity; -import group07.beatbattle.ecs.entities.RoundFactory; -import group07.beatbattle.ecs.systems.AudioSystem; import group07.beatbattle.firebase.FirebaseGateway; -import group07.beatbattle.model.GameSession; -import group07.beatbattle.model.Player; -import group07.beatbattle.model.Question; +import group07.beatbattle.model.domain.GameSession; +import group07.beatbattle.model.domain.Player; +import group07.beatbattle.model.domain.Question; import group07.beatbattle.model.score.ScoreCalculator; import group07.beatbattle.states.LeaderboardState; import group07.beatbattle.states.StartState; @@ -21,8 +17,9 @@ /** * Controls the lifecycle of a single game round. - * On construction creates an ECS round entity, starts audio playback, and (if host) - * starts a Firestore listener that advances the game as soon as all players have answered. + * On construction creates a RoundManager (which owns the ECS entity), starts audio + * playback, and (if host) starts a Firestore listener that advances the game as soon + * as all players have answered. * * Flow: * 1. Player answers -> onAnswerSubmitted() scores it and records it in Firebase. @@ -31,10 +28,19 @@ */ public class RoundController { + /** + * Callback for GameRoundView to react to session cancellation without + * knowing about Firebase constants or the gateway. + */ + public interface SessionCancelledCallback { + void onHostLeft(); + void onCancelled(); + } + private final BeatBattle game; private final Question question; private final GameSession session; - private final Entity roundEntity; + private final RoundManager roundManager; private boolean playerAnswered = false; private boolean transitioned = false; private int activePlayerCount; @@ -45,12 +51,7 @@ public RoundController(BeatBattle game, Question question, GameSession session) this.question = question; this.session = session; this.activePlayerCount = session.getPlayers().size(); - this.roundEntity = RoundFactory.getInstance().createRound( - question.getId(), - question.getSong().getPreviewUrl() - ); - Engine.getInstance().addEntity(roundEntity); - AudioSystem.getInstance().play(roundEntity); + this.roundManager = new RoundManager(question.getId(), question.getSong().getPreviewUrl()); if (game.isLocalHost()) { startAllAnsweredListener(); @@ -63,13 +64,59 @@ public Question getQuestion() { } public float getTimeRemaining() { - TimerComponent timer = roundEntity.getComponent(TimerComponent.class); - return timer != null ? timer.timeRemaining : 0f; + return roundManager.getTimeRemaining(); } public float getTotalTime() { - TimerComponent timer = roundEntity.getComponent(TimerComponent.class); - return timer != null ? timer.totalTime : 30f; + return roundManager.getTotalTime(); + } + + /** Delegates the per-frame ECS update to the engine. Called from GameRoundView.render(). */ + public void update(float delta) { + Engine.getInstance().update(delta); + } + + public boolean isMuted() { + return roundManager.isMuted(); + } + + public void setMuted(boolean muted) { + roundManager.setMuted(muted); + } + + /** + * Registers a Firestore session-state listener on behalf of GameRoundView. + * Only registers for the host; joiners already have a persistent sync listener + * from SessionSyncService that handles STATE_CANCELLED. + */ + public void startSessionStateListener(SessionCancelledCallback callback) { + if (!game.isLocalHost()) return; + String sessionId = game.getLocalSessionId(); + if (sessionId == null || sessionId.isBlank()) return; + + game.getFirebaseGateway().listenToSessionState( + sessionId, + new FirebaseGateway.SessionStateListener() { + @Override + public void onStateChanged(String state, int currentRound, String endReason) { + if (FirebaseGateway.STATE_CANCELLED.equals(state)) { + Gdx.app.postRunnable(() -> { + if (FirebaseGateway.END_REASON_HOST_LEFT.equals(endReason)) { + callback.onHostLeft(); + } else { + callback.onCancelled(); + } + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("RoundController", + "Session state listener failed: " + exception.getMessage()); + } + } + ); } public void onAnswerSubmitted(String answer) { @@ -112,11 +159,10 @@ public boolean isHost() { } private void leaveToStart() { - AudioSystem.getInstance().stop(roundEntity); - Engine.getInstance().removeEntity(roundEntity); + roundManager.stop(); String sid = game.getLocalSessionId(); game.clearLocalSession(); - game.getFirebaseGateway().stopListeningToSessionState(sid); + game.getFirebaseGateway().stopAllListeners(); StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); } @@ -152,7 +198,7 @@ public void onFailure(Exception exception) { } game.clearLocalSession(); - game.getFirebaseGateway().stopListeningToSessionState(sessionId); + game.getFirebaseGateway().stopAllListeners(); game.getFirebaseGateway().removePlayerFromSession( sessionId, playerId, @@ -160,8 +206,7 @@ public void onFailure(Exception exception) { @Override public void onSuccess() { Gdx.app.postRunnable(() -> { - AudioSystem.getInstance().stop(roundEntity); - Engine.getInstance().removeEntity(roundEntity); + roundManager.stop(); StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); }); } @@ -169,8 +214,7 @@ public void onSuccess() { @Override public void onFailure(Exception exception) { Gdx.app.postRunnable(() -> { - AudioSystem.getInstance().stop(roundEntity); - Engine.getInstance().removeEntity(roundEntity); + roundManager.stop(); StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); }); } @@ -333,8 +377,7 @@ public void onFailure(Exception exception) { } private void launchLeaderboard() { - AudioSystem.getInstance().stop(roundEntity); - Engine.getInstance().removeEntity(roundEntity); + roundManager.stop(); LeaderboardController leaderboardController = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, leaderboardController)); } diff --git a/core/src/main/java/group07/beatbattle/controller/RoundManager.java b/core/src/main/java/group07/beatbattle/controller/RoundManager.java new file mode 100644 index 0000000..4542323 --- /dev/null +++ b/core/src/main/java/group07/beatbattle/controller/RoundManager.java @@ -0,0 +1,47 @@ +package group07.beatbattle.controller; + +import group07.beatbattle.ecs.Engine; +import group07.beatbattle.ecs.components.TimerComponent; +import group07.beatbattle.ecs.entities.Entity; +import group07.beatbattle.ecs.entities.RoundFactory; +import group07.beatbattle.ecs.systems.AudioSystem; + +/** + * Wraps the ECS lifecycle for a single game round. + * Hides Engine, Entity, and component access from RoundController so + * the controller layer does not depend on ECS internals. + */ +public class RoundManager { + + private final Entity roundEntity; + + public RoundManager(String roundId, String previewUrl) { + roundEntity = RoundFactory.getInstance().createRound(roundId, previewUrl); + Engine.getInstance().addEntity(roundEntity); + AudioSystem.getInstance().play(roundEntity); + } + + public float getTimeRemaining() { + TimerComponent timer = roundEntity.getComponent(TimerComponent.class); + return timer != null ? timer.timeRemaining : 0f; + } + + public float getTotalTime() { + TimerComponent timer = roundEntity.getComponent(TimerComponent.class); + return timer != null ? timer.totalTime : 30f; + } + + /** Stops audio playback and removes the entity from the ECS engine. */ + public void stop() { + AudioSystem.getInstance().stop(roundEntity); + Engine.getInstance().removeEntity(roundEntity); + } + + public boolean isMuted() { + return AudioSystem.getInstance().isMuted(); + } + + public void setMuted(boolean muted) { + AudioSystem.getInstance().setMuted(muted); + } +} diff --git a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java new file mode 100644 index 0000000..b8d7636 --- /dev/null +++ b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java @@ -0,0 +1,127 @@ +package group07.beatbattle.controller; + +import com.badlogic.gdx.Gdx; + +import java.util.List; + +import group07.beatbattle.BeatBattle; +import group07.beatbattle.firebase.FirebaseGateway; +import group07.beatbattle.model.domain.GameMode; +import group07.beatbattle.model.domain.GameSession; +import group07.beatbattle.model.domain.Player; +import group07.beatbattle.model.domain.Question; +import group07.beatbattle.states.GameOverState; +import group07.beatbattle.states.InRoundState; +import group07.beatbattle.states.LeaderboardState; +import group07.beatbattle.states.LobbyState; +import group07.beatbattle.states.StartState; +import group07.beatbattle.states.StateManager; + +/** + * Manages the persistent Firestore listener that routes joiners through all game states. + * Extracted from LobbyController to keep joiner-sync concerns separate. + * + * Round 0 is already handled by the caller before startJoinerSync() is invoked, + * so the initial lastHandled key is set to "in_round:0:" to avoid re-entering round 0. + */ +public class SessionSyncService { + + private final BeatBattle game; + + public SessionSyncService(BeatBattle game) { + this.game = game; + } + + public void startJoinerSync(String sessionId, GameSession session) { + 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 endReason) { + String key = state + ":" + currentRound + ":" + endReason; + if (key.equals(lastHandled[0])) return; + lastHandled[0] = key; + + 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_LOBBY.equals(state)) { + Gdx.app.postRunnable(() -> { + if (game.getLocalSessionId() == null) return; + game.getFirebaseGateway().stopListeningToSessionState(sessionId); + StateManager.getInstance().setState(new LobbyState( + game, GameMode.JOIN, sessionId, + game.getLocalGamePin(), + game.getLocalPlayerId(), + game.getLocalPlayerName(), + false, + new LobbyController(game) + )); + }); + } else if (FirebaseGateway.STATE_LEADERBOARD.equals(state)) { + transitionToLeaderboard(sessionId, session); + } else if (FirebaseGateway.STATE_IN_ROUND.equals(state)) { + transitionToRound(session, currentRound); + } else if (FirebaseGateway.STATE_GAME_OVER.equals(state)) { + Gdx.app.postRunnable(() -> { + if (game.getLocalSessionId() == null) return; + LeaderboardController lc = new LeaderboardController(game, session); + StateManager.getInstance().setState(new GameOverState(game, lc)); + }); + } + } + + @Override + public void onFailure(Exception exception) { + Gdx.app.error("SessionSyncService", "Joiner sync error: " + exception.getMessage()); + } + } + ); + } + + private void transitionToLeaderboard(String sessionId, GameSession session) { + game.getFirebaseGateway().fetchPlayers( + sessionId, + new FirebaseGateway.PlayersListenerCallback() { + @Override + public void onPlayersChanged(List updated) { + Gdx.app.postRunnable(() -> { + if (game.getLocalSessionId() == null) return; + session.syncPlayers(updated); + LeaderboardController lc = new LeaderboardController(game, session); + StateManager.getInstance().setState(new LeaderboardState(game, lc)); + }); + } + + @Override + public void onFailure(Exception e) { + Gdx.app.postRunnable(() -> { + if (game.getLocalSessionId() == null) return; + LeaderboardController lc = new LeaderboardController(game, session); + StateManager.getInstance().setState(new LeaderboardState(game, lc)); + }); + } + } + ); + } + + private void transitionToRound(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; + RoundController rc = new RoundController(game, question, session); + StateManager.getInstance().setState(new InRoundState(game, rc)); + }); + } +} diff --git a/core/src/main/java/group07/beatbattle/controller/SettingsController.java b/core/src/main/java/group07/beatbattle/controller/SettingsController.java index 0e5fde6..37290fb 100644 --- a/core/src/main/java/group07/beatbattle/controller/SettingsController.java +++ b/core/src/main/java/group07/beatbattle/controller/SettingsController.java @@ -4,7 +4,7 @@ import com.badlogic.gdx.Preferences; import group07.beatbattle.BeatBattle; import group07.beatbattle.utils.Strings; -import group07.beatbattle.model.GameMode; +import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.states.StartState; import group07.beatbattle.states.StateManager; import group07.beatbattle.view.LobbyView; diff --git a/core/src/main/java/group07/beatbattle/ecs/entities/RoundFactory.java b/core/src/main/java/group07/beatbattle/ecs/entities/RoundFactory.java index 9a0a30e..06541a1 100644 --- a/core/src/main/java/group07/beatbattle/ecs/entities/RoundFactory.java +++ b/core/src/main/java/group07/beatbattle/ecs/entities/RoundFactory.java @@ -2,7 +2,7 @@ import group07.beatbattle.ecs.components.AudioComponent; import group07.beatbattle.ecs.components.TimerComponent; -import group07.beatbattle.model.GameRules; +import group07.beatbattle.model.domain.GameRules; /** * Creates ECS round entities used by AudioSystem and RoundSystem. diff --git a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java index a426b3c..f78ee6d 100644 --- a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java @@ -2,7 +2,7 @@ import java.util.List; -import group07.beatbattle.model.Player; +import group07.beatbattle.model.domain.Player; /** * Abstraction layer over the Firebase/Firestore backend. @@ -103,6 +103,13 @@ void deleteSession( /** Deletes all answer documents for a session (called on play again to prevent stale counts). */ void clearAnswers(String sessionId, SimpleCallback callback); + /** + * Removes all active Firestore snapshot listeners registered through this gateway. + * Call when leaving a session entirely (e.g. going back to the start screen) to + * prevent memory leaks from orphaned listeners. + */ + void stopAllListeners(); + // --- Callbacks --- interface GamePinExistsCallback { diff --git a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java index ecbf25a..66c8c92 100644 --- a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java @@ -127,4 +127,9 @@ public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCou public void clearAnswers(String sessionId, SimpleCallback callback) { callback.onSuccess(); } + + @Override + public void stopAllListeners() { + // No-op: no listeners were registered on this platform + } } diff --git a/core/src/main/java/group07/beatbattle/states/JoinCreateState.java b/core/src/main/java/group07/beatbattle/states/JoinCreateState.java index 3caefd4..e5026dc 100644 --- a/core/src/main/java/group07/beatbattle/states/JoinCreateState.java +++ b/core/src/main/java/group07/beatbattle/states/JoinCreateState.java @@ -2,7 +2,7 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.LobbyController; -import group07.beatbattle.model.GameMode; +import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.view.JoinCreateView; /** State for the name/pin entry screen. Mode determines whether the player is creating or joining. */ diff --git a/core/src/main/java/group07/beatbattle/states/LobbyState.java b/core/src/main/java/group07/beatbattle/states/LobbyState.java index 88d74d5..c425fd3 100644 --- a/core/src/main/java/group07/beatbattle/states/LobbyState.java +++ b/core/src/main/java/group07/beatbattle/states/LobbyState.java @@ -2,7 +2,7 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.LobbyController; -import group07.beatbattle.model.GameMode; +import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.view.LobbyView; /** State for the waiting room where players gather before the host starts the game. */ diff --git a/core/src/main/java/group07/beatbattle/states/SettingsState.java b/core/src/main/java/group07/beatbattle/states/SettingsState.java index 4dbba40..6c81651 100644 --- a/core/src/main/java/group07/beatbattle/states/SettingsState.java +++ b/core/src/main/java/group07/beatbattle/states/SettingsState.java @@ -1,7 +1,7 @@ package group07.beatbattle.states; import group07.beatbattle.BeatBattle; -import group07.beatbattle.model.GameMode; +import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.view.SettingsView; diff --git a/core/src/main/java/group07/beatbattle/view/GameOverView.java b/core/src/main/java/group07/beatbattle/view/GameOverView.java index 642df68..921c4de 100644 --- a/core/src/main/java/group07/beatbattle/view/GameOverView.java +++ b/core/src/main/java/group07/beatbattle/view/GameOverView.java @@ -19,11 +19,10 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.LeaderboardController; import group07.beatbattle.utils.Strings; -import group07.beatbattle.model.LeaderboardEntry; +import group07.beatbattle.model.domain.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 GameOverView extends ScreenAdapter { @@ -171,44 +170,26 @@ public void dispose() { } private void startListeningToSessionState() { - // Joiners: the persistent sync listener handles STATE_CANCELLED and STATE_LOBBY (play again). - // Registering here would clobber the sync listener, leaving the joiner stuck on this screen. - if (!controller.isHost()) return; - - 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(); - } - }); - } - } + controller.startSessionStateListener(new LeaderboardController.SessionCancelledCallback() { + @Override + public void onHostLeft() { + if (sessionCancelledHandled) return; + sessionCancelledHandled = true; + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + controller::onSessionCancelled + ); + } - @Override - public void onFailure(Exception exception) { - Gdx.app.error("GameOverView", - "Session state listener failed: " + exception.getMessage()); - } + @Override + public void onCancelled() { + if (sessionCancelledHandled) return; + sessionCancelledHandled = true; + controller.onSessionCancelled(); } - ); + }); } } diff --git a/core/src/main/java/group07/beatbattle/view/GameRoundView.java b/core/src/main/java/group07/beatbattle/view/GameRoundView.java index e045fab..b7814b4 100644 --- a/core/src/main/java/group07/beatbattle/view/GameRoundView.java +++ b/core/src/main/java/group07/beatbattle/view/GameRoundView.java @@ -19,15 +19,12 @@ 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; -import group07.beatbattle.ecs.Engine; -import group07.beatbattle.ecs.systems.AudioSystem; import group07.beatbattle.utils.Strings; -import group07.beatbattle.model.Question; +import group07.beatbattle.model.domain.Question; import java.util.ArrayList; import java.util.List; @@ -124,13 +121,13 @@ public void changed(ChangeEvent event, Actor actor) { timerLabel = new Label(String.valueOf((int) Math.ceil(controller.getTotalTime())), timerStyle()); timerLabel.setAlignment(Align.center); - muted = AudioSystem.getInstance().isMuted(); + muted = controller.isMuted(); muteButton = makeButton("", muted ? iconMuteTex : iconSoundTex); muteButton.addListener(new ChangeListener() { @Override public void changed(ChangeEvent event, Actor actor) { muted = !muted; - AudioSystem.getInstance().setMuted(muted); + controller.setMuted(muted); Texture tex = muted ? iconMuteTex : iconSoundTex; muteButton.getStyle().up = new TextureRegionDrawable(new TextureRegion(tex)); muteButton.getStyle().down = new TextureRegionDrawable(new TextureRegion(tex)); @@ -200,7 +197,7 @@ public void changed(ChangeEvent event, Actor actor) { root.add(grid).expand().center().row(); stage.addActor(root); - startListeningToSessionState(); + startSessionStateListener(); } @Override @@ -213,7 +210,7 @@ public void render(float delta) { Gdx.gl.glClearColor(0.08f, 0.08f, 0.12f, 1f); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); - Engine.getInstance().update(delta); + controller.update(delta); int seconds = (int) Math.ceil(controller.getTimeRemaining()); timerLabel.setText(String.valueOf(seconds)); @@ -357,47 +354,27 @@ private Texture solidTexture(Color color) { return tex; } - private void startListeningToSessionState() { - // Joiners: the persistent sync listener (started in LobbyController.onGameStarted) - // already handles STATE_CANCELLED and is still active. Registering a second listener - // here would overwrite the sync listener's registration and break the joiner flow. - if (!controller.isHost()) return; - - 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(); - } - }); - } - } + private void startSessionStateListener() { + controller.startSessionStateListener(new RoundController.SessionCancelledCallback() { + @Override + public void onHostLeft() { + if (sessionCancelledHandled) return; + sessionCancelledHandled = true; + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + controller::onSessionCancelled + ); + } - @Override - public void onFailure(Exception exception) { - Gdx.app.error("GameRoundView", - "Session state listener failed: " + exception.getMessage()); - } + @Override + public void onCancelled() { + if (sessionCancelledHandled) return; + sessionCancelledHandled = true; + controller.onSessionCancelled(); } - ); + }); } } diff --git a/core/src/main/java/group07/beatbattle/view/JoinCreateView.java b/core/src/main/java/group07/beatbattle/view/JoinCreateView.java index 14cdb72..def1761 100644 --- a/core/src/main/java/group07/beatbattle/view/JoinCreateView.java +++ b/core/src/main/java/group07/beatbattle/view/JoinCreateView.java @@ -15,7 +15,7 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.LobbyController; import group07.beatbattle.utils.Strings; -import group07.beatbattle.model.GameMode; +import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.model.services.LobbyService; import group07.beatbattle.ui.components.BackButton; import group07.beatbattle.ui.components.JoinCreateButton; diff --git a/core/src/main/java/group07/beatbattle/view/LeaderboardView.java b/core/src/main/java/group07/beatbattle/view/LeaderboardView.java index 2f26d18..c40fb3a 100644 --- a/core/src/main/java/group07/beatbattle/view/LeaderboardView.java +++ b/core/src/main/java/group07/beatbattle/view/LeaderboardView.java @@ -17,10 +17,9 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.LeaderboardController; import group07.beatbattle.utils.Strings; -import group07.beatbattle.model.LeaderboardEntry; +import group07.beatbattle.model.domain.LeaderboardEntry; import group07.beatbattle.ui.components.BackButton; import group07.beatbattle.ui.dialog.AlertDialogs; -import group07.beatbattle.firebase.FirebaseGateway; public class LeaderboardView extends ScreenAdapter { @@ -216,44 +215,26 @@ private Label.LabelStyle subtitleStyle() { } private void startListeningToSessionState() { - // Joiners: the persistent sync listener already handles STATE_CANCELLED. - // Registering here would clobber the sync listener, breaking the joiner flow. - if (!controller.isHost()) return; - - 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(); - } - }); - } - } + controller.startSessionStateListener(new LeaderboardController.SessionCancelledCallback() { + @Override + public void onHostLeft() { + if (sessionCancelledHandled) return; + sessionCancelledHandled = true; + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + controller::onSessionCancelled + ); + } - @Override - public void onFailure(Exception exception) { - Gdx.app.error("LeaderboardView", - "Session state listener failed: " + exception.getMessage()); - } + @Override + public void onCancelled() { + if (sessionCancelledHandled) return; + sessionCancelledHandled = true; + controller.onSessionCancelled(); } - ); + }); } } diff --git a/core/src/main/java/group07/beatbattle/view/LobbyView.java b/core/src/main/java/group07/beatbattle/view/LobbyView.java index 7a4490b..f4b7102 100644 --- a/core/src/main/java/group07/beatbattle/view/LobbyView.java +++ b/core/src/main/java/group07/beatbattle/view/LobbyView.java @@ -22,9 +22,8 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.LobbyController; import group07.beatbattle.utils.Strings; -import group07.beatbattle.firebase.FirebaseGateway; -import group07.beatbattle.model.GameMode; -import group07.beatbattle.model.Player; +import group07.beatbattle.model.domain.GameMode; +import group07.beatbattle.model.domain.Player; import group07.beatbattle.model.services.LobbyService; import group07.beatbattle.ui.components.BackButton; import group07.beatbattle.ui.components.RoundSelector; @@ -46,7 +45,6 @@ public class LobbyView extends ScreenAdapter { private final String playerId; private final boolean isHost; private boolean leavingLobby; - private boolean gameStarted; private Label.LabelStyle infoStyle; private Label.LabelStyle titleStyle; @@ -78,7 +76,6 @@ public LobbyView( this.hostName = displayName; this.isHost = isHost; this.leavingLobby = false; - this.gameStarted = false; titleStyle = new Label.LabelStyle(); titleStyle.font = game.getOrbitronFont(); @@ -138,61 +135,43 @@ public void onFailure(Exception exception) { private void startListeningToSessionState() { if (sessionId == null || sessionId.isBlank()) return; - game.getFirebaseGateway().listenToSessionState( - sessionId, - new FirebaseGateway.SessionStateListener() { - @Override - 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( - sessionId, - new FirebaseGateway.GetQuestionsCallback() { - @Override - public void onSuccess(List questions) { - Gdx.app.postRunnable(() -> - controller.onGameStarted(sessionId, questions, currentPlayers) - ); - } - - @Override - public void onFailure(Exception exception) { - Gdx.app.postRunnable(() -> { - gameStarted = false; - setStatusMessage("Failed to load questions"); - }); - } - } - ); - } - } + controller.startSessionStateListener(sessionId, new LobbyController.LobbySessionCallback() { + @Override + public List getCurrentPlayers() { + return currentPlayers; + } - @Override - public void onFailure(Exception exception) { - Gdx.app.postRunnable(() -> - setStatusMessage("Connection error: " + exception.getMessage()) + @Override + public void onHostCancelled(boolean hostLeft) { + if (leavingLobby) return; + if (hostLeft) { + AlertDialogs.showInfo( + stage, + game.getMontserratFont(), + "Session ended", + "The host left the session.", + () -> controller.onHostCancelledSession(mode) ); + } else { + controller.onHostCancelledSession(mode); } } - ); + + @Override + public void onGameStarting() { + setStatusMessage(Strings.gameStarting()); + } + + @Override + public void onGameStartFailed() { + setStatusMessage("Failed to load questions"); + } + + @Override + public void onConnectionError(String error) { + setStatusMessage("Connection error: " + error); + } + }); } private Table createHeader() { diff --git a/core/src/main/java/group07/beatbattle/view/SettingsView.java b/core/src/main/java/group07/beatbattle/view/SettingsView.java index 644a41d..d0954a3 100644 --- a/core/src/main/java/group07/beatbattle/view/SettingsView.java +++ b/core/src/main/java/group07/beatbattle/view/SettingsView.java @@ -13,7 +13,7 @@ import group07.beatbattle.BeatBattle; import group07.beatbattle.controller.SettingsController; import group07.beatbattle.utils.Strings; -import group07.beatbattle.model.GameMode; +import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.ui.components.BackButton; import group07.beatbattle.ui.components.ApplyButton; import group07.beatbattle.ui.components.LanguageSelector; diff --git a/core/src/test/java/group07/beatbattle/GameSimulationTest.java b/core/src/test/java/group07/beatbattle/GameSimulationTest.java index 7f93005..d2196cc 100644 --- a/core/src/test/java/group07/beatbattle/GameSimulationTest.java +++ b/core/src/test/java/group07/beatbattle/GameSimulationTest.java @@ -15,14 +15,14 @@ import group07.beatbattle.firebase.FakeFirebaseGateway; import group07.beatbattle.firebase.FirebaseGateway; -import group07.beatbattle.model.GameRules; -import group07.beatbattle.model.Leaderboard; -import group07.beatbattle.model.LeaderboardEntry; -import group07.beatbattle.model.Player; -import group07.beatbattle.model.Question; -import group07.beatbattle.model.SessionCreationResult; -import group07.beatbattle.model.SessionJoinResult; // used in focused joinSession tests -import group07.beatbattle.model.Song; +import group07.beatbattle.model.domain.GameRules; +import group07.beatbattle.model.domain.Leaderboard; +import group07.beatbattle.model.domain.LeaderboardEntry; +import group07.beatbattle.model.domain.Player; +import group07.beatbattle.model.domain.Question; +import group07.beatbattle.model.dto.SessionCreationResult; +import group07.beatbattle.model.dto.SessionJoinResult; // used in focused joinSession tests +import group07.beatbattle.model.domain.Song; import group07.beatbattle.model.score.ScoreCalculator; import group07.beatbattle.model.services.LobbyService; diff --git a/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java b/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java index 53f617b..e681b28 100644 --- a/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java +++ b/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java @@ -8,7 +8,7 @@ import java.util.Map; import java.util.Set; -import group07.beatbattle.model.Player; +import group07.beatbattle.model.domain.Player; /** * In-memory implementation of FirebaseGateway for unit and integration tests. diff --git a/lwjgl3/src/main/java/group07/beatbattle/lwjgl3/MockMusicService.java b/lwjgl3/src/main/java/group07/beatbattle/lwjgl3/MockMusicService.java index a169463..64254a2 100644 --- a/lwjgl3/src/main/java/group07/beatbattle/lwjgl3/MockMusicService.java +++ b/lwjgl3/src/main/java/group07/beatbattle/lwjgl3/MockMusicService.java @@ -1,6 +1,6 @@ package group07.beatbattle.lwjgl3; -import group07.beatbattle.model.Song; +import group07.beatbattle.model.domain.Song; import group07.beatbattle.model.services.MusicService; import group07.beatbattle.model.services.MusicServiceCallback; From 531522e854c11cfba5c488ae16eac1d7ce16af43 Mon Sep 17 00:00:00 2001 From: odaakj Date: Sun, 19 Apr 2026 14:47:17 +0200 Subject: [PATCH 03/11] Add deletion of session in Firestore 5sec after host leaves the session --- .../controller/LeaderboardController.java | 21 +++++++++++++++++ .../controller/LobbyController.java | 23 +++++++++++++++++++ .../controller/RoundController.java | 21 +++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java index a7b70c1..7eb6e1f 100644 --- a/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java +++ b/core/src/main/java/group07/beatbattle/controller/LeaderboardController.java @@ -1,6 +1,7 @@ package group07.beatbattle.controller; import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.utils.Timer; import java.util.List; @@ -203,6 +204,7 @@ public void onBackToMenu() { new FirebaseGateway.SimpleCallback() { @Override public void onSuccess() { + scheduleSessionDeletion(sessionId); Gdx.app.postRunnable(() -> StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) ); @@ -248,6 +250,25 @@ public void onFailure(Exception exception) { } } + /** + * Schedules deletion of the session document 5 seconds after the host has cancelled it. + * This gives all connected clients time to receive the cancellation event and show + * the "host left" dialog before the Firestore document disappears. + */ + private void scheduleSessionDeletion(String sessionId) { + Timer.schedule(new Timer.Task() { + @Override + public void run() { + game.getFirebaseGateway().deleteSession(sessionId, new FirebaseGateway.DeleteSessionCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { + Gdx.app.error("LeaderboardController", "Failed to delete session: " + e.getMessage()); + } + }); + } + }, 5f); + } + public void onPlayAgain() { LobbyController lobbyController = new LobbyController(game); lobbyController.playAgain(session); diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index ed934b4..1f1752a 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -1,6 +1,7 @@ package group07.beatbattle.controller; import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.utils.Timer; import java.util.ArrayList; import java.util.List; @@ -209,6 +210,9 @@ public void onBackFromLobby( new LobbyService.LeaveLobbyCallback() { @Override public void onSuccess() { + if (isHost) { + scheduleSessionDeletion(sessionId); + } Gdx.app.postRunnable(() -> StateManager.getInstance().setState( new JoinCreateState(game, mode, LobbyController.this) @@ -350,6 +354,25 @@ public void onGameStarted( StateManager.getInstance().setState(new InRoundState(game, roundController)); } + /** + * Schedules deletion of the session document 5 seconds after the host has cancelled it. + * This gives all connected clients time to receive the cancellation event and show + * the "host left" dialog before the Firestore document disappears. + */ + private void scheduleSessionDeletion(String sessionId) { + Timer.schedule(new Timer.Task() { + @Override + public void run() { + game.getFirebaseGateway().deleteSession(sessionId, new FirebaseGateway.DeleteSessionCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { + Gdx.app.error("LobbyController", "Failed to delete session: " + e.getMessage()); + } + }); + } + }, 5f); + } + public void playAgain(GameSession session) { String sessionId = game.getLocalSessionId(); if (sessionId == 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 6b9b652..287a78c 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -1,6 +1,7 @@ package group07.beatbattle.controller; import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.utils.Timer; import java.util.List; @@ -182,6 +183,7 @@ public void onLeaveSession() { new FirebaseGateway.SimpleCallback() { @Override public void onSuccess() { + scheduleSessionDeletion(sessionId); Gdx.app.postRunnable(RoundController.this::leaveToStart); } @@ -381,4 +383,23 @@ private void launchLeaderboard() { LeaderboardController leaderboardController = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, leaderboardController)); } + + /** + * Schedules deletion of the session document 5 seconds after the host has cancelled it. + * This gives all connected clients time to receive the cancellation event and show + * the "host left" dialog before the Firestore document disappears. + */ + private void scheduleSessionDeletion(String sessionId) { + Timer.schedule(new Timer.Task() { + @Override + public void run() { + game.getFirebaseGateway().deleteSession(sessionId, new FirebaseGateway.DeleteSessionCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { + Gdx.app.error("RoundController", "Failed to delete session: " + e.getMessage()); + } + }); + } + }, 5f); + } } From 6c726641a138036271d31cc1010e41320254ea7e Mon Sep 17 00:00:00 2001 From: odaakj Date: Sun, 19 Apr 2026 19:00:13 +0200 Subject: [PATCH 04/11] Fix joiner crash: wrap onGameStarted in Gdx.app.postRunnable --- .../group07/beatbattle/controller/LobbyController.java | 10 ++++++---- .../group07/beatbattle/controller/RoundController.java | 1 + .../beatbattle/controller/SessionSyncService.java | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 1f1752a..26ff91a 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -17,10 +17,10 @@ import group07.beatbattle.model.dto.SessionJoinResult; import group07.beatbattle.model.services.LobbyService; import group07.beatbattle.model.services.MusicServiceCallback; -import group07.beatbattle.states.GameOverState; +import group07.beatbattle.controller.QuestionBuilder; +import group07.beatbattle.controller.SessionSyncService; 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; @@ -262,8 +262,10 @@ public void onStateChanged(String state, int currentRound, String endReason) { new FirebaseGateway.GetQuestionsCallback() { @Override public void onSuccess(List questions) { - List players = callback.getCurrentPlayers(); - onGameStarted(sessionId, questions, players); + Gdx.app.postRunnable(() -> { + List players = callback.getCurrentPlayers(); + onGameStarted(sessionId, questions, players); + }); } @Override diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index 287a78c..050cb17 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -12,6 +12,7 @@ import group07.beatbattle.model.domain.Player; import group07.beatbattle.model.domain.Question; import group07.beatbattle.model.score.ScoreCalculator; +import group07.beatbattle.controller.RoundManager; import group07.beatbattle.states.LeaderboardState; import group07.beatbattle.states.StartState; import group07.beatbattle.states.StateManager; diff --git a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java index b8d7636..8b8cc19 100644 --- a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java +++ b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java @@ -5,6 +5,9 @@ import java.util.List; import group07.beatbattle.BeatBattle; +import group07.beatbattle.controller.LeaderboardController; +import group07.beatbattle.controller.LobbyController; +import group07.beatbattle.controller.RoundController; import group07.beatbattle.firebase.FirebaseGateway; import group07.beatbattle.model.domain.GameMode; import group07.beatbattle.model.domain.GameSession; From 50c5c72605fb05d310fa70854a5f531c35ddd904 Mon Sep 17 00:00:00 2001 From: odaakj Date: Mon, 20 Apr 2026 11:27:52 +0200 Subject: [PATCH 05/11] Fix audio bug for player --- .../beatbattle/controller/RoundController.java | 15 +++++++-------- .../beatbattle/controller/SessionSyncService.java | 8 +++++--- .../beatbattle/ecs/systems/AudioSystem.java | 4 ++++ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index 050cb17..7254a1a 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -200,6 +200,7 @@ public void onFailure(Exception exception) { return; } + roundManager.stop(); game.clearLocalSession(); game.getFirebaseGateway().stopAllListeners(); game.getFirebaseGateway().removePlayerFromSession( @@ -208,18 +209,16 @@ public void onFailure(Exception exception) { new FirebaseGateway.RemovePlayerCallback() { @Override public void onSuccess() { - Gdx.app.postRunnable(() -> { - roundManager.stop(); - StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); - }); + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); } @Override public void onFailure(Exception exception) { - Gdx.app.postRunnable(() -> { - roundManager.stop(); - StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); - }); + Gdx.app.postRunnable(() -> + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) + ); } } ); diff --git a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java index 8b8cc19..7162b3a 100644 --- a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java +++ b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java @@ -13,6 +13,7 @@ import group07.beatbattle.model.domain.GameSession; import group07.beatbattle.model.domain.Player; import group07.beatbattle.model.domain.Question; +import group07.beatbattle.ecs.systems.AudioSystem; import group07.beatbattle.states.GameOverState; import group07.beatbattle.states.InRoundState; import group07.beatbattle.states.LeaderboardState; @@ -50,9 +51,10 @@ public void onStateChanged(String state, int currentRound, String endReason) { if (game.getLocalSessionId() == null) return; if (FirebaseGateway.STATE_CANCELLED.equals(state)) { - Gdx.app.postRunnable(() -> - StateManager.getInstance().setState(new StartState(game, new LobbyController(game))) - ); + Gdx.app.postRunnable(() -> { + AudioSystem.getInstance().stopAll(); + StateManager.getInstance().setState(new StartState(game, new LobbyController(game))); + }); return; } diff --git a/core/src/main/java/group07/beatbattle/ecs/systems/AudioSystem.java b/core/src/main/java/group07/beatbattle/ecs/systems/AudioSystem.java index e38ab6a..2c1e889 100644 --- a/core/src/main/java/group07/beatbattle/ecs/systems/AudioSystem.java +++ b/core/src/main/java/group07/beatbattle/ecs/systems/AudioSystem.java @@ -48,6 +48,10 @@ public void stop(Entity round) { audio.isPlaying = false; } + public void stopAll() { + if (audioPlayer != null) audioPlayer.stop(); + } + public void setMuted(boolean muted) { this.muted = muted; if (audioPlayer != null) audioPlayer.setMuted(muted); From 1f0b86e7568547e6f4e60e471ad151bc2e44ed60 Mon Sep 17 00:00:00 2001 From: odaakj Date: Mon, 20 Apr 2026 11:35:21 +0200 Subject: [PATCH 06/11] Fix audio bug in transition from game round to leaderboard view --- .../java/group07/beatbattle/controller/SessionSyncService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java index 7162b3a..ae805a0 100644 --- a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java +++ b/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java @@ -100,6 +100,7 @@ private void transitionToLeaderboard(String sessionId, GameSession session) { public void onPlayersChanged(List updated) { Gdx.app.postRunnable(() -> { if (game.getLocalSessionId() == null) return; + AudioSystem.getInstance().stopAll(); session.syncPlayers(updated); LeaderboardController lc = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, lc)); @@ -110,6 +111,7 @@ public void onPlayersChanged(List updated) { public void onFailure(Exception e) { Gdx.app.postRunnable(() -> { if (game.getLocalSessionId() == null) return; + AudioSystem.getInstance().stopAll(); LeaderboardController lc = new LeaderboardController(game, session); StateManager.getInstance().setState(new LeaderboardState(game, lc)); }); From 43ffe23af03a61d8875edb9a403987038a17508f Mon Sep 17 00:00:00 2001 From: odaakj Date: Mon, 20 Apr 2026 11:49:36 +0200 Subject: [PATCH 07/11] Fix Firebase listener leak and use UUID for player IDs --- core/src/main/java/group07/beatbattle/BeatBattle.java | 2 ++ .../java/group07/beatbattle/model/services/LobbyService.java | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/group07/beatbattle/BeatBattle.java b/core/src/main/java/group07/beatbattle/BeatBattle.java index 78fb94e..5b5be8b 100644 --- a/core/src/main/java/group07/beatbattle/BeatBattle.java +++ b/core/src/main/java/group07/beatbattle/BeatBattle.java @@ -136,6 +136,8 @@ public void dispose() { if (orbitronFont != null) orbitronFont.dispose(); if (oswaldFont != null) oswaldFont.dispose(); + if (firebaseGateway != null) firebaseGateway.stopAllListeners(); + BackButton.disposeSharedResources(); JoinCreateButton.disposeSharedResources(); InputFieldStyles.dispose(); 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 5ff2a84..b994100 100644 --- a/core/src/main/java/group07/beatbattle/model/services/LobbyService.java +++ b/core/src/main/java/group07/beatbattle/model/services/LobbyService.java @@ -1,6 +1,7 @@ package group07.beatbattle.model.services; import java.util.Random; +import java.util.UUID; import java.util.List; import group07.beatbattle.firebase.FirebaseGateway; @@ -322,10 +323,10 @@ private String generateGamePin() { } private String generateHostId() { - return "host-" + System.currentTimeMillis(); + return "host-" + UUID.randomUUID(); } private String generatePlayerId() { - return "player-" + System.currentTimeMillis(); + return "player-" + UUID.randomUUID(); } } From cdce3a6c391d06fa8a2170b8462a33de8faac2f4 Mon Sep 17 00:00:00 2001 From: odaakj Date: Mon, 20 Apr 2026 11:57:10 +0200 Subject: [PATCH 08/11] Fix presentation of score in leaderboard view --- .../java/group07/beatbattle/model/domain/GameSession.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/group07/beatbattle/model/domain/GameSession.java b/core/src/main/java/group07/beatbattle/model/domain/GameSession.java index 07891a1..854eb32 100644 --- a/core/src/main/java/group07/beatbattle/model/domain/GameSession.java +++ b/core/src/main/java/group07/beatbattle/model/domain/GameSession.java @@ -58,10 +58,8 @@ public void syncPlayers(List updated) { for (Player u : updated) { for (Player local : players) { if (local.getId().equals(u.getId())) { - int roundDelta = u.getScore() - local.getScore(); - if (roundDelta > 0) { - local.addScore(roundDelta); - } + int roundDelta = Math.max(0, u.getScore() - local.getScore()); + local.addScore(roundDelta); } } } From 7754f210a3c35a6f92f30f63aa07a36eca5404be Mon Sep 17 00:00:00 2001 From: odaakj Date: Mon, 20 Apr 2026 13:07:00 +0200 Subject: [PATCH 09/11] Fix showcase of score per new round --- .../java/group07/beatbattle/controller/RoundController.java | 1 + .../java/group07/beatbattle/model/domain/GameSession.java | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index 7254a1a..7266897 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -53,6 +53,7 @@ public RoundController(BeatBattle game, Question question, GameSession session) this.question = question; this.session = session; this.activePlayerCount = session.getPlayers().size(); + for (Player p : session.getPlayers()) p.addScore(0); // reset lastRoundScore for new round this.roundManager = new RoundManager(question.getId(), question.getSong().getPreviewUrl()); if (game.isLocalHost()) { diff --git a/core/src/main/java/group07/beatbattle/model/domain/GameSession.java b/core/src/main/java/group07/beatbattle/model/domain/GameSession.java index 854eb32..07891a1 100644 --- a/core/src/main/java/group07/beatbattle/model/domain/GameSession.java +++ b/core/src/main/java/group07/beatbattle/model/domain/GameSession.java @@ -58,8 +58,10 @@ public void syncPlayers(List updated) { for (Player u : updated) { for (Player local : players) { if (local.getId().equals(u.getId())) { - int roundDelta = Math.max(0, u.getScore() - local.getScore()); - local.addScore(roundDelta); + int roundDelta = u.getScore() - local.getScore(); + if (roundDelta > 0) { + local.addScore(roundDelta); + } } } } From 7d0c34936395acded9b00b17e32f954cee399123 Mon Sep 17 00:00:00 2001 From: odaakj Date: Mon, 20 Apr 2026 13:25:10 +0200 Subject: [PATCH 10/11] Update README.md --- README.md | 152 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 93 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index e6f56de..1d8ad18 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,93 @@ # BeatBattle ๐ŸŽต -A multiplayer music quiz game for Android where players compete to identify songs from short audio clips. +A real-time multiplayer music quiz game for Android where players compete to identify songs from short audio previews. **Course:** TDT4240 โ€“ Software Architecture, NTNU **Group:** 07 +## How it works + +One player hosts a session and receives a 4-character game PIN. Other players join using that PIN. The host fetches songs from the Deezer API and distributes questions to all players via Firebase. Each round plays a 30-second audio preview and presents four multiple-choice options. Faster correct answers award more points. After each round, a leaderboard shows scores and the round delta for every player. The host controls round progression; joiners stay synchronized via persistent Firebase listeners. + ## Tech Stack -- **Platform:** Android (API 26+) -- **Framework:** LibGDX 1.12.x -- **Backend:** Firebase Firestore (real-time sync) -- **Music API:** Deezer API (30s previews) -- **Language:** Java +| Component | Technology | +|-----------|------------| +| Framework | LibGDX 1.14.0 | +| Platforms | Android (API 26+) ยท Desktop (LWJGL3, no real multiplayer) | +| Backend | Firebase Firestore (real-time sync) | +| Music API | Deezer API (top-chart 30s previews) | +| Language | Java 17 | +| Build | Gradle | ## Architecture -The project follows a layered MVC architecture with ECS for entity management: +The project applies three patterns simultaneously: + +**MVC** โ€” Views render state and forward input to Controllers. Controllers orchestrate game logic and Firebase calls. Domain models hold no UI or infrastructure dependencies. + +**Client-Server** โ€” All Firebase access flows through the `FirebaseGateway` interface. The Android implementation hits Firestore; a `NoOpFirebaseGateway` lets the desktop build run without Firebase. No view or domain class imports the Firebase SDK. + +**ECS** โ€” Round timing and audio playback are managed via a lightweight Entity-Component-System. `TimerComponent` and `AudioComponent` hold data; `RoundSystem` and `AudioSystem` contain the logic; `Engine` drives the update loop. `RoundManager` wraps ECS details so controllers stay decoupled from it. + +### Dependency flow ``` -User Interface โ†’ Screens & reusable UI components -Game Logic โ†’ Session, round, scoring, state management -Domain โ†’ Player, Song, GameSession, Submission, etc. -Infrastructure โ†’ Firebase gateway, audio provider, adapters +Views โ†’ Controllers โ†’ Domain โ† FirebaseGateway / Services + โ†• + ECS Engine ``` -Dependencies flow one-directionally: **UI โ†’ Game Logic โ†’ Domain โ† Infrastructure** - ## Project Structure ``` BeatBattle/ -โ”œโ”€โ”€ android/ # Android launcher & manifest -โ”œโ”€โ”€ core/ # Shared game code -โ”‚ โ””โ”€โ”€ src/main/java/group07/beatbattle/ -โ”‚ โ”œโ”€โ”€ ui/ -โ”‚ โ”‚ โ”œโ”€โ”€ screens/ # JoinGameScreen, LobbyScreen, QuizScreen, etc. -โ”‚ โ”‚ โ””โ”€โ”€ components/ # AnswerOptionBtn, TimerView, PlayerList -โ”‚ โ”œโ”€โ”€ logic/ -โ”‚ โ”‚ โ”œโ”€โ”€ session/ # Session creation & joining -โ”‚ โ”‚ โ”œโ”€โ”€ round/ # Round flow & timing -โ”‚ โ”‚ โ”œโ”€โ”€ scoring/ # Score calculation & streaks -โ”‚ โ”‚ โ”œโ”€โ”€ state/ # StateManager & concrete states -โ”‚ โ”‚ โ””โ”€โ”€ api/ # SessionGateway, LeaderboardRepository, AudioProvider -โ”‚ โ”œโ”€โ”€ domain/ -โ”‚ โ”‚ โ””โ”€โ”€ model/ # Player, Song, Question, Submission, etc. -โ”‚ โ””โ”€โ”€ infra/ -โ”‚ โ”œโ”€โ”€ persistence/ # Firebase Firestore implementation -โ”‚ โ”œโ”€โ”€ sync/ # Real-time listeners -โ”‚ โ”œโ”€โ”€ audio/ # Deezer / local audio playback -โ”‚ โ””โ”€โ”€ adapters/ # Interface implementations -โ”œโ”€โ”€ assets/ # Game assets (sprites, sounds, fonts) -โ”œโ”€โ”€ build.gradle # Root build config -โ””โ”€โ”€ settings.gradle +โ”œโ”€โ”€ android/ # Android launcher, Firebase & audio implementations +โ”‚ โ””โ”€โ”€ src/main/java/.../android/ +โ”‚ โ”œโ”€โ”€ AndroidLauncher.java +โ”‚ โ”œโ”€โ”€ AndroidAudioPlayer.java +โ”‚ โ”œโ”€โ”€ DeezerMusicService.java +โ”‚ โ””โ”€โ”€ firebase/ +โ”‚ โ”œโ”€โ”€ AndroidFirebaseGateway.java +โ”‚ โ””โ”€โ”€ FirestoreSessionRepository.java +โ”œโ”€โ”€ lwjgl3/ # Desktop launcher (stub services, no multiplayer) +โ”œโ”€โ”€ core/ # All platform-agnostic game code +โ”‚ โ””โ”€โ”€ src/main/java/.../beatbattle/ +โ”‚ โ”œโ”€โ”€ BeatBattle.java # Root Game class; owns session state & shared fonts +โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”œโ”€โ”€ LobbyController.java # Session creation, joining, game start +โ”‚ โ”‚ โ”œโ”€โ”€ RoundController.java # Per-round lifecycle & answer scoring +โ”‚ โ”‚ โ”œโ”€โ”€ RoundManager.java # ECS wrapper for round entity +โ”‚ โ”‚ โ”œโ”€โ”€ QuestionBuilder.java # Builds questions from Deezer & stores in Firebase +โ”‚ โ”‚ โ”œโ”€โ”€ SessionSyncService.java # Keeps joiners in sync via Firebase listener +โ”‚ โ”‚ โ”œโ”€โ”€ LeaderboardController.java +โ”‚ โ”‚ โ””โ”€โ”€ SettingsController.java +โ”‚ โ”œโ”€โ”€ model/ +โ”‚ โ”‚ โ”œโ”€โ”€ domain/ # GameSession, Player, Question, Song, GameRules, โ€ฆ +โ”‚ โ”‚ โ”œโ”€โ”€ dto/ # SessionCreationResult, SessionJoinResult +โ”‚ โ”‚ โ”œโ”€โ”€ score/ # ScoreCalculator +โ”‚ โ”‚ โ””โ”€โ”€ services/ # LobbyService, MusicService +โ”‚ โ”œโ”€โ”€ ecs/ +โ”‚ โ”‚ โ”œโ”€โ”€ Engine.java +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # TimerComponent, AudioComponent +โ”‚ โ”‚ โ”œโ”€โ”€ systems/ # RoundSystem, AudioSystem +โ”‚ โ”‚ โ””โ”€โ”€ entities/ # Entity, RoundFactory +โ”‚ โ”œโ”€โ”€ states/ # StateManager + Start/Lobby/InRound/Leaderboard/GameOver states +โ”‚ โ”œโ”€โ”€ view/ # One ScreenAdapter per state +โ”‚ โ”œโ”€โ”€ ui/ # Reusable Scene2D components & dialogs +โ”‚ โ”œโ”€โ”€ firebase/ # FirebaseGateway interface + NoOpFirebaseGateway +โ”‚ โ””โ”€โ”€ audio/ # AudioPlayer interface +โ””โ”€โ”€ assets/ # Fonts, images ``` +## Scoring + +- **Wrong answer:** 0 points +- **Correct within first 3 seconds:** 1 000 points +- **Correct after grace period:** linear decay from 1 000 โ†’ 300 points over the remaining 27 seconds + +Round duration is 30 seconds (`GameRules.ROUND_DURATION_SECONDS`). + ## Getting Started ### Prerequisites @@ -67,45 +101,45 @@ BeatBattle/ 1. Clone the repo: ```bash git clone git@git.ntnu.no:milospi/TDT4240gr07-BeatBattle.git - cd BeatBattle + cd TDT4240gr07-BeatBattle ``` -2. Add your `google-services.json` to `android/` (get from Firebase Console โ€” not committed to git) +2. Add your `google-services.json` to `android/` (from Firebase Console โ€” not committed to git) -3. Open in Android Studio or build from terminal: +3. Build and install on a device or emulator: ```bash ./gradlew android:assembleDebug - ``` - -4. Run on emulator or device: - ```bash ./gradlew android:installDebug ``` +### Desktop (no multiplayer) + +```bash +./gradlew lwjgl3:run +``` + +The desktop build uses stub implementations (`NoOpFirebaseGateway`, `MockMusicService`) and is useful for UI development only. + ## Branching Strategy | Branch | Purpose | |--------|---------| | `main` | Stable, releasable code only | -| `dev` | Integration branch โ€” PRs merge here | -| `feature/` | New features (e.g. `feature/lobby-screen`) | -| `bugfix/` | Bug fixes | - - +| `feature/-` | New features | +| `bugfix/-` | Bug fixes | -**Rules:** -- Never push directly to `main` or `dev` +- Never push directly to `main` - All changes go through Pull Requests with at least 1 reviewer -- Keep feature branches short-lived +- Branch names should reference the related issue number ## Team -| Name | Role | -|------|------| -| Anja Pedersen | | -| Julie Hoel | | -| Karya Vindstad | | -| Kristoffer Welle | | -| Milos Pilipovic | | -| Mostafa Saleh | | -| Oda Aasen Kjerstad | | +| Name | +|------| +| Anja Pedersen | +| Julie Hoel | +| Karya Vindstad | +| Kristoffer Welle | +| Milos Pilipovic | +| Mostafa Saleh | +| Oda Aasen Kjerstad | From 94647b3258ba6c92aba33e6edfe4f06edf476ecd Mon Sep 17 00:00:00 2001 From: Mostafa Date: Mon, 20 Apr 2026 13:39:42 +0200 Subject: [PATCH 11/11] refactor services from controller folder to appropriate place and update readme # Conflicts: # README.md --- README.md | 3 --- .../java/group07/beatbattle/controller/LobbyController.java | 4 ++-- .../java/group07/beatbattle/controller/RoundController.java | 2 +- .../group07/beatbattle/{controller => ecs}/RoundManager.java | 3 +-- .../{controller => model/services}/QuestionBuilder.java | 4 ++-- .../{controller => model/services}/SessionSyncService.java | 2 +- 6 files changed, 7 insertions(+), 11 deletions(-) rename core/src/main/java/group07/beatbattle/{controller => ecs}/RoundManager.java (95%) rename core/src/main/java/group07/beatbattle/{controller => model/services}/QuestionBuilder.java (97%) rename core/src/main/java/group07/beatbattle/{controller => model/services}/SessionSyncService.java (99%) diff --git a/README.md b/README.md index 1d8ad18..b6cdddb 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,6 @@ BeatBattle/ โ”‚ โ”œโ”€โ”€ controller/ โ”‚ โ”‚ โ”œโ”€โ”€ LobbyController.java # Session creation, joining, game start โ”‚ โ”‚ โ”œโ”€โ”€ RoundController.java # Per-round lifecycle & answer scoring -โ”‚ โ”‚ โ”œโ”€โ”€ RoundManager.java # ECS wrapper for round entity -โ”‚ โ”‚ โ”œโ”€โ”€ QuestionBuilder.java # Builds questions from Deezer & stores in Firebase -โ”‚ โ”‚ โ”œโ”€โ”€ SessionSyncService.java # Keeps joiners in sync via Firebase listener โ”‚ โ”‚ โ”œโ”€โ”€ LeaderboardController.java โ”‚ โ”‚ โ””โ”€โ”€ SettingsController.java โ”‚ โ”œโ”€โ”€ model/ diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 26ff91a..d09f9d6 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -17,8 +17,8 @@ import group07.beatbattle.model.dto.SessionJoinResult; import group07.beatbattle.model.services.LobbyService; import group07.beatbattle.model.services.MusicServiceCallback; -import group07.beatbattle.controller.QuestionBuilder; -import group07.beatbattle.controller.SessionSyncService; +import group07.beatbattle.model.services.QuestionBuilder; +import group07.beatbattle.model.services.SessionSyncService; import group07.beatbattle.states.InRoundState; import group07.beatbattle.states.JoinCreateState; import group07.beatbattle.states.LobbyState; diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index 7266897..960b709 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -12,7 +12,7 @@ import group07.beatbattle.model.domain.Player; import group07.beatbattle.model.domain.Question; import group07.beatbattle.model.score.ScoreCalculator; -import group07.beatbattle.controller.RoundManager; +import group07.beatbattle.ecs.RoundManager; import group07.beatbattle.states.LeaderboardState; import group07.beatbattle.states.StartState; import group07.beatbattle.states.StateManager; diff --git a/core/src/main/java/group07/beatbattle/controller/RoundManager.java b/core/src/main/java/group07/beatbattle/ecs/RoundManager.java similarity index 95% rename from core/src/main/java/group07/beatbattle/controller/RoundManager.java rename to core/src/main/java/group07/beatbattle/ecs/RoundManager.java index 4542323..9fbe1a5 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundManager.java +++ b/core/src/main/java/group07/beatbattle/ecs/RoundManager.java @@ -1,6 +1,5 @@ -package group07.beatbattle.controller; +package group07.beatbattle.ecs; -import group07.beatbattle.ecs.Engine; import group07.beatbattle.ecs.components.TimerComponent; import group07.beatbattle.ecs.entities.Entity; import group07.beatbattle.ecs.entities.RoundFactory; diff --git a/core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java b/core/src/main/java/group07/beatbattle/model/services/QuestionBuilder.java similarity index 97% rename from core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java rename to core/src/main/java/group07/beatbattle/model/services/QuestionBuilder.java index 61b79f6..7f644e8 100644 --- a/core/src/main/java/group07/beatbattle/controller/QuestionBuilder.java +++ b/core/src/main/java/group07/beatbattle/model/services/QuestionBuilder.java @@ -1,4 +1,4 @@ -package group07.beatbattle.controller; +package group07.beatbattle.model.services; import com.badlogic.gdx.Gdx; @@ -21,7 +21,7 @@ */ public class QuestionBuilder { - static final int OPTIONS_PER_Q = 4; + public static final int OPTIONS_PER_Q = 4; private final BeatBattle game; diff --git a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java b/core/src/main/java/group07/beatbattle/model/services/SessionSyncService.java similarity index 99% rename from core/src/main/java/group07/beatbattle/controller/SessionSyncService.java rename to core/src/main/java/group07/beatbattle/model/services/SessionSyncService.java index ae805a0..3c32590 100644 --- a/core/src/main/java/group07/beatbattle/controller/SessionSyncService.java +++ b/core/src/main/java/group07/beatbattle/model/services/SessionSyncService.java @@ -1,4 +1,4 @@ -package group07.beatbattle.controller; +package group07.beatbattle.model.services; import com.badlogic.gdx.Gdx;