Skip to content

Commit a736364

Browse files
Improved card zone view - dockable windows, zone banners, user prefs, hand UI options (Card-Forge#9969)
- Add "Limit Cards Per Row" slider to control horizontal card layout in hand - Add "Prevent Card Overlap" option to shrink cards instead of overlapping - Sort hand at UI layer so it works for network clients - Update zone colors: graveyard/flashback purple, exile silver, library yellowed - Right-click zone buttons under player portrait to choose between "Open in Window" (floating) and "Add Tab to Hand Panel" (docked) - Add "Close" option to right-click menu on docked zone tabs Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: tool4EvEr <tool4EvEr@>
1 parent b166223 commit a736364

File tree

22 files changed

+1023
-92
lines changed

22 files changed

+1023
-92
lines changed

forge-ai/src/main/java/forge/ai/PlayerControllerAi.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -860,11 +860,11 @@ public boolean playChosenSpellAbility(SpellAbility sa) {
860860
private Runnable getDeferredTargetingPlayerRunnable(SpellAbility sa) {
861861
SpellAbility root = sa;
862862
while (sa != null) {
863-
if (sa.getTargetingPlayer() != null) {
863+
if (sa.hasParam("TargetingPlayer") && sa.getTargetingPlayer() != null) {
864864
return () -> {
865865
SpellAbility cur = root;
866866
while (cur != null) {
867-
if (cur.getTargetingPlayer() != null) {
867+
if (cur.hasParam("TargetingPlayer") && cur.getTargetingPlayer() != null) {
868868
cur.clearTargets();
869869
cur.getTargetingPlayer().getController().chooseTargetsFor(cur);
870870
// there's a chance a target gets selected that makes the cost unaffordable

forge-game/src/main/java/forge/game/phase/PhaseHandler.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,9 @@ private boolean checkStateBasedEffects() {
11781178
if (!allAffectedCards.isEmpty()) {
11791179
game.fireEvent(new GameEventCardStatsChanged(allAffectedCards));
11801180
allAffectedCards.clear();
1181+
// Update flashback views after static abilities have been recalculated,
1182+
// so play-from-zone abilities (e.g. Bolas's Citadel) are reflected
1183+
game.getPlayers().forEach(Player::updateFlashbackForView);
11811184
}
11821185
return false;
11831186
}

forge-game/src/main/java/forge/game/player/Player.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,8 @@ public void updateOpponentsForView() {
324324
}
325325

326326
public void updateFlashbackForView() {
327-
view.updateFlashbackForPlayer(this);
327+
view.updateFlashback(this);
328+
game.fireEvent(new GameEventZone(ZoneType.Flashback, this, EventValueChangeType.Added, null));
328329
}
329330

330331
//get single opponent for player if only one, otherwise returns null

forge-game/src/main/java/forge/game/player/PlayerView.java

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -474,16 +474,14 @@ public int getZoneTypes(TrackableProperty zoneProp) {
474474
return 0;
475475

476476
for (CardView c : cards) {
477-
types.addAll((Collection<? extends CardType.CoreType>) c.getCurrentState().getType().getCoreTypes());
477+
types.addAll(c.getCurrentState().getType().getCoreTypes());
478478
}
479479

480480
return types.size();
481481
}
482482

483483
public boolean hasDelirium() {
484-
if (get(TrackableProperty.HasDelirium) == null)
485-
return false;
486-
return get(TrackableProperty.HasDelirium);
484+
return getZoneTypes(TrackableProperty.Graveyard) >= 4;
487485
}
488486

489487
private static TrackableProperty getZoneProp(final ZoneType zone) {
@@ -510,24 +508,20 @@ void updateZone(PlayerZone zone) {
510508
if (prop == null) { return; }
511509
set(prop, CardView.getCollection(zone.getCards(false)));
512510

513-
//update delirium
514-
if (ZoneType.Graveyard == zone.getZoneType())
515-
set(TrackableProperty.HasDelirium, getZoneTypes(TrackableProperty.Graveyard) >= 4);
516-
517-
//update flashback zone when graveyard, library, or exile zones updated
511+
//update flashback zone when relevant zones change
518512
switch (zone.getZoneType()) {
519-
case Command:
520-
case Graveyard:
521-
case Library:
522-
case Exile:
523-
set(TrackableProperty.Flashback, CardView.getCollection(zone.getPlayer().getCardsIn(ZoneType.Flashback)));
524-
break;
525-
default:
526-
break;
513+
case Command:
514+
case Graveyard:
515+
case Library:
516+
case Exile:
517+
updateFlashback(zone.getPlayer());
518+
break;
519+
default:
520+
break;
527521
}
528522
}
529523

530-
void updateFlashbackForPlayer(Player p) {
524+
void updateFlashback(Player p) {
531525
set(TrackableProperty.Flashback, CardView.getCollection(p.getCardsIn(ZoneType.Flashback)));
532526
}
533527

forge-game/src/main/java/forge/game/zone/MagicStack.java

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,14 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si) {
245245

246246
public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) {
247247
final Card source = sp.getHostCard();
248-
Player activator = sp.getActivatingPlayer();
249248

250249
// if activating player slips through the cracks, assign activating
251250
// Player to the controller here
252-
if (null == activator) {
251+
if (sp.getActivatingPlayer() == null) {
253252
sp.setActivatingPlayer(source.getController());
254-
activator = sp.getActivatingPlayer();
255253
System.out.println(source.getName() + " - activatingPlayer not set before adding to stack.");
256254
}
255+
Player activator = sp.getActivatingPlayer();
257256

258257
// Stop infinite loop. E.g. Scalelord Reckoner mirrormatch with only triggering targets is a draw.
259258
if (game.getStack().size() > 999) {
@@ -366,7 +365,7 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) {
366365
Map<AbilityKey, Object> runParams = AbilityKey.newMap();
367366

368367
if (sp.isSpell() && !sp.isCopied()) {
369-
final Card lki = CardCopyService.getLKICopy(sp.getHostCard());
368+
final Card lki = CardCopyService.getLKICopy(source);
370369
runParams.put(AbilityKey.CardLKI, lki);
371370
thisTurnCast.add(lki);
372371
sp.getActivatingPlayer().addSpellCastThisTurn();
@@ -399,7 +398,7 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) {
399398
}
400399
}
401400

402-
runParams.put(AbilityKey.Activator, sp.getActivatingPlayer());
401+
runParams.put(AbilityKey.Activator, activator);
403402
runParams.put(AbilityKey.SpellAbility, sp);
404403
runParams.put(AbilityKey.CurrentStormCount, thisTurnCast.size());
405404
runParams.put(AbilityKey.CurrentCastSpells, Lists.newArrayList(thisTurnCast));
@@ -433,31 +432,31 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) {
433432
activator.addCycled(sp);
434433
}
435434

436-
if (sp.isCrew() && sp.getHostCard().getType().hasSubtype("Vehicle")) {
435+
if (sp.isCrew() && source.getType().hasSubtype("Vehicle")) {
437436
Iterable<Card> crews = sp.getPaidList("Tapped", true);
438437
if (crews != null) {
439438
for (Card c : crews) {
440-
Map<AbilityKey, Object> crewParams = AbilityKey.mapFromCard(sp.getHostCard());
439+
Map<AbilityKey, Object> crewParams = AbilityKey.mapFromCard(source);
441440
crewParams.put(AbilityKey.Crew, c);
442441
game.getTriggerHandler().runTrigger(TriggerType.Crewed, crewParams, false);
443442
}
444443
}
445444
}
446-
if (sp.isKeyword(Keyword.SADDLE) && sp.getHostCard().getType().hasSubtype("Mount")) {
445+
if (sp.isKeyword(Keyword.SADDLE) && source.getType().hasSubtype("Mount")) {
447446
Iterable<Card> crews = sp.getPaidList("Tapped", true);
448447
if (crews != null) {
449448
for (Card c : crews) {
450-
Map<AbilityKey, Object> saddleParams = AbilityKey.mapFromCard(sp.getHostCard());
449+
Map<AbilityKey, Object> saddleParams = AbilityKey.mapFromCard(source);
451450
saddleParams.put(AbilityKey.Crew, c);
452451
game.getTriggerHandler().runTrigger(TriggerType.Saddled, saddleParams, false);
453452
}
454453
}
455454
}
456-
if (sp.isKeyword(Keyword.STATION) && (sp.getHostCard().getType().hasSubtype("Spacecraft") || (sp.getHostCard().getType().hasSubtype("Planet")))) {
455+
if (sp.isKeyword(Keyword.STATION) && (source.getType().hasSubtype("Spacecraft") || (source.getType().hasSubtype("Planet")))) {
457456
Iterable<Card> crews = sp.getPaidList("Tapped", true);
458457
if (crews != null) {
459458
for (Card c : crews) {
460-
Map<AbilityKey, Object> stationParams = AbilityKey.mapFromCard(sp.getHostCard());
459+
Map<AbilityKey, Object> stationParams = AbilityKey.mapFromCard(source);
461460
stationParams.put(AbilityKey.Crew, c);
462461
game.getTriggerHandler().runTrigger(TriggerType.Stationed, stationParams, false);
463462
}
@@ -514,17 +513,15 @@ public final void add(SpellAbility sp, SpellAbilityStackInstance si, int id) {
514513
game.getTriggerHandler().runTrigger(TriggerType.BecomesTargetOnce, runParams, false);
515514
}
516515

517-
if (sp.getActivatingPlayer() != null && commitCrimeCheck(sp.getActivatingPlayer(), chosenTargets)) {
518-
sp.getActivatingPlayer().commitCrime();
516+
if (commitCrimeCheck(activator, chosenTargets)) {
517+
activator.commitCrime();
519518
}
520519

521520
game.fireEvent(new GameEventZone(ZoneType.Stack, sp, EventValueChangeType.Added));
522521

523-
if (sp.getActivatingPlayer() != null && !game.getCardsPlayerCanActivateInStack().isEmpty()) {
522+
if (!game.getCardsPlayerCanActivateInStack().isEmpty()) {
524523
// This is a bit of a hack that forces the update of externally activatable cards in flashback zone (e.g. Lightning Storm).
525-
for (Player p : game.getPlayers()) {
526-
p.updateFlashbackForView();
527-
}
524+
game.getPlayers().forEach(Player::updateFlashbackForView);
528525
}
529526
}
530527

forge-game/src/main/java/forge/game/zone/PlayerZone.java

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,36 +50,23 @@ public boolean test(final Card c) {
5050
return true;
5151
}
5252

53-
if (c.isLand() && !c.mayPlay(c.getController()).isEmpty()) {
53+
if (!c.mayPlay(c.getController()).isEmpty()) {
5454
return true;
5555
}
5656

57-
boolean graveyardCastable = c.hasKeyword(Keyword.FLASHBACK) ||
58-
c.hasKeyword(Keyword.RETRACE) || c.hasKeyword(Keyword.JUMP_START) || c.hasKeyword(Keyword.ESCAPE) ||
59-
c.hasKeyword(Keyword.DISTURB);
60-
boolean exileCastable = c.isForetold() || c.isOnAdventure();
61-
for (final SpellAbility sa : c.getSpellAbilities()) {
62-
final ZoneType restrictZone = sa.getRestrictions().getZone();
63-
64-
// for mayPlay the restrictZone is null for reasons
65-
if (sa.isSpell() && c.mayPlay(sa.getMayPlay()) != null) {
66-
return true;
67-
}
68-
69-
if (PlayerZone.this.is(restrictZone)) {
70-
return true;
71-
}
72-
73-
//todo add brokkos??
74-
if (sa.isSpell()
75-
&& (graveyardCastable && PlayerZone.this.is(ZoneType.Graveyard))
76-
&& restrictZone.equals(ZoneType.Hand)) {
77-
return true;
78-
}
57+
// Keywords like Flashback/Escape create alternative SAs at play time,
58+
// not stored on the card or in the mayPlay map. Check directly.
59+
if (PlayerZone.this.is(ZoneType.Graveyard) && (c.hasKeyword(Keyword.FLASHBACK)
60+
|| c.hasKeyword(Keyword.RETRACE) || c.hasKeyword(Keyword.JUMP_START)
61+
|| c.hasKeyword(Keyword.ESCAPE) || c.hasKeyword(Keyword.DISTURB))) {
62+
return true;
63+
}
64+
if (PlayerZone.this.is(ZoneType.Exile) && (c.isForetold() || c.isOnAdventure())) {
65+
return true;
66+
}
7967

80-
if (sa.isSpell()
81-
&& (exileCastable && PlayerZone.this.is(ZoneType.Exile))
82-
&& restrictZone.equals(ZoneType.Hand)) {
68+
for (final SpellAbility sa : c.getSpellAbilities()) {
69+
if (PlayerZone.this.is(sa.getRestrictions().getZone())) {
8370
return true;
8471
}
8572
}

forge-game/src/main/java/forge/trackable/TrackableProperty.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,6 @@ public enum TrackableProperty {
232232
IsExtraTurn(TrackableTypes.BooleanType),
233233
ExtraTurnCount(TrackableTypes.IntegerType),
234234
HasPriority(TrackableTypes.BooleanType, FreezeMode.IgnoresFreeze),
235-
HasDelirium(TrackableTypes.BooleanType),
236235
AvatarLifeDifference(TrackableTypes.IntegerType, FreezeMode.IgnoresFreeze),
237236
HasLost(TrackableTypes.BooleanType),
238237

forge-gui-desktop/src/main/java/forge/gui/framework/EDocID.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
*/
44
package forge.gui.framework;
55

6+
import java.util.EnumMap;
7+
import java.util.Map;
8+
69
import com.google.common.collect.ObjectArrays;
710

11+
import forge.game.zone.ZoneType;
812
import forge.screens.deckeditor.views.*;
913
import forge.screens.home.gauntlet.*;
1014
import forge.screens.home.online.VSubmenuOnlineLobby;
@@ -112,7 +116,21 @@ public enum EDocID {
112116
HAND_4 (),
113117
HAND_5 (),
114118
HAND_6 (),
115-
HAND_7 ();
119+
HAND_7 (),
120+
121+
// Dockable zones, use setDoc to register.
122+
ZONE_LIBRARY (),
123+
ZONE_GRAVEYARD (),
124+
ZONE_EXILE (),
125+
ZONE_FLASHBACK (),
126+
ZONE_COMMAND (),
127+
ZONE_ANTE (),
128+
ZONE_SIDEBOARD (),
129+
ZONE_PLANAR_DECK (),
130+
ZONE_SCHEME_DECK (),
131+
ZONE_ATTRACTION_DECK (),
132+
ZONE_CONTRAPTION_DECK (),
133+
ZONE_JUNKYARD ();
116134

117135
public final static EDocID[] Fields = new EDocID[] {FIELD_0, FIELD_1, FIELD_2, FIELD_3, FIELD_4, FIELD_5, FIELD_6, FIELD_7};
118136
public final static EDocID[] Hands = new EDocID[] {HAND_0, HAND_1, HAND_2, HAND_3, HAND_4, HAND_5, HAND_6, HAND_7};
@@ -144,4 +162,25 @@ public IVDoc<? extends ICDoc> getDoc() {
144162
//if (vDoc == null) { throw new NullPointerException("No document found for " + this.name() + "."); }
145163
return vDoc;
146164
}
165+
166+
private static final Map<ZoneType, EDocID> ZONE_DOC_IDS = new EnumMap<>(ZoneType.class);
167+
static {
168+
ZONE_DOC_IDS.put(ZoneType.Library, ZONE_LIBRARY);
169+
ZONE_DOC_IDS.put(ZoneType.Graveyard, ZONE_GRAVEYARD);
170+
ZONE_DOC_IDS.put(ZoneType.Exile, ZONE_EXILE);
171+
ZONE_DOC_IDS.put(ZoneType.Flashback, ZONE_FLASHBACK);
172+
ZONE_DOC_IDS.put(ZoneType.Command, ZONE_COMMAND);
173+
ZONE_DOC_IDS.put(ZoneType.Ante, ZONE_ANTE);
174+
ZONE_DOC_IDS.put(ZoneType.Sideboard, ZONE_SIDEBOARD);
175+
ZONE_DOC_IDS.put(ZoneType.PlanarDeck, ZONE_PLANAR_DECK);
176+
ZONE_DOC_IDS.put(ZoneType.SchemeDeck, ZONE_SCHEME_DECK);
177+
ZONE_DOC_IDS.put(ZoneType.AttractionDeck, ZONE_ATTRACTION_DECK);
178+
ZONE_DOC_IDS.put(ZoneType.ContraptionDeck, ZONE_CONTRAPTION_DECK);
179+
ZONE_DOC_IDS.put(ZoneType.Junkyard, ZONE_JUNKYARD);
180+
}
181+
182+
/** Returns the EDocID for a dockable zone type, or null if the zone type has no EDocID. */
183+
public static EDocID fromZoneType(final ZoneType zone) {
184+
return ZONE_DOC_IDS.get(zone);
185+
}
147186
}

0 commit comments

Comments
 (0)