From 9eafd90a912b9ae4457cd14eee9b8086fd69b684 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:40:49 +0200 Subject: [PATCH] [Game] Playmats (#7101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Game] Playmats Took 19 seconds Took 1 minute * [Playmats] Add fixed override and configurable fallbacks to settings. Took 29 minutes Took 43 seconds * Add main to test. Took 1 minute Took 29 seconds * Move settings to own group Took 11 minutes * Some attempts to refresh macOS compositor Took 2 minutes * Try something else Took 17 minutes * Don't manipulate live list Took 11 minutes * Change things about resolution, address comments. Took 45 minutes Took 12 minutes * Comments. Took 14 minutes Took 8 seconds * Re-order settings menu location Took 2 minutes * Rename PlaymatResolution to Info and add enums Took 8 minutes --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 4 + cockatrice/src/game/game_event_handler.cpp | 9 + cockatrice/src/game/player/player_logic.cpp | 16 + cockatrice/src/game/player/player_logic.h | 22 ++ .../deckview/deck_view_container.cpp | 59 ++++ .../deckview/deck_view_container.h | 6 + cockatrice/src/game_graphics/game_scene.cpp | 36 ++- cockatrice/src/game_graphics/game_scene.h | 2 + cockatrice/src/game_graphics/game_view.cpp | 17 ++ cockatrice/src/game_graphics/game_view.h | 1 + .../player/player_graphics_item.cpp | 168 ++++++++++- .../player/player_graphics_item.h | 16 + .../src/game_graphics/zones/stack_zone.cpp | 18 +- .../src/game_graphics/zones/stack_zone.h | 4 + .../src/game_graphics/zones/table_zone.cpp | 22 +- .../src/game_graphics/zones/table_zone.h | 15 +- .../deck_editor_deck_dock_widget.cpp | 47 ++- .../deck_editor_deck_dock_widget.h | 5 + .../deck_editor/deck_state_manager.cpp | 13 + .../widgets/deck_editor/deck_state_manager.h | 1 + .../playmat/playmat_collection_dialog.cpp | 188 ++++++++++++ .../playmat/playmat_collection_dialog.h | 54 ++++ .../playmat/playmat_preview_widget.cpp | 99 +++++++ .../widgets/playmat/playmat_preview_widget.h | 35 +++ .../playmat/playmat_settings_dialog.cpp | 277 ++++++++++++++++++ .../widgets/playmat/playmat_settings_dialog.h | 85 ++++++ .../interface/widgets/playmat/playmat_utils.h | 69 +++++ .../appearance_settings_page.cpp | 58 +++- .../settings_page/appearance_settings_page.h | 8 + .../src/interface/widgets/tabs/tab_game.cpp | 1 + libcockatrice_deck_list/CMakeLists.txt | 7 +- .../libcockatrice/deck_list/deck_list.cpp | 42 ++- .../libcockatrice/deck_list/deck_list.h | 11 +- .../deck_list/playmat_resolver.cpp | 54 ++++ .../deck_list/playmat_resolver.h | 47 +++ libcockatrice_interfaces/CMakeLists.txt | 2 +- .../interface_interface_settings_provider.h | 47 +++ .../game/server_abstract_participant.cpp | 11 + .../remote/game/server_abstract_participant.h | 3 + .../remote/game/server_abstract_player.cpp | 8 + .../server/remote/game/server_player.cpp | 49 ++++ .../server/remote/game/server_player.h | 2 + .../libcockatrice/protocol/pb/CMakeLists.txt | 1 + .../protocol/pb/command_set_playmat.proto | 9 + .../protocol/pb/game_commands.proto | 5 + .../pb/serverinfo_playerproperties.proto | 10 + .../settings/interface_settings.cpp | 99 +++++++ .../settings/interface_settings.h | 10 + libcockatrice_utility/CMakeLists.txt | 2 + .../libcockatrice/utility/playmat_params.h | 52 ++++ tests/CMakeLists.txt | 7 + tests/playmat_resolver_test.cpp | 126 ++++++++ 52 files changed, 1931 insertions(+), 28 deletions(-) create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h create mode 100644 cockatrice/src/interface/widgets/playmat/playmat_utils.h create mode 100644 libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp create mode 100644 libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto create mode 100644 libcockatrice_utility/libcockatrice/utility/playmat_params.h create mode 100644 tests/playmat_resolver_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a5f9f6747..a966ec51f 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -240,6 +240,10 @@ 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_collection_dialog.cpp + src/interface/widgets/playmat/playmat_collection_dialog.h + 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..bc68d4d7c 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_params()) { + 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_params()) { + 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..45ba09aac 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -250,6 +250,22 @@ void PlayerLogic::setDeck(const DeckList &_deck) emit deckChanged(); } +void PlayerLogic::setPlaymatFromProperties(const ServerInfo_PlayerProperties &props) +{ + if (props.has_playmat_params() && !props.playmat_params().card_name().empty()) { + const auto &pp = props.playmat_params(); + remotePlaymatCard = {QString::fromStdString(pp.card_name()), QString::fromStdString(pp.card_provider_id())}; + remotePlaymatParams = {qBound(0.0, pp.margin_pct_l(), 0.95), qBound(0.0, pp.margin_pct_r(), 0.95), + qBound(0.0, pp.vertical_offset(), 1.0), qBound(0.1, pp.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/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 69941040f..23ed4316d 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -14,12 +14,15 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include #include #include @@ -100,6 +103,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged, this, &DeckViewContainer::setVisualDeckStorageExists); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this, + &DeckViewContainer::onPlaymatSettingsChanged); + switchToDeckSelectView(); } @@ -277,6 +283,8 @@ void DeckViewContainer::loadDeckFromFile(const QString &filePath) void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) { + currentDeck = deck; + QString deckString = deck.writeToString_Native(); if (deckString.length() > MAX_FILE_LENGTH) { @@ -289,6 +297,52 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); + + resolveAndSendPlaymat(); +} + +void DeckViewContainer::resolveAndSendPlaymat() +{ + if (currentDeck.getCardRefList().isEmpty() && currentDeck.getPlaymat().card.isEmpty()) { + return; + } + + const auto &settings = SettingsCache::instance().userInterface(); + const auto fallbackBehavior = static_cast(settings.getPlaymatFallbackBehavior()); + + QList fallbackList = settings.getPlaymatFallbackList(); + + // In random mode with 2+ entries, remove the last-resolved mat to avoid repeats. + if (fallbackBehavior == PlaymatFallbackModeRandom && fallbackList.size() > 1) { + fallbackList.removeAll(lastResolvedPlaymat); + } + + const PlaymatInfo resolved = + resolvePlaymatForDeck(currentDeck, fallbackList, static_cast(settings.getPlaymatMode()), + fallbackBehavior, playmatRotationIndex); + + lastResolvedPlaymat = resolved; + + Command_SetPlaymat playmatCmd; + auto *pp = playmatCmd.mutable_playmat_params(); + pp->set_card_name(resolved.card.name.toStdString()); + pp->set_card_provider_id(resolved.card.providerId.toStdString()); + pp->set_margin_pct_l(resolved.params.marginPctL); + pp->set_margin_pct_r(resolved.params.marginPctR); + pp->set_vertical_offset(resolved.params.verticalOffset); + pp->set_zoom(resolved.params.zoom); + PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId); +} + +void DeckViewContainer::onPlaymatSettingsChanged() +{ + resolveAndSendPlaymat(); +} + +void DeckViewContainer::advancePlaymatRotation() +{ + playmatRotationIndex++; } void DeckViewContainer::loadRemoteDeck() @@ -379,6 +433,10 @@ void DeckViewContainer::sideboardPlanChanged() */ void DeckViewContainer::sendReadyStartCommand(bool ready) { + if (ready) { + resolveAndSendPlaymat(); + } + Command_ReadyStart cmd; cmd.set_ready(ready); parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); @@ -416,6 +474,7 @@ void DeckViewContainer::setSideboardLocked(bool locked) void DeckViewContainer::setDeck(const DeckList &deck) { + currentDeck = deck; deckView->setDeck(deck); switchToDeckLoadedView(); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h index ec024bace..b5317c39a 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -57,6 +57,9 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; TabGame *parentGame; int playerId; + int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. + DeckList currentDeck; ///< Cached deck for live settings re-resolution. + PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. void tryCreateVisualDeckStorageWidget(); void sendReadyStartCommand(bool ready); @@ -75,6 +78,7 @@ private slots: void sideboardLockButtonClicked(); void updateSideboardLockButtonText(); void refreshShortcuts(); + void onPlaymatSettingsChanged(); signals: void newCardAdded(AbstractCardItem *card); void notIdle(); @@ -87,6 +91,8 @@ public: void setSideboardLocked(bool locked); void setDeck(const DeckList &deck); void setVisualDeckStorageExists(bool exists); + void advancePlaymatRotation(); + void resolveAndSendPlaymat(); public slots: void loadDeckFromFile(const QString &filePath); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 4d3144ad4..cd2b12828 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -252,17 +252,27 @@ void GameScene::adjustPlayerRotation(int rotationAdjustment) */ void GameScene::rearrange() { - int firstPlayerIndex = 0; - auto playersPlaying = collectActivePlayers(firstPlayerIndex); - playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); + if (rearranging) { + needsReArrange = true; + return; + } + rearranging = true; + do { + needsReArrange = false; - int columns = determineColumnCount(playersPlaying.size()); - QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); + int firstPlayerIndex = 0; + auto playersPlaying = collectActivePlayers(firstPlayerIndex); + playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); - phasesToolbar->setHeight(sceneSize.height()); - setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + int columns = determineColumnCount(playersPlaying.size()); + QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); - processViewSizeChange(viewSize); + phasesToolbar->setHeight(sceneSize.height()); + setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + + processViewSizeChange(viewSize); + } while (needsReArrange); + rearranging = false; } // ---------- View Size ---------- @@ -459,8 +469,14 @@ void GameScene::resizeColumnsAndPlayers(const QList &minWidthByColumn, qr qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size(); qreal newx = phasesToolbar->getWidth(); - for (int col = 0; col < playersByColumn.size(); ++col) { - for (PlayerGraphicsItem *player : playersByColumn[col]) { + // Snapshot the columns: resizing a player's table can synchronously trigger + // GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect -> + // sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating + // the live container across that re-entrant call would use invalidated iterators. + const QList> columns = playersByColumn; + + for (int col = 0; col < columns.size(); ++col) { + for (PlayerGraphicsItem *player : columns[col]) { player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn); player->setPos(newx, player->y()); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 7f01bf1f5..c12696189 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -55,6 +55,8 @@ private: QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating int playerRotation; ///< Rotation offset for player layout + bool rearranging = false; ///< Guard against re-entrant rearrange + bool needsReArrange = false; ///< Pending rearrange requested during a pass /** * @brief Updates which card is currently hovered based on scene coordinates. diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index b768c8317..ed6355157 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -114,6 +114,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin) } selectionOrigin = _selectionOrigin; + previousBandRect = QRect(); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); rubberBand->show(); } @@ -128,7 +129,17 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount) QPoint cursor = cursorPoint.toPoint(); QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized(); + rubberBand->setGeometry(rect); + if (viewport()) { + // Repaint the union of the previous and current band rects: the vacated + // strip of a child widget is not reliably invalidated on all platforms + // (notably macOS), leaving stale pixels under the selection. + QRect dirty = previousBandRect.isNull() ? rect : previousBandRect.united(rect); + dirty.adjust(-1, -1, 1, 1); + viewport()->update(dirty); + previousBandRect = rect; + } if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) { dragCountLabel->hide(); @@ -171,7 +182,13 @@ void GameView::stopRubberBand() return; } + // Same rationale as resizeRubberBand: repaint the last known band area + // since hiding a child widget doesn't reliably invalidate its region. rubberBand->hide(); + if (viewport() && !previousBandRect.isNull()) { + viewport()->update(previousBandRect.adjusted(-1, -1, 1, 1)); + previousBandRect = QRect(); + } dragCountLabel->hide(); } diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 3f6b60dbc..a23655513 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -27,6 +27,7 @@ private: QWidget *tallyContainer; QGridLayout *tallyLayout; QPointF selectionOrigin; + QRect previousBandRect; ///< Last rubber-band rect for targeted repaint QList cachedTallyRows; ///< Cached entries to avoid redundant rebuilds QSize rebuildTallyLabels(const QList &entries); diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 2831f3393..026e00588 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,9 @@ #include "player_dialogs.h" #include +#include +#include +#include #include PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) @@ -28,6 +34,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 +77,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(); @@ -112,7 +125,6 @@ void PlayerGraphicsItem::initializeZones() rfgZoneGraphicsItem->setPos(base + QPointF(0, 2 * h + h2 + 10)); tableZoneGraphicsItem = new TableZone(player->getTableZone(), mirrored, this); - connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); connect(this, &PlayerGraphicsItem::mirroredChanged, tableZoneGraphicsItem, &TableZone::setMirrored); stackZoneGraphicsItem = @@ -155,10 +167,61 @@ 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->save(); + painter->setClipRect(combinedArea); + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + + // Render from a down-scaled copy of the art so the full-resolution source + // pixmap is never re-sampled at a tiny device size (also much cheaper than + // scaling it on every frame). + const QPixmap scaledPixmap = scaledPlaymatFor(srcRect, painter->worldTransform().mapRect(dstRect).size()); + painter->drawPixmap(dstRect, scaledPixmap, QRectF(scaledPixmap.rect())); + + painter->restore(); + + if (!playmatAttribution.isEmpty()) { + paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } +} + +QPixmap PlayerGraphicsItem::scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize) +{ + // Bucket the render size so the source pixmap is re-scaled at most once per + // zoom step instead of once per frame. + constexpr int bucketSize = 32; + const QSize target = QSize(qMax(1, qRound(deviceDstSize.width() / bucketSize) * bucketSize), + qMax(1, qRound(deviceDstSize.height() / bucketSize) * bucketSize)) + .boundedTo(srcRect.toAlignedRect().size()); + + if (scaledPlaymatKey != target) { + const QPixmap crop = playmatPixmap.copy(srcRect.toAlignedRect()); + scaledPlaymatPixmap = crop.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledPlaymatKey = target; + } + return scaledPlaymatPixmap; } void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) @@ -303,3 +366,100 @@ void PlayerGraphicsItem::updateBoundingRect() emit sizeChanged(); } + +void PlayerGraphicsItem::updatePlaymat() +{ + int visibility = SettingsCache::instance().userInterface().getPlaymatVisibility(); + + // "Don't use playmats" — never show + if (visibility == PlaymatVisibilityNone) { + clearPlaymat(); + return; + } + + // "Show own playmat only" — hide playmats for remote players + if (visibility == PlaymatVisibilityOwnOnly && !player->getPlayerInfo()->getLocal()) { + clearPlaymat(); + return; + } + + CardRef playmatCard; + PlaymatParams params; + + if (player->getHasRemotePlaymat()) { + // Prefer the server-confirmed playmat (updated by Command_SetPlaymat). + playmatCard = player->getRemotePlaymatCard(); + params = player->getRemotePlaymatParams(); + } else if (player->getPlayerInfo()->getLocal()) { + // Local player without a server broadcast yet: apply the full + // settings-based resolution chain (mode, fallback list, behavior). + const auto &settings = SettingsCache::instance().userInterface(); + const PlaymatInfo resolved = resolvePlaymatForDeck( + player->getDeck(), settings.getPlaymatFallbackList(), static_cast(settings.getPlaymatMode()), + static_cast(settings.getPlaymatFallbackBehavior()), 0); + playmatCard = resolved.card; + params = resolved.params; + } else { + // Opponent without a server broadcast: use the deck-embedded playmat. + const DeckList &deck = player->getDeck(); + const PlaymatInfo &deckPlaymat = deck.getPlaymat(); + if (!deckPlaymat.card.isEmpty()) { + playmatCard = deckPlaymat.card; + params = deckPlaymat.params; + } + } + + if (playmatCard.isEmpty()) { + clearPlaymat(); + return; + } + + playmatParams = params; + scaledPlaymatKey = QSize(); // the art crop depends on the params, drop any cached scale + + 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(); + scaledPlaymatKey = QSize(); + 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..e5ae59a61 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,23 @@ private: bool mirrored; bool handVisible = false; + QPixmap playmatPixmap; + QPixmap scaledPlaymatPixmap; // down-scaled copy of playmatPixmap for the current render size + QSize scaledPlaymatKey; // size bucket scaledPlaymatPixmap was rendered for + PlaymatParams playmatParams; + QString playmatAttribution; + bool hasPlaymat = false; + QMetaObject::Connection playmatPixmapConnection; + private slots: void updateBoundingRect(); void rearrangeZones(); + void clearPlaymat(); + void updatePlaymat(); + void onPlaymatPixmapReady(); + +private: + QPixmap scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize); }; #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..e9b14f13d 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -31,8 +31,22 @@ 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; + // See TableZone::onPlaymatChanged for the rationale. Translucent overlay + // over a dynamic playmat should not be held in the device cache. + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + 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..88e9abe6c 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,17 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti paintLandDivider(painter); } +void TableZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + // While a playmat is shown the zone paints a translucent overlay over the + // dynamic playmat behind it. Keep it out of the device cache so the cached + // pixels are never stale relative to the playmat (and to avoid compositing + // artifacts of cached translucent content on some platforms). + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + 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..92915a2ed 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 @@ -184,8 +188,17 @@ public: } void setWidth(qreal _width) { + // The width is stored as an int; truncate to match the previous implicit conversion. + const int newWidth = static_cast(_width); + if (width == newWidth) { + return; + } prepareGeometryChange(); - width = _width; + width = newWidth; + // The parent player item's boundingRect (which clips the playmat painting) is + // derived from this zone's size. Without this signal the playmat is cut off at + // the stale boundingRect edge whenever the scene is resized wider. + emit sizeChanged(); } [[nodiscard]] qreal getWidth() const { 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..14defc8e9 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,10 +12,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -228,10 +231,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 +451,35 @@ void DeckEditorDeckDockWidget::writeBannerCard(int index) deckStateManager->setBannerCard(bannerCard); } +void DeckEditorDeckDockWidget::openPlaymatSettings() +{ + PlaymatInfo current = deckStateManager->getMetadata().playmat; + + PlaymatSettingsDialog dialog(current.card, current.params, this); + if (dialog.exec() == QDialog::Accepted) { + CardRef newCard = dialog.card(); + PlaymatParams newParams = dialog.params(); + + if (newCard.isEmpty()) { + deckStateManager->setPlaymat(PlaymatInfo{}); + } else { + deckStateManager->setPlaymat({newCard, newParams}); + } + + updatePlaymatLabel(); + } +} + +void DeckEditorDeckDockWidget::updatePlaymatLabel() +{ + CardRef playmat = deckStateManager->getMetadata().playmat.card; + 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 +537,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..eda741728 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,19 @@ void DeckStateManager::setBannerCard(const CardRef &bannerCard) doMetadataModified(); } +void DeckStateManager::setPlaymat(const PlaymatInfo &playmat) +{ + PlaymatInfo previous = deckList->getPlaymat(); + if (previous == playmat) { + return; + } + + requestHistorySave(tr("Set playmat to %1").arg(playmat.card.name)); + deckList->setPlaymat(playmat); + + 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..b9c99903e 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,7 @@ public: void setName(const QString &name); void setComments(const QString &comments); void setBannerCard(const CardRef &bannerCard); + void setPlaymat(const PlaymatInfo &playmat); void setTags(const QStringList &tags); void setFormat(const QString &format); ///@} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp new file mode 100644 index 000000000..e970f8413 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp @@ -0,0 +1,188 @@ +#include "playmat_collection_dialog.h" + +#include "../../../client/settings/cache_settings.h" +#include "playmat_settings_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +PlaymatCollectionDialog::PlaymatCollectionDialog(QWidget *parent) : QDialog(parent) +{ + setMinimumWidth(420); + setupUi(); + retranslateUi(); +} + +void PlaymatCollectionDialog::accept() +{ + auto &interfaceSettings = SettingsCache::instance().userInterface(); + interfaceSettings.setPlaymatFallbackList(playmats); + interfaceSettings.setPlaymatFallbackBehavior(modeCombo->currentData().toInt()); + QDialog::accept(); +} + +int PlaymatCollectionDialog::currentRow() const +{ + return playmatList->currentRow(); +} + +void PlaymatCollectionDialog::setupUi() +{ + auto &interfaceSettings = SettingsCache::instance().userInterface(); + playmats = interfaceSettings.getPlaymatFallbackList(); + + playmatList = new QListWidget; + for (const PlaymatInfo &entry : playmats) { + playmatList->addItem(entry.card.name); + } + connect(playmatList, &QListWidget::itemSelectionChanged, this, &PlaymatCollectionDialog::selectionChanged); + connect(playmatList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { editPlaymat(); }); + + addButton = new QPushButton; + editButton = new QPushButton; + removeButton = new QPushButton; + moveUpButton = new QPushButton; + moveDownButton = new QPushButton; + + connect(addButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::addPlaymat); + connect(editButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::editPlaymat); + connect(removeButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::removePlaymat); + connect(moveUpButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatUp); + connect(moveDownButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatDown); + + auto *listButtons = new QVBoxLayout; + listButtons->addWidget(addButton); + listButtons->addWidget(editButton); + listButtons->addWidget(removeButton); + listButtons->addWidget(moveUpButton); + listButtons->addWidget(moveDownButton); + listButtons->addStretch(); + + auto *listRow = new QHBoxLayout; + listRow->addWidget(playmatList, 1); + listRow->addLayout(listButtons); + + modeCombo = new QComboBox; + modeCombo->addItem(QString(), PlaymatFallbackModeFixed); + modeCombo->addItem(QString(), PlaymatFallbackModeRoundRobin); + modeCombo->addItem(QString(), PlaymatFallbackModeRandom); + const int modeIndex = modeCombo->findData(interfaceSettings.getPlaymatFallbackBehavior()); + if (modeIndex >= 0) { + modeCombo->setCurrentIndex(modeIndex); + } + + auto *modeRow = new QHBoxLayout; + modeLabel = new QLabel; + modeLabel->setBuddy(modeCombo); + modeRow->addWidget(modeLabel); + modeRow->addWidget(modeCombo, 1); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &PlaymatCollectionDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *root = new QVBoxLayout; + root->addLayout(listRow); + root->addLayout(modeRow); + root->addWidget(buttonBox); + setLayout(root); + + selectionChanged(); +} + +void PlaymatCollectionDialog::selectionChanged() +{ + const bool hasSelection = playmatList->currentRow() >= 0; + editButton->setEnabled(hasSelection); + removeButton->setEnabled(hasSelection); + moveUpButton->setEnabled(hasSelection && playmatList->currentRow() > 0); + moveDownButton->setEnabled(hasSelection && playmatList->currentRow() < playmatList->count() - 1); +} + +void PlaymatCollectionDialog::addPlaymat() +{ + PlaymatSettingsDialog dialog(CardRef{}, PlaymatParams{}, this); + if (dialog.exec() == QDialog::Accepted) { + const CardRef card = dialog.card(); + if (!card.isEmpty()) { + PlaymatInfo res = {card, dialog.params()}; + playmats.append(res); + playmatList->addItem(res.card.name); + playmatList->setCurrentRow(playmatList->count() - 1); + } + } +} + +void PlaymatCollectionDialog::editPlaymat() +{ + const int row = currentRow(); + if (row < 0) { + return; + } + + const PlaymatInfo ¤t = playmats.at(row); + PlaymatSettingsDialog dialog(current.card, current.params, this); + if (dialog.exec() == QDialog::Accepted) { + const CardRef card = dialog.card(); + if (card.isEmpty()) { + return; // Removal is handled by the Remove button + } + playmats[row] = {card, dialog.params()}; + playmatList->item(row)->setText(card.name); + } +} + +void PlaymatCollectionDialog::removePlaymat() +{ + const int row = currentRow(); + if (row < 0) { + return; + } + playmats.removeAt(row); + delete playmatList->takeItem(row); + selectionChanged(); +} + +void PlaymatCollectionDialog::movePlaymatUp() +{ + const int row = currentRow(); + if (row <= 0) { + return; + } + playmats.swapItemsAt(row, row - 1); + playmatList->insertItem(row - 1, playmatList->takeItem(row)); + playmatList->setCurrentRow(row - 1); + selectionChanged(); +} + +void PlaymatCollectionDialog::movePlaymatDown() +{ + const int row = currentRow(); + if (row < 0 || row >= playmats.size() - 1) { + return; + } + playmats.swapItemsAt(row, row + 1); + playmatList->insertItem(row + 1, playmatList->takeItem(row)); + playmatList->setCurrentRow(row + 1); + selectionChanged(); +} + +void PlaymatCollectionDialog::retranslateUi() +{ + setWindowTitle(tr("Default Playmats")); + addButton->setText(tr("Add...")); + editButton->setText(tr("Edit...")); + removeButton->setText(tr("Remove")); + moveUpButton->setText(tr("Move Up")); + moveDownButton->setText(tr("Move Down")); + modeLabel->setText(tr("List mode:")); + modeCombo->setItemText(0, tr("Fixed (always the first entry)")); + modeCombo->setItemText(1, tr("Round-robin (cycle through entries)")); + modeCombo->setItemText(2, tr("Random (pick one per game)")); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h new file mode 100644 index 000000000..167893f8e --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H +#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H + +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +/** + * @brief Dialog for editing the user-level playmat collection. + * + * The collection is the fallback used when a deck has no playmat of its own. + * It supports multiple entries and a pick mode (always first / round-robin / + * random). The dialog edits a working copy and writes it to the settings only + * when accepted. + */ +class PlaymatCollectionDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PlaymatCollectionDialog(QWidget *parent = nullptr); + + void accept() override; + +private slots: + void addPlaymat(); + void editPlaymat(); + void removePlaymat(); + void movePlaymatUp(); + void movePlaymatDown(); + void selectionChanged(); + +private: + void setupUi(); + void retranslateUi(); + int currentRow() const; + + QList playmats; ///< Working copy edited by the dialog. + QListWidget *playmatList; + QComboBox *modeCombo; + QLabel *modeLabel; + QPushButton *addButton; + QPushButton *editButton; + QPushButton *removeButton; + QPushButton *moveUpButton; + QPushButton *moveDownButton; +}; + +#endif // COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H 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..72c715e13 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -0,0 +1,277 @@ +#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) +{ + 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); + + retranslateUi(); +} + +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; + + 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; + cardNameLabel = new QLabel; + printingLabel = new QLabel; + leftMarginLabel = new QLabel; + rightMarginLabel = new QLabel; + verticalOffsetLabel = new QLabel; + zoomLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + form->addRow(leftMarginLabel, marginLSpin); + form->addRow(rightMarginLabel, marginRSpin); + form->addRow(verticalOffsetLabel, verticalOffsetSpin); + form->addRow(zoomLabel, zoomSpin); + + controlsGroup = new QGroupBox; + controlsGroup->setLayout(form); + + preview = new PlaymatPreviewWidget; + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + previewGroup = new QGroupBox; + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + removeButton = new QPushButton; + buttons->addButton(removeButton, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeButton, &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); +} + +void PlaymatSettingsDialog::retranslateUi() +{ + setWindowTitle(tr("Playmat Settings")); + searchBar->setPlaceholderText(tr("Type a card name...")); + cardNameLabel->setText(tr("Card name:")); + printingLabel->setText(tr("Printing:")); + leftMarginLabel->setText(tr("Left margin (%):")); + rightMarginLabel->setText(tr("Right margin (%):")); + verticalOffsetLabel->setText(tr("Vertical offset:")); + zoomLabel->setText(tr("Zoom:")); + controlsGroup->setTitle(tr("Parameters")); + previewGroup->setTitle(tr("Preview")); + removeButton->setText(tr("Remove Playmat")); +} 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..7ccd1569d --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h @@ -0,0 +1,85 @@ +#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H +#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H + +#include +#include +#include + +class QComboBox; +class QCompleter; +class QDoubleSpinBox; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +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(); + void retranslateUi(); + 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; + + QLabel *cardNameLabel; + QLabel *printingLabel; + QLabel *leftMarginLabel; + QLabel *rightMarginLabel; + QLabel *verticalOffsetLabel; + QLabel *zoomLabel; + QGroupBox *controlsGroup; + QGroupBox *previewGroup; + QPushButton *removeButton; + + 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..149395194 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -7,11 +7,14 @@ #include "../dialogs/override_printing_warning.h" #include "../interface/theme_manager.h" #include "../interface/widgets/general/background_sources.h" +#include "../playmat/playmat_collection_dialog.h" +#include "../playmat/playmat_settings_dialog.h" #include #include #include #include +#include #include #include #include @@ -325,10 +328,52 @@ AppearanceSettingsPage::AppearanceSettingsPage() tableGroupBox = new QGroupBox; tableGroupBox->setLayout(tableGrid); + // Playmat settings + playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll); + playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly); + playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone); + 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); + + // Playmat mode: Override / Fallback / Deck-only + playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck); + playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback); + playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly); + int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode()); + if (modeIdx >= 0) { + playmatModeCombo.setCurrentIndex(modeIdx); + } + connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt()); + }); + playmatModeLabel.setBuddy(&playmatModeCombo); + + // User-level playmat settings: fallback collection. + connect(&playmatDefaultEditButton, &QPushButton::clicked, this, + &AppearanceSettingsPage::openPlaymatCollectionDialog); + + auto *playmatGrid = new QGridLayout; + playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1); + playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1); + playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1); + playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1); + playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1); + playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1); + + playmatGroupBox = new QGroupBox; + playmatGroupBox->setLayout(playmatGrid); + // putting it all together auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(homeTabGroupBox); + mainLayout->addWidget(playmatGroupBox); mainLayout->addWidget(stylingGroupBox); mainLayout->addWidget(menuGroupBox); mainLayout->addWidget(printingsGroupBox); @@ -431,6 +476,12 @@ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value) } } +void AppearanceSettingsPage::openPlaymatCollectionDialog() +{ + PlaymatCollectionDialog dialog(this); + dialog.exec(); +} + void AppearanceSettingsPage::retranslateUi() { themeGroupBox->setTitle(tr("Theme settings")); @@ -489,4 +540,9 @@ 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:")); -} \ No newline at end of file + playmatGroupBox->setTitle(tr("Playmat settings")); + playmatVisibilityLabel.setText(tr("Playmat visibility:")); + playmatModeLabel.setText(tr("Default collection behavior:")); + playmatDefaultLabel.setText(tr("Default playmat collection:")); + playmatDefaultEditButton.setText(tr("Edit...")); +} 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..28abbd537 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -24,6 +24,7 @@ private slots: void cardViewInitialRowsMaxChanged(int value); void cardViewExpandedRowsMaxChanged(int value); + void openPlaymatCollectionDialog(); private: QLabel themeLabel; @@ -59,6 +60,12 @@ private: QCheckBox horizontalHandCheckBox; QCheckBox leftJustifiedHandCheckBox; QCheckBox invertVerticalCoordinateCheckBox; + QLabel playmatVisibilityLabel; + QComboBox playmatVisibilityCombo; + QLabel playmatModeLabel; + QComboBox playmatModeCombo; + QLabel playmatDefaultLabel; + QPushButton playmatDefaultEditButton; QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; QGroupBox *stylingGroupBox; @@ -67,6 +74,7 @@ private: QGroupBox *cardsGroupBox; QGroupBox *cardLayoutGroupBox; QGroupBox *handGroupBox; + QGroupBox *playmatGroupBox; QGroupBox *tableGroupBox; QGroupBox *cardCountersGroupBox; QList cardCounterNames; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index dbf4a5a4a..196ea4526 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -911,6 +911,7 @@ void TabGame::stopGame() QMapIterator i(deckViewContainers); while (i.hasNext()) { i.next(); + i.value()->playerDeckView->advancePlaymatRotation(); i.value()->show(); } diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 33494b9a9..c7a54a390 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,6 +11,7 @@ set(HEADERS libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h + libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) @@ -26,6 +27,7 @@ add_library( libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp + libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) @@ -33,4 +35,7 @@ add_dependencies(libcockatrice_deck_list libcockatrice_protocol) target_include_directories(libcockatrice_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_deck_list PUBLIC libcockatrice_protocol libcockatrice_utility ${QT_CORE_MODULE}) +target_link_libraries( + libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility + ${QT_CORE_MODULE} +) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index d41713302..4ffc1bab7 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() && playmat.card.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(); + PlaymatInfo playmat; + playmat.card = {cardName, providerId}; + // 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). + playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95); + if (!ok) { + playmat.params.marginPctL = 0.07; + } + playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95); + if (!ok) { + playmat.params.marginPctR = 0.07; + } + playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0); + if (!ok) { + playmat.params.verticalOffset = 0.33; + } + playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0); + if (!ok) { + playmat.params.zoom = 1.0; + } + metadata.playmat = playmat; } 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.playmat.card.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", metadata.playmat.card.providerId); + xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4)); + xml->writeCharacters(metadata.playmat.card.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..475d99560 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -18,7 +18,7 @@ #include #include #include -#include +#include class AbstractDecklistNode; class DecklistCardNode; @@ -70,6 +70,7 @@ public: CardRef bannerCard; ///< Optional representative card for the deck. QStringList tags; ///< User-defined tags for deck classification. QString lastLoadedTimestamp; ///< Timestamp string of last load. + PlaymatInfo playmat; ///< Optional playmat background for table+stack zones. /** * @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty. @@ -115,6 +116,10 @@ public: { metadata.bannerCard = _bannerCard; } + void setPlaymat(const PlaymatInfo &_playmat = {}) + { + metadata.playmat = _playmat; + } void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString()) { metadata.lastLoadedTimestamp = _lastLoadedTimestamp; @@ -170,6 +175,10 @@ public: { return metadata.bannerCard; } + PlaymatInfo getPlaymat() const + { + return metadata.playmat; + } QString getLastLoadedTimestamp() const { return metadata.lastLoadedTimestamp; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp new file mode 100644 index 000000000..fd72fea0b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp @@ -0,0 +1,54 @@ +#include "playmat_resolver.h" + +#include + +PlaymatInfo resolveEffectivePlaymat(const DeckList &deck, + const PlaymatInfo &force, + const QList &fallbackList, + PlaymatFallbackMode fallbackMode, + int rotationIndex) +{ + if (!force.card.isEmpty()) { + return force; + } + + const PlaymatInfo &deckPlaymat = deck.getPlaymat(); + if (!deckPlaymat.card.isEmpty()) { + return deckPlaymat; + } + + if (fallbackList.isEmpty()) { + return {}; + } + + switch (fallbackMode) { + case PlaymatFallbackModeFixed: + return fallbackList.first(); + case PlaymatFallbackModeRoundRobin: + return fallbackList.at(rotationIndex % fallbackList.size()); + case PlaymatFallbackModeRandom: + return fallbackList.at(QRandomGenerator::global()->bounded(fallbackList.size())); + } + + return {}; +} + +PlaymatInfo resolvePlaymatForDeck(const DeckList &deck, + const QList &fallbackList, + PlaymatMode mode, + PlaymatFallbackMode fallbackBehavior, + int rotationIndex) +{ + switch (mode) { + case PlaymatModeOverrideDeck: { + const DeckList emptyDeck; + return resolveEffectivePlaymat(emptyDeck, {}, fallbackList, fallbackBehavior, rotationIndex); + } + case PlaymatModeFallback: + return resolveEffectivePlaymat(deck, {}, fallbackList, fallbackBehavior, rotationIndex); + case PlaymatModeDeckOnly: + return deck.getPlaymat(); + } + + return {}; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h new file mode 100644 index 000000000..59bc449c8 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_PLAYMAT_RESOLVER_H +#define COCKATRICE_PLAYMAT_RESOLVER_H + +#include "deck_list.h" + +#include + +/** + * @brief Resolves the effective playmat for a deck per the resolution chain: + * force override > deck-configured playmat > fallback list > none. + * + * @param deck The deck to resolve a playmat for. + * @param force An optional user-level override; wins over everything. Pass an + * empty @ref PlaymatInfo::card to skip it. + * @param fallbackList User-level fallback playmats, consulted only when the + * deck has no configured playmat. + * @param fallbackMode How @p fallbackList is consulted (ignored when empty). + * @param rotationIndex In/out cursor for @c PlaymatFallbackModeRoundRobin; + * advanced once per call. Unused for the other modes. + * @return The effective playmat; an empty @ref PlaymatInfo::card when + * nothing in the chain resolves. + */ +PlaymatInfo resolveEffectivePlaymat(const DeckList &deck, + const PlaymatInfo &force, + const QList &fallbackList, + PlaymatFallbackMode fallbackMode, + int rotationIndex); + +/** + * @brief Resolves the playmat to display for a deck according to the user's + * collection mode (@ref PlaymatMode), combining the deck with the + * given fallback list. + * + * @param deck The deck to resolve a playmat for. + * @param fallbackList User-level fallback playmats. + * @param mode How the collection interacts with the deck-configured playmat. + * @param fallbackBehavior How @p fallbackList is picked from. + * @param rotationIndex Cursor for @c PlaymatFallbackModeRoundRobin. + * @return The effective playmat; an empty @ref PlaymatInfo::card when nothing resolves. + */ +PlaymatInfo resolvePlaymatForDeck(const DeckList &deck, + const QList &fallbackList, + PlaymatMode mode, + PlaymatFallbackMode fallbackBehavior, + int rotationIndex); + +#endif // COCKATRICE_PLAYMAT_RESOLVER_H diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index f606f6207..f53c41807 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -30,4 +30,4 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_interfaces PUBLIC ${QT_CORE_MODULE}) +target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE}) diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h index b77c98357..bc2118cb3 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -1,8 +1,40 @@ #ifndef COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H +#include #include #include +#include + +/** + * @brief Whether playmats are rendered in-game, and for whom. + */ +enum PlaymatVisibility +{ + PlaymatVisibilityNone = 0, ///< Don't use playmats. + PlaymatVisibilityOwnOnly = 1, ///< Show the local player's playmat only. + PlaymatVisibilityAll = 2 ///< Show playmats for all players. +}; + +/** + * @brief How the user-level playmat collection interacts with the deck-configured playmat. + */ +enum PlaymatMode +{ + PlaymatModeOverrideDeck = 0, ///< Always use the collection, ignoring any deck-configured playmat. + PlaymatModeFallback = 1, ///< Prefer the deck-configured playmat; fall back to the collection when absent. + PlaymatModeDeckOnly = 2 ///< Use only the deck-configured playmat, ignoring the collection. +}; + +/** + * @brief How the user-level fallback playmat list is consulted when a deck has no playmat configured. + */ +enum PlaymatFallbackMode +{ + PlaymatFallbackModeFixed = 0, ///< Always use the first entry of the fallback list. + PlaymatFallbackModeRoundRobin = 1, ///< Cycle through the list, advancing one entry per resolution. + PlaymatFallbackModeRandom = 2 ///< Pick a random entry per resolution. +}; class IInterfaceSettingsProvider { @@ -43,6 +75,21 @@ public: [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; [[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0; + + /** @brief Who gets playmats rendered: @ref PlaymatVisibility. */ + [[nodiscard]] virtual int getPlaymatVisibility() const = 0; + + /** @brief User-level playmat collection. Used either as a forced playmat + * (mode == @ref PlaymatModeOverrideDeck) or as a fallback when a deck has none + * (mode == @ref PlaymatModeFallback). */ + [[nodiscard]] virtual QList getPlaymatFallbackList() const = 0; + + /** @brief How the fallback list is applied: @ref PlaymatMode. */ + [[nodiscard]] virtual int getPlaymatMode() const = 0; + + /** @brief How the fallback list is picked from when mode is @ref PlaymatModeFallback: + * @ref PlaymatFallbackMode. */ + [[nodiscard]] virtual int getPlaymatFallbackBehavior() const = 0; }; #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 5c0fdf944..a6280ffa1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -144,6 +145,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetSideboardLock(const Com return Response::RespFunctionNotAllowed; } +Response::ResponseCode Server_AbstractParticipant::cmdSetPlaymat(const Command_SetPlaymat & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespFunctionNotAllowed; +} + Response::ResponseCode Server_AbstractParticipant::cmdConcede(const Command_Concede & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/) @@ -525,6 +533,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::REVERSE_TURN: return cmdReverseTurn(command.GetExtension(Command_ReverseTurn::ext), rc, ges); break; + case GameCommand::SET_PLAYMAT: + return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index a24fa5799..c78ae78c1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -52,6 +52,7 @@ class Command_SetSideboardPlan; class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; +class Command_SetPlaymat; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -124,6 +125,8 @@ public: cmdSetSideboardPlan(const Command_SetSideboardPlan &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdSetSideboardLock(const Command_SetSideboardLock &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode + cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdGameSay(const Command_GameSay &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdShuffle(const Command_Shuffle &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode 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..957a89792 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 &playmat = deck->getPlaymat(); + auto *playmatParams = result.mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.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..cafa33c07 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -250,6 +251,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 &playmat = deck->getPlaymat(); + auto *playmatParams = event.mutable_player_properties()->mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.left(MAX_NAME_LENGTH).toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.left(MAX_NAME_LENGTH).toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); ges.enqueueGameEvent(event, playerId); Context_DeckSelect context; @@ -594,6 +603,46 @@ Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges); } +Response::ResponseCode +Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) +{ + Q_UNUSED(rc); + + if (!deck) { + return Response::RespContextError; + } + + const auto &pp = cmd.playmat_params(); + const auto rawName = QString::fromStdString(pp.card_name()); + const auto rawProviderId = QString::fromStdString(pp.card_provider_id()); + if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + PlaymatInfo playmat; + playmat.card.name = rawName; + playmat.card.providerId = rawProviderId; + playmat.params.marginPctL = qBound(0.0, pp.margin_pct_l(), 0.95); + playmat.params.marginPctR = qBound(0.0, pp.margin_pct_r(), 0.95); + playmat.params.verticalOffset = qBound(0.0, pp.vertical_offset(), 1.0); + playmat.params.zoom = qBound(0.1, pp.zoom(), 4.0); + deck->setPlaymat(playmat); + + Event_PlayerPropertiesChanged event; + auto *props = event.mutable_player_properties(); + props->set_sideboard_locked(sideboardLocked); + props->set_deck_hash(deck->getDeckHash().toStdString()); + auto *playmatParams = props->mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); + ges.enqueueGameEvent(event, playerId); + + return Response::RespOk; +} + void Server_Player::getInfo(ServerInfo_Player *info, Server_AbstractParticipant *recipient, bool omniscient, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h index 5925ed3c2..c35428480 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h @@ -62,6 +62,8 @@ public: cmdSetActivePhase(const Command_SetActivePhase &cmd, ResponseContainer &rc, GameEventStorage &ges) override; Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges) override; + Response::ResponseCode + cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) override; Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges) override; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 20a4cb08d..6a9e40d2d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -46,6 +46,7 @@ set(PROTO_FILES command_set_card_attr.proto command_set_card_counter.proto command_set_counter.proto + command_set_playmat.proto command_set_sideboard_lock.proto command_set_sideboard_plan.proto command_shuffle.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto new file mode 100644 index 000000000..ad9a29a44 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "game_commands.proto"; +import "serverinfo_playerproperties.proto"; +message Command_SetPlaymat { + extend GameCommand { + optional Command_SetPlaymat ext = 1035; + } + optional ServerInfo_PlayerProperties.PlaymatParams playmat_params = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 8ecf7ec9c..2e5b88978 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -175,6 +175,11 @@ message GameCommand { /// Server: Server_Player::cmdReverseTurn /// Client: reflected via subsequent turn events REVERSE_TURN = 1034; + + /// Set the player's playmat independently of the deck. + /// Server: Server_Player::cmdSetPlaymat + /// Client: reflected via player properties changed event + SET_PLAYMAT = 1035; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto index cdd0ee42c..ae19e4018 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto @@ -2,6 +2,15 @@ syntax = "proto2"; import "serverinfo_user.proto"; message ServerInfo_PlayerProperties { + message PlaymatParams { + optional string card_name = 1; + optional string card_provider_id = 2; + optional double margin_pct_l = 3 [default = 0.07]; + optional double margin_pct_r = 4 [default = 0.07]; + optional double vertical_offset = 5 [default = 0.33]; + optional double zoom = 6 [default = 1.0]; + } + optional sint32 player_id = 1; optional ServerInfo_User user_info = 2; optional bool spectator = 3; @@ -11,4 +20,5 @@ message ServerInfo_PlayerProperties { optional sint32 ping_seconds = 7; optional bool sideboard_locked = 8; optional bool judge = 9; + optional PlaymatParams playmat_params = 10; } diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 2f0718533..b92a5fdec 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -1,5 +1,35 @@ #include "interface_settings.h" +namespace +{ +const QChar PLAYMAT_FIELD_SEP = QChar(0x1F); ///< Separator between PlaymatInfo fields. + +QString encodePlaymatInfo(const PlaymatInfo &res) +{ + return res.card.name + PLAYMAT_FIELD_SEP + res.card.providerId + PLAYMAT_FIELD_SEP + + QString::number(res.params.marginPctL, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.marginPctR, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.verticalOffset, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.zoom, 'f', 4); +} + +PlaymatInfo decodePlaymatInfo(const QString &encoded) +{ + const QStringList fields = encoded.split(PLAYMAT_FIELD_SEP); + if (fields.size() != 6) { + return {}; + } + PlaymatInfo res; + res.card.name = fields.at(0); + res.card.providerId = fields.at(1); + res.params.marginPctL = fields.at(2).toDouble(); + res.params.marginPctR = fields.at(3).toDouble(); + res.params.verticalOffset = fields.at(4).toDouble(); + res.params.zoom = fields.at(5).toDouble(); + return res; +} +} // namespace + InterfaceSettings::InterfaceSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "interface.ini", "interface", QString(), parent) { @@ -160,6 +190,35 @@ bool InterfaceSettings::getShowGameSelectorFilterToolbar() const return getValue("showGameSelectorFilterToolbar", QString(), QString(), true).toBool(); } +int InterfaceSettings::getPlaymatVisibility() const +{ + return qBound(0, getValue("playmatvisibility", QString(), QString(), 2).toInt(), 2); +} + +QList InterfaceSettings::getPlaymatFallbackList() const +{ + const QStringList entries = getValue("playmatFallbackList", QString(), QString(), QStringList()).toStringList(); + QList result; + result.reserve(entries.size()); + for (const QString &entry : entries) { + const PlaymatInfo res = decodePlaymatInfo(entry); + if (!res.card.isEmpty()) { + result.append(res); + } + } + return result; +} + +int InterfaceSettings::getPlaymatMode() const +{ + return qBound(0, getValue("playmatMode", QString(), QString(), 1).toInt(), 2); +} + +int InterfaceSettings::getPlaymatFallbackBehavior() const +{ + return qBound(0, getValue("playmatFallbackBehavior", QString(), QString(), 0).toInt(), 2); +} + bool InterfaceSettings::getLifeCounterAnimationsEnabled() const { return getValue("lifeCounterAnimationsEnabled", QString(), QString(), true).toBool(); @@ -343,6 +402,46 @@ 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::setPlaymatFallbackList(const QList &_fallbackList) +{ + QStringList entries; + entries.reserve(_fallbackList.size()); + for (const PlaymatInfo &res : _fallbackList) { + entries.append(encodePlaymatInfo(res)); + } + setValue(entries, "playmatFallbackList"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setPlaymatMode(int _mode) +{ + const int mode = qBound(0, _mode, 2); + if (getPlaymatMode() == mode) { + return; + } + setValue(mode, "playmatMode"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setPlaymatFallbackBehavior(int _behavior) +{ + const int behavior = qBound(0, _behavior, 2); + if (getPlaymatFallbackBehavior() == behavior) { + return; + } + setValue(behavior, "playmatFallbackBehavior"); + emit playmatSettingsChanged(); +} + 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..6b226f307 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -42,6 +42,10 @@ public: [[nodiscard]] bool getShowStatusBar() const override; [[nodiscard]] bool getShowShortcuts() const override; [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; + [[nodiscard]] int getPlaymatVisibility() const override; + [[nodiscard]] QList getPlaymatFallbackList() const override; + [[nodiscard]] int getPlaymatMode() const override; + [[nodiscard]] int getPlaymatFallbackBehavior() const override; [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; [[nodiscard]] bool getBattlefieldFlashEnabled() const override; [[nodiscard]] QStringList getUserListExpandedSections() const override; @@ -77,6 +81,10 @@ public: void setShowStatusBar(bool _showStatusBar); void setShowShortcuts(bool _showShortcuts); void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); + void setPlaymatVisibility(int _visibility); + void setPlaymatFallbackList(const QList &_fallbackList); + void setPlaymatMode(int _mode); + void setPlaymatFallbackBehavior(int _behavior); void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); void setUserListExpandedSections(const QStringList §ions); @@ -91,6 +99,8 @@ signals: void tallyTypeChanged(int type); void showStatusBarChanged(bool state); void showGameSelectorFilterToolbarChanged(bool state); + void playmatVisibilityChanged(int visibility); + void playmatSettingsChanged(); void lifeCounterAnimationsEnabledChanged(bool state); void battlefieldFlashEnabledChanged(bool state); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 79f5a11e4..3a81f179a 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -10,11 +10,13 @@ set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/l ) set(UTILITY_HEADERS + libcockatrice/utility/card_ref.h libcockatrice/utility/color.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h + libcockatrice/utility/playmat_params.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h libcockatrice/utility/counter_limits.h diff --git a/libcockatrice_utility/libcockatrice/utility/playmat_params.h b/libcockatrice_utility/libcockatrice/utility/playmat_params.h new file mode 100644 index 000000000..c64a6f2ae --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/playmat_params.h @@ -0,0 +1,52 @@ +#ifndef COCKATRICE_PLAYMAT_PARAMS_H +#define COCKATRICE_PLAYMAT_PARAMS_H + +#include "card_ref.h" + +#include + +/** + * @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); + } +}; + +/** + * @struct PlaymatInfo + * @ingroup Decks + * @brief A resolved playmat (card + positioning parameters). + */ +struct PlaymatInfo +{ + CardRef card; ///< The card whose art is used as playmat. + PlaymatParams params; ///< Positioning parameters for the playmat card image. + + bool operator==(const PlaymatInfo &other) const + { + return card == other.card && params == other.params; + } +}; + +#endif // COCKATRICE_PLAYMAT_PARAMS_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1c3f4c2c6..51809912b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_test(NAME expression_test COMMAND expression_test) add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test) add_test(NAME test_age_formatting COMMAND test_age_formatting) add_test(NAME password_hash_test COMMAND password_hash_test) +add_test(NAME playmat_resolver_test COMMAND playmat_resolver_test) add_test(NAME server_card_counter_test COMMAND server_card_counter_test) add_test(NAME server_counter_test COMMAND server_counter_test) add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) @@ -21,6 +22,7 @@ add_executable(expression_test expression_test.cpp) add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp) add_executable(test_age_formatting test_age_formatting.cpp) add_executable(password_hash_test password_hash_test.cpp) +add_executable(playmat_resolver_test playmat_resolver_test.cpp) add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp) @@ -56,6 +58,7 @@ if(NOT GTEST_FOUND) add_dependencies(clamped_arithmetic_test gtest) add_dependencies(test_age_formatting gtest) add_dependencies(password_hash_test gtest) + add_dependencies(playmat_resolver_test gtest) add_dependencies(deck_hash_performance_test gtest) add_dependencies(server_card_counter_test gtest) add_dependencies(server_counter_test gtest) @@ -74,6 +77,10 @@ target_link_libraries( target_link_libraries( password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + playmat_resolver_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} diff --git a/tests/playmat_resolver_test.cpp b/tests/playmat_resolver_test.cpp new file mode 100644 index 000000000..a931c0647 --- /dev/null +++ b/tests/playmat_resolver_test.cpp @@ -0,0 +1,126 @@ +#include +#include +#include + +namespace +{ + +PlaymatInfo makePlaymatInfo(const QString &name, const QString &providerId = QString()) +{ + PlaymatInfo info; + info.card = {name, providerId}; + return info; +} + +} // namespace + +TEST(PlaymatResolverTest, EmptyChainReturnsEmpty) +{ + DeckList deck; + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, {}, PlaymatFallbackModeFixed, 0); + EXPECT_TRUE(resolved.card.isEmpty()); +} + +TEST(PlaymatResolverTest, OverrideWinsOverDeckAndFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")}, {}}); + + const PlaymatInfo force = makePlaymatInfo(QStringLiteral("Force Mat"), QStringLiteral("force-provider")); + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, force, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Force Mat")); + EXPECT_EQ(resolved.card.providerId, QStringLiteral("force-provider")); +} + +TEST(PlaymatResolverTest, DeckWinsOverFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")}, {0.1, 0.2, 0.3, 1.5}}); + + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat")); + EXPECT_EQ(resolved.card.providerId, QStringLiteral("deck-provider")); + EXPECT_DOUBLE_EQ(resolved.params.marginPctL, 0.1); + EXPECT_DOUBLE_EQ(resolved.params.verticalOffset, 0.3); +} + +TEST(PlaymatResolverTest, FallbackUsedWhenDeckHasNone) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Fallback Mat")); +} + +TEST(PlaymatResolverTest, FixedAlwaysUsesFirst) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second"))}; + + for (int i = 0; i < 5; ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, i); + EXPECT_EQ(resolved.card.name, QStringLiteral("First")); + } +} + +TEST(PlaymatResolverTest, RoundRobinCyclesAndWraps) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second")), + makePlaymatInfo(QStringLiteral("Third"))}; + + const QStringList expected = {QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third"), + QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third")}; + for (int i = 0; i < expected.size(); ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRoundRobin, i); + EXPECT_EQ(resolved.card.name, expected.at(i)); + } +} + +TEST(PlaymatResolverTest, RoundRobinRespectsCursor) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRoundRobin, 5); + EXPECT_EQ(resolved.card.name, QStringLiteral("Second")); // 5 % 2 == 1 +} + +TEST(PlaymatResolverTest, RandomStaysWithinList) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second")), + makePlaymatInfo(QStringLiteral("Third"))}; + + for (int i = 0; i < 50; ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRandom, i); + ASSERT_FALSE(resolved.card.name.isEmpty()); + EXPECT_TRUE(fallback.contains(resolved)); + } +} + +TEST(PlaymatResolverTest, ForceWithEmptyCardIgnoresFallbackParamsButNotFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat")}, {}}); + + // An empty force entry must not mask the deck-configured playmat. + const PlaymatInfo emptyForce; + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, emptyForce, {}, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat")); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}