Coverage Summary for Class: MainFXMLController (it.polimi.ingsw.gc14.View.GUI)

Class Class, % Method, % Branch, % Line, %
MainFXMLController 0% (0/1) 0% (0/53) 0% (0/137) 0% (0/448)


 package it.polimi.ingsw.gc14.View.GUI;
 
 import it.polimi.ingsw.gc14.Controller.ClientController;
 import it.polimi.ingsw.gc14.Model.*;
 import it.polimi.ingsw.gc14.Model.Cards.BuildingCard;
 import it.polimi.ingsw.gc14.Model.Cards.TribeCard;
 import it.polimi.ingsw.gc14.Model.Orders.OrderPlayer;
 import it.polimi.ingsw.gc14.Network.EventType;
 import javafx.application.Platform;
 import javafx.animation.PauseTransition;
 import javafx.animation.ScaleTransition;
 import javafx.animation.TranslateTransition;
 import javafx.event.Event;
 import javafx.fxml.FXML;
 import javafx.geometry.Insets;
 import javafx.geometry.Pos;
 import javafx.scene.Cursor;
 import javafx.scene.Node;
 import javafx.scene.control.Button;
 import javafx.scene.control.Label;
 import javafx.scene.control.ScrollPane;
 import javafx.scene.effect.ColorAdjust;
 import javafx.scene.effect.DropShadow;
 import javafx.scene.image.*;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.layout.*;
 import javafx.scene.paint.Color;
 import javafx.scene.shape.*;
 import javafx.scene.text.Font;
 import javafx.stage.Popup;
 import javafx.stage.Window;
 import javafx.util.Duration;
 
 import java.util.*;
 
 /**
  * FXML controller for the main game scene.
  *
  * <p>Renders the board (upper/lower tribe and building card rows), player stats,
  * the player's hand, and action buttons (skip, details).
  */
 public class MainFXMLController {
     /** Scrollable container holding all player side-panel cards. */
     @FXML private ScrollPane playerSide;
     /** Root HBox containing all main scene elements. */
     @FXML private HBox mainHBox;
     /** HBox holding the board slot and order cards. */
     @FXML private HBox board;
     /** HBox holding the upper tribe card row (and upper building stack). */
     @FXML private HBox upperList;
     /** HBox holding the lower tribe card row (and lower building stack). */
     @FXML private HBox lowerList;
     /** HBox displaying the local player's card piles. */
     @FXML private HBox myHand;
     /** Button that sends a skip-turn command. */
     @FXML private Button skipBtn;
     /** Button that opens the game details popup. */
     @FXML private Button detailsBtn;
     /** Button that disconnects and closes the client. */
     @FXML private Button quitBtn;
     /** Label showing the current round number and game stage. */
     @FXML private Label infoText;
     /** Maps each player's username to their current food-value label in the side panel. */
     private final Map<String, Label> foodLabels     = new HashMap<>();
     /** Maps each player's username to their current prestige-value label in the side panel. */
     private final Map<String, Label> prestigeLabels = new HashMap<>();
     /** Maps each player's username to their player-card VBox in the side panel. */
     private final Map<String, VBox>  playerCards    = new HashMap<>();
     /** Maps each player's username to their ordered list of card-type icon ImageViews. */
     private final Map<String, List<ImageView>> iconViews = new HashMap<>();
     /** Shared popup used for card previews and building selection. */
     private Popup popup;
     /** HBox inside {@link #popup} that holds the displayed card images. */
     private HBox popupCards;
     /** Client controller for fetching game state and sending player actions. */
     private ClientController controller;
 
     /** {@code true} when the last received event was an error response. */
     private boolean isError;
 
     /**
      * Sets the error flag; called by {@link GUI} before delegating to {@link #render()}.
      *
      * @param error {@code true} if the last received event was an error response.
      */
     public void setError(boolean error) { this.isError = error; }
 
     // ==== IMAGE CACHE ====
     private static final Map<String, Image> imageCache = new HashMap<>();
 
     /** Returns a cached {@link Image} for the given classpath {@code path}, loading it on first access. */
     private Image loadImage(String path) {
         return imageCache.computeIfAbsent(path,
                 p -> new Image(Objects.requireNonNull(getClass().getResourceAsStream(p))));
     }
 
     /**
      * Injects the client controller and wires up the skip-turn button action.
      *
      * @param controller the client controller to use.
      */
     public void setController(ClientController controller) {
         this.controller = controller;
         skipBtn.setOnAction(e -> controller.skipTurn());
 
         PauseTransition quitTimer = new PauseTransition(javafx.util.Duration.seconds(3));
         quitTimer.setOnFinished(e -> {
             quitBtn.setText("Quit");
             quitBtn.setStyle("");
         });
         final boolean[] quitPending = {false};
         quitBtn.setOnAction(e -> {
             if (quitPending[0]) {
                 controller.disconnect();
                 Platform.exit();
                 System.exit(0);
             } else {
                 quitPending[0] = true;
                 quitBtn.setText("Sure?");
                 quitBtn.setStyle("-fx-background-color: #c0392b; -fx-text-fill: white;");
                 quitTimer.playFromStart();
                 quitTimer.setOnFinished(ev -> {
                     quitPending[0] = false;
                     quitBtn.setText("Quit");
                     quitBtn.setStyle("");
                 });
             }
         });
     }
 
     /** Initializes the scene: loads fonts, sets up the popup, and registers input listeners. */
     @FXML
     public void initialize() {
         Font.loadFont(getClass().getResourceAsStream("/Fonts/InknutAntiqua-Regular.ttf"), 14);
 
         addHoverZoom(skipBtn);
         addHoverZoom(detailsBtn);
         addHoverZoom(quitBtn);
         popup = new Popup();
         mainHBox.sceneProperty().addListener((obs, oldScene, newScene) -> {
             if (newScene != null) {
                 newScene.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> {
                     if (popup.isShowing()) popup.hide();
                 });
                 newScene.getRoot().applyCss();
                 newScene.windowProperty().addListener((o2, ow, nw) -> {
                     if (nw != null) {
                         nw.focusedProperty().addListener((o3, wf, nf) -> {
                             if (!nf) popup.hide();
                         });
                     }
                 });
             }
             else {
                 popup.hide();
             }
         });
         initPopup();
         detailsBtn.setOnAction(e -> openDetailsPopup());
         renderBackground();
         isError=false;
     }
 
 
     // ==== RENDER ====
     /** Incrementally re-renders only the parts of the scene affected by the last network event. */
     public void render() {
         if(foodLabels.isEmpty() || controller.getMiniModel().lastEvent == null
                 || controller.getMiniModel().lastEvent.getEventType().equals(EventType.TOTEM_CHOICE)
                 || controller.getMiniModel().lastEvent.getEventType().equals(EventType.RECONNECT_PLAYER)
                 || controller.getMiniModel().lastEvent.getEventType().equals(EventType.DISCONNECTED_PLAYER))
             buildSidePanel();
         if(isError)
         {
             updateSidePanel();
             isError=false;
             return;
         }
         if(controller.getMiniModel().lastEvent==null ||controller.getMiniModel().lastEvent.getEventType().equals(EventType.TOTEM_CHOICE) || controller.getMiniModel().lastEvent.getEventType().equals(EventType.NEXT_ROUND) || controller.getMiniModel().lastEvent.getEventType().equals(EventType.RECONNECT_PLAYER) || controller.getMiniModel().lastEvent.getEventType().equals(EventType.DISCONNECTED_PLAYER)) {
             renderMyHand();
             renderUpper();
             renderBoard();
             renderLower();
             updateSidePanel();
             return;
         }
         if(controller.getMiniModel().lastEvent.getEventType().equals(EventType.DRAW_UPPER_TRIBE) || controller.getMiniModel().lastEvent.getEventType().equals(EventType.DRAW_UPPER_BUILD))
         {
             renderUpper();
         }
         if(controller.getMiniModel().lastEvent.getEventType().equals(EventType.DRAW_LOWER_TRIBE) || controller.getMiniModel().lastEvent.getEventType().equals(EventType.DRAW_LOWER_BUILD))
         {
             renderLower();
         }
         if(controller.getMiniModel().lastEvent.getUsername().equals(controller.getMyUsername()))
         {
             renderMyHand();
         }
         renderBoard();
         updateSidePanel();
     }
 
 
     // ==== EFFECTS ====
     /** Binds a rounded-rectangle clip to {@code img} so its corners are cropped. */
     private void addClip(ImageView img) {
         Rectangle clip = new Rectangle();
         clip.setArcWidth(20);
         clip.setArcHeight(20);
         img.setClip(clip);
         clip.heightProperty().bind(img.layoutBoundsProperty().map(b -> b.getHeight()));
         clip.widthProperty().bind(img.layoutBoundsProperty().map(b -> b.getWidth()));
     }
 
     /** Scales {@code node} to 1.1× on hover and restores on exit; sets a hand cursor. */
     private void addHoverZoom(Node node) {
         ScaleTransition scaleUp = new ScaleTransition(Duration.millis(150), node);
         scaleUp.setToX(1.1);
         scaleUp.setToY(1.1);
         ScaleTransition scaleDown = new ScaleTransition(Duration.millis(150), node);
         scaleDown.setToX(1.0);
         scaleDown.setToY(1.0);
         node.addEventHandler(MouseEvent.MOUSE_ENTERED, e -> { scaleUp.play(); node.setCursor(Cursor.HAND); });
         node.addEventHandler(MouseEvent.MOUSE_EXITED,  e -> { scaleDown.play(); node.setCursor(Cursor.DEFAULT); });
     }
 
     /** Applies a drop-shadow to {@code node} that intensifies on hover. */
     private void addShadow(Node node) {
         DropShadow shadow = new DropShadow();
         shadow.setColor(Color.rgb(0, 0, 0, 0.3));
         shadow.setRadius(6);
         shadow.setOffsetX(2);
         shadow.setOffsetY(2);
         node.setEffect(shadow);
         node.addEventHandler(MouseEvent.MOUSE_ENTERED, e -> {
             shadow.setRadius(16);
             shadow.setOffsetX(4);
             shadow.setOffsetY(4);
             shadow.setColor(Color.rgb(0, 0, 0, 0.5));
         });
         node.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
             shadow.setRadius(6);
             shadow.setOffsetX(2);
             shadow.setOffsetY(2);
             shadow.setColor(Color.rgb(0, 0, 0, 0.3));
         });
     }
 
 
     // ==== ELEMENTS ====
 
     /** Builds the side-panel card widget for {@code player} with totem, stats, and card-type icons. */
     private VBox buildPlayerCard(Player player) {
         VBox card = new VBox(3);
         VBox.setMargin(card, new Insets(15, 15, 0, 15));
         card.getStyleClass().add("player-card");
         card.setPadding(new Insets(4));
 
         HBox headerRow = new HBox(8);
         headerRow.setAlignment(Pos.CENTER);
         ImageView totem = new ImageView(loadImage("/GUIImages/Totems/totem_"
                 + player.getTotem().toString().toLowerCase(Locale.ROOT) + ".png"));
         totem.setFitHeight(20);
         totem.setPreserveRatio(true);
         Label usernameLabel = new Label(player.getUserName());
         usernameLabel.getStyleClass().add("label-medium");
         usernameLabel.setStyle("-fx-text-fill: #711423;");
         if (player.getUserName().equals(controller.getMyUsername())) {
             usernameLabel.setText(usernameLabel.getText() + " (you)");
         }
         if (controller.getMiniModel().disconnectedPlayers.contains(player.getUserName())) {
             card.getStyleClass().add("player-card-crashed");
         }
         headerRow.getChildren().addAll(totem, usernameLabel);
 
         HBox statsRow = new HBox(10);
         statsRow.setAlignment(Pos.CENTER);
 
         HBox foodBox = new HBox(4);
         foodBox.setAlignment(Pos.CENTER);
         ImageView foodIcon = new ImageView(loadImage("/GUIImages/Icons/Food.png"));
         foodIcon.setFitHeight(20);
         foodIcon.setPreserveRatio(true);
         Label foodLabel = new Label(String.valueOf(player.getFoodValue()));
         foodLabel.getStyleClass().add("label-small");
         foodLabels.put(player.getUserName(), foodLabel);
         foodBox.getChildren().addAll(foodIcon, foodLabel);
 
         HBox prestigeBox = new HBox(1);
         prestigeBox.setAlignment(Pos.CENTER);
         ImageView ppIcon = new ImageView(loadImage("/GUIImages/Icons/PrestigePoint.png"));
         ppIcon.setFitHeight(35);
         ppIcon.setPreserveRatio(true);
         Label prestigeLabel = new Label(String.valueOf(player.getPrestigeValue()));
         prestigeLabel.getStyleClass().add("label-small");
         prestigeLabels.put(player.getUserName(), prestigeLabel);
         prestigeBox.getChildren().addAll(ppIcon, prestigeLabel);
 
         ImageView buildingIcon = new ImageView(loadImage("/GUIImages/Icons/Building.png"));
         buildingIcon.setFitWidth(28);
         buildingIcon.setFitHeight(28);
         buildingIcon.setPreserveRatio(true);
         buildingIcon.setOnMouseClicked(e -> {
             ArrayList<PlayableCard> cards = getPlayerCards(player.getUserName(), "building");
             if (!cards.isEmpty()) openPopup(cards);
         });
         addHoverZoom(buildingIcon);
 
         statsRow.getChildren().addAll(foodBox, prestigeBox, buildingIcon);
 
         HBox iconsRow1 = new HBox(6);
         iconsRow1.setSpacing(20);
         iconsRow1.setAlignment(Pos.CENTER);
         HBox iconsRow2 = new HBox(6);
         iconsRow2.setAlignment(Pos.CENTER);
         iconsRow2.setSpacing(20);
 
         String[][] iconTypes = {
                 {"Artist",   "artists"},
                 {"Gatherer", "gatherers"},
                 {"Inventor", "inventors"},
                 {"Builder",  "builders"},
                 {"Shaman",   "shamans"},
                 {"Hunter",   "hunters"}
         };
 
         List<ImageView> icons = new ArrayList<>();
         // building must be index 0; updateSidePanel iterates icons in ["building","artists",...] order
         icons.add(buildingIcon);
 
         for (int i = 0; i < iconTypes.length; i++) {
             String iconName  = iconTypes[i][0];
             String fieldName = iconTypes[i][1];
 
             ImageView icon = new ImageView(loadImage("/GUIImages/Icons/" + iconName + ".png"));
             icon.setFitWidth(28);
             icon.setFitHeight(28);
             icon.setPreserveRatio(true);
             icon.setOnMouseClicked(e -> {
                 ArrayList<PlayableCard> cards = getPlayerCards(player.getUserName(), fieldName);
                 if (!cards.isEmpty()) openPopup(cards);
             });
             addHoverZoom(icon);
             icons.add(icon);
 
             if (i < 3) iconsRow1.getChildren().add(icon);
             else       iconsRow2.getChildren().add(icon);
         }
 
         iconViews.put(player.getUserName(), icons);
 
         card.getChildren().addAll(headerRow, statsRow, iconsRow1, iconsRow2);
         return card;
     }
 
     /** Creates a {@link StackPane} containing the order card image for a game of {@code num} players. */
     private StackPane createOrder(String num) {
         ImageView img = new ImageView(loadImage("/GUIImages/Orders/order-" + num + ".png"));
         img.fitHeightProperty().bind(board.sceneProperty().get().heightProperty().subtract(56).divide(4).multiply(0.94));
         img.setPreserveRatio(true);
         addClip(img);
         return new StackPane(img);
     }
 
     /** Creates a {@link StackPane} for a board slot, optionally overlaying the occupying player's totem. */
     private StackPane createSlot(Slot slot, boolean withZoom, boolean withShadow) {
         ImageView img = new ImageView(loadImage("/GUIImages/Fronts/card-" + slot.getSlotId() + ".png"));
         img.setPreserveRatio(true);
         img.fitHeightProperty().bind(board.sceneProperty().get().heightProperty().subtract(56).divide(4).multiply(0.94));
         addClip(img);
         StackPane wrapper = new StackPane(img);
 
         Player player = controller.getMiniModel().slotPlayerMap.get(slot);
         if (player != null && controller.getMiniModel().getPositionByUsername(player.getUserName()) != -1) {
             ImageView totem = new ImageView(loadImage("/GUIImages/Totems/totem_" + player.getTotem().toString().toLowerCase(Locale.ROOT) + ".png"));
             totem.fitHeightProperty().bind(img.fitHeightProperty().multiply(0.332));
             totem.setPreserveRatio(true);
             StackPane.setAlignment(totem, Pos.TOP_LEFT);
             img.fitHeightProperty().addListener((obs, ov, nv) -> {
                 StackPane.setMargin(totem, new Insets(0, 0, 0, 0.224 * nv.doubleValue()));
             });
             if (img.getFitHeight() > 0) {
                 StackPane.setMargin(totem, new Insets(0, 0, 0, 0.224 * img.getFitHeight()));
             }
             wrapper.getChildren().add(totem);
         }
 
         if (withShadow) addShadow(wrapper);
         if (withZoom)   addHoverZoom(wrapper);
         return wrapper;
     }
 
     /** Creates a {@link StackPane} for a playable card, scaling its height to a fraction of {@code parent}'s scene. */
     private StackPane createCard(PlayableCard card, boolean withZoom, boolean withShadow, Region parent) {
         ImageView img = new ImageView(loadImage("/GUIImages/Fronts/card-" + card.getIdIMG() + ".png"));
         img.setPreserveRatio(true);
         if(controller.getMiniModel().players.size()==5)
         {
             img.fitHeightProperty().bind(parent.getScene().heightProperty().subtract(56).divide(4).multiply(0.85));
         }
         else
             img.fitHeightProperty().bind(parent.getScene().heightProperty().subtract(56).divide(4).multiply(0.90));
         addClip(img);
         StackPane wrapper = new StackPane(img);
         if (withShadow) addShadow(wrapper);
         if (withZoom)   addHoverZoom(wrapper);
         return wrapper;
     }
 
 
 
     // ==== GROUPS ====
     /** Builds the full side panel from scratch, creating a player card for each connected player. */
     private void buildSidePanel() {
         infoText.setText("Round: "+ controller.getMiniModel().currentState.getRound() + " • " + controller.getMiniModel().currentState.getGameStage().toString());
         VBox playerList = new VBox();
         playerList.setFillWidth(true);
         for (Player p : controller.getMiniModel().players.values()) {
             VBox card = buildPlayerCard(p);
             playerCards.put(p.getUserName(), card);
             playerList.getChildren().add(card);
         }
         playerSide.setContent(playerList);
     }
 
     /** Updates food/prestige labels, current-player highlight, card-icon opacity, and plays error shake if needed. */
     private void updateSidePanel() {
         infoText.setText("Round: "+ controller.getMiniModel().currentState.getRound() + " • " + controller.getMiniModel().currentState.getGameStage().toString());
         if (controller.getMiniModel().currentState.getCurrentPlayer() == null) return;
         String current = controller.getMiniModel().currentState.getCurrentPlayer().getUserName();
         String[] fields = {"building", "artists", "gatherers", "inventors", "builders", "shamans", "hunters"};
 
         for (Player p : controller.getMiniModel().players.values()) {
             String u = p.getUserName();
 
             foodLabels.get(u).setText(String.valueOf(p.getFoodValue()));
             prestigeLabels.get(u).setText(String.valueOf(p.getPrestigeValue()));
 
             List<ImageView> icons = iconViews.get(u);
             for (int i = 0; i < icons.size(); i++) {
                 boolean empty = getPlayerCards(u, fields[i]).isEmpty();
                 icons.get(i).setOpacity(empty ? 0.3 : 1.0);
                 icons.get(i).setEffect(empty ? new ColorAdjust() : null);
             }
 
             VBox card = playerCards.get(u);
             if(controller.getMiniModel().disconnectedPlayers.contains(u))
             {
                 card.getStyleClass().clear();
                 card.getStyleClass().add("player-card-crashed");
             }
             else {
                 card.getStyleClass().clear();
                 card.getStyleClass().add("player-card");
             }
 
             if (u.equals(current)) {
                 card.setStyle("-fx-effect: dropshadow(gaussian, #fff8dc, 15, 0.10, 0, 0)");
                 ScaleTransition st = new ScaleTransition(Duration.millis(150), card);
                 st.setToX(1.1);
                 st.setToY(1.1);
                 st.play();
             } else {
                 ScaleTransition st = new ScaleTransition(Duration.millis(150), card);
                 st.setToX(1.0);
                 st.setToY(1.0);
                 st.play();
                 card.setStyle("");
             }
 
             if (isError && u.equals(controller.getMyUsername())) {
                 TranslateTransition tt = new TranslateTransition(Duration.millis(56), card);
                 tt.setFromX(0);
                 tt.setByX(10);
                 tt.setCycleCount(6);
                 tt.setAutoReverse(true);
                 tt.play();
             }
         }
     }
 
     /** Renders the top card of the upper building stack with a count badge; clicking opens the selection popup. */
     private void drawUpperBuilding() {
         ArrayList<BuildingCard> cardList = new ArrayList<>(controller.getMiniModel().upperListBuildingCards);
         if (!cardList.isEmpty()) {
             StackPane img = createCard(cardList.getLast(), true, true, upperList);
             Label label = new Label(String.valueOf(cardList.size()));
             label.setTranslateY(-56);
             label.setTranslateX(10);
             label.getStyleClass().add("label-large");
             StackPane.setAlignment(label, Pos.TOP_RIGHT);
             img.getChildren().add(label);
             img.setOnMouseClicked(e -> openPopupUpperBuilding(cardList));
             upperList.getChildren().add(img);
         }
     }
 
     /** Renders the top card of the lower building stack with a count badge; clicking opens the selection popup. */
     private void drawLowerBuilding() {
         ArrayList<BuildingCard> cardList = new ArrayList<>(controller.getMiniModel().lowerListBuildingCards);
         if (!cardList.isEmpty()) {
             StackPane img = createCard(cardList.getLast(), true, true, lowerList);
             Label label = new Label(String.valueOf(cardList.size()));
             label.setTranslateY(-56);
             label.setTranslateX(10);
             label.getStyleClass().add("label-large");
             StackPane.setAlignment(label, Pos.TOP_RIGHT);
             img.getChildren().add(label);
             img.setOnMouseClicked(e -> openPopupLowerBuilding(cardList));
             lowerList.getChildren().add(img);
         }
     }
 
     /** Renders a single card-type pile in the hand area, or an invisible placeholder if the list is empty. */
     private void drawMyHandList(ArrayList<? extends PlayableCard> cardList) {
         if (!cardList.isEmpty()) {
             StackPane img = createCard(cardList.getLast(), true, true, myHand);
             Label label = new Label(String.valueOf(cardList.size()));
             label.setTranslateY(-56);
             label.setTranslateX(10);
             label.getStyleClass().add("label-large");
             StackPane.setAlignment(label, Pos.TOP_RIGHT);
             img.getChildren().add(label);
             img.setOnMouseClicked(e -> openPopup(new ArrayList<>(cardList)));
             myHand.getChildren().add(img);
         } else {
             Region placeholder = new Region();
             placeholder.prefHeightProperty().bind(myHand.getScene().heightProperty().subtract(56).divide(4).multiply(0.90));
             placeholder.prefWidthProperty().bind(placeholder.prefHeightProperty().multiply(0.675));
             placeholder.setStyle("-fx-background-color: transparent;");
             myHand.getChildren().add(placeholder);
         }
     }
 
     /** Returns a copy of the named card collection for {@code username}. */
     private ArrayList<PlayableCard> getPlayerCards(String username, String type) {
         Player p = controller.getMiniModel().players.get(username);
         return switch (type) {
             case "artists"   -> new ArrayList<>(p.getArtists());
             case "gatherers" -> new ArrayList<>(p.getGatherers());
             case "inventors" -> new ArrayList<>(p.getInventors());
             case "builders"  -> new ArrayList<>(p.getBuilders());
             case "shamans"   -> new ArrayList<>(p.getShamans());
             case "hunters"   -> new ArrayList<>(p.getHunters());
             case "building"  -> new ArrayList<>(p.getBuildingCards());
             default          -> new ArrayList<>();
         };
     }
 
 
     // ==== ABSOLUTES ====
     /** Clears and rebuilds the upper tribe row with click handlers for drawing, then appends the upper building stack. */
     private void renderUpper() {
         upperList.setSpacing(14);
         upperList.setAlignment(Pos.CENTER);
         upperList.getChildren().clear();
 
         int i = 0;
         for (TribeCard card : controller.getMiniModel().upperListTribeCards) {
             final int index = i;
             StackPane img;
             if(!card.isEventCard()) {
                 img = createCard(card, true, true, upperList);
                 img.setOnMouseClicked(e -> controller.drawUpperTribeCard(index));
             } else {
                 img = createCard(card, false, false, upperList);
             }
             upperList.getChildren().add(img);
             i++;
         }
         drawUpperBuilding();
     }
 
     /** Clears and rebuilds the lower tribe row with click handlers for drawing, then appends the lower building stack. */
     private void renderLower() {
         lowerList.setSpacing(14);
         lowerList.setAlignment(Pos.CENTER);
         lowerList.getChildren().clear();
 
         int i = 0;
         for (TribeCard card : controller.getMiniModel().lowerListTribeCards) {
             final int index = i;
             StackPane img;
             if(!card.isEventCard()) {
                 img = createCard(card, true, true, lowerList);
                 img.setOnMouseClicked(e -> controller.drawLowerTribeCard(index));
             } else {
                 img = createCard(card, false, false, lowerList);
             }
             lowerList.getChildren().add(img);
             i++;
         }
         drawLowerBuilding();
     }
 
     /** Sets the full-cover background image on the main HBox container. */
     private void renderBackground() {
         BackgroundSize size = new BackgroundSize(
                 BackgroundSize.AUTO, BackgroundSize.AUTO,
                 false, false, true, true
         );
         mainHBox.setBackground(new Background(new BackgroundImage(
                 loadImage("/GUIImages/Background.png"),
                 BackgroundRepeat.NO_REPEAT,
                 BackgroundRepeat.NO_REPEAT,
                 BackgroundPosition.CENTER,
                 size
         )));
     }
 
     /** Clears the board area and orchestrates deck, order card, and slot-map rendering. */
     private void renderBoard() {
         board.setSpacing(14);
         board.setAlignment(Pos.CENTER);
         board.getChildren().clear();
         renderDeck();
         renderOrder();
         renderSlotMap();
     }
 
     /** Adds the era-specific deck back image to the board. */
     private void renderDeck() {
         String path = switch (controller.getMiniModel().currentState.getEra()) {
             case 2  -> "/GUIImages/Backs/back-030.png";
             case 3  -> "/GUIImages/Backs/back-058.png";
             default -> "/GUIImages/Backs/back-001.png";
         };
         ImageView back = new ImageView(loadImage(path));
         back.fitHeightProperty().bind(board.sceneProperty().get().heightProperty().subtract(56).divide(4).multiply(0.94));
         back.setPreserveRatio(true);
         addClip(back);
         board.getChildren().add(back);
     }
 
     /** Renders the order card and overlays each player's totem at their proportional position. */
     private void renderOrder() {
         int numPlayers = controller.getMiniModel().players.size();
         StackPane card = createOrder(Integer.toString(numPlayers));
 
         Map<Integer, double[]> slotPositions = Map.of(
                 2, new double[]{0.229, 0.413},
                 3, new double[]{0.183, 0.367, 0.548},
                 4, new double[]{0.142, 0.316, 0.503, 0.690},
                 5, new double[]{0.066, 0.251, 0.433, 0.617, 0.802}
         );
         double[] positions = slotPositions.get(numPlayers);
         if (positions == null) return;
 
         Pane overlay = new Pane();
         overlay.setPickOnBounds(false);
         overlay.setMouseTransparent(true);
 
         for (int i = 0; i < controller.getMiniModel().orderLogicCard.getPlayerList().size(); i++) {
             OrderPlayer op = controller.getMiniModel().orderLogicCard.getPlayerList().get(i);
 
             ImageView totem = new ImageView(loadImage(
                     "/GUIImages/Totems/totem_" + op.getPlayer().getTotem().toString().toLowerCase(Locale.ROOT) + ".png"
             ));
             totem.setPreserveRatio(true);
 
             if (op.isPlayed()) {
                 totem.setVisible(false);
             }
 
             final double yRatio = positions[i];
 
             overlay.prefHeightProperty().bind(card.heightProperty());
             overlay.prefWidthProperty().bind(card.widthProperty());
 
             totem.fitHeightProperty().bind(card.heightProperty().multiply(0.323));
 
             card.heightProperty().addListener((obs, ov, nv) -> {
                 totem.setLayoutY(nv.doubleValue() * (yRatio-0.196));
             });
             card.widthProperty().addListener((obs, ov, nv) -> {
                 totem.setLayoutX(nv.doubleValue() * 0.364);
             });
 
             if (card.getHeight() > 0) totem.setLayoutY(card.getHeight() *(yRatio-0.196));
             if (card.getWidth() > 0)  totem.setLayoutX(card.getWidth() * 0.364);
 
             overlay.getChildren().add(totem);
         }
 
         card.getChildren().add(overlay);
         board.getChildren().add(card);
     }
 
     /** Adds a clickable slot widget for each entry in the slot-player map. */
     private void renderSlotMap() {
         int i = 0;
         for (Map.Entry<Slot, Player> entry : controller.getMiniModel().slotPlayerMap.entrySet()) {
             Slot slot = entry.getKey();
             final int index = i;
             StackPane img = createSlot(slot, true, true);
             img.setOnMouseClicked(e -> controller.slotChoice(index));
             board.getChildren().add(img);
             i++;
         }
     }
 
     /** Clears and re-renders all seven card-type piles in the local player's hand area. */
     private void renderMyHand() {
         myHand.getChildren().clear();
         myHand.setSpacing(14);
         myHand.setAlignment(Pos.CENTER);
 
         Player me = controller.getMiniModel().players.get(controller.getMyUsername());
         drawMyHandList(me.getArtists());
         drawMyHandList(me.getGatherers());
         drawMyHandList(me.getInventors());
         drawMyHandList(me.getBuilders());
         drawMyHandList(me.getShamans());
         drawMyHandList(me.getHunters());
         drawMyHandList(me.getBuildingCards());
     }
 
 
     // ==== POPUP ====
     /** Creates and configures the shared card-preview popup container. */
     private void initPopup() {
         popupCards = new HBox(10);
         popupCards.setAlignment(Pos.CENTER);
         popupCards.setPadding(new Insets(10));
         popupCards.setStyle(
                 "-fx-background-color: #b42224;" +
                         "-fx-border-color: black;" +
                         "-fx-border-width: 2;" +
                         "-fx-border-radius: 10;" +
                         "-fx-background-radius: 10;"
         );
 
         popup.getContent().add(popupCards);
         popup.setAutoHide(false);
         popup.addEventHandler(Event.ANY, e -> {
             if (popup.getScene() != null) {
                 popup.getScene().setFill(Color.TRANSPARENT);
             }
         });
     }
 
     /** Centers the popup over the current window and makes it visible. */
     private void showPopup() {
         Window window = myHand.getScene().getWindow();
         popup.show(window, 0, 0);
         popup.getScene().setFill(Color.TRANSPARENT);
         popup.setX(window.getX() + (window.getWidth()  - popup.getWidth())  / 2);
         popup.setY(window.getY() + (window.getHeight() - popup.getHeight()) / 2);
     }
 
     /** Populates the popup with non-clickable card images and shows it. */
     private void openPopup(ArrayList<? extends PlayableCard> cardList) {
         popupCards.getChildren().clear();
         for (PlayableCard card : cardList) {
             popupCards.getChildren().add(createCardPopup(card, false, false));
         }
         showPopup();
     }
 
     /** Populates the popup with clickable upper-building cards; clicking one draws it via the controller. */
     private void openPopupUpperBuilding(ArrayList<BuildingCard> cardList) {
         popupCards.getChildren().clear();
         for (int i = 0; i < cardList.size(); i++) {
             final int index = i;
             StackPane img = createCardPopup(cardList.get(i), true, false);
             img.setOnMouseClicked(e -> {
                 popup.hide();
                 controller.drawUpperBuildingCard(index);
             });
             popupCards.getChildren().add(img);
         }
         showPopup();
     }
 
     /** Populates the popup with clickable lower-building cards; clicking one draws it via the controller. */
     private void openPopupLowerBuilding(ArrayList<BuildingCard> cardList) {
         popupCards.getChildren().clear();
         for (int i = 0; i < cardList.size(); i++) {
             final int index = i;
             StackPane img = createCardPopup(cardList.get(i), true, false);
             img.setOnMouseClicked(e -> {
                 popup.hide();
                 controller.drawLowerBuildingCard(index);
             });
             popupCards.getChildren().add(img);
         }
         showPopup();
     }
 
     /** Shows the deck details/rules card in the popup. */
     private void openDetailsPopup() {
         popupCards.getChildren().clear();
         ImageView img = new ImageView(loadImage("/GUIImages/Backs/back-118.png"));
         img.setFitHeight(290);
         img.setPreserveRatio(true);
         addClip(img);
         StackPane wrapper = new StackPane(img);
         addShadow(wrapper);
         popupCards.getChildren().add(wrapper);
         showPopup();
     }
 
     /** Creates a fixed-height {@link StackPane} card widget suitable for use inside the popup. */
     private StackPane createCardPopup(PlayableCard card, boolean withZoom, boolean withShadow) {
         ImageView img = new ImageView(loadImage("/GUIImages/Fronts/card-" + card.getIdIMG() + ".png"));
         img.setFitHeight(200);
         img.setPreserveRatio(true);
         addClip(img);
         StackPane wrapper = new StackPane(img);
         if (withShadow) addShadow(wrapper);
         if (withZoom)   addHoverZoom(wrapper);
         return wrapper;
     }
 }