diff --git a/CMakeLists.txt b/CMakeLists.txt index b0450943f..91b254c3e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,6 @@ cmake_minimum_required(VERSION 3.21) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_VISIBILITY_PRESET hidden) - project(globed2 VERSION 1.0.1) # set ios archs @@ -48,6 +47,7 @@ option(GLOBED_RELEASE "Release build" OFF) if (CMAKE_BUILD_TYPE STREQUAL "Debug" OR "${CMAKE_BUILD_TYPE}asdf" STREQUAL "asdf" OR ENABLE_DEBUG) set(GLOBED_IS_DEBUG ON) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_HAS_ITERATOR_DEBUGGING=0") endif() # Debug/Release options @@ -148,11 +148,6 @@ CPMAddPackage("gh:dankmeme01/asp2#2378a82") # asp defines if (WIN32) - if (GLOBED_IS_DEBUG) - # debug - target_compile_definitions(asp PRIVATE _HAS_ITERATOR_DEBUGGING=0) - endif() - # thingy target_compile_definitions(asp PRIVATE ASP_ENABLE_FORMAT=1) target_compile_definitions(${PROJECT_NAME} PRIVATE ASP_ENABLE_FORMAT=1) @@ -284,7 +279,11 @@ target_compile_definitions(${PROJECT_NAME} PRIVATE CURL_STATICLIB=1) # we are kinda leeching off of geode but we can always snatch those libraries in the future if (WIN32) - set_target_properties(curl PROPERTIES IMPORTED_LOCATION "$ENV{GEODE_SDK}/loader/include/link/${LIB_PLATFORM}/libcurl.lib") + if (GLOBED_IS_DEBUG) + set_target_properties(curl PROPERTIES IMPORTED_LOCATION "$ENV{GEODE_SDK}/loader/include/link/${LIB_PLATFORM}/gd-libcurl.lib") + else() + set_target_properties(curl PROPERTIES IMPORTED_LOCATION "$ENV{GEODE_SDK}/loader/include/link/${LIB_PLATFORM}/libcurl.lib") + endif() else() set_target_properties(curl PROPERTIES IMPORTED_LOCATION "$ENV{GEODE_SDK}/loader/include/link/${LIB_PLATFORM}/libcurl.a") endif() @@ -311,7 +310,7 @@ if (GLOBED_OSS_BUILD) else() if (WIN32) target_link_libraries(${PROJECT_NAME} "${CMAKE_CURRENT_SOURCE_DIR}/libs/bb/bb.lib") - target_link_libraries(${PROJECT_NAME} ntdll.lib userenv.lib runtimeobject.lib Iphlpapi.lib) + target_link_libraries(${PROJECT_NAME} ntdll.lib userenv.lib runtimeobject.lib Iphlpapi.lib bcrypt.lib) else () target_link_libraries(${PROJECT_NAME} "${CMAKE_CURRENT_SOURCE_DIR}/libs/bb/bb-${LIB_PLATFORM}.a") endif() diff --git a/assets/guides/launch-args.md b/assets/guides/launch-args.md index 138fee467..3860ffecf 100644 --- a/assets/guides/launch-args.md +++ b/assets/guides/launch-args.md @@ -16,6 +16,8 @@ Make sure to use the full format and prefix the option name with `globed-`, so i `no-ssl-verification` - disables SSL certificate verification, useful if you can't connect to the server with an error similar to "SSL peer certificate or SSH remote key was not OK." +`reset-settings` - resets all Globed settings + `crt-fix` - unused in release builds, if you are a developer using Wine and experiencing hangs on launch, you may check `src/platform/os/windows/setup.cpp` for more information. ## Debugging (more dev centered) diff --git a/mod.json b/mod.json index 2ff43a7a1..0361a7e92 100644 --- a/mod.json +++ b/mod.json @@ -2,7 +2,7 @@ "id": "dankmeme.globed2", "name": "Globed", "developer": "dankmeme", - "geode": "4.1.0", + "geode": "4.2.0", "version": "v1.7.2", "gd": { "win": "2.2074", @@ -68,4 +68,4 @@ "resources/sounds/*.ogg" ] } -} +} \ No newline at end of file diff --git a/src/defs/util.hpp b/src/defs/util.hpp index 303dda604..b8afdd7c0 100644 --- a/src/defs/util.hpp +++ b/src/defs/util.hpp @@ -22,13 +22,13 @@ struct ConstexprString { hash = util::crypto::adler32(str); } constexpr bool operator!=(const ConstexprString& other) const { - return std::equal(value, value + N, other.value); + return std::equal(value, value + N - 1, other.value); } constexpr operator std::string() const { - return std::string(value, N); + return std::string(value, N - 1); } constexpr operator std::string_view() const { - return std::string_view(value, N); + return std::string_view(value, N - 1); } char value[N]; uint32_t hash; diff --git a/src/managers/settings.cpp b/src/managers/settings.cpp index 40dbdf13e..527091698 100644 --- a/src/managers/settings.cpp +++ b/src/managers/settings.cpp @@ -1,16 +1,362 @@ #include "settings.hpp" +#include #include using namespace geode::prelude; +using SaveSlot = GlobedSettings::SaveSlot; GlobedSettings::LaunchArgs GlobedSettings::_launchArgs = {}; +void dumpJsonToFile(const matjson::Value& val, const std::filesystem::path& path) { + std::ofstream file(path); + if (!file.is_open()) { + log::warn("dumpJsonToFile failed to open file: {}", path); + return; + } + + file << val.dump(4); +} + GlobedSettings::GlobedSettings() { + // necessary cause race conditions n stuff lol + (void) Mod::get()->loadData(); + + this->loadLaunchArguments(); + + // determine save slot path + saveSlotPath = Mod::get()->getSaveDir() / "saveslots"; + std::error_code ec; + + if (!std::filesystem::exists(saveSlotPath, ec)) { + std::filesystem::create_directories(saveSlotPath, ec); + if (ec != std::error_code{}) { + log::error("Failed to create saveslot directory at {}", saveSlotPath); + log::warn("Will be using root of the save directory for storage"); + saveSlotPath = Mod::get()->getSaveDir(); + } + } + + if (this->forceResetSettings) { + this->deleteAllSaveSlotFiles(); + this->forceResetSettings = false; + } + + auto& container = Mod::get()->getSaveContainer(); + + // determine the save slot to use + auto slot = container.get("settingsv2-save-slot").map([](auto& v) { + return v.template as().unwrapOr(-1); + }).unwrapOr(-1); + + if (slot == (size_t)-1) { + // this is user's first time using settings v2, which means it's either first time using the mod, + // or they have not migrated from settings v1 yet. + + // check if there are any v1 settings present + bool hasV1 = false; + for (const auto& [k, v] : container) { + if (k.starts_with("_gsetting-")) { + hasV1 = true; + break; + } + } + + if (hasV1) { + this->migrateFromV1(); + return; + } + + // if the user has no v1 settings, simply select slot 0 and proceed to load settings (which creates defaults if the file is not present) + slot = 0; + } + + this->switchSlot(slot); + + // start worker thread + workerThread.setStartFunction([] { geode::utils::thread::setName("Settings Worker"); }); + workerThread.setLoopFunction([this](auto& stopToken) { + // yeah so all it does is write to a file :D + auto task = this->workerChannel.pop(); + dumpJsonToFile(task.data, task.path); + }); + workerThread.start(); +} + +void GlobedSettings::save() { + this->reflect(TaskType::Save); + + // save json container to file + this->pushWorkerTask(); +} + +void GlobedSettings::reload() { + // reset all settings to their defaults first + this->reflect(TaskType::Reset); + + this->settingsContainer = this->readSlotData(this->selectedSlot).unwrapOrDefault(); + + // now load the container + this->reflect(TaskType::Load); +} + +void GlobedSettings::reset() { + this->reflect(TaskType::Reset); + + // save json container to file + this->pushWorkerTask(); +} + +void GlobedSettings::switchSlot(size_t index) { + this->selectedSlot = index; + Mod::get()->setSavedValue("settingsv2-save-slot", index); this->reload(); } -void GlobedSettings::reflect(TaskType taskType) { +Result GlobedSettings::createSlot() { + auto id = GEODE_UNWRAP(this->getNextFreeSlot()); + + matjson::Value slotData; + slotData["_saveslot-name"] = fmt::format("Slot {}", id); + + // save to file + dumpJsonToFile(slotData, this->pathForSlot(id)); + + return Ok(id); +} + +size_t GlobedSettings::getSelectedSlot() { + return selectedSlot; +} + +const GlobedSettings::LaunchArgs& GlobedSettings::launchArgs() { + return _launchArgs; +} + +std::vector GlobedSettings::getSaveSlots() { + std::vector slots; + + std::error_code ec{}; + std::filesystem::directory_iterator iterator(this->saveSlotPath, ec); + + if (ec != std::error_code{}) { + log::error("Failed to list saveslot directory: {}", ec.message()); + return slots; + } + + for (const auto& entry : iterator) { + if (entry.path().extension() == ".json") { + std::string filename; + + try { + filename = entry.path().filename().string(); + } catch (const std::exception& e) { + continue; + } + + if (!filename.starts_with("saveslot-")) { + continue; + } + + // extract slot id from filename + auto slotIdStr = std::string_view(filename).substr(9, filename.size() - 14); + size_t slotId = util::format::parse(slotIdStr).value_or(-1); + + if (slotId == -1) { + continue; + } + + auto metares = this->readSlotMetadata(entry, slotId); + if (metares) { + slots.push_back(std::move(metares).unwrap()); + } + } + } + + return slots; +} + +void GlobedSettings::migrateFromV1() { + // clone all values from old save container to a new json value, remove _gsetting- prefix + auto& container = Mod::get()->getSaveContainer(); + std::vector toRemove; + + matjson::Value newContainer; + // assign name + newContainer["_saveslot-name"] = "Slot 0"; + + for (const auto& [k, v] : container) { + if (k.starts_with("_gsetting-")) { + toRemove.push_back(k); + // migrate the key + newContainer[k.substr(10)] = v; + } + } + + for (const auto& k : toRemove) { + // TODO: uncomment after mat fixes the bug + // container.erase(k); + } + + (void) Mod::get()->saveData(); + + // dump everything to save slot 0 + dumpJsonToFile(newContainer, this->pathForSlot(0)); + + // reload slot 0 + this->switchSlot(0); +} + +std::filesystem::path GlobedSettings::pathForSlot(size_t idx) { + return this->saveSlotPath / fmt::format("saveslot-{}.json", idx); +} + +Result GlobedSettings::readSlotData(size_t idx) { + return this->readSlotData(this->pathForSlot(idx)); +} + +Result GlobedSettings::readSlotData(const std::filesystem::path& path) { + std::ifstream infile(path); + if (!infile.is_open()) { + return Err("Failed to open file"); + } + + auto value = matjson::Value::parse(infile).unwrapOr(matjson::Value::object()); + + // if slot name is missing, add a default + if (!value.contains("_saveslot-name")) { + value["_saveslot-name"] = fmt::format("Slot {}", this->selectedSlot); + } + + return Ok(std::move(value)); +} + +Result GlobedSettings::readSlotMetadata(size_t idx) { + return this->readSlotMetadata(this->pathForSlot(idx), idx); +} + +Result GlobedSettings::readSlotMetadata(const std::filesystem::path& path, size_t idx) { + auto slot = GEODE_UNWRAP(this->readSlotData(path)); + + // precondition: _saveslot-name must be present, it's safe to assume so since `readSlotData` inserts it if it's missing + // however, we have no guarantees it's a string :) + return Ok(SaveSlot { + .index = idx, + .name = slot["_saveslot-name"].as().unwrapOrElse( + [idx] { return fmt::format("Slot {}", idx); } + ) + }); +} + +Result GlobedSettings::getNextFreeSlot() { + size_t idx = 0; + + std::error_code ec; + while (std::filesystem::exists(this->pathForSlot(idx), ec) && ec == std::error_code{}) { + idx++; + } + + if (ec != std::error_code{}) { + return Err("Filesystem error: {}", ec.message()); + } + + return Ok(idx); +} + +void GlobedSettings::deleteSlot(size_t index) { + std::error_code ec; + auto path = this->saveSlotPath / fmt::format("saveslot-{}.json", index); + + if (std::filesystem::exists(path, ec) && ec == std::error_code{}) { + std::filesystem::remove(path, ec); + + if (ec != std::error_code{}) { + log::warn("Failed to delete slot {}, system error: {}", index, ec.message()); + } + } else { + log::warn("Failed to delete slot {}, was not found", index); + } +} + +void GlobedSettings::renameSlot(size_t index, std::string_view name) { + if (index == selectedSlot) { + settingsContainer["_saveslot-name"] = name; + + // save container to file, synchronously for UI responsiveness + dumpJsonToFile(settingsContainer, this->pathForSlot(index)); + + return; + } + + // if we are renaming an inactive slot, read the file from disk, change the name attribute, and write it again + auto datares = this->readSlotData(index); + if (!datares) { + log::warn("renameSlot called on a non-existent saveslot index: {}", index); + return; + } + + auto data = std::move(datares).unwrap(); + data["_saveslot-name"] = name; + dumpJsonToFile(data, this->pathForSlot(index)); +} + +void GlobedSettings::deleteAllSaveSlotFiles() { + std::error_code ec; + std::filesystem::directory_iterator iterator(this->saveSlotPath, ec); + + if (ec != std::error_code{}) { + log::error("Failed to list saveslot directory: {}", ec.message()); + return; + } + + for (const auto& entry : iterator) { + if (entry.path().extension() == ".json") { + try { + if (!entry.path().filename().string().starts_with("saveslot-")) { + continue; + } + } catch (const std::exception& e) { + continue; + } + + std::filesystem::remove(entry, ec); + if (ec != std::error_code{}) { + log::warn("Failed to delete save slot file: {}", ec.message()); + } + } + } +} + +void GlobedSettings::pushWorkerTask() { + workerChannel.push(WorkerTask { + .data = this->settingsContainer, + .path = this->pathForSlot(this->selectedSlot) + }); +} + +void GlobedSettings::loadLaunchArguments() { + // load launch arguments with reflection + using LaunchMd = boost::describe::describe_members; + + // iterate through them + boost::mp11::mp_for_each([&, this](auto cd) -> void { + using FlagType = typename asp::member_ptr_to_underlying::type; + auto flagArg = FlagType::Name; + + auto& flag = _launchArgs.*cd.pointer; + flag.set(Loader::get()->getLaunchFlag(flagArg)); + }); + + this->forceResetSettings = _launchArgs.resetSettings; + + // some of those options will do nothing unless the mod is built in debug mode +#ifndef GLOBED_DEBUG + _launchArgs.fakeData = false; +#endif +} + +void GlobedSettings::reflect(GlobedSettings::TaskType tt) { + using enum GlobedSettings::TaskType; using SetMd = boost::describe::describe_members; // iterate through all categories @@ -34,88 +380,63 @@ void GlobedSettings::reflect(TaskType taskType) { if (isFlag) { settingKey = fmt::format("_gflag-{}", setName); } else { - settingKey = fmt::format("_gsetting-{}{}", catName, setName); + settingKey = fmt::format("{}{}", catName, setName); } auto& setting = category.*setd.pointer; // now, depending on whether we are saving settings or loading them, do the appropriate thing - switch (taskType) { - case TaskType::SaveSettings: { - if (this->has(settingKey) || setting.get() != Default || isFlag) { - this->store(settingKey, setting.get()); - } - } break; - case TaskType::LoadSettings: { - this->loadOptionalInto(settingKey, setting.ref()); - } break; - case TaskType::ResetSettings: { - // flags cant be cleared unless hard resetting - if (isFlag) break; - } [[fallthrough]]; - case TaskType::HardResetSettings: { - setting.set(Default); - this->clear(settingKey); - } break; + if (!isFlag) { + switch (tt) { + case Save: { + if (this->has(settingKey) || setting.get() != Default || isFlag) { + this->store(settingKey, setting.get()); + } + } break; + case Load: { + this->loadOptionalInto(settingKey, setting.ref()); + } break; + case Reset: [[fallthrough]]; + case HardReset: { + setting.set(Default); + this->clear(settingKey); + } break; + } + } else { + // flags are saved in saved.json, separate from save slots + switch (tt) { + case Save: { + this->storeFlag(settingKey, setting.get()); + } break; + case Load: { + this->loadOptionalFlagInto(settingKey, setting.ref()); + } break; + case Reset: + // flags cant be cleared unless hard resetting + break; + case HardReset: { + setting.set(false); + this->clearFlag(settingKey); + } break; + } } }); }); } -const GlobedSettings::LaunchArgs& GlobedSettings::launchArgs() { - return _launchArgs; -} - -void GlobedSettings::hardReset() { - this->reflect(TaskType::HardResetSettings); -} - -void GlobedSettings::reset() { - this->reflect(TaskType::ResetSettings); -} - -void GlobedSettings::reload() { - this->reflect(TaskType::LoadSettings); - - auto lf = [](std::string_view x, bool& dest) { - dest = Loader::get()->getLaunchFlag(x); - }; - - // load launch arguments - lf("globed-crt-fix", _launchArgs.crtFix); - lf("globed-verbose-curl", _launchArgs.verboseCurl); - lf("globed-skip-preload", _launchArgs.skipPreload); - lf("globed-debug-preload", _launchArgs.debugPreload); - lf("globed-skip-resource-check", _launchArgs.skipResourceCheck); - lf("globed-tracing", _launchArgs.tracing); - lf("globed-no-ssl-verification", _launchArgs.noSslVerification); - lf("globed-fake-server-data", _launchArgs.fakeData); - - // some of those options will do nothing unless the mod is built in debug mode -#ifndef GLOBED_DEBUG - _launchArgs.fakeData = false; -#endif -} - -void GlobedSettings::save() { - this->reflect(TaskType::SaveSettings); +bool GlobedSettings::has(std::string_view key) { + return settingsContainer.contains(key); } -bool GlobedSettings::has(std::string_view key) { - return Mod::get()->hasSavedValue(key); +bool GlobedSettings::hasFlag(std::string_view key) { + return Mod::get()->getSaveContainer().contains(key); } void GlobedSettings::clear(std::string_view key) { - auto& container = Mod::get()->getSaveContainer(); - container.erase(key); + settingsContainer.erase(key); } -// verify that all members are serialized -#include -static void verifySettings() { - globed::unreachable(); - - GlobedSettings* ptr = nullptr; - ByteBuffer bb; - bb.writeValue(*ptr); +void GlobedSettings::clearFlag(std::string_view key) { + Mod::get()->getSaveContainer().erase(key); } + diff --git a/src/managers/settings.hpp b/src/managers/settings.hpp index 598f113f6..8110beb57 100644 --- a/src/managers/settings.hpp +++ b/src/managers/settings.hpp @@ -3,19 +3,133 @@ #include #include #include - #include - #include +#include +#include + +/* + * v2 save system. + * + * Primary difference from v1 is the support of multiple save slots. + * Save slots can be given custom names to distinguish them from each other. +*/ + class GlobedSettings : public SingletonLeakBase { friend class SingletonLeakBase; GlobedSettings(); +private: + template + struct Arg { + static inline constexpr decltype(_name) Name = _name; + + bool _value; + + Arg() : _value(false) {} + Arg(bool v) : _value(v) {} + + operator bool() const { + return _value; + } + + void operator=(bool v) { + _value = v; + } + + void set(bool v) { + _value = v; + } + }; + +public: + // Launch args + struct LaunchArgs { + Arg<"globed-crt-fix"> crtFix; + Arg<"globed-verbose-curl"> verboseCurl; + Arg<"globed-skip-preload"> skipPreload; + Arg<"globed-debug-preload"> debugPreload; + Arg<"globed-skip-resource-check"> skipResourceCheck; + Arg<"globed-tracing"> tracing; + Arg<"globed-no-ssl-verification"> noSslVerification; + Arg<"globed-fake-server-data"> fakeData; + Arg<"globed-reset-settings"> resetSettings; + }; + +private: + static LaunchArgs _launchArgs; + + struct WorkerTask { + matjson::Value data; + std::filesystem::path path; + }; + + asp::Thread<> workerThread; + asp::Channel workerChannel; + size_t selectedSlot = 0; + matjson::Value settingsContainer; + std::filesystem::path saveSlotPath; + bool forceResetSettings = false; + public: - // Save all settings to the geode save container + // Forcefully saves all settings to a file (in a worker thread, to prevent slowdowns) void save(); + // Reloads settings from the save file, if it exists + void reload(); + + void reset(); + + // Selects a save slot, reloads settings. + void switchSlot(size_t index); + + // Creates a new save slot, returns its index + Result createSlot(); + + size_t getSelectedSlot(); + + // Get the launch arguments for this session + const LaunchArgs& launchArgs(); + + struct SaveSlot { + size_t index; + std::string name; + }; + + // Return all created save slots + std::vector getSaveSlots(); + + Result getNextFreeSlot(); + + void deleteSlot(size_t index); + void renameSlot(size_t index, std::string_view name); + +private: + void migrateFromV1(); + + std::filesystem::path pathForSlot(size_t idx); + + Result readSlotData(size_t idx); + Result readSlotData(const std::filesystem::path& path); + + Result readSlotMetadata(size_t idx); + Result readSlotMetadata(const std::filesystem::path& path, size_t idx); + + void loadLaunchArguments(); + void deleteAllSaveSlotFiles(); + void pushWorkerTask(); + void resetNoSave(); + + enum class TaskType { + Save, Load, Reset, HardReset + }; + + void reflect(TaskType tt); + +public: + // Util classes for defining settings + // T -> T, but float -> globed::ConstexprFloat, as floats cant be used as template arguments template using TypeFixup = std::conditional_t, globed::ConstexprFloat, T>; @@ -234,48 +348,37 @@ class GlobedSettings : public SingletonLeakBase { Admin admin; Flags flags; -private: - // Launch args - static struct LaunchArgs { - bool crtFix = false; - bool verboseCurl = false; - bool skipPreload = false; - bool debugPreload = false; - bool skipResourceCheck = false; - bool tracing = false; - bool noSslVerification = false; - bool fakeData = false; - } _launchArgs; -public: - - const LaunchArgs& launchArgs(); - - // Reset everything, including flags - void hardReset(); - - // Reset all settings, excluding flags - void reset(); - - // Reload all settings from the geode save container - void reload(); - - enum class TaskType { - SaveSettings, LoadSettings, ResetSettings, HardResetSettings - }; - - void reflect(TaskType type); + inline UserPrivacyFlags getPrivacyFlags() { + return UserPrivacyFlags { + .hideFromLists = globed.isInvisible, + .noInvites = globed.noInvites, + .hideInGame = globed.hideInGame, + .hideRoles = globed.hideRoles, + }; + } +private: bool has(std::string_view key); + bool hasFlag(std::string_view key); void clear(std::string_view key); + void clearFlag(std::string_view key); template void store(std::string_view key, const T& val) { - geode::Mod::get()->setSavedValue(key, val); + settingsContainer[key] = val; + } + + inline void storeFlag(std::string_view key, bool val) { + Mod::get()->setSavedValue(key, val); } template T load(std::string_view key) { - return geode::Mod::get()->getSavedValue(key); + return settingsContainer[key].template as().unwrapOr(T()); + } + + bool loadFlag(std::string_view key) { + return Mod::get()->getSavedValue(key); } // If setting is present, loads into `into`. Otherwise does nothing. @@ -286,6 +389,13 @@ class GlobedSettings : public SingletonLeakBase { } } + template + void loadOptionalFlagInto(std::string_view key, T& into) { + if (this->hasFlag(key)) { + into = this->loadFlag(key); + } + } + template std::optional loadOptional(std::string_view key) { return this->has(key) ? this->load(key) : std::nullopt; @@ -295,19 +405,18 @@ class GlobedSettings : public SingletonLeakBase { T loadOrDefault(std::string_view key, const T& defaultval) { return this->has(key) ? this->load(key) : defaultval; } - - UserPrivacyFlags getPrivacyFlags() { - return UserPrivacyFlags { - .hideFromLists = globed.isInvisible, - .noInvites = globed.noInvites, - .hideInGame = globed.hideInGame, - .hideRoles = globed.hideRoles, - }; - } }; /* Enable reflection */ +// Launch args + +GLOBED_SERIALIZABLE_STRUCT(GlobedSettings::LaunchArgs, ( + crtFix, verboseCurl, skipPreload, debugPreload, skipResourceCheck, tracing, noSslVerification, fakeData, resetSettings +)); + +// Settings + GLOBED_SERIALIZABLE_STRUCT(GlobedSettings::Globed, ( autoconnect, tpsCap, preloadAssets, deferPreloadAssets, invitesFrom, editorSupport, increaseLevelList, fragmentationLimit, compressedPlayerCount, useDiscordRPC, editorChanges, changelogPopups, pinnedLevelCollapsed, isInvisible, noInvites, hideInGame, hideRoles diff --git a/src/ui/general/list/list.hpp b/src/ui/general/list/list.hpp index 29ba72a69..b59cb23b0 100644 --- a/src/ui/general/list/list.hpp +++ b/src/ui/general/list/list.hpp @@ -300,6 +300,14 @@ class GlobedListLayer : public cocos2d::CCLayer { return cell; } + float getListWidth() { + return width; + } + + float getListHeight() { + return height; + } + template static GlobedListLayer* create(float width, float height, const T& background, float cellHeight = 0.0f, GlobedListBorderType borderType = GlobedListBorderType::None) { auto ret = new GlobedListLayer(); diff --git a/src/ui/menu/settings/save_slot_switcher_popup.cpp b/src/ui/menu/settings/save_slot_switcher_popup.cpp new file mode 100644 index 000000000..8441ce20e --- /dev/null +++ b/src/ui/menu/settings/save_slot_switcher_popup.cpp @@ -0,0 +1,200 @@ +#include "save_slot_switcher_popup.hpp" + +#include +#include +#include + +using namespace geode::prelude; +using SaveSlot = GlobedSettings::SaveSlot; + +bool SaveSlotSwitcherPopup::setup() { + auto rlayout = util::ui::getPopupLayoutAnchored(m_size); + this->setTitle("Setting Profiles"); + + Build(GlobedListLayer::createForComments(LIST_WIDTH, LIST_HEIGHT)) + .pos(rlayout.fromCenter(0.f, -15.f)) + .parent(m_mainLayer) + .store(list); + + this->refreshList(true); + + return true; +} + +void SaveSlotSwitcherPopup::refreshList(bool scrollToTop) { + this->saveSlots = GlobedSettings::get().getSaveSlots(); + auto lastScrollPos = this->list->getScrollPos(); + + list->removeAllCells(false); + + for (auto& slot : this->saveSlots) { + list->addCellFast(&slot, this); + } + + // button for adding a new slot + list->addCell(nullptr, this); + + list->sort([](ListCell* cell1, ListCell* cell2) { + // the add slot button always goes last + if (!cell1->saveSlot) { + return false; + } + + // sort by index + return cell1->saveSlot->index < cell2->saveSlot->index; + }); + + list->forceUpdate(); + + if (scrollToTop) { + list->scrollToTop(); + } else { + list->scrollToPos(lastScrollPos); + } +} + +SaveSlotSwitcherPopup* SaveSlotSwitcherPopup::create() { + auto ret = new SaveSlotSwitcherPopup; + if (ret->initAnchored(POPUP_WIDTH, POPUP_HEIGHT)) { + ret->autorelease(); + return ret; + } + + delete ret; + return nullptr; +} + +bool SaveSlotSwitcherPopup::ListCell::init(SaveSlot* slot) { + this->setContentSize(CCSize{LIST_WIDTH, CELL_HEIGHT}); + auto rlayout = util::ui::getNodeLayout(this->getContentSize()); + + Build::create(slot->name.c_str(), "bigFont.fnt") + .anchorPoint(0.f, 0.5f) + .limitLabelWidth(220.f, 0.6f, 0.01f) + .pos(rlayout.fromLeft(8.f)) + .parent(this); + + auto* buttonMenu = Build::create() + .layout( + RowLayout::create() + ->setAxisReverse(true) + ->setAxisAlignment(AxisAlignment::End) + ->setGap(3.f) + ) + .contentSize(this->getContentSize() + CCSize{-16.f, 0.f}) + .pos(this->getContentSize() / 2.f) + .parent(this) + .collect(); + + bool active = slot->index == GlobedSettings::get().getSelectedSlot(); + + // button for switching to the slot + auto texture = active ? "GJ_selectSongOnBtn_001.png" : "GJ_playBtn2_001.png"; + + auto switchBtn = Build::createSpriteName(texture) + .with([&](auto* item) { + util::ui::rescaleToMatch(item, {CELL_HEIGHT * 0.75f, CELL_HEIGHT * 0.75f}); + }) + .intoMenuItem([this, slot, active](auto) { + if (active) { + return; + } + + auto& settings = GlobedSettings::get(); + settings.switchSlot(slot->index); + + this->popup->refreshList(); + }) + .parent(buttonMenu) + .collect(); + + // button for renaming the slot + auto spr = Build::createSpriteName("pencil.png"_spr) + .collect(); + + Build::create(spr) + .with([&](auto* item) { + util::ui::rescaleToMatch(item, switchBtn); + }) + .intoMenuItem([this, slot, active](auto) { + AskInputPopup::create("Rename Profile", [this, slot, active](std::string_view name) { + GlobedSettings::get().renameSlot(slot->index, name); + this->popup->refreshList(); + }, 64, slot->name, util::misc::STRING_PRINTABLE_INPUT)->show(); + }) + .parent(buttonMenu); + + // button for deleting the slot + if (!active) { + Build::createSpriteName("GJ_deleteBtn_001.png") + .with([&](auto* item) { + util::ui::rescaleToMatch(item, switchBtn); + }) + .intoMenuItem([this, slot, active](auto) { + auto& settings = GlobedSettings::get(); + settings.deleteSlot(slot->index); + + this->popup->refreshList(); + }) + .parent(buttonMenu); + } + + buttonMenu->updateLayout(); + + return true; +} + +bool SaveSlotSwitcherPopup::ListCell::initAddButton() { + this->setContentSize(CCSize{LIST_WIDTH, CELL_HEIGHT}); + auto rlayout = util::ui::getNodeLayout(this->getContentSize()); + + auto* buttonMenu = Build::create() + .layout(RowLayout::create()) + .contentSize(this->getContentSize() + CCSize{-16.f, 0.f}) + .pos(this->getContentSize() / 2.f) + .parent(this) + .collect(); + + Build::createSpriteName("GJ_plusBtn_001.png") + .with([&](auto* node) { + util::ui::rescaleToMatch(node, {CELL_HEIGHT * 0.8f, CELL_HEIGHT * 0.8f}); + }) + .intoMenuItem([this](auto) { + auto& settings = GlobedSettings::get(); + + auto res = settings.createSlot(); + if (!res) { + FLAlertLayer::create("Error", fmt::format("Failed to create new save slot: {}", res.unwrapErr()), "Ok")->show(); + return; + } + + this->popup->refreshList(); + }) + .scaleMult(1.15f) + .parent(buttonMenu); + + buttonMenu->updateLayout(); + + return true; +} + +SaveSlotSwitcherPopup::ListCell* SaveSlotSwitcherPopup::ListCell::create(SaveSlot* slot, SaveSlotSwitcherPopup* popup) { + auto ret = new ListCell; + ret->popup = popup; + + bool initRes; + + if (slot == nullptr) { + initRes = ret->initAddButton(); + } else { + initRes = ret->init(slot); + } + + if (initRes) { + ret->autorelease(); + return ret; + } + + delete ret; + return nullptr; +} diff --git a/src/ui/menu/settings/save_slot_switcher_popup.hpp b/src/ui/menu/settings/save_slot_switcher_popup.hpp new file mode 100644 index 000000000..26eee5306 --- /dev/null +++ b/src/ui/menu/settings/save_slot_switcher_popup.hpp @@ -0,0 +1,40 @@ +#pragma once +#include + +#include +#include + +class SaveSlotSwitcherPopup : public geode::Popup<> { +public: + static constexpr float POPUP_WIDTH = 360.f; + static constexpr float POPUP_HEIGHT = 220.f; + + static constexpr float LIST_WIDTH = POPUP_WIDTH * 0.85f; + static constexpr float LIST_HEIGHT = POPUP_HEIGHT * 0.65f; + + static SaveSlotSwitcherPopup* create(); + +private: + using SaveSlot = GlobedSettings::SaveSlot; + + bool setup() override; + void refreshList(bool scrollToTop = false); + + class ListCell : public cocos2d::CCNode { + static constexpr float CELL_HEIGHT = 36.f; + using SaveSlot = GlobedSettings::SaveSlot; + + public: + SaveSlot* saveSlot = nullptr; + SaveSlotSwitcherPopup* popup = nullptr; + + static ListCell* create(SaveSlot*, SaveSlotSwitcherPopup* popup); + + private: + bool init(SaveSlot*); + bool initAddButton(); + }; + + GlobedListLayer* list; + std::vector saveSlots; +}; diff --git a/src/ui/menu/settings/settings_layer.cpp b/src/ui/menu/settings/settings_layer.cpp index 34489153b..b05119221 100644 --- a/src/ui/menu/settings/settings_layer.cpp +++ b/src/ui/menu/settings/settings_layer.cpp @@ -2,6 +2,7 @@ #include "setting_header_cell.hpp" #include "setting_cell.hpp" +#include "save_slot_switcher_popup.hpp" #include #include #include @@ -14,6 +15,7 @@ static constexpr float TAB_SCALE = 0.85f; bool GlobedSettingsLayer::init() { if (!CCLayer::init()) return false; + this->prevSettingsSlot = GlobedSettings::get().getSelectedSlot(); this->setID("GlobedSettingsLayer"_spr); auto winsize = CCDirector::get()->getWinSize(); @@ -91,7 +93,17 @@ bool GlobedSettingsLayer::init() { this->addChild(tabsGradientNode); this->addChild(tabsGradientStencil); - Build::createSpriteName("GJ_deleteBtn_001.png") + auto rightMenu = Build::create() + .layout(ColumnLayout::create()->setAxisAlignment(AxisAlignment::Start)) + .anchorPoint(1.f, 0.f) + .pos(winsize.width - 8.f, 8.f) + .contentSize(48.f, winsize.height) + .id("right-side-menu") + .parent(this) + .collect(); + + // Reset settings button + auto resetBtn = Build::createSpriteName("GJ_deleteBtn_001.png") .intoMenuItem([this](auto) { geode::createQuickPopup("Reset all settings", "Are you sure you want to reset all settings? This action is irreversible.", "Cancel", "Ok", [this](auto, bool accepted) { if (accepted) { @@ -101,19 +113,54 @@ bool GlobedSettingsLayer::init() { }); }) .id("btn-reset") - .pos(winsize.width - 30.f, 30.f) - .intoNewParent(CCMenu::create()) - .id("reset-menu") - .pos(0.f, 0.f) - .parent(this); + .parent(rightMenu) + .collect(); + + // TODO: icon + // Save slot button + Build::createSpriteName("GJ_savedSongsBtn_001.png") + .with([&](auto* item) { + util::ui::rescaleToMatch(item, resetBtn); + }) + .intoMenuItem([this](auto) { + SaveSlotSwitcherPopup::create()->show(); + }) + .id("btn-save-slots") + .parent(rightMenu); + + rightMenu->updateLayout(); util::ui::prepareLayer(this); this->remakeList(); + this->scheduleUpdate(); return true; } +void GlobedSettingsLayer::update(float dt) { + if (GlobedSettings::get().getSelectedSlot() == this->prevSettingsSlot) { + return; + } + + this->prevSettingsSlot = GlobedSettings::get().getSelectedSlot(); + + // yeah so alk would probably demote me from lead dev for this code but it is what it is + + auto newLayer = GlobedSettingsLayer::create(); + + for (auto tab : storedTabs) { + tab.second->removeFromParent(); + } + + this->storedTabs = std::move(newLayer->storedTabs); + this->settingCells = std::move(newLayer->settingCells); + this->onTabById(currentTab); + + this->getParent()->addChild(newLayer); + newLayer->removeFromParent(); +} + void GlobedSettingsLayer::onTab(CCObject* sender) { for (const auto& [_, tab] : storedTabs) { if (tab->getParent()) { @@ -125,7 +172,11 @@ void GlobedSettingsLayer::onTab(CCObject* sender) { return; } - currentTab = sender->getTag(); + this->onTabById(sender->getTag()); +} + +void GlobedSettingsLayer::onTabById(int tag) { + currentTab = tag; this->addChild(storedTabs[currentTab]); // i stole this from geode diff --git a/src/ui/menu/settings/settings_layer.hpp b/src/ui/menu/settings/settings_layer.hpp index 4621d13a2..8b4926654 100644 --- a/src/ui/menu/settings/settings_layer.hpp +++ b/src/ui/menu/settings/settings_layer.hpp @@ -21,10 +21,13 @@ class GlobedSettingsLayer : public cocos2d::CCLayer { cocos2d::CCSprite *tabsGradientSprite, *tabsGradientStencil; std::unordered_map> settingCells; int currentTab = -1; + size_t prevSettingsSlot = 0; bool init() override; + void update(float dt) override; void keyBackClicked() override; void onTab(cocos2d::CCObject* sender); + void onTabById(int tag); void remakeList(); void createSettingsCells(int category); diff --git a/src/util/ui.cpp b/src/util/ui.cpp index 33c4d46c7..02f19e019 100644 --- a/src/util/ui.cpp +++ b/src/util/ui.cpp @@ -157,6 +157,22 @@ namespace util::ui { return this->centerBottom + off; } + CCPoint PopupLayout::fromLeft(float x) { + return fromLeft({x, 0.f}); + } + + CCPoint PopupLayout::fromLeft(CCSize off) { + return this->centerLeft + off; + } + + CCPoint PopupLayout::fromRight(float x) { + return fromRight({x, 0.f}); + } + + CCPoint PopupLayout::fromRight(CCSize off) { + return this->centerRight + CCSize{-off.width, off.height}; + } + CCPoint PopupLayout::fromCenter(CCSize off) { return this->center + off; } @@ -227,6 +243,14 @@ namespace util::ui { return layout; } + PopupLayout getNodeLayout(const CCSize& nodeSize) { + return popupLayoutWith(nodeSize, false); + } + + PopupLayout getNodeLayout(float width, float height) { + return getNodeLayout(CCSize{width, height}); + } + PopupLayout getPopupLayout(const CCSize& popupSize) { return popupLayoutWith(popupSize, true); } diff --git a/src/util/ui.hpp b/src/util/ui.hpp index c3fdd6331..5c2ae7311 100644 --- a/src/util/ui.hpp +++ b/src/util/ui.hpp @@ -57,6 +57,12 @@ namespace util::ui { cocos2d::CCPoint fromBottom(float y); cocos2d::CCPoint fromBottom(cocos2d::CCSize off); + cocos2d::CCPoint fromLeft(float x); + cocos2d::CCPoint fromLeft(cocos2d::CCSize off); + + cocos2d::CCPoint fromRight(float x); + cocos2d::CCPoint fromRight(cocos2d::CCSize off); + cocos2d::CCPoint fromCenter(cocos2d::CCSize off); cocos2d::CCPoint fromCenter(float x, float y); @@ -71,10 +77,13 @@ namespace util::ui { cocos2d::CCPoint fromTopLeft(cocos2d::CCSize off); cocos2d::CCPoint fromTopLeft(float x, float y); + }; [[deprecated("use getPopupLayoutAnchored")]] PopupLayout getPopupLayout(const cocos2d::CCSize& popupSize); PopupLayout getPopupLayoutAnchored(const cocos2d::CCSize& popupSize); + PopupLayout getNodeLayout(const cocos2d::CCSize& nodeSize); + PopupLayout getNodeLayout(float width, float height); cocos2d::CCNode* findChildByMenuSelectorRecursive(cocos2d::CCNode* node, uintptr_t function);