diff --git a/.gitignore b/.gitignore index 4cf2515..74c2aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ bin/ obj/ References/ -NewMod/Components +NewMod/Components/Minigames +NewMod/Components/Hidden.cs /packages/ riderModule.iml .idea diff --git a/NewMod/Buttons/EnergyThief/DrainButton.cs b/NewMod/Buttons/EnergyThief/DrainButton.cs index 7d3d00c..1e25552 100644 --- a/NewMod/Buttons/EnergyThief/DrainButton.cs +++ b/NewMod/Buttons/EnergyThief/DrainButton.cs @@ -6,6 +6,7 @@ using ET = NewMod.Roles.NeutralRoles.EnergyThief; using UnityEngine; using NewMod.Utilities; +using Rewired; namespace NewMod.Buttons.EnergyThief { @@ -33,6 +34,11 @@ public class DrainButton : CustomActionButton /// The on-screen position of this button. /// public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// Default keybind for EnergyThief's Drain ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.F; /// /// The duration of the effect applied by this button; in this case, zero. @@ -50,7 +56,7 @@ public class DrainButton : CustomActionButton /// The closest valid PlayerControl, or null if none. public override PlayerControl GetTarget() { - return PlayerControl.LocalPlayer.GetClosestPlayer(true, Distance, false, p => !p.Data.IsDead && !p.Data.Disconnected); + return PlayerControl.LocalPlayer.GetClosestPlayer(true, Distance, false, false, p => !p.Data.IsDead && !p.Data.Disconnected); } /// diff --git a/NewMod/Buttons/Injector/InjectButton.cs b/NewMod/Buttons/Injector/InjectButton.cs index b82bfb9..c20978c 100644 --- a/NewMod/Buttons/Injector/InjectButton.cs +++ b/NewMod/Buttons/Injector/InjectButton.cs @@ -7,6 +7,7 @@ using MiraAPI.Utilities; using System; using static NewMod.Utilities.Utils; +using Rewired; namespace NewMod.Buttons.Injector { @@ -29,7 +30,7 @@ public class InjectButton : CustomActionButton /// /// Maximum allowed injections, configured via . /// - public override int MaxUses => OptionGroupSingleton.Instance.MaxSerumUses; + public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxSerumUses; /// /// Effect duration — unused here since injection is instant. @@ -41,10 +42,15 @@ public class InjectButton : CustomActionButton /// public override ButtonLocation Location => ButtonLocation.BottomLeft; + /// + /// Default keybind for Injector's Inject ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.C; + /// /// Sprite/icon displayed on the button. /// - public override LoadableAsset Sprite => MiraAssets.Empty; + public override LoadableAsset Sprite => NewModAsset.InjectButton; /// /// Returns the closest valid player target within range, @@ -81,10 +87,10 @@ public override bool Enabled(RoleBehaviour role) /// protected override void OnClick() { - var serumValues = Enum.GetValues(typeof(SerumType)); - SerumType randomSerum = (SerumType)serumValues.GetValue(UnityEngine.Random.Range(0, serumValues.Length)); - - RpcApplySerum(PlayerControl.LocalPlayer, Target, randomSerum); + var values = (SerumType[])Enum.GetValues(typeof(SerumType)); + var serum = values[UnityEngine.Random.Range(0, values.Length)]; + + RpcApplySerum(PlayerControl.LocalPlayer, Target, serum); } } } diff --git a/NewMod/Buttons/Necromancer/ReviveButton.cs b/NewMod/Buttons/Necromancer/ReviveButton.cs index 6230084..cfb41df 100644 --- a/NewMod/Buttons/Necromancer/ReviveButton.cs +++ b/NewMod/Buttons/Necromancer/ReviveButton.cs @@ -5,6 +5,7 @@ using NewMod.Roles.ImpostorRoles; using UnityEngine; using NewMod.Utilities; +using Rewired; namespace NewMod.Buttons.Necromancer { @@ -33,6 +34,11 @@ public class ReviveButton : CustomActionButton /// public override float EffectDuration => 0f; + /// + /// Default keybind for Necromancer's Revive ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.V; + /// /// Defines where on the screen this button should appear. /// diff --git a/NewMod/Buttons/Overload/OverloadButton.cs b/NewMod/Buttons/Overload/OverloadButton.cs index 934fb94..908d6d6 100644 --- a/NewMod/Buttons/Overload/OverloadButton.cs +++ b/NewMod/Buttons/Overload/OverloadButton.cs @@ -1,6 +1,7 @@ using MiraAPI.Hud; using MiraAPI.Utilities.Assets; using NewMod.Roles.NeutralRoles; +using Rewired; using UnityEngine; namespace NewMod.Buttons.Overload @@ -41,6 +42,12 @@ public class OverloadButton : CustomActionButton /// public float absorbedCooldown; + /// + /// Stores the default key assigned to the absorbed button's action. + /// Mirrors the keybind of the original absorbed button. + /// + public KeyboardKeyCode absorbedKeybind; + /// /// The name displayed on the button. /// @@ -56,6 +63,11 @@ public class OverloadButton : CustomActionButton /// public override int MaxUses => absorbedMaxUses; + /// + /// Default keybind for Overload's Overload ability. + /// + public override KeyboardKeyCode Defaultkeybind => absorbedKeybind; + /// /// Determines how long the effect from clicking the button lasts. In this case, no duration is set. /// @@ -81,6 +93,7 @@ public void Absorb(CustomActionButton target) absorbedCooldown = target.Cooldown; absorbedMaxUses = target.MaxUses; absorbedSprite = target.Sprite; + absorbedKeybind = target.Defaultkeybind; absorbedOnClick = () => target.GetType().GetMethod("OnClick", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) ?.Invoke(target, null); diff --git a/NewMod/Buttons/Prankster/FakeBodyButton.cs b/NewMod/Buttons/Prankster/FakeBodyButton.cs index b02a05c..bb6db8e 100644 --- a/NewMod/Buttons/Prankster/FakeBodyButton.cs +++ b/NewMod/Buttons/Prankster/FakeBodyButton.cs @@ -6,6 +6,7 @@ using PRK = NewMod.Roles.NeutralRoles.Prankster; using UnityEngine; using NewMod.Utilities; +using Rewired; namespace NewMod.Buttons.Prankster { @@ -34,6 +35,11 @@ public class FakeBodyButton : CustomActionButton /// public override ButtonLocation Location => ButtonLocation.BottomRight; + /// + /// Default keybind for Prankster's Fake Body ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.Z; + /// /// The duration of any effect caused by this button press; in this case, no effect duration is used. /// diff --git a/NewMod/Buttons/PulseBlade/StrikeButton.cs b/NewMod/Buttons/PulseBlade/StrikeButton.cs new file mode 100644 index 0000000..63dc186 --- /dev/null +++ b/NewMod/Buttons/PulseBlade/StrikeButton.cs @@ -0,0 +1,154 @@ +using System.Collections; +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using UnityEngine; +using Pb = NewMod.Roles.ImpostorRoles.PulseBlade; +using MiraAPI.Networking; +using NewMod.Options.Roles.PulseBladeOptions; +using Reactor.Utilities; +using MiraAPI.Utilities; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Rewired; + +namespace NewMod.Buttons.Pulseblade +{ + /// + /// Custom button for the Pulseblade role to perform a high-speed strike on the closest player in aim direction. + /// The strike teleports the user toward the target and executes a stealthy instant kill. + /// + public class StrikeButton : CustomActionButton + { + /// + /// Display name for the button (not shown by default). + /// + public override string Name => "Strike"; + + /// + /// Cooldown between strikes, pulled from . + /// + public override float Cooldown => OptionGroupSingleton.Instance.StrikeCooldown; + + /// + /// Maximum number of strike uses, from . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxStrikeUses; + + /// + /// Effect duration (not used for this button). + /// + public override float EffectDuration => 0f; + + /// + /// Placement of the button on the HUD. + /// + public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// Default keybind for Pulseblade's Strike ability. + /// Requires Shift as a modifier to prevent accidental use. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.G; + public override ModifierKey Modifier1 => ModifierKey.Shift; + /// + /// Sprite used for the button — set to empty; + /// + public override LoadableAsset Sprite => NewModAsset.StrikeButton; + + /// + /// Determines whether the button is active for a given role. + /// + /// The current player's role. + /// True only for Pulseblade role. + public override bool Enabled(RoleBehaviour role) => role is Pb; + + /// + /// Called when the button is pressed by the player. + /// Searches for a valid target and executes the strike. + /// + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + + var target = PlayerControl.AllPlayerControls + .ToArray() + .Where(p => p != player && !p.Data.IsDead && !p.Data.Disconnected && !p.inVent) + .OrderBy(p => Vector2.Distance(player.GetTruePosition(), p.GetTruePosition())) + .FirstOrDefault(p => Vector2.Distance(player.GetTruePosition(), p.GetTruePosition()) <= OptionGroupSingleton.Instance.StrikeRange); + + RpcPulseStrike(player, target); + } + + /// + /// RPC method to perform a Pulseblade strike on a target. + /// + /// The player performing the strike. + /// The victim of the strike. + [MethodRpc((uint)CustomRPC.Dash)] + public static void RpcPulseStrike(PlayerControl source, PlayerControl target) + { + Coroutines.Start(DoPulseStrike(source, target)); + } + + /// + /// Executes the strike: dashes to the target and performs a kill. + /// Hides the body for a short time. + /// + /// The Pulseblade player. + /// The struck victim. + /// IEnumerator. + public static IEnumerator DoPulseStrike(PlayerControl killer, PlayerControl target) + { + var sound = NewModAsset.StrikeSound.LoadAsset(); + float originalSpeed = killer.MyPhysics.Speed; + float dashSpeed = OptionGroupSingleton.Instance.DashSpeed; + + killer.moveable = false; + killer.MyPhysics.inputHandler.enabled = false; + killer.MyPhysics.Speed = dashSpeed; + + while (Vector2.Distance(killer.GetTruePosition(), target.GetTruePosition()) > 0.1f) + { + Vector2 dir = target.GetTruePosition() - killer.GetTruePosition(); + killer.MyPhysics.SetNormalizedVelocity(dir.normalized); + + float step = killer.MyPhysics.TrueSpeed * Time.fixedDeltaTime; + if (step >= dir.magnitude) break; + + yield return new WaitForFixedUpdate(); + } + + killer.MyPhysics.SetNormalizedVelocity(Vector2.zero); + killer.MyPhysics.Speed = originalSpeed; + killer.MyPhysics.inputHandler.enabled = true; + killer.moveable = true; + + SoundManager.Instance.PlaySound(sound, false, 1f); + + killer.RpcCustomMurder( + target, + didSucceed: true, + resetKillTimer: false, + createDeadBody: true, + teleportMurderer: false, + showKillAnim: false, + playKillSound: false + ); + + Utils.RegisterStrikeKill(killer, target); + + var notif = Helpers.CreateAndShowNotification($"Perfect kill {target.Data.PlayerName} eliminated", new(1f, 0.25f, 0.25f), spr: NewModAsset.StrikeIcon.LoadAsset()); + notif.Text.SetOutlineThickness(0.30f); + + var bodies = Helpers.GetNearestDeadBodies(target.GetTruePosition(), 0.5f, Helpers.CreateFilter(Constants.NotShipMask)); + if (bodies != null && bodies.Count > 0) + { + foreach (var b in bodies) if (b) b.gameObject.SetActive(false); + yield return new WaitForSeconds(OptionGroupSingleton.Instance.HideBodyDuration); + foreach (var b in bodies) if (b) b.gameObject.SetActive(true); + } + } + } +} diff --git a/NewMod/Buttons/Revenant/DoomAwakening.cs b/NewMod/Buttons/Revenant/DoomAwakening.cs index fedbb5b..e3d50aa 100644 --- a/NewMod/Buttons/Revenant/DoomAwakening.cs +++ b/NewMod/Buttons/Revenant/DoomAwakening.cs @@ -9,6 +9,8 @@ using NewMod.Utilities; using Reactor.Utilities; using UnityEngine; +using TMPro; +using Rewired; namespace NewMod.Buttons.Revenant { @@ -37,6 +39,12 @@ public class DoomAwakening : CustomActionButton /// public override ButtonLocation Location => ButtonLocation.BottomLeft; + /// + /// Default keybind for Doom's Awakening ability. + /// Requires Alt as a modifier to prevent accidental use. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.B; + public override ModifierKey Modifier1 => ModifierKey.Alt; /// /// Determines how long the effect lasts. Configured in . /// @@ -191,7 +199,9 @@ public System.Collections.IEnumerator StartDoomAwakening(PlayerControl player) SoundManager.Instance.StopSound(clip); RV.StalkingStates.Remove(player.PlayerId); Coroutines.Start(CoroutinesHelper.CoNotify("Doom Awakening ended.")); - Helpers.CreateAndShowNotification($"Doom Awakening killed {killCount} players", Color.red, null, null); + + var doomNotif = Helpers.CreateAndShowNotification($"Doom Awakening killed {killCount} players", Color.red, null, null); + doomNotif.Text.SetOutlineThickness(0.36f); killedPlayers.Clear(); } } diff --git a/NewMod/Buttons/Revenant/FeignDeathButton.cs b/NewMod/Buttons/Revenant/FeignDeathButton.cs index af97c93..ba58f1f 100644 --- a/NewMod/Buttons/Revenant/FeignDeathButton.cs +++ b/NewMod/Buttons/Revenant/FeignDeathButton.cs @@ -6,6 +6,7 @@ using NewMod.Utilities; using Reactor.Utilities; using UnityEngine; +using Rewired; namespace NewMod.Buttons.Revenant { @@ -34,6 +35,13 @@ public class FeignDeathButton : CustomActionButton /// public override ButtonLocation Location => ButtonLocation.BottomRight; + /// + /// Default keybind for Revenant's Feign Death ability. + /// Requires Ctrl as a modifier to prevent accidental use. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.T; + public override ModifierKey Modifier1 => ModifierKey.Control; + /// /// The duration of any effect from this button. In this case, zero. /// diff --git a/NewMod/Buttons/SpecialAgent/AssignButton.cs b/NewMod/Buttons/SpecialAgent/AssignButton.cs index 6302150..06bb3cd 100644 --- a/NewMod/Buttons/SpecialAgent/AssignButton.cs +++ b/NewMod/Buttons/SpecialAgent/AssignButton.cs @@ -7,6 +7,7 @@ using UnityEngine; using NewMod.Utilities; using Reactor.Utilities; +using Rewired; namespace NewMod.Buttons.SpecialAgent { @@ -35,6 +36,11 @@ public class AssignButton : CustomActionButton /// public override ButtonLocation Location => ButtonLocation.BottomLeft; + /// + /// Default keybind for Special Agent's Assign ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.H; + /// /// The duration of any effect triggered by this button; here, it's zero. /// @@ -69,18 +75,20 @@ public override bool CanUse() /// protected override void OnClick() { + NewMod.Instance.Log.LogError("Special Agent assign menu open..."); CustomPlayerMenu menu = CustomPlayerMenu.Create(); SetTimerPaused(true); menu.Begin( - player => !player.Data.IsDead && - !player.Data.Disconnected && + player => !player.Data.IsDead && + !player.Data.Disconnected && player.PlayerId != PlayerControl.LocalPlayer.PlayerId, player => { SA.AssignedPlayer = player; Utils.RpcAssignMission(PlayerControl.LocalPlayer, SA.AssignedPlayer); + NewMod.Instance.Log.LogError($"Assigning target: {SA.AssignedPlayer.Data.PlayerName}"); if (OptionGroupSingleton.Instance.TargetCameraTracking) { diff --git a/NewMod/Buttons/Visionary/CaptureButton.cs b/NewMod/Buttons/Visionary/CaptureButton.cs index a721073..9972408 100644 --- a/NewMod/Buttons/Visionary/CaptureButton.cs +++ b/NewMod/Buttons/Visionary/CaptureButton.cs @@ -7,6 +7,7 @@ using UnityEngine; using Reactor.Utilities; using NewMod.Utilities; +using Rewired; namespace NewMod.Buttons.Visionary { @@ -44,6 +45,10 @@ public class CaptureButton : CustomActionButton /// The location on-screen where this button appears. /// public override ButtonLocation Location => ButtonLocation.BottomLeft; + /// + /// Default keybind for Visionary's Capture ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.N; /// /// Handles the button click, capturing a screenshot and saving it to a unique path. diff --git a/NewMod/Buttons/Visionary/ShowScreenshotButton.cs b/NewMod/Buttons/Visionary/ShowScreenshotButton.cs index 8e7ce90..3d1222f 100644 --- a/NewMod/Buttons/Visionary/ShowScreenshotButton.cs +++ b/NewMod/Buttons/Visionary/ShowScreenshotButton.cs @@ -7,6 +7,8 @@ using NewMod.Utilities; using Reactor.Utilities; using System.Linq; +using Rewired; +using System.IO; namespace NewMod.Buttons.Visionary { @@ -45,13 +47,18 @@ public class ShowScreenshotButton : CustomActionButton /// public override ButtonLocation Location => ButtonLocation.BottomRight; + /// + /// Default keybind for Visionary's Show ability. + /// + public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.M; + /// /// Checks if the button can be used, ensuring there's at least one captured screenshot. /// /// True if base conditions are met and there is a screenshot, otherwise false. public override bool CanUse() { - return base.CanUse() && VisionaryUtilities.CapturedScreenshotPaths.Any(); + return base.CanUse() && Directory.GetFiles(VisionaryUtilities.ScreenshotDirectory, "screenshot_*.png").Any(); } /// diff --git a/NewMod/Components/FearPulseArea.cs b/NewMod/Components/FearPulseArea.cs new file mode 100644 index 0000000..989b31f --- /dev/null +++ b/NewMod/Components/FearPulseArea.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Utilities; +using NewMod.Utilities; +using Reactor.Utilities; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components +{ + [RegisterInIl2Cpp] + public class FearPulseArea(IntPtr ptr) : MonoBehaviour(ptr) + { + public byte ownerId; + float _radius, _duration, _speedMul, _t; + readonly Dictionary _origSpeed = new(); + readonly HashSet _insideNow = new(); + public static readonly HashSet AffectedPlayers = new(); + public static readonly HashSet _speedNotifShown = new(); + public static readonly HashSet _visionNotifShown = new(); + public AudioClip _enterClip; + public AudioClip _heartbeatClip; + public bool _pulsingHb; + + public void Init(byte ownerId, float radius, float duration, float speedMul) + { + this.ownerId = ownerId; + _radius = radius; + _duration = duration; + _speedMul = Mathf.Max(0f, 1f - (speedMul / 100f)); + _enterClip = NewModAsset.FearSound.LoadAsset(); + _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + } + + public void Update() + { + _t += Time.deltaTime; + if (_t > _duration) + { + RestoreAll(); + Destroy(gameObject); + return; + } + + var center = (Vector2)transform.position; + + var nearby = PlayerControl.AllPlayerControls + .ToArray() + .Where(p => !p.Data.IsDead && !p.Data.Disconnected && p.PlayerId != ownerId) + .Where(p => Vector2.Distance(p.GetTruePosition(), center) <= _radius) + .ToList(); + + _insideNow.Clear(); + + foreach (var p in nearby) + { + _insideNow.Add(p.PlayerId); + + if (!_origSpeed.ContainsKey(p.PlayerId)) + { + _origSpeed[p.PlayerId] = p.MyPhysics.Speed; + p.MyPhysics.Speed = _origSpeed[p.PlayerId] * _speedMul; + + if (p.AmOwner && !_speedNotifShown.Contains(p.PlayerId)) + { + _speedNotifShown.Add(p.PlayerId); + var notif = Helpers.CreateAndShowNotification( + "You have entered the Fear Pulse Area. Your speed is reduced!", + Color.red, + spr: NewModAsset.SpeedDebuff.LoadAsset() + ); + notif.Text.SetOutlineThickness(0.36f); + } + } + + if (p.AmOwner) + { + if (!AffectedPlayers.Contains(p.PlayerId)) + { + AffectedPlayers.Add(p.PlayerId); + p.lightSource.lightChild.SetActive(false); + } + + if (!p.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_enterClip)) + SoundManager.Instance.PlaySound(_enterClip, false, 1f); + + if (!_visionNotifShown.Contains(p.PlayerId)) + { + _visionNotifShown.Add(p.PlayerId); + var notif = Helpers.CreateAndShowNotification( + "You have entered the Fear Pulse Area. Your vision is reduced!", + new Color(1f, 0.8f, 0.2f), + spr: NewModAsset.VisionDebuff.LoadAsset() + ); + notif.Text.SetOutlineThickness(0.36f); + } + Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); + } + + if (p.MyPhysics.Velocity.sqrMagnitude > 0.0001f) + { + _pulsingHb = true; + if (!p.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) + SoundManager.Instance.PlaySound(_heartbeatClip, false, 1f); + _pulsingHb = false; + } + } + + if (_origSpeed.Count > 0) + { + var toRestore = _origSpeed.Keys.Where(id => !_insideNow.Contains(id)).ToList(); + foreach (var id in toRestore) + { + var p = Utils.PlayerById(id); + if (p) p.MyPhysics.Speed = _origSpeed[id]; + _origSpeed.Remove(id); + + if (p.AmOwner) + { + AffectedPlayers.Remove(p.PlayerId); + p.lightSource.lightChild.SetActive(true); + _speedNotifShown.Remove(p.PlayerId); + _visionNotifShown.Remove(p.PlayerId); + Helpers.CreateAndShowNotification("Your vision is restored.", new Color(0.8f, 1f, 0.8f)); + } + } + } + } + public void RestoreAll() + { + foreach (var kv in _origSpeed) + { + var p = Utils.PlayerById(kv.Key); + if (p) p.MyPhysics.Speed = kv.Value; + } + _origSpeed.Clear(); + AffectedPlayers.Clear(); + + var lp = PlayerControl.LocalPlayer; + + if (AffectedPlayers.Contains(lp.PlayerId)) + AffectedPlayers.Remove(lp.PlayerId); + + lp.lightSource.lightChild.SetActive(true); + + _speedNotifShown.Remove(lp.PlayerId); + _visionNotifShown.Remove(lp.PlayerId); + } + } +} diff --git a/NewMod/Components/SupressionDomeArea.cs b/NewMod/Components/SupressionDomeArea.cs new file mode 100644 index 0000000..1bf2037 --- /dev/null +++ b/NewMod/Components/SupressionDomeArea.cs @@ -0,0 +1,74 @@ +using System; +using MiraAPI.Utilities; +using NewMod.Utilities; +using Reactor.Utilities; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components +{ + [RegisterInIl2Cpp] + public sealed class SuppressionDomeArea(IntPtr ptr) : MonoBehaviour(ptr) + { + public byte _ownerId; + public float _radius, _duration, _t; + public bool _inside; + public AudioClip _enterClip; + public AudioClip _heartbeatClip; + + public void Init(byte ownerId, float radius, float duration) + { + _ownerId = ownerId; + _radius = radius; + _duration = duration; + _inside = false; + _enterClip = NewModAsset.FearSound.LoadAsset(); + _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + } + + public void Update() + { + _t += Time.deltaTime; + if (_t > _duration) + { + Destroy(gameObject); + return; + } + var lp = PlayerControl.LocalPlayer; + if (!lp || lp.Data == null) return; + if (lp.PlayerId == _ownerId) return; + + bool nowInside = Vector2.Distance(lp.GetTruePosition(), (Vector2)transform.position) <= _radius; + if (nowInside == _inside) return; + + _inside = nowInside; + var hud = HudManager.Instance; + hud.SetHudActive(lp, lp.Data.Role, !_inside); + + if (_inside) + { + Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); + + if (!lp.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_enterClip)) + SoundManager.Instance.PlaySound(_enterClip, false, 1f); + + var notif = Helpers.CreateAndShowNotification( + "You’ve stepped into the Suppression Dome! All your abilities are LOCKED", + Color.red + ); + notif.Text.SetOutlineThickness(0.36f); + + if (lp.MyPhysics.Velocity.sqrMagnitude > 0.0001f && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) + SoundManager.Instance.PlaySound(_heartbeatClip, false, 1f); + } + } + public void OnDestroy() + { + if (!_inside) return; + var lp = PlayerControl.LocalPlayer; + if (!lp || lp.Data == null) return; + HudManager.Instance.SetHudActive(lp, lp.Data.Role, true); + _inside = false; + } + } +} diff --git a/NewMod/Components/WitnessTrapArea.cs b/NewMod/Components/WitnessTrapArea.cs new file mode 100644 index 0000000..71b3f05 --- /dev/null +++ b/NewMod/Components/WitnessTrapArea.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections; +using MiraAPI.Utilities; +using NewMod.Utilities; +using Reactor.Utilities; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components +{ + [RegisterInIl2Cpp] + public class WitnessTrapArea(IntPtr ptr) : MonoBehaviour(ptr) + { + public byte _ownerId; + public float _radius; + public float _freezeSeconds; + public static float _duration; + public bool _consumed; + public AudioClip _enterClip; + public AudioClip _heartbeatClip; + public void Init(byte ownerId, float radius, float freeze, float duration) + { + _ownerId = ownerId; + _radius = radius; + _freezeSeconds = freeze; + _duration = duration; + _enterClip = NewModAsset.FearSound.LoadAsset(); + _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + } + + public void Update() + { + if (_consumed) + { + _duration -= Time.deltaTime; + if (_duration <= 0f) Destroy(gameObject); + return; + } + + var center = (Vector2)transform.position; + + foreach (var p in PlayerControl.AllPlayerControls) + { + if (!p || p.PlayerId == _ownerId || p.Data == null || p.Data.IsDead || p.Data.Disconnected) continue; + + if (Vector2.Distance(p.GetTruePosition(), center) <= _radius) + { + _consumed = true; + _duration = _freezeSeconds + 0.1f; + + if (p.AmOwner) + { + if (Constants.ShouldPlaySfx() && _enterClip && !SoundManager.Instance.SoundIsPlaying(_enterClip)) + SoundManager.Instance.PlaySound(_enterClip, false, 1f); + + if (p.MyPhysics.Velocity.sqrMagnitude > 0.0001f && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) + SoundManager.Instance.PlaySound(_heartbeatClip, false, 1f); + + Coroutines.Start(Freeze(p, _freezeSeconds)); + } + break; + } + } + + _duration -= Time.deltaTime; + if (_duration <= 0f) Destroy(gameObject); + } + + public static IEnumerator Freeze(PlayerControl p, float seconds) + { + p.MyPhysics.inputHandler.enabled = false; + p.moveable = false; + + if (p.AmOwner) + { + Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); + var notif = Helpers.CreateAndShowNotification("You have entered the Intimation Protocol Area. You are frozen!", Color.cyan, spr: NewModAsset.Freeze.LoadAsset()); + notif.Text.SetOutlineThickness(0.36f); + } + + var t = 0f; + while (t < seconds) + { + t += Time.deltaTime; + yield return null; + } + + if (p) + { + p.moveable = true; + p.MyPhysics.inputHandler.enabled = true; + } + } + } +} diff --git a/NewMod/CustomColor/NewModColor.cs b/NewMod/CustomColor/NewModColor.cs index 28b35be..9dd6571 100644 --- a/NewMod/CustomColor/NewModColor.cs +++ b/NewMod/CustomColor/NewModColor.cs @@ -7,14 +7,14 @@ namespace NewMod.Colors public static class NewModColors { // NewMod v1.0.0 - public static CustomColor OceanColor {get;} = new CustomColor("OceaBlue", new Color32(0, 105, 148, 255), new Color32(0, 73, 103, 255)); - public static CustomColor Gold {get;} = new CustomColor("Gold", new Color(1.0f, 0.84f, 0.0f)); // Thanks to : https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/ExampleColors.cs#L13 + public static CustomColor OceanColor { get; } = new CustomColor("OceaBlue", new Color32(0, 105, 148, 255), new Color32(0, 73, 103, 255)); + public static CustomColor Gold { get; } = new CustomColor("Gold", new Color(1.0f, 0.84f, 0.0f)); // Thanks to : https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/ExampleColors.cs#L13 public static CustomColor BloodRed { get; } = new CustomColor("BloodRed", new Color32(138, 3, 3, 255), new Color32(104, 2, 2, 255)); public static CustomColor CrimsonTide { get; } = new CustomColor("CrimsonTide", new Color32(220, 20, 60, 255), new Color32(176, 16, 48, 255)); - public static CustomColor MidnightBlue {get;} = new CustomColor("MidNight",new Color32(25, 25, 112, 255), new Color32(15, 15, 80, 255)); - public static CustomColor NeonGreen {get;} = new CustomColor("NeonGreen", new Color32(57, 255, 20, 255), new Color32(34, 139, 34, 255)); - public static CustomColor ElectricPurple {get;} = new CustomColor("ElectricPurple", new Color32(191, 0, 255, 255), new Color32(128, 0, 170, 255)); - public static CustomColor PastelPink {get;} = new CustomColor("PastelPink", new Color32(255, 182, 193, 255), new Color32(255, 105, 180, 255)); + public static CustomColor MidnightBlue { get; } = new CustomColor("MidNight", new Color32(25, 25, 112, 255), new Color32(15, 15, 80, 255)); + public static CustomColor NeonGreen { get; } = new CustomColor("NeonGreen", new Color32(57, 255, 20, 255), new Color32(34, 139, 34, 255)); + public static CustomColor ElectricPurple { get; } = new CustomColor("ElectricPurple", new Color32(191, 0, 255, 255), new Color32(128, 0, 170, 255)); + public static CustomColor PastelPink { get; } = new CustomColor("PastelPink", new Color32(255, 182, 193, 255), new Color32(255, 105, 180, 255)); public static CustomColor JadeGreen { get; } = new CustomColor("JadeGreen", new Color32(0, 168, 107, 255), new Color32(0, 134, 85, 255)); public static CustomColor CobaltBlue { get; } = new CustomColor("CobaltBlue", new Color32(0, 71, 171, 255), new Color32(0, 57, 137, 255)); public static CustomColor BurntSienna { get; } = new CustomColor("BurntSienna", new Color32(233, 116, 81, 255), new Color32(187, 93, 65, 255)); @@ -22,7 +22,7 @@ public static class NewModColors public static CustomColor VelvetMaroon { get; } = new CustomColor("VelvetMaroon", new Color32(128, 0, 0, 255), new Color32(105, 0, 0, 255)); public static CustomColor DesertRose { get; } = new CustomColor("DesertRose", new Color32(201, 76, 76, 255), new Color32(175, 60, 60, 255)); public static CustomColor AtomicTangerine { get; } = new CustomColor("AtomicTangerine", new Color32(255, 153, 102, 255), new Color32(230, 140, 95, 255)); - public static CustomColor Olive {get;} = new CustomColor("Olive", new Color32(128, 128, 0, 255)); + public static CustomColor Olive { get; } = new CustomColor("Olive", new Color32(128, 128, 0, 255)); // NewMod v1.1.0 public static CustomColor SkyBlue { get; } = new CustomColor("SkyBlue", new Color32(135, 206, 235, 255), new Color32(70, 130, 180, 255)); @@ -41,5 +41,22 @@ public static class NewModColors public static CustomColor Emerald { get; } = new CustomColor("Emerald", new Color32(80, 200, 120, 255), new Color32(0, 201, 87, 255)); public static CustomColor Fuchsia { get; } = new CustomColor("Fuchsia", new Color32(255, 119, 255, 255), new Color32(255, 0, 255, 255)); public static CustomColor NavyBlue { get; } = new CustomColor("NavyBlue", new Color32(0, 0, 128, 255), new Color32(0, 0, 102, 255)); + + // NewMod v1.2.4 + public static CustomColor CyberPink { get; } = new CustomColor("CyberPink", new Color32(255, 20, 147, 255), new Color32(199, 0, 110, 255)); + public static CustomColor PlasmaBlue { get; } = new CustomColor("PlasmaBlue", new Color32(0, 191, 255, 255), new Color32(0, 128, 192, 255)); + public static CustomColor RadiantOrange { get; } = new CustomColor("RadiantOrange", new Color32(255, 94, 19, 255), new Color32(204, 75, 15, 255)); + public static CustomColor ToxicLime { get; } = new CustomColor("ToxicLime", new Color32(173, 255, 47, 255), new Color32(139, 204, 38, 255)); + public static CustomColor VoidBlack { get; } = new CustomColor("VoidBlack", new Color32(10, 10, 10, 255), new Color32(0, 0, 0, 255)); + public static CustomColor SolarFlare { get; } = new CustomColor("SolarFlare", new Color32(255, 140, 0, 255), new Color32(204, 112, 0, 255)); + public static CustomColor ArcticWhite { get; } = new CustomColor("ArcticWhite", new Color32(245, 245, 245, 255), new Color32(220, 220, 220, 255)); + public static CustomColor MysticPurple { get; } = new CustomColor("MysticPurple", new Color32(147, 112, 219, 255), new Color32(118, 90, 175, 255)); + public static CustomColor InfernoRed { get; } = new CustomColor("InfernoRed", new Color32(255, 48, 48, 255), new Color32(204, 38, 38, 255)); + public static CustomColor AquaWave { get; } = new CustomColor("AquaWave", new Color32(64, 224, 208, 255), new Color32(54, 189, 176, 255)); + public static CustomColor RoseGold { get; } = new CustomColor("RoseGold", new Color32(183, 110, 121, 255), new Color32(150, 90, 100, 255)); + public static CustomColor StealthGray { get; } = new CustomColor("StealthGray", new Color32(84, 88, 94, 255), new Color32(64, 66, 70, 255)); + public static CustomColor NeonYellow { get; } = new CustomColor("NeonYellow", new Color32(255, 255, 0, 255), new Color32(204, 204, 0, 255)); + public static CustomColor EmberOrange { get; } = new CustomColor("EmberOrange", new Color32(255, 97, 0, 255), new Color32(204, 77, 0, 255)); + public static CustomColor DeepSeaTeal { get; } = new CustomColor("DeepSeaTeal", new Color32(0, 128, 128, 255), new Color32(0, 102, 102, 255)); } } diff --git a/NewMod/CustomRPC.cs b/NewMod/CustomRPC.cs index 66d7db3..45aed72 100644 --- a/NewMod/CustomRPC.cs +++ b/NewMod/CustomRPC.cs @@ -8,5 +8,10 @@ public enum CustomRPC AssignMission, MissionSuccess, MissionFails, - ApplySerum + ApplySerum, + Dash, + FearPulse, + SuppressionDome, + WitnessTrap, + NotifyChampion } \ No newline at end of file diff --git a/NewMod/DiscordStatus.cs b/NewMod/DiscordStatus.cs index 41d96c4..672a735 100644 --- a/NewMod/DiscordStatus.cs +++ b/NewMod/DiscordStatus.cs @@ -1,48 +1,83 @@ -using HarmonyLib; +// Inspired by: https://github.com/All-Of-Us-Mods/LaunchpadReloaded/blob/master/LaunchpadReloaded/Patches/Generic/DiscordManagerPatch.cs#L12 +using System; using Discord; +using HarmonyLib; using MiraAPI; using UnityEngine; +using UnityEngine.SceneManagement; -namespace NewMod +namespace NewMod.Patches { - [HarmonyPatch(typeof(ActivityManager), nameof(ActivityManager.UpdateActivity))] - public static class DiscordPlayStatusPatch + [HarmonyPatch] + public static class NewModDiscordPatch { - public static void Postfix([HarmonyArgument(0)] Activity activity) + private static Discord.Discord discord; + public static ActivityManager activityManager; + + [HarmonyPrefix] + [HarmonyPatch(typeof(DiscordManager), nameof(DiscordManager.Start))] + public static bool StartPrefix(DiscordManager __instance) + { +#if ANDROID + return true; +#else + const long clientId = 1405946628115791933; + + discord = new Discord.Discord(clientId, (ulong)CreateFlags.Default); + activityManager = discord.GetActivityManager(); + + activityManager.RegisterSteam(945360U); + activityManager.add_OnActivityJoin((Action)__instance.HandleJoinRequest); + + SceneManager.add_sceneLoaded((Action)((scene, _) => + { + __instance.OnSceneChange(scene.name); + })); + __instance.presence = discord; + __instance.SetInMenus(); + + return false; +#endif + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(ActivityManager), nameof(ActivityManager.UpdateActivity))] + public static void UpdateActivityPrefix([HarmonyArgument(0)] ref Activity activity) { if (activity == null) return; - - var isBeta = true; - string details = $"NewMod v{NewMod.ModVersion}" + (isBeta ? " (Beta)" : "(dev)"); + var isBeta = true; + string details = $"NewMod v{NewMod.ModVersion}" + (isBeta ? " (Beta)" : " (Dev)"); activity.Details = details; + activity.State = $"Playing Among Us | NewMod v{NewMod.ModVersion}"; + activity.Assets = new ActivityAssets() + { + LargeImage = "newmodlogov1_2_0", + SmallImage = "nm", + SmallText = "Made with MiraAPI" + }; - try + try { - if (activity.State == "In Menus") + if (activity.State.Contains("Menus")) { - int maxPlayers = GameOptionsManager.Instance.currentNormalGameOptions.MaxPlayers; - var lobbyCode = GameStartManager.Instance.GameRoomNameCode.text; - var miraAPIVersion = MiraApiPlugin.Version; + int maxPlayers = GameOptionsManager.Instance?.currentNormalGameOptions?.MaxPlayers ?? 10; + var lobbyCode = GameStartManager.Instance?.GameRoomNameCode?.text; + var miraVersion = MiraApiPlugin.Version; var platform = Application.platform; - details += $" Players: {maxPlayers} | Lobby Code: {lobbyCode} | MiraAPI Version {miraAPIVersion} | Platform: {platform}"; + activity.Details += $" | Lobby: {lobbyCode} | Max: {maxPlayers} | MiraAPI: {miraVersion} | {platform}"; } - else if (activity.State == "In Game") + if (MeetingHud.Instance) { - if (MeetingHud.Instance) - { - details += " | \nIn Meeting"; - } + activity.Details += " | In Meeting"; } - - activity.Assets.SmallText = "NewMod Made With MiraAPI"; } - catch (System.Exception e) + catch (Exception e) { - NewMod.Instance.Log.LogError($"Error updating Discord activity: {e.Message}\nStackTrace: {e.StackTrace}"); + NewMod.Instance.Log.LogError($"Discord RPC activity update failed: {e.Message}\n{e.StackTrace}"); } } } diff --git a/NewMod/Extensions/RoleExtensions.cs b/NewMod/Extensions/RoleExtensions.cs new file mode 100644 index 0000000..698cc9d --- /dev/null +++ b/NewMod/Extensions/RoleExtensions.cs @@ -0,0 +1,10 @@ +using MiraAPI.Roles; +using NewMod.Roles; + +namespace NewMod.Extensions +{ + public static class RoleExtensions + { + public static bool IsNewModRoleFaction(this ICustomRole role) => role is INewModRole; + } +} \ No newline at end of file diff --git a/NewMod/NewMod.cs b/NewMod/NewMod.cs index fe16d72..46aa10a 100644 --- a/NewMod/NewMod.cs +++ b/NewMod/NewMod.cs @@ -37,7 +37,7 @@ namespace NewMod; public partial class NewMod : BasePlugin, IMiraPlugin { public const string Id = "com.callofcreator.newmod"; - public const string ModVersion = "1.2.1"; + public const string ModVersion = "1.2.4"; public Harmony Harmony { get; } = new Harmony(Id); public static BasePlugin Instance; public static Minigame minigame; diff --git a/NewMod/NewMod.csproj b/NewMod/NewMod.csproj index 7c8caf9..bf3c6b2 100644 --- a/NewMod/NewMod.csproj +++ b/NewMod/NewMod.csproj @@ -21,7 +21,6 @@ - @@ -32,4 +31,10 @@ + + + + ..\libs\MiraAPI.dll + + diff --git a/NewMod/NewModAsset.cs b/NewMod/NewModAsset.cs index 7753ca5..2332d76 100644 --- a/NewMod/NewModAsset.cs +++ b/NewMod/NewModAsset.cs @@ -4,24 +4,39 @@ namespace NewMod; public static class NewModAsset { - // Miscellaneous - public static LoadableResourceAsset Banner { get; } = new("NewMod.Resources.optionImage.png"); - public static LoadableResourceAsset Arrow { get; } = new("NewMod.Resources.Arrow.png"); - public static LoadableResourceAsset ModLogo { get; } = new("NewMod.Resources.Logo.png"); + // Miscellaneous + public static LoadableResourceAsset Banner { get; } = new("NewMod.Resources.optionImage.png"); + public static LoadableResourceAsset Arrow { get; } = new("NewMod.Resources.Arrow.png"); + public static LoadableResourceAsset ModLogo { get; } = new("NewMod.Resources.Logo.png"); - // Button icons - public static LoadableResourceAsset SpecialAgentButton { get; } = new("NewMod.Resources.givemission.png"); - public static LoadableResourceAsset ShowScreenshotButton { get; } = new("NewMod.Resources.showscreenshot.png"); - public static LoadableResourceAsset DoomAwakeningButton { get; } = new("NewMod.Resources.doomawakening.png"); - public static LoadableResourceAsset NecromancerButton { get; } = new("NewMod.Resources.Revive2.png"); - public static LoadableResourceAsset DeadBodySprite { get; } = new("NewMod.Resources.deadbody.png"); - public static LoadableResourceAsset Camera { get; } = new("NewMod.Resources.cam.png"); + // Button icons + public static LoadableResourceAsset SpecialAgentButton { get; } = new("NewMod.Resources.givemission.png"); + public static LoadableResourceAsset ShowScreenshotButton { get; } = new("NewMod.Resources.showscreenshot.png"); + public static LoadableResourceAsset DoomAwakeningButton { get; } = new("NewMod.Resources.doomawakening.png"); + public static LoadableResourceAsset NecromancerButton { get; } = new("NewMod.Resources.Revive2.png"); + public static LoadableResourceAsset InjectButton { get; } = new("NewMod.Resources.inject.png"); + public static LoadableResourceAsset DeadBodySprite { get; } = new("NewMod.Resources.deadbody.png"); + public static LoadableResourceAsset Camera { get; } = new("NewMod.Resources.cam.png"); + public static LoadableResourceAsset StrikeButton { get; } = new("NewMod.Resources.Strike.png"); - // SFX - public static LoadableAudioResourceAsset ReviveSound { get; } = new("NewMod.Resources.Sounds.revive.wav"); - public static LoadableAudioResourceAsset DoomAwakeningSound { get; } = new("NewMod.Resources.Sounds.gloomy_aura.wav"); - public static LoadableAudioResourceAsset DoomAwakeningEndSound { get; } = new("NewMod.Resources.Sounds.evil_laugh.wav"); - public static LoadableAudioResourceAsset DrainSound { get; } = new("NewMod.Resources.Sounds.drain_sound.wav"); - public static LoadableAudioResourceAsset FeignDeathSound { get; } = new("NewMod.Resources.Sounds.feign_death.wav"); - public static LoadableAudioResourceAsset VisionarySound { get; } = new("NewMod.Resources.Sounds.visionary_sound.wav"); + // SFX + public static LoadableAudioResourceAsset ReviveSound { get; } = new("NewMod.Resources.Sounds.revive.wav"); + public static LoadableAudioResourceAsset DoomAwakeningSound { get; } = new("NewMod.Resources.Sounds.gloomy_aura.wav"); + public static LoadableAudioResourceAsset DoomAwakeningEndSound { get; } = new("NewMod.Resources.Sounds.evil_laugh.wav"); + public static LoadableAudioResourceAsset DrainSound { get; } = new("NewMod.Resources.Sounds.drain_sound.wav"); + public static LoadableAudioResourceAsset FeignDeathSound { get; } = new("NewMod.Resources.Sounds.feign_death.wav"); + public static LoadableAudioResourceAsset VisionarySound { get; } = new("NewMod.Resources.Sounds.visionary_sound.wav"); + public static LoadableAudioResourceAsset StrikeSound { get; } = new("NewMod.Resources.Sounds.strike_sound.wav"); + public static LoadableAudioResourceAsset FearSound { get; } = new("NewMod.Resources.Sounds.fear_sound.wav"); + public static LoadableAudioResourceAsset HeartbeatSound { get; } = new("NewMod.Resources.Sounds.heartbeat_sound.wav"); + + // Role Icons + public static LoadableResourceAsset StrikeIcon { get; } = new("NewMod.Resources.RoleIcons.StrikeIcon.png"); + public static LoadableResourceAsset InjectIcon { get; } = new("NewMod.Resources.RoleIcons.InjectIcon.png"); + public static LoadableResourceAsset CrownIcon { get; } = new("NewMod.Resources.RoleIcons.crown.png"); + + // Notif Icons + public static LoadableResourceAsset VisionDebuff { get; } = new("NewMod.Resources.NotifIcons.vision_debuff.png"); + public static LoadableResourceAsset SpeedDebuff { get; } = new("NewMod.Resources.NotifIcons.speed_debuff.png"); + public static LoadableResourceAsset Freeze { get; } = new("NewMod.Resources.NotifIcons.freeze.png"); } \ No newline at end of file diff --git a/NewMod/NewModDateTime.cs b/NewMod/NewModDateTime.cs new file mode 100644 index 0000000..618f2b2 --- /dev/null +++ b/NewMod/NewModDateTime.cs @@ -0,0 +1,14 @@ +using System; + +namespace NewMod; +public static class NewModDateTime +{ + public static DateTime NewModBirthday + { + get + { + var thisYear = new DateTime(DateTime.Now.Year, 8, 28); + return DateTime.Now <= thisYear ? thisYear : new DateTime(DateTime.Now.Year + 1, 8, 28); + } + } +} diff --git a/NewMod/NewModEndReasons.cs b/NewMod/NewModEndReasons.cs index ed23a21..ac39b71 100644 --- a/NewMod/NewModEndReasons.cs +++ b/NewMod/NewModEndReasons.cs @@ -2,13 +2,15 @@ namespace NewMod { public enum NewModEndReasons { - EnergyThiefWin = 100, + EnergyThiefWin = 110, DoubleAgentWin = 111, PranksterWin = 112, SpecialAgentWin = 113, TheVisionaryWin = 114, OverloadWin = 115, EgoistWin = 116, - InjectorWin = 117 + InjectorWin = 117, + PulseBladeWin = 118, + TyrantWin = 119 } } \ No newline at end of file diff --git a/NewMod/NewModEventHandler.cs b/NewMod/NewModEventHandler.cs index 6d64da1..507c4d6 100644 --- a/NewMod/NewModEventHandler.cs +++ b/NewMod/NewModEventHandler.cs @@ -1,8 +1,11 @@ using System.Collections.Generic; using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; using MiraAPI.Events.Vanilla.Usables; using NewMod.Patches; using NewMod.Patches.Roles.Visionary; +using NewMod.Roles.ImpostorRoles; namespace NewMod { @@ -15,7 +18,10 @@ public static void RegisterEventsLogs() $"{nameof(GameEndEvent)}: {nameof(EndGamePatch.OnGameEnd)}", $"{nameof(EnterVentEvent)}: {nameof(VisionaryVentPatch.OnEnterVent)}", $"{nameof(BeforeMurderEvent)}: {nameof(VisionaryMurderPatch.OnBeforeMurder)}", - $"{nameof(AfterMurderEvent)}: {nameof(NewMod.OnAfterMurder)}" + $"{nameof(AfterMurderEvent)}: {nameof(NewMod.OnAfterMurder)}", + $"{nameof(HandleVoteEvent)}: {nameof(Tyrant.OnHandleVote)}", + $"{nameof(StartMeetingEvent)}: {nameof(Tyrant.OnMeetingStart)}", + $"{nameof(ProcessVotesEvent)}: {nameof(Tyrant.OnProcessVotes)}" }; NewMod.Instance.Log.LogInfo("Registered events: " + "\n" + string.Join(", ", registrations)); } diff --git a/NewMod/NewModFaction.cs b/NewMod/NewModFaction.cs new file mode 100644 index 0000000..ee0c6df --- /dev/null +++ b/NewMod/NewModFaction.cs @@ -0,0 +1,8 @@ +namespace NewMod +{ + public enum NewModFaction + { + Apex, + Entropy + } +} \ No newline at end of file diff --git a/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs b/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs index c2136e0..c5eae7b 100644 --- a/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs +++ b/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs @@ -10,9 +10,12 @@ public class EnergyThiefOptions : AbstractOptionGroup { public override string GroupName => "Energy Thief"; - [ModdedNumberOption("Drain Cooldown", min: 10, max: 20, suffixType: MiraNumberSuffixes.Seconds)] + [ModdedNumberOption("Drain Cooldown", min: 10f, max: 20f, suffixType: MiraNumberSuffixes.Seconds)] public float DrainCooldown { get; set; } = 15f; - [ModdedNumberOption("Drain Max Uses", min: 3, max: 5)] + [ModdedNumberOption("Drain Max Uses", min: 3f, max: 5f)] public float DrainMaxUses { get; set; } = 3f; + + [ModdedNumberOption("Required Drain Count", min: 2f, max: 4f)] + public float RequiredDrainCount { get; set; } = 3f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/InjectorOptions/InjectorOptions.cs b/NewMod/Options/Roles/InjectorOptions/InjectorOptions.cs index a89036e..d2a46ae 100644 --- a/NewMod/Options/Roles/InjectorOptions/InjectorOptions.cs +++ b/NewMod/Options/Roles/InjectorOptions/InjectorOptions.cs @@ -15,10 +15,10 @@ public class InjectorOptions : AbstractOptionGroup public float SerumCooldown { get; set; } = 20f; [ModdedNumberOption("Max Serum Uses", min: 1, max: 10)] - public int MaxSerumUses { get; set; } = 3; + public float MaxSerumUses { get; set; } = 3f; [ModdedNumberOption("Injections Required to Win", min: 1, max: 10)] - public int RequiredInjectCount { get; set; } = 3; + public float RequiredInjectCount { get; set; } = 3f; [ModdedNumberOption("Adrenaline Effect (+% Speed)", min: 10, max: 200, increment: 5, suffixType: MiraNumberSuffixes.Percent)] public float AdrenalineSpeedBoost { get; set; } = 10f; @@ -26,11 +26,8 @@ public class InjectorOptions : AbstractOptionGroup [ModdedNumberOption("Immobilize Duration", min: 1, max: 10, suffixType: MiraNumberSuffixes.Seconds)] public float ParalysisDuration { get; set; } = 4f; - [ModdedNumberOption("Bounce Force (Horizontal)", min: 0f, max: 2f, increment: 0.1f)] - public float BounceForceHorizontal { get; set; } = 0.5f; - - [ModdedNumberOption("Bounce Force (Vertical)", min: 0f, max: 2f, increment: 0.1f)] - public float BounceForceVertical { get; set; } = 0.5f; + [ModdedNumberOption("Bounce Force (Horizontal)", min: 1f, max: 2f, increment: 0.1f)] + public float BounceForceHorizontal { get; set; } = 2f; [ModdedToggleOption("Enable Random Bounce Effects")] public bool EnableBounceVariants { get; set; } = true; diff --git a/NewMod/Options/Roles/PulseBladeOptions/PulseBladeOptions.cs b/NewMod/Options/Roles/PulseBladeOptions/PulseBladeOptions.cs new file mode 100644 index 0000000..be9c6fb --- /dev/null +++ b/NewMod/Options/Roles/PulseBladeOptions/PulseBladeOptions.cs @@ -0,0 +1,36 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using NewMod.Roles.ImpostorRoles; + +namespace NewMod.Options.Roles.PulseBladeOptions; + +/// +/// Configurable options for the PulseBlade role. +/// +public class PulseBladeOptions : AbstractOptionGroup +{ + public override string GroupName => "PulseBlade Settings"; + + [ModdedNumberOption("Strike Cooldown", min: 5, max: 60, suffixType: MiraNumberSuffixes.Seconds)] + public float StrikeCooldown { get; set; } = 20f; + + [ModdedNumberOption("Max Strike Uses", min: 1, max: 5)] + public float MaxStrikeUses { get; set; } = 3f; + + [ModdedNumberOption("Strike Range", min: 1f, max: 7f, increment: 1f, suffixType: MiraNumberSuffixes.None)] + public float StrikeRange { get; set; } = 4f; + + [ModdedNumberOption("Dash Speed", min: 2f, max: 10f, increment: 1f, suffixType: MiraNumberSuffixes.None)] + public float DashSpeed { get; set; } = 5f; + + [ModdedNumberOption("Hide Body Duration", min: 0f, max: 10f, increment: 1f, suffixType: MiraNumberSuffixes.Seconds)] + public float HideBodyDuration { get; set; } = 5f; + + [ModdedNumberOption("Required Strikes to Win", min: 1f, max: 4f, increment: 1f, suffixType: MiraNumberSuffixes.None)] + public float RequiredStrikes { get; set; } = 2f; + + [ModdedNumberOption("Players Remaining Threshold", min: 2f, max: 6f, increment: 1f, suffixType: MiraNumberSuffixes.None)] + public float PlayersThreshold { get; set; } = 4f; +} diff --git a/NewMod/Options/Roles/TyrantOptions/TyrantOptions.cs b/NewMod/Options/Roles/TyrantOptions/TyrantOptions.cs new file mode 100644 index 0000000..a381825 --- /dev/null +++ b/NewMod/Options/Roles/TyrantOptions/TyrantOptions.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using NewMod.Roles.ImpostorRoles; + +namespace NewMod.Options.Roles.TyrantOptions +{ + public class TyrantOptions : AbstractOptionGroup + { + public override string GroupName => "Tyrant"; + + [ModdedNumberOption("Fear Pulse Radius", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] + public float FearPulseRadius { get; set; } = 5f; + + [ModdedNumberOption("Fear Pulse Duration", min: 1f, max: 12f, suffixType: MiraNumberSuffixes.Seconds)] + public float FearPulseDuration { get; set; } = 6f; + + [ModdedNumberOption("Fear Pulse Speed Reduction %", min: 30f, max: 80f, suffixType: MiraNumberSuffixes.Percent)] + public float FearPulseSpeed { get; set; } = 20f; + + [ModdedNumberOption("Dome Radius", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] + public float DomeRadius { get; set; } = 5f; + + [ModdedNumberOption("Dome Duration", min: 2f, max: 12f, suffixType: MiraNumberSuffixes.Seconds)] + public float DomeDuration { get; set; } = 8f; + + [ModdedNumberOption("Witness Range", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] + public float WitnessRange { get; set; } = 5f; + + [ModdedNumberOption("Witness Freeze Duration", min: 2f, max: 6f, suffixType: MiraNumberSuffixes.Seconds)] + public float WitnessFreezeDuration { get; set; } = 3f; + + [ModdedNumberOption("Witness Arm Window", min: 1f, max: 12f, suffixType: MiraNumberSuffixes.Seconds)] + public float WitnessArmWindow { get; set; } = 8f; + } +} diff --git a/NewMod/Patches/ClipboardPatch.cs b/NewMod/Patches/ClipboardPatch.cs index 0c7cd3f..ccfcbc2 100644 --- a/NewMod/Patches/ClipboardPatch.cs +++ b/NewMod/Patches/ClipboardPatch.cs @@ -1,3 +1,4 @@ +/* using HarmonyLib; using UnityEngine; @@ -35,3 +36,4 @@ public static void Prefix(ChatController __instance) } } } +*/ diff --git a/NewMod/Patches/EndGamePatch.cs b/NewMod/Patches/EndGamePatch.cs index 45fe903..99d848f 100644 --- a/NewMod/Patches/EndGamePatch.cs +++ b/NewMod/Patches/EndGamePatch.cs @@ -12,6 +12,12 @@ using MiraAPI.GameOptions; using MiraAPI.Events; using NewMod.Options.Roles.InjectorOptions; +using NewMod.Roles; +using System; +using NewMod.Roles.ImpostorRoles; +using NewMod.Options.Roles.PulseBladeOptions; +using MiraAPI.Utilities; +using NewMod.Options.Roles.EnergyThiefOptions; namespace NewMod.Patches { @@ -104,6 +110,21 @@ public static void OnGameEnd(GameEndEvent evt) customWinColor = GetRoleColor(GetRoleType()); endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); break; + case (GameOverReason)NewModEndReasons.InjectorWin: + customWinText = "Injector Victory"; + customWinColor = GetRoleColor(GetRoleType()); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); + break; + case (GameOverReason)NewModEndReasons.PulseBladeWin: + customWinText = "PulseBlade Victory"; + customWinColor = GetRoleColor(GetRoleType()); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); + break; + case (GameOverReason)NewModEndReasons.TyrantWin: + customWinText = "Tyrant Victory"; + customWinColor = GetRoleColor(GetRoleType()); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); + break; default: customWinText = string.Empty; customWinColor = Color.white; @@ -125,7 +146,7 @@ public static void OnGameEnd(GameEndEvent evt) } } - private static string GetRoleName(CachedPlayerData playerData, out Color roleColor) + public static string GetRoleName(CachedPlayerData playerData, out Color roleColor) { RoleTypes roleType = playerData.RoleWhenAlive; RoleBehaviour roleBehaviour = RoleManager.Instance.GetRole(roleType); @@ -135,6 +156,11 @@ private static string GetRoleName(CachedPlayerData playerData, out Color roleCol if (CustomRoleManager.GetCustomRoleBehaviour(roleType, out var customRole)) { roleColor = customRole.RoleColor; + + if (customRole is INewModRole newmodRole) + { + return $"{newmodRole.RoleName}\n{Utils.GetFactionDisplay()}"; + } return customRole.RoleName; } else @@ -184,12 +210,68 @@ public static class CheckGameEndPatch public static bool Prefix(ShipStatus __instance) { if (DestroyableSingleton.InstanceExists) return true; + if (CheckForEndGameFaction(__instance, (GameOverReason)NewModEndReasons.PulseBladeWin)) return false; + if (CheckForEndGameFaction(__instance, (GameOverReason)NewModEndReasons.TyrantWin)) return false; if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.DoubleAgentWin)) return false; if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.SpecialAgentWin)) return false; if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.PranksterWin, 3)) return false; if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.EnergyThiefWin)) return false; return true; } + public static bool CheckForEndGameFaction(ShipStatus __instance, GameOverReason winReason, int maxCount = 1) where TFaction : INewModRole + { + var players = PlayerControl.AllPlayerControls.ToArray() + .Where(p => p.Data.Role is TFaction) + .Take(maxCount) + .ToList(); + + foreach (var player in players) + { + bool shouldEndGame = false; + + if (typeof(TFaction) == typeof(PulseBlade)) + { + var opts = OptionGroupSingleton.Instance; + float requiredStrikes = opts.RequiredStrikes; + float playersThreshold = opts.PlayersThreshold; + + var alives = Helpers.GetAlivePlayers(); + + if (alives.Count > playersThreshold) continue; + + int strikes = Utils.GetStrikes(player.PlayerId); + if (strikes >= requiredStrikes) + { + shouldEndGame = true; + } + } + if (typeof(TFaction) == typeof(Tyrant)) + { + if (Tyrant.ApexThroneReady && Tyrant.ApexThroneOutcomeSet) + { + shouldEndGame = true; + + var tyrantRole = player.Data.Role as Tyrant; + byte champId = tyrantRole.GetChampion(); + var champion = Utils.PlayerById(champId); + + bool championWin = Tyrant.Outcome == Tyrant.ThroneOutcome.ChampionSideWin; + + if (champion && championWin) + { + EndGameResult.CachedWinners.Add(new(champion.Data)); + } + } + } + if (shouldEndGame) + { + GameManager.Instance.RpcEndGame(winReason, false); + CustomStatsManager.IncrementRoleWin((INewModRole)player.Data.Role); + return true; + } + } + return false; + } public static bool CheckEndGameForRole(ShipStatus __instance, GameOverReason winReason, int maxCount = 1) where T : RoleBehaviour { var rolePlayers = PlayerControl.AllPlayerControls.ToArray() @@ -208,6 +290,13 @@ public static bool CheckEndGameForRole(ShipStatus __instance, GameOverReason shouldEndGame = tasksCompleted && isSabotageActive; } if (typeof(T) == typeof(EnergyThief)) + { + int drainCount = Utils.GetDrainCount(player.PlayerId); + int requiredDrainCount = (int)OptionGroupSingleton.Instance.RequiredDrainCount; + + shouldEndGame = drainCount >= requiredDrainCount; + } + if (typeof(T) == typeof(Prankster)) { int WinReportCount = 2; int currentReportCount = PranksterUtilities.GetReportCount(player.PlayerId); @@ -223,7 +312,7 @@ public static bool CheckEndGameForRole(ShipStatus __instance, GameOverReason if (typeof(T) == typeof(InjectorRole)) { int injectedCount = Utils.GetInjectedCount(); - int required = OptionGroupSingleton.Instance.RequiredInjectCount; + int required = (int)OptionGroupSingleton.Instance.RequiredInjectCount; shouldEndGame = injectedCount >= required; } if (shouldEndGame) diff --git a/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs b/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs index f5fdc37..3e2653b 100644 --- a/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs +++ b/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs @@ -13,7 +13,8 @@ public static void Postfix(AmongUsClient __instance, [HarmonyArgument(0)] EndGam Utils.ResetDrainCount(); Utils.ResetMissionSuccessCount(); Utils.ResetMissionFailureCount(); - Utils.ClearInjections(); + Utils.ResetInjections(); + Utils.ResetStrikeCount(); PranksterUtilities.ResetReportCount(); VisionaryUtilities.DeleteAllScreenshots(); Revenant.HasUsedFeignDeath = false; diff --git a/NewMod/Resources/NotifIcons/freeze.png b/NewMod/Resources/NotifIcons/freeze.png new file mode 100644 index 0000000..96ff3f2 Binary files /dev/null and b/NewMod/Resources/NotifIcons/freeze.png differ diff --git a/NewMod/Resources/NotifIcons/speed_debuff.png b/NewMod/Resources/NotifIcons/speed_debuff.png new file mode 100644 index 0000000..3a74f95 Binary files /dev/null and b/NewMod/Resources/NotifIcons/speed_debuff.png differ diff --git a/NewMod/Resources/NotifIcons/vision_debuff.png b/NewMod/Resources/NotifIcons/vision_debuff.png new file mode 100644 index 0000000..89f967c Binary files /dev/null and b/NewMod/Resources/NotifIcons/vision_debuff.png differ diff --git a/NewMod/Resources/RoleIcons/InjectIcon.png b/NewMod/Resources/RoleIcons/InjectIcon.png new file mode 100644 index 0000000..024aa56 Binary files /dev/null and b/NewMod/Resources/RoleIcons/InjectIcon.png differ diff --git a/NewMod/Resources/RoleIcons/StrikeIcon.png b/NewMod/Resources/RoleIcons/StrikeIcon.png new file mode 100644 index 0000000..97fbbec Binary files /dev/null and b/NewMod/Resources/RoleIcons/StrikeIcon.png differ diff --git a/NewMod/Resources/RoleIcons/crown.png b/NewMod/Resources/RoleIcons/crown.png new file mode 100644 index 0000000..f60c393 Binary files /dev/null and b/NewMod/Resources/RoleIcons/crown.png differ diff --git a/NewMod/Resources/Sounds/fear_sound.wav b/NewMod/Resources/Sounds/fear_sound.wav new file mode 100644 index 0000000..701c057 Binary files /dev/null and b/NewMod/Resources/Sounds/fear_sound.wav differ diff --git a/NewMod/Resources/Sounds/heartbeat_sound.wav b/NewMod/Resources/Sounds/heartbeat_sound.wav new file mode 100644 index 0000000..1e2647e Binary files /dev/null and b/NewMod/Resources/Sounds/heartbeat_sound.wav differ diff --git a/NewMod/Resources/Sounds/strike_sound.wav b/NewMod/Resources/Sounds/strike_sound.wav new file mode 100644 index 0000000..22d4057 Binary files /dev/null and b/NewMod/Resources/Sounds/strike_sound.wav differ diff --git a/NewMod/Resources/Strike.png b/NewMod/Resources/Strike.png new file mode 100644 index 0000000..38dbfae Binary files /dev/null and b/NewMod/Resources/Strike.png differ diff --git a/NewMod/Resources/inject.png b/NewMod/Resources/inject.png new file mode 100644 index 0000000..05a3b22 Binary files /dev/null and b/NewMod/Resources/inject.png differ diff --git a/NewMod/Roles/CrewmateRoles/DoubleAgent.cs b/NewMod/Roles/CrewmateRoles/DoubleAgent.cs index ddd534a..f31c0da 100644 --- a/NewMod/Roles/CrewmateRoles/DoubleAgent.cs +++ b/NewMod/Roles/CrewmateRoles/DoubleAgent.cs @@ -7,8 +7,8 @@ namespace NewMod.Roles.CrewmateRoles; public class DoubleAgent : CrewmateRole, ICustomRole { public string RoleName => "Double Agent"; - public string RoleDescription => $"A Crewmate posing as an Impostor: You can't kill or vent, but you can sabotage and confuse the real Impostors. Complete all tasks and sabotage to win\n\nTeam: {Team}."; - public string RoleLongDescription => RoleDescription; + public string RoleDescription => "Mimic. Mislead. Win"; + public string RoleLongDescription => $"A Crewmate posing as an Impostor: You can't kill or vent, but you can sabotage and confuse the real Impostors. Complete all tasks and sabotage to win\n\nTeam: {Team}."; public Color RoleColor => Palette.ImpostorRed; public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Crewmate; diff --git a/NewMod/Roles/CrewmateRoles/Specialist.cs b/NewMod/Roles/CrewmateRoles/Specialist.cs index 87be4b7..fac940d 100644 --- a/NewMod/Roles/CrewmateRoles/Specialist.cs +++ b/NewMod/Roles/CrewmateRoles/Specialist.cs @@ -7,6 +7,7 @@ using MiraAPI.Utilities.Assets; using MiraAPI.Events.Vanilla.Player; using MiraAPI.Events; +using MiraAPI.Utilities; namespace NewMod.Roles.CrewmateRoles; @@ -37,7 +38,7 @@ public class Specialist : CrewmateRole, ICustomRole public static void OnTaskComplete(CompleteTaskEvent evt) { PlayerControl player = evt.Player; - if (!(player.Data.Role is Specialist)) return; + if (player.Data.Role is not Specialist) return; List abilityAction = new List { @@ -47,8 +48,7 @@ public static void OnTaskComplete(CompleteTaskEvent evt) if (target != null) { Utils.RpcRandomDrainActions(player, target); - Coroutines.Start(CoroutinesHelper.CoNotify( - $"Energy Drain activated on {target.Data.PlayerName}!")); + Helpers.CreateAndShowNotification($"Energy Drain activated on {target.Data.PlayerName}!",Color.green); } }, () => @@ -58,15 +58,13 @@ public static void OnTaskComplete(CompleteTaskEvent evt) if (closestBody != null) { Utils.RpcRevive(closestBody); - Coroutines.Start(CoroutinesHelper.CoNotify( - $"Player {player.Data.PlayerName} has been revived.")); + Helpers.CreateAndShowNotification($"Player {player.Data.PlayerName} has been revived.", Color.green); } }, () => { PranksterUtilities.CreatePranksterDeadBody(player, player.PlayerId); - Coroutines.Start(CoroutinesHelper.CoNotify( - "Fake Body created!")); + Helpers.CreateAndShowNotification("Fake Body created!", Color.green); }, () => { @@ -80,8 +78,7 @@ public static void OnTaskComplete(CompleteTaskEvent evt) () => { Utils.RpcAssignMission(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer); - Coroutines.Start(CoroutinesHelper.CoNotify( - "You have been assigned a mission. Complete it or die.")); + Helpers.CreateAndShowNotification("You have been assigned a mission. Complete it or die.", Color.red); } }; diff --git a/NewMod/Roles/INewModRole.cs b/NewMod/Roles/INewModRole.cs new file mode 100644 index 0000000..584c83f --- /dev/null +++ b/NewMod/Roles/INewModRole.cs @@ -0,0 +1,25 @@ +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Roles; +using NewMod.Utilities; + +namespace NewMod.Roles +{ + public interface INewModRole : ICustomRole + { + public static StringBuilder GetRoleTabText(ICustomRole role) + { + var sb = new StringBuilder(); + sb.AppendLine($"{role.RoleColor.ToTextColor()}You are {role.RoleName}"); + sb.AppendLine($"Faction: {Utils.GetFactionDisplay()}"); + sb.AppendLine($"{role.RoleLongDescription}"); + return sb; + } + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + return GetRoleTabText(this); + } + } +} \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/Necromancer.cs b/NewMod/Roles/ImpostorRoles/Necromancer.cs index 57f04fd..b115f59 100644 --- a/NewMod/Roles/ImpostorRoles/Necromancer.cs +++ b/NewMod/Roles/ImpostorRoles/Necromancer.cs @@ -7,7 +7,6 @@ namespace NewMod.Roles.ImpostorRoles; - public class NecromancerRole : ImpostorRole, ICustomRole { public string RoleName => "Necromancer"; diff --git a/NewMod/Roles/ImpostorRoles/PulseBlade.cs b/NewMod/Roles/ImpostorRoles/PulseBlade.cs new file mode 100644 index 0000000..a7c0f07 --- /dev/null +++ b/NewMod/Roles/ImpostorRoles/PulseBlade.cs @@ -0,0 +1,69 @@ +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.GameOptions; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using NewMod.Options.Roles.PulseBladeOptions; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Roles.ImpostorRoles +{ + public class PulseBlade : ImpostorRole, INewModRole + { + public string RoleName => "PulseBlade"; + public string RoleDescription => "Dash. Strike. Clean."; + public string RoleLongDescription => "Dash to eliminate a target with precision. Victim’s body disappears temporarily"; + public Color RoleColor => new(1f, 0.25f, 0.25f); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public NewModFaction Faction => NewModFaction.Apex; + public CustomRoleConfiguration Configuration => new(this) + { + AffectedByLightOnAirship = false, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = false, + Icon = NewModAsset.StrikeIcon + }; + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var strikes = Utils.GetStrikes(PlayerControl.LocalPlayer.PlayerId); + int alive = Helpers.GetAlivePlayers().Count; + int threshold = (int)OptionGroupSingleton.Instance.PlayersThreshold; + int req = (int)OptionGroupSingleton.Instance.RequiredStrikes; + + tabText.AppendLine($"Warning: If your target is far beyond strike range and you strike, you will lose one use."); + tabText.AppendLine("\n"); + tabText.AppendLine($"Win: {strikes}/{req} strikes"); + + if (strikes >= req) + { + if (alive <= threshold) + { + tabText.AppendLine($"Condition met, players ≤ {threshold}. Victory will trigger."); + } + else + { + tabText.AppendLine($"Armed: stay alive until players ≤ {threshold} to win."); + } + } + else + { + int left = req - strikes; + tabText.AppendLine($"{left} more strike{(left == 1 ? "" : "s")} needed to arm your win."); + } + + string aliveHex = alive <= threshold ? ColorUtility.ToHtmlStringRGBA(Palette.AcceptedGreen) : ColorUtility.ToHtmlStringRGBA(Color.yellow); + tabText.AppendLine($"Current Alive: {alive} • Threshold: {threshold}"); + + return tabText; + } + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == (GameOverReason)NewModEndReasons.PulseBladeWin; + } + } +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/Egoist.cs b/NewMod/Roles/NeutralRoles/Egoist.cs index 36fb5dc..aedbd14 100644 --- a/NewMod/Roles/NeutralRoles/Egoist.cs +++ b/NewMod/Roles/NeutralRoles/Egoist.cs @@ -52,7 +52,8 @@ public static void OnEjection(EjectionEvent evt) int minVotes = OptionGroupSingleton.Instance.MinimumVotesToWin; var voters = PlayerControl - .AllPlayerControls.ToArray() + .AllPlayerControls + .ToArray() .Where(p => { var voteData = p.GetVoteData(); diff --git a/NewMod/Roles/NeutralRoles/Injector.cs b/NewMod/Roles/NeutralRoles/Injector.cs index bdd4513..5e34771 100644 --- a/NewMod/Roles/NeutralRoles/Injector.cs +++ b/NewMod/Roles/NeutralRoles/Injector.cs @@ -7,14 +7,14 @@ namespace NewMod.Roles.NeutralRoles; public class InjectorRole : ImpostorRole, ICustomRole { public string RoleName => "Injector"; - public string RoleDescription => "Inject other players with serums that alter their abilities"; - public string RoleLongDescription => "You hold unstable serums. Inject. Distort. Dominate"; + public string RoleDescription => "You hold unstable serums. Inject. Distort. Dominate"; + public string RoleLongDescription => "Inject other players with serums that alter their abilities"; public Color RoleColor => new(0.9f, 0.3f, 0.1f); public ModdedRoleTeams Team => ModdedRoleTeams.Custom; public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; public CustomRoleConfiguration Configuration => new(this) { - Icon = MiraAssets.Empty, + Icon = NewModAsset.InjectIcon, OptionsScreenshot = NewModAsset.Banner, MaxRoleCount = 1, UseVanillaKillButton = false, @@ -23,7 +23,7 @@ public class InjectorRole : ImpostorRole, ICustomRole DefaultChance = 50, DefaultRoleCount = 1, CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab + RoleHintType = RoleHintType.RoleTab, }; public TeamIntroConfiguration TeamConfiguration => new() { diff --git a/NewMod/Roles/NeutralRoles/Overload.cs b/NewMod/Roles/NeutralRoles/Overload.cs index 445d9af..8153e17 100644 --- a/NewMod/Roles/NeutralRoles/Overload.cs +++ b/NewMod/Roles/NeutralRoles/Overload.cs @@ -9,6 +9,7 @@ using NewMod.Utilities; namespace NewMod.Roles.NeutralRoles; + public class OverloadRole : ImpostorRole, ICustomRole { public string RoleName => "Overload"; @@ -36,7 +37,7 @@ public class OverloadRole : ImpostorRole, ICustomRole public static void OnRoundStart(RoundStartEvent evt) { if (PlayerControl.LocalPlayer.Data.Role is not OverloadRole) return; - + if (evt.TriggeredByIntro) { AbsorbedAbilityCount = 0; @@ -68,13 +69,10 @@ public static void UnlockFinalAbility() { var btn = Instantiate(HudManager.Instance.AbilityButton, HudManager.Instance.AbilityButton.transform.parent); btn.name = "FinalButton"; - - var rect = btn.GetComponent(); - rect.anchorMin = new(0.5f, 0.5f); - rect.anchorMax = new(0.5f, 0.5f); - rect.pivot = new(0.5f, 0.5f); - rect.anchoredPosition = Vector2.zero; - + btn.transform.SetParent(HudManager.Instance.transform.Find("Buttons"), false); + btn.GetComponent().anchorMin = btn.GetComponent().anchorMax = btn.GetComponent().pivot = new Vector2(0.5f, 0.5f); + btn.GetComponent().anchoredPosition = Vector2.zero; + btn.GetComponent().sizeDelta = Vector2.zero; btn.OverrideText("OVERLOAD"); btn.transform.SetAsLastSibling(); var passive = btn.GetComponent(); diff --git a/NewMod/Roles/NeutralRoles/Tyrant.cs b/NewMod/Roles/NeutralRoles/Tyrant.cs new file mode 100644 index 0000000..4859715 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/Tyrant.cs @@ -0,0 +1,350 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using NewMod.Colors; +using NewMod.Components; +using NewMod.Options.Roles.TyrantOptions; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using UnityEngine; + +namespace NewMod.Roles.ImpostorRoles +{ + public sealed class Tyrant : ImpostorRole, INewModRole + { + public string RoleName => "Tyrant"; + public string RoleDescription => "Slow them. Bind them. End them"; + public string RoleLongDescription => + "You are the Tyrant. Each kill strengthens your control over the ship:\n"; + public Color RoleColor => new(0.78f, 0.10f, 0.16f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; + public NewModFaction Faction = NewModFaction.Apex; + public CustomRoleConfiguration Configuration => new(this) + { + MaxRoleCount = 1, + OptionsScreenshot = MiraAssets.Empty, + Icon = NewModAsset.CrownIcon, + CanGetKilled = true, + UseVanillaKillButton = true, + CanUseVent = true, + TasksCountForProgress = false, + CanUseSabotage = true, + DefaultChance = 25, + DefaultRoleCount = 1, + CanModifyChance = true, + GhostRole = AmongUs.GameOptions.RoleTypes.Crewmate, + RoleHintType = RoleHintType.RoleTab + }; + public TeamIntroConfiguration TeamConfiguration => new() + { + IntroTeamDescription = RoleDescription, + IntroTeamColor = RoleColor + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var green = Palette.AcceptedGreen.ToHtmlStringRGBA(); + int kills = GetKillCount(); + + string firstKill = "* 1st Kill — Fear Pulse: nearby foes suffer reduced vision and speed for a short time.\n"; + string secondKill = "* 2nd Kill — Zone of Suppression: a dome that disables buttons for those inside.\n"; + string thirdKill = "* 3rd Kill — Intimidation Protocol: the next witness is frozen briefly.\n"; + string fourthKill = "* 4th Kill — Apex Throne: designate a Champion who cannot oppose you.\n"; + + void AppendAbilityLine(int index, string text) + { + if (kills > index) + { + tabText.AppendLine($"{text}"); + } + else if (kills == index) + { + tabText.AppendLine($"{text}"); + tabText.AppendLine($"✓ Unlocked"); + } + else if (index == kills + 1) + { + tabText.AppendLine($"{text}"); + } + else + { + tabText.AppendLine($"{text}"); + } + } + AppendAbilityLine(1, firstKill); + AppendAbilityLine(2, secondKill); + AppendAbilityLine(3, thirdKill); + AppendAbilityLine(4, fourthKill); + + return tabText; + } + public int _kills; + public static Material _circleMat; + public static byte _championId; + public static bool ApexThroneReady; + public static bool ApexThroneOutcomeSet; + public enum ThroneOutcome { None, ChampionSideWin } + public static ThroneOutcome Outcome = ThroneOutcome.None; + public static readonly HashSet PendingBetrayals = new(); + public int GetKillCount() => _kills; + public byte GetChampion() => _championId; + public void SetChampion(byte playerId) => _championId = playerId; + public static void ClearChampion() => _championId = byte.MaxValue; + public static Material GetCircleMat() + { + if (_circleMat) return _circleMat; + _circleMat = new(Shader.Find("Sprites/Default")) + { + renderQueue = 3000 + }; + return _circleMat; + } + public static GameObject CreateCircle(Vector3 pos, float radius, Color color, float duration, int segments = 64) + { + var go = new GameObject("Tyrant_Circle"); + go.transform.position = pos; + + HudManager.Instance.StartCoroutine(Effects.ScaleIn(go.transform, 0f, 1f, 0.5f)); + + var mf = go.AddComponent(); + var mr = go.AddComponent(); + + var mat = new Material(GetCircleMat()) { color = color }; + mr.sharedMaterial = mat; + + float visualRadius = radius; + + segments = Mathf.Max(12, segments); + var verts = new Vector3[segments + 1]; + var tris = new int[segments * 3]; + + verts[0] = Vector3.zero; + for (int i = 0; i < segments; i++) + { + float a = i / (float)segments * Mathf.PI * 2f; + verts[i + 1] = new Vector3(Mathf.Cos(a) * visualRadius, Mathf.Sin(a) * visualRadius, 0f); + tris[i * 3 + 0] = 0; + tris[i * 3 + 1] = i + 1; + tris[i * 3 + 2] = (i == segments - 1) ? 1 : (i + 2); + } + + var mesh = new Mesh { name = "FearPulseFill" }; + mesh.SetVertices(verts); + mesh.SetTriangles(tris, 0, true); + mesh.RecalculateBounds(); + mesh.RecalculateNormals(); + mf.sharedMesh = mesh; + + Coroutines.Start(CoroutinesHelper.DespawnCircle(go, duration)); + return go; + } + + [RegisterEvent] + public static void OnAfterMurderEvent(AfterMurderEvent evt) + { + var tyrant = evt.Source.Data.Role as Tyrant; + + tyrant._kills++; + + if (tyrant.GetKillCount() == 1) + { + RpcSpawnFearPulse(evt.Source, evt.Source.GetTruePosition().x, evt.Source.GetTruePosition().y); + } + else if (tyrant.GetKillCount() == 2) + { + RpcSpawnSuppressionDome(evt.Source, evt.Source.GetTruePosition().x, evt.Source.GetTruePosition().y); + } + else if (tyrant.GetKillCount() == 3) + { + RpcArmWitnessTrap(evt.Source, evt.Source.GetTruePosition().x, evt.Source.GetTruePosition().y); + } + else + { + ApexThroneReady = true; + ApexThroneOutcomeSet = false; + + var menu = CustomPlayerMenu.Create(); + menu.Begin( + player => !player.Data.IsDead && + !player.Data.Disconnected && + player.PlayerId != PlayerControl.LocalPlayer.PlayerId, + player => + { + tyrant.SetChampion(player.PlayerId); + menu.Close(); + + if (tyrant.Player.AmOwner) + Coroutines.Start(CoroutinesHelper.CoNotify("Apex Throne is armed. You have chosen a Champion.")); + + RpcNotifyChampion(tyrant.Player, player); + + }); + } + } + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + if (_championId == byte.MaxValue) return; + if (PlayerControl.LocalPlayer.PlayerId != _championId) return; + + var tyrantPlayer = PlayerControl.AllPlayerControls.ToArray().FirstOrDefault(p => p.Data.Role is Tyrant); + if (!tyrantPlayer) return; + + foreach (var ps in evt.MeetingHud.playerStates) + { + if (ps.TargetPlayerId == tyrantPlayer.PlayerId) + { + ps.NameText.text += "\nTyrant"; + break; + } + } + } + [RegisterEvent] + public static void OnHandleVote(HandleVoteEvent evt) + { + var voter = evt.VoteData.Owner; + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player.Data.Role is not Tyrant tyrant) continue; + if (voter.PlayerId != _championId) continue; + + bool betrays = evt.TargetId == tyrant.Player.PlayerId; + + if (betrays) + { + if (evt.VoteData.VotedFor(evt.TargetId)) + evt.VoteData.RemovePlayerVote(evt.TargetId); + + evt.VoteData.VoteForPlayer(evt.VoteData.Owner.PlayerId); + evt.VoteData.SetRemainingVotes(0); + + PendingBetrayals.Add(voter.PlayerId); + ApexThroneOutcomeSet = true; + Outcome = ThroneOutcome.None; + } + else + { + ApexThroneOutcomeSet = true; + Outcome = ThroneOutcome.ChampionSideWin; + } + if (voter.AmOwner) + { + var msg = (Outcome == ThroneOutcome.ChampionSideWin) + ? "You submitted to the Tyrant’s will." + : "Betrayal detected. You will be punished."; + Coroutines.Start(CoroutinesHelper.CoNotify(msg)); + } + break; + } + } + [RegisterEvent] + public static void OnProcessVotes(ProcessVotesEvent evt) + { + if (PendingBetrayals.Count == 0) return; + + var first = default(byte); + foreach (var id in PendingBetrayals) { first = id; break; } + PendingBetrayals.Clear(); + + var info = GameData.Instance.GetPlayerById(first); + + if (info != null) + { + evt.ExiledPlayer = info; + } + } + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + ApexThroneReady = false; + ApexThroneOutcomeSet = false; + Outcome = ThroneOutcome.None; + ClearChampion(); + } + public void SpawnSuppressionDome(Vector3 pos) + { + var go = new GameObject("Supression_Dome"); + go.transform.position = pos; + + var area = go.AddComponent(); + area.Init(Player.PlayerId, radius: OptionGroupSingleton.Instance.DomeRadius, OptionGroupSingleton.Instance.DomeDuration); + + if (Player.AmOwner) + CreateCircle(Player.GetTruePosition(), OptionGroupSingleton.Instance.DomeRadius, Palette.AcceptedGreen, OptionGroupSingleton.Instance.DomeDuration); + } + public void ArmWitnessTrap(Vector3 pos) + { + var go = new GameObject("WitnessTrap"); + go.transform.position = pos; + + var trap = go.AddComponent(); + trap.Init( + ownerId: Player.PlayerId, + radius: OptionGroupSingleton.Instance.WitnessRange, + freeze: OptionGroupSingleton.Instance.WitnessFreezeDuration, + duration: OptionGroupSingleton.Instance.WitnessArmWindow + ); + + if (Player.AmOwner) + CreateCircle(Player.GetTruePosition(), OptionGroupSingleton.Instance.WitnessRange, Color.cyan, OptionGroupSingleton.Instance.WitnessArmWindow); + } + public void SpawnFearPulse(Vector3 pos) + { + var go = new GameObject("FearPulseArea"); + go.transform.position = pos; + + var area = go.AddComponent(); + area.Init( + ownerId: Player.PlayerId, + radius: OptionGroupSingleton.Instance.FearPulseRadius, + duration: OptionGroupSingleton.Instance.FearPulseDuration, + speedMul: OptionGroupSingleton.Instance.FearPulseSpeed + ); + + if (Player.AmOwner) + CreateCircle(Player.GetTruePosition(), OptionGroupSingleton.Instance.FearPulseRadius, new Color(1f, 0.35f, 0.2f, 0.6f), OptionGroupSingleton.Instance.FearPulseDuration); + } + [MethodRpc((uint)CustomRPC.NotifyChampion)] + public static void RpcNotifyChampion(PlayerControl source, PlayerControl target) + { + if (target.AmOwner) + { + Coroutines.Start(CoroutinesHelper.CoNotify($"{source.Data.PlayerName} is your Tyrant. Obey or be exiled.")); + } + } + [MethodRpc((uint)CustomRPC.FearPulse)] + public static void RpcSpawnFearPulse(PlayerControl source, float x, float y) + { + var tyrant = source.Data.Role as Tyrant; + + tyrant.SpawnFearPulse(new Vector2(x, y)); + } + [MethodRpc((uint)CustomRPC.SuppressionDome)] + public static void RpcSpawnSuppressionDome(PlayerControl source, float x, float y) + { + var tyrant = source.Data.Role as Tyrant; + tyrant.SpawnSuppressionDome(new Vector2(x, y)); + } + [MethodRpc((uint)CustomRPC.WitnessTrap)] + public static void RpcArmWitnessTrap(PlayerControl source, float x, float y) + { + var tyrant = source.Data.Role as Tyrant; + tyrant.ArmWitnessTrap(new Vector2(x, y)); + } + } +} diff --git a/NewMod/Utilities/CoroutinesHelper.cs b/NewMod/Utilities/CoroutinesHelper.cs index ae3bcd6..a894ed8 100644 --- a/NewMod/Utilities/CoroutinesHelper.cs +++ b/NewMod/Utilities/CoroutinesHelper.cs @@ -7,6 +7,7 @@ using TMPro; using MiraAPI.Networking; using NewMod.Roles.NeutralRoles; +using Reactor.Utilities.Extensions; namespace NewMod.Utilities { @@ -185,6 +186,7 @@ public static IEnumerator UsePranksterAbilities(PlayerControl target) yield break; } } + yield return null; } } /// @@ -218,7 +220,7 @@ public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) ignoreColliders: true, ignoreSource: true ) - .Where(p => !p.Data.IsDead && !p.Data.Disconnected && p != PlayerControl.LocalPlayer) + .Where(p => !p.Data.IsDead && !p.Data.Disconnected) .ToList(); if (playersInRange.Count > 0) @@ -253,6 +255,7 @@ public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) } } } + yield return null; } } @@ -385,6 +388,7 @@ public static IEnumerator EnableMovementAfterDelay(PlayerControl target, float d if (target != null && !target.Data.IsDead) { target.moveable = true; + target.MyPhysics.inputHandler.enabled = true; } } /// @@ -417,5 +421,10 @@ public static IEnumerator ResetRepelEffect(PlayerControl target, float delay) target.MyPhysics.body.velocity = Vector2.zero; } } + public static IEnumerator DespawnCircle(GameObject go, float duration) + { + yield return new WaitForSeconds(duration); + go.Destroy(); + } } } diff --git a/NewMod/Utilities/PranksterUtilities.cs b/NewMod/Utilities/PranksterUtilities.cs index 6a871a0..ba8877a 100644 --- a/NewMod/Utilities/PranksterUtilities.cs +++ b/NewMod/Utilities/PranksterUtilities.cs @@ -21,6 +21,10 @@ public static class PranksterUtilities public static void CreatePranksterDeadBody(PlayerControl player, byte parentId) { var randPlayer = Utils.GetRandomPlayer(p => p.Data.IsDead); + if (randPlayer == null) + { + NewMod.Instance.Log.LogError("[PranksterUtilities] CreatePranksterDeadBody: Failed to create dead body, random player is null."); + } var deadBody = Object.Instantiate(GameManager.Instance.DeadBodyPrefab); deadBody.name = PranksterBodyName; deadBody.ParentId = parentId; diff --git a/NewMod/Utilities/Utils.cs b/NewMod/Utilities/Utils.cs index 7b5e25c..2aa4ea6 100644 --- a/NewMod/Utilities/Utils.cs +++ b/NewMod/Utilities/Utils.cs @@ -20,6 +20,8 @@ using NewMod.Roles.NeutralRoles; using MiraAPI.GameOptions; using NewMod.Options.Roles.InjectorOptions; +using MiraAPI.Hud; +using NewMod.Buttons.Pulseblade; namespace NewMod.Utilities { @@ -68,7 +70,15 @@ public static class Utils /// Maps a player ID to a TextMeshPro timer display for missions. /// public static Dictionary MissionTimer = new Dictionary(); + /// + /// A dictionary holding the strike kill counts for each player, indexed by their player ID. + /// + public static readonly Dictionary StrikeKills = new(); + /// + /// The faction associated with the current role. + /// + public static NewModFaction Faction { get; } /// /// Retrieves a PlayerControl instance by its player ID. /// @@ -353,6 +363,33 @@ public static void ResetMissionFailureCount() MissionFailureCount.Clear(); } + /// + /// Resets the strike count for all players. + /// Clears the stored strike kill counts for all players. + /// + public static void ResetStrikeCount() + { + StrikeKills.Clear(); + } + + /// + /// Registers a strike kill for a specific player. + /// Increments the strike kill count for the given killer player. + /// + /// The player who made the strike kill. + /// The player who was struck (victim). + public static void RegisterStrikeKill(PlayerControl killer, PlayerControl victim) + { + var playerId = killer.PlayerId; + StrikeKills[playerId] = StrikeKills.GetValueOrDefault(playerId) + 1; + } + /// + /// Retrieves the total number of strike kills for a specific player. + /// + /// The unique ID of the player whose strike count is being queried. + /// The number of strike kills for the specified player. + public static int GetStrikes(byte playerId) => StrikeKills.GetValueOrDefault(playerId); + /// /// Registers a player as having been injected by the Injector. /// Adds the player's ID to the injected players tracking list. @@ -373,15 +410,13 @@ public static int GetInjectedCount() return InjectedPlayerIds.Count; } - /// /// Clear's InjectedPlayerIds at end of the game /// - public static void ClearInjections() + public static void ResetInjections() { InjectedPlayerIds.Clear(); } - /// /// Sends an RPC to revive a player from a dead body. /// @@ -559,55 +594,64 @@ public static string GetMission(PlayerControl target, MissionType mission) MissionType.ReviveAndKill => "Revive a dead player using Necromancer powers and kill them again", _ => "Unknown mission." }; - switch (mission) + try { - case MissionType.KillMostWanted: - var gameObj = new GameObject(); - var arrow = gameObj.AddComponent(); - gameObj.transform.parent = mostwantedTarget.gameObject.transform; - gameObj.layer = 5; - var renderer = gameObj.AddComponent(); - renderer.sprite = NewModAsset.Arrow.LoadAsset(); - arrow.target = mostwantedTarget.transform.position; - arrow.image = renderer; - - SavePlayerRole(target.PlayerId, target.Data.Role); + switch (mission) + { + case MissionType.KillMostWanted: + NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: KillMostWanted"); + var gameObj = new GameObject(); + var arrow = gameObj.AddComponent(); + gameObj.transform.parent = mostwantedTarget.gameObject.transform; + gameObj.layer = 5; + var renderer = gameObj.AddComponent(); + renderer.sprite = NewModAsset.Arrow.LoadAsset(); + arrow.target = mostwantedTarget.transform.position; + arrow.image = renderer; + + SavePlayerRole(target.PlayerId, target.Data.Role); - if (!target.Data.Role.IsImpostor) - { target.RpcSetRole(RoleTypes.Impostor, true); - } - Coroutines.Start(CoroutinesHelper.CoHandleWantedTarget(arrow, mostwantedTarget, target)); - var rolesHistory = GetPlayerRolesHistory(target.PlayerId); - if (rolesHistory.Count > 0) - { - var lastIndex = rolesHistory.Count - 1; - var originalRole = rolesHistory[lastIndex]; - rolesHistory.RemoveAt(lastIndex); - target.RpcSetRole(originalRole.Role, true); - } - break; + Coroutines.Start(CoroutinesHelper.CoHandleWantedTarget(arrow, mostwantedTarget, target)); - case MissionType.CreateFakeBodies: - if (target.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to Create Dead Bodies")); - } - Coroutines.Start(CoroutinesHelper.UsePranksterAbilities(target)); - break; + var rolesHistory = GetPlayerRolesHistory(target.PlayerId); + if (rolesHistory.Count > 0) + { + var lastIndex = rolesHistory.Count - 1; + var originalRole = rolesHistory[lastIndex]; + rolesHistory.RemoveAt(lastIndex); + target.RpcSetRole(originalRole.Role, true); + } + break; - case MissionType.DrainEnergy: - if (target.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to drain nearby players'energy")); - } - Coroutines.Start(CoroutinesHelper.UseEnergyThiefAbilities(target)); - break; + case MissionType.CreateFakeBodies: + NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: CreateFakeBodies"); + if (target.AmOwner) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to Create Dead Bodies")); + } + Coroutines.Start(CoroutinesHelper.UsePranksterAbilities(target)); + break; - case MissionType.ReviveAndKill: - Coroutines.Start(CoroutinesHelper.CoReviveAndKill(target)); - break; + case MissionType.DrainEnergy: + NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: DrainEnergy"); + if (target.AmOwner) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to drain nearby players'energy")); + } + Coroutines.Start(CoroutinesHelper.UseEnergyThiefAbilities(target)); + break; + + case MissionType.ReviveAndKill: + NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: ReviveAndKill"); + Coroutines.Start(CoroutinesHelper.CoReviveAndKill(target)); + break; + } + } + catch (System.Exception ex) + { + NewMod.Instance.Log.LogError($"Failed to assign mission to {target.Data.PlayerName}. Reason: {ex.Message} | StackTrace: {ex.StackTrace}"); } return selectedMission; } @@ -636,7 +680,19 @@ public static void RpcMissionSuccess(PlayerControl source, PlayerControl target) { SpecialAgent.AssignedPlayer = null; } - target.Data.Role.buttonManager.SetEnabled(); + if (target.Data.Role is ICustomRole role) + { + if (RoleToButtonsMap.TryGetValue(role.GetType(), out var buttonTypes)) + { + foreach (var btnType in buttonTypes) + { + var btn = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == btnType); + + btn.Button.SetEnabled(); + } + } + } + } [MethodRpc((uint)CustomRPC.MissionFails)] @@ -665,7 +721,28 @@ public static void RpcMissionFails(PlayerControl source, PlayerControl target) { SpecialAgent.AssignedPlayer = null; } - target.Data.Role.buttonManager.SetEnabled(); + if (target.Data.Role is ICustomRole role) + { + if (RoleToButtonsMap.TryGetValue(role.GetType(), out var buttonTypes)) + { + foreach (var btnType in buttonTypes) + { + var btn = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == btnType); + + btn.Button.SetEnabled(); + } + } + } + + } + public static string GetFactionDisplay() + { + return Faction switch + { + NewModFaction.Apex => $"Apex", + NewModFaction.Entropy => $"Entropy", + _ => $"Unknown" + }; } /// @@ -710,8 +787,18 @@ public static void RpcAssignMission(PlayerControl source, PlayerControl target) target.myTasks.Insert(0, Missionmessage); // Disable the Role Player's Ability - target.Data.Role.buttonManager.SetDisabled(); + if (target.Data.Role is ICustomRole role) + { + if (RoleToButtonsMap.TryGetValue(role.GetType(), out var buttonTypes)) + { + foreach (var btnType in buttonTypes) + { + var btn = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == btnType); + btn.Button.SetDisabled(); + } + } + } Coroutines.Start(CoroutinesHelper.CoMissionTimer(target, 60f)); } @@ -727,7 +814,6 @@ public static IEnumerator CaptureScreenshot(string filePath) HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, false); SoundManager.Instance.PlaySound(clip, false, 1f, null); ScreenCapture.CaptureScreenshot(filePath, 4); - VisionaryUtilities.CapturedScreenshotPaths.Add(filePath); NewMod.Instance.Log.LogInfo($"Capturing screenshot at {System.IO.Path.GetFileName(filePath)}."); yield return new WaitForSeconds(0.2f); @@ -753,7 +839,6 @@ public static IEnumerator StartFeignDeath(PlayerControl player) showKillAnim: false, playKillSound: false); - SoundManager.Instance.PlaySound(clip, false, 1f, null); if (player.AmOwner) @@ -836,7 +921,8 @@ public static IEnumerator FadeAndDestroy(GameObject ghost, float fadeDuration) { typeof(Prankster), new() { typeof(FakeBodyButton) } }, { typeof(Revenant), new() { typeof(FeignDeathButton), typeof(DoomAwakening) } }, { typeof(SpecialAgent), new() { typeof(AssignButton) } }, - { typeof(TheVisionary), new() { typeof(CaptureButton), typeof(ShowScreenshotButton) } } + { typeof(TheVisionary), new() { typeof(CaptureButton), typeof(ShowScreenshotButton) } }, + { typeof(PulseBlade), new() { typeof(StrikeButton)}}, // TODO: Add Launchpad roles and their associated buttons here }; @@ -869,8 +955,8 @@ public enum SerumType // More Coming Soon! } - [MethodRpc((uint)CustomRPC.ApplySerum)] + [MethodRpc((uint)CustomRPC.ApplySerum)] /// /// Handles applying serum effects to target players for the Injector role. /// @@ -894,7 +980,8 @@ public static void RpcApplySerum(PlayerControl source, PlayerControl target, Ser { float duration = OptionGroupSingleton.Instance.ParalysisDuration; - target.MyPhysics.body.velocity = Vector2.zero; + target.moveable = false; + target.MyPhysics.inputHandler.enabled = false; Coroutines.Start(CoroutinesHelper.EnableMovementAfterDelay(target, duration)); break; @@ -903,12 +990,14 @@ public static void RpcApplySerum(PlayerControl source, PlayerControl target, Ser { float bounceDuration = OptionGroupSingleton.Instance.BounceDuration; float h = OptionGroupSingleton.Instance.BounceForceHorizontal; - float v = OptionGroupSingleton.Instance.BounceForceVertical; + //float v = OptionGroupSingleton.Instance.BounceForceVertical; float maxRotate = OptionGroupSingleton.Instance.BounceRotateEffect.Value; - Vector2 force = new(Random.Range(-h, h), Random.Range(-v, v)); + //Vector2 force = new(Random.Range(-h, h), Random.Range(-v, v)); + + //target.MyPhysics.body.AddForce(force); - target.MyPhysics.body.AddForce(force); + Effects.Bounce(target.transform, bounceDuration, h); if (OptionGroupSingleton.Instance.EnableBounceVariants) { @@ -928,7 +1017,7 @@ public static void RpcApplySerum(PlayerControl source, PlayerControl target, Ser foreach (var other in PlayerControl.AllPlayerControls) { - if (other == target || other.Data.IsDead && other.Data.Disconnected) continue; + if (other == target || other.Data.IsDead || other.Data.Disconnected) continue; float dist = Vector2.Distance(other.GetTruePosition(), target.GetTruePosition()); @@ -943,6 +1032,45 @@ public static void RpcApplySerum(PlayerControl source, PlayerControl target, Ser break; } RegisterPlayerInjection(target); + + if (source.AmOwner) + { + Helpers.CreateAndShowNotification($"Injected {target.Data.PlayerName} with {serumType}", new(0.9f, 0.3f, 0.1f), spr: NewModAsset.InjectIcon.LoadAsset()); + } + } + + /// + /// Tracks the camera on its current target for a given duration, + /// then restores its position to the original state. + /// Optionally applies a shake effect during the final moments. + /// + /// The instance to adjust. + /// The total duration, in seconds, to keep tracking before resetting. + /// + /// An coroutine that handles timing and the optional shake effect. + /// + public static IEnumerator CoShakeCamera(FollowerCamera cam, float duration) + { + float timeElapsed = 0f; + Vector3 originalPos = cam.transform.position; + float shakeThreshold = 1.5f; + + while (timeElapsed < duration) + { + timeElapsed += Time.deltaTime; + if ((duration - timeElapsed) <= shakeThreshold) + { + float shakeMagnitude = 0.3f; + Vector3 shakeOffset = Random.insideUnitSphere * shakeMagnitude; + cam.transform.localPosition = originalPos + shakeOffset; + } + else + { + cam.transform.localPosition = originalPos; + } + yield return null; + } + cam.transform.localPosition = originalPos; } } } diff --git a/NewMod/Utilities/VisionaryUtilities.cs b/NewMod/Utilities/VisionaryUtilities.cs index 402fb73..f088ed7 100644 --- a/NewMod/Utilities/VisionaryUtilities.cs +++ b/NewMod/Utilities/VisionaryUtilities.cs @@ -11,7 +11,20 @@ namespace NewMod.Utilities { public static class VisionaryUtilities { - public static List CapturedScreenshotPaths = new(); + /// + /// The active screenshot panel currently displayed on screen. + /// + public static GameObject _panel; + + /// + /// Indicates whether a screenshot is currently being displayed. + /// + public static bool _showing; + + /// + /// Gets whether the Visionary screenshot panel is currently active and showing. + /// + public static bool IsShowing => _showing; /// /// Gets the directory where Visionary screenshots are stored. If the directory does not exist, it is created. @@ -29,179 +42,132 @@ public static string ScreenshotDirectory } } - /// - /// Displays the most recent screenshot to the Visionary for a specified duration. - /// + // + /// Displays the most recent screenshot to the Visionary for a specified duration. + /// Skips if a meeting is active or another screenshot is already showing. /// The duration, in seconds, to display the screenshot. - /// An IEnumerator that retrieves the latest screenshot. + /// An IEnumerator coroutine to manage screenshot display. + /// public static IEnumerator ShowScreenshots(float displayDuration) { + if (MeetingHud.Instance) yield break; + if (_showing) yield break; string[] files = Directory.GetFiles(ScreenshotDirectory, "screenshot_*.png"); if (files.Length == 0) yield break; Array.Sort(files); - string latestScreenshot = files[files.Length - 1]; + string latestScreenshot = files[^1]; NewMod.Instance.Log.LogInfo($"Displaying the latest screenshot: {latestScreenshot}"); - while (!File.Exists(latestScreenshot)) + float t = 0f; + const float timeout = 3f; + + while (t < timeout) { + FileStream fs = null; + try + { + fs = new FileStream(latestScreenshot, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (fs.Length > 0) break; + } + finally { fs.Dispose(); } + t += Time.deltaTime; yield return null; } + if (t >= timeout) yield break; + byte[] data = File.ReadAllBytes(latestScreenshot); Texture2D tex = new Texture2D(2, 2); tex.LoadImage(data); Sprite screenshotSprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); - var screenshotPanel = new GameObject("Visionary_ScreenshotPanel"); - var canvas = screenshotPanel.AddComponent(); - canvas.renderMode = RenderMode.ScreenSpaceOverlay; - - var group = screenshotPanel.AddComponent(); - group.alpha = 0; + yield return ShowScreenshot(screenshotSprite, File.GetCreationTime(latestScreenshot), displayDuration); + } - var imageObj = new GameObject("ScreenshotImage"); - imageObj.transform.SetParent(screenshotPanel.transform, false); - var image = imageObj.AddComponent(); - image.sprite = screenshotSprite; - image.preserveAspect = true; + /// + /// Displays a screenshot sprite on screen with fade-in and fade-out effects. + /// + /// The screenshot sprite to display. + /// The time the screenshot was taken. + /// The duration, in seconds, to display the screenshot. + /// An IEnumerator coroutine + public static IEnumerator ShowScreenshot(Sprite sprite, DateTime timestamp, float duration) + { + if (_panel) Object.Destroy(_panel); + _panel = new GameObject("Visionary_ScreenshotPanel"); + _showing = true; - var rt = imageObj.GetComponent(); - rt.sizeDelta = new Vector2(800, 600); - rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0.5f, 0.5f); - rt.anchoredPosition = Vector2.zero; + var canvas = _panel.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = 1000; + _panel.AddComponent(); + _panel.AddComponent(); - var bgObj = new GameObject("BorderOnBG"); - bgObj.transform.SetParent(screenshotPanel.transform, false); - var bjImage = bgObj.AddComponent(); - bjImage.color = new Color(0f, 0f, 0f, 0.6f); + var group = _panel.GetComponent(); + group.alpha = 0f; + var bgObj = new GameObject("BorderOnBG"); + bgObj.transform.SetParent(_panel.transform, false); + var bg = bgObj.AddComponent(); + bg.color = new Color(0f, 0f, 0f, 0.6f); var bgRT = bgObj.GetComponent(); bgRT.anchorMin = bgRT.anchorMax = bgRT.pivot = new Vector2(0.5f, 0.5f); bgRT.sizeDelta = new Vector2(810, 610); bgRT.anchoredPosition = Vector2.zero; - bgObj.transform.SetAsFirstSibling(); - - var labelObj = new GameObject("Screenshot Label"); - labelObj.transform.SetParent(screenshotPanel.transform, false); - var label = labelObj.AddComponent(); - label.alignment = TextAlignmentOptions.Center; - label.fontSize = 20; - DateTime captureTime = File.GetCreationTime(latestScreenshot); - label.text = $"*Screenshot taken at: {captureTime.ToShortTimeString()}*"; + var imageObj = new GameObject("ScreenshotImage"); + imageObj.transform.SetParent(_panel.transform, false); + var img = imageObj.AddComponent(); + img.sprite = sprite; + img.preserveAspect = true; + var imgRT = imageObj.GetComponent(); + imgRT.sizeDelta = new Vector2(800, 600); + imgRT.anchorMin = imgRT.anchorMax = imgRT.pivot = new Vector2(0.5f, 0.5f); + imgRT.anchoredPosition = Vector2.zero; + + var labelObj = new GameObject("ScreenshotLabel"); + labelObj.transform.SetParent(_panel.transform, false); + var tmp = labelObj.AddComponent(); + tmp.alignment = TextAlignmentOptions.Center; + tmp.fontSize = 20; + tmp.text = $"*Screenshot taken at: {timestamp.ToShortTimeString()}*"; var labelRT = labelObj.GetComponent(); labelRT.anchorMin = labelRT.anchorMax = labelRT.pivot = new Vector2(0.5f, 0.5f); - labelRT.anchoredPosition = new Vector2(0, 380); labelRT.sizeDelta = new Vector2(800, 50); + labelRT.anchoredPosition = new Vector2(0, 380); - float fadeDuration = 1f; - float elapsed = 0f; - while (elapsed < fadeDuration) - { - elapsed += Time.deltaTime; - float alpha = Mathf.Clamp01(elapsed / fadeDuration); - group.alpha = alpha; - yield return null; - } - group.alpha = 1f; + float fade = 0.15f; + float e = 0f; + while (e < fade) { e += Time.deltaTime; group.alpha = Mathf.Clamp01(e / fade); yield return null; } - yield return new WaitForSeconds(displayDuration); + yield return new WaitForSeconds(duration); - elapsed = 0f; - while (elapsed < fadeDuration) - { - elapsed += Time.deltaTime; - float alpha = 1f - Mathf.Clamp01(elapsed / fadeDuration); - group.alpha = alpha; - yield return null; - } - group.alpha = 0f; + e = 0f; + while (e < fade) { e += Time.deltaTime; group.alpha = 1f - Mathf.Clamp01(e / fade); yield return null; } - Object.Destroy(screenshotPanel); + Object.Destroy(_panel); + _panel = null; + _showing = false; } - /// - /// Displays a screenshot from the given file path to the Visionary for a specified duration. + // + /// Loads and displays a screenshot from a given file path. + /// If the file does not exist, no action is taken. /// - /// The full file path of the screenshot to display. + /// The full path of the screenshot file to display. /// The duration, in seconds, to display the screenshot. - /// An IEnumerator that handles fading the screenshot in and out. + /// An IEnumerator coroutine for handling display. public static IEnumerator ShowScreenshotByPath(string filePath, float displayDuration) { if (!File.Exists(filePath)) yield break; byte[] data = File.ReadAllBytes(filePath); - Texture2D tex = new Texture2D(2, 2); + Texture2D tex = new(2, 2); tex.LoadImage(data); Sprite screenshotSprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); - var screenshotPanel = new GameObject("Visionary_ScreenshotPanel"); - var canvas = screenshotPanel.AddComponent(); - canvas.renderMode = RenderMode.ScreenSpaceOverlay; - - var group = screenshotPanel.AddComponent(); - group.alpha = 0; - - var imageObj = new GameObject("ScreenshotImage"); - imageObj.transform.SetParent(screenshotPanel.transform, false); - var image = imageObj.AddComponent(); - image.sprite = screenshotSprite; - image.preserveAspect = true; - - var rt = imageObj.GetComponent(); - rt.sizeDelta = new Vector2(800, 600); - rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0.5f, 0.5f); - rt.anchoredPosition = Vector2.zero; - - var bgObj = new GameObject("BorderOnBG"); - bgObj.transform.SetParent(screenshotPanel.transform, false); - var bjImage = bgObj.AddComponent(); - bjImage.color = new Color(0f, 0f, 0f, 0.6f); - - var bgRT = bgObj.GetComponent(); - bgRT.anchorMin = bgRT.anchorMax = bgRT.pivot = new Vector2(0.5f, 0.5f); - bgRT.sizeDelta = new Vector2(810, 610); - bgRT.anchoredPosition = Vector2.zero; - bgObj.transform.SetAsFirstSibling(); - - var labelObj = new GameObject("Screenshot Label"); - labelObj.transform.SetParent(screenshotPanel.transform, false); - var label = labelObj.AddComponent(); - label.alignment = TextAlignmentOptions.Center; - label.fontSize = 20; - DateTime captureTime = File.GetCreationTime(filePath); - label.text = $"*Screenshot taken at: {captureTime.ToShortTimeString()}*"; - - var labelRT = labelObj.GetComponent(); - labelRT.anchorMin = labelRT.anchorMax = labelRT.pivot = new Vector2(0.5f, 0.5f); - labelRT.anchoredPosition = new Vector2(0, 380); - labelRT.sizeDelta = new Vector2(800, 50); - - float fadeDuration = 1f; - float elapsed = 0f; - while (elapsed < fadeDuration) - { - elapsed += Time.deltaTime; - float alpha = Mathf.Clamp01(elapsed / fadeDuration); - group.alpha = alpha; - yield return null; - } - group.alpha = 1f; - - yield return new WaitForSeconds(displayDuration); - - elapsed = 0f; - while (elapsed < fadeDuration) - { - elapsed += Time.deltaTime; - float alpha = 1f - Mathf.Clamp01(elapsed / fadeDuration); - group.alpha = alpha; - yield return null; - } - group.alpha = 0f; - - Object.Destroy(screenshotPanel); + yield return ShowScreenshot(screenshotSprite, File.GetCreationTime(filePath), displayDuration); } /// diff --git a/libs/MiraAPI.dll b/libs/MiraAPI.dll new file mode 100644 index 0000000..3045efe Binary files /dev/null and b/libs/MiraAPI.dll differ