diff --git a/AmongUs.props b/AmongUs.props
index e0d7f3c..6f6e0f9 100644
--- a/AmongUs.props
+++ b/AmongUs.props
@@ -2,12 +2,12 @@
-
+
-
+
diff --git a/Directory.Build.props b/Directory.Build.props
index c8f52ba..ac800b3 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,7 +8,7 @@
true
- 0.2.1
+ 0.3.0
dev
All Of Us, XtraCube, Angxl
diff --git a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs
index 4edd4f8..4da744d 100644
--- a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs
+++ b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs
@@ -3,10 +3,10 @@ using MiraAPI.Example.Options.Roles;
using MiraAPI.Example.Roles;
using MiraAPI.GameOptions;
using MiraAPI.Hud;
+using MiraAPI.Keybinds;
using MiraAPI.Modifiers;
using MiraAPI.Utilities;
using MiraAPI.Utilities.Assets;
-using Rewired;
using UnityEngine;
namespace MiraAPI.Example.Buttons.Freezer;
@@ -16,12 +16,10 @@ public class FreezeButton : CustomActionButton
public override string Name => "Freeze";
public override float Cooldown => OptionGroupSingleton.Instance.FreezeDuration;
-
- public override int MaxUses => (int)OptionGroupSingleton.Instance.FreezeUses;
+ public override bool PauseTimerInVent => true;
public override LoadableAsset Sprite => ExampleAssets.ExampleButton;
- public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.T;
- public override ModifierKey Modifier1 => ModifierKey.Control;
+ public override MiraKeybind? Keybind => MiraGlobalKeybinds.PrimaryAbility;
protected override void OnClick()
{
diff --git a/MiraAPI.Example/Buttons/MeetingButton.cs b/MiraAPI.Example/Buttons/MeetingButton.cs
index c0acc20..8ca1b82 100644
--- a/MiraAPI.Example/Buttons/MeetingButton.cs
+++ b/MiraAPI.Example/Buttons/MeetingButton.cs
@@ -1,8 +1,8 @@
using MiraAPI.Example.Modifiers;
using MiraAPI.Hud;
+using MiraAPI.Keybinds;
using MiraAPI.Modifiers;
using MiraAPI.Utilities.Assets;
-using Rewired;
using UnityEngine;
namespace MiraAPI.Example.Buttons;
@@ -16,9 +16,7 @@ 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 MiraKeybind? Keybind => MiraGlobalKeybinds.ModifierPrimaryAbility;
public override bool Enabled(RoleBehaviour? role)
{
diff --git a/MiraAPI.Example/Buttons/NeutralKillerButton.cs b/MiraAPI.Example/Buttons/NeutralKillerButton.cs
index 02fc908..3763577 100644
--- a/MiraAPI.Example/Buttons/NeutralKillerButton.cs
+++ b/MiraAPI.Example/Buttons/NeutralKillerButton.cs
@@ -2,8 +2,8 @@
using MiraAPI.Example.Roles;
using MiraAPI.GameEnd;
using MiraAPI.Hud;
+using MiraAPI.Keybinds;
using MiraAPI.Utilities.Assets;
-using Rewired;
using UnityEngine;
namespace MiraAPI.Example.Buttons;
@@ -13,7 +13,8 @@ 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;
+ public override MiraKeybind Keybind => ExampleKeybinds.NeutralWinKeybind;
+
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 c4af2ca..b59d387 100644
--- a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs
+++ b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs
@@ -3,9 +3,9 @@ using MiraAPI.Example.Options.Roles;
using MiraAPI.Example.Roles;
using MiraAPI.GameOptions;
using MiraAPI.Hud;
+using MiraAPI.Keybinds;
using MiraAPI.Utilities.Assets;
using Reactor.Utilities;
-using Rewired;
using UnityEngine;
namespace MiraAPI.Example.Buttons.Teleporter;
@@ -22,7 +22,8 @@ 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 MiraKeybind? Keybind => MiraGlobalKeybinds.PrimaryAbility;
+
public override bool Enabled(RoleBehaviour? role)
{
return role is TeleporterRole;
diff --git a/MiraAPI.Example/Buttons/ThinkButton.cs b/MiraAPI.Example/Buttons/ThinkButton.cs
new file mode 100644
index 0000000..a6f6855
--- /dev/null
+++ b/MiraAPI.Example/Buttons/ThinkButton.cs
@@ -0,0 +1,35 @@
+using MiraAPI.Hud;
+using MiraAPI.Utilities.Assets;
+using UnityEngine;
+
+namespace MiraAPI.Example.Buttons;
+
+public class ThinkButton : CustomActionButton
+{
+ public override string Name => "Think";
+ public override float Cooldown => 15f;
+ public override float EffectDuration => 10f;
+ public override int MaxUses => 1;
+ public override ButtonUsesMode UsesMode => ButtonUsesMode.PerRound;
+ public override LoadableAsset Sprite => ExampleAssets.ExampleButton;
+ protected override void OnClick()
+ {
+ // This button does absolutely nothing besides changing its text
+ Button!.OverrideText("Thinking...");
+ }
+
+ public override void OnEffectEnd()
+ {
+ Button!.OverrideText("Think");
+ }
+
+ public override bool IsEffectCancellable()
+ {
+ return true;
+ }
+
+ public override bool Enabled(RoleBehaviour? role)
+ {
+ return role != null && role.IsImpostor;
+ }
+}
diff --git a/MiraAPI.Example/ExampleKeybinds.cs b/MiraAPI.Example/ExampleKeybinds.cs
new file mode 100644
index 0000000..dba6b67
--- /dev/null
+++ b/MiraAPI.Example/ExampleKeybinds.cs
@@ -0,0 +1,10 @@
+using MiraAPI.Keybinds;
+using Rewired;
+
+namespace MiraAPI.Example;
+
+[RegisterCustomKeybinds]
+public static class ExampleKeybinds
+{
+ public static MiraKeybind NeutralWinKeybind { get; } = new("Neutral Win", KeyboardKeyCode.C, [ModifierKey.Shift]);
+}
diff --git a/MiraAPI.Example/ExampleLocalSetings.cs b/MiraAPI.Example/ExampleLocalSetings.cs
new file mode 100644
index 0000000..bac0507
--- /dev/null
+++ b/MiraAPI.Example/ExampleLocalSetings.cs
@@ -0,0 +1,53 @@
+using BepInEx.Configuration;
+using MiraAPI.LocalSettings;
+using MiraAPI.LocalSettings.Attributes;
+using MiraAPI.Utilities;
+using UnityEngine;
+
+namespace MiraAPI.Example;
+
+public class ExampleLocalSettings(ConfigFile config) : LocalSettingsTab(config)
+{
+ public override string TabName => "Mira Example";
+ protected override bool ShouldCreateLabels => false;
+
+ public override LocalSettingTabAppearance TabAppearance => new()
+ {
+ ToggleActiveColor = Color.blue,
+ ToggleInactiveColor = Color.red,
+ };
+
+ [LocalSettingsButton]
+ public LocalSettingsButton ExampleButton { get; private set; } = new("Example Button", OnExampleButtonClick);
+
+ [LocalToggleSetting]
+ public ConfigEntry ExampleToggle { get; private set; } = config.Bind("General", "Example Bool", true);
+
+ [LocalToggleSetting]
+ public ConfigEntry ExampleToggle2 { get; private set; } = config.Bind("General", "Example Bool 2", false);
+
+ [LocalSliderSetting(suffixType: MiraNumberSuffixes.Percent, formatString: "0", roundValue: true)]
+ public ConfigEntry ExampleSlider { get; private set; } = config.Bind("General", "Slider", 50f);
+
+ [LocalNumberSetting]
+ public ConfigEntry ExampleNumber { get; private set; } = config.Bind("General", "Example Number", 4f);
+
+ [LocalNumberSetting(min: 0.1f, max: 2.5f, increment: 0.1f, formatString: "0.0", suffixType: MiraNumberSuffixes.Multiplier)]
+ public ConfigEntry ExampleFloatNumber { get; private set; } = config.Bind("General", "Example Multplier", 1f);
+
+ [LocalEnumSetting]
+ public ConfigEntry ExampleEnum { get; private set; } = config.Bind("General", "Example Enum", ExampleEnumSetting.Fries);
+
+ private static void OnExampleButtonClick()
+ {
+ LocalSettingsTabSingleton.Instance.Open();
+ }
+}
+
+public enum ExampleEnumSetting
+{
+ Cheeseburger,
+ Sandwich,
+ Fries,
+ ChickenNuggets,
+}
diff --git a/MiraAPI.Example/ExamplePlugin.cs b/MiraAPI.Example/ExamplePlugin.cs
index b20ec41..0aed9c4 100644
--- a/MiraAPI.Example/ExamplePlugin.cs
+++ b/MiraAPI.Example/ExamplePlugin.cs
@@ -1,4 +1,5 @@
-using BepInEx;
+using System.Linq;
+using BepInEx;
using BepInEx.Configuration;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
diff --git a/MiraAPI/Events/Vanilla/Gameplay/IntroRoleRevealEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/IntroRoleRevealEvent.cs
new file mode 100644
index 0000000..809ce11
--- /dev/null
+++ b/MiraAPI/Events/Vanilla/Gameplay/IntroRoleRevealEvent.cs
@@ -0,0 +1,21 @@
+namespace MiraAPI.Events.Vanilla.Gameplay;
+
+///
+/// The event that is invoked when the player's role is shown on the intro cutscene. Non cancelable.
+///
+public class IntroRoleRevealEvent : MiraEvent
+{
+ ///
+ /// Gets the instance of the IntroCutscene.
+ ///
+ public IntroCutscene IntroCutscene { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The intro cutscene.
+ public IntroRoleRevealEvent(IntroCutscene introCutscene)
+ {
+ IntroCutscene = introCutscene;
+ }
+}
diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/AfterVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/AfterVoteEvent.cs
new file mode 100644
index 0000000..714d3b8
--- /dev/null
+++ b/MiraAPI/Events/Vanilla/Meeting/Voting/AfterVoteEvent.cs
@@ -0,0 +1,30 @@
+using MiraAPI.Voting;
+
+namespace MiraAPI.Events.Vanilla.Meeting.Voting;
+
+///
+/// Event that is invoked after the local player successfully votes a player or skips. This event is not cancelable.
+///
+public class AfterVoteEvent : MiraEvent
+{
+ ///
+ /// Gets the instance of the voter's vote data.
+ ///
+ public PlayerVoteArea VoteArea { get; }
+
+ ///
+ /// Gets the player who voted.
+ ///
+ public PlayerControl Player { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player vote area that was voted on.
+ /// The player who voted.
+ public AfterVoteEvent(PlayerVoteArea playerVoteArea, PlayerControl voter)
+ {
+ VoteArea = playerVoteArea;
+ Player = voter;
+ }
+}
diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/BeforeVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/BeforeVoteEvent.cs
new file mode 100644
index 0000000..1d8d907
--- /dev/null
+++ b/MiraAPI/Events/Vanilla/Meeting/Voting/BeforeVoteEvent.cs
@@ -0,0 +1,30 @@
+using MiraAPI.Voting;
+
+namespace MiraAPI.Events.Vanilla.Meeting.Voting;
+
+///
+/// Event that is invoked before the local player confirms a vote on a player or when skipping. This event is cancelable.
+///
+public class BeforeVoteEvent : MiraCancelableEvent
+{
+ ///
+ /// Gets the instance of the voter's vote data.
+ ///
+ public PlayerVoteArea VoteArea { get; }
+
+ ///
+ /// Gets the player who voted.
+ ///
+ public PlayerControl Player { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player vote area that was voted on.
+ /// The player who voted.
+ public BeforeVoteEvent(PlayerVoteArea playerVoteArea, PlayerControl voter)
+ {
+ VoteArea = playerVoteArea;
+ Player = voter;
+ }
+}
diff --git a/MiraAPI/Hud/ButtonUsesMode.cs b/MiraAPI/Hud/ButtonUsesMode.cs
new file mode 100644
index 0000000..2c43300
--- /dev/null
+++ b/MiraAPI/Hud/ButtonUsesMode.cs
@@ -0,0 +1,17 @@
+namespace MiraAPI.Hud;
+
+///
+/// The uses mode for custom buttons.
+///
+public enum ButtonUsesMode
+{
+ ///
+ /// Uses per game.
+ ///
+ PerGame,
+
+ ///
+ /// Uses per round.
+ ///
+ PerRound,
+}
diff --git a/MiraAPI/Hud/CustomActionButton.cs b/MiraAPI/Hud/CustomActionButton.cs
index 78fa114..6a886ba 100644
--- a/MiraAPI/Hud/CustomActionButton.cs
+++ b/MiraAPI/Hud/CustomActionButton.cs
@@ -1,12 +1,13 @@
using System;
using System.Globalization;
-using System.Linq;
using MiraAPI.Events;
using MiraAPI.Events.Mira;
using MiraAPI.Keybinds;
+using MiraAPI.LocalSettings;
using MiraAPI.Patches;
using MiraAPI.Utilities;
using MiraAPI.Utilities.Assets;
+using Reactor.Utilities;
using Rewired;
using TMPro;
using UnityEngine;
@@ -36,6 +37,12 @@ public abstract class CustomActionButton
///
public abstract float Cooldown { get; }
+ ///
+ /// Gets a value indicating whether the timer should stop decreasing when the player is venting.
+ /// Note that both cooldown decrease and effect timer decrease is disabled.
+ ///
+ public virtual bool PauseTimerInVent => false;
+
///
/// Gets the sprite of the button. Use to load a sprite from a resource path. Use to load a sprite from an asset bundle.
///
@@ -47,7 +54,12 @@ public abstract class CustomActionButton
public virtual string CooldownTimerFormatString => "0";
///
- /// Gets the button's effect duration in seconds. If the button has no effect, set to 0.
+ /// Gets a value indicating whether the button has an effect ability. By default true if is greater than 0.
+ ///
+ public virtual bool HasEffect => EffectDuration > 0;
+
+ ///
+ /// Gets the button's effect duration in seconds. If 0, the effect needs to be ended manually.
///
public virtual float EffectDuration => 0;
@@ -57,26 +69,14 @@ public abstract class CustomActionButton
public virtual int MaxUses => 0;
///
- /// Gets or sets the current key binding for this button.
+ /// Gets the value indicating uses mode.
///
- public abstract KeyboardKeyCode Defaultkeybind { get; }
+ public virtual ButtonUsesMode UsesMode => ButtonUsesMode.PerGame;
///
- /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held with the main key to activate the button.
+ /// Gets the keybind for this button. If null, no keybind will be added.
///
- 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;
+ public virtual BaseKeybind? Keybind => null;
///
/// Gets the button's text outline color.
@@ -88,11 +88,6 @@ public abstract class CustomActionButton
///
public virtual ButtonLocation Location { get; set; } = ButtonLocation.BottomLeft;
- ///
- /// Gets a value indicating whether the button has an effect ability.
- ///
- public bool HasEffect => EffectDuration > 0;
-
///
/// Gets a value indicating whether the button has limited uses.
///
@@ -126,7 +121,12 @@ public abstract class CustomActionButton
///
/// Gets the gameObject used for the keybind icon.
///
- public GameObject KeybindIcon { get; set; }
+ public GameObject? KeybindIcon { get; private set; }
+
+ ///
+ /// Gets or sets the keybind icon text.
+ ///
+ private TextMeshPro? KeybindText { get; set; }
///
/// The method used to create the button.
@@ -200,12 +200,25 @@ public abstract class CustomActionButton
}
}));
- KeybindIcon = Object.Instantiate(Button.usesRemainingSprite.gameObject, Button.transform);
- KeybindIcon.GetComponent().sprite = MiraAssets.KeybindButton.LoadAsset();
- KeybindIcon.transform.GetChild(0).GetComponent().text =
- KeybindManager.GetEntries().First(x => x.Id == $"{Name}_Keybind").Key.ToString();
- KeybindIcon.name = "KeybindIcon";
- KeybindIcon.transform.localPosition = new(0.341f, 0.374f, -0.1f);
+ if (Keybind != null)
+ {
+ Keybind.OnActivate(() =>
+ {
+ if (Enabled(PlayerControl.LocalPlayer.Data.Role))
+ {
+ ClickHandler();
+ }
+ });
+
+ KeybindIcon =
+ Helpers.CreateKeybindIcon(
+ Button.gameObject,
+ Keybind.CurrentKey,
+ new Vector3(MaxUses <= 0 ? -0.4f : 0.4f, 0.45f, -9f)
+ );
+ KeybindText = KeybindIcon.transform.GetChild(0).GetComponent();
+ Button.usesRemainingSprite.transform.localPosition = new(-0.341f, 0.45f, -0.1f);
+ }
}
///
@@ -392,6 +405,13 @@ public abstract class CustomActionButton
{
}
+ ///
+ /// Returns a value indicating whether the effect can be ended early by the player.
+ /// Always false by default.
+ ///
+ /// Can the effect be canceled.
+ public virtual bool IsEffectCancellable() => false;
+
///
/// When the button is usable, this method is called to determine if the button can be clicked.
/// By default, it takes into account the timer, effect, and uses.
@@ -400,7 +420,7 @@ public abstract class CustomActionButton
/// A value that represents whether the button can be clicked or not.
public virtual bool CanClick()
{
- return Timer <= 0 && !EffectActive && CanUse();
+ return (EffectActive ? IsEffectCancellable() : Timer <= 0) && CanUse();
}
///
@@ -410,7 +430,8 @@ public abstract class CustomActionButton
/// A value that represents whether the button should light up or not.
public virtual bool CanUse()
{
- return PlayerControl.LocalPlayer.moveable && (!LimitedUses || UsesLeft > 0);
+ return PlayerControl.LocalPlayer.moveable &&
+ ((EffectActive && IsEffectCancellable()) || (!EffectActive && (!LimitedUses || UsesLeft > 0)));
}
///
@@ -432,6 +453,12 @@ public abstract class CustomActionButton
///
public virtual void ClickHandler()
{
+ if (EffectActive && IsEffectCancellable())
+ {
+ ResetCooldownAndOrEffect();
+ return;
+ }
+
if (!CanClick())
{
return;
@@ -464,14 +491,22 @@ public abstract class CustomActionButton
/// The local PlayerControl.
public virtual void FixedUpdateHandler(PlayerControl playerControl)
{
+ if (Keybind != null && KeybindText != null)
+ {
+ KeybindText.text = Keybind.CurrentKey.ToString();
+ KeybindIcon?.SetActive(ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard &&
+ LocalSettingsTabSingleton.Instance.ShowKeybinds.Value &&
+ Keybind.CurrentKey != KeyboardKeyCode.None);
+ }
+
if (Timer >= 0)
{
- if (!TimerPaused)
+ if (!TimerPaused && (!PauseTimerInVent || !playerControl.inVent))
{
Timer -= Time.deltaTime;
}
}
- else if (HasEffect && EffectActive)
+ else if (HasEffect && EffectActive && EffectDuration > 0)
{
EffectActive = false;
Timer = Cooldown;
@@ -489,7 +524,7 @@ public abstract class CustomActionButton
Button!.SetDisabled();
}
- if (EffectActive)
+ if (EffectActive && EffectDuration > 0)
{
Button.SetFillUp(Timer, EffectDuration);
@@ -573,4 +608,4 @@ public abstract class CustomActionButton : CustomActionButton where T : MonoB
Target = null;
SetOutline(false);
}
-}
\ No newline at end of file
+}
diff --git a/MiraAPI/Hud/CustomButtonManager.cs b/MiraAPI/Hud/CustomButtonManager.cs
index e576595..8e57c40 100644
--- a/MiraAPI/Hud/CustomButtonManager.cs
+++ b/MiraAPI/Hud/CustomButtonManager.cs
@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Reflection;
using MiraAPI.Events.Mira;
using MiraAPI.PluginLoading;
+using UnityEngine;
namespace MiraAPI.Hud;
diff --git a/MiraAPI/Hud/MultiTargetButton.cs b/MiraAPI/Hud/MultiTargetButton.cs
new file mode 100644
index 0000000..8c1ae34
--- /dev/null
+++ b/MiraAPI/Hud/MultiTargetButton.cs
@@ -0,0 +1,78 @@
+using System.Linq;
+using HarmonyLib;
+using Reactor.Utilities.Extensions;
+using UnityEngine;
+
+namespace MiraAPI.Hud;
+
+///
+/// Custom action button that has multiple target objects.
+///
+/// The type of the target objects.
+public abstract class MultiTargetButton : CustomActionButton where T : MonoBehaviour
+{
+ ///
+ /// Gets or sets the list of targets of the button.
+ ///
+ public T[] Targets { get; protected set; } = [];
+
+ ///
+ public virtual float Distance => PlayerControl.LocalPlayer.Data.Role.GetAbilityDistance();
+
+ ///
+ /// Gets the maximum amount of targets the button can have.
+ ///
+ public virtual int MaxTargets { get; } = int.MaxValue;
+
+ ///
+ public virtual bool IsTargetValid(T target)
+ {
+ return target != null;
+ }
+
+ ///
+ /// The method used to get the target objects.
+ ///
+ /// The list of target objects.
+ public abstract T[] GetTargets();
+
+ ///
+ /// The target object to set the oultine.
+ public abstract void SetOutline(T target, bool active);
+
+ ///
+ public override bool CanUse()
+ {
+ var newTargets = GetTargets();
+ foreach (var target in Targets)
+ {
+ if (!newTargets.Contains(target))
+ {
+ SetOutline(target, false);
+ }
+ }
+
+ Targets = newTargets
+ .Where(IsTargetValid)
+ .Take(MaxTargets)
+ .ToArray();
+ Targets.Do(t => SetOutline(t, true));
+
+ return base.CanUse() && Targets.Length > 0;
+ }
+
+ ///
+ public override bool CanClick()
+ {
+ return base.CanClick() && Targets.Length > 0;
+ }
+
+ ///
+ /// Use this to reset the button's targets after used.
+ ///
+ public virtual void ResetTarget()
+ {
+ Targets.Do(t => SetOutline(t, false));
+ Targets = [];
+ }
+}
diff --git a/MiraAPI/Keybinds/BaseKeybind.cs b/MiraAPI/Keybinds/BaseKeybind.cs
new file mode 100644
index 0000000..e4e6ac8
--- /dev/null
+++ b/MiraAPI/Keybinds/BaseKeybind.cs
@@ -0,0 +1,72 @@
+using System;
+using Rewired;
+
+namespace MiraAPI.Keybinds;
+
+///
+/// Base keybind class.
+///
+public class BaseKeybind
+{
+ ///
+ /// Gets the Rewired assinged for this keybind.
+ ///
+ public InputAction? RewiredInputAction { get; internal set; }
+
+ ///
+ /// Gets the unique identifier for this keybind. Used in Rewired.
+ ///
+ public string Id { get; protected set; }
+
+ ///
+ /// Gets the currently assigned keycode.
+ ///
+ public KeyboardKeyCode CurrentKey => KeybindUtils.GetKeycodeByKeybind(this);
+
+ ///
+ /// Gets or sets the handler of the keybind. Invoked when the keybind is activated.
+ ///
+ protected Action Handler { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The unique identifier for this keybind.
+ public BaseKeybind(string id)
+ {
+ Id = id;
+ Handler = () => { };
+ }
+
+ public BaseKeybind()
+ {
+ Handler = () => { };
+ }
+
+ ///
+ /// Triggers the keybind, invoking its handler.
+ ///
+ public void Invoke()
+ {
+ Handler.Invoke();
+ }
+
+ ///
+ /// Adds an that will be invoked when the keybind is used.
+ ///
+ /// The to add.
+ public void OnActivate(Action action)
+ {
+ Handler += action;
+ }
+
+ ///
+ /// Removes an that would be invoked when the keybind is used.
+ /// Not recommended to use - add checks in the method added in itself rather than removing it from the keybind.
+ ///
+ /// The to remove.
+ public void RemoveOnActivate(Action action)
+ {
+ Handler -= action;
+ }
+}
\ No newline at end of file
diff --git a/MiraAPI/Keybinds/KeybindManager.cs b/MiraAPI/Keybinds/KeybindManager.cs
index 3f95f4f..ce212f0 100644
--- a/MiraAPI/Keybinds/KeybindManager.cs
+++ b/MiraAPI/Keybinds/KeybindManager.cs
@@ -1,116 +1,98 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using Il2CppInterop.Runtime.Attributes;
+using MiraAPI.Utilities;
+using Reactor.Utilities;
using Rewired;
-namespace MiraAPI.Keybinds
+namespace MiraAPI.Keybinds;
+
+///
+/// Lets you register and manage mod keybinds with conflict checks and rebinding support.
+///
+public static class KeybindManager
{
///
- /// Lets you register and manage mod keybinds with conflict checks and rebinding support.
+ /// Gets a list of all registered keybinds.
///
- public static class KeybindManager
+ public static List Keybinds { get; } = new();
+
+ [HideFromIl2Cpp]
+ internal static Dictionary VanillaKeybinds { get; set; } = [];
+
+ internal static void RewiredInit()
{
- ///
- /// Stores all info for a registered keybind.
- ///
- public class KeybindEntry
+ try
{
- /// 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
+ var instance = KeybindUtils.RewiredInputManager!;
+ foreach (var keybind in KeybindManager.Keybinds)
{
- 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++)
+ if (instance.userData.actions.ToArray().Any(x => x.name == keybind.Id))
{
- var a = Registered[i];
- var b = Registered[j];
- if (a.Key == b.Key && a.Exclusive && b.Exclusive)
- {
- conflicts.Add((a, b));
- }
+ Logger.Warning($"Keybind of id {keybind.Id} already exists. Skipping it");
+ continue;
}
- }
- return conflicts;
- }
- ///
- /// Returns all currently registered keybind entries.
- ///
- /// A list of all registered keybind entries.
- public static List GetEntries()
+ keybind.RewiredInputAction = instance.userData.RegisterModBind(
+ keybind.Id,
+ keybind.Name,
+ keybind.SourcePluginName,
+ keybind.DefaultKey,
+ modifiers: keybind.ModifierKeys);
+ }
+ }
+ catch (Exception e)
{
- return Registered;
+ Logger.Error($"Error while registering keybinds in Rewired: {e}");
}
}
+
+ ///
+ /// Returns all conflicts where keybinds use the same key.
+ ///
+ /// A list of pairs of keybinds that conflict.
+ public static Dictionary> GetConflicts()
+ {
+ var conflicts = new Dictionary>();
+ foreach (var keybind in Keybinds)
+ {
+ var all = GetKeybindsForKey(keybind.CurrentKey, true);
+ if (all.Length > 1)
+ {
+ if (!conflicts.TryGetValue(keybind.CurrentKey, out var group))
+ {
+ conflicts.Add(keybind.CurrentKey, new List());
+ group = conflicts[keybind.CurrentKey];
+ }
+
+ if (!group.Contains(keybind))
+ {
+ group.Add(keybind);
+ }
+ }
+ }
+
+ return conflicts;
+ }
+
+ ///
+ /// Returns all keybinds for a specified keycode.
+ ///
+ /// The key to look for.
+ /// /// Should only exlusive keybinds be present.
+ /// The list of keybinds.
+ public static MiraKeybind[] GetKeybindsForKey(KeyboardKeyCode keyCode, bool exclusiveCheck = false)
+ {
+ var all = new List();
+ foreach (var keybind in Keybinds)
+ {
+ if (keybind.CurrentKey == keyCode && (keybind.Exclusive || !exclusiveCheck))
+ {
+ all.Add(keybind);
+ }
+ }
+
+ return all.ToArray();
+ }
}
diff --git a/MiraAPI/Keybinds/KeybindUtils.cs b/MiraAPI/Keybinds/KeybindUtils.cs
new file mode 100644
index 0000000..f4371b3
--- /dev/null
+++ b/MiraAPI/Keybinds/KeybindUtils.cs
@@ -0,0 +1,80 @@
+using System;
+using Rewired;
+
+namespace MiraAPI.Keybinds;
+
+///
+/// Collection of keybind-related utility classes.
+///
+public static class KeybindUtils
+{
+ ///
+ /// Gets the Rewired instance.
+ ///
+ public static InputManager_Base? RewiredInputManager { get; internal set; }
+
+ ///
+ /// Returns the currently assigned keycode of a keybind.
+ ///
+ /// The keybind to get the keycode from.
+ /// The currently assigned keycode.
+ public static KeyboardKeyCode GetKeycodeByKeybind(BaseKeybind keybind)
+ {
+ if (keybind.RewiredInputAction == null)
+ {
+ return KeyboardKeyCode.None;
+ }
+
+ return GetKeycodeByActionId(keybind.RewiredInputAction.id);
+ }
+
+ ///
+ /// Gets the keycode for an action with ReInput.
+ ///
+ /// The action ID.
+ /// The keyboard keycode.
+ public static KeyboardKeyCode GetKeycodeByActionId(int actionId)
+ {
+ return GetActionElementMap(actionId)?.keyboardKeyCode ?? KeyboardKeyCode.None;
+ }
+
+ ///
+ /// Gets a by id with ReInput.
+ ///
+ /// The action ID.
+ /// The keyboard keycode.
+ public static ActionElementMap? GetActionElementMap(int actionId)
+ {
+ var player = ReInput.players.GetPlayer(0);
+ return player.controllers.maps.GetFirstElementMapWithAction(ControllerType.Keyboard, actionId, false);
+ }
+
+ ///
+ /// Gets an by id.
+ ///
+ /// The action identifier name.
+ /// The keyboard keycode.
+ public static InputAction? GetInputActionById(int id)
+ {
+ return RewiredInputManager?.userData?.GetActionById(id);
+ }
+
+ ///
+ /// 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())
+ {
+ if (key == exclude) continue;
+ bool used = KeybindManager.Keybinds.Exists(e => e.DefaultKey == key);
+ if (!used) return key;
+ }
+ return KeyboardKeyCode.None;
+ }
+}
diff --git a/MiraAPI/Keybinds/MiraGlobalKeybinds.cs b/MiraAPI/Keybinds/MiraGlobalKeybinds.cs
new file mode 100644
index 0000000..ab41c2c
--- /dev/null
+++ b/MiraAPI/Keybinds/MiraGlobalKeybinds.cs
@@ -0,0 +1,41 @@
+using Rewired;
+
+namespace MiraAPI.Keybinds;
+
+///
+/// Class containing all default keybinds.
+/// Recommended for cross-mod compatibility.
+///
+[RegisterCustomKeybinds]
+public static class MiraGlobalKeybinds
+{
+ ///
+ /// Gets the keybind for primary abilities.
+ ///
+ public static MiraKeybind PrimaryAbility { get; } = new("Primary Ability", KeyboardKeyCode.T);
+
+ ///
+ /// Gets the keybind for secondary abilities.
+ ///
+ public static MiraKeybind SecondaryAbility { get; } = new("Secondary Ability", KeyboardKeyCode.Y);
+
+ ///
+ /// Gets the keybind for Tetiary abilities.
+ ///
+ public static MiraKeybind TertiaryAbility { get; } = new("Tertiary Ability", KeyboardKeyCode.U);
+
+ ///
+ /// Gets the keybind for primary modifier abilities.
+ ///
+ public static MiraKeybind ModifierPrimaryAbility { get; } = new("Modifier Primary Ability", KeyboardKeyCode.I);
+
+ ///
+ /// Gets the keybind for secondary modifier abilities.
+ ///
+ public static MiraKeybind ModifierSecondaryAbility { get; } = new("Modifier Secondary Ability", KeyboardKeyCode.O);
+
+ ///
+ /// Gets the keybind for Tetiary modifier abilities.
+ ///
+ public static MiraKeybind ModifierTertiaryAbility { get; } = new("Modifier Tertiary Ability", KeyboardKeyCode.P);
+}
diff --git a/MiraAPI/Keybinds/MiraKeybind.cs b/MiraAPI/Keybinds/MiraKeybind.cs
new file mode 100644
index 0000000..52d64a1
--- /dev/null
+++ b/MiraAPI/Keybinds/MiraKeybind.cs
@@ -0,0 +1,58 @@
+using System.Globalization;
+using Rewired;
+
+namespace MiraAPI.Keybinds;
+
+///
+/// Represents a registered keybind.
+///
+public class MiraKeybind : BaseKeybind
+{
+ ///
+ /// Gets name of the keybind
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the default keycode of this keybind.
+ ///
+ public KeyboardKeyCode DefaultKey { get; }
+
+ ///
+ /// Gets the modifier keys assigned to this keybind.
+ /// Due to Rewired limitations, there can only be 3 modifier keys.
+ ///
+ public ModifierKey[] ModifierKeys { get; }
+
+ ///
+ /// Gets a value indicating whether this keybind should be checked for conflicts with other exclusive keybinds using the same key.
+ ///
+ public bool Exclusive { get; }
+
+ internal string? SourcePluginName { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the keybind.
+ /// The default keycode.
+ /// Up to 3 optional modifier keys.
+ /// Is exclusive.
+ public MiraKeybind(
+ string name,
+ KeyboardKeyCode? defaultKeycode,
+ ModifierKey[]? modifierKeys = null,
+ bool exclusive = true) : base(name.ToLower(CultureInfo.InvariantCulture).Replace(' ', '_'))
+ {
+ Name = name;
+ DefaultKey = defaultKeycode ?? KeyboardKeyCode.None;
+ ModifierKeys = modifierKeys ?? [];
+ Exclusive = exclusive;
+ }
+
+ ///
+ public override string ToString()
+ {
+ return $"{Name} ({Id})";
+ }
+}
diff --git a/MiraAPI/Keybinds/RegisterCustomKeybindsAttribute.cs b/MiraAPI/Keybinds/RegisterCustomKeybindsAttribute.cs
new file mode 100644
index 0000000..3af9f4e
--- /dev/null
+++ b/MiraAPI/Keybinds/RegisterCustomKeybindsAttribute.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace MiraAPI.Keybinds;
+
+///
+/// Used to mark a class for custom keybinds registration.
+///
+[AttributeUsage(AttributeTargets.Class)]
+public class RegisterCustomKeybindsAttribute : Attribute;
diff --git a/MiraAPI/Keybinds/VanillaKeybind.cs b/MiraAPI/Keybinds/VanillaKeybind.cs
new file mode 100644
index 0000000..c04b83b
--- /dev/null
+++ b/MiraAPI/Keybinds/VanillaKeybind.cs
@@ -0,0 +1,24 @@
+using Rewired;
+
+namespace MiraAPI.Keybinds;
+
+///
+/// Represents a vanilla keybind.
+///
+public class VanillaKeybind : BaseKeybind
+{
+ ///
+ /// Gets the this keybind is binded to.
+ ///
+ public ActionButton? Button { get; internal set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The keybind id.
+ public VanillaKeybind(int id)
+ {
+ RewiredInputAction = KeybindUtils.GetInputActionById(id);
+ Id = RewiredInputAction?.name;
+ }
+}
diff --git a/MiraAPI/Keybinds/VanillaKeybindSingleton.cs b/MiraAPI/Keybinds/VanillaKeybindSingleton.cs
new file mode 100644
index 0000000..980da77
--- /dev/null
+++ b/MiraAPI/Keybinds/VanillaKeybindSingleton.cs
@@ -0,0 +1,17 @@
+namespace MiraAPI.Keybinds;
+
+///
+/// Singleton for getting a vanilla keybind by .
+///
+/// ActionButton type.
+public static class VanillaKeybinding where T : ActionButton
+{
+ private static VanillaKeybind? _instance;
+
+ ///
+ /// Gets the instance of the option group.
+ ///
+#pragma warning disable CA1000
+ public static VanillaKeybind Instance => _instance ??= KeybindManager.VanillaKeybinds[typeof(T)];
+#pragma warning restore CA1000
+}
diff --git a/MiraAPI/LocalSettings/Attributes/LocalEnumSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalEnumSettingAttribute.cs
new file mode 100644
index 0000000..f114818
--- /dev/null
+++ b/MiraAPI/LocalSettings/Attributes/LocalEnumSettingAttribute.cs
@@ -0,0 +1,27 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.LocalSettings.SettingTypes;
+
+namespace MiraAPI.LocalSettings.Attributes;
+
+///
+/// Creates an enum setting for the .
+///
+/// Optional custom enum names.
+///
+[AttributeUsage(AttributeTargets.Property)]
+public class LocalEnumSettingAttribute(
+ string? name = null,
+ string? description = null,
+ string[]? names = null
+ ) : LocalSettingAttribute(name, description)
+{
+ private readonly string? _name = name;
+ private readonly string? _description = description;
+
+ ///
+ public override LocalEnumSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase)
+ {
+ return new LocalEnumSetting(tab, configEntryBase, configEntryBase.SettingType, _name, _description, names);
+ }
+}
diff --git a/MiraAPI/LocalSettings/Attributes/LocalNumberSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalNumberSettingAttribute.cs
new file mode 100644
index 0000000..79d4cc7
--- /dev/null
+++ b/MiraAPI/LocalSettings/Attributes/LocalNumberSettingAttribute.cs
@@ -0,0 +1,36 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.LocalSettings.SettingTypes;
+using MiraAPI.Utilities;
+
+namespace MiraAPI.LocalSettings.Attributes;
+
+///
+/// Creates a number setting for the .
+///
+/// Minimum range.
+/// Maximum range.
+/// Increment per use.
+/// Suffix for the value.
+/// Format string used when formating.
+///
+[AttributeUsage(AttributeTargets.Property)]
+public class LocalNumberSettingAttribute(
+ string? name = null,
+ string? description = null,
+ float min = 1,
+ float max = 5,
+ float increment = 1,
+ MiraNumberSuffixes suffixType = MiraNumberSuffixes.None,
+ string? formatString = null
+ ) : LocalSettingAttribute(name, description)
+{
+ private readonly string? _name = name;
+ private readonly string? _description = description;
+
+ ///
+ public override LocalNumberSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase)
+ {
+ return new LocalNumberSetting(tab, configEntryBase, _name, _description, new FloatRange(min, max), increment, suffixType, formatString);
+ }
+}
diff --git a/MiraAPI/LocalSettings/Attributes/LocalSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalSettingAttribute.cs
new file mode 100644
index 0000000..d966cbb
--- /dev/null
+++ b/MiraAPI/LocalSettings/Attributes/LocalSettingAttribute.cs
@@ -0,0 +1,24 @@
+using System;
+using BepInEx.Configuration;
+
+namespace MiraAPI.LocalSettings.Attributes;
+
+///
+/// Base class for all local settings attributes.
+///
+/// The name of the setting. Defaults to entry key.
+/// The description of the setting. Defalts to entry description.
+[AttributeUsage(AttributeTargets.Property)]
+public abstract class LocalSettingAttribute(
+ string? name = null,
+ string? description = null
+ ) : Attribute
+{
+ ///
+ /// Returns the created setting object.
+ ///
+ /// Gets the tab where the setting is located.
+ /// Gets the config entry the setting is attached to.
+ /// The created class.
+ public abstract ILocalSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase);
+}
diff --git a/MiraAPI/LocalSettings/Attributes/LocalSettingsButtonAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalSettingsButtonAttribute.cs
new file mode 100644
index 0000000..043a81d
--- /dev/null
+++ b/MiraAPI/LocalSettings/Attributes/LocalSettingsButtonAttribute.cs
@@ -0,0 +1,12 @@
+using System;
+using BepInEx.Configuration;
+
+namespace MiraAPI.LocalSettings.Attributes;
+
+///
+/// Attribute for local setting buttons.
+///
+[AttributeUsage(AttributeTargets.Property)]
+public class LocalSettingsButtonAttribute : Attribute
+{
+}
diff --git a/MiraAPI/LocalSettings/Attributes/LocalSliderSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalSliderSettingAttribute.cs
new file mode 100644
index 0000000..c8e5712
--- /dev/null
+++ b/MiraAPI/LocalSettings/Attributes/LocalSliderSettingAttribute.cs
@@ -0,0 +1,36 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.LocalSettings.SettingTypes;
+using MiraAPI.Utilities;
+
+namespace MiraAPI.LocalSettings.Attributes;
+
+///
+/// Creates a slider setting for the .
+///
+///
+/// Minimum range.
+/// Maximum range.
+/// Should the value be rounded.
+/// Suffix for the value.
+[AttributeUsage(AttributeTargets.Property)]
+public class LocalSliderSettingAttribute(
+ string? name = null,
+ float min = 0,
+ float max = 100,
+ string? description = null,
+ bool displayValue = false,
+ string? formatString = null,
+ bool roundValue = false,
+ MiraNumberSuffixes suffixType = MiraNumberSuffixes.None
+ ) : LocalSettingAttribute(name, description)
+{
+ private readonly string? _name = name;
+ private readonly string? _description = description;
+
+ ///
+ public override LocalSliderSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase)
+ {
+ return new LocalSliderSetting(tab, configEntryBase, _name, _description, new FloatRange(min, max), displayValue, suffixType, formatString, roundValue);
+ }
+}
diff --git a/MiraAPI/LocalSettings/Attributes/LocalToggleSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalToggleSettingAttribute.cs
new file mode 100644
index 0000000..2d2aa14
--- /dev/null
+++ b/MiraAPI/LocalSettings/Attributes/LocalToggleSettingAttribute.cs
@@ -0,0 +1,25 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.LocalSettings.SettingTypes;
+
+namespace MiraAPI.LocalSettings.Attributes;
+
+///
+/// Creates a toggle setting for the .
+///
+///
+[AttributeUsage(AttributeTargets.Property)]
+public class LocalToggleSettingAttribute(
+ string? name = null,
+ string? description = null
+ ) : LocalSettingAttribute(name, description)
+{
+ private readonly string? _name = name;
+ private readonly string? _description = description;
+
+ ///
+ public override LocalToggleSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase)
+ {
+ return new LocalToggleSetting(tab, configEntryBase, _name, _description);
+ }
+}
diff --git a/MiraAPI/LocalSettings/ILocalSetting.cs b/MiraAPI/LocalSettings/ILocalSetting.cs
new file mode 100644
index 0000000..a94e102
--- /dev/null
+++ b/MiraAPI/LocalSettings/ILocalSetting.cs
@@ -0,0 +1,42 @@
+using BepInEx.Configuration;
+using UnityEngine;
+
+namespace MiraAPI.LocalSettings;
+
+///
+/// Interface for all local settings.
+///
+public interface ILocalSetting
+{
+ ///
+ /// Gets the preferred name of a setting.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the description of a setting.
+ ///
+ string Description { get; }
+
+ ///
+ /// Gets the setting's gameobject.
+ ///
+ GameObject? Setting { get; }
+
+ ///
+ /// Gets the setting's config entry.
+ ///
+ ConfigEntryBase ConfigEntry { get; }
+
+ ///
+ /// Used to create the setting.
+ ///
+ /// Toggle template.
+ /// Slider template.
+ /// The parent of the setting.
+ /// The offset.
+ /// The order.
+ /// Whether it's the last on the row.
+ /// The created setting.
+ GameObject? CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last);
+}
diff --git a/MiraAPI/LocalSettings/LocalSettingsButton.cs b/MiraAPI/LocalSettings/LocalSettingsButton.cs
new file mode 100644
index 0000000..fa0c0bf
--- /dev/null
+++ b/MiraAPI/LocalSettings/LocalSettingsButton.cs
@@ -0,0 +1,78 @@
+using System;
+using MiraAPI.Utilities;
+using Reactor.Localization.Utilities;
+using Reactor.Utilities.Extensions;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using Object = UnityEngine.Object;
+
+namespace MiraAPI.LocalSettings;
+
+///
+/// Represents a local settings button.
+///
+public class LocalSettingsButton(string text, Action onClick)
+{
+ ///
+ /// Gets or sets the button text.
+ ///
+ public string Text { get; set; } = text;
+
+ ///
+ /// Gets or sets the button on-click action.
+ ///
+ public Action OnClick { get; set; } = onClick;
+
+ ///
+ /// Gets the tab instance this button belongs to.
+ ///
+ public LocalSettingsTab? Tab { get; internal set; }
+
+ internal GameObject CreateButton(ToggleButtonBehaviour toggle, Transform parent, ref float offset, ref int order, bool last)
+ {
+ var button = Object.Instantiate(toggle, parent).GetComponent();
+ var tmp = button.transform.FindChild("Text_TMP").GetComponent();
+ var rollover = button.GetComponent();
+ tmp.GetComponent().Destroy();
+ button.gameObject.SetActive(true);
+
+ var toggleComp = button.GetComponent();
+ var background = toggleComp.Background;
+ button.transform.FindChild("ButtonHighlight")?.gameObject.DestroyImmediate();
+ toggleComp.Destroy();
+
+ if (last && order == 1)
+ {
+ // Button in the middle
+ button.transform.localPosition = new Vector3(0, 1.85f - offset, -7);
+ }
+ else
+ {
+ button.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7);
+ }
+
+ tmp.text = Text;
+ button.name = Text;
+ button.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent();
+ rollover.OutColor = Tab!.TabAppearance.ButtonColor;
+ rollover.OverColor = Tab!.TabAppearance.ButtonHoverColor;
+ rollover.Target = background;
+ background.color = Tab!.TabAppearance.ButtonColor;
+ button.OnClick.AddListener((UnityAction)OnClick);
+
+ background.transform.localScale = new Vector3(1, 1.25f, 1);
+ Helpers.DivideSize(button.gameObject, 1.1f);
+
+ order++;
+ if (order > 2 && !last)
+ {
+ offset += 0.5f;
+ order = 1;
+ }
+ if (last)
+ offset += 0.6f;
+
+ return button.gameObject;
+ }
+}
diff --git a/MiraAPI/LocalSettings/LocalSettingsManager.cs b/MiraAPI/LocalSettings/LocalSettingsManager.cs
new file mode 100644
index 0000000..3b75d05
--- /dev/null
+++ b/MiraAPI/LocalSettings/LocalSettingsManager.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using BepInEx.Unity.IL2CPP;
+using MiraAPI.GameOptions;
+using Reactor.Utilities;
+
+namespace MiraAPI.LocalSettings;
+
+///
+/// Manages local settings.
+///
+public static class LocalSettingsManager
+{
+ internal static readonly Dictionary TypeToTab = [];
+ internal static readonly List Tabs = [];
+
+ internal static bool RegisterTab(Type type, BasePlugin pluginInfo)
+ {
+ if (Activator.CreateInstance(type, pluginInfo.Config) is not LocalSettingsTab tab)
+ {
+ return false;
+ }
+
+ if (TypeToTab.ContainsKey(type))
+ {
+ Logger.Error($"Local settings tab {type.Name} already exists.");
+ return false;
+ }
+
+ Tabs.Add(tab);
+ TypeToTab.Add(type, tab);
+
+ typeof(LocalSettingsTabSingleton<>).MakeGenericType(type)
+#pragma warning disable S3011
+ .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)!
+#pragma warning restore S3011
+ .SetValue(null, tab);
+
+ return true;
+ }
+}
diff --git a/MiraAPI/LocalSettings/LocalSettingsTab.cs b/MiraAPI/LocalSettings/LocalSettingsTab.cs
new file mode 100644
index 0000000..8b9ede8
--- /dev/null
+++ b/MiraAPI/LocalSettings/LocalSettingsTab.cs
@@ -0,0 +1,299 @@
+using System;
+using System.Collections.Generic;
+using BepInEx.Configuration;
+using HarmonyLib;
+using MiraAPI.Patches.LocalSettings;
+using MiraAPI.Utilities;
+using Reactor.Utilities.Extensions;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using Object = UnityEngine.Object;
+
+namespace MiraAPI.LocalSettings;
+
+///
+/// Represents a local settings tab. Gets a button by default.
+///
+public abstract class LocalSettingsTab(ConfigFile config)
+{
+ ///
+ /// Gets the name of the tab.
+ ///
+ public abstract string TabName { get; }
+
+ ///
+ /// Gets the tab appearance.
+ ///
+ public virtual LocalSettingTabAppearance TabAppearance { get; } = new();
+
+ ///
+ /// Gets a value indicating whether a button should be created for the tab.
+ ///
+ public virtual bool ShouldCreateButton => true;
+
+ ///
+ /// Gets a value indicating whether the tab should have category labels.
+ ///
+ protected virtual bool ShouldCreateLabels => true;
+
+ ///
+ /// Gets the mod's config file. Automatically found in the plugin loader.
+ ///
+ public ConfigFile Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
+
+ ///
+ /// Gets the tab button instance.
+ ///
+ public TabGroup? TabButton { get; internal set; }
+
+ ///
+ /// Gets the list of settings.
+ ///
+ public List Settings { get; } = [];
+
+ ///
+ /// Gets the list of buttons.
+ ///
+ public List Buttons { get; } = [];
+
+ ///
+ /// Gets the index of this tab.
+ ///
+ protected int TabIndex => LocalSettingsManager.Tabs.IndexOf(this) + 10;
+
+ private Scroller? Scroller { get; set; }
+
+ ///
+ /// Invoked when an option value is changed.
+ ///
+ /// The config entry which was changed.
+ public virtual void OnOptionChanged(ConfigEntryBase configEntry)
+ {
+ }
+
+ ///
+ /// Attempts to open the tab.
+ /// Override for custom behavior.
+ ///
+ public virtual void Open()
+ {
+ OptionsMenuPatches.Instance?.OpenTabGroup(TabIndex);
+ }
+
+ ///
+ /// Creates the tab and it's content.
+ ///
+ /// The instace.
+ /// The created tab .
+ public virtual GameObject CreateTab(OptionsMenuBehaviour instance)
+ {
+ var tab = Object.Instantiate(instance.transform.FindChild("GeneralTab").gameObject, instance.transform);
+ tab.name = $"{TabName}Tab";
+ tab.transform.DestroyChildren();
+ tab.gameObject.SetActive(false);
+
+ if (Scroller == null)
+ {
+ Scroller = Helpers.CreateScroller(tab.transform, OptionsMenuPatches.MaskCollider);
+ }
+
+ if (TabButton != null)
+ {
+ TabButton.Content = tab.gameObject;
+ }
+
+ var generalLabel = instance.transform.FindChild("GeneralTab").FindChild("ControlGroup")
+ .FindChild("ControlText_TMP").gameObject;
+ var toggle = instance.transform.FindChild("GeneralTab").FindChild("ChatGroup").FindChild("CensorChatButton").GetComponent();
+ var slider = instance.transform.FindChild("GeneralTab").FindChild("SoundGroup").FindChild("SFXSlider").GetComponent();
+
+ Dictionary> entriesByGroup = new();
+ foreach (var entry in Settings)
+ {
+ var group = entry.ConfigEntry.Definition.Section;
+ if (!entriesByGroup.ContainsKey(group))
+ entriesByGroup.Add(group, []);
+
+ entriesByGroup[group].Add(entry);
+ }
+
+ float contentOffset = 0;
+ var contentOrder = 1;
+ var contentIndex = 1;
+
+ foreach (var pair in entriesByGroup)
+ {
+ if (ShouldCreateLabels)
+ {
+ CreateLabel(generalLabel, Scroller.Inner, pair.Key, ref contentOffset);
+ contentOrder = 1;
+ contentIndex = 1;
+ }
+
+ foreach (var setting in pair.Value)
+ {
+ var obj = setting.CreateOption(
+ toggle,
+ slider,
+ Scroller.Inner,
+ ref contentOffset,
+ ref contentOrder,
+ contentIndex == pair.Value.Count);
+
+ obj!.GetComponentsInChildren(true).Do(x =>
+ {
+ x.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
+ x.sortingOrder = 150;
+ x.sortingLayerName = "Default";
+ });
+
+ obj.GetComponentsInChildren(true).Do(x =>
+ {
+ x.sortingOrder = 151;
+ x.sortingLayerName = "Default";
+ });
+
+ obj.GetComponentsInChildren().Do(x => { x.ClickMask = OptionsMenuPatches.MaskCollider; });
+
+ contentIndex++;
+ }
+
+ contentOrder = 1;
+ }
+
+ contentIndex = 1;
+ contentOrder = 1;
+ foreach (var button in Buttons)
+ {
+ var obj = button.CreateButton(
+ toggle,
+ Scroller.Inner,
+ ref contentOffset,
+ ref contentOrder,
+ contentIndex == Buttons.Count);
+
+ obj!.GetComponentsInChildren(true).Do(x =>
+ {
+ x.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
+ x.sortingOrder = 150;
+ x.sortingLayerName = "Default";
+ });
+
+ obj.GetComponentsInChildren(true).Do(x =>
+ {
+ x.sortingOrder = 151;
+ x.sortingLayerName = "Default";
+ });
+
+ obj.GetComponentsInChildren().Do(x => { x.ClickMask = OptionsMenuPatches.MaskCollider; });
+
+ contentIndex++;
+ }
+
+ Scroller.SetBounds(new FloatRange(0, (Settings.Count + Buttons.Count) * 0.5f - 5f), new FloatRange(0, 0));
+ return tab;
+ }
+
+ ///
+ /// Creates the tab button.
+ ///
+ /// The instance.
+ /// The tab index.
+ /// The current button offset.
+ /// The created button .
+ public virtual GameObject CreateTabButton(OptionsMenuBehaviour instance, ref int tabIdx, ref float offset)
+ {
+ var tabButtonObject = Object.Instantiate(instance.Tabs[0], instance.transform);
+ tabButtonObject.name = $"{TabName} Button";
+ tabButtonObject.transform.localPosition = new Vector3(2.4f, 2.1f - offset, 5.5f);
+ tabButtonObject.transform.localScale = new Vector3(1.25f, 1.25f, 1);
+ tabButtonObject.Button.color = TabAppearance.TabButtonColor;
+ TabButton = tabButtonObject;
+
+ var tabButtonText = tabButtonObject.transform.FindChild("Text_TMP").GetComponent();
+ tabButtonText.GetComponent().Destroy(); // i hate text translators
+ tabButtonText.transform.localPosition = new Vector3(0.078f, 0, 0);
+ tabButtonText.transform.localScale = new Vector3(0.9f, 0.9f);
+ tabButtonText.alignment = TextAlignmentOptions.Right;
+ tabButtonText.text = $"{GetShortName(TabName)}";
+
+ var tabButton = tabButtonObject.GetComponent();
+ var rollover = tabButtonObject.Rollover;
+ rollover.OverColor = TabAppearance.TabButtonHoverColor;
+ rollover.OutColor = TabAppearance.TabButtonColor;
+
+ SpriteRenderer? tabButtonRend = null;
+ if (TabAppearance.TabIcon != null)
+ {
+ tabButtonRend = new GameObject("sprite").AddComponent();
+ tabButtonRend.gameObject.layer = 5; // ui layer
+ tabButtonRend.transform.SetParent(tabButtonObject.transform);
+ tabButtonRend.transform.localPosition = new Vector3(0.5f, 0, -2);
+ tabButtonRend.transform.localScale = new Vector3(0.2f, 0.2f, 1);
+ tabButtonRend.sprite = TabAppearance.TabIcon.LoadAsset();
+ tabButtonText.gameObject.SetActive(false);
+ }
+
+ var tabIndex = tabIdx; // Local copies of the tab variables so we can use them in the lambdas
+ var tabOffset = offset;
+ tabButton.OnClick.AddListener((UnityAction)(() => { instance.OpenTabGroup(tabIndex + 10); }));
+ tabButton.OnMouseOver.AddListener((UnityAction)(() =>
+ {
+ tabButton.transform.localPosition = new Vector3(3.55f, 2.1f - tabOffset, 5.5f);
+ tabButtonText.gameObject.SetActive(true);
+ tabButtonText.transform.localPosition = new Vector3(-0.024f, 0, 0);
+ tabButtonText.maxVisibleCharacters = int.MaxValue;
+ tabButtonText.text = TabName;
+ tabButtonText.alignment = TextAlignmentOptions.Left;
+ tabButtonRend?.gameObject.SetActive(!TabAppearance.HideIconOnHover);
+ }));
+ tabButton.OnMouseOut.AddListener((UnityAction)(() =>
+ {
+ if (!tabButtonObject.Content.gameObject.activeSelf)
+ {
+ //tabButtonObject.Button.color = TabColor;
+ //tabButtonObject.Rollover.OutColor = TabColor;
+ }
+
+ tabButton.transform.localPosition = new Vector3(2.4f, 2.1f - tabOffset, 5.5f);
+ tabButtonText.alignment = TextAlignmentOptions.Right;
+ if (tabButtonRend != null)
+ {
+ tabButtonRend.gameObject.SetActive(true);
+ tabButtonText.gameObject.SetActive(false);
+ }
+
+ tabButtonText.transform.localPosition = new Vector3(0.1f, 0, 0);
+ tabButtonText.maxVisibleCharacters = 4;
+ tabButtonText.text = $"{GetShortName(TabName)}";
+ }));
+
+ return tabButtonObject.gameObject;
+ }
+
+ private static void CreateLabel(GameObject template, Transform parent, string text, ref float offset)
+ {
+ var label = Object.Instantiate(template, parent);
+ label.transform.localPosition = new Vector3(0, 1.85f - offset);
+ label.GetComponent().Destroy();
+ label.name = text;
+
+ var tmp = label.GetComponent();
+ tmp.text = $"{text}";
+ tmp.alignment = TextAlignmentOptions.Center;
+
+ var meshRend = label.GetComponent();
+ meshRend.sortingLayerName = "Default";
+ meshRend.sortingOrder = 151;
+
+ offset += 0.5f;
+ }
+
+ private static string GetShortName(string name)
+ {
+ var shortName = string.Empty;
+ name.Split(' ').Do(x => shortName += x[0]);
+ return shortName;
+ }
+}
diff --git a/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs b/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs
new file mode 100644
index 0000000..9692be4
--- /dev/null
+++ b/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs
@@ -0,0 +1,96 @@
+using MiraAPI.Utilities.Assets;
+using UnityEngine;
+
+namespace MiraAPI.LocalSettings;
+
+///
+/// Struct for modifying tab appearance.
+/// Defines how the settings look.
+///
+public record struct LocalSettingTabAppearance()
+{
+ ///
+ /// Gets or sets main color of the tab.
+ ///
+ public Color TabColor { get; set; } = Color.white;
+
+ ///
+ /// Gets or sets tab button icon. No icon by default.
+ ///
+ public LoadableAsset? TabIcon { get; set; }
+
+ ///
+ /// Gets or sets color of the tab button.
+ ///
+ public Color TabButtonColor { get; set; } = Color.white;
+
+ ///
+ /// Gets or sets a value indicating whether the icon should be hidden when tab button is hovered.
+ ///
+ public bool HideIconOnHover { get; set; } = true;
+
+ ///
+ /// Gets or sets hover color of the tab button.
+ ///
+ public Color TabButtonHoverColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets active color of the tab button.
+ ///
+ public Color TabButtonActiveColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets hover color of toggles.
+ ///
+ public Color ToggleHoverColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets active color of toggles.
+ ///
+ public Color ToggleActiveColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets inactive color of toggles.
+ ///
+ public Color ToggleInactiveColor { get; set; } = Color.red;
+
+ ///
+ /// Gets or sets color of number buttons.
+ ///
+ public Color NumberColor { get; set; } = Color.white;
+
+ ///
+ /// Gets or sets hover color of number buttons.
+ ///
+ public Color NumberHoverColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets color of sliders.
+ ///
+ public Color SliderColor { get; set; } = Color.white;
+
+ ///
+ /// Gets or sets hover color of sliders.
+ ///
+ public Color SliderHoverColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets color of enum buttons.
+ ///
+ public Color EnumColor { get; set; } = Color.white;
+
+ ///
+ /// Gets or sets hover color of enum buttons.
+ ///
+ public Color EnumHoverColor { get; set; } = Palette.AcceptedGreen;
+
+ ///
+ /// Gets or sets color of buttons.
+ ///
+ public Color ButtonColor { get; set; } = Color.white;
+
+ ///
+ /// Gets or sets hover color of buttons.
+ ///
+ public Color ButtonHoverColor { get; set; } = Palette.AcceptedGreen;
+}
diff --git a/MiraAPI/LocalSettings/LocalSettingsTabSingleton.cs b/MiraAPI/LocalSettings/LocalSettingsTabSingleton.cs
new file mode 100644
index 0000000..eec81a2
--- /dev/null
+++ b/MiraAPI/LocalSettings/LocalSettingsTabSingleton.cs
@@ -0,0 +1,19 @@
+using System.Linq;
+
+namespace MiraAPI.LocalSettings;
+
+///
+/// Singleton for local settings tabs.
+///
+/// The settings tab type.
+public static class LocalSettingsTabSingleton where T : LocalSettingsTab
+{
+ private static T? _instance;
+
+ ///
+ /// Gets the instance of the setting tab.
+ ///
+#pragma warning disable CA1000
+ public static T Instance => _instance ??= LocalSettingsManager.Tabs.OfType().Single();
+#pragma warning restore CA1000
+}
diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalEnumSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalEnumSetting.cs
new file mode 100644
index 0000000..437eed1
--- /dev/null
+++ b/MiraAPI/LocalSettings/SettingTypes/LocalEnumSetting.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Linq;
+using BepInEx.Configuration;
+using MiraAPI.Utilities;
+using Reactor.Utilities.Extensions;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using Object = UnityEngine.Object;
+
+namespace MiraAPI.LocalSettings.SettingTypes;
+
+///
+/// Local setting class for enums.
+///
+public class LocalEnumSetting : LocalSettingBase
+{
+ ///
+ /// Gets the enum type of the setting.
+ ///
+ public Type EnumType { get; }
+
+ ///
+ /// Gets the enum values.
+ ///
+ public string[] Values { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The enum type.
+ /// The optional values array to replace the enum names.
+ public LocalEnumSetting(
+ Type tab,
+ ConfigEntryBase configEntry,
+ Type enumType,
+ string? name = null,
+ string? description = null,
+ string[]? values = null)
+ : base(tab, configEntry, name, description)
+ {
+ EnumType = enumType;
+ Values = values ?? Enum
+ .GetValues(configEntry.SettingType)
+ .Cast()
+ .Select(x => x.ToDisplayString())
+ .ToArray();
+ }
+
+ ///
+ public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last)
+ {
+ var button = Object.Instantiate(toggle, parent).GetComponent();
+ var tmp = button.transform.FindChild("Text_TMP").GetComponent();
+ var rollover = button.GetComponent();
+ tmp.GetComponent().Destroy();
+ button.gameObject.SetActive(true);
+
+ var toggleComp = button.GetComponent();
+ var background = toggleComp.Background;
+ var highlight = button.transform.FindChild("ButtonHighlight")?.GetComponent();
+ if (highlight != null)
+ {
+ highlight.color = Tab!.TabAppearance.EnumHoverColor;
+ highlight.gameObject.SetActive(false);
+ }
+ toggleComp.Destroy();
+
+ if (last && order == 1)
+ {
+ // Button in the middle
+ button.transform.localPosition = new Vector3(0, 1.85f - offset, -7);
+ }
+ else
+ {
+ button.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7);
+ }
+
+ tmp.text = GetValueText();
+ button.name = Name;
+ button.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent();
+ rollover.OutColor = Tab!.TabAppearance.EnumColor;
+ rollover.OverColor = Tab!.TabAppearance.EnumHoverColor;
+ background.color = Tab!.TabAppearance.EnumColor;
+
+ button.OnClick.AddListener((UnityAction)(() =>
+ {
+ int value = GetValue();
+ value++;
+ if (value >= Values.Length)
+ {
+ value = 0;
+ }
+
+ SetValue(value);
+ tmp.text = GetValueText();
+ }));
+ button.OnMouseOver.AddListener((UnityAction)(() =>
+ {
+ if (!Description.IsNullOrWhiteSpace())
+ {
+ tmp.text = Description;
+ }
+ highlight?.gameObject.SetActive(true);
+ }));
+ button.OnMouseOut.AddListener((UnityAction)(() =>
+ {
+ tmp.text = GetValueText();
+ highlight?.gameObject.SetActive(false);
+ }));
+
+ Helpers.DivideSize(button.gameObject, 1.1f);
+
+ order++;
+ if (order > 2 && !last)
+ {
+ offset += 0.5f;
+ order = 1;
+ }
+ if (last)
+ offset += 0.6f;
+
+ return button.gameObject;
+ }
+
+ ///
+ protected override string GetValueText()
+ {
+ return $"{Name}: {Values[GetValue()]}";
+ }
+}
diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalNumberSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalNumberSetting.cs
new file mode 100644
index 0000000..fb86fdc
--- /dev/null
+++ b/MiraAPI/LocalSettings/SettingTypes/LocalNumberSetting.cs
@@ -0,0 +1,145 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.Utilities;
+using Reactor.Utilities.Extensions;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using Object = UnityEngine.Object;
+
+namespace MiraAPI.LocalSettings.SettingTypes;
+
+///
+/// Local setting class for numbers.
+///
+public class LocalNumberSetting : LocalSettingBase
+{
+ ///
+ /// Gets the range of the button.
+ ///
+ public FloatRange NumberRange { get; }
+
+ ///
+ /// Gets the increment of the value when button is pressed.
+ ///
+ public float Increment { get; }
+
+ ///
+ /// Gets a format for the text to use to format the number.
+ ///
+ public string FormatString { get; }
+
+ ///
+ /// Gets the suffix for the number value.
+ ///
+ public MiraNumberSuffixes SuffixType { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The value range.
+ /// The increment per click.
+ /// The suffix used for formating.
+ /// The format string used for formating.
+ public LocalNumberSetting(
+ Type tab,
+ ConfigEntryBase configEntry,
+ string? name = null,
+ string? description = null,
+ FloatRange? numberRange = null,
+ float? increment = null,
+ MiraNumberSuffixes? suffixType = null,
+ string? formatString = null)
+ : base(tab, configEntry, name, description)
+ {
+ SuffixType = suffixType ?? MiraNumberSuffixes.None;
+ NumberRange = numberRange ?? new FloatRange(1, 5);
+ Increment = increment ?? 1;
+ FormatString = formatString ?? "0";
+ }
+
+ ///
+ public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last)
+ {
+ var button = Object.Instantiate(toggle, parent).GetComponent();
+ var tmp = button.transform.FindChild("Text_TMP").GetComponent();
+ var rollover = button.GetComponent();
+ tmp.GetComponent().Destroy();
+ button.gameObject.SetActive(true);
+
+ var toggleComp = button.GetComponent();
+ var background = toggleComp.Background;
+ var highlight = button.transform.FindChild("ButtonHighlight")?.GetComponent();
+ if (highlight != null)
+ {
+ highlight.color = Tab!.TabAppearance.NumberHoverColor;
+ highlight.gameObject.SetActive(false);
+ }
+ toggleComp.Destroy();
+
+ if (last && order == 1)
+ {
+ // Button in the middle
+ button.transform.localPosition = new Vector3(0, 1.85f - offset, -7);
+ }
+ else
+ {
+ button.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7);
+ }
+
+ tmp.text = GetValueText();
+ button.name = Name;
+ button.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent();
+ rollover.OutColor = Tab!.TabAppearance.NumberColor;
+ rollover.OverColor = Tab!.TabAppearance.NumberHoverColor;
+ background.color = Tab!.TabAppearance.NumberColor;
+
+ button.OnClick.AddListener((UnityAction)(() =>
+ {
+ float value = GetValue();
+ value += Increment;
+ if (value > NumberRange.max)
+ {
+ value = NumberRange.min;
+ }
+
+ SetValue(value);
+ tmp.text = GetValueText();
+ }));
+ button.OnMouseOver.AddListener((UnityAction)(() =>
+ {
+ if (!Description.IsNullOrWhiteSpace())
+ {
+ tmp.text = Description;
+ }
+ highlight?.gameObject.SetActive(true);
+ }));
+ button.OnMouseOut.AddListener((UnityAction)(() =>
+ {
+ tmp.text = GetValueText();
+ highlight?.gameObject.SetActive(false);
+ }));
+
+ Helpers.DivideSize(button.gameObject, 1.1f);
+
+ order++;
+ if (order > 2 && !last)
+ {
+ offset += 0.5f;
+ order = 1;
+ }
+ if (last)
+ offset += 0.6f;
+
+ return button.gameObject;
+ }
+
+ ///
+ protected override string GetValueText()
+ {
+ var value = GetValue();
+ var formated = Helpers.FormatValue(value, SuffixType, FormatString);
+ return $"{Name}: {formated}";
+ }
+}
diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs b/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs
new file mode 100644
index 0000000..c25f04e
--- /dev/null
+++ b/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Linq;
+using BepInEx.Configuration;
+using UnityEngine;
+
+namespace MiraAPI.LocalSettings.SettingTypes;
+
+///
+public abstract class LocalSettingBase : ILocalSetting
+{
+ ///
+ public string Name { get; }
+
+ ///
+ public string Description { get; }
+
+ ///
+ public GameObject? Setting { get; } = null!;
+
+ ///
+ public ConfigEntryBase ConfigEntry { get; }
+
+ ///
+ /// Gets the tab of the local setting.
+ ///
+ public LocalSettingsTab? Tab => LocalSettingsManager.Tabs.FirstOrDefault(x => x.Settings.Contains(this));
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The tab to create the setting in.
+ /// The config entry.
+ /// The name of the setting.
+ /// The description of the setting.
+ protected LocalSettingBase(Type tab, ConfigEntryBase configEntry, string? name = null, string? description = null)
+ {
+ ConfigEntry = configEntry;
+ Name = name ?? ConfigEntry.Definition.Key;
+ Description = description ?? ConfigEntry.Description.Description;
+ LocalSettingsManager.TypeToTab[tab].Settings.Add(this);
+ }
+
+ ///
+ /// Creates an instance of the setting.
+ ///
+ /// The created setting.
+ public abstract GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last);
+
+ ///
+ /// Returns the formated string to use in the text of the setting.
+ ///
+ /// The value text.
+ protected virtual string GetValueText()
+ {
+ return string.Empty;
+ }
+
+ ///
+ /// Gets the value of the config entry, cast to the setting type.
+ ///
+ /// The value.
+ public virtual T GetValue()
+ {
+ return (T)ConfigEntry.BoxedValue;
+ }
+
+ ///
+ /// Sets the value of the config entry.
+ ///
+ /// The value to set to.
+ public virtual void SetValue(T value)
+ {
+ ConfigEntry.BoxedValue = value;
+ Tab?.OnOptionChanged(ConfigEntry);
+ }
+}
diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalSliderSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalSliderSetting.cs
new file mode 100644
index 0000000..ae6f500
--- /dev/null
+++ b/MiraAPI/LocalSettings/SettingTypes/LocalSliderSetting.cs
@@ -0,0 +1,120 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.Utilities;
+using Reactor.Utilities.Extensions;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using Object = UnityEngine.Object;
+
+namespace MiraAPI.LocalSettings.SettingTypes;
+
+///
+/// Local setting class for sliders.
+///
+public class LocalSliderSetting : LocalSettingBase
+{
+ ///
+ /// Gets the range of the slider.
+ ///
+ public FloatRange SliderRange { get; }
+
+ ///
+ /// Gets a value indicating whether the value should be displayed next to name.
+ ///
+ public bool DisplayValue { get; }
+
+ ///
+ /// Gets a format for the text to use to format the number.
+ ///
+ public string FormatString { get; }
+
+ ///
+ /// Gets a value indicating whether the value should be rounded.
+ ///
+ public bool RoundValue { get; }
+
+ ///
+ /// Gets the suffix for the number value.
+ ///
+ public MiraNumberSuffixes SuffixType { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The value range.
+ /// The suffix used for formating.
+ /// The format string used for formating.
+ /// Should the value be rounded.
+ public LocalSliderSetting(
+ Type tab,
+ ConfigEntryBase configEntry,
+ string? name = null,
+ string? description = null,
+ FloatRange? sliderRange = null,
+ bool displayValue = false,
+ MiraNumberSuffixes? suffixType = null,
+ string? formatString = null,
+ bool roundValue = false)
+ : base(tab, configEntry, name, description)
+ {
+ SliderRange = sliderRange ?? new FloatRange(0, 100);
+ DisplayValue = displayValue;
+ SuffixType = suffixType ?? MiraNumberSuffixes.None;
+ FormatString = formatString ?? "0.0";
+ RoundValue = roundValue;
+ }
+
+ ///
+ public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last)
+ {
+ var newSlider = Object.Instantiate(slider, parent).GetComponent();
+ var rollover = newSlider.GetComponent();
+ newSlider.Title = newSlider.transform.FindChild("Text_TMP").GetComponent(); // Why the hell slider has a title property that is not even assigned???
+ newSlider.Title.GetComponent().Destroy();
+ newSlider.gameObject.SetActive(true);
+ newSlider.Bar.color = Tab!.TabAppearance.SliderColor;
+
+ if (order == 2)
+ offset += 0.5f;
+
+ newSlider.Bar.transform.localPosition = new Vector3(2.85f, 0, 0);
+ newSlider.transform.localPosition = new Vector3(-2.12f, 1.85f - offset, -7);
+ newSlider.name = Name;
+ newSlider.Range = new FloatRange(-1.5f, 1.5f);
+ newSlider.SetValue(Mathf.InverseLerp(SliderRange.min, SliderRange.max, GetValue()));
+ rollover.OutColor = Tab!.TabAppearance.SliderColor;
+ rollover.OverColor = Tab!.TabAppearance.SliderHoverColor;
+ newSlider.Title.transform.localPosition = new Vector3(0.5f, 0, -1f);
+ newSlider.Title.horizontalAlignment = DisplayValue ? HorizontalAlignmentOptions.Left : HorizontalAlignmentOptions.Center;
+ newSlider.Title.text = GetValueText();
+
+ newSlider.OnValueChange.AddListener((UnityAction)(() =>
+ {
+ SetValue(RoundValue
+ ? Mathf.Round(Mathf.Lerp(SliderRange.min, SliderRange.max, newSlider.Value))
+ : Mathf.Lerp(SliderRange.min, SliderRange.max, newSlider.Value));
+
+ newSlider.Title.text = GetValueText();
+ }));
+
+ order = 1;
+ offset += 0.5f;
+ return newSlider.gameObject;
+ }
+
+ ///
+ protected override string GetValueText()
+ {
+ if (DisplayValue)
+ {
+ var value = GetValue();
+ var formated = Helpers.FormatValue(value, SuffixType, FormatString);
+ var maxFormated = Helpers.FormatValue(SliderRange.max, SuffixType, FormatString);
+ return $"{Name}: {formated} / {maxFormated}";
+ }
+
+ return $"{Name}";
+ }
+}
diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalToggleSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalToggleSetting.cs
new file mode 100644
index 0000000..54cd8ee
--- /dev/null
+++ b/MiraAPI/LocalSettings/SettingTypes/LocalToggleSetting.cs
@@ -0,0 +1,88 @@
+using System;
+using BepInEx.Configuration;
+using MiraAPI.Utilities;
+using Reactor.Localization.Utilities;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using Object = UnityEngine.Object;
+
+namespace MiraAPI.LocalSettings.SettingTypes;
+
+///
+/// Local setting class for toggles.
+///
+public class LocalToggleSetting : LocalSettingBase
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ public LocalToggleSetting(
+ Type tab,
+ ConfigEntryBase configEntry,
+ string? name = null,
+ string? description = null
+ ) : base(tab, configEntry, name, description)
+ {
+ }
+
+ ///
+ public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last)
+ {
+ var toggleObject = Object.Instantiate(toggle, parent).GetComponent();
+ var tmp = toggleObject.transform.FindChild("Text_TMP").GetComponent();
+ var passiveButton = toggleObject.GetComponent();
+ var rollover = toggleObject.GetComponent();
+ toggleObject.gameObject.SetActive(true);
+
+ if (last && order == 1)
+ {
+ // Toggle in the middle
+ toggleObject.transform.localPosition = new Vector3(0, 1.85f - offset, -7);
+ }
+ else
+ {
+ toggleObject.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7);
+ }
+
+ toggleObject.BaseText = CustomStringName.CreateAndRegister(Name);
+ toggleObject.UpdateText(GetValue());
+ toggleObject.name = Name;
+ toggleObject.Background.color = GetValue() ? Tab!.TabAppearance.ToggleActiveColor : Tab!.TabAppearance.ToggleInactiveColor;
+ passiveButton.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent();
+ rollover.OverColor = Tab!.TabAppearance.ToggleHoverColor;
+
+ passiveButton.OnClick.AddListener((UnityAction)(() =>
+ {
+ SetValue(!GetValue());
+ toggleObject.UpdateText(GetValue());
+ toggleObject.Background.color = GetValue() ? Tab!.TabAppearance.ToggleActiveColor : Tab!.TabAppearance.ToggleInactiveColor;
+ }));
+ passiveButton.OnMouseOver.AddListener((UnityAction)(() =>
+ {
+ if (!Description.IsNullOrWhiteSpace())
+ {
+ tmp.text = Description;
+ }
+ }));
+ passiveButton.OnMouseOut.AddListener((UnityAction)(() =>
+ {
+ toggleObject.UpdateText(GetValue());
+ toggleObject.Background.color = GetValue() ? Tab!.TabAppearance.ToggleActiveColor : Tab!.TabAppearance.ToggleInactiveColor;
+ }));
+
+ Helpers.DivideSize(toggleObject.gameObject, 1.1f);
+
+ order++;
+ if (order > 2 && !last)
+ {
+ offset += 0.5f;
+ order = 1;
+ }
+ if (last)
+ offset += 0.6f;
+
+ return toggleObject.gameObject;
+ }
+}
diff --git a/MiraAPI/MiraApiConfig.cs b/MiraAPI/MiraApiConfig.cs
deleted file mode 100644
index 10c9776..0000000
--- a/MiraAPI/MiraApiConfig.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-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 aa7f3c6..8654cf6 100644
--- a/MiraAPI/MiraApiPlugin.cs
+++ b/MiraAPI/MiraApiPlugin.cs
@@ -30,10 +30,6 @@ 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);
@@ -44,7 +40,6 @@ 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/MiraApiSettings.cs b/MiraAPI/MiraApiSettings.cs
new file mode 100644
index 0000000..59eb6a4
--- /dev/null
+++ b/MiraAPI/MiraApiSettings.cs
@@ -0,0 +1,40 @@
+using BepInEx.Configuration;
+using MiraAPI.LocalSettings;
+using MiraAPI.LocalSettings.Attributes;
+using MiraAPI.Utilities.Assets;
+
+namespace MiraAPI;
+
+///
+/// Mira API Config File Handler
+///
+public class MiraApiSettings(ConfigFile config) : LocalSettingsTab(config)
+{
+ ///
+ public override string TabName => "Mira API";
+
+ ///
+ public override LocalSettingTabAppearance TabAppearance => new()
+ {
+ TabButtonHoverColor = MiraApiPlugin.MiraColor,
+ TabIcon = MiraAssets.SettingsIcon,
+ HideIconOnHover = false,
+ };
+
+ ///
+ /// Gets whether the modifiers hud should be on the left side of the screen (under roles/task tab). Recommended for streamers.
+ ///
+ [LocalToggleSetting]
+ public ConfigEntry ModifiersHudLeftSide { get; private set; } = config.Bind("Displays", "Show Modifiers HUD on Left Side", false);
+
+ ///
+ /// Gets whether to show keybinds in the control mapper.
+ ///
+ public ConfigEntry ShowKeybinds { get; private set; } = config.Bind("Keybinds", "Show Keybinds in Control Mapper", true);
+
+ ///
+ /// Gets whether to apply cosmetic changes to the TaskAdder.
+ ///
+ [LocalToggleSetting]
+ public ConfigEntry PrettyTaskAdder { get; private set; } = config.Bind("Freeplay", "Pretty Task Laptop", true);
+}
diff --git a/MiraAPI/Modifiers/ModifierComponent.cs b/MiraAPI/Modifiers/ModifierComponent.cs
index 4f556f5..ef812a5 100644
--- a/MiraAPI/Modifiers/ModifierComponent.cs
+++ b/MiraAPI/Modifiers/ModifierComponent.cs
@@ -4,6 +4,7 @@ using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Il2CppInterop.Runtime.Attributes;
+using MiraAPI.LocalSettings;
using MiraAPI.Modifiers.ModifierDisplay;
using MiraAPI.Modifiers.Types;
using MiraAPI.Patches.Roles;
@@ -129,7 +130,7 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr)
{
var taskPanelOpen = HudManager.Instance.TaskPanel.open;
var roleTabOpen = HudManagerPatches.RoleTab != null && HudManagerPatches.RoleTab.open;
- var leftSideHud = PluginSingleton.Instance.MiraConfig!.ModifiersHudLeftSide.Value;
+ var leftSideHud = LocalSettingsTabSingleton.Instance.ModifiersHudLeftSide.Value;
if (leftSideHud && (taskPanelOpen || roleTabOpen))
{
diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs
index 0836935..2982f67 100644
--- a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs
+++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs
@@ -1,12 +1,10 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using HarmonyLib;
using Il2CppInterop.Runtime.Attributes;
-using MiraAPI.Patches.Roles;
+using MiraAPI.LocalSettings;
using MiraAPI.Utilities.Assets;
-using Reactor.Utilities;
using Reactor.Utilities.Attributes;
using Reactor.Utilities.Extensions;
using TMPro;
@@ -98,7 +96,7 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr)
IsOpen = false;
_children.gameObject.SetActive(false);
- if (PluginSingleton.Instance.MiraConfig!.ModifiersHudLeftSide.Value)
+ if (LocalSettingsTabSingleton.Instance.ModifiersHudLeftSide.Value)
{
var aspect = GetComponent();
_toggleButton.transform.localPosition = new Vector3(-1f, 2.7f, 0f);
diff --git a/MiraAPI/Modifiers/ModifierExtensions.cs b/MiraAPI/Modifiers/ModifierExtensions.cs
index 309a7d2..9c6f741 100644
--- a/MiraAPI/Modifiers/ModifierExtensions.cs
+++ b/MiraAPI/Modifiers/ModifierExtensions.cs
@@ -63,7 +63,7 @@ public static class ModifierExtensions
///
/// The player to remove the modifier from.
/// The unique ID of the modifier.
- [MethodRpc((uint)MiraRpc.RemoveModifier, SendImmediately = true)]
+ [MethodRpc((uint)MiraRpc.RemoveModifier)]
public static void RpcRemoveModifier(this PlayerControl target, Guid uniqueId)
{
target.RemoveModifier(uniqueId);
diff --git a/MiraAPI/Networking/CustomMurderRpc.cs b/MiraAPI/Networking/CustomMurderRpc.cs
index 4a7bc30..e3005c9 100644
--- a/MiraAPI/Networking/CustomMurderRpc.cs
+++ b/MiraAPI/Networking/CustomMurderRpc.cs
@@ -29,7 +29,7 @@ public static class CustomMurderRpc
/// Should the killer be snapped to the dead player.
/// Should the kill animation be shown.
/// Should the kill sound be played.
- [MethodRpc((uint)MiraRpc.CustomMurder, LocalHandling = RpcLocalHandling.Before, SendImmediately = true)]
+ [MethodRpc((uint)MiraRpc.CustomMurder, LocalHandling = RpcLocalHandling.Before)]
public static void RpcCustomMurder(
this PlayerControl source,
PlayerControl target,
@@ -222,7 +222,7 @@ public static class CustomMurderRpc
if (createDeadBody)
{
- deadBody = Object.Instantiate(GameManager.Instance.DeadBodyPrefab);
+ deadBody = Object.Instantiate(GameManager.Instance.deadBodyPrefab[0]);
deadBody.enabled = false;
deadBody.ParentId = target.PlayerId;
deadBody.bodyRenderers.ToList().ForEach(target.SetPlayerMaterialColors);
diff --git a/MiraAPI/Patches/Events/EndGamePatches.cs b/MiraAPI/Patches/Events/EndGamePatches.cs
index 558c468..fe5a7bb 100644
--- a/MiraAPI/Patches/Events/EndGamePatches.cs
+++ b/MiraAPI/Patches/Events/EndGamePatches.cs
@@ -7,8 +7,17 @@ namespace MiraAPI.Patches.Events;
internal static class EndGamePatches
{
+ [HarmonyPatch(typeof(GameManager), nameof(GameManager.Awake))]
+ public static class GameManagerAwakePatch
+ {
+ public static void Postfix(GameManager __instance)
+ {
+ __instance.enabled = true;
+ }
+ }
+
[HarmonyPatch(typeof(GameManager), nameof(GameManager.RpcEndGame))]
- public static class GameManagerPatches
+ public static class GameManagerRpcEndGamePatch
{
public static bool Prefix(GameOverReason endReason)
{
diff --git a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs
index 07d37f5..3e4053e 100644
--- a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs
+++ b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs
@@ -1,7 +1,5 @@
using HarmonyLib;
using MiraAPI.Modifiers;
-using MiraAPI.Roles;
-using UnityEngine;
namespace MiraAPI.Patches.Freeplay;
diff --git a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs
index ae49ed4..78fa445 100644
--- a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs
+++ b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs
@@ -1,14 +1,18 @@
-using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using System.Text;
using AmongUs.GameOptions;
using HarmonyLib;
+using Il2CppInterop.Runtime.InteropTypes.Arrays;
+using MiraAPI.LocalSettings;
using MiraAPI.Modifiers;
+using MiraAPI.Modifiers.Types;
using MiraAPI.PluginLoading;
using MiraAPI.Roles;
using MiraAPI.Utilities;
using MiraAPI.Utilities.Assets;
-using TMPro;
+using Reactor.Utilities.Extensions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
@@ -20,6 +24,8 @@ namespace MiraAPI.Patches.Freeplay;
internal static class TaskAdderPatches
{
private static Scroller _scroller = null!;
+ private static Dictionary folders = new();
+
[HarmonyPostfix]
[HarmonyPatch(nameof(TaskAdderGame.Begin))]
public static void AddRolesFolder(TaskAdderGame __instance)
@@ -59,46 +65,168 @@ internal static class TaskAdderPatches
__instance.RoleButton.GetComponent().ClickMask = collider;
__instance.RootFolderPrefab.GetComponent().ClickMask = collider;
__instance.RootFolderPrefab.gameObject.SetActive(false);
-
__instance.TaskParent = inner.transform;
+ var crewLocale = TranslationController.Instance.GetString(StringNames.Crewmate);
+ var impLocale = TranslationController.Instance.GetString(StringNames.Impostor);
- var rolesFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner);
- rolesFolder.gameObject.SetActive(false);
- rolesFolder.FolderName = "Roles";
- rolesFolder.name = "RolesFolder";
+ var crewmateFolder = __instance.Root.SubFolders.ToArray().FirstOrDefault(x => x.FolderName == crewLocale)!;
+ var impostorFolder = __instance.Root.SubFolders.ToArray().FirstOrDefault(x => x.FolderName == impLocale)!;
+ //var neutralFolder = __instance.CreateFolder("Neutral", __instance.Root, 2, Color.gray);
+ var modifiersFolder = __instance.CreateFolder("Modifiers", __instance.Root, 0, Color.blue);
- var modifiersFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner);
- modifiersFolder.gameObject.SetActive(false);
- modifiersFolder.FolderName = "Modifiers";
- modifiersFolder.name = "ModifiersFolder";
+ folders.Clear();
+ folders.Add(crewmateFolder.FolderName, crewmateFolder);
+ folders.Add(impostorFolder.FolderName, impostorFolder);
+ //folders.Add("Neutrals", neutralFolder);
+ folders.Add("Modifiers", modifiersFolder);
+ int folderIdx = 2;
foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins)
{
- if (plugin.InternalRoles.Any(x => x.Value is ICustomRole { Configuration.ShowInFreeplay: true }))
+ var pluginFolders = new Dictionary();
+
+ foreach (var role in plugin.InternalRoles
+ .Where(x => x.Value is ICustomRole { Configuration.ShowInFreeplay: true })
+ .Select(x => x.Value))
{
- var newFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner);
- newFolder.name = plugin.PluginId+":Roles";
- newFolder.FolderName = plugin.PluginInfo.Metadata.Name;
- newFolder.gameObject.SetActive(false);
- rolesFolder.SubFolders.Add(newFolder);
+ var customRole = role as ICustomRole;
+ var folderName = customRole!.Configuration.FreeplayFolder;
+ if (!folders.TryGetValue(folderName, out var teamFolder))
+ {
+ teamFolder = __instance.CreateFolder(folderName, __instance.Root, folderIdx++, customRole.IntroConfiguration?.IntroTeamColor ?? Color.gray);
+ folders.Add(folderName, teamFolder);
+ }
+
+ if (!pluginFolders.TryGetValue(folderName, out var pluginFolder))
+ {
+ pluginFolder = __instance.CreateFolder(plugin.PluginInfo.Metadata.Name, teamFolder);
+ pluginFolder.name = $"Roles:{folderName}:{plugin.PluginId}";
+ pluginFolders.Add(folderName, teamFolder);
+ }
}
if (plugin.InternalModifiers.Exists(x => x.ShowInFreeplay))
{
- var newFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner);
- newFolder.name = plugin.PluginId+":Modifiers";
- newFolder.FolderName = plugin.PluginInfo.Metadata.Name;
- newFolder.gameObject.SetActive(false);
- modifiersFolder.SubFolders.Add(newFolder);
+ __instance.CreateFolder(plugin.PluginInfo.Metadata.Name, modifiersFolder)
+ .gameObject.name = plugin.PluginId;
}
}
- __instance.Root.SubFolders.Add(rolesFolder);
- __instance.Root.SubFolders.Add(modifiersFolder);
+ // Modifier folder at the end
+ __instance.Root.SubFolders.Insert(folderIdx, modifiersFolder);
__instance.GoToRoot();
}
+ [HarmonyPrefix]
+ [HarmonyPatch(nameof(TaskAdderGame.PopulateRoot))]
+ private static bool PopulateRootPrefix(TaskAdderGame __instance, TaskAdderGame.FolderType folderType, TaskFolder rootFolder, Il2CppSystem.Collections.Generic.Dictionary folders, Il2CppReferenceArray taskList)
+ {
+ if (folderType != TaskAdderGame.FolderType.Tasks)
+ {
+ if (folderType != TaskAdderGame.FolderType.Roles)
+ {
+ return false;
+ }
+
+ TaskFolder crewFolder = folders["Crewmates"] =
+ Object.Instantiate(__instance.RootFolderPrefab, __instance.transform);
+ crewFolder.gameObject.SetActive(false);
+ crewFolder.SetFolderColor(TaskFolder.FolderColor.Blue);
+ TaskFolder impFolder = folders["Impostors"] =
+ Object.Instantiate(__instance.RootFolderPrefab, __instance.transform);
+ impFolder.gameObject.SetActive(false);
+ impFolder.SetFolderColor(TaskFolder.FolderColor.Red);
+ crewFolder.FolderName = TranslationController.Instance.GetString(StringNames.Crewmate);
+ impFolder.FolderName = TranslationController.Instance.GetString(StringNames.Impostor);
+ rootFolder.SubFolders.Insert(0, crewFolder);
+ rootFolder.SubFolders.Insert(0, impFolder);
+ Il2CppSystem.Collections.Generic.List impRoles = new();
+ Il2CppSystem.Collections.Generic.List crewRoles = new();
+ foreach (var role in RoleManager.Instance.AllRoles)
+ {
+ if (role.Role != RoleTypes.ImpostorGhost && role.Role != RoleTypes.CrewmateGhost && !role.IsCustomRole())
+ {
+ if (role.TeamType == RoleTeamTypes.Crewmate)
+ {
+ crewRoles.Add(role);
+ }
+ else
+ {
+ impRoles.Add(role);
+ }
+ }
+ }
+
+ crewFolder.RoleChildren = crewRoles;
+ impFolder.RoleChildren = impRoles;
+ return false;
+ }
+
+ if (taskList == null)
+ {
+ return false;
+ }
+
+ foreach (NormalPlayerTask normalPlayerTask in taskList)
+ {
+ SystemTypes systemTypes = normalPlayerTask.StartAt;
+ if (normalPlayerTask is DivertPowerTask task)
+ {
+ systemTypes = task.TargetSystem;
+ }
+
+ if (systemTypes == SystemTypes.LowerEngine)
+ {
+ systemTypes = SystemTypes.UpperEngine;
+ }
+
+ if (!folders.TryGetValue(systemTypes.ToString(), out var taskFolder3))
+ {
+ taskFolder3 = folders[systemTypes.ToString()] = Object.Instantiate(__instance.RootFolderPrefab, __instance.transform);
+ taskFolder3.SetFolderColor(TaskFolder.FolderColor.Tan);
+ taskFolder3.gameObject.SetActive(false);
+ if (systemTypes == SystemTypes.UpperEngine)
+ {
+ taskFolder3.FolderName = TranslationController.Instance.GetString(StringNames.Engines);
+ }
+ else
+ {
+ taskFolder3.FolderName = TranslationController.Instance.GetString(systemTypes);
+ }
+
+ rootFolder.SubFolders.Add(taskFolder3);
+ }
+
+ taskFolder3.TaskChildren.Add(normalPlayerTask);
+ }
+
+ return false;
+ }
+
+
+ private static TaskFolder CreateFolder(this TaskAdderGame instance, string name, TaskFolder parent, int idx = -1, Color? color = null)
+ {
+ var folder = Object.Instantiate(instance.RootFolderPrefab, instance.transform);
+ folder.gameObject.SetActive(false);
+ folder.FolderName = name;
+ folder.SetCustomColor(color ?? new Color(0.937f, 0.811f, 0.592f));
+ folder.name = name + "Folder";
+ if (idx == -1)
+ parent.SubFolders.Add(folder);
+ else if (idx > 0)
+ parent.SubFolders.Insert(idx, folder);
+ return folder;
+ }
+
+ private static void SetCustomColor(this TaskFolder folder, Color color)
+ {
+ folder.currentFolderColor = color;
+ folder.folderSpriteRenderer.color = color;
+ folder.buttonRolloverHandler.OutColor = color;
+ folder.buttonRolloverHandler.UnselectedColor = color;
+ }
+
private static void AddFileAsChildCustom(
this TaskAdderGame instance,
TaskAddButton item,
@@ -121,40 +249,46 @@ internal static class TaskAdderPatches
instance.ActiveItems.Add(item.transform);
}
+ private static bool IsChildOf(this TaskFolder child, TaskFolder parent) => parent.SubFolders
+ .ToArray()
+ .Any(x => x.FolderName == child.FolderName);
+
// yes it might be crazy patching the entire method, but i tried so many other methods and only this works :cry:
+ // true -chip
[HarmonyPrefix]
[HarmonyPatch(nameof(TaskAdderGame.ShowFolder))]
public static bool ShowPatch(TaskAdderGame __instance, TaskFolder taskFolder)
{
- var stringBuilder = new Il2CppSystem.Text.StringBuilder(64);
+ StringBuilder stringBuilder = new StringBuilder(64);
__instance.Hierarchy.Add(taskFolder);
- for (var i = 0; i < __instance.Hierarchy.Count; i++)
+ foreach (var t in __instance.Hierarchy)
{
- stringBuilder.Append(__instance.Hierarchy.ToArray()[i].FolderName);
- stringBuilder.Append("\\");
+ stringBuilder.Append(t.FolderName);
+ stringBuilder.Append('\\');
}
-
__instance.PathText.text = stringBuilder.ToString();
- for (var j = 0; j < __instance.ActiveItems.Count; j++)
+ var prettyEnabled = LocalSettingsTabSingleton.Instance.PrettyTaskAdder.Value;
+ if (prettyEnabled)
{
- Object.Destroy(__instance.ActiveItems.ToArray()[j].gameObject);
+ __instance.PathText.fontSizeMin = 3;
+ __instance.PathText.fontSizeMax = 3;
+ __instance.transform.FindChild("TitleText_TMP")?.gameObject.DestroyImmediate();
}
+ __instance.ActiveItems.ToArray().Do(x => x.gameObject.Destroy());
__instance.ActiveItems.Clear();
- var num = 0f;
- var num2 = 0f;
- var num3 = 0f;
- for (var k = 0; k < taskFolder.SubFolders.Count; k++)
- {
- var taskFolder2 = Object.Instantiate(taskFolder.SubFolders.ToArray()[k], __instance.TaskParent);
- var folderTransform = taskFolder2.transform;
+ float num = 0f;
+ float num2 = 0f;
+ float num3 = 0f;
+ foreach (var t in taskFolder.SubFolders)
+ {
+ TaskFolder taskFolder2 = Object.Instantiate(t, __instance.TaskParent);
taskFolder2.gameObject.SetActive(true);
taskFolder2.Parent = __instance;
-
- folderTransform.localPosition = new Vector3(num, num2, 0f);
- folderTransform.localScale = Vector3.one;
- num3 = Mathf.Max(num3, taskFolder2.Text.bounds.size.y + 1.3f);
+ taskFolder2.transform.localPosition = new Vector3(num, num2, 0f);
+ taskFolder2.transform.localScale = Vector3.one;
+ num3 = Mathf.Max(num3, taskFolder2.Text.bounds.size.y + 1.1f);
num += __instance.folderWidth;
if (num > __instance.lineWidth)
{
@@ -163,139 +297,202 @@ internal static class TaskAdderPatches
num3 = 0f;
}
- __instance.ActiveItems.Add(folderTransform);
- if (!taskFolder2 || !taskFolder2.Button)
+ __instance.ActiveItems.Add(taskFolder2.transform);
+ if (taskFolder2 != null && taskFolder2.Button != null)
{
- continue;
- }
-
- ControllerManager.Instance.AddSelectableUiElement(taskFolder2.Button);
- if (!string.IsNullOrEmpty(__instance.restorePreviousSelectionByFolderName) &&
- taskFolder2.FolderName.Equals(__instance.restorePreviousSelectionByFolderName, StringComparison.Ordinal))
- {
- __instance.restorePreviousSelectionFound = taskFolder2.Button;
+ ControllerManager.Instance.AddSelectableUiElement(taskFolder2.Button);
}
}
- var flag = false;
- var list = taskFolder.Children.ToArray().OrderBy(t => t.TaskType).ToList();
-
- for (var l = 0; l < list.Count; l++)
+ var list = taskFolder.TaskChildren
+ .ToArray()
+ .OrderBy(x => x.TaskType)
+ .ToArray();
+ foreach (var task in list)
{
- var taskAddButton = Object.Instantiate(__instance.TaskPrefab);
- taskAddButton.MyTask = list.ToArray()[l];
- switch (taskAddButton.MyTask.TaskType)
+ TaskAddButton taskAddButton = Object.Instantiate(__instance.TaskPrefab);
+ taskAddButton.MyTask = task;
+ switch (task.TaskType)
{
case TaskTypes.DivertPower:
- {
- var targetSystem = taskAddButton.MyTask.Cast().TargetSystem;
- taskAddButton.Text.text = TranslationController.Instance.GetString(
- StringNames.DivertPowerTo,
- TranslationController.Instance.GetString(targetSystem));
- break;
- }
+ {
+ var targetSystem = task.Cast().TargetSystem;
+ taskAddButton.Text.text = TranslationController.Instance.GetString(
+ StringNames.DivertPowerTo,
+ TranslationController.Instance.GetString(targetSystem));
+ break;
+ }
case TaskTypes.FixWeatherNode:
- {
- var nodeId = taskAddButton.MyTask.Cast().NodeId;
- taskAddButton.Text.text =
- TranslationController.Instance.GetString(
- StringNames.FixWeatherNode) + " " +
- TranslationController.Instance.GetString(
- WeatherSwitchGame.ControlNames[nodeId]);
- break;
- }
+ {
+ var nodeId = task.Cast().NodeId;
+ taskAddButton.Text.text =
+ TranslationController.Instance.GetString(
+ StringNames.FixWeatherNode) + " " +
+ TranslationController.Instance.GetString(
+ WeatherSwitchGame.ControlNames[nodeId]);
+ break;
+ }
default:
taskAddButton.Text.text =
- TranslationController.Instance.GetString(taskAddButton.MyTask.TaskType);
+ TranslationController.Instance.GetString(task.TaskType);
break;
}
__instance.AddFileAsChildCustom(taskAddButton, ref num, ref num2, ref num3);
- if (taskAddButton.Button == null)
+ if (taskAddButton != null && taskAddButton.Button != null)
{
- continue;
- }
-
- ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button);
- if (__instance.Hierarchy.Count == 1 || flag)
- {
- continue;
- }
-
- var component =
- ControllerManager.Instance.CurrentUiState.CurrentSelection.GetComponent();
- if (component != null)
- {
- __instance.restorePreviousSelectionByFolderName = component.FolderName;
- }
-
- ControllerManager.Instance.SetDefaultSelection(taskAddButton.Button);
- flag = true;
- }
-
- if (taskFolder.FolderName == "Roles")
- {
- for (var m = 0; m < RoleManager.Instance.AllRoles.Length; m++)
- {
- var roleBehaviour = RoleManager.Instance.AllRoles[m];
- if (roleBehaviour.Role == RoleTypes.ImpostorGhost || roleBehaviour.Role == RoleTypes.CrewmateGhost ||
- CustomRoleManager.CustomRoles.ContainsKey((ushort)roleBehaviour.Role))
- {
- continue;
- }
-
- var taskAddButton2 = Object.Instantiate(__instance.RoleButton);
- taskAddButton2.SafePositionWorld = __instance.SafePositionWorld;
- taskAddButton2.Text.text = "Be_" + roleBehaviour.NiceName + ".exe";
- __instance.AddFileAsChildCustom(taskAddButton2, ref num, ref num2, ref num3);
- taskAddButton2.Role = roleBehaviour;
-
- if (taskAddButton2.Button == null)
- {
- continue;
- }
-
- ControllerManager.Instance.AddSelectableUiElement(taskAddButton2.Button);
- switch (m)
- {
- case 0 when __instance.restorePreviousSelectionFound != null:
- ControllerManager.Instance.SetDefaultSelection(__instance.restorePreviousSelectionFound);
- __instance.restorePreviousSelectionByFolderName = string.Empty;
- __instance.restorePreviousSelectionFound = null;
- break;
- case 0:
- ControllerManager.Instance.SetDefaultSelection(taskAddButton2.Button);
- break;
- }
+ ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button, false);
}
}
- if (taskFolder)
+ var roleChildren = taskFolder.RoleChildren
+ .ToArray()
+ .Where(x => x is not ICustomRole)
+ .ToArray();
+ foreach (var role in roleChildren)
{
- var split = taskFolder.name.Split(':');
- if (split.Length == 2)
+ TaskAddButton roleAddButton = Object.Instantiate(__instance.RoleButton);
+ roleAddButton.SafePositionWorld = __instance.SafePositionWorld;
+ roleAddButton.Text.text = prettyEnabled ? role.GetRoleName() : "Be_" + role.GetRoleName() + ".exe";
+ roleAddButton.Role = role;
+ if (prettyEnabled)
{
- var pluginId = split[0];
- var folderName = split[1];
+ roleAddButton.Text.fontSizeMin = 1;
+ roleAddButton.FileImage.sprite = role.IsImpostor
+ ? MiraAssets.ImpostorFile.LoadAsset()
+ : MiraAssets.CrewmateFile.LoadAsset();
+ }
- if (MiraPluginManager.GetPluginByGuid(pluginId) is { } plugin)
+ __instance.AddFileAsChildCustom(roleAddButton, ref num, ref num2, ref num3);
+ if (roleAddButton != null && roleAddButton.Button != null)
+ {
+ ControllerManager.Instance.AddSelectableUiElement(roleAddButton.Button);
+ }
+ }
+
+ string[] split = [];
+ try
+ {
+ split = taskFolder.name.Replace("(Clone)", string.Empty).Split(':');
+ }
+ catch
+ {
+ // I hate you
+ }
+ if (split is ["Roles", _, _] && folders.Any(x => taskFolder.IsChildOf(x.Value)))
+ {
+ var plugin = MiraPluginManager.GetPluginByGuid(split[2]);
+ if (plugin != null)
+ {
+ foreach (var role in plugin.InternalRoles)
{
- if (folderName == "Roles(Clone)")
+ var roleBehaviour = role.Value;
+ if (roleBehaviour.Role == RoleTypes.ImpostorGhost || roleBehaviour.Role == RoleTypes.CrewmateGhost ||
+ roleBehaviour is ICustomRole { Configuration.ShowInFreeplay: false })
{
- PluginRoles(__instance, plugin, ref num, ref num2, ref num3);
+ continue;
}
- else if (folderName == "Modifiers(Clone)")
+ var crewLocale = TranslationController.Instance.GetString(StringNames.Crewmate);
+ var impLocale = TranslationController.Instance.GetString(StringNames.Impostor);
+
+ var teamFolder = roleBehaviour.IsImpostor ? impLocale : crewLocale;
+ if (roleBehaviour is ICustomRole cs)
{
- PluginModifiers(__instance, plugin, ref num, ref num2, ref num3);
+ teamFolder = cs.Configuration.FreeplayFolder;
}
+ if (split[1] != teamFolder)
+ {
+ continue;
+ }
+
+ var roleAddButton = Object.Instantiate(__instance.RoleButton);
+ roleAddButton.MyTask = null;
+ roleAddButton.SafePositionWorld = __instance.SafePositionWorld;
+ roleAddButton.Text.text = prettyEnabled ? roleBehaviour.GetRoleName() : "Be_" + roleBehaviour.GetRoleName() + ".exe";
+ roleAddButton.Text.EnableMasking();
+ roleAddButton.role = roleBehaviour;
+ if (prettyEnabled)
+ {
+ roleAddButton.Text.fontSizeMin = 1;
+ roleAddButton.FileImage.sprite = roleBehaviour.IsImpostor
+ ? MiraAssets.ImpostorFile.LoadAsset()
+ : MiraAssets.CrewmateFile.LoadAsset();
+ }
+ if (roleBehaviour is ICustomRole custom)
+ {
+ if (prettyEnabled && custom.Team == ModdedRoleTeams.Custom) roleAddButton.FileImage.sprite = MiraAssets.CustomTeamFile.LoadAsset();
+ 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;
+ }
+ roleAddButton.FileImage.color = customColor;
+ roleAddButton.RolloverHandler.OutColor = customColor;
+ }
+
+ __instance.AddFileAsChildCustom(roleAddButton, ref num, ref num2, ref num3);
+ ControllerManager.Instance.AddSelectableUiElement(roleAddButton.Button);
}
}
}
- foreach (var chip in __instance.ActiveItems)
+ if (folders.TryGetValue("Modifiers", out var modifiersFolder) && taskFolder.IsChildOf(modifiersFolder))
{
- chip.GetComponentInChildren().EnableStencilMasking();
- chip.GetComponent().maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
+ var plugin = MiraPluginManager.GetPluginByGuid(taskFolder.name.Replace("(Clone)", string.Empty));
+ if (plugin != null)
+ {
+ foreach (var modifier in plugin.InternalModifiers)
+ {
+ if (!modifier.ShowInFreeplay)
+ {
+ continue;
+ }
+
+ var taskAddButton = Object.Instantiate(__instance.RoleButton);
+ taskAddButton.name = modifier.TypeId.ToString(NumberFormatInfo.InvariantInfo);
+ taskAddButton.role = null;
+ taskAddButton.MyTask = null;
+ taskAddButton.SafePositionWorld = __instance.SafePositionWorld;
+ taskAddButton.Text.text = modifier.ModifierName;
+ taskAddButton.Text.fontSizeMin = 1;
+ taskAddButton.Text.EnableMasking();
+ taskAddButton.FileImage.color = modifier.FreeplayFileColor;
+ taskAddButton.RolloverHandler.OutColor = modifier.FreeplayFileColor;
+ if (modifier is TimedModifier timed)
+ {
+ taskAddButton.FileImage.sprite = MiraAssets.TimedModifierFile.LoadAsset();
+ taskAddButton.Text.text += $" ({timed.Duration}s)";
+ }
+ else
+ {
+ taskAddButton.FileImage.sprite = MiraAssets.ModifierFile.LoadAsset();
+ }
+
+ taskAddButton.Button.OnClick = new Button.ButtonClickedEvent();
+ var m = modifier;
+ taskAddButton.Button.OnClick.AddListener((UnityAction)(() =>
+ {
+ var id = m.TypeId;
+ if (PlayerControl.LocalPlayer.HasModifier(id))
+ {
+ PlayerControl.LocalPlayer.RemoveModifier(id);
+ taskAddButton.Overlay.enabled = false;
+ }
+ else
+ {
+ PlayerControl.LocalPlayer.AddModifier(id);
+ taskAddButton.Overlay.enabled = true;
+ }
+ }));
+
+ __instance.AddFileAsChildCustom(taskAddButton, ref num, ref num2, ref num3);
+ ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button);
+ }
+ }
}
if (_scroller)
@@ -305,122 +502,11 @@ internal static class TaskAdderPatches
_scroller.ScrollToTop();
}
- if (__instance.Hierarchy.Count == 1)
- {
- ControllerManager.Instance.SetBackButton(__instance.BackButton);
- return false;
- }
+ ControllerManager.Instance.SetBackButton(__instance.Hierarchy.Count == 1
+ ? __instance.BackButton
+ : __instance.FolderBackButton);
- ControllerManager.Instance.SetBackButton(__instance.FolderBackButton);
+ __instance.SetPreviousControllerSelection(taskFolder.FolderName);
return false;
}
-
- private static void PluginModifiers(
- TaskAdderGame instance,
- MiraPluginInfo plugin,
- ref float num,
- ref float num2,
- ref float num3)
- {
- for (var m = 0; m < plugin.InternalModifiers.Count; m++)
- {
- var modifier = plugin.InternalModifiers[m];
- if (!modifier.ShowInFreeplay)
- {
- continue;
- }
-
- var taskAddButton = Object.Instantiate(instance.RoleButton);
- taskAddButton.name = modifier.TypeId.ToString(NumberFormatInfo.InvariantInfo);
- taskAddButton.role = null;
- taskAddButton.MyTask = null;
- 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)(() =>
- {
- var id = modifier.TypeId;
- if (PlayerControl.LocalPlayer.HasModifier(id))
- {
- PlayerControl.LocalPlayer.RemoveModifier(id);
- taskAddButton.Overlay.enabled = false;
- }
- else
- {
- PlayerControl.LocalPlayer.AddModifier(id);
- taskAddButton.Overlay.enabled = true;
- }
- }));
-
- instance.AddFileAsChildCustom(taskAddButton, ref num, ref num2, ref num3);
-
- ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button);
- switch (m)
- {
- case 0 when instance.restorePreviousSelectionFound != null:
- ControllerManager.Instance.SetDefaultSelection(instance.restorePreviousSelectionFound);
- instance.restorePreviousSelectionByFolderName = string.Empty;
- instance.restorePreviousSelectionFound = null;
- break;
- case 0:
- ControllerManager.Instance.SetDefaultSelection(taskAddButton.Button);
- break;
- }
- }
- }
-
- private static void PluginRoles(TaskAdderGame instance, MiraPluginInfo plugin, ref float num, ref float num2, ref float num3)
- {
- for (var m = 0; m < plugin.InternalRoles.Count; m++)
- {
- var roleBehaviour = plugin.InternalRoles.ElementAt(m).Value;
- if (roleBehaviour.Role == RoleTypes.ImpostorGhost || roleBehaviour.Role == RoleTypes.CrewmateGhost ||
- roleBehaviour is ICustomRole { Configuration.ShowInFreeplay: false })
- {
- continue;
- }
-
- var taskAddButton2 = Object.Instantiate(instance.RoleButton);
- taskAddButton2.SafePositionWorld = instance.SafePositionWorld;
- taskAddButton2.Text.text = "Be_" + roleBehaviour.NiceName + ".exe";
- taskAddButton2.Text.EnableMasking();
-
- 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;
- }
-
- ControllerManager.Instance.AddSelectableUiElement(taskAddButton2.Button);
- switch (m)
- {
- case 0 when instance.restorePreviousSelectionFound != null:
- ControllerManager.Instance.SetDefaultSelection(instance.restorePreviousSelectionFound);
- instance.restorePreviousSelectionByFolderName = string.Empty;
- instance.restorePreviousSelectionFound = null;
- break;
- case 0:
- ControllerManager.Instance.SetDefaultSelection(taskAddButton2.Button);
- break;
- }
- }
- }
}
diff --git a/MiraAPI/Patches/GameStartManagerPatch.cs b/MiraAPI/Patches/GameStartManagerPatch.cs
deleted file mode 100644
index 7ebb05b..0000000
--- a/MiraAPI/Patches/GameStartManagerPatch.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-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 29b190d..b6f88f4 100644
--- a/MiraAPI/Patches/Hud/ButtonResetPatches.cs
+++ b/MiraAPI/Patches/Hud/ButtonResetPatches.cs
@@ -20,6 +20,11 @@ public static class ButtonResetPatches
{
Logger.Error($"Error resetting cooldown and effect for button {customActionButton.GetType().Name}: {ex}");
}
+
+ if (customActionButton.UsesMode == ButtonUsesMode.PerRound)
+ {
+ customActionButton.SetUses(customActionButton.MaxUses);
+ }
}
}
}
diff --git a/MiraAPI/Patches/HudManagerPatches.cs b/MiraAPI/Patches/HudManagerPatches.cs
index acb4482..e8c13ac 100644
--- a/MiraAPI/Patches/HudManagerPatches.cs
+++ b/MiraAPI/Patches/HudManagerPatches.cs
@@ -1,11 +1,14 @@
-using System;
-using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
using HarmonyLib;
using MiraAPI.Hud;
using MiraAPI.Keybinds;
+using MiraAPI.LocalSettings;
+using MiraAPI.Utilities;
using Reactor.Utilities;
using Reactor.Utilities.Extensions;
using Rewired;
+using TMPro;
using UnityEngine;
using Object = UnityEngine.Object;
@@ -22,6 +25,8 @@ public static class HudManagerPatches
public static Transform? BottomRight { get; private set; }
public static Transform? Buttons { get; private set; }
+ private static Dictionary vanillaKeybindIcons = new();
+
/*
///
/// Trigger hudstart on current custom gamemode
@@ -94,8 +99,35 @@ public static class HudManagerPatches
gridArrange.Start();
gridArrange.ArrangeChilds();
-
aspectPosition.AdjustPosition();
+
+ vanillaKeybindIcons = [];
+ var keybindIconPos = new Vector3(-0.4f, 0.4f, -9.5f);
+ var vanillaButtons = new Dictionary
+ {
+ { __instance.KillButton.gameObject, 8 },
+ { __instance.UseButton.gameObject, 6 },
+ { __instance.ReportButton.gameObject, 7 },
+ { __instance.ImpostorVentButton.gameObject, 50 },
+ { __instance.SabotageButton.gameObject, 4 },
+ { __instance.AbilityButton.gameObject, 49 },
+ };
+
+ foreach (var kvp in vanillaButtons)
+ {
+ var buttonObj = kvp.Key;
+ var actionId = kvp.Value;
+
+ var key = KeybindUtils.GetKeycodeByActionId(actionId);
+ if (key == KeyboardKeyCode.None)
+ {
+ continue;
+ }
+ var icon = Helpers.CreateKeybindIcon(buttonObj, key, keybindIconPos);
+ vanillaKeybindIcons.Add(icon.transform.GetChild(0).GetComponent(), actionId);
+ var comp = buttonObj.GetComponent();
+ KeybindManager.VanillaKeybinds[comp.GetType()].Button = comp;
+ }
}
///
@@ -130,23 +162,30 @@ public static class HudManagerPatches
[HarmonyPatch(nameof(HudManager.Update))]
[HarmonyPostfix]
- public static void StartPostfix()
+ public static void UpdatePostfix()
{
- if (AmongUsClient.Instance.GameState != InnerNet.InnerNetClient.GameStates.Started) return;
- if (PlayerControl.LocalPlayer.Data.IsDead) return;
-
- foreach (var entry in KeybindManager.GetEntries())
+ foreach (var btnIcon in vanillaKeybindIcons)
{
- var player = ReInput.players.GetPlayer(0);
- var keyboard = player.controllers.Keyboard;
+ btnIcon.Key.text = KeybindUtils.GetKeycodeByActionId(btnIcon.Value).ToString();
+ btnIcon.Key.transform.parent.gameObject.SetActive(ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard &&
+ LocalSettingsTabSingleton.Instance.ShowKeybinds.Value);
+ }
- 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)))
+ var player = ReInput.players.GetPlayer(0);
+ foreach (var entry in KeybindManager.Keybinds)
+ {
+ var keyboard = player.controllers.Keyboard;
+ bool modifierKeysPressed = entry.ModifierKeys.All(k => keyboard.GetModifierKey(k)) ||
+ entry.ModifierKeys.Length <= 0;
+ if (player.GetButtonDown(entry.Id) && modifierKeysPressed)
{
- entry.Handler?.Invoke();
+ entry.Invoke();
}
}
+
+ foreach (var entry in KeybindManager.VanillaKeybinds.Values.Where(x => player.GetButtonDown(x.Id)))
+ {
+ entry.Invoke();
+ }
}
}
diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs
index af7ce8c..41217e6 100644
--- a/MiraAPI/Patches/IntroCutscenePatches.cs
+++ b/MiraAPI/Patches/IntroCutscenePatches.cs
@@ -29,6 +29,16 @@ public static class IntroCutscenePatches
MiraEventManager.InvokeEvent(@event);
}
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(IntroCutscene._ShowRole_d__41), nameof(IntroCutscene._ShowRole_d__41.MoveNext))]
+ [HarmonyPriority(Priority.Last)]
+ public static void ShowRolePatch(IntroCutscene._ShowRole_d__41 __instance)
+ {
+ var realInstance = __instance.__4__this;
+ var @event = new IntroRoleRevealEvent(realInstance);
+ MiraEventManager.InvokeEvent(@event);
+ }
+
[HarmonyPrefix]
[HarmonyPatch(nameof(IntroCutscene.BeginImpostor))]
[HarmonyPatch(nameof(IntroCutscene.BeginCrewmate))]
diff --git a/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs b/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs
new file mode 100644
index 0000000..ae46734
--- /dev/null
+++ b/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs
@@ -0,0 +1,67 @@
+using System.Linq;
+using HarmonyLib;
+using MiraAPI.Keybinds;
+using MiraAPI.LocalSettings;
+using Reactor.Utilities;
+using Reactor.Utilities.Extensions;
+using Rewired.UI.ControlMapper;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using UnityEngine.UI;
+
+namespace MiraAPI.Patches.Keybinds;
+
+[HarmonyPatch(typeof(ControlMapper))]
+public static class ControlMapperPatches
+{
+ [HarmonyPrefix]
+ [HarmonyPatch(nameof(ControlMapper.Start))]
+ private static void StartPrefix(ControlMapper __instance)
+ {
+ var doneButton = GameObject.Find("DoneButton")?.GetComponent();
+ if (doneButton == null)
+ {
+ return;
+ }
+
+ var toggle = Object.Instantiate(doneButton, doneButton.transform.parent);
+ var text = toggle.GetComponentInChildren();
+ var entry = LocalSettingsTabSingleton.Instance.ShowKeybinds;
+ toggle.gameObject.name = "KeybindsVisibleToggle";
+ text.text = $"Show Keybinds: {(entry.Value ? "On" : "Off")}";
+ toggle.onClick.RemoveAllListeners();
+ toggle.GetComponent().identifier = "KeybindsVisibleToggle";
+ toggle.onClick.AddListener((UnityAction)(() =>
+ {
+ entry.Value = !entry.Value;
+ text.text = $"Show Keybinds: {(entry.Value ? "On" : "Off")}";
+ }));
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(nameof(ControlMapper.Update))]
+ private static void UpdatePrefix(ControlMapper __instance)
+ {
+ foreach (var element in __instance.themedElements)
+ {
+ var info = element.GetComponent();
+ if (info == null)
+ {
+ continue;
+ }
+
+ var key = info.glyphOrText?.actionElementMap?.keyboardKeyCode;
+ if (key == null)
+ {
+ continue;
+ }
+
+ var conflicts = KeybindManager.GetConflicts();
+ if (conflicts.Any(x => x.Key.ToString() == key.ToString()))
+ {
+ element.GetComponent().color = Color.red;
+ }
+ }
+ }
+}
diff --git a/MiraAPI/Patches/Keybinds/KeybindsPatch.cs b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs
index e609086..c08a47f 100644
--- a/MiraAPI/Patches/Keybinds/KeybindsPatch.cs
+++ b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs
@@ -1,20 +1,36 @@
using HarmonyLib;
using MiraAPI.Keybinds;
-using MiraAPI.Utilities;
+using MonoMod.Utils;
using Rewired;
-namespace MiraAPI.Patches.Keybinds
+namespace MiraAPI.Patches.Keybinds;
+
+[HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))]
+public static class KeybindMenuPatch
{
- [HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))]
- public static class KeybindMenuPatch
+ private static bool _registered;
+
+ [HarmonyPrefix]
+ private static void AwakePrefix(InputManager_Base __instance)
{
- [HarmonyPrefix]
- private static void StartPrefix(InputManager_Base __instance)
+ if (_registered)
{
- foreach (var entry in KeybindManager.GetEntries())
- {
- __instance.userData.RegisterModBind(entry.Id, entry.Description, entry.Key, modifier1: entry.Modifier1, modifier2: entry.Modifier2, modifier3: entry.Modifier3);
- }
+ return;
}
+
+ KeybindUtils.RewiredInputManager = __instance;
+ KeybindManager.RewiredInit();
+ _registered = true;
+
+ // Set these here right after input manager initializes
+ KeybindManager.VanillaKeybinds = new()
+ {
+ { typeof(KillButton), new VanillaKeybind(8) },
+ { typeof(UseButton), new VanillaKeybind(6) },
+ { typeof(ReportButton), new VanillaKeybind(7) },
+ { typeof(VentButton), new VanillaKeybind(50) },
+ { typeof(SabotageButton), new VanillaKeybind(4) },
+ { typeof(AbilityButton), new VanillaKeybind(49) },
+ };
}
}
diff --git a/MiraAPI/Patches/Keybinds/OptionsMenuBehaviourPatch.cs b/MiraAPI/Patches/Keybinds/OptionsMenuBehaviourPatch.cs
new file mode 100644
index 0000000..2511168
--- /dev/null
+++ b/MiraAPI/Patches/Keybinds/OptionsMenuBehaviourPatch.cs
@@ -0,0 +1,40 @@
+using HarmonyLib;
+using MiraAPI.Keybinds;
+using UnityEngine;
+
+namespace MiraAPI.Patches.Keybinds;
+
+[HarmonyPatch(typeof(OptionsMenuBehaviour))]
+public static class OptionsMenuBehaviourPatch
+{
+ private static ButtonRolloverHandler? _remap_rollover;
+ private static SpriteRenderer? _remap_background;
+ private static bool _conflicts => KeybindManager.GetConflicts().Count > 0;
+
+ [HarmonyPostfix]
+ [HarmonyPatch(nameof(OptionsMenuBehaviour.Open))]
+ private static void OpenPostfix(OptionsMenuBehaviour __instance)
+ {
+ _remap_rollover = GameObject.Find("Remap Controls")?.GetComponent()!;
+ _remap_background = _remap_rollover.transform.FindChild("Background").GetComponent();
+ _remap_background.color = _conflicts ? Color.red : Color.white;
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(nameof(OptionsMenuBehaviour.Update))]
+ private static void UpdatePostfix(OptionsMenuBehaviour __instance)
+ {
+ if (_remap_rollover == null)
+ {
+ return;
+ }
+ if (_remap_background == null)
+ {
+ return;
+ }
+
+ _remap_rollover.OutColor = _conflicts ? Color.red : Color.white;
+ _remap_rollover.UnselectedColor = _conflicts ? Color.red : Color.white;
+ _remap_rollover.OverColor = _conflicts ? new Color32(255, 55, 55, 255) : Palette.AcceptedGreen;
+ }
+}
diff --git a/MiraAPI/Patches/LocalSettings/OptionsMenuPatches.cs b/MiraAPI/Patches/LocalSettings/OptionsMenuPatches.cs
new file mode 100644
index 0000000..7434aae
--- /dev/null
+++ b/MiraAPI/Patches/LocalSettings/OptionsMenuPatches.cs
@@ -0,0 +1,232 @@
+using System.Collections.Generic;
+using System.Linq;
+using HarmonyLib;
+using MiraAPI.LocalSettings;
+using MiraAPI.Utilities.Assets;
+using Reactor.Utilities;
+using Reactor.Utilities.Extensions;
+using UnityEngine;
+using UnityEngine.Events;
+using UnityEngine.UI;
+
+namespace MiraAPI.Patches.LocalSettings;
+
+[HarmonyPatch(typeof(OptionsMenuBehaviour))]
+public static class OptionsMenuPatches
+{
+ internal static OptionsMenuBehaviour? Instance { get; private set; }
+ internal static BoxCollider2D MaskCollider;
+ private static SpriteRenderer? background;
+
+ private static int currentPage = 1;
+ private static Dictionary> tabButtons = new();
+
+ ///
+ /// Creates the tabs and their content.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(nameof(OptionsMenuBehaviour.Start))]
+ public static void StartPostfix(OptionsMenuBehaviour __instance)
+ {
+ Instance = __instance;
+ // Fix for tabs not being clickable in the main menu
+ if (!AmongUsClient.Instance.IsInGame)
+ {
+ __instance.Background.GetComponent().enabled = false;
+ __instance.transform.FindChild("Tint").SetLocalZ(5.6f);
+ }
+
+ background = __instance.Background;
+
+ var maskObj = new GameObject
+ {
+ layer = 5,
+ name = "SpriteMask",
+ };
+ maskObj.transform.SetParent(__instance.transform);
+ maskObj.transform.localPosition = new Vector3(0, -0.3f, 0);
+ maskObj.transform.localScale = new Vector3(500, 120, 1);
+
+ var blank = Sprite.Create(
+ Texture2D.whiteTexture,
+ new Rect(0, 0, Texture2D.whiteTexture.width, Texture2D.whiteTexture.height),
+ new Vector2(0.5f, 0.5f));
+
+ var mask = maskObj.AddComponent();
+ mask.sprite = blank;
+ mask.isCustomRangeActive = true;
+ mask.sortingLayerName = "Default";
+ mask.backSortingOrder = 100;
+ mask.frontSortingOrder = 500;
+
+ MaskCollider = maskObj.AddComponent();
+ MaskCollider.size = new Vector2(0.01f, 0.1f);
+ MaskCollider.isTrigger = true;
+ MaskCollider.enabled = true;
+
+ currentPage = 1;
+ tabButtons.Clear();
+ float yOffset = 0;
+ int i = 0;
+ int tabIdx = 0;
+ int page = 1;
+ foreach (var settings in LocalSettingsManager.Tabs)
+ {
+ var tab = settings.CreateTab(__instance);
+ if (settings.ShouldCreateButton)
+ {
+ var button = settings.CreateTabButton(__instance, ref tabIdx, ref yOffset);
+ button.GetComponent().Content = tab;
+ button.SetActive(page == currentPage);
+
+ if (!tabButtons.TryGetValue(page, out var list))
+ {
+ tabButtons.Add(page, new());
+ list = tabButtons[page];
+ }
+ list.Add(button);
+ yOffset += 0.6f;
+ tabIdx++;
+ i++;
+ }
+ if (i > 7)
+ {
+ i = 0;
+ yOffset = 0;
+ page++;
+ }
+ }
+
+ void ChangePage(int increase)
+ {
+ currentPage += increase;
+ if (currentPage > tabButtons.Count)
+ {
+ currentPage = 1;
+ }
+ if (currentPage < 1)
+ {
+ currentPage = tabButtons.Count;
+ }
+ }
+
+ // Create page buttons
+ // TODO: In-game menu has no close button. I tried making one from scratch but failed. Add one later plz
+ if (tabButtons.Count <= 1)
+ {
+ return;
+ }
+
+ var nextButton = Object.Instantiate(__instance.BackButton, __instance.transform).GetComponent();
+ nextButton.transform.localPosition = new Vector3(3.2f, 2.6f, -10);
+ nextButton.transform.localScale = new Vector2(0.25f, 0.25f);
+ var nextRend = nextButton.GetComponent();
+ nextRend.sprite = MiraAssets.NextButton.LoadAsset();
+ nextButton.gameObject.GetComponent().DestroyImmediate();
+
+ var nextRollover = nextButton.gameObject.AddComponent();
+ nextRollover.Target = nextRend;
+ nextRollover.OverColor = Palette.AcceptedGreen;
+
+ nextButton.OnClick = new Button.ButtonClickedEvent();
+ nextButton.OnClick.AddListener(
+ (UnityAction)(() =>
+ {
+ ChangePage(1);
+ UpdatePages();
+ }));
+
+ var backButton = Object.Instantiate(__instance.BackButton, __instance.transform).GetComponent();
+ backButton.transform.localPosition = new Vector3(2.9f, 2.6f, -10);
+ backButton.transform.localScale = new Vector2(0.25f, 0.25f);
+ var backRend = backButton.GetComponent();
+ backRend.sprite = MiraAssets.NextButton.LoadAsset();
+ backRend.flipX = true;
+ backButton.gameObject.GetComponent().DestroyImmediate();
+
+ var backRollover = backButton.gameObject.AddComponent();
+ backRollover.Target = backRend;
+ backRollover.OverColor = Palette.AcceptedGreen;
+
+ backButton.OnClick = new Button.ButtonClickedEvent();
+ backButton.OnClick.AddListener(
+ (UnityAction)(() =>
+ {
+ ChangePage(-1);
+ UpdatePages();
+ }));
+ }
+
+ ///
+ /// Modifies how opening tabs is handled for the custom ones to work.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(nameof(OptionsMenuBehaviour.OpenTabGroup))]
+ public static bool OpenTabGroupPrefix(OptionsMenuBehaviour __instance, ref int index)
+ {
+ __instance.Tabs.ToList().ForEach(x => x.Close()); // Close all vanilla tabs
+ LocalSettingsManager.Tabs.ForEach(CustomClose); // Close all mod tabs
+
+ // Tabs with index 10 and above are the mod settings tabs
+ if (index >= 10)
+ {
+ CustomOpen(LocalSettingsManager.Tabs[index - 10]);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Makes the custom tabs close when opening the options menu and resets pages.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(nameof(OptionsMenuBehaviour.Open))]
+ public static void OpenPostfix(OptionsMenuBehaviour __instance)
+ {
+ LocalSettingsManager.Tabs.ForEach(CustomClose);
+ currentPage = 1;
+ UpdatePages();
+ }
+
+ private static void UpdatePages()
+ {
+ foreach (var pages in tabButtons)
+ {
+ pages.Value?
+ .Where(x => x != null)
+ .Do(
+ x => x?.SetActive(
+ pages.Key == currentPage));
+ }
+ }
+
+ private static void CustomOpen(LocalSettingsTab tab)
+ {
+ if (tab.TabButton?.Button)
+ tab.TabButton!.Button.color = tab.TabAppearance.TabButtonHoverColor;
+
+ if (tab.TabButton?.Rollover)
+ tab.TabButton!.Rollover.OutColor = tab.TabAppearance.TabButtonHoverColor;
+
+ if (tab.TabButton?.Content)
+ tab.TabButton!.Content.SetActive(true);
+
+ if (background != null)
+ background.color = tab.TabAppearance.TabColor;
+ }
+ private static void CustomClose(LocalSettingsTab tab)
+ {
+ if (tab.TabButton?.Button)
+ tab.TabButton!.Button.color = tab.TabAppearance.TabButtonColor;
+
+ if (tab.TabButton?.Rollover)
+ tab.TabButton!.Rollover.OutColor = tab.TabAppearance.TabButtonColor;
+
+ if (tab.TabButton?.Content)
+ tab.TabButton!.Content.SetActive(false);
+
+ if (background != null)
+ background.color = Color.white;
+ }
+}
diff --git a/MiraAPI/Patches/LocalSettings/ToggleBehaviourPatch.cs b/MiraAPI/Patches/LocalSettings/ToggleBehaviourPatch.cs
new file mode 100644
index 0000000..ffbfdea
--- /dev/null
+++ b/MiraAPI/Patches/LocalSettings/ToggleBehaviourPatch.cs
@@ -0,0 +1,37 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace MiraAPI.Patches.LocalSettings;
+
+[HarmonyPatch(typeof(ToggleButtonBehaviour))]
+public static class ToggleBehaviourPatch
+{
+ ///
+ /// ResetText was inlined ofc.
+ /// Skill issue.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(nameof(ToggleButtonBehaviour.UpdateText))]
+ public static bool UpdateTextPrefix(ToggleButtonBehaviour __instance, bool on)
+ {
+ var color = on ? new Color(0f, 1f, 0.16470589f, 1f) : Color.white;
+ __instance.onState = on;
+ __instance.Background.color = color;
+
+ // Used to mask the text
+ __instance.Text.richText = true;
+ __instance.Text.text = $"" +
+ $"{DestroyableSingleton.Instance.GetString(__instance.BaseText)}: " +
+ $"{DestroyableSingleton.Instance.GetString(__instance.onState ? StringNames.SettingsOn : StringNames.SettingsOff)}";
+ __instance.Text.sortingOrder = 151;
+ __instance.Text.renderer.sortingLayerName = "Default";
+ __instance.Text.renderer.sortingOrder = 151;
+
+ if (__instance.Rollover)
+ {
+ __instance.Rollover.ChangeOutColor(color);
+ }
+
+ return false;
+ }
+}
diff --git a/MiraAPI/Patches/Options/GamePresetsTabPatches.cs b/MiraAPI/Patches/Options/GamePresetsTabPatches.cs
index 2b4f77d..ed80bb1 100644
--- a/MiraAPI/Patches/Options/GamePresetsTabPatches.cs
+++ b/MiraAPI/Patches/Options/GamePresetsTabPatches.cs
@@ -6,6 +6,7 @@ using HarmonyLib;
using MiraAPI.PluginLoading;
using MiraAPI.Presets;
using MiraAPI.Roles;
+using MiraAPI.Utilities;
using MiraAPI.Utilities.Assets;
using Reactor.Utilities;
using Reactor.Utilities.Extensions;
@@ -28,22 +29,6 @@ internal static class GamePresetsTabPatches
private static readonly object Lock = new();
- public static void DivideSize(GameObject obj, float amount)
- {
- foreach (var collider in obj.GetComponentsInChildren(true))
- {
- if (collider.TryCast() is { } col)
- {
- col.size = new Vector2(col.size.x / amount, col.size.y);
- }
- }
-
- foreach (var rend in obj.GetComponentsInChildren(true))
- {
- rend.size = new Vector2(rend.size.x / amount, rend.size.y);
- }
- }
-
[HarmonyPatch(typeof(GamePresetsTab), nameof(GamePresetsTab.OnEnable))]
public static class GamePresetsOnEnablePatch
{
@@ -207,7 +192,7 @@ internal static class GamePresetsTabPatches
saveText.transform.parent.localPosition.y,
saveText.transform.parent.localPosition.z);
- DivideSize(saveButton.gameObject, 2f);
+ Helpers.DivideSize(saveButton.gameObject, 2f);
saveButton.OnClick = new Button.ButtonClickedEvent();
saveButton.OnClick.AddListener(
@@ -276,7 +261,7 @@ internal static class GamePresetsTabPatches
var iconSpriteRend = icon.AddComponent();
iconSpriteRend.sprite = MiraAssets.RefreshIcon.LoadAsset();
- DivideSize(refreshButton.gameObject, 2f);
+ Helpers.DivideSize(refreshButton.gameObject, 2f);
refreshButton.buttonText.gameObject.Destroy();
refreshButton.OnClick = new Button.ButtonClickedEvent();
diff --git a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs
index 80f96c9..9d25635 100644
--- a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs
+++ b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs
@@ -1,6 +1,7 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
+using AmongUs.GameOptions;
using HarmonyLib;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MiraAPI.GameOptions;
@@ -57,6 +58,7 @@ public static class RoleSettingMenuPatches
var maskBg = __instance.scrollBar.transform.FindChild("MaskBg");
var hitbox = __instance.scrollBar.transform.FindChild("Hitbox");
+ var dividerImage = __instance.transform.FindChild("HeaderButtons/DividerImage");
if (GameSettingMenuPatches.SelectedModIdx == 0)
{
@@ -67,7 +69,64 @@ public static class RoleSettingMenuPatches
maskBg.localScale = new Vector3(6.6811f, 3.3563f, 0.5598f);
hitbox.localPosition = new Vector3(0.3297f, -.2333f, 4f);
hitbox.localScale = new Vector3(1, 1, 1);
- return true;
+ dividerImage.gameObject.SetActive(true);
+
+ var num = 0.662f;
+
+ var list = CustomRoleManager.AllRoles.Where(
+ r => !r.IsCustomRole() && r.TeamType == RoleTeamTypes.Crewmate && r.Role != RoleTypes.Crewmate &&
+ r.Role != RoleTypes.CrewmateGhost).ToList();
+ var list2 = CustomRoleManager.AllRoles.Where(
+ r => !r.IsCustomRole() && r.TeamType == RoleTeamTypes.Impostor && r.Role != RoleTypes.Impostor &&
+ r.Role != RoleTypes.ImpostorGhost).ToList();
+
+ if (__instance.roleTabs == null || __instance.roleTabs.Count == 0)
+ {
+ var num2 = -1.928f;
+ __instance.roleTabs = new();
+ __instance.roleTabs.Add(__instance.AllButton);
+ foreach (var t in list)
+ {
+ __instance.AddRoleTab(t, ref num2);
+ }
+
+ foreach (var t in list2)
+ {
+ __instance.AddRoleTab(t, ref num2);
+ }
+ }
+
+ var categoryHeaderEditRole = Object.Instantiate(
+ __instance.categoryHeaderEditRoleOrigin,
+ Vector3.zero,
+ Quaternion.identity,
+ __instance.RoleChancesSettings.transform);
+ categoryHeaderEditRole.SetHeader(StringNames.CrewmateRolesHeader, 20);
+ categoryHeaderEditRole.transform.localPosition = new Vector3(4.986f, num, -2f);
+ num -= 0.522f;
+ var num3 = 0;
+ foreach (var t in list)
+ {
+ __instance.CreateQuotaOption(t, ref num, num3);
+ num3++;
+ }
+
+ num -= 0.22f;
+ var categoryHeaderEditRole2 = Object.Instantiate(
+ __instance.categoryHeaderEditRoleOrigin,
+ Vector3.zero,
+ Quaternion.identity,
+ __instance.RoleChancesSettings.transform);
+ categoryHeaderEditRole2.SetHeader(StringNames.ImpostorRolesHeader, 20);
+ categoryHeaderEditRole2.transform.localPosition = new Vector3(4.986f, num, -2f);
+ num -= 0.522f;
+ foreach (var t in list2)
+ {
+ __instance.CreateQuotaOption(t, ref num, num3);
+ num3++;
+ }
+
+ return false;
}
ScrollerNum = 0.522f;
@@ -79,15 +138,16 @@ public static class RoleSettingMenuPatches
maskBg.localScale = new Vector3(6.6811f, 4.1563f, 0.5598f);
hitbox.localPosition = new Vector3(0.3297f, -.6333f, 4f);
hitbox.localScale = new Vector3(1, 1.2f, 1);
+ dividerImage.gameObject.SetActive(false);
- var num3 = 0;
+ var num4 = 0;
var roleGroups = GameSettingMenuPatches.SelectedMod?.InternalRoles.Values.OfType()
.ToLookup(x => x.RoleOptionsGroup);
if (roleGroups is null)
{
- return true;
+ return false;
}
// sort the groups by priority
@@ -197,11 +257,11 @@ public static class RoleSettingMenuPatches
continue;
}
- var option = CreateQuotaOption(__instance, roleBehaviour, num3);
+ var option = CreateQuotaOption(__instance, roleBehaviour, num4);
if (option is not null)
{
RoleOptionSettings.Add(option);
- num3++;
+ num4++;
}
}
}
@@ -528,10 +588,23 @@ public static class RoleSettingMenuPatches
roleOptionSetting.CountPlusBtn.SetInteractable(true);
__instance.roleChances.Add(roleOptionSetting);
- roleOptionSetting.titleText.transform.localPosition = new Vector3(-0.5376f, -0.2923f, 0f);
+ roleOptionSetting.titleText.transform.localPosition = new Vector3(-0.25f, -0.2923f, 0f);
roleOptionSetting.titleText.color = customRole.OptionsMenuColor.FindAlternateColor();
roleOptionSetting.titleText.horizontalAlignment = HorizontalAlignmentOptions.Left;
+ if (customRole.Configuration.Icon != null)
+ {
+ var roleIcon = new GameObject("RoleIcon");
+ roleIcon.transform.parent = roleOptionSetting.transform;
+ roleIcon.transform.localScale = new(.25f, .25f, 1);
+ roleIcon.layer = LayerMask.NameToLayer("UI");
+ roleIcon.transform.localPosition = new Vector3(-1.3f, -0.3f, -2f);
+ var rend = roleIcon.AddComponent();
+ rend.sprite = customRole.Configuration.Icon.LoadAsset();
+
+ rend.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
+ }
+
if (GameSettingMenuPatches.SelectedMod is null ||
GameSettingMenuPatches.SelectedMod.InternalOptionGroups
.Exists(x => x.GroupVisible() && x.OptionableType == role.GetType() && x.Options.Any(y => y.Visible())))
diff --git a/MiraAPI/Patches/PlayerControlPatches.cs b/MiraAPI/Patches/PlayerControlPatches.cs
index a0b88cd..df73774 100644
--- a/MiraAPI/Patches/PlayerControlPatches.cs
+++ b/MiraAPI/Patches/PlayerControlPatches.cs
@@ -1,4 +1,5 @@
-using System.Linq;
+using System.Globalization;
+using System.Linq;
using HarmonyLib;
using MiraAPI.Events;
using MiraAPI.Events.Vanilla.Gameplay;
@@ -67,15 +68,53 @@ internal static class PlayerControlPatches
}
[HarmonyPrefix]
- [HarmonyPatch(nameof(PlayerControl.RpcMurderPlayer))]
- // ReSharper disable once InconsistentNaming
- // Note: This method is partially inlined in 2025.5.20 but we already use custom murder RPCs.
- public static void PlayerControlMurderPrefix(PlayerControl __instance, PlayerControl target, ref bool didSucceed)
+ [HarmonyPatch(nameof(PlayerControl.CheckMurder))]
+ public static void PlayerControlCheckMurderPrefix(PlayerControl __instance, PlayerControl target, ref bool __runOriginal)
{
+ __runOriginal = false;
+
+ __instance.logger.Debug($"Checking if {__instance.PlayerId} murdered {(target == null ? "null player" : target.PlayerId.ToString(NumberFormatInfo.InvariantInfo))}");
+
+ __instance.isKilling = false;
+ if (AmongUsClient.Instance.IsGameOver || !AmongUsClient.Instance.AmHost)
+ {
+ return;
+ }
+
+ if (!target || __instance.Data.IsDead || !__instance.Data.Role.IsImpostor || __instance.Data.Disconnected)
+ {
+ int num = target ? target!.PlayerId : -1;
+ __instance.logger.Warning($"Bad kill from {__instance.PlayerId} to {num}");
+ __instance.RpcMurderPlayer(target, false);
+ return;
+ }
+
+ NetworkedPlayerInfo data = target!.Data;
+ if (data == null || data.IsDead || target.inVent || target.MyPhysics.Animations.IsPlayingEnterVentAnimation() ||
+ target.MyPhysics.Animations.IsPlayingAnyLadderAnimation() || target.inMovingPlat)
+ {
+ __instance.logger.Warning("Invalid target data for kill");
+ __instance.RpcMurderPlayer(target, false);
+ return;
+ }
+
+ if (MeetingHud.Instance)
+ {
+ __instance.logger.Warning("Tried to kill while a meeting was starting");
+ __instance.RpcMurderPlayer(target, false);
+ return;
+ }
+
var beforeMurderEvent = new BeforeMurderEvent(__instance, target);
MiraEventManager.InvokeEvent(beforeMurderEvent);
- didSucceed = beforeMurderEvent.IsCancelled;
+ if (beforeMurderEvent.IsCancelled)
+ {
+ return;
+ }
+
+ __instance.isKilling = true;
+ __instance.RpcMurderPlayer(target, true);
}
[HarmonyPostfix]
diff --git a/MiraAPI/Patches/Roles/EjectionPatches.cs b/MiraAPI/Patches/Roles/EjectionPatches.cs
index 009da27..d37b550 100644
--- a/MiraAPI/Patches/Roles/EjectionPatches.cs
+++ b/MiraAPI/Patches/Roles/EjectionPatches.cs
@@ -33,8 +33,8 @@ internal static class EjectionPatches
__instance.completeString = role.GetCustomEjectionMessage(__instance.initData.networkedPlayer);
}
- [HarmonyPatch(typeof(ExileController), nameof(ExileController.WrapUp))]
- public static class WrapUpPatch
+ [HarmonyPatch(typeof(ExileController), nameof(ExileController.ReEnableGameplay))]
+ public static class ReEnableGameplayPatch
{
public static void Postfix()
{
diff --git a/MiraAPI/Patches/Roles/HowToPlayPatch.cs b/MiraAPI/Patches/Roles/HowToPlayPatch.cs
new file mode 100644
index 0000000..9e5ad24
--- /dev/null
+++ b/MiraAPI/Patches/Roles/HowToPlayPatch.cs
@@ -0,0 +1,49 @@
+using System.Collections.Generic;
+using System.Linq;
+using AmongUs.GameOptions;
+using HarmonyLib;
+using MiraAPI.Roles;
+using UnityEngine;
+
+namespace MiraAPI.Patches.Roles;
+
+[HarmonyPatch(typeof(HowToPlayScene), nameof(HowToPlayScene.OpenRolesSelectionMenu))]
+internal static class HowToPlayPatch
+{
+ // yes i patched the entire method
+ private static void Prefix(HowToPlayScene __instance)
+ {
+ __instance.sceneIndex = 0;
+ __instance.category = HowToPlayScene.HowToPlayCategory.RolesSelection;
+ __instance.startPage.SetActive(false);
+ if (__instance.roleButtonsParent.childCount == 0)
+ {
+ using (IEnumerator enumerator = RoleManager.Instance.AllRoles.ToArray().Where(x => !x.IsCustomRole()).GetEnumerator())
+ {
+ while (enumerator.MoveNext())
+ {
+ RoleBehaviour role = enumerator.Current;
+ if (!role.IsSimpleRole && role.Role != RoleTypes.CrewmateGhost && role.Role != RoleTypes.ImpostorGhost)
+ {
+ HowToPlayRoleButton component = Object.Instantiate(__instance.roleButtonPrefab, __instance.roleButtonsParent).GetComponent();
+ Sprite roleIcon = __instance.rolesScenes.ToArray().First(r => r.role == role.Role).roleIcon;
+ component.SetRoleInfo(role, roleIcon);
+ component.SetButtonAction((Il2CppSystem.Action)(() =>
+ {
+ __instance.OpenRolePage(role.Role);
+ }));
+ __instance.controllerSelectables.Add(component.GetComponent());
+ }
+ }
+ }
+ foreach (UiElement uiElement in __instance.controllerSelectables)
+ {
+ uiElement.ReceiveMouseOut();
+ }
+ ControllerManager.Instance.NewScene(__instance.name, __instance.closeButton, __instance.defaultButtonSelected, __instance.controllerSelectables, false);
+ }
+ __instance.DisableAllScenes();
+ __instance.roleSelectionScene.SetActive(true);
+ ControllerManager.Instance.SetDefaultSelection(__instance.defaultButtonSelected, null);
+ }
+}
diff --git a/MiraAPI/Patches/Roles/KillButtonPatches.cs b/MiraAPI/Patches/Roles/KillButtonPatches.cs
index 2a04e9d..902a9e6 100644
--- a/MiraAPI/Patches/Roles/KillButtonPatches.cs
+++ b/MiraAPI/Patches/Roles/KillButtonPatches.cs
@@ -58,6 +58,11 @@ public static class KillButtonPatches
return false;
}
+ if (PlayerControl.LocalPlayer.Data.Role is not ICustomRole)
+ {
+ return true;
+ }
+
PlayerControl.LocalPlayer.RpcCustomMurder(__instance.currentTarget);
__instance.SetTarget(null);
diff --git a/MiraAPI/Patches/Roles/RoleManagerPatches.cs b/MiraAPI/Patches/Roles/RoleManagerPatches.cs
index fa0cb5a..93daa82 100644
--- a/MiraAPI/Patches/Roles/RoleManagerPatches.cs
+++ b/MiraAPI/Patches/Roles/RoleManagerPatches.cs
@@ -33,7 +33,7 @@ public static class RoleManagerPatches
data.Role.Deinitialize(targetPlayer);
Object.Destroy(data.Role.gameObject);
}
- var roleBehaviour = Object.Instantiate(__instance.AllRoles.First(r => r.Role == roleType), data.gameObject.transform);
+ var roleBehaviour = Object.Instantiate(__instance.AllRoles.ToArray().First(r => r.Role == roleType), data.gameObject.transform);
roleBehaviour.Initialize(targetPlayer);
targetPlayer.Data.Role = roleBehaviour;
targetPlayer.Data.RoleType = roleType;
diff --git a/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs b/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs
index d867139..25143c9 100644
--- a/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs
+++ b/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs
@@ -9,17 +9,17 @@ namespace MiraAPI.Patches.Roles;
///
/// Patches to return the correct role counts.
///
-[HarmonyPatch(typeof(RoleOptionsCollectionV09))]
+[HarmonyPatch(typeof(RoleOptionsCollectionV10))]
public static class RoleOptionsCollectionPatch
{
///
- /// This patch fixes GetNumPerGame being inlined (2025.5.20) in the original code.
+ /// This patch fixes GetNumPerGame being inlined (2025.9.9) in the original code.
///
[HarmonyPrefix]
- [HarmonyPatch(nameof(RoleOptionsCollectionV09.AnyRolesEnabled))]
- public static bool AnyRolesEnabledPrefix(RoleOptionsCollectionV09 __instance, ref bool __result)
+ [HarmonyPatch(nameof(RoleOptionsCollectionV10.AnyRolesEnabled))]
+ public static bool AnyRolesEnabledPrefix(RoleOptionsCollectionV10 __instance, ref bool __result)
{
- foreach (KeyValuePair keyValuePair in __instance.roles)
+ foreach (KeyValuePair keyValuePair in __instance.roles)
{
if (__instance.GetNumPerGame(keyValuePair.Key) > 0)
{
@@ -34,7 +34,7 @@ public static class RoleOptionsCollectionPatch
///
/// Return false to skip original method, true to not.
[HarmonyPrefix]
- [HarmonyPatch(nameof(RoleOptionsCollectionV09.GetChancePerGame))]
+ [HarmonyPatch(nameof(RoleOptionsCollectionV10.GetChancePerGame))]
public static bool GetChancePrefix(RoleTypes role, ref int __result)
{
if (!CustomRoleManager.GetCustomRoleBehaviour(role, out var customRole) || customRole == null)
@@ -64,7 +64,7 @@ public static class RoleOptionsCollectionPatch
///
/// Return false to skip original method, true to not.
[HarmonyPrefix]
- [HarmonyPatch(nameof(RoleOptionsCollectionV09.GetNumPerGame))]
+ [HarmonyPatch(nameof(RoleOptionsCollectionV10.GetNumPerGame))]
public static bool GetNumPrefix(RoleTypes role, ref int __result)
{
if (!CustomRoleManager.GetCustomRoleBehaviour(role, out var customRole) || customRole == null)
diff --git a/MiraAPI/Patches/ServerDropdownPatch.cs b/MiraAPI/Patches/ServerDropdownPatch.cs
index 36da2d7..6ff7e68 100644
--- a/MiraAPI/Patches/ServerDropdownPatch.cs
+++ b/MiraAPI/Patches/ServerDropdownPatch.cs
@@ -1,4 +1,6 @@
-using HarmonyLib;
+using System.Linq;
+using HarmonyLib;
+using Reactor.Utilities;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.SceneManagement;
@@ -8,20 +10,29 @@ namespace MiraAPI.Patches;
[HarmonyPatch(typeof(ServerDropdown), nameof(ServerDropdown.FillServerOptions))]
public static class ServerDropdownPatch
{
+ public static bool RegionEquals(this IRegionInfo region, IRegionInfo other)
+ {
+ return region.Name == other.Name &&
+ region.TranslateName == other.TranslateName &&
+ region.PingServer == other.PingServer &&
+ region.TargetServer == other.TargetServer &&
+ region.Servers.All(s=>other.Servers.Any(x=>x.Equals(s)));
+ }
+
public static bool Prefix(ServerDropdown __instance)
{
var num = 0;
__instance.background.size = new Vector2(8.4f, 4.8f);
- foreach (var regionInfo in DestroyableSingleton.Instance.AvailableRegions)
+ foreach (var regionInfo in ServerManager.Instance.AvailableRegions)
{
var findingGame = SceneManager.GetActiveScene().name is "FindAGame";
- if (DestroyableSingleton.Instance.CurrentRegion.Equals(regionInfo))
+ if (ServerManager.Instance.CurrentRegion.RegionEquals(regionInfo))
{
__instance.defaultButtonSelected = __instance.firstOption;
__instance.firstOption.ChangeButtonText(
- DestroyableSingleton.Instance.GetStringWithDefault(
+ TranslationController.Instance.GetStringWithDefault(
regionInfo.TranslateName,
regionInfo.Name));
}
@@ -34,11 +45,11 @@ public static class ServerDropdownPatch
{
x += 2;
}
- var y = -0.55f * (num / 2);
+ var y = -0.55f * (num / 2f);
serverListButton.transform.localPosition = new Vector3(x, __instance.y_posButton + y, -1f);
serverListButton.transform.localScale = Vector3.one;
serverListButton.Text.text =
- DestroyableSingleton.Instance.GetStringWithDefault(
+ TranslationController.Instance.GetStringWithDefault(
regionInfo.TranslateName,
regionInfo.Name);
serverListButton.Text.ForceMeshUpdate();
@@ -47,9 +58,9 @@ public static class ServerDropdownPatch
__instance.controllerSelectable.Add(serverListButton.Button);
__instance.background.transform.localPosition = new Vector3(
findingGame ? 2f : 0f,
- __instance.initialYPos + (-0.3f * (num / 2)),
+ __instance.initialYPos + -0.3f * (num / 2f),
0f);
- __instance.background.size = new Vector2(__instance.background.size.x, 1.2f + (0.6f * (num / 2)));
+ __instance.background.size = new Vector2(__instance.background.size.x, 1.2f + 0.6f * (num / 2f));
num++;
}
}
diff --git a/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs b/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs
index fe04962..356bf68 100644
--- a/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs
+++ b/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using HarmonyLib;
+using Hazel;
using Il2CppSystem.Text;
namespace MiraAPI.Patches.Stubs;
@@ -229,4 +230,76 @@ public static class RoleBehaviourStubs
{
// nothing needed
}
+
+ ///
+ /// Stub method for RoleBehaviour.UseSecondaryAbility.
+ ///
+ /// The RoleBehaviour object.
+ [HarmonyReversePatch]
+ [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.UseSecondaryAbility))]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static void UseSecondaryAbility(RoleBehaviour instance)
+ {
+ // nothing needed
+ }
+
+ ///
+ /// Stub method for RoleBehaviour.OnRoleSet.
+ ///
+ /// The RoleBehaviour object.
+ [HarmonyReversePatch]
+ [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.OnRoleSet))]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static void OnRoleSet(RoleBehaviour instance)
+ {
+ // nothing needed
+ }
+
+ ///
+ /// Stub method for RoleBehaviour.InitializeMeetingAbilityButton.
+ ///
+ /// The RoleBehaviour object.
+ [HarmonyReversePatch]
+ [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.InitializeMeetingAbilityButton))]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static void InitializeMeetingAbilityButton(RoleBehaviour instance)
+ {
+ // nothing needed
+ }
+
+ ///
+ /// Stub method for RoleBehaviour.FindClosestBody.
+ ///
+ /// The RoleBehaviour object.
+ [HarmonyReversePatch]
+ [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.FindClosestBody))]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static DeadBody FindClosestBody(RoleBehaviour instance)
+ {
+ return null!;
+ }
+
+ ///
+ /// Stub method for RoleBehaviour.HandleRoleRpc.
+ ///
+ /// The RoleBehaviour object.
+ [HarmonyReversePatch]
+ [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.HandleRoleRpc))]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static void HandleRoleRpc(RoleBehaviour instance, byte callId, MessageReader reader)
+ {
+ // nothing needed
+ }
+
+ ///
+ /// Stub method for RoleBehaviour.KillAnimSpecialSetup.
+ ///
+ /// The RoleBehaviour object.
+ [HarmonyReversePatch]
+ [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.KillAnimSpecialSetup))]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static void KillAnimSpecialSetup(RoleBehaviour instance, DeadBody deadBody, PlayerControl killer, PlayerControl victim)
+ {
+ // nothing needed
+ }
}
diff --git a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs
index 7652d4f..9e5c8a4 100644
--- a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs
+++ b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs
@@ -40,7 +40,6 @@ internal static class DummyBehaviourPatches
private static IEnumerator CoDoVote(DummyBehaviour dummy)
{
- Logger.Warning(dummy.voted);
dummy.voted = true;
yield return new WaitForSeconds(dummy.voteTime.Next());
diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs
index 919170d..2aa9a82 100644
--- a/MiraAPI/Patches/Voting/MeetingHudPatches.cs
+++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs
@@ -15,6 +15,24 @@ namespace MiraAPI.Patches.Voting;
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony Convention")]
internal static class MeetingHudPatches
{
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.VoteForMe))]
+ public static bool VotePatch(PlayerVoteArea __instance)
+ {
+ var beforeVoteEvent = new BeforeVoteEvent(__instance, PlayerControl.LocalPlayer);
+ MiraEventManager.InvokeEvent(beforeVoteEvent);
+
+ if (beforeVoteEvent.IsCancelled)
+ {
+ return false;
+ }
+
+ var afterVoteEvent = new AfterVoteEvent(__instance, PlayerControl.LocalPlayer);
+ MiraEventManager.InvokeEvent(afterVoteEvent);
+
+ return true;
+ }
+
[HarmonyPostfix]
[HarmonyPatch(nameof(MeetingHud.Start))]
public static void MeetingHudStartPatch(MeetingHud __instance)
diff --git a/MiraAPI/PluginLoading/MiraPluginInfo.cs b/MiraAPI/PluginLoading/MiraPluginInfo.cs
index 4a454a6..891c3ba 100644
--- a/MiraAPI/PluginLoading/MiraPluginInfo.cs
+++ b/MiraAPI/PluginLoading/MiraPluginInfo.cs
@@ -5,6 +5,7 @@ using BepInEx.Configuration;
using MiraAPI.GameModes;
using MiraAPI.GameOptions;
using MiraAPI.Hud;
+using MiraAPI.LocalSettings;
using MiraAPI.Modifiers;
using MiraAPI.Presets;
diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs
index 02e9102..a74b02c 100644
--- a/MiraAPI/PluginLoading/MiraPluginManager.cs
+++ b/MiraAPI/PluginLoading/MiraPluginManager.cs
@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
+using BepInEx.Configuration;
using BepInEx.Unity.IL2CPP;
using MiraAPI.Colors;
using MiraAPI.Events;
@@ -12,13 +13,15 @@ using MiraAPI.GameOptions;
using MiraAPI.GameOptions.Attributes;
using MiraAPI.Hud;
using MiraAPI.Keybinds;
+using MiraAPI.LocalSettings;
+using MiraAPI.LocalSettings.Attributes;
using MiraAPI.Modifiers;
using MiraAPI.Presets;
using MiraAPI.Roles;
using MiraAPI.Utilities;
using Reactor.Networking;
using Reactor.Utilities;
-using UnityEngine;
+using Rewired;
namespace MiraAPI.PluginLoading;
@@ -92,6 +95,11 @@ public sealed class MiraPluginManager
continue;
}
+ if (RegisterLocalTabs(type, plugin))
+ {
+ continue;
+ }
+
if (RegisterRole(type, info, out var role))
{
roles.Add(role);
@@ -109,6 +117,7 @@ public sealed class MiraPluginManager
}
RegisterColorClasses(type);
+ RegisterKeybinds(type, plugin);
}
info.PluginConfig.Save();
@@ -134,14 +143,10 @@ public sealed class MiraPluginManager
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}.");
- }
};
+
+ RegisterKeybinds(typeof(MiraGlobalKeybinds), PluginSingleton.Instance);
+ RegisterLocalTabs(typeof(MiraApiSettings), PluginSingleton.Instance);
}
///
@@ -312,4 +317,125 @@ public sealed class MiraPluginManager
return false;
}
+
+ private static bool RegisterLocalTabs(Type type, BasePlugin pluginInfo)
+ {
+ try
+ {
+ if (!type.IsAssignableTo(typeof(LocalSettingsTab)))
+ {
+ return false;
+ }
+
+ if (!LocalSettingsManager.RegisterTab(type, pluginInfo))
+ {
+ return false;
+ }
+
+ foreach (var property in type.GetProperties())
+ {
+ if (LocalSettingsManager.TypeToTab[type] is not { } tabInstance)
+ {
+ continue;
+ }
+
+ if (property.GetCustomAttribute() != null &&
+ property.PropertyType.IsAssignableTo(typeof(LocalSettingsButton)))
+ {
+ var button = property.GetValue(tabInstance) as LocalSettingsButton;
+ button!.Tab = tabInstance;
+ tabInstance.Buttons.Add(button);
+ continue;
+ }
+
+ if (!typeof(ConfigEntryBase).IsAssignableFrom(property.PropertyType))
+ {
+ continue;
+ }
+
+ if (property.GetMethod?.IsStatic == true)
+ {
+ Logger.Error($"Option property {property.Name} in {type.Name} must not be static.");
+ continue;
+ }
+
+ if (property.GetValue(tabInstance) is not ConfigEntryBase configEntry)
+ {
+ Logger.Error($"Option property {property.Name} in {type.Name} has to be a config entry.");
+ continue;
+ }
+
+ var attribute = property.GetCustomAttribute();
+ if (attribute == null)
+ {
+ continue;
+ }
+
+ attribute.CreateSetting(type, configEntry);
+ }
+
+ foreach (var field in type.GetFields().Where(f => f.FieldType.IsAssignableTo(typeof(ConfigEntryBase)) && f.GetCustomAttribute() != null))
+ {
+ Logger.Error($"{field.Name} is a field, not a property. Use properties for local settings.");
+ }
+
+ return true;
+ }
+ catch (Exception e)
+ {
+ Logger.Error($"Failed to register options for {type.Name}: {e.ToString()}");
+ }
+
+ return false;
+ }
+
+ private static void RegisterKeybinds(Type type, BasePlugin source)
+ {
+ try
+ {
+ if (type.GetCustomAttribute() == null)
+ {
+ return;
+ }
+
+ if (!type.IsStatic())
+ {
+ Logger.Error($"Keybinds class {type.Name} must be static.");
+ return;
+ }
+
+ foreach (var property in type.GetProperties())
+ {
+ if (property.PropertyType != typeof(MiraKeybind))
+ {
+ continue;
+ }
+
+ if (property.GetValue(null) is not MiraKeybind keybind)
+ {
+ Logger.Error($"Keybind property {property.Name} in {type.Name} is not a MiraKeybind.");
+ continue;
+ }
+
+ KeybindManager.Keybinds.Add(keybind);
+ if (source is IMiraPlugin miraPlugin)
+ {
+ keybind.SourcePluginName = miraPlugin.OptionsTitleText;
+ }
+ else if (source is MiraApiPlugin)
+ {
+ keybind.SourcePluginName = "MiraAPI";
+ }
+ }
+
+ foreach (var field in type.GetFields().Where(f => f.FieldType.IsAssignableTo(typeof(MiraKeybind))))
+ {
+ Logger.Error($"{field.Name} is a field, not a property. Use properties for keybinds.");
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.Error($"Failed to register keybind class {type.Name}: {e}");
+ }
+ }
}
diff --git a/MiraAPI/Resources/CrewmateFile.png b/MiraAPI/Resources/CrewmateFile.png
new file mode 100644
index 0000000..7cf1e74
Binary files /dev/null and b/MiraAPI/Resources/CrewmateFile.png differ
diff --git a/MiraAPI/Resources/CustomFile.png b/MiraAPI/Resources/CustomFile.png
new file mode 100644
index 0000000..90c076b
Binary files /dev/null and b/MiraAPI/Resources/CustomFile.png differ
diff --git a/MiraAPI/Resources/ImpostorFile.png b/MiraAPI/Resources/ImpostorFile.png
new file mode 100644
index 0000000..bf1476b
Binary files /dev/null and b/MiraAPI/Resources/ImpostorFile.png differ
diff --git a/MiraAPI/Resources/ModifierFile.png b/MiraAPI/Resources/ModifierFile.png
new file mode 100644
index 0000000..8d5b78d
Binary files /dev/null and b/MiraAPI/Resources/ModifierFile.png differ
diff --git a/MiraAPI/Resources/Settings.png b/MiraAPI/Resources/Settings.png
new file mode 100644
index 0000000..6f0da71
Binary files /dev/null and b/MiraAPI/Resources/Settings.png differ
diff --git a/MiraAPI/Resources/TimedModifierFile.png b/MiraAPI/Resources/TimedModifierFile.png
new file mode 100644
index 0000000..2909000
Binary files /dev/null and b/MiraAPI/Resources/TimedModifierFile.png differ
diff --git a/MiraAPI/Roles/CustomRoleConfiguration.cs b/MiraAPI/Roles/CustomRoleConfiguration.cs
index 5b0158d..d93fbf2 100644
--- a/MiraAPI/Roles/CustomRoleConfiguration.cs
+++ b/MiraAPI/Roles/CustomRoleConfiguration.cs
@@ -32,6 +32,12 @@ public record struct CustomRoleConfiguration
TasksCountForProgress = role.Team is ModdedRoleTeams.Crewmate;
HideSettings = roleBehaviour?.IsDead == true;
ShowInFreeplay = roleBehaviour?.IsDead == false;
+ FreeplayFolder = role.Team switch
+ {
+ ModdedRoleTeams.Crewmate => TranslationController.Instance.GetString(StringNames.Crewmate),
+ ModdedRoleTeams.Impostor => TranslationController.Instance.GetString(StringNames.Impostor),
+ _ => "Neutral",
+ };
IntroSound = role.Team is ModdedRoleTeams.Crewmate
? CustomRoleManager.CrewmateIntroSound
: CustomRoleManager.ImpostorIntroSound;
@@ -112,6 +118,11 @@ public record struct CustomRoleConfiguration
///
public bool ShowInFreeplay { get; set; }
+ ///
+ /// Gets or sets a freeplay folder this role belongs to. Defaults to team folder.
+ ///
+ public string FreeplayFolder { get; set; }
+
///
/// Gets or sets the outline color for the KillButton if is true.
///
diff --git a/MiraAPI/Roles/CustomRoleManager.cs b/MiraAPI/Roles/CustomRoleManager.cs
index 5e1c3ae..d16153b 100644
--- a/MiraAPI/Roles/CustomRoleManager.cs
+++ b/MiraAPI/Roles/CustomRoleManager.cs
@@ -35,6 +35,11 @@ public static class CustomRoleManager
public static readonly LoadableAsset ImpostorIntroSound =
CustomRoleUtils.GetIntroSound(RoleTypes.Impostor)!;
+ ///
+ /// Gets the list of all roles from vanilla RoleManager.
+ ///
+ public static RoleBehaviour[] AllRoles => RoleManager.Instance.AllRoles.ToArray();
+
///
/// Gets the list of custom roles as RoleBehaviour objects.
///
@@ -57,7 +62,10 @@ public static class CustomRoleManager
internal static void RegisterInRoleManager()
{
- RoleManager.Instance.AllRoles = RoleManager.Instance.AllRoles.Concat(CustomRoles.Values).ToArray();
+ foreach (var role in CustomRoles.Values)
+ {
+ RoleManager.Instance.AllRoles.Add(role);
+ }
foreach (var role in CustomRoles.Values.Where(x => x.IsDead))
{
diff --git a/MiraAPI/Roles/CustomRoleUtils.cs b/MiraAPI/Roles/CustomRoleUtils.cs
index 01b402d..cb4dbfb 100644
--- a/MiraAPI/Roles/CustomRoleUtils.cs
+++ b/MiraAPI/Roles/CustomRoleUtils.cs
@@ -4,7 +4,6 @@ using System.Linq;
using System.Text;
using AmongUs.GameOptions;
using MiraAPI.Utilities.Assets;
-using Reactor.Utilities;
using UnityEngine;
namespace MiraAPI.Roles;
@@ -56,7 +55,7 @@ public static class CustomRoleUtils
/// The intro sound.
public static LoadableAsset? GetIntroSound(RoleTypes roleType)
{
- var role = RoleManager.Instance.AllRoles.FirstOrDefault(role => role.Role == roleType);
+ var role = CustomRoleManager.AllRoles.FirstOrDefault(role => role.Role == roleType);
if (role is ICustomRole customRole)
{
return customRole.Configuration.IntroSound;
diff --git a/MiraAPI/Utilities/Assets/MiraAssets.cs b/MiraAPI/Utilities/Assets/MiraAssets.cs
index 148c65a..4368508 100644
--- a/MiraAPI/Utilities/Assets/MiraAssets.cs
+++ b/MiraAPI/Utilities/Assets/MiraAssets.cs
@@ -70,6 +70,11 @@ public static class MiraAssets
///
public static LoadableResourceAsset NextButton { get; } = new("MiraAPI.Resources.NextButton.png");
+ ///
+ /// Gets the Mira settings icon..
+ ///
+ public static LoadableResourceAsset SettingsIcon { get; } = new("MiraAPI.Resources.Settings.png");
+
///
/// Gets the highlighted Next Button sprite.
///
@@ -99,4 +104,29 @@ public static class MiraAssets
/// Gets the sprite used for the Keybind icons.
///
public static LoadableResourceAsset KeybindButton { get; } = new("MiraAPI.Resources.KeybindButton.png");
+
+ ///
+ /// Gets the sprite used for crewmate role files in TaskAdderGame.
+ ///
+ public static LoadableResourceAsset CrewmateFile { get; } = new("MiraAPI.Resources.CrewmateFile.png");
+
+ ///
+ /// Gets the sprite used for impostor role files in TaskAdderGame.
+ ///
+ public static LoadableResourceAsset ImpostorFile { get; } = new("MiraAPI.Resources.ImpostorFile.png");
+
+ ///
+ /// Gets the sprite used for custom team role files in TaskAdderGame.
+ ///
+ public static LoadableResourceAsset CustomTeamFile { get; } = new("MiraAPI.Resources.CustomFile.png");
+
+ ///
+ /// Gets the sprite used for modifier file in TaskAdderGame.
+ ///
+ public static LoadableResourceAsset ModifierFile { get; } = new("MiraAPI.Resources.ModifierFile.png");
+
+ ///
+ /// Gets the sprite used for timed modifier file in TaskAdderGame.
+ ///
+ public static LoadableResourceAsset TimedModifierFile { get; } = new("MiraAPI.Resources.TimedModifierFile.png");
}
diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs
index 863df73..16cfffd 100644
--- a/MiraAPI/Utilities/Extensions.cs
+++ b/MiraAPI/Utilities/Extensions.cs
@@ -94,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;
@@ -554,24 +554,24 @@ public static class Extensions
/// 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 internal name of the action.
+ /// Text shown in the rebinding UI.
+ /// /// Group shown above the label.
/// 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.
+ /// Optional modifier keys (e.g., Control, Shift, Alt) that must be held together with the main key.
/// 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)
+ public static InputAction RegisterModBind(this UserData userData, string id, string name, string? group, KeyboardKeyCode key, int category = 0, int elementIdentifierId = -1, InputActionType type = InputActionType.Button, ModifierKey[]? modifiers = null)
{
userData.AddAction(category);
-
var action = userData.GetAction(userData.actions.Count - 1)!;
- action.name = actionName;
- action.descriptiveName = description;
+ action.name = id;
+ action.descriptiveName = group != null
+ ? $"{Palette.CrewmateRoleHeaderDarkBlue.ToTextColor()}{group.ReplaceLineEndings(" ")}\n{name}"
+ : name;
action.categoryId = category;
action.type = type;
action.userAssignable = true;
@@ -583,13 +583,17 @@ public static class Extensions
_elementType = ControllerElementType.Button,
_axisContribution = Pole.Positive,
_keyboardKeyCode = key,
- _modifierKey1 = modifier1,
- _modifierKey2 = modifier2,
- _modifierKey3 = modifier3,
};
+
+ if (modifiers != null)
+ {
+ if (modifiers.Length > 0) map._modifierKey1 = modifiers[0];
+ if (modifiers.Length > 1) map._modifierKey2 = modifiers[1];
+ if (modifiers.Length > 2) map._modifierKey3 = modifiers[2];
+ }
+
userData.keyboardMaps[0].actionElementMaps.Add(map);
userData.joystickMaps[0].actionElementMaps.Add(map);
-
- return action.id;
+ return action;
}
}
diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs
index 2bf7b37..a845fb4 100644
--- a/MiraAPI/Utilities/Helpers.cs
+++ b/MiraAPI/Utilities/Helpers.cs
@@ -3,8 +3,10 @@ using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
-using Il2CppSystem.IO;
+using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MiraAPI.Keybinds;
+using MiraAPI.Roles;
+using MiraAPI.Utilities.Assets;
using Rewired;
using TMPro;
using UnityEngine;
@@ -26,6 +28,76 @@ public static class Helpers
return [.. GameData.Instance.AllPlayers.ToArray().Where(x => !x.IsDead && !x.Disconnected && x.Object).Select(x => x.Object)];
}
+ internal static GameObject CreateKeybindIcon(GameObject button, KeyboardKeyCode keyCode, Vector3 localPos)
+ {
+ var keybindIcon = Object.Instantiate(HudManager.Instance.AbilityButton.usesRemainingSprite.gameObject, button.transform);
+ keybindIcon.GetComponent().sprite = MiraAssets.KeybindButton.LoadAsset();
+ keybindIcon.transform.GetComponentInChildren().text = keyCode.ToString();
+ keybindIcon.name = "KeybindIcon";
+ keybindIcon.transform.localPosition = localPos;
+ return keybindIcon;
+ }
+
+ ///
+ /// Creates a draggable scroller. Add items into the scroller.Inner transform. This does not automatically position children.
+ ///
+ /// Where the scroller should be parented to.
+ /// The collider of the scroller. Used to drag.
+ /// The created scroller object.
+ public static Scroller CreateScroller(Transform parent, BoxCollider2D hitBoxCollider)
+ {
+ var scrollObj = new GameObject("Scroller");
+ scrollObj.transform.SetParent(parent);
+ scrollObj.transform.localScale = new Vector3(1, 1, 1);
+
+ var inner = new GameObject("Inner");
+ inner.transform.SetParent(scrollObj.transform);
+ inner.transform.localScale = new Vector3(1, 1, 1);
+ inner.transform.localPosition = new Vector3(0, 0, 2);
+
+ var scroller = scrollObj.AddComponent();
+ scroller.allowX = false;
+ scroller.allowY = true;
+ scroller.DragScrollSpeed = 1f;
+ scroller.Colliders = new Il2CppReferenceArray([hitBoxCollider]);
+ scroller.Inner = inner.transform;
+
+ return scroller;
+ }
+
+ ///
+ /// Divides a button/etc by a certain amount by resizing colliders and renderer sizes.
+ ///
+ /// The object you want to divide.
+ /// How much you want to divide by.
+ public static void DivideSize(GameObject obj, float amount)
+ {
+ foreach (var collider in obj.GetComponentsInChildren(true))
+ {
+ if (collider.TryCast() is { } col)
+ {
+ col.size = new Vector2(col.size.x / amount, col.size.y);
+ }
+ }
+
+ foreach (var rend in obj.GetComponentsInChildren(true))
+ {
+ rend.size = new Vector2(rend.size.x / amount, rend.size.y);
+ }
+ }
+
+ ///
+ /// Gets the keybind for an action with ReInput.
+ ///
+ /// The action ID.
+ /// The keyboard key code.
+ public static KeyboardKeyCode GetKeybindByActionId(int actionId)
+ {
+ var player = ReInput.players.GetPlayer(0);
+ return player.controllers.maps.GetFirstElementMapWithAction(ControllerType.Keyboard, actionId, false)
+ .keyboardKeyCode;
+ }
+
///
/// Determines whether a given probability check succeeds.
///
@@ -406,21 +478,36 @@ public static class Helpers
}
///
- /// Finds and returns an unused KeyCode that is not equal to the excluded key.
+ /// Returns the formated value using the specified suffix and format string.
///
- /// 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)
+ /// The value to format.
+ /// The suffix to add.
+ /// The format string to use to format.
+ /// The formated value.
+ public static string FormatValue(float value, MiraNumberSuffixes suffix = MiraNumberSuffixes.None, string formatString = "0.0")
{
- foreach (KeyboardKeyCode key in Enum.GetValues(typeof(KeyboardKeyCode)))
+ return suffix switch
{
- if (key == exclude) continue;
- bool used = KeybindManager.GetEntries().Exists(e => e.Key == key);
- if (!used) return key;
+ MiraNumberSuffixes.None => value.ToString(formatString, NumberFormatInfo.InvariantInfo),
+ MiraNumberSuffixes.Multiplier => value.ToString(formatString, NumberFormatInfo.InvariantInfo) + "x",
+ MiraNumberSuffixes.Percent => value.ToString(formatString, NumberFormatInfo.InvariantInfo) + "%",
+ _ => TranslationController.Instance.GetString(
+ StringNames.GameSecondsAbbrev,
+ (Il2CppSystem.Object[])[value.ToString(formatString, CultureInfo.InvariantCulture)]),
+ };
+ }
+
+ ///
+ /// Returns the string of a role.
+ ///
+ /// The role to find.
+ /// The role name.
+ public static string GetRoleName(this RoleBehaviour role)
+ {
+ if (role is ICustomRole custom)
+ {
+ return custom.RoleName;
}
- return KeyboardKeyCode.None;
+ return role.NiceName;
}
}
diff --git a/MiraAPI/Voting/VotingUtils.cs b/MiraAPI/Voting/VotingUtils.cs
index 159b576..38fbf16 100644
--- a/MiraAPI/Voting/VotingUtils.cs
+++ b/MiraAPI/Voting/VotingUtils.cs
@@ -71,7 +71,7 @@ public static class VotingUtils
/// The player who is sending the RPC. Should be the host.
/// The player who voted.
/// The player who the voter voted for.
- [MethodRpc((uint)MiraRpc.RemoveVote, SendImmediately = true)]
+ [MethodRpc((uint)MiraRpc.RemoveVote)]
public static void RpcRemoveVote(PlayerControl source, byte voterId, byte votedFor)
{
if (!source.IsHost()) return;
@@ -107,7 +107,7 @@ public static class VotingUtils
/// The player who sent this RPC.
/// The id of the player who casted the vote.
/// The voted player's id.
- [MethodRpc((uint)MiraRpc.CastVote, SendImmediately = true)]
+ [MethodRpc((uint)MiraRpc.CastVote)]
public static void RpcCastVote(PlayerControl source, byte srcPlayerId, byte suspectPlayerId)
{
CustomCastVote(srcPlayerId, suspectPlayerId);