From 437d2e4ed42b88e29f7065c0ffb683df1831f442 Mon Sep 17 00:00:00 2001 From: DawnFire42 Date: Wed, 25 Feb 2026 13:38:03 -0500 Subject: [PATCH] feat(command-zone): integrate into player system Wire command zones into the player object hierarchy, making them functional game elements. Player class modifications: - player.h/cpp: command zone ownership and lifecycle - player_actions.h/cpp: command zone action handlers - player_graphics_item.h/cpp: command zone visual positioning This commit connects all previous command zone components to the player, enabling: - Zone creation during game setup - Zone positioning relative to other player zones - Action routing for command zone interactions - Proper cleanup on player destruction --- cockatrice/src/game/player/player.cpp | 80 ++++- cockatrice/src/game/player/player.h | 46 ++- cockatrice/src/game/player/player_actions.cpp | 188 +++++++++-- cockatrice/src/game/player/player_actions.h | 1 + .../src/game/player/player_graphics_item.cpp | 302 +++++++++++++++++- .../src/game/player/player_graphics_item.h | 82 ++++- 6 files changed, 655 insertions(+), 44 deletions(-) diff --git a/cockatrice/src/game/player/player.cpp b/cockatrice/src/game/player/player.cpp index 0723ae6bc..ce7634b8d 100644 --- a/cockatrice/src/game/player/player.cpp +++ b/cockatrice/src/game/player/player.cpp @@ -5,12 +5,16 @@ #include "../board/arrow_item.h" #include "../board/card_item.h" #include "../board/card_list.h" +#include "../board/commander_tax_counter.h" #include "../board/counter_general.h" #include "../game_scene.h" +#include "../z_values.h" +#include "../zones/command_zone.h" #include "../zones/hand_zone.h" #include "../zones/pile_zone.h" #include "../zones/stack_zone.h" #include "../zones/table_zone.h" +#include "../zones/zone_names.h" #include "player_actions.h" #include "player_target.h" @@ -19,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -32,7 +37,8 @@ Player::Player(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), - conceded(false), zoneId(0), dialogSemaphore(false) + conceded(false), zoneId(0), dialogSemaphore(false), serverHasCommandZone(false), serverHasCompanionZone(false), + serverHasBackgroundZone(false) { initializeZones(); @@ -41,6 +47,9 @@ Player::Player(const ServerInfo_User &info, int _id, bool _local, bool _judge, A playerMenu->setMenusForGraphicItems(); connect(this, &Player::activeChanged, graphicsItem, &PlayerGraphicsItem::onPlayerActiveChanged); + connect(this, &Player::commandZoneSupportChanged, graphicsItem, &PlayerGraphicsItem::setCommandZonesVisible); + connect(this, &Player::companionZoneSupportChanged, graphicsItem, &PlayerGraphicsItem::setCompanionZoneVisible); + connect(this, &Player::backgroundZoneSupportChanged, graphicsItem, &PlayerGraphicsItem::setBackgroundZoneVisible); connect(this, &Player::openDeckEditor, game->getTab(), &TabGame::openDeckEditor); @@ -61,15 +70,19 @@ void Player::forwardActionSignalsToEventHandler() void Player::initializeZones() { - addZone(new PileZoneLogic(this, "deck", false, true, false, this)); - addZone(new PileZoneLogic(this, "grave", false, false, true, this)); - addZone(new PileZoneLogic(this, "rfg", false, false, true, this)); - addZone(new PileZoneLogic(this, "sb", false, false, false, this)); - addZone(new TableZoneLogic(this, "table", true, false, true, this)); - addZone(new StackZoneLogic(this, "stack", true, false, true, this)); + addZone(new PileZoneLogic(this, ZoneNames::DECK, false, true, false, this)); + addZone(new PileZoneLogic(this, ZoneNames::GRAVE, false, false, true, this)); + addZone(new PileZoneLogic(this, ZoneNames::EXILE, false, false, true, this)); + addZone(new PileZoneLogic(this, ZoneNames::SIDEBOARD, false, false, false, this)); + addZone(new TableZoneLogic(this, ZoneNames::TABLE, true, false, true, this)); + addZone(new StackZoneLogic(this, ZoneNames::STACK, true, false, true, this)); bool visibleHand = playerInfo->getLocalOrJudge() || (game->getPlayerManager()->isSpectator() && game->getGameMetaInfo()->spectatorsOmniscient()); - addZone(new HandZoneLogic(this, "hand", false, false, visibleHand, this)); + addZone(new HandZoneLogic(this, ZoneNames::HAND, false, false, visibleHand, this)); + addZone(new CommandZoneLogic(this, ZoneNames::COMMAND, true, false, true, this)); + addZone(new CommandZoneLogic(this, ZoneNames::PARTNER, true, false, true, this)); + addZone(new CommandZoneLogic(this, ZoneNames::COMPANION, true, false, true, this)); + addZone(new CommandZoneLogic(this, ZoneNames::BACKGROUND, true, false, true, this)); } Player::~Player() @@ -97,6 +110,14 @@ void Player::clear() clearCounters(); } +void Player::updateZoneSupport(bool *flag, void (Player::*signal)(bool), bool found) +{ + if (*flag != found) { + *flag = found; + emit(this->*signal)(found); + } +} + void Player::setConceded(bool _conceded) { if (conceded != _conceded) { @@ -125,7 +146,9 @@ void Player::processPlayerInfo(const ServerInfo_Player &info) /* StackZone */ "stack", /* HandZone */ - "hand"}; + "hand", + /* CommandZones */ + "command", "partner", "companion", "background"}; clearCounters(); clearArrows(); @@ -140,7 +163,27 @@ void Player::processPlayerInfo(const ServerInfo_Player &info) emit clearCustomZonesMenu(); + // Check if server has command zones by scanning the zone list + bool foundCommandZone = false; + bool foundCompanionZone = false; + bool foundBackgroundZone = false; const int zoneListSize = info.zone_list_size(); + for (int i = 0; i < zoneListSize; ++i) { + QString zoneName = QString::fromStdString(info.zone_list(i).name()); + if (zoneName == ZoneNames::COMMAND) { + foundCommandZone = true; + } else if (zoneName == ZoneNames::COMPANION) { + foundCompanionZone = true; + } else if (zoneName == ZoneNames::BACKGROUND) { + foundBackgroundZone = true; + } + } + + // Update command zone support flags and notify graphics + updateZoneSupport(&serverHasCommandZone, &Player::commandZoneSupportChanged, foundCommandZone); + updateZoneSupport(&serverHasCompanionZone, &Player::companionZoneSupportChanged, foundCompanionZone); + updateZoneSupport(&serverHasBackgroundZone, &Player::backgroundZoneSupportChanged, foundBackgroundZone); + for (int i = 0; i < zoneListSize; ++i) { const ServerInfo_Zone &zoneInfo = info.zone_list(i); @@ -156,7 +199,7 @@ void Player::processPlayerInfo(const ServerInfo_Player &info) // 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; + bool contentsKnown = false; // Default to false for unknown zone types switch (zoneInfo.type()) { case ServerInfo_Zone::PrivateZone: @@ -172,6 +215,9 @@ void Player::processPlayerInfo(const ServerInfo_Player &info) case ServerInfo_Zone::HiddenZone: contentsKnown = false; break; + + default: + break; } zone = addZone(new PileZoneLogic(this, zoneName, false, /* isShufflable */ false, contentsKnown, this)); @@ -285,6 +331,20 @@ AbstractCounter *Player::addCounter(int counterId, const QString &name, QColor c AbstractCounter *ctr; if (name == "life") { ctr = getGraphicsItem()->getPlayerTarget()->addCounter(counterId, name, value); + } else if (counterId == CounterIds::CommanderTax) { + QGraphicsItem *parent = graphicsItem->getCommandZoneGraphicsItem(); + if (!parent) { + qCWarning(PlayerLog) << "addCounter: Cannot create CommanderTax counter - command zone not available"; + return nullptr; + } + ctr = new CommanderTaxCounter(this, counterId, name, ZoneSizes::TAX_COUNTER_SIZE, value, parent); + } else if (counterId == CounterIds::PartnerTax) { + QGraphicsItem *parent = graphicsItem->getPartnerZoneGraphicsItem(); + if (!parent) { + qCWarning(PlayerLog) << "addCounter: Cannot create PartnerTax counter - partner zone not available"; + return nullptr; + } + ctr = new CommanderTaxCounter(this, counterId, name, ZoneSizes::TAX_COUNTER_SIZE, value, parent); } else { ctr = new GeneralCounter(this, counterId, name, color, radius, value, true, graphicsItem); } diff --git a/cockatrice/src/game/player/player.h b/cockatrice/src/game/player/player.h index 0a03b3abe..0a906fb87 100644 --- a/cockatrice/src/game/player/player.h +++ b/cockatrice/src/game/player/player.h @@ -10,6 +10,7 @@ #include "../../game_graphics/board/abstract_graphics_item.h" #include "../../interface/widgets/menus/tearoff_menu.h" #include "../interface/deck_loader/loaded_deck.h" +#include "../zones/logic/command_zone_logic.h" #include "../zones/logic/hand_zone_logic.h" #include "../zones/logic/pile_zone_logic.h" #include "../zones/logic/stack_zone_logic.h" @@ -75,6 +76,9 @@ signals: void clearCustomZonesMenu(); void addViewCustomZoneActionToCustomZoneMenu(QString zoneName); void resetTopCardMenuActions(); + void commandZoneSupportChanged(bool hasCommandZone); + void companionZoneSupportChanged(bool hasCompanionZone); + void backgroundZoneSupportChanged(bool hasBackgroundZone); public slots: void setActive(bool _active); @@ -143,7 +147,7 @@ public: return zone; } - CardZoneLogic *getZone(const QString zoneName) + CardZoneLogic *getZone(const QString &zoneName) { return zones.value(zoneName); } @@ -188,6 +192,26 @@ public: return qobject_cast(zones.value("hand")); } + CommandZoneLogic *getCommandZone() + { + return qobject_cast(zones.value("command")); + } + + CommandZoneLogic *getPartnerZone() + { + return qobject_cast(zones.value("partner")); + } + + CommandZoneLogic *getCompanionZone() + { + return qobject_cast(zones.value("companion")); + } + + CommandZoneLogic *getBackgroundZone() + { + return qobject_cast(zones.value("background")); + } + AbstractCounter *addCounter(const ServerInfo_Counter &counter); AbstractCounter *addCounter(int counterId, const QString &name, QColor color, int radius, int value); void delCounter(int counterId); @@ -216,6 +240,21 @@ public: return conceded; } + bool hasServerCommandZone() const + { + return serverHasCommandZone; + } + + bool hasServerCompanionZone() const + { + return serverHasCompanionZone; + } + + bool hasServerBackgroundZone() const + { + return serverHasBackgroundZone; + } + void setGameStarted(); void setDialogSemaphore(const bool _active) @@ -249,8 +288,13 @@ private: QMap arrows; bool dialogSemaphore; + bool serverHasCommandZone; + bool serverHasCompanionZone; + bool serverHasBackgroundZone; QList cardsToDelete; + void updateZoneSupport(bool *flag, void (Player::*signal)(bool), bool found); + // void eventConnectionStateChanged(const Event_ConnectionStateChanged &event); }; diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index ed77808b0..31c2343e6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -8,16 +8,21 @@ #include "../zones/hand_zone.h" #include "../zones/logic/view_zone_logic.h" #include "../zones/table_zone.h" +#include "../zones/zone_names.h" #include "card_menu_action_type.h" +#include +#include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -32,6 +37,15 @@ // milliseconds in between triggers of the move top cards until action static constexpr int MOVE_TOP_CARD_UNTIL_INTERVAL = 100; +// Lookup table for move-to-zone actions -> zone name strings +// Consolidates 4 identical switch cases into data-driven dispatch +static const QHash moveToZoneMap = { + {cmMoveToCommandZone, ZoneNames::COMMAND}, + {cmMoveToPartnerZone, ZoneNames::PARTNER}, + {cmMoveToCompanionZone, ZoneNames::COMPANION}, + {cmMoveToBackgroundZone, ZoneNames::BACKGROUND}, +}; + PlayerActions::PlayerActions(Player *_player) : player(_player), lastTokenTableRow(0), movingCardsUntil(false) { moveTopCardTimer = new QTimer(this); @@ -520,7 +534,13 @@ void PlayerActions::moveOneCardUntil(CardItem *card) if (isMatch && movingCardsUntilAutoPlay) { // Directly calling playCard will deadlock, since we are already in the middle of processing an event. // Use QTimer::singleShot to queue up the playCard on the event loop. - QTimer::singleShot(0, this, [card, this] { playCard(card, false); }); + // Use QPointer to safely handle the case where the card is deleted before the timer fires. + QPointer safeCard = card; + QTimer::singleShot(0, this, [safeCard, this] { + if (safeCard) { + playCard(safeCard, false); + } + }); } if (player->getDeckZone()->getCards().empty() || !card) { @@ -1280,7 +1300,10 @@ void PlayerActions::actIncPT(int deltaP, int deltaT) QList commandList; for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); + auto *card = qgraphicsitem_cast(item); + if (!card) { + continue; + } QString pt = card->getPT(); const auto ptList = parsePT(pt); QString newpt; @@ -1313,7 +1336,10 @@ void PlayerActions::actResetPT() int playerid = player->getPlayerInfo()->getId(); QList commandList; for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); + auto *card = qgraphicsitem_cast(item); + if (!card) { + continue; + } QString ptString; if (!card->getFaceDown()) { // leave the pt empty if the card is face down ExactCard ec = card->getCard(); @@ -1380,8 +1406,8 @@ void PlayerActions::actSetPT() auto sel = player->getGameScene()->selectedItems(); for (const auto &item : sel) { - auto *card = static_cast(item); - if (!card->getPT().isEmpty()) { + auto *card = qgraphicsitem_cast(item); + if (card && !card->getPT().isEmpty()) { oldPT = card->getPT(); } } @@ -1399,7 +1425,10 @@ void PlayerActions::actSetPT() QList commandList; for (const auto &item : sel) { - auto *card = static_cast(item); + auto *card = qgraphicsitem_cast(item); + if (!card) { + continue; + } auto *cmd = new Command_SetCardAttr; QString newpt = QString(); if (!empty) { @@ -1498,8 +1527,8 @@ void PlayerActions::actSetAnnotation() QString oldAnnotation; auto sel = player->getGameScene()->selectedItems(); for (const auto &item : sel) { - auto *card = static_cast(item); - if (!card->getAnnotation().isEmpty()) { + auto *card = qgraphicsitem_cast(item); + if (card && !card->getAnnotation().isEmpty()) { oldAnnotation = card->getAnnotation(); } } @@ -1519,7 +1548,10 @@ void PlayerActions::actSetAnnotation() QList commandList; for (const auto &item : sel) { - auto *card = static_cast(item); + auto *card = qgraphicsitem_cast(item); + if (!card) { + continue; + } auto *cmd = new Command_SetCardAttr; cmd->set_zone(card->getZone()->getName().toStdString()); cmd->set_card_id(card->getId()); @@ -1544,9 +1576,8 @@ void PlayerActions::actUnattach() { QList commandList; for (QGraphicsItem *item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); - - if (!card->getAttachedTo()) { + auto *card = qgraphicsitem_cast(item); + if (!card || !card->getAttachedTo()) { continue; } @@ -1566,8 +1597,8 @@ void PlayerActions::actCardCounterTrigger() switch (action->data().toInt() % 1000) { case 9: { // increment counter for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); - if (card->getCounters().value(counterId, 0) < MAX_COUNTERS_ON_CARD) { + auto *card = qgraphicsitem_cast(item); + if (card && card->getCounters().value(counterId, 0) < MAX_COUNTERS_ON_CARD) { auto *cmd = new Command_SetCardCounter; cmd->set_zone(card->getZone()->getName().toStdString()); cmd->set_card_id(card->getId()); @@ -1580,8 +1611,8 @@ void PlayerActions::actCardCounterTrigger() } case 10: { // decrement counter for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); - if (card->getCounters().value(counterId, 0)) { + auto *card = qgraphicsitem_cast(item); + if (card && card->getCounters().value(counterId, 0)) { auto *cmd = new Command_SetCardCounter; cmd->set_zone(card->getZone()->getName().toStdString()); cmd->set_card_id(card->getId()); @@ -1598,8 +1629,9 @@ void PlayerActions::actCardCounterTrigger() int oldValue = 0; if (player->getGameScene()->selectedItems().size() == 1) { - auto *card = static_cast(player->getGameScene()->selectedItems().first()); - oldValue = card->getCounters().value(counterId, 0); + if (auto *card = qgraphicsitem_cast(player->getGameScene()->selectedItems().first())) { + oldValue = card->getCounters().value(counterId, 0); + } } int number = QInputDialog::getInt(player->getGame()->getTab(), tr("Set counters"), tr("Number:"), oldValue, 0, MAX_COUNTERS_ON_CARD, 1, &ok); @@ -1609,7 +1641,10 @@ void PlayerActions::actCardCounterTrigger() } for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); + auto *card = qgraphicsitem_cast(item); + if (!card) { + continue; + } auto *cmd = new Command_SetCardCounter; cmd->set_zone(card->getZone()->getName().toStdString()); cmd->set_card_id(card->getId()); @@ -1640,8 +1675,9 @@ void PlayerActions::playSelectedCards(const bool faceDown) { QList selectedCards; for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); - selectedCards.append(card); + if (auto *card = qgraphicsitem_cast(item)) { + selectedCards.append(card); + } } // CardIds will get shuffled downwards when cards leave the deck. @@ -1667,12 +1703,52 @@ void PlayerActions::actPlayFacedown() playSelectedCards(true); } +void PlayerActions::actPlayAndIncreaseTax() +{ + QList selectedCards; + for (const auto &item : player->getGameScene()->selectedItems()) { + if (auto *card = qgraphicsitem_cast(item)) { + selectedCards.append(card); + } + } + + std::sort(selectedCards.begin(), selectedCards.end(), + [](const auto &card1, const auto &card2) { return card1->getId() > card2->getId(); }); + + for (auto &card : selectedCards) { + if (!card || isUnwritableRevealZone(card->getZone()) || card->getZone()->getName() == ZoneNames::TABLE) { + continue; + } + + QString zoneName = card->getZone()->getName(); + + // Only increment tax for cards from command or partner zones + if (zoneName == ZoneNames::COMMAND || zoneName == ZoneNames::PARTNER) { + // Determine the correct counter ID + int counterId = (zoneName == ZoneNames::PARTNER) ? CounterIds::PartnerTax : CounterIds::CommanderTax; + + // Play the card + playCard(card, false); + + // Increment the tax counter + Command_IncCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_delta(1); + sendGameCommand(cmd); + } else { + // For non-command zone cards, just play normally + playCard(card, false); + } + } +} + void PlayerActions::actHide() { for (const auto &item : player->getGameScene()->selectedItems()) { - auto *card = static_cast(item); - if (card && isUnwritableRevealZone(card->getZone())) { - card->getZone()->removeCard(card); + if (auto *card = qgraphicsitem_cast(item)) { + if (isUnwritableRevealZone(card->getZone())) { + card->getZone()->removeCard(card); + } } } } @@ -1776,7 +1852,14 @@ void PlayerActions::cardMenuAction() QList sel = player->getGameScene()->selectedItems(); QList cardList; while (!sel.isEmpty()) { - cardList.append(qgraphicsitem_cast(sel.takeFirst())); + CardItem *card = qgraphicsitem_cast(sel.takeFirst()); + if (card) { + cardList.append(card); + } + } + + if (cardList.isEmpty()) { + return; } QList commandList; @@ -1863,7 +1946,8 @@ void PlayerActions::cardMenuAction() idList.add_card()->set_card_id(i->getId()); } - switch (static_cast(a->data().toInt())) { + const auto actionType = static_cast(a->data().toInt()); + switch (actionType) { case cmMoveToTopLibrary: { auto *cmd = new Command_MoveCard; cmd->set_start_player_id(startPlayerId); @@ -1944,6 +2028,58 @@ void PlayerActions::cardMenuAction() commandList.append(cmd); break; } + case cmMoveToCommandZone: + case cmMoveToPartnerZone: + case cmMoveToCompanionZone: + case cmMoveToBackgroundZone: { + const QString zoneName = moveToZoneMap.value(actionType); + if (zoneName.isEmpty()) { + qCWarning(PlayerLog) << "Unknown move-to-zone action:" << static_cast(actionType); + break; + } + auto *cmd = new Command_MoveCard; + cmd->set_start_player_id(startPlayerId); + cmd->set_start_zone(startZone.toStdString()); + cmd->mutable_cards_to_move()->CopyFrom(idList); + cmd->set_target_player_id(player->getPlayerInfo()->getId()); + cmd->set_target_zone(zoneName.toStdString()); + cmd->set_x(0); + cmd->set_y(0); + commandList.append(cmd); + break; + } + case cmMoveToTable: { + // Process each card individually to position based on card type + for (const auto &card : cardList) { + auto *cmd = new Command_MoveCard; + cmd->set_start_player_id(startPlayerId); + cmd->set_start_zone(startZone.toStdString()); + cmd->set_target_player_id(player->getPlayerInfo()->getId()); + cmd->set_target_zone("table"); + + CardToMove *cardToMove = cmd->mutable_cards_to_move()->add_card(); + cardToMove->set_card_id(card->getId()); + + // Determine correct row based on card type (same logic as playCardToTable) + int tableRow = 1; // default for non-creatures + ExactCard exactCard = card->getCard(); + if (exactCard) { + const CardInfo &info = exactCard.getInfo(); + tableRow = info.getUiAttributes().tableRow; + // default instant/sorcery cards to the noncreatures row + if (tableRow > 2) { + tableRow = 1; + } + cardToMove->set_pt(info.getPowTough().toStdString()); + cardToMove->set_tapped(info.getUiAttributes().cipt); + } + + cmd->set_x(-1); + cmd->set_y(TableZone::clampValidTableRow(2 - tableRow)); + commandList.append(cmd); + } + break; + } default: break; } diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index b294b5946..8f3159033 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -91,6 +91,7 @@ public slots: void actPlay(); void actPlayFacedown(); + void actPlayAndIncreaseTax(); void actHide(); void actMoveTopCardToPlay(); diff --git a/cockatrice/src/game/player/player_graphics_item.cpp b/cockatrice/src/game/player/player_graphics_item.cpp index 30648c926..ac621c6f8 100644 --- a/cockatrice/src/game/player/player_graphics_item.cpp +++ b/cockatrice/src/game/player/player_graphics_item.cpp @@ -1,13 +1,21 @@ #include "player_graphics_item.h" +// @see tests/command_zone/counter_visibility_test.cpp - test harness duplicates visibility logic +// @see tests/command_zone/command_zone_integration_test.cpp - test harness duplicates counter positioning + #include "../../interface/widgets/tabs/tab_game.h" #include "../board/abstract_card_item.h" +#include "../board/commander_tax_counter.h" #include "../hand_counter.h" +#include "../z_values.h" +#include "../zones/command_zone.h" #include "../zones/hand_zone.h" #include "../zones/pile_zone.h" #include "../zones/stack_zone.h" #include "../zones/table_zone.h" +#include + PlayerGraphicsItem::PlayerGraphicsItem(Player *_player) : player(_player) { connect(&SettingsCache::instance(), &SettingsCache::horizontalHandChanged, this, @@ -83,6 +91,54 @@ void PlayerGraphicsItem::initializeZones() handZoneGraphicsItem = new HandZone(player->getHandZone(), static_cast(tableZoneGraphicsItem->boundingRect().height()), this); + // Command zones - mixed hierarchy: + // - Partner is Qt child of Command (tight coupling) + // - Companion and Background are Qt siblings (direct children of PlayerGraphicsItem) + // with logical parent-child relationships for hierarchy traversal + // + // OWNERSHIP INVARIANT: All command zones must remain Qt children of either + // CommandZone (for Partner) or PlayerGraphicsItem (for Primary, Companion, Background) + // for the duration of their lifetime. The logical parent-child relationships + // (logicalParentZone, logicalChildZones) are raw pointers that rely on this + // ownership guarantee. Do not reparent zones or change destruction order. + + // Primary command zone + commandZoneGraphicsItem = + new CommandZone(player->getCommandZone(), ZoneSizes::COMMAND_ZONE_HEIGHT, CommandZoneType::Primary, this); + commandZoneGraphicsItem->setZValue(ZValues::COMMAND_ZONE); + commandZoneGraphicsItem->setVisible(false); + connect(commandZoneGraphicsItem, &CommandZone::minimizedChanged, this, &PlayerGraphicsItem::rearrangeZones); + + // Partner zone is Qt child of Command (nested - preserves tight Command-Partner relationship) + partnerZoneGraphicsItem = new CommandZone(player->getPartnerZone(), ZoneSizes::COMMAND_ZONE_HEIGHT, + CommandZoneType::Partner, commandZoneGraphicsItem); + partnerZoneGraphicsItem->setZValue(ZValues::PARTNER_ZONE); + connect(partnerZoneGraphicsItem, &CommandZone::expandedChanged, this, &PlayerGraphicsItem::onZoneExpanded); + connect(partnerZoneGraphicsItem, &CommandZone::minimizedChanged, this, &PlayerGraphicsItem::rearrangeZones); + + // Companion zone is SIBLING - direct child of PlayerGraphicsItem (fixes spacing bugs) + companionZoneGraphicsItem = + new CommandZone(player->getCompanionZone(), ZoneSizes::COMMAND_ZONE_HEIGHT, CommandZoneType::Companion, this); + companionZoneGraphicsItem->setZValue(ZValues::PARTNER_ZONE + 1); + companionZoneGraphicsItem->setVisible(false); + connect(companionZoneGraphicsItem, &CommandZone::expandedChanged, this, &PlayerGraphicsItem::onZoneExpanded); + connect(companionZoneGraphicsItem, &CommandZone::minimizedChanged, this, &PlayerGraphicsItem::rearrangeZones); + + // Background zone is SIBLING - direct child of PlayerGraphicsItem (fixes spacing bugs) + backgroundZoneGraphicsItem = + new CommandZone(player->getBackgroundZone(), ZoneSizes::COMMAND_ZONE_HEIGHT, CommandZoneType::Background, this); + backgroundZoneGraphicsItem->setZValue(ZValues::PARTNER_ZONE + 2); + backgroundZoneGraphicsItem->setVisible(false); + connect(backgroundZoneGraphicsItem, &CommandZone::expandedChanged, this, &PlayerGraphicsItem::onZoneExpanded); + connect(backgroundZoneGraphicsItem, &CommandZone::minimizedChanged, this, &PlayerGraphicsItem::rearrangeZones); + + // Set up logical parent-child relationships for sibling zones + // (used by hierarchy traversal methods like tryMinimizeAbove, shouldPreventCollapse) + companionZoneGraphicsItem->setLogicalParentZone(partnerZoneGraphicsItem); + backgroundZoneGraphicsItem->setLogicalParentZone(companionZoneGraphicsItem); + partnerZoneGraphicsItem->addLogicalChildZone(companionZoneGraphicsItem); + companionZoneGraphicsItem->addLogicalChildZone(backgroundZoneGraphicsItem); + connect(handZoneGraphicsItem->getLogic(), &HandZoneLogic::cardCountChanged, handCounter, &HandCounter::updateNumber); connect(handCounter, &HandCounter::showContextMenu, handZoneGraphicsItem, &HandZone::showContextMenu); @@ -111,7 +167,6 @@ void PlayerGraphicsItem::paint(QPainter * /*painter*/, void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) { - // Extend table (and hand, if horizontal) to accommodate the new player width. qreal tableWidth = newPlayerWidth - CARD_HEIGHT - 15 - counterAreaWidth - stackZoneGraphicsItem->boundingRect().width(); if (!SettingsCache::instance().getHorizontalHand()) { @@ -136,10 +191,33 @@ void PlayerGraphicsItem::rearrangeCounters() const qreal padding = 5; qreal ySize = boundingRect().y() + marginTop; - // Place objects + bool commandZonesVisible = commandZoneGraphicsItem->isVisible(); + for (const auto &counter : player->getCounters()) { AbstractCounter *ctr = counter; + if (ctr->getId() == CounterIds::CommanderTax) { + if (commandZonesVisible) { + ctr->setPos(2, 2); + ctr->setZValue(ZValues::TAX_COUNTERS); + ctr->setVisible(true); + } else { + ctr->setVisible(false); + } + continue; + } + + if (ctr->getId() == CounterIds::PartnerTax) { + if (commandZonesVisible && partnerZoneGraphicsItem->isExpanded()) { + ctr->setPos(2, 2); + ctr->setZValue(ZValues::TAX_COUNTERS); + ctr->setVisible(true); + } else { + ctr->setVisible(false); + } + continue; + } + if (!ctr->getShownInCounterArea()) { continue; } @@ -150,9 +228,132 @@ void PlayerGraphicsItem::rearrangeCounters() } } +qreal PlayerGraphicsItem::positionCommandZones(const QPointF &base, bool commandZonesVisible) +{ + qreal runningY = 0; + + // Position command/partner zones only if command zone is visible + if (commandZonesVisible) { + commandZoneGraphicsItem->setPos(base); + partnerZoneGraphicsItem->setPos(0, commandZoneGraphicsItem->currentHeight()); + runningY = commandZoneGraphicsItem->currentHeight() + partnerZoneGraphicsItem->currentHeight(); + } + + // Companion and background are Qt siblings - position based on their own visibility + bool companionZoneEnabled = companionZoneGraphicsItem->isVisible(); + bool backgroundZoneEnabled = backgroundZoneGraphicsItem->isVisible(); + + if (companionZoneEnabled) { + companionZoneGraphicsItem->setPos(base.x(), base.y() + runningY); + runningY += companionZoneGraphicsItem->currentHeight(); + } + + if (backgroundZoneEnabled) { + backgroundZoneGraphicsItem->setPos(base.x(), base.y() + runningY); + runningY += backgroundZoneGraphicsItem->currentHeight(); + } + + // Toggle visibility rules with availability guards: + // A zone's toggle is ONLY visible if: + // 1. The zone itself is enabled (Qt visible), AND + // 2. Either the zone is open OR it's the "next available" to open + // + // Truth table for entry point (game start, nothing open): + // | Cmd Enabled | Comp Enabled | Bg Enabled | Entry Point Toggle | + // |-------------|--------------|------------|--------------------| + // | Y | Y | Y | Partner | + // | Y | N | Y | Partner | + // | N | Y | Y | Companion | + // | N | Y | N | Companion | + // | N | N | Y | Background | + if (commandZonesVisible || companionZoneEnabled || backgroundZoneEnabled) { + bool commandZoneEnabled = commandZonesVisible; + + bool partnerCollapsed = partnerZoneGraphicsItem->isCollapsed(); + bool companionCollapsed = companionZoneGraphicsItem->isCollapsed(); + bool backgroundCollapsed = backgroundZoneGraphicsItem->isCollapsed(); + + bool partnerIsOpen = !partnerCollapsed; + bool companionIsOpen = !companionCollapsed; + bool backgroundIsOpen = !backgroundCollapsed; + bool noDeeperZonesOpen = companionCollapsed && backgroundCollapsed; + + // Partner toggle: visible when open OR at game start (no deeper zones open) + partnerZoneGraphicsItem->setToggleButtonVisible(commandZoneEnabled && (partnerIsOpen || noDeeperZonesOpen)); + + // Companion toggle: visible when open OR when it's next in cascade OR entry point + companionZoneGraphicsItem->setToggleButtonVisible( + companionZoneEnabled && + (companionIsOpen || (partnerIsOpen && backgroundCollapsed) || (!commandZoneEnabled && noDeeperZonesOpen))); + + // Background toggle: visible when open OR parent open OR skipping disabled companion + backgroundZoneGraphicsItem->setToggleButtonVisible( + backgroundZoneEnabled && (backgroundIsOpen || companionIsOpen || (partnerIsOpen && !companionZoneEnabled) || + (!commandZoneEnabled && !companionZoneEnabled))); + } + + stackZoneGraphicsItem->setPos(base + QPointF(0, runningY)); + + return runningY; +} + void PlayerGraphicsItem::rearrangeZones() { auto base = QPointF(CARD_HEIGHT + counterAreaWidth + 15, 0); + + bool commandZoneEnabled = commandZoneGraphicsItem->isVisible(); + bool companionZoneEnabled = companionZoneGraphicsItem->isVisible(); + bool backgroundZoneEnabled = backgroundZoneGraphicsItem->isVisible(); + bool anyCommandFamilyZonesVisible = commandZoneEnabled || companionZoneEnabled || backgroundZoneEnabled; + + // Calculate available stack height, accounting for command zones if present + qreal tableHeight = tableZoneGraphicsItem->boundingRect().height(); + qreal stackHeight = tableHeight; + if (anyCommandFamilyZonesVisible) { + qreal totalCommandHeight = totalCommandZoneHeight(); + + // Safety-net auto-minimize for non-expansion triggers (window resize, etc.) + // Most overflow is now handled at expansion time by onZoneExpanded() + constexpr int MAX_MINIMIZE_ITERATIONS = 3; + int iterations = 0; + while (totalCommandHeight > tableHeight - ZoneSizes::MINIMUM_STACK_HEIGHT && + iterations < MAX_MINIMIZE_ITERATIONS) { + bool minimized = false; + + // Try command zone hierarchy first (includes partner via logical children) + if (commandZoneEnabled) { + minimized = commandZoneGraphicsItem->tryAutoMinimize(); + } + + // Fall back to companion/background directly when command zone is disabled. + // These zones use toggleMinimized() directly since they don't have the same + // hierarchical auto-minimize traversal that command zone provides. + if (!minimized && companionZoneEnabled && companionZoneGraphicsItem->isExpanded() && + !companionZoneGraphicsItem->isMinimized()) { + companionZoneGraphicsItem->toggleMinimized(); + minimized = true; + } + + if (!minimized && backgroundZoneEnabled && backgroundZoneGraphicsItem->isExpanded() && + !backgroundZoneGraphicsItem->isMinimized()) { + backgroundZoneGraphicsItem->toggleMinimized(); + minimized = true; + } + + if (!minimized) + break; + + totalCommandHeight = totalCommandZoneHeight(); + ++iterations; + } + + stackHeight = tableHeight - totalCommandHeight; + if (stackHeight < ZoneSizes::MINIMUM_STACK_HEIGHT) { + stackHeight = ZoneSizes::MINIMUM_STACK_HEIGHT; + } + } + stackZoneGraphicsItem->setHeight(stackHeight); + if (SettingsCache::instance().getHorizontalHand()) { if (mirrored) { if (player->getHandZone()->contentsKnown()) { @@ -163,12 +364,12 @@ void PlayerGraphicsItem::rearrangeZones() player->getPlayerInfo()->setHandVisible(false); } - stackZoneGraphicsItem->setPos(base); + positionCommandZones(base, commandZoneEnabled); base += QPointF(stackZoneGraphicsItem->boundingRect().width(), 0); tableZoneGraphicsItem->setPos(base); } else { - stackZoneGraphicsItem->setPos(base); + positionCommandZones(base, commandZoneEnabled); tableZoneGraphicsItem->setPos(base.x() + stackZoneGraphicsItem->boundingRect().width(), 0); base += QPointF(0, tableZoneGraphicsItem->boundingRect().height()); @@ -188,7 +389,7 @@ void PlayerGraphicsItem::rearrangeZones() handZoneGraphicsItem->setPos(base); base += QPointF(handZoneGraphicsItem->boundingRect().width(), 0); - stackZoneGraphicsItem->setPos(base); + positionCommandZones(base, commandZoneEnabled); base += QPointF(stackZoneGraphicsItem->boundingRect().width(), 0); tableZoneGraphicsItem->setPos(base); @@ -217,4 +418,93 @@ void PlayerGraphicsItem::updateBoundingRect() playerArea->setSize(CARD_HEIGHT + counterAreaWidth + 15, bRect.height()); emit sizeChanged(); -} \ No newline at end of file +} + +qreal PlayerGraphicsItem::totalCommandZoneHeight() const +{ + qreal total = 0; + + // Command zone and partner use Qt parent-child - checking command visibility + // implicitly covers partner (Qt hides children when parent is hidden) + if (commandZoneGraphicsItem->isVisible()) { + total += commandZoneGraphicsItem->currentHeight(); + if (partnerZoneGraphicsItem->isExpanded()) { + total += partnerZoneGraphicsItem->currentHeight(); + } + } + + // Sibling zones have independent visibility - check each one + if (companionZoneGraphicsItem->isVisible()) { + total += companionZoneGraphicsItem->currentHeight(); + } + + if (backgroundZoneGraphicsItem->isVisible()) { + total += backgroundZoneGraphicsItem->currentHeight(); + } + + return total; +} + +void PlayerGraphicsItem::onZoneExpanded(bool expanded) +{ + if (!expanded) { + rearrangeZones(); + return; + } + + auto *expandedZone = qobject_cast(sender()); + if (expandedZone == nullptr) { + rearrangeZones(); + return; + } + + qreal tableHeight = tableZoneGraphicsItem->boundingRect().height(); + qreal totalHeight = totalCommandZoneHeight(); + + constexpr int MAX_MINIMIZE_ITERATIONS = 3; + int iterations = 0; + while (totalHeight > tableHeight - ZoneSizes::MINIMUM_STACK_HEIGHT && iterations < MAX_MINIMIZE_ITERATIONS) { + if (!expandedZone->tryMinimizeAbove()) + break; + + totalHeight = totalCommandZoneHeight(); + ++iterations; + } + + rearrangeZones(); +} + +void PlayerGraphicsItem::setCommandZonesVisible(bool visible) +{ + if (commandZoneGraphicsItem) { + commandZoneGraphicsItem->setVisible(visible); + } + + // Reset partner zone to collapsed state when hiding + if (!visible && partnerZoneGraphicsItem) { + partnerZoneGraphicsItem->setExpanded(false); + } + rearrangeZones(); +} + +void PlayerGraphicsItem::setCompanionZoneVisible(bool visible) +{ + if (companionZoneGraphicsItem) { + companionZoneGraphicsItem->setVisible(visible); + if (!visible) { + companionZoneGraphicsItem->setExpanded(false); + } + } + rearrangeZones(); +} + +void PlayerGraphicsItem::setBackgroundZoneVisible(bool visible) +{ + if (backgroundZoneGraphicsItem) { + backgroundZoneGraphicsItem->setVisible(visible); + if (!visible) { + backgroundZoneGraphicsItem->setExpanded(false); + } + } + rearrangeZones(); +} diff --git a/cockatrice/src/game/player/player_graphics_item.h b/cockatrice/src/game/player/player_graphics_item.h index cba664dd9..18704d6e9 100644 --- a/cockatrice/src/game/player/player_graphics_item.h +++ b/cockatrice/src/game/player/player_graphics_item.h @@ -1,7 +1,7 @@ /** * @file player_graphics_item.h * @ingroup GameGraphicsPlayers - * @brief TODO: Document this. + * @brief Root QGraphicsObject container that owns and lays out all visual game elements for a single player. */ #ifndef COCKATRICE_PLAYER_GRAPHICS_ITEM_H @@ -11,6 +11,7 @@ #include +class CommandZone; class HandZone; class PileZone; class PlayerTarget; @@ -18,6 +19,26 @@ class StackZone; class TableZone; class ZoneViewZone; +/** + * The top-level graphics item representing a single player's entire board state in the game scene. + * + * PlayerGraphicsItem owns and manages every zone graphics item belonging to one player: + * the table (battlefield), hand, deck, graveyard, removed-from-game, sideboard, stack, + * and command/partner zones. It also owns the player avatar target widget, the background + * PlayerArea, and the hand card counter. + * + * Layout responsibilities include: + * - Positioning all zones relative to one another based on hand orientation + * (horizontal or vertical) and whether the view is mirrored (opponent's perspective). + * - Responding to scene width changes by resizing the table and hand zones proportionally. + * - Managing the 55-pixel left counter strip where life totals and other counters are displayed. + * - Showing or hiding command/partner zones when the server signals Commander format support. + * + * This item emits sizeChanged() whenever its bounding rect changes, which GameScene uses + * to reflow the positions of all players in the scene. + * + * @see PlayerArea, PlayerTarget, TableZone, HandZone, PileZone, StackZone, CommandZone + */ class PlayerGraphicsItem : public QGraphicsObject { Q_OBJECT @@ -99,10 +120,29 @@ public: { return handZoneGraphicsItem; } + [[nodiscard]] CommandZone *getCommandZoneGraphicsItem() const + { + return commandZoneGraphicsItem; + } + [[nodiscard]] CommandZone *getPartnerZoneGraphicsItem() const + { + return partnerZoneGraphicsItem; + } + [[nodiscard]] CommandZone *getCompanionZoneGraphicsItem() const + { + return companionZoneGraphicsItem; + } + [[nodiscard]] CommandZone *getBackgroundZoneGraphicsItem() const + { + return backgroundZoneGraphicsItem; + } public slots: void onPlayerActiveChanged(bool _active); void retranslateUi(); + void setCommandZonesVisible(bool visible); + void setCompanionZoneVisible(bool visible); + void setBackgroundZoneVisible(bool visible); signals: void sizeChanged(); @@ -119,13 +159,53 @@ private: TableZone *tableZoneGraphicsItem; StackZone *stackZoneGraphicsItem; HandZone *handZoneGraphicsItem; + CommandZone *commandZoneGraphicsItem; + CommandZone *partnerZoneGraphicsItem; + CommandZone *companionZoneGraphicsItem; + CommandZone *backgroundZoneGraphicsItem; QRectF bRect; bool mirrored; +private: + /** + * @brief Positions command zones and calculates stack zone offset. + * + * This helper method centralizes the command zone positioning logic that + * was previously duplicated across multiple layout branches. It positions + * the command zone at the given base position, positions the partner zone + * relative to it, and returns the Y offset where the stack zone should start. + * + * @param base The position for the command zone + * @param commandZonesVisible Whether command zones are enabled + * @return Y offset from base where stack zone should be positioned + */ + qreal positionCommandZones(const QPointF &base, bool commandZonesVisible); + +private: + /** + * @brief Calculates the total height of all visible command zones. + * + * Sums the current heights of the primary command zone plus any + * expanded partner, companion, and background zones. + * + * @return Total height in pixels of all visible command zones + */ + [[nodiscard]] qreal totalCommandZoneHeight() const; + private slots: void updateBoundingRect(); void rearrangeZones(); void rearrangeCounters(); + /** + * @brief Handles zone expansion, minimizing the zone above if space is needed. + * + * Uses sender() to identify which zone triggered the expansion. When a zone + * expands and there isn't enough vertical space, this slot calls tryMinimizeAbove() + * to minimize the zone above instead of the newly-opened zone. + * + * @param expanded True if the zone is expanding, false if collapsing + */ + void onZoneExpanded(bool expanded); }; #endif // COCKATRICE_PLAYER_GRAPHICS_ITEM_H