diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a5f9f6747..24c6263fe 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -240,6 +240,8 @@ set(cockatrice_SOURCES src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp + src/interface/widgets/playmat/playmat_preview_widget.cpp + src/interface/widgets/playmat/playmat_settings_dialog.cpp src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp src/interface/widgets/replay/replay_manager.cpp diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 95460011f..8e8adab2c 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -285,6 +285,10 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event emit playerJoined(prop); } player->processPlayerInfo(playerInfo); + // Extract playmat from player properties for opponent display + if (prop.has_playmat_card_name()) { + player->setPlaymatFromProperties(prop); + } if (player->getPlayerInfo()->getLocal()) { emit localPlayerDeckSelected(player, playerId, playerInfo); } else { @@ -351,6 +355,11 @@ void GameEventHandler::eventPlayerPropertiesChanged(const Event_PlayerProperties const ServerInfo_PlayerProperties &prop = event.player_properties(); emit playerPropertiesChanged(prop, eventPlayerId); + // Update playmat from player properties + if (prop.has_playmat_card_name()) { + player->setPlaymatFromProperties(prop); + } + const auto contextType = static_cast(getPbExtension(context)); switch (contextType) { case GameEventContext::READY_START: { diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 485e2fc5c..eef550133 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -250,6 +250,23 @@ void PlayerLogic::setDeck(const DeckList &_deck) emit deckChanged(); } +void PlayerLogic::setPlaymatFromProperties(const ServerInfo_PlayerProperties &props) +{ + if (props.has_playmat_card_name() && !props.playmat_card_name().empty()) { + remotePlaymatCard = {QString::fromStdString(props.playmat_card_name()), + QString::fromStdString(props.playmat_card_provider_id())}; + remotePlaymatParams = { + qBound(0.0, props.playmat_margin_pct_l(), 0.95), qBound(0.0, props.playmat_margin_pct_r(), 0.95), + qBound(0.0, props.playmat_vertical_offset(), 1.0), qBound(0.1, props.playmat_zoom(), 4.0)}; + hasRemotePlaymat = true; + } else { + remotePlaymatCard = CardRef{}; + remotePlaymatParams = PlaymatParams{}; + hasRemotePlaymat = false; + } + emit playmatChanged(); +} + CounterState *PlayerLogic::addCounter(const ServerInfo_Counter &counter) { return addCounter(counter.id(), QString::fromStdString(counter.name()), diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index a89cb6eed..6923b3afe 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -17,6 +17,7 @@ #include "../zones/table_zone_logic.h" #include "player_event_handler.h" #include "player_info.h" +#include "player_manager.h" #include #include @@ -72,6 +73,8 @@ signals: const QList &cardList, bool withWritePermission); void deckChanged(); + /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ + void playmatChanged(); void newCardAdded(AbstractCardItem *card); void requestCardMenuUpdate(const CardItem *card); void counterAdded(CounterState *state); @@ -226,6 +229,20 @@ public: void setZoneId(int _zoneId); + void setPlaymatFromProperties(const ServerInfo_PlayerProperties &props); + const CardRef &getRemotePlaymatCard() const + { + return remotePlaymatCard; + } + const PlaymatParams &getRemotePlaymatParams() const + { + return remotePlaymatParams; + } + bool getHasRemotePlaymat() const + { + return hasRemotePlaymat; + } + private: AbstractGame *game; PlayerInfo *playerInfo; @@ -243,6 +260,11 @@ private: bool dialogSemaphore; QList cardsToDelete; + + // Playmat from player properties (for opponent display) + CardRef remotePlaymatCard; + PlaymatParams remotePlaymatParams; + bool hasRemotePlaymat = false; }; class AnnotationDialog : public QInputDialog diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 2831f3393..7a88acdff 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,6 +1,9 @@ #include "player_graphics_item.h" #include "../../game/player/player_actions.h" +#include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/widgets/cards/art_crop_attribution.h" +#include "../../interface/widgets/playmat/playmat_utils.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../board/abstract_card_item.h" #include "../board/counter_general.h" @@ -13,6 +16,8 @@ #include "player_dialogs.h" #include +#include +#include #include PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) @@ -28,6 +33,10 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(player, &PlayerLogic::counterAdded, this, &PlayerGraphicsItem::onCounterAdded); connect(player, &PlayerLogic::counterRemoved, this, &PlayerGraphicsItem::onCounterRemoved); + connect(player, &PlayerLogic::deckChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(player, &PlayerLogic::playmatChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatVisibilityChanged, this, + [this](int) { updatePlaymat(); }); playerMenu = new PlayerMenu(this); @@ -67,6 +76,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); + connect(this, &PlayerGraphicsItem::playmatChanged, tableZoneGraphicsItem, &TableZone::onPlaymatChanged); + connect(this, &PlayerGraphicsItem::playmatChanged, stackZoneGraphicsItem, &StackZone::onPlaymatChanged); + updateBoundingRect(); rearrangeZones(); @@ -155,10 +167,36 @@ qreal PlayerGraphicsItem::getMinimumWidth() const return result; } -void PlayerGraphicsItem::paint(QPainter * /*painter*/, - const QStyleOptionGraphicsItem * /*option*/, - QWidget * /*widget*/) +void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { + if (!hasPlaymat || playmatPixmap.isNull()) { + return; + } + + // Calculate the combined bounding rect of stack + table zones + QPointF stackPos = stackZoneGraphicsItem->pos(); + QPointF tablePos = tableZoneGraphicsItem->pos(); + QSizeF stackSize = stackZoneGraphicsItem->boundingRect().size(); + QSizeF tableSize = tableZoneGraphicsItem->boundingRect().size(); + + // Combined area: from stack left edge to table right edge + double combinedLeft = qMin(stackPos.x(), tablePos.x()); + double combinedTop = qMin(stackPos.y(), tablePos.y()); + double combinedRight = qMax(stackPos.x() + stackSize.width(), tablePos.x() + tableSize.width()); + double combinedBottom = qMax(stackPos.y() + stackSize.height(), tablePos.y() + tableSize.height()); + + QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop); + + const QRectF srcRect = computeArtSourceRect(playmatPixmap.size(), playmatParams); + const QRectF dstRect = coverFitRect(combinedArea, srcRect.size()); + + painter->setClipRect(combinedArea); + painter->drawPixmap(dstRect, playmatPixmap, srcRect); + painter->setClipping(false); + + if (!playmatAttribution.isEmpty()) { + paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } } void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) @@ -303,3 +341,80 @@ void PlayerGraphicsItem::updateBoundingRect() emit sizeChanged(); } + +void PlayerGraphicsItem::updatePlaymat() +{ + int visibility = SettingsCache::instance().userInterface().getPlaymatVisibility(); + + // "Don't use playmats" — never show + if (visibility == 0) { + clearPlaymat(); + return; + } + + CardRef playmatCard; + PlaymatParams params; + + // Check local deck first (for local player) + const DeckList &deck = player->getDeck(); + if (!deck.getPlaymatCard().isEmpty()) { + playmatCard = deck.getPlaymatCard(); + params = deck.getPlaymatParams(); + } else if (visibility == 2 && player->getHasRemotePlaymat()) { + // "Show all" — also show remote playmats from opponents + playmatCard = player->getRemotePlaymatCard(); + params = player->getRemotePlaymatParams(); + } + + if (playmatCard.isEmpty()) { + clearPlaymat(); + return; + } + + playmatParams = params; + + ExactCard card = CardDatabaseManager::query()->getCard(playmatCard); + if (!card) { + clearPlaymat(); + return; + } + + playmatAttribution = buildArtAttribution(card); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + disconnect(playmatPixmapConnection); + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + playmatPixmapConnection = + connect(cardInfo, &CardInfo::pixmapUpdated, this, &PlayerGraphicsItem::onPlaymatPixmapReady); + } + return; + } + + if (!hasPlaymat) { + hasPlaymat = true; + emit playmatChanged(true); + } + playmatPixmap = fullRes; + update(); +} + +void PlayerGraphicsItem::clearPlaymat() +{ + disconnect(playmatPixmapConnection); + playmatAttribution.clear(); + if (hasPlaymat) { + hasPlaymat = false; + playmatPixmap = QPixmap(); + emit playmatChanged(false); + update(); + } +} + +void PlayerGraphicsItem::onPlaymatPixmapReady() +{ + updatePlaymat(); +} diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.h b/cockatrice/src/game_graphics/player/player_graphics_item.h index d02234ded..55e1d1cf1 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.h +++ b/cockatrice/src/game_graphics/player/player_graphics_item.h @@ -11,6 +11,7 @@ #include "../game_scene.h" #include +#include class HandZone; class PileZone; @@ -126,6 +127,7 @@ signals: void playerCountChanged(); void mirroredChanged(bool isMirrored); void cardInfoRequested(const CardRef &cardRef); + void playmatChanged(bool hasPlaymat); private: PlayerLogic *player; @@ -146,9 +148,18 @@ private: bool mirrored; bool handVisible = false; + QPixmap playmatPixmap; + PlaymatParams playmatParams; + QString playmatAttribution; + bool hasPlaymat = false; + QMetaObject::Connection playmatPixmapConnection; + private slots: void updateBoundingRect(); void rearrangeZones(); + void clearPlaymat(); + void updatePlaymat(); + void onPlaymatPixmapReady(); }; #endif // COCKATRICE_PLAYER_GRAPHICS_ITEM_H diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index 184f96d62..812271042 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -31,8 +31,19 @@ QRectF StackZone::boundingRect() const void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish stack zone from table zone (slightly darker) + painter->fillRect(boundingRect(), QColor(0, 0, 0, 80)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } +} + +void StackZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + update(); } void StackZone::handleDropEvent(const QList &dragItems, diff --git a/cockatrice/src/game_graphics/zones/stack_zone.h b/cockatrice/src/game_graphics/zones/stack_zone.h index 147c3e2fc..96b3f96a6 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.h +++ b/cockatrice/src/game_graphics/zones/stack_zone.h @@ -15,9 +15,13 @@ class StackZone : public SelectZone Q_OBJECT private: qreal zoneHeight; + bool playmatActive = false; private slots: void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public: StackZone(StackZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent); /** @brief Resizes the stack zone height, e.g. when sharing vertical space with the command zone. */ diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 306e2927e..aba3dd1bf 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -92,14 +92,19 @@ bool TableZone::isInverted() const void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish table zone from stack zone + painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } if (active) { paintZoneOutline(painter); } else { // inactive player gets a darker table zone with a semi transparent black mask - // this means if the user provides a custom background it will fade + // this means if the user provides a custom background or playmat it will fade painter->fillRect(boundingRect(), FADE_MASK); } @@ -113,6 +118,12 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti paintLandDivider(painter); } +void TableZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + update(); +} + /** Render a soft outline around the edge of the TableZone. diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 1836c96ff..dbb2ed28d 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -86,6 +86,7 @@ private: */ bool active = false; bool mirrored = false; + bool playmatActive = false; [[nodiscard]] bool isInverted() const; @@ -95,6 +96,9 @@ private slots: */ void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public slots: /** Reorganizes CardItems in the TableZone diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index fc53b296f..a398e9086 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../playmat/playmat_settings_dialog.h" #include "../settings_page/user_interface_settings_page.h" #include "../tabs/api/commander_spellbook/commander_bracket_widget.h" #include "deck_list_style_proxy.h" @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -228,10 +230,18 @@ void DeckEditorDeckDockWidget::createDeckDock() upperLayout->addWidget(bannerCardLabel, 4, 0); upperLayout->addWidget(bannerCardComboBox, 4, 1); - upperLayout->addWidget(deckTagsDisplayWidget, 5, 1); + playmatLabel = new QLabel(); + playmatLabel->setObjectName("playmatLabel"); + playmatLabel->setText(tr("Playmat")); + playmatSettingsButton = new QPushButton(tr("Edit Playmat...")); + connect(playmatSettingsButton, &QPushButton::clicked, this, &DeckEditorDeckDockWidget::openPlaymatSettings); + upperLayout->addWidget(playmatLabel, 5, 0); + upperLayout->addWidget(playmatSettingsButton, 5, 1); - upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0); - upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1); + upperLayout->addWidget(deckTagsDisplayWidget, 6, 1); + + upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0); + upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1); hashLabel1 = new QLabel(); hashLabel1->setObjectName("hashLabel1"); @@ -440,6 +450,38 @@ void DeckEditorDeckDockWidget::writeBannerCard(int index) deckStateManager->setBannerCard(bannerCard); } +void DeckEditorDeckDockWidget::openPlaymatSettings() +{ + CardRef currentCard = deckStateManager->getMetadata().playmatCard; + PlaymatParams currentParams = deckStateManager->getMetadata().playmatParams; + + PlaymatSettingsDialog dialog(currentCard, currentParams, this); + if (dialog.exec() == QDialog::Accepted) { + CardRef newCard = dialog.card(); + PlaymatParams newParams = dialog.params(); + + if (newCard.isEmpty()) { + // User clicked "Remove Playmat" + deckStateManager->setPlaymatCard(CardRef{}); + deckStateManager->setPlaymatParams(PlaymatParams{}); + } else { + deckStateManager->setPlaymatCard(newCard); + deckStateManager->setPlaymatParams(newParams); + } + updatePlaymatLabel(); + } +} + +void DeckEditorDeckDockWidget::updatePlaymatLabel() +{ + CardRef playmat = deckStateManager->getMetadata().playmatCard; + if (playmat.isEmpty()) { + playmatSettingsButton->setText(tr("Edit Playmat...")); + } else { + playmatSettingsButton->setText(tr("Edit Playmat (%1)").arg(playmat.name)); + } +} + void DeckEditorDeckDockWidget::applyActiveGroupCriteria() { getModel()->setActiveGroupCriteria( @@ -497,6 +539,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() syncBannerCardComboBoxSelectionWithDeck(); updateBannerCardComboBox(); bannerCardComboBox->blockSignals(false); + updatePlaymatLabel(); updateHash(); formatComboBox->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 540199f0d..a55056bda 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -20,6 +20,7 @@ #include #include #include +#include class CommanderBracketWidget; class DeckListModel; @@ -33,6 +34,8 @@ public: DeckListStyleProxy *proxy; QTreeView *deckView; QComboBox *bannerCardComboBox; + QLabel *playmatLabel; + QPushButton *playmatSettingsButton; void createDeckDock(); ExactCard getCurrentCard(); void retranslateUi(); @@ -102,6 +105,8 @@ private slots: void writeName(); void writeComments(); void writeBannerCard(int); + void openPlaymatSettings(); + void updatePlaymatLabel(); void applyActiveGroupCriteria(); void setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus); void updateHash(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index f8fb450ce..39636d8e2 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -142,6 +142,32 @@ void DeckStateManager::setBannerCard(const CardRef &bannerCard) doMetadataModified(); } +void DeckStateManager::setPlaymatCard(const CardRef &playmatCard) +{ + CardRef previous = deckList->getPlaymatCard(); + if (previous == playmatCard) { + return; + } + + requestHistorySave(tr("Set playmat card to %1 (%2)").arg(playmatCard.name).arg(playmatCard.providerId)); + deckList->setPlaymatCard(playmatCard); + + doMetadataModified(); +} + +void DeckStateManager::setPlaymatParams(const PlaymatParams &playmatParams) +{ + PlaymatParams previous = deckList->getPlaymatParams(); + if (previous == playmatParams) { + return; + } + + requestHistorySave(tr("Updated playmat positioning")); + deckList->setPlaymatParams(playmatParams); + + doMetadataModified(); +} + void DeckStateManager::setTags(const QStringList &tags) { QStringList previous = deckList->getTags(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 6fce6be57..5fd72b780 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -171,6 +171,8 @@ public: void setName(const QString &name); void setComments(const QString &comments); void setBannerCard(const CardRef &bannerCard); + void setPlaymatCard(const CardRef &playmatCard); + void setPlaymatParams(const PlaymatParams &playmatParams); void setTags(const QStringList &tags); void setFormat(const QString &format); ///@} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp new file mode 100644 index 000000000..dc3afc2cd --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp @@ -0,0 +1,99 @@ +#include "playmat_preview_widget.h" + +#include "../cards/art_crop_attribution.h" +#include "playmat_utils.h" + +#include +#include +#include + +PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent) +{ + setMinimumSize(400, 120); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap) +{ + sourcePixmap = pixmap; + update(); +} + +void PlaymatPreviewWidget::setParams(const PlaymatParams &p) +{ + params = p; + update(); +} + +void PlaymatPreviewWidget::setAttribution(const QString &attribution) +{ + attributionText = attribution; + update(); +} + +void PlaymatPreviewWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const QColor accentColor(100, 116, 139); + + // Background + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, accentColor.darker(320)); + bg.setColorAt(1, QColor(18, 22, 30)); + painter.setPen(Qt::NoPen); + painter.setBrush(bg); + painter.drawRoundedRect(cardRect, 6, 6); + painter.setBrush(accentColor); + painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + + if (sourcePixmap.isNull()) { + painter.setPen(QColor(150, 150, 150)); + painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + return; + } + + // Draw the playmat art using the same logic as PlayerGraphicsItem + // The preview area represents the combined stack+table play area + // Stack is ~20% width on the left, table is ~80% on the right + const QRectF playArea = cardRect.adjusted(6, 4, -4, -4); + + const QRectF srcRect = computeArtSourceRect(sourcePixmap.size(), params); + const QRectF dstRect = coverFitRect(playArea, srcRect.size()); + + painter.setClipRect(playArea.toRect()); + painter.drawPixmap(dstRect, sourcePixmap, srcRect); + painter.setClipping(false); + + // Draw zone divider: stack is roughly the left portion + const double stackWidthRatio = 0.18; // Stack is about 18% of total play area + const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio; + + // Subtle semi-transparent overlays to distinguish zones + // Stack zone overlay (slightly darker) + QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height()); + painter.fillRect(stackOverlay, QColor(0, 0, 0, 40)); + + // Table zone overlay (very subtle) + QRectF tableOverlay(stackDividerX, playArea.top(), playArea.width() * (1.0 - stackWidthRatio), playArea.height()); + painter.fillRect(tableOverlay, QColor(0, 0, 0, 20)); + + // Zone divider line + painter.setPen(QPen(QColor(255, 255, 255, 50), 1)); + painter.drawLine(QPointF(stackDividerX, playArea.top()), QPointF(stackDividerX, playArea.bottom())); + + // Land divider line (about 60% down the table area) + const double landDividerY = playArea.top() + playArea.height() * 0.65; + painter.setPen(QPen(QColor(255, 255, 255, 30), 1)); + painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY)); + + // Border around entire play area + painter.setPen(QPen(QColor(70, 80, 95, 120), 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3); + + paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h new file mode 100644 index 000000000..55771192f --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h @@ -0,0 +1,35 @@ +#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H +#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H + +#include +#include +#include + +/** + * @brief Preview widget that shows how a playmat card art will appear + * across the combined table + stack play area. + * + * Renders a miniature mockup with the card art applied using the + * given PlaymatParams, including faint zone divider lines. + */ +class PlaymatPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit PlaymatPreviewWidget(QWidget *parent = nullptr); + + void setPixmap(const QPixmap &pixmap); + void setParams(const PlaymatParams ¶ms); + void setAttribution(const QString &attribution); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + QPixmap sourcePixmap; + PlaymatParams params; + QString attributionText; +}; + +#endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp new file mode 100644 index 000000000..e040173ac --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -0,0 +1,256 @@ +#include "playmat_settings_dialog.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/art_crop_attribution.h" +#include "../utility/completer_utils.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "playmat_preview_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard, + const PlaymatParams &initialParams, + QWidget *parent) + : QDialog(parent), currentCard(initialCard), currentParams(initialParams) +{ + setWindowTitle(tr("Playmat Settings")); + setMinimumWidth(500); + setupUi(); + + // Seed UI from initial values + if (!initialCard.name.isEmpty()) { + searchBar->setText(initialCard.name); + onCardNameChanged(initialCard.name); + + // onCardNameChanged leaves the printing combo on the first printing in + // the database, which would silently change the deck's stored playmat + // card on accept. Restore the stored printing when it resolves locally. + const int storedPrintingIndex = providerComboBox->findData(initialCard.providerId); + if (storedPrintingIndex != -1) { + providerComboBox->setCurrentIndex(storedPrintingIndex); + } else { + // Stored printing not in the local database: keep it rather than + // silently substituting the first printing. + currentCard.providerId = initialCard.providerId; + reloadPreview(); + } + } + marginLSpin->setValue(initialParams.marginPctL); + marginRSpin->setValue(initialParams.marginPctR); + verticalOffsetSpin->setValue(initialParams.verticalOffset); + zoomSpin->setValue(initialParams.zoom); +} + +CardRef PlaymatSettingsDialog::card() const +{ + return currentCard; +} + +PlaymatParams PlaymatSettingsDialog::params() const +{ + return currentParams; +} + +QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, double value, double step) +{ + auto *spin = new QDoubleSpinBox; + spin->setRange(min, max); + spin->setSingleStep(step); + spin->setDecimals(3); + spin->setValue(value); + return spin; +} + +void PlaymatSettingsDialog::initializeSearchBar() +{ + searchBar = new QLineEdit; + searchBar->setPlaceholderText(tr("Type a card name...")); + + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); + cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + searchModel = cardSetup.searchModel; + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; + searchBar->setCompleter(completer); + + connectCardCompleterSearch(searchBar, cardSetup); + + connect(completer, static_cast(&QCompleter::activated), this, + [this](const QString &completion) { + if (searchBar->text() != completion) { + searchBar->setText(completion); + searchBar->setCursorPosition(searchBar->text().length()); + } + onCardNameChanged(completion); + }); + + connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); }); +} + +void PlaymatSettingsDialog::setupUi() +{ + initializeSearchBar(); + + providerComboBox = new QComboBox; + connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() { + currentCard.providerId = providerComboBox->currentData().toString(); + reloadPreview(); + onParamChanged(); + }); + + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); + marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); + verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); + zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); + + auto *form = new QFormLayout; + form->addRow(tr("Card name:"), searchBar); + form->addRow(tr("Printing:"), providerComboBox); + form->addRow(tr("Left margin (%):"), marginLSpin); + form->addRow(tr("Right margin (%):"), marginRSpin); + form->addRow(tr("Vertical offset:"), verticalOffsetSpin); + form->addRow(tr("Zoom:"), zoomSpin); + + auto *controlsGroup = new QGroupBox(tr("Parameters")); + controlsGroup->setLayout(form); + + preview = new PlaymatPreviewWidget; + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + auto *previewGroup = new QGroupBox(tr("Preview")); + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + auto *removeBtn = new QPushButton(tr("Remove Playmat")); + buttons->addButton(removeBtn, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeBtn, &QPushButton::clicked, this, [this]() { + currentCard = CardRef{}; // empty signals removal + accept(); + }); + + auto *root = new QVBoxLayout; + root->addWidget(controlsGroup); + root->addWidget(previewGroup); + root->addWidget(buttons); + setLayout(root); + + connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); +} + +void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } +} + +void PlaymatSettingsDialog::onCardNameChanged(const QString &name) +{ + if (name.isEmpty()) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + const ExactCard card = CardDatabaseManager::query()->getCard({name}); + if (!card) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + providerComboBox->clear(); + return; + } + + currentCard.name = name; + + populateProviderCombo(name); + + if (providerComboBox->count() == 0) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + currentCard.providerId.clear(); + return; + } + + currentCard.providerId = providerComboBox->currentData().toString(); + reloadPreview(); +} + +void PlaymatSettingsDialog::reloadPreview() +{ + if (currentCard.name.isEmpty()) { + return; + } + + ExactCard card = CardDatabaseManager::query()->getCard({currentCard.name, currentCard.providerId}); + if (!card) { + return; + } + + disconnect(pixmapUpdatedConnection); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); }); + } + return; + } + + currentPixmap = fullRes; + preview->setPixmap(currentPixmap); + preview->setParams(currentParams); + preview->setAttribution(buildArtAttribution(card)); +} + +void PlaymatSettingsDialog::onParamChanged() +{ + currentParams.marginPctL = marginLSpin->value(); + currentParams.marginPctR = marginRSpin->value(); + currentParams.verticalOffset = verticalOffsetSpin->value(); + currentParams.zoom = zoomSpin->value(); + preview->setParams(currentParams); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h new file mode 100644 index 000000000..5a9ea43e4 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h @@ -0,0 +1,71 @@ +#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H +#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H + +#include +#include +#include + +class QComboBox; +class QCompleter; +class QLineEdit; +class QDoubleSpinBox; +class CardDatabaseModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class CardCompleterProxyModel; +class PlaymatPreviewWidget; + +/** + * @brief Dialog for configuring the playmat card art for a deck. + * + * Allows the user to select a card from the database and adjust + * positioning parameters (margins, zoom, vertical offset) for how + * the card art appears as a playmat background across the + * combined table + stack play area. + */ +class PlaymatSettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PlaymatSettingsDialog(const CardRef &initialCard = {}, + const PlaymatParams &initialParams = {}, + QWidget *parent = nullptr); + + CardRef card() const; + PlaymatParams params() const; + +private slots: + void onCardNameChanged(const QString &name); + void reloadPreview(); + void onParamChanged(); + +private: + void setupUi(); + void populateProviderCombo(const QString &cardName); + void initializeSearchBar(); + QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); + + QLineEdit *searchBar; + QCompleter *completer; + CardDatabaseModel *cardDatabaseModel; + CardDatabaseDisplayModel *cardDatabaseDisplayModel; + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + + QComboBox *providerComboBox; + + QMetaObject::Connection pixmapUpdatedConnection; + + QDoubleSpinBox *marginLSpin; + QDoubleSpinBox *marginRSpin; + QDoubleSpinBox *verticalOffsetSpin; + QDoubleSpinBox *zoomSpin; + PlaymatPreviewWidget *preview; + + QPixmap currentPixmap; + CardRef currentCard; + PlaymatParams currentParams; +}; + +#endif // COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_utils.h b/cockatrice/src/interface/widgets/playmat/playmat_utils.h new file mode 100644 index 000000000..9ab8190b3 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_utils.h @@ -0,0 +1,69 @@ +#ifndef COCKATRICE_PLAYMAT_UTILS_H +#define COCKATRICE_PLAYMAT_UTILS_H + +#include +#include +#include +#include + +/** + * @brief Computes the source region of the full-resolution card image to use as a playmat. + * + * Parameters are relative to the full card image: horizontal margins trim the card + * borders, the vertical offset positions a square viewing window, and zoom scales + * into that window. The result is clamped to the card image bounds. + * + * @param fullCardSize Size of the full card image. + * @param params Positioning parameters. + * @return Source rectangle in full-card image pixel coordinates. + */ +inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal srcW = fullCardSize.width(); + const qreal srcH = fullCardSize.height(); + + const qreal marginL = params.marginPctL * srcW; + const qreal marginR = params.marginPctR * srcW; + // Guard against margins summing to >= 1 (both are individually in range), + // which would otherwise make the viewing window negative or zero. + const qreal visibleW = qMax(0.0, srcW - marginL - marginR); + const qreal visibleH = visibleW; // square viewing window, keeps art unskewed + + const qreal vCenter = params.verticalOffset * srcH; + qreal srcY = vCenter - visibleH / 2.0; + srcY = qBound(0.0, srcY, srcH - visibleH); + + // Guard the zoom divisor; everything that produces params clamps zoom to + // [0.1, 4.0] already, this keeps the render path self-contained. + const qreal zoom = qBound(0.1, params.zoom, 4.0); + const qreal zoomedW = visibleW / zoom; + const qreal zoomedH = visibleH / zoom; + const qreal zoomedX = marginL + (visibleW - zoomedW) / 2.0; + const qreal zoomedY = srcY + (visibleH - zoomedH) / 2.0; + + return QRectF(zoomedX, zoomedY, zoomedW, zoomedH); +} + +/** + * @brief Returns the destination rectangle that fits a source of the given aspect + * ratio into dstArea using "cover" semantics (no distortion, overflows cropped). + * + * @param dstArea Area to fill. + * @param srcSize Size of the source; only its aspect ratio matters. + * @return Destination rectangle centered in dstArea. + */ +inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize) +{ + const qreal srcAspect = srcSize.width() / srcSize.height(); + const qreal dstAspect = dstArea.width() / dstArea.height(); + + if (srcAspect > dstAspect) { + const qreal dstW = dstArea.height() * srcAspect; + return QRectF(dstArea.left() + (dstArea.width() - dstW) / 2.0, dstArea.top(), dstW, dstArea.height()); + } + + const qreal dstH = dstArea.width() / srcAspect; + return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH); +} + +#endif // COCKATRICE_PLAYMAT_UTILS_H diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index c470e54fe..b78f2f69a 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -317,10 +317,24 @@ AppearanceSettingsPage::AppearanceSettingsPage() &InterfaceSettings::setMinPlayersForMultiColumnLayout); minPlayersForMultiColumnLayoutLabel.setBuddy(&minPlayersForMultiColumnLayoutEdit); + playmatVisibilityCombo.addItem(tr("Show all playmats"), 2); + playmatVisibilityCombo.addItem(tr("Show own playmat only"), 1); + playmatVisibilityCombo.addItem(tr("Don't use playmats"), 0); + int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility()); + if (visIdx >= 0) { + playmatVisibilityCombo.setCurrentIndex(visIdx); + } + connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt()); + }); + playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo); + auto *tableGrid = new QGridLayout; tableGrid->addWidget(&invertVerticalCoordinateCheckBox, 0, 0, 1, 2); tableGrid->addWidget(&minPlayersForMultiColumnLayoutLabel, 1, 0, 1, 1); tableGrid->addWidget(&minPlayersForMultiColumnLayoutEdit, 1, 1, 1, 1); + tableGrid->addWidget(&playmatVisibilityLabel, 2, 0, 1, 1); + tableGrid->addWidget(&playmatVisibilityCombo, 2, 1, 1, 1); tableGroupBox = new QGroupBox; tableGroupBox->setLayout(tableGrid); @@ -489,4 +503,5 @@ void AppearanceSettingsPage::retranslateUi() tableGroupBox->setTitle(tr("Table grid layout")); invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate")); minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:")); + playmatVisibilityLabel.setText(tr("Playmat visibility:")); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 0b6b6832c..0311be302 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -59,6 +59,8 @@ private: QCheckBox horizontalHandCheckBox; QCheckBox leftJustifiedHandCheckBox; QCheckBox invertVerticalCoordinateCheckBox; + QLabel playmatVisibilityLabel; + QComboBox playmatVisibilityCombo; QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; QGroupBox *stylingGroupBox; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index d41713302..f9d0b18e6 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -25,7 +25,7 @@ static const QString CURRENT_SIDEBOARD_PLAN_KEY = ""; bool DeckList::Metadata::isEmpty() const { - return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty(); + return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmatCard.isEmpty(); } DeckList::DeckList() @@ -74,6 +74,36 @@ bool DeckList::readElement(QXmlStreamReader *xml) QString providerId = xml->attributes().value("providerId").toString(); QString cardName = xml->readElementText(); metadata.bannerCard = {cardName, providerId}; + } else if (childName == "playmatCard") { + QString providerId = xml->attributes().value("providerId").toString(); + bool ok; + QString marginLStr = xml->attributes().value("marginPctL").toString(); + QString marginRStr = xml->attributes().value("marginPctR").toString(); + QString vOffStr = xml->attributes().value("verticalOffset").toString(); + QString zoomStr = xml->attributes().value("zoom").toString(); + QString cardName = xml->readElementText(); + metadata.playmatCard = {cardName, providerId}; + PlaymatParams params; + // Clamp to the same ranges as the settings dialog and the remote + // player-properties path so malformed deck files cannot produce + // degenerate art rectangles (e.g. a zoom of 0 dividing by zero). + params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95); + if (!ok) { + params.marginPctL = 0.07; + } + params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95); + if (!ok) { + params.marginPctR = 0.07; + } + params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0); + if (!ok) { + params.verticalOffset = 0.33; + } + params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0); + if (!ok) { + params.zoom = 1.0; + } + metadata.playmatParams = params; } else if (childName == "tags") { metadata.tags.clear(); // Clear existing tags while (xml->readNextStartElement()) { @@ -104,6 +134,16 @@ static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metad xml->writeAttribute("providerId", metadata.bannerCard.providerId); xml->writeCharacters(metadata.bannerCard.name); xml->writeEndElement(); + if (!metadata.playmatCard.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", metadata.playmatCard.providerId); + xml->writeAttribute("marginPctL", QString::number(metadata.playmatParams.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(metadata.playmatParams.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(metadata.playmatParams.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(metadata.playmatParams.zoom, 'f', 4)); + xml->writeCharacters(metadata.playmatCard.name); + xml->writeEndElement(); + } xml->writeTextElement("comments", metadata.comments); // Write tags diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index e792c85dc..21af24627 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -27,6 +27,34 @@ class QIODevice; class QTextStream; class InnerDecklistNode; +/** + * @struct PlaymatParams + * @ingroup Decks + * @brief Positioning parameters for a playmat card image. + * + * Controls how the cropped card art is positioned within the + * combined table+stack play area. The coordinate system is + * relative to the cropped art source image. + */ +struct PlaymatParams +{ + double marginPctL = 0.07; ///< Left margin as fraction of card width (0.0–0.95). + double marginPctR = 0.07; ///< Right margin as fraction of card width (0.0–0.95). + double verticalOffset = 0.33; ///< Vertical position within card (0.0=top, 1.0=bottom). + double zoom = 1.0; ///< Scale factor (0.1–4.0). + + bool operator==(const PlaymatParams &other) const + { + return qFuzzyCompare(marginPctL, other.marginPctL) && qFuzzyCompare(marginPctR, other.marginPctR) && + qFuzzyCompare(verticalOffset, other.verticalOffset) && qFuzzyCompare(zoom, other.zoom); + } + + bool operator!=(const PlaymatParams &other) const + { + return !(*this == other); + } +}; + /** * @class DeckList * @ingroup Decks @@ -70,6 +98,8 @@ public: CardRef bannerCard; ///< Optional representative card for the deck. QStringList tags; ///< User-defined tags for deck classification. QString lastLoadedTimestamp; ///< Timestamp string of last load. + CardRef playmatCard; ///< Optional card used as playmat background for table+stack zones. + PlaymatParams playmatParams; ///< Positioning parameters for the playmat card image. /** * @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty. @@ -115,6 +145,14 @@ public: { metadata.bannerCard = _bannerCard; } + void setPlaymatCard(const CardRef &_playmatCard = {}) + { + metadata.playmatCard = _playmatCard; + } + void setPlaymatParams(const PlaymatParams &_playmatParams = {}) + { + metadata.playmatParams = _playmatParams; + } void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString()) { metadata.lastLoadedTimestamp = _lastLoadedTimestamp; @@ -170,6 +208,14 @@ public: { return metadata.bannerCard; } + CardRef getPlaymatCard() const + { + return metadata.playmatCard; + } + PlaymatParams getPlaymatParams() const + { + return metadata.playmatParams; + } QString getLastLoadedTimestamp() const { return metadata.lastLoadedTimestamp; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h index b77c98357..998dab728 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -43,6 +43,7 @@ public: [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; [[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0; + [[nodiscard]] virtual int getPlaymatVisibility() const = 0; }; #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index 4128c6c90..a4267b714 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -1653,5 +1653,13 @@ void Server_AbstractPlayer::getPlayerProperties(ServerInfo_PlayerProperties &res result.set_ready_start(readyStart); if (deck) { result.set_deck_hash(deck->getDeckHash().toStdString()); + const auto &playmatCard = deck->getPlaymatCard(); + const auto ¶ms = deck->getPlaymatParams(); + result.set_playmat_card_name(playmatCard.name.toStdString()); + result.set_playmat_card_provider_id(playmatCard.providerId.toStdString()); + result.set_playmat_margin_pct_l(params.marginPctL); + result.set_playmat_margin_pct_r(params.marginPctR); + result.set_playmat_vertical_offset(params.verticalOffset); + result.set_playmat_zoom(params.zoom); } } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index d502fc7d6..9b79ae587 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -250,6 +250,14 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r Event_PlayerPropertiesChanged event; event.mutable_player_properties()->set_sideboard_locked(true); event.mutable_player_properties()->set_deck_hash(deck->getDeckHash().toStdString()); + const auto &playmatCard = deck->getPlaymatCard(); + const auto ¶ms = deck->getPlaymatParams(); + event.mutable_player_properties()->set_playmat_card_name(playmatCard.name.toStdString()); + event.mutable_player_properties()->set_playmat_card_provider_id(playmatCard.providerId.toStdString()); + event.mutable_player_properties()->set_playmat_margin_pct_l(params.marginPctL); + event.mutable_player_properties()->set_playmat_margin_pct_r(params.marginPctR); + event.mutable_player_properties()->set_playmat_vertical_offset(params.verticalOffset); + event.mutable_player_properties()->set_playmat_zoom(params.zoom); ges.enqueueGameEvent(event, playerId); Context_DeckSelect context; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto index cdd0ee42c..40a112aeb 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto @@ -11,4 +11,10 @@ message ServerInfo_PlayerProperties { optional sint32 ping_seconds = 7; optional bool sideboard_locked = 8; optional bool judge = 9; + optional string playmat_card_name = 10; + optional string playmat_card_provider_id = 11; + optional double playmat_margin_pct_l = 12 [default = 0.07]; + optional double playmat_margin_pct_r = 13 [default = 0.07]; + optional double playmat_vertical_offset = 14 [default = 0.33]; + optional double playmat_zoom = 15 [default = 1.0]; } diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 2f0718533..fff39f296 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -160,6 +160,11 @@ bool InterfaceSettings::getShowGameSelectorFilterToolbar() const return getValue("showGameSelectorFilterToolbar", QString(), QString(), true).toBool(); } +int InterfaceSettings::getPlaymatVisibility() const +{ + return getValue("playmatvisibility", QString(), QString(), 2).toInt(); +} + bool InterfaceSettings::getLifeCounterAnimationsEnabled() const { return getValue("lifeCounterAnimationsEnabled", QString(), QString(), true).toBool(); @@ -343,6 +348,15 @@ void InterfaceSettings::setShowGameSelectorFilterToolbar(bool _showGameSelectorF emit showGameSelectorFilterToolbarChanged(_showGameSelectorFilterToolbar); } +void InterfaceSettings::setPlaymatVisibility(int _visibility) +{ + if (getPlaymatVisibility() == _visibility) { + return; + } + setValue(_visibility, "playmatvisibility"); + emit playmatVisibilityChanged(_visibility); +} + void InterfaceSettings::setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled) { setValue(_lifeCounterAnimationsEnabled, "lifeCounterAnimationsEnabled"); diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.h b/libcockatrice_settings/libcockatrice/settings/interface_settings.h index 981d28679..a08dbbb2d 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -42,6 +42,7 @@ public: [[nodiscard]] bool getShowStatusBar() const override; [[nodiscard]] bool getShowShortcuts() const override; [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; + [[nodiscard]] int getPlaymatVisibility() const override; [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; [[nodiscard]] bool getBattlefieldFlashEnabled() const override; [[nodiscard]] QStringList getUserListExpandedSections() const override; @@ -77,6 +78,7 @@ public: void setShowStatusBar(bool _showStatusBar); void setShowShortcuts(bool _showShortcuts); void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); + void setPlaymatVisibility(int _visibility); void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); void setUserListExpandedSections(const QStringList §ions); @@ -91,6 +93,7 @@ signals: void tallyTypeChanged(int type); void showStatusBarChanged(bool state); void showGameSelectorFilterToolbarChanged(bool state); + void playmatVisibilityChanged(int visibility); void lifeCounterAnimationsEnabledChanged(bool state); void battlefieldFlashEnabledChanged(bool state);