diff --git a/CMakeLists.txt b/CMakeLists.txt index ff6334a76..dd658157a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -712,6 +712,7 @@ if(NOT LIBRETRO) core/deps/imgui/imgui.cpp core/deps/imgui/imgui_demo.cpp core/deps/imgui/imgui_draw.cpp + core/deps/imgui/imgui_stdlib.cpp core/deps/imgui/imgui_tables.cpp core/deps/imgui/imgui_widgets.cpp) @@ -1012,7 +1013,9 @@ cmrc_add_resources(flycast-resources fonts/printer_kanji24x24.bin.zip) if(NOT LIBRETRO) - cmrc_add_resources(flycast-resources fonts/Roboto-Medium.ttf.zip) + cmrc_add_resources(flycast-resources + fonts/Roboto-Medium.ttf.zip + fonts/Roboto-Regular.ttf.zip) if(ANDROID) cmrc_add_resources(flycast-resources WHENCE resources @@ -1292,6 +1295,7 @@ if(NOT LIBRETRO) core/rend/imgui_driver.h core/rend/gui.cpp core/rend/gui.h + core/rend/gui_achievements.cpp core/rend/gui_android.cpp core/rend/gui_android.h core/rend/gui_chat.h diff --git a/core/achievements/achievements.cpp b/core/achievements/achievements.cpp index 028c93ef1..6d544e184 100644 --- a/core/achievements/achievements.cpp +++ b/core/achievements/achievements.cpp @@ -20,9 +20,10 @@ #include "achievements.h" #include "serialize.h" #ifdef USE_RACHIEVEMENTS +#include "oslib/directory.h" #include "oslib/http_client.h" #include "hw/sh4/sh4_mem.h" -#include "rend/gui.h" +#include "rend/gui_achievements.h" #include "imgread/common.h" #include "cfg/option.h" #include "oslib/oslib.h" @@ -31,7 +32,11 @@ #include #include #include -#include +#include +#include +#include +#include +#include namespace achievements { @@ -43,7 +48,8 @@ public: ~Achievements(); bool init(); void term(); - void login(const char *username, const char *password); + std::future login(const char *username, const char *password); + void logout(); bool isLoggedOn() const { return loggedOn; } void serialize(Serializer& ser); void deserialize(Deserializer& deser); @@ -54,31 +60,42 @@ private: bool createClient(); std::string getGameHash(); void loadGame(); + void gameLoaded(int result, const char *errorMessage); void unloadGame(); void pauseGame(); void resumeGame(); + void loadCache(); + std::string getOrDownloadImage(const char *url); + void diskChange(); static void clientLoginWithTokenCallback(int result, const char *error_message, rc_client_t *client, void *userdata); static void clientLoginWithPasswordCallback(int result, const char *error_message, rc_client_t *client, void *userdata); + void authenticationSuccess(const rc_client_user_t *user); static void clientMessageCallback(const char *message, const rc_client_t *client); static u32 clientReadMemory(u32 address, u8 *buffer, u32 num_bytes, rc_client_t *client); static void clientServerCall(const rc_api_request_t *request, rc_client_server_callback_t callback, void *callback_data, rc_client_t *client); static void clientEventHandler(const rc_client_event_t *event, rc_client_t *client); - static void clientLoadGameCallback(int result, const char *error_message, rc_client_t *client, void *userdata); void handleResetEvent(const rc_client_event_t *event); void handleUnlockEvent(const rc_client_event_t *event); void handleAchievementChallengeIndicatorShowEvent(const rc_client_event_t *event); void handleAchievementChallengeIndicatorHideEvent(const rc_client_event_t *event); + void handleGameCompleted(const rc_client_event_t *event); + void handleShowAchievementProgress(const rc_client_event_t *event); + void handleHideAchievementProgress(const rc_client_event_t *event); + void handleUpdateAchievementProgress(const rc_client_event_t *event); static void emuEventCallback(Event event, void *arg); rc_client_t *rc_client = nullptr; bool loggedOn = false; + std::atomic_bool loadingGame {}; bool active = false; bool paused = false; std::string lastError; + std::future asyncServerCall; cResetEvent resetEvent; - std::thread thread; + std::string cachePath; + std::unordered_map cacheMap; }; bool init() @@ -91,9 +108,14 @@ void term() Achievements::Instance().term(); } -void login(const char *username, const char *password) +std::future login(const char *username, const char *password) { - Achievements::Instance().login(username, password); + return Achievements::Instance().login(username, password); +} + +void logout() +{ + Achievements::Instance().logout(); } bool isLoggedOn() @@ -124,6 +146,7 @@ Achievements::Achievements() EventManager::listen(Event::Terminate, emuEventCallback, this); EventManager::listen(Event::Pause, emuEventCallback, this); EventManager::listen(Event::Resume, emuEventCallback, this); + EventManager::listen(Event::DiskChange, emuEventCallback, this); } Achievements::~Achievements() @@ -132,6 +155,7 @@ Achievements::~Achievements() EventManager::unlisten(Event::Terminate, emuEventCallback, this); EventManager::unlisten(Event::Pause, emuEventCallback, this); EventManager::unlisten(Event::Resume, emuEventCallback, this); + EventManager::unlisten(Event::DiskChange, emuEventCallback, this); term(); } @@ -150,6 +174,7 @@ bool Achievements::init() //rc_client_set_encore_mode_enabled(rc_client, 0); //rc_client_set_unofficial_enabled(rc_client, 0); //rc_client_set_spectator_mode_enabled(rc_client, 0); + loadCache(); if (!config::AchievementsUserName.get().empty() && !config::AchievementsToken.get().empty()) { @@ -182,6 +207,58 @@ bool Achievements::createClient() return true; } +void Achievements::loadCache() +{ + cachePath = get_writable_data_path("achievements/"); + flycast::mkdir(cachePath.c_str(), 0755); + DIR *dir = flycast::opendir(cachePath.c_str()); + if (dir != nullptr) + { + while (true) + { + dirent *direntry = flycast::readdir(dir); + if (direntry == nullptr) + break; + std::string name = direntry->d_name; + if (name == "." || name == "..") + continue; + std::string s = get_file_basename(name); + u64 v = strtoull(s.c_str(), nullptr, 16); + cacheMap[v] = name; + } + } +} + +std::string Achievements::getOrDownloadImage(const char *url) +{ + u64 hash = XXH64(url, strlen(url), 13); + auto it = cacheMap.find(hash); + if (it != cacheMap.end()) + return cachePath + it->second; + std::vector content; + std::string content_type; + int rc = http::get(url, content, content_type); + if (!http::success(rc)) + return {}; + std::stringstream stream; + stream << std::hex << hash; + if (content_type == "image/jpeg") + stream << ".jpg"; + else + stream << ".png"; + std::string path = cachePath + stream.str(); + FILE *f = nowide::fopen(path.c_str(), "wb"); + if (f == nullptr) { + WARN_LOG(COMMON, "Can't save image to %s", path.c_str()); + return {}; + } + fwrite(content.data(), 1, content.size(), f); + fclose(f); + cacheMap[hash] = stream.str(); + DEBUG_LOG(COMMON, "RA: downloaded %s to %s", url, path.c_str()); + return path; +} + void Achievements::term() { if (rc_client == nullptr) @@ -191,48 +268,56 @@ void Achievements::term() rc_client = nullptr; } -static inline void authenticationSuccessMsg() +void Achievements::authenticationSuccess(const rc_client_user_t *user) { NOTICE_LOG(COMMON, "RA Login successful: token %s", config::AchievementsToken.get().c_str()); - std::string msg = "User " + config::AchievementsUserName.get() + " authenticated to RetroAchievements"; - gui_display_notification(msg.c_str(), 5000); + char url[512]; + int rc = rc_client_user_get_image_url(user, url, sizeof(url)); + if (rc == RC_OK) + { + std::string image = getOrDownloadImage(url); + std::string text = "User " + config::AchievementsUserName.get() + " authenticated"; + notifier.notify(Notification::Login, image, text); + } + loggedOn = true; + if (!settings.content.fileName.empty()) // TODO better test? + loadGame(); } -void Achievements::clientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client, - void* userdata) +void Achievements::clientLoginWithTokenCallback(int result, const char *error_message, rc_client_t *client, + void *userdata) { + Achievements *achievements = (Achievements *)rc_client_get_userdata(client); if (result != RC_OK) { WARN_LOG(COMMON, "RA Login failed: %s", error_message); - gui_display_notification(error_message, 10000); + notifier.notify(Notification::Login, "", "RetroAchievements authentication failed", error_message); return; } - - authenticationSuccessMsg(); - Achievements *achievements = (Achievements *)rc_client_get_userdata(client); - achievements->loggedOn = true; + achievements->authenticationSuccess(rc_client_get_user_info(client)); } -void Achievements::login(const char* username, const char* password) +std::future Achievements::login(const char* username, const char* password) { init(); - rc_client_begin_login_with_password(rc_client, username, password, clientLoginWithPasswordCallback, nullptr); - - if (!loggedOn) - throw FlycastException(lastError); + std::promise *promise = new std::promise(); + rc_client_begin_login_with_password(rc_client, username, password, clientLoginWithPasswordCallback, promise); + return promise->get_future(); } -void Achievements::clientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client, - void* userdata) +void Achievements::clientLoginWithPasswordCallback(int result, const char *error_message, rc_client_t *client, + void *userdata) { Achievements *achievements = (Achievements *)rc_client_get_userdata(client); - + std::promise *promise = (std::promise *)userdata; if (result != RC_OK) { - achievements->lastError = rc_error_str(result); + std::string errorMsg = rc_error_str(result); if (error_message != nullptr) - achievements->lastError += ": " + std::string(error_message); - WARN_LOG(COMMON, "RA Login failed: %s", achievements->lastError.c_str()); + errorMsg += ": " + std::string(error_message); + promise->set_exception(std::make_exception_ptr(FlycastException(errorMsg))); + delete promise; + WARN_LOG(COMMON, "RA Login failed: %s", errorMsg.c_str()); return; } @@ -240,15 +325,27 @@ void Achievements::clientLoginWithPasswordCallback(int result, const char* error if (!user || !user->token) { WARN_LOG(COMMON, "RA: rc_client_get_user_info() returned NULL"); + promise->set_exception(std::make_exception_ptr(FlycastException("No user token returned"))); + delete promise; return; } // Store token in config config::AchievementsToken = user->token; SaveSettings(); - achievements->loggedOn = true; + achievements->authenticationSuccess(user); + promise->set_value(); + delete promise; +} - authenticationSuccessMsg(); +void Achievements::logout() +{ + unloadGame(); + rc_client_logout(rc_client); + // Reset token in config + config::AchievementsToken = ""; + SaveSettings(); + loggedOn = false; } void Achievements::clientMessageCallback(const char* message, const rc_client_t* client) @@ -282,27 +379,50 @@ u32 Achievements::clientReadMemory(u32 address, u8* buffer, u32 num_bytes, rc_cl return num_bytes; } -void Achievements::clientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback, - void* callback_data, rc_client_t* client) +void Achievements::clientServerCall(const rc_api_request_t *request, rc_client_server_callback_t callback, + void *callback_data, rc_client_t *client) { - int rc; - std::vector reply; + Achievements *achievements = (Achievements *)rc_client_get_userdata(client); + std::string url {request->url}; + std::string payload; if (request->post_data != nullptr) - rc = http::post(request->url, request->post_data, reply); - else - rc = http::get(request->url, reply); - rc_api_server_response_t rr; - rr.http_status_code = rc; // TODO RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR if connection fails? - rr.body_length = reply.size(); - rr.body = (const char *)reply.data(); - callback(&rr, callback_data); + payload = request->post_data; + std::string contentType; + if (request->content_type != nullptr) + contentType = request->content_type; + const auto& callServer = [url, contentType, payload, callback, callback_data]() + { + ThreadName _("Flycast-RA"); + int rc; + std::vector reply; + if (!payload.empty()) + rc = http::post(url, payload.c_str(), contentType.empty() ? nullptr : contentType.c_str(), reply); + else + rc = http::get(url, reply); + rc_api_server_response_t rr; + rr.http_status_code = rc; // TODO RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR if connection fails? + rr.body_length = reply.size(); + rr.body = (const char *)reply.data(); + callback(&rr, callback_data); + }; + if (achievements->asyncServerCall.valid()) + { + if (achievements->asyncServerCall.wait_for(std::chrono::seconds::zero()) == std::future_status::timeout) + { + INFO_LOG(COMMON, "Async server call already in progress"); + // process synchronously + callServer(); + return; + } + achievements->asyncServerCall.get(); + } + achievements->asyncServerCall = std::async(std::launch::async, callServer); } static void handleServerError(const rc_client_server_error_t* error) { - char buffer[256]; - snprintf(buffer, sizeof(buffer), "%s: %s", error->api, error->error_message); - gui_display_notification(buffer, 5000); + WARN_LOG(COMMON, "RA server error: %s - %s", error->api, error->error_message); + notifier.notify(Notification::Error, "", error->api, error->error_message); } void Achievements::clientEventHandler(const rc_client_event_t* event, rc_client_t* client) @@ -326,13 +446,26 @@ void Achievements::clientEventHandler(const rc_client_event_t* event, rc_client_ achievements->handleAchievementChallengeIndicatorHideEvent(event); break; + case RC_CLIENT_EVENT_GAME_COMPLETED: + achievements->handleGameCompleted(event); + break; + case RC_CLIENT_EVENT_SERVER_ERROR: handleServerError(event->server_error); break; + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW: + achievements->handleShowAchievementProgress(event); + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE: + achievements->handleHideAchievementProgress(event); + break; + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE: + achievements->handleUpdateAchievementProgress(event); + break; + /* TODO - case RC_CLIENT_EVENT_GAME_COMPLETED: case RC_CLIENT_EVENT_LEADERBOARD_STARTED: case RC_CLIENT_EVENT_LEADERBOARD_FAILED: case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED: @@ -340,10 +473,6 @@ void Achievements::clientEventHandler(const rc_client_event_t* event, rc_client_ case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_SHOW: case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE: case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_UPDATE: - case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW: - case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE: - case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE: - case RC_CLIENT_EVENT_SERVER_ERROR: case RC_CLIENT_EVENT_DISCONNECTED: case RC_CLIENT_EVENT_RECONNECTED: */ @@ -366,20 +495,60 @@ void Achievements::handleUnlockEvent(const rc_client_event_t *event) INFO_LOG(COMMON, "RA: Achievement %s (%u) for game %s unlocked", cheevo->title, cheevo->id, settings.content.title.c_str()); - std::string msg = "Achievement " + std::string(cheevo->title) + " unlocked!"; - gui_display_notification(msg.c_str(), 10000); + char url[512]; + int rc = rc_client_achievement_get_image_url(cheevo, cheevo->state, url, sizeof(url)); + if (rc == RC_OK) + { + std::string image = getOrDownloadImage(url); + std::string text = "Achievement " + std::string(cheevo->title) + " unlocked!"; + notifier.notify(Notification::Login, image, text, cheevo->description); + } } void Achievements::handleAchievementChallengeIndicatorShowEvent(const rc_client_event_t *event) { // TODO there might be more than one. Need to display an icon. - std::string msg = "Challenge: " + std::string(event->achievement->title); - gui_display_notification(msg.c_str(), 10000); + //std::string msg = "Challenge: " + std::string(event->achievement->title); + //gui_display_notification(msg.c_str(), 10000); + INFO_LOG(COMMON, "RA: Challenge: %s", event->achievement->title); } void Achievements::handleAchievementChallengeIndicatorHideEvent(const rc_client_event_t *event) { - gui_display_notification("", 1); + // TODO +} + +void Achievements::handleGameCompleted(const rc_client_event_t *event) +{ + const rc_client_game_t* game = rc_client_get_game_info(rc_client); + std::string image; + char url[128]; + if (rc_client_game_get_image_url(game, url, sizeof(url)) == RC_OK) + image = getOrDownloadImage(url); + std::string text1 = (rc_client_get_hardcore_enabled(rc_client) ? "Mastered " : "Completed ") + std::string(game->title); + rc_client_user_game_summary_t summary; + rc_client_get_user_game_summary(rc_client, &summary); + std::stringstream ss; + ss << summary.num_unlocked_achievements << " achievements, " << summary.points_unlocked << " points"; + std::string text3 = rc_client_get_user_info(rc_client)->display_name; + notifier.notify(Notification::Mastery, image, text1, ss.str(), text3); +} + +void Achievements::handleShowAchievementProgress(const rc_client_event_t *event) +{ + handleUpdateAchievementProgress(event); +} +void Achievements::handleHideAchievementProgress(const rc_client_event_t *event) +{ + notifier.notify(Notification::Progress, "", ""); +} +void Achievements::handleUpdateAchievementProgress(const rc_client_event_t *event) +{ + char url[128]; + std::string image; + if (rc_client_achievement_get_image_url(event->achievement, RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE, url, sizeof(url)) == RC_OK) + image = getOrDownloadImage(url); + notifier.notify(Notification::Progress, image, event->achievement->measured_progress); } static bool add150; @@ -419,6 +588,7 @@ static size_t cdreader_read_sector(void* track_handle, u32 sector, void* buffer, { //DEBUG_LOG(COMMON, "RA: cdreader_read_sector track %zd sec %d num %zd", reinterpret_cast(track_handle), sector, requested_bytes); if (requested_bytes == 2048) + // add 150 sectors to FAD corresponding to files add150 = true; if (add150) // FIXME how to get rid of this? sector += 150; @@ -457,6 +627,9 @@ std::string Achievements::getGameHash() { if (settings.platform.isConsole()) { + const u32 diskType = libGDR_GetDiscType(); + if (diskType == NoDisk || diskType == Open) + return ""; add150 = false; rc_hash_cdreader hooks = { cdreader_open_track, @@ -485,12 +658,27 @@ void Achievements::pauseGame() { paused = true; if (active) + { resetEvent.Reset(); + if (asyncServerCall.valid()) + asyncServerCall.get(); + asyncServerCall = std::async(std::launch::async, [this]() { + while (paused) + { + resetEvent.Wait(1000); + if (paused) + rc_client_idle(rc_client); + } + }); + } } void Achievements::resumeGame() { paused = false; + resetEvent.Set(); + if (asyncServerCall.valid()) + asyncServerCall.get(); if (config::EnableAchievements) loadGame(); else @@ -510,11 +698,14 @@ void Achievements::emuEventCallback(Event event, void *arg) instance->unloadGame(); break; case Event::VBlank: - instance->resetEvent.Set(); + rc_client_do_frame(instance->rc_client); break; case Event::Pause: instance->pauseGame(); break; + case Event::DiskChange: + instance->diskChange(); + break; default: break; } @@ -522,34 +713,67 @@ void Achievements::emuEventCallback(Event event, void *arg) void Achievements::loadGame() { + if (loadingGame.exchange(true)) + // already loading + return; if (active) + { // already loaded + loadingGame = false; return; - if (!init() || !isLoggedOn()) + } + if (!init() || !isLoggedOn()) { + if (!isLoggedOn()) + WARN_LOG(COMMON, "Not logged on. Not loading game yet"); + loadingGame = false; return; + } std::string gameHash = getGameHash(); if (!gameHash.empty()) - rc_client_begin_load_game(rc_client, gameHash.c_str(), clientLoadGameCallback, nullptr); - if (!active) - return; - thread = std::thread([this] { - ThreadName _("Flycast-RA"); - while (active) - { - if (!resetEvent.Wait(1000)) { - if (paused) { - DEBUG_LOG(COMMON, "RA: rc_client_idle"); - rc_client_idle(rc_client); - } - else { - INFO_LOG(COMMON, "RA: timeout on event and not paused!"); - } - } - else if (active) - rc_client_do_frame(rc_client); + { + rc_client_begin_load_game(rc_client, gameHash.c_str(), [](int result, const char *error_message, rc_client_t *client, void *userdata) { + ((Achievements *)userdata)->gameLoaded(result, error_message); + }, this); + } +} + +void Achievements::gameLoaded(int result, const char *errorMessage) +{ + if (result != RC_OK) + { + if (result == RC_NO_GAME_LOADED) + // Unknown game. + INFO_LOG(COMMON, "RA: Unknown game, disabling achievements."); + else if (result == RC_LOGIN_REQUIRED) { + // We would've asked to re-authenticate, so leave HC on for now. + // Once we've done so, we'll reload the game. } - }); + else + WARN_LOG(COMMON, "RA Loading game failed: %s", errorMessage); + loadingGame = false; + return; + } + const rc_client_game_t* info = rc_client_get_game_info(rc_client); + if (info == nullptr) + { + WARN_LOG(COMMON, "RA: rc_client_get_game_info() returned NULL"); + loadingGame = false; + return; + } + active = true; + loadingGame = false; EventManager::listen(Event::VBlank, emuEventCallback, this); + NOTICE_LOG(COMMON, "RA: game %d loaded: %s, achievements %d leaderboards %d rich presence %d", info->id, info->title, + rc_client_has_achievements(rc_client), rc_client_has_leaderboards(rc_client), rc_client_has_rich_presence(rc_client)); + std::string image; + char url[512]; + if (rc_client_game_get_image_url(info, url, sizeof(url)) == RC_OK) + image = getOrDownloadImage(url); + rc_client_user_game_summary_t summary; + rc_client_get_user_game_summary(rc_client, &summary); + std::string text = "You have " + std::to_string(summary.num_unlocked_achievements) + + " of " + std::to_string(summary.num_core_achievements) + " achievements unlocked"; + notifier.notify(Notification::Login, image, info->title, text); } void Achievements::unloadGame() @@ -557,42 +781,34 @@ void Achievements::unloadGame() if (!active) return; active = false; + paused = false; resetEvent.Set(); - thread.join(); + if (asyncServerCall.valid()) + asyncServerCall.get(); EventManager::unlisten(Event::VBlank, emuEventCallback, this); rc_client_unload_game(rc_client); } -void Achievements::clientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata) +void Achievements::diskChange() { - Achievements *achiev = (Achievements *)rc_client_get_userdata(client); - if (result == RC_NO_GAME_LOADED) - { - // Unknown game. - INFO_LOG(COMMON, "RA: Unknown game, disabling achievements."); + if (!active) + return; + std::string hash = getGameHash(); + if (hash == "") { + unloadGame(); return; } - if (result == RC_LOGIN_REQUIRED) - { - // We would've asked to re-authenticate, so leave HC on for now. - // Once we've done so, we'll reload the game. - return; - } - if (result != RC_OK) - { - WARN_LOG(COMMON, "RA Loading game failed: %s", error_message); - return; - } - const rc_client_game_t* info = rc_client_get_game_info(client); - if (info == nullptr) - { - WARN_LOG(COMMON, "RA: rc_client_get_game_info() returned NULL"); - return; - } - // TODO? rc_client_game_get_image_url(info, buf, std::size(buf)); - achiev->active = true; - NOTICE_LOG(COMMON, "RA: game %d loaded: %s, achievements %d leaderboards %d rich presence %d", info->id, info->title, - rc_client_has_achievements(client), rc_client_has_leaderboards(client), rc_client_has_rich_presence(client)); + rc_client_begin_change_media_from_hash(rc_client, hash.c_str(), [](int result, const char *errorMessage, rc_client_t *client, void *userdata) { + if (result == RC_HARDCORE_DISABLED) { + notifier.notify(Notification::Login, "", "Hardcore disabled", "Unrecognized media inserted"); + } + else if (result != RC_OK) + { + if (errorMessage == nullptr) + errorMessage = rc_error_str(result); + notifier.notify(Notification::Login, "", "Media change failed", errorMessage); + } + }, this); } void Achievements::serialize(Serializer& ser) diff --git a/core/achievements/achievements.h b/core/achievements/achievements.h index e029ff1b9..28023cc28 100644 --- a/core/achievements/achievements.h +++ b/core/achievements/achievements.h @@ -16,6 +16,7 @@ */ #pragma once #include "types.h" +#include namespace achievements { @@ -23,16 +24,10 @@ namespace achievements bool init(); void term(); -void login(const char *username, const char *password); +std::future login(const char *username, const char *password); +void logout(); bool isLoggedOn(); -#else - -static inline bool init() { return false; } -static inline void term() {} -static inline void login(const char *username, const char *password) {} -static inline bool isLoggedOn() { return false; } - #endif void serialize(Serializer& ser); diff --git a/core/deps/imgui/imgui_stdlib.cpp b/core/deps/imgui/imgui_stdlib.cpp new file mode 100644 index 000000000..cf69aa89a --- /dev/null +++ b/core/deps/imgui/imgui_stdlib.cpp @@ -0,0 +1,85 @@ +// dear imgui: wrappers for C++ standard library (STL) types (std::string, etc.) +// This is also an example of how you may wrap your own similar types. + +// Changelog: +// - v0.10: Initial version. Added InputText() / InputTextMultiline() calls with std::string + +// See more C++ related extension (fmt, RAII, syntaxis sugar) on Wiki: +// https://github.com/ocornut/imgui/wiki/Useful-Extensions#cness + +#include "imgui.h" +#include "imgui_stdlib.h" + +// Clang warnings with -Weverything +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wsign-conversion" // warning: implicit conversion changes signedness +#endif + +struct InputTextCallback_UserData +{ + std::string* Str; + ImGuiInputTextCallback ChainCallback; + void* ChainCallbackUserData; +}; + +static int InputTextCallback(ImGuiInputTextCallbackData* data) +{ + InputTextCallback_UserData* user_data = (InputTextCallback_UserData*)data->UserData; + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) + { + // Resize string callback + // If for some reason we refuse the new length (BufTextLen) and/or capacity (BufSize) we need to set them back to what we want. + std::string* str = user_data->Str; + IM_ASSERT(data->Buf == str->c_str()); + str->resize(data->BufTextLen); + data->Buf = (char*)str->c_str(); + } + else if (user_data->ChainCallback) + { + // Forward to user callback, if any + data->UserData = user_data->ChainCallbackUserData; + return user_data->ChainCallback(data); + } + return 0; +} + +bool ImGui::InputText(const char* label, std::string* str, ImGuiInputTextFlags flags, ImGuiInputTextCallback callback, void* user_data) +{ + IM_ASSERT((flags & ImGuiInputTextFlags_CallbackResize) == 0); + flags |= ImGuiInputTextFlags_CallbackResize; + + InputTextCallback_UserData cb_user_data; + cb_user_data.Str = str; + cb_user_data.ChainCallback = callback; + cb_user_data.ChainCallbackUserData = user_data; + return InputText(label, (char*)str->c_str(), str->capacity() + 1, flags, InputTextCallback, &cb_user_data); +} + +bool ImGui::InputTextMultiline(const char* label, std::string* str, const ImVec2& size, ImGuiInputTextFlags flags, ImGuiInputTextCallback callback, void* user_data) +{ + IM_ASSERT((flags & ImGuiInputTextFlags_CallbackResize) == 0); + flags |= ImGuiInputTextFlags_CallbackResize; + + InputTextCallback_UserData cb_user_data; + cb_user_data.Str = str; + cb_user_data.ChainCallback = callback; + cb_user_data.ChainCallbackUserData = user_data; + return InputTextMultiline(label, (char*)str->c_str(), str->capacity() + 1, size, flags, InputTextCallback, &cb_user_data); +} + +bool ImGui::InputTextWithHint(const char* label, const char* hint, std::string* str, ImGuiInputTextFlags flags, ImGuiInputTextCallback callback, void* user_data) +{ + IM_ASSERT((flags & ImGuiInputTextFlags_CallbackResize) == 0); + flags |= ImGuiInputTextFlags_CallbackResize; + + InputTextCallback_UserData cb_user_data; + cb_user_data.Str = str; + cb_user_data.ChainCallback = callback; + cb_user_data.ChainCallbackUserData = user_data; + return InputTextWithHint(label, hint, (char*)str->c_str(), str->capacity() + 1, flags, InputTextCallback, &cb_user_data); +} + +#if defined(__clang__) +#pragma clang diagnostic pop +#endif diff --git a/core/deps/imgui/imgui_stdlib.h b/core/deps/imgui/imgui_stdlib.h new file mode 100644 index 000000000..835a808f2 --- /dev/null +++ b/core/deps/imgui/imgui_stdlib.h @@ -0,0 +1,21 @@ +// dear imgui: wrappers for C++ standard library (STL) types (std::string, etc.) +// This is also an example of how you may wrap your own similar types. + +// Changelog: +// - v0.10: Initial version. Added InputText() / InputTextMultiline() calls with std::string + +// See more C++ related extension (fmt, RAII, syntaxis sugar) on Wiki: +// https://github.com/ocornut/imgui/wiki/Useful-Extensions#cness + +#pragma once + +#include + +namespace ImGui +{ + // ImGui::InputText() with std::string + // Because text input needs dynamic resizing, we need to setup a callback to grow the capacity + IMGUI_API bool InputText(const char* label, std::string* str, ImGuiInputTextFlags flags = 0, ImGuiInputTextCallback callback = nullptr, void* user_data = nullptr); + IMGUI_API bool InputTextMultiline(const char* label, std::string* str, const ImVec2& size = ImVec2(0, 0), ImGuiInputTextFlags flags = 0, ImGuiInputTextCallback callback = nullptr, void* user_data = nullptr); + IMGUI_API bool InputTextWithHint(const char* label, const char* hint, std::string* str, ImGuiInputTextFlags flags = 0, ImGuiInputTextCallback callback = nullptr, void* user_data = nullptr); +} diff --git a/core/emulator.cpp b/core/emulator.cpp index 03da7a54c..bfdd7a219 100644 --- a/core/emulator.cpp +++ b/core/emulator.cpp @@ -772,33 +772,22 @@ void Emulator::setNetworkState(bool online) void EventManager::registerEvent(Event event, Callback callback, void *param) { unregisterEvent(event, callback, param); - auto it = callbacks.find(event); - if (it != callbacks.end()) - it->second.push_back(std::make_pair(callback, param)); - else - callbacks.insert({ event, { std::make_pair(callback, param) } }); + auto& vector = callbacks[static_cast(event)]; + vector.push_back(std::make_pair(callback, param)); } void EventManager::unregisterEvent(Event event, Callback callback, void *param) { - auto it = callbacks.find(event); - if (it == callbacks.end()) - return; - - auto it2 = std::find(it->second.begin(), it->second.end(), std::make_pair(callback, param)); - if (it2 == it->second.end()) - return; - - it->second.erase(it2); + auto& vector = callbacks[static_cast(event)]; + auto it = std::find(vector.begin(), vector.end(), std::make_pair(callback, param)); + if (it != vector.end()) + vector.erase(it); } void EventManager::broadcastEvent(Event event) { - auto it = callbacks.find(event); - if (it == callbacks.end()) - return; - - for (auto& pair : it->second) + auto& vector = callbacks[static_cast(event)]; + for (auto& pair : vector) pair.first(event, pair.second); } diff --git a/core/emulator.h b/core/emulator.h index 96c7d36c8..f4b8b14c0 100644 --- a/core/emulator.h +++ b/core/emulator.h @@ -23,7 +23,7 @@ #include #include -#include +#include #include #include #include @@ -47,6 +47,8 @@ enum class Event { LoadState, VBlank, Network, + DiskChange, + max = DiskChange }; class EventManager @@ -77,7 +79,7 @@ private: void unregisterEvent(Event event, Callback callback, void *param); void broadcastEvent(Event event); - std::map>> callbacks; + std::array>, static_cast(Event::max) + 1> callbacks; }; struct LoadProgress diff --git a/core/hw/holly/sb.cpp b/core/hw/holly/sb.cpp index 49e47b1df..5966141d4 100644 --- a/core/hw/holly/sb.cpp +++ b/core/hw/holly/sb.cpp @@ -14,6 +14,7 @@ #include "emulator.h" #include "hw/bba/bba.h" #include "serialize.h" +#include u32 sb_regs[0x540]; HollyRegisters hollyRegs; diff --git a/core/imgread/common.cpp b/core/imgread/common.cpp index 6aa6853f5..b99ed778f 100644 --- a/core/imgread/common.cpp +++ b/core/imgread/common.cpp @@ -350,6 +350,7 @@ bool DiscSwap(const std::string& path) { if (!doDiscSwap(path)) throw FlycastException("This media cannot be loaded"); + EventManager::event(Event::DiskChange); // Drive is busy after the lid was closed sns_asc = 4; sns_ascq = 1; diff --git a/core/lua/lua.cpp b/core/lua/lua.cpp index 303543d0a..7ad7e5ce3 100644 --- a/core/lua/lua.cpp +++ b/core/lua/lua.cpp @@ -74,6 +74,9 @@ static void emuEventCallback(Event event, void *) case Event::Network: key = "network"; break; + case Event::DiskChange: + key = "diskChange"; + break; } if (v[key].isFunction()) v[key](); diff --git a/core/oslib/http_client.cpp b/core/oslib/http_client.cpp index 52b707743..3742159b1 100644 --- a/core/oslib/http_client.cpp +++ b/core/oslib/http_client.cpp @@ -98,10 +98,7 @@ static int post(const std::string& url, const char *headers, const u8 *payload, if (payloadSize > 0) { char clen[128]; - if (headers == nullptr) - snprintf(clen, sizeof(clen), "Content-Length: %d\r\nContent-Type: application/x-www-form-urlencoded\r\n", payloadSize); - else - snprintf(clen, sizeof(clen), "Content-Length: %d\r\n", payloadSize); + snprintf(clen, sizeof(clen), "Content-Length: %d\r\n", payloadSize); HttpAddRequestHeaders(hreq, clen, -1L, HTTP_ADDREQ_FLAG_ADD_IF_NEW); } if (!HttpSendRequest(hreq, headers, -1, (void *)payload, payloadSize)) @@ -141,9 +138,14 @@ static int post(const std::string& url, const char *headers, const u8 *payload, return rc; } -int post(const std::string& url, const char *payload, std::vector& reply) +int post(const std::string& url, const char *payload, const char *contentType, std::vector& reply) { - return post(url, nullptr, (const u8 *)payload, strlen(payload), reply); + char buf[512]; + if (contentType != nullptr) { + sprintf(buf, "Content-Type: %s", contentType); + contentType = buf; + } + return post(url, contentType, (const u8 *)payload, strlen(payload), reply); } int post(const std::string& url, const std::vector& fields) @@ -272,13 +274,19 @@ int get(const std::string& url, std::vector& content, std::string& contentTy return (int)httpCode; } -int post(const std::string& url, const char *payload, std::vector& reply) +int post(const std::string& url, const char *payload, const char *contentType, std::vector& reply) { CURL *curl = makeCurlEasy(url); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1); curl_easy_setopt(curl, CURLOPT_POST, 1); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_slist *headers = nullptr; + if (contentType != nullptr) + { + headers = curl_slist_append(headers, ("Content-Type: " + std::string(contentType)).c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } std::vector recvBuffer; curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receiveData); @@ -291,6 +299,7 @@ int post(const std::string& url, const char *payload, std::vector& reply) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); reply = recvBuffer; } + curl_slist_free_all(headers); curl_easy_cleanup(curl); return (int)httpCode; diff --git a/core/oslib/http_client.h b/core/oslib/http_client.h index f4493670d..c71fb668f 100644 --- a/core/oslib/http_client.h +++ b/core/oslib/http_client.h @@ -50,7 +50,7 @@ struct PostField }; int post(const std::string& url, const std::vector& fields); -int post(const std::string& url, const char *payload, std::vector& reply); +int post(const std::string& url, const char *payload, const char *contentType, std::vector& reply); static inline bool success(int status) { return status >= 200 && status < 300; diff --git a/core/rend/gui.cpp b/core/rend/gui.cpp index eca4e892a..e24c1e033 100644 --- a/core/rend/gui.cpp +++ b/core/rend/gui.cpp @@ -22,6 +22,7 @@ #include "hw/maple/maple_if.h" #include "hw/maple/maple_devs.h" #include "imgui.h" +#include "imgui_stdlib.h" #include "network/net_handshake.h" #include "network/ggpo.h" #include "wsi/context.h" @@ -46,6 +47,7 @@ #include "hw/naomi/card_reader.h" #include "oslib/resources.h" #include "achievements/achievements.h" +#include "gui_achievements.h" #if defined(USE_SDL) #include "sdl/sdl.h" #endif @@ -95,6 +97,8 @@ static Chat chat; static std::recursive_mutex guiMutex; using LockGuard = std::lock_guard; +ImFont *largeFont; + static void emuEventCallback(Event event, void *) { switch (event) @@ -215,6 +219,7 @@ void gui_initFonts() ImGuiIO& io = ImGui::GetIO(); io.Fonts->Clear(); + largeFont = nullptr; const float fontSize = 17.f * settings.display.uiScale; size_t dataSize; std::unique_ptr data = resource::load("fonts/Roboto-Medium.ttf", dataSize); @@ -304,7 +309,13 @@ void gui_initFonts() // TODO Linux, iOS, ... #endif - NOTICE_LOG(RENDERER, "Screen DPI is %.0f, size %d x %d. Scaling by %.2f", settings.display.dpi, settings.display.width, settings.display.height, settings.display.uiScale); + // Large font without Asian glyphs + data = resource::load("fonts/Roboto-Regular.ttf", dataSize); + verify(data != nullptr); + const float largeFontSize = 21.f * settings.display.uiScale; + largeFont = io.Fonts->AddFontFromMemoryTTF(data.release(), dataSize, largeFontSize, nullptr, ranges); + + NOTICE_LOG(RENDERER, "Screen DPI is %.0f, size %d x %d. Scaling by %.2f", settings.display.dpi, settings.display.width, settings.display.height, settings.display.uiScale); } void gui_keyboard_input(u16 wc) @@ -1744,26 +1755,42 @@ static void gui_display_settings() { DisabledScope _(!config::EnableAchievements); ImGui::Indent(); - char username[256]; - strcpy(username, config::AchievementsUserName.get().c_str()); - ImGui::InputText("Username", username, sizeof(username), ImGuiInputTextFlags_None, nullptr, nullptr); - config::AchievementsUserName = username; + ImGui::InputText("Username", &config::AchievementsUserName.get(), + achievements::isLoggedOn() ? ImGuiInputTextFlags_ReadOnly : ImGuiInputTextFlags_None, nullptr, nullptr); if (config::EnableAchievements) { + static std::future futureLogin; achievements::init(); if (achievements::isLoggedOn()) + { ImGui::Text("Authentication successful"); + if (futureLogin.valid()) + futureLogin.get(); + if (ImGui::Button("Logout", ScaledVec2(100, 0))) + achievements::logout(); + } else { static char password[256]; ImGui::InputText("Password", password, sizeof(password), ImGuiInputTextFlags_Password, nullptr, nullptr); - if (ImGui::Button("Login", ScaledVec2(100, 0))) + if (futureLogin.valid()) { - try { - achievements::login(config::AchievementsUserName.get().c_str(), password); - } catch (const FlycastException& e) { - gui_error(e.what()); + if (futureLogin.wait_for(std::chrono::seconds::zero()) == std::future_status::timeout) { + ImGui::Text("Authenticating..."); } + else + { + try { + futureLogin.get(); + } catch (const FlycastException& e) { + gui_error(e.what()); + } + } + } + if (ImGui::Button("Login", ScaledVec2(100, 0)) && !futureLogin.valid()) + { + futureLogin = achievements::login(config::AchievementsUserName.get().c_str(), password); + memset(password, 0, sizeof(password)); } } } @@ -2569,12 +2596,9 @@ static void gui_display_settings() config::NetworkEnable = false; OptionCheckbox("Play as Player 1", config::ActAsServer, "Deselect to play as player 2"); - char server_name[256]; - strcpy(server_name, config::NetworkServer.get().c_str()); - ImGui::InputText("Peer", server_name, sizeof(server_name), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); + ImGui::InputText("Peer", &config::NetworkServer.get(), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); ImGui::SameLine(); ShowHelpMarker("Your peer IP address and optional port"); - config::NetworkServer.set(server_name); OptionSlider("Frame Delay", config::GGPODelay, 0, 20, "Sets Frame Delay, advisable for sessions with ping >100 ms"); @@ -2608,12 +2632,9 @@ static void gui_display_settings() "Create a local server for Naomi network games"); if (!config::ActAsServer) { - char server_name[256]; - strcpy(server_name, config::NetworkServer.get().c_str()); - ImGui::InputText("Server", server_name, sizeof(server_name), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); + ImGui::InputText("Server", &config::NetworkServer.get(), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); ImGui::SameLine(); ShowHelpMarker("The server to connect to. Leave blank to find a server automatically on the default port"); - config::NetworkServer.set(server_name); } char localPort[256]; sprintf(localPort, "%d", (int)config::LocalPort); @@ -2624,12 +2645,9 @@ static void gui_display_settings() } else if (config::BattleCableEnable) { - char server_name[256]; - strcpy(server_name, config::NetworkServer.get().c_str()); - ImGui::InputText("Peer", server_name, sizeof(server_name), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); + ImGui::InputText("Peer", &config::NetworkServer.get(), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); ImGui::SameLine(); ShowHelpMarker("The peer to connect to. Leave blank to find a player automatically on the default port"); - config::NetworkServer.set(server_name); char localPort[256]; sprintf(localPort, "%d", (int)config::LocalPort); ImGui::InputText("Local Port", localPort, sizeof(localPort), ImGuiInputTextFlags_CharsDecimal, nullptr, nullptr); @@ -2721,14 +2739,9 @@ static void gui_display_settings() #ifdef USE_LUA header("Lua Scripting"); { - char LuaFileName[256]; - - strcpy(LuaFileName, config::LuaFileName.get().c_str()); - ImGui::InputText("Lua Filename", LuaFileName, sizeof(LuaFileName), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); + ImGui::InputText("Lua Filename", &config::LuaFileName.get(), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); ImGui::SameLine(); ShowHelpMarker("Specify lua filename to use. Should be located in Flycast config directory. Defaults to flycast.lua when empty."); - config::LuaFileName = LuaFileName; - } #endif } @@ -3416,7 +3429,7 @@ void gui_display_osd() gui_newFrame(); ImGui::NewFrame(); - if (!message.empty()) + if (!achievements::notifier.draw() && !message.empty()) { ImGui::SetNextWindowBgAlpha(0); ImGui::SetNextWindowPos(ImVec2(0, ImGui::GetIO().DisplaySize.y), ImGuiCond_Always, ImVec2(0.f, 1.f)); // Lower left corner diff --git a/core/rend/gui_achievements.cpp b/core/rend/gui_achievements.cpp new file mode 100644 index 000000000..97ab1d00e --- /dev/null +++ b/core/rend/gui_achievements.cpp @@ -0,0 +1,171 @@ +/* + Copyright 2024 flyinghead + + This file is part of Flycast. + + Flycast is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + Flycast is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +#include "gui_achievements.h" +#include "gui.h" +#include "gui_util.h" +#include "imgui_driver.h" +#include "stdclass.h" +#include + +extern ImFont *largeFont; + +namespace achievements +{ + +Notification notifier; + +static constexpr u64 DISPLAY_TIME = 5000; +static constexpr u64 START_ANIM_TIME = 500; +static constexpr u64 END_ANIM_TIME = 1000; + +void Notification::notify(Type type, const std::string& image, const std::string& text1, + const std::string& text2, const std::string& text3) +{ + std::lock_guard _(mutex); + u64 now = getTimeMs(); + if (type == Progress) + { + if (!text1.empty()) + { + if (this->type == None) + { + // New progress + startTime = now; + endTime = 0x1000000000000; // never + } + } + else + { + // Hide progress + endTime = now; + } + } + else { + startTime = now; + endTime = startTime + DISPLAY_TIME; + } + this->type = type; + this->imagePath = image; + this->imageId = {}; + text[0] = text1; + text[1] = text2; + text[2] = text3; +} + +bool Notification::draw() +{ + std::lock_guard _(mutex); + if (type == None) + return false; + u64 now = getTimeMs(); + if (now > endTime + END_ANIM_TIME) { + // Hide notification + type = None; + return false; + } + if (now > endTime) + { + // Fade out + float alpha = (std::cos((now - endTime) / (float)END_ANIM_TIME * (float)M_PI) + 1.f) / 2.f; + ImGui::GetStyle().Alpha = alpha; + ImGui::SetNextWindowBgAlpha(alpha / 2.f); + } + else { + ImGui::SetNextWindowBgAlpha(0.5f); + } + if (imageId == ImTextureID{}) + getImage(); + float y = ImGui::GetIO().DisplaySize.y; + if (now - startTime < START_ANIM_TIME) + // Slide up + y += 80.f * settings.display.uiScale * (std::cos((now - startTime) / (float)START_ANIM_TIME * (float)M_PI) + 1.f) / 2.f; + + ImGui::SetNextWindowPos(ImVec2(0, y), ImGuiCond_Always, ImVec2(0.f, 1.f)); // Lower left corner + ImGui::SetNextWindowSizeConstraints(ScaledVec2(80.f, 80.f) + ImVec2(ImGui::GetStyle().WindowPadding.x * 2, 0.f), ImVec2(FLT_MAX, FLT_MAX)); + const float winPaddingX = ImGui::GetStyle().WindowPadding.x; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2{}); + + ImGui::Begin("##achievements", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav + | ImGuiWindowFlags_NoInputs); + const bool hasPic = imageId != ImTextureID{}; + if (ImGui::BeginTable("achievementNotif", hasPic ? 2 : 1, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_NoSavedSettings)) + { + if (hasPic) + ImGui::TableSetupColumn("icon", ImGuiTableColumnFlags_WidthFixed); + ImGui::TableSetupColumn("text", ImGuiTableColumnFlags_WidthStretch); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + if (hasPic) + { + ImGui::Image(imageId, ScaledVec2(80.f, 80.f), { 0.f, 0.f }, { 1.f, 1.f }); + ImGui::TableSetColumnIndex(1); + } + + float w = largeFont->CalcTextSizeA(largeFont->FontSize, FLT_MAX, -1.f, text[0].c_str()).x; + w = std::max(w, ImGui::CalcTextSize(text[1].c_str()).x); + w = std::max(w, ImGui::CalcTextSize(text[2].c_str()).x) + winPaddingX * 2; + int lines = (int)!text[0].empty() + (int)!text[1].empty() + (int)!text[2].empty(); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2{ hasPic ? 0.f : winPaddingX, (3 - lines) * ImGui::GetTextLineHeight() / 2 }); + if (ImGui::BeginChild("##text", ImVec2(w, 0), ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_None)) + { + ImGui::PushFont(largeFont); + ImGui::Text("%s", text[0].c_str()); + ImGui::PopFont(); + if (!text[1].empty()) + ImGui::TextColored(ImVec4(1, 1, 0, 0.7f), "%s", text[1].c_str()); + if (!text[2].empty()) + ImGui::TextColored(ImVec4(1, 1, 0, 0.7f), "%s", text[2].c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleVar(); + + ImGui::EndTable(); + } + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::GetStyle().Alpha = 1.f; + + return true; +} + +void Notification::getImage() +{ + if (imagePath.empty()) + return; + + // Get the texture. Load it if needed. + imageId = imguiDriver->getTexture(imagePath); + if (imageId == ImTextureID()) + { + int width, height; + u8 *imgData = loadImage(imagePath, width, height); + if (imgData != nullptr) + { + try { + imageId = imguiDriver->updateTextureAndAspectRatio(imagePath, imgData, width, height); + } catch (...) { + // vulkan can throw during resizing + } + free(imgData); + } + } +} + +} // namespace achievements diff --git a/core/rend/gui_achievements.h b/core/rend/gui_achievements.h new file mode 100644 index 000000000..009581f20 --- /dev/null +++ b/core/rend/gui_achievements.h @@ -0,0 +1,56 @@ +/* + Copyright 2024 flyinghead + + This file is part of Flycast. + + Flycast is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + Flycast is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +#include "types.h" +#include "imgui.h" +#include + +namespace achievements +{ + +class Notification +{ +public: + enum Type { + None, + Login, + GameLoaded, + Unlocked, + Progress, + Mastery, + Error + }; + void notify(Type type, const std::string& image, const std::string& text1, + const std::string& text2 = {}, const std::string& text3 = {}); + bool draw(); + +private: + void getImage(); + + u64 startTime = 0; + u64 endTime = 0; + Type type = Type::None; + std::string imagePath; + ImTextureID imageId {}; + std::string text[3]; + std::mutex mutex; +}; + +extern Notification notifier; + +} diff --git a/fonts/Roboto-Regular.ttf.zip b/fonts/Roboto-Regular.ttf.zip new file mode 100644 index 000000000..6bc0533e8 Binary files /dev/null and b/fonts/Roboto-Regular.ttf.zip differ diff --git a/shell/android-studio/flycast/src/main/java/com/reicast/emulator/emu/HttpClient.java b/shell/android-studio/flycast/src/main/java/com/reicast/emulator/emu/HttpClient.java index 297507206..929207ce4 100644 --- a/shell/android-studio/flycast/src/main/java/com/reicast/emulator/emu/HttpClient.java +++ b/shell/android-studio/flycast/src/main/java/com/reicast/emulator/emu/HttpClient.java @@ -78,12 +78,12 @@ public class HttpClient { return 500; } - public int post(String urlString, String payload, byte[][] reply) { + public int post(String urlString, String payload, String contentType, byte[][] reply) { try { if (httpClient == null) httpClient = HttpClients.createDefault(); HttpPost httpPost = new HttpPost(urlString); - httpPost.setEntity(new StringEntity(payload, ContentType.APPLICATION_FORM_URLENCODED)); + httpPost.setEntity(new StringEntity(payload, contentType != null ? ContentType.create(contentType) : ContentType.APPLICATION_FORM_URLENCODED)); CloseableHttpResponse response = httpClient.execute(httpPost); InputStream is = response.getEntity().getContent(); diff --git a/shell/android-studio/flycast/src/main/jni/src/http_client.h b/shell/android-studio/flycast/src/main/jni/src/http_client.h index 0d1505fdd..602bf21ef 100644 --- a/shell/android-studio/flycast/src/main/jni/src/http_client.h +++ b/shell/android-studio/flycast/src/main/jni/src/http_client.h @@ -47,15 +47,17 @@ namespace http { return httpStatus; } - int post(const std::string &url, const char *payload, std::vector& reply) + int post(const std::string &url, const char *payload, const char *contentType, std::vector& reply) { jni::String jurl(url); jni::String jpayload(payload); + jni::String jcontentType(contentType); jni::ObjectArray replyOut(1); int httpStatus = jni::env()->CallIntMethod(HttpClient, postRawMid, static_cast(jurl), static_cast(jpayload), + static_cast(jcontentType), static_cast(replyOut)); reply = replyOut[0]; @@ -95,5 +97,5 @@ extern "C" JNIEXPORT void JNICALL Java_com_reicast_emulator_emu_HttpClient_nativ http::HttpClient = env->NewGlobalRef(obj); http::openUrlMid = env->GetMethodID(env->GetObjectClass(obj), "openUrl", "(Ljava/lang/String;[[B[Ljava/lang/String;)I"); http::postMid = env->GetMethodID(env->GetObjectClass(obj), "post", "(Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;)I"); - http::postRawMid = env->GetMethodID(env->GetObjectClass(obj), "post", "(Ljava/lang/String;Ljava/lang/String;[[B)I"); + http::postRawMid = env->GetMethodID(env->GetObjectClass(obj), "post", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[[B)I"); } diff --git a/shell/apple/common/http_client.mm b/shell/apple/common/http_client.mm index 7fd8e26ef..e577face7 100644 --- a/shell/apple/common/http_client.mm +++ b/shell/apple/common/http_client.mm @@ -46,7 +46,7 @@ int get(const std::string& url, std::vector& content, std::string& contentTy return [httpResponse statusCode]; } -int post(const std::string& url, const char *payload, std::vector& reply) +int post(const std::string& url, const char *payload, const char *contentType, std::vector& reply) { NSString *nsurl = [NSString stringWithCString:url.c_str() encoding:[NSString defaultCStringEncoding]]; @@ -59,8 +59,10 @@ int post(const std::string& url, const char *payload, std::vector& reply) [request setHTTPBody:[NSData dataWithBytes:payload length:payloadSize]]; NSString *postLength = [NSString stringWithFormat:@"%ld", (unsigned long)payloadSize]; [request setValue:postLength forHTTPHeaderField:@"Content-Length"]; - NSString *contentType = @"application/x-www-form-urlencoded"; - [request setValue:contentType forHTTPHeaderField:@"Content-Type"]; + NSString *nscontentType = contentType != nullptr ? [NSString stringWithCString:contentType + encoding:[NSString defaultCStringEncoding]] + : @"application/x-www-form-urlencoded"; + [request setValue:nscontentType forHTTPHeaderField:@"Content-Type"]; NSURLResponse *response = nil; NSError *error = nil; diff --git a/shell/uwp/http_client.cpp b/shell/uwp/http_client.cpp index 180bc5da1..68de14302 100644 --- a/shell/uwp/http_client.cpp +++ b/shell/uwp/http_client.cpp @@ -89,7 +89,7 @@ int get(const std::string& url, std::vector& content, std::string& contentTy } } -int post(const std::string& url, const char *payload, std::vector& reply) +int post(const std::string& url, const char *payload, const char *contentType, std::vector& reply) { nowide::wstackstring wurl; if (!wurl.convert(url.c_str())) @@ -97,12 +97,16 @@ int post(const std::string& url, const char *payload, std::vector& reply) nowide::wstackstring wpayload; if (!wpayload.convert(payload)) return 500; + nowide::wstackstring wcontentType; + if (contentType != nullptr && !wcontentType.convert(contentType)) + return 500; try { Uri^ uri = ref new Uri(ref new String(wurl.get())); HttpStringContent^ content = ref new HttpStringContent(ref new String(wpayload.get())); content->Headers->ContentLength = strlen(payload); - content->Headers->ContentType = ref new HttpMediaTypeHeaderValue("application/x-www-form-urlencoded"); + if (contentType != nullptr) + content->Headers->ContentType = ref new HttpMediaTypeHeaderValue(ref new String(wcontentType.get())); IAsyncOperationWithProgress^ op = httpClient->PostAsync(uri, content); cResetEvent asyncEvent;