[Playmats] Add fixed override and configurable fallbacks to settings.

Took 29 minutes

Took 43 seconds
This commit is contained in:
Lukas Brübach 2026-08-13 12:40:04 +02:00
parent 92c8f1a1a1
commit fa669544d3
19 changed files with 845 additions and 16 deletions

View file

@ -240,8 +240,12 @@ 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/playmat/playmat_settings_utils.cpp
src/interface/widgets/playmat/playmat_settings_utils.h
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

View file

@ -8,6 +8,7 @@
#include "../../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h"
#include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h"
#include "../../interface/widgets/dialogs/dlg_load_remote_deck.h"
#include "../../interface/widgets/playmat/playmat_settings_utils.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "deck_view.h"
@ -277,7 +278,11 @@ void DeckViewContainer::loadDeckFromFile(const QString &filePath)
void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck)
{
QString deckString = deck.writeToString_Native();
// Bake the user-level playmat resolution (override > deck > fallback list)
// into the uploaded deck so the server, local and remote players, and
// replays all derive the same effective playmat.
const DeckList effectiveDeck = applyUserPlaymatSettings(deck, SettingsCache::instance().userInterface());
QString deckString = effectiveDeck.writeToString_Native();
if (deckString.length() > MAX_FILE_LENGTH) {
QMessageBox::critical(this, tr("Error"), tr("Deck is greater than maximum file size."));

View file

@ -3,6 +3,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../playmat/playmat_settings_dialog.h"
#include "../playmat/playmat_settings_utils.h"
#include "../settings_page/user_interface_settings_page.h"
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
@ -17,6 +18,7 @@
#include <QTextEdit>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/macros.h>
#include <libcockatrice/utility/string_limits.h>
@ -468,6 +470,14 @@ void DeckEditorDeckDockWidget::openPlaymatSettings()
deckStateManager->setPlaymatCard(newCard);
deckStateManager->setPlaymatParams(newParams);
}
// "Also set as my default playmat": make this the fixed user-level
// fallback, so decks without a playmat of their own use it.
if (dialog.useAsDefault() && !newCard.isEmpty()) {
auto &interfaceSettings = SettingsCache::instance().userInterface();
interfaceSettings.setPlaymatFallbackList({storedFromResolution({newCard, newParams})});
interfaceSettings.setPlaymatFallbackMode(0); // PlaymatFallbackMode::Fixed
}
updatePlaymatLabel();
}
}

View file

@ -0,0 +1,193 @@
#include "playmat_collection_dialog.h"
#include "../../../client/settings/cache_settings.h"
#include "playmat_settings_dialog.h"
#include "playmat_settings_utils.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/interface_settings.h>
PlaymatCollectionDialog::PlaymatCollectionDialog(QWidget *parent) : QDialog(parent)
{
setMinimumWidth(420);
setupUi();
retranslateUi();
}
void PlaymatCollectionDialog::accept()
{
auto &interfaceSettings = SettingsCache::instance().userInterface();
interfaceSettings.setPlaymatFallbackList(playmats);
interfaceSettings.setPlaymatFallbackMode(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 StoredPlaymat &entry : playmats) {
playmatList->addItem(entry.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(), 0);
modeCombo->addItem(QString(), 1);
modeCombo->addItem(QString(), 2);
const int modeIndex = modeCombo->findData(interfaceSettings.getPlaymatFallbackMode());
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::appendEntry(const StoredPlaymat &entry)
{
playmats.append(entry);
playmatList->addItem(entry.name);
playmatList->setCurrentRow(playmatList->count() - 1);
}
void PlaymatCollectionDialog::addPlaymat()
{
PlaymatSettingsDialog dialog(CardRef{}, PlaymatParams{}, this);
if (dialog.exec() == QDialog::Accepted) {
const CardRef card = dialog.card();
if (!card.isEmpty()) {
appendEntry(storedFromResolution({card, dialog.params()}));
}
}
}
void PlaymatCollectionDialog::editPlaymat()
{
const int row = currentRow();
if (row < 0) {
return;
}
const PlaymatResolution current = resolutionFromStoredPlaymat(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] = storedFromResolution({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("When a deck has no playmat:"));
modeCombo->setItemText(0, tr("Always use the first playmat"));
modeCombo->setItemText(1, tr("Cycle through playmats (one per game)"));
modeCombo->setItemText(2, tr("Pick a random playmat per game"));
}

View file

@ -0,0 +1,55 @@
#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H
#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H
#include <QDialog>
#include <libcockatrice/interfaces/interface_interface_settings_provider.h>
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;
void appendEntry(const StoredPlaymat &entry);
QList<StoredPlaymat> 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

View file

@ -7,6 +7,7 @@
#include "card_database_model.h"
#include "playmat_preview_widget.h"
#include <QCheckBox>
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
@ -27,7 +28,6 @@ PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard,
QWidget *parent)
: QDialog(parent), currentCard(initialCard), currentParams(initialParams)
{
setWindowTitle(tr("Playmat Settings"));
setMinimumWidth(500);
setupUi();
@ -53,6 +53,8 @@ PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard,
marginRSpin->setValue(initialParams.marginPctR);
verticalOffsetSpin->setValue(initialParams.verticalOffset);
zoomSpin->setValue(initialParams.zoom);
retranslateUi();
}
CardRef PlaymatSettingsDialog::card() const
@ -65,6 +67,11 @@ PlaymatParams PlaymatSettingsDialog::params() const
return currentParams;
}
bool PlaymatSettingsDialog::useAsDefault() const
{
return useAsDefaultCheckBox->isChecked();
}
QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, double value, double step)
{
auto *spin = new QDoubleSpinBox;
@ -78,7 +85,6 @@ QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, doubl
void PlaymatSettingsDialog::initializeSearchBar()
{
searchBar = new QLineEdit;
searchBar->setPlaceholderText(tr("Type a card name..."));
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
@ -121,36 +127,46 @@ void PlaymatSettingsDialog::setupUi()
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
auto *form = new QFormLayout;
form->addRow(tr("Card name:"), searchBar);
form->addRow(tr("Printing:"), providerComboBox);
form->addRow(tr("Left margin (%):"), marginLSpin);
form->addRow(tr("Right margin (%):"), marginRSpin);
form->addRow(tr("Vertical offset:"), verticalOffsetSpin);
form->addRow(tr("Zoom:"), zoomSpin);
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);
auto *controlsGroup = new QGroupBox(tr("Parameters"));
controlsGroup = new QGroupBox;
controlsGroup->setLayout(form);
useAsDefaultCheckBox = new QCheckBox;
useAsDefaultCheckBox->setEnabled(false);
preview = new PlaymatPreviewWidget;
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
auto *previewGroup = new QGroupBox(tr("Preview"));
previewGroup = new QGroupBox;
previewGroup->setLayout(previewLayout);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
auto *removeBtn = new QPushButton(tr("Remove Playmat"));
buttons->addButton(removeBtn, QDialogButtonBox::ResetRole);
removeButton = new QPushButton;
buttons->addButton(removeButton, QDialogButtonBox::ResetRole);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(removeBtn, &QPushButton::clicked, this, [this]() {
connect(removeButton, &QPushButton::clicked, this, [this]() {
currentCard = CardRef{}; // empty signals removal
accept();
});
auto *root = new QVBoxLayout;
root->addWidget(controlsGroup);
root->addWidget(useAsDefaultCheckBox);
root->addWidget(previewGroup);
root->addWidget(buttons);
setLayout(root);
@ -190,6 +206,8 @@ void PlaymatSettingsDialog::onCardNameChanged(const QString &name)
if (name.isEmpty()) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
useAsDefaultCheckBox->setChecked(false);
useAsDefaultCheckBox->setEnabled(false);
return;
}
@ -198,10 +216,13 @@ void PlaymatSettingsDialog::onCardNameChanged(const QString &name)
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
providerComboBox->clear();
useAsDefaultCheckBox->setChecked(false);
useAsDefaultCheckBox->setEnabled(false);
return;
}
currentCard.name = name;
useAsDefaultCheckBox->setEnabled(true);
populateProviderCombo(name);
@ -254,3 +275,19 @@ void PlaymatSettingsDialog::onParamChanged()
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"));
useAsDefaultCheckBox->setText(tr("Also set as my default playmat (for decks without one)"));
previewGroup->setTitle(tr("Preview"));
removeButton->setText(tr("Remove Playmat"));
}

View file

@ -5,10 +5,14 @@
#include <QPixmap>
#include <libcockatrice/deck_list/deck_list.h>
class QCheckBox;
class QComboBox;
class QCompleter;
class QLineEdit;
class QDoubleSpinBox;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
@ -35,6 +39,12 @@ public:
CardRef card() const;
PlaymatParams params() const;
/**
* @brief Whether the user also wants the configured playmat saved as the
* user-level default (fallback for decks without one).
*/
bool useAsDefault() const;
private slots:
void onCardNameChanged(const QString &name);
void reloadPreview();
@ -44,6 +54,7 @@ private:
void setupUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
void retranslateUi();
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
QLineEdit *searchBar;
@ -57,6 +68,18 @@ private:
QMetaObject::Connection pixmapUpdatedConnection;
QCheckBox *useAsDefaultCheckBox;
QLabel *cardNameLabel;
QLabel *printingLabel;
QLabel *leftMarginLabel;
QLabel *rightMarginLabel;
QLabel *verticalOffsetLabel;
QLabel *zoomLabel;
QGroupBox *controlsGroup;
QGroupBox *previewGroup;
QPushButton *removeButton;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;

View file

@ -0,0 +1,48 @@
#include "playmat_settings_utils.h"
#include <libcockatrice/interfaces/interface_interface_settings_provider.h>
#include <libcockatrice/settings/interface_settings.h>
PlaymatResolution resolutionFromStoredPlaymat(const StoredPlaymat &stored)
{
PlaymatResolution resolution;
resolution.card = {stored.name, stored.providerId};
resolution.params = {qBound(0.0, stored.marginPctL, 0.95), qBound(0.0, stored.marginPctR, 0.95),
qBound(0.0, stored.verticalOffset, 1.0), qBound(0.1, stored.zoom, 4.0)};
return resolution;
}
StoredPlaymat storedFromResolution(const PlaymatResolution &resolution)
{
StoredPlaymat stored;
stored.name = resolution.card.name;
stored.providerId = resolution.card.providerId;
stored.marginPctL = resolution.params.marginPctL;
stored.marginPctR = resolution.params.marginPctR;
stored.verticalOffset = resolution.params.verticalOffset;
stored.zoom = resolution.params.zoom;
return stored;
}
DeckList applyUserPlaymatSettings(const DeckList &deck, const InterfaceSettings &settings)
{
// Per-session cursor for the round-robin mode; advancing per resolution
// (per deck upload, i.e. roughly per game) cycles deterministically.
static int rotationIndex = 0;
QList<PlaymatResolution> fallbackList;
const QList<StoredPlaymat> storedList = settings.getPlaymatFallbackList();
fallbackList.reserve(storedList.size());
for (const StoredPlaymat &stored : storedList) {
fallbackList.append(resolutionFromStoredPlaymat(stored));
}
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, resolutionFromStoredPlaymat(settings.getPlaymatOverride()), fallbackList,
static_cast<PlaymatFallbackMode>(settings.getPlaymatFallbackMode()), rotationIndex);
DeckList effectiveDeck = deck;
effectiveDeck.setPlaymatCard(resolved.card);
effectiveDeck.setPlaymatParams(resolved.params);
return effectiveDeck;
}

View file

@ -0,0 +1,32 @@
#ifndef COCKATRICE_PLAYMAT_SETTINGS_UTILS_H
#define COCKATRICE_PLAYMAT_SETTINGS_UTILS_H
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/interfaces/interface_interface_settings_provider.h>
class InterfaceSettings;
/**
* @brief Converts a stored (settings-level) playmat entry into resolver types,
* clamping the positioning parameters to their valid ranges.
*/
PlaymatResolution resolutionFromStoredPlaymat(const StoredPlaymat &stored);
/**
* @brief Converts a resolved playmat into a stored (settings-level) entry.
*/
StoredPlaymat storedFromResolution(const PlaymatResolution &resolution);
/**
* @brief Applies the user-level playmat settings (override, fallback list and
* mode) to a copy of the deck, returning the deck with the effective
* playmat baked into its metadata.
*
* The resolution chain is: override > deck-configured playmat > fallback list.
* The deck passed in is never modified; the returned copy is intended for
* upload to the server so local and remote players see the same effective mat.
*/
DeckList applyUserPlaymatSettings(const DeckList &deck, const InterfaceSettings &settings);
#endif // COCKATRICE_PLAYMAT_SETTINGS_UTILS_H

View file

@ -7,11 +7,15 @@
#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 "../playmat/playmat_settings_utils.h"
#include <QApplication>
#include <QColorDialog>
#include <QDesktopServices>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QStyleFactory>
#include <QTimer>
@ -329,12 +333,27 @@ AppearanceSettingsPage::AppearanceSettingsPage()
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// User-level playmat settings: override (force all decks) and fallback collection.
connect(&playmatOverrideEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openOverridePlaymatSettings);
connect(&playmatOverrideClearButton, &QPushButton::clicked, this, &AppearanceSettingsPage::clearOverridePlaymat);
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *overrideButtons = new QHBoxLayout;
overrideButtons->addWidget(&playmatOverrideEditButton);
overrideButtons->addWidget(&playmatOverrideClearButton);
auto *tableGrid = new QGridLayout;
tableGrid->addWidget(&invertVerticalCoordinateCheckBox, 0, 0, 1, 2);
tableGrid->addWidget(&minPlayersForMultiColumnLayoutLabel, 1, 0, 1, 1);
tableGrid->addWidget(&minPlayersForMultiColumnLayoutEdit, 1, 1, 1, 1);
tableGrid->addWidget(&playmatVisibilityLabel, 2, 0, 1, 1);
tableGrid->addWidget(&playmatVisibilityCombo, 2, 1, 1, 1);
tableGrid->addWidget(&playmatOverrideLabel, 3, 0, 1, 1);
tableGrid->addLayout(overrideButtons, 3, 1, 1, 1);
tableGrid->addWidget(&playmatDefaultLabel, 4, 0, 1, 1);
tableGrid->addWidget(&playmatDefaultEditButton, 4, 1, 1, 1);
tableGroupBox = new QGroupBox;
tableGroupBox->setLayout(tableGrid);
@ -445,6 +464,42 @@ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value)
}
}
void AppearanceSettingsPage::openOverridePlaymatSettings()
{
auto &interfaceSettings = SettingsCache::instance().userInterface();
const PlaymatResolution current = resolutionFromStoredPlaymat(interfaceSettings.getPlaymatOverride());
PlaymatSettingsDialog dialog(current.card, current.params, this);
if (dialog.exec() == QDialog::Accepted) {
const CardRef card = dialog.card();
if (card.isEmpty()) {
interfaceSettings.setPlaymatOverride(StoredPlaymat{});
} else {
interfaceSettings.setPlaymatOverride(storedFromResolution({card, dialog.params()}));
}
updatePlaymatSummary();
}
}
void AppearanceSettingsPage::clearOverridePlaymat()
{
SettingsCache::instance().userInterface().setPlaymatOverride(StoredPlaymat{});
updatePlaymatSummary();
}
void AppearanceSettingsPage::openPlaymatCollectionDialog()
{
PlaymatCollectionDialog dialog(this);
dialog.exec();
updatePlaymatSummary();
}
void AppearanceSettingsPage::updatePlaymatSummary()
{
const StoredPlaymat overridePlaymat = SettingsCache::instance().userInterface().getPlaymatOverride();
playmatOverrideEditButton.setText(overridePlaymat.isEmpty() ? tr("Set...")
: tr("Change (%1)").arg(overridePlaymat.name));
}
void AppearanceSettingsPage::retranslateUi()
{
themeGroupBox->setTitle(tr("Theme settings"));
@ -504,4 +559,9 @@ void AppearanceSettingsPage::retranslateUi()
invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate"));
minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:"));
playmatVisibilityLabel.setText(tr("Playmat visibility:"));
}
playmatOverrideLabel.setText(tr("Force playmat for all decks:"));
playmatOverrideClearButton.setText(tr("Clear"));
playmatDefaultLabel.setText(tr("Default playmats (when a deck has none):"));
playmatDefaultEditButton.setText(tr("Edit..."));
updatePlaymatSummary();
}

View file

@ -24,6 +24,10 @@ private slots:
void cardViewInitialRowsMaxChanged(int value);
void cardViewExpandedRowsMaxChanged(int value);
void openOverridePlaymatSettings();
void clearOverridePlaymat();
void openPlaymatCollectionDialog();
void updatePlaymatSummary();
private:
QLabel themeLabel;
@ -61,6 +65,11 @@ private:
QCheckBox invertVerticalCoordinateCheckBox;
QLabel playmatVisibilityLabel;
QComboBox playmatVisibilityCombo;
QLabel playmatOverrideLabel;
QPushButton playmatOverrideEditButton;
QPushButton playmatOverrideClearButton;
QLabel playmatDefaultLabel;
QPushButton playmatDefaultEditButton;
QGroupBox *themeGroupBox;
QGroupBox *homeTabGroupBox;
QGroupBox *stylingGroupBox;

View file

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

View file

@ -0,0 +1,33 @@
#include "playmat_resolver.h"
#include <QRandomGenerator>
PlaymatResolution resolveEffectivePlaymat(const DeckList &deck,
const PlaymatResolution &force,
const QList<PlaymatResolution> &fallbackList,
PlaymatFallbackMode fallbackMode,
int &rotationIndex)
{
if (!force.card.isEmpty()) {
return force;
}
if (!deck.getPlaymatCard().isEmpty()) {
return {deck.getPlaymatCard(), deck.getPlaymatParams()};
}
if (fallbackList.isEmpty()) {
return {};
}
switch (fallbackMode) {
case PlaymatFallbackMode::Fixed:
return fallbackList.first();
case PlaymatFallbackMode::RoundRobin:
return fallbackList.at(rotationIndex++ % fallbackList.size());
case PlaymatFallbackMode::Random:
return fallbackList.at(QRandomGenerator::global()->bounded(fallbackList.size()));
}
return {};
}

View file

@ -0,0 +1,54 @@
#ifndef COCKATRICE_PLAYMAT_RESOLVER_H
#define COCKATRICE_PLAYMAT_RESOLVER_H
#include "deck_list.h"
/**
* @brief How the user-level fallback playmat list is consulted when a deck has
* no playmat configured.
*/
enum class PlaymatFallbackMode
{
Fixed = 0, ///< Always use the first entry of the fallback list.
RoundRobin = 1, ///< Cycle through the list, advancing one entry per resolution.
Random = 2 ///< Pick a random entry per resolution.
};
/**
* @struct PlaymatResolution
* @ingroup Decks
* @brief A resolved playmat (card + positioning parameters).
*/
struct PlaymatResolution
{
CardRef card; ///< The card whose art is used as playmat.
PlaymatParams params; ///< Positioning parameters for the playmat card image.
bool operator==(const PlaymatResolution &other) const
{
return card == other.card && params == other.params;
}
};
/**
* @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 PlaymatResolution::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 PlaymatFallbackMode::RoundRobin;
* advanced once per call. Unused for the other modes.
* @return The effective playmat; an empty @ref PlaymatResolution::card when
* nothing in the chain resolves.
*/
PlaymatResolution resolveEffectivePlaymat(const DeckList &deck,
const PlaymatResolution &force,
const QList<PlaymatResolution> &fallbackList,
PlaymatFallbackMode fallbackMode,
int &rotationIndex);
#endif // COCKATRICE_PLAYMAT_RESOLVER_H

View file

@ -1,9 +1,30 @@
#ifndef COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H
#define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H
#include <QList>
#include <QString>
#include <QStringList>
/**
* @struct StoredPlaymat
* @brief A user-level playmat entry as stored in the settings, independent of
* any deck. Mirrors a deck-configured playmat (card + parameters).
*/
struct StoredPlaymat
{
QString name; ///< Card name whose art is used as playmat.
QString providerId; ///< Printing provider id (uuid); may be empty.
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 isEmpty() const
{
return name.isEmpty();
}
};
class IInterfaceSettingsProvider
{
public:
@ -44,6 +65,15 @@ public:
[[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0;
[[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0;
[[nodiscard]] virtual int getPlaymatVisibility() const = 0;
/** @brief User-level fallback playmats, used when a deck has none configured. */
[[nodiscard]] virtual QList<StoredPlaymat> getPlaymatFallbackList() const = 0;
/** @brief User-level override playmat applied to all decks, or empty to disable. */
[[nodiscard]] virtual StoredPlaymat getPlaymatOverride() const = 0;
/** @brief How the fallback list is consulted (0 Fixed, 1 Round-robin, 2 Random). */
[[nodiscard]] virtual int getPlaymatFallbackMode() const = 0;
};
#endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H

View file

@ -1,5 +1,34 @@
#include "interface_settings.h"
namespace
{
const QChar PLAYMAT_FIELD_SEP = QChar(0x1F); ///< Separator between StoredPlaymat fields.
QString encodeStoredPlaymat(const StoredPlaymat &stored)
{
return stored.name + PLAYMAT_FIELD_SEP + stored.providerId + PLAYMAT_FIELD_SEP +
QString::number(stored.marginPctL, 'f', 4) + PLAYMAT_FIELD_SEP + QString::number(stored.marginPctR, 'f', 4) +
PLAYMAT_FIELD_SEP + QString::number(stored.verticalOffset, 'f', 4) + PLAYMAT_FIELD_SEP +
QString::number(stored.zoom, 'f', 4);
}
StoredPlaymat decodeStoredPlaymat(const QString &encoded)
{
const QStringList fields = encoded.split(PLAYMAT_FIELD_SEP);
if (fields.size() != 6) {
return {};
}
StoredPlaymat stored;
stored.name = fields.at(0);
stored.providerId = fields.at(1);
stored.marginPctL = fields.at(2).toDouble();
stored.marginPctR = fields.at(3).toDouble();
stored.verticalOffset = fields.at(4).toDouble();
stored.zoom = fields.at(5).toDouble();
return stored;
}
} // namespace
InterfaceSettings::InterfaceSettings(const QString &settingPath, QObject *parent)
: SettingsManager(settingPath + "interface.ini", "interface", QString(), parent)
{
@ -165,6 +194,30 @@ int InterfaceSettings::getPlaymatVisibility() const
return getValue("playmatvisibility", QString(), QString(), 2).toInt();
}
QList<StoredPlaymat> InterfaceSettings::getPlaymatFallbackList() const
{
const QStringList entries = getValue("playmatFallbackList", QString(), QString(), QStringList()).toStringList();
QList<StoredPlaymat> result;
result.reserve(entries.size());
for (const QString &entry : entries) {
const StoredPlaymat stored = decodeStoredPlaymat(entry);
if (!stored.isEmpty()) {
result.append(stored);
}
}
return result;
}
StoredPlaymat InterfaceSettings::getPlaymatOverride() const
{
return decodeStoredPlaymat(getValue("playmatOverride", QString(), QString(), QString()).toString());
}
int InterfaceSettings::getPlaymatFallbackMode() const
{
return qBound(0, getValue("playmatFallbackMode", QString(), QString(), 1).toInt(), 2);
}
bool InterfaceSettings::getLifeCounterAnimationsEnabled() const
{
return getValue("lifeCounterAnimationsEnabled", QString(), QString(), true).toBool();
@ -357,6 +410,33 @@ void InterfaceSettings::setPlaymatVisibility(int _visibility)
emit playmatVisibilityChanged(_visibility);
}
void InterfaceSettings::setPlaymatFallbackList(const QList<StoredPlaymat> &_fallbackList)
{
QStringList entries;
entries.reserve(_fallbackList.size());
for (const StoredPlaymat &stored : _fallbackList) {
entries.append(encodeStoredPlaymat(stored));
}
setValue(entries, "playmatFallbackList");
emit playmatSettingsChanged();
}
void InterfaceSettings::setPlaymatOverride(const StoredPlaymat &_override)
{
setValue(_override.isEmpty() ? QString() : encodeStoredPlaymat(_override), "playmatOverride");
emit playmatSettingsChanged();
}
void InterfaceSettings::setPlaymatFallbackMode(int _fallbackMode)
{
const int mode = qBound(0, _fallbackMode, 2);
if (getPlaymatFallbackMode() == mode) {
return;
}
setValue(mode, "playmatFallbackMode");
emit playmatSettingsChanged();
}
void InterfaceSettings::setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled)
{
setValue(_lifeCounterAnimationsEnabled, "lifeCounterAnimationsEnabled");

View file

@ -43,6 +43,9 @@ public:
[[nodiscard]] bool getShowShortcuts() const override;
[[nodiscard]] bool getShowGameSelectorFilterToolbar() const override;
[[nodiscard]] int getPlaymatVisibility() const override;
[[nodiscard]] QList<StoredPlaymat> getPlaymatFallbackList() const override;
[[nodiscard]] StoredPlaymat getPlaymatOverride() const override;
[[nodiscard]] int getPlaymatFallbackMode() const override;
[[nodiscard]] bool getLifeCounterAnimationsEnabled() const override;
[[nodiscard]] bool getBattlefieldFlashEnabled() const override;
[[nodiscard]] QStringList getUserListExpandedSections() const override;
@ -79,6 +82,9 @@ public:
void setShowShortcuts(bool _showShortcuts);
void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar);
void setPlaymatVisibility(int _visibility);
void setPlaymatFallbackList(const QList<StoredPlaymat> &_fallbackList);
void setPlaymatOverride(const StoredPlaymat &_override);
void setPlaymatFallbackMode(int _fallbackMode);
void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled);
void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled);
void setUserListExpandedSections(const QStringList &sections);
@ -94,6 +100,7 @@ signals:
void showStatusBarChanged(bool state);
void showGameSelectorFilterToolbarChanged(bool state);
void playmatVisibilityChanged(int visibility);
void playmatSettingsChanged();
void lifeCounterAnimationsEnabledChanged(bool state);
void battlefieldFlashEnabledChanged(bool state);

View file

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

View file

@ -0,0 +1,140 @@
#include <gtest/gtest.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
namespace
{
PlaymatResolution makeResolution(const QString &name, const QString &providerId = QString())
{
PlaymatResolution resolution;
resolution.card = {name, providerId};
return resolution;
}
} // namespace
TEST(PlaymatResolverTest, EmptyChainReturnsEmpty)
{
DeckList deck;
int rotationIndex = 0;
const PlaymatResolution resolved = resolveEffectivePlaymat(deck, {}, {}, PlaymatFallbackMode::Fixed, rotationIndex);
EXPECT_TRUE(resolved.card.isEmpty());
}
TEST(PlaymatResolverTest, OverrideWinsOverDeckAndFallback)
{
DeckList deck;
deck.setPlaymatCard({QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")});
const PlaymatResolution force = makeResolution(QStringLiteral("Force Mat"), QStringLiteral("force-provider"));
const QList<PlaymatResolution> fallback = {makeResolution(QStringLiteral("Fallback Mat"))};
int rotationIndex = 0;
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, force, fallback, PlaymatFallbackMode::Fixed, rotationIndex);
EXPECT_EQ(resolved.card.name, QStringLiteral("Force Mat"));
EXPECT_EQ(resolved.card.providerId, QStringLiteral("force-provider"));
}
TEST(PlaymatResolverTest, DeckWinsOverFallback)
{
DeckList deck;
deck.setPlaymatCard({QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")});
deck.setPlaymatParams({0.1, 0.2, 0.3, 1.5});
const QList<PlaymatResolution> fallback = {makeResolution(QStringLiteral("Fallback Mat"))};
int rotationIndex = 0;
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackMode::Fixed, rotationIndex);
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<PlaymatResolution> fallback = {makeResolution(QStringLiteral("Fallback Mat"))};
int rotationIndex = 0;
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackMode::Fixed, rotationIndex);
EXPECT_EQ(resolved.card.name, QStringLiteral("Fallback Mat"));
}
TEST(PlaymatResolverTest, FixedAlwaysUsesFirst)
{
DeckList deck;
const QList<PlaymatResolution> fallback = {makeResolution(QStringLiteral("First")),
makeResolution(QStringLiteral("Second"))};
int rotationIndex = 3; // ignored in Fixed mode
for (int i = 0; i < 5; ++i) {
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackMode::Fixed, rotationIndex);
EXPECT_EQ(resolved.card.name, QStringLiteral("First"));
}
}
TEST(PlaymatResolverTest, RoundRobinCyclesAndWraps)
{
DeckList deck;
const QList<PlaymatResolution> fallback = {makeResolution(QStringLiteral("First")),
makeResolution(QStringLiteral("Second")),
makeResolution(QStringLiteral("Third"))};
int rotationIndex = 0;
const QStringList expected = {QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third"),
QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third")};
for (const QString &name : expected) {
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackMode::RoundRobin, rotationIndex);
EXPECT_EQ(resolved.card.name, name);
}
EXPECT_EQ(rotationIndex, expected.size());
}
TEST(PlaymatResolverTest, RoundRobinRespectsExistingCursor)
{
DeckList deck;
const QList<PlaymatResolution> fallback = {makeResolution(QStringLiteral("First")),
makeResolution(QStringLiteral("Second"))};
int rotationIndex = 5;
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackMode::RoundRobin, rotationIndex);
EXPECT_EQ(resolved.card.name, QStringLiteral("Second")); // 5 % 2 == 1
EXPECT_EQ(rotationIndex, 6);
}
TEST(PlaymatResolverTest, RandomStaysWithinList)
{
DeckList deck;
const QList<PlaymatResolution> fallback = {makeResolution(QStringLiteral("First")),
makeResolution(QStringLiteral("Second")),
makeResolution(QStringLiteral("Third"))};
int rotationIndex = 0;
for (int i = 0; i < 50; ++i) {
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackMode::Random, rotationIndex);
ASSERT_FALSE(resolved.card.name.isEmpty());
EXPECT_TRUE(fallback.contains(resolved));
}
}
TEST(PlaymatResolverTest, ForceWithEmptyCardIgnoresFallbackParamsButNotFallback)
{
DeckList deck;
deck.setPlaymatCard({QStringLiteral("Deck Mat")});
// An empty force entry must not mask the deck-configured playmat.
const PlaymatResolution emptyForce;
int rotationIndex = 0;
const PlaymatResolution resolved =
resolveEffectivePlaymat(deck, emptyForce, {}, PlaymatFallbackMode::Fixed, rotationIndex);
EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat"));
}