diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4263fc6e2..aec67d426 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -53,6 +53,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp + src/interface/widgets/dialogs/dlg_tournament_settings.cpp src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp @@ -217,6 +218,7 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_state_manager.cpp src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp + src/interface/widgets/draft/tournament_widget.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/banner_widget.cpp @@ -394,6 +396,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_server.cpp src/interface/widgets/tabs/tab_supervisor.cpp src/interface/widgets/tabs/tab_visual_database_display.cpp + src/interface/widgets/tabs/tournament_tab_game_extension.cpp src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 59f3e033d..b931dcb29 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/widgets/tabs/tab_room.h" +#include "dlg_tournament_settings.h" #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -104,14 +106,29 @@ void DlgCreateGame::sharedCtor() shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + tournamentCheckBox = new QCheckBox(tr("Tournament mode")); + tournamentCheckBox->setToolTip(tr("All players submit a deck up front and rounds are paired automatically")); + tournamentSettingsButton = new QPushButton(tr("Settings...")); + tournamentSettingsButton->setEnabled(false); + connect(tournamentCheckBox, &QCheckBox::toggled, tournamentSettingsButton, &QPushButton::setEnabled); + connect(tournamentSettingsButton, &QPushButton::clicked, this, [this] { + DlgTournamentSettings dlg(this); + dlg.setCurrentGamesPerMatch(tournamentSettings.gamesPerMatch); + if (dlg.exec() == QDialog::Accepted) { + tournamentSettings = dlg.getResult(); + } + }); + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); auto *gameSetupOptionsLayout = new QGridLayout; gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0); + gameSetupOptionsLayout->addWidget(tournamentCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(tournamentSettingsButton, 2, 1); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0); } else { createGameAsJudgeCheckBox->setChecked(false); createGameAsJudgeCheckBox->setHidden(true); @@ -188,7 +205,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType } DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(0), gameTypes(_gameTypes) + : QDialog(parent), room(nullptr), gameTypes(_gameTypes) { sharedCtor(); @@ -205,6 +222,8 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetEnabled(false); startingLifeTotalEdit->setEnabled(false); shareDecklistsOnLoadCheckBox->setEnabled(false); + tournamentCheckBox->setEnabled(false); + tournamentSettingsButton->setEnabled(false); descriptionEdit->setText(QString::fromStdString(gameInfo.description())); maxPlayersEdit->setValue(gameInfo.max_players()); @@ -215,6 +234,10 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); + { + const QSignalBlocker blocker(tournamentCheckBox); + tournamentCheckBox->setChecked(gameInfo.is_tournament()); + } QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -252,6 +275,8 @@ void DlgCreateGame::actReset() startingLifeTotalEdit->setValue(20); shareDecklistsOnLoadCheckBox->setChecked(false); + tournamentCheckBox->setChecked(false); + tournamentSettings = DlgTournamentSettingsResult{}; createGameAsJudgeCheckBox->setChecked(false); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -282,6 +307,10 @@ void DlgCreateGame::actOK() cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); cmd.set_starting_life_total(startingLifeTotalEdit->value()); cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); + cmd.set_is_tournament(tournamentCheckBox->isChecked()); + if (tournamentCheckBox->isChecked()) { + cmd.mutable_tournament_settings()->set_games_per_match(tournamentSettings.gamesPerMatch); + } auto _gameTypes = QString(); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 61925286d..9f9294a0c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -7,6 +7,8 @@ #ifndef DLG_CREATEGAME_H #define DLG_CREATEGAME_H +#include "dlg_tournament_settings.h" + #include #include #include @@ -48,6 +50,9 @@ private: QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *tournamentCheckBox; + QPushButton *tournamentSettingsButton; + DlgTournamentSettingsResult tournamentSettings; QDialogButtonBox *buttonBox; QPushButton *clearButton; QCheckBox *rememberGameSettings; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp new file mode 100644 index 000000000..49dbf065a --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp @@ -0,0 +1,48 @@ +#include "dlg_tournament_settings.h" + +#include +#include +#include +#include +#include + +DlgTournamentSettings::DlgTournamentSettings(QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Tournament Settings")); + + auto *mainLayout = new QFormLayout(this); + + gamesPerMatchSpin = new QSpinBox(this); + gamesPerMatchSpin->setRange(1, 5); + gamesPerMatchSpin->setValue(1); + gamesPerMatchSpin->setToolTip(tr("Number of games per match (e.g., 3 for Best of 3)")); + mainLayout->addRow(tr("Games per match:"), gamesPerMatchSpin); + + QLabel *hintLabel = new QLabel(tr("Set to 3 for Best of 3, 5 for Best of 5, etc."), this); + hintLabel->setStyleSheet("color: palette(placeholderText);"); + mainLayout->addRow(QString(), hintLabel); + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTournamentSettings::actOK); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addRow(buttonBox); + + setFixedHeight(sizeHint().height()); +} + +DlgTournamentSettingsResult DlgTournamentSettings::getResult() const +{ + DlgTournamentSettingsResult result; + result.gamesPerMatch = gamesPerMatchSpin->value(); + return result; +} + +void DlgTournamentSettings::setCurrentGamesPerMatch(int n) +{ + gamesPerMatchSpin->setValue(n); +} + +void DlgTournamentSettings::actOK() +{ + accept(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h new file mode 100644 index 000000000..909274ad7 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h @@ -0,0 +1,30 @@ +#ifndef DLG_TOURNAMENT_SETTINGS_H +#define DLG_TOURNAMENT_SETTINGS_H + +#include + +class QDialogButtonBox; +class QSpinBox; + +struct DlgTournamentSettingsResult +{ + int gamesPerMatch = 1; +}; + +class DlgTournamentSettings : public QDialog +{ + Q_OBJECT +public: + explicit DlgTournamentSettings(QWidget *parent = nullptr); + DlgTournamentSettingsResult getResult() const; + void setCurrentGamesPerMatch(int n); + +private slots: + void actOK(); + +private: + QSpinBox *gamesPerMatchSpin; + QDialogButtonBox *buttonBox; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.cpp b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp new file mode 100644 index 000000000..e7aa356b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp @@ -0,0 +1,232 @@ +#include "tournament_widget.h" + +#include +#include +#include +#include +#include + +namespace +{ +const int PLAYER_COLUMN = 0; +const int DECK_COLUMN = 1; +const int WINS_COLUMN = 2; +const int LOSSES_COLUMN = 3; +const int DRAWS_COLUMN = 4; + +const int PLAYER1_COLUMN = 0; +const int SCORE_COLUMN = 1; +const int STATUS_COLUMN = 2; +const int PLAYER2_COLUMN = 3; +} // namespace + +TournamentWidget::TournamentWidget(QWidget *parent) : QWidget(parent) +{ + auto *mainLayout = new QVBoxLayout(this); + + statusLabel = new QLabel(this); + statusLabel->setStyleSheet("font-weight: bold;"); + mainLayout->addWidget(statusLabel); + + roundLabel = new QLabel(this); + mainLayout->addWidget(roundLabel); + + standingsTable = new QTableWidget(this); + standingsTable->setColumnCount(5); + standingsTable->horizontalHeader()->setStretchLastSection(true); + standingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + standingsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + standingsTable->verticalHeader()->setVisible(false); + mainLayout->addWidget(standingsTable); + + pairingsTable = new QTableWidget(this); + pairingsTable->setColumnCount(4); + pairingsTable->horizontalHeader()->setStretchLastSection(true); + pairingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + pairingsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + pairingsTable->verticalHeader()->setVisible(false); + mainLayout->addWidget(pairingsTable); + + openMatchButton = new QPushButton(this); + openMatchButton->setEnabled(false); + connect(openMatchButton, &QPushButton::clicked, this, [this]() { emit openMatchGameRequested(currentGameId); }); + mainLayout->addWidget(openMatchButton); + + retranslateUi(); +} + +void TournamentWidget::retranslateUi() +{ + standingsTable->setHorizontalHeaderLabels({tr("Player"), tr("Deck"), tr("W"), tr("L"), tr("D")}); + pairingsTable->setHorizontalHeaderLabels({tr("Player 1"), tr("Score"), tr("Status"), tr("Player 2")}); + openMatchButton->setText(tr("Open match game")); + + if (lastState.has_phase()) { + updateTournamentState(lastState); + } else { + statusLabel->setText(tr("Tournament")); + roundLabel->setVisible(true); + roundLabel->setText(tr("Waiting for the tournament to start")); + } +} + +void TournamentWidget::setLocalPlayerId(int playerId) +{ + localPlayerId = playerId; +} + +void TournamentWidget::setIsSpectator(bool spectator) +{ + isSpectator = spectator; +} + +QString TournamentWidget::getPlayerName(const Event_TournamentState &state, int playerId) const +{ + for (int i = 0; i < state.players_size(); ++i) { + if (state.players(i).player_id() == playerId) { + return QString::fromStdString(state.players(i).player_name()); + } + } + return tr("Player %1").arg(playerId); +} + +void TournamentWidget::updateTournamentState(const Event_TournamentState &state) +{ + lastState = state; + + int gamesPerMatch = getGamesPerMatch(state); + QString statusText; + switch (state.phase()) { + case Event_TournamentState::PHASE_DECK_BUILDING: + statusText = gamesPerMatch > 1 ? tr("Tournament - Deck building (Best of %1)").arg(gamesPerMatch) + : tr("Tournament - Deck building"); + break; + case Event_TournamentState::PHASE_PLAYING: + statusText = gamesPerMatch > 1 ? tr("Tournament - Playing (Best of %1)").arg(gamesPerMatch) + : tr("Tournament - Playing"); + break; + case Event_TournamentState::PHASE_FINISHED: + statusText = gamesPerMatch > 1 ? tr("Tournament - Finished (Best of %1)").arg(gamesPerMatch) + : tr("Tournament - Finished"); + break; + default: + statusText = tr("Tournament"); + break; + } + statusLabel->setText(statusText); + + // The server does not report rounds before pairing starts; avoid showing "Round 0 of 0". + bool roundStarted = state.current_round() > 0 || state.total_rounds() > 0; + roundLabel->setVisible(roundStarted); + if (roundStarted) { + roundLabel->setText(tr("Round %1 of %2").arg(state.current_round()).arg(state.total_rounds())); + } + + rebuildTables(state); + resolveCurrentGameId(state); + updateOpenMatchButton(); +} + +void TournamentWidget::updateOpenMatchButton() +{ + bool playingPhase = lastState.phase() == Event_TournamentState::PHASE_PLAYING; + openMatchButton->setVisible(playingPhase); + if (!playingPhase) { + return; + } + + if (hasOwnLivePairing) { + openMatchButton->setEnabled(true); + openMatchButton->setText(tr("Open match game")); + openMatchButton->setToolTip(tr("Switch to your current match game")); + } else if (isSpectator && hasAnyLivePairing) { + // Spectators have no pairing of their own but may watch any running match. + openMatchButton->setEnabled(true); + openMatchButton->setText(tr("Spectate live match")); + openMatchButton->setToolTip(tr("Watch a match that is currently running")); + } else { + openMatchButton->setEnabled(false); + openMatchButton->setText(tr("Open match game")); + openMatchButton->setToolTip(tr("No match game is running for you right now")); + } +} + +void TournamentWidget::rebuildTables(const Event_TournamentState &state) +{ + standingsTable->setRowCount(state.players_size()); + for (int i = 0; i < state.players_size(); ++i) { + const auto &player = state.players(i); + + auto *nameItem = new QTableWidgetItem(QString::fromStdString(player.player_name())); + nameItem->setToolTip(QString::fromStdString(player.player_name())); + standingsTable->setItem(i, PLAYER_COLUMN, nameItem); + + auto *deckItem = new QTableWidgetItem(player.deck_submitted() ? tr("Submitted") : tr("Pending")); + deckItem->setToolTip(player.deck_submitted() ? tr("Deck submitted") : tr("Still choosing a deck")); + standingsTable->setItem(i, DECK_COLUMN, deckItem); + + standingsTable->setItem(i, WINS_COLUMN, new QTableWidgetItem(QString::number(player.wins()))); + standingsTable->setItem(i, LOSSES_COLUMN, new QTableWidgetItem(QString::number(player.losses()))); + standingsTable->setItem(i, DRAWS_COLUMN, new QTableWidgetItem(QString::number(player.draws()))); + } + + pairingsTable->setRowCount(state.pairings_size()); + for (int i = 0; i < state.pairings_size(); ++i) { + const auto &pairing = state.pairings(i); + + QString name1 = getPlayerName(state, pairing.player1_id()); + QString name2 = pairing.player2_id() == -1 ? tr("BYE") : getPlayerName(state, pairing.player2_id()); + + QString scoreStr; + if (getGamesPerMatch(state) > 1 && pairing.player2_id() != -1) { + scoreStr = tr("%1 - %2").arg(pairing.player1_match_wins()).arg(pairing.player2_match_wins()); + } + + QString statusStr; + if (pairing.player2_id() == -1) { + statusStr = tr("BYE"); + } else if (pairing.winner_id() != -1) { + statusStr = tr("Finished"); + } else { + statusStr = tr("vs"); + } + + pairingsTable->setItem(i, PLAYER1_COLUMN, new QTableWidgetItem(name1)); + pairingsTable->setItem(i, SCORE_COLUMN, new QTableWidgetItem(scoreStr)); + pairingsTable->setItem(i, STATUS_COLUMN, new QTableWidgetItem(statusStr)); + pairingsTable->setItem(i, PLAYER2_COLUMN, new QTableWidgetItem(name2)); + } +} + +int TournamentWidget::getGamesPerMatch(const Event_TournamentState &state) const +{ + return state.has_settings() ? state.settings().games_per_match() : 1; +} + +void TournamentWidget::resolveCurrentGameId(const Event_TournamentState &state) +{ + currentGameId = -1; + hasOwnLivePairing = false; + hasAnyLivePairing = false; + + for (int i = 0; i < state.pairings_size(); ++i) { + const auto &pairing = state.pairings(i); + if (pairing.game_id() == -1) { + continue; + } + if (!hasOwnLivePairing && localPlayerId != -1 && + (pairing.player1_id() == localPlayerId || pairing.player2_id() == localPlayerId)) { + currentGameId = pairing.game_id(); + hasOwnLivePairing = true; + } + if (!hasAnyLivePairing) { + hasAnyLivePairing = true; + if (!hasOwnLivePairing) { + currentGameId = pairing.game_id(); + } + } + if (hasOwnLivePairing && hasAnyLivePairing) { + return; + } + } +} diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.h b/cockatrice/src/interface/widgets/draft/tournament_widget.h new file mode 100644 index 000000000..4346c79df --- /dev/null +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.h @@ -0,0 +1,58 @@ +#ifndef TOURNAMENT_WIDGET_H +#define TOURNAMENT_WIDGET_H + +#include +#include + +class QLabel; +class QPushButton; +class QTableWidget; +class QTableWidgetItem; + +/** + * @class TournamentWidget + * @ingroup GameViews + * @brief Displays live standings and pairings for a tournament game. + * + * Updated exclusively via updateTournamentState(); the widget holds no + * logic of its own beyond mapping protocol state to table rows. Navigation + * decisions (which pairing belongs to the local player) are resolved here so + * callers only need to react to openMatchGameRequested(). + */ +class TournamentWidget : public QWidget +{ + Q_OBJECT +public: + explicit TournamentWidget(QWidget *parent = nullptr); + + void updateTournamentState(const Event_TournamentState &state); + void setLocalPlayerId(int playerId); + void setIsSpectator(bool spectator); + void retranslateUi(); + +signals: + /*! Emitted when the user asks to open the match game of their current pairing. */ + void openMatchGameRequested(int gameId); + +private: + void rebuildTables(const Event_TournamentState &state); + void resolveCurrentGameId(const Event_TournamentState &state); + void updateOpenMatchButton(); + [[nodiscard]] QString getPlayerName(const Event_TournamentState &state, int playerId) const; + [[nodiscard]] int getGamesPerMatch(const Event_TournamentState &state) const; + + QLabel *statusLabel; + QLabel *roundLabel; + QTableWidget *standingsTable; + QTableWidget *pairingsTable; + QPushButton *openMatchButton; + + int localPlayerId = -1; + bool isSpectator = false; + int currentGameId = -1; + bool hasOwnLivePairing = false; + bool hasAnyLivePairing = false; + Event_TournamentState lastState; +}; + +#endif // TOURNAMENT_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 035ab1004..5505d02da 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -33,6 +33,7 @@ #include "card_database_display_model.h" #include "card_database_model.h" #include "tab_supervisor.h" +#include "tournament_tab_game_extension.h" #include #include @@ -134,6 +135,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, createMenuItems(); createViewMenuItems(); + if (game->getGameMetaInfo()->isTournament()) { + tournamentExtension = new TournamentTabGameExtension(this); + } + connectToGameState(); connectToPlayerManager(); connectToGameEventHandler(); @@ -150,6 +155,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, gameTypes.append(game->getGameMetaInfo()->findRoomGameType(i)); } + if (tournamentExtension) { + tournamentExtension->initializeTournamentMode(); + } + QTimer::singleShot(0, this, &TabGame::loadLayout); } @@ -404,6 +413,10 @@ void TabGame::retranslateUi() } scene->retranslateUi(); + + if (tournamentExtension) { + tournamentExtension->retranslateUi(); + } } void TabGame::refreshShortcuts() @@ -932,8 +945,19 @@ void TabGame::stopGame() } } +bool TabGame::switchToGameTab(int gameId) +{ + return tabSupervisor->switchToGameTabIfAlreadyExists(gameId); +} + void TabGame::closeGame() { + int parentId = game->getGameMetaInfo()->parentGameId(); + if (parentId >= 0 && switchToGameTab(parentId)) { + close(); + return; + } + gameMenu->clear(); gameMenu->addAction(aLeaveGame); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index a05b49a9f..3f774a43d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -19,11 +19,13 @@ #include #include #include +#include #include class CardMenu; class ServerInfo_PlayerProperties; class TabbedDeckViewContainer; +class TournamentTabGameExtension; inline Q_LOGGING_CATEGORY(TabGameLog, "tab_game"); class UserListProxy; @@ -92,6 +94,8 @@ private: QList phaseActions; QAction *aCardMenu; + QPointer tournamentExtension; + /** * @brief The actions associated with managing a QDockWidget */ @@ -189,6 +193,8 @@ public: void connectToGameEventHandler(); void connectMessageLogToGameEventHandler(); void connectPlayerListToGameEventHandler(); + /*! Brings the tab of the given game to front if it is open in this session. */ + bool switchToGameTab(int gameId); TabGame(TabSupervisor *_tabSupervisor, GameReplay *replay); ~TabGame() override; void retranslateUi() override; @@ -202,6 +208,19 @@ public: return game; } + [[nodiscard]] QStackedWidget *getMainWidget() const + { + return mainWidget; + } + [[nodiscard]] QVBoxLayout *getDeckViewContainerLayout() const + { + return deckViewContainerLayout; + } + [[nodiscard]] QWidget *getDeckViewContainerWidget() const + { + return deckViewContainerWidget; + } + public slots: void viewCardInfo(const CardRef &cardRef = {}) const; void resetChatAndPhase(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index ccb687ff3..50e6b1e2f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1067,10 +1067,20 @@ void TabSupervisor::replayLeft(TabGame *tab) } void TabSupervisor::joinReportGame(const int gameId, const int roomId) +{ + startSpectatorJoin(gameId, roomId, tr("Report joins are only available on a remote server.")); +} + +void TabSupervisor::spectatorJoinGame(const int gameId, const int roomId) +{ + startSpectatorJoin(gameId, roomId, tr("Spectating is only available on a remote server.")); +} + +void TabSupervisor::startSpectatorJoin(const int gameId, const int roomId, const QString &unavailableMessage) { auto *remoteClient = qobject_cast(client); if (!remoteClient) { - actShowPopup(tr("Report joins are only available on a remote server.")); + actShowPopup(unavailableMessage); return; } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index adde7f971..ba727c120 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -93,6 +93,8 @@ public: }; private: + void startSpectatorJoin(int gameId, int roomId, const QString &unavailableMessage); + ServerInfo_User *userInfo; AbstractClient *client; UserListManager *userListManager; @@ -192,6 +194,7 @@ public slots: TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false); void openReplay(GameReplay *replay); void joinReportGame(int gameId, int roomId); + void spectatorJoinGame(int gameId, int roomId); void openTabModeration(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp new file mode 100644 index 000000000..acb9bff6c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp @@ -0,0 +1,167 @@ +#include "tournament_tab_game_extension.h" + +#include "../../../game/game_event_handler.h" +#include "../../widgets/draft/tournament_widget.h" +#include "tab_game.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include + +TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObject(parent), tabGame(parent) +{ + tournamentOverviewWidget = new QWidget(parent); + auto *overviewLayout = new QVBoxLayout(tournamentOverviewWidget); + + auto *headerLayout = new QHBoxLayout; + backToGameButton = new QPushButton(tournamentOverviewWidget); + connect(backToGameButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showDeckViewPage); + headerLayout->addWidget(backToGameButton); + headerLayout->addStretch(); + overviewLayout->addLayout(headerLayout); + + tournamentWidget = new TournamentWidget(tournamentOverviewWidget); + tournamentWidget->setLocalPlayerId(parent->getGame()->getPlayerManager()->getLocalPlayerId()); + tournamentWidget->setIsSpectator(parent->getGame()->getPlayerManager()->isSpectator()); + overviewLayout->addWidget(tournamentWidget); + + parent->getMainWidget()->addWidget(tournamentOverviewWidget); + + deckViewStatusLabel = new QLabel(parent->getDeckViewContainerWidget()); + deckViewStatusLabel->setStyleSheet("color: palette(placeholderText);"); + deckViewStatusLabel->setVisible(false); + + standingsButton = new QPushButton(parent->getDeckViewContainerWidget()); + connect(standingsButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showOverviewPage); + standingsButton->setVisible(false); + + connectSignals(); +} + +void TournamentTabGameExtension::connectSignals() +{ + auto *handler = tabGame->getGame()->getGameEventHandler(); + connect(handler, &GameEventHandler::tournamentStateChanged, this, + &TournamentTabGameExtension::onTournamentStateChanged); + connect(tournamentWidget, &TournamentWidget::openMatchGameRequested, this, + &TournamentTabGameExtension::openMatchGame); +} + +void TournamentTabGameExtension::initializeTournamentMode() +{ + if (!tabGame) { + return; + } + + auto *deckLayout = tabGame->getDeckViewContainerLayout(); + int index = 0; + deckLayout->insertWidget(index++, deckViewStatusLabel); + deckLayout->insertWidget(index++, standingsButton); + deckLayout->insertSpacing(index, 4); +} + +void TournamentTabGameExtension::retranslateUi() +{ + backToGameButton->setText(tr("Back to game view")); + standingsButton->setText(tr("Tournament standings")); + tournamentWidget->retranslateUi(); +} + +void TournamentTabGameExtension::updateDeckViewStrip(const Event_TournamentState &state) +{ + int submitted = 0; + for (int i = 0; i < state.players_size(); ++i) { + if (state.players(i).deck_submitted()) { + ++submitted; + } + } + + QString phaseText; + switch (state.phase()) { + case Event_TournamentState::PHASE_DECK_BUILDING: + phaseText = tr("Deck building"); + break; + case Event_TournamentState::PHASE_PLAYING: + phaseText = tr("Round in progress"); + break; + case Event_TournamentState::PHASE_FINISHED: + phaseText = tr("Finished"); + break; + default: + phaseText = tr("Unknown phase"); + break; + } + + // The submission count only matters while players are still picking decks. + QString text = + state.phase() == Event_TournamentState::PHASE_DECK_BUILDING + ? tr("Tournament: %1 - %2/%3 decks submitted").arg(phaseText).arg(submitted).arg(state.players_size()) + : tr("Tournament: %1").arg(phaseText); + deckViewStatusLabel->setText(text); + deckViewStatusLabel->setVisible(true); +} + +void TournamentTabGameExtension::updateNavigationButtons(const Event_TournamentState &state) +{ + bool showStandings = + state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED; + standingsButton->setVisible(showStandings); +} + +void TournamentTabGameExtension::onTournamentStateChanged(const Event_TournamentState &state) +{ + if (!tabGame) { + return; + } + + updateDeckViewStrip(state); + updateNavigationButtons(state); + tournamentWidget->updateTournamentState(state); + + // Switch to the standings page only when the phase itself changes, so score + // updates never yank the user away while they are looking at their deck. + // Instant switches are the app-wide baseline today; motion and sound cues + // for this transition are deferred to the Game dressing phase. + bool overviewPhase = + state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED; + if (overviewPhase && (!hasLastKnownPhase || state.phase() != lastKnownPhase)) { + tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget); + } + lastKnownPhase = state.phase(); + hasLastKnownPhase = true; +} + +void TournamentTabGameExtension::showOverviewPage() +{ + if (tabGame) { + tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget); + } +} + +void TournamentTabGameExtension::showDeckViewPage() +{ + if (tabGame) { + tabGame->getMainWidget()->setCurrentWidget(tabGame->getDeckViewContainerWidget()); + } +} + +void TournamentTabGameExtension::openMatchGame(int gameId) +{ + if (!tabGame || gameId <= 0) { + return; + } + + // Players are auto-joined into their match game by the server, so the tab + // usually already exists and switching is enough. Otherwise (e.g. after + // leaving the match) fall back to joining as spectator. + if (tabGame->switchToGameTab(gameId)) { + return; + } + + int roomId = tabGame->getGame()->getGameMetaInfo()->proto().room_id(); + tabGame->getTabSupervisor()->spectatorJoinGame(gameId, roomId); +} diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h new file mode 100644 index 000000000..0300194c3 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h @@ -0,0 +1,54 @@ +#ifndef TOURNAMENT_TAB_GAME_EXTENSION_H +#define TOURNAMENT_TAB_GAME_EXTENSION_H + +#include +#include +#include + +class QLabel; +class QPushButton; +class QWidget; +class TournamentWidget; +class TabGame; + +/** + * @class TournamentTabGameExtension + * @ingroup GameViews + * @brief Attaches tournament behavior to a TabGame hosting a tournament hub game. + * + * Owns the tournament overview page inside the tab's stacked main widget, + * mirrors tournament state onto the always-visible deck-view status strip, + * and routes user intents (open match game, change settings, switch pages) + * between the two views. + */ +class TournamentTabGameExtension : public QObject +{ + Q_OBJECT +public: + explicit TournamentTabGameExtension(TabGame *parent); + + void initializeTournamentMode(); + void retranslateUi(); + +private slots: + void onTournamentStateChanged(const Event_TournamentState &state); + void showOverviewPage(); + void showDeckViewPage(); + void openMatchGame(int gameId); + +private: + void connectSignals(); + void updateDeckViewStrip(const Event_TournamentState &state); + void updateNavigationButtons(const Event_TournamentState &state); + + QPointer tabGame; + TournamentWidget *tournamentWidget = nullptr; + QWidget *tournamentOverviewWidget = nullptr; + QPushButton *backToGameButton = nullptr; + QPushButton *standingsButton = nullptr; + QLabel *deckViewStatusLabel = nullptr; + Event_TournamentState::TournamentPhase lastKnownPhase = Event_TournamentState::PHASE_DECK_BUILDING; + bool hasLastKnownPhase = false; +}; + +#endif // TOURNAMENT_TAB_GAME_EXTENSION_H