From e6c160e7990c71275d0413be77f437911bfd2fdb Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:54:15 +1030 Subject: [PATCH 01/16] Add token grouping feature for identical permanents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visually groups identical permanents into stacks with count badges. Groups of 5+ show a ×N badge; clicking it selects all for batch attack/block. Configurable via Game > Card Overlays menu with three modes: Off, Tokens & Creatures, All Permanents. Co-Authored-By: Claude Opus 4.6 --- .../screens/match/menus/CardOverlaysMenu.java | 29 +++++++ .../java/forge/view/arcane/CardPanel.java | 49 ++++++++++++ .../main/java/forge/view/arcane/PlayArea.java | 78 +++++++++++++++---- forge-gui/res/languages/en-US.properties | 2 + .../gamemodes/match/input/InputBlock.java | 26 +++++++ .../properties/ForgePreferences.java | 1 + 6 files changed, 172 insertions(+), 13 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java index c6389188311..a8e01b50837 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java @@ -4,9 +4,11 @@ import java.awt.event.ActionListener; import java.awt.event.KeyEvent; +import javax.swing.ButtonGroup; import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JMenuItem; +import javax.swing.JRadioButtonMenuItem; import javax.swing.SwingUtilities; import forge.localinstance.properties.ForgePreferences; @@ -34,9 +36,36 @@ public JMenu getMenu() { menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblPowerOrToughness"), FPref.UI_OVERLAY_CARD_POWER)); menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblCardID"), FPref.UI_OVERLAY_CARD_ID)); menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblAbilityIcon"), FPref.UI_OVERLAY_ABILITY_ICONS)); + menu.addSeparator(); + menu.add(getSubmenu_GroupPermanents()); return menu; } + private JMenu getSubmenu_GroupPermanents() { + final Localizer localizer = Localizer.getInstance(); + final JMenu submenu = new JMenu(localizer.getMessage("cbpGroupPermanents")); + final ButtonGroup group = new ButtonGroup(); + final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS); + + final String[] options = {"Off", "Tokens & Creatures", "All Permanents"}; + for (String option : options) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(option); + item.setSelected(option.equals(current)); + item.addActionListener(getGroupPermanentsAction(option)); + group.add(item); + submenu.add(item); + } + return submenu; + } + + private ActionListener getGroupPermanentsAction(final String value) { + return e -> { + prefs.setPref(FPref.UI_GROUP_PERMANENTS, value); + prefs.save(); + SwingUtilities.invokeLater(matchUI::repaintCardOverlays); + }; + } + private JMenuItem getMenuItem_CardOverlay(String menuCaption, FPref pref) { JCheckBoxMenuItem menu = new JCheckBoxMenuItem(menuCaption); menu.setState(prefs.getPrefBoolean(pref)); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index d2377eb3739..d196df0b818 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -115,6 +115,7 @@ public class CardPanel extends SkinnedPanel implements CardContainer, IDisposabl private boolean isSelected; private boolean hasFlash; private CachedCardImage cachedImage; + private int groupCount; private static Font smallCounterFont; private static Font largeCounterFont; @@ -250,6 +251,13 @@ public final void setDisplayEnabled(final boolean displayEnabled0) { displayEnabled = displayEnabled0; } + public int getGroupCount() { + return groupCount; + } + public void setGroupCount(int count) { + this.groupCount = count; + } + public final void setAnimationPanel(final boolean isAnimationPanel0) { isAnimationPanel = isAnimationPanel0; } @@ -385,6 +393,9 @@ protected final void paintChildren(final Graphics g) { } displayIconOverlay(g, canShow); + if (groupCount >= 5) { + drawGroupCountBadge(g); + } if (canShow) { drawFoilEffect(g, card, cardXOffset, cardYOffset, cardWidth, cardHeight, Math.round(cardWidth * BLACK_BORDER_SIZE)); @@ -496,6 +507,44 @@ private void displayCardNameOverlay(final boolean isVisible, final Dimension img titleText.setVisible(isVisible); } + private void drawGroupCountBadge(final Graphics g) { + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + String text = "\u00D7" + groupCount; + Font badgeFont = new Font("Dialog", Font.BOLD, Math.max(10, cardWidth / 5)); + FontMetrics fm = g2d.getFontMetrics(badgeFont); + + int textWidth = fm.stringWidth(text); + int textHeight = fm.getAscent(); + int padX = Math.max(4, cardWidth / 20); + int padY = Math.max(2, cardHeight / 30); + int badgeWidth = textWidth + padX * 2; + int badgeHeight = textHeight + padY * 2; + int badgeX = cardXOffset + 2; + int badgeY = cardYOffset + 2; + + g2d.setColor(new Color(0, 0, 0, 180)); + g2d.fillRoundRect(badgeX, badgeY, badgeWidth, badgeHeight, 6, 6); + + g2d.setColor(Color.WHITE); + g2d.setFont(badgeFont); + g2d.drawString(text, badgeX + padX, badgeY + padY + textHeight); + } + + public boolean isBadgeHit(int mouseX, int mouseY) { + if (groupCount < 5) { + return false; + } + // Badge region matches drawGroupCountBadge positioning + int badgeWidth = Math.max(30, cardWidth / 3); + int badgeHeight = Math.max(18, cardHeight / 8); + int badgeX = cardXOffset + 2; + int badgeY = cardYOffset + 2; + return mouseX >= badgeX && mouseX <= badgeX + badgeWidth + && mouseY >= badgeY && mouseY <= badgeY + badgeHeight; + } + private void displayIconOverlay(final Graphics g, final boolean canShow) { if (canShow && showCardManaCostOverlay() && cardWidth < 200) { final boolean showSplitMana = card.isSplitCard() && card.getZone() != ZoneType.Battlefield; diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 4cebb525099..0fe5d5169c3 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -76,6 +76,8 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private boolean makeTokenRow = true; private boolean stackCreatures = false; + private boolean groupTokensAndCreatures; + private boolean groupAll; public PlayArea(final CMatchUI matchUI, final FScrollPane scrollPane, final boolean mirror, final PlayerView player, final ZoneType zone) { super(matchUI, scrollPane); @@ -85,6 +87,13 @@ public PlayArea(final CMatchUI matchUI, final FScrollPane scrollPane, final bool this.zone = zone; this.makeTokenRow = FModel.getPreferences().getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); this.stackCreatures = FModel.getPreferences().getPrefBoolean(FPref.UI_STACK_CREATURES); + updateGroupScope(); + } + + private void updateGroupScope() { + String groupScope = FModel.getPreferences().getPref(FPref.UI_GROUP_PERMANENTS); + this.groupTokensAndCreatures = "Tokens & Creatures".equals(groupScope) || "All Permanents".equals(groupScope); + this.groupAll = "All Permanents".equals(groupScope); } private CardStackRow collectAllLands(List remainingPanels) { @@ -117,7 +126,9 @@ private CardStackRow collectAllLands(List remainingPanels) { if (!panel.getAttachedPanels().isEmpty() || !panel.getCard().hasSameCounters(firstPanel.getCard()) || firstPanel.getCard().hasCardAttachments() - || (stack.size() == STACK_MAX_LANDS)) { + || (groupAll && card.isTapped() != firstPanel.getCard().isTapped()) + || (groupAll && card.getDamage() != firstPanel.getCard().getDamage()) + || (!groupAll && stack.size() == STACK_MAX_LANDS)) { // If this land has attachments or the stack is full, // put it to the right. insertIndex = i + 1; @@ -175,7 +186,9 @@ private CardStackRow collectAllTokens(List remainingPanels) { || (card.isSick() != firstCard.isSick()) || !card.hasSamePT(firstCard) || !(card.getText().equals(firstCard.getText())) - || (stack.size() == STACK_MAX_TOKENS)) { + || (groupTokensAndCreatures && card.isTapped() != firstCard.isTapped()) + || (groupTokensAndCreatures && card.getDamage() != firstCard.getDamage()) + || (!groupTokensAndCreatures && stack.size() == STACK_MAX_TOKENS)) { // If this token has attachments or the stack is full, // put it to the right. insertIndex = i + 1; @@ -200,7 +213,7 @@ private CardStackRow collectAllTokens(List remainingPanels) { } private CardStackRow collectAllCreatures(List remainingPanels) { - if(!this.stackCreatures) + if(!this.stackCreatures && !this.groupTokensAndCreatures) return collectUnstacked(remainingPanels, RowType.Creature); final CardStackRow allCreatures = new CardStackRow(); outerLoop: @@ -232,7 +245,10 @@ private CardStackRow collectAllCreatures(List remainingPanels) { || !card.hasSameCounters(firstCard) || (card.isSick() != firstCard.isSick()) || !card.hasSamePT(firstCard) - || (stack.size() == STACK_MAX_CREATURES)) { + || (groupTokensAndCreatures && card.isTapped() != firstCard.isTapped()) + || (groupTokensAndCreatures && card.getDamage() != firstCard.getDamage()) + || (groupTokensAndCreatures && !(card.getText().equals(firstCard.getText()))) + || (!groupTokensAndCreatures && stack.size() == STACK_MAX_CREATURES)) { // If this creature has attachments or the stack is full, // put it to the right. insertIndex = i + 1; @@ -335,11 +351,13 @@ private CardStackRow collectAllOthers(final List remainingPanels) { final CardStateView otherState = otherCard.getCurrentState(); final CardView thisCard = panel.getCard(); final CardStateView thisState = thisCard.getCurrentState(); - if (otherState.getOracleName().equals(thisState.getOracleName()) && s.size() < STACK_MAX_OTHERS) { + if (otherState.getOracleName().equals(thisState.getOracleName()) && (groupAll || s.size() < STACK_MAX_OTHERS)) { if (panel.getAttachedPanels().isEmpty() && thisCard.hasSameCounters(otherCard) && (thisCard.isSick() == otherCard.isSick()) - && (thisCard.isCloned() == otherCard.isCloned())) { + && (thisCard.isCloned() == otherCard.isCloned()) + && (!groupAll || thisCard.isTapped() == otherCard.isTapped()) + && (!groupAll || thisCard.getDamage() == otherCard.getDamage())) { s.add(panel); continue outerLoop; } @@ -365,6 +383,7 @@ public final CardPanel addCard(final CardView card) { @Override public final void doLayout() { + updateGroupScope(); final Rectangle rect = this.getScrollPane().getVisibleRect(); this.playAreaWidth = rect.width; @@ -472,14 +491,39 @@ private void positionAllCards(List template) { x -= r.getWidth(); } } + boolean grouping = groupTokensAndCreatures || groupAll; + int maxVisible = 4; + + // Reset groupCount on all panels in this stack + for (CardPanel p : stack) { p.setGroupCount(0); } + for (int panelIndex = 0, panelCount = stack.size(); panelIndex < panelCount; panelIndex++) { final CardPanel panel = stack.get(panelIndex); - final int stackPosition = panelCount - panelIndex - 1; this.setComponentZOrder(panel, panelIndex); - final int panelX = x + (stackPosition * this.stackSpacingX); - final int panelY = y + (stackPosition * this.stackSpacingY); - //System.out.println("... placinng " + panel.getCard() + " @ (" + panelX + ", " + panelY + ")"); + + int visualPos; + boolean hidden; + if (grouping && panelCount > maxVisible) { + if (panelIndex < maxVisible) { + visualPos = maxVisible - 1 - panelIndex; + hidden = false; + } else { + visualPos = 0; + hidden = true; + } + } else { + visualPos = panelCount - panelIndex - 1; + hidden = false; + } + + final int panelX = x + (visualPos * this.stackSpacingX); + final int panelY = y + (visualPos * this.stackSpacingY); panel.setCardBounds(panelX, panelY, this.getCardWidth(), this.cardHeight); + panel.setDisplayEnabled(!hidden); + } + // Set group count on top card for badge rendering + if (grouping && stack.size() > 1) { + stack.get(0).setGroupCount(stack.size()); } rowBottom = Math.max(rowBottom, y + stack.getHeight()); x += stack.getWidth(); @@ -651,7 +695,11 @@ public final void mouseOver(final CardPanel panel, final MouseEvent evt) { @Override public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) { - selectCard(panel, new MouseTriggerEvent(evt), evt.isShiftDown()); //select entire stack if shift key down + boolean selectAll = evt.isShiftDown(); + if (!selectAll && panel.getGroupCount() >= 5) { + selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); + } + selectCard(panel, new MouseTriggerEvent(evt), selectAll); if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { return; } @@ -928,12 +976,16 @@ private void addAttachedPanels(final CardPanel panel) { } private int getWidth() { - return PlayArea.this.cardWidth + ((this.size() - 1) * PlayArea.this.stackSpacingX) + int visualCount = (PlayArea.this.groupTokensAndCreatures || PlayArea.this.groupAll) + ? Math.min(this.size(), 4) : this.size(); + return PlayArea.this.cardWidth + ((visualCount - 1) * PlayArea.this.stackSpacingX) + PlayArea.this.cardSpacingX; } private int getHeight() { - return PlayArea.this.cardHeight + ((this.size() - 1) * PlayArea.this.stackSpacingY) + int visualCount = (PlayArea.this.groupTokensAndCreatures || PlayArea.this.groupAll) + ? Math.min(this.size(), 4) : this.size(); + return PlayArea.this.cardHeight + ((visualCount - 1) * PlayArea.this.stackSpacingY) + PlayArea.this.cardSpacingY; } } diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 5dde6bb379b..0f476215700 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -242,6 +242,8 @@ nlCardTextHideReminder=When render card images, skip rendering reminder text. nlOpenPacksIndiv=When opening Fat Packs and Booster Boxes, booster packs will be opened and displayed one at a time. nlTokensInSeparateRow=Displays tokens in a separate row on the battlefield below the non-token creatures. nlStackCreatures=Stacks identical creatures on the battlefield like lands, artifacts, and enchantments. +cbpGroupPermanents=Group Identical Permanents +nlGroupPermanents=Group identical permanents into stacks with count badges. Off: legacy stacking. Tokens & Creatures: group tokens and creatures. All Permanents: group all permanent types. nlTimedTargOverlay=Enables throttling-based optimization of targeting overlay to reduce CPU use (only disable if you experience choppiness on older hardware, requires starting a new match). nlCounterDisplayType=Selects the style of the in-game counter display for cards. Text-based is a new tab-like display on the cards. Image-based is the old counter image. Hybrid displays both at once. nlCounterDisplayLocation=Determines where to position the text-based counters on the card: close to the top or close to the bottom. diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java index e16f50f1739..dd88a221eb6 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java @@ -110,6 +110,14 @@ public final boolean onCardSelected(final Card card, final List otherCards if (triggerEvent != null && triggerEvent.getButton() == 3 && card.getController() == defender) { combat.removeFromCombat(card); card.getGame().getMatch().fireEvent(new UiEventBlockerAssigned(CardView.get(card), null)); + if (otherCardsToSelect != null) { + for (Card c : otherCardsToSelect) { + if (c.getController() == defender) { + combat.removeFromCombat(c); + c.getGame().getMatch().fireEvent(new UiEventBlockerAssigned(CardView.get(c), null)); + } + } + } isCorrectAction = true; } else { // is attacking? @@ -123,6 +131,14 @@ public final boolean onCardSelected(final Card card, final List otherCards //if creature already blocking current attacker, remove blocker from combat combat.removeBlockAssignment(currentAttacker, card); card.getGame().getMatch().fireEvent(new UiEventBlockerAssigned(CardView.get(card), null)); + if (otherCardsToSelect != null) { + for (Card c : otherCardsToSelect) { + if (combat.isBlocking(c, currentAttacker)) { + combat.removeBlockAssignment(currentAttacker, c); + c.getGame().getMatch().fireEvent(new UiEventBlockerAssigned(CardView.get(c), null)); + } + } + } isCorrectAction = true; } else { isCorrectAction = CombatUtil.canBlock(currentAttacker, card, combat); @@ -131,6 +147,16 @@ public final boolean onCardSelected(final Card card, final List otherCards card.getGame().getMatch().fireEvent(new UiEventBlockerAssigned( CardView.get(card), CardView.get(currentAttacker))); + if (otherCardsToSelect != null) { + for (Card c : otherCardsToSelect) { + if (CombatUtil.canBlock(currentAttacker, c, combat)) { + combat.addBlocker(currentAttacker, c); + c.getGame().getMatch().fireEvent(new UiEventBlockerAssigned( + CardView.get(c), + CardView.get(currentAttacker))); + } + } + } } } } diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index baf8d3a95ef..ea75c26b249 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -97,6 +97,7 @@ public enum FPref implements PreferencesStore.IPref { UI_SR_OPTIMIZE ("false"), UI_OPEN_PACKS_INDIV ("false"), UI_STACK_CREATURES ("false"), + UI_GROUP_PERMANENTS ("Tokens & Creatures"), UI_TOKENS_IN_SEPARATE_ROW("false"), UI_UPLOAD_DRAFT ("false"), UI_SCALE_LARGER ("true"), From 861deb8d2d6e1d48b2787347de66affc7a5c61d3 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:09:07 +1030 Subject: [PATCH 02/16] Fix group badge click detection coordinate system isBadgeHit() was comparing container-relative mouse coordinates against panel-internal badge coordinates, so the hit test always failed. Use getCardX()/getCardY() to convert to the correct coordinate space. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/forge/view/arcane/CardPanel.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index d196df0b818..27c217bc981 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -536,11 +536,14 @@ public boolean isBadgeHit(int mouseX, int mouseY) { if (groupCount < 5) { return false; } - // Badge region matches drawGroupCountBadge positioning + // Mouse coordinates are container-relative (from PlayArea), so use + // getCardX()/getCardY() which convert panel-internal offsets to + // container coordinates (getX() + cardXOffset). + int badgeX = getCardX() + 2; + int badgeY = getCardY() + 2; + // Use generous hit area to cover the font-metrics-based badge size int badgeWidth = Math.max(30, cardWidth / 3); - int badgeHeight = Math.max(18, cardHeight / 8); - int badgeX = cardXOffset + 2; - int badgeY = cardYOffset + 2; + int badgeHeight = Math.max(20, cardHeight / 6); return mouseX >= badgeX && mouseX <= badgeX + badgeWidth && mouseY >= badgeY && mouseY <= badgeY + badgeHeight; } From 57d64a4f947a920866acb32775cdf4dde9e4196f Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:54:27 +1030 Subject: [PATCH 03/16] Add blocker grouping by combat assignment and improve attacker declaration UX - Group blockers by which attacker they're assigned to block (separate piles per attacker) - Split cards from groups for attacker/blocker declaration with proper regrouping - Only allow splitting cards the game accepts (skip summoning-sick creatures) - Cards regroup with other tapped attackers after declaration via deferred layout - Move Group Permanents menu from Card Overlays submenu to Game menu - Group Permanents menu immediately refreshes the play area layout Co-Authored-By: Claude Opus 4.6 --- .../screens/match/menus/CardOverlaysMenu.java | 29 ----- .../forge/screens/match/menus/GameMenu.java | 32 +++++ .../main/java/forge/view/arcane/PlayArea.java | 121 +++++++++++++++++- 3 files changed, 152 insertions(+), 30 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java index a8e01b50837..c6389188311 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/CardOverlaysMenu.java @@ -4,11 +4,9 @@ import java.awt.event.ActionListener; import java.awt.event.KeyEvent; -import javax.swing.ButtonGroup; import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JMenuItem; -import javax.swing.JRadioButtonMenuItem; import javax.swing.SwingUtilities; import forge.localinstance.properties.ForgePreferences; @@ -36,36 +34,9 @@ public JMenu getMenu() { menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblPowerOrToughness"), FPref.UI_OVERLAY_CARD_POWER)); menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblCardID"), FPref.UI_OVERLAY_CARD_ID)); menu.add(getMenuItem_CardOverlay(Localizer.getInstance().getMessage("lblAbilityIcon"), FPref.UI_OVERLAY_ABILITY_ICONS)); - menu.addSeparator(); - menu.add(getSubmenu_GroupPermanents()); return menu; } - private JMenu getSubmenu_GroupPermanents() { - final Localizer localizer = Localizer.getInstance(); - final JMenu submenu = new JMenu(localizer.getMessage("cbpGroupPermanents")); - final ButtonGroup group = new ButtonGroup(); - final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS); - - final String[] options = {"Off", "Tokens & Creatures", "All Permanents"}; - for (String option : options) { - JRadioButtonMenuItem item = new JRadioButtonMenuItem(option); - item.setSelected(option.equals(current)); - item.addActionListener(getGroupPermanentsAction(option)); - group.add(item); - submenu.add(item); - } - return submenu; - } - - private ActionListener getGroupPermanentsAction(final String value) { - return e -> { - prefs.setPref(FPref.UI_GROUP_PERMANENTS, value); - prefs.save(); - SwingUtilities.invokeLater(matchUI::repaintCardOverlays); - }; - } - private JMenuItem getMenuItem_CardOverlay(String menuCaption, FPref pref) { JCheckBoxMenuItem menu = new JCheckBoxMenuItem(menuCaption); menu.setState(prefs.getPrefBoolean(pref)); diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 3d7db593366..76025ba0305 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -6,6 +6,7 @@ import javax.swing.ButtonGroup; import javax.swing.JMenu; import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; import com.google.common.primitives.Ints; @@ -16,6 +17,7 @@ import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.screens.match.VAutoYields; +import forge.screens.match.views.VField; import forge.screens.match.controllers.CDock.ArcState; import forge.toolbox.FSkin.SkinIcon; import forge.toolbox.FSkin.SkinnedCheckBoxMenuItem; @@ -49,6 +51,7 @@ public JMenu getMenu() { menu.addSeparator(); menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); + menu.add(getSubmenu_GroupPermanents()); menu.add(getMenuItem_AutoYields()); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); @@ -204,4 +207,33 @@ private SkinnedMenuItem getMenuItem_ViewDeckList() { private ActionListener getViewDeckListAction() { return e -> matchUI.viewDeckList(); } + + private SkinnedMenu getSubmenu_GroupPermanents() { + final Localizer localizer = Localizer.getInstance(); + final SkinnedMenu submenu = new SkinnedMenu(localizer.getMessage("cbpGroupPermanents")); + final ButtonGroup group = new ButtonGroup(); + final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS); + + final String[] options = {"Off", "Tokens & Creatures", "All Permanents"}; + for (String option : options) { + SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(option); + item.setSelected(option.equals(current)); + item.addActionListener(getGroupPermanentsAction(option)); + group.add(item); + submenu.add(item); + } + return submenu; + } + + private ActionListener getGroupPermanentsAction(final String value) { + return e -> { + prefs.setPref(FPref.UI_GROUP_PERMANENTS, value); + prefs.save(); + SwingUtilities.invokeLater(() -> { + for (final VField f : matchUI.getFieldViews()) { + f.getTabletop().doLayout(); + } + }); + }; + } } diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 0fe5d5169c3..ce52fd261bd 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -26,6 +26,8 @@ import com.google.common.collect.Lists; import forge.game.card.CardView; +import forge.game.combat.CombatView; +import forge.util.collect.FCollection; import forge.game.card.CardView.CardStateView; import forge.game.player.PlayerView; import forge.game.zone.ZoneType; @@ -64,6 +66,12 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private final boolean mirror; + // Card IDs that have been split from their groups via individual click. + // Survives doLayout() calls; cleared on game state changes via update(). + private final Set splitCardIds = new HashSet<>(); + // Blocker card ID → attacker card ID; rebuilt each doLayout() from CombatView. + private Map blockerAssignments = Collections.emptyMap(); + // Computed in layout. private List rows = new ArrayList<>(); private int cardWidth, cardHeight; @@ -123,6 +131,20 @@ private CardStackRow collectAllLands(List remainingPanels) { insertIndex = i; break; } + // Split cards can't group with non-split cards + boolean cardIsSplit = splitCardIds.contains(card.getId()); + boolean stackIsSplit = splitCardIds.contains(firstPanel.getCard().getId()); + if (cardIsSplit != stackIsSplit) { + insertIndex = i + 1; + continue; + } + // Blockers assigned to different attackers can't group together + int cardTarget = blockerAssignments.getOrDefault(card.getId(), 0); + int stackTarget = blockerAssignments.getOrDefault(firstPanel.getCard().getId(), 0); + if (cardTarget != stackTarget) { + insertIndex = i + 1; + continue; + } if (!panel.getAttachedPanels().isEmpty() || !panel.getCard().hasSameCounters(firstPanel.getCard()) || firstPanel.getCard().hasCardAttachments() @@ -181,6 +203,20 @@ private CardStackRow collectAllTokens(List remainingPanels) { break; } + // Split cards can't group with non-split cards + boolean cardIsSplit = splitCardIds.contains(card.getId()); + boolean stackIsSplit = splitCardIds.contains(firstCard.getId()); + if (cardIsSplit != stackIsSplit) { + insertIndex = i + 1; + continue; + } + // Blockers assigned to different attackers can't group together + int cardTarget = blockerAssignments.getOrDefault(card.getId(), 0); + int stackTarget = blockerAssignments.getOrDefault(firstCard.getId(), 0); + if (cardTarget != stackTarget) { + insertIndex = i + 1; + continue; + } if (!panel.getAttachedPanels().isEmpty() || !card.hasSameCounters(firstPanel.getCard()) || (card.isSick() != firstCard.isSick()) @@ -240,6 +276,20 @@ private CardStackRow collectAllCreatures(List remainingPanels) { insertIndex = i; break; } + // Split cards can't group with non-split cards + boolean cardIsSplit = splitCardIds.contains(card.getId()); + boolean stackIsSplit = splitCardIds.contains(firstCard.getId()); + if (cardIsSplit != stackIsSplit) { + insertIndex = i + 1; + continue; + } + // Blockers assigned to different attackers can't group together + int cardTarget = blockerAssignments.getOrDefault(card.getId(), 0); + int stackTarget = blockerAssignments.getOrDefault(firstCard.getId(), 0); + if (cardTarget != stackTarget) { + insertIndex = i + 1; + continue; + } if (!panel.getAttachedPanels().isEmpty() || card.isCloned() || !card.hasSameCounters(firstCard) @@ -352,6 +402,18 @@ private CardStackRow collectAllOthers(final List remainingPanels) { final CardView thisCard = panel.getCard(); final CardStateView thisState = thisCard.getCurrentState(); if (otherState.getOracleName().equals(thisState.getOracleName()) && (groupAll || s.size() < STACK_MAX_OTHERS)) { + // Split cards can't group with non-split cards + boolean cardIsSplit = splitCardIds.contains(thisCard.getId()); + boolean stackIsSplit = splitCardIds.contains(otherCard.getId()); + if (cardIsSplit != stackIsSplit) { + continue; + } + // Blockers assigned to different attackers can't group together + int cardTarget = blockerAssignments.getOrDefault(thisCard.getId(), 0); + int stackTarget = blockerAssignments.getOrDefault(otherCard.getId(), 0); + if (cardTarget != stackTarget) { + continue; + } if (panel.getAttachedPanels().isEmpty() && thisCard.hasSameCounters(otherCard) && (thisCard.isSick() == otherCard.isSick()) @@ -381,9 +443,28 @@ public final CardPanel addCard(final CardView card) { return placeholder; } + private Map buildBlockerAssignments() { + try { + CombatView combat = getMatchUI().getGameView().getCombat(); + if (combat == null) { return Collections.emptyMap(); } + Map assignments = new HashMap<>(); + for (CardView attacker : combat.getAttackers()) { + FCollection blockers = combat.getPlannedBlockers(attacker); + if (blockers == null) { continue; } + for (CardView blocker : blockers) { + assignments.put(blocker.getId(), attacker.getId()); + } + } + return assignments; + } catch (Exception e) { + return Collections.emptyMap(); + } + } + @Override public final void doLayout() { updateGroupScope(); + blockerAssignments = buildBlockerAssignments(); final Rectangle rect = this.getScrollPane().getVisibleRect(); this.playAreaWidth = rect.width; @@ -699,7 +780,35 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) if (!selectAll && panel.getGroupCount() >= 5) { selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); } + // Split/un-split individual card from group + if (!selectAll && panel.getCard() != null) { + if (splitCardIds.contains(panel.getCard().getId())) { + // Re-clicking a split card un-splits it (re-merges into group) + splitCardIds.remove(panel.getCard().getId()); + doLayout(); + } else { + List stack = panel.getStack(); + if (stack != null && stack.size() >= 5) { + // Split first, then check if the game accepts this card. + // If accepted, doUpdateCard will remove from splitCardIds when + // the card's state changes (e.g. tapping), allowing it to regroup. + splitCardIds.add(panel.getCard().getId()); + if (getMatchUI().getGameController().selectCard(panel.getCard(), null, new MouseTriggerEvent(evt))) { + doLayout(); + if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { + return; + } + super.mouseLeftClicked(panel, evt); + return; + } + // Game rejected - undo the split + splitCardIds.remove(panel.getCard().getId()); + } + } + } selectCard(panel, new MouseTriggerEvent(evt), selectAll); + // Regroup after any state changes (e.g., tapped attackers join tapped pile) + doLayout(); if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { return; } @@ -753,6 +862,8 @@ private boolean selectCard(final CardPanel panel, final MouseTriggerEvent trigge public void update() { FThreads.assertExecutedByEdt(true); + splitCardIds.clear(); + blockerAssignments = Collections.emptyMap(); recalculateCardPanels(model, zone); } @@ -838,6 +949,10 @@ private boolean doUpdateCard(final CardView card, boolean fromRefresh) { if (toPanel == null) { return false; } boolean needLayoutRefresh = false; + boolean tappedStateChanged = card.isTapped() != toPanel.isTapped(); + if (tappedStateChanged) { + splitCardIds.remove(card.getId()); + } if (card.isTapped()) { toPanel.setTapped(true); toPanel.setTappedAngle(forge.view.arcane.CardPanel.TAPPED_ANGLE); @@ -887,8 +1002,12 @@ private boolean doUpdateCard(final CardView card, boolean fromRefresh) { if (needLayoutRefresh && !fromRefresh) { doLayout(); //ensure layout refreshed here if not being called from a full refresh + } else if (tappedStateChanged && !fromRefresh) { + // Deferred layout for tapped state changes - allows split cards to + // regroup with other tapped attackers after async state updates + javax.swing.SwingUtilities.invokeLater(this::doLayout); } - return needLayoutRefresh; + return needLayoutRefresh || tappedStateChanged; } private enum RowType { From db635bb77c9cdff2bbdccb37486e7899b6ff985c Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:25:15 +1030 Subject: [PATCH 04/16] Fix attacker splitting from small groups and merging of solo attackers Lower the split threshold from 5 to 2 when grouping is enabled, so cards can be individually split from groups that have dropped below 5. Mark solo cards as split when declared as attackers so they merge with other split attackers from the same group. Track un-split state to prevent re-adding cards to splitCardIds when un-declaring attackers. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/forge/view/arcane/PlayArea.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index ce52fd261bd..ff8e0084a4c 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -781,14 +781,17 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); } // Split/un-split individual card from group + boolean wasUnsplit = false; if (!selectAll && panel.getCard() != null) { if (splitCardIds.contains(panel.getCard().getId())) { // Re-clicking a split card un-splits it (re-merges into group) splitCardIds.remove(panel.getCard().getId()); doLayout(); + wasUnsplit = true; } else { List stack = panel.getStack(); - if (stack != null && stack.size() >= 5) { + boolean grouping = groupTokensAndCreatures || groupAll; + if (stack != null && stack.size() >= (grouping ? 2 : 5)) { // Split first, then check if the game accepts this card. // If accepted, doUpdateCard will remove from splitCardIds when // the card's state changes (e.g. tapping), allowing it to regroup. @@ -806,8 +809,13 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) } } } - selectCard(panel, new MouseTriggerEvent(evt), selectAll); - // Regroup after any state changes (e.g., tapped attackers join tapped pile) + boolean selected = selectCard(panel, new MouseTriggerEvent(evt), selectAll); + // If this individual card was accepted (e.g. declared as attacker), mark it + // as split so it can merge with other split cards from the same group. + // Skip if the card was just un-split (user is un-declaring, not declaring). + if (selected && !selectAll && !wasUnsplit && panel.getCard() != null) { + splitCardIds.add(panel.getCard().getId()); + } doLayout(); if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { return; From e2fbf15b584add5ff8da69d2a93eb3290f231ce4 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:32:48 +1030 Subject: [PATCH 05/16] Unify Stack/Group Permanents into single menu with tooltips Replace separate "Stack Creatures" checkbox and "Group Permanents" submenu with a single "Stack/Group Permanents" submenu offering four options: Default, Stack Creatures, Group Creatures/Tokens, Group All Permanents. Add "Tokens in Separate Row" checkbox. All options include descriptive tooltips and trigger immediate layout updates. PlayArea now derives all layout flags from the single UI_GROUP_PERMANENTS preference. Co-Authored-By: Claude Opus 4.6 --- .../forge/screens/match/menus/GameMenu.java | 42 +++++++++++++++---- .../main/java/forge/view/arcane/PlayArea.java | 8 ++-- forge-gui/res/languages/en-US.properties | 4 +- .../properties/ForgePreferences.java | 2 +- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 76025ba0305..b6288e9a1fa 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -51,7 +51,8 @@ public JMenu getMenu() { menu.addSeparator(); menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); - menu.add(getSubmenu_GroupPermanents()); + menu.add(getSubmenu_StackGroupPermanents()); + menu.add(getMenuItem_TokensSeparateRow()); menu.add(getMenuItem_AutoYields()); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); @@ -208,23 +209,48 @@ private ActionListener getViewDeckListAction() { return e -> matchUI.viewDeckList(); } - private SkinnedMenu getSubmenu_GroupPermanents() { + private SkinnedMenu getSubmenu_StackGroupPermanents() { final Localizer localizer = Localizer.getInstance(); - final SkinnedMenu submenu = new SkinnedMenu(localizer.getMessage("cbpGroupPermanents")); + final SkinnedMenu submenu = new SkinnedMenu(localizer.getMessage("cbpStackGroupPermanents")); final ButtonGroup group = new ButtonGroup(); final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS); - final String[] options = {"Off", "Tokens & Creatures", "All Permanents"}; - for (String option : options) { - SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(option); - item.setSelected(option.equals(current)); - item.addActionListener(getGroupPermanentsAction(option)); + final String[] options = {"Default", "Stack Creatures", "Group Creatures/Tokens", "Group All Permanents"}; + final String[] tooltips = { + "Creatures are never grouped or stacked. Identical lands and tokens are stacked (up to 5). Identical artifacts and enchantments are stacked (up to 4). Stacking fans cards out so each copy is partially visible.", + "Same as Default, but creatures are also stacked (up to 4).", + "Group identical creatures and tokens (5 or more) into a single compact pile with a count badge.", + "Group all identical permanents (5 or more) into a single compact pile with a count badge." + }; + for (int i = 0; i < options.length; i++) { + SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(options[i]); + item.setToolTipText(tooltips[i]); + item.setSelected(options[i].equals(current)); + item.addActionListener(getGroupPermanentsAction(options[i])); group.add(item); submenu.add(item); } return submenu; } + private SkinnedCheckBoxMenuItem getMenuItem_TokensSeparateRow() { + final Localizer localizer = Localizer.getInstance(); + SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("cbpTokensSeparateRow")); + menuItem.setToolTipText("Show tokens in their own row instead of mixed with creatures."); + menuItem.setState(prefs.getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW)); + menuItem.addActionListener(e -> { + final boolean enabled = !prefs.getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); + prefs.setPref(FPref.UI_TOKENS_IN_SEPARATE_ROW, enabled); + prefs.save(); + SwingUtilities.invokeLater(() -> { + for (final VField f : matchUI.getFieldViews()) { + f.getTabletop().doLayout(); + } + }); + }); + return menuItem; + } + private ActionListener getGroupPermanentsAction(final String value) { return e -> { prefs.setPref(FPref.UI_GROUP_PERMANENTS, value); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index ff8e0084a4c..b00d8c7eaaf 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -93,15 +93,14 @@ public PlayArea(final CMatchUI matchUI, final FScrollPane scrollPane, final bool this.mirror = mirror; this.model = player; this.zone = zone; - this.makeTokenRow = FModel.getPreferences().getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); - this.stackCreatures = FModel.getPreferences().getPrefBoolean(FPref.UI_STACK_CREATURES); updateGroupScope(); } private void updateGroupScope() { String groupScope = FModel.getPreferences().getPref(FPref.UI_GROUP_PERMANENTS); - this.groupTokensAndCreatures = "Tokens & Creatures".equals(groupScope) || "All Permanents".equals(groupScope); - this.groupAll = "All Permanents".equals(groupScope); + this.stackCreatures = "Stack Creatures".equals(groupScope); + this.groupTokensAndCreatures = "Group Creatures/Tokens".equals(groupScope) || "Group All Permanents".equals(groupScope); + this.groupAll = "Group All Permanents".equals(groupScope); } private CardStackRow collectAllLands(List remainingPanels) { @@ -463,6 +462,7 @@ private Map buildBlockerAssignments() { @Override public final void doLayout() { + this.makeTokenRow = FModel.getPreferences().getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); updateGroupScope(); blockerAssignments = buildBlockerAssignments(); final Rectangle rect = this.getScrollPane().getVisibleRect(); diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 0f476215700..b4102712d55 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -243,7 +243,9 @@ nlOpenPacksIndiv=When opening Fat Packs and Booster Boxes, booster packs will be nlTokensInSeparateRow=Displays tokens in a separate row on the battlefield below the non-token creatures. nlStackCreatures=Stacks identical creatures on the battlefield like lands, artifacts, and enchantments. cbpGroupPermanents=Group Identical Permanents -nlGroupPermanents=Group identical permanents into stacks with count badges. Off: legacy stacking. Tokens & Creatures: group tokens and creatures. All Permanents: group all permanent types. +cbpStackGroupPermanents=Stack/Group Permanents +cbpTokensSeparateRow=Tokens in Separate Row +nlGroupPermanents=Control how permanents are stacked and grouped. Off: default layout. Stack: stack identical creatures like lands. Group Creatures/Tokens: group with count badges. Group All Permanents: group all types with badges. nlTimedTargOverlay=Enables throttling-based optimization of targeting overlay to reduce CPU use (only disable if you experience choppiness on older hardware, requires starting a new match). nlCounterDisplayType=Selects the style of the in-game counter display for cards. Text-based is a new tab-like display on the cards. Image-based is the old counter image. Hybrid displays both at once. nlCounterDisplayLocation=Determines where to position the text-based counters on the card: close to the top or close to the bottom. diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index ea75c26b249..d946b0e3a4c 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -97,7 +97,7 @@ public enum FPref implements PreferencesStore.IPref { UI_SR_OPTIMIZE ("false"), UI_OPEN_PACKS_INDIV ("false"), UI_STACK_CREATURES ("false"), - UI_GROUP_PERMANENTS ("Tokens & Creatures"), + UI_GROUP_PERMANENTS ("Default"), UI_TOKENS_IN_SEPARATE_ROW("false"), UI_UPLOAD_DRAFT ("false"), UI_SCALE_LARGER ("true"), From 480250280ea691cc3537cbb7d614e869ce43ed96 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:35:07 +1030 Subject: [PATCH 06/16] Add right-click badge to declare N cards from group Right-clicking the count badge on a group of 5+ identical permanents now prompts for how many to select, then declares that many as attackers/blockers and splits them into a separate visual pile. - Add manual constructor to MouseTriggerEvent for synthetic triggers - Gate prompt on active input (getActivateDescription) to prevent showing during opponent's turn - Skip selectCard for already-declared cards to avoid toggling them off - Clear stale splitCardIds before marking selected subset to ensure correct visual separation after repeated split/merge cycles Co-Authored-By: Claude Opus 4.6 --- .../java/forge/toolbox/MouseTriggerEvent.java | 6 +++ .../main/java/forge/view/arcane/PlayArea.java | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/forge-gui-desktop/src/main/java/forge/toolbox/MouseTriggerEvent.java b/forge-gui-desktop/src/main/java/forge/toolbox/MouseTriggerEvent.java index f37229dccbc..05bf9565031 100644 --- a/forge-gui-desktop/src/main/java/forge/toolbox/MouseTriggerEvent.java +++ b/forge-gui-desktop/src/main/java/forge/toolbox/MouseTriggerEvent.java @@ -17,6 +17,12 @@ public MouseTriggerEvent(final MouseEvent event) { this.y = event.getY(); } + public MouseTriggerEvent(final int button, final int x, final int y) { + this.button = button; + this.x = x; + this.y = y; + } + @Override public int getButton() { return button; diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index b00d8c7eaaf..04dac4cc53c 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -32,6 +32,7 @@ import forge.game.player.PlayerView; import forge.game.zone.ZoneType; import forge.gui.FThreads; +import forge.gui.util.SGuiChoose; import forge.localinstance.properties.ForgePreferences.FPref; import forge.model.FModel; import forge.screens.match.CMatchUI; @@ -825,6 +826,54 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) @Override public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) { + // Right-click on badge of a group of 5+ → prompt for how many to select + if (panel.getGroupCount() >= 5 && panel.isBadgeHit(evt.getX(), evt.getY())) { + List stack = panel.getStack(); + if (stack != null && stack.size() >= 5) { + // Check if the game accepts card selection right now (side-effect-free) + CardView primary = stack.get(0).getCard(); + if (primary == null || getMatchUI().getGameController().getActivateDescription(primary) == null) { + getMatchUI().flashIncorrectAction(); + return; + } + Integer count = SGuiChoose.getInteger("How many to select?", 1, stack.size()); + if (count == null) { + return; // cancelled + } + // Collect the first N card views from the stack + List selected = new ArrayList<>(); + selected.add(primary); + for (int i = 1; i < count && i < stack.size(); i++) { + CardPanel p = stack.get(i); + if (p.getCard() != null) { + selected.add(p.getCard()); + } + } + // If cards are already attacking/blocking, just do a visual split + // without calling selectCard (which would toggle them off) + boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); + if (!alreadyInCombat) { + // Use a synthetic left-click trigger (button=1) so InputAttack + // treats this as a declaration, not an undeclare (button=3) + List others = selected.size() > 1 ? selected.subList(1, selected.size()) : null; + MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); + getMatchUI().getGameController().selectCard(primary, others, leftClickTrigger); + } + // Clear all cards in this stack from splitCardIds, then mark + // only the selected subset as split. This ensures the selected + // cards separate from the remaining ones in the group. + for (CardPanel p : stack) { + if (p.getCard() != null) { + splitCardIds.remove(p.getCard().getId()); + } + } + for (CardView cv : selected) { + splitCardIds.add(cv.getId()); + } + doLayout(); + return; + } + } selectCard(panel, new MouseTriggerEvent(evt), evt.isShiftDown()); //select entire stack if shift key down super.mouseRightClicked(panel, evt); } From 0c4209fe2fd11d45a826e44908805654c05c74d1 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:57:32 +1030 Subject: [PATCH 07/16] Add badge right-click undeclare and context-aware prompts Right-click badge on a group of all-attackers/blockers now undeclares N cards (button=3 trigger) instead of doing a visual-only split. Prompt text adapts to game phase: "declare as attackers", "assign as blockers", or "remove from combat". Co-Authored-By: Claude Opus 4.6 --- .../main/java/forge/view/arcane/PlayArea.java | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 04dac4cc53c..35ff2ec7ea2 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -34,6 +34,7 @@ import forge.gui.FThreads; import forge.gui.util.SGuiChoose; import forge.localinstance.properties.ForgePreferences.FPref; +import forge.util.Localizer; import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.toolbox.FScrollPane; @@ -832,11 +833,28 @@ public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) if (stack != null && stack.size() >= 5) { // Check if the game accepts card selection right now (side-effect-free) CardView primary = stack.get(0).getCard(); - if (primary == null || getMatchUI().getGameController().getActivateDescription(primary) == null) { + String activateDesc = primary != null + ? getMatchUI().getGameController().getActivateDescription(primary) : null; + if (activateDesc == null) { getMatchUI().flashIncorrectAction(); return; } - Integer count = SGuiChoose.getInteger("How many to select?", 1, stack.size()); + // Context-appropriate prompt based on card state + String prompt; + boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); + if (alreadyInCombat) { + prompt = "How many to remove from combat?"; + } else { + Localizer loc = Localizer.getInstance(); + if (activateDesc.equals(loc.getMessage("lblAttackWithCard"))) { + prompt = "How many to declare as attackers?"; + } else if (activateDesc.equals(loc.getMessage("lblBlockWithCard"))) { + prompt = "How many to assign as blockers?"; + } else { + prompt = "How many to select?"; + } + } + Integer count = SGuiChoose.getInteger(prompt, 1, stack.size()); if (count == null) { return; // cancelled } @@ -849,13 +867,13 @@ public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) selected.add(p.getCard()); } } - // If cards are already attacking/blocking, just do a visual split - // without calling selectCard (which would toggle them off) - boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); - if (!alreadyInCombat) { - // Use a synthetic left-click trigger (button=1) so InputAttack - // treats this as a declaration, not an undeclare (button=3) - List others = selected.size() > 1 ? selected.subList(1, selected.size()) : null; + List others = selected.size() > 1 ? selected.subList(1, selected.size()) : null; + if (alreadyInCombat) { + // Use button=3 (right-click) to undeclare the selected cards + MouseTriggerEvent rightClickTrigger = new MouseTriggerEvent(3, evt.getX(), evt.getY()); + getMatchUI().getGameController().selectCard(primary, others, rightClickTrigger); + } else { + // Use button=1 (left-click) to declare as attacker/blocker MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); getMatchUI().getGameController().selectCard(primary, others, leftClickTrigger); } From d87cd726e3bfc0f1e4730d348da8b3db507c527e Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:08:29 +1030 Subject: [PATCH 08/16] Lower group badge threshold from 5 to 4 cards Show the count badge and enable badge click interactions (left-click select-all, right-click declare-N) for groups of 4 or more identical tokens, down from the previous minimum of 5. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/forge/view/arcane/CardPanel.java | 4 ++-- .../src/main/java/forge/view/arcane/PlayArea.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index 27c217bc981..e19639b3f10 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -393,7 +393,7 @@ protected final void paintChildren(final Graphics g) { } displayIconOverlay(g, canShow); - if (groupCount >= 5) { + if (groupCount >= 4) { drawGroupCountBadge(g); } if (canShow) { @@ -533,7 +533,7 @@ private void drawGroupCountBadge(final Graphics g) { } public boolean isBadgeHit(int mouseX, int mouseY) { - if (groupCount < 5) { + if (groupCount < 4) { return false; } // Mouse coordinates are container-relative (from PlayArea), so use diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 35ff2ec7ea2..3d53d516865 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -779,7 +779,7 @@ public final void mouseOver(final CardPanel panel, final MouseEvent evt) { @Override public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) { boolean selectAll = evt.isShiftDown(); - if (!selectAll && panel.getGroupCount() >= 5) { + if (!selectAll && panel.getGroupCount() >= 4) { selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); } // Split/un-split individual card from group @@ -827,10 +827,10 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) @Override public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) { - // Right-click on badge of a group of 5+ → prompt for how many to select - if (panel.getGroupCount() >= 5 && panel.isBadgeHit(evt.getX(), evt.getY())) { + // Right-click on badge of a group of 4+ → prompt for how many to select + if (panel.getGroupCount() >= 4 && panel.isBadgeHit(evt.getX(), evt.getY())) { List stack = panel.getStack(); - if (stack != null && stack.size() >= 5) { + if (stack != null && stack.size() >= 4) { // Check if the game accepts card selection right now (side-effect-free) CardView primary = stack.get(0).getCard(); String activateDesc = primary != null From 0cc3afa35c2aebc14755a1f26450c71a2ba6ef6c Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:02:21 +1030 Subject: [PATCH 09/16] Split tapped and untapped lands into separate piles Previously lands only split by tapped status in "Group All Permanents" mode. Now they always split, matching creature behavior. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/forge/view/arcane/PlayArea.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 3d53d516865..d580677a5d2 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -149,8 +149,8 @@ private CardStackRow collectAllLands(List remainingPanels) { if (!panel.getAttachedPanels().isEmpty() || !panel.getCard().hasSameCounters(firstPanel.getCard()) || firstPanel.getCard().hasCardAttachments() - || (groupAll && card.isTapped() != firstPanel.getCard().isTapped()) - || (groupAll && card.getDamage() != firstPanel.getCard().getDamage()) + || card.isTapped() != firstPanel.getCard().isTapped() + || card.getDamage() != firstPanel.getCard().getDamage() || (!groupAll && stack.size() == STACK_MAX_LANDS)) { // If this land has attachments or the stack is full, // put it to the right. From ae6d08d453a8074fbdaceb735d2206420f3b86c3 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:13:22 +1030 Subject: [PATCH 10/16] Lower group badge threshold from 4 to 2 permanents Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/forge/view/arcane/CardPanel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index e19639b3f10..11a9f4e61b4 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -393,7 +393,7 @@ protected final void paintChildren(final Graphics g) { } displayIconOverlay(g, canShow); - if (groupCount >= 4) { + if (groupCount >= 2) { drawGroupCountBadge(g); } if (canShow) { @@ -533,7 +533,7 @@ private void drawGroupCountBadge(final Graphics g) { } public boolean isBadgeHit(int mouseX, int mouseY) { - if (groupCount < 4) { + if (groupCount < 2) { return false; } // Mouse coordinates are container-relative (from PlayArea), so use From 265cd858cb61cb584fc0bc52f9694c179a4a1cff Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:49:56 +1030 Subject: [PATCH 11/16] Improve token grouping: fix badge hit detection, cache rendering, reduce duplication - Fix badge hit detection on tapped cards by inverse-rotating mouse coordinates - Cache badge font per cardWidth and make background color a static constant - Scale badge corner radius proportionally to card size - Lower badge click threshold from 4 to 2 for both left and right click - Add badge-click undo for tapped lands (undoes batch mana tap) - Dispatch non-combat badge right-clicks individually (sacrifice, targeting) - Extract shared collectStacked() method from three near-identical collect methods - Coalesce rapid tapped-state layout passes via layoutPending flag - Update tooltip text removing outdated "(5 or more)" qualifier Co-Authored-By: Claude Opus 4.6 --- .../forge/screens/match/menus/GameMenu.java | 4 +- .../java/forge/view/arcane/CardPanel.java | 42 ++- .../main/java/forge/view/arcane/PlayArea.java | 304 ++++++------------ 3 files changed, 138 insertions(+), 212 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index b6288e9a1fa..79a95c7cdf7 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -219,8 +219,8 @@ private SkinnedMenu getSubmenu_StackGroupPermanents() { final String[] tooltips = { "Creatures are never grouped or stacked. Identical lands and tokens are stacked (up to 5). Identical artifacts and enchantments are stacked (up to 4). Stacking fans cards out so each copy is partially visible.", "Same as Default, but creatures are also stacked (up to 4).", - "Group identical creatures and tokens (5 or more) into a single compact pile with a count badge.", - "Group all identical permanents (5 or more) into a single compact pile with a count badge." + "Group identical creatures and tokens into a single compact pile with a count badge.", + "Group all identical permanents into a single compact pile with a count badge." }; for (int i = 0; i < options.length; i++) { SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(options[i]); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index 11a9f4e61b4..7b32fe1061f 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java @@ -116,7 +116,10 @@ public class CardPanel extends SkinnedPanel implements CardContainer, IDisposabl private boolean hasFlash; private CachedCardImage cachedImage; private int groupCount; + private Font badgeFont; + private int badgeFontCardWidth; // cardWidth when badgeFont was last computed + private static final Color BADGE_BG_COLOR = new Color(0, 0, 0, 180); private static Font smallCounterFont; private static Font largeCounterFont; @@ -511,8 +514,12 @@ private void drawGroupCountBadge(final Graphics g) { Graphics2D g2d = (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + if (badgeFont == null || badgeFontCardWidth != cardWidth) { + badgeFont = new Font("Dialog", Font.BOLD, Math.max(10, cardWidth / 5)); + badgeFontCardWidth = cardWidth; + } + String text = "\u00D7" + groupCount; - Font badgeFont = new Font("Dialog", Font.BOLD, Math.max(10, cardWidth / 5)); FontMetrics fm = g2d.getFontMetrics(badgeFont); int textWidth = fm.stringWidth(text); @@ -523,9 +530,10 @@ private void drawGroupCountBadge(final Graphics g) { int badgeHeight = textHeight + padY * 2; int badgeX = cardXOffset + 2; int badgeY = cardYOffset + 2; + int cornerRadius = Math.max(4, cardWidth / 16); - g2d.setColor(new Color(0, 0, 0, 180)); - g2d.fillRoundRect(badgeX, badgeY, badgeWidth, badgeHeight, 6, 6); + g2d.setColor(BADGE_BG_COLOR); + g2d.fillRoundRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, cornerRadius); g2d.setColor(Color.WHITE); g2d.setFont(badgeFont); @@ -536,16 +544,28 @@ public boolean isBadgeHit(int mouseX, int mouseY) { if (groupCount < 2) { return false; } - // Mouse coordinates are container-relative (from PlayArea), so use - // getCardX()/getCardY() which convert panel-internal offsets to - // container coordinates (getX() + cardXOffset). - int badgeX = getCardX() + 2; - int badgeY = getCardY() + 2; - // Use generous hit area to cover the font-metrics-based badge size + // Badge is drawn at (cardXOffset+2, cardYOffset+2) in the card's local + // coordinate space. Mouse coordinates are container-relative. When the + // card is tapped, the graphics are rotated but mouse events are not, so + // we must inverse-rotate the mouse point into the card's local frame. + int localX = mouseX - getX(); + int localY = mouseY - getY(); + if (tappedAngle > 0) { + float pivotX = cardXOffset + cardWidth / 2f; + float pivotY = cardYOffset + cardHeight - cardWidth / 2f; + double cos = Math.cos(-tappedAngle); + double sin = Math.sin(-tappedAngle); + float dx = localX - pivotX; + float dy = localY - pivotY; + localX = (int) Math.round(cos * dx - sin * dy + pivotX); + localY = (int) Math.round(sin * dx + cos * dy + pivotY); + } + int badgeX = cardXOffset + 2; + int badgeY = cardYOffset + 2; int badgeWidth = Math.max(30, cardWidth / 3); int badgeHeight = Math.max(20, cardHeight / 6); - return mouseX >= badgeX && mouseX <= badgeX + badgeWidth - && mouseY >= badgeY && mouseY <= badgeY + badgeHeight; + return localX >= badgeX && localX <= badgeX + badgeWidth + && localY >= badgeY && localY <= badgeY + badgeHeight; } private void displayIconOverlay(final Graphics g, final boolean canShow) { diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index d580677a5d2..4fc42f5f195 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -22,6 +22,7 @@ import java.awt.Rectangle; import java.awt.event.MouseEvent; import java.util.*; +import java.util.function.BiPredicate; import com.google.common.collect.Lists; @@ -73,6 +74,8 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private final Set splitCardIds = new HashSet<>(); // Blocker card ID → attacker card ID; rebuilt each doLayout() from CombatView. private Map blockerAssignments = Collections.emptyMap(); + // Coalesces multiple invokeLater(doLayout) calls within a single EDT cycle. + private boolean layoutPending; // Computed in layout. private List rows = new ArrayList<>(); @@ -106,221 +109,99 @@ private void updateGroupScope() { } private CardStackRow collectAllLands(List remainingPanels) { - final CardStackRow allLands = new CardStackRow(); - - outerLoop: - // - for (Iterator iterator = remainingPanels.iterator(); iterator.hasNext(); ) { - CardPanel panel = iterator.next(); - final CardView card = panel.getCard(); - final CardStateView state = card.getCurrentState(); - - if (!RowType.Land.isGoodFor(state)) { - continue; - } - - int insertIndex = -1; - - // Find lands with the same name. - for (int i = 0, n = allLands.size(); i < n; i++) { - final CardStack stack = allLands.get(i); - final CardPanel firstPanel = stack.get(0); - if (firstPanel.getCard().getCurrentState().getOracleName().equals(state.getOracleName())) { - if (!firstPanel.getAttachedPanels().isEmpty() || firstPanel.getCard().hasCardAttachments()) { - // Put this land to the left of lands with the same name - // and attachments. - insertIndex = i; - break; - } - // Split cards can't group with non-split cards - boolean cardIsSplit = splitCardIds.contains(card.getId()); - boolean stackIsSplit = splitCardIds.contains(firstPanel.getCard().getId()); - if (cardIsSplit != stackIsSplit) { - insertIndex = i + 1; - continue; - } - // Blockers assigned to different attackers can't group together - int cardTarget = blockerAssignments.getOrDefault(card.getId(), 0); - int stackTarget = blockerAssignments.getOrDefault(firstPanel.getCard().getId(), 0); - if (cardTarget != stackTarget) { - insertIndex = i + 1; - continue; - } - if (!panel.getAttachedPanels().isEmpty() - || !panel.getCard().hasSameCounters(firstPanel.getCard()) - || firstPanel.getCard().hasCardAttachments() - || card.isTapped() != firstPanel.getCard().isTapped() - || card.getDamage() != firstPanel.getCard().getDamage() - || (!groupAll && stack.size() == STACK_MAX_LANDS)) { - // If this land has attachments or the stack is full, - // put it to the right. - insertIndex = i + 1; - continue; - } - // Add to stack. - stack.add(0, panel); - iterator.remove(); - continue outerLoop; - } - if (insertIndex != -1) { - break; - } - } - - final CardStack stack = new CardStack(); - stack.add(panel); - iterator.remove(); - allLands.add(insertIndex == -1 ? allLands.size() : insertIndex, stack); - } - return allLands; + return collectStacked(remainingPanels, RowType.Land, + (card, first) -> card.hasSameCounters(first) + && card.isTapped() == first.isTapped() + && card.getDamage() == first.getDamage(), + STACK_MAX_LANDS, groupAll); } private CardStackRow collectAllTokens(List remainingPanels) { - final CardStackRow allTokens = new CardStackRow(); - outerLoop: - // - for (Iterator iterator = remainingPanels.iterator(); iterator.hasNext(); ) { - CardPanel panel = iterator.next(); - final CardView card = panel.getCard(); - final CardStateView state = card.getCurrentState(); - - if (!RowType.Token.isGoodFor(state)) { - continue; - } - - int insertIndex = -1; - - // Find tokens with the same name. - for (int i = 0, n = allTokens.size(); i < n; i++) { - final CardStack stack = allTokens.get(i); - final CardPanel firstPanel = stack.get(0); - final CardView firstCard = firstPanel.getCard(); - - if (firstPanel.getCard().getCurrentState().getOracleName().equals(state.getOracleName())) { - if (!firstPanel.getAttachedPanels().isEmpty()) { - // Put this token to the left of tokens with the same - // name and attachments. - insertIndex = i; - break; - } - - // Split cards can't group with non-split cards - boolean cardIsSplit = splitCardIds.contains(card.getId()); - boolean stackIsSplit = splitCardIds.contains(firstCard.getId()); - if (cardIsSplit != stackIsSplit) { - insertIndex = i + 1; - continue; - } - // Blockers assigned to different attackers can't group together - int cardTarget = blockerAssignments.getOrDefault(card.getId(), 0); - int stackTarget = blockerAssignments.getOrDefault(firstCard.getId(), 0); - if (cardTarget != stackTarget) { - insertIndex = i + 1; - continue; - } - if (!panel.getAttachedPanels().isEmpty() - || !card.hasSameCounters(firstPanel.getCard()) - || (card.isSick() != firstCard.isSick()) - || !card.hasSamePT(firstCard) - || !(card.getText().equals(firstCard.getText())) - || (groupTokensAndCreatures && card.isTapped() != firstCard.isTapped()) - || (groupTokensAndCreatures && card.getDamage() != firstCard.getDamage()) - || (!groupTokensAndCreatures && stack.size() == STACK_MAX_TOKENS)) { - // If this token has attachments or the stack is full, - // put it to the right. - insertIndex = i + 1; - continue; - } - // Add to stack. - stack.add(0, panel); - iterator.remove(); - continue outerLoop; - } - if (insertIndex != -1) { - break; - } - } - - final CardStack stack = new CardStack(); - stack.add(panel); - iterator.remove(); - allTokens.add(insertIndex == -1 ? allTokens.size() : insertIndex, stack); - } - return allTokens; + return collectStacked(remainingPanels, RowType.Token, + (card, first) -> card.hasSameCounters(first) + && card.isSick() == first.isSick() + && card.hasSamePT(first) + && card.getText().equals(first.getText()) + && (!groupTokensAndCreatures || card.isTapped() == first.isTapped()) + && (!groupTokensAndCreatures || card.getDamage() == first.getDamage()), + STACK_MAX_TOKENS, groupTokensAndCreatures); } private CardStackRow collectAllCreatures(List remainingPanels) { - if(!this.stackCreatures && !this.groupTokensAndCreatures) + if (!this.stackCreatures && !this.groupTokensAndCreatures) { return collectUnstacked(remainingPanels, RowType.Creature); - final CardStackRow allCreatures = new CardStackRow(); + } + return collectStacked(remainingPanels, RowType.Creature, + (card, first) -> !card.isCloned() + && card.hasSameCounters(first) + && card.isSick() == first.isSick() + && card.hasSamePT(first) + && (!groupTokensAndCreatures || card.isTapped() == first.isTapped()) + && (!groupTokensAndCreatures || card.getDamage() == first.getDamage()) + && (!groupTokensAndCreatures || card.getText().equals(first.getText())), + STACK_MAX_CREATURES, groupTokensAndCreatures); + } + + /** + * Shared stacking logic for lands, tokens, and creatures. Panels matching + * the given RowType are grouped by oracle name into stacks. Within a name + * group, panels are added to an existing stack only if the type-specific + * compatibility predicate passes. Stacks are kept adjacent by name and + * ordered via insertIndex tracking. + */ + private CardStackRow collectStacked(List remainingPanels, RowType type, + BiPredicate isCompatible, int stackMax, boolean unlimitedGrouping) { + final CardStackRow out = new CardStackRow(); outerLoop: - // for (Iterator iterator = remainingPanels.iterator(); iterator.hasNext(); ) { CardPanel panel = iterator.next(); final CardView card = panel.getCard(); final CardStateView state = card.getCurrentState(); - if (!RowType.Creature.isGoodFor(state)) { + if (!type.isGoodFor(state)) { continue; } - int insertIndex = -1; - - // Find creatures with the same name. - for (int i = 0, n = allCreatures.size(); i < n; i++) { - final CardStack stack = allCreatures.get(i); + for (int i = 0, n = out.size(); i < n; i++) { + final CardStack stack = out.get(i); final CardPanel firstPanel = stack.get(0); final CardView firstCard = firstPanel.getCard(); - if (firstCard.getOracleName().equals(card.getOracleName())) { - if (!firstPanel.getAttachedPanels().isEmpty()) { - // Put this creature to the left of creatures with the same - // name and attachments. - insertIndex = i; - break; - } - // Split cards can't group with non-split cards - boolean cardIsSplit = splitCardIds.contains(card.getId()); - boolean stackIsSplit = splitCardIds.contains(firstCard.getId()); - if (cardIsSplit != stackIsSplit) { - insertIndex = i + 1; - continue; - } - // Blockers assigned to different attackers can't group together - int cardTarget = blockerAssignments.getOrDefault(card.getId(), 0); - int stackTarget = blockerAssignments.getOrDefault(firstCard.getId(), 0); - if (cardTarget != stackTarget) { - insertIndex = i + 1; - continue; - } - if (!panel.getAttachedPanels().isEmpty() - || card.isCloned() - || !card.hasSameCounters(firstCard) - || (card.isSick() != firstCard.isSick()) - || !card.hasSamePT(firstCard) - || (groupTokensAndCreatures && card.isTapped() != firstCard.isTapped()) - || (groupTokensAndCreatures && card.getDamage() != firstCard.getDamage()) - || (groupTokensAndCreatures && !(card.getText().equals(firstCard.getText()))) - || (!groupTokensAndCreatures && stack.size() == STACK_MAX_CREATURES)) { - // If this creature has attachments or the stack is full, - // put it to the right. - insertIndex = i + 1; - continue; - } - // Add to stack. - stack.add(0, panel); - iterator.remove(); - continue outerLoop; + if (!firstCard.getCurrentState().getOracleName().equals(state.getOracleName())) { + if (insertIndex != -1) { break; } + continue; } - if (insertIndex != -1) { + // First card in stack has attachments — insert before this stack + if (!firstPanel.getAttachedPanels().isEmpty() || firstCard.hasCardAttachments()) { + insertIndex = i; break; } + // Split cards can't group with non-split cards + if (splitCardIds.contains(card.getId()) != splitCardIds.contains(firstCard.getId())) { + insertIndex = i + 1; + continue; + } + // Blockers assigned to different attackers can't group together + if (blockerAssignments.getOrDefault(card.getId(), 0) + != blockerAssignments.getOrDefault(firstCard.getId(), 0)) { + insertIndex = i + 1; + continue; + } + // Candidate has attachments, type-specific incompatibility, or stack is full + if (!panel.getAttachedPanels().isEmpty() + || !isCompatible.test(card, firstCard) + || (!unlimitedGrouping && stack.size() >= stackMax)) { + insertIndex = i + 1; + continue; + } + stack.add(0, panel); + iterator.remove(); + continue outerLoop; } - final CardStack stack = new CardStack(); stack.add(panel); iterator.remove(); - allCreatures.add(insertIndex == -1 ? allCreatures.size() : insertIndex, stack); + out.add(insertIndex == -1 ? out.size() : insertIndex, stack); } - return allCreatures; + return out; } private CardStackRow collectAllContraptions(List remainingPanels) { @@ -779,7 +660,7 @@ public final void mouseOver(final CardPanel panel, final MouseEvent evt) { @Override public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) { boolean selectAll = evt.isShiftDown(); - if (!selectAll && panel.getGroupCount() >= 4) { + if (!selectAll && panel.getGroupCount() >= 2) { selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); } // Split/un-split individual card from group @@ -811,6 +692,16 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) } } } + // Badge click on tapped cards — undo once per card to reverse a batch + // mana tap, rather than trying to activate (which shows disabled abilities). + if (selectAll && panel.getCard() != null && panel.getCard().isTapped() + && panel.getStack() != null) { + for (int i = 0; i < panel.getStack().size(); i++) { + getMatchUI().getGameController().undoLastAction(); + } + doLayout(); + return; + } boolean selected = selectCard(panel, new MouseTriggerEvent(evt), selectAll); // If this individual card was accepted (e.g. declared as attacker), mark it // as split so it can merge with other split cards from the same group. @@ -827,10 +718,10 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) @Override public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) { - // Right-click on badge of a group of 4+ → prompt for how many to select - if (panel.getGroupCount() >= 4 && panel.isBadgeHit(evt.getX(), evt.getY())) { + // Right-click on badge → prompt for how many to select + if (panel.getGroupCount() >= 2 && panel.isBadgeHit(evt.getX(), evt.getY())) { List stack = panel.getStack(); - if (stack != null && stack.size() >= 4) { + if (stack != null && stack.size() >= 2) { // Check if the game accepts card selection right now (side-effect-free) CardView primary = stack.get(0).getCard(); String activateDesc = primary != null @@ -840,12 +731,12 @@ public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) return; } // Context-appropriate prompt based on card state + Localizer loc = Localizer.getInstance(); String prompt; boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); if (alreadyInCombat) { prompt = "How many to remove from combat?"; } else { - Localizer loc = Localizer.getInstance(); if (activateDesc.equals(loc.getMessage("lblAttackWithCard"))) { prompt = "How many to declare as attackers?"; } else if (activateDesc.equals(loc.getMessage("lblBlockWithCard"))) { @@ -868,14 +759,24 @@ public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) } } List others = selected.size() > 1 ? selected.subList(1, selected.size()) : null; + boolean isCombat = alreadyInCombat + || activateDesc.equals(loc.getMessage("lblAttackWithCard")) + || activateDesc.equals(loc.getMessage("lblBlockWithCard")); if (alreadyInCombat) { // Use button=3 (right-click) to undeclare the selected cards MouseTriggerEvent rightClickTrigger = new MouseTriggerEvent(3, evt.getX(), evt.getY()); getMatchUI().getGameController().selectCard(primary, others, rightClickTrigger); - } else { - // Use button=1 (left-click) to declare as attacker/blocker + } else if (isCombat) { + // Combat inputs handle otherCardsToSelect natively MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); getMatchUI().getGameController().selectCard(primary, others, leftClickTrigger); + } else { + // Non-combat inputs (sacrifice, targeting) ignore otherCardsToSelect, + // so select each card individually + MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); + for (CardView cv : selected) { + getMatchUI().getGameController().selectCard(cv, null, leftClickTrigger); + } } // Clear all cards in this stack from splitCardIds, then mark // only the selected subset as split. This ensures the selected @@ -1079,8 +980,13 @@ private boolean doUpdateCard(final CardView card, boolean fromRefresh) { doLayout(); //ensure layout refreshed here if not being called from a full refresh } else if (tappedStateChanged && !fromRefresh) { // Deferred layout for tapped state changes - allows split cards to - // regroup with other tapped attackers after async state updates - javax.swing.SwingUtilities.invokeLater(this::doLayout); + // regroup with other tapped attackers after async state updates. + // Coalesce via layoutPending so multiple rapid tapped-state changes + // (e.g. declaring 5 attackers at once) only trigger one layout pass. + if (!layoutPending) { + layoutPending = true; + javax.swing.SwingUtilities.invokeLater(() -> { layoutPending = false; doLayout(); }); + } } return needLayoutRefresh || tappedStateChanged; } From 5dd735cf7056bc09925b772673230ecdd4af9021 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:11:55 +1030 Subject: [PATCH 12/16] Fix group badge counting attached panels (equipment/auras) CardStack.add() recursively pulls in attached panels via addAttachedPanels(), inflating stack.size(). The group badge now counts only non-attached panels in the stack. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/forge/view/arcane/PlayArea.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 4fc42f5f195..c019dd837ba 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -486,8 +486,14 @@ private void positionAllCards(List template) { panel.setDisplayEnabled(!hidden); } // Set group count on top card for badge rendering - if (grouping && stack.size() > 1) { - stack.get(0).setGroupCount(stack.size()); + // Exclude attached panels (equipment/auras) — they're pulled + // into the stack by addAttachedPanels but aren't grouped cards + if (grouping) { + int groupCount = (int) stack.stream() + .filter(p -> p.getAttachedToPanel() == null).count(); + if (groupCount > 1) { + stack.get(0).setGroupCount(groupCount); + } } rowBottom = Math.max(rowBottom, y + stack.getHeight()); x += stack.getWidth(); From 2ee5244f5d8f2d13333bc6e525808bebd647a9bb Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:48:53 +1030 Subject: [PATCH 13/16] Localize group permanents UI and decouple preference storage from display - Use internal keys (default/stack/group_creatures/group_all) as preference values instead of English display strings, enabling future localization - Localize all user-facing strings: menu labels, tooltips, combat prompts - Remove dead l10n keys (cbpGroupPermanents, nlGroupPermanents) - Add grouping field to eliminate redundant boolean expressions in PlayArea - Replace silent catch-all exception with null guard in buildBlockerAssignments Co-Authored-By: Claude Opus 4.6 (1M context) --- .../forge/screens/match/menus/GameMenu.java | 22 ++++----- .../main/java/forge/view/arcane/PlayArea.java | 45 +++++++++---------- forge-gui/res/languages/en-US.properties | 15 ++++++- .../properties/ForgePreferences.java | 2 +- 4 files changed, 44 insertions(+), 40 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 5aeccfd364f..6bf376d946a 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java @@ -207,18 +207,14 @@ private SkinnedMenu getSubmenu_StackGroupPermanents() { final ButtonGroup group = new ButtonGroup(); final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS); - final String[] options = {"Default", "Stack Creatures", "Group Creatures/Tokens", "Group All Permanents"}; - final String[] tooltips = { - "Creatures are never grouped or stacked. Identical lands and tokens are stacked (up to 5). Identical artifacts and enchantments are stacked (up to 4). Stacking fans cards out so each copy is partially visible.", - "Same as Default, but creatures are also stacked (up to 4).", - "Group identical creatures and tokens into a single compact pile with a count badge.", - "Group all identical permanents into a single compact pile with a count badge." - }; - for (int i = 0; i < options.length; i++) { - SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(options[i]); - item.setToolTipText(tooltips[i]); - item.setSelected(options[i].equals(current)); - item.addActionListener(getGroupPermanentsAction(options[i])); + final String[] keys = {"default", "stack", "group_creatures", "group_all"}; + final String[] labelKeys = {"lblGroupDefault", "lblGroupStack", "lblGroupCreatures", "lblGroupAll"}; + final String[] tooltipKeys = {"nlGroupDefault", "nlGroupStack", "nlGroupCreatures", "nlGroupAll"}; + for (int i = 0; i < keys.length; i++) { + SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(localizer.getMessage(labelKeys[i])); + item.setToolTipText(localizer.getMessage(tooltipKeys[i])); + item.setSelected(keys[i].equals(current)); + item.addActionListener(getGroupPermanentsAction(keys[i])); group.add(item); submenu.add(item); } @@ -228,7 +224,7 @@ private SkinnedMenu getSubmenu_StackGroupPermanents() { private SkinnedCheckBoxMenuItem getMenuItem_TokensSeparateRow() { final Localizer localizer = Localizer.getInstance(); SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("cbpTokensSeparateRow")); - menuItem.setToolTipText("Show tokens in their own row instead of mixed with creatures."); + menuItem.setToolTipText(localizer.getMessage("nlTokensSeparateRow")); menuItem.setState(prefs.getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW)); menuItem.addActionListener(e -> { final boolean enabled = !prefs.getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index c019dd837ba..e11f7080b65 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -91,6 +91,7 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private boolean stackCreatures = false; private boolean groupTokensAndCreatures; private boolean groupAll; + private boolean grouping; // groupTokensAndCreatures || groupAll public PlayArea(final CMatchUI matchUI, final FScrollPane scrollPane, final boolean mirror, final PlayerView player, final ZoneType zone) { super(matchUI, scrollPane); @@ -103,9 +104,10 @@ public PlayArea(final CMatchUI matchUI, final FScrollPane scrollPane, final bool private void updateGroupScope() { String groupScope = FModel.getPreferences().getPref(FPref.UI_GROUP_PERMANENTS); - this.stackCreatures = "Stack Creatures".equals(groupScope); - this.groupTokensAndCreatures = "Group Creatures/Tokens".equals(groupScope) || "Group All Permanents".equals(groupScope); - this.groupAll = "Group All Permanents".equals(groupScope); + this.stackCreatures = "stack".equals(groupScope); + this.groupTokensAndCreatures = "group_creatures".equals(groupScope) || "group_all".equals(groupScope); + this.groupAll = "group_all".equals(groupScope); + this.grouping = groupTokensAndCreatures || groupAll; } private CardStackRow collectAllLands(List remainingPanels) { @@ -326,21 +328,18 @@ public final CardPanel addCard(final CardView card) { } private Map buildBlockerAssignments() { - try { - CombatView combat = getMatchUI().getGameView().getCombat(); - if (combat == null) { return Collections.emptyMap(); } - Map assignments = new HashMap<>(); - for (CardView attacker : combat.getAttackers()) { - FCollection blockers = combat.getPlannedBlockers(attacker); - if (blockers == null) { continue; } - for (CardView blocker : blockers) { - assignments.put(blocker.getId(), attacker.getId()); - } + if (getMatchUI().getGameView() == null) { return Collections.emptyMap(); } + CombatView combat = getMatchUI().getGameView().getCombat(); + if (combat == null) { return Collections.emptyMap(); } + Map assignments = new HashMap<>(); + for (CardView attacker : combat.getAttackers()) { + FCollection blockers = combat.getPlannedBlockers(attacker); + if (blockers == null) { continue; } + for (CardView blocker : blockers) { + assignments.put(blocker.getId(), attacker.getId()); } - return assignments; - } catch (Exception e) { - return Collections.emptyMap(); } + return assignments; } @Override @@ -455,7 +454,6 @@ private void positionAllCards(List template) { x -= r.getWidth(); } } - boolean grouping = groupTokensAndCreatures || groupAll; int maxVisible = 4; // Reset groupCount on all panels in this stack @@ -679,7 +677,6 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) wasUnsplit = true; } else { List stack = panel.getStack(); - boolean grouping = groupTokensAndCreatures || groupAll; if (stack != null && stack.size() >= (grouping ? 2 : 5)) { // Split first, then check if the game accepts this card. // If accepted, doUpdateCard will remove from splitCardIds when @@ -741,14 +738,14 @@ public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) String prompt; boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); if (alreadyInCombat) { - prompt = "How many to remove from combat?"; + prompt = loc.getMessage("lblGroupHowManyRemove"); } else { if (activateDesc.equals(loc.getMessage("lblAttackWithCard"))) { - prompt = "How many to declare as attackers?"; + prompt = loc.getMessage("lblGroupHowManyAttack"); } else if (activateDesc.equals(loc.getMessage("lblBlockWithCard"))) { - prompt = "How many to assign as blockers?"; + prompt = loc.getMessage("lblGroupHowManyBlock"); } else { - prompt = "How many to select?"; + prompt = loc.getMessage("lblGroupHowManySelect"); } } Integer count = SGuiChoose.getInteger(prompt, 1, stack.size()); @@ -1082,14 +1079,14 @@ private void addAttachedPanels(final CardPanel panel) { } private int getWidth() { - int visualCount = (PlayArea.this.groupTokensAndCreatures || PlayArea.this.groupAll) + int visualCount = PlayArea.this.grouping ? Math.min(this.size(), 4) : this.size(); return PlayArea.this.cardWidth + ((visualCount - 1) * PlayArea.this.stackSpacingX) + PlayArea.this.cardSpacingX; } private int getHeight() { - int visualCount = (PlayArea.this.groupTokensAndCreatures || PlayArea.this.groupAll) + int visualCount = PlayArea.this.grouping ? Math.min(this.size(), 4) : this.size(); return PlayArea.this.cardHeight + ((visualCount - 1) * PlayArea.this.stackSpacingY) + PlayArea.this.cardSpacingY; diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index a64b7ab7f3e..da4e7070570 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -243,10 +243,21 @@ nlCardTextHideReminder=When render card images, skip rendering reminder text. nlOpenPacksIndiv=When opening Fat Packs and Booster Boxes, booster packs will be opened and displayed one at a time. nlTokensInSeparateRow=Displays tokens in a separate row on the battlefield below the non-token creatures. nlStackCreatures=Stacks identical creatures on the battlefield like lands, artifacts, and enchantments. -cbpGroupPermanents=Group Identical Permanents cbpStackGroupPermanents=Stack/Group Permanents cbpTokensSeparateRow=Tokens in Separate Row -nlGroupPermanents=Control how permanents are stacked and grouped. Off: default layout. Stack: stack identical creatures like lands. Group Creatures/Tokens: group with count badges. Group All Permanents: group all types with badges. +lblGroupDefault=Default +lblGroupStack=Stack Creatures +lblGroupCreatures=Group Creatures/Tokens +lblGroupAll=Group All Permanents +nlGroupDefault=Creatures are never grouped or stacked. Identical lands and tokens are stacked (up to 5). Identical artifacts and enchantments are stacked (up to 4). Stacking fans cards out so each copy is partially visible. +nlGroupStack=Same as Default, but creatures are also stacked (up to 4). +nlGroupCreatures=Group identical creatures and tokens into a single compact pile with a count badge. +nlGroupAll=Group all identical permanents into a single compact pile with a count badge. +nlTokensSeparateRow=Show tokens in their own row instead of mixed with creatures. +lblGroupHowManyRemove=How many to remove from combat? +lblGroupHowManyAttack=How many to declare as attackers? +lblGroupHowManyBlock=How many to assign as blockers? +lblGroupHowManySelect=How many to select? nlTimedTargOverlay=Enables throttling-based optimization of targeting overlay to reduce CPU use (only disable if you experience choppiness on older hardware, requires starting a new match). nlCounterDisplayType=Selects the style of the in-game counter display for cards. Text-based is a new tab-like display on the cards. Image-based is the old counter image. Hybrid displays both at once. nlCounterDisplayLocation=Determines where to position the text-based counters on the card: close to the top or close to the bottom. diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 618b0712415..6a0b6b6e4bd 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -103,7 +103,7 @@ public enum FPref implements PreferencesStore.IPref { UI_SR_OPTIMIZE ("false"), UI_OPEN_PACKS_INDIV ("false"), UI_STACK_CREATURES ("false"), - UI_GROUP_PERMANENTS ("Default"), + UI_GROUP_PERMANENTS ("default"), UI_TOKENS_IN_SEPARATE_ROW("false"), UI_UPLOAD_DRAFT ("false"), UI_SCALE_LARGER ("true"), From 626153cf8258a1d346bbef33949c53abda4af9b9 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:32:10 +1030 Subject: [PATCH 14/16] Fix undeclared attackers not merging back into group, refactor grouping logic Right-clicking to undeclare attackers from a grouped pile left them visually separated. splitCardIds was re-added after undeclare, and doUpdateCard cleanup doesn't fire during InputAttack (cards aren't tapped yet). Fixed badge and individual right-click to clean up splitCardIds on undeclare. Refactored PlayArea grouping code: - Extract handleBadgeRightClick from inline block - Unify collectAllOthers with collectStacked - Eliminate wasUnsplit flag via early return in mouseLeftClicked - Gate land tapped/damage checks behind groupAll for consistency - Remove restating comments, revert cosmetic CardView.java change Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/forge/game/card/CardView.java | 1 + .../main/java/forge/view/arcane/PlayArea.java | 285 ++++++++---------- 2 files changed, 133 insertions(+), 153 deletions(-) diff --git a/forge-game/src/main/java/forge/game/card/CardView.java b/forge-game/src/main/java/forge/game/card/CardView.java index 7ea20bf7d3e..cb0b0aa4297 100644 --- a/forge-game/src/main/java/forge/game/card/CardView.java +++ b/forge-game/src/main/java/forge/game/card/CardView.java @@ -1030,6 +1030,7 @@ public boolean needsTransformAnimation() { public void updateNeedsTransformAnimation(boolean value) { set(TrackableProperty.NeedsTransformAnimation, value); } + void updateState(Card c) { updateName(c); updateZoneText(c); diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index e11f7080b65..2d14d112a57 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -69,8 +69,10 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private final boolean mirror; - // Card IDs that have been split from their groups via individual click. - // Survives doLayout() calls; cleared on game state changes via update(). + // Cards visually split from their group. Split and non-split cards + // can't share a stack. doUpdateCard removes entries on tapped state + // changes, but this does NOT cover undeclare during InputAttack since + // cards aren't actually tapped yet — click handlers must clean up. private final Set splitCardIds = new HashSet<>(); // Blocker card ID → attacker card ID; rebuilt each doLayout() from CombatView. private Map blockerAssignments = Collections.emptyMap(); @@ -113,8 +115,8 @@ private void updateGroupScope() { private CardStackRow collectAllLands(List remainingPanels) { return collectStacked(remainingPanels, RowType.Land, (card, first) -> card.hasSameCounters(first) - && card.isTapped() == first.isTapped() - && card.getDamage() == first.getDamage(), + && (!groupAll || card.isTapped() == first.isTapped()) + && (!groupAll || card.getDamage() == first.getDamage()), STACK_MAX_LANDS, groupAll); } @@ -144,13 +146,6 @@ private CardStackRow collectAllCreatures(List remainingPanels) { STACK_MAX_CREATURES, groupTokensAndCreatures); } - /** - * Shared stacking logic for lands, tokens, and creatures. Panels matching - * the given RowType are grouped by oracle name into stacks. Within a name - * group, panels are added to an existing stack only if the type-specific - * compatibility predicate passes. Stacks are kept adjacent by name and - * ordered via insertIndex tracking. - */ private CardStackRow collectStacked(List remainingPanels, RowType type, BiPredicate isCompatible, int stackMax, boolean unlimitedGrouping) { final CardStackRow out = new CardStackRow(); @@ -187,7 +182,6 @@ private CardStackRow collectStacked(List remainingPanels, RowType typ insertIndex = i + 1; continue; } - // Candidate has attachments, type-specific incompatibility, or stack is full if (!panel.getAttachedPanels().isEmpty() || !isCompatible.test(card, firstCard) || (!unlimitedGrouping && stack.size() >= stackMax)) { @@ -270,50 +264,16 @@ private CardStackRow collectUnstacked(List remainingPanels, RowType t return out; } - /** - * Arranges "other" cards that haven't been placed in other rows into stacks - * based on sickness, cloning, counters, and cards attached to them. All cards - * that aren't equipped/enchanted/enchanting/equipping/etc that are otherwise - * the same get stacked. - */ private CardStackRow collectAllOthers(final List remainingPanels) { - CardStackRow out = new CardStackRow(); - outerLoop: - for (final CardPanel panel : remainingPanels) { - for (final CardStack s : out) { - final CardView otherCard = s.get(0).getCard(); - final CardStateView otherState = otherCard.getCurrentState(); - final CardView thisCard = panel.getCard(); - final CardStateView thisState = thisCard.getCurrentState(); - if (otherState.getOracleName().equals(thisState.getOracleName()) && (groupAll || s.size() < STACK_MAX_OTHERS)) { - // Split cards can't group with non-split cards - boolean cardIsSplit = splitCardIds.contains(thisCard.getId()); - boolean stackIsSplit = splitCardIds.contains(otherCard.getId()); - if (cardIsSplit != stackIsSplit) { - continue; - } - // Blockers assigned to different attackers can't group together - int cardTarget = blockerAssignments.getOrDefault(thisCard.getId(), 0); - int stackTarget = blockerAssignments.getOrDefault(otherCard.getId(), 0); - if (cardTarget != stackTarget) { - continue; - } - if (panel.getAttachedPanels().isEmpty() - && thisCard.hasSameCounters(otherCard) - && (thisCard.isSick() == otherCard.isSick()) - && (thisCard.isCloned() == otherCard.isCloned()) - && (!groupAll || thisCard.isTapped() == otherCard.isTapped()) - && (!groupAll || thisCard.getDamage() == otherCard.getDamage())) { - s.add(panel); - continue outerLoop; - } - } - } - - final CardStack stack = new CardStack(); + CardStackRow out = collectStacked(remainingPanels, RowType.Other, + (card, first) -> card.hasSameCounters(first) + && card.isSick() == first.isSick() + && card.isCloned() == first.isCloned() + && (!groupAll || card.isTapped() == first.isTapped()) + && (!groupAll || card.getDamage() == first.getDamage()), + STACK_MAX_OTHERS, groupAll); + for (CardStack stack : out) { stack.alignRight = true; - stack.add(panel); - out.add(stack); } return out; } @@ -456,7 +416,6 @@ private void positionAllCards(List template) { } int maxVisible = 4; - // Reset groupCount on all panels in this stack for (CardPanel p : stack) { p.setGroupCount(0); } for (int panelIndex = 0, panelCount = stack.size(); panelIndex < panelCount; panelIndex++) { @@ -483,7 +442,6 @@ private void positionAllCards(List template) { panel.setCardBounds(panelX, panelY, this.getCardWidth(), this.cardHeight); panel.setDisplayEnabled(!hidden); } - // Set group count on top card for badge rendering // Exclude attached panels (equipment/auras) — they're pulled // into the stack by addAttachedPanels but aren't grouped cards if (grouping) { @@ -667,34 +625,6 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) if (!selectAll && panel.getGroupCount() >= 2) { selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); } - // Split/un-split individual card from group - boolean wasUnsplit = false; - if (!selectAll && panel.getCard() != null) { - if (splitCardIds.contains(panel.getCard().getId())) { - // Re-clicking a split card un-splits it (re-merges into group) - splitCardIds.remove(panel.getCard().getId()); - doLayout(); - wasUnsplit = true; - } else { - List stack = panel.getStack(); - if (stack != null && stack.size() >= (grouping ? 2 : 5)) { - // Split first, then check if the game accepts this card. - // If accepted, doUpdateCard will remove from splitCardIds when - // the card's state changes (e.g. tapping), allowing it to regroup. - splitCardIds.add(panel.getCard().getId()); - if (getMatchUI().getGameController().selectCard(panel.getCard(), null, new MouseTriggerEvent(evt))) { - doLayout(); - if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { - return; - } - super.mouseLeftClicked(panel, evt); - return; - } - // Game rejected - undo the split - splitCardIds.remove(panel.getCard().getId()); - } - } - } // Badge click on tapped cards — undo once per card to reverse a batch // mana tap, rather than trying to activate (which shows disabled abilities). if (selectAll && panel.getCard() != null && panel.getCard().isTapped() @@ -705,11 +635,40 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) doLayout(); return; } + if (!selectAll && panel.getCard() != null) { + if (splitCardIds.contains(panel.getCard().getId())) { + // Re-clicking a split card un-splits it (merges back into group) + // and undeclares via selectCard in one operation. + splitCardIds.remove(panel.getCard().getId()); + selectCard(panel, new MouseTriggerEvent(evt), false); + doLayout(); + if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { + return; + } + super.mouseLeftClicked(panel, evt); + return; + } + List stack = panel.getStack(); + if (stack != null && stack.size() >= (grouping ? 2 : 5)) { + // Split from group, then check if the game accepts this card. + // If accepted, doUpdateCard will remove from splitCardIds when + // the card's tapped state changes, allowing it to regroup. + splitCardIds.add(panel.getCard().getId()); + if (getMatchUI().getGameController().selectCard(panel.getCard(), null, new MouseTriggerEvent(evt))) { + doLayout(); + if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { + return; + } + super.mouseLeftClicked(panel, evt); + return; + } + splitCardIds.remove(panel.getCard().getId()); + } + } boolean selected = selectCard(panel, new MouseTriggerEvent(evt), selectAll); // If this individual card was accepted (e.g. declared as attacker), mark it // as split so it can merge with other split cards from the same group. - // Skip if the card was just un-split (user is un-declaring, not declaring). - if (selected && !selectAll && !wasUnsplit && panel.getCard() != null) { + if (selected && !selectAll && panel.getCard() != null) { splitCardIds.add(panel.getCard().getId()); } doLayout(); @@ -721,85 +680,105 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) @Override public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) { - // Right-click on badge → prompt for how many to select if (panel.getGroupCount() >= 2 && panel.isBadgeHit(evt.getX(), evt.getY())) { List stack = panel.getStack(); if (stack != null && stack.size() >= 2) { - // Check if the game accepts card selection right now (side-effect-free) - CardView primary = stack.get(0).getCard(); - String activateDesc = primary != null - ? getMatchUI().getGameController().getActivateDescription(primary) : null; - if (activateDesc == null) { - getMatchUI().flashIncorrectAction(); - return; - } - // Context-appropriate prompt based on card state - Localizer loc = Localizer.getInstance(); - String prompt; - boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); - if (alreadyInCombat) { - prompt = loc.getMessage("lblGroupHowManyRemove"); - } else { - if (activateDesc.equals(loc.getMessage("lblAttackWithCard"))) { - prompt = loc.getMessage("lblGroupHowManyAttack"); - } else if (activateDesc.equals(loc.getMessage("lblBlockWithCard"))) { - prompt = loc.getMessage("lblGroupHowManyBlock"); - } else { - prompt = loc.getMessage("lblGroupHowManySelect"); - } - } - Integer count = SGuiChoose.getInteger(prompt, 1, stack.size()); - if (count == null) { - return; // cancelled - } - // Collect the first N card views from the stack - List selected = new ArrayList<>(); - selected.add(primary); - for (int i = 1; i < count && i < stack.size(); i++) { - CardPanel p = stack.get(i); - if (p.getCard() != null) { - selected.add(p.getCard()); - } - } - List others = selected.size() > 1 ? selected.subList(1, selected.size()) : null; - boolean isCombat = alreadyInCombat - || activateDesc.equals(loc.getMessage("lblAttackWithCard")) - || activateDesc.equals(loc.getMessage("lblBlockWithCard")); - if (alreadyInCombat) { - // Use button=3 (right-click) to undeclare the selected cards - MouseTriggerEvent rightClickTrigger = new MouseTriggerEvent(3, evt.getX(), evt.getY()); - getMatchUI().getGameController().selectCard(primary, others, rightClickTrigger); - } else if (isCombat) { - // Combat inputs handle otherCardsToSelect natively - MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); - getMatchUI().getGameController().selectCard(primary, others, leftClickTrigger); - } else { - // Non-combat inputs (sacrifice, targeting) ignore otherCardsToSelect, - // so select each card individually - MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); - for (CardView cv : selected) { - getMatchUI().getGameController().selectCard(cv, null, leftClickTrigger); - } - } - // Clear all cards in this stack from splitCardIds, then mark - // only the selected subset as split. This ensures the selected - // cards separate from the remaining ones in the group. - for (CardPanel p : stack) { + handleBadgeRightClick(stack, evt); + return; + } + } + boolean selected = selectCard(panel, new MouseTriggerEvent(evt), evt.isShiftDown()); + // Right-click undeclare: remove from splitCardIds so card merges back + if (selected && panel.getCard() != null) { + splitCardIds.remove(panel.getCard().getId()); + if (evt.isShiftDown() && panel.getStack() != null) { + for (CardPanel p : panel.getStack()) { if (p.getCard() != null) { splitCardIds.remove(p.getCard().getId()); } } - for (CardView cv : selected) { - splitCardIds.add(cv.getId()); - } - doLayout(); - return; } + doLayout(); } - selectCard(panel, new MouseTriggerEvent(evt), evt.isShiftDown()); //select entire stack if shift key down super.mouseRightClicked(panel, evt); } + private void handleBadgeRightClick(final List stack, final MouseEvent evt) { + CardView primary = stack.get(0).getCard(); + String activateDesc = primary != null + ? getMatchUI().getGameController().getActivateDescription(primary) : null; + if (activateDesc == null) { + getMatchUI().flashIncorrectAction(); + return; + } + Localizer loc = Localizer.getInstance(); + boolean alreadyInCombat = primary.isAttacking() || primary.isBlocking(); + String prompt; + if (alreadyInCombat) { + prompt = loc.getMessage("lblGroupHowManyRemove"); + } else if (activateDesc.equals(loc.getMessage("lblAttackWithCard"))) { + prompt = loc.getMessage("lblGroupHowManyAttack"); + } else if (activateDesc.equals(loc.getMessage("lblBlockWithCard"))) { + prompt = loc.getMessage("lblGroupHowManyBlock"); + } else { + prompt = loc.getMessage("lblGroupHowManySelect"); + } + Integer count = SGuiChoose.getInteger(prompt, 1, stack.size()); + if (count == null) { + return; // cancelled + } + List selected = new ArrayList<>(); + selected.add(primary); + for (int i = 1; i < count && i < stack.size(); i++) { + CardPanel p = stack.get(i); + if (p.getCard() != null) { + selected.add(p.getCard()); + } + } + List others = selected.size() > 1 ? selected.subList(1, selected.size()) : null; + boolean isCombat = alreadyInCombat + || activateDesc.equals(loc.getMessage("lblAttackWithCard")) + || activateDesc.equals(loc.getMessage("lblBlockWithCard")); + if (alreadyInCombat) { + MouseTriggerEvent rightClickTrigger = new MouseTriggerEvent(3, evt.getX(), evt.getY()); + getMatchUI().getGameController().selectCard(primary, others, rightClickTrigger); + } else if (isCombat) { + MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); + getMatchUI().getGameController().selectCard(primary, others, leftClickTrigger); + } else { + // Non-combat inputs (sacrifice, targeting) ignore otherCardsToSelect + MouseTriggerEvent leftClickTrigger = new MouseTriggerEvent(1, evt.getX(), evt.getY()); + for (CardView cv : selected) { + getMatchUI().getGameController().selectCard(cv, null, leftClickTrigger); + } + } + // Update splitCardIds: clear whole stack, then mark the subset that + // should remain visually separate. + for (CardPanel p : stack) { + if (p.getCard() != null) { + splitCardIds.remove(p.getCard().getId()); + } + } + if (alreadyInCombat) { + // Undeclaring: selected cards merge back. Non-selected stay split. + Set selectedIds = new HashSet<>(); + for (CardView cv : selected) { + selectedIds.add(cv.getId()); + } + for (CardPanel p : stack) { + if (p.getCard() != null && !selectedIds.contains(p.getCard().getId())) { + splitCardIds.add(p.getCard().getId()); + } + } + } else { + // Declaring/selecting: selected cards split from the rest. + for (CardView cv : selected) { + splitCardIds.add(cv.getId()); + } + } + doLayout(); + } + private boolean selectCard(final CardPanel panel, final MouseTriggerEvent triggerEvent, final boolean selectEntireStack) { List otherCardViewsToSelect = null; List stack = panel.getStack(); From 26adc4f01ecc14561e50f91bd91451ad91ab518a Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:54:00 +1030 Subject: [PATCH 15/16] Fix combat grouping by unboxing Integer comparison, add attacker-side grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combat assignment comparison in collectStacked() used != on boxed Integer values from Map.getOrDefault(). Java only caches boxed Integers for -128 to 127, so for card IDs >= 128 (common in token-heavy games), != compared object references instead of values — causing identical assignments like 164 vs 164 to be treated as different. Fix by unboxing to primitive int before comparing. Also extend the assignment map to cover attacker→blockerHash so identical attackers blocked by different creatures are kept in separate piles. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/forge/view/arcane/PlayArea.java | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index 2d14d112a57..f7f9f375c93 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -74,8 +74,8 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen // changes, but this does NOT cover undeclare during InputAttack since // cards aren't actually tapped yet — click handlers must clean up. private final Set splitCardIds = new HashSet<>(); - // Blocker card ID → attacker card ID; rebuilt each doLayout() from CombatView. - private Map blockerAssignments = Collections.emptyMap(); + // Combat pairing map; rebuilt each doLayout() from CombatView. + private Map combatAssignments = Collections.emptyMap(); // Coalesces multiple invokeLater(doLayout) calls within a single EDT cycle. private boolean layoutPending; @@ -176,9 +176,12 @@ private CardStackRow collectStacked(List remainingPanels, RowType typ insertIndex = i + 1; continue; } - // Blockers assigned to different attackers can't group together - if (blockerAssignments.getOrDefault(card.getId(), 0) - != blockerAssignments.getOrDefault(firstCard.getId(), 0)) { + // Cards with different combat pairings can't group together. + // Must unbox to int — != on boxed Integer compares references, + // not values, for IDs >= 128. + int cardAssign = combatAssignments.getOrDefault(card.getId(), 0); + int firstAssign = combatAssignments.getOrDefault(firstCard.getId(), 0); + if (cardAssign != firstAssign) { insertIndex = i + 1; continue; } @@ -287,17 +290,27 @@ public final CardPanel addCard(final CardView card) { return placeholder; } - private Map buildBlockerAssignments() { + // Builds a combat-assignment map used to prevent grouping of cards with + // different combat pairings. Maps blocker→attacker ID (so blockers of + // different attackers stay separate) AND attacker→hash of blocker IDs + // (so attackers with different blocker sets stay separate). + private Map buildCombatAssignments() { if (getMatchUI().getGameView() == null) { return Collections.emptyMap(); } CombatView combat = getMatchUI().getGameView().getCombat(); if (combat == null) { return Collections.emptyMap(); } Map assignments = new HashMap<>(); for (CardView attacker : combat.getAttackers()) { FCollection blockers = combat.getPlannedBlockers(attacker); - if (blockers == null) { continue; } + if (blockers == null || blockers.isEmpty()) { continue; } for (CardView blocker : blockers) { assignments.put(blocker.getId(), attacker.getId()); } + // Also distinguish attackers by their blocker set + int blockerHash = 0; + for (CardView blocker : blockers) { + blockerHash ^= blocker.getId(); + } + assignments.put(attacker.getId(), blockerHash); } return assignments; } @@ -306,7 +319,7 @@ private Map buildBlockerAssignments() { public final void doLayout() { this.makeTokenRow = FModel.getPreferences().getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); updateGroupScope(); - blockerAssignments = buildBlockerAssignments(); + combatAssignments = buildCombatAssignments(); final Rectangle rect = this.getScrollPane().getVisibleRect(); this.playAreaWidth = rect.width; @@ -821,7 +834,7 @@ private boolean selectCard(final CardPanel panel, final MouseTriggerEvent trigge public void update() { FThreads.assertExecutedByEdt(true); splitCardIds.clear(); - blockerAssignments = Collections.emptyMap(); + combatAssignments = Collections.emptyMap(); recalculateCardPanels(model, zone); } From d5d89b2ad8abf8ce6d839c140c63879b48610474 Mon Sep 17 00:00:00 2001 From: MostCromulent <201167372+MostCromulent@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:47:05 +1030 Subject: [PATCH 16/16] Fix network combat grouping and split-from-group scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildCombatAssignments() now maps attackers to defenders and uses Integer.MIN_VALUE high bit so combat assignments never collide with the default 0 for non-combat cards. Blocker set hash uses multiplicative combining (31*h+id) instead of XOR which cancels to 0 with certain ID combinations. showCombat() triggers doLayout() on all play areas and GameEventCombatUpdate sets needCombatUpdate so the host refreshes when the remote client declares blockers. Removed combatAssignments clearing from update() to prevent a race where updateZones() wipes assignments built by showCombat() in the same processEvents pass. Split-from-group gated on isLocalPlayer(model) — clicking opponent's cards no longer visually splits them. Combat assignment map handles separation when blockers are actually assigned. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/forge/screens/match/CMatchUI.java | 4 ++ .../main/java/forge/view/arcane/PlayArea.java | 44 +++++++++++++------ .../gui/control/FControlGameEventHandler.java | 1 + 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java index 45b18889f61..5d035d36f4c 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java +++ b/forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java @@ -404,6 +404,10 @@ else if (selectedDocBeforeCombat != null) { //re-select doc that was selected be } cCombat.setModel(combat); cCombat.update(); + // Combat pairings changed — rebuild layout so grouping reflects them + for (final VField f : getFieldViews()) { + f.getTabletop().doLayout(); + } } // showCombat(CombatView) @Override diff --git a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java index f7f9f375c93..ed4f1baa7c7 100644 --- a/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java +++ b/forge-gui-desktop/src/main/java/forge/view/arcane/PlayArea.java @@ -26,6 +26,7 @@ import com.google.common.collect.Lists; +import forge.game.GameEntityView; import forge.game.card.CardView; import forge.game.combat.CombatView; import forge.util.collect.FCollection; @@ -292,25 +293,34 @@ public final CardPanel addCard(final CardView card) { // Builds a combat-assignment map used to prevent grouping of cards with // different combat pairings. Maps blocker→attacker ID (so blockers of - // different attackers stay separate) AND attacker→hash of blocker IDs - // (so attackers with different blocker sets stay separate). + // different attackers stay separate), attacker→defender+blockerHash (so + // attackers assigned to different defenders or with different blocker + // sets stay separate). private Map buildCombatAssignments() { if (getMatchUI().getGameView() == null) { return Collections.emptyMap(); } CombatView combat = getMatchUI().getGameView().getCombat(); if (combat == null) { return Collections.emptyMap(); } Map assignments = new HashMap<>(); for (CardView attacker : combat.getAttackers()) { + // High bit ensures combat assignments are always negative, never + // colliding with the default 0 for non-combat cards + GameEntityView defender = combat.getDefender(attacker); + int assignment = (defender != null ? defender.getId() : 0) | Integer.MIN_VALUE; + FCollection blockers = combat.getPlannedBlockers(attacker); - if (blockers == null || blockers.isEmpty()) { continue; } - for (CardView blocker : blockers) { - assignments.put(blocker.getId(), attacker.getId()); - } - // Also distinguish attackers by their blocker set - int blockerHash = 0; - for (CardView blocker : blockers) { - blockerHash ^= blocker.getId(); + if (blockers != null && !blockers.isEmpty()) { + for (CardView blocker : blockers) { + assignments.put(blocker.getId(), attacker.getId() | Integer.MIN_VALUE); + } + // Multiplicative hash, not XOR — XOR of certain ID + // combinations cancels to 0 + int blockerHash = 1; + for (CardView blocker : blockers) { + blockerHash = 31 * blockerHash + blocker.getId(); + } + assignment ^= blockerHash; } - assignments.put(attacker.getId(), blockerHash); + assignments.put(attacker.getId(), assignment); } return assignments; } @@ -634,6 +644,7 @@ public final void mouseOver(final CardPanel panel, final MouseEvent evt) { @Override public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) { + boolean isLocal = getMatchUI().isLocalPlayer(model); boolean selectAll = evt.isShiftDown(); if (!selectAll && panel.getGroupCount() >= 2) { selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); @@ -662,7 +673,8 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) return; } List stack = panel.getStack(); - if (stack != null && stack.size() >= (grouping ? 2 : 5)) { + if (stack != null && stack.size() >= (grouping ? 2 : 5) + && isLocal) { // Split from group, then check if the game accepts this card. // If accepted, doUpdateCard will remove from splitCardIds when // the card's tapped state changes, allowing it to regroup. @@ -681,7 +693,9 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) boolean selected = selectCard(panel, new MouseTriggerEvent(evt), selectAll); // If this individual card was accepted (e.g. declared as attacker), mark it // as split so it can merge with other split cards from the same group. - if (selected && !selectAll && panel.getCard() != null) { + // Only split on the local player's own field — clicking opponent's cards + // (e.g. selecting an attacker during declare blockers) should not split. + if (selected && !selectAll && panel.getCard() != null && isLocal) { splitCardIds.add(panel.getCard().getId()); } doLayout(); @@ -834,7 +848,9 @@ private boolean selectCard(final CardPanel panel, final MouseTriggerEvent trigge public void update() { FThreads.assertExecutedByEdt(true); splitCardIds.clear(); - combatAssignments = Collections.emptyMap(); + // Don't clear combatAssignments — doLayout() rebuilds from CombatView. + // Clearing here races with network play: updateZones() runs AFTER + // showCombat() in the same processEvents pass, wiping assignments recalculateCardPanels(model, zone); } diff --git a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java index cc139d6a155..6e49517758b 100644 --- a/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java +++ b/forge-gui/src/main/java/forge/gui/control/FControlGameEventHandler.java @@ -379,6 +379,7 @@ public Void visit(final GameEventCombatUpdate event) { cards.addAll(event.attackers()); cards.addAll(event.blockers()); + needCombatUpdate = true; refreshFieldUpdate = true; processCards(cards, cardsRefreshDetails);