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
This commit is contained in:
DawnFire42 2026-02-25 13:38:03 -05:00
parent 1ae1d876b1
commit 437d2e4ed4
6 changed files with 655 additions and 44 deletions

View file

@ -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 <QMetaType>
#include <QPainter>
#include <QtConcurrent>
#include <libcockatrice/common/counter_ids.h>
#include <libcockatrice/protocol/pb/command_attach_card.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/event_create_arrow.pb.h>
@ -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);
}

View file

@ -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<HandZoneLogic *>(zones.value("hand"));
}
CommandZoneLogic *getCommandZone()
{
return qobject_cast<CommandZoneLogic *>(zones.value("command"));
}
CommandZoneLogic *getPartnerZone()
{
return qobject_cast<CommandZoneLogic *>(zones.value("partner"));
}
CommandZoneLogic *getCompanionZone()
{
return qobject_cast<CommandZoneLogic *>(zones.value("companion"));
}
CommandZoneLogic *getBackgroundZone()
{
return qobject_cast<CommandZoneLogic *>(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<int, ArrowItem *> arrows;
bool dialogSemaphore;
bool serverHasCommandZone;
bool serverHasCompanionZone;
bool serverHasBackgroundZone;
QList<CardItem *> cardsToDelete;
void updateZoneSupport(bool *flag, void (Player::*signal)(bool), bool found);
// void eventConnectionStateChanged(const Event_ConnectionStateChanged &event);
};

View file

@ -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 <QHash>
#include <QPointer>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/common/counter_ids.h>
#include <libcockatrice/protocol/pb/command_attach_card.pb.h>
#include <libcockatrice/protocol/pb/command_change_zone_properties.pb.h>
#include <libcockatrice/protocol/pb/command_create_token.pb.h>
#include <libcockatrice/protocol/pb/command_draw_cards.pb.h>
#include <libcockatrice/protocol/pb/command_flip_card.pb.h>
#include <libcockatrice/protocol/pb/command_game_say.pb.h>
#include <libcockatrice/protocol/pb/command_inc_counter.pb.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/protocol/pb/command_mulligan.pb.h>
#include <libcockatrice/protocol/pb/command_reveal_cards.pb.h>
@ -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<CardMenuActionType, QString> 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<CardItem> 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<const ::google::protobuf::Message *> commandList;
for (const auto &item : player->getGameScene()->selectedItems()) {
auto *card = static_cast<CardItem *>(item);
auto *card = qgraphicsitem_cast<CardItem *>(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<const ::google::protobuf::Message *> commandList;
for (const auto &item : player->getGameScene()->selectedItems()) {
auto *card = static_cast<CardItem *>(item);
auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(item);
if (!card->getPT().isEmpty()) {
auto *card = qgraphicsitem_cast<CardItem *>(item);
if (card && !card->getPT().isEmpty()) {
oldPT = card->getPT();
}
}
@ -1399,7 +1425,10 @@ void PlayerActions::actSetPT()
QList<const ::google::protobuf::Message *> commandList;
for (const auto &item : sel) {
auto *card = static_cast<CardItem *>(item);
auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(item);
if (!card->getAnnotation().isEmpty()) {
auto *card = qgraphicsitem_cast<CardItem *>(item);
if (card && !card->getAnnotation().isEmpty()) {
oldAnnotation = card->getAnnotation();
}
}
@ -1519,7 +1548,10 @@ void PlayerActions::actSetAnnotation()
QList<const ::google::protobuf::Message *> commandList;
for (const auto &item : sel) {
auto *card = static_cast<CardItem *>(item);
auto *card = qgraphicsitem_cast<CardItem *>(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<const ::google::protobuf::Message *> commandList;
for (QGraphicsItem *item : player->getGameScene()->selectedItems()) {
auto *card = static_cast<CardItem *>(item);
if (!card->getAttachedTo()) {
auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(item);
if (card->getCounters().value(counterId, 0) < MAX_COUNTERS_ON_CARD) {
auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(item);
if (card->getCounters().value(counterId, 0)) {
auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(player->getGameScene()->selectedItems().first());
oldValue = card->getCounters().value(counterId, 0);
if (auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(item);
auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *> selectedCards;
for (const auto &item : player->getGameScene()->selectedItems()) {
auto *card = static_cast<CardItem *>(item);
selectedCards.append(card);
if (auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *> selectedCards;
for (const auto &item : player->getGameScene()->selectedItems()) {
if (auto *card = qgraphicsitem_cast<CardItem *>(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<CardItem *>(item);
if (card && isUnwritableRevealZone(card->getZone())) {
card->getZone()->removeCard(card);
if (auto *card = qgraphicsitem_cast<CardItem *>(item)) {
if (isUnwritableRevealZone(card->getZone())) {
card->getZone()->removeCard(card);
}
}
}
}
@ -1776,7 +1852,14 @@ void PlayerActions::cardMenuAction()
QList<QGraphicsItem *> sel = player->getGameScene()->selectedItems();
QList<CardItem *> cardList;
while (!sel.isEmpty()) {
cardList.append(qgraphicsitem_cast<CardItem *>(sel.takeFirst()));
CardItem *card = qgraphicsitem_cast<CardItem *>(sel.takeFirst());
if (card) {
cardList.append(card);
}
}
if (cardList.isEmpty()) {
return;
}
QList<const ::google::protobuf::Message *> commandList;
@ -1863,7 +1946,8 @@ void PlayerActions::cardMenuAction()
idList.add_card()->set_card_id(i->getId());
}
switch (static_cast<CardMenuActionType>(a->data().toInt())) {
const auto actionType = static_cast<CardMenuActionType>(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<int>(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;
}

View file

@ -91,6 +91,7 @@ public slots:
void actPlay();
void actPlayFacedown();
void actPlayAndIncreaseTax();
void actHide();
void actMoveTopCardToPlay();

View file

@ -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 <libcockatrice/common/counter_ids.h>
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<int>(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();
}
}
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<CommandZone *>(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();
}

View file

@ -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 <QGraphicsObject>
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