diff --git a/MiraAPI/Hud/CustomMultiSelectMenu.cs b/MiraAPI/Hud/CustomMultiSelectMenu.cs new file mode 100644 index 0000000..0e87ade --- /dev/null +++ b/MiraAPI/Hud/CustomMultiSelectMenu.cs @@ -0,0 +1,218 @@ +using MiraAPI.Patches.Stubs; +using MiraAPI.Utilities.Assets; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using UnityEngine; +using UnityEngine.Events; + +namespace MiraAPI.Hud; + +/// +/// Multi-select using the as a base. +/// +/// The type of object each entry represents. +[SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "Unity convention.")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Read above.")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Read above.")] +public abstract class CustomMultiSelectMenu : CustomPhoneMenu.MenuEntry> where TEntry : class +{ + private int totalSelections; + private bool shouldConfirm; + private bool canRepeat; + private readonly List selectedEntries = []; + + private LoadableAsset? hoverSelectSprite; + private LoadableAsset? hoverDeselectSprite; + private Color? activeColor; + private Color? hoverSelectColor; + private Color? hoverDeselectColor; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + [SuppressMessage("Minor Code Smell", "S1104:Fields should not have public accessibility", Justification = "Unity convention.")] + public UiElement confirmButton; +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + private Action>? onSelection; + + /// + /// Menu Entry used when multiple entries are at play. + /// + /// The panel of the entry. + /// The entry itself. + public record MenuEntry(ShapeshifterPanel Panel, TEntry Entry) : IMenuEntry; + + /// + /// Creates a . + /// + /// The type of . + /// The to use when an entry is selected but not hovered over. + /// The to use when an entry is hovered over while not selected. + /// The to use when an entry is hovered over while not selected. + /// The to use when an entry is hovered over while selected. + /// The to use when an entry is hovered over while selected. + /// Function that can optionally be run when the mouse is moved outside a menu panel. + /// Function that can optionally be run when the mouse is moved over a menu panel. + /// New object. + protected static TMenu Create( + Color? activeColor = null, + LoadableAsset? hoverSelectSprite = null, + Color? hoverSelectColor = null, + LoadableAsset? hoverDeselectSprite = null, + Color? hoverDeselectColor = null, + PanelButtonOnMouse? onMouseOut = null, + PanelButtonOnMouse? onMouseOver = null + ) where TMenu : CustomMultiSelectMenu, new() + { + TMenu customMenu = Create(onMouseOut, onMouseOver); + + customMenu.confirmButton = null!; // TODO: create/add confirm button + + var button = customMenu.confirmButton.GetComponent(); + button.OnClick.RemoveAllListeners(); + button.OnClick.AddListener((UnityAction)customMenu.OnCompleteSelection); + + customMenu.activeColor = activeColor; + + customMenu.hoverSelectSprite = hoverSelectSprite; + customMenu.hoverSelectColor = hoverSelectColor; + + customMenu.hoverDeselectSprite = hoverDeselectSprite ?? hoverSelectSprite; + customMenu.hoverDeselectColor = hoverDeselectColor ?? hoverSelectColor; + + return customMenu; + } + + /// + /// Setup the given once created, given its index, entry, and onClick action. + /// + /// The panel to configure. + /// The 's index. + /// The 's entry. + /// Action to perform when the is clicked on. + protected abstract void SetupPanelEntry(ShapeshifterPanel panel, int i, TEntry entry, Action onClick); + + /// + /// Begins/opens the custom multi-select menu. + /// + /// All entries to give the custom menu. + /// Function called when all selections are made/confirmed. + /// The number of selections required. + /// Whether the entire selection should be confirmed manually. + /// If the same entry can be selected multiple times, else unselect entry on click. + protected void Begin(IEnumerable entries, Action?> onClick, int totalSelections, bool shouldConfirm, bool canRepeat = false) + { + MinigameStubs.Begin(Component, null); + + this.totalSelections = totalSelections; + this.shouldConfirm = shouldConfirm; + this.canRepeat = canRepeat; + this.onSelection = onClick; + + var back = backButton.GetComponent(); + back.OnClick.AddListener((UnityAction)(() => + { + selectedEntries.Clear(); + onClick(null); + })); + + DebugAnalytics.Instance.Analytics.MinigameOpened(PlayerControl.LocalPlayer.Data, Component.TaskType); + var list2 = new Il2CppSystem.Collections.Generic.List(); + RegisterPanels( + entries, + (shapeshifterPanel, i, entry) => + { + var num = i % 3; + var num2 = i / 3; + shapeshifterPanel.transform.localPosition = new Vector3(xStart + num * xOffset, yStart + num2 * yOffset, -1f); + SetupPanelEntry(shapeshifterPanel, i, entry, () => OnEntryClick(entry)); + list2.Add(shapeshifterPanel.Button); + }, + (p, e) => new MenuEntry(p, e)); + ControllerManager.Instance.OpenOverlayMenu(name, backButton, defaultButtonSelected, list2); + } + + /// + /// Begins/opens the custom menu, only requiring a single selection. + /// + /// All entries to give the custom menu. + /// Function called when the selection is made. + /// Whether the set of both selections should be confirmed manually. + protected void Begin(IEnumerable entries, Action onClick, bool shouldConfirm) + { + Begin(entries, list => onClick(list?[0]), 1, shouldConfirm); + } + + /// + /// Begins/opens the custom menu, requiring two selections. + /// + /// All entries to give the custom menu. + /// Function called when both selections are made. + /// Whether the set of both selections should be confirmed manually. + /// If the same entry can be selected both times, else unselect entry on click. + protected void Begin(IEnumerable entries, Action onClick, bool shouldConfirm, bool canRepeat = false) + { + Begin(entries, list => onClick(list?[0], list?[1]), 2, shouldConfirm, canRepeat); + } + + private void OnEntryClick(TEntry entry) + { + MenuEntry menuEntry = MenuEntries.First(e => e.Entry == entry); + + if (!canRepeat && selectedEntries.Remove(menuEntry)) // Unselect previous choice + { + // TODO: if confirm button is enabled, disable it + SetNameplateAppearance(menuEntry, false); + return; + } + + if (selectedEntries.Count >= totalSelections) // Do not go past total selections. + { + return; + } + + selectedEntries.Add(menuEntry); + if (selectedEntries.Count <= totalSelections) // Add choice to list of selections + { + SetNameplateAppearance(menuEntry, true); + } + if (selectedEntries.Count < totalSelections) + { + return; + } + + // Total selections reached + if (shouldConfirm) + { + // TODO: enable confirm button + return; + } + + OnCompleteSelection(); + selectedEntries.Clear(); + } + + private void OnCompleteSelection() + { + onSelection!([.. selectedEntries.Select(se => se.Entry)]); + } + + /// + protected override bool IsEntrySelected(IMenuEntry entry) + { + return selectedEntries.Any(e => e.Panel == entry.Panel); + } + + /// + /// + /// Whether the menu entry is actively selected. + protected void SetNameplateAppearance(IMenuEntry menuEntry, bool isSelected) + { + SetNameplateAppearance( + menuEntry, + isSelected ? hoverDeselectSprite : hoverSelectSprite, + isSelected ? hoverDeselectColor : hoverSelectColor, + isSelected ? activeColor : Color.clear); + } +} diff --git a/MiraAPI/Hud/CustomPaginableMenu.cs b/MiraAPI/Hud/CustomPaginableMenu.cs new file mode 100644 index 0000000..9c6b7b6 --- /dev/null +++ b/MiraAPI/Hud/CustomPaginableMenu.cs @@ -0,0 +1,399 @@ +using MiraAPI.Patches.Stubs; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +using CppCollections = Il2CppSystem.Collections.Generic; +using Object = UnityEngine.Object; + +namespace MiraAPI.Hud; + +/// +/// Paginable using the as a base. +/// +[SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "Unity convention.")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Read above.")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Read above.")] +public abstract class CustomPaginableMenu : CustomPhoneMenu +{ + /// + /// Menu Entry used for when multiple pages are needed. + /// + /// The panel. + /// The key by which the panels are sorted. + public record MenuEntry(ShapeshifterPanel Panel, string SortKey) : IMenuEntry; + + /// + /// Gets the name of the menu. + /// + protected abstract string Name { get; } + + /// + /// Gets the prefab used to generate the search field. + /// + protected abstract TextBoxTMP? PrefabTextbox { get; } + + /// + protected override float MenuDepth => -60f; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + protected int currentPage; +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + private TextBoxTMP? searchTextbox; + private string searchText = string.Empty; + private TextMeshPro? noResultsText; + + private const int ItemsPerPage = 15; + + /// + /// Creates a . + /// + /// The type of . + /// Function that can optionally be run when the mouse is moved outside a menu panel. + /// Function that can optionally be run when the mouse is moved over a menu panel. + /// New object. + protected static new TMenu Create(PanelButtonOnMouse? onMouseOut = null, PanelButtonOnMouse? onMouseOver = null) where TMenu : CustomPaginableMenu, new() + { + TMenu customMenu = CustomPhoneMenu.Create(onMouseOut, onMouseOver); + + var nextButton = Object.Instantiate(customMenu.backButton, customMenu.transform).gameObject; + nextButton.transform.localPosition = new Vector3(1.85f, -2.185f, customMenu.MenuDepth); + nextButton.transform.localScale = new Vector3(0.65f, 0.65f, 1); + nextButton.name = "RightArrowButton"; + nextButton.GetComponent().sprite = MiraAssets.NextButton.LoadAsset(); + nextButton.gameObject.GetComponent().DestroyImmediate(); + + var passiveButton = nextButton.gameObject.GetComponent(); + passiveButton.OnClick = new Button.ButtonClickedEvent(); + passiveButton.OnClick.AddListener((UnityAction)customMenu.NextPage); + + var backButton = Object.Instantiate(nextButton, customMenu.transform).gameObject; + backButton.transform.localPosition = new Vector3(-1.85f, -2.185f, customMenu.MenuDepth); + backButton.name = "LeftArrowButton"; + backButton.gameObject.GetComponent().Destroy(); + backButton.GetComponent().flipX = true; + var prevPassive = backButton.gameObject.GetComponent(); + prevPassive.OnClick.AddListener((UnityAction)customMenu.PreviousPage); + + customMenu.PhoneUI.GetChild(0).GetComponent().material = + PlayerControl.LocalPlayer.cosmetics.currentBodySprite.BodySprite.material; + customMenu.PhoneUI.GetChild(1).GetComponent().material = + PlayerControl.LocalPlayer.cosmetics.currentBodySprite.BodySprite.material; + + return customMenu; + } + + private static string NormalizeForSearch(string? text) + { + return string.IsNullOrWhiteSpace(text) + ? string.Empty + : text.Trim().ToLowerInvariant(); + } + + private List GetFilteredEntries() + { + var query = NormalizeForSearch(searchText); + + if (string.IsNullOrEmpty(query)) + return MenuEntries; + + return [.. MenuEntries + .Where(e => e.SortKey.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(e => e.SortKey.Equals(query, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(e => e.SortKey.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(e => e.SortKey.Contains(query, StringComparison.OrdinalIgnoreCase)) + .ThenBy(e => e.SortKey, StringComparer.OrdinalIgnoreCase)]; + } + + private static int GetTotalPages(int itemCount) + { + return Mathf.Max(1, Mathf.CeilToInt(itemCount / (float)ItemsPerPage)); + } + + private void RefreshControllerOverlay(CppCollections.List list) + { + if (ControllerManager.Instance && backButton != null) + { + ControllerManager.Instance.OpenOverlayMenu(name, backButton, defaultButtonSelected, list); + } + } + + private IEnumerator CoRestoreFocus() + { + yield return null; + searchTextbox?.GiveFocus(); + } + + private void NextPage() + { + var filtered = GetFilteredEntries(); + var pages = GetTotalPages(filtered.Count); + currentPage = (currentPage + 1) % pages; + var list = ShowPage(); + RefreshControllerOverlay(list); + } + + private void PreviousPage() + { + var filtered = GetFilteredEntries(); + var pages = GetTotalPages(filtered.Count); + currentPage = (currentPage - 1 + pages) % pages; + var list = ShowPage(); + RefreshControllerOverlay(list); + } + + /// + /// Shows the current page. + /// + /// The Il2CPP list of ui elements displayed on the page. + public CppCollections.List ShowPage() + { + foreach (var entry in MenuEntries) + { + entry.Panel.gameObject.SetActive(false); + } + + var filtered = GetFilteredEntries(); + noResultsText?.gameObject.SetActive(filtered.Count == 0 && !string.IsNullOrWhiteSpace(searchText)); + var totalPages = GetTotalPages(filtered.Count); + currentPage = Mathf.Clamp(currentPage, 0, totalPages - 1); + + var list = filtered.Skip(currentPage * ItemsPerPage).Take(ItemsPerPage).ToList(); + var list2 = new CppCollections.List(); + + for (var i = 0; i < list.Count; i++) + { + var entry = list[i]; + var num = i % 3; + var num2 = i / 3 % 5; + entry.Panel.transform.localPosition = + new Vector3(xStart + num * xOffset, yStart + num2 * yOffset, -1f); + entry.Panel.gameObject.SetActive(true); + list2.Add(entry.Panel.Button); + } + + return list2; + } + + /// + /// Ensures that the search UI exists. + /// + protected void EnsureSearchUi() + { + if (searchTextbox != null) + { + return; + } + + var gridCenterX = xStart + xOffset; + var desiredSearchBarCenterY = yStart + 0.55f; + + if (PrefabTextbox == null) + { + return; + } + + var searchRoot = PrefabTextbox.transform.parent != null ? PrefabTextbox.transform.parent.gameObject : PrefabTextbox.gameObject; + var searchObj = Object.Instantiate(searchRoot, transform); + searchObj.name = $"{Name}SearchBar"; + + foreach (var aspect in searchObj.GetComponentsInChildren(true)) + { + aspect.DestroyImmediate(); + } + + searchTextbox = searchObj.GetComponentInChildren(true); + if (searchTextbox == null) + { + return; + } + + try + { + var placeholder = searchTextbox.transform.parent.GetChild(2).GetComponent(); + placeholder?.gameObject.SetActive(false); + } + catch + { + foreach (var tmp in searchObj.GetComponentsInChildren(true)) + { + if (tmp != searchTextbox.outputText && + (tmp.text.Contains("Search", StringComparison.OrdinalIgnoreCase) || + tmp.text.Contains("Here", StringComparison.OrdinalIgnoreCase))) + { + tmp.gameObject.SetActive(false); + } + } + } + + searchObj.transform.localScale = new Vector3(0.7f, 0.7f, 1f); + searchObj.transform.localPosition = new Vector3(gridCenterX, desiredSearchBarCenterY, -1f); + + var searchBounds = CalcSpriteBoundsInParentSpace(transform, searchObj); + var deltaX = gridCenterX - searchBounds.center.x; + var deltaY = desiredSearchBarCenterY - searchBounds.center.y; + searchObj.transform.localPosition += new Vector3(deltaX, deltaY, 0f); + searchBounds = CalcSpriteBoundsInParentSpace(transform, searchObj); + + var wikiClickSound = HudManager.Instance?.MapButton?.ClickSound; + + var searchFocusButton = searchTextbox.gameObject.GetComponent() + ?? searchTextbox.gameObject.AddComponent(); + if (wikiClickSound != null) + { + searchFocusButton.ClickSound = wikiClickSound; + } + searchFocusButton.OnClick.RemoveAllListeners(); + searchFocusButton.OnClick.AddListener((UnityAction)(Action)(() => + { + searchTextbox.GiveFocus(); + })); + + searchTextbox.SetText(string.Empty); + searchTextbox.OnChange.RemoveAllListeners(); + searchTextbox.OnChange.AddListener((UnityAction)(Action)(() => + { + searchText = searchTextbox.outputText.text ?? string.Empty; + currentPage = 0; + var list = ShowPage(); + RefreshControllerOverlay(list); + + if (searchTextbox != null) + { + Coroutines.Start(CoRestoreFocus()); + } + })); + + var label = Object.Instantiate(HudManager.Instance?.TaskPanel.taskText, transform); + if (label != null) + { + label.name = $"{Name}SearchLabel"; + label.text = "Search"; + label.alignment = TextAlignmentOptions.Center; + label.fontSize = label.fontSizeMin = label.fontSizeMax = 2.1f; + label.color = Color.white; + label.transform.localPosition = new Vector3(gridCenterX, searchBounds.max.y + 0.18f, -1f); + + if (searchTextbox.outputText != null) + { + label.font = searchTextbox.outputText.font; + label.fontMaterial = searchTextbox.outputText.fontMaterial; + } + } + + noResultsText = Object.Instantiate(HudManager.Instance?.TaskPanel.taskText, transform); + if (noResultsText != null) + { + noResultsText.name = $"{Name}NoResultsText"; + noResultsText.text = "No results"; + noResultsText.alignment = TextAlignmentOptions.Center; + noResultsText.fontSize = noResultsText.fontSizeMin = noResultsText.fontSizeMax = 2.25f; + noResultsText.color = Color.white; + noResultsText.transform.localPosition = new Vector3(gridCenterX, yStart + 0.1f, -1f); + noResultsText.gameObject.SetActive(false); + } + + if (backButton == null || searchTextbox == null) + return; + + var clearButtonX = searchBounds.max.x + 0.2f; + var clearButtonY = searchBounds.center.y; + + var clearObj = Object.Instantiate(backButton.gameObject, transform); + clearObj.name = "ClearSearchButton"; + clearObj.transform.localScale = new Vector3(0.35f, 0.35f, 1f); + clearObj.transform.localPosition = new Vector3(clearButtonX, clearButtonY, -1f); + + clearObj.GetComponent()?.DestroyImmediate(); + clearObj.GetComponent()?.DestroyImmediate(); + + var clearSearchButton = clearObj.GetComponent(); + if (clearSearchButton == null) + { + return; + } + if (wikiClickSound != null) + { + clearSearchButton.ClickSound = wikiClickSound; + } + + clearSearchButton.OnClick.RemoveAllListeners(); + clearSearchButton.OnClick = new Button.ButtonClickedEvent(); + clearSearchButton.OnClick.AddListener((UnityAction)(() => + { + if (searchTextbox == null) + { + return; + } + + searchTextbox.SetText(string.Empty); + searchText = string.Empty; + currentPage = 0; + var list = ShowPage(); + RefreshControllerOverlay(list); + })); + } + + private static Bounds CalcSpriteBoundsInParentSpace(Transform parent, GameObject root) + { + var first = true; + var bounds = default(Bounds); + + foreach (var r in root.GetComponentsInChildren(true)) + { + if (r == null || r.sprite == null) + continue; + + var b = r.bounds; + var c = b.center; + var e = b.extents; + + var p1 = parent.InverseTransformPoint(new Vector3(c.x - e.x, c.y - e.y, c.z)); + var p2 = parent.InverseTransformPoint(new Vector3(c.x - e.x, c.y + e.y, c.z)); + var p3 = parent.InverseTransformPoint(new Vector3(c.x + e.x, c.y - e.y, c.z)); + var p4 = parent.InverseTransformPoint(new Vector3(c.x + e.x, c.y + e.y, c.z)); + + if (first) + { + bounds = new Bounds(p1, Vector3.zero); + first = false; + } + + bounds.Encapsulate(p1); + bounds.Encapsulate(p2); + bounds.Encapsulate(p3); + bounds.Encapsulate(p4); + } + + return bounds; + } + + /// + /// Begins/opens the custom player menu. After registering panels, it will prepare the search, pages, and open the menu. + /// + /// Function where all panels should be registered. + protected void Begin(Action registerEntryPanels) + { + MinigameStubs.Begin(Component, null); + + searchText = string.Empty; + currentPage = 0; + + registerEntryPanels(); + + EnsureSearchUi(); + + var list2 = ShowPage(); + + ControllerManager.Instance.OpenOverlayMenu(name, backButton, defaultButtonSelected, list2); + } +} diff --git a/MiraAPI/Hud/CustomPhoneMenu.cs b/MiraAPI/Hud/CustomPhoneMenu.cs new file mode 100644 index 0000000..38edd89 --- /dev/null +++ b/MiraAPI/Hud/CustomPhoneMenu.cs @@ -0,0 +1,335 @@ +using AmongUs.GameOptions; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities.Attributes; +using Reactor.Utilities.Extensions; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.Hud; + +/// +/// Component registered in Il2Cpp domain to handle Unity events for custom menus. +/// +[RegisterInIl2Cpp] +public class CustomPhoneMenuComponent(IntPtr cppPtr) : Minigame(cppPtr) +{ + [SuppressMessage("Style", "IDE0051:Remove unused private members", Justification = "Unity convention.")] + private void OnDisable() + { + if (ControllerManager.Instance) + { + ControllerManager.Instance.CloseOverlayMenu(name); + } + } + + [Obsolete("Should not be called, see the respective Begin methods instead.", true)] + [SuppressMessage("Info Code Smell", "S1133:Deprecated code should be removed", Justification = "Retained.")] +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member (Justification: Intentional to avoid mods calling Begin when they are not supposed to.) +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member (Justification: Base game code.) + public override void Begin(PlayerTask task) +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member +#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member + { + throw new NotSupportedException("Should not be called, see the respective Begin methods instead."); + } +} + +/// +/// Defines an entry in a with a Panel. +/// +public interface IMenuEntry +{ + /// + /// Gets the base Panel of the menu entry. + /// + ShapeshifterPanel Panel { get; } +} + +/// +/// Defines a custom menu with a list of entries. +/// +public interface ICustomMenu +{ + /// + /// Gets all registered s. + /// + List MenuEntries { get; } +} + +/// +/// Defines a custom menu with a list of only entries. +/// +/// Only implement this if you want to define a single new type of menu entries explicitly. +/// Must reference the member of the superclass that is being hidden by this one to work. +/// +/// The type of menu entry. +public interface ICustomMenu : ICustomMenu where TMenu : IMenuEntry +{ + /// + /// Gets all registered s. + /// + new List MenuEntries { get; } +} + +/// +/// Custom Phone Menu logic using the as a base. +/// +[SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "Unity Convention.")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Read above.")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Read above.")] +[SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Proxy for Unity properties.")] +public abstract class CustomPhoneMenu : ICustomMenu +{ + /// + /// Menu Entry used when specifically only the Panel itself is required. + /// + /// The instance. + protected sealed record class BasicEntry(ShapeshifterPanel Panel) : IMenuEntry + { + /// + /// Implicitly converts to . + /// + /// The entry that contains the reference to the panel. + public static implicit operator ShapeshifterPanel(BasicEntry entry) + { + return entry.Panel; + } + + /// + /// Implicitly converts to an instance of . + /// + /// The panel instance to make an entry off of. + public static implicit operator BasicEntry(ShapeshifterPanel panel) + { + return new(panel); + } + } + + /// + /// A delegate that is invoked when the mouse interacts with a panel. + /// + /// The panel's highlight that the delegate modifies. + /// The panel's icon that the delegate modifies. + /// A flag that indicates if the current panel is selected. + public delegate void PanelButtonOnMouse(SpriteRenderer highlight, SpriteRenderer icon, bool isSelected); + + /// + /// Gets the wrapped component that the menu modifies. + /// + public CustomPhoneMenuComponent Component { get; internal set; } = null!; + + /// + public List MenuEntries { get; protected set; } = []; + + /// + /// Gets all of the panels of the menu. + /// + public List EntryPanels => [.. MenuEntries.Select(e => e.Panel)]; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member (Justification: Unity convention.) +#pragma warning disable S1104 // Fields should not have public accessibility (Justification: Read above.) + public Transform transform => Component.transform; + public GameObject gameObject => Component.gameObject; + public string name => Component.name; + + public float xStart = -0.8f; + public float yStart = 2.15f; + public float xOffset = 1.95f; + public float yOffset = -0.65f; + + public ShapeshifterPanel panelPrefab; + public UiElement backButton; + public UiElement defaultButtonSelected; + + protected PanelButtonOnMouse? onMouseOverAction; + protected PanelButtonOnMouse? onMouseOutAction; +#pragma warning restore S1104 // Fields should not have public accessibility +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Gets the transform of the menu's Phone UI. + /// + public Transform PhoneUI => transform.FindChild("PhoneUI"); + + /// + /// Gets the z-axis depth that the menu is instantiated at. + /// + protected virtual float MenuDepth => -50f; + + /// + /// Closes the menu. + /// + public void Close() + { + Component.Close(); + } + + /// + /// Creates a . + /// + /// The type of . + /// Function that can optionally be run when the mouse is moved outside a menu panel. + /// Function that can optionally be run when the mouse is moved over a menu panel. + /// New object. + protected static TMenu Create(PanelButtonOnMouse? onMouseOut = null, PanelButtonOnMouse? onMouseOver = null) where TMenu : CustomPhoneMenu, new() + { + var shapeShifterRole = RoleManager.Instance.GetRole(RoleTypes.Shapeshifter); + + var ogMenu = shapeShifterRole.TryCast()!.ShapeshifterMenu; + var newMenu = Object.Instantiate(ogMenu); + var component = newMenu.gameObject.AddComponent(); + var customMenu = new TMenu + { + Component = component, + panelPrefab = newMenu.PanelPrefab, + xStart = newMenu.XStart, + yStart = newMenu.YStart, + xOffset = newMenu.XOffset, + yOffset = newMenu.YOffset, + defaultButtonSelected = newMenu.DefaultButtonSelected, + backButton = newMenu.BackButton, + }; + + var back = customMenu.backButton.GetComponent(); + back.OnClick.RemoveAllListeners(); + back.OnClick.AddListener((UnityAction)customMenu.Close); + + component.CloseSound = newMenu.CloseSound; + component.logger = newMenu.logger; + component.OpenSound = newMenu.OpenSound; + + newMenu.DestroyImmediate(); + + customMenu.transform.SetParent(Camera.main!.transform, false); + customMenu.transform.localPosition = new Vector3(0f, 0f, customMenu.MenuDepth); + + customMenu.onMouseOverAction = onMouseOver; + customMenu.onMouseOutAction = onMouseOut; + + return customMenu; + } + + /// + /// Register new menu panels given a set of entries. + /// + /// The type of the entry. + /// The entries to create panels for. + /// Function to configure the menu's panel once created, given its index and entry. + /// Function to create a for a given panel and it's entry. + /// If , it will create s, acting as wrappers for the s. + protected void RegisterPanels( + IEnumerable entries, + Action entryPanelConfig, + Func? menuEntryMaker = null) + { + int currentEntries = MenuEntries.Count; + + var list = entries.ToList(); + + for (var i = 0; i < list.Count; i++) + { + var index = currentEntries + i; + var entry = list[i]; + + var shapeshifterPanel = Object.Instantiate(panelPrefab, transform); + shapeshifterPanel.transform.localPosition = new Vector3(0f, 0f, -1f); + entryPanelConfig(shapeshifterPanel, index, entry); + + menuEntryMaker ??= (s, _) => new BasicEntry(s); + var menuEntry = menuEntryMaker(shapeshifterPanel, entry); + MenuEntries.Add(menuEntry); + + var button = shapeshifterPanel.Button; + var nameplate = shapeshifterPanel.gameObject.transform.FindChild("Nameplate"); + var highlight = nameplate.FindChild("Highlight").GetComponent(); + var icon = highlight.transform.GetChild(0).GetComponent(); + + if (onMouseOverAction != null) + { + button.OnMouseOver.RemoveAllListeners(); + button.OnMouseOver = new UnityEvent(); + button.OnMouseOver.AddListener((UnityAction) + (() => onMouseOverAction(highlight, icon, IsEntrySelected(menuEntry)))); + } + if (onMouseOutAction != null) + { + button.OnMouseOut.RemoveAllListeners(); + button.OnMouseOut = new UnityEvent(); + button.OnMouseOut.AddListener((UnityAction) + (() => onMouseOutAction(highlight, icon, IsEntrySelected(menuEntry)))); + } + } + } + + /// + /// + /// Action to perform when a given entry is clicked on. + /// Argument will be if the back button was clicked instead. + /// Function to configure the menu's panel once created, given its index, entry, and onClick action. + /// + protected void RegisterPanels( + IEnumerable entries, + Action onEntryClick, + Action entryPanelActionConfig, + Func? menuEntryMaker = null) + { + RegisterPanels(entries, (p, i, e) => entryPanelActionConfig(p, i, e, () => onEntryClick(e)), menuEntryMaker); + } + + /// + /// Determines if a given entry is currently selected, not just hovered over. + /// + /// The to check. + /// if the is selected, else . + protected virtual bool IsEntrySelected(IMenuEntry entry) + { + return false; + } + + /// + /// Set the icon, over color, and unselected color for a given entry. + /// + /// The menu entry. + /// The to use as the icon, if any. + /// The to use when hovering over an entry. + /// The to use when not hovering over an entry. + protected static void SetNameplateAppearance( + IMenuEntry menuEntry, LoadableAsset? sprite, Color? overColor, Color? unselectedColor) + { + ShapeshifterPanel panel = menuEntry.Panel; + + var nameplate = panel.gameObject.transform.FindChild("Nameplate"); + var highlight = nameplate.FindChild("Highlight").GetComponent(); + var icon = highlight.transform.GetChild(0).GetComponent(); + if (sprite != null) + { + icon.sprite = sprite.LoadAsset(); + } + var button = nameplate.GetComponent(); + if (overColor is { } oColor) + { + button.OverColor = oColor; + } + if (unselectedColor is { } uColor) + { + button.UnselectedColor = uColor; + } + } +} + +/// +/// The type of menu entries. +public abstract class CustomPhoneMenu : CustomPhoneMenu, ICustomMenu where TMenu : IMenuEntry +{ + /// + public new List MenuEntries + { + get => [.. base.MenuEntries.Cast()]; + protected set => base.MenuEntries = [.. value.Cast()]; + } +} diff --git a/MiraAPI/Hud/CustomPlayerMenu.cs b/MiraAPI/Hud/CustomPlayerMenu.cs index 8fc248d..a0b57a2 100644 --- a/MiraAPI/Hud/CustomPlayerMenu.cs +++ b/MiraAPI/Hud/CustomPlayerMenu.cs @@ -2,36 +2,22 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using AmongUs.GameOptions; -using Il2CppInterop.Runtime.Attributes; -using MiraAPI.Patches.Stubs; -using Reactor.Utilities.Attributes; -using Reactor.Utilities.Extensions; using UnityEngine; -using UnityEngine.Events; namespace MiraAPI.Hud; /// /// Custom Player Menu using the as a base. /// -/// Used by Il2Cpp. Do not use constructor, this is a . -[RegisterInIl2Cpp] [SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "Unity Convention.")] -[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Unity Convention.")] -[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Unity Convention.")] -[SuppressMessage("Style", "IDE0051:Remove unused private members", Justification = "Unity convention.")] -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member (Justification: Unity fields to mirror base game ShapeshifterMinigame; not worth documenting) -public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Read above.")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Read above.")] +public class CustomPlayerMenu : CustomMultiSelectMenu { - public ShapeshifterPanel panelPrefab; - public float xStart = -0.8f; - public float yStart = 2.15f; - public float xOffset = 1.95f; - public float yOffset = -0.65f; - public UiElement backButton; - public UiElement defaultButtonSelected; + [SuppressMessage("Minor Code Smell", "S1104:Fields should not have public accessibility", Justification = "Read above.")] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member public List potentialVictims; +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member /// /// Creates a . @@ -39,43 +25,7 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) /// New object. public static CustomPlayerMenu Create() { - var shapeShifterRole = RoleManager.Instance.GetRole(RoleTypes.Shapeshifter); - - var ogMenu = shapeShifterRole.TryCast()!.ShapeshifterMenu; - var newMenu = Instantiate(ogMenu); - var customMenu = newMenu.gameObject.AddComponent(); - - customMenu.panelPrefab = newMenu.PanelPrefab; - customMenu.xStart = newMenu.XStart; - customMenu.yStart = newMenu.YStart; - customMenu.xOffset = newMenu.XOffset; - customMenu.yOffset = newMenu.YOffset; - customMenu.backButton = newMenu.BackButton; - - var back = customMenu.backButton.GetComponent(); - back.OnClick.RemoveAllListeners(); - back.OnClick.AddListener((UnityAction)Instance.Close); - - customMenu.CloseSound = newMenu.CloseSound; - customMenu.logger = newMenu.logger; - customMenu.OpenSound = newMenu.OpenSound; - - newMenu.DestroyImmediate(); - - customMenu.transform.SetParent(Camera.main!.transform, false); - customMenu.transform.localPosition = new Vector3(0f, 0f, -50f); - return customMenu; - } - - private void OnDisable() - { - ControllerManager.Instance.CloseOverlayMenu(name); - } - - /// - public override void Begin(PlayerTask task) - { - throw new NotSupportedException("Use the other Begin method."); + return Create(); } /// @@ -83,36 +33,31 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) /// /// Function to determine if player should show in the custom menu. /// action for player. - [HideFromIl2Cpp] - public void Begin(Func playerMatch, Action onClick) + /// Whether the set of both selections should be confirmed manually. + public void Begin(Func playerMatch, Action onClick, bool shouldConfirm = false) { - MinigameStubs.Begin(this, null); + Begin(PlayerControl.AllPlayerControls.ToArray().Where(playerMatch), onClick, shouldConfirm); + potentialVictims = EntryPanels; + } - var back = backButton.GetComponent(); - back.OnClick.AddListener((UnityAction)(() => - { - onClick(null); - })); + /// + /// Begins/opens the custom player menu. + /// + /// Function to determine if player should show in the custom menu. + /// action for the two-player selection. + /// Whether the set of both selections should be confirmed manually. + /// If the same entry can be selected both times, else unselect entry on click. + public void Begin(Func playerMatch, Action onClick, bool shouldConfirm = false, bool canRepeat = false) + { + Begin(PlayerControl.AllPlayerControls.ToArray().Where(playerMatch), onClick, shouldConfirm, canRepeat); + potentialVictims = EntryPanels; + } - DebugAnalytics.Instance.Analytics.MinigameOpened(PlayerControl.LocalPlayer.Data, TaskType); - var list = PlayerControl.AllPlayerControls.ToArray().Where(playerMatch).ToList(); - potentialVictims = []; - var list2 = new CppCollections.List(); - - for (var i = 0; i < list.Count; i++) - { - var player = list[i]; - var num = i % 3; - var num2 = i / 3; - var flag = PlayerControl.LocalPlayer.Data.Role.NameColor == player.Data.Role.NameColor; - var shapeshifterPanel = Instantiate(panelPrefab, transform); - shapeshifterPanel.transform.localPosition = new Vector3(xStart + num * xOffset, yStart + num2 * yOffset, -1f); - shapeshifterPanel.SetPlayer(i, player.Data, (Il2CppSystem.Action)(() => onClick(player))); - shapeshifterPanel.NameText.color = flag ? player.Data.Role.NameColor : Color.white; - potentialVictims.Add(shapeshifterPanel); - list2.Add(shapeshifterPanel.Button); - } - - ControllerManager.Instance.OpenOverlayMenu(name, backButton, defaultButtonSelected, list2); + /// + protected override void SetupPanelEntry(ShapeshifterPanel panel, int i, PlayerControl entry, Action onClick) + { + var flag = PlayerControl.LocalPlayer.Data.Role.NameColor == entry.Data.Role.NameColor; + panel.SetPlayer(i, entry.Data, (Il2CppSystem.Action)onClick); + panel.NameText.color = flag ? entry.Data.Role.NameColor : Color.white; } } diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs index 555ab55..84defb8 100644 --- a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs @@ -176,7 +176,7 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) _pagination.gameObject.SetActive(true); if (_pageText != null) { - _pageText.text = $"Page {_currentPage + 1}/{Mathf.Max(totalPages, 1)}"; + _pageText.text = $"{MiraLocaleManager.Get("MiraApi.ModifierDisplay.Page", "Page")} {_currentPage + 1}/{Mathf.Max(totalPages, 1)}"; } _nextButton.gameObject.SetActive(true); @@ -213,7 +213,7 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) _modifiers.Remove(mod.Key); } - _toggleBtnText.text = $"Modifiers ({filteredModifiers.Count})"; + _toggleBtnText.text = $"{MiraLocaleManager.Get("Modifiers", "Modifiers")} ({filteredModifiers.Count})"; _toggleButton.gameObject.SetActive(filteredModifiers.Count != 0); diff --git a/MiraAPI/Patches/Options/MenuState.cs b/MiraAPI/Patches/Options/MenuState.cs index 611724d..d76ce13 100644 --- a/MiraAPI/Patches/Options/MenuState.cs +++ b/MiraAPI/Patches/Options/MenuState.cs @@ -262,7 +262,7 @@ public class MenuState(IntPtr cppPtr) : MonoBehaviour(cppPtr) var smC1Btn = Instantiate(smModBtn, smModBtn.transform.parent); _smallButtons[MenuButton.CustomOne] = smC1Btn; smC1Btn.name = "SmallCustomOneButton"; - smC1Btn.buttonText.text = "Custom 1"; + smC1Btn.buttonText.text = "MiraApi.CustomCategory1".Translate(); smC1Btn.OnClick = new Button.ButtonClickedEvent(); smC1Btn.OnClick.AddListener( (UnityAction)(() => @@ -280,7 +280,7 @@ public class MenuState(IntPtr cppPtr) : MonoBehaviour(cppPtr) var smC2Btn = Instantiate(smModBtn, smModBtn.transform.parent); _smallButtons[MenuButton.CustomTwo] = smC2Btn; smC2Btn.name = "SmallCustomTwoButton"; - smC2Btn.buttonText.text = "Custom 2"; + smC2Btn.buttonText.text = "MiraApi.CustomCategory2".Translate(); smC2Btn.OnClick = new Button.ButtonClickedEvent(); smC2Btn.OnClick.AddListener( (UnityAction)(() => @@ -382,15 +382,16 @@ public class MenuState(IntPtr cppPtr) : MonoBehaviour(cppPtr) private void UpdateUi() { + var pageText = "MiraApi.Page".Translate(); // Update mod text if (CurrentModIdx == 0) { - _text.text = $"(Page 1/{ModCount + 1})\nMain"; + _text.text = $"({pageText} 1/{ModCount + 1})\n{"MiraApi.Main".Translate()}"; } else { var modName = CurrentMod.MiraPlugin.OptionsTitleText.Translate(); - _text.text = $"(Page {CurrentModIdx + 1}/{ModCount + 1})\n" + + _text.text = $"({pageText} {CurrentModIdx + 1}/{ModCount + 1})\n" + modName[..Math.Min(modName.Length, 25)]; } diff --git a/MiraAPI/Resources/Locale/en_US.xml b/MiraAPI/Resources/Locale/en_US.xml index a00952f..7cd2c1c 100644 --- a/MiraAPI/Resources/Locale/en_US.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -40,4 +40,7 @@ Outlive the timer or eliminate the crew. Neut Neutral Roles Other + Page + Page + Main