Merge pull request 'feat(gamelist): Add 'Surprise Me!' random game feature' (#114) from feat/surprise-me into main

Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/114
This commit is contained in:
Collecting
2026-01-28 07:40:01 +01:00
4 changed files with 306 additions and 0 deletions

8
dist/dice.svg vendored Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="3" fill="#FEFEFE"/>
<circle cx="8.5" cy="8.5" r="1.5" fill="#1E1E1E"/>
<circle cx="15.5" cy="8.5" r="1.5" fill="#1E1E1E"/>
<circle cx="12" cy="12" r="1.5" fill="#1E1E1E"/>
<circle cx="8.5" cy="15.5" r="1.5" fill="#1E1E1E"/>
<circle cx="15.5" cy="15.5" r="1.5" fill="#1E1E1E"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -1,5 +1,6 @@
<!--
SPDX-FileCopyrightText: 2021 yuzu Emulator Project
SPDX-FileCopyrightText: 2026 citron Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
@@ -7,4 +8,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<qresource prefix="/img">
<file alias="citron.ico">../../dist/citron.ico</file>
</qresource>
<qresource prefix="/">
<file alias="dist/dice.svg">../../dist/dice.svg</file>
</qresource>
</RCC>

View File

@@ -29,6 +29,15 @@
#include <QUrlQuery>
#include <QtConcurrent/QtConcurrent>
#include <QFutureWatcher>
#include <QDialog>
#include <QPushButton>
#include <QLabel>
#include <QTimer>
#include <QPropertyAnimation>
#include <QEasingCurve>
#include <random>
#include <vector>
#include <QSizePolicy>
#include <fmt/format.h>
#include "common/common_types.h"
#include "common/logging/log.h"
@@ -47,6 +56,211 @@
#include "citron/uisettings.h"
#include "citron/util/controller_navigation.h"
// A helper struct to cleanly pass game data
struct SurpriseGame {
QString name;
QString path;
quint64 title_id;
QPixmap icon;
};
// This is the custom widget that shows the actual spinning game icons
class GameReelWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal scrollOffset READ getScrollOffset WRITE setScrollOffset)
public:
explicit GameReelWidget(QWidget* parent = nullptr) : QWidget(parent), m_scroll_offset(0.0) {
setFixedHeight(150);
}
void setGameReel(const QVector<SurpriseGame>& games) {
m_games = games;
update();
}
qreal getScrollOffset() const { return m_scroll_offset; }
void setScrollOffset(qreal offset) {
m_scroll_offset = offset;
update();
}
protected:
void paintEvent(QPaintEvent* event) override {
if (m_games.isEmpty()) return;
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const int icon_size = 128;
const int icon_spacing = 15;
const int total_slot_width = icon_size + icon_spacing;
const int widget_center_x = width() / 2;
const int widget_center_y = height() / 2;
painter.fillRect(rect(), palette().color(QPalette::Window));
QColor highlight_color = palette().color(QPalette::Highlight);
painter.fillRect(widget_center_x - 2, 0, 4, height(), highlight_color);
for (int i = 0; i < m_games.size(); ++i) {
const qreal icon_x_position = (widget_center_x - icon_size / 2) + (i * total_slot_width) - m_scroll_offset;
const int draw_x = static_cast<int>(icon_x_position);
const int draw_y = widget_center_y - (icon_size / 2);
if (draw_x + icon_size < 0 || draw_x > width()) {
continue;
}
painter.save();
QPainterPath path;
path.addRoundedRect(draw_x, draw_y, icon_size, icon_size, 12, 12);
painter.setClipPath(path);
painter.drawPixmap(draw_x, draw_y, icon_size, icon_size, m_games[i].icon);
painter.restore();
}
}
private:
QVector<SurpriseGame> m_games;
qreal m_scroll_offset;
};
// This is the main pop-up window that holds the spinning icons, title, and buttons
class SurpriseMeDialog : public QDialog {
Q_OBJECT
public:
explicit SurpriseMeDialog(QVector<SurpriseGame> games, QWidget* parent = nullptr)
: QDialog(parent), m_available_games(games), m_last_choice({QString(), QString(), 0, QPixmap()}) {
setWindowTitle(tr("Surprise Me!"));
setModal(true);
setFixedSize(540, 280);
auto* layout = new QVBoxLayout(this);
layout->setSpacing(15);
layout->setContentsMargins(15, 15, 15, 15);
m_reel_widget = new GameReelWidget(this);
m_game_title_label = new QLabel(tr("Spinning..."), this);
m_launch_button = new QPushButton(tr("Launch Game"), this);
m_reroll_button = new QPushButton(tr("Try Again?"), this);
m_launch_button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_reroll_button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_launch_button->setStyleSheet(QStringLiteral("padding: 5px;"));
m_reroll_button->setStyleSheet(QStringLiteral("padding: 5px;"));
m_launch_button->setMinimumHeight(35);
m_reroll_button->setMinimumHeight(35);
QFont title_font = m_game_title_label->font();
title_font.setPointSize(16);
title_font.setBold(true);
m_game_title_label->setFont(title_font);
m_game_title_label->setAlignment(Qt::AlignCenter);
m_game_title_label->setWordWrap(true);
auto* button_layout = new QHBoxLayout();
button_layout->addWidget(m_reroll_button);
button_layout->addWidget(m_launch_button);
layout->addWidget(m_reel_widget);
layout->addWidget(m_game_title_label);
layout->addLayout(button_layout);
m_launch_button->setEnabled(false);
m_reroll_button->setEnabled(false);
m_animation = new QPropertyAnimation(m_reel_widget, "scrollOffset", this);
m_animation->setEasingCurve(QEasingCurve::OutCubic);
connect(m_launch_button, &QPushButton::clicked, this, &SurpriseMeDialog::onLaunch);
connect(m_reroll_button, &QPushButton::clicked, this, &SurpriseMeDialog::startRoll);
QTimer::singleShot(100, this, &SurpriseMeDialog::startRoll);
}
const SurpriseGame& getFinalChoice() const { return m_last_choice; }
private slots:
void startRoll() {
if (m_available_games.isEmpty()) {
m_game_title_label->setText(tr("No more games to choose!"));
m_reroll_button->setEnabled(false);
return;
}
m_game_title_label->setText(tr("Spinning..."));
m_launch_button->setEnabled(false);
m_reroll_button->setEnabled(false);
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> full_distrib(0, m_available_games.size() - 1);
const int winning_index = full_distrib(gen);
const SurpriseGame winner = m_available_games.at(winning_index);
m_available_games.removeAt(winning_index);
QVector<SurpriseGame> reel;
if (!m_available_games.isEmpty()) {
std::uniform_int_distribution<> filler_distrib(0, m_available_games.size() - 1);
for (int i = 0; i < 20; ++i) reel.push_back(m_available_games.at(filler_distrib(gen)));
reel.push_back(winner);
for (int i = 0; i < 20; ++i) reel.push_back(m_available_games.at(filler_distrib(gen)));
} else {
reel.push_back(winner);
}
m_reel_widget->setGameReel(reel);
const int icon_size = 128;
const int icon_spacing = 15;
const int total_slot_width = icon_size + icon_spacing;
const qreal start_offset = 0;
const int winning_reel_index = m_available_games.isEmpty() ? 0 : 20;
const qreal end_offset = (winning_reel_index * total_slot_width);
m_animation->stop();
m_reel_widget->setScrollOffset(start_offset);
m_animation->setDuration(4000);
m_animation->setStartValue(start_offset);
m_animation->setEndValue(end_offset);
disconnect(m_animation, &QPropertyAnimation::finished, nullptr, nullptr);
connect(m_animation, &QPropertyAnimation::finished, this, [this, winner]() {
m_last_choice = winner;
onRollFinished();
});
m_animation->start();
}
void onRollFinished() {
m_game_title_label->setText(m_last_choice.name);
m_launch_button->setEnabled(true);
if (!m_available_games.isEmpty()) {
m_reroll_button->setEnabled(true);
}
}
void onLaunch() { accept(); }
private:
QVector<SurpriseGame> m_available_games;
SurpriseGame m_last_choice;
GameReelWidget* m_reel_widget;
QLabel* m_game_title_label;
QPushButton* m_launch_button;
QPushButton* m_reroll_button;
QPropertyAnimation* m_animation;
};
// Static helper for Save Detection
static QString GetDetectedEmulatorName(const QString& path, u64 program_id, const QString& citron_nand_base) {
QString abs_path = QDir(path).absolutePath();
@@ -698,6 +912,25 @@ play_time_manager{play_time_manager_}, system{system_} {
));
connect(btn_sort_az, &QToolButton::clicked, this, &GameList::ToggleSortOrder);
// Surprise Me button - positioned after sort button
btn_surprise_me = new QToolButton(toolbar);
btn_surprise_me->setIcon(QIcon(QStringLiteral(":/dist/dice.svg")));
btn_surprise_me->setToolTip(tr("Surprise Me! (Choose Random Game)"));
btn_surprise_me->setAutoRaise(true);
btn_surprise_me->setIconSize(QSize(16, 16));
btn_surprise_me->setFixedSize(32, 32);
btn_surprise_me->setStyleSheet(QStringLiteral(
"QToolButton {"
" border: 1px solid palette(mid);"
" border-radius: 4px;"
" background: palette(button);"
"}"
"QToolButton:hover {"
" background: palette(light);"
"}"
));
connect(btn_surprise_me, &QToolButton::clicked, this, &GameList::onSurpriseMeClicked);
// Create progress bar
progress_bar = new QProgressBar(this);
progress_bar->setVisible(false);
@@ -713,6 +946,7 @@ play_time_manager{play_time_manager_}, system{system_} {
toolbar_layout->addWidget(btn_grid_view);
toolbar_layout->addWidget(slider_title_size);
toolbar_layout->addWidget(btn_sort_az);
toolbar_layout->addWidget(btn_surprise_me);
toolbar_layout->addStretch(); // Push search to the right
toolbar_layout->addWidget(search_field);
@@ -1884,3 +2118,61 @@ void GameList::RefreshCompatibilityList() {
reply->deleteLater();
});
}
void GameList::onSurpriseMeClicked() {
QVector<SurpriseGame> all_games;
// Go through the list and gather info for every game (name, icon, path)
for (int i = 0; i < item_model->rowCount(); ++i) {
QStandardItem* folder = item_model->item(i, 0);
if (!folder || folder->data(GameListItem::TypeRole).value<GameListItemType>() == GameListItemType::AddDir) {
continue;
}
for (int j = 0; j < folder->rowCount(); ++j) {
QStandardItem* game_item = folder->child(j, 0);
if (game_item && game_item->data(GameListItem::TypeRole).value<GameListItemType>() == GameListItemType::Game) {
QString game_title = game_item->data(GameListItemPath::TitleRole).toString();
if (game_title.isEmpty()) {
std::string filename;
Common::SplitPath(game_item->data(GameListItemPath::FullPathRole).toString().toStdString(), nullptr, &filename, nullptr);
game_title = QString::fromStdString(filename);
}
QPixmap icon = game_item->data(Qt::DecorationRole).value<QPixmap>();
if (icon.isNull()) {
// Use a generic icon if a game is missing one
icon = QIcon::fromTheme(QStringLiteral("application-x-executable")).pixmap(128, 128);
}
all_games.append({
game_title,
game_item->data(GameListItemPath::FullPathRole).toString(),
static_cast<quint64>(game_item->data(GameListItemPath::ProgramIdRole).toULongLong()),
icon
});
}
}
}
if (all_games.empty()) {
QMessageBox::information(this, tr("Surprise Me!"), tr("No games available to choose from!"));
return;
}
// Create and show animated dialog
SurpriseMeDialog dialog(all_games, this);
const int result = dialog.exec();
// If the user clicked "Launch Game"...
if (result == QDialog::Accepted) {
const SurpriseGame choice = dialog.getFinalChoice();
if (!choice.path.isEmpty()) {
// ...then launch the game
emit GameChosen(choice.path, choice.title_id);
}
}
// If the user just closes the window (or clicks the 'X'), nothing happens.
}
#include "game_list.moc"

View File

@@ -149,6 +149,7 @@ public slots:
void OnConfigurationChanged();
private slots:
void onSurpriseMeClicked();
void UpdateProgressBarColor();
void OnItemExpanded(const QModelIndex& item);
void OnTextChanged(const QString& new_text);
@@ -200,6 +201,7 @@ private:
QToolButton* btn_grid_view = nullptr;
QSlider* slider_title_size = nullptr;
QToolButton* btn_sort_az = nullptr;
QToolButton* btn_surprise_me = nullptr;
Qt::SortOrder current_sort_order = Qt::AscendingOrder;
QTreeView* tree_view = nullptr;
QListView* list_view = nullptr;