diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b18846079..9268050ac 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -234,6 +234,9 @@ set(cockatrice_SOURCES src/interface/widgets/general/layout_containers/flow_widget.cpp src/interface/widgets/general/layout_containers/overlap_control_widget.cpp src/interface/widgets/general/layout_containers/overlap_widget.cpp + src/interface/widgets/general/tutorial/tutorial_bubble_widget.cpp + src/interface/widgets/general/tutorial/tutorial_controller.cpp + src/interface/widgets/general/tutorial/tutorial_overlay.cpp src/interface/widgets/menus/deck_editor_menu.cpp src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp src/interface/widgets/printing_selector/card_amount_widget.cpp diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 08eccf1e4..49af0b5f5 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -113,6 +113,16 @@ SettingsCache::SettingsCache() QString settingsPath = getSettingsPath(); + // Decide once, before any migration writes new files, whether this is an + // existing install. Fresh installs have no *.ini files and no legacy + // NativeFormat keys; upgrades have one or the other. Fallbacks are disabled + // so that on macOS the global preferences domain (AppleLanguages etc.) is + // not mistaken for existing Cockatrice settings on a fresh install. + QSettings legacy; + legacy.setFallbacksEnabled(false); + const bool hadExistingSettings = + !QDir(settingsPath).entryList({"*.ini"}, QDir::Files).isEmpty() || !legacy.allKeys().isEmpty(); + settings = new QSettings(settingsPath + "global.ini", QSettings::IniFormat, this); // Both migrations below overwrite per-domain files in place, so snapshot the whole settings @@ -158,6 +168,12 @@ SettingsCache::SettingsCache() personalSettings = new PersonalSettings(settingsPath, this); cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this); interfaceSettings = new InterfaceSettings(settingsPath, this); + if (hadExistingSettings) { + QSettings iface(settingsPath + "interface.ini", QSettings::IniFormat); + if (!iface.contains("interface/tutorialCompleted")) { + interfaceSettings->setTutorialCompleted(true); + } + } deckEditorSettings = new DeckEditorSettings(settingsPath, this); pathsSettings = new PathsSettings(settingsPath, this); visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index fe63d7a6f..394aa99ba 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -136,6 +136,23 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) switchToDeckSelectView(); } +TutorialSequence DeckViewContainer::generateTutorialSequence() +{ + TutorialSequence deckViewContainerSequence; + deckViewContainerSequence.name = tr("Loading and selecting decks"); + + deckViewContainerSequence.addStep( + {this, tr("There are multiple ways to select a deck:\n\n- From a local file" + "\n- From the contents of your clipboard\nFrom an external online service")}); + + // The VDS widget only exists when "show visual deck storage in game" is on. + if (visualDeckStorageWidget) { + visualDeckStorageWidget->generateTutorialSequence(deckViewContainerSequence); + } + + return deckViewContainerSequence; +} + /** * Creates the VDS widget and inserts it into the layout. No-ops if the widget already exists */ diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h index c064a5b5a..ce20f8bf3 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -7,6 +7,7 @@ #ifndef DECK_VIEW_CONTAINER_H #define DECK_VIEW_CONTAINER_H +#include "../../interface/widgets/general/tutorial/tutorial_controller.h" #include "libcockatrice/deck_list/deck_list.h" #include "libcockatrice/utility/playmat_params.h" @@ -91,6 +92,7 @@ signals: public: DeckViewContainer(int _playerId, TabGame *parent); void retranslateUi(); + TutorialSequence generateTutorialSequence(); void setReadyStart(bool ready); void readyAndUpdate(); void setSideboardLocked(bool locked); diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp index 75ede0ca8..425c62b30 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp @@ -24,6 +24,11 @@ TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent) updateTabBarVisibility(); } +TutorialSequence TabbedDeckViewContainer::generateTutorialSequence() +{ + return playerDeckView->generateTutorialSequence(); +} + void TabbedDeckViewContainer::addOpponentDeckView(const DeckList &opponentDeck, int opponentId, QString opponentName) { if (opponentDeckViews.contains(opponentId)) { diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h index 7d73b78a2..63fedb5c2 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h @@ -6,6 +6,9 @@ #ifndef TABBED_DECK_VIEW_CONTAINER_H #define TABBED_DECK_VIEW_CONTAINER_H +#include "../../interface/widgets/general/tutorial/tutorial_controller.h" +#include "deck_view_container.h" + #include #include #include @@ -22,6 +25,7 @@ class TabbedDeckViewContainer : public QTabWidget public: explicit TabbedDeckViewContainer(int _playerId, TabGame *parent); + TutorialSequence generateTutorialSequence(); void closeTab(int index); void updateTabBarVisibility(); void addOpponentDeckView(const DeckList &opponentDeck, int opponentId, QString opponentName); diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp index e0ad7af43..f4b6a8eef 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp @@ -1,6 +1,8 @@ #include "deck_analytics_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../general/tutorial/tutorial_controller.h" +#include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "abstract_analytics_panel_widget.h" #include "add_analytics_panel_dialog.h" #include "analytics_panel_widget_factory.h" @@ -8,6 +10,7 @@ #include "analyzer_modules/mana_curve/mana_curve_config.h" #include "analyzer_modules/mana_devotion/mana_devotion_config.h" #include "deck_list_statistics_analyzer.h" +#include "libcockatrice/utility/qt_utils.h" #include "resizable_panel.h" #include @@ -72,6 +75,46 @@ DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnal retranslateUi(); } +TutorialSequence DeckAnalyticsWidget::generateTutorialSequence() +{ + TutorialSequence analyticsSequence; + analyticsSequence.name = tr("Deck Analytics"); + + TutorialStep introStep; + introStep.targetWidget = this; + introStep.text = tr("This is the deck analytics tab.\n\nHere, you can view more detailed information about your " + "deck via the use of specialized analytics widgets."); + introStep.onEnter = [this]() { + auto tabWidget = QtUtils::findParentOfType(this); + if (tabWidget) { + tabWidget->setCurrentWidget(tabWidget->deckAnalytics); + } + }; + + analyticsSequence.addStep(introStep); + + TutorialStep controlStep; + controlStep.targetWidget = controlContainer; + controlStep.text = tr( + "These controls will allow you to customize your analytics widget layout.\n\nAll widgets can be resized or " + "reordered with the handle at their bottom.\nTo remove a widget, you first have to select it.\nSaving your " + "layout will ensure that it is the default for all future decks you open, whereas loading the layout will " + "allow you to revert back to your previous configuration in case you decide you do not like your new layout."); + + analyticsSequence.addStep(controlStep); + + TutorialStep widgetStep; + widgetStep.targetWidget = this; + widgetStep.text = + tr("Finally, let's talk about the analytics widgets themselves.\n\nHow the various analytics are displayed for " + "each widget can be configured by clicking on the cogwheel next to the respective banner.\nHovering over " + "the segments of different diagram types will reveal which cards belong to the respective segment."); + + analyticsSequence.addStep(widgetStep); + + return analyticsSequence; +} + void DeckAnalyticsWidget::retranslateUi() { addButton->setText(tr("Add Panel")); diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h index f1e071ff6..793343ba1 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h @@ -7,6 +7,8 @@ #ifndef DECK_ANALYTICS_WIDGET_H #define DECK_ANALYTICS_WIDGET_H +#include "../general/tutorial/tutorial_controller.h" + #include #include #include @@ -35,6 +37,7 @@ public: explicit DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnalyzer *analyzer); void retranslateUi(); void includeSideboardChanged(bool checked); + TutorialSequence generateTutorialSequence(); private slots: void onAddPanel(); 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 30fde1595..9845630fb 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 @@ -4,6 +4,7 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../../key_signals.h" #include "../../pixel_map_generator.h" +#include "../general/tutorial/tutorial_controller.h" #include "../playmat/playmat_settings_dialog.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/abstract_tab_deck_editor.h" @@ -359,6 +360,22 @@ void DeckEditorDeckDockWidget::createDeckDock() } } +TutorialSequence DeckEditorDeckDockWidget::generateTutorialSequence() +{ + TutorialSequence sequence; + sequence.name = tr("The Deck Info Widget"); + + TutorialStep introStep; + introStep.targetWidget = this; + introStep.text = tr("This is the deck info widget.\n\nHere, you can adjust all kinds of metadata such as the name, " + "the comments, or the tags of a deck.\nIt also displays the contents of your deck in a list " + "and provides buttons to manipulate the decklist."); + + sequence.addStep(introStep); + + return sequence; +} + void DeckEditorDeckDockWidget::initializeFormats() { QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys(); 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 02910e390..cacf91c3d 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 @@ -9,6 +9,7 @@ #define DECK_EDITOR_DECK_DOCK_WIDGET_H #include "../../key_signals.h" +#include "../general/tutorial/tutorial_controller.h" #include "libcockatrice/card/printing/exact_card.h" #include @@ -63,6 +64,8 @@ public: return deckView->selectionModel(); } + TutorialSequence generateTutorialSequence(); + public slots: void selectPrevCard(); void selectNextCard(); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index db4501efc..a2c21617a 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -21,6 +21,7 @@ #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" #include "libcockatrice/utility/card_ref.h" +#include "tutorial/tutorial_controller.h" #include #include @@ -60,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +105,33 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundShuffleFrequencyChanged, this, &HomeWidget::onBackgroundShuffleFrequencyChanged); + + tutorialController = new TutorialController(this); + auto sequence = TutorialSequence(); + sequence.addStep({connectButton, "Connect to a server to play here!"}); + auto vdeStep = TutorialStep{visualDeckEditorButton, "Create a new deck from cards in the database here!"}; + vdeStep.requiresInteraction = true; + vdeStep.allowClickThrough = true; + vdeStep.validationHint = "Open the deck editor to try it out!"; + vdeStep.validationTiming = ValidationTiming::OnSignal; + vdeStep.autoAdvanceOnValid = true; + vdeStep.validator = []() { return true; }; + vdeStep.signalHook = [this](TutorialController *controller) { + return connect(visualDeckEditorButton, &QAbstractButton::clicked, controller, + &TutorialController::checkValidation); + }; + + sequence.addStep(vdeStep); + sequence.addStep({visualDeckStorageButton, "Browse the decks in your local collection."}); + sequence.addStep({visualDatabaseDisplayButton, "View the card database here."}); + sequence.addStep( + {edhrecButton, "Browse EDHRec, an external service designed to provide card recommendations for decks."}); + sequence.addStep({archidektButton, "Browse Archidekt, an external service that allows users to store " + "decklists and import them to your local collection."}); + sequence.addStep({replayButton, "View replays of your past games here."}); + sequence.addStep({exitButton, "Exit the application."}); + tutorialController->addSequence(sequence); + // Lambda is cleaner to read than overloading this connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this, [this] { repaint(); }); @@ -147,6 +176,16 @@ void HomeWidget::retranslateUi() } } +void HomeWidget::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (!tutorialStarted && !SettingsCache::instance().userInterface().getTutorialCompleted()) { + tutorialStarted = true; + // Start on next event loop iteration so everything is fully painted + QTimer::singleShot(3, tutorialController, [this] { tutorialController->start(); }); + } +} + void HomeWidget::initializeBackgroundFromSource() { // The featured logo is theme/scheme-derived too; reload it alongside the diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index fec1db950..b343c793d 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -23,6 +23,7 @@ class HomeStyledButton; class QGroupBox; class QTimer; class TabSupervisor; +class TutorialController; class HomeWidget : public QWidget { @@ -37,6 +38,7 @@ public: public slots: void paintEvent(QPaintEvent *event) override; + void showEvent(QShowEvent *event) override; void initializeBackgroundFromSource(); void onBackgroundShuffleFrequencyChanged(); void updateBackgroundProperties(); @@ -48,6 +50,8 @@ private: QTimer *cardChangeTimer; TabSupervisor *tabSupervisor; QPixmap background; + TutorialController *tutorialController; + bool tutorialStarted = false; CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr; DeckList backgroundSourceDeck; QLabel *logoLabel = nullptr; diff --git a/cockatrice/src/interface/widgets/general/tutorial/tutorial_bubble_widget.cpp b/cockatrice/src/interface/widgets/general/tutorial/tutorial_bubble_widget.cpp new file mode 100644 index 000000000..288b5cb24 --- /dev/null +++ b/cockatrice/src/interface/widgets/general/tutorial/tutorial_bubble_widget.cpp @@ -0,0 +1,107 @@ +#include "tutorial_bubble_widget.h" + +BubbleWidget::BubbleWidget(QWidget *parent) : QFrame(parent) +{ + setFrameStyle(QFrame::StyledPanel | QFrame::Raised); + setStyleSheet("QFrame { background:white; border-radius:8px; }" + "QLabel { color:black; }"); + + layout = new QGridLayout(this); + layout->setContentsMargins(12, 10, 12, 10); + layout->setHorizontalSpacing(8); + layout->setVerticalSpacing(8); + + // Step counter (e.g., "Step 2 of 5") + counterLabel = new QLabel(this); + counterLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + counterLabel->setStyleSheet("color: #555; font-size: 11px;"); + + // Overall progress (e.g., "12 of 45 total") + progressLabel = new QLabel(this); + progressLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + progressLabel->setStyleSheet("color: #888; font-size: 10px;"); + progressLabel->setAlignment(Qt::AlignRight); + + // Main tutorial text + textLabel = new QLabel(this); + textLabel->setWordWrap(true); + textLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + textLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + textLabel->setStyleSheet("color:black;"); + + // Interaction hint (e.g., "Click the highlighted area") + interactionLabel = new QLabel(this); + interactionLabel->setWordWrap(true); + interactionLabel->setStyleSheet("color: #0066cc; font-style: italic; font-size: 11px;"); + interactionLabel->hide(); + + // Validation hint (error message) + validationLabel = new QLabel(this); + validationLabel->setWordWrap(true); + validationLabel->setStyleSheet("color: #cc3300; background: #ffe6e6; padding: 6px; " + "border-radius: 4px; font-size: 11px;"); + validationLabel->hide(); + + // Layout + layout->addWidget(counterLabel, 0, 0, Qt::AlignLeft | Qt::AlignVCenter); + layout->addWidget(progressLabel, 0, 1, Qt::AlignRight | Qt::AlignVCenter); + layout->addWidget(textLabel, 1, 0, 1, 2); + layout->addWidget(interactionLabel, 2, 0, 1, 2); + layout->addWidget(validationLabel, 3, 0, 1, 2); + + layout->setColumnStretch(1, 1); + + setMaximumWidth(420); + + // Timer for auto-hiding validation hints + validationTimer = new QTimer(this); + validationTimer->setSingleShot(true); + connect(validationTimer, &QTimer::timeout, this, &BubbleWidget::clearValidationHint); +} + +void BubbleWidget::setText(const QString &text) +{ + textLabel->setText(text); + update(); +} + +void BubbleWidget::setProgress(int stepNum, int totalSteps, int overallStep, int overallTotal) +{ + // Per-sequence progress + counterLabel->setText(QString("Step %1 of %2").arg(stepNum).arg(totalSteps)); + + // Overall progress across all sequences + progressLabel->setText(QString("(%1 of %2 total)").arg(overallStep).arg(overallTotal)); + progressLabel->show(); +} + +void BubbleWidget::setInteractionHint(const QString &hint) +{ + if (hint.isEmpty()) { + interactionLabel->hide(); + } else { + interactionLabel->setText(hint); + interactionLabel->show(); + } + adjustSize(); +} + +void BubbleWidget::setValidationHint(const QString &hint) +{ + if (hint.isEmpty()) { + clearValidationHint(); + } else { + validationLabel->setText("⚠️ " + hint); + validationLabel->show(); + adjustSize(); + + // Auto-hide after 4 seconds + validationTimer->start(4000); + } +} + +void BubbleWidget::clearValidationHint() +{ + validationLabel->hide(); + adjustSize(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/general/tutorial/tutorial_bubble_widget.h b/cockatrice/src/interface/widgets/general/tutorial/tutorial_bubble_widget.h new file mode 100644 index 000000000..c64ed5428 --- /dev/null +++ b/cockatrice/src/interface/widgets/general/tutorial/tutorial_bubble_widget.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_TUTORIAL_BUBBLE_WIDGET_H +#define COCKATRICE_TUTORIAL_BUBBLE_WIDGET_H +#include +#include +#include +#include +#include + +class BubbleWidget : public QFrame +{ + Q_OBJECT + +public: + explicit BubbleWidget(QWidget *parent = nullptr); + + void setText(const QString &text); + void setProgress(int stepNum, int totalSteps, int overallStep, int overallTotal); + void setInteractionHint(const QString &hint); + void setValidationHint(const QString &hint); + QGridLayout *getLayout() const + { + return layout; + } + +private: + void clearValidationHint(); + + QLabel *counterLabel; + QLabel *textLabel; + QLabel *interactionLabel; // Shows "Click to continue" + QLabel *validationLabel; // Shows validation errors + QLabel *progressLabel; // Shows overall progress + QGridLayout *layout; + QTimer *validationTimer; // Auto-hide validation hint +}; + +#endif // COCKATRICE_TUTORIAL_BUBBLE_WIDGET_H diff --git a/cockatrice/src/interface/widgets/general/tutorial/tutorial_controller.cpp b/cockatrice/src/interface/widgets/general/tutorial/tutorial_controller.cpp new file mode 100644 index 000000000..2b50804ee --- /dev/null +++ b/cockatrice/src/interface/widgets/general/tutorial/tutorial_controller.cpp @@ -0,0 +1,356 @@ +#include "tutorial_controller.h" + +#include "../../../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include + +TutorialController::TutorialController(QWidget *_tutorializedWidget) + : QObject(_tutorializedWidget), tutorializedWidget(_tutorializedWidget) +{ + tutorialOverlay = new TutorialOverlay(tutorializedWidget->window()); + + tutorialOverlay->setWindowFlags(tutorialOverlay->windowFlags() | Qt::FramelessWindowHint); + tutorialOverlay->hide(); + + connect(tutorialOverlay, &TutorialOverlay::nextStep, this, &TutorialController::attemptAdvance); + connect(tutorialOverlay, &TutorialOverlay::prevStep, this, &TutorialController::prevStep); + connect(tutorialOverlay, &TutorialOverlay::nextSequence, this, &TutorialController::nextSequence); + connect(tutorialOverlay, &TutorialOverlay::prevSequence, this, &TutorialController::prevSequence); + connect(tutorialOverlay, &TutorialOverlay::skipTutorial, this, &TutorialController::exitTutorial); + connect(tutorialOverlay, &TutorialOverlay::targetClicked, this, &TutorialController::handleTargetClicked); +} + +TutorialController::~TutorialController() +{ + // The overlay is parented to the top-level window, which outlives the widget + // this controller is attached to (e.g. a closed tab), so it must be cleaned + // up explicitly when the controller goes away without exitTutorial(). + if (tutorialOverlay) { + tutorialOverlay->hide(); + tutorialOverlay->deleteLater(); + } + tutorialOverlay = nullptr; +} + +void TutorialController::addSequence(const TutorialSequence &seq) +{ + sequences.append(seq); +} + +void TutorialController::start() +{ + if (sequences.isEmpty() || tutorialCompleted) { + return; + } + + QTimer::singleShot(0, this, [this]() { + QWidget *win = tutorializedWidget->window(); + + tutorialOverlay->setParent(win); // triggers changeEvent and installs filter + tutorialOverlay->setGeometry(win->rect()); + tutorialOverlay->show(); + tutorialOverlay->raise(); + + currentSequence = 0; + currentStep = 0; + showStep(); + }); +} + +void TutorialController::handleTargetClicked() +{ + if (currentSequence < 0 || currentStep < 0) { + return; + } + + const auto &step = sequences[currentSequence].steps[currentStep]; + + // If this step requires interaction AND uses OnAdvance validation, advance when clicked + // For OnSignal/OnChange, the click just triggers the action - validation happens via signal + if (step.requiresInteraction && step.validationTiming == ValidationTiming::OnAdvance) { + attemptAdvance(); + } +} + +void TutorialController::attemptAdvance() +{ + if (currentSequence < 0 || currentStep < 0) { + return; + } + + const auto &step = sequences[currentSequence].steps[currentStep]; + + // Only validate on advance if timing is set to OnAdvance + if (step.validationTiming == ValidationTiming::OnAdvance) { + if (!validateCurrentStep()) { + return; // Validation failed, stay on current step + } + } + + // Validation passed or not required, proceed to next step + nextStep(); +} + +bool TutorialController::validateCurrentStep() +{ + if (currentSequence < 0 || currentSequence >= sequences.size()) { + return true; // No validation needed + } + + const auto &step = sequences[currentSequence].steps[currentStep]; + + // If there's a validator function, check it + if (step.validator) { + bool valid = step.validator(); + if (!valid) { + // Show validation hint + tutorialOverlay->showValidationHint(step.validationHint); + return false; + } + } + + return true; +} + +void TutorialController::nextStep() +{ + if (currentSequence < 0) { + return; + } + + runExitForCurrentStep(); + + if (currentStep >= sequences[currentSequence].steps.size() - 1) { + nextSequence(); + return; + } + + currentStep++; + showStep(); +} + +void TutorialController::prevStep() +{ + if (currentSequence < 0) { + return; + } + + runExitForCurrentStep(); + + if (currentStep == 0) { + prevSequence(); + return; + } + + currentStep--; + showStep(); +} + +void TutorialController::nextSequence() +{ + if (currentSequence < 0) { + return; + } + + currentSequence++; + currentStep = 0; + + if (currentSequence >= sequences.size()) { + exitTutorial(); + return; + } + + showStep(); +} + +void TutorialController::prevSequence() +{ + if (currentSequence <= 0) { + // Already at the very first step — just re-show it + currentStep = 0; + showStep(); + return; + } + + currentSequence--; + currentStep = 0; + showStep(); +} + +void TutorialController::exitTutorial() +{ + cleanupValidationMonitoring(); + if (tutorialOverlay) { + tutorialOverlay->hide(); + tutorialOverlay->deleteLater(); + } + tutorialOverlay = nullptr; + currentSequence = -1; + currentStep = -1; + tutorialCompleted = true; + SettingsCache::instance().userInterface().setTutorialCompleted(true); + deleteLater(); +} + +void TutorialController::runExitForCurrentStep() +{ + if (currentSequence < 0 || currentSequence >= sequences.size()) { + return; + } + const auto &steps = sequences[currentSequence].steps; + if (currentStep < 0 || currentStep >= steps.size()) { + return; + } + if (steps[currentStep].onExit) { + steps[currentStep].onExit(); + } +} + +void TutorialController::updateProgress() +{ + if (currentSequence < 0 || currentSequence >= sequences.size()) { + return; + } + + const auto &seq = sequences[currentSequence]; + + // Calculate total steps across all sequences + int totalSteps = 0; + int currentOverallStep = 0; + + for (int i = 0; i < sequences.size(); ++i) { + int seqSteps = sequences[i].steps.size(); + totalSteps += seqSteps; + + if (i < currentSequence) { + currentOverallStep += seqSteps; + } + } + + currentOverallStep += currentStep + 1; // +1 because steps are 0-indexed + + // Update overlay with progress info + tutorialOverlay->setProgress(currentStep + 1, // Current step in sequence (1-indexed) + seq.steps.size(), // Total steps in sequence + currentOverallStep, // Overall step number + totalSteps, // Total steps in tutorial + seq.name); // Sequence title +} + +void TutorialController::showStep() +{ + if (currentSequence < 0 || currentSequence >= sequences.size()) { + return; + } + const auto &seq = sequences[currentSequence]; + if (currentStep < 0 || currentStep >= seq.steps.size()) { + return; + } + + cleanupValidationMonitoring(); + advanceScheduled = false; + + const auto &step = seq.steps[currentStep]; + + if (step.onEnter) { + step.onEnter(); + } + + tutorialOverlay->setTargetWidget(step.targetWidget); + tutorialOverlay->setText(step.text); + tutorialOverlay->setInteractive(step.requiresInteraction, step.allowClickThrough); + + if (!step.customInteractionHint.isEmpty()) { + tutorialOverlay->setInteractionHint(step.customInteractionHint); + } else if (step.requiresInteraction) { + tutorialOverlay->setInteractionHint("👆 Click the highlighted area to continue"); + } else { + tutorialOverlay->setInteractionHint(""); + } + + setupValidationMonitoring(); + updateProgress(); + + tutorialOverlay->parentResized(); + tutorialOverlay->raise(); + tutorialOverlay->update(); +} + +void TutorialController::setupValidationMonitoring() +{ + if (currentSequence < 0 || currentSequence >= sequences.size()) { + return; + } + if (currentStep < 0 || currentStep >= sequences[currentSequence].steps.size()) { + return; + } + + const auto &step = sequences[currentSequence].steps[currentStep]; + + // Handle OnSignal validation - connect to any custom signal + if (step.validationTiming == ValidationTiming::OnSignal && step.signalHook) { + validationConnection = step.signalHook(this); + return; + } + + // Handle OnChange validation - widget-specific + if (step.validationTiming == ValidationTiming::OnChange && step.validator) { + if (QLineEdit *lineEdit = qobject_cast(step.targetWidget)) { + validationConnection = + connect(lineEdit, &QLineEdit::textChanged, this, &TutorialController::checkValidation); + } else if (QTextEdit *textEdit = qobject_cast(step.targetWidget)) { + validationConnection = + connect(textEdit, &QTextEdit::textChanged, this, &TutorialController::checkValidation); + } else if (QPlainTextEdit *plainText = qobject_cast(step.targetWidget)) { + validationConnection = + connect(plainText, &QPlainTextEdit::textChanged, this, &TutorialController::checkValidation); + } else if (QComboBox *combo = qobject_cast(step.targetWidget)) { + validationConnection = connect(combo, QOverload::of(&QComboBox::currentIndexChanged), this, + &TutorialController::checkValidation); + } + } +} + +void TutorialController::cleanupValidationMonitoring() +{ + if (validationConnection) { + disconnect(validationConnection); + validationConnection = QMetaObject::Connection(); + } +} + +void TutorialController::checkValidation() +{ + if (currentSequence < 0 || currentSequence >= sequences.size()) { + return; + } + if (currentStep < 0 || currentStep >= sequences[currentSequence].steps.size()) { + return; + } + + const auto &step = sequences[currentSequence].steps[currentStep]; + + if (step.validator) { + bool isValid = step.validator(); + + if (isValid) { + // Clear any validation hints + tutorialOverlay->showValidationHint(""); + + // Auto-advance if enabled + if (step.autoAdvanceOnValid && !advanceScheduled) { + advanceScheduled = true; + QTimer::singleShot(500, this, [this]() { + advanceScheduled = false; + nextStep(); + }); + } + } + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/general/tutorial/tutorial_controller.h b/cockatrice/src/interface/widgets/general/tutorial/tutorial_controller.h new file mode 100644 index 000000000..617a2972d --- /dev/null +++ b/cockatrice/src/interface/widgets/general/tutorial/tutorial_controller.h @@ -0,0 +1,116 @@ +#ifndef COCKATRICE_TUTORIAL_CONTROLLER_H +#define COCKATRICE_TUTORIAL_CONTROLLER_H + +#include "tutorial_overlay.h" + +#include +#include +#include +#include + +enum class ValidationTiming +{ + OnAdvance, // Validate when user clicks next/clicks target (default) + OnChange, // Validate whenever target widget changes (for text input) + OnSignal, // Validate when a specific signal is emitted + Manual // Only validate when explicitly triggered +}; + +class TutorialController; + +struct TutorialStep +{ + // QPointer so a destroyed widget is detected instead of dereferencing a dangling pointer. + QPointer targetWidget; + QString text; + std::function onEnter = nullptr; + std::function onExit = nullptr; + + // Interactive features + bool requiresInteraction = false; // Must click target to advance + bool allowClickThrough = false; // Clicks pass through to target widget + std::function validator = nullptr; // Check if task completed + QString validationHint = ""; // Show if validation fails + ValidationTiming validationTiming = ValidationTiming::OnAdvance; + + // Auto-advance when validation passes (useful for text input) + bool autoAdvanceOnValid = false; + + // Custom interaction hint (overrides default "Click to continue") + QString customInteractionHint = nullptr; + + // Establishes the connection used for signal-based validation (ValidationTiming::OnSignal). + // Called with the controller so call sites can connect a specific signal to + // TutorialController::checkValidation using pointer-to-member, which is checked at + // compile time unlike string-based SIGNAL() connections. + std::function signalHook = nullptr; +}; + +struct TutorialSequence +{ + QString name; + QList steps; + + void addStep(const TutorialStep &step) + { + steps.append(step); + } + + void addSteps(const TutorialSequence &sequence) + { + steps.append(sequence.steps); + } +}; + +class TutorialController : public QObject +{ + Q_OBJECT + +public: + explicit TutorialController(QWidget *_tutorializedWidget); + ~TutorialController() override; + + void addSequence(const TutorialSequence &seq); + void start(); + + TutorialOverlay *getOverlay() + { + return tutorialOverlay; + } + +public slots: + void nextStep(); + void prevStep(); + void nextSequence(); + void prevSequence(); + void runExitForCurrentStep(); + void exitTutorial(); + void handleTargetClicked(); // Handle clicks on highlighted widget + void attemptAdvance(); // Try to advance with validation + void checkValidation(); // Check validation for OnChange timing + +private: + void showStep(); + void updateProgress(); // Update progress indicators + bool validateCurrentStep(); // Check if step requirements met + void setupValidationMonitoring(); // Setup automatic validation checking + void cleanupValidationMonitoring(); // Cleanup validation watchers + + QWidget *tutorializedWidget; + TutorialOverlay *tutorialOverlay; + QList sequences; + + bool tutorialCompleted = false; + + int currentSequence = -1; + int currentStep = -1; + + // For OnChange validation monitoring + QMetaObject::Connection validationConnection; + + // True while an auto-advance timer is pending, so repeated signal emissions + // can't queue more than one advance. + bool advanceScheduled = false; +}; + +#endif // COCKATRICE_TUTORIAL_CONTROLLER_H diff --git a/cockatrice/src/interface/widgets/general/tutorial/tutorial_overlay.cpp b/cockatrice/src/interface/widgets/general/tutorial/tutorial_overlay.cpp new file mode 100644 index 000000000..271556fad --- /dev/null +++ b/cockatrice/src/interface/widgets/general/tutorial/tutorial_overlay.cpp @@ -0,0 +1,426 @@ +#include "tutorial_overlay.h" + +#include "tutorial_bubble_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TutorialOverlay::TutorialOverlay(QWidget *parent) : QWidget(parent) +{ + setAttribute(Qt::WA_TranslucentBackground, true); + setAttribute(Qt::WA_TransparentForMouseEvents, false); + setAttribute(Qt::WA_StaticContents, false); + setAttribute(Qt::WA_NoSystemBackground, true); + setAutoFillBackground(false); + + if (parent) { + parent->installEventFilter(this); + setGeometry(parent->rect()); + raise(); + } + + controlBar = new QFrame(this); + controlBar->setStyleSheet( + "QFrame { background: rgba(30,30,30,200); border-radius: 6px; }" + "QPushButton { padding: 6px 10px; border: 1px solid #aaa; border-radius: 4px; background:#f5f5f5; }" + "QPushButton:hover { background:#eaeaea; }"); + + QHBoxLayout *barLayout = new QHBoxLayout(controlBar); + barLayout->setContentsMargins(8, 4, 8, 4); + + titleLabel = new QLabel("Tutorial", controlBar); + titleLabel->setStyleSheet("color:white; font-weight:bold;"); + barLayout->addWidget(titleLabel); + barLayout->addStretch(); + + auto mkBtn = [&](const QString &t, const QString &tip) { + QPushButton *b = new QPushButton(t, controlBar); + b->setToolTip(tip); + return b; + }; + + QPushButton *prevSeq = mkBtn("⏮", "Previous chapter"); + QPushButton *prev = mkBtn("◀", "Previous step"); + nextButton = mkBtn("▶", "Next step"); + nextSeqButton = mkBtn("⏭", "Next chapter"); + QPushButton *close = mkBtn("✕", "Exit tutorial"); + + barLayout->addWidget(prevSeq); + barLayout->addWidget(prev); + barLayout->addWidget(nextButton); + barLayout->addWidget(nextSeqButton); + barLayout->addWidget(close); + + connect(prev, &QPushButton::clicked, this, &TutorialOverlay::prevStep); + connect(nextButton, &QPushButton::clicked, this, &TutorialOverlay::nextStep); + connect(prevSeq, &QPushButton::clicked, this, &TutorialOverlay::prevSequence); + connect(nextSeqButton, &QPushButton::clicked, this, &TutorialOverlay::nextSequence); + connect(close, &QPushButton::clicked, this, &TutorialOverlay::skipTutorial); + + bubble = new BubbleWidget(this); + bubble->hide(); + + controlBar->hide(); +} + +void TutorialOverlay::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + + if (parentWidget()) { + setGeometry(0, 0, parentWidget()->width(), parentWidget()->height()); + } + + raise(); + parentResized(); +} + +void TutorialOverlay::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::ParentChange) { + // Remove filter from old parent (Qt has already changed parentWidget() by now, + // so iterate sender list isn't easy — reinstall unconditionally on the new parent) + QWidget *p = parentWidget(); + if (p) { + p->installEventFilter(this); // safe to call multiple times + setGeometry(p->rect()); + raise(); + } + } + QWidget::changeEvent(event); +} + +void TutorialOverlay::setTitle(const QString &title) +{ + titleLabel->setText(title); +} + +void TutorialOverlay::setBlocking(bool block) +{ + blockInput = block; + setAttribute(Qt::WA_TransparentForMouseEvents, !blockInput); +} + +void TutorialOverlay::setInteractive(bool interactive, bool clickThrough) +{ + isInteractive = interactive; + allowClickThrough = clickThrough; + + if (nextButton) { + nextButton->setEnabled(!interactive); + nextButton->setToolTip(interactive ? "Complete the highlighted action to continue" : "Next step"); + } + + if (nextSeqButton) { + nextSeqButton->setEnabled(!interactive); + nextSeqButton->setToolTip(interactive ? "Complete the highlighted action to continue" : "Next chapter"); + } + + updateMask(); +} + +void TutorialOverlay::setInteractionHint(const QString &hint) +{ + bubble->setInteractionHint(hint); +} + +void TutorialOverlay::showValidationHint(const QString &hint) +{ + if (!hint.isEmpty()) { + bubble->setValidationHint(hint); + } +} + +void TutorialOverlay::setProgress(int stepNum, + int totalSteps, + int overallStep, + int overallTotal, + const QString &sequenceTitle) +{ + bubble->setProgress(stepNum, totalSteps, overallStep, overallTotal); + + if (!sequenceTitle.isEmpty()) { + titleLabel->setText(sequenceTitle); + } +} + +void TutorialOverlay::setTargetWidget(QWidget *w) +{ + + if (targetWidget) { + targetWidget->removeEventFilter(this); + } + + targetWidget = w; + + if (targetWidget) { + targetWidget->installEventFilter(this); + } + + recomputeLayout(); +} + +void TutorialOverlay::setText(const QString &t) +{ + + tutorialText = t; + bubble->setText(t); + bubble->adjustSize(); + recomputeLayout(); +} + +QRect TutorialOverlay::currentHoleRect() const +{ + if (!targetWidget || !targetWidget->isVisible()) { + return QRect(); + } + + QPoint p = targetWidget->mapToGlobal(QPoint(0, 0)); + QPoint local = mapFromGlobal(p); + + QRect r(local, targetWidget->size()); + r = r.adjusted(-6, -6, 6, 6); + + return r; +} + +void TutorialOverlay::mousePressEvent(QMouseEvent *event) +{ + QRect hole = currentHoleRect(); + + if (hole.contains(event->pos())) { + + if (!allowClickThrough && isInteractive && !qobject_cast(targetWidget) && + !qobject_cast(targetWidget) && !qobject_cast(targetWidget) && + !qobject_cast(targetWidget)) { + QTimer::singleShot(100, this, [this]() { emit targetClicked(); }); + } + return; + } + + event->accept(); +} + +void TutorialOverlay::updateMask() +{ + clearMask(); + + if (!isVisible() || !parentWidget()) { + return; + } + if (!allowClickThrough) { + return; + } + + QRect hole = currentHoleRect(); + + if (!hole.isEmpty()) { + QRegion full(rect()); + QRegion cut(hole); + setMask(full.subtracted(cut)); + } +} + +bool TutorialOverlay::event(QEvent *event) +{ + return QWidget::event(event); +} + +void TutorialOverlay::resizeEvent(QResizeEvent *) +{ + recomputeLayout(); +} + +bool TutorialOverlay::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == parentWidget()) { + switch (event->type()) { + case QEvent::Resize: + case QEvent::Move: // window dragged to another monitor / position + parentResized(); + break; + default: + break; + } + } + + if (obj == targetWidget) { + switch (event->type()) { + case QEvent::Show: + QMetaObject::invokeMethod(this, [this] { recomputeLayout(); }, Qt::QueuedConnection); + break; + case QEvent::Hide: + case QEvent::Move: + case QEvent::Resize: + recomputeLayout(); + break; + default: + break; + } + } + + return QWidget::eventFilter(obj, event); +} + +void TutorialOverlay::parentResized() +{ + if (!parentWidget()) { + return; + } + + const QSize s = parentWidget()->size(); + + setGeometry(0, 0, s.width(), s.height()); + + clearMask(); + recomputeLayout(); + + setAttribute(Qt::WA_NoSystemBackground, false); + setAttribute(Qt::WA_NoSystemBackground, true); + + update(); +} + +void TutorialOverlay::recomputeLayout() +{ + if (!parentWidget()) { + return; + } + + // The overlay is parented to the top-level window; its client size (rect()) + // is the visible area, whereas window()->geometry() includes the frame. + resize(parentWidget()->size()); + + bubble->adjustSize(); + + QRect hole = currentHoleRect(); + + if (hole.isEmpty()) { + bubble->hide(); + controlBar->hide(); + + hide(); + + update(); + return; + } + + show(); + raise(); + + QSize bsize = bubble->sizeHint().expandedTo(QSize(160, 60)); + highlightBubbleRect = computeBubbleRect(hole, bsize); + + bubble->setGeometry(highlightBubbleRect); + bubble->show(); + + controlBar->adjustSize(); + controlBar->show(); + + const int margin = 8; + QRect r = rect(); + + QList positions = {{r.right() - controlBar->width() - margin, r.bottom() - controlBar->height() - margin}, + {r.right() - controlBar->width() - margin, margin}, + {margin, r.bottom() - controlBar->height() - margin}, + {margin, margin}}; + + QRect placedRect; + + for (const QPoint &pos : positions) { + QRect proposed(pos, controlBar->size()); + + if (!proposed.intersects(hole) && !proposed.intersects(highlightBubbleRect)) { + placedRect = proposed; + break; + } + } + + if (!placedRect.isValid()) { + placedRect = QRect(QPoint(margin, margin), controlBar->size()); + } + + controlBar->move(placedRect.topLeft()); + controlBar->show(); + + updateMask(); + update(); +} + +QRect TutorialOverlay::computeBubbleRect(const QRect &hole, const QSize &bubbleSize) const +{ + QRect r = rect(); + + QRect bubble; + + if (hole.isEmpty()) { + bubble = QRect(r.center() - QPoint(bubbleSize.width() / 2, bubbleSize.height() / 2), bubbleSize); + } else { + const int margin = 16; + + bubble = QRect(hole.right() + margin, hole.top(), bubbleSize.width(), bubbleSize.height()); + + if (!r.contains(bubble)) { + bubble.moveLeft(hole.left() - margin - bubbleSize.width()); + } + + if (!r.contains(bubble)) { + bubble.moveLeft(hole.center().x() - bubbleSize.width() / 2); + bubble.moveTop(hole.top() - margin - bubbleSize.height()); + } + + if (!r.contains(bubble)) { + bubble.moveTop(hole.bottom() + margin); + } + } + + int maxLeft = qMax(r.left(), r.right() - bubble.width()); + int maxTop = qMax(r.top(), r.bottom() - bubble.height()); + + bubble.moveLeft(qBound(r.left(), bubble.left(), maxLeft)); + bubble.moveTop(qBound(r.top(), bubble.top(), maxTop)); + + return bubble; +} + +void TutorialOverlay::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + QRect hole = currentHoleRect(); + + if (hole.isEmpty()) { + p.fillRect(rect(), QColor(0, 0, 0, 160)); + } else { + QPainterPath fullPath; + fullPath.addRect(rect()); + + QPainterPath holePath; + holePath.addRoundedRect(hole, 8, 8); + + QPainterPath overlayPath = fullPath.subtracted(holePath); + p.fillPath(overlayPath, QColor(0, 0, 0, 160)); + + if (isInteractive) { + QPen pen(QColor(100, 200, 255, 180), 2); + pen.setStyle(Qt::DashLine); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(hole, 8, 8); + } + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/general/tutorial/tutorial_overlay.h b/cockatrice/src/interface/widgets/general/tutorial/tutorial_overlay.h new file mode 100644 index 000000000..f47467482 --- /dev/null +++ b/cockatrice/src/interface/widgets/general/tutorial/tutorial_overlay.h @@ -0,0 +1,69 @@ +#ifndef TUTORIAL_OVERLAY_H +#define TUTORIAL_OVERLAY_H + +#include +#include + +class QFrame; +class QLabel; +class QPushButton; +class BubbleWidget; + +class TutorialOverlay : public QWidget +{ + Q_OBJECT + +public: + explicit TutorialOverlay(QWidget *parent = nullptr); + + void setTitle(const QString &title); + void setBlocking(bool block); + void setTargetWidget(QWidget *w); + void setText(const QString &t); + void setInteractive(bool interactive, bool clickThrough); + void setInteractionHint(const QString &hint); + void showValidationHint(const QString &hint); + void setProgress(int stepNum, int totalSteps, int overallStep, int overallTotal, const QString &sequenceTitle); + + void parentResized(); + QRect currentHoleRect() const; + bool eventFilter(QObject *obj, QEvent *event) override; + +signals: + void nextStep(); + void prevStep(); + void nextSequence(); + void prevSequence(); + void skipTutorial(); + void targetClicked(); + +protected: + void showEvent(QShowEvent *event) override; + bool event(QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void updateMask(); + void changeEvent(QEvent *event) override; + +private: + void recomputeLayout(); + QRect computeBubbleRect(const QRect &hole, const QSize &bubbleSize) const; + + // QPointer: the target can be deleted while the overlay lives on (e.g. toggling + // the in-game VDS setting deleteLater()s it), so never hold a raw pointer across steps. + QPointer targetWidget; + QFrame *controlBar = nullptr; + QLabel *titleLabel = nullptr; + QPushButton *nextButton = nullptr; + QPushButton *nextSeqButton = nullptr; + BubbleWidget *bubble = nullptr; + + QString tutorialText; + QRect highlightBubbleRect; + bool blockInput = true; + bool isInteractive = false; + bool allowClickThrough = false; +}; + +#endif // TUTORIAL_OVERLAY_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 34d6f8a51..99c3ed432 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -39,6 +39,7 @@ #include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" #include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/qt_utils.h" #include "tab_supervisor.h" #include @@ -67,10 +68,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -190,6 +193,210 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, } QTimer::singleShot(0, this, &TabGame::loadLayout); + + tutorialController = new TutorialController(this); + + TutorialSequence lobbySequence; + + TutorialStep introStep{deckViewContainerWidget, tr("Let's try this out.")}; + lobbySequence.addStep(introStep); + + tutorialController->addSequence(lobbySequence); +} + +void TabGame::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (!tutorialController || tutorialStarted || SettingsCache::instance().userInterface().getTutorialCompleted()) { + return; + } + // Only start once all sequences are registered so advancing can't run past the + // end of an incomplete sequence list. + if (tutorialInitialized) { + tutorialStarted = true; + // Start on next event loop iteration so everything is fully painted + QTimer::singleShot(3, tutorialController, [this] { tutorialController->start(); }); + } +} + +void TabGame::finishTutorialInitialization() +{ + if (tutorialInitialized) { + return; + } else { + tutorialInitialized = true; + } + + // No sequence building (and no widget access) once the tutorial is done. + if (SettingsCache::instance().userInterface().getTutorialCompleted()) { + return; + } + + auto deckViewSequence = deckViewContainers.first()->generateTutorialSequence(); + + tutorialController->addSequence(deckViewSequence); + + TutorialSequence deckSelectSequence; + deckSelectSequence.name = tr("Deck selection and readying up"); + + TutorialStep loadDeckStep; + loadDeckStep.targetWidget = deckViewContainers.first(); + loadDeckStep.text = tr("Let's load a deck now."); + loadDeckStep.allowClickThrough = true; + loadDeckStep.requiresInteraction = true; + loadDeckStep.autoAdvanceOnValid = true; + loadDeckStep.validationTiming = ValidationTiming::OnSignal; + loadDeckStep.signalHook = [this](TutorialController *controller) { + return connect(game->getGameEventHandler(), &GameEventHandler::logDeckSelect, controller, + &TutorialController::checkValidation); + }; + loadDeckStep.validator = [] { return true; }; + + deckSelectSequence.addStep(loadDeckStep); + + TutorialStep readyUpStep; + readyUpStep.targetWidget = deckViewContainers.first(); + readyUpStep.text = tr("Let's ready up now."); + readyUpStep.allowClickThrough = true; + readyUpStep.requiresInteraction = true; + readyUpStep.autoAdvanceOnValid = true; + readyUpStep.validationTiming = ValidationTiming::OnSignal; + readyUpStep.signalHook = [this](TutorialController *controller) { + return connect(this, &TabGame::localPlayerReadyStateChanged, controller, &TutorialController::checkValidation); + }; + readyUpStep.validator = [] { return true; }; + + deckSelectSequence.addStep(readyUpStep); + + tutorialController->addSequence(deckSelectSequence); + + TutorialSequence gamePlaySequence; + gamePlaySequence.name = tr("Gameplay"); + + gamePlaySequence.addStep( + {gamePlayAreaWidget, + tr("Welcome to your first game! It's just a singleplayer game for now to teach you the controls.")}); + gamePlaySequence.addStep( + {gamePlayAreaWidget, + tr("Unfortunately, due to the way the game tab works, we can't highlight any specific gameplay elements but " + "we're confident you'll be able to spot all the relevant elements on-screen.")}); + gamePlaySequence.addStep( + {gamePlayAreaWidget, + tr("Let's go over them quickly, left-to-right.\n\nThe phase toolbar\nThe player area\nThe battlefield")}); + gamePlaySequence.addStep( + {gamePlayAreaWidget, + tr("First up, is the phase toolbar. This toolbar shows the current phase of the turn. You can advance it by " + "pressing\n\n" + "- Tab (simply advances the phase)\n" + "- Ctrl+Space (advances the phase and performes any associated actions)\n" + "- Clicking directly on the phase you want to change to.\n\n" + "You can also pass the turn here, although, you should note that most players prefer you simply leave your " + "turn on the end step and allow them to 'take' the turn from you by pressing 'Next turn' themselves.")}); + gamePlaySequence.addStep({gamePlayAreaWidget, tr("Next up, is your player area.\n\nHere you can find:\n\n- Your " + "avatar\n- Your life-counter\n- Various counters you can use to " + "track temporary resources (i.e. mana)\n- Your " + "library,\n- Your hand\n- Your graveyard\n- Your exile")}); + gamePlaySequence.addStep( + {gamePlayAreaWidget, + tr("To the right of your player area, and taking up most of the screen, is your battlefield.\nThe relevant " + "zones here are, left-to-right, top-to-bottom:\n- The Stack\n- The Battlefield (Currently highlighted " + "because it is your turn)\n- Your Hand")}); + gamePlaySequence.addStep( + {gamePlayAreaWidget, + tr("Before we dive any deeper into the actual controls, remember this:\n\nYou can perform almost " + "EVERY action by right-clicking the relevant object or zone!")}); + gamePlaySequence.addStep( + {gamePlayAreaWidget, tr("However, there are shortcuts and conveniences to speed up your games and make your " + "life easier.\n\nLet's run through a typical game start now to get you up to speed.")}); + + TutorialStep lifeCounterStep; + lifeCounterStep.targetWidget = gamePlayAreaWidget; + lifeCounterStep.text = + tr("To control your life total, you can:\n\nSet it directly using Ctrl+L\nLeft-click the " + "number on your avatar to increment it.\nRight-click the number on your avatar to decrement " + "it.\nMiddle-click the number on your avatar to open up an interval menu up to +-10."); + lifeCounterStep.requiresInteraction = true; + lifeCounterStep.allowClickThrough = true; + lifeCounterStep.autoAdvanceOnValid = true; + lifeCounterStep.validationTiming = ValidationTiming::OnSignal; + lifeCounterStep.signalHook = [this](TutorialController *controller) { + return connect(game->getPlayerManager() + ->getActiveLocalPlayer(game->getPlayerManager()->getLocalPlayerId()) + ->getPlayerEventHandler(), + &PlayerEventHandler::logSetCounter, controller, &TutorialController::checkValidation); + }; + lifeCounterStep.validator = [this] { + auto counters = + game->getPlayerManager()->getActiveLocalPlayer(game->getPlayerManager()->getLocalPlayerId())->getCounters(); + for (auto counter : counters) { + if (counter->getName() == "life") { + return counter->getValue() == 10; + } + } + return false; + }; + lifeCounterStep.validationHint = tr("Set your life total to 10 using any of these methods."); + + gamePlaySequence.addStep(lifeCounterStep); + + TutorialStep diceRollStep; + diceRollStep.targetWidget = gamePlayAreaWidget; + diceRollStep.text = tr("Fantastic! Let's roll a dice now. Many players use this to determine the initial turn " + "order.\nYou can right-click the battlefield and choose the menu " + "option or use the shortcut (Default Ctrl+I)."); + diceRollStep.requiresInteraction = true; + diceRollStep.allowClickThrough = true; + diceRollStep.autoAdvanceOnValid = true; + diceRollStep.validationTiming = ValidationTiming::OnSignal; + diceRollStep.signalHook = [this](TutorialController *controller) { + return connect(game->getPlayerManager() + ->getActiveLocalPlayer(game->getPlayerManager()->getLocalPlayerId()) + ->getPlayerEventHandler(), + &PlayerEventHandler::logRollDie, controller, &TutorialController::checkValidation); + }; + diceRollStep.validator = [] { return true; }; + diceRollStep.validationHint = tr("Roll a dice using any of these methods."); + + gamePlaySequence.addStep(diceRollStep); + + TutorialStep mulliganStep; + mulliganStep.targetWidget = gamePlayAreaWidget; + mulliganStep.text = + tr("Alright, with that out of the way, we can get down to business:\n\nDrawing cards!\n\nTo draw your initial " + "hand:\n\n- Right-click your hand in the player area and select 'Take mulligan'\n-n Right-click your hand " + "zone on the battlefield and select 'Take mulligan'\n- Use the default shortcut (Ctrl+M)"); + mulliganStep.requiresInteraction = true; + mulliganStep.allowClickThrough = true; + mulliganStep.autoAdvanceOnValid = true; + mulliganStep.validationTiming = ValidationTiming::OnSignal; + mulliganStep.signalHook = [this](TutorialController *controller) { + return connect(game->getPlayerManager() + ->getActiveLocalPlayer(game->getPlayerManager()->getLocalPlayerId()) + ->getPlayerEventHandler(), + &PlayerEventHandler::logDrawCards, controller, &TutorialController::checkValidation); + }; + mulliganStep.validator = [this] { + return game->getPlayerManager() + ->getActiveLocalPlayer(game->getPlayerManager()->getLocalPlayerId()) + ->getHandZone() + ->getCards() + .size() == 7; + }; + mulliganStep.validationHint = tr("Mulligan to 7 cards using any of these methods."); + + gamePlaySequence.addStep(mulliganStep); + + tutorialController->addSequence(gamePlaySequence); + + if (tutorialStarted) { + return; + } + // The tab may have been shown before the local player joined; start now that + // the full sequence list is registered. + if (isVisible()) { + tutorialStarted = true; + QTimer::singleShot(3, tutorialController, [this] { tutorialController->start(); }); + } } void TabGame::connectToGameState() @@ -893,6 +1100,8 @@ void TabGame::addLocalPlayer(PlayerLogic *newPlayer, int playerId) deckView->playerDeckView->readyAndUpdate(); }); } + + finishTutorialInitialization(); } void TabGame::processPlayerLeave(PlayerLogic *leavingPlayer) @@ -979,6 +1188,7 @@ void TabGame::processLocalPlayerSideboardLocked(int playerId, bool sideboardLock void TabGame::processLocalPlayerReadyStateChanged(int playerId, bool ready) { deckViewContainers.value(playerId)->playerDeckView->setReadyStart(ready); + emit localPlayerReadyStateChanged(ready); } void TabGame::createZoneForPlayer(PlayerLogic *newPlayer, int playerId) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 412529dc2..bf4e572c8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -47,6 +47,7 @@ inline Q_LOGGING_CATEGORY(TabGameLog, "tab_game"); class UserListProxy; class AbstractClient; +class TutorialController; class GameView; class GameScene; class ReplayWidget; @@ -68,6 +69,9 @@ class TabGame : public Tab Q_OBJECT private: AbstractGame *game; + TutorialController *tutorialController = nullptr; + bool tutorialStarted = false; + bool tutorialInitialized = false; const UserListProxy *userListProxy; ReplayWidget *replayWidget = nullptr; QStringList gameTypes; @@ -143,6 +147,7 @@ private: void createReplayDock(GameReplay *replay); void updateInviteButtonState(); signals: + void localPlayerReadyStateChanged(bool ready); void gameClosing(TabGame *tab); void containerProcessingStarted(const GameEventContext &context); void containerProcessingDone(); @@ -186,16 +191,17 @@ private slots: void processPlayerLeave(PlayerLogic *leavingPlayer); void actResetLayout(); - void hideEvent(QHideEvent *event) override; - protected slots: void closeEvent(QCloseEvent *event) override; + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; public: TabGame(TabSupervisor *_tabSupervisor, QList &_clients, const Event_GameJoined &event, const QMap &_roomGameTypes); + void finishTutorialInitialization(); void connectToGameState(); void connectToPlayerManager(); void connectToGameEventHandler(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..3fcc37af8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -174,6 +174,10 @@ public: { return tabServer; } + [[nodiscard]] TabHome *getTabHome() const + { + return tabHome; + } [[nodiscard]] const QMap &getRoomTabs() const { return roomTabs; diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 84bf026f7..1ef55e294 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -10,10 +10,12 @@ #include "../../deck_editor/deck_state_manager.h" #include "../../deck_editor/deck_zone_dialog.h" #include "../../interface/widgets/deck_analytics/deck_analytics_widget.h" +#include "../../interface/widgets/general/tutorial/tutorial_controller.h" #include "../../interface/widgets/visual_deck_editor/visual_deck_editor_widget.h" #include "../../menus/deck_editor_menu.h" #include "../../visual_database_display/visual_database_display_widget.h" #include "../../visual_deck_editor/visual_deck_editor_sample_hand_widget.h" +#include "../tab_home.h" #include "../tab_supervisor.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -45,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +75,85 @@ TabDeckEditorVisual::TabDeckEditorVisual(TabSupervisor *_tabSupervisor) : Abstra loadLayout(); filterDockWidget->setHidden(true); cardDatabaseDockWidget->setHidden(true); + tutorialController = new TutorialController(this); + createTutorialSequences(); +} + +void TabDeckEditorVisual::createTutorialSequences() +{ + auto deckDockSequence = deckDockWidget->generateTutorialSequence(); + + tutorialController->addSequence(deckDockSequence); + + auto sequence = TutorialSequence(); + + sequence.addStep({tabContainer->tabBar(), + "The Visual Deck Editor has multiple different functionalities.\n\nYou can cycle " + "through them by using these tabs.\n\nLet's start with the Visual Deck View."}); + sequence.addStep({tabContainer->visualDeckView, + "The cards in your deck will be displayed here, allowing for an easy overview.\n\nLet's try " + "adding some now, so you can see it in action!", + [this]() { tabContainer->setCurrentWidget(tabContainer->visualDeckView); }}); + + tutorialController->addSequence(sequence); + + auto vdeSequence = tabContainer->visualDeckView->addTutorialSteps(); + vdeSequence.addStep({tabContainer->tabBar(), "Let's look at the database tab now."}); + tutorialController->addSequence(vdeSequence); + + auto vddSequence = tabContainer->visualDatabaseDisplay->addTutorialSteps(); + vddSequence.steps.prepend( + {tabContainer->visualDatabaseDisplay, + "You can view the database here, either as card images or in the old table display " + "style.\n\nAdditionally, there are many powerful and easy to use filters available.\n\nLet's dive in!", + [this]() { tabContainer->setCurrentWidget(tabContainer->visualDatabaseDisplay); }}); + + tutorialController->addSequence(vddSequence); + + auto analyticsSequence = tabContainer->deckAnalytics->generateTutorialSequence(); + analyticsSequence.steps.prepend({tabContainer->tabBar(), "Let's look at the analytics tab now."}); + + TutorialStep analyticsConclusionStep; + analyticsConclusionStep.targetWidget = tabContainer->tabBar(); + analyticsConclusionStep.text = + tr("That was it for the analytics tab.\n\nLet's now look at an equally useful tab, which provides you with " + "detailed information about possible hands, invaluable information when testing out a new deck."); + + analyticsSequence.addStep(analyticsConclusionStep); + + tutorialController->addSequence(analyticsSequence); + + auto sampleHandSequence = tabContainer->sampleHandWidget->generateTutorialSequence(); + + tutorialController->addSequence(sampleHandSequence); + + TutorialSequence endSequence; + endSequence.name = tr("Visual Deck Editor Conclusion"); + + TutorialStep introStep; + introStep.targetWidget = this; + introStep.text = tr("This concludes the Visual Deck Editor tutorial."); + introStep.onEnter = [this]() { tabContainer->setCurrentWidget(tabContainer->visualDeckView); }; + endSequence.addStep(introStep); + + TutorialStep conclusionStep; + conclusionStep.targetWidget = tabSupervisor->tabBar(); + conclusionStep.text = + tr("Let's go back to the Home Tab now to explore where you can manage your newly created deck."); + conclusionStep.onExit = [this]() { tabSupervisor->setCurrentWidget(tabSupervisor->getTabHome()); }; + endSequence.addStep(conclusionStep); + + tutorialController->addSequence(endSequence); +} + +void TabDeckEditorVisual::showEvent(QShowEvent *ev) +{ + QWidget::showEvent(ev); + if (!tutorialStarted && !SettingsCache::instance().userInterface().getTutorialCompleted()) { + tutorialStarted = true; + // Start on next event loop iteration so everything is fully painted + QTimer::singleShot(0, tutorialController, [this] { tutorialController->start(); }); + } } /** @brief Creates the central frame containing the tab container. */ diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index f5eb9981a..c847cd5fe 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -15,6 +15,7 @@ class QWidget; class TabDeckEditorVisualTabWidget; class TabSupervisor; +class TutorialController; /** * @class TabDeckEditorVisual * @ingroup DeckEditorTabs @@ -66,7 +67,17 @@ class TabDeckEditorVisual : public AbstractTabDeckEditor { Q_OBJECT +private: + TutorialController *tutorialController = nullptr; + bool tutorialStarted = false; + + /** + * @brief Build and register all tutorial sequences for this tab. + */ + void createTutorialSequences(); + protected slots: + void showEvent(QShowEvent *ev) override; /** * @brief Load the editor layout from settings. */ diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h index f97310c1b..cdb4fd44d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h @@ -31,6 +31,11 @@ public: void initialize(); void retranslateUi(); + SettingsButtonWidget *getSetFilterWidget() + { + return quickFilterSetWidget; + } + private: VisualDatabaseDisplayWidget *visualDatabaseDisplay; DeckListModel *deckListModel; diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 6037418ad..80a03bec4 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -7,8 +7,14 @@ #include "../../pixel_map_generator.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" +#include "../deck_editor/deck_state_manager.h" #include "../general/layout_containers/flow_widget.h" +#include "../general/tutorial/tutorial_controller.h" +#include "../quick_settings/settings_button_widget.h" +#include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" +#include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "../utility/custom_line_edit.h" +#include "../visual_deck_editor/visual_deck_editor_widget.h" #include "card_database_display_model.h" #include "card_database_model.h" #include "card_database_visual_display_model.h" @@ -21,7 +27,9 @@ #include "libcockatrice/filters/filter_card.h" #include "libcockatrice/filters/filter_tree.h" #include "visual_database_display_color_filter_widget.h" +#include "visual_database_display_filter_save_load_widget.h" #include "visual_database_display_filter_toolbar_widget.h" +#include "visual_database_display_set_filter_widget.h" #include #include @@ -42,6 +50,7 @@ #include #include #include +#include class QMouseEvent; @@ -182,6 +191,81 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, retranslateUi(); } +TutorialSequence VisualDatabaseDisplayWidget::addTutorialSteps() +{ + auto sequence = TutorialSequence(); + sequence.addStep({colorFilterWidget, "Filter the database by colors with these controls"}); + TutorialStep displayModeStep; + displayModeStep.targetWidget = displayModeButton; + displayModeStep.text = tr("You can change back to the old table display-style with this button."); + displayModeStep.allowClickThrough = true; + sequence.addStep(displayModeStep); + sequence.addStep({filterContainer, "Use these controls for quick access to common filters."}); + + TutorialStep setFilterStep; + setFilterStep.targetWidget = filterContainer->getSetFilterWidget(); + setFilterStep.text = tr("Let's try it out now by selecting a set filter!"); + setFilterStep.allowClickThrough = true; + setFilterStep.requiresInteraction = true; + setFilterStep.autoAdvanceOnValid = true; + setFilterStep.validationTiming = ValidationTiming::OnSignal; + setFilterStep.signalHook = [this](TutorialController *controller) { + return connect(filterModel, &FilterTreeModel::layoutChanged, controller, &TutorialController::checkValidation); + }; + setFilterStep.validator = [] { return true; }; + sequence.addStep(setFilterStep); + + TutorialStep explorationStep; + explorationStep.targetWidget = this; + explorationStep.text = tr("Try it out!\n\nAdd 5 different new cards to the deck by clicking on them!"); + explorationStep.allowClickThrough = true; + explorationStep.requiresInteraction = true; + explorationStep.autoAdvanceOnValid = true; + explorationStep.validationTiming = ValidationTiming::OnSignal; + if (QtUtils::findParentOfType(this)) { + explorationStep.onEnter = [this] { + auto deckEditor = QtUtils::findParentOfType(this); + // Only clear an empty starter deck; never destroy a deck the user may + // have opened with existing cards. + if (deckEditor->deckStateManager->getModel()->getDeckList()->getCardList().isEmpty()) { + deckEditor->deckStateManager->clearDeck(); + } + }; + auto deckModel = QtUtils::findParentOfType(this)->deckStateManager->getModel(); + explorationStep.signalHook = [deckModel](TutorialController *controller) { + return connect(deckModel, &DeckListModel::cardNodesChanged, controller, + &TutorialController::checkValidation); + }; + explorationStep.validator = [this] { + if (QtUtils::findParentOfType(this)) { + return QtUtils::findParentOfType(this) + ->deckStateManager->getModel() + ->getDeckList() + ->getCardList() + .size() >= 5; + } + return true; + }; + } + + sequence.addStep(explorationStep); + + TutorialStep conclusionStep; + conclusionStep.targetWidget = this; + conclusionStep.text = tr( + "Great!\n\nLet's look at them in the deck view before we conclude this tutorial with the analytics widgets."); + conclusionStep.onExit = [this]() { + auto tabWidget = QtUtils::findParentOfType(this); + if (tabWidget) { + tabWidget->setCurrentWidget(tabWidget->visualDeckView); + } + }; + + sequence.addStep(conclusionStep); + + return sequence; +} + void VisualDatabaseDisplayWidget::initialize() { databaseLoadIndicator->setVisible(false); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 6148670a3..9e68212c9 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -7,6 +7,7 @@ #ifndef VISUAL_DATABASE_DISPLAY_WIDGET_H #define VISUAL_DATABASE_DISPLAY_WIDGET_H +#include "../general/tutorial/tutorial_controller.h" #include "libcockatrice/card/card_info.h" #include @@ -72,6 +73,7 @@ public: bool isVisualDisplayMode() const; public slots: + TutorialSequence addTutorialSteps(); /** * @brief Pushes the active set filters to the visual grid. * diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 63ef07835..0e0e40ef1 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -157,6 +157,64 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() emit displayTypeChanged(currentDisplayType); } +void VisualDeckDisplayOptionsWidget::generateTutorialSequence(TutorialSequence &sequence) +{ + TutorialStep introStep; + introStep.targetWidget = this; + introStep.text = tr("You can change how the deck is displayed, grouped, and sorted here."); + + sequence.addStep(introStep); + + TutorialStep displayTypeStep; + displayTypeStep.targetWidget = displayTypeButton; + displayTypeStep.text = + tr("You can change the layout of the displayed cards by clicking on this button.\n\nThe overlap type will " + "stack cards on top of each other, leaving the top exposed for easy skimming.\nYou can always hover your " + "mouse over a card to display a zoomed version of it.\n\nThe flat layout will display cards next to each " + "other, without any overlap.\n\nLet's switch to flat now!"); + displayTypeStep.allowClickThrough = true; + displayTypeStep.requiresInteraction = true; + displayTypeStep.validationTiming = ValidationTiming::OnSignal; + displayTypeStep.signalHook = [this](TutorialController *controller) { + return connect(displayTypeButton, &QAbstractButton::clicked, controller, &TutorialController::checkValidation); + }; + displayTypeStep.autoAdvanceOnValid = true; + displayTypeStep.validator = [] { return true; }; + + sequence.addStep(displayTypeStep); + + TutorialStep groupStep; + groupStep.targetWidget = groupByComboBox; + groupStep.text = tr("You can change how cards are grouped here.\n\nLet's change cards to be grouped by 'Color'"); + groupStep.allowClickThrough = true; + groupStep.requiresInteraction = true; + groupStep.validationTiming = ValidationTiming::OnChange; + groupStep.autoAdvanceOnValid = true; + groupStep.validator = [this]() { return groupByComboBox->currentIndex() == 2; }; + groupStep.validationHint = tr("Select the 'Color' option"); + + sequence.addStep(groupStep); + + TutorialStep sortStep; + sortStep.targetWidget = sortCriteriaButton; + sortStep.text = + tr("Let's check out sorting now. In the visual deck view, sort modifiers are hierarchical,\n meaning " + "that the cards will first be sorted using the top-most criteria\nand then, if cards are equal using this " + "criteria,\nthe next criteria in the list will be used as a tie-breaker.\n\n" + "Change the sorting to be based primarily on converted mana cost (cmc) by dragging it to the top."); + sortStep.allowClickThrough = true; + sortStep.requiresInteraction = true; + sortStep.autoAdvanceOnValid = true; + sortStep.validationTiming = ValidationTiming::OnSignal; + sortStep.signalHook = [this](TutorialController *controller) { + return connect(this, &VisualDeckDisplayOptionsWidget::sortCriteriaChanged, controller, + &TutorialController::checkValidation); + }; + sortStep.validator = []() { return true; }; + + sequence.addStep(sortStep); +} + void VisualDeckDisplayOptionsWidget::updateCompactMode(bool mode) { displayTypeButton->setCompact(mode); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h index c40b8ffeb..65b453d3d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_VISUAL_DECK_DISPLAY_OPTIONS_WIDGET_H #define COCKATRICE_VISUAL_DECK_DISPLAY_OPTIONS_WIDGET_H +#include "../general/tutorial/tutorial_controller.h" #include "visual_deck_editor_widget.h" #include @@ -96,6 +97,8 @@ public: return activeSortCriteria; } + void generateTutorialSequence(TutorialSequence &sequence); + protected: /** @brief Retranslates on QEvent::LanguageChange. */ void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index 91ad63433..785f69775 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -4,12 +4,16 @@ #include "../cards/card_info_picture_widget.h" #include "../cards/card_size_widget.h" #include "../deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.h" +#include "../deck_analytics/deck_list_statistics_analyzer.h" #include "../general/layout_containers/flow_widget.h" +#include "../general/tutorial/tutorial_controller.h" +#include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "deck_list_model.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/deck_list_card_node.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/utility/qt_utils.h" #include #include @@ -88,6 +92,27 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare retranslateUi(); } +TutorialSequence VisualDeckEditorSampleHandWidget::generateTutorialSequence() +{ + TutorialSequence sampleHandSequence; + sampleHandSequence.name = tr("Sample Hand"); + + TutorialStep introStep; + introStep.targetWidget = this; + introStep.text = tr("This is the sample hand tab.\n\nHere, you can draw a sample hand from your deck without " + "having to start a game as well as view statistical information about your draws."); + introStep.onEnter = [this]() { + auto tabWidget = QtUtils::findParentOfType(this); + if (tabWidget) { + tabWidget->setCurrentWidget(tabWidget->sampleHandWidget); + } + }; + + sampleHandSequence.addStep(introStep); + + return sampleHandSequence; +} + void VisualDeckEditorSampleHandWidget::retranslateUi() { resetButton->setText(tr("Draw a new sample hand")); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h index 16f343b75..6d9b2da47 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h @@ -7,6 +7,7 @@ #ifndef VISUAL_DECK_EDITOR_SAMPLE_HAND_WIDGET_H #define VISUAL_DECK_EDITOR_SAMPLE_HAND_WIDGET_H +#include "../general/tutorial/tutorial_controller.h" #include "libcockatrice/card/printing/exact_card.h" #include @@ -31,6 +32,7 @@ public: DeckListModel *deckListModel, DeckListStatisticsAnalyzer *statsAnalyzer); QList getRandomCards(int amountToGet); + TutorialSequence generateTutorialSequence(); public slots: void updateDisplay(); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 506ffa2b4..5a63ea79e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -39,11 +39,13 @@ #include #include #include +#include #include #include #include #include #include +#include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, DeckListModel *_deckListModel, @@ -457,4 +459,91 @@ void VisualDeckEditorWidget::onSelectionChanged(const QItemSelection &selected, } } } +} + +TutorialSequence VisualDeckEditorWidget::addTutorialSteps() +{ + TutorialSequence sequence; + sequence.name = "Adding Cards to Your Deck"; + + TutorialStep introStep; + introStep.targetWidget = displayOptionsAndSearch; + introStep.text = "There are two ways of adding cards to your deck:\n\n" + "The first is by using the quick search bar in the deck view tab.\n" + "This is helpful if you already know which card you would like to add " + "and will provide name suggestions as you type.\n\n" + "We'll look at the second way, through the database tab, later."; + sequence.addStep(introStep); + + TutorialStep searchStep; + searchStep.targetWidget = searchBar; + searchStep.text = "Let's try it out now!\nType the name of a card into the search bar."; + searchStep.allowClickThrough = true; + searchStep.requiresInteraction = true; + searchStep.autoAdvanceOnValid = true; + searchStep.validationTiming = ValidationTiming::OnChange; + searchStep.validator = [this]() { + return CardDatabaseManager::query()->getCard({searchBar->text()}) != ExactCard(); + }; + searchStep.validationHint = "Please enter a valid card name."; + searchStep.customInteractionHint = "✏️ Type a valid card name to continue"; + + sequence.addStep(searchStep); + + TutorialStep addStep; + addStep.targetWidget = searchPushButton; + addStep.text = "Click this button to add the card to your deck."; + addStep.allowClickThrough = true; + addStep.requiresInteraction = true; + addStep.autoAdvanceOnValid = true; + addStep.validationTiming = ValidationTiming::OnSignal; + addStep.signalHook = [this](TutorialController *controller) { + return connect(deckListModel, &DeckListModel::cardAddedAt, controller, &TutorialController::checkValidation); + }; + addStep.validator = [this]() { return deckListModel->getCardNodes().size() >= 1; }; + + sequence.addStep(addStep); + + TutorialStep organizationStep; + organizationStep.targetWidget = this; + organizationStep.text = "Let's look at how cards are organized and displayed now.\n\nWe'll add some random cards " + "from the database to your deck, so you can see it in action properly."; + organizationStep.onExit = [this]() { + // getCardList() counts distinct card nodes, not copies, so once every random pick only bumps an + // existing node's count the size never grows. With fewer than ~60 distinct cards in the database + // this would loop forever, so bound the attempts to always terminate. + static constexpr int MaxFillAttempts = 600; + int attempts = 0; + while (deckListModel->getDeckList()->getCardList().size() < 60 && attempts < MaxFillAttempts) { + deckListModel->addCard(CardDatabaseManager::query()->getRandomCard(), DECK_ZONE_MAIN); + ++attempts; + } + }; + + sequence.addStep(organizationStep); + + TutorialStep hoverStep; + hoverStep.targetWidget = this; + hoverStep.text = "Great! Take some time to explore these new cards in the current display mode.\n\nYou can select " + "a card by clicking on it with the left mouse button.\nYou can select multiple cards by holding " + "down CTRL or Shift.\nYou can clear the current selection by clicking on an area without a " + "card.\nDouble-clicking a card will move it between main and sideboard.\nRight-clicking a card " + "will remove it from the deck.\n\nYou can hover over a card to see a zoomed version of it."; + hoverStep.allowClickThrough = true; + + sequence.addStep(hoverStep); + + displayOptionsWidget->generateTutorialSequence(sequence); + + TutorialStep conclusionStep; + conclusionStep.targetWidget = this; + conclusionStep.text = + "Great!\n\nNow that you've learned about all the different ways of displaying the cards in " + "your deck, it's time to move on to searching for new cards for your deck in style and ease.\n\nYou can stay " + "on this screen to play around with the display options and advance when you are ready."; + conclusionStep.allowClickThrough = true; + + sequence.addStep(conclusionStep); + + return sequence; } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h index 0b8ff19fb..27ceb14ab 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h @@ -7,6 +7,8 @@ #ifndef VISUAL_DECK_EDITOR_H #define VISUAL_DECK_EDITOR_H +#include "../general/tutorial/tutorial_controller.h" + #include #include #include @@ -59,6 +61,7 @@ public: void setSelectionModel(QItemSelectionModel *model); void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected); + TutorialSequence addTutorialSteps(); void updatePlaceholderVisibility(); QItemSelectionModel *getSelectionModel() const { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 4e1d6293f..c33d4fe13 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../pixel_map_generator.h" +#include "../general/tutorial/tutorial_controller.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "libcockatrice/card/database/card_database.h" @@ -157,6 +158,28 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare } } +void VisualDeckStorageWidget::generateTutorialSequence(TutorialSequence &vdsSequence) +{ + vdsSequence.addStep( + {this, tr("This is the visual deck storage. It displays all the files and folders located in " + "your default deck storage location. You can adjust this location in the settings.")}); + vdsSequence.addStep({searchAndSortContainer, + tr("You can filter the decks in your collection using these widgets. Check the (i) symbol in " + "the search bar for more information on the syntax used to filter decks.")}); + vdsSequence.addStep( + {tagFilterWidget, + tr("Additionally, the VDS allows you to assign and filter by tags. This is very helpful for large deck " + "collections, as it allows you to group similar decks (i.e. by power level or theme) and then 'drill down' " + "to exactly the combination of tags that interests you (i.e. 'I want to play a mid-power deck focused on " + "this type of card that wins with this strategy.')\n\nYou can left-click a tag to add it to the " + "filter.\nThe widget will then automatically filter the list to only include tags from decks which also " + "contain your original tag.\n\nYou can exclude a tag by right-clicking it.\n\nYou can clear a tags filter " + "status with the middle mouse button.")}); + vdsSequence.addStep({scrollArea, tr("This is where all your local decks will be displayed. You can customize their " + "display status using the cogwheel in the top right.\nYou can select a deck by " + "double-clicking it.\n\nRight-click a deck for more options.")}); +} + void VisualDeckStorageWidget::refreshIfPossible() { if (scrollArea->widget() != databaseLoadIndicator) { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index 19d16f107..d837323a1 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -6,6 +6,8 @@ #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H +#include "../general/tutorial/tutorial_controller.h" + #include #include #include @@ -42,6 +44,7 @@ public: void setShareSelectedFiles(const QStringList &paths); void clearShareSelection(); void setShareAvailable(bool available); + void generateTutorialSequence(TutorialSequence &vdsSequence); VisualDeckStorageTagFilterWidget *tagFilterWidget; bool deckPreviewSelectionAnimationEnabled; diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 256d9eb26..a1a1e57d1 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -5,6 +5,8 @@ #include "libcockatrice/utility/playmat_params.h" #include +#include +#include #include #include #include @@ -246,6 +248,11 @@ QStringList InterfaceSettings::getUserListExpandedSections() const .toStringList(); } +bool InterfaceSettings::getTutorialCompleted() const +{ + return getValue("tutorialCompleted", QString(), QString(), false).toBool(); +} + void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus) { setValue(_useTearOffMenus, "useTearOffMenus"); @@ -469,3 +476,8 @@ void InterfaceSettings::setUserListExpandedSections(const QStringList §ions) { setValue(sections, "userListExpandedSections"); } + +void InterfaceSettings::setTutorialCompleted(bool value) +{ + setValue(value, "tutorialCompleted"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.h b/libcockatrice_settings/libcockatrice/settings/interface_settings.h index 41bb6ee17..8bede58d9 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -57,6 +57,7 @@ public: [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; [[nodiscard]] bool getBattlefieldFlashEnabled() const override; [[nodiscard]] QStringList getUserListExpandedSections() const override; + [[nodiscard]] bool getTutorialCompleted() const; void setUseTearOffMenus(bool _useTearOffMenus); void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); @@ -96,6 +97,7 @@ public: void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); void setUserListExpandedSections(const QStringList §ions); + void setTutorialCompleted(bool value); signals: void useTearOffMenusChanged(bool state);