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 fb27f14..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;
-#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
-#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
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.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Unity Convention")]
-[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Unity Convention")]
-public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr)
+[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 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,45 +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 NotImplementedException("Use the other Begin method.");
+ return Create();
}
///
@@ -85,35 +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 Il2CppSystem.Collections.Generic.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;
}
}