From cce40a85dddc11e8d88f50679f3ba46fbb3d5537 Mon Sep 17 00:00:00 2001 From: Silent Date: Fri, 18 Jun 2021 23:40:11 +0200 Subject: [PATCH] Implemented Leaderboards UI --- src/frontend-common/cheevos.cpp | 137 +++++++++- src/frontend-common/cheevos.h | 17 +- src/frontend-common/fullscreen_ui.cpp | 348 +++++++++++++++++++++++++- src/frontend-common/fullscreen_ui.h | 1 + 4 files changed, 499 insertions(+), 4 deletions(-) diff --git a/src/frontend-common/cheevos.cpp b/src/frontend-common/cheevos.cpp index feec63480..34ac35a22 100644 --- a/src/frontend-common/cheevos.cpp +++ b/src/frontend-common/cheevos.cpp @@ -79,6 +79,9 @@ static bool s_has_rich_presence = false; static std::string s_rich_presence_string; static Common::Timer s_last_ping_time; +static u32 s_last_queried_lboard; +static std::optional> s_lboard_entries; + static u32 s_total_image_downloads; static u32 s_completed_image_downloads; static bool s_image_download_progress_active; @@ -745,7 +748,7 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo lboard.id = id; lboard.title = title; lboard.description = std::move(description); - lboard.format = format; + lboard.format = rc_parse_format(format); s_leaderboards.push_back(std::move(lboard)); const int err = rc_runtime_activate_lboard(&s_rcheevos_runtime, id, memaddr, nullptr, 0); @@ -798,6 +801,69 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo } } +static void GetLbInfoCallback(s32 status_code, const FrontendCommon::HTTPDownloader::Request::Data& data) +{ + rapidjson::Document doc; + if (!ParseResponseJSON("Get Leaderboard Info", status_code, data, doc)) + return; + + if (!doc.HasMember("LeaderboardData") || !doc["LeaderboardData"].IsObject()) + { + FormattedError("No leaderboard returned from server."); + return; + } + + // parse info + const auto lb_data(doc["LeaderboardData"].GetObject()); + if (!lb_data["LBID"].IsUint()) + { + FormattedError("Leaderboard data is missing leadeboard ID"); + return; + } + + const u32 lbid = lb_data["LBID"].GetUint(); + if (lbid != s_last_queried_lboard) + { + // User has already requested another leaderboard, drop this data + return; + } + + if (lb_data.HasMember("Entries") && lb_data["Entries"].IsArray()) + { + const Leaderboard* leaderboard = GetLeaderboardByID(lbid); + if (leaderboard == nullptr) + { + Log_ErrorPrintf("Attempting to list unknown leaderboard %u", lbid); + return; + } + + std::vector entries; + + const auto lb_entries(lb_data["Entries"].GetArray()); + for (const auto& entry : lb_entries) + { + if (!entry.HasMember("User") || !entry["User"].IsString() || !entry.HasMember("Score") || + !entry["Score"].IsNumber() || !entry.HasMember("Rank") || !entry["Rank"].IsNumber()) + { + continue; + } + + char score[128]; + rc_format_value(score, sizeof(score), entry["Score"].GetInt(), leaderboard->format); + + LeaderboardEntry lbe; + lbe.user = entry["User"].GetString(); + lbe.rank = entry["Rank"].GetUint(); + lbe.formatted_score = score; + lbe.is_self = lbe.user == s_username; + + entries.push_back(std::move(lbe)); + } + + s_lboard_entries = std::move(entries); + } +} + static void GetPatches(u32 game_id) { char url[512]; @@ -1074,6 +1140,72 @@ u32 GetCurrentPointsForGame() return points; } +bool EnumerateLeaderboards(std::function callback) +{ + for (const Leaderboard& lboard : s_leaderboards) + { + if (!callback(lboard)) + return false; + } + + return true; +} + +std::optional TryEnumerateLeaderboardEntries(u32 id, std::function callback) +{ + if (id == s_last_queried_lboard) + { + if (s_lboard_entries) + { + for (const LeaderboardEntry& entry : *s_lboard_entries) + { + if (!callback(entry)) + return false; + } + return true; + } + } + else + { + // TODO: Add paging? For now, stick to defaults + char url[512]; + + size_t written = 0; + rc_url_build_dorequest(url, sizeof(url), &written, "lbinfo", s_username.c_str()); + rc_url_append_unum(url, sizeof(url), &written, "i", id); + rc_url_append_unum(url, sizeof(url), &written, "c", + 15); // Just over what a single page can store, should be a reasonable amount for now + // rc_url_append_unum(url, sizeof(url), &written, "o", 0); + + s_last_queried_lboard = id; + s_lboard_entries.reset(); + s_http_downloader->CreateRequest(url, GetLbInfoCallback); + } + + return std::nullopt; +} + +const Leaderboard* GetLeaderboardByID(u32 id) +{ + for (const Leaderboard& lb : s_leaderboards) + { + if (lb.id == id) + return &lb; + } + + return nullptr; +} + +u32 GetLeaderboardCount() +{ + return static_cast(s_leaderboards.size()); +} + +bool IsLeaderboardTimeType(const Leaderboard& leaderboard) +{ + return leaderboard.format != RC_FORMAT_SCORE && leaderboard.format != RC_FORMAT_VALUE; +} + void ActivateLockedAchievements() { for (Achievement& cheevo : s_achievements) @@ -1124,7 +1256,8 @@ static void UnlockAchievementCallback(s32 status_code, const FrontendCommon::HTT static void SubmitLeaderboardCallback(s32 status_code, const FrontendCommon::HTTPDownloader::Request::Data& data) { - // Do nothing (for now?) + // Force the next leaderboard query to repopulate everything, just in case the user wants to see their new score + s_last_queried_lboard = 0; } void UnlockAchievement(u32 achievement_id, bool add_notification /* = true*/) diff --git a/src/frontend-common/cheevos.h b/src/frontend-common/cheevos.h index b7b033d32..69d9941b4 100644 --- a/src/frontend-common/cheevos.h +++ b/src/frontend-common/cheevos.h @@ -1,6 +1,7 @@ #pragma once #include "core/types.h" #include +#include #include class CDImage; @@ -33,7 +34,15 @@ struct Leaderboard u32 id; std::string title; std::string description; - std::string format; + int format; +}; + +struct LeaderboardEntry +{ + std::string user; + std::string formatted_score; + u32 rank; + bool is_self; }; extern bool g_active; @@ -98,6 +107,12 @@ u32 GetAchievementCount(); u32 GetMaximumPointsForGame(); u32 GetCurrentPointsForGame(); +bool EnumerateLeaderboards(std::function callback); +std::optional TryEnumerateLeaderboardEntries(u32 id, std::function callback); +const Leaderboard* GetLeaderboardByID(u32 id); +u32 GetLeaderboardCount(); +bool IsLeaderboardTimeType(const Leaderboard& leaderboard); + void UnlockAchievement(u32 achievement_id, bool add_notification = true); void SubmitLeaderboard(u32 leaderboard_id, int value); diff --git a/src/frontend-common/fullscreen_ui.cpp b/src/frontend-common/fullscreen_ui.cpp index cfdb4a6ed..33bb305d6 100644 --- a/src/frontend-common/fullscreen_ui.cpp +++ b/src/frontend-common/fullscreen_ui.cpp @@ -85,6 +85,7 @@ static void ReturnToMainWindow(); static void DrawLandingWindow(); static void DrawQuickMenu(MainWindowType type); static void DrawAchievementWindow(); +static void DrawLeaderboardsWindow(); static void DrawDebugMenu(); static void DrawStatsOverlay(); static void DrawOSDMessages(); @@ -112,6 +113,7 @@ static bool s_quick_menu_was_open = false; static bool s_was_paused_on_quick_menu_open = false; static bool s_about_window_open = false; static u32 s_close_button_state = 0; +static std::optional s_open_leaderboard_id; ////////////////////////////////////////////////////////////////////////// // Resources @@ -343,6 +345,9 @@ void Render() case MainWindowType::Achievements: DrawAchievementWindow(); break; + case MainWindowType::Leaderboards: + DrawLeaderboardsWindow(); + break; default: break; } @@ -2531,7 +2536,7 @@ void DrawQuickMenu(MainWindowType type) if (BeginFullscreenWindow(window_pos, window_size, "pause_menu", ImVec4(0.0f, 0.0f, 0.0f, 0.0f), 0.0f, 10.0f, ImGuiWindowFlags_NoBackground)) { - BeginMenuButtons(12, 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING, + BeginMenuButtons(13, 1.0f, ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING, ImGuiFullscreen::LAYOUT_MENU_BUTTON_Y_PADDING, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); @@ -2550,8 +2555,16 @@ void DrawQuickMenu(MainWindowType type) if (ActiveButton(ICON_FA_TROPHY " Achievements", false, achievements_enabled)) s_current_main_window = MainWindowType::Achievements; + const bool leaderboards_enabled = Cheevos::HasActiveGame() && (Cheevos::GetLeaderboardCount() > 0); + if (ActiveButton(ICON_FA_STOPWATCH " Leaderboards", false, leaderboards_enabled)) + { + s_current_main_window = MainWindowType::Leaderboards; + s_open_leaderboard_id.reset(); + } + #else ActiveButton(ICON_FA_TROPHY " Achievements", false, false); + ActiveButton(ICON_FA_STOPWATCH " Leaderboards", false, false); #endif if (ActiveButton(ICON_FA_CAMERA " Save Screenshot", false)) @@ -4259,9 +4272,342 @@ void DrawAchievementWindow() EndFullscreenWindow(); } +static void DrawLeaderboardListEntry(const Cheevos::Leaderboard& lboard) +{ + static constexpr float alpha = 0.8f; + + TinyString id_str; + id_str.Format("%u", lboard.id); + + ImRect bb; + bool visible, hovered; + bool pressed = + MenuButtonFrame(id_str, true, LAYOUT_MENU_BUTTON_HEIGHT, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + if (!visible) + return; + + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + const float text_start_x = bb.Min.x + LayoutScale(15.0f); + const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + const ImRect summary_bb(ImVec2(text_start_x, midpoint), bb.Max); + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, lboard.title.c_str(), lboard.title.c_str() + lboard.title.size(), + nullptr, ImVec2(0.0f, 0.0f), &title_bb); + ImGui::PopFont(); + + if (!lboard.description.empty()) + { + ImGui::PushFont(g_medium_font); + ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, lboard.description.c_str(), + lboard.description.c_str() + lboard.description.size(), nullptr, ImVec2(0.0f, 0.0f), + &summary_bb); + ImGui::PopFont(); + } + + if (pressed) + { + s_open_leaderboard_id = lboard.id; + } +} + +static void DrawLeaderboardEntry(const Cheevos::LeaderboardEntry& lbEntry, float rank_column_width, + float name_column_width, float column_spacing) +{ + static constexpr float alpha = 0.8f; + + ImRect bb; + bool visible, hovered; + bool pressed = MenuButtonFrame(lbEntry.user.c_str(), true, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, + &bb.Min, &bb.Max, 0, alpha); + if (!visible) + return; + + const float spacing = LayoutScale(10.0f); + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + float text_start_x = bb.Min.x + LayoutScale(15.0f); + SmallString text; + + text.Format("%u", lbEntry.rank); + + ImGui::PushFont(g_large_font); + if (lbEntry.is_self) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(255, 242, 0, 255)); + } + + const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(), + nullptr, ImVec2(0.0f, 0.0f), &rank_bb); + text_start_x += rank_column_width + column_spacing; + + const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, lbEntry.user.c_str(), lbEntry.user.c_str() + lbEntry.user.size(), + nullptr, ImVec2(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)); + ImGui::RenderTextClipped(score_bb.Min, score_bb.Max, lbEntry.formatted_score.c_str(), + lbEntry.formatted_score.c_str() + lbEntry.formatted_score.size(), nullptr, + ImVec2(0.0f, 0.0f), &score_bb); + + if (lbEntry.is_self) + { + ImGui::PopStyleColor(); + } + + ImGui::PopFont(); + + // This API DOES list the submission date/time, but is it relevant? +#if 0 + if (!cheevo.locked) + { + ImGui::PushFont(g_medium_font); + + const ImRect time_bb(ImVec2(text_start_x, bb.Min.y), + ImVec2(bb.Max.x, bb.Min.y + g_medium_font->FontSize + LayoutScale(4.0f))); + text.Format("Unlocked 21 Feb, 2019 @ 3:14am"); + ImGui::RenderTextClipped(time_bb.Min, time_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(), + nullptr, ImVec2(1.0f, 0.0f), &time_bb); + ImGui::PopFont(); + } +#endif + + if (pressed) + { + // Anything? + } +} + +void DrawLeaderboardsWindow() +{ + static constexpr float alpha = 0.8f; + static constexpr float heading_height_unscaled = 110.0f; + + ImGui::SetNextWindowBgAlpha(alpha); + + const bool is_leaderboard_open = s_open_leaderboard_id.has_value(); + bool close_leaderboard_on_exit = false; + + const ImVec4 background(0.13f, 0.13f, 0.13f, alpha); + const ImVec2 display_size(ImGui::GetIO().DisplaySize); + const float padding = LayoutScale(10.0f); + const float spacing = LayoutScale(10.0f); + float heading_height = LayoutScale(heading_height_unscaled); + if (is_leaderboard_open) + { + // Add space for a legend - spacing + 1 line of text + spacing + line + heading_height += spacing + LayoutScale(LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing; + } + + const float rank_column_width = + g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits::max(), -1.0f, "99999").x; + const float name_column_width = + g_large_font + ->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits::max(), -1.0f, "WWWWWWWWWWWWWWWWWWWW") + .x; + const float column_spacing = spacing * 2.0f; + + if (BeginFullscreenWindow( + ImVec2(0.0f, 0.0f), ImVec2(display_size.x, heading_height), "leaderboards_heading", background, 0.0f, 0.0f, + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse)) + { + ImRect bb; + bool visible, hovered; + bool pressed = MenuButtonFrame("leaderboards_heading", false, heading_height_unscaled, &visible, &hovered, &bb.Min, + &bb.Max, 0, alpha); + UNREFERENCED_VARIABLE(pressed); + + if (visible) + { + const float image_height = LayoutScale(85.0f); + + const ImVec2 icon_min(bb.Min + ImVec2(padding, padding)); + const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height)); + + const std::string& icon_path = Cheevos::GetGameIcon(); + if (!icon_path.empty()) + { + HostDisplayTexture* badge = GetCachedTexture(icon_path); + if (badge) + { + ImGui::GetWindowDrawList()->AddImage(badge->GetHandle(), icon_min, icon_max, ImVec2(0.0f, 0.0f), + ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255)); + } + } + + float left = bb.Min.x + padding + image_height + spacing; + float right = bb.Max.x - padding; + float top = bb.Min.y + padding; + ImDrawList* dl = ImGui::GetWindowDrawList(); + SmallString text; + ImVec2 text_size; + + const u32 leaderboard_count = Cheevos::GetLeaderboardCount(); + + if (!is_leaderboard_open) + { + if (FloatingButton(ICON_FA_WINDOW_CLOSE, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) || + WantsToCloseMenu()) + { + ReturnToMainWindow(); + } + } + else + { + if (FloatingButton(ICON_FA_CARET_SQUARE_LEFT, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) || + WantsToCloseMenu()) + { + close_leaderboard_on_exit = true; + } + } + + const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize)); + text.Assign(Cheevos::GetGameTitle()); + + top += g_large_font->FontSize + spacing; + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(), + nullptr, ImVec2(0.0f, 0.0f), &title_bb); + ImGui::PopFont(); + + if (s_open_leaderboard_id) + { + const Cheevos::Leaderboard* lboard = Cheevos::GetLeaderboardByID(*s_open_leaderboard_id); + if (lboard != nullptr) + { + const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize)); + text.Assign(lboard->title); + + top += g_large_font->FontSize + spacing; + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped(subtitle_bb.Min, subtitle_bb.Max, text.GetCharArray(), + text.GetCharArray() + text.GetLength(), nullptr, ImVec2(0.0f, 0.0f), &subtitle_bb); + ImGui::PopFont(); + + text.Assign(lboard->description); + } + else + { + text.Clear(); + } + } + else + { + text.Format("This game has %u leaderboards.", leaderboard_count); + } + + const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize)); + top += g_medium_font->FontSize + spacing; + + ImGui::PushFont(g_medium_font); + ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, text.GetCharArray(), + text.GetCharArray() + text.GetLength(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb); + ImGui::PopFont(); + } + + if (is_leaderboard_open) + { + pressed = MenuButtonFrame("legend", false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min, + &bb.Max, 0, alpha); + + UNREFERENCED_VARIABLE(pressed); + + if (visible) + { + const Cheevos::Leaderboard* lboard = Cheevos::GetLeaderboardByID(*s_open_leaderboard_id); + + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + float text_start_x = bb.Min.x + LayoutScale(15.0f) + padding; + + ImGui::PushFont(g_large_font); + + const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, "Rank", nullptr, nullptr, ImVec2(0.0f, 0.0f), &rank_bb); + text_start_x += rank_column_width + column_spacing; + + const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, "Name", nullptr, nullptr, ImVec2(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)); + ImGui::RenderTextClipped(score_bb.Min, score_bb.Max, + lboard != nullptr && Cheevos::IsLeaderboardTimeType(*lboard) ? "Time" : "Score", + nullptr, nullptr, ImVec2(0.0f, 0.0f), &score_bb); + + ImGui::PopFont(); + + const float line_thickness = LayoutScale(1.0f); + const float line_padding = LayoutScale(5.0f); + const ImVec2 line_start(bb.Min.x, bb.Min.y + g_large_font->FontSize + line_padding); + const ImVec2 line_end(bb.Max.x, line_start.y); + ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled), + line_thickness); + } + } + } + EndFullscreenWindow(); + + ImGui::SetNextWindowBgAlpha(alpha); + + if (!is_leaderboard_open) + { + if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height), + "leaderboards", background, 0.0f, 0.0f, 0)) + { + BeginMenuButtons(); + + Cheevos::EnumerateLeaderboards([](const Cheevos::Leaderboard& lboard) -> bool { + DrawLeaderboardListEntry(lboard); + + return true; + }); + + EndMenuButtons(); + } + EndFullscreenWindow(); + } + else + { + if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height), + "leaderboard", background, 0.0f, 0.0f, 0)) + { + BeginMenuButtons(); + + const auto result = Cheevos::TryEnumerateLeaderboardEntries( + *s_open_leaderboard_id, + [rank_column_width, name_column_width, column_spacing](const Cheevos::LeaderboardEntry& lbEntry) -> bool { + DrawLeaderboardEntry(lbEntry, rank_column_width, name_column_width, column_spacing); + return true; + }); + + if (!result.has_value()) + { + ImGui::PushFont(g_large_font); + + const ImVec2 pos_min(0.0f, heading_height); + const ImVec2 pos_max(display_size.x, display_size.y); + ImGui::RenderTextClipped(pos_min, pos_max, "Downloading leaderboard data, please wait...", nullptr, nullptr, + ImVec2(0.5f, 0.5f)); + + ImGui::PopFont(); + } + + EndMenuButtons(); + } + EndFullscreenWindow(); + } + + if (close_leaderboard_on_exit) + s_open_leaderboard_id.reset(); +} + #else void DrawAchievementWindow() {} +void DrawLeaderboardsWindow() {} #endif diff --git a/src/frontend-common/fullscreen_ui.h b/src/frontend-common/fullscreen_ui.h index 7b6ec4269..430be1d76 100644 --- a/src/frontend-common/fullscreen_ui.h +++ b/src/frontend-common/fullscreen_ui.h @@ -19,6 +19,7 @@ enum class MainWindowType Settings, QuickMenu, Achievements, + Leaderboards, }; enum class SettingsPage