diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java index 897bc04..ceae38c 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java @@ -5,6 +5,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController.ChatKey; import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel; import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatType; import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message; +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.io.IOException; import java.net.URL; import java.util.ArrayList; @@ -70,7 +71,11 @@ public class ChatBoxController { ChatModel globalChatModel = new ChatModel(global, username, -1, null); chatController.getChatModelMap().put(new ChatController.ChatKey(global), globalChatModel); addChatTab("GLOBAL", globalChatModel, global); - addWhisperChatButton.setOnAction(_ -> addWhisperChatButton.show()); + addWhisperChatButton.setOnAction( + _ -> { + SoundManager.getInstance().playButtonClick(); + addWhisperChatButton.show(); + }); } /** @@ -90,14 +95,13 @@ public class ChatBoxController { MenuItem menuItem = new MenuItem(targetUserName); addWhisperChatButton.getItems().add(menuItem); menuItem.setOnAction( - _ -> - addWhisperChat( - targetUserName, - new ChatModel( - ChatType.WHISPER, - username, - -1, - targetUserName))); + _ -> { + SoundManager.getInstance().playButtonClick(); + addWhisperChat( + targetUserName, + new ChatModel( + ChatType.WHISPER, username, -1, targetUserName)); + }); }); } @@ -124,14 +128,16 @@ public class ChatBoxController { if (oldUsername.equals(item.getText())) { item.setText(newUsername); item.setOnAction( - _ -> - addWhisperChat( - newUsername, - new ChatModel( - ChatType.WHISPER, - username, - -1, - newUsername))); + _ -> { + SoundManager.getInstance().playButtonClick(); + addWhisperChat( + newUsername, + new ChatModel( + ChatType.WHISPER, + username, + -1, + newUsername)); + }); break; } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java index fc9bff8..7b0593f 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java @@ -3,6 +3,7 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui; import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController; import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel; import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message; +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.net.URL; import java.util.ResourceBundle; import javafx.application.Platform; @@ -76,6 +77,7 @@ public class ChatViewController implements Initializable { * The input field is cleared after sending. */ public void sendMessage() { + SoundManager.getInstance().playButtonClick(); String message = inputField.getText().trim(); if (!message.isEmpty()) { inputField.clear(); diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java index b0d05ae..7eb38b1 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java @@ -15,6 +15,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.Noteboo import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.SettingsController; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController; +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.io.IOException; import java.net.URL; import java.util.HashSet; @@ -74,6 +75,7 @@ public class CasinoGameController { private Image dealerImage; private javafx.animation.Timeline timeline; private boolean gameStarted = false; + private boolean firstCardUpdate = true; private java.util.List lastCommunityKeys = java.util.List.of(); private java.util.List lastMyCardKeys = java.util.List.of(); private int lastPot = Integer.MIN_VALUE; @@ -198,6 +200,38 @@ public class CasinoGameController { return suit + ":" + value; } + /** + * Count how many new non-null card keys were added when transitioning from old keys to new + * keys. + * + * @param oldKeys The previous list of card keys + * @param newKeys The new list of card keys + * @return The count of new (non-null) cards that were revealed + */ + private int countNewCards(java.util.List oldKeys, java.util.List newKeys) { + int count = 0; + int minSize = Math.min(oldKeys.size(), newKeys.size()); + + // Count cards that changed from "null" to actual card + for (int i = 0; i < minSize; i++) { + String oldKey = oldKeys.get(i); + String newKey = newKeys.get(i); + if ("null".equals(oldKey) && !"null".equals(newKey)) { + count++; + } + } + + // Count any new cards beyond the previous size (if applicable) + for (int i = minSize; i < newKeys.size(); i++) { + String newKey = newKeys.get(i); + if (!"null".equals(newKey)) { + count++; + } + } + + return Math.max(1, count); // At least play one sound if cards changed + } + /** * Generate a list of unique keys for a list of cards. * @@ -641,14 +675,28 @@ public class CasinoGameController { var newCommunityKeys = cardKeys(community, TOTAL_SLOTS); if (!newCommunityKeys.equals(lastCommunityKeys)) { + int newCardCount = countNewCards(lastCommunityKeys, newCommunityKeys); lastCommunityKeys = newCommunityKeys; renderCommunityCards(community); + if (!firstCardUpdate) { + SoundManager.getInstance().playCardRevealMultiple(newCardCount); + } } var newMyCardKeys = cardKeys(myCards, PLAYER_SLOTS); if (!newMyCardKeys.equals(lastMyCardKeys)) { + int newCardCount = countNewCards(lastMyCardKeys, newMyCardKeys); lastMyCardKeys = newMyCardKeys; renderPlayerCards(myCards); + // Don't play sound on first card update (initial animation) + if (!firstCardUpdate) { + SoundManager.getInstance().playCardRevealMultiple(newCardCount); + } + } + + // After first update, enable sounds for subsequent card reveals + if (firstCardUpdate) { + firstCardUpdate = false; } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java index 4de45f9..f59ba14 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java @@ -8,6 +8,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId; import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui; +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; @@ -338,7 +339,7 @@ public class TaskbarController { // Integer targetBet = resolveTargetBet(ActionType.RAISE_BUTTON, lastState); // if (targetBet == null) { - // return ""; + // return ""; // } // int totalContribution = Math.max(0, targetBet); @@ -528,7 +529,7 @@ public class TaskbarController { boolean phaseChanged) { // boolean firstPlayerNewRound = - // phaseChanged && isFirstPreflopPlayer(state, me); + // phaseChanged && isFirstPreflopPlayer(state, me); inputActionAllowed = false; if (!isMyTurn || isOutOrFinished || state == null || me == null) { setBetButtonVisible(false); @@ -1538,18 +1539,21 @@ public class TaskbarController { */ @FXML private void onInputSubmittedAction() { + SoundManager.getInstance().playButtonClick(); processBet(); } /** Called when the Call button is clicked. */ @FXML private void onInputPlayerCall() { + SoundManager.getInstance().playButtonClick(); submitAction(ActionType.CALL_BUTTON); } /** Called when the Fold button is clicked. */ @FXML private void onInputPlayerFold() { + SoundManager.getInstance().playButtonClick(); if (gameService == null) { LOGGER.error("GameService not initialized"); @@ -1573,6 +1577,7 @@ public class TaskbarController { /** Called when the Raise button is clicked. */ @FXML private void onInputPlayerRaise() { + SoundManager.getInstance().playButtonClick(); submitPresetInputAndProcess("raise"); } @@ -1614,6 +1619,7 @@ public class TaskbarController { */ @FXML private void onExitButtonClick() { + SoundManager.getInstance().playButtonClick(); javafx.application.Platform.runLater( () -> { // Close game stage @@ -1739,6 +1745,7 @@ public class TaskbarController { */ @FXML private void onBrowserButtonClick() { + SoundManager.getInstance().playButtonClick(); try { Path path = Paths.get( @@ -1798,6 +1805,7 @@ public class TaskbarController { /** Opens the highscore popup window from the taskbar. */ @FXML private void onHighscoreButtonClick() { + SoundManager.getInstance().playButtonClick(); try { var shared = ClientApp.getSharedClientService(); if (shared == null || shared.isOffline()) { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java index 67415c5..742bf3b 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java @@ -1,6 +1,7 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui; /** Main UI application for Casono. Loads the main FXML layout and sets up the stage. */ +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.io.IOException; import javafx.application.Application; import javafx.fxml.FXMLLoader; @@ -30,6 +31,9 @@ public class Casinomainui extends Application { * @throws IOException If loading the FXML fails. */ public void start(Stage stage) throws IOException { + // Pre-load sounds to avoid delays on first play + SoundManager.getInstance().preloadSounds(); + // If the launcher passed an address argument (ip:port), expose it as // system properties so controllers can read it without embedding defaults. var raw = getParameters().getRaw(); diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java index 7557ac9..c51722a 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java @@ -5,6 +5,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController; import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.HighscoreViewController; +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.io.IOException; import java.net.URL; import javafx.application.Platform; @@ -170,6 +171,7 @@ public class CasinomainuiController { /** Handles the login button action. Validates input and calls LobbyClient.login(). */ @FXML public void handleLoginButton() { + SoundManager.getInstance().playButtonClick(); String username = usernameField == null ? null : usernameField.getText(); if (username == null || username.isBlank()) { showAlert("Please enter a username."); @@ -259,6 +261,7 @@ public class CasinomainuiController { /** Handles the exit button action to close the application. */ @FXML public void handleexitbutton() { + SoundManager.getInstance().playButtonClick(); if (chatController != null) { chatController.shutdown(); } @@ -272,6 +275,7 @@ public class CasinomainuiController { */ @FXML public void handleCreateLobbyButton() { + SoundManager.getInstance().playButtonClick(); if (translationManager.isFull()) { LOGGER.warn("Grid is full! No more lobbies available."); return; diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java index 6d0db59..b572975 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java @@ -6,6 +6,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI; import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter; +import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -364,6 +365,7 @@ public class LobbyButtonGridManager { btn.setOnAction( e -> { + SoundManager.getInstance().playButtonClick(); Integer targetLobbyId = translationManager.getLobbyIdForButton(buttonId); if (targetLobbyId != null) { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/ui/sound/SoundManager.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/ui/sound/SoundManager.java new file mode 100644 index 0000000..5e7f6e6 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/ui/sound/SoundManager.java @@ -0,0 +1,182 @@ +package ch.unibas.dmi.dbis.cs108.casono.ui.sound; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.event.ActionEvent; +import javafx.scene.media.AudioClip; +import javafx.util.Duration; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Manages sound effects for the Casono game. Provides methods to play sounds for card reveals and + * button clicks. + */ +public class SoundManager { + private static final Logger LOGGER = LogManager.getLogger(SoundManager.class); + private static final SoundManager INSTANCE = new SoundManager(); + private final Map soundCache = new HashMap<>(); + private static final float MIN_VOLUME = 0.0f; + private static final float MAX_VOLUME = 1.0f; + private static final float DEFAULT_VOLUME = 0.5f; + private float volume = DEFAULT_VOLUME; + private static final long CARD_REVEAL_STAGGER_MS = 120; // Delay between card reveal sounds + + private SoundManager() {} + + /** Returns the singleton instance of SoundManager. */ + public static SoundManager getInstance() { + return INSTANCE; + } + + /** + * Pre-loads critical sounds into cache to avoid delays on first play. Should be called at + * application startup. + */ + public void preloadSounds() { + try { + getOrLoadSound("button-click"); + getOrLoadSound("card-reveal"); + } catch (Exception e) { + LOGGER.error("Error preloading sounds", e); + } + } + + /** Plays a button click sound. */ + public void playButtonClick() { + playSound("button-click"); + } + + /** Plays a card reveal sound (for dealt or community cards). */ + public void playCardReveal() { + playSound("card-reveal"); + } + + /** + * Plays multiple card reveal sounds in sequence with slight stagger/overlap. Used when multiple + * cards are revealed at once (e.g., Flop reveals 3 cards, Turn reveals 1 card). Sounds play + * with a slight delay between them to simulate a person revealing cards one by one. + * + * @param count The number of cards being revealed. Each card gets its own sound with stagger. + */ + public void playCardRevealMultiple(int count) { + if (count <= 0) { + return; + } + if (count == 1) { + playCardReveal(); + return; + } + + for (int i = 0; i < count; i++) { + final int cardIndex = i; + long delayMs = cardIndex * CARD_REVEAL_STAGGER_MS; + + PauseTransition pause = new PauseTransition(Duration.millis(delayMs)); + pause.setOnFinished(this::onCardRevealDelayFinished); + pause.play(); + } + } + + private void onCardRevealDelayFinished(ActionEvent event) { + Platform.runLater(this::playCardRevealSafely); + } + + private void playCardRevealSafely() { + try { + playCardReveal(); + } catch (Exception e) { + LOGGER.error("Error playing card reveal sound", e); + } + } + + /** Plays a card shuffle sound. */ + public void playCardShuffle() { + playSound("card-shuffle"); + } + + /** Plays a pot/chip sound. */ + public void playChip() { + playSound("chip"); + } + + /** + * Plays a generic sound by key. The sound file should exist at: /sounds/{category}/{key}.mp3 or + * .wav + */ + public void playSound(String soundKey) { + try { + AudioClip audioClip = getOrLoadSound(soundKey); + if (audioClip != null) { + audioClip.setVolume(volume); + audioClip.play(); + } + } catch (Exception e) { + LOGGER.error("Error playing sound: {}", soundKey, e); + } + } + + /** Gets or loads a sound from cache. Categorizes sounds automatically based on key prefix. */ + private AudioClip getOrLoadSound(String soundKey) { + if (soundCache.containsKey(soundKey)) { + return soundCache.get(soundKey); + } + + String category = categorizeSound(soundKey); + String resourcePath = String.format("/sounds/%s/%s.wav", category, soundKey); + + try { + URL soundUrl = getClass().getResource(resourcePath); + if (soundUrl != null) { + AudioClip clip = new AudioClip(soundUrl.toString()); + soundCache.put(soundKey, clip); + return clip; + } else { + LOGGER.warn("Sound resource not found: {}", resourcePath); + return null; + } + } catch (Exception e) { + LOGGER.error("Failed to load sound: {}", resourcePath, e); + return null; + } + } + + /** Categorizes a sound based on its key prefix. */ + private String categorizeSound(String soundKey) { + if (soundKey.startsWith("button")) { + return "buttons"; + } else if (soundKey.startsWith("card")) { + return "cards"; + } else { + return "game"; + } + } + + /** Sets the volume for sound effects (0.0 - 1.0). */ + public void setVolume(float volume) { + this.volume = Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, volume)); + } + + /** Gets the current volume level. */ + public float getVolume() { + return volume; + } + + /** Clears the sound cache. */ + public void clearCache() { + soundCache.clear(); + } + + /** Mutes all sounds by setting volume to 0. */ + public void mute() { + setVolume(0.0f); + } + + /** Unmutes sounds by restoring to default volume. */ + public void unmute() { + setVolume(DEFAULT_VOLUME); + } +} diff --git a/src/main/resources/sounds/buttons/button-click.wav b/src/main/resources/sounds/buttons/button-click.wav new file mode 100644 index 0000000..397cb37 Binary files /dev/null and b/src/main/resources/sounds/buttons/button-click.wav differ diff --git a/src/main/resources/sounds/cards/card-reveal.wav b/src/main/resources/sounds/cards/card-reveal.wav new file mode 100644 index 0000000..1d873eb Binary files /dev/null and b/src/main/resources/sounds/cards/card-reveal.wav differ