diff --git a/documents/diary/26-04.md b/documents/diary/26-04.md new file mode 100644 index 0000000..3fb13bb --- /dev/null +++ b/documents/diary/26-04.md @@ -0,0 +1,11 @@ +# Besprechung vom 26.04.2024 + +## Fortschritt für die Implementierung der Unit Tests +Die Unit Tests für alle zentralen Komponenten des Netzwerkes wurden erfolgreich implementiert. +Bei dieser Gelegenheit wurden auch Bugs behoben und die Codequalität verbessert. + +In einzelnen Bereichen wurde noch nicht die vorgenommene Quote von 50% erreicht, weitere Unit-Tests sollen bis zum nächsten Meilenstein folgen. Komponenten beteiligt an den Kernbereichen sind größtenteils abgedeckt. + +## Perspektivische Änderungen für den letzten Meilenstein +Ein getroffener Kompromiss um den vierten Meilenstein fristgerecht zu erreichen, war das zurückstellen der Codequalität. +In der Zeit bis zum sechsten Meilenstein sollen diese Provisorien noch einmal überarbeitet und finalisiert werden. diff --git a/documents/diary/README.md b/documents/diary/README.md index 6304353..932870f 100644 --- a/documents/diary/README.md +++ b/documents/diary/README.md @@ -36,7 +36,9 @@ None, as milestone 3 covered the assessment of individual technical understandin ## Milestone 5 -*(Meeting notes to be added here when available)* +### [26. April 2026](26-04.md) +- Progress on unit tests and code quality improvements +- Discussion of compromises made for MS4 and plans for finalizing code quality by MS6 ## Milestone 6 diff --git a/documents/docs/game-engine/casono-markdown-render-engine.js b/documents/docs/game-engine/casono-markdown-render-engine.js new file mode 100644 index 0000000..966dbba --- /dev/null +++ b/documents/docs/game-engine/casono-markdown-render-engine.js @@ -0,0 +1,84 @@ +const marked = { + parse: function (md) { + if (!md) return ""; + + let toc = []; + + md = md.replace(/^(#{1,3})\s+(.*)$/gim, (m, hashes, title) => { + const level = hashes.length; + + const id = title + .toLowerCase() + .trim() + .replace(/[^\wäöüß]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + toc.push({ level, title, id }); + + return ` + + ${title} + + `; + }); + + md = md.replace(/```([\s\S]*?)```/g, (_, code) => { + return `
${escapeHtml(code)}
`; + }); + + md = md.replace(/!\[(.*?)\]\((.*?)\)/gim, + `$1` + ); + + md = md.replace(/\[(.*?)\]\((.*?)\)/gim, + `$1` + ); + + md = md.replace(/`(.*?)`/gim, + `$1` + ); + + md = md.replace(/\*\*(.*?)\*\*/gim, "$1"); + md = md.replace(/\*(.*?)\*/gim, "$1"); + + md = md.replace(/^\s*-\s(.+)$/gim, "
  • $1
  • "); + md = wrapLists(md); + + md = md + .replace(/\n{2,}/g, "

    ") + .replace(/^(?!$1

    "); + + return buildToc(toc) + md; + } +}; + +function buildToc(items) { + if (!items.length) return ""; + + let html = `
    `; + + items.forEach(i => { + html += ` +
    + ${i.title} +
    + `; + }); + + html += `
    `; + return html; +} + +function wrapLists(html) { + return html.replace(/(
  • .*?<\/li>)/gs, ""); +} + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +window.marked = marked; diff --git a/documents/docs/game-engine/images/activate-js.png b/documents/docs/game-engine/images/activate-js.png new file mode 100644 index 0000000..dce5bc5 Binary files /dev/null and b/documents/docs/game-engine/images/activate-js.png differ diff --git a/documents/docs/game-engine/manual.html b/documents/docs/game-engine/manual.html new file mode 100644 index 0000000..27ca621 --- /dev/null +++ b/documents/docs/game-engine/manual.html @@ -0,0 +1,316 @@ + + + + + Markdown Viewer + + + + + + + +
    + + + + + + + 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 f3ab44d..61d28bc 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 @@ -1,11 +1,14 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui; import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController; +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.client.game.Card; import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService; import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState; import ch.unibas.dmi.dbis.cs108.casono.client.game.Player; 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.ClientService; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController; @@ -138,11 +141,21 @@ public class CasinoGameController { private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 35L; private static final double CHAT_WIDTH = 400; private static final double CHAT_HEIGHT = 600; + private static final String ACTIVE_CARD_BOX_STYLE_CLASS = "player-cards-active-turn"; + private static final int DEALER_OFFSET = 3; + private static final String FIRST_PREFLOP_INFO_TEMPLATE = + "Bet the big blind amount: %d$ or +%d$"; private String chatUsername; private ClientService chatClientService; private int chatLobbyId = -1; private boolean chatInitialized; + private boolean gameFinished; + private boolean localWinAnnouncementSent; + private String lastWinnerName; + private static final int SMALL_BLIND = 100; + private static final int BIG_BLIND = 200; + private static final int NO_BET = 0; /** Standard constructor. Used by FXML. */ public CasinoGameController() { @@ -194,11 +207,18 @@ public class CasinoGameController { if (controller != null && myPlayerId != null) { controller.setGameService(gameService, myPlayerId); } + configureTaskbarLobbyActionAnnouncements(); } + /** + * Set the PlayerId of the current player. + * + * @param chatController The ChatController instance used for managing the chat functionality. + */ public void startChat(ChatController chatController) { this.chatController = chatController; initializeChatIfPossible(); + configureTaskbarLobbyActionAnnouncements(); } /** Set the PlayerId of the current player. */ @@ -210,6 +230,7 @@ public class CasinoGameController { if (taskbarController != null && gameService != null && myPlayerId != null) { taskbarController.setGameService(gameService, myPlayerId); } + configureTaskbarLobbyActionAnnouncements(); if (communityCardsBox == null) { LOGGER.warning("communityCardsBox is NULL"); @@ -230,6 +251,7 @@ public class CasinoGameController { } myDealerIcon.setVisible(false); + tableText.setWrapText(true); javafx.stage.Screen screen = javafx.stage.Screen.getPrimary(); double screenHeight = screen.getBounds().getHeight(); @@ -262,6 +284,7 @@ public class CasinoGameController { this.chatUsername = username; this.chatClientService = clientService; this.chatLobbyId = lobbyId; + configureTaskbarLobbyActionAnnouncements(); } /** @@ -477,7 +500,7 @@ public class CasinoGameController { */ private void applyState(GameState s) { - if (s == null) { + if (s == null || gameFinished) { return; } @@ -490,27 +513,33 @@ public class CasinoGameController { + (s.players != null ? s.players.size() : 0)); List players = (s.players != null) ? s.players : List.of(); - List community = (s.communityCards != null) ? s.communityCards : List.of(); - List myCards = getMyCards(players); - - java.util.List newCommunityKeys = cardKeys(community, TOTAL_SLOTS); - if (!newCommunityKeys.equals(lastCommunityKeys)) { - lastCommunityKeys = newCommunityKeys; - renderCommunityCards(community); + String winnerName = resolveWinnerName(s); + if (winnerName != null && !winnerName.isBlank()) { + lastWinnerName = winnerName; + } else if (!isTerminalPhase(s.phase)) { + lastWinnerName = null; } - java.util.List newMyCardKeys = cardKeys(myCards, PLAYER_SLOTS); - if (!newMyCardKeys.equals(lastMyCardKeys)) { - lastMyCardKeys = newMyCardKeys; - renderPlayerCards(myCards); + if (isGameFinishedState(s, winnerName)) { + updatePlayers(players, -1); + syncWhisperTargetsFromPlayers(players); + updateGameInfo(s); + highlightDealer(s); + updateTaskbar(s); + clearActiveTurnHighlights(); + announceLocalWinIfNeeded(s); + finishGameUiLoop(); + return; } + updateCards(s, players); + if (s.pot != lastPot) { lastPot = s.pot; renderPot(s.pot); } - updatePlayers(players); + updatePlayers(players, s.activePlayer); syncWhisperTargetsFromPlayers(players); updateGameInfo(s); highlightDealer(s); @@ -533,6 +562,32 @@ public class CasinoGameController { } } + /** + * Update the displayed community cards and the player's hole cards based on the current game + * state. + * + * @param s The current game state containing the community cards and the list of players, used + * to determine. + * @param players The list of players currently in the game, used to retrieve the player's hole + * cards for display. If null, it will be treated as an empty list. + */ + private void updateCards(GameState s, List players) { + List community = (s.communityCards != null) ? s.communityCards : List.of(); + List myCards = getMyCards(players); + + var newCommunityKeys = cardKeys(community, TOTAL_SLOTS); + if (!newCommunityKeys.equals(lastCommunityKeys)) { + lastCommunityKeys = newCommunityKeys; + renderCommunityCards(community); + } + + var newMyCardKeys = cardKeys(myCards, PLAYER_SLOTS); + if (!newMyCardKeys.equals(lastMyCardKeys)) { + lastMyCardKeys = newMyCardKeys; + renderPlayerCards(myCards); + } + } + /** * Synchronize the whisper chat targets with the current list of players. * @@ -610,14 +665,102 @@ public class CasinoGameController { */ private void updateGameInfo(GameState s) { - String text = "Phase: " + s.phase; - - if (s.winnerIndex >= 0 && s.winnerIndex < s.players.size()) { - Player winner = s.players.get(s.winnerIndex); - text = "Winner: " + winner.getName(); + if (s == null) { + tableText.setText("Phase: -"); + return; } - tableText.setText(text); + String winnerName = resolveWinnerName(s); + if (winnerName != null && !winnerName.isBlank()) { + tableText.setText("Winner: " + winnerName); + return; + } + + String firstPreflopInfo = resolveFirstPreflopInfo(s); + if (firstPreflopInfo != null) { + tableText.setText(firstPreflopInfo); + return; + } + + String phaseText = formatPhaseText(s.phase); + StringBuilder text = new StringBuilder("Phase: ").append(phaseText); + + tableText.setText(text.toString()); + } + + private String formatPhaseText(String phase) { + if (phase == null || phase.isBlank()) { + return "-"; + } + return phase.toUpperCase().replace("_", " "); + } + + /** + * Returns a context-specific info message for the first player to act preflop. + * + * @param s The current game state. + * @return An English hint with the big blind amount and its double, or null if the special case + * does not apply. + */ + private String resolveFirstPreflopInfo(GameState s) { + if (s == null || s.players == null || s.players.size() < 2) { + return null; + } + + if (!"PREFLOP".equalsIgnoreCase(s.phase)) { + return null; + } + + int firstIndex = + (s.players.size() == 2) ? s.dealer : (s.dealer + DEALER_OFFSET) % s.players.size(); + if (s.activePlayer != firstIndex) { + return null; + } + + if (!isInitialPreflopBlindLayout(s)) { + return null; + } + + int callTarget = Math.max(0, s.currentBet); + if (callTarget <= 0) { + return null; + } + + long raiseTarget = (long) callTarget * 2L; + int safeRaiseTarget = + raiseTarget > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) raiseTarget; + return String.format(FIRST_PREFLOP_INFO_TEMPLATE, callTarget, safeRaiseTarget); + } + + /** + * Checks whether the preflop state still matches the initial blind layout. + * + * @param s The current game state. + * @return true if exactly one small blind and one big blind are present and all other players + * have not yet committed chips. + */ + private boolean isInitialPreflopBlindLayout(GameState s) { + int sbCount = 0; + int bbCount = 0; + int zeroCount = 0; + + for (Player player : s.players) { + if (player == null) { + continue; + } + + int bet = Math.max(0, player.getBet()); + if (bet == SMALL_BLIND) { + sbCount++; + } else if (bet == BIG_BLIND) { + bbCount++; + } else if (bet == NO_BET) { + zeroCount++; + } + } + + int playerCount = s.players.size(); + return playerCount >= 2 && sbCount == 1 && bbCount == 1 && zeroCount == playerCount - 2; } /** @@ -644,8 +787,10 @@ public class CasinoGameController { * @param players The list of players currently in the game, used to determine which players are * opponents and update their display in the UI accordingly. If the list is null or empty, * all opponent slots will be cleared. + * @param activePlayerIndex The index of the active player in the players list, used to + * determine whose turn it is and update the UI highlights to reflect that. */ - private void updatePlayers(List players) { + private void updatePlayers(List players, int activePlayerIndex) { if (players == null || players.isEmpty()) { clearOpponentSlots(); return; @@ -671,6 +816,7 @@ public class CasinoGameController { safeRefresh(player1Controller); safeRefresh(player2Controller); safeRefresh(player3Controller); + updateActiveTurnHighlights(players, activePlayerIndex); } /** @@ -695,7 +841,7 @@ public class CasinoGameController { } PlayerId pid = p.getId(); - boolean isMe = myPlayerId != null && pid != null && pid.equals(myPlayerId); + boolean isMe = myPlayerId != null && myPlayerId.equals(pid); if (isMe) { meFound = true; @@ -767,6 +913,318 @@ public class CasinoGameController { safeRefresh(player1Controller); safeRefresh(player2Controller); safeRefresh(player3Controller); + clearActiveTurnHighlights(); + setTaskbarTurnHighlight(false); + } + + /** Clear all visual highlights in the UI that indicate the active player's turn. */ + private void clearActiveTurnHighlights() { + if (player1Controller != null) { + player1Controller.setTurnHighlighted(false); + } + + if (player2Controller != null) { + player2Controller.setTurnHighlighted(false); + } + + if (player3Controller != null) { + player3Controller.setTurnHighlighted(false); + } + + if (playerStatusController != null) { + playerStatusController.setTurnHighlighted(false); + } + + setPlayerCardsTurnHighlighted(false); + } + + /** + * Set the visual highlight state of the player's hole cards to indicate whether it is currently + * the player's turn in the game. + * + * @param highlighted true to highlight the player's hole cards, indicating that it is their + * turn, or false to remove the highlight when it is not their turn. + */ + private void setPlayerCardsTurnHighlighted(boolean highlighted) { + if (playerCardsBox == null) { + return; + } + + for (Node child : playerCardsBox.getChildren()) { + if (child instanceof ImageView) { + toggleStyleClass(child, ACTIVE_CARD_BOX_STYLE_CLASS, highlighted); + } + } + } + + /** + * Update the visual highlights in the UI to indicate which player's turn it is based on the + * active. + * + * @param players The list of players currently in the game, used to identify the active player + * and update the turn highlights accordingly. + * @param activePlayerIndex The index of the active player in the players list, used to + * determine whose turn it is and update the UI highlights to reflect that. + */ + private void updateActiveTurnHighlights(List players, int activePlayerIndex) { + clearActiveTurnHighlights(); + + Player activePlayer = getPlayerAtIndex(players, activePlayerIndex); + if (activePlayer == null || activePlayer.getId() == null) { + setTaskbarTurnHighlight(false); + return; + } + + if (myPlayerId != null && myPlayerId.equals(activePlayer.getId())) { + setPlayerCardsTurnHighlighted(true); + setTaskbarTurnHighlight(true); + return; + } + + setTaskbarTurnHighlight(false); + + if (player1Controller != null && player1Controller.hasPlayer(activePlayer)) { + player1Controller.setTurnHighlighted(true); + return; + } + + if (player2Controller != null && player2Controller.hasPlayer(activePlayer)) { + player2Controller.setTurnHighlighted(true); + return; + } + + if (player3Controller != null && player3Controller.hasPlayer(activePlayer)) { + player3Controller.setTurnHighlighted(true); + } + } + + /** + * Set the turn highlight state in the taskbar controller, which visually indicates whether it + * is the current player's turn in the game. + * + * @param highlighted true to highlight the turn in the taskbar, false to remove the highlight. + */ + private void setTaskbarTurnHighlight(boolean highlighted) { + TaskbarController taskbarCtrl = resolveTaskbarController(); + if (taskbarCtrl != null) { + taskbarCtrl.setTurnHighlighted(highlighted); + } + } + + /** + * Retrieve the Player object at the specified index from the list of players. + * + * @param players The list of players from which to retrieve the player at the specified index. + * @param index The index of the player to retrieve from the list. + * @return The Player object at the specified index if it exists and is valid, or null if the + * index is out of bounds or if the players list is null or empty. + */ + private Player getPlayerAtIndex(List players, int index) { + if (players == null || index < 0 || index >= players.size()) { + return null; + } + + return players.get(index); + } + + /** + * Resolve the winner of the hand based on the game state. + * + * @param s The current game state, which may contain information about the players, their + * states, and the winner index. + * @return The Player object representing the winner if it can be determined from the game + * state, or null if the winner cannot be resolved or if the game is still in progress. + */ + private Player resolveWinner(GameState s) { + if (s == null || s.players == null || s.players.isEmpty()) { + return null; + } + + if (s.winnerIndex >= 0 && s.winnerIndex < s.players.size()) { + return s.players.get(s.winnerIndex); + } + + Player remaining = null; + int remainingCount = 0; + for (Player player : s.players) { + if (player == null) { + continue; + } + if (player.getState() == PlayerState.FOLDED) { + continue; + } + if (remainingCount > 0) { + return null; + } + remaining = player; + remainingCount++; + } + + if (remaining != null && remainingCount == 1) { + return remaining; + } + + return null; + } + + /** + * Resolve the name of the winner based on the game state. + * + * @param s The current game state, which may contain information about the players, their + * states, and the winner index. + * @return The name of the winner if it can be determined from the game state, or null if the + * winner cannot be resolved or if the game is still in progress. + */ + private String resolveWinnerName(GameState s) { + Player winner = resolveWinner(s); + if (winner != null) { + String name = winner.getName(); + if (name != null && !name.isBlank()) { + return name; + } + } + + if (isTerminalPhase(s != null ? s.phase : null) + && lastWinnerName != null + && !lastWinnerName.isBlank()) { + return lastWinnerName; + } + + return null; + } + + /** + * Sends a short lobby chat message when the local player wins, but only once per hand. + * + * @param s The current game state. + */ + private void announceLocalWinIfNeeded(GameState s) { + if (localWinAnnouncementSent || s == null || !isTerminalPhase(s.phase)) { + return; + } + + Player winner = resolveWinner(s); + boolean isLocalWinner = + winner != null + && winner.getId() != null + && myPlayerId != null + && myPlayerId.equals(winner.getId()); + + if (!isLocalWinner && lastWinnerName != null && !lastWinnerName.isBlank()) { + String localName = + (chatUsername != null && !chatUsername.isBlank()) + ? chatUsername + : (winner != null ? winner.getName() : null); + isLocalWinner = localName != null && localName.equalsIgnoreCase(lastWinnerName); + } + + if (!isLocalWinner) { + return; + } + + localWinAnnouncementSent = true; + sendLobbyActionMessage("I won"); + } + + /** + * Determine if the game is in a finished state based on the game state and the presence of a + * winner name. + * + * @param s The current game state, which may be null or contain information about the phase, + * players, and winner index. + * @param winnerName The name of the winner, which may be null or blank if the winner is not yet + * determined or if the game is still in progress. + * @return true if the game is considered finished based on the provided state and winner + * information, false otherwise. + */ + private boolean isGameFinishedState(GameState s, String winnerName) { + if (s == null) { + return false; + } + + boolean winnerKnown = winnerName != null && !winnerName.isBlank(); + if (!winnerKnown) { + return false; + } + + if (isTerminalPhase(s.phase)) { + return true; + } + + if (s.winnerIndex >= 0 && s.players != null && s.winnerIndex < s.players.size()) { + return true; + } + + return hasSingleRemainingPlayer(s); + } + + /** + * Determine if there is only one remaining active player in the game state. + * + * @param s The current game state, which may be null or contain a list of players. + * @return true if there is exactly one active player remaining (not folded), false otherwise. + */ + private boolean hasSingleRemainingPlayer(GameState s) { + if (s == null || s.players == null || s.players.isEmpty()) { + return false; + } + + int remaining = 0; + for (Player player : s.players) { + if (player == null || player.getState() == PlayerState.FOLDED) { + continue; + } + remaining++; + if (remaining > 1) { + return false; + } + } + return remaining == 1; + } + + /** + * Determine if the current phase of the game is a terminal phase, such as "FINISHED" or + * "SHOWDOWN". + * + * @param phase The current phase of the game, which may be null or blank. + * @return true if the phase is considered terminal, indicating that the hand has ended and a + * winner can be declared, false otherwise. + */ + private boolean isTerminalPhase(String phase) { + return "FINISHED".equalsIgnoreCase(phase) || "SHOWDOWN".equalsIgnoreCase(phase); + } + + /** Finish the game UI loop by stopping the timeline that updates the UI. */ + private void finishGameUiLoop() { + if (gameFinished) { + return; + } + gameFinished = true; + if (timeline != null) { + timeline.stop(); + } + } + + /** + * Toggle a CSS style class on a JavaFX Node based on the active state. + * + * @param node The JavaFX Node on which to toggle the style class. + * @param styleClass The name of the CSS style class to toggle. + * @param active A boolean indicating whether to add (true) or remove (false) the style class + * from the node. + */ + private void toggleStyleClass(Node node, String styleClass, boolean active) { + if (node == null || styleClass == null || styleClass.isBlank()) { + return; + } + + if (active) { + if (!node.getStyleClass().contains(styleClass)) { + node.getStyleClass().add(styleClass); + } + } else { + node.getStyleClass().remove(styleClass); + } } /** @@ -1315,6 +1773,47 @@ public class CasinoGameController { if (controller != null && gameService != null && myPlayerId != null) { controller.setGameService(gameService, myPlayerId); } + configureTaskbarLobbyActionAnnouncements(); + } + + /** + * Configure the TaskbarController to announce lobby actions by sending chat messages when + * certain actions occur in the game, such as winning a hand. + */ + private void configureTaskbarLobbyActionAnnouncements() { + TaskbarController controller = resolveTaskbarController(); + if (controller == null) { + return; + } + controller.setLobbyActionAnnouncer(this::sendLobbyActionMessage); + } + + /** + * Send a lobby action message to the chat controller with the specified action label, which can + * be used to announce game events in the lobby chat. + * + * @param actionLabel The label describing the action to announce in the lobby chat, such as "I + * won" when the local player wins a hand. + */ + private void sendLobbyActionMessage(String actionLabel) { + if (chatController == null + || chatLobbyId < 0 + || actionLabel == null + || actionLabel.isBlank()) { + return; + } + + String sender = + (chatUsername != null && !chatUsername.isBlank()) + ? chatUsername + : chatController.getCurrentUsername(); + if (sender == null || sender.isBlank()) { + return; + } + + Message message = + new Message(ChatType.LOBBY, chatLobbyId, sender, null, actionLabel.trim()); + chatController.onSendToNetwork(message); } /** diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java index 72b0730..6a360a3 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java @@ -4,17 +4,24 @@ import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashSet; +import java.util.Map; import java.util.Optional; import java.util.Set; import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; +import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; import javafx.scene.control.TextField; import javafx.scene.image.Image; import javafx.scene.image.ImageView; @@ -63,6 +70,11 @@ public class CasinoBrowserController { private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class); + private static final ObservableList URL_SUGGESTIONS = + FXCollections.observableArrayList(TRUSTED_DOMAINS); + + private static ContextMenu autoCompleteMenu = new ContextMenu(); + private static final int LOGO_HEIGHT = 40; private static final int CORNER_RADIUS = 40; private static final int HBOX_SPACIN = 10; @@ -74,12 +86,15 @@ public class CasinoBrowserController { private static final int ALERT_HEIGHT = 300; private static final String LOGO_PATH = "/images/logoinverted.png"; private static final String LOGO_PATH_MAIN = "/images/logo.png"; + private static final int MAX_AUTOCOMPLETE_RESULTS = 5; static { CookieHandler.setDefault(COOKIE_MANAGER); TRUSTED_DOMAINS.add("wikipedia.org"); + TRUSTED_DOMAINS.add("youtube.com"); TRUSTED_DOMAINS.add("github.com"); + TRUSTED_DOMAINS.add("search.brave.com"); TRUSTED_DOMAINS.add("oracle.com"); TRUSTED_DOMAINS.add("stackoverflow.com"); TRUSTED_DOMAINS.add("docs.oracle.com"); @@ -94,10 +109,63 @@ public class CasinoBrowserController { TRUSTED_DOMAINS.add("metager.de"); TRUSTED_DOMAINS.add("unibas.ch"); TRUSTED_DOMAINS.add("mojeek.com"); - TRUSTED_DOMAINS.add("searx.be "); + TRUSTED_DOMAINS.add("searx.be"); TRUSTED_DOMAINS.add("startpage.com"); } + /** Aliases for common websites. */ + private static final java.util.Map ALIASES = + Map.ofEntries( + Map.entry("wiki", "wikipedia.org"), + Map.entry("yt", "youtube.com"), + Map.entry("bra", "search.brave.com"), + Map.entry("bs", "search.brave.com"), + Map.entry("gh", "github.com"), + Map.entry("google", "google.com"), + Map.entry("duck", "duckduckgo.com"), + Map.entry("bing", "bing.com"), + Map.entry("meta", "metager.de"), + Map.entry("mojeek", "mojeek.com"), + Map.entry("searx", "searx.be"), + Map.entry("startpage", "startpage.com"), + Map.entry("stack", "stackoverflow.com"), + Map.entry("so", "stackoverflow.com"), + Map.entry("oracle", "oracle.com"), + Map.entry("docs", "docs.oracle.com"), + Map.entry("mdn", "developer.mozilla.org"), + Map.entry("maven", "maven.apache.org"), + Map.entry("gradle", "gradle.org"), + Map.entry("spring", "spring.io"), + Map.entry("jetbrains", "jetbrains.com"), + Map.entry("uni", "unibas.ch")); + + /** + * Resolves user input into a valid URL. + * + * @param input User input from the URL field, which can be a full URL, a domain name, or an + * alias. + * @return A properly formatted URL string that can be loaded by the browser, or the original + * input if it cannot be resolved. + */ + private static String resolveInputToUrl(String input) { + if (input == null || input.isBlank()) { + return input; + } + + input = input.trim(); + + String alias = ALIASES.get(input.toLowerCase()); + if (alias != null) { + return "https://" + alias; + } + + if (input.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*")) { + return input; + } + + return "https://" + input; + } + private static boolean javascriptEnabled = false; /** Deletes all cookies stored during the current browser session. */ @@ -124,6 +192,15 @@ public class CasinoBrowserController { WebView webView = new WebView(); WebEngine engine = webView.getEngine(); + engine.locationProperty() + .addListener( + (obs, oldUrl, newUrl) -> { + if (isDownloadUrl(newUrl)) { + LOGGER.warn("Download navigation blocked: " + newUrl); + engine.getLoadWorker().cancel(); + } + }); + engine.setJavaScriptEnabled(false); configurePopupBlocker(engine); @@ -203,9 +280,9 @@ public class CasinoBrowserController { engine.setCreatePopupHandler( config -> { Alert alert = new Alert(Alert.AlertType.WARNING); - alert.setTitle("Popup blockiert"); + alert.setTitle("Popup blocked"); alert.setHeaderText(null); - alert.setContentText("Popup wurde aus Sicherheitsgründen blockiert."); + alert.setContentText("Casono Browser blocked a security threat."); try { var stream = CasinoBrowserController.class.getResourceAsStream(LOGO_PATH); @@ -283,9 +360,90 @@ public class CasinoBrowserController { urlField.getStyleClass().add("gray-input-field"); HBox.setHgrow(urlField, Priority.ALWAYS); + urlField.textProperty() + .addListener( + (obs, oldText, newText) -> { + if (newText == null || newText.isBlank()) { + autoCompleteMenu.hide(); + return; + } + + String input = newText.toLowerCase(); + + String aliasMatch = ALIASES.get(input); + if (aliasMatch != null) { + autoCompleteMenu.getItems().clear(); + + MenuItem item = new MenuItem(aliasMatch); + item.setOnAction( + e -> { + urlField.setText("https://" + aliasMatch); + autoCompleteMenu.hide(); + }); + + autoCompleteMenu.getItems().add(item); + autoCompleteMenu.show(urlField, javafx.geometry.Side.BOTTOM, 0, 0); + return; + } + + updateSuggestions(input, urlField); + }); + + urlField.focusedProperty() + .addListener( + (obs, oldVal, newVal) -> { + if (!newVal) { + autoCompleteMenu.hide(); + } + }); + return urlField; } + /** + * Updates the autocomplete suggestions based on the current input in the URL field. Suggestions + * are filtered from the list of trusted domains and sorted to prioritize those that start with + * the input. + * + * @param input The current text input from the URL field, used to filter and sort suggestions. + * @param urlField The TextField for the URL input, used to position the autocomplete menu and + * update its content. + */ + private static void updateSuggestions(String input, TextField urlField) { + autoCompleteMenu.getItems().clear(); + + URL_SUGGESTIONS.stream() + .filter( + domain -> + domain.toLowerCase().startsWith(input) + || domain.toLowerCase().contains(input)) + .sorted( + (a, b) -> { + boolean aStarts = a.startsWith(input); + boolean bStarts = b.startsWith(input); + return Boolean.compare(!aStarts, !bStarts); + }) + .limit(MAX_AUTOCOMPLETE_RESULTS) + .forEach( + domain -> { + MenuItem item = new MenuItem(domain); + + item.setOnAction( + e -> { + urlField.setText("https://" + domain); + autoCompleteMenu.hide(); + }); + + autoCompleteMenu.getItems().add(item); + }); + + if (!autoCompleteMenu.getItems().isEmpty()) { + autoCompleteMenu.show(urlField, javafx.geometry.Side.BOTTOM, 0, 0); + } else { + autoCompleteMenu.hide(); + } + } + /** * Security Label * @@ -305,7 +463,7 @@ public class CasinoBrowserController { * @return Toggle button */ public static Button createJsToggle(WebEngine engine) { - Button jsToggle = new Button("JS EINSCHALTEN"); + Button jsToggle = new Button("JS TURN ON"); jsToggle.getStyleClass().add("red-button"); jsToggle.setOnAction( @@ -319,7 +477,7 @@ public class CasinoBrowserController { jsToggle.getStyleClass().add("yellow-button"); } else { - jsToggle.setText("JS EINSCHALTEN"); + jsToggle.setText("JS TURN ON"); jsToggle.getStyleClass().removeAll("yellow-button"); jsToggle.getStyleClass().add("red-button"); } @@ -410,7 +568,16 @@ public class CasinoBrowserController { */ public static void configureUrlEvents( WebEngine engine, TextField urlField, Label securityLabel) { - urlField.setOnAction(e -> loadUrlSafely(engine, urlField.getText(), securityLabel)); + urlField.setOnKeyPressed( + e -> { + if (e.getCode() == KeyCode.ENTER) { + if (!autoCompleteMenu.isShowing()) { + String resolved = resolveInputToUrl(urlField.getText()); + urlField.setText(resolved); + loadUrlSafely(engine, resolved, securityLabel); + } + } + }); engine.locationProperty().addListener((obs, o, n) -> urlField.setText(n)); } @@ -468,25 +635,54 @@ public class CasinoBrowserController { */ private static void loadUrlSafely(WebEngine engine, String url, Label securityLabel) { try { - if (!url.startsWith("http")) { + if (!url.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*")) { url = "https://" + url; } URI uri = new URI(url); + String scheme = uri.getScheme(); - // HTTPS required - if (!"https".equalsIgnoreCase(uri.getScheme())) { + String fullUrl = uri.toString(); + + if (isDownloadUrl(fullUrl)) { + securityLabel.setText("BLOCKED DOWNLOAD"); + + LOGGER.warn("Download blocked: " + fullUrl); + return; + } + + if ("file".equalsIgnoreCase(scheme)) { + Path allowed = + Paths.get( + System.getProperty("user.dir"), + "documents", + "docs", + "game-engine", + "manual.html") + .normalize(); + + Path requested = Paths.get(uri).normalize(); + + if (requested.equals(allowed)) { + securityLabel.setText("LOCAL OK"); + engine.load(uri.toString()); + } else { + securityLabel.setText("BLOCKED"); + } + return; + } + + if (!"https".equalsIgnoreCase(scheme)) { securityLabel.setText("BLOCKED"); return; } String host = uri.getHost(); - if (host == null) { + if (host == null || host.isBlank()) { securityLabel.setText("ERROR"); return; } - // Secure Domain Check boolean trusted = TRUSTED_DOMAINS.stream() .anyMatch(domain -> host.equals(domain) || host.endsWith("." + domain)); @@ -500,9 +696,11 @@ public class CasinoBrowserController { } else { securityLabel.setText("SAFE"); } + engine.load(uri.toString()); + } catch (Exception e) { - securityLabel.setText("ERROR"); + securityLabel.setText("INVALID"); } } @@ -519,7 +717,7 @@ public class CasinoBrowserController { alert.setHeaderText("This website is unknown"); String content = host - + "\n\nThis site has not been verified by the Casono browser.\n" + + "\n\nThis site has not been verified by the Casono Browser.\n" + "Do you still want to open it?"; alert.setContentText(content); @@ -545,4 +743,18 @@ public class CasinoBrowserController { return true; } + + /** + * Checks if a given URL points to a downloadable file based on its extension. + * + * @param url the URL to check + * @return true if the URL is likely a download link, false otherwise + */ + private static boolean isDownloadUrl(String url) { + if (url == null) { + return false; + } + + return url.matches(".*\\.(exe|zip|dmg|msi|apk|jar|pdf)(\\?.*)?$"); + } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java index cfa2aad..48738c2 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java @@ -2,12 +2,15 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents; import ch.unibas.dmi.dbis.cs108.casono.client.game.Player; import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState; +import java.util.logging.Level; import java.util.logging.Logger; import javafx.fxml.FXML; import javafx.scene.control.Label; import javafx.scene.image.Image; import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; +import javafx.scene.layout.VBox; /** * Controller for displaying a player's status in the poker game, including their name, chip count @@ -21,11 +24,16 @@ public class PlayerStatusController { @FXML private Label playerMoney; @FXML private ImageView dealerIcon; @FXML private Pane parent; + @FXML private VBox playerStatusBox; + @FXML private HBox statusInnerBoxTop; + @FXML private HBox statusInnerBoxBottom; private Image dealerImage; private Player player; - private static final String DEALER_IMAGE_PATH = "/images/chip-dealer-blue-3.png"; + private boolean turnHighlighted; + private static final String DEALER_IMAGE_PATH = "/images/chip-dealer-blue-5.png"; private static final double DEALER_ICON_X_FACTOR = 0.8; + private static final String TURN_HIGHLIGHT_STYLE_CLASS = "player-status-active-turn"; /** Initialize the controller, load dealer image, and set up bindings. */ @FXML @@ -42,8 +50,7 @@ public class PlayerStatusController { } } catch (Exception e) { - LOGGER.severe("Error: loading dealer image:"); - e.printStackTrace(); + LOGGER.log(Level.SEVERE, "Error loading dealer image", e); } dealerIcon.layoutXProperty().bind(parent.widthProperty().multiply(DEALER_ICON_X_FACTOR)); @@ -57,13 +64,20 @@ public class PlayerStatusController { */ public void setPlayer(Player player) { this.player = player; + if (player == null) { + turnHighlighted = false; + } refresh(); + updateTurnHighlightStyle(); } /** Refresh UI safely */ public void refresh() { if (player == null) { + playerName.setText("-"); + playerMoney.setText("0 $"); + dealerIcon.setVisible(false); return; } @@ -84,6 +98,33 @@ public class PlayerStatusController { } } + /** + * Highlight or unhighlight this player slot as the currently active turn. + * + * @param highlighted true if this slot should be highlighted, false otherwise. + */ + public void setTurnHighlighted(boolean highlighted) { + this.turnHighlighted = highlighted; + updateTurnHighlightStyle(); + } + + /** + * Check whether the controller is currently bound to the provided player. + * + * @param candidate the player to compare against. + * @return true if both represent the same player. + */ + public boolean hasPlayer(Player candidate) { + if (player == null + || candidate == null + || player.getId() == null + || candidate.getId() == null) { + return false; + } + + return player.getId().equals(candidate.getId()); + } + /** * External quick update * @@ -107,4 +148,26 @@ public class PlayerStatusController { dealerIcon.setImage(dealerImage); } } + + /** + * Update the visual style of the player status box to indicate whether it's currently this + * player's turn. + */ + private void updateTurnHighlightStyle() { + if (statusInnerBoxTop == null || statusInnerBoxBottom == null) { + return; + } + + if (turnHighlighted) { + if (!statusInnerBoxTop.getStyleClass().contains(TURN_HIGHLIGHT_STYLE_CLASS)) { + statusInnerBoxTop.getStyleClass().add(TURN_HIGHLIGHT_STYLE_CLASS); + } + if (!statusInnerBoxBottom.getStyleClass().contains(TURN_HIGHLIGHT_STYLE_CLASS)) { + statusInnerBoxBottom.getStyleClass().add(TURN_HIGHLIGHT_STYLE_CLASS); + } + } else { + statusInnerBoxTop.getStyleClass().remove(TURN_HIGHLIGHT_STYLE_CLASS); + statusInnerBoxBottom.getStyleClass().remove(TURN_HIGHLIGHT_STYLE_CLASS); + } + } } 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 ca3dadd..f393cb1 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,11 +8,22 @@ 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 java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; import javafx.scene.control.Label; import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; @@ -59,8 +70,16 @@ public class TaskbarController { private static final String STYLE_RED_INPUT = "red-input-field"; private static final double SCALE_NORMAL = 1.0; private static final int DEALER_OFFSET = 3; + private static final String LOGO_PATH = "/images/logoinverted.png"; + private static final String LOGO_PATH_MAIN = "/images/logo.png"; + private static final double ALERT_LOGO_HEIGHT = 40.0; private boolean inputActionAllowed; private int lastReferenceBet = BIG_BLIND; + private Consumer lobbyActionAnnouncer; + private String lastPhase; + private static final int FIRST_PLAYER_MAX_BET = 3000; + private static final int MAX_INPUT_LENGTH = 6; + private static final double MAX_CHIP_PERCENT = 0.30; /** Standard constructor. Used by FXML. */ public TaskbarController() { @@ -213,6 +232,11 @@ public class TaskbarController { this.myPlayerId = myPlayerId; } + /** Sets an optional callback that publishes simple action labels to lobby chat. */ + public void setLobbyActionAnnouncer(Consumer lobbyActionAnnouncer) { + this.lobbyActionAnnouncer = lobbyActionAnnouncer; + } + /** * Initializes the taskbar controller. This method is called automatically after the FXML * components have been loaded. @@ -220,15 +244,102 @@ public class TaskbarController { @FXML public void initialize() { updateBasicButtons(false, false); + if (taskbarInput != null) { + + UnaryOperator filter = + change -> { + String newText = change.getControlNewText(); + + if (newText.length() > MAX_INPUT_LENGTH) { + return null; + } + + if (!newText.matches("\\d*")) { + return null; + } + + return change; + }; + + taskbarInput.setTextFormatter(new TextFormatter<>(filter)); + taskbarInput .textProperty() .addListener((obs, oldValue, newValue) -> refreshBetInputUi()); } + Tooltip callTooltip = new Tooltip(); + callButton.setTooltip(callTooltip); + callButton.setOnMouseEntered(e -> callTooltip.setText(previewCall())); + + Tooltip raiseTooltip = new Tooltip(); + raiseButton.setTooltip(raiseTooltip); + raiseButton.setOnMouseEntered(e -> raiseTooltip.setText(previewRaise())); + setBetButtonVisible(false); } + /** + * Generates a preview string for the Call action, showing the amount that would be called based + * on the current game state and the player's bet status. + * + * @return A string representing the Call action preview, including the amount that would be + * called, or an empty string if the game state is not available. + */ + private String previewCall() { + if (lastState == null) { + return ""; + } + + int callTarget = effectiveCallTarget(lastState); + Player me = findCurrentPlayer(lastState); + + int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + int amount = Math.max(0, callTarget - alreadyInvested); + + return "CALL " + amount; + } + + /** + * Generates a preview string for the Raise action, showing the amount that would be raised. + * + * @return A string representing the Raise action preview, including the amount that would be + * raised. + */ + private String previewRaise() { + if (lastState == null) { + return ""; + } + + // int callTarget = effectiveCallTarget(lastState); + // Player me = findCurrentPlayer(lastState); + + // int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + + // Integer targetBet = resolveTargetBet(ActionType.RAISE_BUTTON, lastState); + // if (targetBet == null) { + // return ""; + // } + + // int totalContribution = Math.max(0, targetBet); + + GameState state = ensureLatestStateForAction(ActionType.CALL_BUTTON.name().toLowerCase()); + + // int raiseBy = Math.max(0, targetBet); + int raiseBy = effectiveCallTarget(state); + + int callTarget = effectiveCallTarget(lastState); + Player me = findCurrentPlayer(lastState); + + int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + int amount = Math.max(0, callTarget - alreadyInvested); + + int raiseamout = amount + raiseBy; + + return "RAISE +" + raiseBy + " TO " + raiseamout; + } + /** * Updates the taskbar based on the current game state and the player's status. * @@ -248,6 +359,7 @@ public class TaskbarController { } this.lastState = state; + if (state.currentBet > 0) { lastReferenceBet = state.currentBet; } @@ -269,8 +381,14 @@ public class TaskbarController { boolean isOut = me.getState() == PlayerState.FOLDED; boolean isGameFinished = isHandFinished(state); + boolean phaseChanged = lastPhase == null || !lastPhase.equalsIgnoreCase(state.phase); + + lastPhase = state.phase; + updateBasicButtons(isMyTurn, isOut || isGameFinished); - applyActionAvailability(state, me, isMyTurn, isOut || isGameFinished); + + applyActionAvailability(state, me, isMyTurn, isOut || isGameFinished, phaseChanged); + setMoney(me.getChips()); refreshBetInputUi(); } @@ -287,7 +405,14 @@ public class TaskbarController { * if the game is finished, which disables actions. */ private void applyActionAvailability( - GameState state, Player me, boolean isMyTurn, boolean isOutOrFinished) { + GameState state, + Player me, + boolean isMyTurn, + boolean isOutOrFinished, + boolean phaseChanged) { + + // boolean firstPlayerNewRound = + // phaseChanged && isFirstPreflopPlayerInputOnly(state, me); inputActionAllowed = false; if (!isMyTurn || isOutOrFinished || state == null || me == null) { setBetButtonVisible(false); @@ -308,7 +433,17 @@ public class TaskbarController { return; } - if (isFirstPreflopPlayerInputOnly(state, me)) { + // if (isFirstPreflopPlayerInputOnly(state, me)) { + // setActionEnabled(betButton, true); + // setActionEnabled(callButton, false); + // setActionEnabled(foldButton, false); + // setActionEnabled(raiseButton, false); + // activateInputField(taskbarInput); + // inputActionAllowed = true; + // return; + // } + + if (isFirstPlayerOfPhase(state, me)) { setActionEnabled(betButton, true); setActionEnabled(callButton, false); setActionEnabled(foldButton, false); @@ -353,7 +488,16 @@ public class TaskbarController { if (value > Integer.MAX_VALUE / 2) { return Integer.MAX_VALUE; } - return value * 2; + + int callTarget = effectiveCallTarget(lastState); + Player me = findCurrentPlayer(lastState); + + int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + int amount = Math.max(0, callTarget - alreadyInvested); + + int raisevalue = amount + value; + + return raisevalue; } /** @@ -431,10 +575,22 @@ public class TaskbarController { * @return An integer representing the effective call target for the current game state. */ private int effectiveCallTarget(GameState state) { - if (state != null && state.currentBet > 0) { - return state.currentBet; + int currentBet = (state != null) ? Math.max(0, state.currentBet) : 0; + int rememberedBet = Math.max(0, lastReferenceBet); + + if (state != null && isPreflop(state.phase) && isInitialPreflopBlindLayout(state)) { + return currentBet; } - return Math.max(0, lastReferenceBet); + + if (currentBet <= 0) { + return rememberedBet; + } + + if (rememberedBet <= 0) { + return currentBet; + } + + return Math.max(currentBet, rememberedBet); } /** @@ -473,6 +629,46 @@ public class TaskbarController { return isInitialPreflopBlindLayout(state); } + /** + * Checks if the current player is the first to act in the current phase of the game, based on + * the dealer position and the active player index. + * + * @param state The current GameState object representing the state of the game, which includes + * information about + * @param me The Player object representing the current player, used to determine their + * position. + * @return A boolean value indicating whether the current player is the first to act in the + * current phase of the game. + */ + private boolean isFirstPlayerOfPhase(GameState state, Player me) { + + if (state == null || me == null || state.players == null) { + return false; + } + + int size = state.players.size(); + if (size < 2) { + return false; + } + + int myIndex = state.players.indexOf(me); + if (myIndex < 0) { + return false; + } + + int dealer = state.dealer; + + int firstIndex; + + if (isPreflop(state.phase)) { + firstIndex = (size == 2) ? dealer : (dealer + DEALER_OFFSET) % size; + } else { + firstIndex = (dealer + 1) % size; + } + + return state.activePlayer == firstIndex && myIndex == firstIndex; + } + /** * Checks if the initial blind layout is still in place during the pre-flop phase. * @@ -482,6 +678,10 @@ public class TaskbarController { * the pre-flop phase. */ private boolean isInitialPreflopBlindLayout(GameState state) { + if (state == null || state.players == null || state.players.isEmpty()) { + return false; + } + int sbCount = 0; int bbCount = 0; int zeroCount = 0; @@ -618,57 +818,31 @@ public class TaskbarController { } GameState state = ensureLatestStateForAction(actionType.name().toLowerCase()); - if (state == null) { - return; - } - - if (isHandFinished(state)) { - LOGGER.info("Action {} ignored: hand is already finished", actionType); - update(state, myPlayerId); + if (state == null || isHandFinished(state)) { + if (state != null) { + LOGGER.info("Action {} ignored: hand finished", actionType); + update(state, myPlayerId); + } return; } Player me = findCurrentPlayer(state); if (me == null) { - LOGGER.error("Action {} blocked: player missing in state", actionType); + LOGGER.error("Action {} blocked: player missing", actionType); return; } - int callTarget = effectiveCallTarget(state); - int targetBet; - if (actionType == ActionType.CALL_BUTTON) { - targetBet = callTarget; - } else if (actionType == ActionType.RAISE_BUTTON) { - targetBet = safeDouble(callTarget); - } else { - Integer fromInput = parseInputTarget(taskbarInput.getText()); - if (fromInput == null) { - return; - } - targetBet = fromInput; + Integer targetBet = resolveTargetBet(actionType, state); + if (targetBet == null) { + return; } ValidationResult validation = validateTarget(state, me, targetBet); - if (!validation.valid) { - refreshBetInputUi(); + if (!handleValidation(validation, actionType)) { return; } - if (validation.warning) { - showWarningDialog("WARNING", validation.message); - } - - if (state.currentBet <= 0) { - gameService.bet(targetBet); - LOGGER.info("Player BET (target={})", targetBet); - } else if (targetBet == callTarget) { - gameService.call(); - LOGGER.info("Player CALL (target={})", targetBet); - } else { - int raiseBy = Math.max(0, targetBet - state.currentBet); - gameService.raise(raiseBy); - LOGGER.info("Player RAISE by {} (target={})", raiseBy, targetBet); - } + executeAction(state, targetBet, actionType); if (targetBet > 0) { lastReferenceBet = targetBet; @@ -678,6 +852,148 @@ public class TaskbarController { refreshGame(); } + /** + * Resolves the target bet amount based on the type of action being submitted and the current + * game state. + * + * @param actionType The type of action being submitted, which determines how the target bet is + * calculated. + * @param state The current GameState object representing the state of the game, which is used + * to determine the effective call target and to validate the input for the bet amount. + * @return An Integer representing the resolved target bet amount for the action being + * submitted, or null if the input is invalid or cannot be resolved based on the action type + * and game state. + */ + private Integer resolveTargetBet(ActionType actionType, GameState state) { + int callTarget = effectiveCallTarget(state); + + if (actionType == ActionType.CALL_BUTTON) { + return callTarget; + } + + if (actionType == ActionType.RAISE_BUTTON) { + Integer input = parseInputTarget(taskbarInput.getText()); + return input != null ? input : callTarget + BIG_BLIND; + } + + return parseInputTarget(taskbarInput.getText()); + } + + /** + * Handles the validation result of a proposed action, including displaying warnings and + * refreshing the UI if the action is blocked or cancelled. + * + * @param validation The ValidationResult object representing the outcome of validating the + * proposed action, which includes whether the action is valid, if it triggers a warning, + * and any associated messages. + * @param actionType The type of action being processed, used for logging purposes to indicate + * which action is being validated and potentially blocked or cancelled. + * @return A boolean value indicating whether the action is valid and can proceed (true) or if + * it is blocked or cancelled due to validation failure or user cancellation after a warning + * (false). + */ + private boolean handleValidation(ValidationResult validation, ActionType actionType) { + if (!validation.valid) { + refreshBetInputUi(); + return false; + } + + if (validation.warning) { + boolean confirmed = showWarningDialog("WARNING", validation.message); + if (!confirmed) { + LOGGER.info("Action {} cancelled after warning", actionType); + refreshBetInputUi(); + return false; + } + } + return true; + } + + /** + * Executes the player action by sending the appropriate command to the GameService based on the + * target bet and the current game state. + * + * @param state The current GameState object representing the state of the game, which includes + * information about the current bet and the player's status, used to determine how to + * execute the action based on the target bet. + * @param targetBet The integer value representing the target bet amount for the action being + * executed, which is used to determine + */ + private void executeAction(GameState state, int targetBet, ActionType actionType) { + int callTarget = effectiveCallTarget(state); + String phase = (state != null ? state.phase : "UNKNOWN"); + + int alreadyInvested = + (findCurrentPlayer(state) != null) + ? Math.max(0, findCurrentPlayer(state).getBet()) + : 0; + + if (actionType == ActionType.CALL_BUTTON) { + + int amount = Math.max(0, callTarget - alreadyInvested); + + gameService.call(); + + LOGGER.info("[{}] CALL +{} (total to {})", phase, amount, callTarget); + announceLobbyAction("CALL " + amount); + + return; + } + + if (actionType == ActionType.RAISE_BUTTON) { + + int raiseBy = Math.max(0, targetBet - callTarget); + + gameService.raise(raiseBy); + + LOGGER.info("[{}] RAISE +{} (target={})", phase, raiseBy, targetBet); + announceLobbyAction("RAISE +" + raiseBy + " TO " + targetBet); + + return; + } + + if (state.currentBet <= 0) { + + gameService.bet(targetBet); + + LOGGER.info("[{}] BET {}", phase, targetBet); + announceLobbyAction("BET " + targetBet); + + return; + } + + if (targetBet == callTarget) { + + int amount = Math.max(0, callTarget - alreadyInvested); + + gameService.call(); + + LOGGER.info("[{}] CALL +{} (total to {})", phase, amount, callTarget); + announceLobbyAction("CALL " + amount); + + } else { + + int raiseBy = Math.max(0, targetBet - callTarget); + + gameService.raise(raiseBy); + + LOGGER.info("[{}] RAISE +{} (target={})", phase, raiseBy, targetBet); + announceLobbyAction("RAISE +" + raiseBy + " TO " + targetBet); + } + } + + /** Publishes a simple action label to the lobby chat, if a publisher is configured. */ + private void announceLobbyAction(String actionLabel) { + if (lobbyActionAnnouncer == null || actionLabel == null || actionLabel.isBlank()) { + return; + } + try { + lobbyActionAnnouncer.accept(actionLabel.trim()); + } catch (RuntimeException ex) { + LOGGER.warn("Could not publish lobby action '{}': {}", actionLabel, ex.getMessage()); + } + } + /** * Ensures that the latest game state is available for processing a player action. If the last * known state is null, it attempts to fetch the current state from the GameService. @@ -691,8 +1007,15 @@ public class TaskbarController { if (text == null || text.trim().isEmpty()) { return null; } + try { - return Integer.parseInt(text.trim()); + int value = Integer.parseInt(text.trim()); + + if (value % SMALL_BLIND != 0) { + return null; + } + + return value; } catch (NumberFormatException e) { return null; } @@ -737,21 +1060,44 @@ public class TaskbarController { * triggers a warning, or if it is blocked due to being invalid or too risky. */ private ValidationResult validateTarget(GameState state, Player me, int targetBet) { + if (state == null || me == null) { return ValidationResult.blocked("Invalid game state"); } - int callTarget = effectiveCallTarget(state); - int raiseTarget = safeDouble(callTarget); - if (targetBet != callTarget && targetBet != raiseTarget) { - return ValidationResult.blocked("Only calls or exactly two raises allowed"); + final int callTarget = effectiveCallTarget(state); + + final int alreadyInvested = Math.max(0, me.getBet()); + final int chips = Math.max(0, me.getChips()); + + final int required = Math.max(0, targetBet - alreadyInvested); + + final int minRequiredToCall = Math.max(0, callTarget - alreadyInvested); + + if (required < minRequiredToCall) { + return ValidationResult.blocked("Bet must match at least the call amount"); } - int alreadyInvested = Math.max(0, me.getBet()); - int required = Math.max(0, targetBet - alreadyInvested); - int chips = Math.max(0, me.getChips()); + if (isFirstPlayerOfPhase(state, me)) { + if (required > FIRST_PLAYER_MAX_BET) { + return ValidationResult.blocked( + "First player cannot bet more than " + FIRST_PLAYER_MAX_BET); + } + } - if (targetBet == callTarget && required == 0) { + if (isPreflop(state.phase)) { + int maxAllowed = (int) Math.floor(me.getChips() * MAX_CHIP_PERCENT); + + if (required > maxAllowed) { + return ValidationResult.blocked( + "Preflop: You can only bet up to 30% of your stack (" + maxAllowed + ")"); + } + } + + if (required == minRequiredToCall) { + if (required > chips) { + return ValidationResult.blocked("Not enough chips to call"); + } return ValidationResult.ok(); } @@ -760,16 +1106,19 @@ public class TaskbarController { } if (required > chips) { - return ValidationResult.blocked("Not enough chips for the next bet"); + return ValidationResult.blocked("Not enough chips for this bet"); } BetRisk risk = evaluateBetRisk(state, required, chips); + if (risk == BetRisk.BLOCKED) { - return ValidationResult.blocked("Assignment for this phase is blocked"); + return ValidationResult.blocked("Bet too risky for this phase"); } + if (risk == BetRisk.WARNING) { return ValidationResult.warning(warningTextForPhase(state)); } + return ValidationResult.ok(); } @@ -849,12 +1198,50 @@ public class TaskbarController { * @param content The string representing the content of the warning message, which is displayed * in the body of the dialog. */ - private void showWarningDialog(String title, String content) { + private boolean showWarningDialog(String title, String content) { Alert alert = new Alert(Alert.AlertType.WARNING); alert.setTitle(title); alert.setHeaderText(null); alert.setContentText(content); - alert.showAndWait(); + ButtonType proceedButton = new ButtonType("CONTINUE"); + ButtonType cancelButton = new ButtonType("CANCEL", ButtonBar.ButtonData.CANCEL_CLOSE); + alert.getButtonTypes().setAll(proceedButton, cancelButton); + applyAlertBranding(alert); + Optional result = alert.showAndWait(); + return result.isPresent() && result.get() == proceedButton; + } + + /** Applies Casono icon and logo to alert dialogs when resources are available. */ + private void applyAlertBranding(Alert alert) { + if (alert == null) { + return; + } + + try (var iconStream = TaskbarController.class.getResourceAsStream(LOGO_PATH); + var mainLogoStream = TaskbarController.class.getResourceAsStream(LOGO_PATH_MAIN)) { + if (iconStream != null) { + Image icon = new Image(iconStream); + if (!icon.isError() + && alert.getDialogPane() != null + && alert.getDialogPane().getScene() != null + && alert.getDialogPane().getScene().getWindow() + instanceof javafx.stage.Stage stage) { + stage.getIcons().add(icon); + } + } + + if (mainLogoStream != null) { + Image mainLogo = new Image(mainLogoStream); + if (!mainLogo.isError()) { + ImageView logoView = new ImageView(mainLogo); + logoView.setFitHeight(ALERT_LOGO_HEIGHT); + logoView.setPreserveRatio(true); + alert.setGraphic(logoView); + } + } + } catch (Exception e) { + LOGGER.warn("Could not load alert logo resources: {}", e.getMessage()); + } } /** @@ -865,6 +1252,9 @@ public class TaskbarController { */ @FXML private void onTaskbarPressed(MouseEvent event) { + if (taskbar == null || event == null) { + return; + } xOffset = event.getSceneX() - taskbar.getLayoutX(); yOffset = event.getSceneY() - taskbar.getLayoutY(); } @@ -873,17 +1263,25 @@ public class TaskbarController { * Called while dragging the taskbar with the mouse. Updates the position and slightly scales * the taskbar for visual feedback. * - *

    TODO: It still needs to be fixed that the taskbar cannot disappear out of the window. - * * @param event Das Mausereignis */ @FXML private void onTaskbarDragged(MouseEvent event) { - taskbar.setLayoutX(event.getSceneX() - xOffset); - taskbar.setLayoutY(event.getSceneY() - yOffset); + if (taskbar == null || event == null || taskbar.getScene() == null) { + return; + } taskbar.setScaleX(TASKBAR_SCALE); taskbar.setScaleY(TASKBAR_SCALE); + + double targetX = event.getSceneX() - xOffset; + double targetY = event.getSceneY() - yOffset; + + double maxX = Math.max(0, taskbar.getScene().getWidth() - scaledNodeWidth()); + double maxY = Math.max(0, taskbar.getScene().getHeight() - scaledNodeHeight()); + + taskbar.setLayoutX(clamp(targetX, 0, maxX)); + taskbar.setLayoutY(clamp(targetY, 0, maxY)); } /** @@ -894,8 +1292,62 @@ public class TaskbarController { */ @FXML private void onTaskbarReleased(MouseEvent event) { + if (taskbar == null) { + return; + } taskbar.setScaleX(SCALE_NORMAL); taskbar.setScaleY(SCALE_NORMAL); + + if (taskbar.getScene() == null) { + return; + } + + double maxX = Math.max(0, taskbar.getScene().getWidth() - scaledNodeWidth()); + double maxY = Math.max(0, taskbar.getScene().getHeight() - scaledNodeHeight()); + + taskbar.setLayoutX(clamp(taskbar.getLayoutX(), 0, maxX)); + taskbar.setLayoutY(clamp(taskbar.getLayoutY(), 0, maxY)); + } + + /** + * Calculates the scaled width of the taskbar node based on its current bounds and scale factor. + * + * @return The scaled width of the taskbar node, ensuring it is non-negative and accounts for + * the current scale applied to the node. + */ + private double scaledNodeWidth() { + double width = taskbar.getBoundsInLocal().getWidth(); + if (width <= 0) { + width = taskbar.prefWidth(-1); + } + return Math.max(0, width * taskbar.getScaleX()); + } + + /** + * Calculates the scaled height of the taskbar node based on its current bounds and scale + * factor. + * + * @return The scaled height of the taskbar node, ensuring it is non-negative and accounts for + * the current scale applied to the node. + */ + private double scaledNodeHeight() { + double height = taskbar.getBoundsInLocal().getHeight(); + if (height <= 0) { + height = taskbar.prefHeight(-1); + } + return Math.max(0, height * taskbar.getScaleY()); + } + + /** + * Clamps a value between a minimum and maximum bound. + * + * @param value the value to clamp + * @param min the lower bound + * @param max the upper bound + * @return the clamped value in the range [min, max] + */ + private double clamp(double value, double min, double max) { + return Math.max(min, Math.min(value, max)); } /** @@ -928,7 +1380,7 @@ public class TaskbarController { /** Called when the Call button is clicked. */ @FXML private void onInputPlayerCall() { - submitPresetInputAndProcess("call"); + submitAction(ActionType.CALL_BUTTON); } /** Called when the Fold button is clicked. */ @@ -949,6 +1401,7 @@ public class TaskbarController { gameService.fold(); LOGGER.info("Player FOLD"); + announceLobbyAction("FOLD"); refreshGame(); } @@ -1062,6 +1515,9 @@ public class TaskbarController { GameState refreshed = gameService.refresh(); if (refreshed != null) { lastState = refreshed; + if (refreshed.currentBet > 0 && refreshed.currentBet >= lastReferenceBet) { + lastReferenceBet = refreshed.currentBet; + } return refreshed; } } catch (Exception e) { @@ -1074,12 +1530,24 @@ public class TaskbarController { /** * Opens the integrated Casono web browser. * - *

    TODO: Replace the start URL with the official project website (e.g., Tips & Tricks page) - * once the content for strategies and support is available. + *

    once the content for strategies and support is available. */ @FXML private void onBrowserButtonClick() { - CasinoBrowserController.open("wikipedia.org"); + try { + Path path = + Paths.get( + System.getProperty("user.dir"), + "documents", + "docs", + "game-engine", + "manual.html"); + + CasinoBrowserController.open(path.toUri().toString()); + + } catch (Exception e) { + e.printStackTrace(); + } } /** Opens the highscore popup window from the taskbar. */ @@ -1092,6 +1560,7 @@ public class TaskbarController { alert.setTitle("Info"); alert.setHeaderText(null); alert.setContentText("No active server connection for highscores."); + applyAlertBranding(alert); alert.showAndWait(); return; } @@ -1124,4 +1593,23 @@ public class TaskbarController { LOGGER.error("Could not open highscore window from taskbar: {}", e.getMessage()); } } + + /** + * Highlight or unhighlight the taskbar when it is the player's turn. + * + * @param highlighted true to highlight, false to remove highlight + */ + public void setTurnHighlighted(boolean highlighted) { + if (taskbar == null) { + return; + } + + if (highlighted) { + if (!taskbar.getStyleClass().contains("player-active-turn")) { + taskbar.getStyleClass().add("player-active-turn"); + } + } else { + taskbar.getStyleClass().remove("player-active-turn"); + } + } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateHandler.java index d9fb584..c0d00b9 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateHandler.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateHandler.java @@ -10,9 +10,16 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; /** Handler for GET_GAME_STATE: returns pot, phase, community cards and per-player info. */ public class GetGameStateHandler extends CommandHandler { + private static final Logger LOGGER = Logger.getLogger(GetGameStateHandler.class.getName()); + private static final int FINISHED_CLEANUP_DELAY_SECONDS = 2; + private final LobbyManager lobbyManager; private final UserRegistry userRegistry; @@ -73,11 +80,12 @@ public class GetGameStateHandler extends CommandHandler { return; } - if (game.getState().getPhase() == GamePhase.FINISHED) { - cleanupLobby(lobby, lobbyId); - } - responseDispatcher.dispatch(new GetGameStateResponse(request.getContext(), game, username)); + + if (game.getState().getPhase() == GamePhase.FINISHED) { + CompletableFuture.delayedExecutor(FINISHED_CLEANUP_DELAY_SECONDS, TimeUnit.SECONDS) + .execute(() -> cleanupLobby(lobby, lobbyId)); + } } /** @@ -85,15 +93,20 @@ public class GetGameStateHandler extends CommandHandler { * when the game reaches FINISHED phase. */ private void cleanupLobby(Lobby lobby, LobbyId lobbyId) { + if (lobby == null || lobbyId == null) { + return; + } + try { - // Remove all players from the lobby for (String playerName : lobby.getPlayerNames()) { lobbyManager.removePlayer(playerName); } - // Reset the game controller so the lobby returns to CREATED state lobby.initGame(null); } catch (RuntimeException e) { - // Log silently to avoid disrupting game state response + LOGGER.log( + Level.WARNING, + "Failed to cleanup lobby " + lobbyId + " during FINISHED state", + e); } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java index 795a513..f55fd36 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java @@ -69,6 +69,10 @@ public class GetGameStateResponse extends SuccessResponse { return; } + if (!state.tryMarkWinnerPersistedForHand()) { + return; + } + Player winner = findPlayerByIndex(state, winnerIndex); if (winner == null || winner.getId() == null || winner.getId().value() == null) { return; diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/state/GameState.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/state/GameState.java index 4cff068..ddcad80 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/state/GameState.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/state/GameState.java @@ -35,6 +35,7 @@ public class GameState { private int currentPlayerIndex; private boolean handActive; private boolean allowOutOfTurn; + private boolean winnerPersistedForHand; private GamePhase phase; private int dealerIndex; @@ -551,6 +552,7 @@ public class GameState { */ public void startNewHand() { handActive = true; + resetWinnerPersistedForHand(); phase = GamePhase.PREFLOP; pot = new Pot(); @@ -575,6 +577,24 @@ public class GameState { setCurrentPlayerToPreflopFirstToAct(); } + /** + * Marks the winner as already persisted for the current hand. + * + * @return true exactly once per hand; false on all subsequent calls. + */ + public synchronized boolean tryMarkWinnerPersistedForHand() { + if (winnerPersistedForHand) { + return false; + } + winnerPersistedForHand = true; + return true; + } + + /** Resets the winner persistence guard for a new hand. */ + public synchronized void resetWinnerPersistedForHand() { + winnerPersistedForHand = false; + } + public void foldPlayer(PlayerId playerId) { getPlayer(playerId).setFolded(true); } diff --git a/src/main/resources/images/card-background-3.png b/src/main/resources/images/card-background-3.png index beb9f82..374f856 100644 Binary files a/src/main/resources/images/card-background-3.png and b/src/main/resources/images/card-background-3.png differ diff --git a/src/main/resources/images/chip-dealer-blue-5.png b/src/main/resources/images/chip-dealer-blue-5.png new file mode 100644 index 0000000..3a25130 Binary files /dev/null and b/src/main/resources/images/chip-dealer-blue-5.png differ diff --git a/src/main/resources/ui-structure/Casinogameui.css b/src/main/resources/ui-structure/Casinogameui.css index ff0c5d5..4ac89cc 100644 --- a/src/main/resources/ui-structure/Casinogameui.css +++ b/src/main/resources/ui-structure/Casinogameui.css @@ -48,7 +48,7 @@ } .taskbar { - -fx-background-color: rgba(13, 158, 59); + -fx-background-color: rgb(13, 158, 59); -fx-background-radius: 23; -fx-border-radius: 20; -fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10; @@ -56,13 +56,17 @@ -fx-padding: 10 25; -fx-cursor: move; -fx-effect: dropshadow(one-pass-box, rgba(0,0,0,0.8), 0, 0, 5, 5); - transition: all 0.2s ease-in-out; } .taskbar:hover { -fx-border-color: #ffffff; } +.taskbar.player-active-turn { + /* Active turn should only change the shadow to avoid flicker/transparency jumps. */ + -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5); +} + .black-input-field { -fx-background-color: #1a1a1a; -fx-text-fill: #ffffff; @@ -229,6 +233,13 @@ -fx-opacity: 1; } +.player-card.player-cards-active-turn { + -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5); +} + +.dealer-box.player-cards-active-turn { + -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5); +} .chips-icon { -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); @@ -243,6 +254,10 @@ -fx-padding: 10; } +/*.player-cards-box.player-cards-active-turn {*/ +/* -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);*/ +/*}*/ + .money-value { -fx-text-fill: #ffffff; -fx-font-size: 18px; @@ -297,6 +312,14 @@ -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); } +.status-inner-box-top.player-status-active-turn { + -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5); +} + +.status-inner-box-bottom.player-status-active-turn { + -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5); +} + .status-label-small { -fx-text-fill: #ffffff; -fx-font-size: 18px; @@ -369,3 +392,24 @@ -fx-font-weight: bold; -fx-font-size: 16px; } + +.context-menu { + -fx-background-color: #333333; + -fx-text-fill: #ffffff; + -fx-font-family: "Courier New"; + -fx-font-weight: bold; + -fx-background-radius: 8; + -fx-border-radius: 8; + -fx-border-color: #333333; + -fx-color-color: #cccccc; + -fx-border-width: 2; + -fx-padding: 5 12; +} + +.context-menu .menu-item { + -fx-background-color: #333333; +} + +.context-menu .menu-item:focused { + -fx-background-color: #333333; +} diff --git a/src/main/resources/ui-structure/Casinogameui.fxml b/src/main/resources/ui-structure/Casinogameui.fxml index 6e4369f..e225aa1 100644 --- a/src/main/resources/ui-structure/Casinogameui.fxml +++ b/src/main/resources/ui-structure/Casinogameui.fxml @@ -67,7 +67,7 @@