diff --git a/Celeste.Mod.mm/Content/Dialog/English.txt b/Celeste.Mod.mm/Content/Dialog/English.txt index 6dff3d939..dc1a1d859 100755 --- a/Celeste.Mod.mm/Content/Dialog/English.txt +++ b/Celeste.Mod.mm/Content/Dialog/English.txt @@ -266,21 +266,23 @@ OOBE_SETTINGS_OK= OK # Mod Toggle Menu - MODOPTIONS_MODTOGGLE= TOGGLE MODS - MODOPTIONS_MODTOGGLE_LOADING= Loading mod information... - MODOPTIONS_MODTOGGLE_TOGGLEDEPS= Toggle Dependencies Automatically - MODOPTIONS_MODTOGGLE_TOGGLEDEPS_MESSAGE1= When you enable a mod, all its dependencies will be enabled. - MODOPTIONS_MODTOGGLE_TOGGLEDEPS_MESSAGE2= When you disable a mod, all mods that depend on it will be disabled. - MODOPTIONS_MODTOGGLE_MESSAGE_1= If you enable or disable mods, your blacklist.txt will be replaced, - MODOPTIONS_MODTOGGLE_MESSAGE_2= and Celeste will restart to apply changes. - MODOPTIONS_MODTOGGLE_MESSAGE_3= Highlighted mods are used by other enabled mods as a dependency. - MODOPTIONS_MODTOGGLE_WHITELISTWARN= Disable your whitelist for these settings to be applied properly. - MODOPTIONS_MODTOGGLE_ENABLEALL= Enable All - MODOPTIONS_MODTOGGLE_DISABLEALL= Disable All - MODOPTIONS_MODTOGGLE_CANCEL= Cancel - MODOPTIONS_MODTOGGLE_ZIPS= Zip Files - MODOPTIONS_MODTOGGLE_DIRECTORIES= Directories - MODOPTIONS_MODTOGGLE_BINS= Map Bin Files + MODOPTIONS_MODTOGGLE= TOGGLE MODS + MODOPTIONS_MODTOGGLE_LOADING= Loading mod information... + MODOPTIONS_MODTOGGLE_TOGGLEDEPS= Toggle Dependencies Automatically + MODOPTIONS_MODTOGGLE_TOGGLEDEPS_MESSAGE1= When you enable a mod, all its dependencies will be enabled. + MODOPTIONS_MODTOGGLE_TOGGLEDEPS_MESSAGE2= When you disable a mod, all mods that depend on it will be disabled. + MODOPTIONS_MODTOGGLE_PROTECTFAVORITES= Protect Favorites + MODOPTIONS_MODTOGGLE_PROTECTFAVORITES_MESSAGE= Press {0} to add or remove mods from your favorite list. + MODOPTIONS_MODTOGGLE_MESSAGE_1= If you enable or disable mods, your blacklist.txt will be replaced, + MODOPTIONS_MODTOGGLE_MESSAGE_2= and Celeste will restart to apply changes. + MODOPTIONS_MODTOGGLE_MESSAGE_3= Highlighted mods are used by other enabled mods as a dependency. + MODOPTIONS_MODTOGGLE_WHITELISTWARN= Disable your whitelist for these settings to be applied properly. + MODOPTIONS_MODTOGGLE_ENABLEALL= Enable All + MODOPTIONS_MODTOGGLE_DISABLEALL= Disable All + MODOPTIONS_MODTOGGLE_CANCEL= Cancel + MODOPTIONS_MODTOGGLE_ZIPS= Zip Files + MODOPTIONS_MODTOGGLE_DIRECTORIES= Directories + MODOPTIONS_MODTOGGLE_BINS= Map Bin Files # Asset Reload Helper ASSETRELOADHELPER_RELOADINGMAP= Reloading map diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs index fe583ea42..00d59b42c 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Loader.cs @@ -38,6 +38,12 @@ public static class Loader { /// public static ReadOnlyCollection Blacklist => _Blacklist?.AsReadOnly(); + /// + /// The path to the Everest /Mods/favorites.txt file. + /// + public static string PathFavorites { get; internal set; } + internal static HashSet Favorites = new HashSet(); + /// /// The path to the Everest /Mods/temporaryblacklist.txt file. /// @@ -156,6 +162,15 @@ internal static void LoadAuto() { } } + PathFavorites = Path.Combine(PathMods, "favorites.txt"); + if (File.Exists(PathFavorites)) { + Favorites = new HashSet(File.ReadAllLines(PathFavorites).Select(l => (l.StartsWith("#") ? "" : l).Trim())); + } else { + using (StreamWriter writer = File.CreateText(PathFavorites)) { + writer.WriteLine("# This is the favorites list. Lines starting with # are ignored."); + } + } + Stopwatch watch = Stopwatch.StartNew(); enforceOptionalDependencies = true; diff --git a/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs b/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs index cb579c68a..806c4c91d 100755 --- a/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs +++ b/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs @@ -19,12 +19,25 @@ class OuiModToggler : OuiGenericMenu, OuiModOptions.ISubmenu { // list of blacklisted mods when the menu was open private HashSet blacklistedModsOriginal; + // list of favorite mods + private HashSet favoriteMods; + // list of favorite mods when the menu was open + private HashSet favoriteModsOriginal; + // maps each dependency to all its dependents + private Dictionary> favoriteModDependencies; + private bool toggleDependencies = true; + private bool protectFavorites = true; + private TextMenuExt.SubHeaderExt restartMessage1; private TextMenuExt.SubHeaderExt restartMessage2; + // maps each filename to its Everest modules private Dictionary modYamls; + // maps each mod name to its newest Everest module + private Dictionary modFilename; + private Dictionary modToggles; private Task modLoadingTask; @@ -187,6 +200,7 @@ protected override void addOptionsToMenu(patch_TextMenu menu) { MainThreadHelper.Do(() => { modToggles = new Dictionary(); + modFilename = BuildModFilenameDictionary(modYamls); // remove the "loading..." message menu.Remove(loading); @@ -217,6 +231,11 @@ protected override void addOptionsToMenu(patch_TextMenu menu) { menu.Add(new TextMenu.Button(Dialog.Clean("MODOPTIONS_MODTOGGLE_DISABLEALL")).Pressed(() => { blacklistedMods.Clear(); foreach (KeyValuePair toggle in modToggles) { + bool isFavoriteDependency = favoriteMods.Contains(toggle.Key) || favoriteModDependencies.ContainsKey(toggle.Key); + if (protectFavorites && isFavoriteDependency) { + continue; + } + toggle.Value.Index = 0; blacklistedMods.Add(toggle.Key); } @@ -231,15 +250,41 @@ protected override void addOptionsToMenu(patch_TextMenu menu) { toggleDependenciesButton.AddDescription(menu, Dialog.Clean("MODOPTIONS_MODTOGGLE_TOGGLEDEPS_MESSAGE2")); toggleDependenciesButton.AddDescription(menu, Dialog.Clean("MODOPTIONS_MODTOGGLE_TOGGLEDEPS_MESSAGE1")); + TextMenu.Item toggleProtectFavoritesButton; + menu.Add(toggleProtectFavoritesButton = new TextMenu.OnOff(Dialog.Clean("MODOPTIONS_MODTOGGLE_PROTECTFAVORITES"), protectFavorites) + .Change(value => protectFavorites = value)); + + TextMenuExt.EaseInSubMenuWithInputs favoriteToolTip = new TextMenuExt.EaseInSubMenuWithInputs( + string.Format(Dialog.Get("MODOPTIONS_MODTOGGLE_PROTECTFAVORITES_MESSAGE"), '|'), + '|', + new Monocle.VirtualButton[] { Input.MenuJournal }, + false + ) { TextColor = Color.Gray }; + + menu.Add(favoriteToolTip); + + toggleProtectFavoritesButton.OnEnter += () => { + // make the description appear. + favoriteToolTip.FadeVisible = true; + }; + toggleProtectFavoritesButton.OnLeave += () => { + // make the description disappear. + favoriteToolTip.FadeVisible = false; + }; + + // "cancel" button to leave the screen without saving menu.Add(new TextMenu.Button(Dialog.Clean("MODOPTIONS_MODTOGGLE_CANCEL")).Pressed(() => { blacklistedMods = blacklistedModsOriginal; + favoriteMods = favoriteModsOriginal; onBackPressed(Overworld); })); // reset the mods list allMods = new List(); blacklistedMods = new HashSet(); + favoriteMods = new HashSet(); + favoriteModDependencies = new Dictionary>(); string[] files; bool headerInserted; @@ -292,18 +337,16 @@ protected override void addOptionsToMenu(patch_TextMenu menu) { // sort the mods list alphabetically, for output in the blacklist.txt file later. allMods.Sort((a, b) => a.ToLowerInvariant().CompareTo(b.ToLowerInvariant())); - // adjust the mods' color if they are required dependencies for other mods - foreach (KeyValuePair toggle in modToggles) { - if (modHasDependencies(toggle.Key)) { - ((patch_TextMenu.patch_Option) (object) toggle.Value).UnselectedColor = Color.Goldenrod; - } - } + // clone the list to be able to check if the list changed when leaving the menu. + blacklistedModsOriginal = new HashSet(blacklistedMods); + favoriteModsOriginal = new HashSet(favoriteMods); + + // set colors to mods listings + updateHighlightedMods(); // snap the menu so that it doesn't show a scroll up. menu.Y = menu.ScrollTargetY; - // clone the list to be able to check if the list changed when leaving the menu. - blacklistedModsOriginal = new HashSet(blacklistedMods); // loading is done! modLoadingTask = null; @@ -313,32 +356,79 @@ protected override void addOptionsToMenu(patch_TextMenu menu) { } private void addFileToMenu(TextMenu menu, string file) { - TextMenu.OnOff option; - bool enabled = !Everest.Loader.Blacklist.Contains(file); - menu.Add(option = (TextMenu.OnOff) new TextMenu.OnOff(file.Length > 40 ? file.Substring(0, 40) + "..." : file, enabled) - .Change(b => { - if (b) { - removeFromBlacklist(file); - } else { - addToBlacklist(file); - } + bool favorite = Everest.Loader.Favorites.Contains(file); - updateHighlightedMods(); - })); + TextMenu.OnOff option = new(file.Length > 40 ? file.Substring(0, 40) + "..." : file, enabled); + + option.Change(b => { + if (b) { + removeFromBlacklist(file); + } else { + addToBlacklist(file); + } + + updateHighlightedMods(); + }).AltPressed(() => { + if (!favoriteMods.Contains(file)) { + Audio.Play(SFX.ui_main_button_toggle_on); + addToFavorites(file); + } else { + Audio.Play(SFX.ui_main_button_toggle_off); + removeFromFavorites(file); + } + + option.SelectWiggler.Start(); + + updateHighlightedMods(); + }); + + menu.Add(option); allMods.Add(file); if (!enabled) { blacklistedMods.Add(file); } + if (favorite) { + // because we don't store the dependencies of favorite mods we want to call addToFavorites to walk the dependencies graph + addToFavorites(file); + } modToggles[file] = option; } + private Dictionary BuildModFilenameDictionary(Dictionary modYamls) { + Dictionary everestModulesByModName = new(); + + foreach (KeyValuePair pair in modYamls) { + foreach (EverestModuleMetadata currentModule in pair.Value) { + if (everestModulesByModName.TryGetValue(currentModule.Name, out EverestModuleMetadata previousModule)) { + if (previousModule.Version < currentModule.Version) { + everestModulesByModName[currentModule.Name] = currentModule; + } + } else { + everestModulesByModName[currentModule.Name] = currentModule; + } + } + } + + + return everestModulesByModName + .ToDictionary(dictEntry => dictEntry.Key, dictEntry => Path.GetFileName(dictEntry.Value.PathArchive ?? dictEntry.Value.PathDirectory)); + } + private void updateHighlightedMods() { // adjust the mods' color if they are required dependencies for other mods foreach (KeyValuePair toggle in modToggles) { - ((patch_TextMenu.patch_Option) (object) toggle.Value).UnselectedColor = modHasDependencies(toggle.Key) ? Color.Goldenrod : Color.White; + Color unselectedColor = Color.White; + if (favoriteMods.Contains(toggle.Key)) { + unselectedColor = Color.DeepPink; + } else if (favoriteModDependencies.ContainsKey(toggle.Key)) { + unselectedColor = Color.LightPink; + } else if (modHasDependencies(toggle.Key)) { + unselectedColor = Color.Goldenrod; + } + ((patch_TextMenu.patch_Option) (object) toggle.Value).UnselectedColor = unselectedColor; } // turn the warning text about restarting/overwriting blacklist.txt orange/red if something was changed (so pressing Back will trigger a restart). @@ -390,27 +480,76 @@ private void removeFromBlacklist(string file) { blacklistedMods.Remove(file); Logger.Log(LogLevel.Verbose, "OuiModToggler", $"{file} was removed from the blacklist"); - if (toggleDependencies && modYamls.TryGetValue(file, out EverestModuleMetadata[] metadatas)) { + if (toggleDependencies && TryGetModDependenciesFileNames(file, out List dependencies)) { // we should remove all of the mod's dependencies from the blacklist. - foreach (EverestModuleMetadata metadata in metadatas) { - foreach (string dependency in metadata.Dependencies.Select(dep => dep.Name)) { - // we want to go through all the other mods' info to found the one we want. - KeyValuePair? found = null; - foreach (KeyValuePair candidateMetadatas in modYamls) { - foreach (EverestModuleMetadata candidateMetadata in candidateMetadatas.Value) { - if (candidateMetadata.Name == dependency) { - // we found it! - if (found == null || found.Value.Value.Version < candidateMetadata.Version) { - found = new KeyValuePair(candidateMetadatas.Key, candidateMetadata); - } - } - } - } - if (found.HasValue) { - // we found where the dependency is: activate it. - removeFromBlacklist(found.Value.Key); - modToggles[found.Value.Key].Index = 1; - } + foreach (string modFileName in dependencies) { + removeFromBlacklist(modFileName); + modToggles[modFileName].Index = 1; + } + } + } + + private void addToFavorites(string modFileName) { + favoriteMods.Add(modFileName); + Logger.Log(LogLevel.Verbose, "OuiModToggler", $"{modFileName} was added to favorites"); + + if (TryGetModDependenciesFileNames(modFileName, out List dependenciesFileNames)) { + foreach (string dependenciesFileName in dependenciesFileNames) { + addToFavoritesDependencies(dependenciesFileName, modFileName); + } + } + } + + private void addToFavoritesDependencies(string modFileName, string dependentModFileName) { + bool existsInFavoriteDependencies = favoriteModDependencies.TryGetValue(modFileName, out HashSet dependents); + + // If we have a cyclical dependencies we want to stop after the first occurrence of a mod, or if somehow a mod reached itself. + if ((existsInFavoriteDependencies && dependents.Contains(dependentModFileName)) || modFileName == dependentModFileName) { + return; + } + + if (!existsInFavoriteDependencies) { + dependents = favoriteModDependencies[modFileName] = new HashSet(); + } + + // Add dependent mod + dependents.Add(dependentModFileName); + Logger.Log(LogLevel.Verbose, "OuiModToggler", $"{modFileName} was added as a favorite dependency of {dependentModFileName}"); + + + // we want to walk the dependence graph and add all the sub-dependencies as dependencies of the original dependentModFileName + if (TryGetModDependenciesFileNames(modFileName, out List dependenciesFileNames)) { + foreach (string dependencyFileName in dependenciesFileNames) { + addToFavoritesDependencies(dependencyFileName, dependentModFileName); + } + } + } + + private void removeFromFavorites(string modFileName) { + favoriteMods.Remove(modFileName); + Logger.Log(LogLevel.Verbose, "OuiModToggler", $"{modFileName} was removed from favorites"); + + if (TryGetModDependenciesFileNames(modFileName, out List dependenciesFileNames)) { + foreach (string dependencyFileName in dependenciesFileNames) { + removeFromFavoritesDependencies(dependencyFileName, modFileName); + } + } + } + + private void removeFromFavoritesDependencies(string modFileName, string dependentModFileName) { + if (favoriteModDependencies.TryGetValue(modFileName, out HashSet dependents) && dependents.Contains(dependentModFileName)) { + + dependents.Remove(dependentModFileName); + Logger.Log(LogLevel.Verbose, "OuiModToggler", $"{modFileName} was removed from being a favorite dependency of {dependentModFileName}"); + + if (dependents.Count == 0) { + favoriteModDependencies.Remove(modFileName); + Logger.Log(LogLevel.Verbose, "OuiModToggler", $"{modFileName} is no longer a favorite dependency"); + } + + if (TryGetModDependenciesFileNames(modFileName, out List dependenciesFileNames)) { + foreach (string dependencyFileName in dependenciesFileNames) { + removeFromFavoritesDependencies(dependencyFileName, dependentModFileName); } } } @@ -419,6 +558,18 @@ private void removeFromBlacklist(string file) { private void onBackPressed(Overworld overworld) { // "back" only works if the loading is done. if (modLoadingTask == null || modLoadingTask.IsCompleted || modLoadingTask.IsCanceled || modLoadingTask.IsFaulted) { + if (!favoriteModsOriginal.SetEquals(favoriteMods)) { + Everest.Loader.Favorites = favoriteMods; + using (StreamWriter writer = File.CreateText(Everest.Loader.PathFavorites)) { + // header + writer.WriteLine("# This is the favorite list. Lines starting with # are ignored."); + writer.WriteLine(""); + + foreach (string mod in favoriteMods) { + writer.WriteLine(mod); + } + } + } if (blacklistedModsOriginal.SetEquals(blacklistedMods)) { // nothing changed, go back to Mod Options overworld.Goto(); @@ -442,6 +593,26 @@ private void onBackPressed(Overworld overworld) { } } + private bool TryGetModDependenciesFileNames(string modFilename, out List dependenciesFileNames) { + if (modYamls.TryGetValue(modFilename, out EverestModuleMetadata[] metadatas)) { + dependenciesFileNames = new List(); + + foreach (EverestModuleMetadata metadata in metadatas) { + foreach (string dependencyName in metadata.Dependencies.Select((dep) => dep.Name)) { + if (this.modFilename.TryGetValue(dependencyName, out string dependencyFileName)) { + dependenciesFileNames.Add(dependencyFileName); + } + } + } + + return true; + } + + + dependenciesFileNames = null; + return false; + } + private bool modHasDependencies(string modFilename) { if (modYamls.TryGetValue(modFilename, out EverestModuleMetadata[] metadatas)) { // this mod has a yaml, check all of the metadata entries (99% of the time there is one only). @@ -472,9 +643,14 @@ public override IEnumerator Leave(Oui next) { restartMessage1 = null; restartMessage2 = null; modYamls = null; + modFilename = null; modToggles = null; modLoadingTask = null; toggleDependencies = true; + protectFavorites = true; + favoriteMods = null; + favoriteModsOriginal = null; + favoriteModDependencies = null; } } } diff --git a/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs b/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs index 247579c11..9fbc8bf12 100644 --- a/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs +++ b/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs @@ -1363,5 +1363,107 @@ public void Dispose() { } } + public class SubMenuWithInputs : TextMenu.Item, IItemExt { + public Color TextColor { get; set; } = Color.Gray; + public Color ButtonColor { get; set; } = Color.White; + public Color StrokeColor { get; set; } = Color.White; + public float Alpha { get; set; } = 1f; + public float Scale { get; set; } = 0.6f; + public string Icon { get; set; } + public float? IconWidth { get; set; } + public bool IconOutline { get; set; } + public Vector2 Offset { get; set; } + private readonly object[] Items; + + public SubMenuWithInputs(string text, char separator, VirtualButton[] buttons) { + + string[] parts = text.Split(separator); + Items = new object[parts.Length * 2 - 1]; + + for (int index = 0; index < Items.Length; index++) { + if (index % 2 == 0) { + // add text + Items[index] = parts[index / 2]; + } else { + // add VirtualButton + Items[index] = buttons[index / 2]; + } + } + } + + public override float Height() { + return ActiveFont.LineHeight; + } + + public override void Render(Vector2 position, bool highlighted) { + Vector2 lineOffset = position; + Vector2 justify = new(0f, 0.5f); + float strokeAlpha = Alpha * Alpha * Alpha; + + + foreach (object item in Items) { + if (item is string) { + ActiveFont.DrawOutline(item as string, lineOffset, justify, Vector2.One * Scale, TextColor * Alpha, 2f, Color.Black * strokeAlpha); + lineOffset.X += ActiveFont.Measure(item as string).X * Scale; + } else if (item is VirtualButton) { + VirtualButton virtualButton = item as VirtualButton; + MTexture buttonTexture; + + if (Input.GuiInputController()) { + buttonTexture = Input.GuiButton(virtualButton, Input.PrefixMode.Attached); + } else if (virtualButton.Binding.Keyboard.Count > 0) { + buttonTexture = Input.GuiKey(virtualButton.Binding.Keyboard[0]); + } else { + buttonTexture = Input.GuiKey(Microsoft.Xna.Framework.Input.Keys.None); + } + + buttonTexture.DrawJustified(lineOffset, justify, ButtonColor * strokeAlpha, Scale); + lineOffset.X += buttonTexture.Width * Scale; + } + } + } + } + + // TODO: this was copy pasted from EaseInSubHeaderExt, find a way to abstract away the EaseIn behavior + public class EaseInSubMenuWithInputs : SubMenuWithInputs { + public bool FadeVisible { get; set; } = true; + private float uneasedAlpha; + + public EaseInSubMenuWithInputs( + string text, + char separator, + VirtualButton[] buttons, + bool initiallyVisible + ) : base(text, separator, buttons) { + FadeVisible = initiallyVisible; + Alpha = FadeVisible ? 1 : 0; + uneasedAlpha = Alpha; + } + + public override float Height() { + if (Container != null) { + return MathHelper.Lerp(-Container.ItemSpacing, base.Height(), Alpha); + } else { + return base.Height(); + } + } + + public override void Update() { + base.Update(); + + // gradually make the sub-header fade in or out. (~333ms fade) + float targetAlpha = FadeVisible ? 1 : 0; + if (uneasedAlpha != targetAlpha) { + uneasedAlpha = Calc.Approach(uneasedAlpha, targetAlpha, Engine.RawDeltaTime * 3f); + + if (FadeVisible) + Alpha = Ease.SineOut(uneasedAlpha); + else + Alpha = Ease.SineIn(uneasedAlpha); + } + + Visible = Alpha != 0; + } + } } }