Skip to content

Commit 6b4be1c

Browse files
Group identical permanents into numbered stacks (Card-Forge#9751)
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. 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 user preference for max stack depth (default 4, range 1-10) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 399e640 commit 6b4be1c

12 files changed

Lines changed: 654 additions & 236 deletions

File tree

forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import forge.toolbox.FLabel;
3030
import forge.toolbox.FOptionPane;
3131
import forge.util.Localizer;
32+
import forge.view.arcane.PlayArea;
3233
import org.apache.commons.lang3.StringUtils;
3334
import org.apache.commons.lang3.tuple.Pair;
3435

@@ -37,6 +38,7 @@
3738
import java.awt.event.ItemEvent;
3839
import java.io.File;
3940
import java.util.ArrayList;
41+
import java.util.LinkedHashMap;
4042
import java.util.List;
4143
import java.util.Map;
4244

@@ -156,7 +158,6 @@ public void initialize() {
156158
lstControls.add(Pair.of(view.getCbCardTextHideReminder(), FPref.UI_CARD_IMAGE_RENDER_HIDE_REMINDER_TEXT));
157159
lstControls.add(Pair.of(view.getCbOpenPacksIndiv(), FPref.UI_OPEN_PACKS_INDIV));
158160
lstControls.add(Pair.of(view.getCbTokensInSeparateRow(), FPref.UI_TOKENS_IN_SEPARATE_ROW));
159-
lstControls.add(Pair.of(view.getCbStackCreatures(), FPref.UI_STACK_CREATURES));
160161
lstControls.add(Pair.of(view.getCbSeparateCombatStacks(), FPref.UI_SEPARATE_COMBAT_STACKS));
161162
lstControls.add(Pair.of(view.getCbManaLostPrompt(), FPref.UI_MANA_LOST_PROMPT));
162163
lstControls.add(Pair.of(view.getCbEscapeEndsTurn(), FPref.UI_ALLOW_ESC_TO_END_TURN));
@@ -227,6 +228,8 @@ public void initialize() {
227228
initializeColorIdentityCombobox();
228229
initializeSwitchStatesCombobox();
229230
initializeAutoYieldModeComboBox();
231+
initializeStackGroupPermanentsComboBox();
232+
initializeMaxStackDepthComboBox();
230233
initializeCounterDisplayTypeComboBox();
231234
initializeCounterDisplayLocationComboBox();
232235
initializeGraveyardOrderingComboBox();
@@ -592,6 +595,45 @@ private void initializeGraveyardOrderingComboBox() {
592595
panel.setComboBox(comboBox, selectedItem);
593596
}
594597

598+
private void initializeStackGroupPermanentsComboBox() {
599+
final Localizer localizer = Localizer.getInstance();
600+
final String[] keys = {"default", "stack", "group_creatures", "group_all"};
601+
final String[] labelKeys = {"lblGroupDefault", "lblGroupStack", "lblGroupCreatures", "lblGroupAll"};
602+
final Map<String, String> mapping = new LinkedHashMap<>();
603+
final String[] labels = new String[keys.length];
604+
for (int i = 0; i < keys.length; i++) {
605+
labels[i] = localizer.getMessage(labelKeys[i]);
606+
mapping.put(labels[i], keys[i]);
607+
}
608+
final FComboBoxPanel<String> panel = this.view.getCbpStackGroupPermanents();
609+
final FComboBox<String> comboBox = createLocalizedComboBox(labels, FPref.UI_GROUP_PERMANENTS, mapping);
610+
final String savedValue = this.prefs.getPref(FPref.UI_GROUP_PERMANENTS);
611+
final String selectedLabel = mapping.entrySet().stream()
612+
.filter(e -> e.getValue().equals(savedValue))
613+
.map(Map.Entry::getKey)
614+
.findFirst()
615+
.orElse(labels[0]);
616+
panel.setComboBox(comboBox, selectedLabel);
617+
}
618+
619+
private void initializeMaxStackDepthComboBox() {
620+
final Integer[] elems = new Integer[PlayArea.MAX_STACK_DEPTH - PlayArea.MIN_STACK_DEPTH + 1];
621+
for (int i = 0; i < elems.length; i++) {
622+
elems[i] = PlayArea.MIN_STACK_DEPTH + i;
623+
}
624+
final FPref userSetting = FPref.UI_MAX_STACK_DEPTH;
625+
final FComboBoxPanel<Integer> panel = this.view.getCbpMaxStackDepth();
626+
final FComboBox<Integer> comboBox = createComboBox(elems, userSetting);
627+
comboBox.setMaximumRowCount(elems.length);
628+
Integer selectedItem;
629+
try {
630+
selectedItem = Integer.valueOf(this.prefs.getPref(userSetting));
631+
} catch (NumberFormatException e) {
632+
selectedItem = 4;
633+
}
634+
panel.setComboBox(comboBox, selectedItem);
635+
}
636+
595637
private void initializeCounterDisplayTypeComboBox() {
596638

597639
final String[] elements = new String[ForgeConstants.CounterDisplayType.values().length];

forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ public enum VSubmenuPreferences implements IVSubmenu<CSubmenuPreferences> {
112112
private final JCheckBox cbCardTextHideReminder = new OptionsCheckBox(localizer.getMessage("cbCardTextHideReminder"));
113113
private final JCheckBox cbOpenPacksIndiv = new OptionsCheckBox(localizer.getMessage("cbOpenPacksIndiv"));
114114
private final JCheckBox cbTokensInSeparateRow = new OptionsCheckBox(localizer.getMessage("cbTokensInSeparateRow"));
115-
private final JCheckBox cbStackCreatures = new OptionsCheckBox(localizer.getMessage("cbStackCreatures"));
116115
private final JCheckBox cbSeparateCombatStacks = new OptionsCheckBox(localizer.getMessage("cbSeparateCombatStacks"));
117116
private final JCheckBox cbFilterLandsByColorId = new OptionsCheckBox(localizer.getMessage("cbFilterLandsByColorId"));
118117
private final JCheckBox cbShowStormCount = new OptionsCheckBox(localizer.getMessage("cbShowStormCount"));
@@ -154,6 +153,8 @@ public enum VSubmenuPreferences implements IVSubmenu<CSubmenuPreferences> {
154153
private final FComboBoxPanel<String> cbpLandPlayed = new FComboBoxPanel<>(localizer.getMessage("cbpLandPlayed")+":");
155154
private final FComboBoxPanel<String> cbpDisplayCurrentCardColors = new FComboBoxPanel<>(localizer.getMessage("cbpDisplayCurrentCardColors")+":");
156155
private final FComboBoxPanel<String> cbpAutoYieldMode = new FComboBoxPanel<>(localizer.getMessage("cbpAutoYieldMode")+":");
156+
private final FComboBoxPanel<String> cbpStackGroupPermanents = new FComboBoxPanel<>(localizer.getMessage("cbpStackGroupPermanents")+":");
157+
private final FComboBoxPanel<Integer> cbpMaxStackDepth = new FComboBoxPanel<>(localizer.getMessage("cbpMaxStackDepth")+":");
157158
private final FComboBoxPanel<String> cbpCounterDisplayType = new FComboBoxPanel<>(localizer.getMessage("cbpCounterDisplayType")+":");
158159
private final FComboBoxPanel<String> cbpCounterDisplayLocation =new FComboBoxPanel<>(localizer.getMessage("cbpCounterDisplayLocation")+":");
159160
private final FComboBoxPanel<String> cbpGraveyardOrdering = new FComboBoxPanel<>(localizer.getMessage("cbpGraveyardOrdering")+":");
@@ -447,8 +448,11 @@ public enum VSubmenuPreferences implements IVSubmenu<CSubmenuPreferences> {
447448
pnlPrefs.add(cbTokensInSeparateRow, titleConstraints);
448449
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlTokensInSeparateRow")), descriptionConstraints);
449450

450-
pnlPrefs.add(cbStackCreatures, titleConstraints);
451-
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlStackCreatures")), descriptionConstraints);
451+
pnlPrefs.add(cbpStackGroupPermanents, comboBoxConstraints);
452+
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlGroupPermanents")), descriptionConstraints);
453+
454+
pnlPrefs.add(cbpMaxStackDepth, comboBoxConstraints);
455+
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlMaxStackDepth")), descriptionConstraints);
452456

453457
pnlPrefs.add(cbSeparateCombatStacks, titleConstraints);
454458
pnlPrefs.add(new NoteLabel(localizer.getMessage("nlSeparateCombatStacks")), descriptionConstraints);
@@ -903,6 +907,14 @@ public FComboBoxPanel<String> getAutoYieldModeComboBoxPanel() {
903907
return cbpAutoYieldMode;
904908
}
905909

910+
public FComboBoxPanel<String> getCbpStackGroupPermanents() {
911+
return cbpStackGroupPermanents;
912+
}
913+
914+
public FComboBoxPanel<Integer> getCbpMaxStackDepth() {
915+
return cbpMaxStackDepth;
916+
}
917+
906918
public FComboBoxPanel<String> getCounterDisplayTypeComboBoxPanel() {
907919
return cbpCounterDisplayType;
908920
}
@@ -1013,10 +1025,6 @@ public final JCheckBox getCbTokensInSeparateRow() {
10131025
return cbTokensInSeparateRow;
10141026
}
10151027

1016-
public final JCheckBox getCbStackCreatures() {
1017-
return cbStackCreatures;
1018-
}
1019-
10201028
public final JCheckBox getCbSeparateCombatStacks() {
10211029
return cbSeparateCombatStacks;
10221030
}

forge-gui-desktop/src/main/java/forge/screens/match/CMatchUI.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,17 @@ else if (selectedDocBeforeCombat != null) { //re-select doc that was selected be
427427
}
428428
cCombat.setModel(combat);
429429
cCombat.update();
430-
if (isPreferenceEnabled(FPref.UI_SEPARATE_COMBAT_STACKS)) {
430+
431+
// Combat pairings changed — rebuild layout so grouping reflects them
432+
if (!"default".equals(FModel.getPreferences().getPref(FPref.UI_GROUP_PERMANENTS))
433+
|| isPreferenceEnabled(FPref.UI_SEPARATE_COMBAT_STACKS)) {
431434
FThreads.invokeInEdtNowOrLater(() -> {
432435
for (final VField f : getFieldViews()) {
433436
f.getTabletop().doLayout();
434437
}
435438
});
436439
}
437-
} // showCombat(CombatView)
440+
}
438441

439442
@Override
440443
public void updateDependencies() {

forge-gui-desktop/src/main/java/forge/screens/match/menus/GameMenu.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import javax.swing.JMenu;
88
import javax.swing.JPopupMenu;
99
import javax.swing.KeyStroke;
10+
import javax.swing.SwingUtilities;
1011

1112
import com.google.common.primitives.Ints;
1213

@@ -18,8 +19,10 @@
1819
import forge.model.FModel;
1920
import forge.screens.match.CMatchUI;
2021
import forge.screens.match.VAutoYields;
22+
import forge.screens.match.views.VField;
2123
import forge.screens.match.controllers.CDock.ArcState;
2224
import forge.toolbox.FSkin.SkinIcon;
25+
import forge.toolbox.FSkin.SkinnedCheckBoxMenuItem;
2326
import forge.toolbox.FSkin.SkinnedMenu;
2427
import forge.toolbox.FSkin.SkinnedMenuItem;
2528
import forge.toolbox.FSkin.SkinnedRadioButtonMenuItem;
@@ -50,6 +53,9 @@ public JMenu getMenu() {
5053
menu.addSeparator();
5154
menu.add(getMenuItem_TargetingArcs());
5255
menu.add(new CardOverlaysMenu(matchUI).getMenu());
56+
menu.add(getSubmenu_StackGroupPermanents());
57+
menu.add(getMenuItem_TokensSeparateRow());
58+
menu.add(getMenuItem_SeparateCombatStacks());
5359
menu.add(getMenuItem_AutoYields());
5460
menu.addSeparator();
5561
menu.add(getMenuItem_ViewDeckList());
@@ -194,4 +200,72 @@ private SkinnedMenuItem getMenuItem_ViewDeckList() {
194200
private ActionListener getViewDeckListAction() {
195201
return e -> matchUI.viewDeckList();
196202
}
203+
204+
private SkinnedMenu getSubmenu_StackGroupPermanents() {
205+
final Localizer localizer = Localizer.getInstance();
206+
final SkinnedMenu submenu = new SkinnedMenu(localizer.getMessage("cbpStackGroupPermanents"));
207+
final ButtonGroup group = new ButtonGroup();
208+
final String current = prefs.getPref(FPref.UI_GROUP_PERMANENTS);
209+
210+
final String[] keys = {"default", "stack", "group_creatures", "group_all"};
211+
final String[] labelKeys = {"lblGroupDefault", "lblGroupStack", "lblGroupCreatures", "lblGroupAll"};
212+
final String[] tooltipKeys = {"nlGroupDefault", "nlGroupStack", "nlGroupCreatures", "nlGroupAll"};
213+
for (int i = 0; i < keys.length; i++) {
214+
SkinnedRadioButtonMenuItem item = new SkinnedRadioButtonMenuItem(localizer.getMessage(labelKeys[i]));
215+
item.setToolTipText(localizer.getMessage(tooltipKeys[i]));
216+
item.setSelected(keys[i].equals(current));
217+
item.addActionListener(getGroupPermanentsAction(keys[i]));
218+
group.add(item);
219+
submenu.add(item);
220+
}
221+
return submenu;
222+
}
223+
224+
private SkinnedCheckBoxMenuItem getMenuItem_TokensSeparateRow() {
225+
final Localizer localizer = Localizer.getInstance();
226+
SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("cbpTokensSeparateRow"));
227+
menuItem.setToolTipText(localizer.getMessage("nlTokensSeparateRow"));
228+
menuItem.setState(prefs.getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW));
229+
menuItem.addActionListener(e -> {
230+
final boolean enabled = !prefs.getPrefBoolean(FPref.UI_TOKENS_IN_SEPARATE_ROW);
231+
prefs.setPref(FPref.UI_TOKENS_IN_SEPARATE_ROW, enabled);
232+
prefs.save();
233+
SwingUtilities.invokeLater(() -> {
234+
for (final VField f : matchUI.getFieldViews()) {
235+
f.getTabletop().doLayout();
236+
}
237+
});
238+
});
239+
return menuItem;
240+
}
241+
242+
private SkinnedCheckBoxMenuItem getMenuItem_SeparateCombatStacks() {
243+
final Localizer localizer = Localizer.getInstance();
244+
SkinnedCheckBoxMenuItem menuItem = new SkinnedCheckBoxMenuItem(localizer.getMessage("cbSeparateCombatStacks"));
245+
menuItem.setToolTipText(localizer.getMessage("nlSeparateCombatStacks"));
246+
menuItem.setState(prefs.getPrefBoolean(FPref.UI_SEPARATE_COMBAT_STACKS));
247+
menuItem.addActionListener(e -> {
248+
final boolean enabled = !prefs.getPrefBoolean(FPref.UI_SEPARATE_COMBAT_STACKS);
249+
prefs.setPref(FPref.UI_SEPARATE_COMBAT_STACKS, enabled);
250+
prefs.save();
251+
SwingUtilities.invokeLater(() -> {
252+
for (final VField f : matchUI.getFieldViews()) {
253+
f.getTabletop().doLayout();
254+
}
255+
});
256+
});
257+
return menuItem;
258+
}
259+
260+
private ActionListener getGroupPermanentsAction(final String value) {
261+
return e -> {
262+
prefs.setPref(FPref.UI_GROUP_PERMANENTS, value);
263+
prefs.save();
264+
SwingUtilities.invokeLater(() -> {
265+
for (final VField f : matchUI.getFieldViews()) {
266+
f.getTabletop().doLayout();
267+
}
268+
});
269+
};
270+
}
197271
}

forge-gui-desktop/src/main/java/forge/toolbox/MouseTriggerEvent.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ public MouseTriggerEvent(final MouseEvent event) {
1717
this.y = event.getY();
1818
}
1919

20+
public MouseTriggerEvent(final int button, final int x, final int y) {
21+
this.button = button;
22+
this.x = x;
23+
this.y = y;
24+
}
25+
2026
@Override
2127
public int getButton() {
2228
return button;

forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@ ZoneType.Flashback, new Color(80, 20, 100)
126126
private String zoneBannerText;
127127
private Color zoneBannerColor;
128128
private CachedCardImage cachedImage;
129+
private int groupCount;
130+
private Font badgeFont;
131+
private int badgeFontCardWidth; // cardWidth when badgeFont was last computed
129132

133+
private static final Color BADGE_BG_COLOR = new Color(0, 0, 0, 180);
130134
private static Font smallCounterFont;
131135
private static Font largeCounterFont;
132136

@@ -261,6 +265,13 @@ public final void setDisplayEnabled(final boolean displayEnabled0) {
261265
displayEnabled = displayEnabled0;
262266
}
263267

268+
public int getGroupCount() {
269+
return groupCount;
270+
}
271+
public void setGroupCount(int count) {
272+
this.groupCount = count;
273+
}
274+
264275
public final void setAnimationPanel(final boolean isAnimationPanel0) {
265276
isAnimationPanel = isAnimationPanel0;
266277
}
@@ -400,6 +411,9 @@ protected final void paintChildren(final Graphics g) {
400411

401412
}
402413
displayIconOverlay(g, canShow);
414+
if (groupCount >= 2) {
415+
drawGroupCountBadge(g);
416+
}
403417
if (zoneBannerText != null) {
404418
drawZoneBanner(g);
405419
}
@@ -514,6 +528,64 @@ private void displayCardNameOverlay(final boolean isVisible, final Dimension img
514528
titleText.setVisible(isVisible);
515529
}
516530

531+
private void drawGroupCountBadge(final Graphics g) {
532+
Graphics2D g2d = (Graphics2D) g;
533+
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
534+
535+
if (badgeFont == null || badgeFontCardWidth != cardWidth) {
536+
badgeFont = new Font("Dialog", Font.BOLD, Math.max(10, cardWidth / 5));
537+
badgeFontCardWidth = cardWidth;
538+
}
539+
540+
String text = "\u00D7" + groupCount;
541+
FontMetrics fm = g2d.getFontMetrics(badgeFont);
542+
543+
int textWidth = fm.stringWidth(text);
544+
int textHeight = fm.getAscent();
545+
int padX = Math.max(4, cardWidth / 20);
546+
int padY = Math.max(2, cardHeight / 30);
547+
int badgeWidth = textWidth + padX * 2;
548+
int badgeHeight = textHeight + padY * 2;
549+
int badgeX = cardXOffset + 2;
550+
int badgeY = cardYOffset + 2;
551+
int cornerRadius = Math.max(4, cardWidth / 16);
552+
553+
g2d.setColor(BADGE_BG_COLOR);
554+
g2d.fillRoundRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, cornerRadius);
555+
556+
g2d.setColor(Color.WHITE);
557+
g2d.setFont(badgeFont);
558+
g2d.drawString(text, badgeX + padX, badgeY + padY + textHeight);
559+
}
560+
561+
public boolean isBadgeHit(int mouseX, int mouseY) {
562+
if (groupCount < 2) {
563+
return false;
564+
}
565+
// Badge is drawn at (cardXOffset+2, cardYOffset+2) in the card's local
566+
// coordinate space. Mouse coordinates are container-relative. When the
567+
// card is tapped, the graphics are rotated but mouse events are not, so
568+
// we must inverse-rotate the mouse point into the card's local frame.
569+
int localX = mouseX - getX();
570+
int localY = mouseY - getY();
571+
if (tappedAngle > 0) {
572+
float pivotX = cardXOffset + cardWidth / 2f;
573+
float pivotY = cardYOffset + cardHeight - cardWidth / 2f;
574+
double cos = Math.cos(-tappedAngle);
575+
double sin = Math.sin(-tappedAngle);
576+
float dx = localX - pivotX;
577+
float dy = localY - pivotY;
578+
localX = (int) Math.round(cos * dx - sin * dy + pivotX);
579+
localY = (int) Math.round(sin * dx + cos * dy + pivotY);
580+
}
581+
int badgeX = cardXOffset + 2;
582+
int badgeY = cardYOffset + 2;
583+
int badgeWidth = Math.max(30, cardWidth / 3);
584+
int badgeHeight = Math.max(20, cardHeight / 6);
585+
return localX >= badgeX && localX <= badgeX + badgeWidth
586+
&& localY >= badgeY && localY <= badgeY + badgeHeight;
587+
}
588+
517589
private void displayIconOverlay(final Graphics g, final boolean canShow) {
518590
if (canShow && showCardManaCostOverlay() && cardWidth < 200) {
519591
final boolean showSplitMana = card.isSplitCard() && card.getZone() != ZoneType.Battlefield;

0 commit comments

Comments
 (0)