From 574b6197a23619ed8f59b6e7bb949d69f4a7a016 Mon Sep 17 00:00:00 2001 From: madeline Date: Sat, 25 Apr 2026 18:23:12 -0700 Subject: [PATCH] achievements --- files.cmake | 3 + include/dusk/achievements.h | 65 ++++ include/dusk/settings.h | 1 + src/dusk/achievements.cpp | 467 +++++++++++++++++++++++++ src/dusk/imgui/ImGuiAchievements.cpp | 228 ++++++++++++ src/dusk/imgui/ImGuiAchievements.hpp | 23 ++ src/dusk/imgui/ImGuiConsole.cpp | 11 + src/dusk/imgui/ImGuiFirstRunPreset.cpp | 1 + src/dusk/imgui/ImGuiMenuGame.cpp | 1 + src/dusk/imgui/ImGuiMenuTools.cpp | 10 + src/dusk/imgui/ImGuiMenuTools.hpp | 6 + src/dusk/settings.cpp | 2 + 12 files changed, 818 insertions(+) create mode 100644 include/dusk/achievements.h create mode 100644 src/dusk/achievements.cpp create mode 100644 src/dusk/imgui/ImGuiAchievements.cpp create mode 100644 src/dusk/imgui/ImGuiAchievements.hpp diff --git a/files.cmake b/files.cmake index af101b6c47..01b3863505 100644 --- a/files.cmake +++ b/files.cmake @@ -1460,6 +1460,9 @@ set(DUSK_FILES src/dusk/imgui/ImGuiSaveEditor.cpp src/dusk/imgui/ImGuiStateShare.hpp src/dusk/imgui/ImGuiStateShare.cpp + src/dusk/imgui/ImGuiAchievements.hpp + src/dusk/imgui/ImGuiAchievements.cpp + src/dusk/achievements.cpp src/dusk/iso_validate.cpp src/dusk/offset_ptr.cpp src/dusk/OSContext.cpp diff --git a/include/dusk/achievements.h b/include/dusk/achievements.h new file mode 100644 index 0000000000..cd4294b6f1 --- /dev/null +++ b/include/dusk/achievements.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "nlohmann/json.hpp" + +namespace dusk { + +enum class AchievementCategory : uint8_t { + Story, + Collection, + Challenge, + Minigame, + Glitched +}; + +struct Achievement { + const char* key; + const char* name; + const char* description; + AchievementCategory category; + bool isCounter; + int32_t goal; + int32_t progress; + bool unlocked; +}; + +// Responsible for updating a.progress. +// Use extra for any per-achievement state that must survive across frames or sessions, extra is saved +using AchievementCheckFn = std::function; + +class AchievementSystem { +public: + static AchievementSystem& get(); + + void load(); + void save(); + void tick(); + void clearAll(); + + std::vector getAchievements() const; + bool hasPendingUnlock() const { return !m_pendingUnlocks.empty(); } + std::string consumePendingUnlock(); + +private: + struct Entry { + Achievement achievement; + AchievementCheckFn check; + nlohmann::json extra; + }; + + AchievementSystem(); + static std::vector makeEntries(); + void processEntry(Entry& e); + + std::vector m_entries; + bool m_loaded = false; + bool m_dirty = false; + std::queue m_pendingUnlocks; +}; + +} // namespace dusk diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 039e51af7d..8d67805ff3 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -70,6 +70,7 @@ struct UserSettings { ConfigVar disableMainHUD; ConfigVar pauseOnFocusLost; ConfigVar enableLinkDollRotation; + ConfigVar enableAchievementNotifications; // Graphics diff --git a/src/dusk/achievements.cpp b/src/dusk/achievements.cpp new file mode 100644 index 0000000000..071332a7c2 --- /dev/null +++ b/src/dusk/achievements.cpp @@ -0,0 +1,467 @@ +#include "dusk/achievements.h" +#include "dusk/io.hpp" +#include "dusk/main.h" +#include "d/d_com_inf_game.h" +#include "d/d_meter2_info.h" +#include "d/actor/d_a_alink.h" +#include "d/actor/d_a_npc4.h" +#include "d/actor/d_a_player.h" +#include "d/d_demo.h" +#include "f_pc/f_pc_name.h" + +#include +#include + +namespace dusk { + +using json = nlohmann::json; + +static void checkGoatHerding(Achievement& a, int32_t threshMs) { + if (dMeter2Info_getMaxCount() != 20 || dMeter2Info_getNowCount() != 20) { + return; + } + const int32_t elapsed = dMeter2Info_getTimeMs(); + if (elapsed > 0 && elapsed <= threshMs) { + a.progress = 1; + } +} + +static constexpr auto ACHIEVEMENTS_FILENAME = "achievements.json"; + +std::vector AchievementSystem::makeEntries() { + return { + { + { + "hero_of_twilight", + "Hero of Twilight", + "Deliver the finishing blow to Ganondorf.", + AchievementCategory::Story, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link != nullptr && link->mProcID == daAlink_c::PROC_GANON_FINISH) { + a.progress = 1; + } + }, + {} + }, + { + { + "rollgoal_8", + "Rollgoal Novice", + "Complete the first 8 rollgoal stages.", + AchievementCategory::Minigame, + true, 8, 0, false + }, + [](Achievement& a, json&) { + a.progress = std::min((int)dComIfGs_getEventReg(0xf63f), 8); + }, + {} + }, + { + { + "rollgoal_all", + "Lost Your Marbles", + "Complete all rollgoal stages.", + AchievementCategory::Minigame, + true, 64, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_isEventBit(dSv_event_flag_c::KORO2_ALLCLEAR)) { + a.progress = 64; + } else { + a.progress = dComIfGs_getEventReg(0xf63f); + } + }, + {} + }, + { + { + "goat_30s", + "Ranch Hand", + "Herd all 20 goats into the pen in under 30 seconds.", + AchievementCategory::Minigame, + false, 0, 0, false + }, + [](Achievement& a, json&) { + checkGoatHerding(a, 30000); + }, + {} + }, + { + { + "goat_20s", + "Bane of Howard", + "Herd all 20 goats into the pen in under 20 seconds.", + AchievementCategory::Minigame, + false, 0, 0, false + }, + [](Achievement& a, json&) { + checkGoatHerding(a, 20000); + }, + {} + }, + { + { + "goat_18s", + "King of the Ranch", + "Herd all 20 goats into the pen in under 18 seconds.", + AchievementCategory::Minigame, + false, 0, 0, false + }, + [](Achievement& a, json&) { + checkGoatHerding(a, 18000); + }, + {} + }, + { + { + "cave_of_ordeals", + "Conqueror of Ordeals", + "Clear all 50 floors of the Cave of Ordeals.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (daNpcF_chkEvtBit(0x1F9)) { + a.progress = 1; + } + }, + {} + }, + { + { + "cave_of_ordeals_heartless", + "Indomitable", + "Clear all 50 floors of the Cave of Ordeals with only 3 heart containers.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (daNpcF_chkEvtBit(0x1F9) && dComIfGs_getMaxLife() <= 15) { + a.progress = 1; + } + }, + {} + }, + { + { + "speedrun_12h", + "Been There Done That", + "Defeat Ganondorf with a total save file play time under 12 hours.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); + if (ticks / OS_TIMER_CLOCK < 12 * 3600) { + a.progress = 1; + } + }, + {} + }, + { + { + "speedrun_8h", + "Swift Blade", + "Defeat Ganondorf with a total save file play time under 6 hours.", + AchievementCategory::Challenge, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); + if (ticks / OS_TIMER_CLOCK < 8 * 3600) { + a.progress = 1; + } + }, + {} + }, + { + { + "princess_of_bugs", + "The Princess of Bugs", + "Deliver all 24 golden bugs to Agitha.", + AchievementCategory::Collection, + true, 24, 0, false + }, + [](Achievement& a, json&) { + a.progress = dComIfGs_checkGetInsectNum(); + }, + {} + }, + { + { + "all_poes", + "Poe Collector", + "Collect all 60 Poe Souls.", + AchievementCategory::Collection, + true, 60, 0, false + }, + [](Achievement& a, json&) { + a.progress = dComIfGs_getPohSpiritNum(); + }, + {} + }, + { + { + "hylian_loach", + "Legendary Catch", + "Catch a Hylian Loach.", + AchievementCategory::Collection, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_getFishNum(1) > 0) { + a.progress = 1; + } + }, + {} + }, + { + { + "all_fish", + "Gone Fishin'", + "Catch all 6 species of fish.", + AchievementCategory::Collection, + true, 6, 0, false + }, + [](Achievement& a, json&) { + int nUniqueFish = 0; + for (int i = 0; i < 6; ++i) { + if (dComIfGs_getFishNum(i) != 0) { + nUniqueFish++; + } + } + a.progress = nUniqueFish; + }, + {} + }, + { + { + "a_big_heart", + "A Big Heart", + "Reach maximum health with all 20 heart containers.", + AchievementCategory::Collection, + true, 20, 0, false + }, + [](Achievement& a, json&) { + a.progress = dComIfGs_getMaxLife() / 5; + }, + {} + }, + { + { + "back_in_time", + "Back in Time", + "Perform the Back in Time glitch to play on the title screen.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + static int titleNoDemoFrames = 0; + if (fopAcM_SearchByName(fpcNm_TITLE_e) == nullptr) { + titleNoDemoFrames = 0; + return; + } + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link != nullptr && dDemo_c::getMode() == 0) { + if (++titleNoDemoFrames >= 60) { + a.progress = 1; + } + } else { + titleNoDemoFrames = 0; + } + }, + {} + }, + { + { + "early_master_sword", + "Early Master Sword", + "Obtain the Master Sword before completing Midna's Desperate Hour.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_isCollectSword(COLLECT_MASTER_SWORD) && !dComIfGs_isEventBit(0x1E08)) { + a.progress = 1; + } + }, + {} + }, + { + { + "earliest_master_sword", + "Earliest Master Sword", + "Obtain the Master Sword before meeting Midna.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + if (dComIfGs_isCollectSword(COLLECT_MASTER_SWORD) && !dComIfGs_isTransformLV(0)) { + a.progress = 1; + } + }, + {} + }, + { + { + "ultimate_delivery", + "The Ultimate Delivery", + "Have all 16 postman letters at the same time.", + AchievementCategory::Glitched, + true, 16, 0, false + }, + [](Achievement& a, json&) { + a.progress = dMeter2Info_getRecieveLetterNum(); + }, + {} + }, + { + { + "speedrun_4h", + "Hero of Time", + "Defeat Ganondorf with a total save file play time under 4 hours.", + AchievementCategory::Glitched, + false, 0, 0, false + }, + [](Achievement& a, json&) { + const auto* link = static_cast(daPy_getPlayerActorClass()); + if (link == nullptr || link->mProcID != daAlink_c::PROC_GANON_FINISH) { + return; + } + const int64_t ticks = (static_cast(OSGetTime()) - dComIfGs_getSaveStartTime()) + dComIfGs_getSaveTotalTime(); + if (ticks / OS_TIMER_CLOCK < 4 * 3600) { + a.progress = 1; + } + }, + {} + } + }; +} + +AchievementSystem::AchievementSystem() : m_entries(makeEntries()) {} + +AchievementSystem& AchievementSystem::get() { + static AchievementSystem instance; + return instance; +} + +std::string AchievementSystem::consumePendingUnlock() { + std::string msg = std::move(m_pendingUnlocks.front()); + m_pendingUnlocks.pop(); + return msg; +} + +std::vector AchievementSystem::getAchievements() const { + std::vector result; + result.reserve(m_entries.size()); + for (const auto& e : m_entries) { + result.push_back(e.achievement); + } + return result; +} + +void AchievementSystem::load() { + m_loaded = true; + const auto filePath = dusk::ConfigPath / ACHIEVEMENTS_FILENAME; + if (!std::filesystem::exists(filePath)) { + return; + } + try { + auto data = io::FileStream::ReadAllBytes(filePath.string().c_str()); + auto j = json::parse(data); + if (!j.is_object()) { + return; + } + for (auto& e : m_entries) { + if (!j.contains(e.achievement.key)) { + continue; + } + const auto& entry = j[e.achievement.key]; + if (entry.contains("progress")) { + e.achievement.progress = entry["progress"].get(); + } + if (entry.contains("unlocked")) { + e.achievement.unlocked = entry["unlocked"].get(); + } + if (entry.contains("extra")) { + e.extra = entry["extra"]; + } + } + } catch (const std::exception&) {} +} + +void AchievementSystem::save() { + json j = json::object(); + for (const auto& e : m_entries) { + json entry = { + {"progress", e.achievement.progress}, + {"unlocked", e.achievement.unlocked}, + }; + if (!e.extra.is_null()) { + entry["extra"] = e.extra; + } + j[e.achievement.key] = std::move(entry); + } + try { + io::FileStream::WriteAllText( + (dusk::ConfigPath / ACHIEVEMENTS_FILENAME).string().c_str(), + j.dump(2) + ); + } catch (const std::exception&) {} +} + +void AchievementSystem::clearAll() { + m_entries = makeEntries(); + save(); +} + +void AchievementSystem::processEntry(Entry& e) { + if (e.achievement.unlocked) { + return; + } + const int32_t prevProgress = e.achievement.progress; + e.check(e.achievement, e.extra); + + const bool progressChanged = e.achievement.progress != prevProgress; + const bool nowUnlocked = e.achievement.isCounter ? + e.achievement.progress >= e.achievement.goal : + e.achievement.progress > 0; + + if (nowUnlocked) { + e.achievement.progress = e.achievement.isCounter ? e.achievement.goal : 1; + e.achievement.unlocked = true; + m_pendingUnlocks.push(e.achievement.name); + m_dirty = true; + } else if (progressChanged) { + m_dirty = true; + } +} + +void AchievementSystem::tick() { + if (!m_loaded) { + load(); + } + if (!dusk::IsGameLaunched) { + return; + } + for (auto& e : m_entries) { + processEntry(e); + } + if (m_dirty) { + save(); + m_dirty = false; + } +} + +} // namespace dusk diff --git a/src/dusk/imgui/ImGuiAchievements.cpp b/src/dusk/imgui/ImGuiAchievements.cpp new file mode 100644 index 0000000000..4b844b8386 --- /dev/null +++ b/src/dusk/imgui/ImGuiAchievements.cpp @@ -0,0 +1,228 @@ +#include "ImGuiAchievements.hpp" +#include "ImGuiConfig.hpp" +#include "dusk/achievements.h" +#include "dusk/settings.h" +#include "fmt/format.h" +#include "imgui.h" + +namespace dusk { + +void ImGuiAchievements::notify(std::string name) { + if (m_notifyTimer <= 0.f) { + m_notifyName = std::move(name); + m_notifyTimer = NOTIFY_DURATION; + } else { + m_notifyQueue.push(std::move(name)); + } +} + +void ImGuiAchievements::showNotification() { + if (!getSettings().game.enableAchievementNotifications.getValue()) { + return; + } + if (m_notifyTimer <= 0.f) { + if (m_notifyQueue.empty()) { + return; + } + m_notifyName = std::move(m_notifyQueue.front()); + m_notifyQueue.pop(); + m_notifyTimer = NOTIFY_DURATION; + } + + m_notifyTimer -= ImGui::GetIO().DeltaTime; + + const float alpha = std::min({ + m_notifyTimer / NOTIFY_FADE_TIME, + (NOTIFY_DURATION - m_notifyTimer) / NOTIFY_FADE_TIME, + 1.0f + }); + + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + const float padding = 12.0f; + ImGui::SetNextWindowPos( + ImVec2(viewport->WorkPos.x + viewport->WorkSize.x - padding, viewport->WorkPos.y + padding), + ImGuiCond_Always, ImVec2(1.0f, 0.0f) + ); + + ImGui::SetNextWindowBgAlpha(alpha * 0.92f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.06f, 0.01f, alpha * 0.92f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(1.0f, 0.8f, 0.1f, alpha)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, alpha)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14.0f, 10.0f)); + + constexpr ImGuiWindowFlags flags = + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoInputs; + + if (ImGui::Begin("##achievement_notify", nullptr, flags)) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.82f, 0.1f, alpha)); + ImGui::TextUnformatted("Achievement Unlocked!"); + ImGui::PopStyleColor(); + ImGui::Spacing(); + ImGui::TextUnformatted(m_notifyName.c_str()); + } + ImGui::End(); + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(3); +} + +void ImGuiAchievements::draw(bool& open) { + showNotification(); + + if (!open) { + return; + } + + ImGui::SetNextWindowSizeConstraints(ImVec2(640, 200), ImVec2(800, 900)); + ImGui::SetNextWindowSize(ImVec2(640, 480), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin( + "Achievements", &open, + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav) + ) + { + ImGui::End(); + return; + } + + const auto achievements = AchievementSystem::get().getAchievements(); + + int unlocked = 0; + for (const auto& a : achievements) { + if (a.unlocked) { + ++unlocked; + } + } + + ImGui::Text("%d / %d achievements unlocked", unlocked, (int)achievements.size()); + ImGui::SameLine(); + config::ImGuiCheckbox("Notifications", getSettings().game.enableAchievementNotifications); + ImGui::Separator(); + + static const struct { + AchievementCategory cat; + const char* label; + ImVec4 color; + } ACHIEVEMENT_CATEGORIES[] = { + {AchievementCategory::Story, "Story", ImVec4(1.0f, 0.82f, 0.1f, 1.0f)}, + {AchievementCategory::Collection, "Collection", ImVec4(0.3f, 0.85f, 0.4f, 1.0f)}, + {AchievementCategory::Challenge, "Challenge", ImVec4(1.0f, 0.65f, 0.15f, 1.0f)}, + {AchievementCategory::Minigame, "Minigame", ImVec4(0.5f, 0.85f, 1.0f, 1.0f)}, + {AchievementCategory::Glitched, "Glitched", ImVec4(0.75f, 0.4f, 1.0f, 1.0f)}, + }; + + const float footerHeight = ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeightWithSpacing(); + + if (ImGui::BeginTabBar("##achievement_tabs", ImGuiTabBarFlags_FittingPolicyScroll)) { + for (const auto& catInfo : ACHIEVEMENT_CATEGORIES) { + int catTotal = 0, catUnlocked = 0; + for (const auto& a : achievements) { + if (a.category == catInfo.cat) { + ++catTotal; + if (a.unlocked) { + ++catUnlocked; + } + } + } + if (catTotal == 0) { + continue; + } + + const std::string tabLabel = fmt::format("{} ({}/{})", catInfo.label, catUnlocked, catTotal); + + ImGui::PushStyleColor(ImGuiCol_Text, catInfo.color); + const bool tabOpen = ImGui::BeginTabItem(tabLabel.c_str()); + ImGui::PopStyleColor(); + + if (tabOpen) { + ImGui::BeginChild( + "##cat_list", + ImVec2(0, -footerHeight), + ImGuiChildFlags_None, + ImGuiWindowFlags_NoBackground + ); + + ImGui::Spacing(); + + for (const auto& a : achievements) { + if (a.category != catInfo.cat) { + continue; + } + ImGui::PushID(a.key); + + ImGui::PushStyleColor( + ImGuiCol_Text, + a.unlocked ? + ImVec4(1.0f, 0.65f, 0.15f, 1.0f) : + ImGui::GetStyleColorVec4(ImGuiCol_Text) + ); + + ImGui::TextUnformatted(a.name); + ImGui::PopStyleColor(); + + const char* statusLabel = a.unlocked ? "[Unlocked]" : "[Locked]"; + ImGui::SameLine( + ImGui::GetContentRegionMax().x - + ImGui::CalcTextSize(statusLabel).x + ); + + if (a.unlocked) { + ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "%s", statusLabel); + } else { + ImGui::TextColored(ImVec4(0.8f, 0.2f, 0.2f, 1.0f), "%s", statusLabel); + } + + ImGui::TextDisabled("%s", a.description); + + if (a.isCounter) { + const float fraction = a.goal > 0 ? (float)(a.progress) / (float)(a.goal) : 1.0f; + const std::string overlay = fmt::format("{} / {}", a.progress, a.goal); + ImGui::PushStyleColor( + ImGuiCol_PlotHistogram, + a.unlocked ? + ImVec4(0.4f, 0.7f, 0.1f, 1.0f) : + ImVec4(0.2f, 0.45f, 0.8f, 1.0f) + ); + ImGui::ProgressBar(fraction, ImVec2(-1.0f, 0.0f), overlay.c_str()); + ImGui::PopStyleColor(); + } + + ImGui::Spacing(); + ImGui::PopID(); + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Clear All Achievements")) { + ImGui::OpenPopup("##confirm_clear"); + } + + if (ImGui::BeginPopup("##confirm_clear")) { + ImGui::Text("Reset all achievement progress?"); + ImGui::Spacing(); + if (ImGui::Button("Yes, reset all")) { + AchievementSystem::get().clearAll(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::End(); +} + +} // namespace dusk diff --git a/src/dusk/imgui/ImGuiAchievements.hpp b/src/dusk/imgui/ImGuiAchievements.hpp new file mode 100644 index 0000000000..5ee77373fc --- /dev/null +++ b/src/dusk/imgui/ImGuiAchievements.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace dusk { + +class ImGuiAchievements { +public: + void draw(bool& open); + void notify(std::string name); + +private: + void showNotification(); + + std::string m_notifyName; + float m_notifyTimer = 0.f; + std::queue m_notifyQueue; + static constexpr float NOTIFY_DURATION = 4.0f; + static constexpr float NOTIFY_FADE_TIME = 0.5f; +}; + +} // namespace dusk diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 085b34dc2d..c8a9fa86aa 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -14,6 +14,7 @@ #include "SDL3/SDL_events.h" #include "SDL3/SDL_mouse.h" #include "aurora/lib/window.hpp" +#include "dusk/achievements.h" #include "dusk/audio/DuskAudioSystem.h" #include "dusk/config.hpp" #include "dusk/dusk.h" @@ -295,6 +296,15 @@ namespace dusk { UpdateSettings(); + AchievementSystem::get().tick(); + while (AchievementSystem::get().hasPendingUnlock()) { + if (getSettings().game.enableAchievementNotifications) { + m_menuTools.notifyAchievement(AchievementSystem::get().consumePendingUnlock()); + } else { + AchievementSystem::get().consumePendingUnlock(); + } + } + if ((ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) && ImGui::IsKeyPressed(ImGuiKey_R)) { @@ -374,6 +384,7 @@ namespace dusk { m_menuTools.ShowSaveEditor(); } m_menuTools.ShowStateShare(); + m_menuTools.ShowAchievements(); DuskDebugPad(); // temporary, remove later // Hide mouse cursor if the F1 menu is not open and the cursor is idle for 3 seconds. diff --git a/src/dusk/imgui/ImGuiFirstRunPreset.cpp b/src/dusk/imgui/ImGuiFirstRunPreset.cpp index fb2d181562..8009305870 100644 --- a/src/dusk/imgui/ImGuiFirstRunPreset.cpp +++ b/src/dusk/imgui/ImGuiFirstRunPreset.cpp @@ -35,6 +35,7 @@ static void ApplyPresetDusk() { ApplyPresetHD(); auto& s = getSettings(); + s.game.enableAchievementNotifications.setValue(true); s.game.enableQuickTransform.setValue(true); s.game.instantSaves.setValue(true); s.game.midnasLamentNonStop.setValue(true); diff --git a/src/dusk/imgui/ImGuiMenuGame.cpp b/src/dusk/imgui/ImGuiMenuGame.cpp index 7e54f2ffa3..8cb50e143d 100644 --- a/src/dusk/imgui/ImGuiMenuGame.cpp +++ b/src/dusk/imgui/ImGuiMenuGame.cpp @@ -444,6 +444,7 @@ namespace dusk { void ImGuiMenuGame::drawInterfaceMenu() { if (ImGui::BeginMenu("Interface")) { + config::ImGuiCheckbox("Achievement Notifications", getSettings().game.enableAchievementNotifications); config::ImGuiCheckbox("Skip Pre-Launch UI", getSettings().backend.skipPreLaunchUI); config::ImGuiCheckbox("Show Pipeline Compilation", getSettings().backend.showPipelineCompilation); #if DUSK_ENABLE_SENTRY_NATIVE diff --git a/src/dusk/imgui/ImGuiMenuTools.cpp b/src/dusk/imgui/ImGuiMenuTools.cpp index 00a03e635b..a82067a438 100644 --- a/src/dusk/imgui/ImGuiMenuTools.cpp +++ b/src/dusk/imgui/ImGuiMenuTools.cpp @@ -58,6 +58,8 @@ namespace dusk { ImGui::EndDisabled(); } + ImGui::MenuItem("Achievements", nullptr, &m_showAchievements); + #if DUSK_CAN_OPEN_DATA_FOLDER ImGui::Separator(); if (ImGui::MenuItem("Open Data Folder")) { @@ -252,4 +254,12 @@ namespace dusk { ImGui::End(); ImGui::PopFont(); } + + void ImGuiMenuTools::ShowAchievements() { + m_achievementsWindow.draw(m_showAchievements); + } + + void ImGuiMenuTools::notifyAchievement(std::string name) { + m_achievementsWindow.notify(std::move(name)); + } } diff --git a/src/dusk/imgui/ImGuiMenuTools.hpp b/src/dusk/imgui/ImGuiMenuTools.hpp index e48aab2742..de94ad2d8f 100644 --- a/src/dusk/imgui/ImGuiMenuTools.hpp +++ b/src/dusk/imgui/ImGuiMenuTools.hpp @@ -5,6 +5,7 @@ #include #include "imgui.h" +#include "ImGuiAchievements.hpp" #include "ImGuiSaveEditor.hpp" #include "ImGuiStateShare.hpp" @@ -26,6 +27,8 @@ namespace dusk { void ShowAudioDebug(); void ShowSaveEditor(); void ShowStateShare(); + void ShowAchievements(); + void notifyAchievement(std::string name); private: bool m_showDebugOverlay = false; @@ -65,6 +68,9 @@ namespace dusk { bool m_showStateShare = false; ImGuiStateShare m_stateShare; + + bool m_showAchievements = false; + ImGuiAchievements m_achievementsWindow; }; } diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 3e6ee733fe..b6dc559610 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -44,6 +44,7 @@ UserSettings g_userSettings = { .disableMainHUD {"game.disableMainHUD", false}, .pauseOnFocusLost {"game.pauseOnFocusLost", false}, .enableLinkDollRotation = {"game.enableLinkDollRotation", false }, + .enableAchievementNotifications {"game.enableAchievementNotifications", false}, // Graphics .bloomMode {"game.bloomMode", BloomMode::Classic}, @@ -153,6 +154,7 @@ void registerSettings() { Register(g_userSettings.game.freeMagicArmor); Register(g_userSettings.game.restoreWiiGlitches); Register(g_userSettings.game.enableLinkDollRotation); + Register(g_userSettings.game.enableAchievementNotifications); Register(g_userSettings.game.noMissClimbing); Register(g_userSettings.game.noLowHpSound); Register(g_userSettings.game.midnasLamentNonStop);