diff --git a/MiraAPI/GameOptions/IModdedOption.cs b/MiraAPI/GameOptions/IModdedOption.cs index 099194e..fdf71a6 100644 --- a/MiraAPI/GameOptions/IModdedOption.cs +++ b/MiraAPI/GameOptions/IModdedOption.cs @@ -121,4 +121,24 @@ public interface IModdedOption /// public interface IModdedOptionList : IReadOnlyList { + /// + /// Gets the function to name the keys of the individual options in the configuration file. + /// + /// Return to use the default of the property's name with its index appended. + /// + Func? ConfigName { get; init; } +} + +/// +/// Interface for list of options. +/// +/// The type of . +public interface IModdedOptionList : IModdedOptionList + where T : IModdedOption +{ + /// + new T this[int index] { get; } + + /// + new IEnumerator GetEnumerator(); } diff --git a/MiraAPI/GameOptions/ISummarizedOptions.cs b/MiraAPI/GameOptions/ISummarizedOptions.cs index 60b5ea6..7873902 100644 --- a/MiraAPI/GameOptions/ISummarizedOptions.cs +++ b/MiraAPI/GameOptions/ISummarizedOptions.cs @@ -9,13 +9,13 @@ namespace MiraAPI.GameOptions; public interface ISummarizedOptions { /// - /// Option string keys (StringNames) to omit from the wiki options list (e.g., a large set of related options). + /// Gets the option string keys (StringNames) to omit from the wiki options list (e.g., a large set of related options). /// IReadOnlySet WikiHiddenOptionKeys { get; } /// - /// Summary lines to insert when the first hidden option would have appeared. + /// Gets summary lines to insert when the first hidden option would have appeared. /// Lines should already be formatted as "Title: Value". /// IEnumerable GetWikiOptionSummaryLines(); -} \ No newline at end of file +} diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index 387a1f8..b893040 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -198,9 +198,11 @@ public static class ModdedOptionsManager return; } + Func configName = optionList.ConfigName ?? (i => property.Name + (i + 1)); + for (int i = 0; i < optionList.Count; i++) { - RegisterOption(optionList[i], group, property.Name + i, pluginInfo); + RegisterOption(optionList[i], group, configName(i), pluginInfo); } } @@ -277,7 +279,7 @@ public static class ModdedOptionsManager option.Visible = () => visibilityFunc(i); } - RegisterOption(option, group, property.Name + i, pluginInfo); + RegisterOption(option, group, property.Name + (i + 1), pluginInfo); } } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs index 6f9e8d5..257ae6e 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs @@ -9,7 +9,7 @@ namespace MiraAPI.GameOptions.OptionTypes; /// Represents a modded option list. /// /// The type of options. -public class ModdedOptionList : IModdedOptionList +public class ModdedOptionList : IModdedOptionList where T : IModdedOption { /// @@ -20,6 +20,9 @@ public class ModdedOptionList : IModdedOptionList /// public IReadOnlyList Options { get; } + /// + public Func? ConfigName { get; init; } + /// /// Initializes a new instance of the class. /// @@ -41,10 +44,10 @@ public class ModdedOptionList : IModdedOptionList Options = [.. options]; } - /// - public IEnumerator GetEnumerator() + /// + public IEnumerator GetEnumerator() { - return ((IEnumerable)Options).GetEnumerator(); + return Options.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() @@ -52,6 +55,11 @@ public class ModdedOptionList : IModdedOptionList return Options.GetEnumerator(); } + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)Options).GetEnumerator(); + } + /// /// Indexes the option of type . /// diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index d68a87d..4da8968 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -4,6 +4,7 @@ using BepInEx; using BepInEx.Configuration; using BepInEx.Unity.IL2CPP; using HarmonyLib; +using MiraAPI.Patches; using MiraAPI.Patches.Roles; using MiraAPI.PluginLoading; using MiraAPI.Translation; @@ -63,9 +64,30 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin private static MiraPluginManager? PluginManager { get; set; } internal Harmony Harmony { get; } = new(Id); + public static ConfigEntry WikiSortingGroup { get; private set; } + + public static ConfigEntry WikiSortingMethod { get; private set; } + + public static ConfigEntry WikiSortingFilter { get; private set; } + /// public override void Load() { + WikiSortingGroup = Config.Bind( + "Wiki", + "WikiSortingGroup", + SortingGroups.Ungrouped, + "Determines how clusters of roles / modifiers are grouped together, if at all."); + WikiSortingMethod = Config.Bind( + "Wiki", + "WikiSortingMethod", + SortingMethod.Alphabetical, + "Determines if the list of roles / modifiers are organized in ascending or descending order."); + WikiSortingFilter = Config.Bind( + "Wiki", + "WikiSortingFilter", + SortingFilter.AllRoles, + "Determines what roles / modifiers are displayed at once."); Harmony.PatchAll(); RoleOptionsCollectionPatch.PatchRoleMethods(Harmony); UiResetEvents.Initialize(); diff --git a/MiraAPI/Modifiers/BaseModifier.cs b/MiraAPI/Modifiers/BaseModifier.cs index 0017fd9..3daeb56 100644 --- a/MiraAPI/Modifiers/BaseModifier.cs +++ b/MiraAPI/Modifiers/BaseModifier.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Generic; +using Il2CppInterop.Runtime.Attributes; using MiraAPI.GameOptions; using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Translation; +using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using TMPro; using UnityEngine; namespace MiraAPI.Modifiers; @@ -44,16 +50,98 @@ public abstract class BaseModifier : IOptionable x => x.InternalModifiers.Exists(y => y.TypeId == TypeId)) ?? throw new InvalidOperationException("Modifier is not registered."); + /// + /// Gets the id part used to build the modifier's translation keys. It is recommended to simply call it what the modifier is, as other mods may utilize it. + /// + public virtual string IdPart => GetType().Name; + + /// + /// Gets the id part used to build the modifier's translation keys. It is recommended to call it ModGuid.Modifier.Type. + /// + public virtual string IdPrefix => GetType().Namespace!; + /// /// Gets the modifier name. /// - public abstract string ModifierName { get; } + public virtual string ModifierName => MiraLocaleManager.Get(ModifierNameLocale); + + /// + /// Gets the modifier's name id for localization. + /// + public virtual string ModifierNameLocale => MiraLocaleManager.BuildTranslationId(IdPrefix, IdPart); + + /// + /// Gets the medium description of the modifier. Used in the modifier guide and options menu. + /// + public virtual string ModifierMedDescription => MiraLocaleManager.Get(ModifierMedDescriptionLocale, GetDescription()); + + /// + /// Gets the modifier's medium description id for localization. + /// + public virtual string ModifierMedDescriptionLocale => MiraLocaleManager.BuildTranslationId(IdPrefix, IdPart, "MedDescription"); + + /// + /// Gets the wiki description of the modifier. Used in the wiki and normally appends the options text as well. + /// + public virtual string ModifierWikiDescription => MiraLocaleManager.Get(ModifierWikiDescriptionLocale, GetDescription()) + + Helpers.GetOptionsText(GetType()); + + /// + /// Gets the modifier's wiki description id for localization. + /// + public virtual string ModifierWikiDescriptionLocale => MiraLocaleManager.BuildTranslationId(IdPrefix, IdPart, "WikiDescription"); + + /// + /// Gets the category of the modifier. Used in the wiki. + /// + public virtual string ModifierCategoryTitle => MiraLocaleManager.Get("Modifier"); + + /// + /// Gets whether the modifier is forcibly shown or disabled in the wiki screen. + /// + /// if the modifier is always displayed, otherwise if it is never displayable, or if it is dictated by amount and chance. + public virtual bool? ForceShowModifierOnWiki => false; + + /// + /// Gets the information to display in the wiki. + /// + /// The guide object. + /// The title text object. + /// The title's icon. + /// The scroller parent. + /// The of the wiki. + public virtual GameObject GetAdvancedWiki(MatchInfoGuide guide, TextMeshPro titleText, SpriteRenderer titleIcon, Scroller parent) + { + var abilities = WikiAbilities; + return Helpers.CreateAdvancedWikiPage( + guide, + titleText, + titleIcon, + parent, + ModifierNameLocale, + $"{ModifierName} ({ModifierCategoryTitle})".TrimStart(), + ModifierIcon ?? MiraAssets.IconNeutral.LoadAsset(), + ModifierWikiDescription, + abilities); + } + + /// + /// Gets the list of abilities or other similar information to display below the text of a modifier's wiki page. + /// + [HideFromIl2Cpp] + public virtual List WikiAbilities => []; /// /// Gets the modifier icon. Useless if is . /// public virtual LoadableAsset? ModifierIcon => null; + /// + /// Gets the for the Modifier Icon. + /// + [HideFromIl2Cpp] + public virtual TMP_SpriteAsset IconTmp => null!; + /// /// Gets a value indicating whether the modifier is hidden on the UI. Will be hidden either way if no description is provided. /// @@ -69,6 +157,11 @@ public abstract class BaseModifier : IOptionable /// public virtual Color FreeplayFileColor => Color.gray; + /// + /// Gets the of the modifier, used in the wiki and other UI outside freeplay. + /// + public virtual Color GeneralColor => FreeplayFileColor; + /// /// Gets a value indicating whether the modifier is unique. If , the player can only have one instance of this modifier. /// diff --git a/MiraAPI/Modifiers/Types/GameModifier.cs b/MiraAPI/Modifiers/Types/GameModifier.cs index f574fd3..b432920 100644 --- a/MiraAPI/Modifiers/Types/GameModifier.cs +++ b/MiraAPI/Modifiers/Types/GameModifier.cs @@ -9,6 +9,9 @@ namespace MiraAPI.Modifiers.Types; [MiraIgnore] public abstract class GameModifier : BaseModifier { + /// + public override bool? ForceShowModifierOnWiki => null; + /// public override bool ShowInFreeplay => true; diff --git a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs index 7f0920d..e65f4c3 100644 --- a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs +++ b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs @@ -618,6 +618,8 @@ internal static class GameOptionsMenuPatch { menu.MapPicker.gameObject.SetActive(false); var mod = MenuState.Instance.CurrentMod; + var container = MenuState.Instance.CurrentContainer.transform; + PresetManager.DefaultPresets.TryGetValue(mod, out var defaultPreset); var filteredGroups = MenuState.Instance.CurrentMenu switch { @@ -643,15 +645,13 @@ internal static class GameOptionsMenuPatch { foreach (var group in filteredGroups) { - yield return CoCreateGroup(menu, group); + yield return CoCreateGroup(menu, group, container, defaultPreset); } } } - private static IEnumerator CoCreateGroup(GameOptionsMenu menu, AbstractOptionGroup group) + private static IEnumerator CoCreateGroup(GameOptionsMenu menu, AbstractOptionGroup group, Transform container, OptionPreset? defaultPreset) { - var container = MenuState.Instance.CurrentContainer.transform; - group.Ready = false; if (group.Header == null || !group.Header) { @@ -713,12 +713,6 @@ internal static class GameOptionsMenuPatch menu.playerOptionOrigin, container)); - OptionPreset? defaultPreset = null; - if (PresetManager.DefaultPresets.TryGetValue(MenuState.Instance.CurrentMod, out var preset)) - { - defaultPreset = preset; - } - yield return options.CoLoopWithBudget(newOpt => { newOpt.SetClickMask(menu.ButtonClickMask); @@ -765,73 +759,53 @@ internal static class GameOptionsMenuPatch } menu.Children.Add(newOpt); - var resetBtn = new GameObject("ResetOption") + if (defaultPreset != null && defaultPreset.IsOptionInPreset(newOpt) && + newOpt is ToggleOption or NumberOption or StringOption) { - transform = + var resetBtn = new GameObject("ResetOption") { - parent = newOpt.transform, - localScale = new(.5f, .5f, 1), - localPosition = new Vector3(-3.1f, 0f, -2f), - }, - layer = LayerMask.NameToLayer("UI"), - }; - var resetRend = resetBtn.AddComponent(); - resetRend.sprite = MiraAssets.ResetButton.LoadAsset(); - resetRend.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; - resetRend.color = group.GroupColor.Equals(MiraApiPlugin.DefaultHeaderColor) - ? Color.white - : group.GroupColor.FindAlternateColor(); - var resetBoxCol = resetBtn.gameObject.AddComponent(); - resetBoxCol.size = new Vector2(1f, 1f); - resetBoxCol.offset = new Vector2(0, 0); - var passiveButton = resetBtn.AddComponent(); - passiveButton.OnClick = new Button.ButtonClickedEvent(); - passiveButton.ClickSound = menu.BackButton.GetComponent().ClickSound; - passiveButton.OnMouseOver = new UnityEvent(); - passiveButton.OnMouseOver.AddListener( - (UnityAction)(() => - { - resetRend.color = group.GroupColor != MiraApiPlugin.DefaultHeaderColor - ? group.GroupColor - : MiraAssets.AcceptedTeal; - })); - passiveButton.OnMouseOut = new UnityEvent(); - passiveButton.OnMouseOut.AddListener( - (UnityAction)(() => - { - resetRend.color = group.GroupColor.Equals(MiraApiPlugin.DefaultHeaderColor) - ? Color.white - : group.GroupColor.FindAlternateColor(); - })); - if (newOpt is ToggleOption toggleOpt) - { + transform = + { + parent = newOpt.transform, + localScale = new(.5f, .5f, 1), + localPosition = new Vector3(-3.1f, 0f, -2f), + }, + layer = LayerMask.NameToLayer("UI"), + }; + var resetRend = resetBtn.AddComponent(); + resetRend.sprite = MiraAssets.ResetButton.LoadAsset(); + resetRend.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + resetRend.color = group.GroupColor.Equals(MiraApiPlugin.DefaultHeaderColor) + ? Color.white + : group.GroupColor.FindAlternateColor(); + var resetBoxCol = resetBtn.gameObject.AddComponent(); + resetBoxCol.size = new Vector2(1f, 1f); + resetBoxCol.offset = new Vector2(0, 0); + var passiveButton = resetBtn.AddComponent(); + passiveButton.OnClick = new Button.ButtonClickedEvent(); + passiveButton.ClickSound = menu.BackButton.GetComponent().ClickSound; + passiveButton.OnMouseOver = new UnityEvent(); + passiveButton.OnMouseOver.AddListener( + (UnityAction)(() => + { + resetRend.color = group.GroupColor != MiraApiPlugin.DefaultHeaderColor + ? group.GroupColor + : MiraAssets.AcceptedTeal; + })); + passiveButton.OnMouseOut = new UnityEvent(); + passiveButton.OnMouseOut.AddListener( + (UnityAction)(() => + { + resetRend.color = group.GroupColor.Equals(MiraApiPlugin.DefaultHeaderColor) + ? Color.white + : group.GroupColor.FindAlternateColor(); + })); passiveButton.OnClick.AddListener( (UnityAction)(() => { - defaultPreset!.ResetOption(toggleOpt); + defaultPreset.ResetOption(newOpt); })); } - else if (newOpt is NumberOption numOpt) - { - passiveButton.OnClick.AddListener( - (UnityAction)(() => - { - defaultPreset!.ResetOption(numOpt); - })); - } - else if (newOpt is StringOption strOpt) - { - passiveButton.OnClick.AddListener( - (UnityAction)(() => - { - defaultPreset!.ResetOption(strOpt); - })); - } - - if (!defaultPreset!.IsOptionInPreset(newOpt)) - { - resetBtn.Destroy(); - } newOpt.Initialize(); newOpt.gameObject.SetActive(false); diff --git a/MiraAPI/Patches/Options/OptionsPatches.cs b/MiraAPI/Patches/Options/OptionsPatches.cs index 8e77363..670a782 100644 --- a/MiraAPI/Patches/Options/OptionsPatches.cs +++ b/MiraAPI/Patches/Options/OptionsPatches.cs @@ -4,7 +4,6 @@ using MiraAPI.Roles; using MiraAPI.Translation; using MiraAPI.Utilities; using UnityEngine; -using Object = Il2CppSystem.Object; namespace MiraAPI.Patches.Options; diff --git a/MiraAPI/Patches/RoleGuidePatches.cs b/MiraAPI/Patches/RoleGuidePatches.cs index 5822bf8..92080e8 100644 --- a/MiraAPI/Patches/RoleGuidePatches.cs +++ b/MiraAPI/Patches/RoleGuidePatches.cs @@ -1,13 +1,19 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using AmongUs.GameOptions; using HarmonyLib; using Innersloth.Assets; +using MiraAPI.Modifiers; +using MiraAPI.Modifiers.Types; using MiraAPI.Roles; +using MiraAPI.Translation; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using Reactor.Utilities; using Reactor.Utilities.Extensions; using TMPro; using UnityEngine; @@ -20,19 +26,50 @@ namespace MiraAPI.Patches; [HarmonyPatch] public static class RoleGuidePatches { + private const int RolesTabIndex = 2; + private const int ModifiersTabIndex = 3; + private const float SearchDebounceSeconds = 0.3f; + + private static readonly List RoleEntries = []; + private static readonly List ModifierEntries = []; + + public static GameObject SearchComponentContainer; public static PassiveButton SearchIconButton; private static SpriteRenderer _searchIconIdle; private static SpriteRenderer _searchIconHover; + public static TextMeshPro OrderSortingTmp; 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 TextBoxTMP _searchBoxTmp; + private static GameObject _currentAdvancedTabObject; + private static TextMeshPro _titleText; + private static SpriteRenderer _titleIcon; + private static bool _suppressSearchRefresh; + private static IEnumerator? _searchDebounce; + private static string _appliedSearch = string.Empty; + private static float _openedAt; + private static bool _uiInitialized; + private static MatchInfoGuide? _guide; + private static IEnumerator? _populate; + private static bool _loading; + 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; [HarmonyPostfix] [HarmonyPriority(Priority.First)] @@ -40,20 +77,25 @@ public static class RoleGuidePatches [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.CreateHnSModeSettings))] public static void CreateNormalModeSettings(MatchInfoGuide __instance) { - __instance.MatchInfoRoleMaskArea.transform.localPosition = new Vector3(-0.0184f, 0.15f, -0.1f); + ApplyPanelLayout(__instance); + } - __instance.matchInfoPlayersMaskArea.transform.localPosition = - __instance.matchInfoSettingsMaskArea.transform.localPosition = new Vector3(1.22f, -0.335f, -0.1f); + private static void ApplyPanelLayout(MatchInfoGuide instance) + { + instance.MatchInfoRoleMaskArea.transform.localPosition = new Vector3(-0.0184f, 0.15f, -0.1f); - __instance.matchInfoPlayersMaskArea.size = - __instance.matchInfoSettingsMaskArea.size = - __instance.MatchInfoRoleMaskArea.size = new Vector2(-6, 1.8f); + instance.matchInfoPlayersMaskArea.transform.localPosition = + instance.matchInfoSettingsMaskArea.transform.localPosition = new Vector3(1.22f, -0.335f, -0.1f); - var playerMenuGradient = __instance.matchInfoPlayersMaskArea.transform.parent.GetAllChildren() + instance.matchInfoPlayersMaskArea.size = + instance.matchInfoSettingsMaskArea.size = + instance.MatchInfoRoleMaskArea.size = new Vector2(-6, 1.8f); + + var playerMenuGradient = instance.matchInfoPlayersMaskArea.transform.parent.GetAllChildren() .First(x => x.name.Contains("BG_Gradient")); - var settingsenuGradient = __instance.matchInfoSettingsMaskArea.transform.parent.GetAllChildren() + var settingsenuGradient = instance.matchInfoSettingsMaskArea.transform.parent.GetAllChildren() .First(x => x.name.Contains("BG_Gradient")); - var roleMenuGradient = __instance.MatchInfoRoleMaskArea.transform.parent.GetAllChildren() + var roleMenuGradient = instance.MatchInfoRoleMaskArea.transform.parent.GetAllChildren() .First(x => x.name.Contains("BG_Gradient")); playerMenuGradient .GetComponent() @@ -64,8 +106,6 @@ public static class RoleGuidePatches .maskInteraction = SpriteMaskInteraction.VisibleInsideMask; roleMenuGradient.transform.localScale = new Vector3(0.5297f, 0.1565f, 1); roleMenuGradient.transform.localPosition = new Vector3(0, -0.62f, -5); - var wikiTab = Object.Instantiate(__instance.settingsTabs[2], __instance.settingsTabs[2].transform.parent); - advancedWikiTab = wikiTab.GetComponent(); } [HarmonyPrefix] @@ -74,6 +114,107 @@ public static class RoleGuidePatches public static void Awake(MatchInfoGuide __instance) { __instance.transitionOpen.targetSize = 1.3f; + if (_guide != __instance) + { + ResetState(); + } + } + + /// + /// 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. + /// + private static void ResetState() + { + if (_populate != null) + { + Coroutines.Stop(_populate); + _populate = null; + } + + if (_searchDebounce != null) + { + Coroutines.Stop(_searchDebounce); + _searchDebounce = null; + } + + RoleEntries.Clear(); + ModifierEntries.Clear(); + _guide = null; + _uiInitialized = false; + _loading = false; + _appliedSearch = string.Empty; + _suppressSearchRefresh = false; + _titleText = null!; + _titleIcon = null!; + _searchBoxTmp = null!; + _rolesScroller = null!; + _modifiersScroller = null!; + _advancedInfoTabScroller = null!; + _currentAdvancedTabObject = null!; + _loadingSpinner = null; + SearchIconButton = null!; + SearchSortingOrderButton = null!; + SearchSortingGroupButton = null!; + SearchSortingFilterButton = null!; + _searchIconIdle = null!; + _searchIconHover = null!; + _searchSortingOrderIdle = null!; + _searchSortingOrderHover = null!; + _searchSortingGroupIdle = null!; + _searchSortingGroupHover = null!; + _searchSortingFilterIdle = null!; + _searchSortingFilterHover = null!; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.Awake))] + private static void AwakePostfix(MatchInfoGuide __instance) + { + Coroutines.Start(CoPreload(__instance)); + } + + public static void TriggerPreload(MatchInfoGuide instance) + { + if (!instance || _populate != null) + { + return; + } + + Coroutines.Start(CoPreload(instance)); + } + + private static IEnumerator CoPreload(MatchInfoGuide instance) + { + while (instance && (!HudManager.InstanceExists || !GameManager.Instance)) + { + yield return null; + } + + if (!instance || GameManager.Instance.TryCast() == null) + { + yield break; + } + + _guide = instance; + if (RoleEntries.Count == 0) + { + BuildEntries(); + } + + if (_populate != null) + { + yield break; + } + + _populate = Coroutines.Start(CoPreloadPanels()); + } + + private static IEnumerator CoPreloadPanels() + { + yield return CoForEachBudgeted(RoleEntries, PreloadPanel); + yield return CoForEachBudgeted(ModifierEntries, PreloadPanel); + _populate = null; } [HarmonyPrefix] @@ -81,6 +222,11 @@ public static class RoleGuidePatches [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.Open))] public static bool Open(MatchInfoGuide __instance) { + if (!HudManager.InstanceExists || !GameManager.Instance || !PlayerControl.LocalPlayer) + { + return false; + } + if (HudManager.Instance.GameMenu.IsOpen || HudManager.Instance.Chat.IsOpenOrOpening) { return false; @@ -105,248 +251,73 @@ public static class RoleGuidePatches var enabled = ActiveInputManager.currentControlType == ActiveInputManager.InputType.Joystick; __instance.glyphL.enabled = enabled; __instance.glyphR.enabled = enabled; - if (!titleText) + if (!_titleText) { - titleText = __instance.transitionOpen.transform.FindChild("Text_Title")?.GetComponent()!; - if (titleText) + _titleText = __instance.transitionOpen.transform.FindChild("Text_Title")?.GetComponent()!; + if (_titleText) { - titleText.transform.GetComponent().Destroy(); + _titleText.transform.localPosition += new Vector3(0.5f, 0); + _titleText.transform.GetComponent().Destroy(); } } - - if (titleText) + if (!_titleIcon) { - titleText.text = TranslationController.Instance.GetString(StringNames.MatchInfoGuideTitle); + var titleIconObj = new GameObject("TitleIcon"); + _titleIcon = titleIconObj.AddComponent(); + _titleIcon.transform.SetParent(_titleText.transform.parent); + _titleIcon.transform.localPosition = new Vector3(-2.75f, 1.4f, 0); + titleIconObj.layer = _titleText.gameObject.layer; + } + + if (_titleText) + { + _titleText.text = TranslationController.Instance.GetString(StringNames.MatchInfoGuideTitle); } var regGame = GameManager.Instance.TryCast() != null; if (regGame) { + if (!_uiInitialized) + { + Initialize(__instance); + } + + if (RoleEntries.Count == 0) + { + BuildEntries(); + } + if (__instance.NormalModeSettings.Count == 0) { - var sortingOrderButton = Object.Instantiate( - HudManager.Instance.SettingsButton, - __instance.TabButtons[2].transform.parent); - SearchSortingOrderButton = sortingOrderButton.GetComponent(); - SearchSortingOrderButton.name = "SearchSortingOrderButton"; - _searchSortingOrderIdle = SearchSortingOrderButton.inactiveSprites.GetComponent(); - _searchSortingOrderIdle.sprite = MiraAssets.SortingAzIdleSprite; - _searchSortingOrderHover = SearchSortingOrderButton.activeSprites.GetComponent(); - _searchSortingOrderHover.sprite = MiraAssets.SortingAzHoverSprite; - SearchSortingOrderButton.OnClick = new Button.ButtonClickedEvent(); - SearchSortingOrderButton.OnClick.AddListener(new Action(SwitchMethod)); - sortingOrderButton.transform.localPosition = new Vector3(2.4f, 0.765f, -0.1f); - sortingOrderButton.transform.localScale = Vector3.one; - sortingOrderButton.transform.GetChild(2).gameObject.SetActive(false); - - var sortingGroupButton = Object.Instantiate( - sortingOrderButton, - sortingOrderButton.transform.parent); - SearchSortingGroupButton = sortingGroupButton.GetComponent(); - SearchSortingGroupButton.name = "SearchSortingGroupButton"; - _searchSortingGroupIdle = SearchSortingGroupButton.inactiveSprites.GetComponent(); - _searchSortingGroupIdle.sprite = MiraAssets.GroupNoneIdleSprite; - _searchSortingGroupHover = SearchSortingGroupButton.activeSprites.GetComponent(); - _searchSortingGroupHover.sprite = MiraAssets.GroupNoneHoverSprite; - SearchSortingGroupButton.OnClick = new Button.ButtonClickedEvent(); - SearchSortingGroupButton.OnClick.AddListener(new Action(SwitchGrouping)); - sortingGroupButton.transform.localPosition = new Vector3(1.8f, 0.765f, -0.1f); - sortingGroupButton.transform.localScale = Vector3.one; - - var sortingFilterButton = Object.Instantiate( - sortingOrderButton, - sortingOrderButton.transform.parent); - SearchSortingFilterButton = sortingFilterButton.GetComponent(); - SearchSortingFilterButton.name = "SearchSortingFilterButton"; - _searchSortingFilterIdle = SearchSortingFilterButton.inactiveSprites.GetComponent(); - _searchSortingFilterIdle.sprite = MiraAssets.VisibleOnIdleSprite; - _searchSortingFilterHover = SearchSortingFilterButton.activeSprites.GetComponent(); - _searchSortingFilterHover.sprite = MiraAssets.VisibleOnHoverSprite; - SearchSortingFilterButton.OnClick = new Button.ButtonClickedEvent(); - SearchSortingFilterButton.OnClick.AddListener(new Action(SwitchFilter)); - sortingFilterButton.transform.localPosition = new Vector3(1f, 0.765f, -0.1f); - sortingFilterButton.transform.localScale = Vector3.one; - - __instance.numOfTabs = 3; - __instance.TabButtons[2].SelectButton(true); - __instance.MatchInfoRoleMaskArea.transform.localPosition = new Vector3(-0.0184f, 0.15f, -0.1f); - - __instance.matchInfoPlayersMaskArea.transform.localPosition = - __instance.matchInfoSettingsMaskArea.transform.localPosition = new Vector3(1.22f, -0.335f, -0.1f); - - __instance.matchInfoPlayersMaskArea.size = - __instance.matchInfoSettingsMaskArea.size = - __instance.MatchInfoRoleMaskArea.size = new Vector2(-6, 1.8f); - - var playerMenuGradient = __instance.matchInfoPlayersMaskArea.transform.parent.GetAllChildren() - .First(x => x.name.Contains("BG_Gradient")); - var settingsenuGradient = __instance.matchInfoSettingsMaskArea.transform.parent.GetAllChildren() - .First(x => x.name.Contains("BG_Gradient")); - var roleMenuGradient = __instance.MatchInfoRoleMaskArea.transform.parent.GetAllChildren() - .First(x => x.name.Contains("BG_Gradient")); - playerMenuGradient - .GetComponent() - .maskInteraction = - settingsenuGradient.GetComponent() - .maskInteraction = - roleMenuGradient.GetComponent() - .maskInteraction = SpriteMaskInteraction.VisibleInsideMask; - roleMenuGradient.transform.localScale = new Vector3(0.5297f, 0.1565f, 1); - roleMenuGradient.transform.localPosition = new Vector3(0, -0.62f, -5); - __instance.MatchInfoRoleMaskArea.material.SetInt(PlayerMaterial.MaskLayer, 50); - __instance.matchInfoSettingsMaskArea.material.SetInt(PlayerMaterial.MaskLayer, 50); - - var wikiTab = Object.Instantiate( - __instance.settingsTabs[2], - __instance.settingsTabs[2].transform.parent); - wikiTab.transform.FindChild("MaskArea")?.transform.localPosition = new Vector3(-0.0184f, 0.15f, -0.1f); - wikiTab.transform.GetAllChildren() - .First(x => x.name.Contains("BG_Gradient")).transform.localPosition = new Vector3(0, -0.62f, -5); - advancedWikiTab = wikiTab.GetComponent(); - - DisplayNormalRoleSettings(__instance, true); - - var searchIconButton = Object.Instantiate( - sortingOrderButton, - sortingOrderButton.transform.parent); - SearchIconButton = searchIconButton.GetComponent(); - SearchIconButton.name = "SearchIconButton"; - _searchIconIdle = SearchIconButton.inactiveSprites.GetComponent(); - _searchIconIdle.sprite = MiraAssets.SearchIconIdleSprite; - _searchIconHover = SearchIconButton.activeSprites.GetComponent(); - _searchIconHover.sprite = MiraAssets.SearchIconHoverSprite; - SearchIconButton.OnClick = new Button.ButtonClickedEvent(); - SearchIconButton.OnClick.AddListener(new Action(ClearSearchText)); - searchIconButton.transform.localPosition = new Vector3(-2.79f, 0.765f, -0.1f); - searchIconButton.transform.localScale = Vector3.one; - - var searchBox = Object.Instantiate( - __instance.TabButtons[2].gameObject, - __instance.TabButtons[2].transform.parent); - var searchButton = searchBox.GetComponent(); - var tmpText = searchButton.transform.GetChild(0).GetComponent(); - tmpText.GetComponent().Destroy(); - tmpText.color = new Color(0.75f, 0.75f, 0.75f); - tmpText.text = string.Empty; - tmpText.fontSizeMax = 4; - tmpText.overflowMode = TextOverflowModes.Ellipsis; - tmpText.alignment = TextAlignmentOptions.Left; - tmpText.horizontalAlignment = HorizontalAlignmentOptions.Left; - tmpText.rectTransform.offsetMax = new Vector2(-0.1531f, 0.2972f); - tmpText.rectTransform.sizeDelta = new Vector2(3, 1); - tmpText.transform.localPosition = new Vector3(0, 0.0343f, -0.2f); - var inactive = searchButton.inactiveSprites; - var selected = searchButton.selectedSprites; - var highlight = searchButton.activeSprites; - var disabledSprite = searchButton.disabledSprites; - disabledSprite.GetComponent().size = new Vector2(3, 0.6f); - searchButton.Destroy(); - inactive.gameObject.SetActive(false); - selected.gameObject.SetActive(false); - highlight.gameObject.SetActive(false); - disabledSprite.gameObject.SetActive(true); - var button = searchBox.AddComponent(); - button.OnUp = true; - searchBoxTmp = searchBox.AddComponent(); - searchBoxTmp.outputText = tmpText; - button.OnClick.AddListener((UnityAction)(() => { searchBoxTmp.GiveFocus(); })); - button.OnMouseOver = new UnityEvent(); - button.OnMouseOut = new UnityEvent(); - _rolesScroller = __instance.settingsTabs[2].GetComponent(); - searchBoxTmp.OnChange = new Button.ButtonClickedEvent(); - searchBoxTmp.OnChange.AddListener( - (UnityAction)(() => - { - var text = searchBoxTmp.outputText.text; - if (text != string.Empty) - { - _searchIconIdle.sprite = MiraAssets.SearchClearIdleSprite; - _searchIconHover.sprite = MiraAssets.SearchClearHoverSprite; - } - else - { - _searchIconIdle.sprite = MiraAssets.SearchIconIdleSprite; - _searchIconHover.sprite = MiraAssets.SearchIconHoverSprite; - } - - var newSorted = RolePanels - .OrderByDescending(child => - child.Value.GetTitle().Equals(text, StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(child => child.Value.GetTitle().Contains( - text, - StringComparison.InvariantCultureIgnoreCase)) - .ThenBy(GetSortingOrder()); - - foreach (var pair in newSorted) - { - pair.Value.Panel.transform.SetAsLastSibling(); - } - - _rolesScroller.ScrollToTop(); - })); - searchBoxTmp.transform.localPosition = new Vector3(-1.438f, 0.756f, -0.2f); - - var playerButton = __instance.TabButtons[0]; - var playerCollider = playerButton.GetComponent(); - playerButton.transform.GetChild(0).gameObject.SetActive(false); - var playerBtnInactive = playerButton.inactiveSprites.GetComponent(); - playerBtnInactive.sprite = MiraAssets.WikiPlayersButtonIdleSprite; - playerBtnInactive.size = Vector2.one; - playerBtnInactive.transform.GetChild(0).gameObject.SetActive(false); - var playerBtnSelected = playerButton.selectedSprites.GetComponent(); - playerBtnSelected.sprite = MiraAssets.WikiPlayersButtonOpenSprite; - playerBtnSelected.size = Vector2.one; - playerBtnSelected.transform.GetChild(0).gameObject.SetActive(false); - var playerBtnHighlight = playerButton.activeSprites.GetComponent(); - playerBtnHighlight.sprite = MiraAssets.WikiPlayersButtonHoverSprite; - playerBtnHighlight.size = Vector2.one; - playerBtnHighlight.transform.GetChild(0).gameObject.SetActive(false); - - var settingButton = __instance.TabButtons[1]; - var settingsCollider = settingButton.GetComponent(); - settingButton.transform.GetChild(0).gameObject.SetActive(false); - var settingBtnInactive = settingButton.inactiveSprites.GetComponent(); - settingBtnInactive.sprite = MiraAssets.WikiSettingsButtonIdleSprite; - settingBtnInactive.size = Vector2.one; - settingBtnInactive.transform.GetChild(0).gameObject.SetActive(false); - var settingBtnSelected = settingButton.selectedSprites.GetComponent(); - settingBtnSelected.sprite = MiraAssets.WikiSettingsButtonOpenSprite; - settingBtnSelected.size = Vector2.one; - settingBtnSelected.transform.GetChild(0).gameObject.SetActive(false); - var settingBtnHighlight = settingButton.activeSprites.GetComponent(); - settingBtnHighlight.sprite = MiraAssets.WikiSettingsButtonHoverSprite; - settingBtnHighlight.size = Vector2.one; - settingBtnHighlight.transform.GetChild(0).gameObject.SetActive(false); - - var rolesButton = __instance.TabButtons[2]; - var rolesCollider = rolesButton.GetComponent(); - rolesButton.transform.GetChild(0).gameObject.SetActive(false); - var rolesBtnInactive = rolesButton.inactiveSprites.GetComponent(); - rolesBtnInactive.sprite = MiraAssets.WikiRolesButtonIdleSprite; - rolesBtnInactive.size = Vector2.one; - rolesBtnInactive.transform.GetChild(0).gameObject.SetActive(false); - var rolesBtnSelected = rolesButton.selectedSprites.GetComponent(); - rolesBtnSelected.sprite = MiraAssets.WikiRolesButtonOpenSprite; - rolesBtnSelected.size = Vector2.one; - rolesBtnSelected.transform.GetChild(0).gameObject.SetActive(false); - var rolesBtnHighlight = rolesButton.activeSprites.GetComponent(); - rolesBtnHighlight.sprite = MiraAssets.WikiRolesButtonHoverSprite; - rolesBtnHighlight.size = Vector2.one; - rolesBtnHighlight.transform.GetChild(0).gameObject.SetActive(false); - - playerCollider.size = settingsCollider.size = rolesCollider.size = new Vector2(0.8f, 0.74f); - playerCollider.offset = settingsCollider.offset = rolesCollider.offset = Vector2.zero; - playerButton.transform.localScale = settingButton.transform.localScale = - rolesButton.transform.localScale = new Vector3(0.7f, 0.7f, 1); - playerButton.transform.localPosition = new Vector3(-3.6f, 0.656f, -0.2f); - settingButton.transform.localPosition = new Vector3(-3.6f, 0.056f, -0.2f); - rolesButton.transform.localPosition = new Vector3(-3.6f, -0.544f, -0.2f); - } - else - { - DisplayNormalRoleSettings(__instance, false); - ClearSearchText(); + __instance.CreateSettingsEntry( + StringNames.GameNumImpostors, + GameManager.Instance.AllGameSettingData[StringNames.GameNumImpostors] + .GetValueString(GameManager.Instance.LogicOptions.NumImpostors)); + __instance.CreateSettingsEntry( + StringNames.GameKillCooldown, + GameManager.Instance.AllGameSettingData[StringNames.GameKillCooldown] + .GetValueString(GameManager.Instance.LogicOptions.GetKillCooldown())); + __instance.CreateSettingsEntry( + StringNames.GameEmergencyCooldown, + GameManager.Instance.AllGameSettingData[StringNames.GameEmergencyCooldown] + .GetValueString(GameManager.Instance.LogicOptions.GetEmergencyCooldown())); + __instance.CreateSettingsEntry( + StringNames.GameVisualTasks, + __instance.GetBoolString(GameManager.Instance.LogicOptions.GetVisualTasks())); + __instance.CreateSettingsEntry( + StringNames.GameAnonymousVotes, + __instance.GetBoolString(GameManager.Instance.LogicOptions.GetAnonymousVotes())); + __instance.CreateSettingsEntry( + StringNames.GameConfirmImpostor, + __instance.GetBoolString(GameManager.Instance.LogicOptions.GetConfirmImpostor())); + __instance.CreateSettingsEntry( + StringNames.GameTaskBarMode, + GameManager.Instance.LogicOptions.GetTaskBarMode().ToString()); + __instance.CreatePlayerEntries(); + __instance.rolesEnabledMessage.SetActive(false); } + + ResetSearchBox(); } else if (__instance.HnSModeSettings.Count == 0) { @@ -356,340 +327,891 @@ public static class RoleGuidePatches } PlayerControl.LocalPlayer.NetTransform.Halt(); + HidePanels(RoleEntries); + HidePanels(ModifierEntries); __instance.MatchInfoParent.SetActive(true); - var instance = ControllerManager.Instance; - var currentUiState = ControllerManager.Instance.CurrentUiState; - var controllerSelectable = __instance.ControllerSelectable; - instance.SetUpSelectables(currentUiState, controllerSelectable[^1], __instance.ControllerSelectable); - var instance2 = ControllerManager.Instance; - var controllerSelectable2 = __instance.ControllerSelectable; - instance2.SetCurrentSelected(controllerSelectable2[^1]); - __instance.SetActiveTab(regGame ? 2 : 0); + _openedAt = Time.time; + if (__instance.ControllerSelectable.Count > 0) + { + var controllerManager = ControllerManager.Instance; + controllerManager.SetUpSelectables( + controllerManager.CurrentUiState, + __instance.ControllerSelectable[^1], + __instance.ControllerSelectable); + controllerManager.SetCurrentSelected(__instance.ControllerSelectable[^1]); + } + + __instance.SetActiveTab(regGame ? RolesTabIndex : 0); + if (regGame) + { + OpenTab(RolesTabIndex, __instance.transitionOpen.duration); + } + return false; } - private static readonly Dictionary RolePanels = []; - private static Scroller advancedWikiTab; - private static TextBoxTMP searchBoxTmp; - private static GameObject currentAdvancedTabObject; - private static TextMeshPro titleText; - - public sealed class DetailedPanel(MatchInfoRolePanel panel, string title, string category, string modId) + private static void Initialize(MatchInfoGuide instance) { - public MatchInfoRolePanel Panel = panel; - public string Title = title; - public string Category = category; - public string ModId = modId; - public int Amount { get; set; } - public int Chance { get; set; } - public float LikelyhoodOfRole { get; set; } + if (_uiInitialized) + { + return; + } + + _sortFilter = MiraApiPlugin.WikiSortingFilter.Value; + _sortMethod = MiraApiPlugin.WikiSortingMethod.Value; + _sortGrouping = MiraApiPlugin.WikiSortingGroup.Value; + + if (_populate != null) + { + Coroutines.Stop(_populate); + _populate = null; + } + + _guide = instance; + _loading = false; + SearchComponentContainer = new GameObject(); + SearchComponentContainer.transform.SetParent(instance.MatchInfoParent.transform); + SearchComponentContainer.transform.localPosition = Vector3.zero; + instance.MatchInfoParent.transform.GetChild(1).transform.SetParent(SearchComponentContainer.transform); + + var sortingOrderButton = Object.Instantiate( + HudManager.Instance.SettingsButton, + SearchComponentContainer.transform); + sortingOrderButton.gameObject.SetActive(true); + SearchSortingOrderButton = sortingOrderButton.GetComponent(); + SetupSearchButton( + SearchSortingOrderButton, + "SearchSortingOrderButton", + SwitchMethod, + GetSortMethodSprite(_sortMethod, false), + GetSortMethodSprite(_sortMethod, true), + new Vector3(2.4f, 0.765f, -0.1f), + out _searchSortingOrderIdle, + out _searchSortingOrderHover); + + var sortingGroupButton = Object.Instantiate( + sortingOrderButton, + SearchComponentContainer.transform); + sortingGroupButton.gameObject.SetActive(true); + SearchSortingGroupButton = sortingGroupButton.GetComponent(); + SetupSearchButton( + SearchSortingGroupButton, + "SearchSortingGroupButton", + SwitchGrouping, + GetSortGroupSprite(_sortGrouping, false), + GetSortGroupSprite(_sortGrouping, true), + new Vector3(1.7f, 0.765f, -0.1f), + out _searchSortingGroupIdle, + out _searchSortingGroupHover); + + var sortingFilterButton = Object.Instantiate( + sortingOrderButton, + SearchComponentContainer.transform); + sortingFilterButton.gameObject.SetActive(true); + SearchSortingFilterButton = sortingFilterButton.GetComponent(); + SetupSearchButton( + SearchSortingFilterButton, + "SearchSortingFilterButton", + SwitchFilter, + GetSortFilterSprite(_sortFilter, false), + GetSortFilterSprite(_sortFilter, true), + new Vector3(1f, 0.765f, -0.1f), + out _searchSortingFilterIdle, + out _searchSortingFilterHover); + + instance.numOfTabs = 4; + instance.TabButtons[2].SelectButton(true); + ApplyPanelLayout(instance); + instance.MatchInfoRoleMaskArea.material.SetInt(PlayerMaterial.MaskLayer, 50); + instance.matchInfoSettingsMaskArea.material.SetInt(PlayerMaterial.MaskLayer, 50); + + var modifiersTab = Object.Instantiate( + instance.settingsTabs[RolesTabIndex], + instance.settingsTabs[RolesTabIndex].transform.parent); + modifiersTab.name = "ModifiersPanel"; + 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); + + var wikiTab = Object.Instantiate( + instance.settingsTabs[RolesTabIndex], + instance.settingsTabs[RolesTabIndex].transform.parent); + wikiTab.name = "AdvancedWikiPanels"; + 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(); + var wikiScrollHitbox = wikiTab.transform.FindChild("Hitbox"); + _advancedInfoTabScroller.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); + + var spinnerParent = instance.settingsTabs[RolesTabIndex].transform.parent; + var spinnerPos = spinnerParent.InverseTransformPoint(instance.MatchInfoRoleMaskArea.transform.position); + spinnerPos.z = -5f; + _loadingSpinner = LoadingRing.Create(spinnerParent, spinnerPos, 0.6f); + _loadingSpinner.GetComponent().color = MiraAssets.AcceptedTeal; + + var searchIconButton = Object.Instantiate( + sortingOrderButton, + sortingOrderButton.transform.parent); + searchIconButton.gameObject.SetActive(true); + SearchIconButton = searchIconButton.GetComponent(); + SetupSearchButton( + SearchIconButton, + "SearchIconButton", + ClearSearchText, + MiraAssets.SearchIconIdleSprite, + MiraAssets.SearchIconHoverSprite, + new Vector3(-2.79f, 0.765f, -0.1f), + out _searchIconIdle, + out _searchIconHover); + + var searchBox = Object.Instantiate( + instance.TabButtons[2].gameObject, + SearchComponentContainer.transform); + var searchButton = searchBox.GetComponent(); + var tmpText = searchButton.transform.GetChild(0).GetComponent(); + tmpText.GetComponent().Destroy(); + tmpText.color = new Color(0.75f, 0.75f, 0.75f); + tmpText.text = string.Empty; + tmpText.fontSizeMax = 4; + tmpText.overflowMode = TextOverflowModes.Ellipsis; + tmpText.alignment = TextAlignmentOptions.Left; + tmpText.horizontalAlignment = HorizontalAlignmentOptions.Left; + tmpText.rectTransform.offsetMax = new Vector2(-0.1531f, 0.2972f); + tmpText.rectTransform.sizeDelta = new Vector2(3, 1); + tmpText.transform.localPosition = new Vector3(0, 0.0343f, -0.2f); + var inactive = searchButton.inactiveSprites; + var selected = searchButton.selectedSprites; + var highlight = searchButton.activeSprites; + var disabledSprite = searchButton.disabledSprites; + disabledSprite.GetComponent().size = new Vector2(3, 0.6f); + searchButton.Destroy(); + inactive.gameObject.SetActive(false); + selected.gameObject.SetActive(false); + highlight.gameObject.SetActive(false); + disabledSprite.gameObject.SetActive(true); + var button = searchBox.AddComponent(); + button.OnUp = true; + var template = HudManager.Instance.Chat.freeChatField.textArea; + _searchBoxTmp = Object.Instantiate(template, searchBox.transform); + _searchBoxTmp.gameObject.SetActive(true); + _searchBoxTmp.name = "SearchField"; + _searchBoxTmp.transform.localPosition = Vector3.zero; + _searchBoxTmp.OnEnter = new Button.ButtonClickedEvent(); + _searchBoxTmp.OnChange = new Button.ButtonClickedEvent(); + _searchBoxTmp.OnFocus = new Button.ButtonClickedEvent(); + _searchBoxTmp.OnFocusLost = new Button.ButtonClickedEvent(); + _searchBoxTmp.sendButtonGlyph = null; + _searchBoxTmp.SendOnFullChars = false; + _searchBoxTmp.ClearOnFocus = false; + _searchBoxTmp.characterLimit = 40; + _searchBoxTmp.AllowSymbols = true; + _searchBoxTmp.allowAllCharacters = true; + if (_searchBoxTmp.Background) + { + _searchBoxTmp.Background.enabled = false; + } + + var boxCollider = _searchBoxTmp.GetComponent(); + if (boxCollider) + { + boxCollider.size = new Vector2(3f, 0.6f); + boxCollider.offset = Vector2.zero; + } + + var outputText = _searchBoxTmp.outputText; + var textParent = tmpText.transform.parent; + outputText.transform.SetParent(textParent, false); + outputText.GetComponent()?.Destroy(); + CopyTextLayout(tmpText, outputText); + outputText.color = new Color(0.75f, 0.75f, 0.75f); + outputText.text = string.Empty; + + var placeholderText = _searchBoxTmp.placeholderText; + if (placeholderText) + { + placeholderText.transform.SetParent(textParent, false); + placeholderText.GetComponent()?.Destroy(); + CopyTextLayout(tmpText, placeholderText); + placeholderText.color = new Color(0.55f, 0.55f, 0.55f); + placeholderText.text = MiraLocaleManager.Get("MiraApi.Wiki.SearchPlaceholder"); + placeholderText.gameObject.SetActive(true); + } + + if (_searchBoxTmp.Pipe) + { + _searchBoxTmp.Pipe.transform.SetParent(textParent, false); + _searchBoxTmp.Pipe.transform.localPosition = new Vector3( + outputText.transform.localPosition.x - + (outputText.rectTransform.sizeDelta.x * outputText.rectTransform.pivot.x), + outputText.transform.localPosition.y + _searchBoxTmp.caretYOffset, + -0.3f); + } + + tmpText.gameObject.Destroy(); + + button.OnClick.AddListener((UnityAction)(() => { _searchBoxTmp.GiveFocus(); })); + button.OnMouseOver = new UnityEvent(); + button.OnMouseOut = new UnityEvent(); + _rolesScroller = instance.settingsTabs[RolesTabIndex].GetComponent(); + _searchBoxTmp.OnChange.AddListener( + (UnityAction)(() => + { + if (GetSearchText() != string.Empty) + { + _searchIconIdle.sprite = MiraAssets.SearchClearIdleSprite; + _searchIconHover.sprite = MiraAssets.SearchClearHoverSprite; + } + else + { + _searchIconIdle.sprite = MiraAssets.SearchIconIdleSprite; + _searchIconHover.sprite = MiraAssets.SearchIconHoverSprite; + } + + if (_suppressSearchRefresh) + { + return; + } + + QueueSearchRefresh(); + })); + searchBox.transform.localPosition = new Vector3(-1.438f, 0.756f, -0.2f); + + var playerButton = instance.TabButtons[0]; + SetupTabButton( + playerButton, + MiraAssets.WikiPlayersButtonIdleSprite, + MiraAssets.WikiPlayersButtonOpenSprite, + MiraAssets.WikiPlayersButtonHoverSprite); + + var settingButton = instance.TabButtons[1]; + SetupTabButton( + settingButton, + MiraAssets.WikiSettingsButtonIdleSprite, + MiraAssets.WikiSettingsButtonOpenSprite, + MiraAssets.WikiSettingsButtonHoverSprite); + + var rolesButton = instance.TabButtons[2]; + SetupTabButton( + rolesButton, + MiraAssets.WikiRolesButtonIdleSprite, + MiraAssets.WikiRolesButtonOpenSprite, + MiraAssets.WikiRolesButtonHoverSprite); + rolesButton.OnClick = new Button.ButtonClickedEvent(); + rolesButton.OnClick.AddListener((Action)(() => OpenRolesTab())); + + var modifiersButton = Object.Instantiate( + instance.TabButtons[2], + instance.TabButtons[2].transform.parent); + SetupTabButton( + modifiersButton, + MiraAssets.WikiRolesButtonIdleSprite, + MiraAssets.WikiRolesButtonOpenSprite, + MiraAssets.WikiRolesButtonHoverSprite); + modifiersButton.OnClick = new Button.ButtonClickedEvent(); + modifiersButton.OnClick.AddListener((Action)(() => OpenModifiersTab())); + instance.TabButtons.Add(modifiersButton); + + OrderSortingTmp = Object.Instantiate( + _titleText, + sortingOrderButton.transform); + OrderSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.OrderLabel"); + OrderSortingTmp.fontSize = OrderSortingTmp.fontSizeMax = 3f; + OrderSortingTmp.alignment = TextAlignmentOptions.Center; + OrderSortingTmp.horizontalAlignment = HorizontalAlignmentOptions.Center; + OrderSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); + + GroupSortingTmp = Object.Instantiate( + OrderSortingTmp, + sortingGroupButton.transform); + OrderSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.GroupingLabel"); + GroupSortingTmp.transform.localPosition = new Vector3(-0.65f, 0.4f, 0); + + FilterSortingTmp = Object.Instantiate( + OrderSortingTmp, + sortingFilterButton.transform); + 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); + rolesButton.transform.localPosition = new Vector3(-3.6f, -0.544f, -0.2f); + modifiersButton.transform.localPosition = new Vector3(-3.6f, -1.144f, -0.2f); + _uiInitialized = true; } - private static SortingFilter sortFilter = SortingFilter.EnabledOnly; - private static SortingMethod sortMethod = SortingMethod.Alphabetical; - private static SortingGroups sortOrder = SortingGroups.Ungrouped; + private static void BuildEntries() + { + foreach (var modifier in ModifierManager.Modifiers.OfType()) + { + ModifierEntries.Add(new DetailedPanel( + modifier.ModifierName, + modifier.ModifierCategoryTitle, + modifier.ParentMod.MiraPlugin.GetAbbreviatedModName(), + null, + modifier)); + } + + 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)) + { + var modId = roleBehaviour is ICustomRole custom + ? custom.ParentMod.MiraPlugin.GetAbbreviatedModName() + : "AU"; + RoleEntries.Add(new DetailedPanel( + roleBehaviour.GetRoleName(), + roleBehaviour.GetCategoryTitle(), + modId, + roleBehaviour, + null)); + } + } + + private static void SetupTabButton(MatchInfoGuideTabButton button, Sprite inactive, Sprite selected, Sprite hover) + { + var buttonCollider = button.GetComponent(); + button.transform.GetChild(0).gameObject.SetActive(false); + var settingBtnInactive = button.inactiveSprites.GetComponent(); + settingBtnInactive.sprite = inactive; + settingBtnInactive.transform.GetChild(0).gameObject.SetActive(false); + var settingBtnSelected = button.selectedSprites.GetComponent(); + settingBtnSelected.sprite = selected; + settingBtnSelected.transform.GetChild(0).gameObject.SetActive(false); + var settingBtnHighlight = button.activeSprites.GetComponent(); + settingBtnHighlight.sprite = hover; + settingBtnInactive.drawMode = settingBtnSelected.drawMode = settingBtnHighlight.drawMode = SpriteDrawMode.Sliced; + settingBtnInactive.size = settingBtnSelected.size = settingBtnHighlight.size = Vector2.one; + settingBtnHighlight.transform.GetChild(0).gameObject.SetActive(false); + buttonCollider.size = new Vector2(0.8f, 0.74f); + buttonCollider.offset = Vector2.zero; + button.transform.localScale = new Vector3(0.7f, 0.7f, 1); + } + + private static void SetupSearchButton(PassiveButton button, string objName, Action onClick, Sprite inactive, Sprite hover, Vector3 pos, out SpriteRenderer inactiveSprite, out SpriteRenderer hoverSprite) + { + button.name = objName; + inactiveSprite = button.inactiveSprites.GetComponent(); + inactiveSprite.sprite = inactive; + hoverSprite = button.activeSprites.GetComponent(); + hoverSprite.sprite = hover; + button.OnClick = new Button.ButtonClickedEvent(); + button.OnClick.AddListener(onClick); + + button.transform.localPosition = pos; + button.transform.localScale = Vector3.one; + button.transform.GetChild(2).gameObject.SetActive(false); + } + + private static void OpenTab(int tabIndex, float delay = 0f) + { + _advancedInfoTabScroller?.gameObject.SetActive(false); + MatchInfoGuide.Instance.SetActiveTab(tabIndex); + HidePanels(tabIndex == ModifiersTabIndex ? ModifierEntries : RoleEntries); + StartPopulate(delay); + } + + private static void HidePanels(List entries) + { + foreach (var entry in entries) + { + if (entry.Panel && entry.Panel!.gameObject.activeSelf) + { + entry.Panel.gameObject.SetActive(false); + } + } + } + + public static void OpenModifiersTab() => OpenTab(ModifiersTabIndex); + + public static void OpenRolesTab() => OpenTab(RolesTabIndex); + + public sealed class DetailedPanel(string title, string category, string modId, RoleBehaviour? role, GameModifier? modifier) + { + public MatchInfoRolePanel? Panel { get; set; } + public string Title { get; } = title; + public string Category { get; } = category; + public string ModId { get; } = modId; + public RoleBehaviour? Role { get; } = role; + public GameModifier? Modifier { get; } = modifier; + public int Amount { get; set; } + public int Chance { get; set; } + public float Likelihood { get; set; } + public bool Visible { get; set; } + + public string GetSortKey(SortingGroups grouping) + { + return grouping switch + { + SortingGroups.Faction => + $"{Category} ({Title}) {Likelihood.ToString("0000.0", CultureInfo.InvariantCulture)}", + SortingGroups.AmountChance => + $"{Likelihood.ToString("0000.0", CultureInfo.InvariantCulture)} {Title} ({Category})", + _ => + $"{Title} ({Category}) {Likelihood.ToString("0000.0", CultureInfo.InvariantCulture)}", + }; + } + } + + private static string GetSearchText() + { + return _searchBoxTmp && _searchBoxTmp.outputText ? _searchBoxTmp.outputText.text : string.Empty; + } + + private static bool ShouldShowPanel(bool? forceShow, bool isDisabled, bool isHidden) + { + return forceShow ?? + !(isHidden || (isDisabled && _sortFilter is SortingFilter.EnabledOnly) || + (!isDisabled && _sortFilter is SortingFilter.DisabledOnly)); + } + + private static void UpdateSpinner() + { + if (_loadingSpinner != null) + { + _loadingSpinner.gameObject.SetActive( + _loading && MatchInfoGuide.Instance.activeTabIndex is RolesTabIndex or ModifiersTabIndex && + !(_advancedInfoTabScroller && _advancedInfoTabScroller.gameObject.activeSelf)); + } + } + + private static float GetGridScrollBounds(int count) + { + return Mathf.Clamp(Mathf.Ceil(count / 2f) * 1.3f - 1.5f, 0f, 999f); + } + + private static void StartPopulate(float delay = 0f) + { + if (_populate != null) + { + Coroutines.Stop(_populate); + } + + _loading = true; + UpdateSpinner(); + _populate = Coroutines.Start(CoPopulate(delay)); + } + + private static IEnumerator CoPopulate(float delay) + { + if (delay > 0f) + { + yield return new WaitForSeconds(delay); + } + else + { + yield return null; + } + + if (!_guide) + { + yield break; + } + + var modifiers = IsModifiersTabActive; + var entries = modifiers ? ModifierEntries : RoleEntries; + var shown = UpdateEntries(entries, modifiers); + yield return CoForEachBudgeted(shown, entry => + { + EnsurePanel(entry); + entry.Panel!.transform.SetAsLastSibling(); + if (!entry.Panel.gameObject.activeSelf) + { + entry.Panel.gameObject.SetActive(true); + } + }); + _loading = false; + UpdateSpinner(); + yield return CoForEachBudgeted(entries, EnsurePanel); + yield return CoForEachBudgeted(modifiers ? RoleEntries : ModifierEntries, PreloadPanel); + _populate = null; + } + + private static IEnumerator CoForEachBudgeted(IEnumerable collection, Action action) + { + var fps = Application.targetFrameRate > 0 ? Application.targetFrameRate : 60; + var budget = Math.Max(1L, 1000L / (fps * 4)); + var timer = new Stopwatch(); + timer.Start(); + foreach (var item in collection) + { + action(item); + if (timer.ElapsedMilliseconds > budget) + { + timer.Restart(); + yield return null; + if (!_guide) + { + yield break; + } + } + } + } + + private static void EnsurePanel(DetailedPanel entry) + { + if (entry.Panel) + { + return; + } + + if (!_guide || !_guide!.MatchInfoRolePanelPrefab) + { + return; + } + + var parent = entry.Modifier != null ? _modifiersScroller?.Inner : _rolesScroller?.Inner; + if (parent == null) + { + return; + } + + var panel = Object.Instantiate(_guide.MatchInfoRolePanelPrefab, parent); + 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!)); + } + + panel.gameObject.SetActive(false); + entry.Panel = panel; + } + + private static void PreloadPanel(DetailedPanel entry) + { + if (!entry.Panel) + { + ComputeCounts(entry); + EnsurePanel(entry); + } + } + + private static void ComputeCounts(DetailedPanel entry) + { + int amount; + int chance; + bool? forceShow = null; + bool isHidden; + if (entry.Modifier is { } modifier) + { + amount = modifier.GetAmountPerGame(); + chance = modifier.GetAssignmentChance(); + forceShow = modifier.ForceShowModifierOnWiki; + isHidden = !modifier.CanSpawnOnCurrentMode() || modifier.GetDescription() == string.Empty; + } + else + { + var role = entry.Role!; + if (role is ICustomRole custom) + { + amount = custom.GetCount().GetValueOrDefault(0); + chance = custom.GetChance().GetValueOrDefault(0); + forceShow = custom.ForceShowRoleOnWiki; + } + else + { + amount = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetNumPerGame(role.Role); + chance = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetChancePerGame(role.Role); + } + + isHidden = (role is ICustomRole custom2 && !custom2.CanSpawnOnCurrentMode()) || + (Enum.IsDefined(role.Role) && role.IsRoleBlacklisted()); + } + + entry.Amount = amount; + entry.Chance = chance; + entry.Likelihood = amount * chance; + entry.Visible = ShouldShowPanel(forceShow, amount == 0 || chance == 0, isHidden); + } + + private static List UpdateEntries(List entries, bool modifiers) + { + foreach (var entry in entries) + { + ComputeCounts(entry); + if (entry.Panel) + { + SetPanelCount(entry.Panel!, entry.Amount, entry.Chance, entry.ModId); + if (!entry.Visible && entry.Panel!.gameObject.activeSelf) + { + entry.Panel.gameObject.SetActive(false); + } + } + } + + 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 instance = MatchInfoGuide.Instance; + if (modifiers) + { + _modifiersScroller.SetYBoundsMax(GetGridScrollBounds(shown.Count)); + /*instance.rolesEnabledMessage.SetActive(shown.Count == 0);*/ + } + else + { + instance.MatchInfoRoleScroller.SetYBoundsMax(GetGridScrollBounds(shown.Count)); + instance.rolesEnabledMessage.SetActive(shown.Count == 0); + } + + return shown; + } + + private static void RefreshActiveTab(bool scrollToTop) + { + StartPopulate(); + if (scrollToTop) + { + (IsModifiersTabActive ? _modifiersScroller : _rolesScroller).ScrollToTop(); + } + } + + private static void QueueSearchRefresh() + { + if (_searchDebounce != null) + { + Coroutines.Stop(_searchDebounce); + } + + _searchDebounce = Coroutines.Start(CoSearchRefresh()); + } + + private static IEnumerator CoSearchRefresh() + { + yield return new WaitForSeconds(SearchDebounceSeconds); + _searchDebounce = null; + var text = GetSearchText(); + if (text == _appliedSearch) + { + yield break; + } + + _appliedSearch = text; + RefreshActiveTab(true); + } + + private static void ResetSearchBox() + { + if (_searchDebounce != null) + { + Coroutines.Stop(_searchDebounce); + _searchDebounce = null; + } + + _appliedSearch = string.Empty; + if (_searchIconIdle) + { + _searchIconIdle.sprite = MiraAssets.SearchIconIdleSprite; + } + + if (_searchIconHover) + { + _searchIconHover.sprite = MiraAssets.SearchIconHoverSprite; + } + + if (_searchBoxTmp && GetSearchText().Length > 0) + { + _suppressSearchRefresh = true; + _searchBoxTmp.Clear(); + _suppressSearchRefresh = false; + } + } public static void ClearSearchText() { - _searchIconIdle.sprite = MiraAssets.SearchIconIdleSprite; - _searchIconHover.sprite = MiraAssets.SearchIconHoverSprite; - try - { - searchBoxTmp.outputText.SetText(string.Empty); - searchBoxTmp.SetText(string.Empty); - } - catch - { - // ignored - } - - var newSorted = sortMethod is SortingMethod.Alphabetical - ? RolePanels.OrderBy(GetSortingOrder()) - : RolePanels.OrderByDescending(GetSortingOrder()); - - foreach (var pair in newSorted) - { - pair.Value.Panel.transform.SetAsLastSibling(); - } - - _rolesScroller.ScrollToTop(); + ResetSearchBox(); + RefreshActiveTab(true); } public static void SwitchFilter() { - var stepUp = (SortingFilter)((int)sortFilter + 1); + var stepUp = (SortingFilter)((int)_sortFilter + 1); if (Enum.IsDefined(stepUp)) { - sortFilter = stepUp; + _sortFilter = stepUp; } else { - sortFilter = SortingFilter.EnabledOnly; + _sortFilter = SortingFilter.EnabledOnly; } - switch (sortFilter) + MiraApiPlugin.WikiSortingFilter.Value = _sortFilter; + + _searchSortingFilterIdle.sprite = GetSortFilterSprite(_sortFilter, false); + _searchSortingFilterHover.sprite = GetSortFilterSprite(_sortFilter, true); + + RefreshActiveTab(false); + } + + private static Sprite GetSortFilterSprite(SortingFilter value, bool hover) + { + if (hover) + { + switch (value) + { + case SortingFilter.AllRoles: + return MiraAssets.VisibleAllHoverSprite; + case SortingFilter.DisabledOnly: + return MiraAssets.VisibleOffHoverSprite; + default: + return MiraAssets.VisibleOnHoverSprite; + } + } + + switch (value) { case SortingFilter.AllRoles: - _searchSortingFilterIdle.sprite = MiraAssets.VisibleAllIdleSprite; - _searchSortingFilterHover.sprite = MiraAssets.VisibleAllHoverSprite; - break; + return MiraAssets.VisibleAllIdleSprite; case SortingFilter.DisabledOnly: - _searchSortingFilterIdle.sprite = MiraAssets.VisibleOffIdleSprite; - _searchSortingFilterHover.sprite = MiraAssets.VisibleOffHoverSprite; - break; + return MiraAssets.VisibleOffIdleSprite; default: - _searchSortingFilterIdle.sprite = MiraAssets.VisibleOnIdleSprite; - _searchSortingFilterHover.sprite = MiraAssets.VisibleOnHoverSprite; - break; - } - - ToggleRoleVisibility(); - var newSorted = sortMethod is SortingMethod.Alphabetical - ? RolePanels.OrderBy(GetSortingOrder()) - : RolePanels.OrderByDescending(GetSortingOrder()); - - foreach (var pair in newSorted) - { - pair.Value.Panel.transform.SetAsLastSibling(); + return MiraAssets.VisibleOnIdleSprite; } } public static void SwitchMethod() { - var stepUp = (SortingMethod)((int)sortMethod + 1); + var stepUp = (SortingMethod)((int)_sortMethod + 1); if (Enum.IsDefined(stepUp)) { - sortMethod = stepUp; + _sortMethod = stepUp; } else { - sortMethod = SortingMethod.Alphabetical; + _sortMethod = SortingMethod.Alphabetical; } - _searchSortingOrderIdle.sprite = sortMethod is SortingMethod.Alphabetical + MiraApiPlugin.WikiSortingMethod.Value = _sortMethod; + + _searchSortingOrderIdle.sprite = GetSortMethodSprite(_sortMethod, false); + _searchSortingOrderHover.sprite = GetSortMethodSprite(_sortMethod, true); + + RefreshActiveTab(false); + } + + private static Sprite GetSortMethodSprite(SortingMethod value, bool hover) + { + if (hover) + { + return value is SortingMethod.Alphabetical + ? MiraAssets.SortingAzHoverSprite + : MiraAssets.SortingZaHoverSprite; + } + + return _sortMethod is SortingMethod.Alphabetical ? MiraAssets.SortingAzIdleSprite : MiraAssets.SortingZaIdleSprite; - _searchSortingOrderHover.sprite = sortMethod is SortingMethod.Alphabetical - ? MiraAssets.SortingAzHoverSprite - : MiraAssets.SortingZaHoverSprite; - - var newSorted = sortMethod is SortingMethod.Alphabetical - ? RolePanels.OrderBy(GetSortingOrder()) - : RolePanels.OrderByDescending(GetSortingOrder()); - - foreach (var pair in newSorted) - { - pair.Value.Panel.transform.SetAsLastSibling(); - } } public static void SwitchGrouping() { - var stepUp = (SortingGroups)((int)sortOrder + 1); + var stepUp = (SortingGroups)((int)_sortGrouping + 1); if (Enum.IsDefined(stepUp)) { - sortOrder = stepUp; + _sortGrouping = stepUp; } else { - sortOrder = SortingGroups.Ungrouped; + _sortGrouping = SortingGroups.Ungrouped; } - switch (sortOrder) - { - case SortingGroups.Faction: - _searchSortingGroupIdle.sprite = MiraAssets.GroupFactionIdleSprite; - _searchSortingGroupHover.sprite = MiraAssets.GroupFactionHoverSprite; - break; - case SortingGroups.AmountChance: - _searchSortingGroupIdle.sprite = MiraAssets.GroupCountIdleSprite; - _searchSortingGroupHover.sprite = MiraAssets.GroupCountHoverSprite; - break; - default: - _searchSortingGroupIdle.sprite = MiraAssets.GroupNoneIdleSprite; - _searchSortingGroupHover.sprite = MiraAssets.GroupNoneHoverSprite; - break; - } + MiraApiPlugin.WikiSortingGroup.Value = _sortGrouping; - var newSorted = sortMethod is SortingMethod.Alphabetical - ? RolePanels.OrderBy(GetSortingOrder()) - : RolePanels.OrderByDescending(GetSortingOrder()); + _searchSortingGroupIdle.sprite = GetSortGroupSprite(_sortGrouping, false); + _searchSortingGroupHover.sprite = GetSortGroupSprite(_sortGrouping, true); - foreach (var pair in newSorted) - { - pair.Value.Panel.transform.SetAsLastSibling(); - } + RefreshActiveTab(false); } - public static void DisplayNormalRoleSettings(MatchInfoGuide instance, bool reset) + private static Sprite GetSortGroupSprite(SortingGroups value, bool hover) { - var inner = instance.settingsTabs[2].GetComponent().Inner; - if (reset) + if (hover) { - RolePanels.Clear(); - instance.CreateSettingsEntry( - StringNames.GameNumImpostors, - GameManager.Instance.AllGameSettingData[StringNames.GameNumImpostors] - .GetValueString(GameManager.Instance.LogicOptions.NumImpostors)); - instance.CreateSettingsEntry( - StringNames.GameKillCooldown, - GameManager.Instance.AllGameSettingData[StringNames.GameKillCooldown] - .GetValueString(GameManager.Instance.LogicOptions.GetKillCooldown())); - instance.CreateSettingsEntry( - StringNames.GameEmergencyCooldown, - GameManager.Instance.AllGameSettingData[StringNames.GameEmergencyCooldown] - .GetValueString(GameManager.Instance.LogicOptions.GetEmergencyCooldown())); - instance.CreateSettingsEntry( - StringNames.GameVisualTasks, - instance.GetBoolString(GameManager.Instance.LogicOptions.GetVisualTasks())); - instance.CreateSettingsEntry( - StringNames.GameAnonymousVotes, - instance.GetBoolString(GameManager.Instance.LogicOptions.GetAnonymousVotes())); - instance.CreateSettingsEntry( - StringNames.GameConfirmImpostor, - instance.GetBoolString(GameManager.Instance.LogicOptions.GetConfirmImpostor())); - instance.CreateSettingsEntry( - StringNames.GameTaskBarMode, - GameManager.Instance.LogicOptions.GetTaskBarMode().ToString()); - - var hoverColor = new Color32(255, 255, 255, 150); - foreach (var roleBehaviour in RoleManager.Instance.AllRoles.ToArray().OrderBy(x => x.GetRoleName())) + switch (value) { - if (roleBehaviour.Role is not RoleTypes.Crewmate and not RoleTypes.Impostor and - not RoleTypes.CrewmateGhost and - not RoleTypes.ImpostorGhost) - { - var panel = Object.Instantiate( - instance.MatchInfoRolePanelPrefab, - inner); - var amount = - GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetNumPerGame(roleBehaviour.Role); - var chance = - GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetChancePerGame(roleBehaviour.Role); - panel.SetPanel( - roleBehaviour, - amount, - chance); - var 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(); - passiveButton.ClickSound = HudManager.Instance.MapButton.ClickSound; - passiveButton.OnMouseOver = new UnityEvent(); - passiveButton.OnMouseOver.AddListener((UnityAction)(() => { panel.roleIcon.color = hoverColor; })); - passiveButton.OnMouseOut = new UnityEvent(); - passiveButton.OnMouseOut.AddListener((UnityAction)(() => { panel.roleIcon.color = Color.white; })); - - passiveButton.OnClick = new Button.ButtonClickedEvent(); - passiveButton.OnClick.AddListener( - (Action)(() => { DisplayAdvancedWiki(instance, roleBehaviour); })); - RolePanels.Add( - roleBehaviour, - new DetailedPanel( - panel, - roleBehaviour.GetRoleName(), - roleBehaviour.GetCategoryTitle(), - roleBehaviour is ICustomRole custom - ? custom.ParentMod.MiraPlugin.GetAbbreviatedModName() - : "AU")); - } + case SortingGroups.Faction: + return MiraAssets.GroupFactionHoverSprite; + case SortingGroups.AmountChance: + return MiraAssets.GroupCountHoverSprite; + default: + return MiraAssets.GroupNoneHoverSprite; } } - advancedWikiTab?.gameObject.SetActive(false); - - ToggleRoleVisibility(); - if (reset) - { - instance.CreatePlayerEntries(); - } - } - - public static void ToggleRoleVisibility() - { - var num = 0; - foreach (var (roleData, holder) in RolePanels) - { - var panel = holder.Panel; - var amount = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetNumPerGame(roleData.Role); - var chance = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetChancePerGame(roleData.Role); - var showVal = roleData is ICustomRole custom ? custom.ForceShowRoleOnWiki : null; - var forciblyShow = showVal switch - { - true => 2, - false => 1, - _ => 0, - }; - panel.SetPanel( - roleData, - amount, - chance); - holder.Amount = amount; - holder.Chance = chance; - holder.LikelyhoodOfRole = amount * chance; - var isRoleDisabled = amount == 0 || chance == 0; - var isRoleNotVisible = (roleData is ICustomRole custom2 && - !custom2.CanSpawnOnCurrentMode()) || - (Enum.IsDefined(roleData.Role) && roleData.IsRoleBlacklisted()); - if ((forciblyShow == 0 && ((isRoleDisabled && sortFilter is SortingFilter.EnabledOnly) || - (!isRoleDisabled && sortFilter is SortingFilter.DisabledOnly) || - isRoleNotVisible)) - || forciblyShow == 1) - { - panel.gameObject.SetActive(false); - continue; - } - - panel.gameObject.SetActive(true); - num++; - } - - var newSorted = sortMethod is SortingMethod.Alphabetical - ? RolePanels.OrderBy(GetSortingOrder()) - : RolePanels.OrderByDescending(GetSortingOrder()); - - foreach (var pair in newSorted) - { - pair.Value.Panel.transform.SetAsLastSibling(); - } - - var instance = MatchInfoGuide.Instance; - instance.rolesEnabledMessage.SetActive(num == 0); - - instance.MatchInfoRoleScroller.SetYBoundsMax(Mathf.Clamp(Mathf.Ceil(num / 2f) * 1.3f - 1.5f, 0f, 999f)); - instance.MatchInfoRoleScroller.SetYBoundsMax(Mathf.Clamp(Mathf.Ceil(num / 2f) * 1.3f - 1.5f, 0f, 999f)); - } - - public static Func, string> GetSortingOrder() - { - switch (sortOrder) + switch (value) { case SortingGroups.Faction: - return x => - $"{x.Value.Category} ({x.Value.Title}) {x.Value.LikelyhoodOfRole.ToString("0000.0", CultureInfo.InvariantCulture)}"; + return MiraAssets.GroupFactionIdleSprite; case SortingGroups.AmountChance: - return x => - $"{x.Value.LikelyhoodOfRole.ToString("0000.0", CultureInfo.InvariantCulture)} {x.Value.Title} ({x.Value.Category})"; + return MiraAssets.GroupCountIdleSprite; default: - return x => - $"{x.Value.Title} ({x.Value.Category}) {x.Value.LikelyhoodOfRole.ToString("0000.0", CultureInfo.InvariantCulture)}"; + return MiraAssets.GroupNoneIdleSprite; } } - public static string GetTitle(this DetailedPanel panel) + private static void CopyTextLayout(TextMeshPro source, TextMeshPro target) { - switch (sortOrder) - { - case SortingGroups.Faction: - return - $"{panel.Category} ({panel.Title}) {panel.LikelyhoodOfRole.ToString("0000.0", CultureInfo.InvariantCulture)}"; - case SortingGroups.AmountChance: - return - $"{panel.LikelyhoodOfRole.ToString("0000.0", CultureInfo.InvariantCulture)} {panel.Title} ({panel.Category})"; - default: - return - $"{panel.Title} ({panel.Category}) {panel.LikelyhoodOfRole.ToString("0000.0", CultureInfo.InvariantCulture)}"; - } + target.font = source.font; + target.fontSharedMaterial = source.fontSharedMaterial; + target.fontSize = source.fontSize; + target.fontSizeMin = source.fontSizeMin; + target.fontSizeMax = source.fontSizeMax; + target.enableAutoSizing = source.enableAutoSizing; + target.overflowMode = source.overflowMode; + target.alignment = source.alignment; + target.horizontalAlignment = source.horizontalAlignment; + target.verticalAlignment = source.verticalAlignment; + target.margin = source.margin; + target.enableWordWrapping = source.enableWordWrapping; + var sourceRect = source.rectTransform; + var targetRect = target.rectTransform; + targetRect.anchorMin = sourceRect.anchorMin; + targetRect.anchorMax = sourceRect.anchorMax; + targetRect.pivot = sourceRect.pivot; + targetRect.sizeDelta = sourceRect.sizeDelta; + targetRect.anchoredPosition = sourceRect.anchoredPosition; + targetRect.localScale = sourceRect.localScale; + targetRect.localRotation = sourceRect.localRotation; + targetRect.localPosition = sourceRect.localPosition; + } + + private static void SetupPanelButton(MatchInfoRolePanel panel, Action onClick) + { + var 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(); + passiveButton.ClickSound = HudManager.Instance.MapButton.ClickSound; + passiveButton.OnMouseOver = new UnityEvent(); + passiveButton.OnMouseOver.AddListener( + (UnityAction)(() => { panel.roleIcon.color = new Color32(255, 255, 255, 150); })); + passiveButton.OnMouseOut = new UnityEvent(); + passiveButton.OnMouseOut.AddListener((UnityAction)(() => { panel.roleIcon.color = Color.white; })); + + passiveButton.OnClick = new Button.ButtonClickedEvent(); + passiveButton.OnClick.AddListener(onClick); } [HarmonyPrefix] @@ -712,10 +1234,13 @@ public static class RoleGuidePatches component.NameText.text += $"\n{networkedPlayerInfo.GetPlayerColorString()}"; var namePlate = HatManager.Instance.GetNamePlateById(networkedPlayerInfo.DefaultOutfit.NamePlateId); - __instance.StartCoroutine( - __instance.CoLoadAssetAsync( - namePlate.GetAssetReference(), - (Action?)LoadNameplate)); + if (namePlate != null) + { + __instance.StartCoroutine( + __instance.CoLoadAssetAsync( + namePlate.GetAssetReference(), + (Action?)LoadNameplate)); + } void LoadNameplate(NamePlateViewData viewData) { @@ -731,41 +1256,157 @@ public static class RoleGuidePatches [HarmonyPostfix] [HarmonyPriority(Priority.First)] [HarmonyPatch(typeof(MatchInfoGuide), nameof(MatchInfoGuide.SetActiveTab))] - private static void SetActiveTab() + private static void SetActiveTab(int tabIndex) { - if (titleText) + if (_titleText && _titleIcon) { - titleText.text = TranslationController.Instance.GetString(StringNames.MatchInfoGuideTitle); + switch (tabIndex) + { + case 0: + _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(); + break; + case RolesTabIndex: + _titleText.text = MiraLocaleManager.Get("Roles"); + _titleIcon.sprite = GetRandomRoleIcon(); + break; + case ModifiersTabIndex: + _titleText.text = MiraLocaleManager.Get("Modifiers"); + _titleIcon.sprite = GetRandomModifierIcon(); + break; + default: + _titleText.text = TranslationController.Instance.GetString(StringNames.MatchInfoGuideTitle); + _titleIcon.sprite = MiraAssets.IconPlayerList.LoadAsset(); + break; + } + + _titleIcon.SetSizeLimit(0.55f); } - advancedWikiTab?.gameObject.SetActive(false); + _advancedInfoTabScroller?.gameObject.SetActive(false); + SearchComponentContainer?.SetActive(true); + UpdateSpinner(); + } + + public static Sprite GetRandomModifierIcon() + { + var activeMods = PlayerControl.LocalPlayer.GetModifiers() + .Where(x => !x.HideOnUi && x.ModifierIcon != null).ToList(); + if (activeMods.Count > 0) + { + return activeMods.Random()!.ModifierIcon!.LoadAsset(); + } + + return GetRandomFactionIcon(); + } + + public static Sprite GetRandomRoleIcon() + { + var aliveRole = PlayerControl.LocalPlayer.Data?.Role; + if (aliveRole != null && aliveRole.RoleIconSolid != null) + { + return aliveRole.RoleIconSolid; + } + + return GetRandomFactionIcon(); + } + + public static Sprite GetRandomFactionIcon() + { + var random = UnityEngine.Random.Range(1, 4); + if (random == 1) + { + return MiraAssets.IconCrewmate.LoadAsset(); + } + + if (random == 2) + { + return MiraAssets.IconImpostor.LoadAsset(); + } + + return MiraAssets.IconNeutral.LoadAsset(); + } + + private static bool TryPrepareAdvancedWiki(MatchInfoGuide instance) + { + instance.settingsTabs[RolesTabIndex].SetActive(false); + _modifiersScroller?.gameObject.SetActive(false); + if (_currentAdvancedTabObject) + { + _currentAdvancedTabObject.SetActive(false); + _currentAdvancedTabObject.Destroy(); + } + + if (SearchComponentContainer) + { + SearchComponentContainer.SetActive(false); + } + + if (_advancedInfoTabScroller == null) + { + Warning("Wiki tab is null."); + return false; + } + + _advancedInfoTabScroller.gameObject.SetActive(true); + UpdateSpinner(); + return true; + } + + private static IEnumerator CoDisplayAdvancedWikiLater(float delay, Action display) + { + yield return new WaitForSeconds(delay); + if (_guide && MatchInfoGuide.Instance && MatchInfoGuide.Instance.MatchInfoParent.activeSelf) + { + display(); + } + } + + public static void DisplayAdvancedWiki(MatchInfoGuide instance, BaseModifier modifier) + { + var remaining = _openedAt + instance.transitionOpen.duration - Time.time; + if (remaining > 0f) + { + Coroutines.Start(CoDisplayAdvancedWikiLater(remaining, () => DisplayAdvancedWiki(instance, modifier))); + return; + } + + var name = modifier.ModifierName; + Info($"Opening advanced tab for {name}."); + if (!TryPrepareAdvancedWiki(instance)) + { + return; + } + + _currentAdvancedTabObject = modifier.GetAdvancedWiki(instance, _titleText, _titleIcon, _advancedInfoTabScroller); } public static void DisplayAdvancedWiki(MatchInfoGuide instance, RoleBehaviour role) { - Warning($"Opening advanced tab for {role.GetRoleName()}."); - instance.settingsTabs[2].SetActive(false); - advancedWikiTab?.gameObject.SetActive(true); - if (currentAdvancedTabObject) + var remaining = _openedAt + instance.transitionOpen.duration - Time.time; + if (remaining > 0f) { - currentAdvancedTabObject.SetActive(false); - currentAdvancedTabObject.Destroy(); + Coroutines.Start(CoDisplayAdvancedWikiLater(remaining, () => DisplayAdvancedWiki(instance, role))); + return; } - if (advancedWikiTab == null) + Info($"Opening advanced tab for {role.GetRoleName()}."); + if (!TryPrepareAdvancedWiki(instance)) { - Warning($"Wiki tab is null."); return; } if (role is ICustomRole custom) { - currentAdvancedTabObject = custom.GetAdvancedWiki(instance, titleText, advancedWikiTab); + _currentAdvancedTabObject = custom.GetAdvancedWiki(instance, _titleText, _titleIcon, _advancedInfoTabScroller); } else { - advancedWikiTab.ScrollToTop(); - var titleTxt = role.GetRoleName() + + var titleTxt = $"{role.GetRoleName()}" + $" ({TranslationController.Instance.GetString(role.TeamType is RoleTeamTypes.Crewmate ? StringNames.Crewmate : StringNames.Impostor)})"; var description = TranslationController.Instance.GetString(role.BlurbNameLong); if (description.Contains("STRMISS")) @@ -777,16 +1418,16 @@ public static class RoleGuidePatches } } - currentAdvancedTabObject = Helpers.CreateAdvancedWikiTab( + _currentAdvancedTabObject = Helpers.CreateAdvancedWikiPage( instance, + _titleText, + _titleIcon, + _advancedInfoTabScroller, role.Role.ToString(), titleTxt, + role.RoleIconColor, description, - titleText, - out var desc); - currentAdvancedTabObject.transform.SetParent(advancedWikiTab.Inner.transform); - currentAdvancedTabObject.transform.localPosition = new Vector3(0f, 0f, 0f); - advancedWikiTab.SetYBoundsMax(Mathf.Clamp(desc.textBounds.size.y - 2, 0f, 999f)); + []); } } @@ -825,6 +1466,66 @@ public static class RoleGuidePatches return false; } + private static void SetPanelCount(MatchInfoRolePanel panel, int amount, int chance, string modId) + { + panel.roleCount.text = string.Format( + CultureInfo.InvariantCulture, + "{0} at {1}% ({2})", + amount.ToString(CultureInfo.InvariantCulture), + chance, + modId); + } + + public static void SetRolePanel( + this MatchInfoRolePanel instance, + RoleBehaviour role, + int numPerGame, + int chancePerGame) + { + if (role is ICustomRole customRole) + { + instance.roleName.text = customRole.RoleName; + instance.roleDescription.text = + $"{role.GetCategoryTitle()}\n" + customRole.RoleMedDescription; + instance.roleIcon.sprite = customRole.Configuration.Icon?.LoadAsset(); + SetPanelCount(instance, numPerGame, chancePerGame, customRole.ParentMod.MiraPlugin.GetAbbreviatedModName()); + } + else + { + instance.roleName.text = role.NiceName; + instance.roleDescription.text = $"{role.GetCategoryTitle()}\n" + role.BlurbMed; + instance.roleIcon.sprite = role.RoleIconColor; + SetPanelCount(instance, numPerGame, chancePerGame, "AU"); + } + + instance.roleIcon.SetSizeLimit(0.13f); + instance.roleIcon.material.SetInt(PlayerMaterial.MaskLayer, 50); + instance.roleName.fontMaterial.SetFloat(instance.STENCIL_NAME, 50f); + instance.roleDescription.fontMaterial.SetFloat(instance.STENCIL_NAME, 50f); + instance.roleCount.fontMaterial.SetFloat(instance.STENCIL_NAME, 50f); + instance.roleIcon.transform.localScale = new Vector3(4f, 4f, 1f); + } + + public static void SetModifierPanel( + this MatchInfoRolePanel instance, + GameModifier modifier, + int numPerGame, + int chancePerGame) + { + instance.roleName.text = modifier.ModifierName; + instance.roleDescription.text = + $"{modifier.ModifierCategoryTitle}\n" + modifier.ModifierMedDescription; + instance.roleIcon.sprite = modifier.ModifierIcon?.LoadAsset(); + SetPanelCount(instance, numPerGame, chancePerGame, modifier.ParentMod.MiraPlugin.GetAbbreviatedModName()); + + instance.roleIcon.SetSizeLimit(0.13f); + instance.roleIcon.material.SetInt(PlayerMaterial.MaskLayer, 50); + instance.roleName.fontMaterial.SetFloat(instance.STENCIL_NAME, 50f); + instance.roleDescription.fontMaterial.SetFloat(instance.STENCIL_NAME, 50f); + instance.roleCount.fontMaterial.SetFloat(instance.STENCIL_NAME, 50f); + instance.roleIcon.transform.localScale = new Vector3(4f, 4f, 1f); + } + public static string GetCategoryTitle(this RoleBehaviour role) { if (role is ICustomRole customRole) @@ -846,18 +1547,18 @@ public enum SortingGroups { Ungrouped, Faction, - AmountChance + AmountChance, } public enum SortingMethod { Alphabetical, - AlphabeticalDescending + AlphabeticalDescending, } public enum SortingFilter { EnabledOnly, DisabledOnly, - AllRoles + AllRoles, } diff --git a/MiraAPI/Presets/OptionPreset.cs b/MiraAPI/Presets/OptionPreset.cs index 01758c5..077afc9 100644 --- a/MiraAPI/Presets/OptionPreset.cs +++ b/MiraAPI/Presets/OptionPreset.cs @@ -70,7 +70,12 @@ public class OptionPreset(string name, MiraPluginInfo plugin, ConfigFile presetC /// The option to reset. public void ResetOption(OptionBehaviour baseOption) { - var selectedOpt = Plugin.InternalOptions.First(x => x.OptionBehaviour == baseOption); + var selectedOpt = Plugin.InternalOptions.FirstOrDefault(x => x.OptionBehaviour == baseOption); + if (selectedOpt == null) + { + return; + } + selectedOpt.LoadFromPreset(PresetConfig); /*ModdedOptionsManager.SyncAllOptions(); @@ -84,7 +89,7 @@ public class OptionPreset(string name, MiraPluginInfo plugin, ConfigFile presetC /// The value of whether the option is in the preset. public bool IsOptionInPreset(OptionBehaviour baseOption) { - var selectedOpt = Plugin.InternalOptions.First(x => x.OptionBehaviour == baseOption); - return selectedOpt.ConfigDefinition != null && PresetConfig.ContainsKey(selectedOpt.ConfigDefinition); + var selectedOpt = Plugin.InternalOptions.FirstOrDefault(x => x.OptionBehaviour == baseOption); + return selectedOpt != null && selectedOpt.ConfigDefinition != null && PresetConfig.ContainsKey(selectedOpt.ConfigDefinition); } } diff --git a/MiraAPI/Presets/SavePresetPopup.cs b/MiraAPI/Presets/SavePresetPopup.cs index 8df354c..b9932ff 100644 --- a/MiraAPI/Presets/SavePresetPopup.cs +++ b/MiraAPI/Presets/SavePresetPopup.cs @@ -9,7 +9,10 @@ using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; +// TODO: for the love of fucking god please add a namespace on the asset bundle AND here - Atony. +/* namespace MiraAPI.Presets; +*/ [RegisterInIl2Cpp] [SuppressMessage("Style", "IDE0051:Remove unused private members", Justification = "Unity convention.")] diff --git a/MiraAPI/Resources/Locale/en_US.xml b/MiraAPI/Resources/Locale/en_US.xml index 028119a..ba60484 100644 --- a/MiraAPI/Resources/Locale/en_US.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -53,4 +53,10 @@ Outlive the timer or eliminate the crew. Selective Meeting Ability Meeting Menu Ability Options + Player List + Game Settings + Search... + Filter + Grouping + Order diff --git a/MiraAPI/Resources/Wiki/IconCrewmate.png b/MiraAPI/Resources/Wiki/IconCrewmate.png new file mode 100644 index 0000000..61f706f Binary files /dev/null and b/MiraAPI/Resources/Wiki/IconCrewmate.png differ diff --git a/MiraAPI/Resources/Wiki/IconImpostor.png b/MiraAPI/Resources/Wiki/IconImpostor.png new file mode 100644 index 0000000..6100cd1 Binary files /dev/null and b/MiraAPI/Resources/Wiki/IconImpostor.png differ diff --git a/MiraAPI/Resources/Wiki/IconNeutral.png b/MiraAPI/Resources/Wiki/IconNeutral.png new file mode 100644 index 0000000..984041c Binary files /dev/null and b/MiraAPI/Resources/Wiki/IconNeutral.png differ diff --git a/MiraAPI/Resources/Wiki/IconPlayerList.png b/MiraAPI/Resources/Wiki/IconPlayerList.png new file mode 100644 index 0000000..234baa0 Binary files /dev/null and b/MiraAPI/Resources/Wiki/IconPlayerList.png differ diff --git a/MiraAPI/Resources/Wiki/IconSettings.png b/MiraAPI/Resources/Wiki/IconSettings.png new file mode 100644 index 0000000..50452dc Binary files /dev/null and b/MiraAPI/Resources/Wiki/IconSettings.png differ diff --git a/MiraAPI/Resources/Wiki/IconTerminology.png b/MiraAPI/Resources/Wiki/IconTerminology.png new file mode 100644 index 0000000..8b95d62 Binary files /dev/null and b/MiraAPI/Resources/Wiki/IconTerminology.png differ diff --git a/MiraAPI/Roles/CustomRoleManager.cs b/MiraAPI/Roles/CustomRoleManager.cs index 060b1a5..0548eaa 100644 --- a/MiraAPI/Roles/CustomRoleManager.cs +++ b/MiraAPI/Roles/CustomRoleManager.cs @@ -45,6 +45,11 @@ public static class CustomRoleManager /// public static IReadOnlyList CustomRoleBehaviours { get; private set; } = []; + /// + /// Gets the of all roles as objects. + /// + public static IReadOnlyList AllStoredRoleBehaviours { get; private set; } = []; + /// /// Gets the of custom roles as objects. /// @@ -52,6 +57,7 @@ public static class CustomRoleManager internal static readonly Dictionary CustomRoles = []; internal static readonly Dictionary RoleIds = []; + internal static readonly Dictionary RoleParentMods = []; private static CppCollections.List? _emptySettings; private static Il2CppReferenceArray? _emptyKillAnimations; @@ -77,6 +83,7 @@ public static class CustomRoleManager CustomRoleBehaviours = [.. CustomRoles.Values]; CustomMiraRoles = [.. CustomRoles.Values.OfType()]; + AllStoredRoleBehaviours = [.. RoleManager.Instance.AllRoles]; } internal static void RegisterRoleTypes(List roles, MiraPluginInfo pluginInfo) @@ -99,6 +106,7 @@ public static class CustomRoleManager } pluginInfo.InternalRoles.Add((ushort)role.Role, role); + RoleParentMods[roleType] = pluginInfo; } catch (Exception ex) { @@ -205,7 +213,9 @@ public static class CustomRoleManager /// A object representing the parent mod of the role. public static MiraPluginInfo FindParentMod(ICustomRole role) { - return MiraPluginManager.Instance.RegisteredPlugins.First(plugin => plugin.InternalRoles.ContainsValue(role as RoleBehaviour ?? throw new InvalidOperationException())); + return RoleParentMods.TryGetValue(role.GetType(), out var mod) + ? mod + : MiraPluginManager.Instance.RegisteredPlugins.First(plugin => plugin.InternalRoles.ContainsValue(role as RoleBehaviour ?? throw new InvalidOperationException())); } /// diff --git a/MiraAPI/Roles/ICustomRole.cs b/MiraAPI/Roles/ICustomRole.cs index dc38423..4d5e247 100644 --- a/MiraAPI/Roles/ICustomRole.cs +++ b/MiraAPI/Roles/ICustomRole.cs @@ -12,8 +12,6 @@ using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; using TMPro; using UnityEngine; -using UnityEngine.UI; -using Object = UnityEngine.Object; namespace MiraAPI.Roles; @@ -320,58 +318,22 @@ public interface ICustomRole : IOptionable /// /// The guide object. /// The title text object. + /// The title's icon. /// The scroller parent. /// The of the wiki. - GameObject GetAdvancedWiki(MatchInfoGuide guide, TextMeshPro titleText, Scroller parent) + GameObject GetAdvancedWiki(MatchInfoGuide guide, TextMeshPro titleText, SpriteRenderer titleIcon, Scroller parent) { - parent.ScrollToTop(); - var obj = Helpers.CreateAdvancedWikiTab( + var abilities = WikiAbilities; + return Helpers.CreateAdvancedWikiPage( guide, - RoleNameLocale, - RoleName + $" ({RoleFactionTitle})", - RoleWikiDescription, titleText, - out var desc); - var num = 0; - if (WikiAbilities.Count != 0) - { - var grid = Object.Instantiate(parent.Inner, obj.transform); - var layoutGroup = grid.GetComponent(); - layoutGroup.startAxis = GridLayoutGroup.Axis.Vertical; - layoutGroup.constraint = GridLayoutGroup.Constraint.FixedColumnCount; - layoutGroup.spacing = new Vector2(0.75f, 0.4f); - grid.DestroyChildren(); - foreach (var ability in WikiAbilities) - { - num++; - var panel = Object.Instantiate( - guide.MatchInfoRolePanelPrefab, - grid); - panel.roleCount.text = ability.AbilityType; - panel.roleCount.transform.localPosition += new Vector3(-0.04f, 0); - panel.roleName.text = ability.Name; - panel.roleName.transform.localPosition += new Vector3(-0.04f, 0); - panel.roleDescription.text = ability.Description; - panel.roleDescription.rectTransform.sizeDelta = new Vector2(2.601f, 0.8f); - panel.roleDescription.transform.localPosition += new Vector3(0, -0.1f); - panel.roleIcon.sprite = ability.Icon.LoadAsset(); - panel.roleIcon.SetSizeLimit(0.13f); - - panel.roleIcon.material.SetInt(PlayerMaterial.MaskLayer, 50); - panel.roleName.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); - panel.roleDescription.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); - panel.roleCount.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); - panel.roleIcon.transform.localScale = new Vector3(3.5f, 3.5f, 1f); - } - - grid.localPosition = new Vector3(-3.9f, 1.1f - desc.textBounds.size.y, 0f); - grid.localScale = new Vector3(1.3f, 1.3f, 1); - } - - obj.transform.SetParent(parent.Inner.transform); - obj.transform.localPosition = new Vector3(0f, 0f, 0f); - parent.SetYBoundsMax(Mathf.Clamp((desc.textBounds.size.y - 2) + (Mathf.Ceil(num / 2f) * 1.45f), 0f, 999f)); - return obj; + titleIcon, + parent, + RoleNameLocale, + $"{RoleName} ({RoleFactionTitle})", + Configuration.Icon ?? MiraAssets.IconNeutral.LoadAsset(), + RoleWikiDescription, + abilities); } /// diff --git a/MiraAPI/Utilities/Assets/MiraAssets.cs b/MiraAPI/Utilities/Assets/MiraAssets.cs index 249e942..0feb75c 100644 --- a/MiraAPI/Utilities/Assets/MiraAssets.cs +++ b/MiraAPI/Utilities/Assets/MiraAssets.cs @@ -19,7 +19,7 @@ public static class MiraAssets new Vector2(0.5f, 0.5f), 100f, 0U, - SpriteMeshType.Tight, + SpriteMeshType.FullRect, new Vector4(20, 20, 20, 20)); RoundedBox = new LoadableAssetWrapper(boxSprite); @@ -305,5 +305,35 @@ public static class MiraAssets /// public static LoadableResourceAsset VisibleOnIdleSprite { get; } = new("MiraAPI.Resources.Wiki.VisibleOnIdle.png"); + /// + /// Gets the sprite used for crewmates. + /// + public static LoadableResourceAsset IconCrewmate { get; } = new("MiraAPI.Resources.Wiki.IconCrewmate.png"); + + /// + /// Gets the sprite used for impostors. + /// + public static LoadableResourceAsset IconImpostor { get; } = new("MiraAPI.Resources.Wiki.IconImpostor.png"); + + /// + /// Gets the sprite used for neutrals. + /// + public static LoadableResourceAsset IconNeutral { get; } = new("MiraAPI.Resources.Wiki.IconNeutral.png"); + + /// + /// Gets the sprite used for the player list. + /// + public static LoadableResourceAsset IconPlayerList { get; } = new("MiraAPI.Resources.Wiki.IconPlayerList.png"); + + /// + /// Gets the sprite used for settings. + /// + public static LoadableResourceAsset IconSettings { get; } = new("MiraAPI.Resources.Wiki.IconSettings.png"); + + /// + /// Gets the sprite used for terminology. + /// + public static LoadableResourceAsset IconTerminology { get; } = new("MiraAPI.Resources.Wiki.IconTerminology.png"); + internal static LoadableResourceAsset BlankSprite { get; } = new("MiraAPI.Resources.BlankSprite.png"); } diff --git a/MiraAPI/Utilities/Assets/SpriteTools.cs b/MiraAPI/Utilities/Assets/SpriteTools.cs index b0d4a1e..05ebcfc 100644 --- a/MiraAPI/Utilities/Assets/SpriteTools.cs +++ b/MiraAPI/Utilities/Assets/SpriteTools.cs @@ -48,7 +48,13 @@ public static class SpriteTools public static Sprite LoadSpriteFromPath(string resourcePath, Assembly assembly, float pixelsPerUnit) { var tex = LoadTextureFromResourcePath(resourcePath, assembly); - var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), pixelsPerUnit); + var sprite = Sprite.Create( + tex, + new Rect(0, 0, tex.width, tex.height), + new Vector2(0.5f, 0.5f), + pixelsPerUnit, + 0, + SpriteMeshType.FullRect); sprite.name = resourcePath; return sprite; } diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index 57cd64b..7a3381c 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -242,7 +242,7 @@ public static class Extensions public static IEnumerator CoLoopWithBudget(this IEnumerable collection, Action action) { var fps = Application.targetFrameRate > 0 ? Application.targetFrameRate : 60; - long budget = 1 / (fps * 2); // default is half of current frame time. + var budget = 1000L / (fps * 2); // default is half of current frame time. yield return collection.CoLoopWithBudget(budget, action); } @@ -372,8 +372,8 @@ public static class Extensions public static void ClearGarbageCollector() { Resources.UnloadUnusedAssets(); - Il2CppSystem.GC.Collect(); - GC.Collect(); + /*Il2CppSystem.GC.Collect(); + GC.Collect();*/ } /// diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index 3584588..773a4ae 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -6,16 +6,19 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; using MiraAPI.GameOptions; using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Modifiers; using MiraAPI.Roles; using MiraAPI.Translation; using MiraAPI.Utilities.Assets; using Rewired; using TMPro; using UnityEngine; +using UnityEngine.UI; using MethodBase = System.Reflection.MethodBase; using Object = UnityEngine.Object; using Random = UnityEngine.Random; @@ -27,83 +30,67 @@ namespace MiraAPI.Utilities; /// public static class Helpers { + private static readonly Regex TrailingZeroRegex = new(@"\.0+(?!\d)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + public static ReadOnlyCollection? GetModdedOptionsForType(Type classType) { - var optionGroups = - AccessTools.Field(typeof(ModdedOptionsManager), "Groups").GetValue(null) as List; + var group = ModdedOptionsManager.Groups.FirstOrDefault(x => x.OptionableType == classType); - return optionGroups?.FirstOrDefault(x => x.OptionableType == classType)?.Children; + return group?.Children; } public static string GetOptionsText(Type classType) { - var options = GetModdedOptionsForType(classType); + var group = ModdedOptionsManager.Groups.FirstOrDefault(x => x.OptionableType == classType); + var options = group?.Children; + var summaryProvider = group as ISummarizedOptions; + var hiddenKeys = summaryProvider?.WikiHiddenOptionKeys; + if (options == null) { return string.Empty; } - ISummarizedOptions? summaryProvider; - IReadOnlySet? hiddenKeys; - try - { - var optionGroups = - AccessTools.Field(typeof(ModdedOptionsManager), "Groups").GetValue(null) as List; - summaryProvider = - optionGroups?.FirstOrDefault(x => x.OptionableType == classType) as ISummarizedOptions; - hiddenKeys = summaryProvider?.WikiHiddenOptionKeys; - } - catch - { - summaryProvider = null; - hiddenKeys = null; - } - - var builder = new StringBuilder(); - builder.AppendLine( - MiraApiPlugin.Culture, - $"\n \n{MiraLocaleManager.Get("Options")}"); - + var lines = new List(); var insertedSummary = false; foreach (var option in options) { - if (!insertedSummary && summaryProvider != null && hiddenKeys != null) + StringNames? key = option switch { - StringNames? key = option switch - { - ModdedToggleOption t => t.StringName, - ModdedEnumOption e => e.StringName, - ModdedNumberOption n => n.StringName, - _ => null, - }; - if (key.HasValue && hiddenKeys.Contains(key.Value)) - { - foreach (var line in summaryProvider.GetWikiOptionSummaryLines()) - { - if (!string.IsNullOrWhiteSpace(line)) - { - builder.AppendLine(line); - } - } + ModdedToggleOption t => t.StringName, + ModdedEnumOption e => e.StringName, + ModdedNumberOption n => n.StringName, + _ => null, + }; - insertedSummary = true; + if (!insertedSummary && summaryProvider != null && hiddenKeys != null && + key.HasValue && hiddenKeys.Contains(key.Value)) + { + foreach (var line in summaryProvider.GetWikiOptionSummaryLines()) + { + if (!string.IsNullOrWhiteSpace(line)) + { + lines.Add(line); + } } + + insertedSummary = true; + } + + if (!option.Visible()) + { + continue; + } + + if (key.HasValue && hiddenKeys?.Contains(key.Value) == true) + { + continue; } switch (option) { case ModdedToggleOption toggleOption: - if (!toggleOption.Visible()) - { - continue; - } - - if (hiddenKeys != null && hiddenKeys.Contains(toggleOption.StringName)) - { - continue; - } - - builder.AppendLine( + lines.Add( TranslationController.Instance.GetString(toggleOption.StringName) + ": " + toggleOption.Value); break; @@ -116,65 +103,49 @@ public static class Helpers builder.AppendLine(enumOption.Title + ": " + enumOption.Values[enumOption.Value]); break;*/ case ModdedEnumOption enumOption: - if (!enumOption.Visible()) - { - continue; - } - - if (hiddenKeys != null && hiddenKeys.Contains(enumOption.StringName)) - { - continue; - } - - builder.AppendLine( + lines.Add( TranslationController.Instance.GetString(enumOption.StringName) + ": " + MiraLocaleManager.Get( enumOption.Values[enumOption.Value], enumOption.Values[enumOption.Value])); break; case ModdedNumberOption numberOption: - if (!numberOption.Visible()) - { - continue; - } - - if (hiddenKeys != null && hiddenKeys.Contains(numberOption.StringName)) - { - continue; - } - - var optionStr = numberOption.Data.GetValueString(numberOption.Value); - if (optionStr.Contains(".000")) - { - optionStr = optionStr.Replace(".000", string.Empty); - } - else if (optionStr.Contains(".00")) - { - optionStr = optionStr.Replace(".00", string.Empty); - } - else if (optionStr.Contains(".0")) - { - optionStr = optionStr.Replace(".0", string.Empty); - } + var optionStr = TrailingZeroRegex.Replace( + numberOption.Data.GetValueString(numberOption.Value), + string.Empty); var title = TranslationController.Instance.GetString(numberOption.StringName); if (numberOption is { NegativeWordValue: not "#", Value: -1 }) { - builder.AppendLine(title + $": {numberOption.NegativeWordValue}"); + lines.Add(title + $": {numberOption.NegativeWordValue}"); } else if (numberOption is { ZeroWordValue: not "#", Value: 0 }) { - builder.AppendLine(title + $": {numberOption.ZeroWordValue}"); + lines.Add(title + $": {numberOption.ZeroWordValue}"); } else { - builder.AppendLine(title + ": " + optionStr); + lines.Add(title + ": " + optionStr); } break; } } + if (lines.Count == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + builder.AppendLine( + MiraApiPlugin.Culture, + $"\n \n{MiraLocaleManager.Get("Options")}"); + foreach (var line in lines) + { + builder.AppendLine(line); + } + return builder.ToString(); } @@ -718,6 +689,40 @@ public static class Helpers return role is ICustomRole custom ? custom.RoleLongDescription : role.BlurbLong; } + /// + /// Returns the TextMeshPro sprite tag for a role, matching how role names are shown in-game. + /// Uses the role's if set, otherwise falls back to the faction icon. + /// + /// The . + /// A <sprite> rich text tag. + public static string GetTmpIcon(this RoleBehaviour role) + { + return role is ICustomRole custom ? custom.GetTmpIcon() : $""; + } + + /// + /// Returns the TextMeshPro sprite tag for a custom role, matching how role names are shown in-game. + /// Uses the role's if set, otherwise falls back to the faction icon. + /// + /// The . + /// A <sprite> rich text tag. + public static string GetTmpIcon(this ICustomRole role) + { + return role.Configuration.IconTmp + ? $"" + : $""; + } + + /// + /// Returns the TextMeshPro sprite tag for a modifier, or an empty string if it has no . + /// + /// The . + /// A <sprite> rich text tag, or . + public static string GetTmpIcon(this BaseModifier modifier) + { + return modifier.IconTmp ? $"" : string.Empty; + } + /// /// Returns whether the is blacklisted from appearing and spawning. (Only applicable to vanilla roles). /// @@ -736,14 +741,19 @@ public static class Helpers /// The guide panel to attach the tab to. /// The name of the tab. /// The title of the tab. + /// The icon of the page. /// The description of the tab. /// The instance of the title. + /// The instance for the title. /// The instance of the description. /// The of the tab. - public static GameObject CreateAdvancedWikiTab(MatchInfoGuide guide, string objName, string title, string description, TextMeshPro titleTmp, out TextMeshPro descriptionTmp) + public static GameObject CreateAdvancedWikiTab(MatchInfoGuide guide, string objName, string title, Sprite icon, string description, TextMeshPro titleTmp, SpriteRenderer titleIcon, out TextMeshPro descriptionTmp) { var obj = new GameObject(objName); + titleTmp.richText = true; titleTmp.text = title; + titleIcon.sprite = icon; + titleIcon.SetSizeLimit(0.55f); descriptionTmp = Object.Instantiate(guide.MatchInfoRolePanelPrefab.roleCount, obj.transform); descriptionTmp.fontSizeMin = descriptionTmp.fontSizeMax = descriptionTmp.fontSize = 2f; var sourceTmp = HudManager.Instance.TaskPanel.taskText; @@ -757,4 +767,103 @@ public static class Helpers descriptionTmp.ForceMeshUpdate(); return obj; } + + /// + /// Creates a full advanced wiki page with a title, a description, and an optional grid of ability panels. + /// + /// The guide object. + /// The instance of the title. + /// The instance for the title. + /// The the page should be parented to. + /// The name of the page object. + /// The title of the page. + /// The icon of the page. + /// The description of the page. + /// The abilities to display in a grid below the description. + /// Whether to use variable text sizing for ability descriptions (allows different heights). + /// The of the wiki page. + public static GameObject CreateAdvancedWikiPage( + MatchInfoGuide guide, + TextMeshPro titleTmp, + SpriteRenderer titleIcon, + Scroller parent, + string objName, + string title, + Sprite icon, + string description, + IReadOnlyCollection abilities, + bool variableTextSizing = false) + { + parent.ScrollToTop(); + var obj = CreateAdvancedWikiTab( + guide, + objName, + title, + icon, + description, + titleTmp, + titleIcon, + out var desc); + var num = 0; + var maxTextSize = 0f; + if (abilities.Count != 0) + { + var grid = Object.Instantiate(parent.Inner, obj.transform); + var layoutGroup = grid.GetComponent(); + layoutGroup.startAxis = GridLayoutGroup.Axis.Vertical; + layoutGroup.constraint = GridLayoutGroup.Constraint.FixedColumnCount; + layoutGroup.spacing = new Vector2(0.75f, 0.4f); + grid.DestroyChildren(); + foreach (var ability in abilities) + { + num++; + var panel = Object.Instantiate(guide.MatchInfoRolePanelPrefab, grid); + SetupAbilityPanel(panel, ability, variableTextSizing); + if (variableTextSizing) + { + var descSize = panel.roleDescription.textBounds.size.y; + if (maxTextSize < descSize) + { + maxTextSize = descSize; + } + } + } + + grid.localPosition = new Vector3(-3.9f, 1.1f - desc.textBounds.size.y, 0f); + grid.localScale = new Vector3(1.3f, 1.3f, 1); + } + + obj.transform.SetParent(parent.Inner.transform); + obj.transform.localPosition = new Vector3(0f, 0.5f, 0f); + var scrollHeight = variableTextSizing + ? (desc.textBounds.size.y - 2.5f) + maxTextSize + 0.475f + : desc.textBounds.size.y - 2.5f + Mathf.Ceil(num / 2f) * 1.45f; + parent.SetYBoundsMax(Mathf.Clamp(scrollHeight, 0f, 999f)); + return obj; + } + + private static void SetupAbilityPanel(MatchInfoRolePanel panel, AdvancedWikiAbilityDescription ability, bool variableTextSizing = false) + { + panel.roleCount.text = ability.AbilityType; + panel.roleCount.transform.localPosition += new Vector3(-0.04f, 0); + panel.roleName.text = ability.Name; + panel.roleName.transform.localPosition += new Vector3(-0.04f, 0); + panel.roleDescription.text = ability.Description; + panel.roleDescription.rectTransform.sizeDelta = new Vector2(2.601f, 0.8f); + panel.roleDescription.transform.localPosition += new Vector3(0, -0.1f); + if (variableTextSizing) + { + panel.roleDescription.alignment = TextAlignmentOptions.Top; + panel.roleDescription.fontSizeMin = 1.5f; + panel.roleDescription.ForceMeshUpdate(); + } + panel.roleIcon.sprite = ability.Icon.LoadAsset(); + panel.roleIcon.SetSizeLimit(0.13f); + + panel.roleIcon.material.SetInt(PlayerMaterial.MaskLayer, 50); + panel.roleName.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); + panel.roleDescription.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); + panel.roleCount.fontMaterial.SetFloat(panel.STENCIL_NAME, 50f); + panel.roleIcon.transform.localScale = new Vector3(3.5f, 3.5f, 1f); + } } diff --git a/MiraAPI/Utilities/LoadingRing.cs b/MiraAPI/Utilities/LoadingRing.cs new file mode 100644 index 0000000..3f8af87 --- /dev/null +++ b/MiraAPI/Utilities/LoadingRing.cs @@ -0,0 +1,88 @@ +using System; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace MiraAPI.Utilities; + +/// +/// A spinning ring used to indicate that content is loading. +/// +[RegisterInIl2Cpp] +public class LoadingRing(IntPtr cppPtr) : MonoBehaviour(cppPtr) +{ + private static Sprite? _ringSprite; + + /// + /// Gets or sets the rotation speed in degrees per second. + /// + public float Speed { get; set; } = 270f; + + /// + /// Creates a on the UI layer. + /// + /// The transform to parent the spinner to. + /// The local position of the spinner. + /// The uniform scale of the spinner. + /// The created . + public static LoadingRing Create(Transform parent, Vector3 localPosition, float scale) + { + if (!_ringSprite) + { + _ringSprite = CreateRingSprite(); + } + + var obj = new GameObject("LoadingRing") + { + transform = + { + parent = parent, + localPosition = localPosition, + localScale = Vector3.one * scale, + }, + layer = LayerMask.NameToLayer("UI"), + }; + var renderer = obj.AddComponent(); + renderer.sprite = _ringSprite; + return obj.AddComponent(); + } + + /// + /// Rotates the ring. + /// + public void Update() + { + transform.Rotate(0f, 0f, -Speed * Time.deltaTime); + } + + private static Sprite CreateRingSprite() + { + const int size = 128; + const float innerRadius = 46f; + const float outerRadius = 60f; + var texture = new Texture2D(size, size, TextureFormat.RGBA32, false) + { + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp, + }; + var pixels = new Color[size * size]; + for (var y = 0; y < size; y++) + { + for (var x = 0; x < size; x++) + { + var dx = x + 0.5f - (size / 2f); + var dy = y + 0.5f - (size / 2f); + var radius = Mathf.Sqrt((dx * dx) + (dy * dy)); + var ring = Mathf.Clamp01(radius - innerRadius) * Mathf.Clamp01(outerRadius - radius); + var angle = (Mathf.Atan2(dy, dx) + Mathf.PI) / (2f * Mathf.PI); + pixels[(y * size) + x] = new Color(1f, 1f, 1f, ring * Mathf.Lerp(1f, 0.1f, angle)); + } + } + + texture.SetPixels(pixels); + texture.Apply(); + texture.hideFlags = HideFlags.HideAndDontSave; + var sprite = Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f); + sprite.hideFlags = HideFlags.HideAndDontSave; + return sprite; + } +}