Merge branch 'feat/game-ui-optimization' into 'main'

Feat: Game UI optimization

See merge request cs108-fs26/Gruppe-13!186
This commit is contained in:
Julian Kropff
2026-05-16 20:52:37 +00:00
6 changed files with 300 additions and 53 deletions
@@ -1,7 +1,9 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
import ch.unibas.dmi.dbis.cs108.casono.client.network.GameClient;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -11,6 +13,9 @@ public class GameService {
private final GameClient client;
private GameState state;
private final Queue<Runnable> actionQueue = new LinkedList<>();
private volatile boolean actionInProgress = false;
private static final long ACTION_TIMEOUT_MS = 5000;
/**
* Constructs a GameService with the specified GameClient.
@@ -112,12 +117,12 @@ public class GameService {
/** Retrieves the current phase of the game. */
public void call() {
client.sendCall();
queueAction(() -> client.sendCall());
}
/** Retrieves the current phase of the game. */
public void fold() {
client.sendFold();
queueAction(() -> client.sendFold());
}
/**
@@ -126,7 +131,7 @@ public class GameService {
* @param amount The amount to bet. Must be a positive integer.
*/
public void bet(int amount) {
client.sendBet(amount);
queueAction(() -> client.sendBet(amount));
}
/**
@@ -135,7 +140,63 @@ public class GameService {
* @param amount The amount to raise. Must be a positive integer.
*/
public void raise(int amount) {
client.sendRaise(amount);
queueAction(() -> client.sendRaise(amount));
}
/**
* Queue an action to be executed sequentially. Only one action is processed at a time to
* prevent concurrent operations from overwhelming the server or UI.
*
* @param action The action to queue and execute.
*/
private void queueAction(Runnable action) {
synchronized (actionQueue) {
actionQueue.offer(action);
if (!actionInProgress) {
processNextAction();
}
}
}
/**
* Process the next queued action if one is available. This method ensures that only one action
* is in progress at any given time.
*/
private void processNextAction() {
synchronized (actionQueue) {
if (actionQueue.isEmpty() || actionInProgress) {
return;
}
actionInProgress = true;
Runnable action = actionQueue.poll();
if (action != null) {
try {
LOG.fine("Processing queued action");
action.run();
} catch (Exception e) {
LOG.log(Level.WARNING, "Error executing queued action: " + e.getMessage(), e);
} finally {
actionInProgress = false;
if (!actionQueue.isEmpty()) {
processNextAction();
}
}
} else {
actionInProgress = false;
}
}
}
/**
* Check if an action is currently in progress, preventing the UI from accepting new actions
* while one is being processed.
*
* @return true if an action is in progress, false otherwise.
*/
public boolean isActionInProgress() {
return actionInProgress;
}
/** Ensures that the game state has been initialized before accessing it. */
@@ -92,19 +92,39 @@ public class GameClient {
}
public void sendCall() {
client.processCommand("CALL GAME_ID=" + gameId);
try {
client.processCommand("CALL GAME_ID=" + gameId);
LOG.fine("CALL command sent");
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send CALL: " + e.getMessage(), e);
}
}
public void sendFold() {
client.processCommand("FOLD GAME_ID=" + gameId);
try {
client.processCommand("FOLD GAME_ID=" + gameId);
LOG.fine("FOLD command sent");
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send FOLD: " + e.getMessage(), e);
}
}
public void sendBet(int amount) {
client.processCommand("BET GAME_ID=" + gameId + " AMOUNT=" + amount);
try {
client.processCommand("BET GAME_ID=" + gameId + " AMOUNT=" + amount);
LOG.fine("BET command sent: amount=" + amount);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send BET: " + e.getMessage(), e);
}
}
public void sendRaise(int amount) {
client.processCommand("RAISE GAME_ID=" + gameId + " AMOUNT=" + amount);
try {
client.processCommand("RAISE GAME_ID=" + gameId + " AMOUNT=" + amount);
LOG.fine("RAISE command sent: amount=" + amount);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send RAISE: " + e.getMessage(), e);
}
}
private GameState parseGameState(String input) {
@@ -23,6 +23,9 @@ import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Pos;
@@ -33,6 +36,7 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
/**
* Controller for the casino gaming area.
@@ -122,6 +126,8 @@ public class CasinoGameController {
private static final int DEALER_PLAYER_3 = 2;
private static final int DEALER_MYSELF = 3;
private static final double CARD_START_OPACITY = 0.0;
private static final int TARGET_FPS = 30;
private static final double FRAME_TIME_MS = 1000.0 / TARGET_FPS;
private static final double CARD_START_SCALE = 0.85;
private static final double COMMUNITY_CARD_HEIGHT_RATIO = 0.22;
private static final double COMMUNITY_CARD_WIDTH_RATIO = 0.11;
@@ -149,16 +155,17 @@ public class CasinoGameController {
private static final double CHIP_TRANSLATE_RESET_Y = 0.0;
private static final double CHIP_FADE_TO = 1.0;
private static final double CHIP_SCALE_TO = 1.0;
private static final long CHIP_FADE_DURATION_MS = 200;
private static final long CHIP_SCALE_DURATION_MS = 220;
private static final long CHIP_DROP_DURATION_MS = 250;
private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 35L;
private static final long CHIP_FADE_DURATION_MS = 100;
private static final long CHIP_SCALE_DURATION_MS = 110;
private static final long CHIP_DROP_DURATION_MS = 120;
private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 15L;
private static final int MAX_POT_ROWS = 2;
private static final double POT_CHIP_H_GAP = 6.0;
private static final double POT_CHIP_V_GAP = 6.0;
private static final double POT_WIDTH_FACTOR = 0.45;
private static final double FALLBACK_POT_WIDTH = 500.0;
private static final double FALLBACK_CHIP_WIDTH = 36.0;
private volatile boolean updating = false;
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";
@@ -178,6 +185,7 @@ public class CasinoGameController {
private static final int NO_BET = 0;
private boolean myDealerIconSizeBound;
private java.util.List<String> lobbyPlayerNames = java.util.List.of();
private static final int MAX_ANIMATED_CHIPS = 12;
/** Standard constructor. Used by FXML. */
public CasinoGameController() {
@@ -498,12 +506,24 @@ public class CasinoGameController {
private void startLoop() {
timeline =
new javafx.animation.Timeline(
new javafx.animation.KeyFrame(
javafx.util.Duration.seconds(UI_UPDATE_INTERVAL_SECONDS),
e -> updateUI()));
new Timeline(
new KeyFrame(
Duration.millis(FRAME_TIME_MS),
e -> {
if (updating) {
return;
}
timeline.setCycleCount(javafx.animation.Animation.INDEFINITE);
updating = true;
try {
updateUI();
} finally {
updating = false;
}
}));
timeline.setCycleCount(Animation.INDEFINITE);
timeline.play();
}
@@ -625,6 +645,12 @@ public class CasinoGameController {
updateGameInfo(s);
highlightDealer(s);
updateTaskbar(s);
if (s.pot != lastPot) {
lastPot = s.pot;
renderPot(s.pot);
}
clearActiveTurnHighlights();
announceLocalWinIfNeeded(s);
finishGameUiLoop();
@@ -644,6 +670,9 @@ public class CasinoGameController {
highlightDealer(s);
updateTaskbar(s);
// Force refresh of money display to handle UI stalls
refreshMoneyDisplay();
LOGGER.info("myPlayerId=" + myPlayerId);
for (int i = 0; i < players.size(); i++) {
Player p = players.get(i);
@@ -903,6 +932,30 @@ public class CasinoGameController {
controller.update(s, myPlayerName);
}
/**
* Force refresh of the money display in the taskbar to ensure it always reflects the current
* player's chip count, even during rapid game state changes.
*/
private void refreshMoneyDisplay() {
TaskbarController controller = resolveTaskbarController();
if (controller == null || gameService == null) {
return;
}
try {
List<Player> players = gameService.getPlayers();
if (players != null) {
for (Player p : players) {
if (isCurrentPlayer(p)) {
controller.setMoney(p.getChips());
return;
}
}
}
} catch (Exception e) {
LOGGER.warning("Could not refresh money display: " + e.getMessage());
}
}
/**
* Update the opponent player slots based on the list of players in the game state. This method
* identifies the opponents by comparing their PlayerIds with myPlayerId and updates the
@@ -1829,6 +1882,10 @@ public class CasinoGameController {
* @param pot The total amount in the pot that needs to be represented with chips on the UI.
*/
private void renderPot(int pot) {
if (!javafx.application.Platform.isFxApplicationThread()) {
javafx.application.Platform.runLater(() -> renderPot(pot));
return;
}
potBox.getChildren().clear();
@@ -1869,7 +1926,13 @@ public class CasinoGameController {
bottomRow.getChildren().add(chip);
}
animateChipAppear(chip, index);
if (index < MAX_ANIMATED_CHIPS) {
animateChipAppear(chip, index);
} else {
chip.setOpacity(CHIP_FADE_TO);
chip.setScaleX(CHIP_SCALE_TO);
chip.setScaleY(CHIP_SCALE_TO);
}
remaining -= chipValue;
index++;
@@ -1961,10 +2024,7 @@ public class CasinoGameController {
*/
private void bindChipSize(ImageView view, javafx.scene.Scene scene) {
view.fitHeightProperty()
.bind(
scene.heightProperty().multiply(CHIP_HEIGHT_RATIO) // kleiner als Karten
);
view.fitHeightProperty().bind(scene.heightProperty().multiply(CHIP_HEIGHT_RATIO));
view.fitWidthProperty().bind(scene.widthProperty().multiply(CHIP_WIDTH_RATIO));
}
@@ -112,6 +112,7 @@ public class CasinoBrowserController {
TRUSTED_DOMAINS.add("mojeek.com");
TRUSTED_DOMAINS.add("searx.be");
TRUSTED_DOMAINS.add("startpage.com");
TRUSTED_DOMAINS.add("vscode.dev");
}
/** Aliases for common websites. */
@@ -138,7 +139,10 @@ public class CasinoBrowserController {
Map.entry("gradle", "gradle.org"),
Map.entry("spring", "spring.io"),
Map.entry("jetbrains", "jetbrains.com"),
Map.entry("uni", "unibas.ch"));
Map.entry("uni", "unibas.ch"),
Map.entry("vs", "vscode.dev"),
Map.entry("vscode", "vscode.dev"),
Map.entry("vs code", "vscode.dev"));
/**
* Resolves user input into a valid URL.
@@ -653,23 +657,22 @@ public class CasinoBrowserController {
}
if ("file".equalsIgnoreCase(scheme)) {
Path allowed =
Paths.get(
System.getProperty("user.dir"),
"documents",
"docs",
"game-engine",
"manual.html")
.normalize();
Path baseDir = Paths.get(System.getProperty("user.dir")).normalize();
Path manual = baseDir.resolve("documents/docs/game-engine/manual.html").normalize();
Path outreach = baseDir.resolve("outreach/index.html").normalize();
Path requested = Paths.get(uri).normalize();
if (requested.equals(allowed)) {
if (requested.equals(manual) || requested.equals(outreach)) {
securityLabel.setText("LOCAL OK");
engine.load(uri.toString());
} else {
securityLabel.setText("BLOCKED");
}
return;
}
@@ -91,6 +91,7 @@ public class TaskbarController {
private int lastPotSnapshot = 0;
private int lastObservedIncrease = 0;
private int lastRaiseIncrement = 0;
private static final long REFRESH_DELAY_MS = 500;
/** Standard constructor. Used by FXML. */
public TaskbarController() {
@@ -568,7 +569,17 @@ public class TaskbarController {
return;
}
if (isFirstFlopPlayer(state, me)) {
// if (isFirstFlopPlayer(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);
@@ -754,6 +765,35 @@ public class TaskbarController {
return isInitialPreflopBlindLayout(state);
}
/**
* Finds the first player who can still act when starting from a specific index.
*
* @param state The current game state.
* @param startIndex The index from which the search should begin.
* @return The first eligible player index, or -1 if none can act.
*/
private int findFirstActingPlayerIndex(GameState state, int startIndex) {
if (state == null || state.players == null || state.players.isEmpty()) {
return -1;
}
int size = state.players.size();
int normalizedStart = ((startIndex % size) + size) % size;
for (int i = 0; i < size; i++) {
int candidate = (normalizedStart + i) % size;
Player player = state.players.get(candidate);
if (player != null
&& player.getState() != PlayerState.FOLDED
&& player.getChips() > 0) {
return candidate;
}
}
return -1;
}
/**
* Checks if the current player is the first to act on the flop.
*
@@ -783,19 +823,10 @@ public class TaskbarController {
return false;
}
int firstIndex = (state.dealer + 1) % size;
int firstIndex = findFirstActingPlayerIndex(state, (state.dealer + 1) % size);
for (int i = 0; i < size; i++) {
int candidate = (firstIndex + i) % size;
Player p = state.players.get(candidate);
if (p != null && p.getState() != PlayerState.FOLDED && p.getChips() > 0) {
firstIndex = candidate;
break;
}
if (firstIndex < 0) {
return false;
}
return state.activePlayer == firstIndex && myIndex == firstIndex;
@@ -828,14 +859,19 @@ public class TaskbarController {
return false;
}
int dealer = state.dealer;
int firstIndex;
if (isPreflop(state.phase)) {
firstIndex = (size == 2) ? dealer : (dealer + DEALER_OFFSET) % size;
firstIndex =
findFirstActingPlayerIndex(
state,
(size == 2) ? state.dealer : (state.dealer + DEALER_OFFSET) % size);
} else {
firstIndex = (dealer + 1) % size;
firstIndex = findFirstActingPlayerIndex(state, (state.dealer + 1) % size);
}
if (firstIndex < 0) {
return false;
}
return state.activePlayer == firstIndex && myIndex == firstIndex;
@@ -989,6 +1025,11 @@ public class TaskbarController {
return;
}
if (gameService.isActionInProgress()) {
LOGGER.info("Action {} blocked: another action is in progress", actionType);
return;
}
GameState state = ensureLatestStateForAction(actionType.name().toLowerCase());
if (state == null || isHandFinished(state)) {
if (state != null) {
@@ -1014,6 +1055,8 @@ public class TaskbarController {
return;
}
disableAllActionButtons();
executeAction(state, targetBet, actionType);
if (targetBet > 0) {
@@ -1021,7 +1064,35 @@ public class TaskbarController {
}
taskbarInput.clear();
refreshGame();
javafx.application.Platform.runLater(
() -> {
try {
Thread.sleep(REFRESH_DELAY_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
refreshGame();
});
}
/** Disables all action buttons to prevent multiple concurrent action submissions. */
private void disableAllActionButtons() {
if (betButton != null) {
betButton.setDisable(true);
}
if (callButton != null) {
callButton.setDisable(true);
}
if (raiseButton != null) {
raiseButton.setDisable(true);
}
if (foldButton != null) {
foldButton.setDisable(true);
}
if (taskbarInput != null) {
taskbarInput.setDisable(true);
}
}
/**
@@ -1791,6 +1862,24 @@ public class TaskbarController {
}
}
/**
* Opens the integrated Casono web browser.
*
* <p>once the content for strategies and support is available.
*/
@FXML
private void onBrowserButtonClickCasono() {
SoundManager.getInstance().playButtonClick();
try {
Path path = Paths.get(System.getProperty("user.dir"), "outreach", "index.html");
CasinoBrowserController.open(path.toUri().toString());
} catch (Exception e) {
e.printStackTrace();
}
}
/** Opens the integrated Casono Web Browser. */
@FXML
private void onBrowserButtonClickWiki() {
@@ -1815,6 +1904,18 @@ public class TaskbarController {
}
}
/** Opens the integrated Casono Web Browser. */
@FXML
private void onBrowserButtonClickVSCode() {
try {
CasinoBrowserController.open("https://vscode.dev/");
} catch (Exception e) {
e.printStackTrace();
}
}
/** Opens the tips notebook if it's currently closed. */
@FXML
private void onShowTipsButtonClick() {
@@ -24,9 +24,11 @@
<MenuButton text="HELP" styleClass="gray-button">
<items>
<MenuItem text="SHOW TIPS" onAction="#onShowTipsButtonClick" />
<MenuItem text="CASONO BROWSER: Manual" onAction="#onBrowserButtonClick" />
<MenuItem text="CASONO BROWSER: Wikipedia" onAction="#onBrowserButtonClickWiki" />
<MenuItem text="CASONO BROWSER: Brave Search" onAction="#onBrowserButtonClickBrave" />
<MenuItem text="CB: Manual" onAction="#onBrowserButtonClick" />
<MenuItem text="CB: Casono" onAction="#onBrowserButtonClickCasono" />
<MenuItem text="CB: Wikipedia" onAction="#onBrowserButtonClickWiki" />
<MenuItem text="CB: Brave Search" onAction="#onBrowserButtonClickBrave" />
<MenuItem text="CB: VS Code" onAction="#onBrowserButtonClickVSCode" />
</items>
</MenuButton>