feat(command-zone): add graphics implementation with integration tests

Implement CommandZone - the main Qt graphics class for rendering and
interacting with command zones.

This commit brings together all previous components:
- Uses CommandZoneState for visibility management
- Uses CommandZoneLogic for card data handling
- Uses ZoneToggleButton for visibility controls
- Uses CommanderTaxCounter for tax display
- Uses z_values.h for proper visual layering

Zone modifications for command zone support:
- SelectZone: add command zone to zone selection
- StackZone: support command zone in stack operations
- TableZone: command zone positioning integration
- ViewZoneWidget: command zone viewing support

Integration tests verify:
- Counter parenting behavior
- Zone state coordination
- Full graphics stack interaction
This commit is contained in:
DawnFire42 2026-02-25 13:35:09 -05:00
parent 78db49a9b3
commit 6e83d64622
12 changed files with 1570 additions and 8 deletions

View file

@ -0,0 +1,374 @@
#include "command_zone.h"
// @see CommandZoneState for the extracted visibility/height state machine
#include "../../client/settings/cache_settings.h"
#include "../../interface/theme_manager.h"
#include "../board/abstract_counter.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
#include "../player/player.h"
#include "../player/player_actions.h"
#include "../z_values.h"
#include "select_zone.h"
#include "zone_toggle_button.h"
#include <QDebug>
#include <QGraphicsRectItem>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <libcockatrice/common/counter_ids.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
CommandZone::CommandZone(CommandZoneLogic *_logic, int _zoneHeight, CommandZoneType _zoneType, QGraphicsItem *parent)
: SelectZone(_logic, parent), m_state(_zoneHeight, _zoneType), toggleButton(nullptr), cardClipContainer(nullptr)
{
connect(themeManager, &ThemeManager::themeChanged, this, &CommandZone::updateBg);
updateBg();
setCacheMode(DeviceCoordinateCache);
setFlag(QGraphicsItem::ItemClipsChildrenToShape, false);
cardClipContainer = new QGraphicsRectItem(this);
cardClipContainer->setFlag(QGraphicsItem::ItemClipsChildrenToShape, true);
cardClipContainer->setPen(Qt::NoPen);
cardClipContainer->setBrush(Qt::NoBrush);
cardClipContainer->setRect(boundingRect());
cardClipContainer->setZValue(ZValues::CARD_BASE);
disconnect(getLogic(), &CardZoneLogic::cardAdded, this, &CardZone::onCardAdded);
connect(getLogic(), &CardZoneLogic::cardAdded, this, [this](CardItem *card) {
if (cardClipContainer && card) {
card->setParentItem(cardClipContainer);
card->update();
}
});
if (!isPrimary()) {
toggleButton = new ZoneToggleButton(getZoneType(), this);
connect(toggleButton, &ZoneToggleButton::clicked, this, &CommandZone::toggleExpanded);
repositionToggleButton();
}
}
CommandZone::~CommandZone()
{
disconnect(getLogic(), &CardZoneLogic::cardAdded, this, nullptr);
}
void CommandZone::updateBg()
{
update();
}
QRectF CommandZone::boundingRect() const
{
return {0, 0, ZoneSizes::COMMAND_ZONE_WIDTH, currentHeight()};
}
qreal CommandZone::currentHeight() const
{
return m_state.currentHeight();
}
bool CommandZone::isPrimary() const
{
return m_state.isPrimary();
}
bool CommandZone::isMinimized() const
{
return m_state.isMinimized();
}
bool CommandZone::isExpanded() const
{
return m_state.isExpanded();
}
bool CommandZone::isCollapsed() const
{
return m_state.isCollapsed();
}
void CommandZone::toggleExpanded()
{
applyStateChange(m_state.tryToggleExpanded(shouldPreventCollapse()));
}
void CommandZone::toggleMinimized()
{
applyStateChange(m_state.tryToggleMinimized(), true);
}
void CommandZone::applyStateChange(const StateChangeResult &result, bool updateTogglePosition)
{
if (!result.geometryChanged) {
return;
}
prepareGeometryChange();
if (cardClipContainer) {
cardClipContainer->setRect(boundingRect());
}
if (toggleButton) {
toggleButton->setExpanded(m_state.isExpanded());
}
reorganizeCards();
if (updateTogglePosition) {
repositionToggleButton();
}
update();
if (result.shouldEmitMinimized) {
emit minimizedChanged(m_state.isMinimized());
}
if (result.shouldEmitExpanded) {
emit expandedChanged(m_state.isExpanded());
}
}
void CommandZone::paint(QPainter *painter,
[[maybe_unused]] const QStyleOptionGraphicsItem *option,
[[maybe_unused]] QWidget *widget)
{
if (isCollapsed()) {
return;
}
QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Command, getLogic()->getPlayer()->getZoneId());
QPointF scenePos = mapToScene(QPointF(0, 0));
painter->setBrushOrigin(-scenePos);
painter->fillRect(boundingRect(), brush);
}
void CommandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
CardZoneLogic *startZone,
const QPoint &dropPoint)
{
if (dragItems.isEmpty()) {
return;
}
if (startZone == nullptr || startZone->getPlayer() == nullptr) {
qCWarning(CommandZoneLog) << "handleDropEvent: Invalid startZone or player";
return;
}
// Primary and Partner zones hold exactly one card; Companion and Background support multiple
if (!supportsMultipleCards(getZoneType()) && !getLogic()->getCards().isEmpty()) {
qCDebug(CommandZoneLog) << "handleDropEvent: Single-card zone already occupied, ignoring drop";
return;
}
Command_MoveCard cmd;
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(getLogic()->getName().toStdString());
// Calculate insertion index from drop position for multi-card zones
int index = 0;
const auto &cards = getLogic()->getCards();
if (!cards.isEmpty()) {
const auto cardCount = static_cast<int>(cards.size());
const qreal cardHeight = cards.at(0)->boundingRect().height();
index = qRound(divideCardSpaceInZone(dropPoint.y(), cardCount, boundingRect().height(), cardHeight, true));
index = qBound(0, index, cardCount);
}
cmd.set_x(index);
cmd.set_y(0);
// Add all dragged cards to the move command
for (const CardDragItem *item : dragItems) {
if (item != nullptr) {
cmd.mutable_cards_to_move()->add_card()->set_card_id(item->getId());
}
}
getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd);
}
void CommandZone::reorganizeCards()
{
const auto &cards = getLogic()->getCards();
if (cardClipContainer) {
cardClipContainer->setRect(boundingRect());
}
// Hide all cards when collapsed
if (isCollapsed()) {
for (CardItem *card : cards) {
if (card != nullptr) {
card->setVisible(false);
}
}
update();
return;
}
if (cards.isEmpty()) {
update();
return;
}
const auto cardCount = static_cast<int>(cards.size());
const qreal totalWidth = boundingRect().width();
const qreal totalHeight = boundingRect().height();
const qreal cardWidth = cards.at(0)->boundingRect().width();
const qreal cardHeight = cards.at(0)->boundingRect().height();
// Zigzag horizontal positioning (StackZone pattern)
// Single card stays centered for visual consistency with Primary/Partner zones
const qreal xMargin = 5;
const qreal x1 = xMargin;
const qreal x2 = totalWidth - xMargin - cardWidth;
for (int i = 0; i < cardCount; i++) {
CardItem *card = cards.at(i);
if (card == nullptr) {
continue;
}
// Horizontal: center single card, zigzag for multiple
qreal x = (cardCount == 1) ? (totalWidth - cardWidth) / 2.0 : ((i % 2) ? x2 : x1);
// Vertical: use divideCardSpaceInZone for proper stacking with overlap compression.
// divideCardSpaceInZone can return negative Y when totalHeight < cardHeight.
// Clamp to 0 so card tops remain visible when clipped by cardClipContainer.
const qreal y = qMax(0.0, divideCardSpaceInZone(i, cardCount, totalHeight, cardHeight));
card->setVisible(true);
card->setPos(x, y);
card->setRealZValue(ZValues::CARD_BASE + i);
}
update();
}
void CommandZone::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
toggleMinimized();
event->accept();
} else {
SelectZone::mouseDoubleClickEvent(event);
}
}
void CommandZone::repositionToggleButton()
{
if (toggleButton) {
QRectF btnRect = toggleButton->boundingRect();
qreal btnX = (boundingRect().width() - btnRect.width()) / 2.0;
qreal btnY = 0;
toggleButton->setPos(btnX, btnY);
}
}
void CommandZone::setExpanded(bool _expanded)
{
applyStateChange(m_state.trySetExpanded(_expanded, shouldPreventCollapse()));
}
bool CommandZone::shouldPreventCollapse() const
{
if (isPrimary())
return false;
if (!getLogic()->getCards().isEmpty())
return true;
if (getZoneType() == CommandZoneType::Partner) {
Player *player = getLogic()->getPlayer();
if (player) {
AbstractCounter *partnerTax = player->getCounters().value(CounterIds::PartnerTax, nullptr);
if (partnerTax && partnerTax->getValue() > 0)
return true;
}
}
// Check Qt children (Partner has toggle button as child, and Partner is nested under Command)
for (QGraphicsItem *child : childItems()) {
if (auto *childZone = dynamic_cast<CommandZone *>(child)) {
if (childZone->shouldPreventCollapse())
return true;
}
}
// Check logical children (for sibling zones: Companion is logical child of Partner,
// Background is logical child of Companion)
for (CommandZone *childZone : logicalChildZones) {
if (childZone != nullptr && childZone->shouldPreventCollapse())
return true;
}
return false;
}
bool CommandZone::tryAutoMinimize()
{
// Check Qt children first (Partner is nested under Command)
for (QGraphicsItem *child : childItems()) {
if (auto *childZone = dynamic_cast<CommandZone *>(child)) {
if (childZone->tryAutoMinimize())
return true;
}
}
// Check logical children (sibling zones: Companion under Partner, Background under Companion)
for (CommandZone *childZone : logicalChildZones) {
if (childZone != nullptr && childZone->tryAutoMinimize())
return true;
}
if (isExpanded() && !isMinimized() && !isPrimary()) {
toggleMinimized();
return true;
}
return false;
}
CommandZone *CommandZone::getParentCommandZone() const
{
QGraphicsItem *parent = parentItem();
while (parent != nullptr) {
if (auto *parentZone = dynamic_cast<CommandZone *>(parent)) {
return parentZone;
}
parent = parent->parentItem();
}
return nullptr;
}
bool CommandZone::tryMinimizeAbove()
{
// First check logical parent (for sibling zones: Companion, Background)
CommandZone *parentZone = logicalParentZone;
// Fall back to Qt parent chain for Partner zone (still nested under Command)
if (parentZone == nullptr) {
parentZone = getParentCommandZone();
}
if (parentZone != nullptr && parentZone->isExpanded() && !parentZone->isMinimized()) {
parentZone->toggleMinimized();
return true;
}
// If parent can't minimize, try grandparent (recursion bounded by zone depth)
if (parentZone != nullptr) {
return parentZone->tryMinimizeAbove();
}
return false;
}
void CommandZone::setToggleButtonVisible(bool visible)
{
if (toggleButton) {
toggleButton->setVisible(visible);
}
}

View file

@ -0,0 +1,190 @@
/**
* @file command_zone.h
* @ingroup GameGraphicsZones
* @brief Graphics layer for the command zone, used for Commander format.
*/
#ifndef COCKATRICE_COMMAND_ZONE_H
#define COCKATRICE_COMMAND_ZONE_H
#include "command_zone_state.h"
#include "command_zone_types.h"
#include "logic/command_zone_logic.h"
#include "select_zone.h"
#include <QLoggingCategory>
inline Q_LOGGING_CATEGORY(CommandZoneLog, "command_zone");
class ZoneToggleButton;
/**
* @class CommandZone
* @brief Graphics layer for command zones in Commander format games.
*
* Supports primary (always visible) and collapsible (partner, companion, background)
* variants. Primary/Partner hold one card each; Companion/Background support multiple
* cards for edge-case mechanics. Single cards display centered; multiple cards use
* zigzag horizontal positioning with vertical stacking.
*
* @see CommandZoneState for visibility/height state machine
* @see supportsMultipleCards() in command_zone_types.h
*/
class CommandZone : public SelectZone
{
Q_OBJECT
private:
CommandZoneState m_state; ///< Pure state machine for visibility/height
ZoneToggleButton *toggleButton; ///< Only created for collapsible zones
QGraphicsRectItem *cardClipContainer; ///< Clips cards to zone bounds when minimized
/**
* @brief Logical parent for Qt-sibling zones (Companion, Background).
*
* INVARIANT: A zone has EITHER a Qt parent CommandZone (Partner) OR this
* logical parent, never both. tryMinimizeAbove() relies on this exclusivity.
*/
CommandZone *logicalParentZone = nullptr;
/**
* @brief Logical child zones for sibling architecture.
*
* Tracks zones that are logically children but Qt siblings. Used by
* shouldPreventCollapse() and tryAutoMinimize() to traverse the hierarchy.
*/
QList<CommandZone *> logicalChildZones;
public:
/**
* @brief Constructs a CommandZone graphics item.
* @param _logic Logic layer managing card data
* @param _zoneHeight Zone height in pixels
* @param _zoneType Type of command zone (Primary, Partner, Companion, Background)
* @param parent Parent graphics item
*/
CommandZone(CommandZoneLogic *_logic, int _zoneHeight, CommandZoneType _zoneType, QGraphicsItem *parent);
~CommandZone() override;
/**
* @brief Handles card drops, calculating insertion position from drop point.
* @param dragItems Cards being dragged
* @param startZone Source zone
* @param dropPoint Drop position in local coordinates
*/
void
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
[[nodiscard]] QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
void reorganizeCards() override;
/**
* @brief Sets expanded/collapsed state of a collapsible zone.
*
* When collapsed, background and cards are hidden but toggle button remains.
* When expanded, zone renders normally with centered cards.
* No-op for Primary zone.
*
* @param expanded True to show zone content, false to collapse
*/
void setExpanded(bool expanded);
void toggleMinimized();
[[nodiscard]] bool isMinimized() const;
[[nodiscard]] bool isExpanded() const;
[[nodiscard]] bool isCollapsed() const;
[[nodiscard]] bool isPrimary() const;
[[nodiscard]] CommandZoneType getZoneType() const
{
return m_state.getZoneType();
}
[[nodiscard]] qreal currentHeight() const;
/**
* @brief Sets the logical parent zone for sibling architecture.
* @param parent The logical parent CommandZone, or nullptr for nested zones
*/
void setLogicalParentZone(CommandZone *parent)
{
logicalParentZone = parent;
}
/**
* @brief Gets the logical parent zone.
* @return The logical parent, or nullptr if using Qt parent-child hierarchy
*/
[[nodiscard]] CommandZone *getLogicalParentZone() const
{
return logicalParentZone;
}
/**
* @brief Adds a logical child zone for sibling architecture.
* @param child The zone that is logically a child but a Qt sibling
*/
void addLogicalChildZone(CommandZone *child)
{
logicalChildZones.append(child);
}
/**
* @brief Attempts to auto-minimize this zone or a child zone (depth-first).
*
* Tries children first (depth-first, bottom-up) to minimize the
* deepest non-minimized zone. Used as a fallback safety-net in
* rearrangeZones() for non-expansion triggers (e.g., window resize).
*
* For user-initiated zone expansion, tryMinimizeAbove() is preferred
* as it minimizes the zone above instead of below.
*
* @return True if a zone was minimized, false if no minimization possible
* @see tryMinimizeAbove()
*/
bool tryAutoMinimize();
/**
* @brief Returns the nearest CommandZone ancestor in the graphics item hierarchy.
* @return Pointer to parent CommandZone, or nullptr if none exists.
*/
[[nodiscard]] CommandZone *getParentCommandZone() const;
/**
* @brief Attempts to minimize the zone directly above this one.
*
* Walks up the parent chain to find the first ancestor CommandZone that
* can be minimized (is expanded and not already minimized). The Primary
* zone can be minimized (reduced to 25% height) but cannot be collapsed.
*
* Recursion is bounded by zone hierarchy depth (max 4 zones).
*
* @return True if an ancestor zone was minimized, false if none could be.
* @see tryAutoMinimize()
*/
bool tryMinimizeAbove();
/**
* @brief Sets toggle button visibility.
* @param visible Whether toggle should be visible
*/
void setToggleButtonVisible(bool visible);
public slots:
void toggleExpanded();
signals:
void expandedChanged(bool isExpanded);
void minimizedChanged(bool isMinimized);
protected:
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
private slots:
void updateBg();
private:
void repositionToggleButton();
void applyStateChange(const StateChangeResult &result, bool updateTogglePosition = false);
[[nodiscard]] bool shouldPreventCollapse() const;
};
#endif // COCKATRICE_COMMAND_ZONE_H

View file

@ -9,6 +9,15 @@
qreal divideCardSpaceInZone(qreal index, int cardCount, qreal totalHeight, qreal cardHeight, bool reverse)
{
// Handle single card case - return centered position or index 0
if (cardCount <= 1) {
if (reverse) {
return 0; // Single card maps to index 0
} else {
return (totalHeight - cardHeight) / 2.0; // Center the single card
}
}
qreal cardMinOverlap = cardHeight * SettingsCache::instance().getStackCardOverlapPercent() / 100;
qreal desiredHeight = cardHeight * cardCount - cardMinOverlap * (cardCount - 1);
qreal y;

View file

@ -1,7 +1,7 @@
/**
* @file select_zone.h
* @ingroup GameGraphicsZones
* @brief TODO: Document this.
* @brief Base class for zones with directly clickable, laid-out cards.
*/
#ifndef SELECTZONE_H
@ -12,7 +12,16 @@
#include <QSet>
/**
* A CardZone where the cards are laid out, with each card directly interactable by clicking.
* @class SelectZone
* @brief Base class for zones where cards are laid out and directly clickable.
*
* SelectZone provides mouse interaction handling for zones that display cards
* in a visible layout (as opposed to hidden zones like the deck). It supports
* click-to-select and rectangle selection via click-and-drag.
*
* Subclasses include TableZone, HandZone, and CommandZone.
*
* @see CardZone
*/
class SelectZone : public CardZone
{

View file

@ -33,6 +33,11 @@ QRectF StackZone::boundingRect() const
void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId());
// Set brush origin to compensate for zone position, keeping texture fixed in scene coordinates
QPointF scenePos = pos();
painter->setBrushOrigin(-scenePos);
painter->fillRect(boundingRect(), brush);
}
@ -106,3 +111,11 @@ void StackZone::reorganizeCards()
}
update();
}
void StackZone::setHeight(qreal newHeight)
{
prepareGeometryChange();
zoneHeight = newHeight;
reorganizeCards();
update();
}

View file

@ -25,6 +25,7 @@ public:
QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
void reorganizeCards() override;
void setHeight(qreal newHeight);
};
#endif

View file

@ -7,6 +7,7 @@
#include "../board/card_item.h"
#include "../player/player.h"
#include "../player/player_actions.h"
#include "../z_value_layer_manager.h"
#include "logic/table_zone_logic.h"
#include <QGraphicsScene>
@ -167,7 +168,9 @@ void TableZone::reorganizeCards()
actualY += 15;
getLogic()->getCards()[i]->setPos(actualX, actualY);
getLogic()->getCards()[i]->setRealZValue((actualY + CARD_HEIGHT) * 100000 + (actualX + 1) * 100);
qreal cardZValue = (actualY + CARD_HEIGHT) * 100000 + (actualX + 1) * 100;
Q_ASSERT(ZValueLayerManager::isValidCardZValue(cardZValue));
getLogic()->getCards()[i]->setRealZValue(cardZValue);
QListIterator<CardItem *> attachedCardIterator(getLogic()->getCards()[i]->getAttachedCards());
int j = 0;
@ -177,7 +180,9 @@ void TableZone::reorganizeCards()
qreal childX = actualX - j * STACKED_CARD_OFFSET_X;
qreal childY = y + 5;
attachedCard->setPos(childX, childY);
attachedCard->setRealZValue((childY + CARD_HEIGHT) * 100000 + (childX + 1) * 100);
qreal attachedZValue = (childY + CARD_HEIGHT) * 100000 + (childX + 1) * 100;
Q_ASSERT(ZValueLayerManager::isValidCardZValue(attachedZValue));
attachedCard->setRealZValue(attachedZValue);
}
}

View file

@ -7,6 +7,7 @@
#include "../game_scene.h"
#include "../player/player.h"
#include "../player/player_actions.h"
#include "../z_values.h"
#include "view_zone.h"
#include <QCheckBox>
@ -47,7 +48,7 @@ ZoneViewWidget::ZoneViewWidget(Player *_player,
{
setAcceptHoverEvents(true);
setAttribute(Qt::WA_DeleteOnClose);
setZValue(2000000006);
setZValue(ZValues::ZONE_VIEW_WIDGET);
setFlag(ItemIgnoresTransformations);
QGraphicsLinearLayout *vbox = new QGraphicsLinearLayout(Qt::Vertical);
@ -71,7 +72,7 @@ ZoneViewWidget::ZoneViewWidget(Player *_player,
QGraphicsProxyWidget *searchEditProxy = new QGraphicsProxyWidget;
searchEditProxy->setWidget(&searchEdit);
searchEditProxy->setZValue(2000000007);
searchEditProxy->setZValue(ZValues::DRAG_ITEM);
vbox->addItem(searchEditProxy);
// top row
@ -80,13 +81,13 @@ ZoneViewWidget::ZoneViewWidget(Player *_player,
// groupBy options
QGraphicsProxyWidget *groupBySelectorProxy = new QGraphicsProxyWidget;
groupBySelectorProxy->setWidget(&groupBySelector);
groupBySelectorProxy->setZValue(2000000008);
groupBySelectorProxy->setZValue(ZValues::TOP_UI);
hTopRow->addItem(groupBySelectorProxy);
// sortBy options
QGraphicsProxyWidget *sortBySelectorProxy = new QGraphicsProxyWidget;
sortBySelectorProxy->setWidget(&sortBySelector);
sortBySelectorProxy->setZValue(2000000007);
sortBySelectorProxy->setZValue(ZValues::DRAG_ITEM);
hTopRow->addItem(sortBySelectorProxy);
vbox->addItem(hTopRow);