Feat: New Game UI Features:

- Call, Raise, Fold, Bet and Winner action are displayed in the Lobby Chat.
- More security features in the Game UI
- The Game UI is now even more Texas Hold'em friendly
This commit is contained in:
Julian Kropff
2026-04-26 16:58:23 +02:00
parent 8ca41c5ab4
commit fb68ecc777
2 changed files with 414 additions and 51 deletions
@@ -1,6 +1,8 @@
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;
@@ -142,14 +144,18 @@ public class CasinoGameController {
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 =
"Set the big blind amount: %d$ or %d$";
"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() {
@@ -201,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. */
@@ -217,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");
@@ -270,6 +284,7 @@ public class CasinoGameController {
this.chatUsername = username;
this.chatClientService = clientService;
this.chatLobbyId = lobbyId;
configureTaskbarLobbyActionAnnouncements();
}
/**
@@ -512,6 +527,7 @@ public class CasinoGameController {
highlightDealer(s);
updateTaskbar(s);
clearActiveTurnHighlights();
announceLocalWinIfNeeded(s);
finishGameUiLoop();
return;
}
@@ -683,11 +699,11 @@ public class CasinoGameController {
* 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.
* @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 || myPlayerId == null || s.players == null || s.players.size() < 2) {
if (s == null || s.players == null || s.players.size() < 2) {
return null;
}
@@ -695,22 +711,9 @@ public class CasinoGameController {
return null;
}
int myIndex = -1;
for (int i = 0; i < s.players.size(); i++) {
Player player = s.players.get(i);
if (player != null && myPlayerId.equals(player.getId())) {
myIndex = i;
break;
}
}
if (myIndex < 0) {
return null;
}
int firstIndex =
(s.players.size() == 2) ? s.dealer : (s.dealer + DEALER_OFFSET) % s.players.size();
if (s.activePlayer != firstIndex || myIndex != firstIndex) {
if (s.activePlayer != firstIndex) {
return null;
}
@@ -724,7 +727,8 @@ public class CasinoGameController {
}
long raiseTarget = (long) callTarget * 2L;
int safeRaiseTarget = raiseTarget > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) raiseTarget;
int safeRaiseTarget =
raiseTarget > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) raiseTarget;
return String.format(FIRST_PREFLOP_INFO_TEMPLATE, callTarget, safeRaiseTarget);
}
@@ -746,11 +750,11 @@ public class CasinoGameController {
}
int bet = Math.max(0, player.getBet());
if (bet == 100) {
if (bet == SMALL_BLIND) {
sbCount++;
} else if (bet == 200) {
} else if (bet == BIG_BLIND) {
bbCount++;
} else if (bet == 0) {
} else if (bet == NO_BET) {
zeroCount++;
}
}
@@ -1089,6 +1093,39 @@ public class CasinoGameController {
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.
@@ -1736,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);
}
/**
@@ -11,6 +11,8 @@ 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;
@@ -18,6 +20,8 @@ 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;
@@ -71,6 +75,11 @@ public class TaskbarController {
private static final double ALERT_LOGO_HEIGHT = 40.0;
private boolean inputActionAllowed;
private int lastReferenceBet = BIG_BLIND;
private Consumer<String> 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() {
@@ -223,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<String> lobbyActionAnnouncer) {
this.lobbyActionAnnouncer = lobbyActionAnnouncer;
}
/**
* Initializes the taskbar controller. This method is called automatically after the FXML
* components have been loaded.
@@ -230,15 +244,102 @@ public class TaskbarController {
@FXML
public void initialize() {
updateBasicButtons(false, false);
if (taskbarInput != null) {
UnaryOperator<TextFormatter.Change> 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.
*
@@ -258,6 +359,7 @@ public class TaskbarController {
}
this.lastState = state;
if (state.currentBet > 0) {
lastReferenceBet = state.currentBet;
}
@@ -279,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();
}
@@ -297,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);
@@ -318,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);
@@ -363,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;
}
/**
@@ -441,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);
}
/**
@@ -483,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.
*
@@ -492,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;
@@ -652,7 +842,7 @@ public class TaskbarController {
return;
}
executeAction(state, targetBet);
executeAction(state, targetBet, actionType);
if (targetBet > 0) {
lastReferenceBet = targetBet;
@@ -682,7 +872,8 @@ public class TaskbarController {
}
if (actionType == ActionType.RAISE_BUTTON) {
return safeDouble(callTarget);
Integer input = parseInputTarget(taskbarInput.getText());
return input != null ? input : callTarget + BIG_BLIND;
}
return parseInputTarget(taskbarInput.getText());
@@ -728,19 +919,78 @@ public class TaskbarController {
* @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) {
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 {}", targetBet);
} else if (targetBet == callTarget) {
LOGGER.info("[{}] BET {}", phase, targetBet);
announceLobbyAction("BET " + targetBet);
return;
}
if (targetBet == callTarget) {
int amount = Math.max(0, callTarget - alreadyInvested);
gameService.call();
LOGGER.info("CALL {}", targetBet);
LOGGER.info("[{}] CALL +{} (total to {})", phase, amount, callTarget);
announceLobbyAction("CALL " + amount);
} else {
int raiseBy = Math.max(0, targetBet - state.currentBet);
int raiseBy = Math.max(0, targetBet - callTarget);
gameService.raise(raiseBy);
LOGGER.info("RAISE {} (target={})", raiseBy, targetBet);
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());
}
}
@@ -757,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;
}
@@ -803,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();
}
@@ -826,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();
}
@@ -980,8 +1263,6 @@ public class TaskbarController {
* Called while dragging the taskbar with the mouse. Updates the position and slightly scales
* the taskbar for visual feedback.
*
* <p>TODO: It still needs to be fixed that the taskbar cannot disappear out of the window.
*
* @param event Das Mausereignis
*/
@FXML
@@ -1099,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. */
@@ -1120,6 +1401,7 @@ public class TaskbarController {
gameService.fold();
LOGGER.info("Player FOLD");
announceLobbyAction("FOLD");
refreshGame();
}
@@ -1233,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) {