diff --git a/Directory.Build.props b/Directory.Build.props index f6b4be7..c8f52ba 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ true - 0.2.0 + 0.2.1 dev All Of Us, XtraCube, Angxl diff --git a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs index 3b7363a..4edd4f8 100644 --- a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs +++ b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs @@ -6,6 +6,7 @@ using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons.Freezer; @@ -19,6 +20,8 @@ public class FreezeButton : CustomActionButton public override int MaxUses => (int)OptionGroupSingleton.Instance.FreezeUses; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.T; + public override ModifierKey Modifier1 => ModifierKey.Control; protected override void OnClick() { diff --git a/MiraAPI.Example/Buttons/MeetingButton.cs b/MiraAPI.Example/Buttons/MeetingButton.cs index c4d59e3..c0acc20 100644 --- a/MiraAPI.Example/Buttons/MeetingButton.cs +++ b/MiraAPI.Example/Buttons/MeetingButton.cs @@ -2,6 +2,7 @@ using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons; @@ -15,6 +16,9 @@ public class MeetingButton : CustomActionButton public override int MaxUses => 3; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.P; + public override ModifierKey Modifier1 => ModifierKey.Control; + public override ModifierKey Modifier2 => ModifierKey.Shift; public override bool Enabled(RoleBehaviour? role) { diff --git a/MiraAPI.Example/Buttons/NeutralKillerButton.cs b/MiraAPI.Example/Buttons/NeutralKillerButton.cs index d38253a..02fc908 100644 --- a/MiraAPI.Example/Buttons/NeutralKillerButton.cs +++ b/MiraAPI.Example/Buttons/NeutralKillerButton.cs @@ -3,6 +3,7 @@ using MiraAPI.Example.Roles; using MiraAPI.GameEnd; using MiraAPI.Hud; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons; @@ -12,6 +13,7 @@ public class NeutralKillerButton : CustomActionButton public override string Name => "Win Game"; public override float Cooldown => 0f; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.K; protected override void OnClick() { CustomGameOver.Trigger([PlayerControl.LocalPlayer.Data]); diff --git a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs index 76a5b9e..c4af2ca 100644 --- a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs +++ b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs @@ -5,6 +5,7 @@ using MiraAPI.GameOptions; using MiraAPI.Hud; using MiraAPI.Utilities.Assets; using Reactor.Utilities; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons.Teleporter; @@ -21,7 +22,7 @@ public class TeleportButton : CustomActionButton public override LoadableAsset Sprite => ExampleAssets.TeleportButton; public override Color TextOutlineColor => new Color32(221, 176, 152, 255); public static bool IsZoom { get; private set; } - + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.M; public override bool Enabled(RoleBehaviour? role) { return role is TeleporterRole; diff --git a/MiraAPI.Example/ExampleColors.cs b/MiraAPI.Example/ExampleColors.cs index 6833d3c..d61eb49 100644 --- a/MiraAPI.Example/ExampleColors.cs +++ b/MiraAPI.Example/ExampleColors.cs @@ -6,7 +6,10 @@ namespace MiraAPI.Example; [RegisterCustomColors] public static class ExampleColors { - public static CustomColor Cerulean { get; } = new("Cerulean", new Color(0.0f, 0.48f, 0.65f)); + public static CustomColor Cerulean { get; } = new("Cerulean", new Color(0.0f, 0.48f, 0.65f)) + { + ColorBrightness = CustomColorBrightness.Lighter, + }; public static CustomColor Rose { get; } = new("Rose", new Color(0.98f, 0.26f, 0.62f)); diff --git a/MiraAPI.Example/Options/ExampleOptions2.cs b/MiraAPI.Example/Options/ExampleOptions2.cs index 9abf375..bc3c910 100644 --- a/MiraAPI.Example/Options/ExampleOptions2.cs +++ b/MiraAPI.Example/Options/ExampleOptions2.cs @@ -10,6 +10,8 @@ public class ExampleOptions2 : AbstractOptionGroup public override uint GroupPriority => 0; // This group will be displayed first. The default value is uint.MaxValue. + public ModdedPlayerOption PlayerOption2 { get; } = new("YOU MUST CHOSE A PLAYER", false); + public ModdedPlayerOption PlayerOption { get; } = new("Eh whatever"); public ModdedToggleOption ToggleOpt1 { get; } = new("Toggle Option 1", false); public ModdedToggleOption ToggleOpt2 { get; } = new("Toggle Option 2", false) diff --git a/MiraAPI/Colors/CustomColor.cs b/MiraAPI/Colors/CustomColor.cs index 7bbe87a..89663d1 100644 --- a/MiraAPI/Colors/CustomColor.cs +++ b/MiraAPI/Colors/CustomColor.cs @@ -13,19 +13,24 @@ namespace MiraAPI.Colors; public sealed class CustomColor(StringNames name, Color32 mainColor, Color32 shadowColor) { /// - /// Gets the main color. + /// Gets or sets the main color. /// - public Color32 MainColor { get; } = mainColor; + public Color32 MainColor { get; set; } = mainColor; /// - /// Gets the shadow color. + /// Gets or sets the shadow color. /// - public Color32 ShadowColor { get; } = shadowColor; + public Color32 ShadowColor { get; set; } = shadowColor; /// - /// Gets the name of the color. + /// Gets or sets the name of the color. /// - public StringNames Name { get; } = name; + public StringNames Name { get; set; } = name; + + /// + /// Gets or sets whether the color is lighter or darker. Can be used to help with colorblind stuff. + /// + public CustomColorBrightness ColorBrightness { get; set; } = CustomColorBrightness.Darker; /// public CustomColor(StringNames name, Color32 mainColor) : this(name, mainColor, mainColor.GetShadowColor(60)) @@ -42,3 +47,19 @@ public sealed class CustomColor(StringNames name, Color32 mainColor, Color32 sha { } } + +/// +/// Can be used to help with colorblind stuff. +/// +public enum CustomColorBrightness +{ + /// + /// A lighter color. + /// + Lighter, + + /// + /// A darker color. + /// + Darker, +} diff --git a/MiraAPI/Events/Vanilla/Gameplay/BeforeRoundStartEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/BeforeRoundStartEvent.cs new file mode 100644 index 0000000..f9d41ee --- /dev/null +++ b/MiraAPI/Events/Vanilla/Gameplay/BeforeRoundStartEvent.cs @@ -0,0 +1,21 @@ +namespace MiraAPI.Events.Vanilla.Gameplay; + +/// +/// Event invoked on IntroCutscene.OnDestroy and ExileController.WrapUp to determine if it should run the round event or not. +/// +public class BeforeRoundStartEvent : MiraCancelableEvent +{ + /// + /// Gets a value indicating whether the event was triggered by the IntroCutscene or ExileController. + /// + public bool TriggeredByIntro { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Whether the event was triggered by the intro or not. + public BeforeRoundStartEvent(bool triggeredByIntro) + { + TriggeredByIntro = triggeredByIntro; + } +} diff --git a/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs index a50fde5..470f85a 100644 --- a/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs +++ b/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs @@ -1,7 +1,7 @@ namespace MiraAPI.Events.Vanilla.Gameplay; /// -/// Start Round event, invoked on IntroCutscene.OnDestroy and ExileController.WrapUp. +/// Start Round event, invoked on IntroCutscene.OnDestroy and ExileController.WrapUp if the event isn't cancelled. /// public class RoundStartEvent : MiraEvent { diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/DummyVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/DummyVoteEvent.cs new file mode 100644 index 0000000..2f5cba0 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/DummyVoteEvent.cs @@ -0,0 +1,44 @@ +using System; +using MiraAPI.Utilities; +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Invoked before the dummy selects their vote. If canceled, the dummy won't vote. +/// +public class DummyVoteEvent : MiraCancelableEvent +{ + /// + /// Gets the dummy that needs to vote. + /// + public DummyBehaviour Dummy { get; } + + /// + /// Gets the instance of the dummy's vote data. + /// + public PlayerVoteData VoteData { get; } + + /// + /// Gets or sets a value indicating whether the dummy can vote to skip the meeting. + /// + public bool CanSkip { get; set; } + + /// + /// Gets or sets a predicate for validating whether the dummy can vote on a player. + /// By default, excludes dead and disconnected players. + /// + public Predicate PlayerIsValid { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The voting dummy. + public DummyVoteEvent(DummyBehaviour dummy) + { + Dummy = dummy; + VoteData = dummy.myPlayer.GetVoteData(); + CanSkip = true; + PlayerIsValid = x => !x.Data.IsDead && !x.Data.Disconnected; + } +} diff --git a/MiraAPI/GameOptions/IModdedOption.cs b/MiraAPI/GameOptions/IModdedOption.cs index 95173fb..947b6ff 100644 --- a/MiraAPI/GameOptions/IModdedOption.cs +++ b/MiraAPI/GameOptions/IModdedOption.cs @@ -62,9 +62,10 @@ public interface IModdedOption /// The ToggleOption template. /// The NumberOption template. /// The StringOption template. + /// The PlayerOption template. /// >The Transform container for the option. /// >The created OptionBehaviour object. - OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container); + OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container); /// /// Gets the value as a float. diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index 0adc461..86e2006 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -6,6 +6,7 @@ using System.Reflection; using BepInEx.Configuration; using HarmonyLib; using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Networking; using MiraAPI.PluginLoading; using MiraAPI.Utilities; @@ -22,6 +23,7 @@ public static class ModdedOptionsManager private static readonly Dictionary OptionAttributes = []; private static readonly Dictionary TypeToGroup = []; + internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary ModdedOptions = []; internal static readonly List Groups = []; diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 18f13c6..0bec22f 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -44,7 +44,7 @@ public class ModdedEnumOption : ModdedOption } /// - public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container) + public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container) { var stringOption = Object.Instantiate(stringOpt, container); @@ -135,7 +135,7 @@ public class ModdedEnumOption : ModdedOption where T : Enum } /// - public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container) + public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container) { var stringOption = Object.Instantiate(stringOpt, container); diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs index a01ffe8..53378e1 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs @@ -93,6 +93,7 @@ public class ModdedNumberOption : ModdedOption ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, + PlayerOption playerOpt, Transform container) { var numberOption = Object.Instantiate(numberOpt, Vector3.zero, Quaternion.identity, container); diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs index 3c98d24..311f294 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs @@ -37,6 +37,7 @@ public abstract class ModdedOption : IModdedOption { if (_parentMod != null || value == null) return; _parentMod = value; + var entry = _parentMod.GetConfigFile().Bind(ConfigDefinition, DefaultValue); Value = entry.Value; } @@ -177,6 +178,7 @@ public abstract class ModdedOption : IModdedOption ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, + PlayerOption playerOpt, Transform container); /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs new file mode 100644 index 0000000..6069a2c --- /dev/null +++ b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using Il2CppSystem.Collections.Generic; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace MiraAPI.GameOptions.OptionTypes; + +/// +/// An option for selecting an ingame player. It returns an index of the values list, NOT A PLAYER ID. To get a player out of this, index the Values list. +/// +public class ModdedPlayerOption : ModdedOption +{ + /// + /// Gets or sets the included players. + /// + public List Values { get; set; } + + /// + /// Gets or sets a filter for the players included in the option. + /// + public Func? PlayerFilter { get; set; } + + /// + /// Gets or sets a value indicating whether the option includes a "None" option. If None is selected, value will return -1. + /// + public bool AllowNone { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// Whether the option includes a none option. If None is selected, value will return -1. + public ModdedPlayerOption(string title, bool allowNone = true) : base(title, allowNone ? -1 : 0, false) + { + Data = ScriptableObject.CreateInstance(); + var data = (PlayerSelectionGameSetting)Data; + + data.Title = StringName; + data.Type = global::OptionTypes.Player; + + Values = new List(); + AllowNone = allowNone; + Value = DefaultValue; + } + + /// + public override OptionBehaviour CreateOption( + ToggleOption toggleOpt, + NumberOption numberOpt, + StringOption stringOpt, + PlayerOption playerOpt, + Transform container) + { + var playerOption = Object.Instantiate(playerOpt, container); + + var filteredList = GetFilteredPlayers(); + Values = filteredList.ToIl2CppList(); + + playerOption.SetUpFromData(Data, 20); + playerOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + + playerOption.Title = StringName; + playerOption.TitleText.text = TranslationController.Instance.GetString(StringName); + playerOption.Values = Values; + playerOption.Value = Value; + playerOption.playerIndex = filteredList.FindIndex(p => p.PlayerId == Value); + + OptionBehaviour = playerOption; + ModdedOptionsManager.CreatedPlayerOptions.TryAdd(playerOption, this); + + playerOption.SetValueText(); + playerOption.AdjustButtonsActiveState(); + + return playerOption; + } + + /// + /// Returns a player ran through the filter. + /// + /// A list of filtered players. + public System.Collections.Generic.List GetFilteredPlayers() + { + return GameData.Instance.AllPlayers.ToArray() + .Where(x => PlayerFilter?.Invoke(x) ?? true) + .ToList(); + } + + /// + /// Returns a player from the value. If AllowNone is enabled and none is enabled this will return null. + /// + /// A player or null if chosen none. + public NetworkedPlayerInfo? GetPlayerValue() + { + return Value < 0 ? null : Values[Value]; + } + + /// + public override float GetFloatData() + { + return Value; + } + + /// + public override NetData GetNetData() + { + return new NetData(Id, BitConverter.GetBytes(Value)); + } + + /// + public override void HandleNetData(byte[] data) + { + SetValue(BitConverter.ToInt32(data)); + } + + /// + public override int GetValueFromOptionBehaviour(OptionBehaviour optionBehaviour) + { + return optionBehaviour.GetInt(); + } + + /// + protected override void OnValueChanged(int newValue) + { + HudManager.Instance.Notifier.AddSettingsChangeMessage(StringName, Data.GetValueString(newValue), false); + if (!OptionBehaviour) + { + return; + } + + if (OptionBehaviour is PlayerOption opt) + { + opt.Value = newValue; + } + } +} diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs index 26ecc74..2cdd0e1 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs @@ -26,7 +26,7 @@ public class ModdedToggleOption : ModdedOption } /// - public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container) + public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container) { var toggleOption = Object.Instantiate(toggleOpt, Vector3.zero, Quaternion.identity, container); diff --git a/MiraAPI/Hud/CustomActionButton.cs b/MiraAPI/Hud/CustomActionButton.cs index 988aac9..ac48c4a 100644 --- a/MiraAPI/Hud/CustomActionButton.cs +++ b/MiraAPI/Hud/CustomActionButton.cs @@ -5,6 +5,7 @@ using MiraAPI.Events.Mira; using MiraAPI.Patches; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; @@ -52,6 +53,28 @@ public abstract class CustomActionButton /// public virtual int MaxUses => 0; + /// + /// Gets or sets the current key binding for this button. + /// + public abstract KeyboardKeyCode Defaultkeybind { get; } + + /// + /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held with the main key to activate the button. + /// + public virtual ModifierKey Modifier1 => ModifierKey.None; + + /// + /// The second optional modifier key. + /// Set to if unused. + /// + public virtual ModifierKey Modifier2 => ModifierKey.None; + + /// + /// The third optional modifier key. + /// Set to if unused. + /// + public virtual ModifierKey Modifier3 => ModifierKey.None; + /// /// Gets the button's text outline color. /// diff --git a/MiraAPI/Keybinds/KeybindManager.cs b/MiraAPI/Keybinds/KeybindManager.cs new file mode 100644 index 0000000..3f95f4f --- /dev/null +++ b/MiraAPI/Keybinds/KeybindManager.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using Rewired; + +namespace MiraAPI.Keybinds +{ + /// + /// Lets you register and manage mod keybinds with conflict checks and rebinding support. + /// + public static class KeybindManager + { + /// + /// Stores all info for a registered keybind. + /// + public class KeybindEntry + { + /// Unique identifier for this keybind. + public string Id; + + /// Text shown in the Among Us rebinding menu to describe this keybind. + public string Description; + + /// + /// The method that runs when this keybind is pressed. + /// + public Action Handler; + + /// + /// The key currently assigned to this keybind. + /// + public KeyboardKeyCode Key; + + /// + /// If true, this keybind will be checked for conflicts with other exclusive keybinds using the same key. + /// + public bool Exclusive; + + /// + /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held with the main key. + /// + public ModifierKey Modifier1 = ModifierKey.None; + + /// + /// The second optional modifier key, if needed. Set to ModifierKey.None if unused. + /// + public ModifierKey Modifier2 = ModifierKey.None; + + /// + /// The third optional modifier key, if needed. Set to ModifierKey.None if unused. + /// + public ModifierKey Modifier3 = ModifierKey.None; + } + + private static readonly List Registered = new(); + + /// + /// Registers a new mod keybind. + /// + /// Unique identifier for the keybind. + /// Description to display in UI. + /// Default key to use if no rebind exists. + /// Method to invoke when the keybind is pressed. + /// If true, conflicts with other exclusive keybinds on the same key. + /// Optional first modifier key (e.g., Control, Shift, Alt) that must be held with the main key. + /// Optional second modifier key. Use ModifierKey.None if unused. + /// Optional third modifier key. Use ModifierKey.None if unused. + /// Returns the key that was registered for this keybind. + public static KeyboardKeyCode Register(string id, string description, KeyboardKeyCode key, Action handler, bool exclusive = true, ModifierKey modifier1 = ModifierKey.None, ModifierKey modifier2 = ModifierKey.None, ModifierKey modifier3 = ModifierKey.None) + { + Registered.Add(new KeybindEntry + { + Id = id, + Description = description, + Key = key, + Handler = handler, + Exclusive = exclusive, + Modifier1 = modifier1, + Modifier2 = modifier2, + Modifier3 = modifier3, + }); + + return key; + } + + /// + /// Returns all conflicts where exclusive keybinds use the same key. + /// + /// A list of pairs of keybinds that conflict. + public static List<(KeybindEntry, KeybindEntry)> GetConflicts() + { + var conflicts = new List<(KeybindEntry, KeybindEntry)>(); + for (int i = 0; i < Registered.Count; i++) + { + for (int j = i + 1; j < Registered.Count; j++) + { + var a = Registered[i]; + var b = Registered[j]; + if (a.Key == b.Key && a.Exclusive && b.Exclusive) + { + conflicts.Add((a, b)); + } + } + } + return conflicts; + } + + /// + /// Returns all currently registered keybind entries. + /// + /// A list of all registered keybind entries. + public static List GetEntries() + { + return Registered; + } + } +} diff --git a/MiraAPI/MiraApiConfig.cs b/MiraAPI/MiraApiConfig.cs new file mode 100644 index 0000000..10c9776 --- /dev/null +++ b/MiraAPI/MiraApiConfig.cs @@ -0,0 +1,23 @@ +using BepInEx; +using BepInEx.Configuration; +using BepInEx.Unity.IL2CPP; +using HarmonyLib; +using MiraAPI.PluginLoading; +using Reactor; +using Reactor.Networking; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace MiraAPI; + +/// +/// Mira API Config File Handler +/// +public class MiraApiConfig(ConfigFile config) +{ + /// + /// Gets whether the modifiers hud should be on the left side of the screen (under roles/task tab). Recommended for streamers. + /// + public ConfigEntry ModifiersHudLeftSide { get; private set; } = config.Bind("Displays", "Show Modifiers HUD on Left Side", false); +} diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index 8654cf6..aa7f3c6 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -30,6 +30,10 @@ public partial class MiraApiPlugin : BasePlugin /// public static Color DefaultHeaderColor { get; } = new Color32(77, 77, 77, 255); + /// + /// Gets the Mira Api Config Handler. + /// + public MiraApiConfig? MiraConfig { get; private set; } private static MiraPluginManager? PluginManager { get; set; } internal Harmony Harmony { get; } = new(Id); @@ -40,6 +44,7 @@ public partial class MiraApiPlugin : BasePlugin ReactorCredits.Register("Mira API", Version, true, ReactorCredits.AlwaysShow); + MiraConfig = new MiraApiConfig(Config); PluginManager = new MiraPluginManager(); PluginManager.Initialize(); } diff --git a/MiraAPI/Modifiers/BaseModifier.cs b/MiraAPI/Modifiers/BaseModifier.cs index 52e30b6..0e31779 100644 --- a/MiraAPI/Modifiers/BaseModifier.cs +++ b/MiraAPI/Modifiers/BaseModifier.cs @@ -64,6 +64,11 @@ public abstract class BaseModifier : IOptionable /// public virtual bool ShowInFreeplay => false; + /// + /// Gets a value indicating the color that should be used for the modifier within freeplay. + /// + public virtual Color FreeplayFileColor => Color.gray; + /// /// Gets a value indicating whether the modifier is unique. If true, the player can only have one instance of this modifier. /// diff --git a/MiraAPI/Modifiers/ModifierComponent.cs b/MiraAPI/Modifiers/ModifierComponent.cs index 5c29dc2..4f556f5 100644 --- a/MiraAPI/Modifiers/ModifierComponent.cs +++ b/MiraAPI/Modifiers/ModifierComponent.cs @@ -6,6 +6,7 @@ using System.Linq; using Il2CppInterop.Runtime.Attributes; using MiraAPI.Modifiers.ModifierDisplay; using MiraAPI.Modifiers.Types; +using MiraAPI.Patches.Roles; using Reactor.Utilities; using Reactor.Utilities.Attributes; using UnityEngine; @@ -126,7 +127,18 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) if (_player.AmOwner && ModifierDisplay && HudManager.InstanceExists) { - ModifierDisplay!.gameObject.SetActive(MeetingHud.Instance || HudManager.Instance.TaskPanel.isActiveAndEnabled); + var taskPanelOpen = HudManager.Instance.TaskPanel.open; + var roleTabOpen = HudManagerPatches.RoleTab != null && HudManagerPatches.RoleTab.open; + var leftSideHud = PluginSingleton.Instance.MiraConfig!.ModifiersHudLeftSide.Value; + + if (leftSideHud && (taskPanelOpen || roleTabOpen)) + { + ModifierDisplay!.gameObject.SetActive(false); + } + else + { + ModifierDisplay!.gameObject.SetActive(MeetingHud.Instance || HudManager.Instance.TaskPanel.isActiveAndEnabled); + } } } diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs index f1a614e..0836935 100644 --- a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Patches.Roles; using MiraAPI.Utilities.Assets; +using Reactor.Utilities; using Reactor.Utilities.Attributes; using Reactor.Utilities.Extensions; using TMPro; @@ -94,6 +97,16 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) IsOpen = false; _children.gameObject.SetActive(false); + + if (PluginSingleton.Instance.MiraConfig!.ModifiersHudLeftSide.Value) + { + var aspect = GetComponent(); + _toggleButton.transform.localPosition = new Vector3(-1f, 2.7f, 0f); + _children.transform.localPosition = new Vector3(-0.2f, -0.05f, 0f); + aspect.Alignment = AspectPosition.EdgeAlignments.LeftTop; + aspect.DistanceFromEdge = new Vector3(1.8f, 2.55f, -20f); + aspect.AdjustPosition(); + } } /// @@ -109,7 +122,16 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) /// public void ToggleTab() { - IsOpen = !IsOpen; + SetOpened(!IsOpen); + } + + /// + /// Use this to set the visibility of the modifiers tab. + /// + /// Whether it should be visible or not. + public void SetOpened(bool val) + { + IsOpen = val; _children.gameObject.SetActive(IsOpen); RefreshModifiers(); } @@ -118,7 +140,6 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) { _modifiers.Remove(component.Modifier!); component.gameObject.DestroyImmediate(); - RefreshModifiers(); } @@ -194,13 +215,7 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) _toggleBtnText.text = $"Modifiers ({filteredModifiers.Count})"; - if (filteredModifiers.Count == 0) - { - _toggleButton.gameObject.SetActive(false); - return; - } - - _toggleButton.gameObject.SetActive(true); + _toggleButton.gameObject.SetActive(filteredModifiers.Count != 0); foreach (var mod in modifiersToAdd) { diff --git a/MiraAPI/Modifiers/ModifierManager.cs b/MiraAPI/Modifiers/ModifierManager.cs index 1b38efd..f34d11f 100644 --- a/MiraAPI/Modifiers/ModifierManager.cs +++ b/MiraAPI/Modifiers/ModifierManager.cs @@ -5,6 +5,7 @@ using System.Runtime.Serialization; using MiraAPI.Modifiers.Types; using MiraAPI.PluginLoading; using MiraAPI.Roles; +using MiraAPI.Utilities; using Reactor.Utilities; using Reactor.Utilities.Extensions; using Random = System.Random; @@ -21,6 +22,13 @@ public static class ModifierManager /// public static bool MiraAssignsModifiers { get; set; } = true; + /// + /// Gets the list of all registered modifiers. + /// + public static IReadOnlyList Modifiers { get; internal set; } = []; + + internal static List InternalModifiers { get; } = []; + private static readonly Dictionary IdToTypeModifierMap = []; private static readonly Dictionary TypeToIdModifierMap = []; private static readonly Dictionary> PrioritiesToIdsMap = []; @@ -80,6 +88,7 @@ public static class ModifierManager } info.InternalModifiers.Add(modifier); + InternalModifiers.Add(modifier); if (modifier is not GameModifier gameModifier) { @@ -117,6 +126,7 @@ public static class ModifierManager .Select(x => Activator.CreateInstance(x.Value) as GameModifier) .OfType() .Where(x => x.GetAmountPerGame() > 0 && x.GetAssignmentChance() > 0) + .Shuffle() .OrderByDescending(x => x.Priority()) .ToArray(); diff --git a/MiraAPI/Patches/ChatControllerPatch.cs b/MiraAPI/Patches/ChatControllerPatch.cs new file mode 100644 index 0000000..e65be7f --- /dev/null +++ b/MiraAPI/Patches/ChatControllerPatch.cs @@ -0,0 +1,35 @@ +using HarmonyLib; +using UnityEngine; + +namespace MiraAPI.Patches; + +#pragma warning disable SA1629 +/// +/// Allows players to paste text into chat. +/// Source: https://github.com/CallOfCreator/NewMod/blob/main/NewMod/Patches/ClipboardPatch.cs +/// +#pragma warning restore SA1629 +[HarmonyPatch(typeof(ChatController), nameof(ChatController.Update))] +public static class ChatControllerPatch +{ + public static void Prefix(ChatController __instance) + { + if (!HudManager.Instance.Chat.IsOpenOrOpening) return; + + var ctrlPressed = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl); + + if (!ctrlPressed || !Input.GetKeyDown(KeyCode.V)) return; + + var clipboard = GUIUtility.systemCopyBuffer; + + if (string.IsNullOrWhiteSpace(clipboard)) return; + clipboard = clipboard.Replace("<", string.Empty) + .Replace(">", string.Empty) + .Replace("\r", string.Empty); + + if (!Input.GetKey(KeyCode.LeftShift) && !Input.GetKey(KeyCode.RightShift)) + clipboard = clipboard.Replace("\n", string.Empty); + + __instance.freeChatField.textArea.SetText(__instance.freeChatField.textArea.text + clipboard); + } +} diff --git a/MiraAPI/Patches/Events/CanUsePatches.cs b/MiraAPI/Patches/Events/CanUsePatches.cs index 143f176..6206591 100644 --- a/MiraAPI/Patches/Events/CanUsePatches.cs +++ b/MiraAPI/Patches/Events/CanUsePatches.cs @@ -12,14 +12,16 @@ public static class CanUsePatches public static IEnumerable TargetMethods() { yield return AccessTools.Method(typeof(Console), nameof(Console.CanUse)); - yield return AccessTools.Method(typeof(MapConsole), nameof(MapConsole.CanUse)); - yield return AccessTools.Method(typeof(SystemConsole), nameof(SystemConsole.CanUse)); - yield return AccessTools.Method(typeof(Ladder), nameof(Ladder.CanUse)); - yield return AccessTools.Method(typeof(PlatformConsole), nameof(PlatformConsole.CanUse)); - yield return AccessTools.Method(typeof(OpenDoorConsole), nameof(OpenDoorConsole.CanUse)); + yield return AccessTools.Method(typeof(DeconControl), nameof(DeconControl.CanUse)); yield return AccessTools.Method(typeof(DoorConsole), nameof(DoorConsole.CanUse)); + yield return AccessTools.Method(typeof(Ladder), nameof(Ladder.CanUse)); + yield return AccessTools.Method(typeof(MapConsole), nameof(MapConsole.CanUse)); + yield return AccessTools.Method(typeof(OpenDoorConsole), nameof(OpenDoorConsole.CanUse)); yield return AccessTools.Method(typeof(OptionsConsole), nameof(OptionsConsole.CanUse)); + yield return AccessTools.Method(typeof(PlatformConsole), nameof(PlatformConsole.CanUse)); + yield return AccessTools.Method(typeof(SystemConsole), nameof(SystemConsole.CanUse)); yield return AccessTools.Method(typeof(ZiplineConsole), nameof(ZiplineConsole.CanUse)); + // vent is not necessary as it has its own patch } [HarmonyPriority(Priority.Last)] diff --git a/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs b/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs new file mode 100644 index 0000000..12c95c1 --- /dev/null +++ b/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs @@ -0,0 +1,17 @@ +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; + +namespace MiraAPI.Patches.Events; + +[HarmonyPatch(typeof(TutorialManager._RunTutorial_d__3), nameof(TutorialManager._RunTutorial_d__3.MoveNext))] +public static class FreeplayRoundStartPatch +{ + public static void Postfix(TutorialManager._RunTutorial_d__3 __instance, ref bool __result) + { + if (!__result) + { + MiraEventManager.InvokeEvent(new RoundStartEvent(true)); + } + } +} diff --git a/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs b/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs index b726999..65768d4 100644 --- a/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs +++ b/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs @@ -9,7 +9,12 @@ internal static class SpawnInMinigamePatch { public static void Postfix() { - var @event = new RoundStartEvent(false); + var @event = new BeforeRoundStartEvent(false); MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + + var @event2 = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event2); } } diff --git a/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs b/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs index d2664b9..07c358a 100644 --- a/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs +++ b/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs @@ -25,7 +25,12 @@ internal static class SubmergedSpawnGamePatch public static void Postfix() { - var @event = new RoundStartEvent(false); + var @event = new BeforeRoundStartEvent(false); MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + + var @event2 = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event2); } } diff --git a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs index 184bafb..07d37f5 100644 --- a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs +++ b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs @@ -22,15 +22,4 @@ internal static class TaskAddButtonPatches return true; } - - [HarmonyPostfix] - [HarmonyPatch(nameof(TaskAddButton.Role), MethodType.Setter)] - public static void RoleGetterPatch(TaskAddButton __instance) - { - if (__instance.role is ICustomRole { Team: ModdedRoleTeams.Custom } customRole) - { - __instance.FileImage.color = customRole.IntroConfiguration?.IntroTeamColor ?? Color.gray; - } - __instance.RolloverHandler.OutColor = __instance.FileImage.color; - } } diff --git a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs index f90ffba..ae49ed4 100644 --- a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs +++ b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs @@ -10,6 +10,7 @@ using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; using TMPro; using UnityEngine; +using UnityEngine.Events; using UnityEngine.UI; using Object = UnityEngine.Object; @@ -19,7 +20,6 @@ namespace MiraAPI.Patches.Freeplay; internal static class TaskAdderPatches { private static Scroller _scroller = null!; - [HarmonyPostfix] [HarmonyPatch(nameof(TaskAdderGame.Begin))] public static void AddRolesFolder(TaskAdderGame __instance) @@ -32,6 +32,11 @@ internal static class TaskAdderPatches _scroller.allowY = true; _scroller.Inner = inner.transform; + var scrollerHelper = __instance.TaskParent.gameObject.AddComponent(); + scrollerHelper.scroller = _scroller; + scrollerHelper.verticalAxis = RewiredConstsEnum.Action.TaskRVertical; + scrollerHelper.scrollSpeed = 10f; + GameObject hitbox = new("Hitbox") { layer = 5, @@ -295,7 +300,7 @@ internal static class TaskAdderPatches if (_scroller) { - _scroller.CalculateAndSetYBounds(__instance.ActiveItems.Count, 6, 5, 1.4f); + _scroller.CalculateAndSetYBounds(__instance.ActiveItems.Count, 6, 4.5f, 1.65f); _scroller.SetYBoundsMin(0.0f); _scroller.ScrollToTop(); } @@ -332,7 +337,8 @@ internal static class TaskAdderPatches taskAddButton.SafePositionWorld = instance.SafePositionWorld; taskAddButton.Text.text = modifier.ModifierName; taskAddButton.Text.EnableMasking(); - + taskAddButton.FileImage.color = modifier.FreeplayFileColor; + taskAddButton.RolloverHandler.OutColor = modifier.FreeplayFileColor; taskAddButton.Button.OnClick = new Button.ButtonClickedEvent(); taskAddButton.Button.OnClick.AddListener((UnityEngine.Events.UnityAction)(() => { @@ -384,6 +390,20 @@ internal static class TaskAdderPatches instance.AddFileAsChildCustom(taskAddButton2, ref num, ref num2, ref num3); taskAddButton2.Role = roleBehaviour; + if (roleBehaviour is ICustomRole custom) + { + var customColor = custom.IntroConfiguration?.IntroTeamColor ?? Color.gray; + if (custom.Team is ModdedRoleTeams.Crewmate) + { + customColor = Palette.CrewmateBlue; + } + else if (custom.Team is ModdedRoleTeams.Impostor) + { + customColor = Palette.ImpostorRed; + } + taskAddButton2.FileImage.color = customColor; + taskAddButton2.RolloverHandler.OutColor = customColor; + } if (taskAddButton2.Button == null) { continue; diff --git a/MiraAPI/Patches/GameStartManagerPatch.cs b/MiraAPI/Patches/GameStartManagerPatch.cs new file mode 100644 index 0000000..7ebb05b --- /dev/null +++ b/MiraAPI/Patches/GameStartManagerPatch.cs @@ -0,0 +1,36 @@ +using HarmonyLib; +using MiraAPI.Keybinds; +using UnityEngine; + +namespace MiraAPI.Patches; + +/// +/// Checks keybind conflicts in the lobby and disables secondary ones. +/// +[HarmonyPatch(typeof(GameStartManager))] +public static class GameStartManagerPatch +{ + public static bool conflictShown = false; + + [HarmonyPatch(nameof(GameStartManager.Start))] + [HarmonyPostfix] + public static void StartPostfix(GameStartManager __instance) + { + if (conflictShown) return; + + var conflicts = KeybindManager.GetConflicts(); + if (conflicts.Count == 0) return; + + conflictShown = true; + + var message = "The following keybinds are disabled due to conflicts:\n\n"; + + foreach (var (a, b) in conflicts) + { + message += $"• {b.Description} (conflicts with {a.Description})\n"; + b.Handler = null; + } + + HudManager.Instance.ShowPopUp(message.TrimEnd()); + } +} diff --git a/MiraAPI/Patches/Hud/ButtonResetPatches.cs b/MiraAPI/Patches/Hud/ButtonResetPatches.cs index 0859bea..29b190d 100644 --- a/MiraAPI/Patches/Hud/ButtonResetPatches.cs +++ b/MiraAPI/Patches/Hud/ButtonResetPatches.cs @@ -1,37 +1,25 @@ using HarmonyLib; using MiraAPI.Hud; +using Reactor.Utilities; namespace MiraAPI.Patches.Hud; -/// -/// Reset button patches. -/// -[HarmonyPatch] +[HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))] +[HarmonyPatch(typeof(ExileController), nameof(ExileController.ReEnableGameplay))] public static class ButtonResetPatches { - /// - /// Resets the cooldown and effect of all custom buttons when the meeting starts. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))] - public static void MeetingHudStartPostfix() + public static void Postfix() { foreach (var customActionButton in CustomButtonManager.CustomButtons) { - customActionButton.ResetCooldownAndOrEffect(); - } - } - - /// - /// Resets the cooldown and effect of all custom buttons after the exile screen is closed. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(ExileController), nameof(ExileController.ReEnableGameplay))] - public static void ExileControllerReEnableGameplayPostfix() - { - foreach (var customActionButton in CustomButtonManager.CustomButtons) - { - customActionButton.ResetCooldownAndOrEffect(); + try + { + customActionButton.ResetCooldownAndOrEffect(); + } + catch (System.Exception ex) + { + Logger.Error($"Error resetting cooldown and effect for button {customActionButton.GetType().Name}: {ex}"); + } } } } diff --git a/MiraAPI/Patches/HudManagerPatches.cs b/MiraAPI/Patches/HudManagerPatches.cs index 27a8026..acb4482 100644 --- a/MiraAPI/Patches/HudManagerPatches.cs +++ b/MiraAPI/Patches/HudManagerPatches.cs @@ -1,6 +1,11 @@ -using HarmonyLib; +using System; +using System.Collections; +using HarmonyLib; using MiraAPI.Hud; +using MiraAPI.Keybinds; +using Reactor.Utilities; using Reactor.Utilities.Extensions; +using Rewired; using UnityEngine; using Object = UnityEngine.Object; @@ -77,7 +82,14 @@ public static class HudManagerPatches continue; } - button.CreateButton(location); + try + { + button.CreateButton(location); + } + catch (System.Exception e) + { + Logger.Error($"Failed to create custom button {button.GetType().Name}: {e}"); + } } gridArrange.Start(); @@ -105,7 +117,36 @@ public static class HudManagerPatches foreach (var button in CustomButtonManager.CustomButtons) { - button.SetActive(isActive, role); + try + { + button.SetActive(isActive, role); + } + catch (System.Exception e) + { + Logger.Error($"Failed to set custom button {button.GetType().Name} active: {e}"); + } + } + } + + [HarmonyPatch(nameof(HudManager.Update))] + [HarmonyPostfix] + public static void StartPostfix() + { + if (AmongUsClient.Instance.GameState != InnerNet.InnerNetClient.GameStates.Started) return; + if (PlayerControl.LocalPlayer.Data.IsDead) return; + + foreach (var entry in KeybindManager.GetEntries()) + { + var player = ReInput.players.GetPlayer(0); + var keyboard = player.controllers.Keyboard; + + if (player.GetButtonDown(entry.Id) && + (entry.Modifier1 == ModifierKey.None || keyboard.GetModifierKey(entry.Modifier1)) && + (entry.Modifier2 == ModifierKey.None || keyboard.GetModifierKey(entry.Modifier2)) && + (entry.Modifier3 == ModifierKey.None || keyboard.GetModifierKey(entry.Modifier3))) + { + entry.Handler?.Invoke(); + } } } } diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs index d068f26..af7ce8c 100644 --- a/MiraAPI/Patches/IntroCutscenePatches.cs +++ b/MiraAPI/Patches/IntroCutscenePatches.cs @@ -61,6 +61,11 @@ public static class IntroCutscenePatches public static void GameBeginPatch(IntroCutscene __instance) { MiraEventManager.InvokeEvent(new IntroEndEvent(__instance)); + + var @event = new BeforeRoundStartEvent(true); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; MiraEventManager.InvokeEvent(new RoundStartEvent(true)); } } diff --git a/MiraAPI/Patches/Keybinds/KeybindsPatch.cs b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs new file mode 100644 index 0000000..e609086 --- /dev/null +++ b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs @@ -0,0 +1,20 @@ +using HarmonyLib; +using MiraAPI.Keybinds; +using MiraAPI.Utilities; +using Rewired; + +namespace MiraAPI.Patches.Keybinds +{ + [HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))] + public static class KeybindMenuPatch + { + [HarmonyPrefix] + private static void StartPrefix(InputManager_Base __instance) + { + foreach (var entry in KeybindManager.GetEntries()) + { + __instance.userData.RegisterModBind(entry.Id, entry.Description, entry.Key, modifier1: entry.Modifier1, modifier2: entry.Modifier2, modifier3: entry.Modifier3); + } + } + } +} diff --git a/MiraAPI/Patches/Menu/HatsTabPatches.cs b/MiraAPI/Patches/Menu/HatsTabPatches.cs index daf87b1..8d36e9c 100644 --- a/MiraAPI/Patches/Menu/HatsTabPatches.cs +++ b/MiraAPI/Patches/Menu/HatsTabPatches.cs @@ -84,7 +84,7 @@ public static class HatsTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{sortedHats.Count})\nPress Ctrl or Tab to cycle pages"; text.alignment = TextAlignmentOptions.Center; text.fontSize = 3f; text.fontSizeMax = 3f; @@ -114,7 +114,7 @@ public static class HatsTabPatches yield return null; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); yield return new WaitForSeconds(0.01f); } __instance.currentHatIsEquipped = true; diff --git a/MiraAPI/Patches/Menu/NameplatesTabPatches.cs b/MiraAPI/Patches/Menu/NameplatesTabPatches.cs index d66c8f3..11657a0 100644 --- a/MiraAPI/Patches/Menu/NameplatesTabPatches.cs +++ b/MiraAPI/Patches/Menu/NameplatesTabPatches.cs @@ -73,7 +73,7 @@ public static class NameplatesTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{SortedNameplates.Count})\nPress Ctrl or Tab to cycle pages"; text.alignment = TextAlignmentOptions.Center; text.fontSize = 3f; text.fontSizeMax = 3f; @@ -91,7 +91,7 @@ public static class NameplatesTabPatches hatIndex += 1; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); __instance.currentNameplateIsEquipped = true; } diff --git a/MiraAPI/Patches/Menu/SkinsTabPatches.cs b/MiraAPI/Patches/Menu/SkinsTabPatches.cs index b102fb1..9378d8b 100644 --- a/MiraAPI/Patches/Menu/SkinsTabPatches.cs +++ b/MiraAPI/Patches/Menu/SkinsTabPatches.cs @@ -76,7 +76,7 @@ public static class SkinsTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{sortedSkins.Count})\nPress Ctrl or Tab to cycle pages"; text.alignment = TextAlignmentOptions.Center; text.fontSize = 3f; text.fontSizeMax = 3f; @@ -94,7 +94,7 @@ public static class SkinsTabPatches hatIndex += 1; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); __instance.currentSkinIsEquipped = true; } diff --git a/MiraAPI/Patches/Menu/VisorsTabPatches.cs b/MiraAPI/Patches/Menu/VisorsTabPatches.cs index 81845c6..92d5b8e 100644 --- a/MiraAPI/Patches/Menu/VisorsTabPatches.cs +++ b/MiraAPI/Patches/Menu/VisorsTabPatches.cs @@ -72,7 +72,7 @@ public static class VisorsTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{SortedVisors.Count})\nPress Ctrl or Tab to cycle pages"; text.alignment = TextAlignmentOptions.Center; text.fontSize = 3f; text.fontSizeMax = 3f; @@ -90,7 +90,7 @@ public static class VisorsTabPatches hatIndex += 1; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); __instance.currentVisorIsEquipped = true; } diff --git a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs index 0d88920..155b50d 100644 --- a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs +++ b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs @@ -220,6 +220,7 @@ internal static class GameOptionsMenuPatch menu.checkboxOrigin, menu.numberOptionOrigin, menu.stringOptionOrigin, + menu.playerOptionOrigin, menu.settingsContainer)); foreach (var newOpt in options) diff --git a/MiraAPI/Patches/Options/GameSettingMenuPatches.cs b/MiraAPI/Patches/Options/GameSettingMenuPatches.cs index 7313206..fd9c78b 100644 --- a/MiraAPI/Patches/Options/GameSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/GameSettingMenuPatches.cs @@ -288,7 +288,7 @@ internal static class GameSettingMenuPatches { if (_text is not null && SelectedModIdx == 0) { - _text.text = "Default"; + _text.text = $"(Page 0/{MiraPluginManager.Instance.RegisteredPlugins.Length})\nDefault"; _text.fontSizeMax = 3.2f; SelectedMod = null; } @@ -298,7 +298,7 @@ internal static class GameSettingMenuPatches SelectedMod = MiraPluginManager.Instance.RegisteredPlugins[SelectedModIdx - 1]; var name = SelectedMod.MiraPlugin.OptionsTitleText; - _text.text = name[..Math.Min(name.Length, 25)]; + _text.text = $"(Page {SelectedModIdx}/{MiraPluginManager.Instance.RegisteredPlugins.Length})\n" + name[..Math.Min(name.Length, 25)]; } bool replaceWithModifiers = true; diff --git a/MiraAPI/Patches/Options/OptionsPatches.cs b/MiraAPI/Patches/Options/OptionsPatches.cs index 1a388d8..469a053 100644 --- a/MiraAPI/Patches/Options/OptionsPatches.cs +++ b/MiraAPI/Patches/Options/OptionsPatches.cs @@ -1,7 +1,13 @@ -using HarmonyLib; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; using Il2CppSystem; +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Roles; using MiraAPI.Utilities; +using UnityEngine; +using Object = Il2CppSystem.Object; namespace MiraAPI.Patches.Options; @@ -22,7 +28,9 @@ public static class OptionsPatches __instance.roleMaxCount = 1; } - __instance.roleChance += 10; + var increment = Input.GetKey(KeyCode.LeftShift) ? 5 : 10; + __instance.roleChance += increment; + if (__instance.roleChance > 100) { __instance.roleChance = 0; @@ -46,7 +54,9 @@ public static class OptionsPatches __instance.roleMaxCount = 1; } - __instance.roleChance -= 10; + var increment = Input.GetKey(KeyCode.LeftShift) ? 5 : 10; + __instance.roleChance -= increment; + if (__instance.roleChance < 0) { __instance.roleChance = 100; @@ -107,6 +117,7 @@ public static class OptionsPatches [HarmonyPrefix] [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.AdjustButtonsActiveState))] [HarmonyPatch(typeof(StringOption), nameof(StringOption.AdjustButtonsActiveState))] + [HarmonyPatch(typeof(PlayerOption), nameof(PlayerOption.AdjustButtonsActiveState))] [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.AdjustChanceButtonsActiveState))] [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.AdjustCountButtonsActiveState))] public static bool AdjustButtonsPrefix(OptionBehaviour __instance) @@ -123,6 +134,11 @@ public static class OptionsPatches stringOption.MinusBtn.SetInteractable(true); stringOption.PlusBtn.SetInteractable(true); } + if (__instance.TryCast() is { } playerOption) + { + playerOption.MinusBtn.SetInteractable(true); + playerOption.PlusBtn.SetInteractable(true); + } return false; } @@ -172,6 +188,7 @@ public static class OptionsPatches [HarmonyPrefix] [HarmonyPatch(typeof(StringOption), nameof(StringOption.UpdateValue))] [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.UpdateValue))] + [HarmonyPatch(typeof(PlayerOption), nameof(PlayerOption.UpdateValue))] public static bool UpdateValuePrefix(OptionBehaviour __instance) { return !__instance.IsCustom(); diff --git a/MiraAPI/Patches/Options/PlayerOptionPatches.cs b/MiraAPI/Patches/Options/PlayerOptionPatches.cs new file mode 100644 index 0000000..2b77bc1 --- /dev/null +++ b/MiraAPI/Patches/Options/PlayerOptionPatches.cs @@ -0,0 +1,128 @@ +using System; +using System.Linq; +using HarmonyLib; +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using Reactor.Utilities; +using UnityEngine; +using Object = Il2CppSystem.Object; + +namespace MiraAPI.Patches.Options; + +[HarmonyPatch(typeof(PlayerOption))] +public static class PlayerOptionPatches +{ + // Updates the list of players according to the filter. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.FixedUpdate))] + public static bool PlayerOptionUpdatePatch(PlayerOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + if (!ModdedOptionsManager.CreatedPlayerOptions.TryGetValue(__instance, out var moddedPlrOpt)) return false; + + var filteredPlayers = moddedPlrOpt.GetFilteredPlayers(); + if (filteredPlayers.Count != __instance.Values.Count) + { + filteredPlayers.Sort((a, b) => a.PlayerId.CompareTo(b.PlayerId)); + + __instance.Values = filteredPlayers.ToIl2CppList(); + moddedPlrOpt.Values = __instance.Values; + __instance.playerIndex = filteredPlayers.FindIndex(p => p.PlayerId == __instance.Value); + __instance.SetValueText(); + } + + __instance.CheckValueChanged(); + + return false; + } + + // Disable going under 0 if you aren't allowed to choose None. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.UpdatePlayerIndex))] + public static bool UpdatePlayerIndexPatch(PlayerOption __instance, int index) + { + if (!__instance.IsCustom() || ModdedOptionsManager.CreatedPlayerOptions[__instance].AllowNone) + { + return true; + } + + var newIndex = Mathf.Clamp(index, 0, __instance.Values.Count - 1); + __instance.playerIndex = newIndex; + __instance.Value = __instance.Values[newIndex].PlayerId; + return false; + } + + // Replace "Round Robin" with "None" in the Host Options Menu. + [HarmonyPostfix] + [HarmonyPatch(nameof(PlayerOption.SetValueText))] + public static void SetValueTextPatch(PlayerOption __instance) + { + if (__instance.playerIndex < 0) + { + __instance.ValueText.text = TranslationController.Instance.GetString(StringNames.FilterNone); + } + } + + // Innersloth hardcode literally replaces the value with the HNS impostor option OnEnable, so I have to patch that out. Smh. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.OnEnable))] + public static bool OnEnablePatch(PlayerOption __instance) + { + return !__instance.IsCustom(); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.GetInt))] + public static bool GetIntPatch(PlayerOption __instance, ref int __result) + { + if (!__instance.IsCustom()) + { + return true; + } + + __result = __instance.Value; + return false; + } + + // Used to replace "Round Robin" with "None" for option notification & view pane. + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerSelectionGameSetting), nameof(PlayerSelectionGameSetting.GetValueString))] + public static bool GetStringPatch(PlayerSelectionGameSetting __instance, float value, ref string __result) + { + var option = ModdedOptionsManager.ModdedOptions.FirstOrDefault(x => x.Value.Data == __instance).Value; + if (option == null) return true; + + var networkedPlayerInfo = GameData.Instance.AllPlayers.ToArray().FirstOrDefault(p => p.PlayerId == (int)value); + if (networkedPlayerInfo != null) + { + __result = networkedPlayerInfo.PlayerName; + return false; + } + + __result = TranslationController.Instance.GetString(StringNames.FilterNone); + return false; + } + + // "UpdateValue" was inlined and I needed to replace it, so I just rewrote the method where it's called from. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.CheckValueChanged))] + public static bool UpdateValuePatch(PlayerOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + if (__instance.oldValue == __instance.Value) return false; + + __instance.oldValue = __instance.Value; + __instance.SetValueText(); + + return false; + } +} diff --git a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs index 71ecdc1..80f96c9 100644 --- a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; +using MiraAPI.GameOptions; using MiraAPI.Networking; using MiraAPI.Roles; using MiraAPI.Utilities; @@ -30,6 +31,9 @@ public static class RoleSettingMenuPatches private static float ScrollerNum { get; set; } = 0.522f; + private static RoleBehaviour? CurrentRole { get; set; } + private static List? CurrentRoleOptions { get; set; } + [HarmonyPostfix] [HarmonyPatch(nameof(RolesSettingsMenu.SetQuotaTab))] public static void SetQuotaTabPostfix(RolesSettingsMenu __instance) @@ -45,6 +49,8 @@ public static class RoleSettingMenuPatches RoleOptionSettings.ForEach(Object.Destroy); Headers.Clear(); RoleOptionSettings.Clear(); + CurrentRole = null; + CurrentRoleOptions = null; __instance.roleChances = new Il2CppSystem.Collections.Generic.List(); __instance.advancedSettingChildren = new Il2CppSystem.Collections.Generic.List(); @@ -262,9 +268,31 @@ public static class RoleSettingMenuPatches [HarmonyPrefix] [HarmonyPatch(nameof(RolesSettingsMenu.Update))] - public static bool UpdatePatch() + public static bool UpdatePatch(RolesSettingsMenu __instance) { - return GameSettingMenuPatches.SelectedModIdx == 0; + if (GameSettingMenuPatches.SelectedModIdx == 0) return true; + if (CurrentRole == null || CurrentRoleOptions == null) return false; + + var hasImage = CurrentRole.RoleScreenshot != null; + var num = hasImage ? -0.872f : -1; + + foreach (var opt in CurrentRoleOptions) + { + if (opt.OptionBehaviour == null) continue; + + if (!opt.Visible.Invoke()) + { + opt.OptionBehaviour.gameObject.SetActive(false); + continue; + } + + opt.OptionBehaviour.transform.localPosition = new Vector3(hasImage ? 2.17f : 1.1f, num, -2f); + opt.OptionBehaviour.gameObject.SetActive(true); + num += -0.45f; + } + + __instance.scrollBar.SetYBoundsMax(-num - 3); + return false; } [HarmonyPostfix] @@ -276,6 +304,8 @@ public static class RoleSettingMenuPatches return; } + CurrentRole = null; + CurrentRoleOptions = null; __instance.scrollBar.SetScrollBounds(__instance); } @@ -328,27 +358,26 @@ public static class RoleSettingMenuPatches optBehaviour.gameObject.DestroyImmediate(); } + CurrentRole = role; __instance.advancedSettingChildren.Clear(); - var hasImage = role.RoleScreenshot != null; - var num = hasImage ? -0.872f : -1; - // TODO: create sub groups under the role settings. var filteredOptions = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups .Where(x => x.GroupVisible() && x.OptionableType == role.GetType()) .SelectMany(x => x.Options) - .Where(x => x.Visible()) .ToList() ?? []; + CurrentRoleOptions = filteredOptions; + foreach (var option in filteredOptions) { var newOpt = option.CreateOption( __instance.checkboxOrigin, __instance.numberOptionOrigin, __instance.stringOptionOrigin, + GameSettingMenu.Instance.GameSettingsTab.playerOptionOrigin, __instance.AdvancedRolesSettings.transform); - newOpt.transform.localPosition = new Vector3(hasImage ? 2.17f : 1.1f, num, -2f); newOpt.SetClickMask(__instance.ButtonClickMask); SpriteRenderer[] componentsInChildren = newOpt.GetComponentsInChildren(true); @@ -366,11 +395,10 @@ public static class RoleSettingMenuPatches newOpt.LabelBackground.enabled = false; __instance.advancedSettingChildren.Add(newOpt); - num += -0.45f; + newOpt.gameObject.SetActive(false); newOpt.Initialize(); } - __instance.scrollBar.SetYBoundsMax(-num - 3); __instance.scrollBar.ScrollToTop(); } @@ -506,7 +534,7 @@ public static class RoleSettingMenuPatches if (GameSettingMenuPatches.SelectedMod is null || GameSettingMenuPatches.SelectedMod.InternalOptionGroups - .Exists(x => x.OptionableType == role.GetType())) + .Exists(x => x.GroupVisible() && x.OptionableType == role.GetType() && x.Options.Any(y => y.Visible()))) { var newButton = Object.Instantiate(roleOptionSetting.buttons[0], roleOptionSetting.transform); newButton.name = "ConfigButton"; diff --git a/MiraAPI/Patches/PlayerControlPatches.cs b/MiraAPI/Patches/PlayerControlPatches.cs index c93aa6d..a0b88cd 100644 --- a/MiraAPI/Patches/PlayerControlPatches.cs +++ b/MiraAPI/Patches/PlayerControlPatches.cs @@ -7,6 +7,7 @@ using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Utilities; using MiraAPI.Voting; +using Reactor.Utilities; using Reactor.Utilities.Extensions; namespace MiraAPI.Patches; @@ -94,12 +95,19 @@ internal static class PlayerControlPatches continue; } - if (!button.Enabled(__instance.Data?.Role)) + try { - continue; - } + if (!button.Enabled(__instance.Data?.Role)) + { + continue; + } - button.FixedUpdateHandler(__instance); + button.FixedUpdateHandler(__instance); + } + catch (System.Exception e) + { + Logger.Error($"Failed to update custom button {button.GetType().Name}: {e}"); + } } } } diff --git a/MiraAPI/Patches/Roles/EjectionPatches.cs b/MiraAPI/Patches/Roles/EjectionPatches.cs index 85641eb..009da27 100644 --- a/MiraAPI/Patches/Roles/EjectionPatches.cs +++ b/MiraAPI/Patches/Roles/EjectionPatches.cs @@ -38,8 +38,13 @@ internal static class EjectionPatches { public static void Postfix() { - var @event = new RoundStartEvent(false); + var @event = new BeforeRoundStartEvent(false); MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + + var @event2 = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event2); } } } diff --git a/MiraAPI/Patches/Roles/GameStartupPatch.cs b/MiraAPI/Patches/Roles/GameStartupPatch.cs index 6ab234d..9f88bdd 100644 --- a/MiraAPI/Patches/Roles/GameStartupPatch.cs +++ b/MiraAPI/Patches/Roles/GameStartupPatch.cs @@ -23,7 +23,11 @@ public static class GameStartupPatch _runOnce = true; - if (MiraPluginManager.Instance.QueuedRoleRegistrations.Count <= 0) return; + if (MiraPluginManager.Instance.QueuedRoleRegistrations.Count <= 0) + { + return; + } + foreach (var queue in MiraPluginManager.Instance.QueuedRoleRegistrations) { CustomRoleManager.RegisterRoleTypes(queue.Value, queue.Key); diff --git a/MiraAPI/Patches/Roles/HudManagerPatches.cs b/MiraAPI/Patches/Roles/HudManagerPatches.cs index 29affce..348f739 100644 --- a/MiraAPI/Patches/Roles/HudManagerPatches.cs +++ b/MiraAPI/Patches/Roles/HudManagerPatches.cs @@ -12,7 +12,7 @@ namespace MiraAPI.Patches.Roles; public static class HudManagerPatches { // Custom role tab. - private static TaskPanelBehaviour? _roleTab; + public static TaskPanelBehaviour? RoleTab; /// /// Fixes Kill Button not showing for Neutral killing role. @@ -38,9 +38,9 @@ public static class HudManagerPatches __instance.SabotageButton.gameObject.SetActive(isActive && customRole.Configuration.CanUseSabotage); } - if (_roleTab) + if (RoleTab) { - _roleTab?.gameObject.SetActive(isActive); + RoleTab?.gameObject.SetActive(isActive); } } @@ -62,16 +62,16 @@ public static class HudManagerPatches if (role is ICustomRole { Configuration.RoleHintType: RoleHintType.RoleTab } customRole) { - if (_roleTab == null) + if (RoleTab == null) { - _roleTab = CustomRoleManager.CreateRoleTab(customRole); + RoleTab = CustomRoleManager.CreateRoleTab(customRole); } - CustomRoleManager.UpdateRoleTab(_roleTab, customRole); + CustomRoleManager.UpdateRoleTab(RoleTab, customRole); } - else if (_roleTab) + else if (RoleTab) { - _roleTab?.gameObject.Destroy(); + RoleTab?.gameObject.Destroy(); } } } diff --git a/MiraAPI/Patches/ServerDropdownPatch.cs b/MiraAPI/Patches/ServerDropdownPatch.cs index 0fb96e3..36da2d7 100644 --- a/MiraAPI/Patches/ServerDropdownPatch.cs +++ b/MiraAPI/Patches/ServerDropdownPatch.cs @@ -1,6 +1,7 @@ using HarmonyLib; using UnityEngine; using UnityEngine.Events; +using UnityEngine.SceneManagement; namespace MiraAPI.Patches; @@ -14,6 +15,8 @@ public static class ServerDropdownPatch foreach (var regionInfo in DestroyableSingleton.Instance.AvailableRegions) { + var findingGame = SceneManager.GetActiveScene().name is "FindAGame"; + if (DestroyableSingleton.Instance.CurrentRegion.Equals(regionInfo)) { __instance.defaultButtonSelected = __instance.firstOption; @@ -27,6 +30,10 @@ public static class ServerDropdownPatch var region = regionInfo; var serverListButton = __instance.ButtonPool.Get(); var x = num % 2 == 0 ? -2 : 2; + if (findingGame) + { + x += 2; + } var y = -0.55f * (num / 2); serverListButton.transform.localPosition = new Vector3(x, __instance.y_posButton + y, -1f); serverListButton.transform.localScale = Vector3.one; @@ -39,7 +46,7 @@ public static class ServerDropdownPatch serverListButton.Button.OnClick.AddListener((UnityAction)(() => { __instance.ChooseOption(region); })); __instance.controllerSelectable.Add(serverListButton.Button); __instance.background.transform.localPosition = new Vector3( - 0f, + findingGame ? 2f : 0f, __instance.initialYPos + (-0.3f * (num / 2)), 0f); __instance.background.size = new Vector2(__instance.background.size.x, 1.2f + (0.6f * (num / 2))); diff --git a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs index a276946..7652d4f 100644 --- a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs +++ b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs @@ -1,16 +1,79 @@ -using HarmonyLib; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using BepInEx.Unity.IL2CPP.Utils.Collections; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting.Voting; using MiraAPI.Utilities; +using MiraAPI.Voting; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using UnityEngine; namespace MiraAPI.Patches.Voting; -[HarmonyPatch(typeof(DummyBehaviour), nameof(DummyBehaviour.Update))] -internal static class DummyBehaviourPatch +[HarmonyPatch(typeof(DummyBehaviour))] +internal static class DummyBehaviourPatches { - public static void Postfix(DummyBehaviour __instance) + [HarmonyPrefix] + [HarmonyPatch(nameof(DummyBehaviour.Update))] + public static bool DummyUpdatePatch(DummyBehaviour __instance) { - if (MeetingHud.Instance && __instance.myPlayer.GetVoteData()) + NetworkedPlayerInfo data = __instance.myPlayer.Data; + if (data == null || data.IsDead) return false; + + if (MeetingHud.Instance) { - __instance.voted = __instance.myPlayer.GetVoteData().VotesRemaining == 0; + if (!__instance.voted) + { + __instance.StartCoroutine(CoDoVote(__instance).WrapToIl2Cpp()); + } + } + else + { + __instance.voted = false; + } + + return false; + } + + private static IEnumerator CoDoVote(DummyBehaviour dummy) + { + Logger.Warning(dummy.voted); + dummy.voted = true; + yield return new WaitForSeconds(dummy.voteTime.Next()); + + var dummyVoteEvent = new DummyVoteEvent(dummy); + MiraEventManager.InvokeEvent(dummyVoteEvent); + + if (dummyVoteEvent.IsCancelled) + { + dummy.voted = false; + yield break; + } + + List potentialSuspects = new(); + potentialSuspects.AddRange(PlayerControl.AllPlayerControls + .ToArray() + .Where(p => p != dummy.myPlayer) + .Where(p => dummyVoteEvent.PlayerIsValid(p)) + .Select(p => p.PlayerId)); + + if (dummyVoteEvent.CanSkip) + { + potentialSuspects.Add(253); + } + + if (CanVote(dummy)) + { + VotingUtils.RpcCastVote(PlayerControl.LocalPlayer, dummy.myPlayer.PlayerId, potentialSuspects.Random()); } } + + private static bool CanVote(this DummyBehaviour dummy) + { + var dummyVoteData = dummy.myPlayer.GetVoteData(); + return dummyVoteData.VotesRemaining > 0; + } } diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 9dae0fd..02e9102 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -11,12 +11,14 @@ using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Modifiers; using MiraAPI.Presets; using MiraAPI.Roles; using MiraAPI.Utilities; using Reactor.Networking; using Reactor.Utilities; +using UnityEngine; namespace MiraAPI.PluginLoading; @@ -129,7 +131,16 @@ public sealed class MiraPluginManager CustomButtonManager.Buttons = new ReadOnlyCollection(CustomButtonManager.CustomButtons); // Cache all the registered plugins into an array for easy access - RegisteredPlugins = [.._registeredPlugins.Values]; + RegisteredPlugins = [.. _registeredPlugins.Values]; + + ModifierManager.Modifiers = new ReadOnlyCollection(ModifierManager.InternalModifiers); + + foreach (var button in CustomButtonManager.Buttons) + { + KeybindManager.Register($"{button.Name}_Keybind", $"Keybind for {button.Name}", button.Defaultkeybind, button.ClickHandler, modifier1: button.Modifier1, modifier2: button.Modifier2, modifier3: button.Modifier3); + + Logger.Info($"Registered keybind for button '{button.GetType().Name}' with default key {button.Defaultkeybind}."); + } }; } diff --git a/MiraAPI/Presets/SavePresetPopup.cs b/MiraAPI/Presets/SavePresetPopup.cs index 9449857..87959e7 100644 --- a/MiraAPI/Presets/SavePresetPopup.cs +++ b/MiraAPI/Presets/SavePresetPopup.cs @@ -96,6 +96,3 @@ public class SavePresetPopup(nint cppPtr) : Minigame(cppPtr) popup.Begin(null); } } - - - diff --git a/MiraAPI/Resources/mirabundle-win.bundle b/MiraAPI/Resources/mirabundle-win.bundle index d73dcbe..06d8340 100644 Binary files a/MiraAPI/Resources/mirabundle-win.bundle and b/MiraAPI/Resources/mirabundle-win.bundle differ diff --git a/MiraAPI/Roles/CustomRoleManager.cs b/MiraAPI/Roles/CustomRoleManager.cs index 20d61d0..5e1c3ae 100644 --- a/MiraAPI/Roles/CustomRoleManager.cs +++ b/MiraAPI/Roles/CustomRoleManager.cs @@ -35,6 +35,16 @@ public static class CustomRoleManager public static readonly LoadableAsset ImpostorIntroSound = CustomRoleUtils.GetIntroSound(RoleTypes.Impostor)!; + /// + /// Gets the list of custom roles as RoleBehaviour objects. + /// + public static IReadOnlyList CustomRoleBehaviours { get; private set; } = []; + + /// + /// Gets the list of custom roles as ICustomRole objects. + /// + public static IReadOnlyList CustomMiraRoles { get; private set; } = []; + internal static readonly Dictionary CustomRoles = []; internal static readonly Dictionary RoleIds = []; @@ -53,6 +63,9 @@ public static class CustomRoleManager { RoleManager.GhostRoles.Add(role.Role); } + + CustomRoleBehaviours = CustomRoles.Values.ToList(); + CustomMiraRoles = CustomRoles.Values.OfType().ToList(); } internal static void RegisterRoleTypes(List roles, MiraPluginInfo pluginInfo) diff --git a/MiraAPI/Utilities/EnumerableExtensions.cs b/MiraAPI/Utilities/EnumerableExtensions.cs new file mode 100644 index 0000000..5e0bc35 --- /dev/null +++ b/MiraAPI/Utilities/EnumerableExtensions.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MiraAPI.Utilities; + +/// +/// Extensions for IEnumerable. +/// +public static class EnumerableExtensions +{ + /// + /// Shuffle the elements of the source enumerable using a new instance of Random. + /// + /// The source enumerable. + /// The type of the elements. + /// >The shuffled enumerable. + public static IEnumerable Shuffle(this IEnumerable source) + { + return source.Shuffle(new Random()); + } + + /// + /// Shuffle the elements of the source enumerable using the provided Random instance. + /// + /// The source enumerable. + /// The Random instance to use for shuffling. + /// The type of the elements. + /// >The shuffled enumerable. + public static IEnumerable Shuffle(this IEnumerable source, Random rng) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(rng); + + return source.ShuffleIterator(rng); + } + + private static IEnumerable ShuffleIterator(this IEnumerable source, Random rng) + { + var buffer = source.ToList(); + for (var i = 0; i < buffer.Count; i++) + { + var j = rng.Next(i, buffer.Count); + yield return buffer[j]; + + buffer[j] = buffer[i]; + } + } +} diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index 64e02f9..863df73 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -10,6 +10,8 @@ using MiraAPI.Networking; using MiraAPI.Roles; using MiraAPI.Voting; using Reactor.Utilities; +using Rewired; +using Rewired.Data; using TMPro; using UnityEngine; @@ -92,7 +94,7 @@ public static class Extensions /// The current timer value. /// The maximum timer value. /// The format string to use for the timer text. - public static void SetCooldownFormat(this ActionButton button, float timer, float maxTimer, string format="0") + public static void SetCooldownFormat(this ActionButton button, float timer, float maxTimer, string format = "0") { var num = Mathf.Clamp(timer / maxTimer, 0f, 1f); button.isCoolingDown = num > 0f; @@ -113,7 +115,7 @@ public static class Extensions /// The current timer value. /// The maximum timer value. /// The format string to use for the timer text. - public static void SetFillUpFormat(this ActionButton button, float timer, float maxTimer, string format="0") + public static void SetFillUpFormat(this ActionButton button, float timer, float maxTimer, string format = "0") { var num = Mathf.Clamp(timer / maxTimer, 0f, 1f); button.isCoolingDown = num > 0f; @@ -154,6 +156,24 @@ public static class Extensions return TutorialManager.InstanceExists || AmongUsClient.Instance.HostId == playerControl.OwnerId; } + /// + /// Used to convert a System.Collections.Generic.List to Il2cppSystem. + /// + /// The list. + /// The type in the list. + /// The converted list. + public static Il2CppSystem.Collections.Generic.List ToIl2CppList(this List systemList) + { + var il2cppList = new Il2CppSystem.Collections.Generic.List(); + + foreach (var item in systemList) + { + il2cppList.Add(item); + } + + return il2cppList; + } + /// /// Determines if a float is an integer. /// @@ -495,6 +515,7 @@ public static class Extensions /// Whether impostors should be included in the search. /// The radius to search within. /// Whether colliders should be ignored when searching. + /// Determines if Ghosts are included. /// Optional predicate to test if the object is valid. /// The closest player if there is one, false otherwise. public static PlayerControl? GetClosestPlayer( @@ -502,16 +523,16 @@ public static class Extensions bool includeImpostors, float distance, bool ignoreColliders = false, + bool includeGhosts = false, Predicate? predicate = null) { var filteredPlayers = Helpers.GetClosestPlayers(playerControl, distance, ignoreColliders) .Where( playerInfo => !playerInfo.Data.Disconnected && playerInfo.PlayerId != playerControl.PlayerId && - !playerInfo.Data.IsDead && + (includeGhosts || !playerInfo.Data.IsDead) && (includeImpostors || !playerInfo.Data.Role.IsImpostor)) .ToList(); - return predicate != null ? filteredPlayers.Find(predicate) : filteredPlayers.FirstOrDefault(); } @@ -526,4 +547,49 @@ public static class Extensions renderer.material.SetColor(ShaderID.OutlineColor, color ?? Color.clear); renderer.material.SetColor(ShaderID.AddColor, color ?? Color.clear); } + + // Inspired by: https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/Keybinds.cs#L29 + + /// + /// Registers a new mod keybind as a user-assignable button action in Rewired. + /// + /// The Rewired user data to add the action to. + /// The internal name of the action. + /// Text shown in the rebinding UI. + /// The default key to assign to this action. + /// Category ID to group actions in Rewired (default is 0). + /// The element identifier ID (default is -1, meaning none specified). + /// The for this action (default is Button). + /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held together with the main key. + /// The second optional modifier key. Set to if not used. + /// The third optional modifier key. Set to if not used. + /// The action ID of the newly registered action. + public static int RegisterModBind(this UserData userData, string actionName, string description, KeyboardKeyCode key, int category = 0, int elementIdentifierId = -1, InputActionType type = InputActionType.Button, ModifierKey modifier1 = ModifierKey.None, ModifierKey modifier2 = ModifierKey.None, ModifierKey modifier3 = ModifierKey.None) + { + userData.AddAction(category); + + var action = userData.GetAction(userData.actions.Count - 1)!; + + action.name = actionName; + action.descriptiveName = description; + action.categoryId = category; + action.type = type; + action.userAssignable = true; + + var map = new ActionElementMap + { + _elementIdentifierId = elementIdentifierId, + _actionId = action.id, + _elementType = ControllerElementType.Button, + _axisContribution = Pole.Positive, + _keyboardKeyCode = key, + _modifierKey1 = modifier1, + _modifierKey2 = modifier2, + _modifierKey3 = modifier3, + }; + userData.keyboardMaps[0].actionElementMaps.Add(map); + userData.joystickMaps[0].actionElementMaps.Add(map); + + return action.id; + } } diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index 7d8ce9b..2bf7b37 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -1,7 +1,11 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Il2CppSystem.IO; +using MiraAPI.Keybinds; +using Rewired; using TMPro; using UnityEngine; using Object = UnityEngine.Object; @@ -19,7 +23,7 @@ public static class Helpers /// A list of alive players. public static List GetAlivePlayers() { - return [.. GameData.Instance.AllPlayers.ToArray().Where(x => !x.IsDead && !x.Disconnected && x.Object).Select(x=>x.Object)]; + return [.. GameData.Instance.AllPlayers.ToArray().Where(x => !x.IsDead && !x.Disconnected && x.Object).Select(x => x.Object)]; } /// @@ -36,10 +40,10 @@ public static class Helpers case 100: return true; default: - { - var num = Random.RandomRangeInt(1, 101); - return num <= probability; - } + { + var num = UnityEngine.Random.RandomRangeInt(1, 101); + return num <= probability; + } } } @@ -398,6 +402,25 @@ public static class Helpers public static string RandomString(int length, string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") { return new string(Enumerable.Repeat(chars, length) - .Select(s => s[Random.RandomRangeInt(0, s.Length)]).ToArray()); + .Select(s => s[UnityEngine.Random.RandomRangeInt(0, s.Length)]).ToArray()); + } + + /// + /// Finds and returns an unused KeyCode that is not equal to the excluded key. + /// + /// The KeyCode to skip during the search. + /// + /// The first available KeyCode not currently used by any registered keybind, + /// or KeyCode.None if none are available. + /// + public static KeyboardKeyCode FindAvailableKey(KeyboardKeyCode exclude) + { + foreach (KeyboardKeyCode key in Enum.GetValues(typeof(KeyboardKeyCode))) + { + if (key == exclude) continue; + bool used = KeybindManager.GetEntries().Exists(e => e.Key == key); + if (!used) return key; + } + return KeyboardKeyCode.None; } }