diff --git a/forge-ai/src/main/java/forge/ai/AvailableActions.java b/forge-ai/src/main/java/forge/ai/AvailableActions.java index be2d96b421c..b04d2bfab71 100644 --- a/forge-ai/src/main/java/forge/ai/AvailableActions.java +++ b/forge-ai/src/main/java/forge/ai/AvailableActions.java @@ -2,64 +2,100 @@ import forge.game.card.Card; import forge.game.card.CardLists; +import forge.game.card.CardView; import forge.game.player.Player; import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; import org.tinylog.Logger; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import java.util.stream.Collectors; // Heuristic: does the player have any playable action this priority window? -// Bounded by timeoutMs; returns true on expiry (false-positive — player is prompted). +// Bounded by timeoutMs; FP-safe on expiry — unvisited cards are marked actionable. public final class AvailableActions { private AvailableActions() {} + /** sortByCmc: cheap-cost-first so early-exit hits faster. + * Battlefield isn't sorted: activation costs are per-ability, not the permanent's CMC. */ + private record ZoneScan(ZoneType zone, boolean sortByCmc) {} + + private static final List SCANS = List.of( + new ZoneScan(ZoneType.Hand, true), + new ZoneScan(ZoneType.Battlefield, false), + new ZoneScan(ZoneType.Flashback, true)); + + /** Boolean form: early-exits on the first actionable card. */ public static boolean compute(Player player, long timeoutMs) { - long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; + return withAiController(player, () -> !walk(player, timeoutMs, true).isEmpty()); + } - // Run the predictive sweep under an AI controller so cost-adjustment chooseX dispatches don't prompt (mirrors InputPayMana auto-pay). - boolean[] result = {false}; + /** Set form: walks every card so highlight consumers can mark the actionable subset. */ + public static Set collectActionable(Player player, long timeoutMs) { + return withAiController(player, () -> walk(player, timeoutMs, false)); + } + + /** Run the predictive sweep under an AI controller so cost-adjustment chooseX + * dispatches don't prompt (mirrors InputPayMana auto-pay). */ + private static T withAiController(Player player, Supplier body) { + AtomicReference result = new AtomicReference<>(); player.runWithController( - () -> result[0] = scan(player, deadlineNanos, timeoutMs), + () -> result.set(body.get()), new PlayerControllerAi(player.getGame(), player, player.getOriginalLobbyPlayer())); - return result[0]; + return result.get(); } - private static boolean scan(Player player, long deadlineNanos, long timeoutMs) { - for (Card card : sortedCardsIn(player, ZoneType.Hand)) { - for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { - if (checkTimeout(deadlineNanos, timeoutMs)) return true; - if (sa.isSpell()) { - if (canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { - return true; - } - } else if (sa.isLandAbility()) { - return true; + private static Set walk(Player player, long timeoutMs, boolean earlyExit) { + long deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000L; + Set actionable = new HashSet<>(); + Set visited = new HashSet<>(); + + for (ZoneScan scan : SCANS) { + Iterable cards = scan.sortByCmc() + ? sortedCardsIn(player, scan.zone()) + : player.getCardsIn(scan.zone()); + for (Card card : cards) { + if (checkTimeout(deadlineNanos, timeoutMs)) { + addUnvisited(actionable, visited, player); + return actionable; + } + CardView cv = card.getView(); + visited.add(cv); + if (cardHasActionable(card, player)) { + actionable.add(cv); + if (earlyExit) return actionable; } } } + return actionable; + } - // Not sorted: activation costs are per-ability, not the permanent's CMC. - for (Card card : player.getCardsIn(ZoneType.Battlefield)) { - for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { - if (checkTimeout(deadlineNanos, timeoutMs)) return true; - if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { - return true; - } + // Land plays come through with no cost, no targets; spells and activated abilities (incl. + // hand-activations like Channel) all hit the same shape: not a mana ability, affordable, targetable. + private static boolean cardHasActionable(Card card, Player player) { + for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { + if (!sa.isManaAbility() && canAfford(sa, player) + && ComputerUtilAbility.isFullyTargetable(sa)) { + return true; } } + return false; + } - for (Card card : sortedCardsIn(player, ZoneType.Flashback)) { - for (SpellAbility sa : card.getAllPossibleAbilities(player, true)) { - if (checkTimeout(deadlineNanos, timeoutMs)) return true; - if (!sa.isManaAbility() && canAfford(sa, player) && ComputerUtilAbility.isFullyTargetable(sa)) { - return true; - } + /** Timeout fallback: mark only the cards we never got to evaluate (FP-safe). + * Cards we visited and ruled out keep their determination. */ + private static void addUnvisited(Set actionable, Set visited, Player player) { + for (ZoneScan scan : SCANS) { + for (Card c : player.getCardsIn(scan.zone())) { + CardView cv = c.getView(); + if (!visited.contains(cv)) actionable.add(cv); } } - - return false; } // Sort cheap cards first so cheap-to-validate matches early-exit diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 52f788ef821..a853d9c86cb 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -109,6 +109,7 @@ public void initialize() { lstControls.add(Pair.of(view.getCbOrderCombatants(), FPref.LEGACY_ORDER_COMBATANTS)); lstControls.add(Pair.of(view.getCbScaleLarger(), FPref.UI_SCALE_LARGER)); lstControls.add(Pair.of(view.getCbRenderBlackCardBorders(), FPref.UI_RENDER_BLACK_BORDERS)); + lstControls.add(Pair.of(view.getCbShowActionableHighlights(), FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)); lstControls.add(Pair.of(view.getCbLargeCardViewers(), FPref.UI_LARGE_CARD_VIEWERS)); lstControls.add(Pair.of(view.getCbSmallDeckViewer(), FPref.UI_SMALL_DECK_VIEWER)); lstControls.add(Pair.of(view.getCbRandomArtInPools(), FPref.UI_RANDOM_ART_IN_POOLS)); @@ -225,10 +226,42 @@ public void initialize() { initializeServerPortButton(); initializeAfkTimeoutButton(); initializeDefaultLanguageComboBox(); + initializeActionableHighlightColorField(); disableLazyLoading(); } + private void initializeActionableHighlightColorField() { + final forge.toolbox.FTextField field = view.getTxtActionableHighlightColor(); + field.setText(prefs.getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR)); + field.addFocusListener(new java.awt.event.FocusAdapter() { + @Override public void focusLost(java.awt.event.FocusEvent e) { saveActionableHighlightColor(field); } + }); + field.addActionListener(e -> saveActionableHighlightColor(field)); + } + + private void saveActionableHighlightColor(forge.toolbox.FTextField field) { + if (updating) return; + String normalized = normalizeHexColor(field.getText()); + if (normalized == null) { + // Invalid input: revert the field to the persisted value rather than silently keeping garbage. + field.setText(prefs.getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR)); + return; + } + field.setText(normalized); + prefs.setPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR, normalized); + prefs.save(); + } + + /** Accepts a case-insensitive 6-char RGB hex; returns it uppercased, or + * null when input isn't 6 hex characters. */ + private static String normalizeHexColor(String raw) { + if (raw == null) return null; + String s = raw.trim(); + if (s.length() != 6 || !s.matches("[0-9A-Fa-f]{6}")) return null; + return s.toUpperCase(); + } + /* (non-Javadoc) * @see forge.gui.control.home.IControlSubmenu#update() */ @@ -246,6 +279,7 @@ public void update() { for(final Pair kv: lstControls) { kv.getKey().setSelected(prefs.getPrefBoolean(kv.getValue())); } + view.getTxtActionableHighlightColor().setText(prefs.getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR)); view.reloadShortcuts(); SwingUtilities.invokeLater(() -> view.getCbRemoveSmall().requestFocusInWindow()); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index e6be6e2acf5..270144503bd 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -90,6 +90,8 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbCloneImgSource = new OptionsCheckBox(localizer.getMessage("cbCloneImgSource")); private final JCheckBox cbScaleLarger = new OptionsCheckBox(localizer.getMessage("cbScaleLarger")); private final JCheckBox cbRenderBlackCardBorders = new OptionsCheckBox(localizer.getMessage("cbRenderBlackCardBorders")); + private final JCheckBox cbShowActionableHighlights = new OptionsCheckBox(localizer.getMessage("cbShowActionableHighlights")); + private final FTextField txtActionableHighlightColor = new FTextField.Builder().ghostText("66CCFF").maxLength(6).build(); private final JCheckBox cbLargeCardViewers = new OptionsCheckBox(localizer.getMessage("cbLargeCardViewers")); private final JCheckBox cbSmallDeckViewer = new OptionsCheckBox(localizer.getMessage("cbSmallDeckViewer")); private final JCheckBox cbDisplayFoil = new OptionsCheckBox(localizer.getMessage("cbDisplayFoil")); @@ -407,6 +409,12 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbRenderBlackCardBorders, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlRenderBlackCardBorders")), descriptionConstraints); + pnlPrefs.add(cbShowActionableHighlights, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlShowActionableHighlights")), descriptionConstraints); + + pnlPrefs.add(getActionableHighlightColorPanel(), titleConstraints + ", h 26px!"); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlActionableHighlightColor")), descriptionConstraints); + pnlPrefs.add(cbLargeCardViewers, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlLargeCardViewers")), descriptionConstraints); @@ -815,6 +823,16 @@ public JCheckBox getCbRenderBlackCardBorders() { return cbRenderBlackCardBorders; } + /** @return {@link javax.swing.JCheckBox} */ + public JCheckBox getCbShowActionableHighlights() { + return cbShowActionableHighlights; + } + + /** @return text field holding the hex color for actionable highlights */ + public FTextField getTxtActionableHighlightColor() { + return txtActionableHighlightColor; + } + /** @return {@link javax.swing.JCheckBox} */ public JCheckBox getCbLargeCardViewers() { return cbLargeCardViewers; @@ -1141,6 +1159,15 @@ private JPanel getPlayerNamePanel() { return p; } + private JPanel getActionableHighlightColorPanel() { + JPanel p = new JPanel(new MigLayout("insets 0, gap 0!")); + p.setOpaque(false); + FLabel lbl = new FLabel.Builder().text(localizer.getMessage("lblActionableHighlightColor") + ": ").fontSize(12).fontStyle(Font.BOLD).build(); + p.add(lbl, "aligny top, h 100%, gap 4px 0 0 0"); + p.add(txtActionableHighlightColor, "aligny top, h 100%, w 120px!"); + return p; + } + private JPanel getServerPortPanel() { JPanel p = new JPanel(new MigLayout("insets 0, gap 0!")); p.setOpaque(false); 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 729ef85b70f..6a73e38d48f 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 @@ -692,6 +692,38 @@ public void setHighlighted(final Iterable entities, final boolea } } + @Override + public void setWeaklySelectable(final Iterable cards) { + super.setWeaklySelectable(cards); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if (p.getCards(ZoneType.Battlefield) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if (p.getCards(ZoneType.Hand) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + FloatingZone.refreshAll(); + }); + } + + @Override + public void clearWeaklySelectable() { + super.clearWeaklySelectable(); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if (p.getCards(ZoneType.Battlefield) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if (p.getCards(ZoneType.Hand) != null) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + FloatingZone.refreshAll(); + }); + } + @Override public void refreshField() { super.refreshField(); 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 4ba3532cbd8..116ad4fb6f2 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 @@ -379,9 +379,23 @@ protected final void paintComponent(final Graphics g) { g2d.setColor(Color.WHITE); final int ins = 1; g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); + } else if (isPreferenceEnabled(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) && matchUI.isWeaklySelectable(getCard())) { + g2d.setColor(parseActionableHighlightColor()); + final int ins = 1; + g2d.fillRoundRect(cardXOffset+ins, cardYOffset+ins, cardWidth-ins*2, cardHeight-ins*2, cornerSize-ins, cornerSize-ins); } } + /** Pref is normalized to 6 hex chars on the write side; this just parses, + * falling back to the FPref default if the stored value is malformed. */ + private static Color parseActionableHighlightColor() { + String s = forge.model.FModel.getPreferences().getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR); + try { + if (s != null && s.length() == 6) return new Color(Integer.parseInt(s, 16)); + } catch (NumberFormatException ignored) {} + return new Color(Integer.parseInt(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR.getDefault(), 16)); + } + private void drawManaCost(final Graphics g, final ManaCost cost, final int deltaY) { final int width = CardFaceSymbols.getWidth(cost); final int height = CardFaceSymbols.getHeight(); diff --git a/forge-gui-mobile/src/forge/card/CardRenderer.java b/forge-gui-mobile/src/forge/card/CardRenderer.java index b0552f95e81..a30b78b2f0a 100644 --- a/forge-gui-mobile/src/forge/card/CardRenderer.java +++ b/forge-gui-mobile/src/forge/card/CardRenderer.java @@ -68,6 +68,23 @@ public enum CardStackPosition { BehindVert } + /** Pref is normalized to 6 hex chars on the write side; this just parses, + * falling back to the FPref default if the stored value is malformed. */ + private static Color parseActionableHighlightColor() { + String s = FModel.getPreferences().getPref(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR); + try { + if (s != null && s.length() == 6) return rgbFromHex(s); + } catch (NumberFormatException ignored) {} + return rgbFromHex(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR.getDefault()); + } + + private static Color rgbFromHex(String s) { + int r = Integer.parseInt(s.substring(0, 2), 16); + int g = Integer.parseInt(s.substring(2, 4), 16); + int b = Integer.parseInt(s.substring(4, 6), 16); + return FSkinColor.fromRGB(r, g, b); + } + // class that simplifies the callback logic of CachedCardImage static class RendererCachedCardImage extends CachedCardImage { boolean clearcardArtCache = false; @@ -815,6 +832,9 @@ public static void drawCardWithOverlays(Graphics g, CardView card, float x, floa //Magenta outline when card is chosen if (MatchController.instance.isHighlighted(card)) { g.drawRect(BORDER_THICKNESS, Color.MAGENTA, cx, cy, cw, ch); + } else if (!unselectable && FModel.getPreferences().getPrefBoolean(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS) + && MatchController.instance.isWeaklySelectable(card)) { + g.drawRect(BORDER_THICKNESS, parseActionableHighlightColor(), cx, cy, cw, ch); } //Ability Icons if (unselectable) { diff --git a/forge-gui-mobile/src/forge/screens/match/MatchController.java b/forge-gui-mobile/src/forge/screens/match/MatchController.java index 61e9da82759..c493735e998 100644 --- a/forge-gui-mobile/src/forge/screens/match/MatchController.java +++ b/forge-gui-mobile/src/forge/screens/match/MatchController.java @@ -546,6 +546,36 @@ public void clearSelectables() { }); } + @Override + public void setWeaklySelectable(final Iterable cards) { + super.setWeaklySelectable(cards); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if ( p.getCards(ZoneType.Battlefield) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if ( p.getCards(ZoneType.Hand) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + }); + } + + @Override + public void clearWeaklySelectable() { + super.clearWeaklySelectable(); + FThreads.invokeInEdtNowOrLater(() -> { + for (final PlayerView p : getGameView().getPlayers()) { + if ( p.getCards(ZoneType.Battlefield) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Battlefield).threadSafeIterable() : p.getCards(ZoneType.Battlefield)); + } + if ( p.getCards(ZoneType.Hand) != null ) { + updateCards(isNetGame() ? p.getCards(ZoneType.Hand).threadSafeIterable() : p.getCards(ZoneType.Hand)); + } + } + }); + } + @Override public void afterGameEnd() { super.afterGameEnd(); diff --git a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java index dab6848e69e..6f388a2e3c5 100644 --- a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java +++ b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java @@ -254,6 +254,13 @@ public void valueChanged(String newValue) { lstSettings.addItem(new BooleanSetting(FPref.UI_SHOW_STORM_COUNT_IN_PROMPT, Forge.getLocalizer().getMessage("cbShowStormCount"), Forge.getLocalizer().getMessage("nlShowStormCount")), 1); + lstSettings.addItem(new BooleanSetting(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS, + Forge.getLocalizer().getMessage("cbShowActionableHighlights"), + Forge.getLocalizer().getMessage("nlShowActionableHighlights")), 1); + lstSettings.addItem(new HexColorSetting(FPref.UI_ACTIONABLE_HIGHLIGHT_COLOR, + Forge.getLocalizer().getMessage("lblActionableHighlightColor"), + Forge.getLocalizer().getMessage("nlActionableHighlightColor"), + "66CCFF"), 1); lstSettings.addItem(new CustomSelectSetting(FPref.UI_ALLOW_ORDER_GRAVEYARD_WHEN_NEEDED, Forge.getLocalizer().getMessage("lblOrderGraveyard"), Forge.getLocalizer().getMessage("nlOrderGraveyard"), @@ -1035,6 +1042,52 @@ public void drawPrefValue(Graphics g, FSkinFont font, FSkinColor color, float x, } } + /** Text input that accepts a 6-char RGB hex and persists the uppercase form. */ + private class HexColorSetting extends Setting { + private final String defaultValue; + + public HexColorSetting(FPref pref0, String label0, String description0, String defaultValue0) { + super(pref0, label0 + ":", description0); + this.defaultValue = defaultValue0; + } + + @Override + public void select() { + String currentValue = FModel.getPreferences().getPref((FPref) pref); + if (currentValue == null || currentValue.isEmpty()) currentValue = defaultValue; + FOptionPane.showInputDialog( + label, + description, + currentValue, + null, + input -> { + if (input == null) return; + String normalized = normalizeHexColor(input); + if (normalized == null) { + FOptionPane.showMessageDialog("Please enter a 6-digit RGB hex (e.g. 66CCFF).", "Invalid Color"); + return; + } + FModel.getPreferences().setPref((FPref) pref, normalized); + FModel.getPreferences().save(); + }, + false + ); + } + + @Override + public void drawPrefValue(Graphics g, FSkinFont font, FSkinColor color, float x, float y, float w, float h) { + String value = FModel.getPreferences().getPref((FPref) pref); + g.drawText(value, font, color, x, y, w, h, false, Align.right, false); + } + + private String normalizeHexColor(String raw) { + if (raw == null) return null; + String s = raw.trim(); + if (s.length() != 6 || !s.matches("[0-9A-Fa-f]{6}")) return null; + return s.toUpperCase(); + } + } + private class CustomLogCategoriesDialog extends FDialog { private final FScrollPane scroller; diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 3ef3b9d9afc..3b69d88e415 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -108,6 +108,8 @@ cbImageFetcher=Automatically Download Missing Card Art cbCloneImgSource=Clones Use Original Card Art cbScaleLarger=Scale Image Larger cbRenderBlackCardBorders=Render Black Card Borders +cbShowActionableHighlights=Highlight Actionable Cards +lblActionableHighlightColor=Highlight Color (hex) cbLargeCardViewers=Use Large Card Viewers cbSmallDeckViewer=Use Small Deck Viewer cbDisplayFoil=Display Foil Overlay @@ -241,6 +243,8 @@ nlDisplayFoil=Displays foil cards with the visual foil overlay effect. nlRandomFoil=Adds foil effect to random cards. nlScaleLarger=Allows card pictures to be expanded larger than their original size. nlRenderBlackCardBorders=Render black borders around card images. +nlShowActionableHighlights=Show an outline on cards you can currently play or activate. +nlActionableHighlightColor=6-character RGB hex code for the highlight outline (case insensitive). nlLargeCardViewers=Makes all card viewers much larger for use with high resolution images. Will not fit on smaller screens. nlSmallDeckViewer=Sets the deck viewer window to be 800x600 rather than a proportion of the screen size. nlRandomArtInPools=Generates cards with random art in generated limited mode card pools. diff --git a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java index d9aa3e0305b..e12f76b2df9 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/AbstractGuiGame.java @@ -354,6 +354,23 @@ public int getSelectionMax() { return selectionMax; } + private final Set weaklySelectableCards = Sets.newHashSet(); + + public void setWeaklySelectable(final Iterable cards) { + weaklySelectableCards.clear(); + for (CardView cv : cards) { + weaklySelectableCards.add(cv); + } + } + + public void clearWeaklySelectable() { + weaklySelectableCards.clear(); + } + + public boolean isWeaklySelectable(final CardView card) { + return weaklySelectableCards.contains(card); + } + public boolean isGamePaused() { return gamePause; } diff --git a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java index 5bc222645c7..c6514e14232 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/YieldController.java @@ -58,7 +58,10 @@ public class YieldController { FPref.YIELD_SUPPRESS_AFTER_END, FPref.YIELD_AVAILABLE_ACTIONS_BUDGET_MS, FPref.YIELD_DECLINE_SCOPE_STACK_YIELD, - FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS); + FPref.YIELD_DECLINE_SCOPE_NO_ACTIONS, + // Not a yield pref, but seeded the same way: the host runs the actionable scan on + // the remote player's behalf and must use that client's highlight setting, not its own. + FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS); private final PlayerControllerHuman owner; diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java index 9aca0381c69..c81c1cd12e2 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputAttack.java @@ -68,6 +68,7 @@ public InputAttack(PlayerControllerHuman controller, Player attacks0, Combat com @Override public final void showMessage() { + getController().pushAttackerCandidates(playerAttacks, combat); setCurrentDefender(defenders.getFirst()); if (currentDefender == null) { @@ -95,6 +96,12 @@ private void disablePrompt() { getController().getGui().updateButtons(getOwner(), localizer.getMessage("lblDisabled"), localizer.getMessage("lblDisabled"), false, false, false); } + @Override + protected void onStop() { + // Clear so highlights don't survive autopass. + getController().clearActionableCards(); + } + @Override protected final void onOk() { // Propaganda costs could have been paid here. @@ -345,6 +352,10 @@ private void updateMessage() { updatePrompt(); + // Re-push after each click so declared attackers stop glowing. + // Can't reuse showMessage() — it also resets currentDefender. + getController().pushAttackerCandidates(playerAttacks, combat); + if (combat != null) getController().getGame().fireEvent(GameEventCombatUpdate.fromCards(combat.getAttackers(), combat.getAllBlockers())); 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 bf60efabe51..c06f8d47c18 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 @@ -71,6 +71,7 @@ public InputBlock(final PlayerControllerHuman controller, final Player defender0 /** {@inheritDoc} */ @Override protected final void showMessage() { + getController().pushBlockerCandidates(defender, combat); // could add "Reset Blockers" button Localizer localizer = Localizer.getInstance(); getController().getGui().updateButtons(getOwner(), true, false, true); @@ -89,6 +90,12 @@ protected final void showMessage() { getController().getGui().showCombat(); } + @Override + protected void onStop() { + // Clear so highlights don't survive autopass. + getController().clearActionableCards(); + } + /** {@inheritDoc} */ @Override public final void onOk() { diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java index a4ed7256084..ff17af66107 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPassPriority.java @@ -65,6 +65,11 @@ public InputPassPriority(final PlayerControllerHuman controller) { super(controller); } + @Override + protected void onStop() { + getController().clearActionableCards(); + } + @Override public void showAndWait() { final FServerManager server = FServerManager.getInstance(); @@ -158,6 +163,7 @@ private void showNormalPrompt() { pendingSuggestion = null; pendingSuggestionMessage = null; + getController().pushActionableCards(false); showMessage(getTurnPhasePriorityMessage(getController().getGame())); chosenSa = null; Localizer localizer = Localizer.getInstance(); diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java index 88c832d7116..aa91d3492ce 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java @@ -62,6 +62,7 @@ protected InputPayMana(final PlayerControllerHuman controller, final SpellAbilit @Override protected void onStop() { + getController().clearActionableCards(); if (!isFinished()) { // Clear current Mana cost being paid for SA saPaidFor.setManaCostBeingPaid(null); @@ -400,6 +401,8 @@ protected final void updateMessage() { if (activateDelayedCard()) { return; } + // Drop just-tapped sources from the highlight set. + getController().pushActionableCards(true); if (supportAutoPay()) { if (canPayManaCost == null) { //use AI utility to determine if mana cost can be paid if that hasn't been determined yet @@ -422,6 +425,7 @@ public Boolean evaluate() { @Override public void showMessage() { if (isFinished()) { return; } + getController().pushActionableCards(true); updateButtons(); onStateChanged(); } diff --git a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java index a873f0ae574..680c2c87211 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/ProtocolMethod.java @@ -66,6 +66,8 @@ public enum ProtocolMethod implements IHasForgeLog { setCard (Mode.SERVER, Void.TYPE, CardView.class), setSelectables (Mode.SERVER, Void.TYPE, Iterable/*CardView*/.class, Integer.TYPE, Integer.TYPE), clearSelectables (Mode.SERVER, Void.TYPE), + setWeaklySelectable (Mode.SERVER, Void.TYPE, Iterable/*CardView*/.class), + clearWeaklySelectable(Mode.SERVER, Void.TYPE), // TODO case "setPlayerAvatar": openZones (Mode.SERVER, PlayerZoneUpdates.class, PlayerView.class, Collection/*ZoneType*/.class, Map/*PlayerView,Object*/.class, Boolean.TYPE), restoreOldZones (Mode.SERVER, Void.TYPE, PlayerView.class, PlayerZoneUpdates.class), diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java index fda619498b0..6d3d77435ee 100644 --- a/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java +++ b/forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClientGuiGame.java @@ -498,6 +498,18 @@ public void clearSelectables() { syncAndSend(ProtocolMethod.clearSelectables); } + @Override + public void setWeaklySelectable(final Iterable cards) { + updateGameView(); + send(ProtocolMethod.setWeaklySelectable, cards); + } + + @Override + public void clearWeaklySelectable() { + updateGameView(); + send(ProtocolMethod.clearWeaklySelectable); + } + @Override public void setPlayerAvatar(final LobbyPlayer player, final IHasIcon ihi) { // TODO Auto-generated method stub diff --git a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java index 13f02014da5..da1f7ea7593 100644 --- a/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java +++ b/forge-gui/src/main/java/forge/gui/interfaces/IGuiGame.java @@ -269,6 +269,10 @@ default List order(String title, String top, int remainingObjectsMin, int boolean isSelecting(); + void setWeaklySelectable(final Iterable cards); + + void clearWeaklySelectable(); + boolean isGamePaused(); void setGamePause(boolean pause); 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 10b61e380c1..2c34852d9a0 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -109,6 +109,8 @@ public enum FPref implements PreferencesStore.IPref { UI_TOKENS_IN_SEPARATE_ROW("false"), UI_SCALE_LARGER ("true"), UI_RENDER_BLACK_BORDERS ("true"), + UI_SHOW_ACTIONABLE_HIGHLIGHTS ("false"), + UI_ACTIONABLE_HIGHLIGHT_COLOR ("66CCFF"), UI_LARGE_CARD_VIEWERS ("false"), UI_RANDOM_ART_IN_POOLS ("true"), UI_COMPACT_PROMPT ("false"), diff --git a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java index fcbb2a2f4d5..9a452fe7ba3 100644 --- a/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java +++ b/forge-gui/src/main/java/forge/player/PlayerControllerHuman.java @@ -1557,6 +1557,104 @@ public void declareBlockers(final Player defender, final Combat combat) { getGui().updateAutoPassPrompt(); } + private static final ZoneType[] ACTIONABLE_PAYMENT_ZONES = new ZoneType[] { + ZoneType.Hand, ZoneType.Battlefield, ZoneType.Graveyard, ZoneType.Exile, ZoneType.Command + }; + + /** Cache from {@link AvailableActions#collectActionable}, populated in + * {@link #chooseSpellAbilityToPlay} and reused by {@link #pushActionableCards}. */ + private Set cachedActionableCards; + + /** Push the actionable-card set to the GUI. Payment mode falls back to the + * "playable mana ability" predicate; non-payment reuses {@link #cachedActionableCards}. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ + public void pushActionableCards(boolean paymentMode) { + if (!yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + getGui().clearWeaklySelectable(); + return; + } + + if (paymentMode) { + final Set result = Sets.newHashSet(); + for (ZoneType zone : ACTIONABLE_PAYMENT_ZONES) { + for (Card c : player.getCardsIn(zone)) { + if (cardHasPlayableManaAbility(c)) { + result.add(c.getView()); + } + } + } + getGui().setWeaklySelectable(result); + return; + } + + // Reuse the priority-time scan; recompute if neither APINA nor highlights triggered it. + Set actionable = cachedActionableCards; + if (actionable == null) { + actionable = AvailableActions.collectActionable(getPlayer(), computeAvailableActionsBudgetMs(getPlayer())); + } + getGui().setWeaklySelectable(actionable); + } + + private boolean cardHasPlayableManaAbility(Card c) { + for (SpellAbility sa : c.getAllPossibleAbilities(player, true)) { + if (sa.isManaAbility() && sa.canPlay()) return true; + } + return false; + } + + public void clearActionableCards() { + getGui().clearWeaklySelectable(); + } + + /** Push undeclared, legal attacker candidates. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ + public void pushAttackerCandidates(final Player attackingPlayer, + final forge.game.combat.Combat combat) { + if (!yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + getGui().clearWeaklySelectable(); + return; + } + final Set result = Sets.newHashSet(); + for (final Card c : attackingPlayer.getCreaturesInPlay()) { + if (!forge.game.combat.CombatUtil.canAttack(c)) continue; + // Drop already-declared attackers. + if (combat != null && combat.isAttacking(c)) continue; + result.add(c.getView()); + } + getGui().setWeaklySelectable(result); + } + + /** Push defenders that could block at least one current attacker. + * Gated on {@link FPref#UI_SHOW_ACTIONABLE_HIGHLIGHTS}. */ + public void pushBlockerCandidates(final Player defendingPlayer, + final forge.game.combat.Combat combat) { + if (!yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS)) { + getGui().clearWeaklySelectable(); + return; + } + final Set result = Sets.newHashSet(); + if (combat == null) { + getGui().setWeaklySelectable(result); + return; + } + final Iterable attackers = combat.getAttackers(); + for (final Card blocker : defendingPlayer.getCreaturesInPlay()) { + if (!forge.game.combat.CombatUtil.canBlock(blocker)) continue; + // Only highlight if the blocker can block at least one live attacker. + boolean canBlockSomething = false; + for (final Card atk : attackers) { + if (forge.game.combat.CombatUtil.canBlock(atk, blocker, combat)) { + canBlockSomething = true; + break; + } + } + if (canBlockSomething) { + result.add(blocker.getView()); + } + } + getGui().setWeaklySelectable(result); + } + @Override public List chooseSpellAbilityToPlay() { netLog.trace("ENTRY for player {}, phase={}, isGameOver={}", @@ -1564,10 +1662,22 @@ public List chooseSpellAbilityToPlay() { final MagicStack stack = getGame().getStack(); // Skip when already yielding — yield proceeds regardless of available-actions. - // Yield check first: it's a field read, vs needsAvailableActions which does 3 synced FPref reads. - if (!yieldController.isYieldActive() && needsAvailableActions()) { + // Compute the actionable set when APINA / suggestions / highlights need it. + boolean highlightsEnabled = yieldController.getBoolPref(FPref.UI_SHOW_ACTIONABLE_HIGHLIGHTS); + if (!yieldController.isYieldActive() && (needsAvailableActions() || highlightsEnabled)) { long timeoutMs = computeAvailableActionsBudgetMs(getPlayer()); - getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + if (highlightsEnabled) { + // Highlights need the full set; APINA derives its boolean from the same scan. + Set actionable = AvailableActions.collectActionable(getPlayer(), timeoutMs); + cachedActionableCards = actionable; + getPlayer().getView().setHasAvailableActions(!actionable.isEmpty()); + } else { + // APINA only — early-exit boolean scan is cheaper than a full walk. + cachedActionableCards = null; + getPlayer().getView().setHasAvailableActions(AvailableActions.compute(getPlayer(), timeoutMs)); + } + } else { + cachedActionableCards = null; } // yieldJustEndedFlag is read from the EDT (didYieldJustEnd); synchronized writer/reader pair handles visibility.