diff --git a/Source/Core/Core/AchievementManager.cpp b/Source/Core/Core/AchievementManager.cpp index 1b6ff11d8e..d5acdc84e9 100644 --- a/Source/Core/Core/AchievementManager.cpp +++ b/Source/Core/Core/AchievementManager.cpp @@ -7,6 +7,7 @@ #include +#include #include #include "Common/HttpRequest.h" @@ -268,9 +269,15 @@ void AchievementManager::ActivateDeactivateLeaderboards() for (u32 ix = 0; ix < m_game_data.num_leaderboards; ix++) { auto leaderboard = m_game_data.leaderboards[ix]; + u32 leaderboard_id = leaderboard.id; if (m_is_game_loaded && leaderboards_enabled && hardcore_mode_enabled) { - rc_runtime_activate_lboard(&m_runtime, leaderboard.id, leaderboard.definition, nullptr, 0); + rc_runtime_activate_lboard(&m_runtime, leaderboard_id, leaderboard.definition, nullptr, 0); + m_queue.EmplaceItem([this, leaderboard_id] { + FetchBoardInfo(leaderboard_id); + if (m_update_callback) + m_update_callback(); + }); } else { @@ -712,6 +719,12 @@ AchievementManager::GetAchievementProgress(AchievementId achievement_id, u32* va return ResponseType::SUCCESS; } +const std::unordered_map& +AchievementManager::GetLeaderboardsInfo() const +{ + return m_leaderboard_map; +} + AchievementManager::RichPresence AchievementManager::GetRichPresence() { std::lock_guard lg{m_lock}; @@ -732,6 +745,7 @@ void AchievementManager::CloseGame() m_game_id = 0; m_game_badge.name = ""; m_unlock_map.clear(); + m_leaderboard_map.clear(); rc_api_destroy_fetch_game_data_response(&m_game_data); std::memset(&m_game_data, 0, sizeof(m_game_data)); m_queue.Cancel(); @@ -955,6 +969,90 @@ AchievementManager::ResponseType AchievementManager::FetchUnlockData(bool hardco return r_type; } +AchievementManager::ResponseType AchievementManager::FetchBoardInfo(AchievementId leaderboard_id) +{ + std::string username = Config::Get(Config::RA_USERNAME); + LeaderboardStatus lboard{}; + + { + rc_api_fetch_leaderboard_info_response_t board_info{}; + const rc_api_fetch_leaderboard_info_request_t fetch_board_request = { + .leaderboard_id = leaderboard_id, .count = 4, .first_entry = 1, .username = nullptr}; + const ResponseType r_type = + Request( + fetch_board_request, &board_info, rc_api_init_fetch_leaderboard_info_request, + rc_api_process_fetch_leaderboard_info_response); + if (r_type != ResponseType::SUCCESS) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to fetch info for leaderboard ID {}.", leaderboard_id); + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + return r_type; + } + lboard.name = board_info.title; + lboard.description = board_info.description; + lboard.entries.clear(); + for (u32 i = 0; i < board_info.num_entries; ++i) + { + const auto& org_entry = board_info.entries[i]; + LeaderboardEntry dest_entry = + LeaderboardEntry{.username = org_entry.username, .rank = org_entry.rank}; + if (rc_runtime_format_lboard_value(dest_entry.score.data(), FORMAT_SIZE, org_entry.score, + board_info.format) == 0) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to format leaderboard score {}.", org_entry.score); + strncpy(dest_entry.score.data(), fmt::format("{}", org_entry.score).c_str(), FORMAT_SIZE); + } + lboard.entries[org_entry.index] = dest_entry; + } + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + } + + { + // Retrieve, if exists, the player's entry, the two entries above the player, and the two + // entries below the player, for a total of five entries. Technically I only need one entry + // below, but the API is ambiguous what happens if an even number and a username are provided. + rc_api_fetch_leaderboard_info_response_t board_info{}; + const rc_api_fetch_leaderboard_info_request_t fetch_board_request = { + .leaderboard_id = leaderboard_id, + .count = 5, + .first_entry = 0, + .username = username.c_str()}; + const ResponseType r_type = + Request( + fetch_board_request, &board_info, rc_api_init_fetch_leaderboard_info_request, + rc_api_process_fetch_leaderboard_info_response); + if (r_type != ResponseType::SUCCESS) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to fetch info for leaderboard ID {}.", leaderboard_id); + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + return r_type; + } + for (u32 i = 0; i < board_info.num_entries; ++i) + { + const auto& org_entry = board_info.entries[i]; + LeaderboardEntry dest_entry = + LeaderboardEntry{.username = org_entry.username, .rank = org_entry.rank}; + if (rc_runtime_format_lboard_value(dest_entry.score.data(), FORMAT_SIZE, org_entry.score, + board_info.format) == 0) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to format leaderboard score {}.", org_entry.score); + strncpy(dest_entry.score.data(), fmt::format("{}", org_entry.score).c_str(), FORMAT_SIZE); + } + lboard.entries[org_entry.index] = dest_entry; + if (org_entry.username == username) + lboard.player_index = org_entry.index; + } + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + } + + { + std::lock_guard lg{m_lock}; + m_leaderboard_map[leaderboard_id] = lboard; + } + + return ResponseType::SUCCESS; +} + void AchievementManager::ActivateDeactivateAchievement(AchievementId id, bool enabled, bool unofficial, bool encore) { @@ -1198,7 +1296,12 @@ void AchievementManager::HandleLeaderboardTriggeredEvent(const rc_runtime_event_ m_game_data.leaderboards[ix].title), OSD::Duration::VERY_LONG, OSD::Color::YELLOW); } - return; + m_queue.EmplaceItem([this, event_id] { + FetchBoardInfo(event_id); + if (m_update_callback) + m_update_callback(); + }); + break; } } ERROR_LOG_FMT(ACHIEVEMENTS, "Invalid leaderboard triggered event with id {}.", event_id); diff --git a/Source/Core/Core/AchievementManager.h b/Source/Core/Core/AchievementManager.h index 2e2e3e938d..b5c1eee200 100644 --- a/Source/Core/Core/AchievementManager.h +++ b/Source/Core/Core/AchievementManager.h @@ -54,6 +54,7 @@ public: using AchievementId = u32; static constexpr size_t FORMAT_SIZE = 24; using FormattedValue = std::array; + using LeaderboardRank = u32; static constexpr size_t RP_SIZE = 256; using RichPresence = std::array; using Badge = std::vector; @@ -83,6 +84,21 @@ public: static constexpr std::string_view GOLD = "#FFD700"; static constexpr std::string_view BLUE = "#0B71C1"; + struct LeaderboardEntry + { + std::string username; + FormattedValue score; + LeaderboardRank rank; + }; + + struct LeaderboardStatus + { + std::string name; + std::string description; + u32 player_index = 0; + std::unordered_map entries; + }; + static AchievementManager* GetInstance(); void Init(); void SetUpdateCallback(UpdateCallback callback); @@ -113,6 +129,7 @@ public: const UnlockStatus& GetUnlockStatus(AchievementId achievement_id) const; AchievementManager::ResponseType GetAchievementProgress(AchievementId achievement_id, u32* value, u32* target); + const std::unordered_map& GetLeaderboardsInfo() const; RichPresence GetRichPresence(); void CloseGame(); @@ -129,6 +146,7 @@ private: ResponseType StartRASession(); ResponseType FetchGameData(); ResponseType FetchUnlockData(bool hardcore); + ResponseType FetchBoardInfo(AchievementId leaderboard_id); void ActivateDeactivateAchievement(AchievementId id, bool enabled, bool unofficial, bool encore); void GenerateRichPresence(); @@ -165,6 +183,7 @@ private: time_t m_last_ping_time = 0; std::unordered_map m_unlock_map; + std::unordered_map m_leaderboard_map; Common::WorkQueueThread> m_queue; Common::WorkQueueThread> m_image_queue; diff --git a/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp new file mode 100644 index 0000000000..243bc4453c --- /dev/null +++ b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp @@ -0,0 +1,127 @@ +// Copyright 2023 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifdef USE_RETRO_ACHIEVEMENTS +#include "DolphinQt/Achievements/AchievementLeaderboardWidget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Core/AchievementManager.h" +#include "Core/Config/AchievementSettings.h" +#include "Core/Config/MainSettings.h" +#include "Core/Core.h" + +#include "DolphinQt/Config/ControllerInterface/ControllerInterfaceWindow.h" +#include "DolphinQt/QtUtils/ClearLayoutRecursively.h" +#include "DolphinQt/QtUtils/ModalMessageBox.h" +#include "DolphinQt/QtUtils/NonDefaultQPushButton.h" +#include "DolphinQt/QtUtils/SignalBlocking.h" +#include "DolphinQt/Settings.h" + +AchievementLeaderboardWidget::AchievementLeaderboardWidget(QWidget* parent) : QWidget(parent) +{ + m_common_box = new QGroupBox(); + m_common_layout = new QGridLayout(); + + { + std::lock_guard lg{*AchievementManager::GetInstance()->GetLock()}; + UpdateData(); + } + + m_common_box->setLayout(m_common_layout); + + auto* layout = new QVBoxLayout; + layout->setContentsMargins(0, 0, 0, 0); + layout->setAlignment(Qt::AlignTop); + layout->addWidget(m_common_box); + setLayout(layout); +} + +void AchievementLeaderboardWidget::UpdateData() +{ + ClearLayoutRecursively(m_common_layout); + + if (!AchievementManager::GetInstance()->IsGameLoaded()) + return; + const auto& leaderboards = AchievementManager::GetInstance()->GetLeaderboardsInfo(); + int row = 0; + for (const auto& board_row : leaderboards) + { + const AchievementManager::LeaderboardStatus& board = board_row.second; + QLabel* a_title = new QLabel(QString::fromStdString(board.name)); + QLabel* a_description = new QLabel(QString::fromStdString(board.description)); + QVBoxLayout* a_col_left = new QVBoxLayout(); + a_col_left->addWidget(a_title); + a_col_left->addWidget(a_description); + if (row > 0) + { + QFrame* a_divider = new QFrame(); + a_divider->setFrameShape(QFrame::HLine); + m_common_layout->addWidget(a_divider, row - 1, 0); + } + m_common_layout->addLayout(a_col_left, row, 0); + // Each leaderboard entry is displayed with four values. These are *generally* intended to be, + // in order, the first place entry, the entry one above the player, the player's entry, and + // the entry one below the player. + // Edge cases: + // * If there are fewer than four entries in the leaderboard, all entries will be shown in + // order and the remainder of the list will be padded with empty values. + // * If the player does not currently have a score in the leaderboard, or is in the top 3, + // the four slots will be the top four players in order. + // * If the player is last place, the player will be in the fourth slot, and the second and + // third slots will be the two players above them. The first slot will always be first place. + std::array to_display{1, 2, 3, 4}; + if (board.player_index > to_display.size() - 1) + { + // If the rank one below than the player is found, offset = 1. + u32 offset = static_cast(board.entries.count(board.player_index + 1)); + // Example: player is 10th place but not last + // to_display = {1, 10-3+1+1, 10-3+1+2, 10-3+1+3} = {1, 9, 10, 11} + // Example: player is 15th place and is last + // to_display = {1, 15-3+0+1, 15-3+0+2, 15-3+0+3} = {1, 13, 14, 15} + for (size_t i = 1; i < to_display.size(); ++i) + to_display[i] = board.player_index - 3 + offset + static_cast(i); + } + for (size_t i = 0; i < to_display.size(); ++i) + { + u32 index = to_display[i]; + QLabel* a_rank = new QLabel(QStringLiteral("---")); + QLabel* a_username = new QLabel(QStringLiteral("---")); + QLabel* a_score = new QLabel(QStringLiteral("---")); + const auto it = board.entries.find(index); + if (it != board.entries.end()) + { + a_rank->setText(tr("Rank %1").arg(it->second.rank)); + a_username->setText(QString::fromStdString(it->second.username)); + a_score->setText(QString::fromUtf8(it->second.score.data())); + } + QVBoxLayout* a_col = new QVBoxLayout(); + a_col->addWidget(a_rank); + a_col->addWidget(a_username); + a_col->addWidget(a_score); + if (row > 0) + { + QFrame* a_divider = new QFrame(); + a_divider->setFrameShape(QFrame::HLine); + m_common_layout->addWidget(a_divider, row - 1, static_cast(i) + 1); + } + m_common_layout->addLayout(a_col, row, static_cast(i) + 1); + } + row += 2; + } +} + +#endif // USE_RETRO_ACHIEVEMENTS diff --git a/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h new file mode 100644 index 0000000000..055ea6ab3f --- /dev/null +++ b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h @@ -0,0 +1,24 @@ +// Copyright 2023 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#ifdef USE_RETRO_ACHIEVEMENTS +#include + +class QGroupBox; +class QGridLayout; + +class AchievementLeaderboardWidget final : public QWidget +{ + Q_OBJECT +public: + explicit AchievementLeaderboardWidget(QWidget* parent); + void UpdateData(); + +private: + QGroupBox* m_common_box; + QGridLayout* m_common_layout; +}; + +#endif // USE_RETRO_ACHIEVEMENTS diff --git a/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp b/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp index d376da6109..8d01663c87 100644 --- a/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp +++ b/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp @@ -11,6 +11,7 @@ #include #include "DolphinQt/Achievements/AchievementHeaderWidget.h" +#include "DolphinQt/Achievements/AchievementLeaderboardWidget.h" #include "DolphinQt/Achievements/AchievementProgressWidget.h" #include "DolphinQt/Achievements/AchievementSettingsWidget.h" #include "DolphinQt/QtUtils/QueueOnObject.h" @@ -42,10 +43,14 @@ void AchievementsWindow::CreateMainLayout() m_header_widget = new AchievementHeaderWidget(this); m_tab_widget = new QTabWidget(); m_progress_widget = new AchievementProgressWidget(m_tab_widget); + m_leaderboard_widget = new AchievementLeaderboardWidget(m_tab_widget); m_tab_widget->addTab( GetWrappedWidget(new AchievementSettingsWidget(m_tab_widget, this), this, 125, 100), tr("Settings")); m_tab_widget->addTab(GetWrappedWidget(m_progress_widget, this, 125, 100), tr("Progress")); + m_tab_widget->setTabVisible(1, AchievementManager::GetInstance()->IsGameLoaded()); + m_tab_widget->addTab(GetWrappedWidget(m_leaderboard_widget, this, 125, 100), tr("Leaderboards")); + m_tab_widget->setTabVisible(2, AchievementManager::GetInstance()->IsGameLoaded()); m_button_box = new QDialogButtonBox(QDialogButtonBox::Close); @@ -70,6 +75,8 @@ void AchievementsWindow::UpdateData() // Settings tab handles its own updates ... indeed, that calls this m_progress_widget->UpdateData(); m_tab_widget->setTabVisible(1, AchievementManager::GetInstance()->IsGameLoaded()); + m_leaderboard_widget->UpdateData(); + m_tab_widget->setTabVisible(2, AchievementManager::GetInstance()->IsGameLoaded()); } update(); } diff --git a/Source/Core/DolphinQt/Achievements/AchievementsWindow.h b/Source/Core/DolphinQt/Achievements/AchievementsWindow.h index d8407a3a17..6fd7165e1f 100644 --- a/Source/Core/DolphinQt/Achievements/AchievementsWindow.h +++ b/Source/Core/DolphinQt/Achievements/AchievementsWindow.h @@ -10,6 +10,7 @@ #include "DolphinQt/QtUtils/QueueOnObject.h" class AchievementHeaderWidget; +class AchievementLeaderboardWidget; class AchievementProgressWidget; class QDialogButtonBox; class QTabWidget; @@ -30,6 +31,7 @@ private: AchievementHeaderWidget* m_header_widget; QTabWidget* m_tab_widget; AchievementProgressWidget* m_progress_widget; + AchievementLeaderboardWidget* m_leaderboard_widget; QDialogButtonBox* m_button_box; }; diff --git a/Source/Core/DolphinQt/CMakeLists.txt b/Source/Core/DolphinQt/CMakeLists.txt index 87fc14c766..b271fab7fb 100644 --- a/Source/Core/DolphinQt/CMakeLists.txt +++ b/Source/Core/DolphinQt/CMakeLists.txt @@ -30,6 +30,8 @@ add_executable(dolphin-emu CheatsManager.h Achievements/AchievementHeaderWidget.cpp Achievements/AchievementHeaderWidget.h + Achievements/AchievementLeaderboardWidget.cpp + Achievements/AchievementLeaderboardWidget.h Achievements/AchievementProgressWidget.cpp Achievements/AchievementProgressWidget.h Achievements/AchievementSettingsWidget.cpp diff --git a/Source/Core/DolphinQt/DolphinQt.vcxproj b/Source/Core/DolphinQt/DolphinQt.vcxproj index f73ea5c1cc..35f1110e4c 100644 --- a/Source/Core/DolphinQt/DolphinQt.vcxproj +++ b/Source/Core/DolphinQt/DolphinQt.vcxproj @@ -51,6 +51,7 @@ + @@ -261,6 +262,7 @@ +