diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index 6f33856..b5745e5 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -14,6 +14,7 @@ public class ExampleOptions : AbstractOptionGroup { public override string GroupName => "options.exampleOptions1"; public override Color GroupColor => Color.green; + public override bool ShowInMatchInfoGuide => true; [ModdedToggleOption("options.exampleOptions1.toggleOpt1")] [ModdedOptionVisibility] // Automatically resolves to using ToggleOpt1Visibility, can also target toggle options diff --git a/MiraAPI.Example/Options/ExampleOptions2.cs b/MiraAPI.Example/Options/ExampleOptions2.cs index bdc5a1b..e0d4aaa 100644 --- a/MiraAPI.Example/Options/ExampleOptions2.cs +++ b/MiraAPI.Example/Options/ExampleOptions2.cs @@ -9,6 +9,7 @@ public class ExampleOptions2 : AbstractOptionGroup public override string GroupName => "options.exampleOptions2"; public override uint GroupPriority => 0; // This group will be displayed first. The default value is uint.MaxValue. public override MenuCategory ParentMenu => MenuCategory.CustomTwo; + public override bool ShowInMatchInfoGuide => true; public ModdedPlayerOption PlayerOption2 { get; } = new("options.exampleOptions2.youMustChoseAPlayer", false); public ModdedPlayerOption PlayerOption { get; } = new("options.exampleOptions2.ehWhatever"); diff --git a/MiraAPI/GameModes/GameModeOption.cs b/MiraAPI/GameModes/GameModeOption.cs index c58030a..2d1cc88 100644 --- a/MiraAPI/GameModes/GameModeOption.cs +++ b/MiraAPI/GameModes/GameModeOption.cs @@ -4,6 +4,7 @@ using System.Linq; using HarmonyLib; using MiraAPI.GameOptions; using MiraAPI.Networking; +using MiraAPI.Patches; using MiraAPI.Patches.GameModes; using MiraAPI.Patches.Options; using MiraAPI.Translation; @@ -128,6 +129,9 @@ public static class GameModeOption MenuState.Instance.QueuedRoleMenuRefresh[roleMenu.Key] = true; } } + + GameOptionsMenuPatch.RefreshForGameMode(); + RoleGuidePatches.RefreshSettingsForGameMode(); } // TODO: could make Values a dict of AbstractGameMode too @@ -143,12 +147,6 @@ public static class GameModeOption Info($"Game mode changed to {option.GetInt()}"); RpcSyncGamemode(PlayerControl.LocalPlayer, option.GetInt()); - if (GameSettingMenu.Instance && CustomGameModeManager.ActiveMode != null) - { - GameOptionsMenuPatch.ToggleGamemodeOptions(CustomGameModeManager.ActiveMode, __instance); - GameSettingMenu.Instance.RoleSettingsButton.gameObject.SetActive(CustomGameModeManager.ActiveMode.ShowNormalRoleSettings); - } - return false; } diff --git a/MiraAPI/GameOptions/AbstractOptionGroup.cs b/MiraAPI/GameOptions/AbstractOptionGroup.cs index c2977c2..52d8fdf 100644 --- a/MiraAPI/GameOptions/AbstractOptionGroup.cs +++ b/MiraAPI/GameOptions/AbstractOptionGroup.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Il2CppInterop.Runtime.Attributes; using MiraAPI.Modifiers; using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; using UnityEngine; namespace MiraAPI.GameOptions; @@ -26,6 +28,12 @@ public abstract class AbstractOptionGroup /// public abstract string GroupName { get; } + /// + /// Gets the sprite of the group that is displayed in the match info guide. + /// + [HideFromIl2Cpp] + public virtual LoadableAsset Sprite => MiraAssets.IconSettings; + /// /// Gets the Optionable type of the group. /// @@ -49,6 +57,42 @@ public abstract class AbstractOptionGroup /// public virtual Func GroupVisible => () => true; + /// + /// Gets a value indicating whether this group's options are shown in the Match Info Guide. + /// + public virtual bool ShowInMatchInfoGuide => ParentMenu is MenuCategory.Game; + + /// + /// Gets the name title of the group when it is in the match info guide. + /// + public virtual string RoleGuideTitle => GroupName; + + /// + /// Gets the key used to combine this group with other option groups on one Match Info Guide page. + /// Groups are combined only with groups from the same plugin that use the same non-empty key. + /// + public virtual string? MatchInfoGuidePageKey => null; + + /// + /// Gets the title of the Match Info Guide page. For groups sharing a page key, the first + /// group in menu order supplies the page title. + /// + public virtual string MatchInfoGuidePageTitle => RoleGuideTitle; + + /// + /// Gets the ordering priority of this Match Info Guide page. Lower values appear first + /// among pages with the same menu category and group priority. Groups sharing a page key + /// use the lowest page-order value among those groups. + /// + public virtual int MatchInfoGuidePageOrder => int.MaxValue; + + /// + /// Gets the number of columns used to lay out options on this Match Info Guide page. + /// For a combined page, groups are placed into these columns. Values are limited to 1-6; + /// for groups sharing a page key, the first group in menu order supplies this value. + /// + public virtual int MatchInfoGuidePageColumns => 2; + /// /// Gets the group . This is used to color the group in the options menu. /// diff --git a/MiraAPI/MiraApiSettings.cs b/MiraAPI/MiraApiSettings.cs index bb6ed51..1047fcd 100644 --- a/MiraAPI/MiraApiSettings.cs +++ b/MiraAPI/MiraApiSettings.cs @@ -69,6 +69,10 @@ public class MiraApiSettings(ConfigFile config) : LocalSettingsTab(config) OldUiButtonScaleFactor = TopRightButtonsFactorSlider.Value; } + else if (configEntry == MatchInfoGuideScaleFactor && MatchInfoGuide.Instance) + { + RoleGuidePatches.ApplyGuideScale(MatchInfoGuide.Instance, MatchInfoGuideScaleFactor.Value); + } else if (configEntry == WikiOnBottomRow) { SetUpButtonPositions(); @@ -198,6 +202,12 @@ public class MiraApiSettings(ConfigFile config) : LocalSettingsTab(config) [LocalSliderSetting(min: 0.3f, max: 2f, suffixType: MiraNumberSuffixes.Multiplier, formatString: "0.00", displayValue: true)] public ConfigEntry TopRightButtonsFactorSlider { get; private set; } = config.Bind("MiraApi.VisualsUi", "MiraApi.TopRightButtonScaleFactor", 1f); + /// + /// Gets the scale of the match info guide. + /// + [LocalSliderSetting(min: 0.5f, max: 1f, suffixType: MiraNumberSuffixes.Multiplier, formatString: "0.00", displayValue: true)] + public ConfigEntry MatchInfoGuideScaleFactor { get; private set; } = config.Bind("MiraApi.VisualsUi", "MiraApi.MatchInfoGuideScaleFactor", 1f); + /// /// Gets the fps specified by the player. /// diff --git a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs index 7e2b312..4313101 100644 --- a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs +++ b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs @@ -38,6 +38,7 @@ internal static class GameOptionsMenuPatch [HarmonyPatch(nameof(GameOptionsMenu.Initialize))] public static bool InitPatch(GameOptionsMenu __instance) { + Helpers.CacheAdvancedSettingsRowPrefab(__instance.numberOptionOrigin); __instance.Children ??= new(); __instance.Children.Clear(); @@ -187,6 +188,66 @@ internal static class GameOptionsMenuPatch instance.scrollBar.SetYBoundsMax(-num - 1.65f); } + internal static void RefreshForGameMode() + { + if (CustomGameModeManager.ActiveMode == null || + !GameSettingMenu.Instance || + !GameSettingMenu.Instance.GameSettingsTab || + !MenuState.Instance || + MenuState.Instance.CurrentModIdx != 0 || + GameModeGroups.Count == 0) + { + return; + } + + ToggleGamemodeOptions(CustomGameModeManager.ActiveMode, GameSettingMenu.Instance.GameSettingsTab); + GameSettingMenu.Instance.RoleSettingsButton.gameObject.SetActive( + CustomGameModeManager.ActiveMode.ShowNormalRoleSettings); + } + + internal static List> GetActiveGameSettingsRows(Func formatBool) + { + var menu = GameSettingMenu.Instance ? GameSettingMenu.Instance.GameSettingsTab : null; + if (!menu || menu.Children == null) + { + return []; + } + + var rows = new List>(); + foreach (var option in menu.Children) + { + if (!option || !option!.gameObject.activeInHierarchy || + option == menu.MapPicker || + (GameModeOption.OptionBehaviour && option == GameModeOption.OptionBehaviour) || + !option.Data) + { + continue; + } + + var title = option switch + { + ToggleOption toggle => toggle.TitleText.text, + NumberOption number => number.TitleText.text, + StringOption str => str.TitleText.text, + PlayerOption player => player.TitleText.text, + _ => TranslationController.Instance.GetString(option.Data.Title), + }; + + var value = option switch + { + ToggleOption toggle => formatBool(toggle.GetBool()), + NumberOption number => number.ValueText.text, + StringOption str => str.ValueText.text, + PlayerOption player => player.ValueText.text, + _ => option.Data.GetValueString(option.GetFloat()), + }; + + rows.Add(new KeyValuePair(title, value)); + } + + return rows; + } + private static void CreateSettings(GameOptionsMenu instance, Transform container) { var num = 0.713f; diff --git a/MiraAPI/Patches/Options/GameSettingMenuPatches.cs b/MiraAPI/Patches/Options/GameSettingMenuPatches.cs index e6ae2d5..6c1ede7 100644 --- a/MiraAPI/Patches/Options/GameSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/GameSettingMenuPatches.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using MiraAPI.Utilities; namespace MiraAPI.Patches.Options; @@ -13,6 +14,11 @@ internal static class GameSettingMenuPatches [HarmonyPatch(nameof(GameSettingMenu.OnEnable))] public static void OnEnablePrefix(GameSettingMenu __instance) { + if (__instance.GameSettingsTab && __instance.GameSettingsTab.numberOptionOrigin) + { + Helpers.CacheAdvancedSettingsRowPrefab(__instance.GameSettingsTab.numberOptionOrigin); + } + // Add Menu State object if (!__instance.gameObject.GetComponent()) { diff --git a/MiraAPI/Patches/RoleGuidePatches.cs b/MiraAPI/Patches/RoleGuidePatches.cs index 508b3f7..b8196fc 100644 --- a/MiraAPI/Patches/RoleGuidePatches.cs +++ b/MiraAPI/Patches/RoleGuidePatches.cs @@ -7,8 +7,11 @@ using System.Linq; using AmongUs.GameOptions; using HarmonyLib; using Innersloth.Assets; +using MiraAPI.GameModes; +using MiraAPI.LocalSettings; using MiraAPI.Modifiers; using MiraAPI.Modifiers.Types; +using MiraAPI.Patches.Options; using MiraAPI.Roles; using MiraAPI.Translation; using MiraAPI.Utilities; @@ -26,27 +29,38 @@ namespace MiraAPI.Patches; [HarmonyPatch] public static class RoleGuidePatches { + private const int SettingsTabIndex = 1; private const int RolesTabIndex = 2; private const int ModifiersTabIndex = 3; - private const float SearchDebounceSeconds = 0.3f; + private const float MinGuideScaleFactor = 0.5f; + private const float MaxGuideScaleFactor = 1f; + private const float SearchDebounceSeconds = 0.05f; private static readonly List RoleEntries = []; private static readonly List ModifierEntries = []; + private static readonly List UntrackedRolePanelDiagnostics = []; private static GameObject searchComponentContainer; + public static PassiveButton SearchIconButton; private static SpriteRenderer _searchIconIdle; private static SpriteRenderer _searchIconHover; + public static PassiveButton SearchSortingOrderButton; private static SpriteRenderer _searchSortingOrderIdle; private static SpriteRenderer _searchSortingOrderHover; + public static TextMeshPro GroupSortingTmp; + public static PassiveButton SearchSortingGroupButton; private static SpriteRenderer _searchSortingGroupIdle; private static SpriteRenderer _searchSortingGroupHover; + public static TextMeshPro FilterSortingTmp; + public static PassiveButton SearchSortingFilterButton; private static SpriteRenderer _searchSortingFilterIdle; private static SpriteRenderer _searchSortingFilterHover; private static Scroller _rolesScroller; private static Scroller _modifiersScroller; - private static Scroller _advancedInfoTabScroller; + private static GameObject _modifierNoResultsMessage; + private static Scroller _wikiScroller; private static TextBoxTMP _searchBoxTmp; - private static GameObject _currentAdvancedTabObject; + private static GameObject _currentWikiPage; private static TextMeshPro _titleText; private static SpriteRenderer _titleIcon; private static bool _suppressSearchRefresh; @@ -55,14 +69,21 @@ public static class RoleGuidePatches private static float _openedAt; private static bool _uiInitialized; private static MatchInfoGuide? _guide; + private static int _activeGuideTabIndex = -1; private static IEnumerator? _populate; private static bool _loading; + private static Type? _roleEntriesMode; + private static bool _roleEntriesInitialized; + private static bool _rolePanelsCleaned; private static LoadingRing? _loadingSpinner; private static SortingFilter _sortFilter = SortingFilter.EnabledOnly; private static SortingMethod _sortMethod = SortingMethod.Alphabetical; private static SortingGroups _sortGrouping = SortingGroups.Ungrouped; - private static bool IsModifiersTabActive => MatchInfoGuide.Instance.activeTabIndex == ModifiersTabIndex; + private static int ActiveGuideTabIndex => + _guide && MatchInfoGuide.Instance == _guide ? MatchInfoGuide.Instance.activeTabIndex : _activeGuideTabIndex; + + private static bool IsModifiersTabActive => ActiveGuideTabIndex == ModifiersTabIndex; [HarmonyPostfix] [HarmonyPriority(Priority.First)] @@ -106,13 +127,29 @@ public static class RoleGuidePatches [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.Awake))] public static void Awake(MatchInfoGuide __instance) { - __instance.transitionOpen.targetSize = 1.3f; + __instance.transitionOpen.targetSize = Helpers.MatchInfoGuideBaseScale; if (_guide != __instance) { ResetState(); } } + public static void ApplyGuideScale(MatchInfoGuide instance, float scaleFactor) + { + if (!instance || !instance.transitionOpen) + { + return; + } + + var targetSize = Helpers.MatchInfoGuideBaseScale * + Mathf.Clamp(scaleFactor, MinGuideScaleFactor, MaxGuideScaleFactor); + instance.transitionOpen.targetSize = targetSize; + if (instance.MatchInfoParent.activeSelf) + { + instance.transitionOpen.transform.localScale = Vector3.one * targetSize; + } + } + /// /// Clears all cached state tied to a previous instance. /// Every game spawns a fresh guide, so anything we cached from the old one is a destroyed Unity object. @@ -133,7 +170,12 @@ public static class RoleGuidePatches RoleEntries.Clear(); ModifierEntries.Clear(); + UntrackedRolePanelDiagnostics.Clear(); + _roleEntriesMode = null; + _roleEntriesInitialized = false; + _rolePanelsCleaned = false; _guide = null; + _activeGuideTabIndex = -1; _uiInitialized = false; _loading = false; _appliedSearch = string.Empty; @@ -143,9 +185,15 @@ public static class RoleGuidePatches _searchBoxTmp = null!; _rolesScroller = null!; _modifiersScroller = null!; - _advancedInfoTabScroller = null!; - _currentAdvancedTabObject = null!; + _modifierNoResultsMessage = null!; + _wikiScroller = null!; + _currentWikiPage = null!; _loadingSpinner = null; + ResetSettingsState(); + SearchIconButton = null!; + SearchSortingOrderButton = null!; + SearchSortingGroupButton = null!; + SearchSortingFilterButton = null!; _searchIconIdle = null!; _searchIconHover = null!; _searchSortingOrderIdle = null!; @@ -160,6 +208,7 @@ public static class RoleGuidePatches [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.Awake))] private static void AwakePostfix(MatchInfoGuide __instance) { + ApplyGuideScale(__instance, LocalSettingsTabSingleton.Instance.MatchInfoGuideScaleFactor.Value); Coroutines.Start(CoPreload(__instance)); } @@ -186,11 +235,7 @@ public static class RoleGuidePatches } _guide = instance; - if (RoleEntries.Count == 0) - { - BuildEntries(); - } - + BuildEntries(); if (_populate != null) { yield break; @@ -221,6 +266,8 @@ public static class RoleGuidePatches return false; } + ApplyGuideScale(__instance, LocalSettingsTabSingleton.Instance.MatchInfoGuideScaleFactor.Value); + if (Minigame.Instance != null) { Minigame.Instance.Close(); @@ -351,6 +398,8 @@ public static class RoleGuidePatches _sortMethod = MiraApiPlugin.WikiSortingMethod.Value; _sortGrouping = MiraApiPlugin.WikiSortingGroup.Value; + SetNoResultsText(instance.rolesEnabledMessage); + if (_populate != null) { Coroutines.Stop(_populate); @@ -368,9 +417,9 @@ public static class RoleGuidePatches HudManager.Instance.SettingsButton, searchComponentContainer.transform); sortingOrderButton.gameObject.SetActive(true); - var searchSortingOrderButton = sortingOrderButton.GetComponent(); + SearchSortingOrderButton = sortingOrderButton.GetComponent(); SetupSearchButton( - searchSortingOrderButton, + SearchSortingOrderButton, "SearchSortingOrderButton", SwitchMethod, GetSortMethodSprite(_sortMethod, false), @@ -383,9 +432,9 @@ public static class RoleGuidePatches sortingOrderButton, searchComponentContainer.transform); sortingGroupButton.gameObject.SetActive(true); - var searchSortingGroupButton = sortingGroupButton.GetComponent(); + SearchSortingGroupButton = sortingGroupButton.GetComponent(); SetupSearchButton( - searchSortingGroupButton, + SearchSortingGroupButton, "SearchSortingGroupButton", SwitchGrouping, GetSortGroupSprite(_sortGrouping, false), @@ -398,9 +447,9 @@ public static class RoleGuidePatches sortingOrderButton, searchComponentContainer.transform); sortingFilterButton.gameObject.SetActive(true); - var searchSortingFilterButton = sortingFilterButton.GetComponent(); + SearchSortingFilterButton = sortingFilterButton.GetComponent(); SetupSearchButton( - searchSortingFilterButton, + SearchSortingFilterButton, "SearchSortingFilterButton", SwitchFilter, GetSortFilterSprite(_sortFilter, false), @@ -419,29 +468,37 @@ public static class RoleGuidePatches instance.settingsTabs[RolesTabIndex], instance.settingsTabs[RolesTabIndex].transform.parent); modifiersTab.name = "ModifiersPanel"; + _modifierNoResultsMessage = Object.Instantiate( + instance.rolesEnabledMessage, + modifiersTab.transform, + true); + _modifierNoResultsMessage.name = "NoResultsFound"; + _modifierNoResultsMessage.SetActive(false); + SetNoResultsText(_modifierNoResultsMessage); modifiersTab.transform.FindChild("MaskArea")?.transform.localPosition = new Vector3(-0.0184f, 0.15f, -0.1f); modifiersTab.transform.GetAllChildren() .First(x => x.name.Contains("BG_Gradient")).transform.localPosition = new Vector3(0, -0.62f, -5); _modifiersScroller = modifiersTab.GetComponent(); instance.settingsTabs.Add(modifiersTab); + InitializeSettingsPanel(instance); var wikiTab = Object.Instantiate( instance.settingsTabs[RolesTabIndex], instance.settingsTabs[RolesTabIndex].transform.parent); - wikiTab.name = "AdvancedWikiPanels"; + wikiTab.name = "WikiPanels"; var wikiMask = wikiTab.transform.FindChild("MaskArea"); wikiMask.transform.localPosition = new Vector3(-0.0184f, 0.425f, -0.1f); wikiMask.transform.localScale = new Vector3(1, 1.3f, 1); wikiTab.transform.GetAllChildren() .First(x => x.name.Contains("BG_Gradient")).transform.localPosition = new Vector3(0, -0.62f, -5); - _advancedInfoTabScroller = wikiTab.GetComponent(); + _wikiScroller = wikiTab.GetComponent(); var wikiScrollHitbox = wikiTab.transform.FindChild("Hitbox"); - _advancedInfoTabScroller.ScrollbarYBounds.max = 1.2f; + _wikiScroller.ScrollbarYBounds.max = 1.2f; wikiScrollHitbox.GetComponent().size = new Vector2(5.75f, 2.4f); wikiScrollHitbox.transform.localPosition += new Vector3(0, 0.3f); - _advancedInfoTabScroller.ScrollbarY.trackGraphic.transform.localPosition = new Vector3(2.8364f, 0.3649f, -0.2f); - _advancedInfoTabScroller.ScrollbarY.trackGraphic.transform.localScale = new Vector3(0.0234f, 2.068f, 0.5598f); + _wikiScroller.ScrollbarY.trackGraphic.transform.localPosition = new Vector3(2.8364f, 0.3649f, -0.2f); + _wikiScroller.ScrollbarY.trackGraphic.transform.localScale = new Vector3(0.0234f, 2.068f, 0.5598f); var spinnerParent = instance.settingsTabs[RolesTabIndex].transform.parent; var spinnerPos = spinnerParent.InverseTransformPoint(instance.MatchInfoRoleMaskArea.transform.position); @@ -453,8 +510,9 @@ public static class RoleGuidePatches sortingOrderButton, sortingOrderButton.transform.parent); searchIconButton.gameObject.SetActive(true); + SearchIconButton = searchIconButton.GetComponent(); SetupSearchButton( - searchIconButton.GetComponent(), + SearchIconButton, "SearchIconButton", ClearSearchText, MiraAssets.SearchIconIdleSprite, @@ -603,9 +661,9 @@ public static class RoleGuidePatches instance.TabButtons[2].transform.parent); SetupTabButton( modifiersButton, - MiraAssets.WikiRolesButtonIdleSprite, - MiraAssets.WikiRolesButtonOpenSprite, - MiraAssets.WikiRolesButtonHoverSprite); + MiraAssets.WikiModifiersButtonIdleSprite, + MiraAssets.WikiModifiersButtonOpenSprite, + MiraAssets.WikiModifiersButtonHoverSprite); modifiersButton.OnClick = new Button.ButtonClickedEvent(); modifiersButton.OnClick.AddListener((Action)(() => OpenModifiersTab())); instance.TabButtons.Add(modifiersButton); @@ -619,17 +677,17 @@ public static class RoleGuidePatches orderSortingTmp.horizontalAlignment = HorizontalAlignmentOptions.Center; orderSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); - var groupSortingTmp = Object.Instantiate( + GroupSortingTmp = Object.Instantiate( orderSortingTmp, sortingGroupButton.transform); - orderSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.GroupingLabel"); - groupSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); + GroupSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.GroupingLabel"); + GroupSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); - var filterSortingTmp = Object.Instantiate( + FilterSortingTmp = Object.Instantiate( orderSortingTmp, sortingFilterButton.transform); - filterSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.FilterLabel"); - filterSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); + FilterSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.FilterLabel"); + FilterSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); playerButton.transform.localPosition = new Vector3(-3.6f, 0.656f, -0.2f); settingButton.transform.localPosition = new Vector3(-3.6f, 0.056f, -0.2f); @@ -640,19 +698,42 @@ public static class RoleGuidePatches private static void BuildEntries() { - foreach (var modifier in ModifierManager.Modifiers.OfType()) + if (ModifierEntries.Count == 0) { - ModifierEntries.Add(new DetailedPanel( - modifier.ModifierName, - modifier.ModifierCategoryTitle, - modifier.ParentMod.MiraPlugin.GetAbbreviatedModName(), - null, - modifier)); + foreach (var modifier in ModifierManager.Modifiers.OfType()) + { + ModifierEntries.Add(new DetailedPanel( + modifier.ModifierName, + modifier.ModifierCategoryTitle, + modifier.ParentMod.MiraPlugin.GetAbbreviatedModName(), + null, + modifier)); + } } + var activeMode = CustomGameModeManager.ActiveMode; + var activeModeType = activeMode?.GetType(); + if (_roleEntriesInitialized && _roleEntriesMode == activeModeType) + { + return; + } + + foreach (var entry in RoleEntries) + { + if (entry.Panel) + { + entry.Panel!.gameObject.Destroy(); + } + } + + RoleEntries.Clear(); + _rolePanelsCleaned = false; foreach (var roleBehaviour in CustomRoleManager.AllStoredRoleBehaviours.Where(x => x.Role is not RoleTypes.Crewmate and not RoleTypes.Impostor and - not RoleTypes.CrewmateGhost and not RoleTypes.ImpostorGhost)) + not RoleTypes.CrewmateGhost and not RoleTypes.ImpostorGhost && + CustomRoleUtils.CanSpawnOnCurrentMode(x)) + .GroupBy(x => x.Role) + .Select(group => group.First())) { var modId = roleBehaviour is ICustomRole custom ? custom.ParentMod.MiraPlugin.GetAbbreviatedModName() @@ -664,6 +745,9 @@ public static class RoleGuidePatches roleBehaviour, null)); } + + _roleEntriesMode = activeModeType; + _roleEntriesInitialized = true; } private static void SetupTabButton(MatchInfoGuideTabButton button, Sprite inactive, Sprite selected, Sprite hover) @@ -703,12 +787,57 @@ public static class RoleGuidePatches private static void OpenTab(int tabIndex, float delay = 0f) { - _advancedInfoTabScroller?.gameObject.SetActive(false); + _wikiScroller?.gameObject.SetActive(false); + _activeGuideTabIndex = tabIndex; MatchInfoGuide.Instance.SetActiveTab(tabIndex); + if (tabIndex == RolesTabIndex) + { + BuildEntries(); + if (!_rolePanelsCleaned) + { + _rolePanelsCleaned = RemoveUntrackedRolePanels(); + } + } + HidePanels(tabIndex == ModifiersTabIndex ? ModifierEntries : RoleEntries); StartPopulate(delay); } + private static bool RemoveUntrackedRolePanels() + { + if (_rolesScroller?.Inner == null) + { + return false; + } + + var trackedPanels = RoleEntries + .Where(entry => entry.Panel) + .Select(entry => entry.Panel!) + .ToHashSet(); + foreach (var panel in _rolesScroller.Inner.GetComponentsInChildren(true)) + { + if (!trackedPanels.Contains(panel)) + { + var panelName = panel.roleName ? panel.roleName.text : panel.name; + UntrackedRolePanelDiagnostics.Add(panelName); + panel.gameObject.Destroy(); + } + } + + if (UntrackedRolePanelDiagnostics.Count > 0) + { + var panelNames = string.Join(", ", UntrackedRolePanelDiagnostics.Take(10)); + var additionalCount = UntrackedRolePanelDiagnostics.Count - 10; + Logger.Warning( + $"Removed or blocked {UntrackedRolePanelDiagnostics.Count} untracked Match Info role panel(s) in " + + $"{CustomGameModeManager.ActiveMode?.GetType().Name ?? "vanilla"} mode: {panelNames}" + + (additionalCount > 0 ? $", and {additionalCount} more" : string.Empty)); + UntrackedRolePanelDiagnostics.Clear(); + } + + return true; + } + private static void HidePanels(List entries) { foreach (var entry in entries) @@ -730,6 +859,26 @@ public static class RoleGuidePatches OpenTab(RolesTabIndex); } + private static void SetNoResultsText(GameObject message) + { + var text = message.GetComponentInChildren(true); + if (!text) + { + return; + } + + text.GetComponent()?.Destroy(); + text.text = MiraLocaleManager.Get("MiraApi.Wiki.NoResultsFound", "No Results Found"); + } + + private static void SetNoResultsVisible(GameObject? message, bool visible) + { + if (message != null && message) + { + message.SetActive(visible); + } + } + private sealed class DetailedPanel(string title, string category, string modId, RoleBehaviour? role, GameModifier? modifier) { public MatchInfoRolePanel? Panel { get; set; } @@ -741,6 +890,8 @@ public static class RoleGuidePatches public int Amount { get; set; } public int Chance { get; set; } public float Likelihood { get; set; } + public bool CanShow { get; set; } + public bool CountsReady { get; set; } public bool Visible { get; set; } public string GetSortKey(SortingGroups grouping) @@ -774,8 +925,8 @@ public static class RoleGuidePatches if (_loadingSpinner != null) { _loadingSpinner.gameObject.SetActive( - _loading && MatchInfoGuide.Instance.activeTabIndex is RolesTabIndex or ModifiersTabIndex && - !(_advancedInfoTabScroller && _advancedInfoTabScroller.gameObject.activeSelf)); + _loading && ActiveGuideTabIndex is RolesTabIndex or ModifiersTabIndex && + !(_wikiScroller && _wikiScroller.gameObject.activeSelf)); } } @@ -819,8 +970,16 @@ public static class RoleGuidePatches }); _loading = false; UpdateSpinner(); - yield return CoForEachBudgeted(entries, EnsurePanel); - yield return CoForEachBudgeted(modifiers ? RoleEntries : ModifierEntries, PreloadPanel); + if (!modifiers) + { + RemoveUntrackedRolePanels(); + yield return null; + if (_guide) + { + RemoveUntrackedRolePanels(); + } + } + _populate = null; } @@ -849,6 +1008,7 @@ public static class RoleGuidePatches { if (entry.Panel) { + SetupPanelButton(entry.Panel!, () => OpenAdvancedWiki(entry)); return; } @@ -867,18 +1027,29 @@ public static class RoleGuidePatches if (entry.Modifier != null) { panel.SetModifierPanel(entry.Modifier, entry.Amount, entry.Chance); - SetupPanelButton(panel, () => DisplayAdvancedWiki(MatchInfoGuide.Instance, entry.Modifier)); } else { panel.SetRolePanel(entry.Role!, entry.Amount, entry.Chance); - SetupPanelButton(panel, () => DisplayAdvancedWiki(MatchInfoGuide.Instance, entry.Role!)); } + SetupPanelButton(panel, () => OpenAdvancedWiki(entry)); panel.gameObject.SetActive(false); entry.Panel = panel; } + private static void OpenAdvancedWiki(DetailedPanel entry) + { + if (entry.Modifier != null) + { + DisplayAdvancedWiki(MatchInfoGuide.Instance, entry.Modifier); + } + else if (entry.Role != null) + { + DisplayAdvancedWiki(MatchInfoGuide.Instance, entry.Role); + } + } + private static void PreloadPanel(DetailedPanel entry) { if (!entry.Panel) @@ -923,14 +1094,18 @@ public static class RoleGuidePatches entry.Amount = amount; entry.Chance = chance; entry.Likelihood = amount * chance; - entry.Visible = ShouldShowPanel(forceShow, amount == 0 || chance == 0, isHidden); + entry.CanShow = ShouldShowPanel(forceShow, amount == 0 || chance == 0, isHidden); + entry.CountsReady = true; + entry.Visible = entry.CanShow; } private static List UpdateEntries(List entries, bool modifiers) { + var searchText = GetSearchText(); foreach (var entry in entries) { ComputeCounts(entry); + entry.Visible = entry.CanShow && MatchesSearch(entry, searchText); if (entry.Panel) { SetPanelCount(entry.Panel!, entry.Amount, entry.Chance, entry.ModId); @@ -941,34 +1116,51 @@ public static class RoleGuidePatches } } - var searchText = GetSearchText(); - var sorted = entries.Where(p => p.Visible) - .OrderByDescending(p => p.Title.Equals(searchText, StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(p => - p.Title.Contains(searchText, StringComparison.OrdinalIgnoreCase) || - p.Category.Contains(searchText, StringComparison.OrdinalIgnoreCase)); - var shown = (_sortMethod is SortingMethod.Alphabetical - ? sorted.ThenBy(p => p.GetSortKey(_sortGrouping)) - : sorted.ThenByDescending(p => p.GetSortKey(_sortGrouping))) - .ToList(); + var shown = SortEntries(entries, searchText); var instance = MatchInfoGuide.Instance; if (modifiers) { _modifiersScroller.SetYBoundsMax(GetGridScrollBounds(shown.Count)); - /*instance.rolesEnabledMessage.SetActive(shown.Count == 0);*/ + SetNoResultsVisible(_modifierNoResultsMessage, shown.Count == 0); } else { instance.MatchInfoRoleScroller.SetYBoundsMax(GetGridScrollBounds(shown.Count)); - instance.rolesEnabledMessage.SetActive(shown.Count == 0); + SetNoResultsVisible(instance.rolesEnabledMessage, shown.Count == 0); } return shown; } + private static List SortEntries(List entries, string searchText) + { + var sorted = entries.Where(p => p.Visible) + .OrderByDescending(p => p.Title.Equals(searchText, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(p => + p.Title.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + p.Category.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + return (_sortMethod is SortingMethod.Alphabetical + ? sorted.ThenBy(p => p.GetSortKey(_sortGrouping)) + : sorted.ThenByDescending(p => p.GetSortKey(_sortGrouping))) + .ToList(); + } + + private static bool MatchesSearch(DetailedPanel entry, string searchText) + { + return searchText.Length == 0 || + entry.Title.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + entry.Category.Contains(searchText, StringComparison.OrdinalIgnoreCase); + } + private static void RefreshActiveTab(bool scrollToTop) { + if (IsSettingsTabActive) + { + RefreshSettingsTab(scrollToTop); + return; + } + StartPopulate(); if (scrollToTop) { @@ -997,9 +1189,85 @@ public static class RoleGuidePatches } _appliedSearch = text; + if (IsSettingsTabActive) + { + RefreshSettingsTab(true); + yield break; + } + + if (TryRefreshSearch(text)) + { + yield break; + } + RefreshActiveTab(true); } + private static bool TryRefreshSearch(string searchText) + { + var guide = MatchInfoGuide.Instance; + if (!_guide || guide != _guide) + { + return false; + } + + var activeTabIndex = ActiveGuideTabIndex; + var entries = activeTabIndex switch + { + RolesTabIndex => RoleEntries, + ModifiersTabIndex => ModifierEntries, + _ => null, + }; + if (entries == null || _populate != null || !ArePanelsReady(entries)) + { + return false; + } + + foreach (var entry in entries) + { + entry.Visible = entry.CanShow && MatchesSearch(entry, searchText); + if (entry.Panel!.gameObject.activeSelf != entry.Visible) + { + entry.Panel.gameObject.SetActive(entry.Visible); + } + } + + var shown = SortEntries(entries, searchText); + foreach (var entry in shown) + { + entry.Panel!.transform.SetAsLastSibling(); + } + + var scroller = activeTabIndex == ModifiersTabIndex + ? _modifiersScroller + : guide.MatchInfoRoleScroller; + scroller.SetYBoundsMax(GetGridScrollBounds(shown.Count)); + var noResultsMessage = activeTabIndex == ModifiersTabIndex + ? _modifierNoResultsMessage + : guide.rolesEnabledMessage; + SetNoResultsVisible(noResultsMessage, shown.Count == 0); + scroller.ScrollToTop(); + return true; + } + + private static bool ArePanelsReady(List entries) + { + if (entries.Count == 0) + { + return false; + } + + foreach (var entry in entries) + { + if (!entry.CountsReady || !entry.Panel) + { + return false; + } + } + + return true; + } + private static void ResetSearchBox() { if (_searchDebounce != null) @@ -1035,6 +1303,12 @@ public static class RoleGuidePatches public static void SwitchFilter() { + if (IsSettingsTabActive) + { + ChangeSettingsPage(-1); + return; + } + var stepUp = (SortingFilter)((int)_sortFilter + 1); _sortFilter = Enum.IsDefined(stepUp) ? stepUp : SortingFilter.EnabledOnly; @@ -1075,6 +1349,11 @@ public static class RoleGuidePatches public static void SwitchMethod() { + if (IsSettingsTabActive) + { + return; + } + var stepUp = (SortingMethod)((int)_sortMethod + 1); _sortMethod = Enum.IsDefined(stepUp) ? stepUp : SortingMethod.Alphabetical; @@ -1103,6 +1382,12 @@ public static class RoleGuidePatches public static void SwitchGrouping() { + if (IsSettingsTabActive) + { + ChangeSettingsPage(1); + return; + } + var stepUp = (SortingGroups)((int)_sortGrouping + 1); if (Enum.IsDefined(stepUp)) { @@ -1175,10 +1460,22 @@ public static class RoleGuidePatches private static void SetupPanelButton(MatchInfoRolePanel panel, Action onClick) { - var collider = panel.roleIcon.gameObject.AddComponent(); + var collider = panel.roleIcon.gameObject.GetComponent(); + if (!collider) + { + collider = panel.roleIcon.gameObject.AddComponent(); + } + collider.size = new Vector2(0.13f, 0.13f); collider.offset = new Vector2(0, 0); - var passiveButton = panel.roleIcon.gameObject.AddComponent(); + collider.enabled = true; + var passiveButton = panel.roleIcon.gameObject.GetComponent(); + if (!passiveButton) + { + passiveButton = panel.roleIcon.gameObject.AddComponent(); + } + + passiveButton.enabled = true; passiveButton.ClickSound = HudManager.Instance.MapButton.ClickSound; passiveButton.OnMouseOver = new UnityEvent(); passiveButton.OnMouseOver.AddListener( @@ -1234,6 +1531,9 @@ public static class RoleGuidePatches [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.SetActiveTab))] private static void SetActiveTab(int tabIndex) { + _activeGuideTabIndex = tabIndex; + HideNoResultsMessages(); + if (_titleText && _titleIcon) { switch (tabIndex) @@ -1242,9 +1542,9 @@ public static class RoleGuidePatches _titleText.text = MiraLocaleManager.Get("MiraApi.Wiki.PlayersTab"); _titleIcon.sprite = MiraAssets.IconPlayerList.LoadAsset(); break; - case 1: - _titleText.text = MiraLocaleManager.Get("MiraApi.Wiki.SettingsTab"); - _titleIcon.sprite = MiraAssets.IconSettings.LoadAsset(); + case SettingsTabIndex: + _titleText.text = GetSettingsTitle(); + _titleIcon.sprite = GetSettingsIcon(); break; case RolesTabIndex: _titleText.text = MiraLocaleManager.Get("Roles"); @@ -1263,11 +1563,32 @@ public static class RoleGuidePatches _titleIcon.SetSizeLimit(0.55f); } - _advancedInfoTabScroller?.gameObject.SetActive(false); + _wikiScroller?.gameObject.SetActive(false); searchComponentContainer?.SetActive(true); + if (tabIndex == SettingsTabIndex) + { + EnterSettingsTab(); + } + else + { + LeaveSettingsTab(); + } + UpdateSpinner(); } + private static void HideNoResultsMessages() + { + var guide = _guide; + if (guide != null && guide) + { + SetNoResultsVisible(guide.rolesEnabledMessage, false); + } + + SetNoResultsVisible(_modifierNoResultsMessage, false); + SetNoResultsVisible(_settingsNoResults, false); + } + public static Sprite GetRandomModifierIcon() { var activeMods = PlayerControl.LocalPlayer.GetModifiers() @@ -1307,14 +1628,15 @@ public static class RoleGuidePatches return MiraAssets.IconNeutral.LoadAsset(); } - private static bool TryPrepareAdvancedWiki(MatchInfoGuide instance) + private static bool TryPrepareWikiPage(MatchInfoGuide instance) { instance.settingsTabs[RolesTabIndex].SetActive(false); _modifiersScroller?.gameObject.SetActive(false); - if (_currentAdvancedTabObject) + LeaveSettingsTab(); + if (_currentWikiPage) { - _currentAdvancedTabObject.SetActive(false); - _currentAdvancedTabObject.Destroy(); + _currentWikiPage.SetActive(false); + _currentWikiPage.Destroy(); } if (searchComponentContainer) @@ -1322,18 +1644,18 @@ public static class RoleGuidePatches searchComponentContainer.SetActive(false); } - if (_advancedInfoTabScroller == null) + if (_wikiScroller == null) { Warning("Wiki tab is null."); return false; } - _advancedInfoTabScroller.gameObject.SetActive(true); + _wikiScroller.gameObject.SetActive(true); UpdateSpinner(); return true; } - private static IEnumerator CoDisplayAdvancedWikiLater(float delay, Action display) + private static IEnumerator CoDisplayWikiPageLater(float delay, Action display) { yield return new WaitForSeconds(delay); if (_guide && MatchInfoGuide.Instance && MatchInfoGuide.Instance.MatchInfoParent.activeSelf) @@ -1347,18 +1669,18 @@ public static class RoleGuidePatches var remaining = _openedAt + instance.transitionOpen.duration - Time.time; if (remaining > 0f) { - Coroutines.Start(CoDisplayAdvancedWikiLater(remaining, () => DisplayAdvancedWiki(instance, modifier))); + Coroutines.Start(CoDisplayWikiPageLater(remaining, () => DisplayAdvancedWiki(instance, modifier))); return; } var name = modifier.ModifierName; - Info($"Opening advanced tab for {name}."); - if (!TryPrepareAdvancedWiki(instance)) + Info($"Opening wiki page for {name}."); + if (!TryPrepareWikiPage(instance)) { return; } - _currentAdvancedTabObject = modifier.GetAdvancedWiki(instance, _titleText, _titleIcon, _advancedInfoTabScroller); + _currentWikiPage = modifier.GetAdvancedWiki(instance, _titleText, _titleIcon, _wikiScroller); } public static void DisplayAdvancedWiki(MatchInfoGuide instance, RoleBehaviour role) @@ -1366,19 +1688,19 @@ public static class RoleGuidePatches var remaining = _openedAt + instance.transitionOpen.duration - Time.time; if (remaining > 0f) { - Coroutines.Start(CoDisplayAdvancedWikiLater(remaining, () => DisplayAdvancedWiki(instance, role))); + Coroutines.Start(CoDisplayWikiPageLater(remaining, () => DisplayAdvancedWiki(instance, role))); return; } - Info($"Opening advanced tab for {role.GetRoleName()}."); - if (!TryPrepareAdvancedWiki(instance)) + Info($"Opening wiki page for {role.GetRoleName()}."); + if (!TryPrepareWikiPage(instance)) { return; } if (role is ICustomRole custom) { - _currentAdvancedTabObject = custom.GetAdvancedWiki(instance, _titleText, _titleIcon, _advancedInfoTabScroller); + _currentWikiPage = custom.GetAdvancedWiki(instance, _titleText, _titleIcon, _wikiScroller); } else { @@ -1394,11 +1716,11 @@ public static class RoleGuidePatches } } - _currentAdvancedTabObject = Helpers.CreateAdvancedWikiPage( + _currentWikiPage = Helpers.CreateAdvancedWikiPage( instance, _titleText, _titleIcon, - _advancedInfoTabScroller, + _wikiScroller, role.Role.ToString(), titleTxt, role.RoleIconColor, @@ -1412,6 +1734,17 @@ public static class RoleGuidePatches [HarmonyPatch(typeof(MatchInfoRolePanel), nameof(MatchInfoRolePanel.SetPanel))] public static bool SetPanel(MatchInfoRolePanel __instance, RoleBehaviour role, int numPerGame, int chancePerGame) { + var rolesInner = _rolesScroller ? _rolesScroller.Inner : null; + if (rolesInner && + __instance.transform.IsChildOf(rolesInner) && + !RoleEntries.Any(entry => entry.Panel == __instance)) + { + UntrackedRolePanelDiagnostics.Add( + role is ICustomRole untrackedRole ? untrackedRole.RoleName : role.NiceName); + __instance.gameObject.Destroy(); + return false; + } + __instance.roleCount.text = string.Format( CultureInfo.InvariantCulture, "{0} at {1}%", @@ -1517,6 +1850,391 @@ public static class RoleGuidePatches { return customRole.RoleFactionTitle; } + + // Settings page state. + private static Scroller? _settingsScroller; + private static GameObject? _settingsNoResults; + private static GameObject? _settingsPageObject; + private static List _settingsPages = []; + private static int _settingsPage; // 0 = vanilla settings, 1..N = _settingsPages[page - 1] + private static bool _settingsSearching; + + // Save the sort-button appearance while they are used for page navigation. + private static bool _sortButtonsRepurposed; + private static string _savedFilterLabel = string.Empty; + private static string _savedGroupLabel = string.Empty; + private static Vector3 _savedFilterIconScale = Vector3.one; + private static Vector3 _savedGroupIconScale = Vector3.one; + private static Vector3 _savedFilterLabelPosition = Vector3.zero; + private static Vector3 _savedGroupLabelPosition = Vector3.zero; + + private static bool IsSettingsTabActive => + _uiInitialized && MatchInfoGuide.Instance && ActiveGuideTabIndex == SettingsTabIndex; + + private static void ResetSettingsState() + { + _settingsScroller = null; + _settingsNoResults = null; + _settingsPageObject = null; + _settingsPages = []; + _settingsPage = 0; + _settingsSearching = false; + _sortButtonsRepurposed = false; + } + + private static void InitializeSettingsPanel(MatchInfoGuide instance) + { + var rolesTab = instance.settingsTabs[RolesTabIndex]; + var settingsTab = Object.Instantiate(rolesTab, rolesTab.transform.parent); + settingsTab.name = "SettingsOptionsPanel"; + settingsTab.transform.FindChild("MaskArea")?.transform.localPosition = new Vector3(-0.0184f, 0.15f, -0.1f); + settingsTab.transform.GetAllChildren() + .First(x => x.name.Contains("BG_Gradient")).transform.localPosition = new Vector3(0, -0.62f, -5); + _settingsScroller = settingsTab.GetComponent(); + settingsTab.SetActive(false); + + _settingsNoResults = Object.Instantiate( + instance.rolesEnabledMessage, + settingsTab.transform, + true); + _settingsNoResults.name = "SettingsNoResultsFound"; + _settingsNoResults.SetActive(false); + SetNoResultsText(_settingsNoResults); + } + + private static void EnterSettingsTab() + { + if (!_uiInitialized) + { + return; + } + + RefreshSettingsTab(true); + } + + private static void LeaveSettingsTab() + { + if (_settingsScroller != null) + { + _settingsScroller.gameObject.SetActive(false); + } + + if (_settingsNoResults) + { + _settingsNoResults!.SetActive(false); + } + + RestoreSortButtons(); + } + + private static void ChangeSettingsPage(int delta) + { + var total = _settingsPages.Count + 1; + if (total <= 1) + { + return; + } + + _settingsPage = (_settingsPage + delta + total) % total; + RefreshSettingsTab(true); + } + + /// + /// Updates the Settings tab with the current option values. + /// + private static void RefreshSettingsTab(bool scrollToTop) + { + var guide = MatchInfoGuide.Instance; + if (!_uiInitialized || !guide) + { + return; + } + + var vanillaTab = guide.settingsTabs[SettingsTabIndex]; + vanillaTab.SetActive(false); + var vanillaEntries = GameOptionsMenuPatch.GetActiveGameSettingsRows(guide.GetBoolString); + if (vanillaEntries.Count == 0) + { + vanillaEntries = BuildVanillaSettingsEntries(guide); + } + + _settingsPages = GuideOptionPages.BuildPages(guide.GetBoolString); + if (_settingsPage > _settingsPages.Count) + { + _settingsPage = 0; + } + + var searchText = GetSearchText().Trim(); + _settingsSearching = searchText.Length > 0; + + if (_settingsSearching) + { + ShowSettingsEntries(GuideOptionPages.Search(_settingsPages, searchText), true, scrollToTop); + vanillaTab.SetActive(false); + RepurposeSortButtons(false); + } + else if (_settingsPage == 0) + { + ShowSettingsRows(vanillaEntries, scrollToTop); + RepurposeSortButtons(true); + } + else + { + var page = _settingsPages[_settingsPage - 1]; + ShowSettingsEntries( + page.Entries, + page.Groups.Count > 1, + scrollToTop, + page.Columns, + page.Groups.Count > 1); + vanillaTab.SetActive(false); + RepurposeSortButtons(true); + } + + UpdateSettingsTitle(); + } + + internal static void RefreshSettingsForGameMode() + { + if (IsSettingsTabActive) + { + RefreshSettingsTab(false); + } + } + + private static List> BuildVanillaSettingsEntries(MatchInfoGuide guide) + { + foreach (var setting in guide.NormalModeSettings.ToArray()) + { + if (setting) + { + setting.Destroy(); + } + } + + guide.NormalModeSettings.Clear(); + guide.CreateNormalModeSettings(); + + var entries = new List>(guide.NormalModeSettings.Count); + foreach (var setting in guide.NormalModeSettings) + { + if (!setting) + { + continue; + } + + var texts = setting.GetComponentsInChildren(true); + if (texts.Length < 2) + { + Warning($"Could not read vanilla settings row '{setting.name}'."); + continue; + } + + entries.Add(new KeyValuePair(texts[0].text, texts[1].text)); + } + + return entries; + } + + private static void ShowSettingsEntries( + List entries, + bool showGroup, + bool scrollToTop, + int columns = 2, + bool groupsAsColumns = false) + { + var orderedEntries = showGroup + ? entries.GroupBy(entry => entry.Group).SelectMany(group => group) + : entries; + var rows = orderedEntries + .Select(entry => new AdvancedSettingsRow(entry.Title, entry.Value, showGroup ? entry.Group : null)) + .ToList(); + ShowSettingsRows(rows, scrollToTop, columns, groupsAsColumns); + _settingsNoResults?.SetActive(_settingsSearching && entries.Count == 0); + } + + private static void ShowSettingsRows(IReadOnlyList> rows, bool scrollToTop) + { + var advancedRows = rows + .Select(row => new AdvancedSettingsRow(row.Key, row.Value, null)) + .ToList(); + ShowSettingsRows(advancedRows, scrollToTop); + } + + private static void ShowSettingsRows( + List rows, + bool scrollToTop, + int columns = 2, + bool groupsAsColumns = false) + { + var guide = MatchInfoGuide.Instance; + if (_settingsScroller == null || !guide || !guide.MatchInfoRolePanelPrefab) + { + // Use the vanilla settings tab if the custom panel is unavailable. + guide?.settingsTabs[SettingsTabIndex].SetActive(true); + return; + } + + if (_settingsPageObject) + { + _settingsPageObject!.SetActive(false); + _settingsPageObject.Destroy(); + _settingsPageObject = null; + } + + _settingsScroller.gameObject.SetActive(true); + _settingsPageObject = Helpers.CreateAdvancedSettingsPage( + guide, + _settingsScroller, + rows, + columns, + groupsAsColumns); + _settingsNoResults?.SetActive(false); + if (scrollToTop) + { + _settingsScroller.ScrollToTop(); + } + } + + private static string GetSettingsTitle() + { + var baseTitle = MiraLocaleManager.Get("MiraApi.Wiki.SettingsTab"); + if (!_uiInitialized) + { + return baseTitle; + } + + if (_settingsSearching) + { + return $"{baseTitle}: {MiraLocaleManager.Get("MiraApi.Wiki.SearchResults", "Search Results")}"; + } + + if (_settingsPage == 0 || _settingsPages.Count == 0) + { + var vanillaTitle = $"{MiraLocaleManager.Get("MiraApi.Wiki.VanillaSettings", "Vanilla")} Settings"; + return $"{vanillaTitle} ({_settingsPage + 1}/{_settingsPages.Count + 1})"; + } + + var counter = $"({_settingsPage + 1}/{_settingsPages.Count + 1})"; + var pageTitle = _settingsPages[_settingsPage - 1].Title; + var titleAlreadyDescribesSettings = pageTitle.EndsWith("settings", StringComparison.OrdinalIgnoreCase) || + pageTitle.EndsWith("options", StringComparison.OrdinalIgnoreCase); + return titleAlreadyDescribesSettings + ? $"{pageTitle} {counter}" + : $"{pageTitle} Settings {counter}"; + } + + private static Sprite GetSettingsIcon() + { + if (!_uiInitialized || _settingsSearching || _settingsPage == 0 || _settingsPages.Count == 0) + { + return MiraAssets.IconSettings; + } + + return _settingsPages[_settingsPage - 1].Sprite; + } + + private static void UpdateSettingsTitle() + { + if (_titleText && IsSettingsTabActive) + { + _titleText.text = GetSettingsTitle(); + if (_titleIcon) + { + _titleIcon.sprite = GetSettingsIcon(); + + _titleIcon.SetSizeLimit(0.55f); + } + } + } + + /// + /// Turns the Filter button into "previous page" and the Grouping button into "next page", + /// and hides the Order button (it only sorts roles/modifiers). + /// + /// False while searching, since search results are a single page. + private static void RepurposeSortButtons(bool paginationVisible) + { + if (!SearchSortingFilterButton || !SearchSortingGroupButton || !SearchSortingOrderButton) + { + return; + } + + if (!_sortButtonsRepurposed) + { + _savedFilterLabel = FilterSortingTmp.text; + _savedGroupLabel = GroupSortingTmp.text; + _savedFilterIconScale = _searchSortingFilterIdle.transform.localScale; + _savedGroupIconScale = _searchSortingGroupIdle.transform.localScale; + _savedFilterLabelPosition = FilterSortingTmp.transform.localPosition; + _savedGroupLabelPosition = GroupSortingTmp.transform.localPosition; + + SetPaginationSprites(_searchSortingFilterIdle, _searchSortingFilterHover, true); + SetPaginationSprites(_searchSortingGroupIdle, _searchSortingGroupHover, false); + FilterSortingTmp.transform.localPosition = new Vector3( + -1.25f, + 0f, + _savedFilterLabelPosition.z); + GroupSortingTmp.transform.localPosition = new Vector3( + 0f, + 0f, + _savedGroupLabelPosition.z); + FilterSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.PrevPage", "Prev"); + GroupSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.NextPage", "Next"); + _sortButtonsRepurposed = true; + } + + SearchSortingOrderButton.gameObject.SetActive(false); + SearchSortingFilterButton.gameObject.SetActive(paginationVisible); + SearchSortingGroupButton.gameObject.SetActive(paginationVisible); + } + + private static void SetPaginationSprites(SpriteRenderer idle, SpriteRenderer hover, bool isLeft) + { + var idleSprite = isLeft ? MiraAssets.PageLeftIdle.LoadAsset() : MiraAssets.PageRightIdle.LoadAsset(); + var hoverSprite = isLeft ? MiraAssets.PageLeftHover.LoadAsset() : MiraAssets.PageRightHover.LoadAsset(); + + // Match the on-screen size of the sort icon being replaced, since the arrow art differs in size. + var currentHeight = idle.sprite ? idle.sprite.bounds.size.y * idle.transform.localScale.y : 0f; + var newHeight = idleSprite.bounds.size.y; + var scale = currentHeight > 0f && newHeight > 0f ? currentHeight / newHeight : 1f; + + idle.sprite = idleSprite; + hover.sprite = hoverSprite; + idle.transform.localScale = hover.transform.localScale = new Vector3(scale, scale, 1f); + } + + private static void RestoreSortButtons() + { + if (!_sortButtonsRepurposed) + { + return; + } + + _sortButtonsRepurposed = false; + if (!SearchSortingFilterButton || !SearchSortingGroupButton || !SearchSortingOrderButton) + { + return; + } + + _searchSortingFilterIdle.sprite = GetSortFilterSprite(_sortFilter, false); + _searchSortingFilterHover.sprite = GetSortFilterSprite(_sortFilter, true); + _searchSortingGroupIdle.sprite = GetSortGroupSprite(_sortGrouping, false); + _searchSortingGroupHover.sprite = GetSortGroupSprite(_sortGrouping, true); + _searchSortingFilterIdle.flipX = _searchSortingFilterHover.flipX = false; + _searchSortingGroupIdle.flipX = _searchSortingGroupHover.flipX = false; + _searchSortingFilterIdle.transform.localScale = + _searchSortingFilterHover.transform.localScale = _savedFilterIconScale; + _searchSortingGroupIdle.transform.localScale = + _searchSortingGroupHover.transform.localScale = _savedGroupIconScale; + FilterSortingTmp.text = _savedFilterLabel; + GroupSortingTmp.text = _savedGroupLabel; + FilterSortingTmp.transform.localPosition = _savedFilterLabelPosition; + GroupSortingTmp.transform.localPosition = _savedGroupLabelPosition; + SearchSortingOrderButton.gameObject.SetActive(true); + SearchSortingFilterButton.gameObject.SetActive(true); + SearchSortingGroupButton.gameObject.SetActive(true); + } } internal enum SortingGroups @@ -1537,4 +2255,4 @@ internal enum SortingFilter EnabledOnly, DisabledOnly, AllRoles, -} +} \ No newline at end of file diff --git a/MiraAPI/Resources/Locale/en_US.xml b/MiraAPI/Resources/Locale/en_US.xml index 4ee0714..3cff5f4 100644 --- a/MiraAPI/Resources/Locale/en_US.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -8,6 +8,7 @@ Show Keybinds on Buttons Button Scale Factor Top-Right UI Scale Factor + Match Info Guide Scale Factor Max FPS Display Wiki on Bottom Row Enable Sabotage Flashes @@ -60,4 +61,9 @@ Outlive the timer or eliminate the crew. Filter Grouping Order + Vanilla + Search Results + Previous + Next + No Results Found diff --git a/MiraAPI/Resources/Wiki/PageLeftHover.png b/MiraAPI/Resources/Wiki/PageLeftHover.png new file mode 100644 index 0000000..7fa3b15 Binary files /dev/null and b/MiraAPI/Resources/Wiki/PageLeftHover.png differ diff --git a/MiraAPI/Resources/Wiki/PageLeftIdle.png b/MiraAPI/Resources/Wiki/PageLeftIdle.png new file mode 100644 index 0000000..cbedc93 Binary files /dev/null and b/MiraAPI/Resources/Wiki/PageLeftIdle.png differ diff --git a/MiraAPI/Resources/Wiki/PageRightHover.png b/MiraAPI/Resources/Wiki/PageRightHover.png new file mode 100644 index 0000000..8ffb435 Binary files /dev/null and b/MiraAPI/Resources/Wiki/PageRightHover.png differ diff --git a/MiraAPI/Resources/Wiki/PageRightIdle.png b/MiraAPI/Resources/Wiki/PageRightIdle.png new file mode 100644 index 0000000..0cb09f6 Binary files /dev/null and b/MiraAPI/Resources/Wiki/PageRightIdle.png differ diff --git a/MiraAPI/Resources/Wiki/SidebarModifiersButtonHover.png b/MiraAPI/Resources/Wiki/SidebarModifiersButtonHover.png new file mode 100644 index 0000000..090abc5 Binary files /dev/null and b/MiraAPI/Resources/Wiki/SidebarModifiersButtonHover.png differ diff --git a/MiraAPI/Resources/Wiki/SidebarModifiersButtonIdle.png b/MiraAPI/Resources/Wiki/SidebarModifiersButtonIdle.png new file mode 100644 index 0000000..2712441 Binary files /dev/null and b/MiraAPI/Resources/Wiki/SidebarModifiersButtonIdle.png differ diff --git a/MiraAPI/Resources/Wiki/SidebarModifiersButtonOpen.png b/MiraAPI/Resources/Wiki/SidebarModifiersButtonOpen.png new file mode 100644 index 0000000..1127956 Binary files /dev/null and b/MiraAPI/Resources/Wiki/SidebarModifiersButtonOpen.png differ diff --git a/MiraAPI/Resources/Wiki/SidebarSettingsButtonHover.png b/MiraAPI/Resources/Wiki/SidebarSettingsButtonHover.png index 7813904..b4e53a9 100644 Binary files a/MiraAPI/Resources/Wiki/SidebarSettingsButtonHover.png and b/MiraAPI/Resources/Wiki/SidebarSettingsButtonHover.png differ diff --git a/MiraAPI/Resources/Wiki/SidebarSettingsButtonIdle.png b/MiraAPI/Resources/Wiki/SidebarSettingsButtonIdle.png index fdf65df..2d040a6 100644 Binary files a/MiraAPI/Resources/Wiki/SidebarSettingsButtonIdle.png and b/MiraAPI/Resources/Wiki/SidebarSettingsButtonIdle.png differ diff --git a/MiraAPI/Resources/Wiki/SidebarSettingsButtonOpen.png b/MiraAPI/Resources/Wiki/SidebarSettingsButtonOpen.png index ec5bb14..e750bac 100644 Binary files a/MiraAPI/Resources/Wiki/SidebarSettingsButtonOpen.png and b/MiraAPI/Resources/Wiki/SidebarSettingsButtonOpen.png differ diff --git a/MiraAPI/Utilities/Assets/MiraAssets.cs b/MiraAPI/Utilities/Assets/MiraAssets.cs index 0feb75c..705bc32 100644 --- a/MiraAPI/Utilities/Assets/MiraAssets.cs +++ b/MiraAPI/Utilities/Assets/MiraAssets.cs @@ -205,6 +205,21 @@ public static class MiraAssets /// public static LoadableResourceAsset WikiRolesButtonOpenSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarRolesButtonOpen.png"); + /// + /// Gets the sprite used for the modifiers button while hovering. + /// + public static LoadableResourceAsset WikiModifiersButtonHoverSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarModifiersButtonHover.png"); + + /// + /// Gets the sprite used for the modifiers button while hovering. + /// + public static LoadableResourceAsset WikiModifiersButtonIdleSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarModifiersButtonIdle.png"); + + /// + /// Gets the sprite used for the modifiers button while hovering. + /// + public static LoadableResourceAsset WikiModifiersButtonOpenSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarModifiersButtonOpen.png"); + /// /// Gets the sprite used for the roles button while hovering. /// @@ -335,5 +350,25 @@ public static class MiraAssets /// public static LoadableResourceAsset IconTerminology { get; } = new("MiraAPI.Resources.Wiki.IconTerminology.png"); + /// + /// Gets the Page switching sprite. + /// + public static LoadableResourceAsset PageLeftHover { get; } = new("MiraAPI.Resources.Wiki.PageLeftHover.png"); + + /// + /// Gets the Page switching sprite. + /// + public static LoadableResourceAsset PageLeftIdle { get; } = new("MiraAPI.Resources.Wiki.PageLeftIdle.png"); + + /// + /// Gets the Page switching sprite. + /// + public static LoadableResourceAsset PageRightHover { get; } = new("MiraAPI.Resources.Wiki.PageRightHover.png"); + + /// + /// Gets the Page switching sprite. + /// + public static LoadableResourceAsset PageRightIdle { get; } = new("MiraAPI.Resources.Wiki.PageRightIdle.png"); + internal static LoadableResourceAsset BlankSprite { get; } = new("MiraAPI.Resources.BlankSprite.png"); } diff --git a/MiraAPI/Utilities/GuideOptionsPatches.cs b/MiraAPI/Utilities/GuideOptionsPatches.cs new file mode 100644 index 0000000..22703b5 --- /dev/null +++ b/MiraAPI/Utilities/GuideOptionsPatches.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.PluginLoading; +using MiraAPI.Translation; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using UnityEngine; + +namespace MiraAPI.Utilities; + +internal sealed record GuideOptionEntry( + string Title, + string Value, + AbstractOptionGroup Group, + string GroupName, + string ModAbbreviation, + IReadOnlyList ModSearchTerms); + +internal sealed class GuideOptionPage( + AbstractOptionGroup group, + string title, + int order, + int menuOrder, + uint groupPriority, + int columns, + string modAbbreviation, + IReadOnlyList modSearchTerms, + List entries) +{ + public AbstractOptionGroup Group { get; } = group; + + public List Groups { get; } = [group]; + + public string Title { get; } = title; + + public LoadableAsset Sprite { get; } = group.Sprite; + + public int Order { get; set; } = order; + + public int MenuOrder { get; set; } = menuOrder; + + public uint GroupPriority { get; set; } = groupPriority; + + public int Columns { get; } = columns; + + public string ModAbbreviation { get; } = modAbbreviation; + + public HashSet ModSearchTerms { get; } = new(modSearchTerms, StringComparer.OrdinalIgnoreCase); + + public List Entries { get; } = entries; +} + +internal static class GuideOptionPages +{ + private static readonly Regex TrailingZeroRegex = new(@"\.0+(?!\d)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + + public static List BuildPages(Func formatBool) + { + var pages = new List(); + var combinedPages = new Dictionary(StringComparer.OrdinalIgnoreCase); + var mode = CustomGameModeManager.ActiveMode; + + var groups = MiraPluginManager.Instance.PluginsWithOptionsOrGameModes + .SelectMany(plugin => plugin.InternalOptionGroups + .Where(group => group.ShowInMatchInfoGuide && IsSettingsGroup(group, mode) && SafeGroupVisible(group)) + .Select(group => ( + Plugin: plugin, + Group: group, + ModAbbreviation: plugin.MiraPlugin.GetAbbreviatedModName()))) + .OrderBy(item => GetMenuOrder(item.Group.ParentMenu)) + .ThenBy(item => item.Group.GroupPriority) + .ThenBy(item => item.Group.GroupName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.ModAbbreviation, StringComparer.OrdinalIgnoreCase); + + foreach (var item in groups) + { + var groupName = item.Group.GroupName.Translate(); + var modSearchTerms = GetModSearchTerms(item.Plugin, item.Group); + var entries = new List(); + + foreach (var option in item.Group.Options) + { + if (!SafeOptionVisible(option)) + { + continue; + } + + var entry = TryCreateEntry( + option, + item.Group, + groupName, + item.ModAbbreviation, + modSearchTerms, + formatBool); + if (entry != null) + { + entries.Add(entry); + } + } + + if (entries.Count > 0) + { + var pageKey = item.Group.MatchInfoGuidePageKey?.Trim(); + var combinedKey = string.IsNullOrEmpty(pageKey) ? null : $"{item.Plugin.PluginId}\0{pageKey}"; + if (combinedKey != null && combinedPages.TryGetValue(combinedKey, out var combinedPage)) + { + combinedPage.Groups.Add(item.Group); + combinedPage.Entries.AddRange(entries); + combinedPage.ModSearchTerms.UnionWith(modSearchTerms); + combinedPage.Order = Math.Min(combinedPage.Order, item.Group.MatchInfoGuidePageOrder); + combinedPage.MenuOrder = Math.Min( + combinedPage.MenuOrder, + GetMenuOrder(item.Group.ParentMenu)); + combinedPage.GroupPriority = Math.Min( + combinedPage.GroupPriority, + item.Group.GroupPriority); + } + else + { + var page = new GuideOptionPage( + item.Group, + item.Group.MatchInfoGuidePageTitle, + item.Group.MatchInfoGuidePageOrder, + GetMenuOrder(item.Group.ParentMenu), + item.Group.GroupPriority, + Mathf.Clamp(item.Group.MatchInfoGuidePageColumns, 1, 6), + item.ModAbbreviation, + modSearchTerms, + entries); + pages.Add(page); + if (combinedKey != null) + { + combinedPages.Add(combinedKey, page); + } + } + } + } + + return pages + .OrderBy(page => page.MenuOrder) + .ThenBy(page => page.GroupPriority) + .ThenBy(page => page.Order) + .ThenBy(page => page.Group.GroupName, StringComparer.OrdinalIgnoreCase) + .ThenBy(page => page.ModAbbreviation, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static int GetMenuOrder(MenuCategory menu) + { + return menu switch + { + MenuCategory.Game => 0, + MenuCategory.CustomOne => 1, + MenuCategory.CustomTwo => 2, + _ => int.MaxValue, + }; + } + + public static List Search(IEnumerable pages, string query) + { + query = query.Trim(); + if (query.Length == 0) + { + return []; + } + + const StringComparison cmp = StringComparison.OrdinalIgnoreCase; + var pageList = pages.ToList(); + var normalizedQuery = NormalizeSearchText(query); + var matchingModPages = normalizedQuery.Length < 3 + ? Array.Empty() + : pageList + .Where(page => page.ModSearchTerms.Any(term => + NormalizeSearchText(term).Contains(normalizedQuery, StringComparison.Ordinal))) + .ToArray(); + + if (matchingModPages.Length > 0) + { + return matchingModPages.SelectMany(page => page.Entries).ToList(); + } + + return pageList + .SelectMany(page => page.Entries + .Select(entry => (Entry: entry, Rank: Rank(entry, query, cmp))) + .Where(result => result.Rank < int.MaxValue) + .OrderBy(result => result.Rank) + .Select(result => result.Entry)) + .ToList(); + } + + private static string[] GetModSearchTerms(MiraPluginInfo plugin, AbstractOptionGroup group) + { + var terms = new HashSet(StringComparer.OrdinalIgnoreCase) + { + plugin.PluginId, + plugin.PluginInfo.Metadata.Name, + plugin.MiraPlugin.OptionsTitleText, + plugin.MiraPlugin.ParentLocaleId, + plugin.MiraPlugin.GetAbbreviatedModName(), + }; + + AddNamespace(terms, plugin.MiraPlugin.GetType()); + AddNamespace(terms, group.GetType()); + foreach (var option in group.Options) + { + AddNamespace(terms, option.GetType()); + } + + return terms.Where(term => !string.IsNullOrWhiteSpace(term)).ToArray(); + } + + private static void AddNamespace(HashSet terms, Type type) + { + if (!string.IsNullOrWhiteSpace(type.Namespace)) + { + terms.Add(type.Namespace); + } + } + + private static string NormalizeSearchText(string value) + { + return new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + } + + private static int Rank(GuideOptionEntry entry, string query, StringComparison cmp) + { + if (entry.Title.Equals(query, cmp)) + { + return 0; + } + + if (entry.Title.Contains(query, cmp)) + { + return 1; + } + + if (entry.GroupName.Contains(query, cmp) || entry.ModAbbreviation.Contains(query, cmp)) + { + return 2; + } + + return entry.Value.Contains(query, cmp) ? 3 : int.MaxValue; + } + + private static bool IsSettingsGroup(AbstractOptionGroup group, AbstractGameMode? mode) + { + if (group.ParentMenu is not (MenuCategory.Game or MenuCategory.CustomOne or MenuCategory.CustomTwo)) + { + return false; + } + + if (group.OptionableType == null) + { + return true; + } + + return mode != null && typeof(AbstractGameMode).IsAssignableFrom(group.OptionableType) && + mode.GetType().IsAssignableTo(group.OptionableType); + } + + private static bool SafeGroupVisible(AbstractOptionGroup group) + { + try + { + return group.GroupVisible(); + } + catch (Exception e) + { + Logger.Warning($"GroupVisible threw for {group.GetType().Name}: {e.Message}"); + return false; + } + } + + private static bool SafeOptionVisible(IModdedOption option) + { + try + { + return option.Visible(); + } + catch (Exception e) + { + Logger.Warning($"Visible threw for option '{option.Title}': {e.Message}"); + return false; + } + } + + private static GuideOptionEntry? TryCreateEntry( + IModdedOption option, + AbstractOptionGroup group, + string groupName, + string modAbbreviation, + IReadOnlyList modSearchTerms, + Func formatBool) + { + try + { + var title = TranslationController.Instance.GetString(option.StringName); + var value = FormatValue(option, formatBool); + return new GuideOptionEntry(title, value, group, groupName, modAbbreviation, modSearchTerms); + } + catch (Exception e) + { + Logger.Warning($"Could not format option '{option.Title}' for the guide: {e.Message}"); + return null; + } + } + + private static string FormatValue(IModdedOption option, Func formatBool) + { + switch (option) + { + case ModdedToggleOption toggle: + return formatBool(toggle.Value); + + case ModdedEnumOption enumOption: + return MiraLocaleManager.Get( + enumOption.Values[enumOption.Value], + enumOption.Values[enumOption.Value]); + + case ModdedNumberOption number: + if (number is { NegativeWordValue: not "#", Value: -1 }) + { + return number.NegativeWordValue; + } + + if (number is { ZeroWordValue: not "#", Value: 0 }) + { + return number.ZeroWordValue; + } + + return TrailingZeroRegex.Replace(number.Data.GetValueString(number.Value), string.Empty); + + default: + return option.Data.Type == global::OptionTypes.Checkbox + ? formatBool(Math.Abs(option.GetFloatData() - 1f) < 0.0001f) + : option.Data.GetValueString(option.GetFloatData()); + } + } +} \ No newline at end of file diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index 413fb59..570bb04 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using HarmonyLib; +using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.InteropTypes.Arrays; using MiraAPI.GameOptions; using MiraAPI.GameOptions.OptionTypes; @@ -25,12 +26,16 @@ using Random = UnityEngine.Random; namespace MiraAPI.Utilities; +internal readonly record struct AdvancedSettingsRow(string Title, string Value, AbstractOptionGroup? Group); + /// /// A class that contains helper methods. /// public static class Helpers { + internal const float MatchInfoGuideBaseScale = 1.3f; private static readonly Regex TrailingZeroRegex = new(@"\.0+(?!\d)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + private static NumberOption? _advancedSettingsRowPrefab; /// /// Gets all of the options stored within the provided type. @@ -843,7 +848,8 @@ public static class Helpers grid.localScale = new Vector3(1.3f, 1.3f, 1); } - obj.transform.SetParent(parent.Inner.transform); + obj.transform.SetParent(parent.Inner.transform, false); + obj.transform.localScale = Vector3.one / MatchInfoGuideBaseScale; obj.transform.localPosition = new Vector3(0f, 0.5f, 0f); var scrollHeight = variableTextSizing ? desc.textBounds.size.y - 2.5f + maxTextSize + 0.475f @@ -852,6 +858,444 @@ public static class Helpers return obj; } + /// + /// Caches a persistent clone of the vanilla settings row for use in the Match Info Guide. + /// + /// The vanilla settings row prefab. + internal static void CacheAdvancedSettingsRowPrefab(NumberOption? rowPrefab) + { + if (!rowPrefab) + { + foreach (var candidate in Resources.FindObjectsOfTypeAll(Il2CppType.Of())) + { + var menu = candidate.TryCast(); + var settingsTab = menu ? menu!.GameSettingsTab : null; + if (settingsTab && settingsTab!.numberOptionOrigin) + { + rowPrefab = settingsTab.numberOptionOrigin; + break; + } + } + } + + if (!rowPrefab) + { + foreach (var candidate in Resources.FindObjectsOfTypeAll(Il2CppType.Of())) + { + var option = candidate.TryCast(); + if (!option) + { + continue; + } + + if (option!.TitleText && option.ValueText && option.PlusBtn && option.MinusBtn) + { + rowPrefab = option; + break; + } + } + } + + if (_advancedSettingsRowPrefab || !rowPrefab) + { + return; + } + + var cachedPrefab = Object.Instantiate(rowPrefab!.gameObject); + cachedPrefab.name = "MiraAPIAdvancedSettingsRowPrefab"; + cachedPrefab.transform.SetParent(null, false); + cachedPrefab.transform.localPosition = Vector3.zero; + cachedPrefab.transform.localRotation = Quaternion.identity; + cachedPrefab.SetActive(false); + Object.DontDestroyOnLoad(cachedPrefab); + _advancedSettingsRowPrefab = cachedPrefab.GetComponent(); + } + + /// + /// Creates a page of vanilla settings rows inside a roles-style scroller. + /// + /// The guide object. + /// The the page is parented to. + /// The rows to show: key is the label, value is the displayed value. + /// The that holds all the rows. Destroy it to remove the page. + public static GameObject CreateAdvancedSettingsPage( + MatchInfoGuide guide, + Scroller parent, + IReadOnlyList> settings) + { + return CreateAdvancedSettingsPage( + guide, + parent, + settings.Select(setting => new AdvancedSettingsRow(setting.Key, setting.Value, null)).ToList(), + 2, + false); + } + + internal static GameObject CreateAdvancedSettingsPage( + MatchInfoGuide guide, + Scroller parent, + List settings, + int columns = 2, + bool groupsAsColumns = false) + { + const float contentTop = 0.95f; + const float horizontalOffset = -0.2f; + const float headerContentGap = 0.2f; + const int contentSortingOrder = 151; + + columns = Mathf.Clamp(columns, 1, 6); + var inner = parent.Inner; + var layout = inner.GetComponent(); + if (layout) + { + layout.enabled = false; + } + + var columnGap = layout ? Mathf.Clamp(layout.spacing.x, 0.1f, 0.6f) : 0.3f; + const int baseColumnCount = 2; + var baseColumnWidth = layout ? Mathf.Clamp(layout.cellSize.x, 2f, 3.2f) : 2.7f; + var availableWidth = baseColumnWidth * baseColumnCount + columnGap * (baseColumnCount - 1); + var columnWidth = Mathf.Clamp( + (availableWidth - (columns - 1) * columnGap) / columns, + 1f, + 3.2f); + var layer = inner.gameObject.layer; + var page = new GameObject("AdvancedSettingsPage") { layer = layer }; + page.transform.SetParent(inner, false); + page.transform.localPosition = Vector3.zero; + page.SetActive(false); + + var gameSettingsMenu = GameSettingMenu.Instance; + CacheAdvancedSettingsRowPrefab(gameSettingsMenu ? gameSettingsMenu.GameSettingsTab.numberOptionOrigin : null); + var rowPrefab = _advancedSettingsRowPrefab; + if (!rowPrefab) + { + Error("Could not render MiraAPI settings rows because the vanilla NumberOption prefab was not cached."); + return page; + } + + var spriteBounds = rowPrefab!.GetComponentsInChildren(true) + .Where(renderer => + renderer.enabled && + renderer.sprite && + renderer.transform != rowPrefab.PlusBtn.transform && + !renderer.transform.IsChildOf(rowPrefab.PlusBtn.transform) && + renderer.transform != rowPrefab.MinusBtn.transform && + !renderer.transform.IsChildOf(rowPrefab.MinusBtn.transform)) + .Select(renderer => renderer.bounds) + .ToArray(); + if (spriteBounds.Length == 0) + { + Error("Could not render MiraAPI settings rows because the vanilla NumberOption prefab has no sprites."); + return page; + } + + var prefabBounds = spriteBounds.Skip(1).Aggregate( + spriteBounds[0], + (bounds, next) => + { + bounds.Encapsulate(next); + return bounds; + }); + var rowScale = (columnWidth / prefabBounds.size.x) * 1.22f; + var rowHeight = prefabBounds.size.y * rowScale; + var rowPitch = 0.45f * rowScale; + var groupedSettings = groupsAsColumns + ? settings + .Where(setting => setting.Group != null) + .GroupBy(setting => setting.Group!) + .ToList() + : []; + groupsAsColumns = groupedSettings.Count > 1; + if (groupsAsColumns) + { + rowScale = (columnWidth / prefabBounds.size.x) * (columns > 2 ? 1.15f : 1f); + rowHeight = prefabBounds.size.y * rowScale; + rowPitch = 0.45f * rowScale; + } + + var groupColumns = new Dictionary(); + var groupTopOffsets = new Dictionary(); + var groupRows = new Dictionary(); + if (groupsAsColumns) + { + var bandCount = Mathf.CeilToInt(groupedSettings.Count / (float)columns); + var bandHeights = new float[bandCount]; + for (var i = 0; i < groupedSettings.Count; i++) + { + var group = groupedSettings[i].Key; + var band = i / columns; + groupColumns[group] = i % columns; + bandHeights[band] = Mathf.Max( + bandHeights[band], + rowPitch * (groupedSettings[i].Count() + 1) + headerContentGap + 0.25f); + } + + var bandOffset = 0f; + for (var band = 0; band < bandCount; band++) + { + for (var i = band * columns; i < Mathf.Min((band + 1) * columns, groupedSettings.Count); i++) + { + groupTopOffsets[groupedSettings[i].Key] = bandOffset; + groupRows[groupedSettings[i].Key] = 0; + } + + bandOffset += bandHeights[band]; + } + } + + var contentBottom = float.PositiveInfinity; + var previousGroup = (AbstractOptionGroup?)null; + var rowIndex = 0; + var column = 0; + var groupSpacing = 0f; + + for (var i = 0; i < settings.Count; i++) + { + if (settings[i].Group != previousGroup && settings[i].Group is { } group) + { + if (groupsAsColumns) + { + column = groupColumns[group]; + } + else + { + if (previousGroup != null) + { + groupSpacing += 0.2f; + } + + if (column != 0) + { + rowIndex++; + column = 0; + } + } + + var headerSource = group.Header; + if (!headerSource && gameSettingsMenu && gameSettingsMenu.GameSettingsTab) + { + headerSource = gameSettingsMenu.GameSettingsTab.categoryHeaderOrigin; + } + + if (!headerSource) + { + foreach (var candidate in Resources.FindObjectsOfTypeAll(Il2CppType.Of())) + { + var candidateHeader = candidate.TryCast(); + if (candidateHeader && candidateHeader!.Background && candidateHeader.Title) + { + headerSource = candidateHeader; + break; + } + } + } + + if (headerSource) + { + const float headerScale = 0.46f; + var headerWidth = groupsAsColumns + ? columnWidth / headerScale + : (columns * columnWidth + (columns - 1) * columnGap) / headerScale; + var header = Object.Instantiate(headerSource!, page.transform, false); + header.transform.localScale = Vector3.one * headerScale; + if (groupsAsColumns) + { + var totalWidth = columns * columnWidth + (columns - 1) * columnGap; + var groupColumn = groupColumns[group]; + var groupLeft = -totalWidth / 2f + horizontalOffset + + groupColumn * (columnWidth + columnGap); + header.transform.localPosition = new Vector3( + groupLeft + (columnWidth / 2f) - 0.25f, + contentTop - groupTopOffsets[group], + -1f); + } + else + { + header.transform.localPosition = new Vector3( + horizontalOffset - 0.5f, + contentTop - (rowIndex * rowPitch) - groupSpacing, + -1f); + } + + header.SetHeader(MiraLocaleManager.GetOrCreateLocaleString(group.GroupName), 20); + if (header.Title) + { + var titleTranslator = header.Title.GetComponent(); + if (titleTranslator) + { + Object.Destroy(titleTranslator); + } + + header.Title.text = group.GroupName.Translate(); + header.Title.color = Color.white; + header.Title.horizontalAlignment = HorizontalAlignmentOptions.Left; + var titleTransform = header.Title.rectTransform; + var titlePosition = titleTransform.localPosition; + titlePosition.x = 0.5f; + titleTransform.localPosition = titlePosition; + titleTransform.SetSizeWithCurrentAnchors( + RectTransform.Axis.Horizontal, + headerWidth - 0.3f); + } + + if (header.Background) + { + header.Background.sprite = MiraAssets.CategoryHeader.LoadAsset(); + header.Background.drawMode = SpriteDrawMode.Sliced; + header.Background.color = group.GroupColor; + header.Background.transform.localPosition = new Vector3(0.5f, -0.1833f, 0f); + header.Background.size = new Vector2(headerWidth, header.Background.size.y); + } + + if (header.Divider) + { + header.Divider.color = group.GroupColor; + } + + header.gameObject.SetActive(true); + foreach (var renderer in header.GetComponentsInChildren(true)) + { + renderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + renderer.material.SetInt(PlayerMaterial.MaskLayer, 50); + renderer.sortingOrder = contentSortingOrder; + if (renderer.enabled && renderer.sprite) + { + contentBottom = Mathf.Min( + contentBottom, + inner.InverseTransformPoint(renderer.bounds.min).y); + } + } + + foreach (var text in header.GetComponentsInChildren(true)) + { + text.fontMaterial.SetFloat(ShaderID.StencilComp, 3f); + text.fontMaterial.SetFloat(ShaderID.Stencil, 50f); + text.renderer.sortingOrder = contentSortingOrder; + if (text.enabled) + { + text.ForceMeshUpdate(); + contentBottom = Mathf.Min( + contentBottom, + inner.InverseTransformPoint(text.bounds.min).y); + } + } + + if (!groupsAsColumns) + { + rowIndex++; + } + } + + previousGroup = group; + } + + var originX = -((columns * columnWidth) + ((columns - 1) * columnGap)) / 2f + + (column * (columnWidth + columnGap)) + + horizontalOffset; + var centerY = groupsAsColumns && settings[i].Group is { } rowGroup + ? contentTop - groupTopOffsets[rowGroup] - + (rowPitch * (groupRows[rowGroup] + 1)) - headerContentGap - (rowHeight / 2f) + : contentTop - (rowHeight / 2f) - (rowIndex * rowPitch) - groupSpacing; + var row = Object.Instantiate(rowPrefab, page.transform); + row.enabled = false; + row.PlusBtn.gameObject.SetActive(false); + row.MinusBtn.gameObject.SetActive(false); + + foreach (var button in row.GetComponentsInChildren(true)) + { + button.enabled = false; + } + + foreach (var collider in row.GetComponentsInChildren(true)) + { + collider.enabled = false; + } + + row.transform.localScale = rowPrefab.transform.localScale * rowScale; + row.transform.localPosition = new Vector3( + originX + (columnWidth / 2f) - (prefabBounds.center.x * rowScale), + centerY - (prefabBounds.center.y * rowScale), + 0f); + row.TitleText.text = settings[i].Title; + row.ValueText.text = settings[i].Value; + + foreach (var text in row.GetComponentsInChildren(true)) + { + if (text.TryGetComponent(out var translator)) + { + translator.enabled = false; + Object.Destroy(translator); + } + + text.fontMaterial.SetFloat(ShaderID.StencilComp, 3f); + text.fontMaterial.SetFloat(ShaderID.Stencil, 50f); + text.renderer.sortingOrder = contentSortingOrder; + if (text.enabled) + { + text.ForceMeshUpdate(); + } + } + + foreach (var renderer in row.GetComponentsInChildren(true)) + { + renderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + renderer.material.SetInt(PlayerMaterial.MaskLayer, 50); + renderer.sortingOrder = contentSortingOrder; + if (renderer.enabled && + renderer.sprite && + renderer.transform != row.PlusBtn.transform && + !renderer.transform.IsChildOf(row.PlusBtn.transform) && + renderer.transform != row.MinusBtn.transform && + !renderer.transform.IsChildOf(row.MinusBtn.transform)) + { + contentBottom = Mathf.Min( + contentBottom, + inner.InverseTransformPoint(renderer.bounds.min).y); + } + } + + foreach (var text in row.GetComponentsInChildren(true)) + { + if (text.enabled) + { + contentBottom = Mathf.Min( + contentBottom, + inner.InverseTransformPoint(text.bounds.min).y); + } + } + + row.gameObject.SetActive(true); + if (groupsAsColumns && settings[i].Group is { } renderedGroup) + { + groupRows[renderedGroup]++; + } + else + { + column++; + if (column == columns) + { + column = 0; + rowIndex++; + } + } + } + + var maskArea = parent.transform.FindChild("MaskArea")?.GetComponent(); + var maskBottom = maskArea + ? inner.InverseTransformPoint(maskArea!.bounds.min).y + : -0.75f; + var contentOverflow = float.IsPositiveInfinity(contentBottom) + ? 0f + : maskBottom - contentBottom; + var scrollRange = contentOverflow > 0.05f + ? contentOverflow + 0.05f + : 0f; + parent.SetYBoundsMax(Mathf.Clamp(scrollRange, 0f, 999f)); + page.SetActive(true); + return page; + } + private static void SetupAbilityPanel(MatchInfoRolePanel panel, AdvancedWikiAbilityDescription ability, bool variableTextSizing = false) { panel.roleCount.text = ability.AbilityType; @@ -877,4 +1321,4 @@ public static class Helpers panel.roleCount.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); panel.roleIcon.transform.localScale = new Vector3(3.5f, 3.5f, 1f); } -} +} \ No newline at end of file