diff --git a/MiraAPI.Example/ExampleAssets.cs b/MiraAPI.Example/ExampleAssets.cs index 903afd8..b0ca484 100644 --- a/MiraAPI.Example/ExampleAssets.cs +++ b/MiraAPI.Example/ExampleAssets.cs @@ -9,5 +9,6 @@ public static class ExampleAssets // Credit to EpicHorrors for the teleport button asset. public static LoadableResourceAsset TeleportButton { get; } = new("MiraAPI.Example.Resources.TeleportButton.png"); + public static LoadableResourceAsset SharpshootButton { get; } = new("MiraAPI.Example.Resources.SharpshootButton.png"); public static LoadableResourceAsset Banner { get; } = new("MiraAPI.Example.Resources.FortniteBanner.jpeg"); } diff --git a/MiraAPI.Example/MeetingAbilities/EndMeetingButton.cs b/MiraAPI.Example/MeetingAbilities/EndMeetingButton.cs new file mode 100644 index 0000000..0d39027 --- /dev/null +++ b/MiraAPI.Example/MeetingAbilities/EndMeetingButton.cs @@ -0,0 +1,33 @@ +using MiraAPI.Example.Modifiers; +using MiraAPI.Example.Roles; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.MeetingAbilities; +using MiraAPI.Modifiers; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.Example.MeetingAbilities; + +public class EndMeetingButton : MeetingActionButton +{ + public override string Name => "End Meeting"; + + public override float Cooldown => 15; + + public override float InitialCooldown => 30; + + public override int MaxUses => 3; + + public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + + public override bool Enabled(RoleBehaviour? role) + { + return PlayerControl.LocalPlayer != null && role is MayorRole; + } + + protected override void OnClick() + { + MeetingHud.Instance.RpcClose(); + } +} diff --git a/MiraAPI.Example/MeetingAbilities/ShootButton.cs b/MiraAPI.Example/MeetingAbilities/ShootButton.cs new file mode 100644 index 0000000..ad5f336 --- /dev/null +++ b/MiraAPI.Example/MeetingAbilities/ShootButton.cs @@ -0,0 +1,34 @@ +using MiraAPI.MeetingAbilities; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.Example.MeetingAbilities; + +public class ShootButton : TargetedMeetingButton +{ + public override string Name => "Shoot"; + + public override int MaxUses => 3; + + public override float InitialCooldown => 5; + + public override float Cooldown => 3; + + public override LoadableAsset Sprite => ExampleAssets.SharpshootButton; + + public override Color OutlineColor => Color.red; + + public override bool Enabled(RoleBehaviour r) + { + return r.IsImpostor; + } + + protected override void OnClick(PlayerVoteArea playerVoteArea) + { + playerVoteArea.SetDisabled(); + var player = playerVoteArea.GetPlayer(); + PlayerControl.LocalPlayer.RpcCustomMurder(player); + } +} \ No newline at end of file diff --git a/MiraAPI.Example/MeetingAbilities/ShotgunButton.cs b/MiraAPI.Example/MeetingAbilities/ShotgunButton.cs new file mode 100644 index 0000000..bc9f20c --- /dev/null +++ b/MiraAPI.Example/MeetingAbilities/ShotgunButton.cs @@ -0,0 +1,51 @@ +using System; +using MiraAPI.MeetingAbilities; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.Example.MeetingAbilities; + +public class ShotgunButton : MultiTargetMeetingButton +{ + public override string Name => "Shotgun"; + + public override int MaxUses => 3; + + public override float InitialCooldown => 5; + + public override float Cooldown => 3; + + public override LoadableAsset Sprite => ExampleAssets.SharpshootButton; + + public override Color OutlineColor => Color.red; + + public override bool Enabled(RoleBehaviour r) + { + return r.IsImpostor; + } + + protected override void OnClick(PlayerVoteArea playerVoteArea) + { + } + + public override LoadableAsset SpriteActive => ExampleAssets.CallMeetingButton; + + public override int TargetsCount => 4; + + protected override void OnSelect(PlayerVoteArea playerVoteArea, bool toggle) + { + HudManager.Instance.PlayerCam.ShakeScreen(0.5f, 2); + } + + public override void OnFinish() + { + foreach (var playerVoteArea in Targets.Keys) + { + playerVoteArea.SetDisabled(); + var player = playerVoteArea.GetPlayer(); + PlayerControl.LocalPlayer.RpcCustomMurder(player); + } + } +} \ No newline at end of file diff --git a/MiraAPI.Example/Resources/SharpshootButton.png b/MiraAPI.Example/Resources/SharpshootButton.png new file mode 100644 index 0000000..f8ccae6 Binary files /dev/null and b/MiraAPI.Example/Resources/SharpshootButton.png differ diff --git a/MiraAPI/Hud/MultiTargetButton.cs b/MiraAPI/Hud/MultiTargetMeetingButton.cs similarity index 96% rename from MiraAPI/Hud/MultiTargetButton.cs rename to MiraAPI/Hud/MultiTargetMeetingButton.cs index d52d845..2a36219 100644 --- a/MiraAPI/Hud/MultiTargetButton.cs +++ b/MiraAPI/Hud/MultiTargetMeetingButton.cs @@ -8,7 +8,7 @@ namespace MiraAPI.Hud; /// that has multiple target objects. /// /// The type of the target objects. -public abstract class MultiTargetButton : CustomActionButton where T : MonoBehaviour +public abstract class MultiTargetMeetingButton : CustomActionButton where T : MonoBehaviour { /// /// Gets or sets the list of targets of the button. diff --git a/MiraAPI/MeetingAbilities/MeetingAbilityBehaviour.cs b/MiraAPI/MeetingAbilities/MeetingAbilityBehaviour.cs new file mode 100644 index 0000000..8474757 --- /dev/null +++ b/MiraAPI/MeetingAbilities/MeetingAbilityBehaviour.cs @@ -0,0 +1,120 @@ +using System; +using System.Globalization; +using Reactor.Utilities.Attributes; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; + +namespace MiraAPI.MeetingAbilities; + +/// +/// A component for handling targeted meeting buttons. +/// +[RegisterInIl2Cpp] +public class MeetingAbilityBehaviour : MonoBehaviour +{ + /// + /// Gets or sets whether the component has been initialized. + /// + private bool _init; + + /// + /// The attached to this component. + /// + private TargetedMeetingButton _button = null!; + + /// + /// The of this component. + /// + public PassiveButton Button; + + /// + /// The parent . + /// + public PlayerVoteArea VoteArea; + + /// + /// The button's component. + /// + public SpriteRenderer Renderer; + + /// + /// The label used for showing the ability's cooldown. + /// + public TextMeshPro CooldownText; + + /// + /// The label used for showing the ability's remaining uses. + /// + public TextMeshPro UsesText; + + /// + /// Initializes the various properties of this component. + /// + /// The to initialize this component for. + /// The parent . + public void Initialize(TargetedMeetingButton button, PlayerVoteArea voteArea) + { + if (_init) return; + VoteArea = voteArea; + _button = button; + Renderer = GetComponent(); + SetFillUp(1, 1); + if (!TryGetComponent(out Button)) + { + Error($"Could not initialize MeetingButtonBehaviour for {Button.GetType().Name}, Destroying..."); + Destroy(this); + } + _init = true; + CooldownText = Instantiate(HudManager.Instance.KillButton.cooldownTimerText, transform); + CooldownText.GetComponent().Destroy(); + CooldownText.transform.localPosition = new Vector3(0, 0, -10); + CooldownText.m_maxFontSize = 3f; + CooldownText.color = new Color(1, 1, 1, 0.7f); + UsesText = Instantiate(CooldownText, transform); + UsesText.transform.localPosition = new Vector3(0.25f, 0.25f, -10); + UsesText.transform.localScale = Vector3.one * 0.7f; + + CooldownText.gameObject.SetActive(true); + UsesText.gameObject.SetActive(true); + } + + private void OnEnable() + { + if (!_init) return; + Button.enabled = true; + Renderer.enabled = true; + UsesText.enabled = true; + CooldownText.enabled = true; + if (_button.Enabled(PlayerControl.LocalPlayer.Data.Role) && _button.IsTargetValid(VoteArea)) return; + Button.enabled = false; + Renderer.enabled = false; + UsesText.enabled = false; + CooldownText.enabled = false; + } + + private void Update() + { + if (!_init) return; + if (_button.Timer < 0.01f) + { + Renderer.material.SetFloat("_Desat", 0.0f); + CooldownText.text = string.Empty; + UsesText.text = _button.LimitedUses ? _button.UsesLeft.ToString(CultureInfo.InvariantCulture) : string.Empty; + } + else + { + Renderer.material.SetFloat("_Desat", 0.5f); + CooldownText.text = _button.Timer.ToString(_button.CooldownTimerFormatString, NumberFormatInfo.InvariantInfo); + UsesText.text = string.Empty; + } + SetFillUp(_button.Timer, _button.InitialCooldown); + } + + public void SetFillUp(float timer, float maxTimer) + { + float percentCool = Mathf.Clamp(timer / maxTimer, 0.001f, 1f); + if (percentCool <= 0) percentCool = 1f; + Renderer.material.SetFloat("_Percent", percentCool); + } +} diff --git a/MiraAPI/MeetingAbilities/MeetingActionButton.cs b/MiraAPI/MeetingAbilities/MeetingActionButton.cs new file mode 100644 index 0000000..88cc799 --- /dev/null +++ b/MiraAPI/MeetingAbilities/MeetingActionButton.cs @@ -0,0 +1,321 @@ +using System; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Hud; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace MiraAPI.MeetingAbilities; + +/// +/// Class for making custom meeting action buttons. +/// +public abstract class MeetingActionButton +{ + /// + /// Gets the name and text of the button. + /// + public abstract string Name { get; } + + /// + /// Gets the initial cooldown duration in seconds. + /// + public virtual float InitialCooldown => Cooldown; + + /// + /// Gets the button's cooldown duration in seconds. + /// + public abstract float Cooldown { get; } + + /// + /// Gets the sprite of the button. Use to load a sprite from a resource path. Use to load a sprite from an asset bundle. + /// + public abstract LoadableAsset Sprite { get; } + + /// + /// Gets the format string for the cooldown timer. + /// + public virtual string CooldownTimerFormatString => "0"; + + /// + /// Gets a value indicating whether the button has limited uses. + /// + public bool LimitedUses => ZeroIsInfinite ? MaxUses > 0 : MaxUses >= 0; + + /// + /// Gets the maximum number of uses the button has. If the button has infinite uses, set to 0 or -1 based on what value that ZeroIsInfinite is set to. + /// + public virtual int MaxUses => ZeroIsInfinite ? 0 : -1; + + /// + /// Gets the value indicating uses mode. + /// + public virtual MeetingButtonUsesMode UsesMode => MeetingButtonUsesMode.PerGame; + + /// + /// Gets the button's text outline . + /// + public virtual Color TextOutlineColor => Color.clear; + + /// + /// Gets or sets a value indicating whether limited uses are determined via zero or a negative number of uses. + /// + public virtual bool ZeroIsInfinite { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the timer is currently active. + /// + public bool TimerPaused { get; set; } + + /// + /// Gets or sets the amount of uses left. + /// + public int UsesLeft { get; set; } + + /// + /// Gets or sets the timer variable to measure cooldowns and effects. + /// + public float Timer { get; set; } + + /// + /// Gets or sets the object in game. This is created by Mira API automatically. + /// + public ActionButton? Button { get; set; } + + /// + /// The method used to create the button. + /// + /// The parent of the button. + public virtual void CreateButton(Transform parent) + { + if (Button) + { + return; + } + + Timer = AmongUsClient.Instance?.NetworkMode == NetworkModes.FreePlay ? 0 : InitialCooldown; + TimerPaused = false; + + Button = Object.Instantiate(MeetingHud.Instance.MeetingAbilityButton, parent); + Button.name = Name + "Button"; + Button.OverrideText(Name.ToUpperInvariant()); + + Button.graphic.sprite = Sprite.LoadAsset(); + + Button.SetUsesRemaining(UsesLeft); + if (MaxUses <= 0) + { + Button.SetInfiniteUses(); + } + + if (TextOutlineColor != Color.clear) + { + SetTextOutline(TextOutlineColor); + } + + var pb = Button.GetComponent(); + pb.OnClick = new Button.ButtonClickedEvent(); + pb.OnClick.AddListener((UnityAction)(() => + { + ClickHandler(); + })); + Button.gameObject.SetActive(Enabled(PlayerControl.LocalPlayer.Data.Role)); + } + + /// + /// A utility function to change the outline of the button's text. + /// + /// The new . + public virtual void SetTextOutline(Color color) + { + Button?.buttonLabelText.SetOutlineColor(color); + } + + /// + /// A utility function to override the of the button. + /// + /// The new to override with. + public virtual void OverrideSprite(Sprite sprite) + { + if (Button != null) + { + Button.graphic.sprite = sprite; + } + } + + /// + /// A utility function to override the name of the button. + /// + /// The new name to override with. + public virtual void OverrideName(string name) + { + Button?.OverrideText(name); + } + + /// + /// Set the button's timer. + /// + /// The time you want to set to. + public virtual void SetTimer(float time) + { + Timer = Mathf.Clamp(time, -1, float.MaxValue); + } + + /// + /// Increase the button's timer. + /// + /// The amount you want to increase by. + public virtual void IncreaseTimer(float amount) + { + SetTimer(Timer + amount); + } + + /// + /// Decrease the button's timer. + /// + /// The amount you want to decrease by. + public virtual void DecreaseTimer(float amount) + { + SetTimer(Timer - amount); + } + + /// + /// Sets whether the timer is paused or not. + /// + /// Whether you want to pause/resume the timer. + public virtual void SetTimerPaused(bool val) + { + TimerPaused = val; + } + + /// + /// Set the amount of uses this button has left. + /// + /// The amount you want to set to. + public virtual void SetUses(int amount) + { + UsesLeft = Mathf.Clamp(amount, 0, int.MaxValue); + Button?.SetUsesRemaining(UsesLeft); + + if (Button != null) + { + Button.usesRemainingSprite.color = UsesLeft == 0 ? Color.red : Color.white; + } + } + + /// + /// Increase the amount of uses this button has left. + /// + /// The amount you want to increase by. Default: 1. + public virtual void IncreaseUses(int amount = 1) + { + SetUses(UsesLeft + amount); + } + + /// + /// Decrease the amount of uses this button has left. + /// + /// The amount you want to decrease by. Default: 1. + public virtual void DecreaseUses(int amount = 1) + { + SetUses(UsesLeft - amount); + } + + /// + /// A utility function that runs with the local player's if the button is enabled. + /// + /// the instance. + protected virtual void FixedUpdate(MeetingHud hud) + { + } + + /// + /// Callback method for the button click event. + /// + protected abstract void OnClick(); + + /// + /// This method determines if the button should be active or not. + /// means the button is active, means the button is disabled. + /// + /// The of the local player. + /// if the button is enabled, otherwise. + public abstract bool Enabled(RoleBehaviour? role); + + /// + /// 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. + /// You can override it to change the behavior. + /// + /// A value that represents whether the button can be clicked or not. + public virtual bool CanClick() + { + return Timer <= 0 && CanUse(); + } + + /// + /// Whether the button should light up or not. This is also the base for CanClick. + /// You can override it to change the behaviour. Do not include timer in here, that is for CanClick. + /// + /// A value that represents whether the button should light up or not. + public virtual bool CanUse() + { + return !LimitedUses || UsesLeft > 0; + } + + /// + /// This method handles the button click event. It is a wrapper for the method. + /// This method takes into account cooldowns, effects, and uses, before calling the method. + /// It can be overridden for custom behavior. + /// + public virtual void ClickHandler() + { + if (!CanClick()) + { + return; + } + + if (LimitedUses) + { + UsesLeft--; + Button?.SetUsesRemaining(UsesLeft); + } + + Timer = Cooldown; + OnClick(); + } + + /// + /// This method is called on the method. It is a wrapper for the method. + /// By default, it handles the cooldown and effect timers, and sets the button to enabled or disabled. + /// It can be overridden for custom behavior. + /// + /// The instance. + public virtual void UpdateHandler(MeetingHud hud) + { + if (Timer >= 0 && !TimerPaused) + { + Timer -= Time.deltaTime; + } + + if (Button) + { + if (CanUse()) + { + Button!.SetEnabled(); + } + else + { + Button!.SetDisabled(); + } + Button.SetCooldownFormat(Timer, Cooldown, CooldownTimerFormatString); + Button.SetCoolDown(Timer, Cooldown); + } + + FixedUpdate(hud); + } +} diff --git a/MiraAPI/MeetingAbilities/MeetingButtonManager.cs b/MiraAPI/MeetingAbilities/MeetingButtonManager.cs new file mode 100644 index 0000000..b45e00b --- /dev/null +++ b/MiraAPI/MeetingAbilities/MeetingButtonManager.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Modifiers; +using MiraAPI.Modifiers.Types; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using MiraAPI.Utilities.Components; +using UnityEngine; + +namespace MiraAPI.MeetingAbilities; + +/// +/// Class for managing all registered meeting abilities. +/// +public static class MeetingButtonManager +{ + /// + /// Gets a list if==of all registered . + /// + public static List TargetedButtons { get; internal set; } = new(); + + /// + /// Gets a list if==of all registered . + /// + public static List UntargetedButtons { get; internal set; } = new(); + + internal static bool RegisterTargetedMeetingButton(Type type, MiraPluginInfo info) + { + if (!typeof(TargetedMeetingButton).IsAssignableFrom(type)) + { + return false; + } + + var button = Activator.CreateInstance(type) as TargetedMeetingButton; + info.InternalTargetedMeetingButtons.Add(button); + TargetedButtons.Add(button); + return true; + } + + internal static bool RegisterMeetingButton(Type type, MiraPluginInfo info) + { + if (!typeof(MeetingActionButton).IsAssignableFrom(type)) + { + return false; + } + + var button = Activator.CreateInstance(type) as MeetingActionButton; + info.InternalMeetingButtons.Add(button); + UntargetedButtons.Add(button); + return true; + } + + private static CenteredGridArrange? actionButtonsParent = null!; + + public static void OnGameStart() + { + foreach (var ability in TargetedButtons.Where(x => x.UsesMode == MeetingButtonUsesMode.PerGame)) + { + ability.UsesLeft = ability.MaxUses; + } + foreach (var ability in UntargetedButtons.Where(x => x.UsesMode == MeetingButtonUsesMode.PerGame)) + { + ability.UsesLeft = ability.MaxUses; + } + } + + /// + /// Called on . Handles button creation, and ability uses. + /// + /// The instance. + public static void OnMeetingStart(MeetingHud meetingHud) + { + // Untargeted buttons parent. + actionButtonsParent = new GameObject("ActionsButtonParent").AddComponent(); + actionButtonsParent.transform.SetParent(meetingHud.MeetingAbilityButton.transform.parent, meetingHud.MeetingAbilityButton.transform.parent); + actionButtonsParent.CellSize = new Vector2(0.7f, 0.7f); + actionButtonsParent.transform.localPosition = new Vector3(0, -2.1f, -10); + meetingHud.MeetingAbilityButton.transform.SetParent(actionButtonsParent.transform); + + // Untargeted buttons creation. + foreach (var ability in UntargetedButtons) + { + try + { + ability.CreateButton(actionButtonsParent.transform); + if (ability.UsesMode == MeetingButtonUsesMode.PerMeeting) ability.UsesLeft = ability.MaxUses; + ability.Timer = ability.InitialCooldown; + } + catch (Exception e) + { + Error($"Failed to create meeting button {ability.Name}: {e.Message}"); + } + } + + // Targeted buttons creation. + foreach (var ability in TargetedButtons) + { + try + { + foreach (var playerVoteArea in meetingHud.playerStates) + { + var btn = ability.CreateButton(playerVoteArea); + btn.transform.SetParent(playerVoteArea.Buttons.transform); + playerVoteArea.CancelButton.transform.SetAsFirstSibling(); + } + + if (ability.UsesMode == MeetingButtonUsesMode.PerMeeting) ability.UsesLeft = ability.MaxUses; + if (ability is MultiTargetMeetingButton multiAbility) multiAbility.Targets = new(); + } + catch (Exception e) + { + Error($"Failed to create targeted meeting button {ability.Name}: {e.Message}"); + } + } + } +} diff --git a/MiraAPI/MeetingAbilities/MeetingButtonUsesMode.cs b/MiraAPI/MeetingAbilities/MeetingButtonUsesMode.cs new file mode 100644 index 0000000..a4ddb40 --- /dev/null +++ b/MiraAPI/MeetingAbilities/MeetingButtonUsesMode.cs @@ -0,0 +1,7 @@ +namespace MiraAPI.MeetingAbilities; + +public enum MeetingButtonUsesMode +{ + PerGame, + PerMeeting +} \ No newline at end of file diff --git a/MiraAPI/MeetingAbilities/MultiTargetMeetingButton.cs b/MiraAPI/MeetingAbilities/MultiTargetMeetingButton.cs new file mode 100644 index 0000000..b4dcffa --- /dev/null +++ b/MiraAPI/MeetingAbilities/MultiTargetMeetingButton.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Utilities.Assets; +using UnityEngine; +using UnityEngine.UI; + +namespace MiraAPI.MeetingAbilities; + +/// +/// Abstract class for creating multi target meeting abilities +/// +public abstract class MultiTargetMeetingButton : TargetedMeetingButton +{ + /// + /// The used for when the button is on. + /// + public abstract LoadableAsset SpriteActive { get; } + + /// + /// Gets the required targets count. + /// + public abstract int TargetsCount { get; } + + /// + /// Gets or sets all selected targets. + /// + public Dictionary Targets { get; set; } = new(); + + /// + public override void ClickHandler(MeetingAbilityBehaviour button, PlayerVoteArea playerVoteArea) + { + if (UsesLeft <= 0 && LimitedUses) return; + if (Timer > 0) return; + var toggle = !Targets.Remove(playerVoteArea); + if (toggle) Targets.Add(playerVoteArea, button); + button.Renderer.sprite = toggle ? SpriteActive.LoadAsset() : Sprite.LoadAsset(); + if (Targets.Count >= TargetsCount) + { + playerVoteArea.Cancel(); + HandleFinish(); + return; + } + OnSelect(playerVoteArea, toggle); + } + + /// + /// Handles the finish logic, increasing timer, and decreasing uses. + /// + public virtual void HandleFinish() + { + Targets = Targets.Take(TargetsCount).ToDictionary(pair => pair.Key, pair => pair.Value); + foreach (var button in Targets.Values) + { + button.Renderer.sprite = Sprite.LoadAsset(); + } + Timer = Cooldown; + UsesLeft -= 1; + OnFinish(); + Targets = new(); + } + + /// + /// Callback method for when a player is selected. + /// + /// The target . + /// Whether the button was toggled on/off. + protected abstract void OnSelect(PlayerVoteArea playerVoteArea, bool toggle); + + /// + /// Callback method for the button click. + /// USE INSTEAD!!!. + /// + /// The target . + protected override void OnClick(PlayerVoteArea playerVoteArea) + { + } + + /// + /// Callback method for when enough targets are chosen. + /// + public abstract void OnFinish(); + + /// + public override MeetingAbilityBehaviour CreateButton(PlayerVoteArea playerVoteArea) + { + Targets = new(); + var button = base.CreateButton(playerVoteArea); + button.Button.OnClick = new Button.ButtonClickedEvent(); + button.Button.OnClick.AddListener(new System.Action(() => { ClickHandler(button, playerVoteArea); })); + return button; + } +} diff --git a/MiraAPI/MeetingAbilities/TargetedMeetingButton.cs b/MiraAPI/MeetingAbilities/TargetedMeetingButton.cs new file mode 100644 index 0000000..324e7c7 --- /dev/null +++ b/MiraAPI/MeetingAbilities/TargetedMeetingButton.cs @@ -0,0 +1,155 @@ +using System.Collections.Generic; +using MiraAPI.Utilities.Assets; +using UnityEngine; +using UnityEngine.UI; + +namespace MiraAPI.MeetingAbilities; + +/// +/// Abstract class for creating targeted meeting abilities. +/// +public abstract class TargetedMeetingButton +{ + /// + /// Gets the name of the . + /// + public abstract string Name { get; } + + /// + /// Gets the Max Uses of the button. + /// Set it to 0 for Infinite uses. + /// + public abstract int MaxUses { get; } + + /// + /// Gets or sets the amount of uses left for this button. + /// + public int UsesLeft { get; set; } + + /// + /// Gets a value indicating whether the has limited uses. + /// + public bool LimitedUses => MaxUses != 0; + + /// + /// Gets the cooldown time for this button upon meeting start. + /// Defaults to unless overridden. + /// + public virtual float InitialCooldown => Cooldown; + + /// + /// Gets the cooldown time for this button after being used. + /// + public abstract float Cooldown { get; } + + /// + /// Gets or sets the current cooldown timer for this button. + /// + public float Timer { get; set; } + + /// + /// Gets the cooldown time for this button. + /// + public virtual string CooldownTimerFormatString => "0"; + + /// + /// Gets the sprite used for this button. + /// + public abstract LoadableAsset Sprite { get; } + + /// + /// Gets the button's outline color. + /// + public abstract Color OutlineColor { get; } + + /// + /// Gets the for this button. + /// + public virtual MeetingButtonUsesMode UsesMode { get; } = MeetingButtonUsesMode.PerGame; + + /// + /// Determines if the button is enabled. + /// + /// The local player's . + /// Whether the button is enabled or not. + public abstract bool Enabled(RoleBehaviour r); + + /// + /// Callback method for when the button is clicked. + /// + /// The target . + protected abstract void OnClick(PlayerVoteArea playerVoteArea); + + /// + /// Handles the button creation. + /// + /// The target . + /// The instance. + public virtual MeetingAbilityBehaviour CreateButton(PlayerVoteArea playerVoteArea) + { + if (MeetingHud.Instance == null) return null!; + + var btn = Object.Instantiate(MeetingHud.Instance.PlayerButtonPrefab.ConfirmButton).GetComponent(); + btn.gameObject.name = Name + "Button"; + + var spriteRenderer = btn.GetComponent(); + spriteRenderer.sprite = Sprite.LoadAsset(); + spriteRenderer.material = new Material(HudManager.Instance.KillButton.graphic.material); + spriteRenderer.SetCooldownNormalizedUvs(); + btn.gameObject.SetActive(true); + + btn.transform.GetChild(0).GetComponent().color = OutlineColor; + + btn.OnClick = new Button.ButtonClickedEvent(); + var abilityBehaviour = btn.gameObject.AddComponent(); + abilityBehaviour.Initialize(this, playerVoteArea); + btn.OnClick.AddListener(new System.Action(() => ClickHandler(abilityBehaviour, playerVoteArea))); + Timer = InitialCooldown; + + return abilityBehaviour; + } + + /// + /// Called every . + /// Handles the timer logic. + /// + public virtual void UpdateHandler() + { + Timer -= Time.deltaTime; + Timer = Mathf.Clamp(Timer, 0, int.MaxValue); + FixedUpdate(); + } + + /// + /// Callback method for handling logic every frame. + /// + public virtual void FixedUpdate() + { + } + + /// + /// Called when the button is clicked + /// Handles increasing cooldown and decreasing uses remaining. + /// + /// The instance. + /// The target . + public virtual void ClickHandler(MeetingAbilityBehaviour button, PlayerVoteArea playerVoteArea) + { + if (UsesLeft <= 0 && LimitedUses) return; + if (Timer > 0) return; + Timer = Cooldown; + UsesLeft--; + playerVoteArea.Cancel(); + OnClick(playerVoteArea); + } + + /// + /// Determines whether the target is valid. + /// + /// The target . + /// Whether the target is valid or not. + public virtual bool IsTargetValid(PlayerVoteArea playerVoteArea) + { + return playerVoteArea != null && playerVoteArea.PlayerId.Value != PlayerControl.LocalPlayer.Data.PlayerId; + } +} diff --git a/MiraAPI/MeetingAbilities/TargetedMeetingButtonSingleton.cs b/MiraAPI/MeetingAbilities/TargetedMeetingButtonSingleton.cs new file mode 100644 index 0000000..47dc791 --- /dev/null +++ b/MiraAPI/MeetingAbilities/TargetedMeetingButtonSingleton.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace MiraAPI.MeetingAbilities; + +/// +/// A utility class to get the instance of a . +/// +/// The type of the button you are trying to access. +public static class TargetedMeetingButtonSingleton where T : TargetedMeetingButton +{ + private static T? _instance; + + /// + /// Gets the instance of the button. + /// + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "This is a utility class to get the instance of a targeted meeting button.")] + public static T Instance => _instance ??= MeetingButtonManager.TargetedButtons.OfType().Single(); +} diff --git a/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs b/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs index 3bda24d..0f582e1 100644 --- a/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs +++ b/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs @@ -1,7 +1,9 @@ -using System.Reflection; +using System.Linq; +using System.Reflection; using HarmonyLib; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.MeetingAbilities; using MiraAPI.Utilities; namespace MiraAPI.Patches.Events; @@ -18,6 +20,7 @@ public static class FreeplayRoundStartPatch { if (!__result) { + MeetingButtonManager.OnGameStart(); MiraEventManager.InvokeEvent(new RoundStartEvent(true)); } } diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs index ae227bd..fecc455 100644 --- a/MiraAPI/Patches/IntroCutscenePatches.cs +++ b/MiraAPI/Patches/IntroCutscenePatches.cs @@ -1,8 +1,10 @@ -using System.Reflection; +using System.Linq; +using System.Reflection; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.MeetingAbilities; using MiraAPI.GameModes; using MiraAPI.Roles; using MiraAPI.Utilities; @@ -126,7 +128,9 @@ public static class IntroCutscenePatches { introCutscene = __instance.Cast(); } + Info("IntroCutscene ended"); + MeetingButtonManager.OnGameStart(); MiraEventManager.InvokeEvent(new IntroEndEvent(introCutscene)); var @event = new BeforeRoundStartEvent(true); diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs index d88c109..e907f34 100644 --- a/MiraAPI/Patches/Voting/MeetingHudPatches.cs +++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs @@ -1,4 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; @@ -6,9 +8,12 @@ using InnerNet; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Meeting; using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.MeetingAbilities; using MiraAPI.Modifiers; using MiraAPI.Utilities; +using MiraAPI.Utilities.Components; using MiraAPI.Voting; +using UnityEngine; namespace MiraAPI.Patches.Voting; @@ -34,10 +39,46 @@ internal static class MeetingHudPatches return true; } + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.Select))] + public static bool VoteAreaSelectPatch(PlayerVoteArea __instance) + { + if (PlayerControl.LocalPlayer.Data.IsDead || __instance.AmDead || !(bool) (UnityEngine.Object) __instance.Parent) + return false; + JudgeRole? judgeRole = PlayerControl.LocalPlayer.Data.Role.TryCast(); + bool flag = judgeRole && PlayerControl.LocalPlayer.PlayerId != __instance.PlayerId; + __instance.JudgeOverruleButton?.gameObject.SetActive(flag); + if (__instance.VoteComplete || !__instance.Parent.Select((byte) __instance.PlayerId)) + return false; + __instance.Buttons.SetActive(true); + + float startPos = __instance.AnimateButtonsFromLeft ? 0.2f : 1.95f; + + Il2CppSystem.Collections.Generic.List selectableElements = new Il2CppSystem.Collections.Generic.List(); + foreach (var btn in __instance.Buttons.GetComponentsInChildren()) + { + selectableElements.Add(btn); + } + + for (int i = 0; i < selectableElements.Count; i++) + { + var button = selectableElements[i]; + float endPos = 1.3f - 0.65f * i; + float duration = 0.25f + 0.1f * i; + __instance.StartCoroutine(Effects.All(Effects.Lerp(duration, (Action) (t => + button.transform.localPosition = Vector2.Lerp(Vector2.right * startPos, Vector2.right * endPos, Effects.ExpOut(t)))))); + } + + ControllerManager.Instance.OpenOverlayMenu(__instance.name, __instance.CancelButton, selectableElements[1], selectableElements); + + return false; + } + [HarmonyPostfix] [HarmonyPatch(nameof(MeetingHud.Start))] public static void MeetingHudStartPatch(MeetingHud __instance) { + MeetingButtonManager.OnMeetingStart(__instance); foreach (var plr in PlayerControl.AllPlayerControls) { var voteData = plr.GetVoteData(); @@ -78,6 +119,16 @@ internal static class MeetingHudPatches [HarmonyPatch(nameof(MeetingHud.Update))] public static void ForceSkipPatch(MeetingHud __instance) { + foreach (var targetedMeetingAbility in MeetingButtonManager.TargetedButtons.Where(x => x.Enabled(PlayerControl.LocalPlayer.Data.Role))) + { + targetedMeetingAbility.UpdateHandler(); + } + + foreach (var meetingAbility in MeetingButtonManager.UntargetedButtons.Where(x => x.Enabled(PlayerControl.LocalPlayer.Data.Role))) + { + meetingAbility.UpdateHandler(__instance); + } + if (__instance.state is not (MeetingHud.MeetingStates.NotVoted or MeetingHud.MeetingStates.Voted)) { return; @@ -106,7 +157,7 @@ internal static class MeetingHudPatches [HarmonyPrefix] [HarmonyPatch(nameof(MeetingHud.Select))] - public static bool SelectPatch(MeetingHud __instance, int suspectStateIdx) + public static bool MeetingHudSelectPatch(MeetingHud __instance, int suspectStateIdx) { var voteData = PlayerControl.LocalPlayer.GetVoteData(); diff --git a/MiraAPI/PluginLoading/MiraPluginInfo.cs b/MiraAPI/PluginLoading/MiraPluginInfo.cs index a48cceb..874c32b 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.MeetingAbilities; using MiraAPI.Modifiers; using MiraAPI.Presets; @@ -58,6 +59,16 @@ public class MiraPluginInfo /// public IReadOnlyCollection Buttons { get; private set; } = null!; + /// + /// Gets a of this 's . + /// + public IReadOnlyCollection TargetedMeetingButtons { get; private set; } = null!; + + /// + /// Gets a of this 's . + /// + public IReadOnlyCollection MeetingButtons { get; private set; } = null!; + /// /// Gets a of this 's s. /// @@ -71,6 +82,8 @@ public class MiraPluginInfo Options = [..InternalOptions]; Roles = new ReadOnlyDictionary(InternalRoles); Buttons = [..InternalButtons]; + MeetingButtons = [..InternalMeetingButtons]; + TargetedMeetingButtons = [..InternalTargetedMeetingButtons]; } internal List InternalPresets { get; } = []; @@ -86,6 +99,10 @@ public class MiraPluginInfo internal List InternalButtons { get; } = []; + internal List InternalTargetedMeetingButtons { get; } = []; + + internal List InternalMeetingButtons { get; } = []; + /// /// Gets the plugin's ID, as defined in the plugin's BepInEx metadata. /// diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 543b78d..bf9e563 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -18,6 +18,7 @@ using MiraAPI.Hud; using MiraAPI.Keybinds; using MiraAPI.LocalSettings; using MiraAPI.LocalSettings.Attributes; +using MiraAPI.MeetingAbilities; using MiraAPI.Modifiers; using MiraAPI.Patches.Options; using MiraAPI.Presets; @@ -223,6 +224,17 @@ public sealed class MiraPluginManager continue; } + + if (RegisterMeetingAbility(type, info)) + { + continue; + } + + if (RegisterTargetedMeetingAbility(type, info)) + { + continue; + } + if (RegisterGameModeAttribute(type, info)) { continue; @@ -583,4 +595,29 @@ public sealed class MiraPluginManager Error($"Failed to register keybind class {type.Name}: {e}"); } } + + private static bool RegisterMeetingAbility(Type type, MiraPluginInfo info) + { + try + { + return MeetingButtonManager.RegisterMeetingButton(type, info); + } + catch (Exception e) + { + Error($"Failed to register meeting ability {type.Name}: {e}"); + return false; + } + } + private static bool RegisterTargetedMeetingAbility(Type type, MiraPluginInfo info) + { + try + { + return MeetingButtonManager.RegisterTargetedMeetingButton(type, info); + } + catch (Exception e) + { + Error($"Failed to register targeted meeting ability {type.Name}: {e}"); + return false; + } + } } diff --git a/MiraAPI/Utilities/Components/CenteredGridArrange.cs b/MiraAPI/Utilities/Components/CenteredGridArrange.cs new file mode 100644 index 0000000..ee32b26 --- /dev/null +++ b/MiraAPI/Utilities/Components/CenteredGridArrange.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace MiraAPI.Utilities.Components; + +/// +/// Utility component for arranging objects in a centered layout, similar to . +/// +/// +[RegisterInIl2Cpp] +public class CenteredGridArrange(IntPtr ptr) : MonoBehaviour(ptr) +{ + /// + /// Gets or sets the cell size, which is used for spacing. + /// + public Vector2 CellSize; + + /// + /// Gets or sets the maximum amount of columns. + /// + public int MaxColumns = 6; + + private List cells; + private static List currentChildren = new List(); + + private void Start() + { + cells = new List(); + GetChildsActive(); + CheckCurrentChildren(); + } + + private void FixedUpdate() => CheckCurrentChildren(); + + private void CheckCurrentChildren() + { + GetChildsActive(); + if (cells.SequenceEqual(currentChildren)) + return; + cells.Clear(); + foreach (Transform currentChild in currentChildren) + cells.Add(currentChild); + ArrangeChilds(); + } + + private void GetChildsActive() + { + currentChildren.Clear(); + foreach (var obj in transform) + { + var child = obj.TryCast(); + if (child == null) continue; + if (child.gameObject.activeSelf) + currentChildren.Add(child); + } + } + + private void ArrangeChilds() + { + if (cells.Count == 0) + return; + + int totalRows = Mathf.CeilToInt((float)cells.Count / MaxColumns); + float totalHeight = (totalRows - 1) * CellSize.y; + float startY = transform.position.y + totalHeight * 0.5f; + + for (int index = 0; index < cells.Count; ++index) + { + int row = index / MaxColumns; + int rowStartIndex = row * MaxColumns; + int itemsInRow = Mathf.Min(MaxColumns, cells.Count - rowStartIndex); + int col = index - rowStartIndex; + + float rowWidth = (itemsInRow - 1) * CellSize.x; + float startX = transform.position.x - rowWidth * 0.5f; + + float x = startX + col * CellSize.x; + float y = startY - row * CellSize.y; + + Transform cell = cells[index]; + cell.position = new Vector3(x, y, cell.position.z); + } + } +}