Files
duckstation/src/core/fullscreenui_achievements.cpp
Stenzek fa39e63bbb Achievements: Show leaderboard times as relative
With a tooltip showing the absolute time.
2026-01-05 13:40:15 +10:00

2072 lines
91 KiB
C++

// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "achievements_private.h"
#include "fullscreenui_private.h"
#include "gpu_thread.h"
#include "host.h"
#include "system.h"
#include "util/imgui_manager.h"
#include "util/translation.h"
#include "common/assert.h"
#include "common/log.h"
#include "common/string_util.h"
#include "common/time_helpers.h"
#include "common/timer.h"
#include "IconsEmoji.h"
#include "IconsPromptFont.h"
LOG_CHANNEL(FullscreenUI);
#ifndef __ANDROID__
namespace FullscreenUI {
static constexpr const char* ACHEIVEMENT_DETAILS_URL_TEMPLATE = "https://retroachievements.org/achievement/{}";
static constexpr const char* PROFILE_DETAILS_URL_TEMPLATE = "https://retroachievements.org/user/{}";
static constexpr float WINDOW_ALPHA = 0.9f;
static constexpr float WINDOW_HEADING_ALPHA = 0.95f;
static constexpr u32 LEADERBOARD_NEARBY_ENTRIES_TO_FETCH = 10;
static constexpr u32 LEADERBOARD_ALL_FETCH_SIZE = 20;
// How long the last progress update is shown in the pause menu.
static constexpr float PAUSE_MENU_PROGRESS_DISPLAY_TIME = 60.0f;
namespace {
struct PauseMenuAchievementInfo
{
std::string title;
std::string description;
std::string badge_path;
u32 achievement_id;
float measured_percent;
};
struct PauseMenuAchievementInfoWithPoints : PauseMenuAchievementInfo
{
u32 points;
};
struct PauseMenuMeasuredAchievementInfo : PauseMenuAchievementInfo
{
std::string measured_progress;
};
struct PauseMenuTimedMeasuredAchievementInfo : PauseMenuMeasuredAchievementInfo
{
// can't use imgui deltatime here because this is only updated when paused
Timer::Value show_time;
};
} // namespace
static void AddSubsetInfo(const rc_client_subset_t* subset);
static bool IsCoreSubsetOpen();
static void SetCurrentSubsetID(u32 subset_id);
static void DrawSubsetSelector();
template<typename T>
static void CollectSubsetsFromList(const T* list, bool include_achievements, bool include_leaderboards);
template<typename T>
static bool IsBucketVisibleInCurrentSubset(const T& bucket);
static const std::string& GetCachedAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked);
template<typename T>
static void CachePauseMenuAchievementInfo(const rc_client_achievement_t* achievement, std::optional<T>& value);
static void DrawAchievement(const rc_client_achievement_t* cheevo);
static void LeaderboardFetchNearbyCallback(int result, const char* error_message,
rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
void* callback_userdata);
static void LeaderboardFetchAllCallback(int result, const char* error_message, rc_client_leaderboard_entry_list_t* list,
rc_client_t* client, void* callback_userdata);
static bool OpenLeaderboardById(u32 leaderboard_id);
static void FetchNextLeaderboardEntries();
static void CloseLeaderboard();
static void DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard);
static void DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, u32 index, bool is_self,
float rank_column_width, float name_column_width, float time_column_width,
float column_spacing, std::time_t current_time, const std::tm& current_tm);
static SmallString FormatRelativeTimestamp(std::time_t timestamp, std::time_t current_time, const std::tm& current_tm);
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<SubsetInfo> subset_info_list;
const SubsetInfo* open_subset = nullptr;
rc_client_achievement_list_t* achievement_list = nullptr;
std::vector<std::tuple<const void*, std::string, bool>> achievement_badge_paths;
std::optional<PauseMenuAchievementInfoWithPoints> most_recent_unlock;
std::optional<PauseMenuMeasuredAchievementInfo> achievement_nearest_completion;
std::optional<PauseMenuTimedMeasuredAchievementInfo> most_recent_progress_update;
rc_client_leaderboard_list_t* leaderboard_list = nullptr;
const rc_client_leaderboard_t* open_leaderboard = nullptr;
rc_client_async_handle_t* leaderboard_fetch_handle = nullptr;
std::vector<rc_client_leaderboard_entry_list_t*> leaderboard_entry_lists;
std::vector<std::pair<const rc_client_leaderboard_entry_t*, std::string>> leaderboard_user_icon_paths;
rc_client_leaderboard_entry_list_t* leaderboard_nearby_entries;
bool is_showing_all_leaderboard_entries = false;
bool has_fetched_all_leaderboard_entries = false;
u32 last_open_subset_id = 0;
};
} // namespace
ALIGN_TO_CACHE_LINE static AchievementsLocals s_achievements_locals;
} // namespace FullscreenUI
void FullscreenUI::ClearAchievementsState()
{
// NOTE: can be called on the CPU thread. don't mess with any GPU thread state
// will already be held if we're clearing as a result of achievements shutting down
const auto lock = Achievements::GetLock();
CloseLeaderboard();
s_achievements_locals.achievement_badge_paths = {};
s_achievements_locals.leaderboard_user_icon_paths = {};
s_achievements_locals.leaderboard_entry_lists = {};
if (s_achievements_locals.leaderboard_list)
{
rc_client_destroy_leaderboard_list(s_achievements_locals.leaderboard_list);
s_achievements_locals.leaderboard_list = nullptr;
}
if (s_achievements_locals.achievement_list)
{
rc_client_destroy_achievement_list(s_achievements_locals.achievement_list);
s_achievements_locals.achievement_list = nullptr;
}
s_achievements_locals.open_subset = nullptr;
s_achievements_locals.subset_info_list.clear();
s_achievements_locals.last_open_subset_id = 0;
s_achievements_locals.most_recent_unlock.reset();
s_achievements_locals.achievement_nearest_completion.reset();
}
const std::string& FullscreenUI::GetCachedAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked)
{
for (const auto& [l_cheevo, l_path, l_state] : s_achievements_locals.achievement_badge_paths)
{
if (l_cheevo == achievement && l_state == locked)
return l_path;
}
std::string path = Achievements::GetAchievementBadgePath(achievement, locked);
return std::get<1>(s_achievements_locals.achievement_badge_paths.emplace_back(achievement, std::move(path), locked));
}
template<typename T>
void FullscreenUI::CachePauseMenuAchievementInfo(const rc_client_achievement_t* achievement, std::optional<T>& value)
{
if (!achievement)
{
value.reset();
return;
}
if (!value.has_value())
value.emplace();
// have to take a copy because with RAIntegration the achievement pointer does not persist
value->title = achievement->title;
value->description = achievement->description;
value->badge_path = Achievements::GetAchievementBadgePath(achievement, false);
value->measured_percent = achievement->measured_percent;
value->achievement_id = achievement->id;
if constexpr (std::is_base_of_v<PauseMenuAchievementInfoWithPoints, T>)
value->points = achievement->points;
if constexpr (std::is_base_of_v<PauseMenuMeasuredAchievementInfo, T>)
value->measured_progress = achievement->measured_progress;
if constexpr (std::is_same_v<PauseMenuTimedMeasuredAchievementInfo, T>)
value->show_time = Timer::GetCurrentValue();
}
void FullscreenUI::UpdateAchievementsRecentUnlockAndAlmostThere()
{
const auto lock = Achievements::GetLock();
if (!Achievements::HasActiveGame())
{
s_achievements_locals.most_recent_unlock.reset();
s_achievements_locals.achievement_nearest_completion.reset();
return;
}
rc_client_achievement_list_t* const achievements =
rc_client_create_achievement_list(Achievements::GetClient(), RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS);
if (!achievements)
{
s_achievements_locals.most_recent_unlock.reset();
s_achievements_locals.achievement_nearest_completion.reset();
return;
}
const rc_client_achievement_t* most_recent_unlock = nullptr;
const rc_client_achievement_t* nearest_completion = nullptr;
for (u32 i = 0; i < achievements->num_buckets; i++)
{
const rc_client_achievement_bucket_t& bucket = achievements->buckets[i];
for (u32 j = 0; j < bucket.num_achievements; j++)
{
const rc_client_achievement_t* achievement = bucket.achievements[j];
if (achievement->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED)
{
if (!most_recent_unlock || achievement->unlock_time > most_recent_unlock->unlock_time)
most_recent_unlock = achievement;
}
else
{
// find the achievement with the greatest normalized progress, but skip anything below 80%,
// matching the rc_client definition of "almost there"
const float percent_cutoff = 80.0f;
if (achievement->measured_percent >= percent_cutoff &&
(!nearest_completion || achievement->measured_percent > nearest_completion->measured_percent))
{
nearest_completion = achievement;
}
}
}
}
CachePauseMenuAchievementInfo(most_recent_unlock, s_achievements_locals.most_recent_unlock);
CachePauseMenuAchievementInfo(nearest_completion, s_achievements_locals.achievement_nearest_completion);
rc_client_destroy_achievement_list(achievements);
}
void FullscreenUI::UpdateAchievementsLastProgressUpdate(const rc_client_achievement_t* achievement)
{
CachePauseMenuAchievementInfo(achievement, s_achievements_locals.most_recent_progress_update);
}
void FullscreenUI::DrawAchievementsPauseMenuOverlays(float start_pos_y)
{
if (!Achievements::HasActiveGame() || !Achievements::HasAchievements())
return;
const auto lock = Achievements::GetLock();
rc_client_t* const client = Achievements::GetClient();
const ImVec2& display_size = ImGui::GetIO().DisplaySize;
const float box_margin = LayoutScale(10.0f);
const float box_width = LayoutScale(450.0f);
const float box_padding = LayoutScale(15.0f);
const float box_content_width = box_width - box_padding - box_padding;
const float box_rounding = LayoutScale(20.0f);
const u32 box_background_color = ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 0.8f));
const ImU32 box_title_text_color =
ImGui::GetColorU32(DarkerColor(UIStyle.BackgroundTextColor, 0.9f)) | IM_COL32_A_MASK;
const ImU32 title_text_color = ImGui::GetColorU32(UIStyle.BackgroundTextColor) | IM_COL32_A_MASK;
const ImU32 text_color =
ImGui::GetColorU32(DarkerColor(DarkerColor(UIStyle.BackgroundTextColor, 0.9f))) | IM_COL32_A_MASK;
const float paragraph_spacing = LayoutScale(10.0f);
const float text_spacing = LayoutScale(2.0f);
const float progress_height = LayoutScale(20.0f);
const float progress_rounding = LayoutScale(5.0f);
const float badge_size = LayoutScale(32.0f);
const float badge_text_width = box_content_width - badge_size - (text_spacing * 3.0f);
const bool disconnected = rc_client_is_disconnected(client);
const int pending_count = disconnected ? rc_client_get_award_achievement_pending_count(client) : 0;
ImDrawList* dl = ImGui::GetBackgroundDrawList();
float box_height = box_padding + box_padding + UIStyle.MediumFontSize + paragraph_spacing + progress_height +
((pending_count > 0) ? (paragraph_spacing + UIStyle.MediumFontSize) : 0.0f);
ImVec2 box_min = ImVec2(display_size.x - box_width - box_margin, start_pos_y + box_margin);
ImVec2 box_max = ImVec2(box_min.x + box_width, box_min.y + box_height);
ImVec2 text_pos = ImVec2(box_min.x + box_padding, box_min.y + box_padding);
ImVec2 text_size;
TinyString buffer;
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
// title
{
const rc_client_user_game_summary_t& summary = Achievements::GetGameSummary();
buffer.format(ICON_EMOJI_UNLOCKED " {}",
TRANSLATE_DISAMBIG_SV("Achievements", "Achievements Unlocked", "Pause Menu"));
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, box_title_text_color,
IMSTR_START_END(buffer));
const float unlocked_fraction =
static_cast<float>(summary.num_unlocked_achievements) / static_cast<float>(summary.num_core_achievements);
buffer.format("{}%", static_cast<u32>(std::round(unlocked_fraction * 100.0f)));
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.BoldFontWeight, FLT_MAX, 0.0f,
IMSTR_START_END(buffer));
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight,
ImVec2(text_pos.x + (box_content_width - text_size.x), text_pos.y), text_color,
IMSTR_START_END(buffer));
text_pos.y += UIStyle.MediumFontSize + paragraph_spacing;
const ImRect progress_bb(text_pos, text_pos + ImVec2(box_content_width, progress_height));
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(UIStyle.PrimaryDarkColor),
progress_rounding);
if (summary.num_unlocked_achievements > 0)
{
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryColor)), 0.0f,
unlocked_fraction, progress_rounding);
}
buffer.format("{}/{}", summary.num_unlocked_achievements, summary.num_core_achievements);
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.BoldFontWeight, FLT_MAX, 0.0f,
IMSTR_START_END(buffer));
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight,
ImVec2(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f)),
ImGui::GetColorU32(UIStyle.PrimaryTextColor), IMSTR_START_END(buffer));
text_pos.y += progress_height;
if (pending_count > 0)
{
text_pos.y += paragraph_spacing;
buffer.format(ICON_EMOJI_WARNING " {}",
TRANSLATE_PLURAL_SSTR("Achievements", "%n unlocks have not been confirmed by the server.",
"Pause Menu", pending_count));
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, title_text_color,
IMSTR_START_END(buffer));
text_pos.y += UIStyle.MediumFontSize;
}
}
const auto draw_achievement_in_box = [&box_margin, &box_width, &box_padding, &box_rounding, &box_content_width,
&box_background_color, &box_min, &box_max, &badge_text_width, &dl,
&box_title_text_color, &title_text_color, &text_color, &paragraph_spacing,
&text_spacing, &progress_rounding, &text_pos, &badge_size](
std::string_view box_title, std::string_view title,
std::string_view description, const std::string& badge_path,
std::string_view measured_progress, float measured_percent, u32 points) {
const ImVec2 description_size =
description.empty() ? ImVec2(0.0f, 0.0f) :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, FLT_MAX,
badge_text_width, IMSTR_START_END(description));
const float box_height = box_padding + box_padding + UIStyle.MediumFontSize + paragraph_spacing +
std::max((title.empty() ? 0.0f : UIStyle.MediumSmallFontSize) +
(description.empty() ? 0.0f : (text_spacing + description_size.y)),
badge_size);
box_min = ImVec2(box_min.x, box_max.y + box_margin);
box_max = ImVec2(box_min.x + box_width, box_min.y + box_height);
text_pos = ImVec2(box_min.x + box_padding, box_min.y + box_padding);
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
ImVec4 clip_rect = ImVec4(text_pos.x, text_pos.y, text_pos.x + box_content_width, box_max.y);
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, box_title_text_color,
IMSTR_START_END(box_title), 0.0f, &clip_rect);
if (!measured_progress.empty())
{
const float progress_width = LayoutScale(100.0f);
const float progress_height = UIStyle.MediumFontSize;
const ImRect progress_bb(ImVec2(text_pos.x + box_content_width - progress_width, text_pos.y),
ImVec2(text_pos.x + box_content_width, text_pos.y + progress_height));
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(UIStyle.PrimaryDarkColor),
progress_rounding);
if (measured_percent > 0.0f)
{
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryColor)), 0.0f,
measured_percent * 0.01f, progress_rounding);
}
const ImVec2 measured_progress_size =
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight, FLT_MAX, badge_text_width,
IMSTR_START_END(measured_progress));
dl->AddText(
UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
ImVec2(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (measured_progress_size.x / 2.0f),
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) -
(measured_progress_size.y / 2.0f)),
ImGui::GetColorU32(UIStyle.PrimaryTextColor), IMSTR_START_END(measured_progress));
}
text_pos.y += UIStyle.MediumFontSize + paragraph_spacing;
const ImVec2 image_max = ImVec2(text_pos.x + badge_size, text_pos.y + badge_size);
GPUTexture* const badge_tex = GetCachedTextureAsync(badge_path);
dl->AddImage(badge_tex, text_pos, image_max);
TinyString points_text;
float points_width = 0.0f;
if (points > 0)
{
points_text.format(ICON_EMOJI_TROPHY " {}", points);
points_width = UIStyle.Font
->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight, FLT_MAX, 0.0f,
IMSTR_START_END(points_text))
.x;
}
ImVec2 badge_text_pos = ImVec2(image_max.x + (text_spacing * 3.0f), text_pos.y);
if (!title.empty())
{
clip_rect =
ImVec4(badge_text_pos.x, badge_text_pos.y, badge_text_pos.x + badge_text_width - points_width, box_max.y);
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight, badge_text_pos, title_text_color,
IMSTR_START_END(title), 0.0f, &clip_rect);
if (points > 0)
{
clip_rect = ImVec4(clip_rect.z, clip_rect.y, clip_rect.z + points_width, clip_rect.w);
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight, ImVec2(clip_rect.x, clip_rect.y),
title_text_color, IMSTR_START_END(points_text), 0.0f, &clip_rect);
}
badge_text_pos.y += UIStyle.MediumSmallFontSize;
}
if (!description.empty())
{
clip_rect = ImVec4(badge_text_pos.x, badge_text_pos.y, badge_text_pos.x + badge_text_width, box_max.y);
badge_text_pos.y += text_spacing;
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, badge_text_pos, text_color,
IMSTR_START_END(description), badge_text_width, &clip_rect);
badge_text_pos.y += description_size.y;
}
};
const auto get_achievement_height = [&badge_size, &badge_text_width, &text_spacing](std::string_view description) {
const ImVec2 description_size =
description.empty() ? ImVec2(0.0f, 0.0f) :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, FLT_MAX,
badge_text_width, IMSTR_START_END(description));
const float text_height = UIStyle.MediumSmallFontSize + text_spacing + description_size.y;
return std::max(text_height, badge_size);
};
const auto draw_achievement_with_summary = [&box_max, &badge_text_width, &dl, &title_text_color, &text_color,
&text_spacing, &text_pos,
&badge_size](std::string_view title, std::string_view description,
const std::string& badge_path) {
const ImVec2 image_max = ImVec2(text_pos.x + badge_size, text_pos.y + badge_size);
ImVec2 badge_text_pos = ImVec2(image_max.x + (text_spacing * 3.0f), text_pos.y);
const ImVec4 clip_rect = ImVec4(badge_text_pos.x, badge_text_pos.y, badge_text_pos.x + badge_text_width, box_max.y);
ImVec2 text_size = description.empty() ?
ImVec2(0.0f, 0.0f) :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, FLT_MAX,
badge_text_width, IMSTR_START_END(description));
GPUTexture* badge_tex = GetCachedTextureAsync(badge_path);
dl->AddImage(badge_tex, text_pos, image_max);
if (!title.empty())
{
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight, badge_text_pos, title_text_color,
IMSTR_START_END(title), 0.0f, &clip_rect);
badge_text_pos.y += UIStyle.MediumSmallFontSize + text_spacing;
}
if (!description.empty())
{
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, badge_text_pos, text_color,
IMSTR_START_END(description), badge_text_width, &clip_rect);
badge_text_pos.y += text_size.y;
}
text_pos.y = badge_text_pos.y;
};
if (s_achievements_locals.most_recent_unlock.has_value())
{
buffer.format(ICON_FA_LOCK_OPEN " {}", TRANSLATE_DISAMBIG_SV("Achievements", "Most Recent", "Pause Menu"));
draw_achievement_in_box(
buffer, s_achievements_locals.most_recent_unlock->title, s_achievements_locals.most_recent_unlock->description,
s_achievements_locals.most_recent_unlock->badge_path, {}, 0.0f, s_achievements_locals.most_recent_unlock->points);
// extra spacing if we have two
text_pos.y += s_achievements_locals.achievement_nearest_completion ? (paragraph_spacing + paragraph_spacing) : 0.0f;
}
// don't duplicate nearest completion if it was also the most recent progress update
if (s_achievements_locals.achievement_nearest_completion.has_value() &&
(!s_achievements_locals.most_recent_progress_update ||
s_achievements_locals.most_recent_progress_update->achievement_id !=
s_achievements_locals.achievement_nearest_completion->achievement_id))
{
buffer.format(ICON_FA_GAUGE_HIGH " {}", TRANSLATE_DISAMBIG_SV("Achievements", "Nearest Completion", "Pause Menu"));
draw_achievement_in_box(buffer, s_achievements_locals.achievement_nearest_completion->title,
s_achievements_locals.achievement_nearest_completion->description,
s_achievements_locals.achievement_nearest_completion->badge_path,
s_achievements_locals.achievement_nearest_completion->measured_progress,
s_achievements_locals.achievement_nearest_completion->measured_percent, 0);
text_pos.y += paragraph_spacing;
}
if (s_achievements_locals.most_recent_progress_update.has_value())
{
if (Timer::ConvertValueToSeconds(Timer::GetCurrentValue() -
s_achievements_locals.most_recent_progress_update->show_time) <
PAUSE_MENU_PROGRESS_DISPLAY_TIME)
{
buffer.format(ICON_FA_RULER_HORIZONTAL " {}",
TRANSLATE_DISAMBIG_SV("Achievements", "Last Progress Update", "Pause Menu"));
draw_achievement_in_box(buffer, s_achievements_locals.most_recent_progress_update->title,
s_achievements_locals.most_recent_progress_update->description,
s_achievements_locals.most_recent_progress_update->badge_path,
s_achievements_locals.most_recent_progress_update->measured_progress,
s_achievements_locals.most_recent_progress_update->measured_percent, 0);
text_pos.y += paragraph_spacing;
}
else
{
s_achievements_locals.most_recent_progress_update.reset();
}
}
// Challenge indicators
if (const std::span<const Achievements::ActiveChallengeIndicator> challenge_indicators =
Achievements::GetActiveChallengeIndicators();
!challenge_indicators.empty())
{
box_height = box_padding + box_padding + UIStyle.MediumFontSize;
for (size_t i = 0; i < challenge_indicators.size(); i++)
{
const Achievements::ActiveChallengeIndicator& indicator = challenge_indicators[i];
box_height += paragraph_spacing + get_achievement_height(indicator.achievement->description) +
((i == (challenge_indicators.size() - 1)) ? 0.0f : paragraph_spacing);
}
box_min = ImVec2(box_min.x, box_max.y + box_margin);
box_max = ImVec2(box_min.x + box_width, box_min.y + box_height);
text_pos = ImVec2(box_min.x + box_padding, box_min.y + box_padding);
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
buffer.format(ICON_FA_STOPWATCH " {}",
TRANSLATE_DISAMBIG_SV("Achievements", "Active Challenge Achievements", "Pause Menu"));
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, box_title_text_color,
IMSTR_START_END(buffer));
text_pos.y += UIStyle.MediumFontSize;
for (const Achievements::ActiveChallengeIndicator& indicator : challenge_indicators)
{
text_pos.y += paragraph_spacing;
draw_achievement_with_summary(indicator.achievement->title, indicator.achievement->description,
indicator.badge_path);
text_pos.y += paragraph_spacing;
}
}
}
void FullscreenUI::OpenAchievementsWindow()
{
// NOTE: Called from CPU thread.
if (!System::IsValid())
return;
const auto lock = Achievements::GetLock();
if (!Achievements::IsActive() || !Achievements::HasAchievements())
{
Host::AddIconOSDMessage(OSDMessageType::Info, "AchievementsUnavailable", Achievements::RA_LOGO_ICON_NAME,
TRANSLATE_STR("Achievements", "Achievements are not available."),
Achievements::IsActive() ?
TRANSLATE_STR("Achievements", "This game has no achievements.") :
TRANSLATE_STR("Achievements", "Achievements are disabled in settings."));
return;
}
GPUThread::RunOnThread([]() {
Initialize();
PauseForMenuOpen(false);
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
BeginTransition(SHORT_TRANSITION_TIME, &SwitchToAchievements);
});
}
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<u32> 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))
{
EnqueueSoundEffect(SFX_NAV_MOVE);
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))
{
EnqueueSoundEffect(SFX_NAV_MOVE);
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;
s_achievements_locals.last_open_subset_id = subset_id;
QueueResetFocus(FocusResetType::ViewChanged);
return;
}
}
}
template<typename T>
void FullscreenUI::CollectSubsetsFromList(const T* list, bool include_achievements, bool include_leaderboards)
{
s_achievements_locals.open_subset = nullptr;
s_achievements_locals.subset_info_list.clear();
// Prefer rc_client grabbing subsets if possible. Old external clients won't support this.
rc_client_subset_list_t* subset_list = rc_client_create_subset_list(Achievements::GetClient());
if (subset_list && subset_list->num_subsets > 0)
{
// If there is only a single subset, we don't want to show a selector.
if (subset_list->num_subsets > 1)
{
for (u32 i = 0; i < subset_list->num_subsets; i++)
{
const rc_client_subset_t* subset = subset_list->subsets[i];
if ((include_achievements && subset->num_achievements > 0) ||
(include_leaderboards && subset->num_leaderboards > 0))
{
AddSubsetInfo(subset);
}
}
}
}
else 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);
}
}
}
if (subset_list)
rc_client_destroy_subset_list(subset_list);
// hopefully the first will be core...
if (!s_achievements_locals.subset_info_list.empty())
{
const auto it = std::ranges::find_if(s_achievements_locals.subset_info_list, [](const SubsetInfo& info) {
return info.subset_id == s_achievements_locals.last_open_subset_id;
});
if (it != s_achievements_locals.subset_info_list.end())
s_achievements_locals.open_subset = &(*it);
else
s_achievements_locals.open_subset = &s_achievements_locals.subset_info_list[0];
}
}
template<typename T>
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();
if (!Achievements::HasAchievements())
{
ClosePauseMenuImmediately();
return;
}
s_achievements_locals.achievement_badge_paths = {};
if (s_achievements_locals.achievement_list)
rc_client_destroy_achievement_list(s_achievements_locals.achievement_list);
s_achievements_locals.achievement_list = rc_client_create_achievement_list(
Achievements::GetClient(), RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS /*RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE*/);
if (!s_achievements_locals.achievement_list)
{
ERROR_LOG("rc_client_create_achievement_list() returned null");
ClosePauseMenuImmediately();
return;
}
// sort unlocked achievements by unlock time
for (size_t i = 0; i < s_achievements_locals.achievement_list->num_buckets; i++)
{
const rc_client_achievement_bucket_t* bucket = &s_achievements_locals.achievement_list->buckets[i];
if (bucket->bucket_type == RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED)
{
std::sort(bucket->achievements, bucket->achievements + bucket->num_achievements,
[](const rc_client_achievement_t* a, const rc_client_achievement_t* b) {
return a->unlock_time > b->unlock_time;
});
}
}
CollectSubsetsFromList(s_achievements_locals.achievement_list, true, false);
SwitchToMainWindow(MainWindowType::Achievements);
}
void FullscreenUI::DrawAchievementsWindow()
{
const auto lock = Achievements::GetLock();
// achievements can get turned off via the main UI
if (!s_achievements_locals.achievement_list)
{
ReturnToPreviousWindow();
return;
}
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);
const ImVec4 background = ModAlpha(UIStyle.BackgroundColor, WINDOW_ALPHA);
const ImVec4 heading_background = ModAlpha(UIStyle.BackgroundColor, WINDOW_HEADING_ALPHA);
const ImVec2 display_size = ImGui::GetIO().DisplaySize;
const float heading_height = LayoutScale(heading_height_unscaled);
bool close_window = false;
if (BeginFullscreenWindow(ImVec2(), ImVec2(display_size.x, heading_height), "achievements_heading",
heading_background, 0.0f, ImVec2(10.0f, 10.0f),
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoScrollWithMouse))
{
const ImVec2 pos = ImGui::GetCursorScreenPos() + ImGui::GetStyle().FramePadding;
const float spacing = LayoutScale(LAYOUT_MENU_ITEM_TITLE_SUMMARY_SPACING);
const float image_size = LayoutScale(75.0f);
if (const std::string& path = Achievements::GetGameIconPath(); !path.empty())
{
GPUTexture* badge = GetCachedTextureAsync(path);
if (badge)
{
ImGui::GetWindowDrawList()->AddImage(badge, pos, pos + ImVec2(image_size, image_size), ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
}
}
float left = pos.x + image_size + LayoutScale(10.0f);
float right = pos.x + GetMenuButtonAvailableWidth();
float top = pos.y;
ImDrawList* dl = ImGui::GetWindowDrawList();
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));
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)"));
top += UIStyle.LargeFontSize + spacing;
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min, title_bb.Max,
ImGui::GetColorU32(ImGuiCol_Text), text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &title_bb);
const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
if (summary.num_core_achievements > 0)
{
text.assign(ICON_EMOJI_UNLOCKED " ");
if (summary.num_unlocked_achievements == summary.num_core_achievements)
{
text.append(TRANSLATE_PLURAL_SSTR("Achievements", "You have unlocked all achievements and earned %n points!",
"Point count", summary.points_unlocked));
}
else
{
text.append_format(
TRANSLATE_FS("Achievements",
"You have unlocked {0} of {1} achievements, earning {2} of {3} possible points."),
summary.num_unlocked_achievements, summary.num_core_achievements, summary.points_unlocked,
summary.points_core);
}
}
else
{
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;
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, summary_bb.Min,
summary_bb.Max, ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])),
text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &summary_bb);
if (summary.num_unsupported_achievements)
{
text.format("{} {}", ICON_EMOJI_WARNING,
TRANSLATE_PLURAL_SSTR("Achievements",
"%n achievements are not supported by DuckStation and cannot be unlocked.",
"Unsupported achievement count", summary.num_unsupported_achievements));
const ImRect unsupported_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, unsupported_bb.Min,
unsupported_bb.Max,
ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])), text, nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &unsupported_bb);
top += UIStyle.MediumFontSize + spacing;
}
if (summary.beaten_time > 0 || summary.completed_time > 0)
{
text.assign(ICON_EMOJI_CHECKMARK_BUTTON " ");
if (summary.beaten_time > 0)
{
const std::string beaten_time =
Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast<s64>(summary.beaten_time));
if (summary.completed_time > 0)
{
const std::string completion_time =
Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast<s64>(summary.completed_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
{
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<s64>(summary.completed_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));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, beaten_bb.Min,
beaten_bb.Max, ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])),
text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &beaten_bb);
top += UIStyle.MediumFontSize + spacing;
}
if (summary.num_core_achievements > 0)
{
const float progress_height = LayoutScale(20.0f);
const float progress_rounding = LayoutScale(5.0f);
const ImRect progress_bb(ImVec2(left, top), ImVec2(right, top + progress_height));
const float fraction =
static_cast<float>(summary.num_unlocked_achievements) / static_cast<float>(summary.num_core_achievements);
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(UIStyle.PrimaryDarkColor),
progress_rounding);
if (summary.num_unlocked_achievements > 0)
{
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(UIStyle.SecondaryColor), 0.0f, fraction,
progress_rounding);
}
text.format("{}%", static_cast<u32>(std::round(fraction * 100.0f)));
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX, 0.0f,
IMSTR_START_END(text));
const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) -
(text_size.y / 2.0f));
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos,
ImGui::GetColorU32(UIStyle.PrimaryTextColor), IMSTR_START_END(text));
// top += progress_height + spacing;
}
}
EndFullscreenWindow();
// See note in FullscreenUI::DrawSettingsWindow().
if (IsFocusResetFromWindowChange())
ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
if (BeginFullscreenWindow(ImVec2(0.0f, heading_height),
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(LAYOUT_FOOTER_HEIGHT)),
"achievements", background, 0.0f,
ImVec2(LAYOUT_MENU_WINDOW_X_PADDING, LAYOUT_MENU_WINDOW_Y_PADDING), 0))
{
static bool buckets_collapsed[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {};
static constexpr std::pair<const char*, const char*> bucket_names[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {
{ICON_FA_TRIANGLE_EXCLAMATION, TRANSLATE_NOOP("Achievements", "Unknown")},
{ICON_FA_LOCK, TRANSLATE_NOOP("Achievements", "Locked")},
{ICON_FA_UNLOCK, TRANSLATE_NOOP("Achievements", "Unlocked")},
{ICON_FA_TRIANGLE_EXCLAMATION, TRANSLATE_NOOP("Achievements", "Unsupported")},
{ICON_FA_CIRCLE_QUESTION, TRANSLATE_NOOP("Achievements", "Unofficial")},
{ICON_FA_UNLOCK, TRANSLATE_NOOP("Achievements", "Recently Unlocked")},
{ICON_FA_STOPWATCH, TRANSLATE_NOOP("Achievements", "Active Challenges")},
{ICON_FA_RULER_HORIZONTAL, TRANSLATE_NOOP("Achievements", "Almost There")},
{ICON_FA_TRIANGLE_EXCLAMATION, TRANSLATE_NOOP("Achievements", "Unsynchronized")},
};
ResetFocusHere();
BeginMenuButtons();
for (u32 bucket_type : {RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE,
RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED, RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE,
RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED, RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL, RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED})
{
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 || !IsBucketVisibleInCurrentSubset(bucket))
continue;
DebugAssert(bucket.bucket_type < NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS);
bool& bucket_collapsed = buckets_collapsed[bucket.bucket_type];
bucket_collapsed ^= MenuHeadingButton(
TinyString::from_format("{} {}", bucket_names[bucket.bucket_type].first,
Host::TranslateToStringView("Achievements", bucket_names[bucket.bucket_type].second)),
bucket_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP, UIStyle.MediumLargeFontSize);
if (!bucket_collapsed)
{
for (u32 i = 0; i < bucket.num_achievements; i++)
DrawAchievement(bucket.achievements[i]);
}
}
}
EndMenuButtons();
}
EndFullscreenWindow();
if (IsGamepadInputSource())
{
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
{
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)
ReturnToPreviousWindow();
}
void FullscreenUI::DrawAchievement(const rc_client_achievement_t* cheevo)
{
static constexpr const float progress_height_unscaled = 20.0f;
static constexpr const float progress_rounding_unscaled = 5.0f;
static constexpr const float& title_font_size = UIStyle.LargeFontSize;
static constexpr const float& title_font_weight = UIStyle.BoldFontWeight;
static constexpr const float& subtitle_font_size = UIStyle.MediumFontSize;
static constexpr const float& subtitle_font_weight = UIStyle.NormalFontWeight;
static constexpr const float& type_badge_font_size = UIStyle.MediumSmallFontSize;
static constexpr const float& type_badge_font_weight = UIStyle.BoldFontWeight;
const std::string_view title(cheevo->title);
const std::string_view description = cheevo->description ? std::string_view(cheevo->description) : std::string_view();
const std::string_view measured_progress(cheevo->measured_progress);
const bool is_unlocked = (cheevo->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
const bool is_measured = (!is_unlocked && !measured_progress.empty());
ImVec2 type_badge_padding;
ImVec2 type_badge_size;
float type_badge_spacing = 0.0f;
float type_badge_rounding = 0.0f;
ImU32 type_badge_bg_color = 0;
TinyString type_badge_text;
switch (cheevo->type)
{
case RC_CLIENT_ACHIEVEMENT_TYPE_MISSABLE:
type_badge_text.format(ICON_PF_ACHIEVEMENTS_MISSABLE " {}", TRANSLATE_SV("Achievements", "Missable"));
type_badge_bg_color = IM_COL32(205, 45, 32, 255);
break;
case RC_CLIENT_ACHIEVEMENT_TYPE_PROGRESSION:
type_badge_text.format(ICON_PF_ACHIEVEMENTS_PROGRESSION " {}", TRANSLATE_SV("Achievements", "Progression"));
type_badge_bg_color = IM_COL32(13, 71, 161, 255);
break;
case RC_CLIENT_ACHIEVEMENT_TYPE_WIN:
type_badge_text.format(ICON_PF_ACHIEVEMENTS_PROGRESSION " {}", TRANSLATE_SV("Achievements", "Win Condition"));
type_badge_bg_color = IM_COL32(50, 110, 30, 255);
break;
}
if (!type_badge_text.empty())
{
type_badge_padding = LayoutScale(5.0f, 3.0f);
type_badge_spacing = LayoutScale(10.0f);
type_badge_rounding = LayoutScale(3.0f);
type_badge_size = UIStyle.Font->CalcTextSizeA(type_badge_font_size, type_badge_font_weight, FLT_MAX, 0.0f,
IMSTR_START_END(type_badge_text));
type_badge_size += type_badge_padding * 2.0f;
}
const ImVec2 image_size = LayoutScale(50.0f, 50.0f);
const float image_right_padding = LayoutScale(15.0f);
const float avail_width = GetMenuButtonAvailableWidth();
const float spacing = LayoutScale(4.0f);
const ImVec2 right_side_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX,
0.0f, TRANSLATE("Achievements", "XXX points"));
const float max_text_width = avail_width - (image_size.x + image_right_padding + spacing + right_side_size.x);
const float max_title_width =
max_text_width - (type_badge_text.empty() ? 0.0f : type_badge_size.x + type_badge_spacing);
const ImVec2 title_size = UIStyle.Font->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight, FLT_MAX,
max_title_width, IMSTR_START_END(title));
const ImVec2 description_size = description.empty() ?
ImVec2() :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight,
FLT_MAX, max_text_width, IMSTR_START_END(description));
const float content_height = (title_size.y + spacing + description_size.y + spacing + UIStyle.MediumFontSize) +
(is_measured ? (spacing + LayoutScale(progress_height_unscaled)) : 0.0f) +
LayoutScale(LAYOUT_MENU_ITEM_EXTRA_HEIGHT);
SmallString text;
text.format("chv_{}", cheevo->id);
ImRect bb;
bool visible, hovered;
const bool clicked = MenuButtonFrame(text, content_height, true, &bb, &visible, &hovered);
if (!visible)
return;
ImDrawList* const dl = ImGui::GetWindowDrawList();
if (const std::string& badge_path = GetCachedAchievementBadgePath(cheevo, !is_unlocked); !badge_path.empty())
{
GPUTexture* badge = GetCachedTextureAsync(badge_path);
if (badge)
{
const ImRect image_bb = CenterImage(ImRect(bb.Min, bb.Min + image_size), badge);
dl->AddImage(badge, image_bb.Min, image_bb.Max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
IM_COL32(255, 255, 255, 255));
}
}
// make it easier to compute bounding boxes...
ImVec2 current_pos = ImVec2(bb.Min.x + image_size.x + image_right_padding, bb.Min.y);
// -- Title --
const ImRect title_bb(current_pos, current_pos + title_size);
const u32 text_color = ImGui::GetColorU32(UIStyle.SecondaryTextColor);
RenderShadowedTextClipped(dl, UIStyle.Font, title_font_size, title_font_weight, title_bb.Min, title_bb.Max,
text_color, title, &title_size, ImVec2(0.0f, 0.0f), max_title_width, &title_bb);
current_pos.y += title_size.y + spacing;
// -- Type Badge --
if (!type_badge_text.empty())
{
const ImVec2 type_badge_pos(title_bb.Min.x + title_size.x + type_badge_spacing,
ImFloor(title_bb.Min.y + (title_font_size - type_badge_size.y) * 0.5f));
dl->AddRectFilled(type_badge_pos, type_badge_pos + type_badge_size, type_badge_bg_color, type_badge_rounding);
const ImVec2 type_badge_text_pos = type_badge_pos + type_badge_padding;
const ImVec4 type_badge_text_clip = ImVec4(type_badge_pos.x, type_badge_pos.y, type_badge_pos.x + type_badge_size.x,
type_badge_pos.y + type_badge_size.y);
dl->AddText(UIStyle.Font, type_badge_font_size, type_badge_font_weight, type_badge_text_pos,
IM_COL32(255, 255, 255, 255), IMSTR_START_END(type_badge_text), 0.0f, &type_badge_text_clip);
}
// -- Description --
const u32 description_color = ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryTextColor));
if (!description.empty())
{
const ImRect description_bb(current_pos, current_pos + description_size);
RenderShadowedTextClipped(dl, UIStyle.Font, subtitle_font_size, subtitle_font_weight, description_bb.Min,
description_bb.Max, description_color, description, &description_size, ImVec2(0.0f, 0.0f),
max_text_width, &description_bb);
current_pos.y += description_size.y + spacing;
}
// -- Rarity --
// display hc if hc is active
const float rarity_to_display =
rc_client_get_hardcore_enabled(Achievements::GetClient()) ? cheevo->rarity_hardcore : cheevo->rarity;
const ImRect rarity_bb(current_pos, ImVec2(current_pos.x + max_text_width, current_pos.y + UIStyle.MediumFontSize));
const u32 rarity_color = ImGui::GetColorU32(DarkerColor(DarkerColor(UIStyle.SecondaryTextColor)));
if (is_unlocked)
{
const std::string date =
Host::FormatNumber(Host::NumberFormatType::LongDateTime, static_cast<s64>(cheevo->unlock_time));
text.format(TRANSLATE_FS("Achievements", "Unlocked: {} | {:.1f}% of players have this achievement"), date,
rarity_to_display);
RenderShadowedTextClipped(dl, UIStyle.Font, subtitle_font_size, subtitle_font_weight, rarity_bb.Min, rarity_bb.Max,
rarity_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &rarity_bb);
}
else
{
text.format(TRANSLATE_FS("Achievements", "{:.1f}% of players have this achievement"), rarity_to_display);
RenderShadowedTextClipped(dl, UIStyle.Font, subtitle_font_size, subtitle_font_weight, rarity_bb.Min, rarity_bb.Max,
rarity_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &rarity_bb);
}
current_pos.y += UIStyle.MediumFontSize + spacing;
if (is_measured)
{
const float progress_rounding = LayoutScale(progress_rounding_unscaled);
const ImRect progress_bb(current_pos,
ImVec2(max_text_width, current_pos.y + LayoutScale(progress_height_unscaled)));
const float fraction = cheevo->measured_percent * 0.01f;
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(UIStyle.PrimaryDarkColor),
progress_rounding);
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(UIStyle.SecondaryColor), 0.0f, fraction,
progress_rounding);
const ImVec2 text_size = UIStyle.Font->CalcTextSizeA(subtitle_font_size, subtitle_font_weight, FLT_MAX, 0.0f,
IMSTR_START_END(measured_progress));
const ImVec2 text_pos =
ImFloor(ImVec2(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f)));
dl->AddText(UIStyle.Font, subtitle_font_size, subtitle_font_weight, text_pos,
ImGui::GetColorU32(UIStyle.PrimaryTextColor), IMSTR_START_END(measured_progress));
}
// right side items
current_pos = ImVec2(bb.Max.x - right_side_size.x, bb.Min.y);
// -- Lock Icon and Points --
const std::string_view lock_text = is_unlocked ? ICON_EMOJI_UNLOCKED : ICON_FA_LOCK;
const ImVec2 lock_size =
UIStyle.Font->CalcTextSizeA(title_font_size, 0.0f, FLT_MAX, 0.0f, IMSTR_START_END(lock_text));
const ImRect lock_bb(current_pos, ImVec2(bb.Max.x, current_pos.y + lock_size.y));
RenderShadowedTextClipped(dl, UIStyle.Font, title_font_size, 0.0f, lock_bb.Min, lock_bb.Max, text_color, lock_text,
&lock_size, ImVec2(0.5f, 0.0f), 0.0f, &lock_bb);
current_pos.y += lock_size.y + spacing;
text = TRANSLATE_PLURAL_SSTR("Achievements", "%n points", "Achievement points", cheevo->points);
const ImVec2 points_size =
UIStyle.Font->CalcTextSizeA(subtitle_font_size, subtitle_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(text));
const ImRect points_bb(current_pos, ImVec2(bb.Max.x, current_pos.y + points_size.y));
RenderShadowedTextClipped(dl, UIStyle.Font, subtitle_font_size, subtitle_font_weight, points_bb.Min, points_bb.Max,
description_color, text, &points_size, ImVec2(0.5f, 0.0f), 0.0f, &points_bb);
if (clicked)
{
const std::string url = fmt::format(fmt::runtime(ACHEIVEMENT_DETAILS_URL_TEMPLATE), cheevo->id);
INFO_LOG("Opening achievement details: {}", url);
Host::OpenURL(url);
}
}
void FullscreenUI::OpenLeaderboardsWindow()
{
if (!System::IsValid())
return;
const auto lock = Achievements::GetLock();
if (!Achievements::IsActive() || !Achievements::HasLeaderboards())
{
Host::AddIconOSDMessage(OSDMessageType::Info, "LeaderboardsUnavailable", Achievements::RA_LOGO_ICON_NAME,
TRANSLATE_STR("Achievements", "Leaderboards are not available."),
Achievements::IsActive() ?
TRANSLATE_STR("Achievements", "This game has no leaderboards.") :
TRANSLATE_STR("Achievements", "Achievements are disabled in settings."));
return;
}
GPUThread::RunOnThread([]() {
Initialize();
PauseForMenuOpen(false);
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
BeginTransition(SHORT_TRANSITION_TIME, &SwitchToLeaderboards);
});
}
void FullscreenUI::SwitchToLeaderboards()
{
const auto lock = Achievements::GetLock();
if (!Achievements::HasLeaderboards())
{
ClosePauseMenuImmediately();
return;
}
s_achievements_locals.achievement_badge_paths = {};
CloseLeaderboard();
if (s_achievements_locals.leaderboard_list)
rc_client_destroy_leaderboard_list(s_achievements_locals.leaderboard_list);
s_achievements_locals.leaderboard_list =
rc_client_create_leaderboard_list(Achievements::GetClient(), RC_CLIENT_LEADERBOARD_LIST_GROUPING_NONE);
if (!s_achievements_locals.leaderboard_list)
{
ERROR_LOG("rc_client_create_leaderboard_list() returned null");
ClosePauseMenuImmediately();
return;
}
CollectSubsetsFromList(s_achievements_locals.leaderboard_list, false, true);
SwitchToMainWindow(MainWindowType::Leaderboards);
}
void FullscreenUI::DrawLeaderboardsWindow()
{
static constexpr float heading_height_unscaled = 102.0f;
const auto lock = Achievements::GetLock();
if (!s_achievements_locals.leaderboard_list)
{
ReturnToPreviousWindow();
return;
}
const bool is_leaderboard_open = (s_achievements_locals.open_leaderboard != nullptr);
bool close_leaderboard_on_exit = false;
SmallString text;
const ImVec4 background = ModAlpha(UIStyle.BackgroundColor, WINDOW_ALPHA);
const ImVec4 heading_background = ModAlpha(UIStyle.BackgroundColor, WINDOW_HEADING_ALPHA);
const ImVec2 display_size = ImGui::GetIO().DisplaySize;
const u32 text_color = ImGui::GetColorU32(ImGuiCol_Text);
const float spacing = LayoutScale(10.0f);
const float spacing_small = ImFloor(spacing * 0.5f);
const float heading_height = LayoutScale(heading_height_unscaled);
if (BeginFullscreenWindow(ImVec2(), ImVec2(display_size.x, heading_height), "leaderboards_heading",
heading_background, 0.0f, ImVec2(10.0f, 10.0f),
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoScrollWithMouse))
{
const ImVec2 heading_pos = ImGui::GetCursorScreenPos() + ImGui::GetStyle().FramePadding;
const float image_size = LayoutScale(75.0f);
if (const std::string& icon = Achievements::GetGameIconPath(); !icon.empty())
{
GPUTexture* badge = GetCachedTextureAsync(icon);
if (badge)
{
ImGui::GetWindowDrawList()->AddImage(badge, heading_pos, heading_pos + ImVec2(image_size, image_size),
ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
}
}
float left = heading_pos.x + image_size + spacing;
float right = heading_pos.x + GetMenuButtonAvailableWidth();
float top = heading_pos.y;
const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize));
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;
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min, title_bb.Max,
text_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &title_bb);
u32 summary_color;
if (is_leaderboard_open)
{
const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize));
text.assign(s_achievements_locals.open_leaderboard->title);
top += UIStyle.MediumLargeFontSize + spacing_small;
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight, subtitle_bb.Min,
subtitle_bb.Max,
ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])), text, nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &subtitle_bb);
text.assign(s_achievements_locals.open_leaderboard->description);
summary_color = ImGui::GetColorU32(DarkerColor(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])));
}
else
{
u32 count = 0;
for (u32 i = 0; i < s_achievements_locals.leaderboard_list->num_buckets; i++)
{
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]));
}
const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
top += UIStyle.MediumFontSize + spacing_small;
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, summary_bb.Min,
summary_bb.Max, summary_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &summary_bb);
if (!is_leaderboard_open && !Achievements::IsHardcoreModeActive())
{
const ImRect hardcore_warning_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
top += UIStyle.MediumFontSize + spacing_small;
text.format(
ICON_EMOJI_WARNING " {}",
TRANSLATE_SV("Achievements",
"Submitting scores is disabled because hardcore mode is off. Leaderboards are read-only."));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, hardcore_warning_bb.Min,
hardcore_warning_bb.Max,
ImGui::GetColorU32(DarkerColor(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]))),
text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &hardcore_warning_bb);
}
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();
}
else
{
const auto show_nearby_title = FullscreenUI::IconStackString(ICON_EMOJI_CLIPBOARD, FSUI_NSTR("Show Nearby"));
const auto show_all_title = FullscreenUI::IconStackString(ICON_EMOJI_NOTEBOOK, FSUI_NSTR("Show All"));
const float& nav_font_size = UIStyle.MediumFontSize;
constexpr float nav_font_weight = UIStyle.BoldFontWeight;
constexpr float nav_x_padding = 12.0f;
constexpr float nav_y_padding = 8.0f;
const float nav_width =
CalcFloatingNavBarButtonWidth(show_nearby_title, 0.0f, nav_font_size, nav_font_weight, nav_x_padding) +
CalcFloatingNavBarButtonWidth(show_all_title, 0.0f, nav_font_size, nav_font_weight, nav_x_padding);
const ImVec2 saved_cursor_pos = ImGui::GetCursorPos();
BeginFloatingNavBar(30.0f, 10.0f, nav_width, nav_font_size, 1.0f, 0.0f, nav_x_padding, nav_y_padding);
const bool view_toggled =
(ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, false) ||
ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakSlow, false) || ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false) ||
ImGui::IsKeyPressed(ImGuiKey_GamepadDpadRight, false) ||
ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakFast, false) || ImGui::IsKeyPressed(ImGuiKey_RightArrow, false));
bool new_view = view_toggled ? !s_achievements_locals.is_showing_all_leaderboard_entries :
s_achievements_locals.is_showing_all_leaderboard_entries;
for (const bool show_all : {false, true})
{
if (FloatingNavBarIcon(show_all ? show_all_title.view() : show_nearby_title.view(), nullptr,
s_achievements_locals.is_showing_all_leaderboard_entries == show_all, 0.0f,
nav_font_size, nav_font_weight))
{
new_view = show_all;
}
}
if (s_achievements_locals.is_showing_all_leaderboard_entries != new_view)
{
BeginTransition(DEFAULT_TRANSITION_TIME, [new_view]() {
s_achievements_locals.is_showing_all_leaderboard_entries = new_view;
QueueResetFocus(FocusResetType::ViewChanged);
});
}
EndFloatingNavBar();
ImGui::SetCursorPos(saved_cursor_pos);
if (FloatingButton(ICON_FA_XMARK, 10.0f, 10.0f, 1.0f, 0.0f, true) || WantsToCloseMenu())
close_leaderboard_on_exit = true;
}
}
EndFullscreenWindow();
// See note in FullscreenUI::DrawSettingsWindow().
if (IsFocusResetFromWindowChange())
ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
if (!is_leaderboard_open)
{
if (BeginFullscreenWindow(
ImVec2(0.0f, heading_height),
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(LAYOUT_FOOTER_HEIGHT)), "leaderboards",
background, 0.0f, ImVec2(LAYOUT_MENU_WINDOW_X_PADDING, LAYOUT_MENU_WINDOW_Y_PADDING), 0))
{
ResetFocusHere();
BeginMenuButtons();
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]);
}
EndMenuButtons();
}
EndFullscreenWindow();
if (IsGamepadInputSource())
{
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"))});
}
}
}
else
{
if (BeginFullscreenWindow(
ImVec2(0.0f, heading_height),
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(LAYOUT_FOOTER_HEIGHT)), "leaderboard",
background, 0.0f, ImVec2(LAYOUT_MENU_WINDOW_X_PADDING, LAYOUT_MENU_WINDOW_Y_PADDING), 0))
{
const ImVec2 heading_start_pos = ImGui::GetCursorScreenPos();
ImVec2 column_heading_pos = heading_start_pos;
float end_x = column_heading_pos.x + ImGui::GetContentRegionAvail().x;
// and the padding for the frame itself
column_heading_pos.x += LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING);
end_x -= LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING);
const float rank_column_width = UIStyle.Font
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight,
std::numeric_limits<float>::max(), -1.0f, "99999")
.x;
const float name_column_width =
UIStyle.Font
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight, std::numeric_limits<float>::max(), -1.0f,
"WWWWWWWWWWWWWWWWWWWWWW")
.x;
const float time_column_width = UIStyle.Font
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight,
std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWW")
.x;
const float column_spacing = spacing * 2.0f;
const u32 heading_color = ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]));
const float midpoint = column_heading_pos.y + UIStyle.MediumLargeFontSize + LayoutScale(4.0f);
float text_start_x = column_heading_pos.x;
const ImRect rank_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight, rank_bb.Min,
rank_bb.Max, heading_color, TRANSLATE_SV("Achievements", "Rank"), nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &rank_bb);
text_start_x += rank_column_width + column_spacing;
const ImRect user_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight, user_bb.Min,
user_bb.Max, heading_color, TRANSLATE_SV("Achievements", "Name"), nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &user_bb);
text_start_x += name_column_width + column_spacing;
static const char* value_headings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
TRANSLATE_NOOP("Achievements", "Time"),
TRANSLATE_NOOP("Achievements", "Score"),
TRANSLATE_NOOP("Achievements", "Value"),
};
const ImRect score_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
RenderShadowedTextClipped(
UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight, score_bb.Min, score_bb.Max, heading_color,
Host::TranslateToStringView("Achievements",
value_headings[std::min<u8>(s_achievements_locals.open_leaderboard->format,
NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)]),
nullptr, ImVec2(0.0f, 0.0f), 0.0f, &score_bb);
text_start_x += time_column_width + column_spacing;
const ImRect date_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight, date_bb.Min,
date_bb.Max, heading_color, TRANSLATE_SV("Achievements", "Date Submitted"), nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &date_bb);
const float line_thickness = LayoutScale(1.0f);
const float line_padding = LayoutScale(5.0f);
const ImVec2 line_start(column_heading_pos.x, column_heading_pos.y + UIStyle.MediumLargeFontSize + line_padding);
const ImVec2 line_end(end_x, line_start.y);
ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled),
line_thickness);
// keep imgui happy
ImGui::Dummy(ImVec2(end_x - heading_start_pos.x, spacing + UIStyle.MediumLargeFontSize));
BeginMenuButtons(0, 0.0f, LAYOUT_MENU_BUTTON_X_PADDING, 8.0f, 0.0f, 4.0f);
ResetFocusHere();
// for drawing time popups
const std::time_t current_time = std::time(nullptr);
std::optional<std::tm> current_tm = Common::LocalTime(current_time);
if (!current_tm.has_value())
current_tm = std::tm{};
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, LayoutScale(10.0f));
ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, 0.0f);
if (!s_achievements_locals.is_showing_all_leaderboard_entries)
{
if (s_achievements_locals.leaderboard_nearby_entries)
{
for (u32 i = 0; i < s_achievements_locals.leaderboard_nearby_entries->num_entries; i++)
{
DrawLeaderboardEntry(s_achievements_locals.leaderboard_nearby_entries->entries[i], i,
static_cast<s32>(i) == s_achievements_locals.leaderboard_nearby_entries->user_index,
rank_column_width, name_column_width, time_column_width, column_spacing, current_time,
current_tm.value());
}
}
else
{
const ImVec2 pos_min(0.0f, heading_height);
const ImVec2 pos_max(display_size.x, display_size.y);
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.BoldFontWeight, pos_min, pos_max,
text_color,
TRANSLATE_SV("Achievements", "Downloading leaderboard data, please wait..."),
nullptr, ImVec2(0.5f, 0.5f), 0.0f);
}
}
else
{
for (const rc_client_leaderboard_entry_list_t* list : s_achievements_locals.leaderboard_entry_lists)
{
for (u32 i = 0; i < list->num_entries; i++)
{
DrawLeaderboardEntry(list->entries[i], i, static_cast<s32>(i) == list->user_index, rank_column_width,
name_column_width, time_column_width, column_spacing, current_time,
current_tm.value());
}
}
if (!s_achievements_locals.has_fetched_all_leaderboard_entries)
{
bool visible;
text.format(ICON_FA_HOURGLASS_HALF " {}", TRANSLATE_SV("Achievements", "Loading..."));
MenuButtonWithVisibilityQuery(text, text, {}, {}, &visible, false);
if (visible && !s_achievements_locals.leaderboard_fetch_handle)
FetchNextLeaderboardEntries();
}
}
ImGui::PopStyleVar(2);
EndMenuButtons();
}
EndFullscreenWindow();
if (IsGamepadInputSource())
{
SetFullscreenFooterText(
std::array{std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, TRANSLATE_SV("Achievements", "Change Page")),
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 Profile")),
std::make_pair(ICON_PF_BUTTON_B, TRANSLATE_SV("Achievements", "Back"))});
}
else
{
SetFullscreenFooterText(std::array{
std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, TRANSLATE_SV("Achievements", "Change Page")),
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 Profile")),
std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back"))});
}
}
if (close_leaderboard_on_exit)
{
BeginTransition([]() {
CloseLeaderboard();
QueueResetFocus(FocusResetType::ViewChanged);
});
}
}
void FullscreenUI::DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, u32 index, bool is_self,
float rank_column_width, float name_column_width, float time_column_width,
float column_spacing, std::time_t current_time, const std::tm& current_tm)
{
ImRect bb;
bool visible, hovered;
bool pressed = MenuButtonFrame(entry.user, UIStyle.MediumLargeFontSize, true, &bb, &visible, &hovered);
if (!visible)
return;
const float midpoint = bb.Min.y + UIStyle.MediumLargeFontSize + LayoutScale(4.0f);
float text_start_x = bb.Min.x;
SmallString text;
text.format("{}", entry.rank);
const u32 text_color =
is_self ? IM_COL32(255, 242, 0, 255) :
ImGui::GetColorU32(((index % 2) == 0) ? DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]) :
ImGui::GetStyle().Colors[ImGuiCol_Text]);
const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
const float font_weight = is_self ? UIStyle.BoldFontWeight : UIStyle.NormalFontWeight;
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, font_weight, rank_bb.Min, rank_bb.Max,
text_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &rank_bb);
text_start_x += rank_column_width + column_spacing;
const float icon_size = bb.Max.y - bb.Min.y;
const ImRect icon_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
GPUTexture* icon_tex = nullptr;
if (auto it = std::find_if(s_achievements_locals.leaderboard_user_icon_paths.begin(),
s_achievements_locals.leaderboard_user_icon_paths.end(),
[&entry](const auto& it) { return it.first == &entry; });
it != s_achievements_locals.leaderboard_user_icon_paths.end())
{
if (!it->second.empty())
icon_tex = GetCachedTextureAsync(it->second);
}
else
{
std::string path = Achievements::GetLeaderboardUserBadgePath(&entry);
if (!path.empty())
{
icon_tex = GetCachedTextureAsync(path);
s_achievements_locals.leaderboard_user_icon_paths.emplace_back(&entry, std::move(path));
}
}
if (icon_tex)
{
const ImRect fit_icon_bb = CenterImage(ImRect(icon_bb.Min, icon_bb.Min + ImVec2(icon_size, icon_size)), icon_tex);
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(icon_tex), fit_icon_bb.Min, fit_icon_bb.Max);
}
const float icon_spacing = LayoutScale(10.0f);
const ImRect user_bb(ImVec2(text_start_x + icon_spacing + icon_size, bb.Min.y), ImVec2(bb.Max.x, midpoint));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, font_weight, user_bb.Min, user_bb.Max,
text_color, entry.user, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &user_bb);
text_start_x += name_column_width + column_spacing;
const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, font_weight, score_bb.Min, score_bb.Max,
text_color, entry.display, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &score_bb);
text_start_x += time_column_width + column_spacing;
const ImRect time_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
const SmallString relative_time =
FormatRelativeTimestamp(static_cast<std::time_t>(entry.submitted), current_time, current_tm);
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumLargeFontSize, font_weight, time_bb.Min, time_bb.Max,
text_color, relative_time, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &time_bb);
if (time_bb.Contains(ImGui::GetIO().MousePos) && ImGui::BeginItemTooltip())
{
const std::string submit_time =
Host::FormatNumber(Host::NumberFormatType::LongDateTime, static_cast<s64>(entry.submitted));
ImGui::PushFont(UIStyle.Font, UIStyle.MediumLargeFontSize, UIStyle.NormalFontWeight);
ImGui::Text(ICON_EMOJI_CLOCK_FIVE_OCLOCK " %s", submit_time.c_str());
ImGui::PopFont();
ImGui::EndTooltip();
}
if (pressed)
{
const std::string url = fmt::format(fmt::runtime(PROFILE_DETAILS_URL_TEMPLATE), entry.user);
INFO_LOG("Opening profile details: {}", url);
Host::OpenURL(url);
}
}
SmallString FullscreenUI::FormatRelativeTimestamp(time_t timestamp, time_t current_time, const std::tm& current_tm)
{
const s64 diff = static_cast<s64>(current_time) - static_cast<s64>(timestamp);
constexpr s64 MINUTE = 60;
constexpr s64 HOUR = 60 * MINUTE;
constexpr s64 DAY = 24 * HOUR;
constexpr s64 WEEK = 7 * DAY;
if (diff < MINUTE)
return SmallString(TRANSLATE_SV("Achievements", "Just now"));
if (diff < HOUR)
{
const s64 minutes = diff / MINUTE;
return TRANSLATE_PLURAL_SSTR("Achievements", "%n minutes ago", "Relative time", static_cast<int>(minutes));
}
if (diff < DAY)
{
const s64 hours = diff / HOUR;
return TRANSLATE_PLURAL_SSTR("Achievements", "%n hours ago", "Relative time", static_cast<int>(hours));
}
if (diff < DAY * 2)
{
// Check if it's actually today vs yesterday
const std::optional<std::tm> timestamp_tm = Common::LocalTime(timestamp);
if (timestamp_tm.has_value() && timestamp_tm->tm_yday == current_tm.tm_yday &&
timestamp_tm->tm_year == current_tm.tm_year)
{
return SmallString(TRANSLATE_SV("Achievements", "Today"));
}
return SmallString(TRANSLATE_SV("Achievements", "Yesterday"));
}
if (diff < WEEK)
{
const s64 days = diff / DAY;
return TRANSLATE_PLURAL_SSTR("Achievements", "%n days ago", "Relative time", static_cast<int>(days));
}
const std::optional<std::tm> timestamp_tm = Common::LocalTime(timestamp);
if (!timestamp_tm.has_value())
return SmallString();
const int year_diff = current_tm.tm_year - timestamp_tm->tm_year;
const int month_diff = current_tm.tm_mon - timestamp_tm->tm_mon;
const int total_months = year_diff * 12 + month_diff;
if (total_months == 0)
{
// Less than a month - use weeks
const s64 weeks = diff / WEEK;
return TRANSLATE_PLURAL_SSTR("Achievements", "%n weeks ago", "Relative time", static_cast<int>(weeks));
}
if (total_months < 12)
return TRANSLATE_PLURAL_SSTR("Achievements", "%n months ago", "Relative time", total_months);
// For years, adjust if we haven't reached the anniversary yet
int years = year_diff;
if (current_tm.tm_mon < timestamp_tm->tm_mon ||
(current_tm.tm_mon == timestamp_tm->tm_mon && current_tm.tm_mday < timestamp_tm->tm_mday))
{
years--;
}
// Edge case: less than a full year but more than 11 months
if (years < 1)
return TRANSLATE_PLURAL_SSTR("Achievements", "%n months ago", "Relative time", total_months);
return TRANSLATE_PLURAL_SSTR("Achievements", "%n years ago", "Relative time", years);
}
void FullscreenUI::DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard)
{
SmallString title;
title.format("{}##{}", lboard->title, lboard->id);
std::string_view summary;
if (lboard->description && lboard->description[0] != '\0')
summary = lboard->description;
if (MenuButton(title, summary))
BeginTransition([id = lboard->id]() { OpenLeaderboardById(id); });
}
bool FullscreenUI::OpenLeaderboardById(u32 leaderboard_id)
{
const auto lock = Achievements::GetLock();
if (!Achievements::IsActive())
return false;
rc_client_t* const client = Achievements::GetClient();
const rc_client_leaderboard_t* lb = rc_client_get_leaderboard_info(client, leaderboard_id);
if (!lb)
return false;
DEV_LOG("Opening leaderboard '{}' ({})", lb->title, lb->id);
CloseLeaderboard();
s_achievements_locals.open_leaderboard = lb;
s_achievements_locals.is_showing_all_leaderboard_entries = false;
s_achievements_locals.has_fetched_all_leaderboard_entries = false;
s_achievements_locals.leaderboard_fetch_handle = rc_client_begin_fetch_leaderboard_entries_around_user(
client, lb->id, LEADERBOARD_NEARBY_ENTRIES_TO_FETCH, LeaderboardFetchNearbyCallback, nullptr);
QueueResetFocus(FocusResetType::Other);
return true;
}
void FullscreenUI::LeaderboardFetchNearbyCallback(int result, const char* error_message,
rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
void* callback_userdata)
{
// should be already locked
s_achievements_locals.leaderboard_fetch_handle = nullptr;
if (result != RC_OK)
{
ShowToast(OSDMessageType::Error, TRANSLATE_STR("Achievements", "Leaderboard download failed"), error_message);
CloseLeaderboard();
return;
}
if (s_achievements_locals.leaderboard_nearby_entries)
rc_client_destroy_leaderboard_entry_list(s_achievements_locals.leaderboard_nearby_entries);
s_achievements_locals.leaderboard_nearby_entries = list;
QueueResetFocus(FocusResetType::Other);
}
void FullscreenUI::LeaderboardFetchAllCallback(int result, const char* error_message,
rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
void* callback_userdata)
{
// should be already locked
s_achievements_locals.leaderboard_fetch_handle = nullptr;
if (result != RC_OK)
{
ShowToast(OSDMessageType::Error, TRANSLATE_STR("Achievements", "Leaderboard download failed"), error_message);
CloseLeaderboard();
return;
}
if (s_achievements_locals.leaderboard_entry_lists.empty())
QueueResetFocus(FocusResetType::Other);
s_achievements_locals.leaderboard_entry_lists.push_back(list);
// at the end if we don't have the request size full of entries
s_achievements_locals.has_fetched_all_leaderboard_entries |= (list->num_entries < LEADERBOARD_ALL_FETCH_SIZE);
}
void FullscreenUI::FetchNextLeaderboardEntries()
{
u32 start = 1;
for (rc_client_leaderboard_entry_list_t* list : s_achievements_locals.leaderboard_entry_lists)
start += list->num_entries;
DEV_LOG("Fetching entries {} to {}", start, start + LEADERBOARD_ALL_FETCH_SIZE);
rc_client_t* const client = Achievements::GetClient();
if (s_achievements_locals.leaderboard_fetch_handle)
rc_client_abort_async(client, s_achievements_locals.leaderboard_fetch_handle);
s_achievements_locals.leaderboard_fetch_handle =
rc_client_begin_fetch_leaderboard_entries(client, s_achievements_locals.open_leaderboard->id, start,
LEADERBOARD_ALL_FETCH_SIZE, LeaderboardFetchAllCallback, nullptr);
}
void FullscreenUI::CloseLeaderboard()
{
s_achievements_locals.leaderboard_user_icon_paths.clear();
for (auto iter = s_achievements_locals.leaderboard_entry_lists.rbegin();
iter != s_achievements_locals.leaderboard_entry_lists.rend(); ++iter)
{
rc_client_destroy_leaderboard_entry_list(*iter);
}
s_achievements_locals.leaderboard_entry_lists.clear();
if (s_achievements_locals.leaderboard_nearby_entries)
{
rc_client_destroy_leaderboard_entry_list(s_achievements_locals.leaderboard_nearby_entries);
s_achievements_locals.leaderboard_nearby_entries = nullptr;
}
if (s_achievements_locals.leaderboard_fetch_handle)
{
rc_client_abort_async(Achievements::GetClient(), s_achievements_locals.leaderboard_fetch_handle);
s_achievements_locals.leaderboard_fetch_handle = nullptr;
}
s_achievements_locals.open_leaderboard = nullptr;
s_achievements_locals.has_fetched_all_leaderboard_entries = false;
s_achievements_locals.is_showing_all_leaderboard_entries = false;
}
#endif // __ANDROID__