From ac0a29988486e7db363b53c0cd9de647d20d1e1c Mon Sep 17 00:00:00 2001 From: Stenzek Date: Sun, 7 Dec 2025 18:20:13 +1000 Subject: [PATCH] FullscreenUI: Add achievement subset selection --- src/core/achievements.cpp | 27 ++- src/core/achievements_private.h | 2 + src/core/fullscreenui_achievements.cpp | 323 ++++++++++++++++++++++--- 3 files changed, 313 insertions(+), 39 deletions(-) diff --git a/src/core/achievements.cpp b/src/core/achievements.cpp index bb87d67c9..63a019611 100644 --- a/src/core/achievements.cpp +++ b/src/core/achievements.cpp @@ -1394,16 +1394,7 @@ void Achievements::HandleSubsetCompleteEvent(const rc_client_event_t* event) if (g_settings.achievements_notifications && event->subset->badge_name[0] != '\0') { // Need to grab the icon for the subset. - std::string badge_path = GetLocalImagePath(event->subset->badge_name, RC_IMAGE_TYPE_GAME); - if (!FileSystem::FileExists(badge_path.c_str())) - { - std::string url; - if (IsUsingRAIntegration() || !event->subset->badge_url) - url = GetImageURL(event->subset->badge_name, RC_IMAGE_TYPE_GAME); - else - url = event->subset->badge_url; - DownloadImage(std::move(url), badge_path); - } + std::string badge_path = GetSubsetBadgePath(event->subset); std::string message = fmt::format( TRANSLATE_FS("Achievements", "Subset complete.\n{0}, {1}."), @@ -1873,6 +1864,22 @@ std::string Achievements::GetLeaderboardUserBadgePath(const rc_client_leaderboar return path; } +std::string Achievements::GetSubsetBadgePath(const rc_client_subset_t* subset) +{ + std::string badge_path = GetLocalImagePath(subset->badge_name, RC_IMAGE_TYPE_GAME); + if (!FileSystem::FileExists(badge_path.c_str())) + { + std::string url; + if (IsUsingRAIntegration() || !subset->badge_url) + url = GetImageURL(subset->badge_name, RC_IMAGE_TYPE_GAME); + else + url = subset->badge_url; + DownloadImage(std::move(url), badge_path); + } + + return badge_path; +} + bool Achievements::IsLoggedIn() { return (rc_client_get_user_info(s_state.client) != nullptr); diff --git a/src/core/achievements_private.h b/src/core/achievements_private.h index a5bdefa7e..ad757619a 100644 --- a/src/core/achievements_private.h +++ b/src/core/achievements_private.h @@ -32,6 +32,8 @@ std::string GetAchievementBadgePath(const rc_client_achievement_t* achievement, bool download_if_missing = true); std::string GetLeaderboardUserBadgePath(const rc_client_leaderboard_entry_t* entry); +std::string GetSubsetBadgePath(const rc_client_subset_t* subset); + } // namespace Achievements #ifndef __ANDROID__ diff --git a/src/core/fullscreenui_achievements.cpp b/src/core/fullscreenui_achievements.cpp index 40b6ccee0..6a10735f3 100644 --- a/src/core/fullscreenui_achievements.cpp +++ b/src/core/fullscreenui_achievements.cpp @@ -10,6 +10,7 @@ #include "common/assert.h" #include "common/log.h" +#include "common/string_util.h" #include "common/timer.h" #include "IconsEmoji.h" @@ -62,6 +63,15 @@ struct PauseMenuTimedMeasuredAchievementInfo : PauseMenuMeasuredAchievementInfo } // namespace +static void AddSubsetInfo(const rc_client_subset_t* subset); +static bool IsCoreSubsetOpen(); +static void SetCurrentSubsetID(u32 subset_id); +static void DrawSubsetSelector(); +template +static void CollectSubsetsFromList(const T* list); +template +static bool IsBucketVisibleInCurrentSubset(const T& bucket); + static const std::string& GetCachedAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked); template @@ -85,8 +95,22 @@ static void DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, u32 namespace { +struct SubsetInfo +{ + std::string full_name; + std::string short_name; + std::string badge_path; + u32 subset_id; + u32 num_leaderboards; + rc_client_user_game_summary_t summary; +}; + struct AchievementsLocals { + // Shared by both achievements and leaderboards, TODO: add all filter + std::vector subset_info_list; + const SubsetInfo* open_subset = nullptr; + rc_client_achievement_list_t* achievement_list = nullptr; std::vector> achievement_badge_paths; @@ -135,6 +159,9 @@ void FullscreenUI::ClearAchievementsState() s_achievements_locals.achievement_list = nullptr; } + s_achievements_locals.open_subset = nullptr; + s_achievements_locals.subset_info_list.clear(); + s_achievements_locals.most_recent_unlock.reset(); s_achievements_locals.achievement_nearest_completion.reset(); } @@ -581,6 +608,159 @@ void FullscreenUI::OpenAchievementsWindow() }); } +void FullscreenUI::AddSubsetInfo(const rc_client_subset_t* subset) +{ + const std::string_view game_title = Achievements::GetGameTitle(); + + SubsetInfo info; + info.subset_id = subset->id; + + // is this the core subset? + std::string_view subset_title = subset->title; + if (game_title == subset_title) + { + info.full_name = subset_title; + info.short_name = TRANSLATE_STR("Achievements", "Core"); + } + else if (subset_title.starts_with(game_title)) + { + info.full_name = subset_title; + info.short_name = StringUtil::StripWhitespace(subset_title.substr(game_title.size())); + if (info.short_name.starts_with("-")) + info.short_name = StringUtil::StripWhitespace(info.short_name.substr(1)); + if (info.short_name.empty()) + info.short_name = StringUtil::ToChars(subset->id); + } + else + { + info.full_name = fmt::format(TRANSLATE_FS("Achievements", "{0} - {1}"), game_title, subset_title); + info.short_name = subset_title; + } + + info.badge_path = Achievements::GetSubsetBadgePath(subset); + info.num_leaderboards = subset->num_leaderboards; + + info.summary = {}; + rc_client_get_user_subset_summary(Achievements::GetClient(), subset->id, &info.summary); + + s_achievements_locals.subset_info_list.push_back(std::move(info)); +} + +void FullscreenUI::DrawSubsetSelector() +{ + DebugAssert(s_achievements_locals.open_subset); + + const float& font_size = UIStyle.MediumFontSize; + constexpr float font_weight = UIStyle.BoldFontWeight; + constexpr float nav_x_padding = 12.0f; + constexpr float nav_y_padding = 8.0f; + + float nav_width = 0.0f; + for (const SubsetInfo& subset : s_achievements_locals.subset_info_list) + nav_width += CalcFloatingNavBarButtonWidth(subset.short_name, font_size, font_size, font_weight, nav_x_padding); + + BeginFloatingNavBar(30.0f, 10.0f, nav_width, font_size, 1.0f, 0.0f, nav_x_padding, nav_y_padding); + + std::optional new_subset_id; + + for (size_t i = 0; i < s_achievements_locals.subset_info_list.size(); i++) + { + const SubsetInfo& subset = s_achievements_locals.subset_info_list[i]; + GPUTexture* badge = GetCachedTextureAsync(subset.badge_path); + + if (FloatingNavBarIcon(subset.short_name, badge, (&subset == s_achievements_locals.open_subset), font_size, + font_size, font_weight)) + { + new_subset_id = subset.subset_id; + } + } + + if (!new_subset_id.has_value()) + { + const size_t i = s_achievements_locals.open_subset - &s_achievements_locals.subset_info_list[0]; + + if (ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, true) || + ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakSlow, true) || ImGui::IsKeyPressed(ImGuiKey_LeftArrow, true)) + { + new_subset_id = (i == 0) ? s_achievements_locals.subset_info_list.back().subset_id : + s_achievements_locals.subset_info_list[i - 1].subset_id; + } + else if (ImGui::IsKeyPressed(ImGuiKey_GamepadDpadRight, true) || + ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakFast, true) || ImGui::IsKeyPressed(ImGuiKey_RightArrow, true)) + { + new_subset_id = ((i + 1) == s_achievements_locals.subset_info_list.size()) ? + s_achievements_locals.subset_info_list.front().subset_id : + s_achievements_locals.subset_info_list[i + 1].subset_id; + } + } + + EndFloatingNavBar(); + + if (new_subset_id.has_value()) + { + BeginTransition(DEFAULT_TRANSITION_TIME, + [new_subset_id = new_subset_id.value()]() { SetCurrentSubsetID(new_subset_id); }); + } +} + +bool FullscreenUI::IsCoreSubsetOpen() +{ + return (!s_achievements_locals.open_subset || + s_achievements_locals.open_subset == &s_achievements_locals.subset_info_list[0]); +} + +void FullscreenUI::SetCurrentSubsetID(u32 subset_id) +{ + for (const SubsetInfo& info : s_achievements_locals.subset_info_list) + { + if (info.subset_id == subset_id) + { + s_achievements_locals.open_subset = &info; + QueueResetFocus(FocusResetType::ViewChanged); + return; + } + } +} + +template +void FullscreenUI::CollectSubsetsFromList(const T* list) +{ + s_achievements_locals.open_subset = nullptr; + s_achievements_locals.subset_info_list.clear(); + if (std::any_of(list->buckets, list->buckets + list->num_buckets, + [&list](const auto& it) { return it.subset_id != list->buckets[0].subset_id; })) + { + for (u32 bucket_idx = 0; bucket_idx < list->num_buckets; bucket_idx++) + { + const auto& bucket = list->buckets[bucket_idx]; + if (std::ranges::none_of(s_achievements_locals.subset_info_list, + [&bucket](const SubsetInfo& info) { return info.subset_id == bucket.subset_id; })) + { + const rc_client_subset_t* subset = rc_client_get_subset_info(Achievements::GetClient(), bucket.subset_id); + if (subset) + AddSubsetInfo(subset); + } + } + + // hopefully the first will be core... + Assert(s_achievements_locals.subset_info_list.size() > 1); + s_achievements_locals.open_subset = &s_achievements_locals.subset_info_list[0]; + } +} + +template +bool FullscreenUI::IsBucketVisibleInCurrentSubset(const T& bucket) +{ + // Show e.g. active challenges/leaderboards in all subsets. + if (bucket.subset_id == 0) + return true; + + if (s_achievements_locals.open_subset && bucket.subset_id != s_achievements_locals.open_subset->subset_id) + return false; + + return true; +} + void FullscreenUI::SwitchToAchievements() { const auto lock = Achievements::GetLock(); @@ -617,6 +797,7 @@ void FullscreenUI::SwitchToAchievements() } } + CollectSubsetsFromList(s_achievements_locals.achievement_list); SwitchToMainWindow(MainWindowType::Achievements); } @@ -631,7 +812,9 @@ void FullscreenUI::DrawAchievementsWindow() return; } - const rc_client_user_game_summary_t& summary = Achievements::GetGameSummary(); + const rc_client_user_game_summary_t& summary = + s_achievements_locals.open_subset ? s_achievements_locals.open_subset->summary : Achievements::GetGameSummary(); + const bool is_core_subset = IsCoreSubsetOpen(); const float heading_height_unscaled = ((summary.beaten_time > 0 || summary.completed_time) ? 122.0f : 102.0f) + ((summary.num_unsupported_achievements > 0) ? 20.0f : 0.0f); @@ -667,10 +850,16 @@ void FullscreenUI::DrawAchievementsWindow() SmallString text; ImVec2 text_size; + if (s_achievements_locals.open_subset) + DrawSubsetSelector(); + close_window = (FloatingButton(ICON_FA_XMARK, 10.0f, 10.0f, 1.0f, 0.0f, true) || WantsToCloseMenu()); const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize)); - text.assign(Achievements::GetGameTitle()); + if (s_achievements_locals.open_subset) + text.assign(s_achievements_locals.open_subset->full_name); + else + text.assign(Achievements::GetGameTitle()); if (rc_client_get_hardcore_enabled(Achievements::GetClient())) text.append(TRANSLATE_SV("Achievements", " (Hardcore Mode)")); @@ -700,7 +889,8 @@ void FullscreenUI::DrawAchievementsWindow() } else { - text.format(ICON_FA_BAN " {}", TRANSLATE_SV("Achievements", "This game has no achievements.")); + text.format(ICON_FA_BAN " {}", is_core_subset ? TRANSLATE_SV("Achievements", "This game has no achievements.") : + TRANSLATE_SV("Achievements", "This subset has no achievements.")); } top += UIStyle.MediumFontSize + spacing; @@ -737,19 +927,33 @@ void FullscreenUI::DrawAchievementsWindow() { const std::string completion_time = Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast(summary.completed_time)); - text.append_format(TRANSLATE_FS("Achievements", "Game was beaten on {0}, and completed on {1}."), beaten_time, - completion_time); + if (is_core_subset) + { + text.append_format(TRANSLATE_FS("Achievements", "Game was beaten on {0}, and completed on {1}."), + beaten_time, completion_time); + } + else + { + text.append_format(TRANSLATE_FS("Achievements", "Subset was beaten on {0}, and completed on {1}."), + beaten_time, completion_time); + } } else { - text.append_format(TRANSLATE_FS("Achievements", "Game was beaten on {0}."), beaten_time); + if (is_core_subset) + text.append_format(TRANSLATE_FS("Achievements", "Game was beaten on {0}."), beaten_time); + else + text.append_format(TRANSLATE_FS("Achievements", "Subset was beaten on {0}."), beaten_time); } } else { const std::string completion_time = Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast(summary.completed_time)); - text.append_format(TRANSLATE_FS("Achievements", "Game was completed on {0}."), completion_time); + if (is_core_subset) + text.append_format(TRANSLATE_FS("Achievements", "Game was completed on {0}."), completion_time); + else + text.append_format(TRANSLATE_FS("Achievements", "Subset was completed on {0}."), completion_time); } const ImRect beaten_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize)); @@ -821,12 +1025,11 @@ void FullscreenUI::DrawAchievementsWindow() for (u32 bucket_idx = 0; bucket_idx < s_achievements_locals.achievement_list->num_buckets; bucket_idx++) { const rc_client_achievement_bucket_t& bucket = s_achievements_locals.achievement_list->buckets[bucket_idx]; - if (bucket.bucket_type != bucket_type) + if (bucket.bucket_type != bucket_type || !IsBucketVisibleInCurrentSubset(bucket)) continue; DebugAssert(bucket.bucket_type < NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS); - // TODO: Once subsets are supported, this will need to change. bool& bucket_collapsed = buckets_collapsed[bucket.bucket_type]; bucket_collapsed ^= MenuHeadingButton( TinyString::from_format("{} {}", bucket_names[bucket.bucket_type].first, @@ -854,17 +1057,39 @@ void FullscreenUI::DrawAchievementsWindow() if (IsGamepadInputSource()) { - SetFullscreenFooterText( - std::array{std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), - std::make_pair(ICON_PF_BUTTON_A, TRANSLATE_SV("Achievements", "View Details")), - std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))}); + if (s_achievements_locals.open_subset) + { + SetFullscreenFooterText( + std::array{std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, TRANSLATE_SV("Achievements", "Change Subset")), + std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_BUTTON_A, TRANSLATE_SV("Achievements", "View Details")), + std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))}); + } + else + { + SetFullscreenFooterText( + std::array{std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_BUTTON_A, TRANSLATE_SV("Achievements", "View Details")), + std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))}); + } } else { - SetFullscreenFooterText( - std::array{std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), - std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "View Details")), - std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))}); + if (s_achievements_locals.open_subset) + { + SetFullscreenFooterText(std::array{ + std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, TRANSLATE_SV("Achievements", "Change Subset")), + std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "View Details")), + std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))}); + } + else + { + SetFullscreenFooterText(std::array{ + std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "View Details")), + std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))}); + } } if (close_window) @@ -1079,6 +1304,7 @@ void FullscreenUI::SwitchToLeaderboards() return; } + CollectSubsetsFromList(s_achievements_locals.leaderboard_list); SwitchToMainWindow(MainWindowType::Leaderboards); } @@ -1129,7 +1355,10 @@ void FullscreenUI::DrawLeaderboardsWindow() float top = heading_pos.y; const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize)); - text.assign(Achievements::GetGameTitle()); + if (s_achievements_locals.open_subset) + text.assign(s_achievements_locals.open_subset->full_name); + else + text.assign(Achievements::GetGameTitle()); top += UIStyle.LargeFontSize + spacing_small; @@ -1156,8 +1385,17 @@ void FullscreenUI::DrawLeaderboardsWindow() { u32 count = 0; for (u32 i = 0; i < s_achievements_locals.leaderboard_list->num_buckets; i++) - count += s_achievements_locals.leaderboard_list->buckets[i].num_leaderboards; - text = TRANSLATE_PLURAL_SSTR("Achievements", "This game has %n leaderboards.", "Leaderboard count", count); + { + const rc_client_leaderboard_bucket_t& bucket = s_achievements_locals.leaderboard_list->buckets[i]; + if (IsBucketVisibleInCurrentSubset(bucket)) + count += bucket.num_leaderboards; + } + + if (IsCoreSubsetOpen()) + text = TRANSLATE_PLURAL_SSTR("Achievements", "This game has %n leaderboards.", "Leaderboard count", count); + else + text = TRANSLATE_PLURAL_SSTR("Achievements", "This subset has %n leaderboards.", "Leaderboard count", count); + summary_color = ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])); } @@ -1185,6 +1423,9 @@ void FullscreenUI::DrawLeaderboardsWindow() if (!is_leaderboard_open) { + if (s_achievements_locals.open_subset) + DrawSubsetSelector(); + if (FloatingButton(ICON_FA_XMARK, 10.0f, 10.0f, 1.0f, 0.0f, true) || WantsToCloseMenu()) ReturnToPreviousWindow(); } @@ -1254,6 +1495,9 @@ void FullscreenUI::DrawLeaderboardsWindow() for (u32 bucket_index = 0; bucket_index < s_achievements_locals.leaderboard_list->num_buckets; bucket_index++) { const rc_client_leaderboard_bucket_t& bucket = s_achievements_locals.leaderboard_list->buckets[bucket_index]; + if (!IsBucketVisibleInCurrentSubset(bucket)) + continue; + for (u32 i = 0; i < bucket.num_leaderboards; i++) DrawLeaderboardListEntry(bucket.leaderboards[i]); } @@ -1264,19 +1508,40 @@ void FullscreenUI::DrawLeaderboardsWindow() if (IsGamepadInputSource()) { - SetFullscreenFooterText( - std::array{std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), - std::make_pair(ICON_PF_BUTTON_A, TRANSLATE_SV("Achievements", "Open Leaderboard")), - std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))}); + if (s_achievements_locals.open_subset) + { + SetFullscreenFooterText( + std::array{std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, TRANSLATE_SV("Achievements", "Change Subset")), + std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_BUTTON_A, TRANSLATE_SV("Achievements", "Open Leaderboard")), + std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))}); + } + else + { + SetFullscreenFooterText( + std::array{std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_BUTTON_A, TRANSLATE_SV("Achievements", "Open Leaderboard")), + std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))}); + } } else { + if (s_achievements_locals.open_subset) + { + SetFullscreenFooterText(std::array{ + std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, TRANSLATE_SV("Achievements", "Change Subset")), + std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "Open Leaderboard")), + std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))}); + } + else + { + SetFullscreenFooterText(std::array{ + std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), + std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "Open Leaderboard")), + std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))}); + } } - - SetFullscreenFooterText( - std::array{std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")), - std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "Open Leaderboard")), - std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))}); } else {