client: Support arbitrary game zones (#5877)

* Remove `isView` flag from CardZone

This flag is used for two purposes:

 1. It is used as a check for casting to a zone to a `ZoneViewZone`;

 2. Non-view zones are added to the player's zones on construction

This patch removes the `isView` flag and instead:

 1. We directly cast zones to `ZoneViewZone` using a dynamic (qobject)
    cast and use the result of the cast instead of the `isView` flag to
    detect if we are a view zone or not;

 2. The player records its own zones when they are created, simplifying
    control flow.

* Review

* client: Support arbitrary game zones

Currently, the client ignores cards in unknown zones, as there is an
implicit assumption that the set of zones known by the server and the
client are the same.

This patch makes it so that the client accept "custom zones" from the
server (zones outside the builtin deck, graveyard, exile, sideboard,
table, stack and hand zones) using the information from the
ServerInfo_CardZone. Moving cards from/into these zones happens
through a "View custom zone" action in the Game > Player menu and
properly appears in the chat.

Note that this patch intentionally does not introduce any support for
having the server actually create such zones. Instead, this patch aims
to improve backwards compatibility for when we do get to adding this
capability in the future, by making sure that current clients will be
able to interact with future new zones (even if suboptimally).
This commit is contained in:
Basile Clement
2025-05-07 03:18:08 +02:00
committed by GitHub
parent a07c1badd8
commit 286a7494d3
7 changed files with 90 additions and 8 deletions

View File

@@ -144,7 +144,7 @@ Player::Player(const ServerInfo_User &info, int _id, bool _local, bool _judge, T
PileZone *sb = addZone(new PileZone(this, "sb", false, false, playerArea));
sb->setVisible(false);
table = addZone(new TableZone(this, this));
table = addZone(new TableZone(this, "table", this));
connect(table, &TableZone::sizeChanged, this, &Player::updateBoundingRect);
stack = addZone(new StackZone(this, (int)table->boundingRect().height(), this));
@@ -400,6 +400,9 @@ Player::Player(const ServerInfo_User &info, int _id, bool _local, bool _judge, T
sbMenu->addAction(aViewSideboard);
sb->setMenu(sbMenu, aViewSideboard);
mCustomZones = playerMenu->addMenu(QString());
mCustomZones->menuAction()->setVisible(false);
aUntapAll = new QAction(this);
connect(aUntapAll, &QAction::triggered, this, &Player::actUntapAll);
@@ -455,6 +458,7 @@ Player::Player(const ServerInfo_User &info, int _id, bool _local, bool _judge, T
if (!local && !judge) {
countersMenu = nullptr;
sbMenu = nullptr;
mCustomZones = nullptr;
aCreateAnotherToken = nullptr;
createPredefinedTokenMenu = nullptr;
}
@@ -829,6 +833,11 @@ void Player::retranslateUi()
sbMenu->setTitle(tr("&Sideboard"));
libraryMenu->setTitle(tr("&Library"));
countersMenu->setTitle(tr("&Counters"));
mCustomZones->setTitle(tr("C&ustom Zones"));
for (auto aViewZone : mCustomZones->actions()) {
aViewZone->setText(tr("View custom zone '%1'").arg(aViewZone->data().toString()));
}
aUntapAll->setText(tr("&Untap all permanents"));
aRollDie->setText(tr("R&oll die..."));
@@ -2715,19 +2724,79 @@ void Player::paint(QPainter * /*painter*/, const QStyleOptionGraphicsItem * /*op
void Player::processPlayerInfo(const ServerInfo_Player &info)
{
static QSet<QString> builtinZones{/* PileZones */
"deck", "grave", "rfg", "sb",
/* TableZone */
"table",
/* StackZone */
"stack",
/* HandZone */
"hand"};
clearCounters();
clearArrows();
QMapIterator<QString, CardZone *> zoneIt(zones);
QMutableMapIterator<QString, CardZone *> zoneIt(zones);
while (zoneIt.hasNext()) {
zoneIt.next().value()->clearContents();
if (!builtinZones.contains(zoneIt.key())) {
zoneIt.remove();
}
}
// Can be null if we are not the local player!
if (mCustomZones) {
mCustomZones->clear();
mCustomZones->menuAction()->setVisible(false);
}
const int zoneListSize = info.zone_list_size();
for (int i = 0; i < zoneListSize; ++i) {
const ServerInfo_Zone &zoneInfo = info.zone_list(i);
CardZone *zone = zones.value(QString::fromStdString(zoneInfo.name()), 0);
QString zoneName = QString::fromStdString(zoneInfo.name());
CardZone *zone = zones.value(zoneName, 0);
if (!zone) {
// Create a new CardZone if it doesn't exist
if (zoneInfo.with_coords()) {
// Visibility not currently supported for TableZone
zone = addZone(new TableZone(this, zoneName, this));
} else {
// Zones without coordinats are always treated as non-shufflable
// PileZones, although supporting alternate hand or stack zones
// might make sense in some scenarios.
bool contentsKnown;
switch (zoneInfo.type()) {
case ServerInfo_Zone::PrivateZone:
contentsKnown = local || judge || (game->getSpectator() && game->getSpectatorsSeeEverything());
break;
case ServerInfo_Zone::PublicZone:
contentsKnown = true;
break;
case ServerInfo_Zone::HiddenZone:
contentsKnown = false;
break;
}
zone = addZone(new PileZone(this, zoneName, /* isShufflable */ false, contentsKnown, this));
}
// Non-builtin zones are hidden by default and can't be interacted
// with, except through menus.
zone->setVisible(false);
if (mCustomZones) {
mCustomZones->menuAction()->setVisible(true);
QAction *aViewZone = mCustomZones->addAction(tr("View custom zone '%1'").arg(zoneName));
aViewZone->setData(zoneName);
connect(aViewZone, &QAction::triggered, this,
[zoneName, this]() { static_cast<GameScene *>(scene())->toggleZoneView(this, zoneName, -1); });
}
continue;
}

View File

@@ -253,7 +253,7 @@ public:
private:
TabGame *game;
QMenu *sbMenu, *countersMenu, *sayMenu, *createPredefinedTokenMenu, *mRevealLibrary, *mLendLibrary, *mRevealTopCard,
*mRevealHand, *mRevealRandomHandCard, *mRevealRandomGraveyardCard;
*mRevealHand, *mRevealRandomHandCard, *mRevealRandomGraveyardCard, *mCustomZones;
TearOffMenu *moveGraveMenu, *moveRfgMenu, *graveMenu, *moveHandMenu, *handMenu, *libraryMenu, *topLibraryMenu,
*bottomLibraryMenu, *rfgMenu, *playerMenu;
QList<QMenu *> playerLists;

View File

@@ -92,6 +92,10 @@ QString CardZone::getTranslatedName(bool theirOwn, GrammaticalCase gc) const
default:
break;
}
else {
return (theirOwn ? tr("their custom zone '%1'", "nominative").arg(name)
: tr("%1's custom zone '%2'", "nominative").arg(ownerName).arg(name));
}
return QString();
}

View File

@@ -19,8 +19,8 @@ const QColor TableZone::FADE_MASK = QColor(0, 0, 0, 80);
const QColor TableZone::GRADIENT_COLOR = QColor(255, 255, 255, 150);
const QColor TableZone::GRADIENT_COLORLESS = QColor(255, 255, 255, 0);
TableZone::TableZone(Player *_p, QGraphicsItem *parent)
: SelectZone(_p, "table", true, false, true, parent), active(false)
TableZone::TableZone(Player *_p, const QString &name, QGraphicsItem *parent)
: SelectZone(_p, name, true, false, true, parent), active(false)
{
connect(themeManager, &ThemeManager::themeChanged, this, &TableZone::updateBg);
connect(&SettingsCache::instance(), &SettingsCache::invertVerticalCoordinateChanged, this,

View File

@@ -98,7 +98,7 @@ public:
@param _p the Player
@param parent defaults to null
*/
explicit TableZone(Player *_p, QGraphicsItem *parent = nullptr);
explicit TableZone(Player *_p, const QString &name, QGraphicsItem *parent = nullptr);
/**
@return a QRectF of the TableZone bounding box.

View File

@@ -86,6 +86,8 @@ QPair<QString, QString> MessageLogWidget::getFromStr(CardZone *zone, QString car
fromStr = tr(" from sideboard");
} else if (zoneName == STACK_ZONE_NAME) {
fromStr = tr(" from the stack");
} else {
fromStr = tr(" from custom zone '%1'").arg(zoneName);
}
if (!cardNameContainsStartZone) {
@@ -321,13 +323,16 @@ void MessageLogWidget::logMoveCard(Player *player,
} else if (targetZoneName == STACK_ZONE_NAME) {
soundEngine->playSound("play_card");
finalStr = tr("%1 plays %2%3.");
} else {
finalStr = tr("%1 moves %2%3 to custom zone '%4'.");
}
if (usesNewX) {
appendHtmlServerMessage(
finalStr.arg(sanitizeHtml(player->getName())).arg(cardStr).arg(nameFrom.second).arg(newX));
} else {
appendHtmlServerMessage(finalStr.arg(sanitizeHtml(player->getName())).arg(cardStr).arg(nameFrom.second));
appendHtmlServerMessage(
finalStr.arg(sanitizeHtml(player->getName())).arg(cardStr).arg(nameFrom.second).arg(targetZoneName));
}
}

View File

@@ -11,6 +11,10 @@ message ServerInfo_Zone {
// setting beingLookedAt to true.
// Cards in a zone with the type HiddenZone are referenced by their
// list index, whereas cards in any other zone are referenced by their ids.
//
// WARNING: Adding new zone types will break compatibility with older
// clients. Older clients will read new zone types as PrivateZone, which
// is likely *NOT* what you want.
PrivateZone = 0;
PublicZone = 1;