Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e6c160e
Add token grouping feature for identical permanents
MostCromulent Feb 14, 2026
861deb8
Fix group badge click detection coordinate system
MostCromulent Feb 14, 2026
57d64a4
Add blocker grouping by combat assignment and improve attacker declar…
MostCromulent Feb 14, 2026
db635bb
Fix attacker splitting from small groups and merging of solo attackers
MostCromulent Feb 14, 2026
e2fbf15
Unify Stack/Group Permanents into single menu with tooltips
MostCromulent Feb 14, 2026
4802502
Add right-click badge to declare N cards from group
MostCromulent Feb 15, 2026
0c4209f
Add badge right-click undeclare and context-aware prompts
MostCromulent Feb 15, 2026
d87cd72
Lower group badge threshold from 5 to 4 cards
MostCromulent Feb 18, 2026
0cc3afa
Split tapped and untapped lands into separate piles
MostCromulent Feb 22, 2026
ae6d08d
Lower group badge threshold from 4 to 2 permanents
MostCromulent Feb 22, 2026
265cd85
Improve token grouping: fix badge hit detection, cache rendering, red…
MostCromulent Feb 28, 2026
61c7bcb
Merge remote-tracking branch 'origin/master' into tokengroup
MostCromulent Feb 28, 2026
7b3b9fc
Merge branch 'master' into tokengroup
MostCromulent Mar 30, 2026
5dd735c
Fix group badge counting attached panels (equipment/auras)
MostCromulent Mar 30, 2026
2ee5244
Localize group permanents UI and decouple preference storage from dis…
MostCromulent Mar 31, 2026
626153c
Fix undeclared attackers not merging back into group, refactor groupi…
MostCromulent Apr 3, 2026
26adc4f
Fix combat grouping by unboxing Integer comparison, add attacker-side…
MostCromulent Apr 3, 2026
d5d89b2
Fix network combat grouping and split-from-group scope
MostCromulent Apr 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions forge-game/src/main/java/forge/game/card/CardView.java
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ public boolean needsTransformAnimation() {
public void updateNeedsTransformAnimation(boolean value) {
set(TrackableProperty.NeedsTransformAnimation, value);
}

void updateState(Card c) {
updateName(c);
updateZoneText(c);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();
}
});
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions forge-gui-desktop/src/main/java/forge/view/arcane/CardPanel.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -400,6 +411,9 @@ protected final void paintChildren(final Graphics g) {

}
displayIconOverlay(g, canShow);
if (groupCount >= 2) {
drawGroupCountBadge(g);
}
if (zoneBannerText != null) {
drawZoneBanner(g);
}
Expand Down Expand Up @@ -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;
Expand Down
Loading