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/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/screens/match/menus/GameMenu.java b/forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java index 5a3c2ffa77c..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 @@ -7,6 +7,7 @@ import javax.swing.JMenu; import javax.swing.JPopupMenu; import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; import com.google.common.primitives.Ints; @@ -18,8 +19,10 @@ 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; import forge.toolbox.FSkin.SkinnedMenu; import forge.toolbox.FSkin.SkinnedMenuItem; import forge.toolbox.FSkin.SkinnedRadioButtonMenuItem; @@ -50,6 +53,8 @@ public JMenu getMenu() { menu.addSeparator(); menu.add(getMenuItem_TargetingArcs()); menu.add(new CardOverlaysMenu(matchUI).getMenu()); + menu.add(getSubmenu_StackGroupPermanents()); + menu.add(getMenuItem_TokensSeparateRow()); menu.add(getMenuItem_AutoYields()); menu.addSeparator(); menu.add(getMenuItem_ViewDeckList()); @@ -195,4 +200,54 @@ private SkinnedMenuItem getMenuItem_ViewDeckList() { private ActionListener getViewDeckListAction() { return e -> matchUI.viewDeckList(); } + + private SkinnedMenu getSubmenu_StackGroupPermanents() { + final Localizer localizer = Localizer.getInstance(); + final SkinnedMenu submenu = new SkinnedMenu(localizer.getMessage("cbpStackGroupPermanents")); + final ButtonGroup group = new ButtonGroup(); + final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS); + + 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); + } + return submenu; + } + + private SkinnedCheckBoxMenuItem getMenuItem_TokensSeparateRow() { + final Localizer localizer = Localizer.getInstance(); + SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("cbpTokensSeparateRow")); + 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); + 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); + prefs.save(); + SwingUtilities.invokeLater(() -> { + for (final VField f : matchUI.getFieldViews()) { + f.getTabletop().doLayout(); + } + }); + }; + } } 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/CardPanel.java b/forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java index fa88215c07c..ad3f0f6b132 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 @@ -126,7 +126,11 @@ ZoneType.Flashback, new Color(80, 20, 100) private String zoneBannerText; private Color zoneBannerColor; 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; @@ -261,6 +265,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; } @@ -400,6 +411,9 @@ protected final void paintChildren(final Graphics g) { } displayIconOverlay(g, canShow); + if (groupCount >= 2) { + drawGroupCountBadge(g); + } if (zoneBannerText != null) { drawZoneBanner(g); } @@ -514,6 +528,64 @@ 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); + + if (badgeFont == null || badgeFontCardWidth != cardWidth) { + badgeFont = new Font("Dialog", Font.BOLD, Math.max(10, cardWidth / 5)); + badgeFontCardWidth = cardWidth; + } + + String text = "\u00D7" + groupCount; + 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; + int cornerRadius = Math.max(4, cardWidth / 16); + + g2d.setColor(BADGE_BG_COLOR); + g2d.fillRoundRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, cornerRadius); + + g2d.setColor(Color.WHITE); + g2d.setFont(badgeFont); + g2d.drawString(text, badgeX + padX, badgeY + padY + textHeight); + } + + public boolean isBadgeHit(int mouseX, int mouseY) { + if (groupCount < 2) { + return false; + } + // 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 localX >= badgeX && localX <= badgeX + badgeWidth + && localY >= badgeY && localY <= 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..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 @@ -22,15 +22,21 @@ import java.awt.Rectangle; import java.awt.event.MouseEvent; import java.util.*; +import java.util.function.BiPredicate; 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; import forge.game.card.CardView.CardStateView; 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.util.Localizer; import forge.model.FModel; import forge.screens.match.CMatchUI; import forge.toolbox.FScrollPane; @@ -64,6 +70,16 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private final boolean mirror; + // 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<>(); + // 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; + // Computed in layout. private List rows = new ArrayList<>(); private int cardWidth, cardHeight; @@ -76,6 +92,9 @@ public class PlayArea extends CardPanelContainer implements CardPanelMouseListen private boolean makeTokenRow = true; 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); @@ -83,177 +102,106 @@ 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 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; - } - if (!panel.getAttachedPanels().isEmpty() - || !panel.getCard().hasSameCounters(firstPanel.getCard()) - || firstPanel.getCard().hasCardAttachments() - || (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; - } - } + private void updateGroupScope() { + String groupScope = FModel.getPreferences().getPref(FPref.UI_GROUP_PERMANENTS); + 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; + } - final CardStack stack = new CardStack(); - stack.add(panel); - iterator.remove(); - allLands.add(insertIndex == -1 ? allLands.size() : insertIndex, stack); - } - return allLands; + private CardStackRow collectAllLands(List remainingPanels) { + return collectStacked(remainingPanels, RowType.Land, + (card, first) -> card.hasSameCounters(first) + && (!groupAll || card.isTapped() == first.isTapped()) + && (!groupAll || 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; - } - - if (!panel.getAttachedPanels().isEmpty() - || !card.hasSameCounters(firstPanel.getCard()) - || (card.isSick() != firstCard.isSick()) - || !card.hasSamePT(firstCard) - || !(card.getText().equals(firstCard.getText())) - || (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) + 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); + } + + 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; - } - if (!panel.getAttachedPanels().isEmpty() - || card.isCloned() - || !card.hasSameCounters(firstCard) - || (card.isSick() != firstCard.isSick()) - || !card.hasSamePT(firstCard) - || (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; + } + // 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; + } + 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) { @@ -320,36 +268,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()) && s.size() < STACK_MAX_OTHERS) { - if (panel.getAttachedPanels().isEmpty() - && thisCard.hasSameCounters(otherCard) - && (thisCard.isSick() == otherCard.isSick()) - && (thisCard.isCloned() == otherCard.isCloned())) { - 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; } @@ -363,8 +291,45 @@ public final CardPanel addCard(final CardView card) { return placeholder; } + // 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), 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()) { + 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(), assignment); + } + return assignments; + } + @Override public final void doLayout() { + this.makeTokenRow = FModel.getPreferences().getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW); + updateGroupScope(); + combatAssignments = buildCombatAssignments(); final Rectangle rect = this.getScrollPane().getVisibleRect(); this.playAreaWidth = rect.width; @@ -472,14 +437,42 @@ private void positionAllCards(List template) { x -= r.getWidth(); } } + int maxVisible = 4; + + 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); + } + // 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(); @@ -651,7 +644,61 @@ 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 isLocal = getMatchUI().isLocalPlayer(model); + boolean selectAll = evt.isShiftDown(); + if (!selectAll && panel.getGroupCount() >= 2) { + selectAll = panel.isBadgeHit(evt.getX(), evt.getY()); + } + // 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; + } + 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) + && 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. + 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. + // 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(); if ((panel.getTappedAngle() != 0) && (panel.getTappedAngle() != CardPanel.TAPPED_ANGLE)) { return; } @@ -660,10 +707,105 @@ public final void mouseLeftClicked(final CardPanel panel, final MouseEvent evt) @Override public final void mouseRightClicked(final CardPanel panel, final MouseEvent evt) { - selectCard(panel, new MouseTriggerEvent(evt), evt.isShiftDown()); //select entire stack if shift key down + if (panel.getGroupCount() >= 2 && panel.isBadgeHit(evt.getX(), evt.getY())) { + List stack = panel.getStack(); + if (stack != null && stack.size() >= 2) { + 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()); + } + } + } + doLayout(); + } 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(); @@ -705,6 +847,10 @@ private boolean selectCard(final CardPanel panel, final MouseTriggerEvent trigge public void update() { FThreads.assertExecutedByEdt(true); + splitCardIds.clear(); + // 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); } @@ -790,6 +936,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); @@ -839,8 +989,17 @@ 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. + // 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; + return needLayoutRefresh || tappedStateChanged; } private enum RowType { @@ -928,12 +1087,16 @@ private void addAttachedPanels(final CardPanel panel) { } private int getWidth() { - return PlayArea.this.cardWidth + ((this.size() - 1) * PlayArea.this.stackSpacingX) + 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() { - return PlayArea.this.cardHeight + ((this.size() - 1) * PlayArea.this.stackSpacingY) + 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 d624acb5097..da4e7070570 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -243,6 +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. +cbpStackGroupPermanents=Stack/Group Permanents +cbpTokensSeparateRow=Tokens in Separate Row +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/gamemodes/match/input/InputBlock.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputBlock.java index 328cfac9f63..3fe862840e8 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/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); 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 8fce9d9ef09..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,6 +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_TOKENS_IN_SEPARATE_ROW("false"), UI_UPLOAD_DRAFT ("false"), UI_SCALE_LARGER ("true"),