Files
duckstation/src/core/fullscreenui_achievements.cpp
T
Stenzek 9275e1ad77 Achievements: Add RA SVG icon variant
Can't be used for everything.
2026-04-12 12:15:07 +10:00

3207 lines
139 KiB
C++

// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "achievements_private.h"
#include "fullscreenui_private.h"
#include "host.h"
#include "system.h"
#include "video_thread.h"
#include "util/gpu_device.h"
#include "util/gpu_texture.h"
#include "util/imgui_manager.h"
#include "util/translation.h"
#include "common/assert.h"
#include "common/easing.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"
#include <bitset>
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 = 20;
static constexpr u32 LEADERBOARD_ALL_FETCH_SIZE = 50;
// How long the last progress update is shown in the pause menu.
static constexpr float PAUSE_MENU_PROGRESS_DISPLAY_TIME = 60.0f;
// Notification animation times.
static constexpr float NOTIFICATION_APPEAR_ANIMATION_TIME = 0.2f;
static constexpr float NOTIFICATION_DISAPPEAR_ANIMATION_TIME = 0.5f;
static constexpr float CHALLENGE_INDICATOR_FADE_IN_TIME = 0.1f;
static constexpr float CHALLENGE_INDICATOR_FADE_OUT_TIME = 0.3f;
namespace {
struct Notification
{
std::string key;
std::string title;
std::string text;
std::string note;
std::string badge_path;
u64 start_time;
u64 move_time;
float duration;
float target_y;
float last_y;
u16 min_width;
AchievementNotificationNoteType note_type;
bool small_font;
};
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;
};
struct PauseMenuLeaderboardInfo
{
std::string title;
std::string description;
std::string tracker_value;
u32 leaderboard_id;
u32 format;
};
} // namespace
static float EffectiveNotificationScale(s16 scale);
static void DrawNotifications(NotificationLayout& layout);
static void DrawIndicators(NotificationLayout& layout);
static void UpdateAchievementOverlaysRunIdle();
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 void SortLockedAchievements();
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, const ImVec2& prefetch_range);
static void OpenAchievementDetails(u32 achievement_id);
static void SetAchievementPinned(u32 achievement_id, bool pinned);
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 bool 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 void DrawLeaderboardLoadingIndicator(float pos_y, float avail_height, bool short_text);
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
{
std::vector<Notification> notifications;
// Shared by both achievements and leaderboards
std::vector<SubsetInfo> subset_info_list;
const SubsetInfo* open_subset = 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;
std::vector<PauseMenuLeaderboardInfo> active_leaderboards;
rc_client_achievement_list_t* achievement_list = nullptr;
std::bitset<NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS> achievement_buckets_collapsed = {};
u32 scroll_to_achievement_id = 0;
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.notifications = {};
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.scroll_to_achievement_id = 0;
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();
UpdateAchievementOverlaysRunIdle();
}
void FullscreenUI::DrawAchievementsOverlays()
{
if (!Achievements::IsActive())
return;
const auto lock = Achievements::GetLock();
NotificationLayout layout(g_gpu_settings.achievements_notification_location);
DrawNotifications(layout);
if (Achievements::HasActiveGame())
{
// need to group them together if they're in the same location
if (g_gpu_settings.achievements_indicator_location != layout.GetLocation())
layout = NotificationLayout(g_gpu_settings.achievements_indicator_location);
DrawIndicators(layout);
}
}
void FullscreenUI::AddAchievementNotification(std::string key, float duration, std::string image_path,
std::string title, std::string text, std::string note,
AchievementNotificationNoteType note_type, u16 min_width, bool small_font)
{
const bool prev_had_notifications = s_achievements_locals.notifications.empty();
const Timer::Value current_time = Timer::GetCurrentValue();
if (!key.empty())
{
for (auto it = s_achievements_locals.notifications.begin(); it != s_achievements_locals.notifications.end(); ++it)
{
if (it->key == key)
{
it->duration = duration;
it->title = std::move(title);
it->text = std::move(text);
it->note = std::move(note);
it->badge_path = std::move(image_path);
it->min_width = min_width;
it->note_type = note_type;
it->small_font = small_font;
// Don't fade it in again
const float time_passed = static_cast<float>(Timer::ConvertValueToSeconds(current_time - it->start_time));
it->start_time =
current_time - Timer::ConvertSecondsToValue(std::min(time_passed, NOTIFICATION_APPEAR_ANIMATION_TIME));
return;
}
}
}
Notification notif;
notif.key = std::move(key);
notif.duration = duration;
notif.title = std::move(title);
notif.text = std::move(text);
notif.note = std::move(note);
notif.badge_path = std::move(image_path);
notif.start_time = current_time;
notif.move_time = current_time;
notif.target_y = -1.0f;
notif.last_y = -1.0f;
notif.min_width = min_width;
notif.note_type = note_type;
notif.small_font = small_font;
s_achievements_locals.notifications.push_back(std::move(notif));
if (!prev_had_notifications)
UpdateAchievementOverlaysRunIdle();
}
float FullscreenUI::EffectiveNotificationScale(s16 scale)
{
// <0 = osd scale, 0 = layout scale, >0 = custom scale
if (scale < 0)
return ImGuiManager::GetGlobalScale();
if (scale == Settings::ACHIEVEMENT_NOTIFICATION_SCALE_AUTO)
return UIStyle.LayoutScale;
// apply window scale as well so it's independent of displays
const float window_scale = g_gpu_device->HasMainSwapChain() ? g_gpu_device->GetMainSwapChain()->GetScale() : 1.0f;
return static_cast<float>(scale) * window_scale * 0.01f;
}
void FullscreenUI::DrawNotifications(NotificationLayout& layout)
{
if (s_achievements_locals.notifications.empty())
return;
static constexpr float MOVE_DURATION = 0.5f;
const Timer::Value current_time = Timer::GetCurrentValue();
const float scale = EffectiveNotificationScale(g_gpu_settings.achievements_notification_scale);
const float normal_horizontal_padding = ImCeil(20.0f * scale);
const float normal_vertical_padding = ImCeil(15.0f * scale);
const float small_horizontal_padding = ImCeil(10.0f * scale);
const float small_vertical_padding = small_horizontal_padding;
const float horizontal_spacing = ImCeil(10.0f * scale);
const float larger_horizontal_spacing = ImCeil(18.0f * scale);
const float vertical_spacing = ImCeil(4.0f * scale);
const float normal_badge_size = ImCeil(48.0f * scale);
const float small_badge_size = ImCeil(32.0f * scale);
const float min_width = ImCeil(200.0f * scale);
const float max_width = ImCeil(600.0f * scale);
const float max_text_width = max_width - normal_badge_size - (normal_horizontal_padding * 2.0f) - horizontal_spacing;
const float normal_min_height = (normal_vertical_padding * 2.0f) + normal_badge_size;
const float small_min_height = (small_vertical_padding * 2.0f) + small_badge_size;
const float note_icon_padding = ImCeil(30.0f * scale);
ImFont*& font = UIStyle.Font;
const float normal_title_font_size = ImCeil(LAYOUT_LARGE_FONT_SIZE * scale);
const float small_title_font_size = ImCeil(LAYOUT_MEDIUM_FONT_SIZE * scale);
static constexpr const float& title_font_weight = UIStyle.BoldFontWeight;
const float normal_text_font_size = ImCeil(LAYOUT_MEDIUM_FONT_SIZE * scale);
const float small_text_font_size = ImCeil(LAYOUT_MEDIUM_SMALL_FONT_SIZE * scale);
static constexpr const float& text_font_weight = UIStyle.NormalFontWeight;
const float note_text_size = ImCeil(LAYOUT_MEDIUM_FONT_SIZE * scale);
static constexpr const float& note_text_weight = UIStyle.BoldFontWeight;
const float note_icon_size = ImCeil(LAYOUT_LARGE_FONT_SIZE * scale);
const ImVec4 left_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 1.3f);
const ImVec4 right_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 0.8f);
const bool blur_background = g_gpu_settings.display_blur_message_backgrounds && !FullscreenUI::HasActiveWindow() &&
FullscreenUI::CanBlurBackground();
ImDrawList* const dl = ImGui::GetForegroundDrawList();
for (auto iter = s_achievements_locals.notifications.begin(); iter != s_achievements_locals.notifications.end();)
{
Notification& notif = *iter;
const float time_passed = static_cast<float>(Timer::ConvertValueToSeconds(current_time - notif.start_time));
if (time_passed >= notif.duration)
{
iter = s_achievements_locals.notifications.erase(iter);
continue;
}
// place note to the right of the title
GPUTexture* note_image = nullptr;
float note_font_size, note_font_weight, note_offset_y, note_spacing;
ImVec2 note_size;
switch (notif.note_type)
{
case AchievementNotificationNoteType::Text:
note_font_size = note_text_size;
note_font_weight = note_text_weight;
note_size = font->CalcTextSizeA(note_font_size, note_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(notif.note));
note_offset_y = 0.0f;
note_spacing = larger_horizontal_spacing;
break;
case AchievementNotificationNoteType::IconText:
note_font_size = note_icon_size;
note_font_weight = UIStyle.NormalFontWeight;
note_size = font->CalcTextSizeA(note_font_size, note_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(notif.note));
note_offset_y = 0.0f;
note_spacing = note_icon_padding;
break;
case AchievementNotificationNoteType::Spinner:
note_font_size = 0.0f;
note_font_weight = 0.0f;
note_size = ImVec2(note_text_size, note_text_size);
note_offset_y = ImFloor((note_icon_size - note_text_size) * 0.5f);
note_spacing = note_icon_padding;
break;
case AchievementNotificationNoteType::Image:
note_font_size = 0.0f;
note_font_weight = 0.0f;
note_image = GetCachedTexture(notif.note, static_cast<u32>(note_text_size), static_cast<u32>(note_text_size));
note_size = (note_image && note_image->GetWidth() > note_image->GetHeight()) ?
ImVec2(note_text_size * (static_cast<float>(note_image->GetWidth()) /
static_cast<float>(note_image->GetHeight())),
note_text_size) :
ImVec2(note_text_size, note_text_size * (static_cast<float>(note_image->GetHeight()) /
static_cast<float>(note_image->GetWidth())));
note_offset_y = ImFloor((note_icon_size - note_text_size) * 0.5f);
note_spacing = note_icon_padding;
break;
case AchievementNotificationNoteType::None:
default:
note_font_size = 0.0f;
note_font_weight = 0.0f;
note_size = ImVec2();
note_offset_y = 0.0f;
note_spacing = 0.0f;
break;
}
const float title_font_size = notif.small_font ? small_title_font_size : normal_title_font_size;
const float text_font_size = notif.small_font ? small_text_font_size : normal_text_font_size;
const ImVec2 title_size = font->CalcTextSizeA(title_font_size, title_font_weight, max_text_width - note_size.x,
max_text_width - note_size.x, IMSTR_START_END(notif.title));
const ImVec2 text_size = font->CalcTextSizeA(text_font_size, text_font_weight, max_text_width, max_text_width,
IMSTR_START_END(notif.text));
const float badge_size = notif.small_font ? small_badge_size : normal_badge_size;
const float horizontal_padding = notif.small_font ? small_horizontal_padding : normal_horizontal_padding;
const float vertical_padding = notif.small_font ? small_vertical_padding : normal_vertical_padding;
const float box_width = std::max((horizontal_padding * 2.0f) + badge_size + horizontal_spacing +
ImCeil(std::max(title_size.x + note_spacing + note_size.x, text_size.x)),
std::max(static_cast<float>(ImCeil(notif.min_width * scale)), min_width));
const float box_height =
std::max((vertical_padding * 2.0f) + ImCeil(title_size.y) + vertical_spacing + ImCeil(text_size.y),
notif.small_font ? small_min_height : normal_min_height);
const auto& [expected_pos, opacity] =
layout.GetNextPosition(box_width, box_height, time_passed, notif.duration, NOTIFICATION_APPEAR_ANIMATION_TIME,
NOTIFICATION_DISAPPEAR_ANIMATION_TIME, 0.2f);
float actual_y;
if (!layout.IsVerticalAnimation() || opacity == 1.0f)
{
actual_y = notif.last_y;
if (notif.target_y != expected_pos.y)
{
notif.move_time = current_time;
notif.target_y = expected_pos.y;
notif.last_y = (notif.last_y < 0.0f) ? expected_pos.y : notif.last_y;
actual_y = notif.last_y;
}
else if (actual_y != expected_pos.y)
{
const float time_since_move = static_cast<float>(Timer::ConvertValueToSeconds(current_time - notif.move_time));
if (time_since_move >= MOVE_DURATION)
{
notif.move_time = current_time;
notif.last_y = notif.target_y;
actual_y = notif.last_y;
}
else
{
const float frac = Easing::OutExpo(time_since_move / MOVE_DURATION);
actual_y = notif.last_y - ((notif.last_y - notif.target_y) * frac);
}
}
}
else
{
actual_y = expected_pos.y;
}
const ImVec2 box_min(expected_pos.x, actual_y);
const ImVec2 box_max(box_min.x + box_width, box_min.y + box_height);
if (blur_background && BeginBlurBackground(dl, box_min, box_max))
{
dl->AddRectFilled(box_min, box_max, ImGui::GetColorU32(ModAlpha(left_background_color, opacity)),
horizontal_padding);
EndBlurBackground(dl);
}
else
{
const float background_opacity = opacity * 0.95f;
const float min_rounded_width = horizontal_padding * 2.0f;
DrawRoundedGradientRect(
dl, box_min, box_max, ImGui::GetColorU32(ModAlpha(left_background_color, background_opacity)),
ImGui::GetColorU32(ModAlpha(ImLerp(left_background_color, right_background_color,
(box_width - min_rounded_width) / (max_width - min_rounded_width)),
background_opacity)),
horizontal_padding);
}
const ImVec2 badge_min(box_min.x + horizontal_padding, box_min.y + vertical_padding);
const ImVec2 badge_max(badge_min.x + badge_size, badge_min.y + badge_size);
if (!notif.badge_path.empty())
{
GPUTexture* tex = GetCachedTexture(notif.badge_path, static_cast<u32>(badge_size), static_cast<u32>(badge_size));
if (tex)
{
dl->AddImage(tex, badge_min, badge_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity)));
}
}
const u32 title_col = ImGui::GetColorU32(ModAlpha(UIStyle.ToastTextColor, opacity));
const u32 text_col = ImGui::GetColorU32(ModAlpha(DarkerColor(UIStyle.ToastTextColor), opacity));
const ImVec2 title_pos = ImVec2(badge_max.x + horizontal_spacing, box_min.y + vertical_padding);
const ImRect title_bb = ImRect(title_pos, title_pos + title_size);
RenderShadowedTextClipped(dl, font, title_font_size, title_font_weight, title_bb.Min, title_bb.Max, title_col,
notif.title, &title_size, ImVec2(0.0f, 0.0f), max_text_width - note_size.x, &title_bb);
const ImVec2 text_pos = ImVec2(badge_max.x + horizontal_spacing, title_bb.Max.y + vertical_spacing);
const ImRect text_bb = ImRect(text_pos, text_pos + text_size);
RenderShadowedTextClipped(dl, font, text_font_size, text_font_weight, text_bb.Min, text_bb.Max, text_col,
notif.text, &text_size, ImVec2(0.0f, 0.0f), max_text_width, &text_bb);
const ImVec2 note_pos =
ImVec2((box_min.x + box_width) - horizontal_padding - note_size.x, box_min.y + vertical_padding + note_offset_y);
switch (notif.note_type)
{
case AchievementNotificationNoteType::Text:
case AchievementNotificationNoteType::IconText:
{
const ImRect note_bb = ImRect(note_pos, note_pos + note_size);
RenderShadowedTextClipped(dl, font, note_font_size, note_font_weight, note_bb.Min, note_bb.Max, title_col,
notif.note, &note_size, ImVec2(0.0f, 0.0f), max_text_width, &note_bb);
}
break;
case AchievementNotificationNoteType::Spinner:
{
DrawSpinner(dl, note_pos, title_col, note_size.x, ImCeil(4.0f * scale));
}
break;
case AchievementNotificationNoteType::Image:
{
if (note_image)
{
const ImRect image_rect = CenterImage(note_size, note_image);
dl->AddImage(note_image, note_pos + image_rect.Min, note_pos + image_rect.Max, ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity)));
}
}
break;
case AchievementNotificationNoteType::None:
default:
break;
}
++iter;
}
// cleared?
if (s_achievements_locals.notifications.empty())
VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::AchievementOverlaysActive, false);
}
void FullscreenUI::DrawIndicators(NotificationLayout& layout)
{
static constexpr float INDICATOR_WIDTH_COEFF = 0.3f;
static constexpr const float& font_weight = UIStyle.BoldFontWeight;
static constexpr float bg_opacity = 0.8f;
const float scale = EffectiveNotificationScale(g_gpu_settings.achievements_indicator_scale);
const float spacing = ImCeil(10.0f * scale);
const ImVec2 padding = ImVec2(ImCeil(16.0f * scale), ImCeil(10.0f * scale));
const float rounding = ImCeil(10.0f * scale);
const float image_size = ImCeil(50.0f * scale);
const float font_size = ImCeil(LAYOUT_MEDIUM_FONT_SIZE * scale);
const ImGuiIO& io = ImGui::GetIO();
const bool blur_background = g_gpu_settings.display_blur_message_backgrounds && !FullscreenUI::HasActiveWindow() &&
FullscreenUI::CanBlurBackground();
ImDrawList* const dl = ImGui::GetBackgroundDrawList();
if (std::vector<Achievements::ActiveChallengeIndicator>& indicators = Achievements::GetActiveChallengeIndicators();
!indicators.empty() &&
(g_gpu_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::PersistentIcon ||
g_gpu_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::TemporaryIcon))
{
const bool use_time_remaining =
(g_gpu_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::TemporaryIcon);
const float x_advance = image_size + spacing;
const float total_width = image_size + (static_cast<float>(indicators.size() - 1) * x_advance);
ImVec2 current_position = layout.GetFixedPosition(total_width, image_size);
for (auto it = indicators.begin(); it != indicators.end();)
{
Achievements::ActiveChallengeIndicator& indicator = *it;
bool active = indicator.active;
if (use_time_remaining)
{
indicator.time_remaining = std::max(indicator.time_remaining - io.DeltaTime, 0.0f);
active = (indicator.time_remaining > 0.0f);
}
const float target_opacity = active ? 1.0f : 0.0f;
const float rate = active ? CHALLENGE_INDICATOR_FADE_IN_TIME : -CHALLENGE_INDICATOR_FADE_OUT_TIME;
indicator.opacity =
(indicator.opacity != target_opacity) ? ImSaturate(indicator.opacity + (io.DeltaTime / rate)) : target_opacity;
GPUTexture* badge = FullscreenUI::GetCachedTextureAsync(indicator.badge_path);
if (badge)
{
dl->AddImage(badge, current_position, current_position + ImVec2(image_size, image_size), ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, indicator.opacity)));
}
current_position.x += x_advance;
if (!indicator.active && indicator.opacity <= 0.01f)
{
DEV_LOG("Remove challenge indicator");
it = indicators.erase(it);
}
else
{
++it;
}
}
}
if (std::optional<Achievements::AchievementProgressIndicator>& indicator = Achievements::GetActiveProgressIndicator();
indicator.has_value())
{
indicator->time += indicator->active ? io.DeltaTime : -io.DeltaTime;
const ImVec4 left_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 1.3f);
const ImVec4 right_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 0.8f);
const float progress_image_size = ImCeil(32.0f * scale);
const std::string_view text = indicator->achievement->measured_progress;
const ImVec2 text_size = UIStyle.Font->CalcTextSizeA(font_size, font_weight, FLT_MAX, 0.0f, IMSTR_START_END(text));
const float box_width = progress_image_size + text_size.x + spacing + padding.x * 2.0f;
const float box_height = progress_image_size + padding.y * 2.0f;
const auto& [box_min, opacity] = layout.GetNextPosition(
box_width, box_height, indicator->active, indicator->time, Achievements::INDICATOR_FADE_IN_TIME,
Achievements::INDICATOR_FADE_OUT_TIME, INDICATOR_WIDTH_COEFF);
const ImVec2 box_max = box_min + ImVec2(box_width, box_height);
if (blur_background && BeginBlurBackground(dl, box_min, box_max))
{
dl->AddRectFilled(box_min, box_max, ImGui::GetColorU32(ModAlpha(left_background_color, opacity)), rounding);
EndBlurBackground(dl);
}
else
{
DrawRoundedGradientRect(dl, box_min, box_max,
ImGui::GetColorU32(ModAlpha(left_background_color, opacity * bg_opacity)),
ImGui::GetColorU32(ModAlpha(right_background_color, opacity * bg_opacity)), rounding);
}
GPUTexture* const badge = FullscreenUI::GetCachedTextureAsync(indicator->badge_path);
if (badge)
{
const ImVec2 badge_pos = box_min + padding;
dl->AddImage(badge, badge_pos, badge_pos + ImVec2(progress_image_size, progress_image_size), ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity)));
}
const ImVec2 text_pos = box_min + ImVec2(padding.x + progress_image_size + spacing,
ImFloor((box_max.y - box_min.y - text_size.y) * 0.5f));
const ImRect text_clip_rect(text_pos, box_max);
RenderShadowedTextClipped(dl, UIStyle.Font, font_size, font_weight, text_pos, box_max,
ImGui::GetColorU32(ModAlpha(UIStyle.ToastTextColor, opacity)), text, &text_size,
ImVec2(0.0f, 0.0f), 0.0f, &text_clip_rect);
if (!indicator->active && opacity <= 0.01f)
{
DEV_LOG("Remove progress indicator");
indicator.reset();
}
}
if (std::vector<Achievements::LeaderboardTrackerIndicator>& trackers =
Achievements::GetLeaderboardTrackerIndicators();
!trackers.empty())
{
const ImVec4 left_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 1.3f);
const ImVec4 right_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 0.8f);
TinyString tstr;
const auto measure_tracker = [&font_size, &tstr](const Achievements::LeaderboardTrackerIndicator& indicator) {
tstr.assign(ICON_FA_STOPWATCH " ");
for (u32 i = 0; i < indicator.text.length(); i++)
{
// 8 is typically the widest digit
if (indicator.text[i] >= '0' && indicator.text[i] <= '9')
tstr.append('8');
else
tstr.append(indicator.text[i]);
}
return UIStyle.Font->CalcTextSizeA(font_size, font_weight, FLT_MAX, 0.0f, IMSTR_START_END(tstr));
};
const auto draw_tracker =
[&padding, &rounding, &font_size, &blur_background, &dl, &left_background_color, &right_background_color, &tstr,
&measure_tracker](Achievements::LeaderboardTrackerIndicator& indicator, const ImVec2& pos, float opacity) {
const ImVec2 size = measure_tracker(indicator);
const float box_width = size.x + padding.x * 2.0f;
const float box_height = size.y + padding.y * 2.0f;
const ImRect box(pos, ImVec2(pos.x + box_width, pos.y + box_height));
if (blur_background && BeginBlurBackground(dl, box.Min, box.Max))
{
dl->AddRectFilled(box.Min, box.Max, ImGui::GetColorU32(ModAlpha(left_background_color, opacity)), rounding);
EndBlurBackground(dl);
}
else
{
DrawRoundedGradientRect(dl, box.Min, box.Max,
ImGui::GetColorU32(ModAlpha(left_background_color, opacity * bg_opacity)),
ImGui::GetColorU32(ModAlpha(right_background_color, opacity * bg_opacity)), rounding);
}
tstr.format(ICON_FA_STOPWATCH " {}", indicator.text);
const u32 text_col = ImGui::GetColorU32(ModAlpha(UIStyle.ToastTextColor, opacity));
RenderShadowedTextClipped(dl, UIStyle.Font, font_size, font_weight, box.Min + padding, box.Max, text_col, tstr,
nullptr, ImVec2(0.0f, 0.0f), 0.0f, &box);
return box_width;
};
// animations are not currently handled for more than one tracker... but this should be rare
if (trackers.size() > 1)
{
float total_width = 0.0f;
float max_height = 0.0f;
for (const Achievements::LeaderboardTrackerIndicator& indicator : trackers)
{
const ImVec2 size = measure_tracker(indicator);
total_width += ((total_width > 0.0f) ? spacing : 0.0f) + size.x + padding.x * 2.0f;
max_height = std::max(max_height, size.y);
}
ImVec2 current_pos = layout.GetFixedPosition(total_width, max_height + padding.y * 2.0f);
for (auto it = trackers.begin(); it != trackers.end();)
{
Achievements::LeaderboardTrackerIndicator& indicator = *it;
indicator.time += indicator.active ? io.DeltaTime : -io.DeltaTime;
current_pos.x += draw_tracker(indicator, current_pos, 1.0f) + spacing;
if (!indicator.active)
{
DEV_LOG("Remove tracker indicator");
it = trackers.erase(it);
}
else
{
++it;
}
}
}
else
{
// don't need to precalc size here either :D
Achievements::LeaderboardTrackerIndicator& indicator = trackers.front();
indicator.time += indicator.active ? io.DeltaTime : -io.DeltaTime;
const ImVec2 size = measure_tracker(indicator);
const float box_width = size.x + padding.x * 2.0f;
const float box_height = size.y + padding.y * 2.0f;
const auto& [box_pos, opacity] = layout.GetNextPosition(
box_width, box_height, indicator.active, indicator.time, Achievements::INDICATOR_FADE_IN_TIME,
Achievements::INDICATOR_FADE_OUT_TIME, INDICATOR_WIDTH_COEFF);
draw_tracker(indicator, box_pos, opacity);
if (!indicator.active && opacity <= 0.01f)
{
DEV_LOG("Remove tracker indicator");
trackers.clear();
}
}
}
if (const std::vector<Achievements::PinnedAchievementIndicator>& pinned =
Achievements::GetPinnedAchievementIndicators();
!pinned.empty())
{
const ImVec4 left_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 1.3f);
const ImVec4 right_background_color = DarkerColor(UIStyle.ToastBackgroundColor, 0.8f);
const float pinned_image_size = ImCeil(20.0f * scale);
const float pinned_vertical_spacing = ImCeil(4.0f * scale);
static constexpr float pinned_bg_opacity = 0.7f;
// Get max width, we want to draw all these in one box.
// TODO: This looks up achievements multiple times, not fast..
float box_width = 0.0f;
float box_height = 0.0f;
for (const Achievements::PinnedAchievementIndicator& indicator : pinned)
{
const rc_client_achievement_t* achievement =
rc_client_get_achievement_info(Achievements::GetClient(), indicator.achievement_id);
if (!achievement || achievement->measured_progress[0] == '\0')
continue;
const ImVec2 text_size =
UIStyle.Font->CalcTextSizeA(font_size, font_weight, FLT_MAX, 0.0f, achievement->measured_progress);
box_width = std::max(text_size.x, box_width);
box_height += ((box_height > 0.0f) ? pinned_vertical_spacing : 0.0f) + pinned_image_size;
}
// If the measuring hasn't begun yet, we don't want to display nothing
if (box_height > 0.0f)
{
// Add image width
box_width += pinned_image_size + spacing;
const float box_padded_width = box_width + padding.x * 2.0f;
const float box_padded_height = box_height + padding.y * 2.0f;
ImVec2 box_min = layout.GetFixedPosition(box_padded_width, box_padded_height);
ImVec2 box_max = box_min + ImVec2(box_padded_width, box_padded_height);
// NOTE: Not blurred since they're persistent.
DrawRoundedGradientRect(dl, box_min, box_max,
ImGui::GetColorU32(ModAlpha(left_background_color, pinned_bg_opacity)),
ImGui::GetColorU32(ModAlpha(right_background_color, pinned_bg_opacity)), rounding);
box_min += padding;
box_max -= padding;
ImVec2 pos = box_min;
for (const Achievements::PinnedAchievementIndicator& indicator : pinned)
{
const rc_client_achievement_t* achievement =
rc_client_get_achievement_info(Achievements::GetClient(), indicator.achievement_id);
if (!achievement || achievement->measured_progress[0] == '\0')
continue;
const std::string_view text(achievement->measured_progress);
const ImVec2 text_size =
UIStyle.Font->CalcTextSizeA(font_size, font_weight, FLT_MAX, 0.0f, IMSTR_START_END(text));
const float total_width = (pinned_image_size + spacing + text_size.x);
const float start_x = pos.x + ImFloor((box_width - total_width) * 0.5f);
GPUTexture* const badge = FullscreenUI::GetCachedTextureAsync(indicator.badge_path);
if (badge)
{
const ImVec2 badge_pos = ImVec2(start_x, pos.y);
dl->AddImage(badge, badge_pos, badge_pos + ImVec2(pinned_image_size, pinned_image_size), ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
}
const ImRect text_clip_rect(ImVec2(start_x + pinned_image_size + spacing, pos.y),
ImVec2(box_max.x, pos.y + pinned_image_size));
RenderShadowedTextClipped(dl, UIStyle.Font, font_size, font_weight, text_clip_rect.Min, text_clip_rect.Max,
ImGui::GetColorU32(UIStyle.ToastTextColor), text, &text_size, ImVec2(0.0f, 0.5f),
0.0f, &text_clip_rect);
pos.y += pinned_image_size + pinned_vertical_spacing;
}
}
}
}
void FullscreenUI::UpdateAchievementOverlaysRunIdle()
{
// early out if we're already on the GPU thread
if (VideoThread::IsOnThread())
{
VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::AchievementOverlaysActive,
!s_achievements_locals.notifications.empty());
return;
}
// need to check it again once we're executing on the gpu thread, it could've changed since
VideoThread::RunOnThread([]() {
bool is_active;
{
const auto lock = Achievements::GetLock();
is_active = !s_achievements_locals.notifications.empty();
}
VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::AchievementOverlaysActive, is_active);
});
}
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::UpdateAchievementsPauseScreenInfo()
{
const auto lock = Achievements::GetLock();
if (!Achievements::HasActiveGame())
{
s_achievements_locals.most_recent_unlock.reset();
s_achievements_locals.achievement_nearest_completion.reset();
s_achievements_locals.active_leaderboards.clear();
return;
}
rc_client_achievement_list_t* const achievements =
Achievements::HasAchievements() ?
rc_client_create_achievement_list(Achievements::GetClient(), RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS) :
nullptr;
if (achievements)
{
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);
}
else
{
s_achievements_locals.most_recent_unlock.reset();
s_achievements_locals.achievement_nearest_completion.reset();
}
rc_client_leaderboard_list_t* const leaderboards =
Achievements::HasLeaderboards() ?
rc_client_create_leaderboard_list(Achievements::GetClient(), RC_CLIENT_LEADERBOARD_LIST_GROUPING_NONE) :
nullptr;
if (leaderboards)
{
std::vector<PauseMenuLeaderboardInfo>& active_lbs = s_achievements_locals.active_leaderboards;
size_t num_active_lbs = 0;
for (u32 i = 0; i < leaderboards->num_buckets; i++)
{
const rc_client_leaderboard_bucket_t& bucket = leaderboards->buckets[i];
for (u32 j = 0; j < bucket.num_leaderboards; j++)
{
const rc_client_leaderboard_t* leaderboard = bucket.leaderboards[j];
if (leaderboard->state != RC_CLIENT_LEADERBOARD_STATE_TRACKING)
continue;
// avoid alloc if unnecessary
if (num_active_lbs >= active_lbs.size() || active_lbs[num_active_lbs].leaderboard_id != leaderboard->id)
{
if (num_active_lbs < active_lbs.size())
active_lbs.erase(active_lbs.begin() + num_active_lbs, active_lbs.end());
PauseMenuLeaderboardInfo& lbinfo = active_lbs.emplace_back();
lbinfo.title = leaderboard->title;
if (leaderboard->description)
lbinfo.description = leaderboard->description;
}
if (leaderboard->tracker_value)
active_lbs[num_active_lbs].tracker_value = leaderboard->tracker_value;
num_active_lbs++;
}
}
// remove extras
if (num_active_lbs < active_lbs.size())
active_lbs.erase(active_lbs.begin() + num_active_lbs, active_lbs.end());
rc_client_destroy_leaderboard_list(leaderboards);
}
else
{
s_achievements_locals.active_leaderboards.clear();
}
}
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 ImVec2 display_margin = LayoutScale(16.0f, 16.0f);
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.PopupBackgroundColor, UIStyle.BlurMenuBackground ? 1.0f : 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 - display_margin.x, start_pos_y + display_margin.y);
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;
if (UIStyle.BlurMenuBackground && BeginBlurBackground(dl, box_min, box_max))
{
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
EndBlurBackground(dl);
}
else
{
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::RenderRectFilledInRangeH(
dl, progress_bb, ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryColor)), progress_bb.Min.x,
progress_bb.Min.x + (unlocked_fraction * progress_bb.GetWidth()), 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);
if (UIStyle.BlurMenuBackground && BeginBlurBackground(dl, box_min, box_max))
{
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
EndBlurBackground(dl);
}
else
{
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::RenderRectFilledInRangeH(
dl, progress_bb, ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryColor)), progress_bb.Min.x,
progress_bb.Min.x + ((measured_percent * 0.01f) * progress_bb.GetWidth()), 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() :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, FLT_MAX,
badge_text_width, IMSTR_START_END(description));
const float text_height =
UIStyle.MediumSmallFontSize + (description.empty() ? 0.0f : (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() :
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;
}
if (!description.empty())
{
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 += 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_FLAG_CHECKERED " {}",
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);
if (UIStyle.BlurMenuBackground && BeginBlurBackground(dl, box_min, box_max))
{
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
EndBlurBackground(dl);
}
else
{
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
}
buffer.format(ICON_FA_HAND_FIST " {}",
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;
}
}
// Leaderboards
if (!s_achievements_locals.active_leaderboards.empty())
{
box_height = box_padding + box_padding + UIStyle.MediumFontSize;
const std::string_view icon_template = Achievements::GetLeaderboardFormatIcon(RC_CLIENT_LEADERBOARD_FORMAT_TIME);
const float leaderboard_icon_size =
UIStyle.Font
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.NormalFontWeight, FLT_MAX, 0.0f, IMSTR_START_END(icon_template))
.x;
const float leaderboard_icon_reserve = leaderboard_icon_size + (text_spacing * 3.0f);
const float avail_text_width = box_content_width - leaderboard_icon_reserve;
for (size_t i = 0; i < s_achievements_locals.active_leaderboards.size(); i++)
{
const PauseMenuLeaderboardInfo& lbinfo = s_achievements_locals.active_leaderboards[i];
box_height += paragraph_spacing;
const ImVec2 tracker_size = lbinfo.tracker_value.empty() ?
ImVec2() :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
FLT_MAX, 0.0f, IMSTR_START_END(lbinfo.tracker_value));
const float avail_title_width =
avail_text_width - ((tracker_size.x > 0.0f) ? (tracker_size.x + text_spacing) : 0.0f);
const ImVec2 title_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
FLT_MAX, avail_title_width, IMSTR_START_END(lbinfo.title));
box_height += title_size.y;
if (!lbinfo.description.empty())
{
const ImVec2 description_size =
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, FLT_MAX, avail_text_width,
IMSTR_START_END(lbinfo.description));
box_height += text_spacing + description_size.y;
}
box_height += ((i == (s_achievements_locals.active_leaderboards.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);
if (UIStyle.BlurMenuBackground && BeginBlurBackground(dl, box_min, box_max))
{
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
EndBlurBackground(dl);
}
else
{
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
}
buffer.format(ICON_FA_STOPWATCH " {}",
TRANSLATE_DISAMBIG_SV("Achievements", "Active Leaderboard Attempts", "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;
const ImVec4 clip_rect = ImVec4(text_pos.x, text_pos.y, text_pos.x + box_content_width, text_pos.y + box_height);
for (const PauseMenuLeaderboardInfo& lbinfo : s_achievements_locals.active_leaderboards)
{
text_pos.y += paragraph_spacing;
const std::string_view icon = Achievements::GetLeaderboardFormatIcon(lbinfo.format);
dl->AddText(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.NormalFontWeight, text_pos, title_text_color,
IMSTR_START_END(icon), 0.0f, &clip_rect);
const ImVec2 tracker_size = lbinfo.tracker_value.empty() ?
ImVec2() :
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
FLT_MAX, 0.0f, IMSTR_START_END(lbinfo.tracker_value));
const float avail_title_width =
avail_text_width - ((tracker_size.x > 0.0f) ? (tracker_size.x + text_spacing) : 0.0f);
const ImVec2 title_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
FLT_MAX, avail_title_width, IMSTR_START_END(lbinfo.title));
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
ImVec2(text_pos.x + leaderboard_icon_reserve, text_pos.y), title_text_color,
IMSTR_START_END(lbinfo.title), avail_title_width, &clip_rect);
if (!lbinfo.tracker_value.empty())
{
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.BoldFontWeight,
ImVec2(box_max.x - box_padding - tracker_size.x, text_pos.y), title_text_color,
IMSTR_START_END(lbinfo.tracker_value), 0.0f, &clip_rect);
}
text_pos.y += title_size.y;
if (!lbinfo.description.empty())
{
text_pos.y += text_spacing;
const ImVec2 description_size =
UIStyle.Font->CalcTextSizeA(UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight, FLT_MAX, avail_text_width,
IMSTR_START_END(lbinfo.description));
dl->AddText(UIStyle.Font, UIStyle.MediumSmallFontSize, UIStyle.NormalFontWeight,
ImVec2(text_pos.x + leaderboard_icon_reserve, text_pos.y), text_color,
IMSTR_START_END(lbinfo.description), avail_text_width, &clip_rect);
text_pos.y += description_size.y;
}
text_pos.y += paragraph_spacing;
}
}
}
void FullscreenUI::OpenAchievementsWindow()
{
// NOTE: Called from CPU thread.
if (!System::IsValid())
return;
if (const auto lock = Achievements::GetLock(); !Achievements::IsActive() || !Achievements::HasAchievements())
{
Host::AddIconOSDMessage(OSDMessageType::Info, "AchievementsUnavailable", Achievements::RA_LOGO_SVG_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;
}
const bool was_paused = System::IsPaused();
VideoThread::RunOnThread([was_paused]() {
Initialize();
if (!CanCurrentMainWindowStack() || !SetPendingMainWindowSwitch())
return;
PauseForMenuOpen(was_paused, false);
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
BeginTransition(SHORT_TRANSITION_TIME, &SwitchToAchievements);
});
if (!was_paused)
System::PauseSystem(true);
}
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.
const auto subset_active = [&include_achievements, &include_leaderboards](const rc_client_subset_t* subset) {
return ((include_achievements && subset->num_achievements > 0) ||
(include_leaderboards && subset->num_leaderboards > 0));
};
if (std::count_if(subset_list->subsets, subset_list->subsets + subset_list->num_subsets, subset_active) > 1)
{
for (u32 i = 0; i < subset_list->num_subsets; i++)
{
const rc_client_subset_t* subset = subset_list->subsets[i];
if (subset_active(subset))
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::SortLockedAchievements()
{
// Sort achievements to put pinned achievements at the top.
// Don't do this with RAIntegration, since we're modifying its data and who knows what it's doing..
if (!s_achievements_locals.achievement_list || Achievements::IsUsingRAIntegration() ||
Achievements::GetPinnedAchievementIndicators().empty())
{
return;
}
for (u32 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_LOCKED)
continue;
std::sort(bucket->achievements, bucket->achievements + bucket->num_achievements,
[](const rc_client_achievement_t* a, const rc_client_achievement_t* b) {
return (Achievements::IsAchievementPinned(a->id) && !Achievements::IsAchievementPinned(b->id));
});
}
}
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_SUBSET_BUCKETS);
if (!s_achievements_locals.achievement_list)
{
ERROR_LOG("rc_client_create_achievement_list() returned null");
ClosePauseMenuImmediately();
return;
}
// reset collapsed buckets
s_achievements_locals.achievement_buckets_collapsed.reset();
s_achievements_locals.scroll_to_achievement_id = 0;
// 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);
SortLockedAchievements();
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(DarkerColor(UIStyle.BackgroundColor, 1.5f), 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,
true))
{
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) || (!AreAnyDialogsOpen() && WantsToCloseMenu()));
const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize));
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 (IsCoreSubsetOpen())
{
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
{
if (summary.num_unlocked_achievements == summary.num_core_achievements)
{
text.append(TRANSLATE_PLURAL_SSTR("Achievements",
"You have unlocked all achievements in this subset and earned %n points!",
"Point count", summary.points_unlocked));
}
else
{
text.append_format(
TRANSLATE_FS(
"Achievements",
"You have unlocked {0} of {1} achievements in this subset, 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::RenderRectFilledInRangeH(dl, progress_bb, ImGui::GetColorU32(UIStyle.SecondaryColor), progress_bb.Min.x,
progress_bb.Min.x + (fraction * progress_bb.GetWidth()), 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, true))
{
static constexpr std::pair<const char*, const char*> bucket_names[] = {
{ICON_FA_CIRCLE_QUESTION, 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_FLASK_VIAL, TRANSLATE_NOOP("Achievements", "Unofficial")},
{ICON_FA_LOCK_OPEN, TRANSLATE_NOOP("Achievements", "Recently Unlocked")},
{ICON_FA_HAND_FIST, TRANSLATE_NOOP("Achievements", "Active Challenges")},
{ICON_FA_FLAG_CHECKERED, TRANSLATE_NOOP("Achievements", "Almost There")},
{ICON_EMOJI_WARNING, TRANSLATE_NOOP("Achievements", "Not Confirmed")},
{ICON_FA_UNLOCK, TRANSLATE_NOOP("Achievements", "Unlocked in Softcore")},
};
static_assert(std::size(bucket_names) == NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS);
ResetFocusHere();
BeginMenuButtons();
// Prefetch badges for up to 3 screens worth of achievements based on current scroll position. This should help
// reduce loading placeholders when scrolling through a large list of achievements.
const ImGuiWindow* const win = ImGui::GetCurrentWindowRead();
const ImVec2 prefetch_range = ImVec2(win->Scroll.y, win->Scroll.y + (win->ClipRect.GetHeight() * 3.0f));
for (u32 bucket_type : {RC_CLIENT_ACHIEVEMENT_BUCKET_UNSYNCED, 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_UNLOCKED_SOFTCORE,
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);
if (MenuHeadingButton(TinyString::from_format(
"{} {}", bucket_names[bucket.bucket_type].first,
Host::TranslateToStringView("Achievements", bucket_names[bucket.bucket_type].second)),
s_achievements_locals.achievement_buckets_collapsed[bucket.bucket_type] ?
ICON_FA_CHEVRON_DOWN :
ICON_FA_CHEVRON_UP,
UIStyle.MediumLargeFontSize))
{
s_achievements_locals.achievement_buckets_collapsed.flip(bucket.bucket_type);
}
if (!s_achievements_locals.achievement_buckets_collapsed[bucket.bucket_type])
{
for (u32 i = 0; i < bucket.num_achievements; i++)
DrawAchievement(bucket.achievements[i], prefetch_range);
}
}
}
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_X, TRANSLATE_SV("Achievements", "Pin Achievement")),
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_X, TRANSLATE_SV("Achievements", "Pin Achievement")),
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_F4, 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_F4, TRANSLATE_SV("Achievements", "Pin Achievement")),
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, const ImVec2& prefetch_range)
{
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_pinned = (!is_unlocked && Achievements::IsAchievementPinned(cheevo->id));
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;
}
static constexpr const float& pin_font_size = UIStyle.MediumLargeFontSize;
const std::string_view pin_text = is_pinned ? std::string_view(ICON_FA_THUMBTACK) : std::string_view();
const ImVec2 pin_size = pin_text.empty() ?
ImVec2() :
UIStyle.Font->CalcTextSizeA(pin_font_size, 0.0f, FLT_MAX, 0.0f, IMSTR_START_END(pin_text));
const float pin_spacing = pin_text.empty() ? 0.0f : LayoutScale(5.0f);
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 * 2.0f) + 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) -
(pin_text.empty() ? 0.0f : pin_size.x + pin_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);
const float pos_y = ImGui::GetCursorPosY();
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)
{
if (pos_y >= prefetch_range.x && pos_y <= prefetch_range.y)
GetCachedAchievementBadgePath(cheevo, !is_unlocked);
return;
}
if (s_achievements_locals.scroll_to_achievement_id == cheevo->id)
{
s_achievements_locals.scroll_to_achievement_id = 0;
ImGui::ScrollToItem(ImGuiScrollFlags_KeepVisibleEdgeY);
}
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 + Pin Icon --
const u32 text_color = ImGui::GetColorU32(UIStyle.SecondaryTextColor);
const u32 description_color = ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryTextColor));
ImRect title_bb(current_pos, current_pos + title_size);
if (!pin_text.empty())
{
const ImVec2 pin_pos = ImVec2(title_bb.Min.x, title_bb.Min.y + LayoutScale(1.0f));
RenderShadowedTextClipped(dl, UIStyle.Font, pin_font_size, 0.0f, pin_pos, pin_pos + pin_size, description_color,
pin_text, &pin_size, ImVec2(0.0f, 0.0f), 0.0, &title_bb);
title_bb.Min.x += pin_size.x + pin_spacing;
title_bb.Max.x += pin_size.x + pin_spacing;
}
// -- Title --
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.Max.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 --
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(current_pos.x + 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::RenderRectFilledInRangeH(dl, progress_bb, ImGui::GetColorU32(UIStyle.SecondaryColor), progress_bb.Min.x,
progress_bb.Min.x + (fraction * progress_bb.GetWidth()), 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);
current_pos.y += points_size.y + spacing;
if (clicked)
{
// Open non-measured achievements directly.
if (is_measured || is_pinned)
{
ChoiceDialogOptions options;
options.emplace_back(FSUI_ICONSTR(ICON_FA_LINK, "Open on RetroAchievements"), false);
options.emplace_back(is_pinned ? FSUI_ICONSTR(ICON_FA_THUMBTACK_SLASH, "Unpin from OSD") :
FSUI_ICONSTR(ICON_FA_THUMBTACK, "Pin to OSD"),
false);
OpenChoiceDialog(cheevo->title, false, std::move(options),
[achievement_id = cheevo->id, is_pinned](s32 index, const std::string& title, bool checked) {
switch (index)
{
case 0: // Open on RetroAchievements
OpenAchievementDetails(achievement_id);
break;
case 1: // Pin/Unpin
SetAchievementPinned(achievement_id, !is_pinned);
break;
default:
break;
}
});
}
else
{
OpenAchievementDetails(cheevo->id);
}
}
else if (hovered)
{
if (ImGui::IsKeyPressed(ImGuiKey_F4, false) || ImGui::IsKeyPressed(ImGuiKey_NavGamepadMenu, false))
{
if (is_measured)
SetAchievementPinned(cheevo->id, !Achievements::IsAchievementPinned(cheevo->id));
else
ShowToast(OSDMessageType::Error, {}, FSUI_STR("Only measured achievements can be pinned."));
}
}
}
void FullscreenUI::OpenAchievementDetails(u32 achievement_id)
{
const std::string url = fmt::format(fmt::runtime(ACHEIVEMENT_DETAILS_URL_TEMPLATE), achievement_id);
INFO_LOG("Opening achievement details: {}", url);
Host::OpenURL(url);
}
void FullscreenUI::SetAchievementPinned(u32 achievement_id, bool pinned)
{
DebugAssert(VideoThread::IsOnThread());
BeginTransition(DEFAULT_TRANSITION_TIME, [achievement_id, pinned]() {
const rc_client_achievement_t* achievement =
rc_client_get_achievement_info(Achievements::GetClient(), achievement_id);
if (!achievement)
return;
ShowToast(
OSDMessageType::Info, {},
pinned ?
FSUI_ICONSTR(ICON_FA_THUMBTACK, SmallString::from_format(FSUI_FSTR("{} pinned."), achievement->title)) :
FSUI_ICONSTR(ICON_FA_THUMBTACK_SLASH, SmallString::from_format(FSUI_FSTR("{} unpinned."), achievement->title)));
Achievements::SetAchievementPinned(achievement_id, pinned);
SortLockedAchievements();
s_achievements_locals.scroll_to_achievement_id = achievement_id;
});
}
void FullscreenUI::OpenLeaderboardsWindow()
{
if (!System::IsValid())
return;
if (const auto lock = Achievements::GetLock(); !Achievements::IsActive() || !Achievements::HasLeaderboards())
{
Host::AddIconOSDMessage(OSDMessageType::Info, "LeaderboardsUnavailable", Achievements::RA_LOGO_SVG_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;
}
const bool was_paused = System::IsPaused();
VideoThread::RunOnThread([was_paused]() {
Initialize();
if (!CanCurrentMainWindowStack() || !SetPendingMainWindowSwitch())
return;
PauseForMenuOpen(was_paused, false);
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
BeginTransition(SHORT_TRANSITION_TIME, &SwitchToLeaderboards);
});
if (!was_paused)
System::PauseSystem(true);
}
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(DarkerColor(UIStyle.BackgroundColor, 1.5f), 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,
true))
{
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, true))
{
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, true))
{
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);
u32 total_count = 0;
u32 last_visible_count = 0;
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++)
{
total_count++;
if (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()))
{
last_visible_count = total_count;
}
}
}
else
{
DrawLeaderboardLoadingIndicator(heading_height, display_size.y - heading_height, false);
}
}
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++)
{
total_count++;
if (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()))
{
last_visible_count = total_count;
}
}
}
if (!s_achievements_locals.has_fetched_all_leaderboard_entries)
{
// if showing the last few, fetch the next batch
if ((total_count - last_visible_count) <= 3 && !s_achievements_locals.leaderboard_fetch_handle)
FetchNextLeaderboardEntries();
// show the loading indicator in the bottom-right
if (s_achievements_locals.leaderboard_fetch_handle)
{
const ImRect& win_rc = ImGui::GetCurrentWindow()->InnerRect;
DrawLeaderboardLoadingIndicator(win_rc.Min.y, win_rc.GetHeight(), true);
}
}
}
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);
});
}
}
bool 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 false;
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);
}
return true;
}
void FullscreenUI::DrawLeaderboardLoadingIndicator(float pos_y, float avail_height, bool short_text)
{
static constexpr const float& font_weight = UIStyle.BoldFontWeight;
const float font_size = short_text ? UIStyle.MediumFontSize : UIStyle.MediumLargeFontSize;
const u32 color = ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text], 0.9f));
const std::string_view text = short_text ?
TRANSLATE_SV("Achievements", "Loading...") :
TRANSLATE_SV("Achievements", "Downloading leaderboard data, please wait...");
const ImVec2 text_size = UIStyle.Font->CalcTextSizeA(font_size, font_weight, FLT_MAX, 0.0f, IMSTR_START_END(text));
const float spinner_size = font_size;
const float spinner_spacing = short_text ? LayoutScale(12.0f) : LayoutScale(16.0f);
const float total_width = spinner_size + spinner_spacing + text_size.x;
const float display_width = ImGui::GetIO().DisplaySize.x;
// position in right side of screen if short text, center otherwise
const ImVec2 pos = short_text ? ImVec2((display_width - total_width) - LayoutScale(40.0f),
pos_y + avail_height - font_size - LayoutScale(25.0f)) :
ImVec2(ImFloor((display_width - total_width) * 0.5f),
ImFloor(pos_y + (avail_height - font_size) * 0.5f));
// for short text, draw a background box
if (short_text)
{
const ImVec2 padding = ImVec2(LayoutScale(16.0f), LayoutScale(12.0f));
ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(pos.x - padding.x, pos.y - padding.y),
ImVec2(pos.x + total_width + padding.x, pos.y + font_size + padding.y),
ImGui::GetColorU32(ModAlpha(UIStyle.PopupBackgroundColor, 0.5f)),
LayoutScale(LAYOUT_MENU_ITEM_BORDER_ROUNDING));
}
DrawSpinner(ImGui::GetWindowDrawList(), pos, color, spinner_size, LayoutScale(3.0f));
const ImVec2 text_pos = ImVec2(pos.x + spinner_size + spinner_spacing, pos.y);
RenderShadowedTextClipped(UIStyle.Font, font_size, UIStyle.BoldFontWeight, text_pos, text_pos + text_size, color,
text, &text_size);
}
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 int minutes = static_cast<int>(diff / MINUTE);
return TRANSLATE_PLURAL_SSTR("Achievements", "%n minutes ago", "Relative time", minutes);
}
if (diff < DAY)
{
const int hours = static_cast<int>(diff / HOUR);
return TRANSLATE_PLURAL_SSTR("Achievements", "%n hours ago", "Relative time", 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 int days = static_cast<int>(diff / DAY);
return TRANSLATE_PLURAL_SSTR("Achievements", "%n days ago", "Relative time", 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 int weeks = static_cast<int>(diff / WEEK);
return TRANSLATE_PLURAL_SSTR("Achievements", "%n weeks ago", "Relative time", 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)
{
VideoThread::RunOnThread([error_message = std::string(error_message)]() mutable {
ShowToast(OSDMessageType::Error, TRANSLATE_STR("Achievements", "Leaderboard download failed"),
std::move(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)
{
VideoThread::RunOnThread([error_message = std::string(error_message)]() mutable {
ShowToast(OSDMessageType::Error, TRANSLATE_STR("Achievements", "Leaderboard download failed"),
std::move(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__