From ad7ebdf5554ca08b0f93e5e0b1fd3892b2dcd714 Mon Sep 17 00:00:00 2001 From: KhromeHearts Date: Thu, 15 Oct 2026 04:31:36 -0600 Subject: [PATCH 01/18] hub: added installed-mod status, filters and settings Plain logic behind the new Manage Mods window: - InstalledMod: snapshot of a recognized mod, its status (Enabled, Disabled, Failed, Missing dependency, Restart required) derived from NML's load state and saved disabled flag, and where it was found. - InstalledModQuery: filter, search (name or author) and sort. - ModHubSettings: preferences in NML/mod_hub_settings.json. Search text is not stored. - ModManagerService: reads AllRecognizedMods and applies choices through the existing TryEnableMod / DisableMod paths. --- services/InstalledMod.cs | 129 +++++++++++++++++++++++++ services/InstalledModQuery.cs | 88 +++++++++++++++++ services/ModHubSettings.cs | 70 ++++++++++++++ services/ModManagerService.cs | 172 ++++++++++++++++++++++++++++++++++ 4 files changed, 459 insertions(+) create mode 100644 services/InstalledMod.cs create mode 100644 services/InstalledModQuery.cs create mode 100644 services/ModHubSettings.cs create mode 100644 services/ModManagerService.cs diff --git a/services/InstalledMod.cs b/services/InstalledMod.cs new file mode 100644 index 0000000..ed98238 --- /dev/null +++ b/services/InstalledMod.cs @@ -0,0 +1,129 @@ +using NeoModLoader.api; + +namespace NeoModLoader.services; + +/// +/// State of an installed mod as shown in Manage Mods. +/// +internal enum InstalledModStatus +{ + Enabled, + Disabled, + Failed, + MissingDependency, + + /// + /// The saved choice differs from what is loaded now and applies at the next start. + /// + RestartRequired +} + +/// +/// Where a mod was found. +/// +internal enum ModSource +{ + Workshop, + ModsFolder, + NativeModsFolder, + BepInEx, + Other +} + +/// +/// Snapshot of one recognized mod, taken when Manage Mods refreshes. +/// +internal sealed class InstalledMod +{ + public ModDeclare Declaration { get; set; } + + /// + /// Localized display name. + /// + public string Name { get; set; } = ""; + + /// + /// Localized display author. + /// + public string Author { get; set; } = ""; + + public InstalledModStatus Status { get; set; } + + /// + /// The user's saved choice, which can differ from what is loaded until the next start. + /// + public bool Enabled { get; set; } + + public ModSource Source { get; set; } + + /// + /// When the mod's folder or mod.json was last written on this computer (installed, updated or edited). + /// + public DateTime LastChanged { get; set; } + + public bool HasProblem => Status is InstalledModStatus.Failed or InstalledModStatus.MissingDependency; + + /// NML's load state of the mod in this session + /// Whether the mod is saved as disabled + /// Whether a required dependency is not installed + public static InstalledModStatus ResolveStatus(ModState pState, bool pDisabled, bool pMissingDependency) + { + if (pState == ModState.LOADED) + { + // NML cannot unload a mod, so disabling a loaded one only takes effect at the next start. + return pDisabled ? InstalledModStatus.RestartRequired : InstalledModStatus.Enabled; + } + + if (pDisabled) return InstalledModStatus.Disabled; + + // Saved as enabled but still DISABLED in this session: it was enabled for the next start (Enable All). + if (pState == ModState.DISABLED) return InstalledModStatus.RestartRequired; + + return pMissingDependency ? InstalledModStatus.MissingDependency : InstalledModStatus.Failed; + } + + /// Declared mod type + /// Whether the mod was found in Steam's Workshop content folder + /// The mod's folder + /// The game's Mods folder + /// The game's StreamingAssets/mods folder + public static ModSource DetectSource(ModTypeEnum pModType, bool pIsWorkshopLoaded, string pFolderPath, + string pModsPath, string pNativeModsPath) + { + if (pModType == ModTypeEnum.BEPINEX) return ModSource.BepInEx; + if (pIsWorkshopLoaded) return ModSource.Workshop; + if (IsInside(pFolderPath, pModsPath)) return ModSource.ModsFolder; + if (IsInside(pFolderPath, pNativeModsPath)) return ModSource.NativeModsFolder; + return ModSource.Other; + } + + /// + /// Read the Workshop item id from a folder under "workshop/content/<game id>/<item id>". + /// + public static bool TryGetWorkshopId(string pFolderPath, ulong pGameId, out ulong pFileId) + { + pFileId = 0; + if (string.IsNullOrEmpty(pFolderPath)) return false; + + string[] segments = pFolderPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string game_id = pGameId.ToString(); + for (int i = 2; i < segments.Length - 1; i++) + { + if (segments[i] == game_id && segments[i - 1] == "content" && segments[i - 2] == "workshop") + { + return ulong.TryParse(segments[i + 1], out pFileId); + } + } + + return false; + } + + private static bool IsInside(string pPath, string pFolder) + { + if (string.IsNullOrEmpty(pPath) || string.IsNullOrEmpty(pFolder)) return false; + + string folder = Path.GetFullPath(pFolder).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return Path.GetFullPath(pPath).StartsWith(folder, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/services/InstalledModQuery.cs b/services/InstalledModQuery.cs new file mode 100644 index 0000000..72e33ef --- /dev/null +++ b/services/InstalledModQuery.cs @@ -0,0 +1,88 @@ +namespace NeoModLoader.services; + +/// +/// Which mods Manage Mods shows. +/// +internal enum InstalledModFilter +{ + All, + Enabled, + Disabled, + + /// + /// Failed to load or missing a dependency. + /// + Problems +} + +internal enum InstalledModSort +{ + NameAscending, + NameDescending, + + /// + /// Newest first. + /// + RecentlyChanged +} + +/// +/// What the search text is matched against. +/// +internal enum ModSearchMode +{ + Name, + Author +} + +/// +/// Filter, search and order of the Manage Mods list. +/// +internal sealed class InstalledModQuery +{ + public InstalledModFilter Filter { get; set; } = InstalledModFilter.All; + public InstalledModSort Sort { get; set; } = InstalledModSort.NameAscending; + public ModSearchMode SearchMode { get; set; } = ModSearchMode.Name; + + /// + /// Case-insensitive part of a name or author. Empty matches everything. + /// + public string SearchText { get; set; } = ""; + + public List Apply(IEnumerable pMods) + { + IEnumerable matches = pMods.Where(mod => MatchesFilter(mod) && MatchesSearch(mod)); + return Order(matches).ToList(); + } + + private bool MatchesFilter(InstalledMod pMod) + { + return Filter switch + { + InstalledModFilter.Enabled => pMod.Enabled, + InstalledModFilter.Disabled => !pMod.Enabled, + InstalledModFilter.Problems => pMod.HasProblem, + _ => true + }; + } + + private bool MatchesSearch(InstalledMod pMod) + { + string search_text = SearchText?.Trim(); + if (string.IsNullOrEmpty(search_text)) return true; + + string field = SearchMode == ModSearchMode.Author ? pMod.Author : pMod.Name; + return field != null && field.IndexOf(search_text, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private IEnumerable Order(IEnumerable pMods) + { + return Sort switch + { + InstalledModSort.NameDescending => pMods.OrderByDescending(mod => mod.Name, StringComparer.OrdinalIgnoreCase), + InstalledModSort.RecentlyChanged => pMods.OrderByDescending(mod => mod.LastChanged) + .ThenBy(mod => mod.Name, StringComparer.OrdinalIgnoreCase), + _ => pMods.OrderBy(mod => mod.Name, StringComparer.OrdinalIgnoreCase) + }; + } +} diff --git a/services/ModHubSettings.cs b/services/ModHubSettings.cs new file mode 100644 index 0000000..7626391 --- /dev/null +++ b/services/ModHubSettings.cs @@ -0,0 +1,70 @@ +using NeoModLoader.constants; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace NeoModLoader.services; + +/// +/// Mod Hub preferences, stored in NML/mod_hub_settings.json. +/// +/// +/// Search text is deliberately not stored, so an old search never narrows the list after a restart. +/// Kept out of mod_compile_records.json, which maps mod UIDs to compile records. +/// +internal sealed class ModHubSettings +{ + private static readonly string default_path = Path.Combine(Paths.NMLPath, "mod_hub_settings.json"); + private static ModHubSettings instance; + + [JsonProperty("manager_filter")] + [JsonConverter(typeof(StringEnumConverter))] + public InstalledModFilter ManagerFilter { get; set; } = InstalledModFilter.All; + + [JsonProperty("manager_sort")] + [JsonConverter(typeof(StringEnumConverter))] + public InstalledModSort ManagerSort { get; set; } = InstalledModSort.NameAscending; + + [JsonProperty("manager_search_mode")] + [JsonConverter(typeof(StringEnumConverter))] + public ModSearchMode ManagerSearchMode { get; set; } = ModSearchMode.Name; + + /// + /// Settings of this game session, read from disk on first use. + /// + public static ModHubSettings Instance => instance ??= Load(default_path); + + /// + /// Read settings, falling back to defaults (with a warning) when the file is invalid. + /// + public static ModHubSettings Load(string pPath) + { + if (!File.Exists(pPath)) return new ModHubSettings(); + try + { + return JsonConvert.DeserializeObject(File.ReadAllText(pPath)) ?? + throw new JsonException("The file is empty."); + } + catch (Exception e) + { + LogService.LogWarning($"Ignoring invalid Mod Hub settings at {pPath}: {e.Message}"); + return new ModHubSettings(); + } + } + + public void Save() + { + Save(default_path); + } + + public void Save(string pPath) + { + try + { + File.WriteAllText(pPath, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + catch (Exception e) + { + LogService.LogError($"Failed to save Mod Hub settings to {pPath}: {e.Message}"); + } + } +} diff --git a/services/ModManagerService.cs b/services/ModManagerService.cs new file mode 100644 index 0000000..37a0155 --- /dev/null +++ b/services/ModManagerService.cs @@ -0,0 +1,172 @@ +using NeoModLoader.api; +using NeoModLoader.constants; +using NeoModLoader.utils; +using UnityEngine; + +namespace NeoModLoader.services; + +/// +/// Whether a dependency of a mod is available. +/// +internal enum DependencyState +{ + Loaded, + + /// + /// Recognized by NML but not loaded in this session. + /// + Installed, + Missing +} + +/// +/// Reads NML's recognized mods for Manage Mods and applies the user's choices through the existing +/// enable and disable paths. +/// +internal static class ModManagerService +{ + public static List GetInstalledMods() + { + return WorldBoxMod.AllRecognizedMods.Select(pair => Describe(pair.Key, pair.Value)).ToList(); + } + + public static InstalledMod Describe(ModDeclare pMod) + { + return Describe(pMod, WorldBoxMod.AllRecognizedMods[pMod]); + } + + private static InstalledMod Describe(ModDeclare pMod, ModState pState) + { + bool disabled = ModInfoUtils.isModDisabled(pMod.UID); + bool missing_dependency = pMod.Dependencies.Any(uid => GetDependencyState(uid) == DependencyState.Missing); + return new InstalledMod + { + Declaration = pMod, + Name = pMod.GetDisplayName() ?? "", + Author = pMod.GetDisplayAuthor() ?? "", + Enabled = !disabled, + Status = InstalledMod.ResolveStatus(pState, disabled, missing_dependency), + Source = InstalledMod.DetectSource(pMod.ModType, pMod.IsWorkshopLoaded, pMod.FolderPath, Paths.ModsPath, + Paths.NativeModsPath), + LastChanged = GetLastChanged(pMod.FolderPath) + }; + } + + public static DependencyState GetDependencyState(string pModUid) + { + if (!ModInfoUtils.TryGetRecognizedMod(pModUid, out ModDeclare dependency)) return DependencyState.Missing; + return WorldBoxMod.AllRecognizedMods[dependency] == ModState.LOADED + ? DependencyState.Loaded + : DependencyState.Installed; + } + + public static Sprite GetIcon(ModDeclare pMod) + { + if (!string.IsNullOrEmpty(pMod.IconPath)) + { + string icon_path = Path.Combine(pMod.FolderPath, pMod.IconPath); + Sprite icon = File.Exists(icon_path) ? SpriteLoadUtils.LoadSingleSprite(icon_path) : null; + if (icon != null) return icon; + } + + return InternalResourcesGetter.GetIcon(); + } + + /// + /// Enable a disabled mod (compiled and loaded now, as before) or disable an enabled one (a loaded mod stays + /// loaded until the next start). + /// + public static void Toggle(ModDeclare pMod) + { + if (ModInfoUtils.isModDisabled(pMod.UID)) + { + ModCompileLoadService.TryEnableMod(pMod); + } + else + { + ModCompileLoadService.DisableMod(pMod); + } + } + + public static int CountDisabled() + { + return WorldBoxMod.AllRecognizedMods.Keys.Count(mod => ModInfoUtils.isModDisabled(mod.UID)); + } + + public static int CountEnabled() + { + return WorldBoxMod.AllRecognizedMods.Keys.Count(mod => !ModInfoUtils.isModDisabled(mod.UID)); + } + + /// + /// Save every disabled mod as enabled. Nothing is compiled or loaded now; they load at the next start. + /// + public static void EnableAll() + { + foreach (ModDeclare mod in WorldBoxMod.AllRecognizedMods.Keys.ToList()) + { + if (ModInfoUtils.isModDisabled(mod.UID)) ModInfoUtils.setModDisabled(mod.UID, false, false); + } + + ModInfoUtils.SaveModRecords(); + } + + /// + /// Save every mod as disabled. Loaded mods stay loaded until the next start. + /// + public static void DisableAll() + { + foreach (ModDeclare mod in WorldBoxMod.AllRecognizedMods.Keys.ToList()) + { + if (!ModInfoUtils.isModDisabled(mod.UID)) ModDepenSolveService.SetModDesiredEnabled(mod, false, false); + } + + ModInfoUtils.SaveModRecords(); + } + + /// + /// The config of a loaded mod, or null when it has none. Every is + /// , so a config without any item counts as none. + /// + public static ModConfig GetConfig(ModDeclare pMod) + { + if (!WorldBoxMod.TryGetLoadedMod(pMod, out IMod mod) || mod is not IConfigurable configurable) return null; + + ModConfig config = configurable.GetConfig(); + return config != null && config._config.Values.Any(group => group.Count > 0) ? config : null; + } + + /// + /// Hot reload is a developer tool: offered only in the game's developer mode, as before. + /// + public static bool CanReload(ModDeclare pMod) + { + return Config.isEditor && ModReloadService.CanReload(pMod); + } + + public static void OpenFolder(string pFolderPath) + { + if (!Directory.Exists(pFolderPath)) + { + LogService.LogWarning($"Cannot open missing folder {pFolderPath}"); + return; + } + + Application.OpenURL(pFolderPath); + } + + private static DateTime GetLastChanged(string pFolderPath) + { + try + { + DateTime folder = Directory.GetLastWriteTimeUtc(pFolderPath); + DateTime mod_json = File.GetLastWriteTimeUtc(Path.Combine(pFolderPath, Paths.ModDeclarationFileName)); + return folder > mod_json ? folder : mod_json; + } + catch (Exception e) + { + LogService.LogWarning($"Cannot read the last write time of {pFolderPath}: {e.Message}"); + return DateTime.MinValue; + } + } +} From 65a0e907c9741edfbbe12326c61f7ba0a4c8ed79 Mon Sep 17 00:00:00 2001 From: KhromeHearts Date: Thu, 15 Oct 2026 04:31:37 -0600 Subject: [PATCH 02/18] ui: added Mod Hub, Manage Mods and Mod Info windows - ModHubWindow: entry menu, one entry per window, plus NML's version. - ModManagerWindow: pooled list of every recognized mod with filter, sort, search, Enable all / Disable all (confirmed, applied at the next start) and Open Mods folder. - ModManagerRow: status badge that toggles the mod, source icon, info button, config button only when the config has items, reload button in developer mode, and the 8-click shortcut to the upload window. - ModInfoWindow: status, source, folder, tags, dependencies with their state and the description, shown as plain text. - ConfirmWindow: small reusable confirmation dialog. Locale keys are appended at the end of en, cz and ch. Tooltip keys are lower case because the game snake-cases tooltip keys before the lookup. --- resources/locales/ch.json | 59 +++++++++- resources/locales/cz.json | 59 +++++++++- resources/locales/en.json | 59 +++++++++- ui/ConfirmWindow.cs | 49 +++++++++ ui/ModHubUI.cs | 139 ++++++++++++++++++++++++ ui/ModHubWindow.cs | 82 ++++++++++++++ ui/ModInfoWindow.cs | 179 ++++++++++++++++++++++++++++++ ui/ModManagerWindow.cs | 196 +++++++++++++++++++++++++++++++++ ui/prefabs/ModManagerRow.cs | 211 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1030 insertions(+), 3 deletions(-) create mode 100644 ui/ConfirmWindow.cs create mode 100644 ui/ModHubUI.cs create mode 100644 ui/ModHubWindow.cs create mode 100644 ui/ModInfoWindow.cs create mode 100644 ui/ModManagerWindow.cs create mode 100644 ui/prefabs/ModManagerRow.cs diff --git a/resources/locales/ch.json b/resources/locales/ch.json index 36457e5..8893dc8 100644 --- a/resources/locales/ch.json +++ b/resources/locales/ch.json @@ -105,5 +105,62 @@ "mod_state_disabled": "當前已禁用", "mod_state_failed": "當前載入失敗", "mod_next_state_enabled": "下次將啟用", - "mod_next_state_disabled": "下次將禁用" + "mod_next_state_disabled": "下次將禁用", + "ModHub Title": "模組", + "ModManager Title": "管理模組", + "ModManager Description": "啟用、禁用和設定已安裝的模組。", + "WorkshopMods Description": "載入遊戲執行期間訂閱的創意工坊模組。", + "ModInfo Title": "模組資訊", + "NMLConfirm Title": "確定嗎?", + "mod_hub_confirm": "確定", + "mod_hub_cancel": "取消", + "mod_hub_filter": "顯示", + "mod_hub_filter_all": "全部模組", + "mod_hub_filter_enabled": "已啟用的模組", + "mod_hub_filter_disabled": "已禁用的模組", + "mod_hub_filter_problems": "載入失敗或缺少前置模組的模組", + "mod_hub_sort": "排序", + "mod_hub_sort_nameascending": "名稱,A 到 Z", + "mod_hub_sort_namedescending": "名稱,Z 到 A", + "mod_hub_sort_recentlychanged": "最近變更:最近安裝、更新或編輯的排在前面", + "mod_hub_search_mode": "搜尋依據", + "mod_hub_search_mode_name": "名稱", + "mod_hub_search_mode_author": "作者", + "mod_hub_clear_search": "清除搜尋", + "mod_hub_enable_all": "全部啟用", + "mod_hub_disable_all": "全部禁用", + "mod_hub_open_mods_folder": "模組資料夾", + "mod_hub_enable_all_confirm": "啟用 {0} 個已禁用的模組?\n\n它們將在下次啟動遊戲時編譯並載入。", + "mod_hub_disable_all_confirm": "禁用全部 {0} 個已啟用的模組?\n\n目前已載入的模組在重新啟動遊戲前仍會生效。", + "mod_hub_status_enabled": "已啟用", + "mod_hub_status_disabled": "已禁用", + "mod_hub_status_failed": "載入失敗", + "mod_hub_status_missingdependency": "缺少前置", + "mod_hub_status_restartrequired": "需要重新啟動", + "mod_hub_status_enabled_tip": "已載入並生效。點擊禁用;重新啟動遊戲前它仍保持載入。", + "mod_hub_status_disabled_tip": "未載入。點擊啟用:NML 會立即編譯並載入它。", + "mod_hub_status_failed_tip": "已啟用,但載入失敗。點擊禁用。", + "mod_hub_status_missingdependency_tip": "已啟用,但缺少所需的模組。點擊禁用。\n缺少:", + "mod_hub_status_restartrequired_tip": "你的變更將在重新啟動遊戲後生效。點擊復原。", + "mod_hub_source_workshop": "Steam 創意工坊", + "mod_hub_source_modsfolder": "Mods 資料夾", + "mod_hub_source_nativemodsfolder": "遊戲的 StreamingAssets/mods 資料夾", + "mod_hub_source_bepinex": "BepInEx 外掛", + "mod_hub_source_other": "其他資料夾", + "mod_hub_info": "模組資訊", + "mod_hub_info_status": "狀態:{0}", + "mod_hub_info_source": "來源:{0}", + "mod_hub_info_folder": "資料夾:{0}", + "mod_hub_info_tags": "標籤:{0}", + "mod_hub_info_dependencies": "必需的模組:", + "mod_hub_info_optional_dependencies": "可選的模組:", + "mod_hub_dependency_loaded": "已載入", + "mod_hub_dependency_installed": "已安裝,未載入", + "mod_hub_dependency_missing": "缺失", + "mod_hub_info_no_description": "此模組沒有描述。", + "mod_hub_workshop_page": "創意工坊頁面", + "mod_hub_open_folder": "開啟資料夾", + "mod_hub_configure": "設定", + "mod_hub_reload": "重新載入模組(開發者工具:重新編譯並修補程式碼)", + "mod_hub_no_match": "沒有符合篩選或搜尋條件的模組。" } \ No newline at end of file diff --git a/resources/locales/cz.json b/resources/locales/cz.json index e1b2f8b..1d838e5 100644 --- a/resources/locales/cz.json +++ b/resources/locales/cz.json @@ -105,5 +105,62 @@ "mod_state_disabled": "当前已禁用", "mod_state_failed": "当前加载失败", "mod_next_state_enabled": "下次将启用", - "mod_next_state_disabled": "下次将禁用" + "mod_next_state_disabled": "下次将禁用", + "ModHub Title": "模组", + "ModManager Title": "管理模组", + "ModManager Description": "启用、禁用和设置已安装的模组。", + "WorkshopMods Description": "加载游戏运行期间订阅的创意工坊模组。", + "ModInfo Title": "模组信息", + "NMLConfirm Title": "确定吗?", + "mod_hub_confirm": "确定", + "mod_hub_cancel": "取消", + "mod_hub_filter": "显示", + "mod_hub_filter_all": "全部模组", + "mod_hub_filter_enabled": "已启用的模组", + "mod_hub_filter_disabled": "已禁用的模组", + "mod_hub_filter_problems": "加载失败或缺少前置模组的模组", + "mod_hub_sort": "排序", + "mod_hub_sort_nameascending": "名称,A 到 Z", + "mod_hub_sort_namedescending": "名称,Z 到 A", + "mod_hub_sort_recentlychanged": "最近更改:最近安装、更新或编辑的排在前面", + "mod_hub_search_mode": "搜索依据", + "mod_hub_search_mode_name": "名称", + "mod_hub_search_mode_author": "作者", + "mod_hub_clear_search": "清除搜索", + "mod_hub_enable_all": "全部启用", + "mod_hub_disable_all": "全部禁用", + "mod_hub_open_mods_folder": "模组文件夹", + "mod_hub_enable_all_confirm": "启用 {0} 个已禁用的模组?\n\n它们将在下次启动游戏时编译并加载。", + "mod_hub_disable_all_confirm": "禁用全部 {0} 个已启用的模组?\n\n当前已加载的模组在重启游戏前仍会生效。", + "mod_hub_status_enabled": "已启用", + "mod_hub_status_disabled": "已禁用", + "mod_hub_status_failed": "加载失败", + "mod_hub_status_missingdependency": "缺少前置", + "mod_hub_status_restartrequired": "需要重启", + "mod_hub_status_enabled_tip": "已加载并生效。点击禁用;重启游戏前它仍保持加载。", + "mod_hub_status_disabled_tip": "未加载。点击启用:NML 会立即编译并加载它。", + "mod_hub_status_failed_tip": "已启用,但加载失败。点击禁用。", + "mod_hub_status_missingdependency_tip": "已启用,但缺少所需的模组。点击禁用。\n缺少:", + "mod_hub_status_restartrequired_tip": "你的更改将在重启游戏后生效。点击撤销。", + "mod_hub_source_workshop": "Steam 创意工坊", + "mod_hub_source_modsfolder": "Mods 文件夹", + "mod_hub_source_nativemodsfolder": "游戏的 StreamingAssets/mods 文件夹", + "mod_hub_source_bepinex": "BepInEx 插件", + "mod_hub_source_other": "其他文件夹", + "mod_hub_info": "模组信息", + "mod_hub_info_status": "状态:{0}", + "mod_hub_info_source": "来源:{0}", + "mod_hub_info_folder": "文件夹:{0}", + "mod_hub_info_tags": "标签:{0}", + "mod_hub_info_dependencies": "必需的模组:", + "mod_hub_info_optional_dependencies": "可选的模组:", + "mod_hub_dependency_loaded": "已加载", + "mod_hub_dependency_installed": "已安装,未加载", + "mod_hub_dependency_missing": "缺失", + "mod_hub_info_no_description": "此模组没有描述。", + "mod_hub_workshop_page": "创意工坊页面", + "mod_hub_open_folder": "打开文件夹", + "mod_hub_configure": "设置", + "mod_hub_reload": "重载模组(开发者工具:重新编译并修补代码)", + "mod_hub_no_match": "没有符合筛选或搜索条件的模组。" } \ No newline at end of file diff --git a/resources/locales/en.json b/resources/locales/en.json index 3df9c00..c47459a 100644 --- a/resources/locales/en.json +++ b/resources/locales/en.json @@ -105,5 +105,62 @@ "mod_state_disabled": "Current Disabled", "mod_state_failed": "Current Failed", "mod_next_state_enabled": "will load", - "mod_next_state_disabled": "won't load" + "mod_next_state_disabled": "won't load", + "ModHub Title": "Mods", + "ModManager Title": "Manage Mods", + "ModManager Description": "Enable, disable and configure your installed mods.", + "WorkshopMods Description": "Load Workshop mods you subscribed to while the game was running.", + "ModInfo Title": "Mod Info", + "NMLConfirm Title": "Are you sure?", + "mod_hub_confirm": "Confirm", + "mod_hub_cancel": "Cancel", + "mod_hub_filter": "Show", + "mod_hub_filter_all": "All mods", + "mod_hub_filter_enabled": "Enabled mods", + "mod_hub_filter_disabled": "Disabled mods", + "mod_hub_filter_problems": "Mods that failed to load or miss a required mod", + "mod_hub_sort": "Sort", + "mod_hub_sort_nameascending": "Name, A to Z", + "mod_hub_sort_namedescending": "Name, Z to A", + "mod_hub_sort_recentlychanged": "Recently changed: last installed, updated or edited first", + "mod_hub_search_mode": "Search by", + "mod_hub_search_mode_name": "Name", + "mod_hub_search_mode_author": "Author", + "mod_hub_clear_search": "Clear search", + "mod_hub_enable_all": "Enable all", + "mod_hub_disable_all": "Disable all", + "mod_hub_open_mods_folder": "Mods folder", + "mod_hub_enable_all_confirm": "Enable {0} disabled mods?\n\nThey will be compiled and loaded the next time you start the game.", + "mod_hub_disable_all_confirm": "Disable all {0} enabled mods?\n\nMods that are loaded now stay active until you restart the game.", + "mod_hub_status_enabled": "Enabled", + "mod_hub_status_disabled": "Disabled", + "mod_hub_status_failed": "Failed", + "mod_hub_status_missingdependency": "Missing dependency", + "mod_hub_status_restartrequired": "Restart required", + "mod_hub_status_enabled_tip": "Loaded and active. Click to disable it. It stays loaded until you restart the game.", + "mod_hub_status_disabled_tip": "Not loaded. Click to enable it: NML compiles and loads it right away.", + "mod_hub_status_failed_tip": "Enabled, but it failed to load. Click to disable it.", + "mod_hub_status_missingdependency_tip": "Enabled, but a mod it requires is not installed. Click to disable it.\nMissing:", + "mod_hub_status_restartrequired_tip": "Your change applies when you restart the game. Click to undo it.", + "mod_hub_source_workshop": "Steam Workshop", + "mod_hub_source_modsfolder": "Mods folder", + "mod_hub_source_nativemodsfolder": "The game's StreamingAssets/mods folder", + "mod_hub_source_bepinex": "BepInEx plugin", + "mod_hub_source_other": "Other folder", + "mod_hub_info": "Mod info", + "mod_hub_info_status": "Status: {0}", + "mod_hub_info_source": "Source: {0}", + "mod_hub_info_folder": "Folder: {0}", + "mod_hub_info_tags": "Tags: {0}", + "mod_hub_info_dependencies": "Required mods:", + "mod_hub_info_optional_dependencies": "Optional mods:", + "mod_hub_dependency_loaded": "loaded", + "mod_hub_dependency_installed": "installed, not loaded", + "mod_hub_dependency_missing": "missing", + "mod_hub_info_no_description": "This mod has no description.", + "mod_hub_workshop_page": "Workshop page", + "mod_hub_open_folder": "Open folder", + "mod_hub_configure": "Configure", + "mod_hub_reload": "Reload mod (developer tool: recompiles and patches its code)", + "mod_hub_no_match": "No mods match the filter or search." } \ No newline at end of file diff --git a/ui/ConfirmWindow.cs b/ui/ConfirmWindow.cs new file mode 100644 index 0000000..4582352 --- /dev/null +++ b/ui/ConfirmWindow.cs @@ -0,0 +1,49 @@ +using NeoModLoader.api; +using UnityEngine; +using UnityEngine.UI; + +namespace NeoModLoader.ui; + +/// +/// Asks the player to confirm an action before it runs. +/// +internal class ConfirmWindow : AbstractWindow +{ + private Text message; + private Action on_confirm; + + /// Localized question that says what will happen + /// Runs when the player confirms, before returning to the previous window + public static void ShowWindow(string pMessage, Action pOnConfirm) + { + Instance.message.text = pMessage; + Instance.on_confirm = pOnConfirm; + ScrollWindow.showWindow(WindowId); + } + + protected override void Init() + { + BackgroundTransform.Find("Scroll View").gameObject.SetActive(false); + message = ModHubUI.CreateText(BackgroundTransform, "Message", new Vector2(200, 170), new Vector2(0, 25), 9, + TextAnchor.MiddleCenter); + ModHubUI.CreateTextButton(BackgroundTransform, "Confirm", "mod_hub_confirm", new Vector2(80, 24), + new Vector2(-45, -100), Confirm); + ModHubUI.CreateTextButton(BackgroundTransform, "Cancel", "mod_hub_cancel", new Vector2(80, 24), + new Vector2(45, -100), Close); + } + + private void Confirm() + { + Action action = on_confirm; + on_confirm = null; + // Run first, so the previous window shows the result when it is enabled again. + action?.Invoke(); + Close(); + } + + private void Close() + { + on_confirm = null; + GetComponent().clickBack(); + } +} diff --git a/ui/ModHubUI.cs b/ui/ModHubUI.cs new file mode 100644 index 0000000..fca9d7c --- /dev/null +++ b/ui/ModHubUI.cs @@ -0,0 +1,139 @@ +using NeoModLoader.General; +using NeoModLoader.General.UI.Prefabs; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace NeoModLoader.ui; + +/// +/// Small helpers shared by the Mod Hub windows. +/// +internal static class ModHubUI +{ + private const string tooltip_title_key = "mod_hub_tooltip_title"; + private const string tooltip_text_key = "mod_hub_tooltip_text"; + + public static readonly Color MutedTextColor = new(0.75f, 0.75f, 0.75f); + + public static Sprite GameIcon(string pName) + { + return SpriteTextureLoader.getSprite("ui/icons/" + pName); + } + + /// + /// Locale key for an enum value, such as "mod_hub_status_enabled". + /// + /// + /// Keys are lower case because the game's tooltips convert keys with capitals to snake case before the lookup. + /// + public static string LocaleKey(string pPrefix, Enum pValue) + { + return pPrefix + "_" + pValue.ToString().ToLowerInvariant(); + } + + /// + /// A plain text. Rich text is off because names, authors and descriptions come from mods. + /// + public static Text CreateText(Transform pParent, string pName, Vector2 pSize, Vector2 pPosition, int pFontSize, + TextAnchor pAlignment) + { + var obj = new GameObject(pName, typeof(Text)); + obj.transform.SetParent(pParent, false); + obj.transform.localPosition = pPosition; + obj.GetComponent().sizeDelta = pSize; + + Text text = obj.GetComponent(); + OT.InitializeCommonText(text); + text.supportRichText = false; + text.fontSize = pFontSize; + text.alignment = pAlignment; + text.horizontalOverflow = HorizontalWrapMode.Wrap; + text.verticalOverflow = VerticalWrapMode.Truncate; + text.raycastTarget = false; + return text; + } + + /// + /// Show the locale key in and follow language changes. + /// + public static void SetLocaleKey(Text pText, string pKey) + { + LocalizedText localized = pText.GetComponent(); + if (localized == null) localized = pText.gameObject.AddComponent(); + // It registers itself for language changes when it starts. + localized.setKeyAndUpdate(pKey); + } + + /// + /// An icon button with an optional tooltip, given as the locale key of its title. + /// + /// + /// Pass a null click action when building a prefab: Unity does not copy listeners added in code to clones. + /// + public static SimpleButton CreateIconButton(Transform pParent, string pName, Sprite pIcon, Vector2 pSize, + Vector2 pPosition, UnityAction pOnClick, string pTooltipKey) + { + SimpleButton button = SimpleButton.Instantiate(pParent, pName: pName); + button.Setup(pOnClick, pIcon, pSize: pSize); + button.transform.localPosition = pPosition; + if (pTooltipKey != null) + { + // Stored in serialized fields, so the tooltip also works on clones of a prefab. + button.TipButton.enabled = true; + button.TipButton.type = "normal"; + button.TipButton.textOnClick = pTooltipKey; + button.TipButton.hoverAction = button.TipButton.showTooltipDefault; + } + + return button; + } + + /// + /// A text button whose label is the locale key . + /// + public static SimpleButton CreateTextButton(Transform pParent, string pName, string pTextKey, Vector2 pSize, + Vector2 pPosition, UnityAction pOnClick) + { + SimpleButton button = SimpleButton.Instantiate(pParent, pName: pName); + button.Setup(pOnClick, null, LM.Get(pTextKey), pSize); + SetLocaleKey(button.Text, pTextKey); + button.transform.localPosition = pPosition; + return button; + } + + /// + /// Grey out a button that has nothing to do. + /// + public static void SetInteractable(SimpleButton pButton, bool pInteractable) + { + pButton.Button.interactable = pInteractable; + pButton.Background.color = pInteractable ? Color.white : Color.gray; + } + + /// + /// Show plain text when hovering . The texts are read on every hover. + /// + public static void SetTextTooltip(SimpleButton pButton, Func pTitle, Func pText) + { + pButton.TipButton.enabled = true; + pButton.TipButton.setHoverAction(() => ShowTextTooltip(pButton.gameObject, pTitle(), pText())); + } + + /// + /// Show a tooltip with plain text, such as a mod name or an error message. + /// + /// + /// The game's tooltips only display locale keys, so the text is passed through two reusable keys. + /// + public static void ShowTextTooltip(GameObject pTarget, string pTitle, string pText) + { + LM.AddToCurrentLocale(tooltip_title_key, pTitle ?? ""); + LM.AddToCurrentLocale(tooltip_text_key, pText ?? ""); + Tooltip.show(pTarget, "normal", new TooltipData + { + tip_name = tooltip_title_key, + tip_description = string.IsNullOrEmpty(pText) ? null : tooltip_text_key + }); + } +} diff --git a/ui/ModHubWindow.cs b/ui/ModHubWindow.cs new file mode 100644 index 0000000..077cd82 --- /dev/null +++ b/ui/ModHubWindow.cs @@ -0,0 +1,82 @@ +using NeoModLoader.api; +using NeoModLoader.constants; +using NeoModLoader.General; +using NeoModLoader.General.UI.Prefabs; +using NeoModLoader.utils; +using UnityEngine; +using UnityEngine.UI; + +namespace NeoModLoader.ui; + +/// +/// Menu opened by NML's power button. Each entry opens one mod screen. +/// +internal class ModHubWindow : AbstractWindow +{ + private static readonly Vector2 entry_size = new(200, 50); + + /// + /// One entry per window. Its title and description are the locale keys "{id} Title" and "{id} Description". + /// + private static readonly (string WindowId, Func Icon)[] entries = + { + ("ModManager", InternalResourcesGetter.GetIcon), + ("WorkshopMods", () => ModHubUI.GameIcon("iconSteam")) + }; + + protected override void Init() + { + BackgroundTransform.Find("Scroll View").gameObject.SetActive(false); + for (int i = 0; i < entries.Length; i++) + { + CreateEntry(entries[i].WindowId, entries[i].Icon(), new Vector2(0, 95 - i * (entry_size.y + 8))); + } + + CreateVersionFooter(); + } + + private void CreateEntry(string pWindowId, Sprite pIcon, Vector2 pPosition) + { + var entry = new GameObject(pWindowId, typeof(Image), typeof(Button)); + entry.transform.SetParent(BackgroundTransform, false); + entry.transform.localPosition = pPosition; + entry.GetComponent().sizeDelta = entry_size; + Image background = entry.GetComponent(); + background.sprite = SpriteTextureLoader.getSprite("ui/special/windowInnerSliced"); + background.type = Image.Type.Sliced; + entry.GetComponent