Merge branch 'dev' into options-improvements

This commit is contained in:
AS authored and GitHub committed 2026-10-16 20:52:49 -04:00
commit 0f51eee1be
22 files changed
+1759 -91

No files matched your search

@@ -14,6 +14,7 @@ public class ExampleOptions : AbstractOptionGroup
{
public override string GroupName => "options.exampleOptions1";
public override Color GroupColor => Color.green;
public override bool ShowInMatchInfoGuide => true;
[ModdedToggleOption("options.exampleOptions1.toggleOpt1")]
[ModdedOptionVisibility] // Automatically resolves to using ToggleOpt1Visibility, can also target toggle options
@@ -9,6 +9,7 @@ public class ExampleOptions2 : AbstractOptionGroup
public override string GroupName => "options.exampleOptions2";
public override uint GroupPriority => 0; // This group will be displayed first. The default value is uint.MaxValue.
public override MenuCategory ParentMenu => MenuCategory.CustomTwo;
public override bool ShowInMatchInfoGuide => true;
public ModdedPlayerOption PlayerOption2 { get; } = new("options.exampleOptions2.youMustChoseAPlayer", false);
public ModdedPlayerOption PlayerOption { get; } = new("options.exampleOptions2.ehWhatever");
+4 -6
View File
@@ -4,6 +4,7 @@ using System.Linq;
using HarmonyLib;
using MiraAPI.GameOptions;
using MiraAPI.Networking;
using MiraAPI.Patches;
using MiraAPI.Patches.GameModes;
using MiraAPI.Patches.Options;
using MiraAPI.Translation;
@@ -128,6 +129,9 @@ public static class GameModeOption
MenuState.Instance.QueuedRoleMenuRefresh[roleMenu.Key] = true;
}
}
GameOptionsMenuPatch.RefreshForGameMode();
RoleGuidePatches.RefreshSettingsForGameMode();
}
// TODO: could make Values a dict of AbstractGameMode too
@@ -143,12 +147,6 @@ public static class GameModeOption
Info($"Game mode changed to {option.GetInt()}");
RpcSyncGamemode(PlayerControl.LocalPlayer, option.GetInt());
if (GameSettingMenu.Instance && CustomGameModeManager.ActiveMode != null)
{
GameOptionsMenuPatch.ToggleGamemodeOptions(CustomGameModeManager.ActiveMode, __instance);
GameSettingMenu.Instance.RoleSettingsButton.gameObject.SetActive(CustomGameModeManager.ActiveMode.ShowNormalRoleSettings);
}
return false;
}
@@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Il2CppInterop.Runtime.Attributes;
using MiraAPI.Modifiers;
using MiraAPI.Roles;
using MiraAPI.Utilities.Assets;
using UnityEngine;
namespace MiraAPI.GameOptions;
@@ -26,6 +28,12 @@ public abstract class AbstractOptionGroup
/// </summary>
public abstract string GroupName { get; }
/// <summary>
/// Gets the sprite of the group that is displayed in the match info guide.
/// </summary>
[HideFromIl2Cpp]
public virtual LoadableAsset<Sprite> Sprite => MiraAssets.IconSettings;
/// <summary>
/// Gets the Optionable type of the group.
/// </summary>
@@ -49,6 +57,42 @@ public abstract class AbstractOptionGroup
/// </summary>
public virtual Func<bool> GroupVisible => () => true;
/// <summary>
/// Gets a value indicating whether this group's options are shown in the Match Info Guide.
/// </summary>
public virtual bool ShowInMatchInfoGuide => ParentMenu is MenuCategory.Game;
/// <summary>
/// Gets the name title of the group when it is in the match info guide.
/// </summary>
public virtual string RoleGuideTitle => GroupName;
/// <summary>
/// Gets the key used to combine this group with other option groups on one Match Info Guide page.
/// Groups are combined only with groups from the same plugin that use the same non-empty key.
/// </summary>
public virtual string? MatchInfoGuidePageKey => null;
/// <summary>
/// Gets the title of the Match Info Guide page. For groups sharing a page key, the first
/// group in menu order supplies the page title.
/// </summary>
public virtual string MatchInfoGuidePageTitle => RoleGuideTitle;
/// <summary>
/// Gets the ordering priority of this Match Info Guide page. Lower values appear first
/// among pages with the same menu category and group priority. Groups sharing a page key
/// use the lowest page-order value among those groups.
/// </summary>
public virtual int MatchInfoGuidePageOrder => int.MaxValue;
/// <summary>
/// Gets the number of columns used to lay out options on this Match Info Guide page.
/// For a combined page, groups are placed into these columns. Values are limited to 1-6;
/// for groups sharing a page key, the first group in menu order supplies this value.
/// </summary>
public virtual int MatchInfoGuidePageColumns => 2;
/// <summary>
/// Gets the group <see cref="Color"/>. This is used to color the group in the options menu.
/// </summary>
+10
View File
@@ -69,6 +69,10 @@ public class MiraApiSettings(ConfigFile config) : LocalSettingsTab(config)
OldUiButtonScaleFactor = TopRightButtonsFactorSlider.Value;
}
else if (configEntry == MatchInfoGuideScaleFactor && MatchInfoGuide.Instance)
{
RoleGuidePatches.ApplyGuideScale(MatchInfoGuide.Instance, MatchInfoGuideScaleFactor.Value);
}
else if (configEntry == WikiOnBottomRow)
{
SetUpButtonPositions();
@@ -198,6 +202,12 @@ public class MiraApiSettings(ConfigFile config) : LocalSettingsTab(config)
[LocalSliderSetting(min: 0.3f, max: 2f, suffixType: MiraNumberSuffixes.Multiplier, formatString: "0.00", displayValue: true)]
public ConfigEntry<float> TopRightButtonsFactorSlider { get; private set; } = config.Bind("MiraApi.VisualsUi", "MiraApi.TopRightButtonScaleFactor", 1f);
/// <summary>
/// Gets the scale of the match info guide.
/// </summary>
[LocalSliderSetting(min: 0.5f, max: 1f, suffixType: MiraNumberSuffixes.Multiplier, formatString: "0.00", displayValue: true)]
public ConfigEntry<float> MatchInfoGuideScaleFactor { get; private set; } = config.Bind("MiraApi.VisualsUi", "MiraApi.MatchInfoGuideScaleFactor", 1f);
/// <summary>
/// Gets the fps specified by the player.
/// </summary>
@@ -38,6 +38,7 @@ internal static class GameOptionsMenuPatch
[HarmonyPatch(nameof(GameOptionsMenu.Initialize))]
public static bool InitPatch(GameOptionsMenu __instance)
{
Helpers.CacheAdvancedSettingsRowPrefab(__instance.numberOptionOrigin);
__instance.Children ??= new();
__instance.Children.Clear();
@@ -187,6 +188,66 @@ internal static class GameOptionsMenuPatch
instance.scrollBar.SetYBoundsMax(-num - 1.65f);
}
internal static void RefreshForGameMode()
{
if (CustomGameModeManager.ActiveMode == null ||
!GameSettingMenu.Instance ||
!GameSettingMenu.Instance.GameSettingsTab ||
!MenuState.Instance ||
MenuState.Instance.CurrentModIdx != 0 ||
GameModeGroups.Count == 0)
{
return;
}
ToggleGamemodeOptions(CustomGameModeManager.ActiveMode, GameSettingMenu.Instance.GameSettingsTab);
GameSettingMenu.Instance.RoleSettingsButton.gameObject.SetActive(
CustomGameModeManager.ActiveMode.ShowNormalRoleSettings);
}
internal static List<KeyValuePair<string, string>> GetActiveGameSettingsRows(Func<bool, string> formatBool)
{
var menu = GameSettingMenu.Instance ? GameSettingMenu.Instance.GameSettingsTab : null;
if (!menu || menu.Children == null)
{
return [];
}
var rows = new List<KeyValuePair<string, string>>();
foreach (var option in menu.Children)
{
if (!option || !option!.gameObject.activeInHierarchy ||
option == menu.MapPicker ||
(GameModeOption.OptionBehaviour && option == GameModeOption.OptionBehaviour) ||
!option.Data)
{
continue;
}
var title = option switch
{
ToggleOption toggle => toggle.TitleText.text,
NumberOption number => number.TitleText.text,
StringOption str => str.TitleText.text,
PlayerOption player => player.TitleText.text,
_ => TranslationController.Instance.GetString(option.Data.Title),
};
var value = option switch
{
ToggleOption toggle => formatBool(toggle.GetBool()),
NumberOption number => number.ValueText.text,
StringOption str => str.ValueText.text,
PlayerOption player => player.ValueText.text,
_ => option.Data.GetValueString(option.GetFloat()),
};
rows.Add(new KeyValuePair<string, string>(title, value));
}
return rows;
}
private static void CreateSettings(GameOptionsMenu instance, Transform container)
{
var num = 0.713f;
@@ -1,4 +1,5 @@
using HarmonyLib;
using MiraAPI.Utilities;
namespace MiraAPI.Patches.Options;
@@ -13,6 +14,11 @@ internal static class GameSettingMenuPatches
[HarmonyPatch(nameof(GameSettingMenu.OnEnable))]
public static void OnEnablePrefix(GameSettingMenu __instance)
{
if (__instance.GameSettingsTab && __instance.GameSettingsTab.numberOptionOrigin)
{
Helpers.CacheAdvancedSettingsRowPrefab(__instance.GameSettingsTab.numberOptionOrigin);
}
// Add Menu State object
if (!__instance.gameObject.GetComponent<MenuState>())
{
File diff suppressed because it is too large. Load diff
+6
View File
@@ -8,6 +8,7 @@
<string name="MiraApi.ShowKeybindsOnButtons">Show Keybinds on Buttons</string>
<string name="MiraApi.ButtonScaleFactor">Button Scale Factor</string>
<string name="MiraApi.TopRightButtonScaleFactor">Top-Right UI Scale Factor</string>
<string name="MiraApi.MatchInfoGuideScaleFactor">Match Info Guide Scale Factor</string>
<string name="MiraApi.MaxFps">Max FPS</string>
<string name="MiraApi.WikiOnBottomRow">Display Wiki on Bottom Row</string>
<string name="MiraApi.EnableSabotageFlashes">Enable Sabotage Flashes</string>
@@ -60,4 +61,9 @@ Outlive the timer or eliminate the crew.</string>
<string name="MiraApi.Wiki.FilterLabel">Filter</string>
<string name="MiraApi.Wiki.GroupingLabel">Grouping</string>
<string name="MiraApi.Wiki.OrderLabel">Order</string>
<string name="MiraApi.Wiki.VanillaSettings">Vanilla</string>
<string name="MiraApi.Wiki.SearchResults">Search Results</string>
<string name="MiraApi.Wiki.PreviousPage">Previous</string>
<string name="MiraApi.Wiki.NextPage">Next</string>
<string name="MiraApi.Wiki.NoResultsFound">No Results Found</string>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+35
View File
@@ -205,6 +205,21 @@ public static class MiraAssets
/// </summary>
public static LoadableResourceAsset WikiRolesButtonOpenSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarRolesButtonOpen.png");
/// <summary>
/// Gets the sprite used for the modifiers button while hovering.
/// </summary>
public static LoadableResourceAsset WikiModifiersButtonHoverSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarModifiersButtonHover.png");
/// <summary>
/// Gets the sprite used for the modifiers button while hovering.
/// </summary>
public static LoadableResourceAsset WikiModifiersButtonIdleSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarModifiersButtonIdle.png");
/// <summary>
/// Gets the sprite used for the modifiers button while hovering.
/// </summary>
public static LoadableResourceAsset WikiModifiersButtonOpenSprite { get; } = new("MiraAPI.Resources.Wiki.SidebarModifiersButtonOpen.png");
/// <summary>
/// Gets the sprite used for the roles button while hovering.
/// </summary>
@@ -335,5 +350,25 @@ public static class MiraAssets
/// </summary>
public static LoadableResourceAsset IconTerminology { get; } = new("MiraAPI.Resources.Wiki.IconTerminology.png");
/// <summary>
/// Gets the Page switching sprite.
/// </summary>
public static LoadableResourceAsset PageLeftHover { get; } = new("MiraAPI.Resources.Wiki.PageLeftHover.png");
/// <summary>
/// Gets the Page switching sprite.
/// </summary>
public static LoadableResourceAsset PageLeftIdle { get; } = new("MiraAPI.Resources.Wiki.PageLeftIdle.png");
/// <summary>
/// Gets the Page switching sprite.
/// </summary>
public static LoadableResourceAsset PageRightHover { get; } = new("MiraAPI.Resources.Wiki.PageRightHover.png");
/// <summary>
/// Gets the Page switching sprite.
/// </summary>
public static LoadableResourceAsset PageRightIdle { get; } = new("MiraAPI.Resources.Wiki.PageRightIdle.png");
internal static LoadableResourceAsset BlankSprite { get; } = new("MiraAPI.Resources.BlankSprite.png");
}
+344
View File
@@ -0,0 +1,344 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using MiraAPI.GameModes;
using MiraAPI.GameOptions;
using MiraAPI.GameOptions.OptionTypes;
using MiraAPI.PluginLoading;
using MiraAPI.Translation;
using MiraAPI.Utilities.Assets;
using Reactor.Utilities;
using UnityEngine;
namespace MiraAPI.Utilities;
internal sealed record GuideOptionEntry(
string Title,
string Value,
AbstractOptionGroup Group,
string GroupName,
string ModAbbreviation,
IReadOnlyList<string> ModSearchTerms);
internal sealed class GuideOptionPage(
AbstractOptionGroup group,
string title,
int order,
int menuOrder,
uint groupPriority,
int columns,
string modAbbreviation,
IReadOnlyList<string> modSearchTerms,
List<GuideOptionEntry> entries)
{
public AbstractOptionGroup Group { get; } = group;
public List<AbstractOptionGroup> Groups { get; } = [group];
public string Title { get; } = title;
public LoadableAsset<Sprite> Sprite { get; } = group.Sprite;
public int Order { get; set; } = order;
public int MenuOrder { get; set; } = menuOrder;
public uint GroupPriority { get; set; } = groupPriority;
public int Columns { get; } = columns;
public string ModAbbreviation { get; } = modAbbreviation;
public HashSet<string> ModSearchTerms { get; } = new(modSearchTerms, StringComparer.OrdinalIgnoreCase);
public List<GuideOptionEntry> Entries { get; } = entries;
}
internal static class GuideOptionPages
{
private static readonly Regex TrailingZeroRegex = new(@"\.0+(?!\d)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
public static List<GuideOptionPage> BuildPages(Func<bool, string> formatBool)
{
var pages = new List<GuideOptionPage>();
var combinedPages = new Dictionary<string, GuideOptionPage>(StringComparer.OrdinalIgnoreCase);
var mode = CustomGameModeManager.ActiveMode;
var groups = MiraPluginManager.Instance.PluginsWithOptionsOrGameModes
.SelectMany(plugin => plugin.InternalOptionGroups
.Where(group => group.ShowInMatchInfoGuide && IsSettingsGroup(group, mode) && SafeGroupVisible(group))
.Select(group => (
Plugin: plugin,
Group: group,
ModAbbreviation: plugin.MiraPlugin.GetAbbreviatedModName())))
.OrderBy(item => GetMenuOrder(item.Group.ParentMenu))
.ThenBy(item => item.Group.GroupPriority)
.ThenBy(item => item.Group.GroupName, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.ModAbbreviation, StringComparer.OrdinalIgnoreCase);
foreach (var item in groups)
{
var groupName = item.Group.GroupName.Translate();
var modSearchTerms = GetModSearchTerms(item.Plugin, item.Group);
var entries = new List<GuideOptionEntry>();
foreach (var option in item.Group.Options)
{
if (!SafeOptionVisible(option))
{
continue;
}
var entry = TryCreateEntry(
option,
item.Group,
groupName,
item.ModAbbreviation,
modSearchTerms,
formatBool);
if (entry != null)
{
entries.Add(entry);
}
}
if (entries.Count > 0)
{
var pageKey = item.Group.MatchInfoGuidePageKey?.Trim();
var combinedKey = string.IsNullOrEmpty(pageKey) ? null : $"{item.Plugin.PluginId}\0{pageKey}";
if (combinedKey != null && combinedPages.TryGetValue(combinedKey, out var combinedPage))
{
combinedPage.Groups.Add(item.Group);
combinedPage.Entries.AddRange(entries);
combinedPage.ModSearchTerms.UnionWith(modSearchTerms);
combinedPage.Order = Math.Min(combinedPage.Order, item.Group.MatchInfoGuidePageOrder);
combinedPage.MenuOrder = Math.Min(
combinedPage.MenuOrder,
GetMenuOrder(item.Group.ParentMenu));
combinedPage.GroupPriority = Math.Min(
combinedPage.GroupPriority,
item.Group.GroupPriority);
}
else
{
var page = new GuideOptionPage(
item.Group,
item.Group.MatchInfoGuidePageTitle,
item.Group.MatchInfoGuidePageOrder,
GetMenuOrder(item.Group.ParentMenu),
item.Group.GroupPriority,
Mathf.Clamp(item.Group.MatchInfoGuidePageColumns, 1, 6),
item.ModAbbreviation,
modSearchTerms,
entries);
pages.Add(page);
if (combinedKey != null)
{
combinedPages.Add(combinedKey, page);
}
}
}
}
return pages
.OrderBy(page => page.MenuOrder)
.ThenBy(page => page.GroupPriority)
.ThenBy(page => page.Order)
.ThenBy(page => page.Group.GroupName, StringComparer.OrdinalIgnoreCase)
.ThenBy(page => page.ModAbbreviation, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static int GetMenuOrder(MenuCategory menu)
{
return menu switch
{
MenuCategory.Game => 0,
MenuCategory.CustomOne => 1,
MenuCategory.CustomTwo => 2,
_ => int.MaxValue,
};
}
public static List<GuideOptionEntry> Search(IEnumerable<GuideOptionPage> pages, string query)
{
query = query.Trim();
if (query.Length == 0)
{
return [];
}
const StringComparison cmp = StringComparison.OrdinalIgnoreCase;
var pageList = pages.ToList();
var normalizedQuery = NormalizeSearchText(query);
var matchingModPages = normalizedQuery.Length < 3
? Array.Empty<GuideOptionPage>()
: pageList
.Where(page => page.ModSearchTerms.Any(term =>
NormalizeSearchText(term).Contains(normalizedQuery, StringComparison.Ordinal)))
.ToArray();
if (matchingModPages.Length > 0)
{
return matchingModPages.SelectMany(page => page.Entries).ToList();
}
return pageList
.SelectMany(page => page.Entries
.Select(entry => (Entry: entry, Rank: Rank(entry, query, cmp)))
.Where(result => result.Rank < int.MaxValue)
.OrderBy(result => result.Rank)
.Select(result => result.Entry))
.ToList();
}
private static string[] GetModSearchTerms(MiraPluginInfo plugin, AbstractOptionGroup group)
{
var terms = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
plugin.PluginId,
plugin.PluginInfo.Metadata.Name,
plugin.MiraPlugin.OptionsTitleText,
plugin.MiraPlugin.ParentLocaleId,
plugin.MiraPlugin.GetAbbreviatedModName(),
};
AddNamespace(terms, plugin.MiraPlugin.GetType());
AddNamespace(terms, group.GetType());
foreach (var option in group.Options)
{
AddNamespace(terms, option.GetType());
}
return terms.Where(term => !string.IsNullOrWhiteSpace(term)).ToArray();
}
private static void AddNamespace(HashSet<string> terms, Type type)
{
if (!string.IsNullOrWhiteSpace(type.Namespace))
{
terms.Add(type.Namespace);
}
}
private static string NormalizeSearchText(string value)
{
return new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
}
private static int Rank(GuideOptionEntry entry, string query, StringComparison cmp)
{
if (entry.Title.Equals(query, cmp))
{
return 0;
}
if (entry.Title.Contains(query, cmp))
{
return 1;
}
if (entry.GroupName.Contains(query, cmp) || entry.ModAbbreviation.Contains(query, cmp))
{
return 2;
}
return entry.Value.Contains(query, cmp) ? 3 : int.MaxValue;
}
private static bool IsSettingsGroup(AbstractOptionGroup group, AbstractGameMode? mode)
{
if (group.ParentMenu is not (MenuCategory.Game or MenuCategory.CustomOne or MenuCategory.CustomTwo))
{
return false;
}
if (group.OptionableType == null)
{
return true;
}
return mode != null && typeof(AbstractGameMode).IsAssignableFrom(group.OptionableType) &&
mode.GetType().IsAssignableTo(group.OptionableType);
}
private static bool SafeGroupVisible(AbstractOptionGroup group)
{
try
{
return group.GroupVisible();
}
catch (Exception e)
{
Logger<MiraApiPlugin>.Warning($"GroupVisible threw for {group.GetType().Name}: {e.Message}");
return false;
}
}
private static bool SafeOptionVisible(IModdedOption option)
{
try
{
return option.Visible();
}
catch (Exception e)
{
Logger<MiraApiPlugin>.Warning($"Visible threw for option '{option.Title}': {e.Message}");
return false;
}
}
private static GuideOptionEntry? TryCreateEntry(
IModdedOption option,
AbstractOptionGroup group,
string groupName,
string modAbbreviation,
IReadOnlyList<string> modSearchTerms,
Func<bool, string> formatBool)
{
try
{
var title = TranslationController.Instance.GetString(option.StringName);
var value = FormatValue(option, formatBool);
return new GuideOptionEntry(title, value, group, groupName, modAbbreviation, modSearchTerms);
}
catch (Exception e)
{
Logger<MiraApiPlugin>.Warning($"Could not format option '{option.Title}' for the guide: {e.Message}");
return null;
}
}
private static string FormatValue(IModdedOption option, Func<bool, string> formatBool)
{
switch (option)
{
case ModdedToggleOption toggle:
return formatBool(toggle.Value);
case ModdedEnumOption enumOption:
return MiraLocaleManager.Get(
enumOption.Values[enumOption.Value],
enumOption.Values[enumOption.Value]);
case ModdedNumberOption number:
if (number is { NegativeWordValue: not "#", Value: -1 })
{
return number.NegativeWordValue;
}
if (number is { ZeroWordValue: not "#", Value: 0 })
{
return number.ZeroWordValue;
}
return TrailingZeroRegex.Replace(number.Data.GetValueString(number.Value), string.Empty);
default:
return option.Data.Type == global::OptionTypes.Checkbox
? formatBool(Math.Abs(option.GetFloatData() - 1f) < 0.0001f)
: option.Data.GetValueString(option.GetFloatData());
}
}
}
+445 -1
View File
@@ -8,6 +8,7 @@ using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MiraAPI.GameOptions;
using MiraAPI.GameOptions.OptionTypes;
@@ -25,12 +26,16 @@ using Random = UnityEngine.Random;
namespace MiraAPI.Utilities;
internal readonly record struct AdvancedSettingsRow(string Title, string Value, AbstractOptionGroup? Group);
/// <summary>
/// A class that contains helper methods.
/// </summary>
public static class Helpers
{
internal const float MatchInfoGuideBaseScale = 1.3f;
private static readonly Regex TrailingZeroRegex = new(@"\.0+(?!\d)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static NumberOption? _advancedSettingsRowPrefab;
/// <summary>
/// Gets all of the options stored within the provided type.
@@ -843,7 +848,8 @@ public static class Helpers
grid.localScale = new Vector3(1.3f, 1.3f, 1);
}
obj.transform.SetParent(parent.Inner.transform);
obj.transform.SetParent(parent.Inner.transform, false);
obj.transform.localScale = Vector3.one / MatchInfoGuideBaseScale;
obj.transform.localPosition = new Vector3(0f, 0.5f, 0f);
var scrollHeight = variableTextSizing
? desc.textBounds.size.y - 2.5f + maxTextSize + 0.475f
@@ -852,6 +858,444 @@ public static class Helpers
return obj;
}
/// <summary>
/// Caches a persistent clone of the vanilla settings row for use in the Match Info Guide.
/// </summary>
/// <param name="rowPrefab">The vanilla settings row prefab.</param>
internal static void CacheAdvancedSettingsRowPrefab(NumberOption? rowPrefab)
{
if (!rowPrefab)
{
foreach (var candidate in Resources.FindObjectsOfTypeAll(Il2CppType.Of<GameSettingMenu>()))
{
var menu = candidate.TryCast<GameSettingMenu>();
var settingsTab = menu ? menu!.GameSettingsTab : null;
if (settingsTab && settingsTab!.numberOptionOrigin)
{
rowPrefab = settingsTab.numberOptionOrigin;
break;
}
}
}
if (!rowPrefab)
{
foreach (var candidate in Resources.FindObjectsOfTypeAll(Il2CppType.Of<NumberOption>()))
{
var option = candidate.TryCast<NumberOption>();
if (!option)
{
continue;
}
if (option!.TitleText && option.ValueText && option.PlusBtn && option.MinusBtn)
{
rowPrefab = option;
break;
}
}
}
if (_advancedSettingsRowPrefab || !rowPrefab)
{
return;
}
var cachedPrefab = Object.Instantiate(rowPrefab!.gameObject);
cachedPrefab.name = "MiraAPIAdvancedSettingsRowPrefab";
cachedPrefab.transform.SetParent(null, false);
cachedPrefab.transform.localPosition = Vector3.zero;
cachedPrefab.transform.localRotation = Quaternion.identity;
cachedPrefab.SetActive(false);
Object.DontDestroyOnLoad(cachedPrefab);
_advancedSettingsRowPrefab = cachedPrefab.GetComponent<NumberOption>();
}
/// <summary>
/// Creates a page of vanilla settings rows inside a roles-style scroller.
/// </summary>
/// <param name="guide">The guide object.</param>
/// <param name="parent">The <see cref="Scroller"/> the page is parented to.</param>
/// <param name="settings">The rows to show: key is the label, value is the displayed value.</param>
/// <returns>The <see cref="GameObject"/> that holds all the rows. Destroy it to remove the page.</returns>
public static GameObject CreateAdvancedSettingsPage(
MatchInfoGuide guide,
Scroller parent,
IReadOnlyList<KeyValuePair<string, string>> settings)
{
return CreateAdvancedSettingsPage(
guide,
parent,
settings.Select(setting => new AdvancedSettingsRow(setting.Key, setting.Value, null)).ToList(),
2,
false);
}
internal static GameObject CreateAdvancedSettingsPage(
MatchInfoGuide guide,
Scroller parent,
List<AdvancedSettingsRow> settings,
int columns = 2,
bool groupsAsColumns = false)
{
const float contentTop = 0.95f;
const float horizontalOffset = -0.2f;
const float headerContentGap = 0.2f;
const int contentSortingOrder = 151;
columns = Mathf.Clamp(columns, 1, 6);
var inner = parent.Inner;
var layout = inner.GetComponent<GridLayoutGroup>();
if (layout)
{
layout.enabled = false;
}
var columnGap = layout ? Mathf.Clamp(layout.spacing.x, 0.1f, 0.6f) : 0.3f;
const int baseColumnCount = 2;
var baseColumnWidth = layout ? Mathf.Clamp(layout.cellSize.x, 2f, 3.2f) : 2.7f;
var availableWidth = baseColumnWidth * baseColumnCount + columnGap * (baseColumnCount - 1);
var columnWidth = Mathf.Clamp(
(availableWidth - (columns - 1) * columnGap) / columns,
1f,
3.2f);
var layer = inner.gameObject.layer;
var page = new GameObject("AdvancedSettingsPage") { layer = layer };
page.transform.SetParent(inner, false);
page.transform.localPosition = Vector3.zero;
page.SetActive(false);
var gameSettingsMenu = GameSettingMenu.Instance;
CacheAdvancedSettingsRowPrefab(gameSettingsMenu ? gameSettingsMenu.GameSettingsTab.numberOptionOrigin : null);
var rowPrefab = _advancedSettingsRowPrefab;
if (!rowPrefab)
{
Error("Could not render MiraAPI settings rows because the vanilla NumberOption prefab was not cached.");
return page;
}
var spriteBounds = rowPrefab!.GetComponentsInChildren<SpriteRenderer>(true)
.Where(renderer =>
renderer.enabled &&
renderer.sprite &&
renderer.transform != rowPrefab.PlusBtn.transform &&
!renderer.transform.IsChildOf(rowPrefab.PlusBtn.transform) &&
renderer.transform != rowPrefab.MinusBtn.transform &&
!renderer.transform.IsChildOf(rowPrefab.MinusBtn.transform))
.Select(renderer => renderer.bounds)
.ToArray();
if (spriteBounds.Length == 0)
{
Error("Could not render MiraAPI settings rows because the vanilla NumberOption prefab has no sprites.");
return page;
}
var prefabBounds = spriteBounds.Skip(1).Aggregate(
spriteBounds[0],
(bounds, next) =>
{
bounds.Encapsulate(next);
return bounds;
});
var rowScale = (columnWidth / prefabBounds.size.x) * 1.22f;
var rowHeight = prefabBounds.size.y * rowScale;
var rowPitch = 0.45f * rowScale;
var groupedSettings = groupsAsColumns
? settings
.Where(setting => setting.Group != null)
.GroupBy(setting => setting.Group!)
.ToList()
: [];
groupsAsColumns = groupedSettings.Count > 1;
if (groupsAsColumns)
{
rowScale = (columnWidth / prefabBounds.size.x) * (columns > 2 ? 1.15f : 1f);
rowHeight = prefabBounds.size.y * rowScale;
rowPitch = 0.45f * rowScale;
}
var groupColumns = new Dictionary<AbstractOptionGroup, int>();
var groupTopOffsets = new Dictionary<AbstractOptionGroup, float>();
var groupRows = new Dictionary<AbstractOptionGroup, int>();
if (groupsAsColumns)
{
var bandCount = Mathf.CeilToInt(groupedSettings.Count / (float)columns);
var bandHeights = new float[bandCount];
for (var i = 0; i < groupedSettings.Count; i++)
{
var group = groupedSettings[i].Key;
var band = i / columns;
groupColumns[group] = i % columns;
bandHeights[band] = Mathf.Max(
bandHeights[band],
rowPitch * (groupedSettings[i].Count() + 1) + headerContentGap + 0.25f);
}
var bandOffset = 0f;
for (var band = 0; band < bandCount; band++)
{
for (var i = band * columns; i < Mathf.Min((band + 1) * columns, groupedSettings.Count); i++)
{
groupTopOffsets[groupedSettings[i].Key] = bandOffset;
groupRows[groupedSettings[i].Key] = 0;
}
bandOffset += bandHeights[band];
}
}
var contentBottom = float.PositiveInfinity;
var previousGroup = (AbstractOptionGroup?)null;
var rowIndex = 0;
var column = 0;
var groupSpacing = 0f;
for (var i = 0; i < settings.Count; i++)
{
if (settings[i].Group != previousGroup && settings[i].Group is { } group)
{
if (groupsAsColumns)
{
column = groupColumns[group];
}
else
{
if (previousGroup != null)
{
groupSpacing += 0.2f;
}
if (column != 0)
{
rowIndex++;
column = 0;
}
}
var headerSource = group.Header;
if (!headerSource && gameSettingsMenu && gameSettingsMenu.GameSettingsTab)
{
headerSource = gameSettingsMenu.GameSettingsTab.categoryHeaderOrigin;
}
if (!headerSource)
{
foreach (var candidate in Resources.FindObjectsOfTypeAll(Il2CppType.Of<CategoryHeaderMasked>()))
{
var candidateHeader = candidate.TryCast<CategoryHeaderMasked>();
if (candidateHeader && candidateHeader!.Background && candidateHeader.Title)
{
headerSource = candidateHeader;
break;
}
}
}
if (headerSource)
{
const float headerScale = 0.46f;
var headerWidth = groupsAsColumns
? columnWidth / headerScale
: (columns * columnWidth + (columns - 1) * columnGap) / headerScale;
var header = Object.Instantiate(headerSource!, page.transform, false);
header.transform.localScale = Vector3.one * headerScale;
if (groupsAsColumns)
{
var totalWidth = columns * columnWidth + (columns - 1) * columnGap;
var groupColumn = groupColumns[group];
var groupLeft = -totalWidth / 2f + horizontalOffset +
groupColumn * (columnWidth + columnGap);
header.transform.localPosition = new Vector3(
groupLeft + (columnWidth / 2f) - 0.25f,
contentTop - groupTopOffsets[group],
-1f);
}
else
{
header.transform.localPosition = new Vector3(
horizontalOffset - 0.5f,
contentTop - (rowIndex * rowPitch) - groupSpacing,
-1f);
}
header.SetHeader(MiraLocaleManager.GetOrCreateLocaleString(group.GroupName), 20);
if (header.Title)
{
var titleTranslator = header.Title.GetComponent<TextTranslatorTMP>();
if (titleTranslator)
{
Object.Destroy(titleTranslator);
}
header.Title.text = group.GroupName.Translate();
header.Title.color = Color.white;
header.Title.horizontalAlignment = HorizontalAlignmentOptions.Left;
var titleTransform = header.Title.rectTransform;
var titlePosition = titleTransform.localPosition;
titlePosition.x = 0.5f;
titleTransform.localPosition = titlePosition;
titleTransform.SetSizeWithCurrentAnchors(
RectTransform.Axis.Horizontal,
headerWidth - 0.3f);
}
if (header.Background)
{
header.Background.sprite = MiraAssets.CategoryHeader.LoadAsset();
header.Background.drawMode = SpriteDrawMode.Sliced;
header.Background.color = group.GroupColor;
header.Background.transform.localPosition = new Vector3(0.5f, -0.1833f, 0f);
header.Background.size = new Vector2(headerWidth, header.Background.size.y);
}
if (header.Divider)
{
header.Divider.color = group.GroupColor;
}
header.gameObject.SetActive(true);
foreach (var renderer in header.GetComponentsInChildren<SpriteRenderer>(true))
{
renderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
renderer.material.SetInt(PlayerMaterial.MaskLayer, 50);
renderer.sortingOrder = contentSortingOrder;
if (renderer.enabled && renderer.sprite)
{
contentBottom = Mathf.Min(
contentBottom,
inner.InverseTransformPoint(renderer.bounds.min).y);
}
}
foreach (var text in header.GetComponentsInChildren<TextMeshPro>(true))
{
text.fontMaterial.SetFloat(ShaderID.StencilComp, 3f);
text.fontMaterial.SetFloat(ShaderID.Stencil, 50f);
text.renderer.sortingOrder = contentSortingOrder;
if (text.enabled)
{
text.ForceMeshUpdate();
contentBottom = Mathf.Min(
contentBottom,
inner.InverseTransformPoint(text.bounds.min).y);
}
}
if (!groupsAsColumns)
{
rowIndex++;
}
}
previousGroup = group;
}
var originX = -((columns * columnWidth) + ((columns - 1) * columnGap)) / 2f +
(column * (columnWidth + columnGap)) +
horizontalOffset;
var centerY = groupsAsColumns && settings[i].Group is { } rowGroup
? contentTop - groupTopOffsets[rowGroup] -
(rowPitch * (groupRows[rowGroup] + 1)) - headerContentGap - (rowHeight / 2f)
: contentTop - (rowHeight / 2f) - (rowIndex * rowPitch) - groupSpacing;
var row = Object.Instantiate(rowPrefab, page.transform);
row.enabled = false;
row.PlusBtn.gameObject.SetActive(false);
row.MinusBtn.gameObject.SetActive(false);
foreach (var button in row.GetComponentsInChildren<PassiveButton>(true))
{
button.enabled = false;
}
foreach (var collider in row.GetComponentsInChildren<Collider2D>(true))
{
collider.enabled = false;
}
row.transform.localScale = rowPrefab.transform.localScale * rowScale;
row.transform.localPosition = new Vector3(
originX + (columnWidth / 2f) - (prefabBounds.center.x * rowScale),
centerY - (prefabBounds.center.y * rowScale),
0f);
row.TitleText.text = settings[i].Title;
row.ValueText.text = settings[i].Value;
foreach (var text in row.GetComponentsInChildren<TextMeshPro>(true))
{
if (text.TryGetComponent<TextTranslatorTMP>(out var translator))
{
translator.enabled = false;
Object.Destroy(translator);
}
text.fontMaterial.SetFloat(ShaderID.StencilComp, 3f);
text.fontMaterial.SetFloat(ShaderID.Stencil, 50f);
text.renderer.sortingOrder = contentSortingOrder;
if (text.enabled)
{
text.ForceMeshUpdate();
}
}
foreach (var renderer in row.GetComponentsInChildren<SpriteRenderer>(true))
{
renderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
renderer.material.SetInt(PlayerMaterial.MaskLayer, 50);
renderer.sortingOrder = contentSortingOrder;
if (renderer.enabled &&
renderer.sprite &&
renderer.transform != row.PlusBtn.transform &&
!renderer.transform.IsChildOf(row.PlusBtn.transform) &&
renderer.transform != row.MinusBtn.transform &&
!renderer.transform.IsChildOf(row.MinusBtn.transform))
{
contentBottom = Mathf.Min(
contentBottom,
inner.InverseTransformPoint(renderer.bounds.min).y);
}
}
foreach (var text in row.GetComponentsInChildren<TextMeshPro>(true))
{
if (text.enabled)
{
contentBottom = Mathf.Min(
contentBottom,
inner.InverseTransformPoint(text.bounds.min).y);
}
}
row.gameObject.SetActive(true);
if (groupsAsColumns && settings[i].Group is { } renderedGroup)
{
groupRows[renderedGroup]++;
}
else
{
column++;
if (column == columns)
{
column = 0;
rowIndex++;
}
}
}
var maskArea = parent.transform.FindChild("MaskArea")?.GetComponent<SpriteRenderer>();
var maskBottom = maskArea
? inner.InverseTransformPoint(maskArea!.bounds.min).y
: -0.75f;
var contentOverflow = float.IsPositiveInfinity(contentBottom)
? 0f
: maskBottom - contentBottom;
var scrollRange = contentOverflow > 0.05f
? contentOverflow + 0.05f
: 0f;
parent.SetYBoundsMax(Mathf.Clamp(scrollRange, 0f, 999f));
page.SetActive(true);
return page;
}
private static void SetupAbilityPanel(MatchInfoRolePanel panel, AdvancedWikiAbilityDescription ability, bool variableTextSizing = false)
{
panel.roleCount.text = ability.AbilityType;