diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b93150f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: NewMod CI + +on: + push: + branches: + - main + - dev + - au-2025.3.25 + pull_request: + branches: + - main + - dev + - au-2025.3.25 + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + BuildingInsideCI: true + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + submodules: true + + - name: Cache Dependencies + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + ~/.cache/bepinex + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore NuGet Packages + run: dotnet restore NewMod/NewMod.csproj + + - name: Build NewMod (Release) + run: dotnet build NewMod/NewMod.csproj --configuration Release --no-restore + + - name: Build NewMod (Debug) + run: dotnet build NewMod/NewMod.csproj --configuration Debug --no-restore + + - name: Upload NewMod DLL (Release) + uses: actions/upload-artifact@v4 + with: + name: NewMod + path: NewMod/bin/Release/net6.0/NewMod.dll + + - name: Upload NewMod DLL (Debug) + uses: actions/upload-artifact@v4 + with: + name: NewMod-Debug + path: NewMod/bin/Debug/net6.0/NewMod.dll + + release: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Download Release DLL + uses: actions/download-artifact@v4 + with: + name: NewMod + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: v1.2.${{ github.run_number }} + files: NewMod/bin/Release/net6.0/NewMod.dll + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 670815b..0081692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ bin/ obj/ +libs/* References/ /packages/ riderModule.iml diff --git a/New-Mod.sln b/NewMod.sln similarity index 79% rename from New-Mod.sln rename to NewMod.sln index d2bada0..e9228dc 100644 --- a/New-Mod.sln +++ b/NewMod.sln @@ -1,5 +1,4 @@ -๏ปฟ -Microsoft Visual Studio Solution File, Format Version 12.00 +๏ปฟMicrosoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.002.0 MinimumVisualStudioVersion = 10.0.40219.1 @@ -9,12 +8,15 @@ Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU + ANDROID|Any CPU = ANDROID|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FC05DD49-CE3A-41F4-8452-37686FE23D0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FC05DD49-CE3A-41F4-8452-37686FE23D0A}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC05DD49-CE3A-41F4-8452-37686FE23D0A}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC05DD49-CE3A-41F4-8452-37686FE23D0A}.Release|Any CPU.Build.0 = Release|Any CPU + {FC05DD49-CE3A-41F4-8452-37686FE23D0A}.ANDROID|Any CPU.ActiveCfg = ANDROID|Any CPU + {FC05DD49-CE3A-41F4-8452-37686FE23D0A}.ANDROID|Any CPU.Build.0 = ANDROID|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/NewMod/Buttons/AssignButton.cs b/NewMod/Buttons/AssignButton.cs deleted file mode 100644 index 3a50e3b..0000000 --- a/NewMod/Buttons/AssignButton.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.SpecialAgentOptions; -using NewMod.Roles.NeutralRoles; -using UnityEngine; -using AmongUs.GameOptions; -using Object = UnityEngine.Object; -using NewMod.Utilities; -using Reactor.Utilities; - -namespace NewMod.Buttons -{ - [RegisterButton] - public class AssignButton : CustomActionButton - { - public override string Name => "ASSIGN MISSION"; - public override float Cooldown => OptionGroupSingleton.Instance.AssignCooldown; - public override int MaxUses => (int)OptionGroupSingleton.Instance.AssignMaxUses; - public override ButtonLocation Location => ButtonLocation.BottomRight; - public override float EffectDuration => 0f; - public override LoadableAsset Sprite => MiraAssets.Empty; - - private NetworkedPlayerInfo targetPlayer; - - public override bool Enabled(RoleBehaviour role) - { - return role is SpecialAgent; - } - public override bool CanUse() - { - return SpecialAgent.AssignedPlayer == null; - } - protected override void OnClick() - { - ShapeshifterRole shapeshifterRole = Object.Instantiate( - RoleManager.Instance.AllRoles.First(r => r.Role == RoleTypes.Shapeshifter) - ).TryCast(); - - ShapeshifterMinigame minigame = Object.Instantiate(shapeshifterRole.ShapeshifterMenu); - Object.Destroy(shapeshifterRole.gameObject); - minigame.name = "SpecialAgent Mission"; - minigame.transform.SetParent(Camera.main.transform, false); - minigame.transform.localPosition = new Vector3(0f, 0f, -50f); - Minigame.Instance = minigame; - minigame.MyTask = null; - minigame.MyNormTask = null; - - if (PlayerControl.LocalPlayer) - { - if (MapBehaviour.Instance) - { - MapBehaviour.Instance.Close(); - } - PlayerControl.LocalPlayer.MyPhysics.SetNormalizedVelocity(Vector2.zero); - } - - minigame.StartCoroutine(minigame.CoAnimateOpen()); - DestroyableSingleton.Instance.Analytics.MinigameOpened(PlayerControl.LocalPlayer.Data, minigame.TaskType); - minigame.potentialVictims = new Il2CppSystem.Collections.Generic.List(); - - List playerList = PlayerControl.AllPlayerControls.ToArray() - .Where(p => !p.Data.IsDead && !p.Data.Disconnected && p.PlayerId != PlayerControl.LocalPlayer.PlayerId) - .ToList(); - - Il2CppSystem.Collections.Generic.List uiElements = new(); - - for (int i = 0; i < playerList.Count; i++) - { - var player = playerList[i]; - int num = i % 3; - int num2 = i / 3; - bool flag = PlayerControl.LocalPlayer.Data.Role.NameColor == player.Data.Role.NameColor; - - ShapeshifterPanel panel = Object.Instantiate(minigame.PanelPrefab, minigame.transform); - panel.transform.localPosition = new Vector3( - minigame.XStart + num * minigame.XOffset, - minigame.YStart + num2 * minigame.YOffset, - -1f - ); - - NetworkedPlayerInfo playerInfo = player.Data; - - panel.SetPlayer(i, playerInfo, (Il2CppSystem.Action)(() => - { - SpecialAgent.AssignedPlayer = playerInfo.Object; - - Utils.AssignMission(SpecialAgent.AssignedPlayer); - - if (OptionGroupSingleton.Instance.TargetCameraTracking) - { - var cam = Camera.main.GetComponent(); - if (cam != null) - { - cam.SetTarget(targetPlayer.Object); - Coroutines.Start(CoResetCamera(cam, OptionGroupSingleton.Instance.CameraTrackingDuration)); - } - } - minigame.Close(); - })); - - panel.NameText.color = flag ? player.Data.Role.NameColor : Color.white; - minigame.potentialVictims.Add(panel); - uiElements.Add(panel.Button); - } - - ControllerManager.Instance.OpenOverlayMenu( - minigame.name, - minigame.BackButton, - minigame.DefaultButtonSelected, - uiElements - ); - } - public static IEnumerator CoResetCamera(FollowerCamera cam, float duration) - { - float timeElapsed = 0f; - - while (timeElapsed < duration) - { - timeElapsed += Time.deltaTime; - yield return null; - } - - if (cam != null) - { - cam.SetTarget(PlayerControl.LocalPlayer); - } - } - } -} diff --git a/NewMod/Buttons/CaptureButton.cs b/NewMod/Buttons/CaptureButton.cs deleted file mode 100644 index fa60414..0000000 --- a/NewMod/Buttons/CaptureButton.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.IO; -using MiraAPI.Utilities.Assets; -using MiraAPI.Hud; -using MiraAPI.GameOptions; -using NewMod.Options.Roles.VisionaryOptions; -using NewMod.Roles.CrewmateRoles; -using UnityEngine; -using Reactor.Utilities; -using NewMod.Utilities; - -namespace NewMod.Buttons -{ - [RegisterButton] - public class CaptureButton : CustomActionButton - { - public override string Name => "Capture"; - public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; - public override float EffectDuration => 0; - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; - public override LoadableAsset Sprite => NewModAsset.Camera; - public override ButtonLocation Location => ButtonLocation.BottomLeft; - protected override void OnClick() - { - var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); - string path = Path.Combine(VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png"); - Coroutines.Start(Utils.CaptureScreenshot(path)); - } - public override bool Enabled(RoleBehaviour role) - { - return role is TheVisionary; - } - } -} diff --git a/NewMod/Buttons/DrainButton.cs b/NewMod/Buttons/DrainButton.cs deleted file mode 100644 index 0acdea4..0000000 --- a/NewMod/Buttons/DrainButton.cs +++ /dev/null @@ -1,60 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.EnergyThiefOptions; -using NewMod.Roles.NeutralRoles; -using UnityEngine; -using NewMod.Utilities; - -namespace NewMod.Buttons; - -[RegisterButton] -public class DrainButton : CustomActionButton -{ - public override string Name => "DRAIN"; - public override float Cooldown => OptionGroupSingleton.Instance.DrainCooldown; - public override int MaxUses => (int)OptionGroupSingleton.Instance.DrainMaxUses; - public override ButtonLocation Location => ButtonLocation.BottomRight; - public override float EffectDuration => 0f; - public override LoadableAsset Sprite => MiraAssets.Empty; - public override PlayerControl GetTarget() - { - return PlayerControl.LocalPlayer.GetClosestPlayer(true, Distance, false, p => !p.Data.IsDead && !p.Data.Disconnected); - } - public override void SetOutline(bool active) - { - Target?.cosmetics.SetOutline(active, new Il2CppSystem.Nullable(Color.magenta)); - } - public override bool IsTargetValid(PlayerControl? target) - { - return true; - } - public override bool Enabled(RoleBehaviour role) - { - return role is EnergyThief; - } - protected override void OnClick() - { - PendingEffectManager.AddPendingEffect(Target); - - Utils.RecordDrainCount(PlayerControl.LocalPlayer); - - if (PlayerControl.LocalPlayer.AmOwner) - { - HudManager.Instance.Notifier.AddDisconnectMessage($"The Drain effect will be applied to {Target.Data.PlayerName} after the meeting ends."); - } - Utils.waitingPlayers.Add(PlayerControl.LocalPlayer); - } - - public override bool CanUse() - { - if (Utils.waitingPlayers.Contains(PlayerControl.LocalPlayer)) - { - return false; - } - return base.CanUse(); - } -} - - \ No newline at end of file diff --git a/NewMod/Buttons/EnergyThief/DrainButton.cs b/NewMod/Buttons/EnergyThief/DrainButton.cs new file mode 100644 index 0000000..61000cd --- /dev/null +++ b/NewMod/Buttons/EnergyThief/DrainButton.cs @@ -0,0 +1,116 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.EnergyThiefOptions; +using ET = NewMod.Roles.NeutralRoles.EnergyThief; +using UnityEngine; +using NewMod.Utilities; + +namespace NewMod.Buttons.EnergyThief +{ + /// + /// Defines a custom action button for the role. + /// + public class DrainButton : CustomActionButton + { + /// + /// Gets the display name for this button. + /// + public override string Name => "Drain"; + + /// + /// Gets the cooldown value for this button, based on EnergyThiefOptions. + /// + public override float Cooldown => OptionGroupSingleton.Instance.DrainCooldown; + + /// + /// Gets the maximum number of uses for this button, based on EnergyThiefOptions. + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.DrainMaxUses; + + /// + /// The on-screen position of this button. + /// + public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// The duration of the effect applied by this button; in this case, zero. + /// + public override float EffectDuration => 0f; + + /// + /// Gets the sprite asset for this button. Currently set to an empty sprite. + /// + public override LoadableAsset Sprite => MiraAssets.Empty; + + /// + /// Determines the target for this button action. + /// + /// 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); + } + + /// + /// Enables or disables an outline around the targeted player. + /// + /// Whether to enable or disable the outline. + public override void SetOutline(bool active) + { + Target?.cosmetics.SetOutline(active, new Il2CppSystem.Nullable(Color.magenta)); + } + + /// + /// Determines whether the targeted player is valid for draining. + /// + /// The candidate player. + /// Always true in this implementation. + public override bool IsTargetValid(PlayerControl target) + { + return true; + } + + /// + /// Specifies whether the button is enabled for a given role. + /// + /// The role to check. + /// True if the role is EnergyThief, otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is ET; + } + + /// + /// Invoked when the button is clicked. Records a drain count, queues a pending effect, + /// notifies the local player, and locks the button from reuse until the meeting ends. + /// + protected override void OnClick() + { + PendingEffectManager.AddPendingEffect(Target); + + Utils.RecordDrainCount(PlayerControl.LocalPlayer); + + if (PlayerControl.LocalPlayer.AmOwner) + { + HudManager.Instance.Notifier.AddDisconnectMessage($"The Drain effect will be applied to {Target.Data.PlayerName} after the meeting ends."); + } + Utils.waitingPlayers.Add(PlayerControl.LocalPlayer); + } + + /// + /// Checks if the button can be used, preventing usage if the local player + /// is already in the waitingPlayers set. + /// + /// True if usable, otherwise false. + public override bool CanUse() + { + if (Utils.waitingPlayers.Contains(PlayerControl.LocalPlayer)) + { + return false; + } + return base.CanUse(); + } + } +} diff --git a/NewMod/Buttons/FakeBodyButton.cs b/NewMod/Buttons/FakeBodyButton.cs deleted file mode 100644 index c42e091..0000000 --- a/NewMod/Buttons/FakeBodyButton.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.PranksterOptions; -using NewMod.Roles.NeutralRoles; -using UnityEngine; -using NewMod.Utilities; - -namespace NewMod.Buttons -{ - [RegisterButton] - public class FakeBodyButton : CustomActionButton - { - public override string Name => "Prank"; - public override float Cooldown => OptionGroupSingleton.Instance.PrankCooldown; - public override int MaxUses => (int)OptionGroupSingleton.Instance.PrankMaxUses; - public override ButtonLocation Location => ButtonLocation.BottomRight; - public override float EffectDuration => 0f; - public override LoadableAsset Sprite => NewModAsset.DeadBodySprite; - protected override void OnClick() - { - PranksterUtilities.CreatePranksterDeadBody(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.PlayerId); - } - public override bool Enabled(RoleBehaviour role) - { - return role is Prankster; - } - } -} \ No newline at end of file diff --git a/NewMod/Buttons/Necromancer/ReviveButton.cs b/NewMod/Buttons/Necromancer/ReviveButton.cs new file mode 100644 index 0000000..6230084 --- /dev/null +++ b/NewMod/Buttons/Necromancer/ReviveButton.cs @@ -0,0 +1,104 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.NecromancerOptions; +using NewMod.Roles.ImpostorRoles; +using UnityEngine; +using NewMod.Utilities; + +namespace NewMod.Buttons.Necromancer +{ + /// + /// Defines a custom action button for the role. + /// + public class ReviveButton : CustomActionButton + { + /// + /// The name displayed on the button. Intentionally left empty to show an existing name elsewhere. + /// + public override string Name => ""; // It's currently empty since the button has already a name on it + + /// + /// Gets the cooldown time for this button, based on . + /// + public override float Cooldown => OptionGroupSingleton.Instance.ButtonCooldown; + + /// + /// Gets the maximum number of uses for this button, based on . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.AbilityUses; + + /// + /// Determines how long the effect from clicking the button lasts. In this case, no duration is set. + /// + public override float EffectDuration => 0f; + + /// + /// Defines where on the screen this button should appear. + /// + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + /// + /// The visual icon for this button, set to the necromancer sprite asset. + /// + public override LoadableAsset Sprite => NewModAsset.NecromancerButton; + + /// + /// Invoked when the revive button is clicked. Plays a sound and revives the nearest dead body. + /// + protected override void OnClick() + { + SoundManager.Instance.PlaySound(NewModAsset.ReviveSound?.LoadAsset(), false, 2f); + + var closestBody = Utils.GetClosestBody(); + if (closestBody != null) + { + Utils.RpcRevive(closestBody); + } + } + + /// + /// Determines whether this button is enabled for the role, returning true if the role is . + /// + /// The current player's role. + /// True if the role is Necromancer; otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is NecromancerRole; + } + + /// + /// Checks whether the player can currently use the revive button, ensuring cooldowns, ability uses, and conditions are met. + /// + /// True if all requirements to use this button are met; otherwise false. + public override bool CanUse() + { + bool isTimerDone = Timer <= 0; + bool hasUsesLeft = UsesLeft > 0; + var closestBody = Utils.GetClosestBody(); + bool isNearDeadBody = closestBody != null; + bool isFakeBody = isNearDeadBody && PranksterUtilities.IsPranksterBody(closestBody); + + if (closestBody == null) + { + return false; + } + + bool wasNotKilledByNecromancer = true; + var deadBody = closestBody.GetComponent(); + if (deadBody != null) + { + var killedPlayer = GameData.Instance.GetPlayerById(deadBody.ParentId)?.Object; + if (killedPlayer != null) + { + var killer = Utils.GetKiller(killedPlayer); + if (killer != null && killer.Data.Role is NecromancerRole) + { + wasNotKilledByNecromancer = false; + } + } + } + return isTimerDone && hasUsesLeft && isNearDeadBody && wasNotKilledByNecromancer && !isFakeBody; + } + } +} diff --git a/NewMod/Buttons/NecromancerButton.cs b/NewMod/Buttons/NecromancerButton.cs deleted file mode 100644 index 2207e81..0000000 --- a/NewMod/Buttons/NecromancerButton.cs +++ /dev/null @@ -1,64 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.NecromancerOptions; -using NewMod.Roles.ImpostorRoles; -using UnityEngine; -using NewMod.Utilities; - -namespace NewMod.Buttons; - -[RegisterButton] -public class NecromancerButton : CustomActionButton -{ - public override string Name => ""; // It's currently empty since the button has already a name on it - public override float Cooldown => OptionGroupSingleton.Instance.ButtonCooldown; - public override int MaxUses => (int)OptionGroupSingleton.Instance.AbilityUses; - public override float EffectDuration => 0f; - public override ButtonLocation Location => ButtonLocation.BottomLeft; - public override LoadableAsset Sprite => NewModAsset.NecromancerButton; - protected override void OnClick() - { - NewMod.Instance.Log.LogMessage("Button Clicked!"); - - var closestBody = Utils.GetClosestBody(); - if (closestBody != null) - { - Utils.RpcRevive(closestBody); - } - } - public override bool Enabled(RoleBehaviour role) - { - return role is NecromancerRole; - } - public override bool CanUse() - { - bool isTimerDone = Timer <= 0; - bool hasUsesLeft = UsesLeft > 0; - var closestBody = Utils.GetClosestBody(); - bool isNearDeadBody = closestBody != null; - bool isFakeBody = isNearDeadBody && PranksterUtilities.IsPranksterBody(closestBody); - - if (closestBody == null) - { - return false; - } - - bool wasNotKilledByNecromancer = true; - var deadBody = closestBody.GetComponent(); - if (deadBody != null) - { - var killedPlayer = GameData.Instance.GetPlayerById(deadBody.ParentId)?.Object; - if (killedPlayer != null) - { - var killer = Utils.GetKiller(killedPlayer); - if (killer != null && killer.Data.Role is NecromancerRole) - { - wasNotKilledByNecromancer = false; - } - } - } - return isTimerDone && hasUsesLeft && isNearDeadBody && wasNotKilledByNecromancer && !isFakeBody; - } -} - \ No newline at end of file diff --git a/NewMod/Buttons/Overload/OverloadButton.cs b/NewMod/Buttons/Overload/OverloadButton.cs new file mode 100644 index 0000000..934fb94 --- /dev/null +++ b/NewMod/Buttons/Overload/OverloadButton.cs @@ -0,0 +1,122 @@ +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Overload +{ + /// + /// Defines a custom action button for the Overload role. + /// This button mimics another role's ability by adopting its appearance and functionality. + /// + public class OverloadButton : CustomActionButton + { + /// + /// The display text shown on the button UI. + /// Set by the absorbed ability. + /// + public string absorbedText = ""; + + /// + /// The sprite icon used on the button. + /// Loaded from the absorbed ability. + /// + public LoadableAsset absorbedSprite; + + /// + /// The method invoked when the Overload button is clicked. + /// Mirrors the absorbed button's behavior. + /// + public System.Action absorbedOnClick; + + /// + /// Maximum number of times the button can be used. + /// Mirrors the absorbed button's uses. + /// + public int absorbedMaxUses; + + /// + /// Cooldown in seconds for reusing the button. + /// Mirrors the absorbed button's cooldown. + /// + public float absorbedCooldown; + + /// + /// The name displayed on the button. + /// + public override string Name => absorbedText; + + /// + /// Cooldown duration before the button can be reused. + /// + public override float Cooldown => absorbedCooldown; + + /// + /// Number of remaining uses. Zero means unlimited. + /// + public override int MaxUses => absorbedMaxUses; + + /// + /// Determines how long the effect from clicking the button lasts. In this case, no duration is set. + /// + public override float EffectDuration => 0f; + + /// + /// The UI position for the button. + /// + public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// The icon displayed on the button. + /// + public override LoadableAsset Sprite => absorbedSprite; + + /// + /// Copies functionality and appearance from another role's button. + /// + /// The button to absorb. + public void Absorb(CustomActionButton target) + { + absorbedText = target.Name; + absorbedCooldown = target.Cooldown; + absorbedMaxUses = target.MaxUses; + absorbedSprite = target.Sprite; + absorbedOnClick = () => target.GetType().GetMethod("OnClick", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.Invoke(target, null); + + OverrideName(absorbedText); + OverrideSprite(absorbedSprite.LoadAsset()); + SetUses(absorbedMaxUses); + SetTimer(0f); + } + + /// + /// Called when the player presses the button. + /// + protected override void OnClick() + { + absorbedOnClick?.Invoke(); + + } + + /// + /// Determines whether this button should be active on the HUD. + /// Only visible when the role is Overload and an ability has been absorbed. + /// + /// The role of the player. + /// True if Overload with a valid absorbed ability. + public override bool Enabled(RoleBehaviour role) + { + return role is OverloadRole && absorbedOnClick != null; + } + + /// + /// Determines if the button can currently be pressed. + /// + /// True if usable. + public override bool CanUse() + { + return base.CanUse() && absorbedOnClick != null; + } + } +} diff --git a/NewMod/Buttons/Prankster/FakeBodyButton.cs b/NewMod/Buttons/Prankster/FakeBodyButton.cs new file mode 100644 index 0000000..b02a05c --- /dev/null +++ b/NewMod/Buttons/Prankster/FakeBodyButton.cs @@ -0,0 +1,74 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.PranksterOptions; +using PRK = NewMod.Roles.NeutralRoles.Prankster; +using UnityEngine; +using NewMod.Utilities; + +namespace NewMod.Buttons.Prankster +{ + /// + /// Defines a custom action button for the role. + /// + public class FakeBodyButton : CustomActionButton + { + /// + /// Gets the name displayed on the button. + /// + public override string Name => "Prank"; + + /// + /// Gets the cooldown duration for using the prank ability, based on . + /// + public override float Cooldown => OptionGroupSingleton.Instance.PrankCooldown; + + /// + /// Gets the maximum number of times the prank ability can be used, based on . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.PrankMaxUses; + + /// + /// Determines where on the screen this button will appear. + /// + public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// The duration of any effect caused by this button press; in this case, no effect duration is used. + /// + public override float EffectDuration => 0f; + + /// + /// The sprite asset representing this button. + /// + public override LoadableAsset Sprite => NewModAsset.DeadBodySprite; + + /// + /// Checks whether the Prankster can use this button, ensuring that there is at least one dead player in the game. + /// + /// True if the base conditions are met and there is a dead player, otherwise false. + public override bool CanUse() + { + return base.CanUse() && Utils.AnyDeadPlayer(); + } + + /// + /// Called when the button is clicked. Spawns a fake dead body at the local player's position. + /// + protected override void OnClick() + { + PranksterUtilities.CreatePranksterDeadBody(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.PlayerId); + } + + /// + /// Determines whether this button is enabled for the specified role. + /// + /// The player's current role. + /// True if the role is , otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is PRK; + } + } +} diff --git a/NewMod/Buttons/Revenant/DoomAwakening.cs b/NewMod/Buttons/Revenant/DoomAwakening.cs new file mode 100644 index 0000000..fedbb5b --- /dev/null +++ b/NewMod/Buttons/Revenant/DoomAwakening.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using NewMod.Options.Roles.RevenantOptions; +using RV = NewMod.Roles.ImpostorRoles.Revenant; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Revenant +{ + /// + /// Defines a custom action button for the role. + /// + public class DoomAwakening : CustomActionButton + { + /// + /// The name displayed on the button. + /// + public override string Name => ""; + + /// + /// Cooldown time for this ability, as configured in . + /// + public override float Cooldown => OptionGroupSingleton.Instance.DoomAwakeningCooldown; + + /// + /// Maximum uses allowed for this ability, as configured in . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.DoomAwakeningMaxUses; + + /// + /// Defines the on-screen position of this button. + /// + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + /// + /// Determines how long the effect lasts. Configured in . + /// + public override float EffectDuration => OptionGroupSingleton.Instance.DoomAwakeningDuration; + + /// + /// The icon or sprite representing this button. + /// + public override LoadableAsset Sprite => NewModAsset.DoomAwakeningButton; + + /// + /// Specifies whether this button is enabled for the specified role. + /// + /// The role under consideration. + /// True if the role is , otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is RV; + } + + /// + /// Checks if this button can be used, ensuring that Feign Death has already been used. + /// + /// True if base conditions are met and the player has used Feign Death, otherwise false. + public override bool CanUse() + { + return base.CanUse() && RV.HasUsedFeignDeath; + } + + /// + /// Invoked when the button is clicked. Starts the Doom Awakening sequence. + /// + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + Coroutines.Start(StartDoomAwakening(player)); + } + public static List killedPlayers = new(); + + /// + /// Executes the Doom Awakening effect, increasing speed, fading the screen, and killing nearby players. + /// + /// The PlayerControl instance of the local player. + /// An IEnumerator for coroutine control. + public System.Collections.IEnumerator StartDoomAwakening(PlayerControl player) + { + float originalSpeed = player.MyPhysics.Speed; + player.MyPhysics.Speed *= 2f; + + var clip = NewModAsset.DoomAwakeningSound.LoadAsset(); + SoundManager.Instance.PlaySound(clip, true, 1f, null); + + var fullScreen = HudManager.Instance.FullScreen; + fullScreen.color = new Color(1f, 0f, 0f, 0f); + fullScreen.gameObject.SetActive(true); + + float fadeInTime = 0.5f; + + // Fade in to a red overlay + for (float t = 0; t < fadeInTime; t += Time.deltaTime) + { + float alpha = Mathf.Lerp(0f, 0.3f, t / fadeInTime); + fullScreen.color = new Color(1f, 0f, 0f, alpha); + yield return null; + } + fullScreen.color = new Color(1f, 0f, 0f, 0.3f); + + float duration = EffectDuration; + float timer = 0f; + int killCount = 0; + + float ghostInterval = 0.2f; + float ghostTimer = 0f; + + Queue ghosts = new Queue(); + SpriteRenderer playerRenderer = player.cosmetics.normalBodySprite.BodySprite; + + while (timer < duration) + { + timer += Time.deltaTime; + ghostTimer += Time.deltaTime; + + if (ghostTimer >= ghostInterval && player.MyPhysics.Speed > 0.01f) + { + ghostTimer = 0f; + GameObject ghost = new("Revenant-Ghost"); + var ghostRenderer = ghost.AddComponent(); + ghostRenderer.sprite = playerRenderer.sprite; + ghostRenderer.flipX = playerRenderer.flipX; + ghostRenderer.flipY = playerRenderer.flipY; + ghostRenderer.sharedMaterial = playerRenderer.sharedMaterial; + PlayerMaterial.SetColors(player.Data.DefaultOutfit.ColorId, ghostRenderer); + ghostRenderer.sortingLayerID = playerRenderer.sortingLayerID; + ghostRenderer.sortingOrder = playerRenderer.sortingOrder + 1; + ghost.transform.position = player.transform.position; + ghost.transform.rotation = player.transform.rotation; + ghost.transform.localScale = player.transform.lossyScale; + + Coroutines.Start(Utils.FadeAndDestroy(ghost, 1f)); + ghosts.Enqueue(ghost); + + // Limit the number of ghost sprites + if (ghosts.Count > 5) + { + var oldGhost = ghosts.Dequeue(); + if (oldGhost != null) + Object.Destroy(oldGhost); + } + } + // Kill any nearby players + foreach (var target in PlayerControl.AllPlayerControls) + { + if (target == player || target.Data.IsDead || target.Data.Disconnected || target.inVent || target.Data.Role.IsImpostor) + continue; + + if (Vector2.Distance(player.GetTruePosition(), target.GetTruePosition()) < 1f) + { + player.RpcCustomMurder(target, + didSucceed: true, + resetKillTimer: false, + createDeadBody: true, + teleportMurderer: false, + showKillAnim: false, + playKillSound: true); + killCount++; + killedPlayers.Add(target); + } + if (target.AmOwner) + { + SoundManager.Instance.PlaySound(NewModAsset.DoomAwakeningEndSound.LoadAsset(), false, 1f, null); + } + } + yield return null; + } + if (killedPlayers.Count >= 3) + { + SoundManager.Instance.PlaySound(NewModAsset.DoomAwakeningEndSound.LoadAsset(), false, 1f, null); + } + + // Fade out the red overlay + float fadeOutTime = 0.5f; + for (float t = 0f; t < fadeOutTime; t += Time.deltaTime) + { + float alpha = Mathf.Lerp(0.3f, 0f, t / fadeOutTime); + fullScreen.color = new Color(1f, 0f, 0f, alpha); + yield return null; + } + fullScreen.gameObject.SetActive(false); + + // Restore original speed and conclude + player.MyPhysics.Speed = originalSpeed; + 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); + killedPlayers.Clear(); + } + } +} diff --git a/NewMod/Buttons/Revenant/FeignDeathButton.cs b/NewMod/Buttons/Revenant/FeignDeathButton.cs new file mode 100644 index 0000000..af97c93 --- /dev/null +++ b/NewMod/Buttons/Revenant/FeignDeathButton.cs @@ -0,0 +1,74 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.RevenantOptions; +using Rev = NewMod.Roles.ImpostorRoles.Revenant; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Revenant +{ + /// + /// Defines a custom action button for the role. + /// + public class FeignDeathButton : CustomActionButton + { + /// + /// The name displayed on the button. + /// + public override string Name => "Feign Death"; + + /// + /// The cooldown time for the Feign Death ability, as set in . + /// + public override float Cooldown => OptionGroupSingleton.Instance.FeignDeathCooldown; + + /// + /// The maximum uses of the Feign Death ability, as set in . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.FeignDeathMaxUses; + + /// + /// Determines where on the screen this button appears. + /// + public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// The duration of any effect from this button. In this case, zero. + /// + public override float EffectDuration => 0f; + + /// + /// The icon or sprite used for this button. Here, set to an empty sprite. + /// + public override LoadableAsset Sprite => MiraAssets.Empty; + + /// + /// Specifies whether the button is enabled for the given role, ensuring Feign Death hasn't been used yet. + /// + /// The player's current role. + /// True if the role is and Feign Death hasn't been used, otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is Rev && !Rev.HasUsedFeignDeath; + } + /// + /// Checks if this button can be used + /// + /// True if base conditions are met and the player hasn't used Feign Death; otherwise, false. + public override bool CanUse() + { + return base.CanUse() && !Rev.HasUsedFeignDeath; + } + + /// + /// Invoked when the Feign Death button is clicked, starting the feign death coroutine. + /// + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + Coroutines.Start(Utils.StartFeignDeath(player)); + } + } +} diff --git a/NewMod/Buttons/ShowScreenshotButton.cs b/NewMod/Buttons/ShowScreenshotButton.cs deleted file mode 100644 index c71aeb7..0000000 --- a/NewMod/Buttons/ShowScreenshotButton.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MiraAPI.Utilities.Assets; -using MiraAPI.Hud; -using MiraAPI.GameOptions; -using NewMod.Options.Roles.VisionaryOptions; -using NewMod.Roles.CrewmateRoles; -using UnityEngine; -using NewMod.Utilities; -using Reactor.Utilities; -using System.Linq; - -namespace NewMod.Buttons -{ - [RegisterButton] - public class ShowScreenshotButton : CustomActionButton - { - public override string Name => "ShowScreenshot"; - public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; - public override float EffectDuration => 0; - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; - public override LoadableAsset Sprite => MiraAssets.Empty; - public override ButtonLocation Location => ButtonLocation.BottomRight; - public override bool CanUse() - { - return base.CanUse() && VisionaryUtilities.CapturedScreenshotPaths.Any(); - } - protected override void OnClick() - { - Coroutines.Start(VisionaryUtilities.ShowScreenshots(OptionGroupSingleton.Instance.MaxDisplayDuration)); - } - public override bool Enabled(RoleBehaviour role) - { - return role is TheVisionary; - } - } -} diff --git a/NewMod/Buttons/SpecialAgent/AssignButton.cs b/NewMod/Buttons/SpecialAgent/AssignButton.cs new file mode 100644 index 0000000..6302150 --- /dev/null +++ b/NewMod/Buttons/SpecialAgent/AssignButton.cs @@ -0,0 +1,129 @@ +using System.Collections; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.SpecialAgentOptions; +using SA = NewMod.Roles.NeutralRoles.SpecialAgent; +using UnityEngine; +using NewMod.Utilities; +using Reactor.Utilities; + +namespace NewMod.Buttons.SpecialAgent +{ + /// + /// Defines a custom action button for the role. + /// + public class AssignButton : CustomActionButton + { + /// + /// The name displayed on this button. + /// + public override string Name => "Assign Mission"; + + /// + /// The cooldown time before this button can be used again, based on . + /// + public override float Cooldown => OptionGroupSingleton.Instance.AssignCooldown; + + /// + /// The maximum number of times the assign action can be performed, based on . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.AssignMaxUses; + + /// + /// The on-screen location of this button. + /// + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + /// + /// The duration of any effect triggered by this button; here, it's zero. + /// + public override float EffectDuration => 0f; + + /// + /// The sprite icon representing this button. + /// + public override LoadableAsset Sprite => NewModAsset.SpecialAgentButton; + + /// + /// Specifies whether this button is enabled for the specified role. + /// + /// The current role to check. + /// True if the role is , otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is SA; + } + + /// + /// Checks whether the player can use this button, ensuring no one has been assigned a mission yet. + /// + /// True if base conditions are met and no player is assigned, otherwise false. + public override bool CanUse() + { + return base.CanUse() && SA.AssignedPlayer == null; + } + + /// + /// Invoked when the button is clicked. Opens a custom player menu to pick a mission target. + /// + protected override void OnClick() + { + CustomPlayerMenu menu = CustomPlayerMenu.Create(); + + SetTimerPaused(true); + + menu.Begin( + player => !player.Data.IsDead && + !player.Data.Disconnected && + player.PlayerId != PlayerControl.LocalPlayer.PlayerId, + player => + { + SA.AssignedPlayer = player; + Utils.RpcAssignMission(PlayerControl.LocalPlayer, SA.AssignedPlayer); + + if (OptionGroupSingleton.Instance.TargetCameraTracking) + { + var cam = Camera.main.GetComponent(); + cam?.SetTarget(player); + Coroutines.Start(CoResetCamera(cam, OptionGroupSingleton.Instance.CameraTrackingDuration)); + } + menu.Close(); + SetTimerPaused(false); + } + ); + } + + /// + /// Resets the camera target to the local player after a specified duration, optionally shaking the camera at the end. + /// + /// The to reset. + /// How long to track the target before resetting. + /// An for coroutine control. + public static IEnumerator CoResetCamera(FollowerCamera cam, float duration) + { + float timeElapsed = 0f; + Vector3 originalPosition = cam.transform.position; + float shakeThreshold = 1.5f; + bool shouldShake = OptionGroupSingleton.Instance.ShouldShakeCamera; + + while (timeElapsed < duration) + { + timeElapsed += Time.deltaTime; + if (shouldShake && (duration - timeElapsed) <= shakeThreshold) + { + float shakeMagnitude = 0.3f; + Vector3 shakeOffset = Random.insideUnitSphere * shakeMagnitude; + cam.transform.localPosition = originalPosition + shakeOffset; + } + else + { + cam.transform.localPosition = originalPosition; + } + yield return null; + } + cam.transform.localPosition = originalPosition; + cam?.SetTarget(PlayerControl.LocalPlayer); + } + } +} diff --git a/NewMod/Buttons/Visionary/CaptureButton.cs b/NewMod/Buttons/Visionary/CaptureButton.cs new file mode 100644 index 0000000..a721073 --- /dev/null +++ b/NewMod/Buttons/Visionary/CaptureButton.cs @@ -0,0 +1,68 @@ +using System.IO; +using MiraAPI.Utilities.Assets; +using MiraAPI.Hud; +using MiraAPI.GameOptions; +using NewMod.Options.Roles.VisionaryOptions; +using NewMod.Roles.CrewmateRoles; +using UnityEngine; +using Reactor.Utilities; +using NewMod.Utilities; + +namespace NewMod.Buttons.Visionary +{ + /// + /// Defines a custom action button for the role. + /// + public class CaptureButton : CustomActionButton + { + /// + /// The name shown on this button. + /// + public override string Name => "Capture"; + + /// + /// The cooldown time before this button can be used again, based on . + /// + public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; + + /// + /// The duration of any effect triggered by this button; here, none. + /// + public override float EffectDuration => 0; + + /// + /// The maximum number of screenshots the user can capture, based on . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; + + /// + /// The icon or sprite associated with this button, set to a camera sprite. + /// + public override LoadableAsset Sprite => NewModAsset.Camera; + + /// + /// The location on-screen where this button appears. + /// + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + /// + /// Handles the button click, capturing a screenshot and saving it to a unique path. + /// + protected override void OnClick() + { + var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); + string path = Path.Combine(VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png"); + Coroutines.Start(Utils.CaptureScreenshot(path)); + } + + /// + /// Determines whether this button is enabled for the given role. + /// + /// The current player's role. + /// True if the role is , otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is TheVisionary; + } + } +} diff --git a/NewMod/Buttons/Visionary/ShowScreenshotButton.cs b/NewMod/Buttons/Visionary/ShowScreenshotButton.cs new file mode 100644 index 0000000..8e7ce90 --- /dev/null +++ b/NewMod/Buttons/Visionary/ShowScreenshotButton.cs @@ -0,0 +1,75 @@ +using MiraAPI.Utilities.Assets; +using MiraAPI.Hud; +using MiraAPI.GameOptions; +using NewMod.Options.Roles.VisionaryOptions; +using NewMod.Roles.CrewmateRoles; +using UnityEngine; +using NewMod.Utilities; +using Reactor.Utilities; +using System.Linq; + +namespace NewMod.Buttons.Visionary +{ + /// + /// Defines a custom action button for the role. + /// + public class ShowScreenshotButton : CustomActionButton + { + /// + /// The name displayed on this button. + /// + public override string Name => ""; + + /// + /// The cooldown time for this button, based on . + /// + public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; + + /// + /// The duration of any effect triggered by this button; in this case, none. + /// + public override float EffectDuration => 0; + + /// + /// The maximum number of times screenshots can be shown, based on . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; + + /// + /// The sprite asset for this button. + /// + public override LoadableAsset Sprite => NewModAsset.ShowScreenshotButton; + + /// + /// The on-screen location where the button will appear. + /// + public override ButtonLocation Location => ButtonLocation.BottomRight; + + /// + /// 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(); + } + + /// + /// Invoked when the button is clicked, starting the coroutine to display screenshots. + /// + protected override void OnClick() + { + Coroutines.Start(VisionaryUtilities.ShowScreenshots(OptionGroupSingleton.Instance.MaxDisplayDuration)); + } + + /// + /// Determines whether this button is enabled for the specified role. + /// + /// The player's current role. + /// True if the role is , otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is TheVisionary; + } + } +} diff --git a/NewMod/CustomGameModes/RevivalRoyale.cs b/NewMod/CustomGameModes/RevivalRoyale.cs index d55023a..27cac7c 100644 --- a/NewMod/CustomGameModes/RevivalRoyale.cs +++ b/NewMod/CustomGameModes/RevivalRoyale.cs @@ -5,14 +5,13 @@ using MiraAPI.Hud; using MiraAPI.Roles; using MiraAPI.Utilities; -using NewMod.Buttons; +using NewMod.Buttons.Necromancer; using NewMod.Roles.ImpostorRoles; using TMPro; namespace NewMod.CustomGameModes { - [RegisterGameMode] - public class RevivalRoyale : CustomGameMode + public class RevivalRoyale : CustomGameMode { public TextMeshPro ReviveCounter; public Dictionary playerReviveCounts = new Dictionary(); @@ -21,19 +20,17 @@ public class RevivalRoyale : CustomGameMode public override string Name => "Revival Royale"; public override string Description => "Everyone is a Necromancer. Revive as many bodies as you can to outlast your opponents.\nThe one with the most revivals wins the Revival Royale"; public override int Id => 1; - public override void Initialize() { foreach (var player in PlayerControl.AllPlayerControls) { - player.RpcSetRole((RoleTypes)RoleId.Get()); - playerReviveCounts[player] = ReviveCount; - playerStates[player] = player.Data.IsDead; - CustomButtonSingleton.Instance.IncreaseUses(3); - NewMod.Instance.Log.LogMessage("Initialize RevivalRoyale GameMode done!"); + player.RpcSetRole((RoleTypes)RoleId.Get()); + playerReviveCounts[player] = ReviveCount; + playerStates[player] = player.Data.IsDead; + CustomButtonSingleton.Instance.IncreaseUses(3); + NewMod.Instance.Log.LogMessage("Initialize RevivalRoyale GameMode done!"); } } - public override void HudUpdate(HudManager instance) { foreach (var player in PlayerControl.AllPlayerControls) @@ -50,7 +47,11 @@ public override void HudUpdate(HudManager instance) ReviveCounter.text = $"Revive Count: {ReviveCount}"; if (ReviveCount >= 6) { + #if PC + GameManager.Instance.RpcEndGame(GameOverReason.ImpostorsByKill, true); + #else GameManager.Instance.RpcEndGame(GameOverReason.ImpostorByKill, true); + #endif break; } } @@ -75,10 +76,6 @@ public override void CheckGameEnd(out bool runOriginal, LogicGameFlowNormal inst { runOriginal = false; } - public override bool AreGameSettingsEnabled() - { - return false; - } public override bool CanReport(DeadBody body) { return false; diff --git a/NewMod/CustomRPC.cs b/NewMod/CustomRPC.cs index 81b42cb..cb282f8 100644 --- a/NewMod/CustomRPC.cs +++ b/NewMod/CustomRPC.cs @@ -4,5 +4,7 @@ public enum CustomRPC Revive, Drain, FakeBody, - AssignMission + AssignMission, + MissionSuccess, + MissionFails } \ No newline at end of file diff --git a/NewMod/DebugWindow.cs b/NewMod/DebugWindow.cs index bddca33..294e2f3 100644 --- a/NewMod/DebugWindow.cs +++ b/NewMod/DebugWindow.cs @@ -1,28 +1,37 @@ using AmongUs.GameOptions; -using Il2CppInterop.Runtime.Attributes; +using System.Linq; +using System; using MiraAPI.Hud; +using MiraAPI.Modifiers; using MiraAPI.Roles; -using MiraAPI.Utilities; -using NewMod.Buttons; +using NewMod.Utilities; using NewMod.Modifiers; using NewMod.Roles.CrewmateRoles; using NewMod.Roles.ImpostorRoles; using NewMod.Roles.NeutralRoles; +using NewMod.Buttons.EnergyThief; +using NewMod.Buttons.SpecialAgent; +using NewMod.Buttons.Visionary; +using NewMod.Buttons.Prankster; +using NewMod.Buttons.Necromancer; using Reactor.Utilities.Attributes; using Reactor.Utilities.ImGui; using UnityEngine; +using UnityEngine.Events; +using Il2CppInterop.Runtime.Attributes; +using NewMod.Buttons.Overload; -namespace NewMod; - -[RegisterInIl2Cpp] -public class DebugWindow(nint ptr) : MonoBehaviour(ptr) +namespace NewMod { - [HideFromIl2Cpp] - public bool EnableDebugger {get; set;} = false; - public readonly DragWindow DebuggingWindow = new(new Rect(10, 10, 0, 0), "NewMod Debug Window", () => - { - bool isFreeplay = AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay; - + [RegisterInIl2Cpp] + public class DebugWindow(nint ptr) : MonoBehaviour(ptr) + { + [HideFromIl2Cpp] + public bool EnableDebugger { get; set; } = false; + public readonly DragWindow DebuggingWindow = new(new Rect(10, 10, 0, 0), "NewMod Debug Window", () => + { + bool isFreeplay = AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay; + if (GUILayout.Button("Become Explosive Modifier")) { if (!isFreeplay) return; @@ -30,27 +39,27 @@ public class DebugWindow(nint ptr) : MonoBehaviour(ptr) } if (GUILayout.Button("Remove Explosive Modifier")) { - if (!isFreeplay) return; + if (!isFreeplay) return; PlayerControl.LocalPlayer.RpcRemoveModifier(); } if (GUILayout.Button("Disable Collider")) { - if (!isFreeplay) return; + if (!isFreeplay) return; PlayerControl.LocalPlayer.Collider.enabled = false; } if (GUILayout.Button("Enable Collider")) { - if (!isFreeplay) return; + if (!isFreeplay) return; PlayerControl.LocalPlayer.Collider.enabled = true; } if (GUILayout.Button("Become Necromancer")) { - if (!isFreeplay) return; + if (!isFreeplay) return; PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); } if (GUILayout.Button("Become DoubleAgent")) { - if (!isFreeplay) return; + if (!isFreeplay) return; PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); } if (GUILayout.Button("Become EnergyThief")) @@ -61,19 +70,19 @@ public class DebugWindow(nint ptr) : MonoBehaviour(ptr) if (GUILayout.Button("Become SpecialAgent")) { if (!isFreeplay) return; - PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); + PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); } if (GUILayout.Button("Force Start Game")) { if (GameOptionsManager.Instance.CurrentGameOptions.NumImpostors is 1) return; AmongUsClient.Instance.StartGame(); - } + } if (GUILayout.Button("Increases Uses by 3")) { var player = PlayerControl.LocalPlayer; if (player.Data.Role is NecromancerRole) { - CustomButtonSingleton.Instance.IncreaseUses(3); + CustomButtonSingleton.Instance.IncreaseUses(3); } else if (player.Data.Role is EnergyThief) { @@ -93,15 +102,66 @@ public class DebugWindow(nint ptr) : MonoBehaviour(ptr) CustomButtonSingleton.Instance.IncreaseUses(3); } } - }); + if (GUILayout.Button("Randomly Cast a Vote")) + { + if (!MeetingHud.Instance) return; + var randPlayer = Utils.GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); + MeetingHud.Instance.CmdCastVote(PlayerControl.LocalPlayer.PlayerId, randPlayer.PlayerId); + } + GUILayout.Space(4); - public void OnGUI() - { - if (EnableDebugger) DebuggingWindow.OnGUI(); - } - public void Update() - { - if (Input.GetKey(KeyCode.F3)) - EnableDebugger = !EnableDebugger; - } -} \ No newline at end of file + GUILayout.Label("Overload button tests", GUI.skin.box); + + if (GUILayout.Button("Test Overload Finale")) + { + OverloadRole.UnlockFinalAbility(); + } + if (GUILayout.Button("Test Absorb")) + { + var prey = Utils.GetRandomPlayer(p => + !p.Data.IsDead && + !p.Data.Disconnected && + p.PlayerId != PlayerControl.LocalPlayer.PlayerId); + if (prey != null) + { + if (prey.Data.Role is ICustomRole customRole && Utils.RoleToButtonsMap.TryGetValue(customRole.GetType(), out var buttonsType)) + { + Debug.Log("Starting to absorb ability..."); + + foreach (var buttonType in buttonsType) + { + var button = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == buttonType); + + if (button != null) + { + CustomButtonSingleton.Instance.Absorb(button); + } + Debug.Log($"[Overload] Successfully absorbed ability: {button.Name}"); + } + } + else if (prey.Data.Role.Ability != null) + { + var btn = Instantiate( + HudManager.Instance.AbilityButton, + HudManager.Instance.AbilityButton.transform.parent); + btn.SetFromSettings(prey.Data.Role.Ability); + var pb = btn.GetComponent(); + pb.OnClick.RemoveAllListeners(); + pb.OnClick.AddListener((UnityAction)prey.Data.Role.UseAbility); + } + } + } + }); + public void OnGUI() + { + if (EnableDebugger) DebuggingWindow.OnGUI(); + } + public void Update() + { + if (Input.GetKey(KeyCode.F3)) + { + EnableDebugger = !EnableDebugger; + } + } + } +} diff --git a/NewMod/DiscordStatus.cs b/NewMod/DiscordStatus.cs index 259c68a..41d96c4 100644 --- a/NewMod/DiscordStatus.cs +++ b/NewMod/DiscordStatus.cs @@ -8,13 +8,13 @@ namespace NewMod [HarmonyPatch(typeof(ActivityManager), nameof(ActivityManager.UpdateActivity))] public static class DiscordPlayStatusPatch { - public static void Prefix([HarmonyArgument(0)] Activity activity) + public static void Postfix([HarmonyArgument(0)] Activity activity) { if (activity == null) return; - var isBeta = false; + var isBeta = true; - string details = $"New Mod v{NewMod.ModVersion}" + (isBeta ? " (Beta)" : "(dev)"); + string details = $"NewMod v{NewMod.ModVersion}" + (isBeta ? " (Beta)" : "(dev)"); activity.Details = details; diff --git a/NewMod/ModCompatibility.cs b/NewMod/ModCompatibility.cs new file mode 100644 index 0000000..178a4de --- /dev/null +++ b/NewMod/ModCompatibility.cs @@ -0,0 +1,69 @@ +using System; +using System.Reflection; +using BepInEx.Unity.IL2CPP; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; + +namespace NewMod +{ + public static class ModCompatibility + { + public const string LaunchpadReloaded_GUID = "dev.xtracube.launchpad"; + public static bool IsLaunchpadLoaded() + { + return IL2CPPChainloader.Instance.Plugins.ContainsKey(LaunchpadReloaded_GUID); + } + public static bool LaunchpadLoaded(out Assembly asm) + { + asm = null; + if (!IL2CPPChainloader.Instance.Plugins.TryGetValue(LaunchpadReloaded_GUID, out var lp)) return false; + asm = lp.Instance.GetType().Assembly; + return asm != null; + } + public static void Initialize() + { + if (!IsLaunchpadLoaded()) return; + + NewMod.Instance.Log.LogMessage("LaunchpadReloaded detected. Enabling compatibility..."); + } + public static void DisableRole(string roleName, string pluginGuid) + { + var plugin = MiraPluginManager.GetPluginByGuid(pluginGuid); + if (plugin == null) return; + + foreach (var kv in plugin.GetRoles()) + { + var role = kv.Value; + + if (role is ICustomRole customRole && customRole.RoleName.Equals(roleName, StringComparison.OrdinalIgnoreCase)) + { + try + { + var config = customRole.Configuration; + customRole.SetChance(0); + customRole.SetCount(0); + customRole.ParentMod.PluginConfig.Save(); + return; + } + catch (Exception e) + { + NewMod.Instance.Log.LogError($"Failed to disable role '{roleName}': {e.Message}"); + } + } + } + } + public static bool IsRoleActive(string roleName) + { + foreach (var roles in RoleManager.Instance.AllRoles) + { + CustomRoleManager.GetCustomRoleBehaviour(roles.Role, out var customRole); + + if (customRole != null && customRole.RoleName.Equals(roleName, StringComparison.OrdinalIgnoreCase)) + { + return customRole.GetChance() > 0 && customRole.GetCount() > 0; + } + } + return false; + } + } +} diff --git a/NewMod/Modifiers/ExplosiveModifier.cs b/NewMod/Modifiers/ExplosiveModifier.cs index 720e7ad..87ec638 100644 --- a/NewMod/Modifiers/ExplosiveModifier.cs +++ b/NewMod/Modifiers/ExplosiveModifier.cs @@ -1,39 +1,30 @@ using MiraAPI.GameOptions; -using MiraAPI.Modifiers; using MiraAPI.Modifiers.Types; using MiraAPI.Utilities; using NewMod.Utilities; -using NewMod.Options; using MiraAPI.Networking; using UnityEngine; +using NewMod.Options.Modifiers; namespace NewMod.Modifiers; -[RegisterModifier] public class ExplosiveModifier : TimedModifier { public override string ModifierName => "Explosive"; public override bool HideOnUi => false; public override bool AutoStart => true; - public override float Duration => OptionGroupSingleton.Instance.Duration; + public override bool ShowInFreeplay => true; + public override float Duration => OptionGroupSingleton.Instance.Duration; public override bool RemoveOnComplete => true; private bool isFlashing = false; - public override bool CanVent() + public override bool? CanVent() { return Player.Data.Role.CanVent; } - public override string GetHudString() + public override string GetDescription() { return ModifierName + "\nif you die, all nearby players are killed"; } - public override void OnActivate() - { - NewMod.Instance.Log.LogInfo("Activated!"); - } - public override void OnDeactivate() - { - NewMod.Instance.Log.LogInfo("Deactivated!"); - } public override void FixedUpdate() { base.FixedUpdate(); @@ -58,14 +49,14 @@ public override void FixedUpdate() } public override void OnTimerComplete() { - + } public override void OnDeath(DeathReason deathReason) { var murderer = Utils.GetKiller(Player); if (murderer == null) return; - var closestPlayers = Helpers.GetClosestPlayers(Player.GetTruePosition(), OptionGroupSingleton.Instance.KillDistance, true); + var closestPlayers = Helpers.GetClosestPlayers(Player.GetTruePosition(), OptionGroupSingleton.Instance.KillDistance, true); foreach (var player in closestPlayers) { @@ -79,7 +70,7 @@ public override void OnDeath(DeathReason deathReason) playKillSound: true, teleportMurderer: false ); - NewMod.Instance.Log.LogInfo($"{player.Data.PlayerName} has been killed by the explosion."); + NewMod.Instance.Log.LogInfo($"{player.Data.PlayerName} has been killed by the explosion."); } } } \ No newline at end of file diff --git a/NewMod/Modifiers/FalseFormModifier.cs b/NewMod/Modifiers/FalseFormModifier.cs new file mode 100644 index 0000000..b53b664 --- /dev/null +++ b/NewMod/Modifiers/FalseFormModifier.cs @@ -0,0 +1,73 @@ +using MiraAPI.GameOptions; +using MiraAPI.Modifiers.Types; +using MiraAPI.Utilities; +using NewMod.Options.Modifiers; +using UnityEngine; + +namespace NewMod.Modifiers +{ + public class FalseFormModifier : TimedModifier + { + public override string ModifierName => "FalseForm"; + public override bool AutoStart => OptionGroupSingleton.Instance.EnableModifier; + public override float Duration => (int)OptionGroupSingleton.Instance.FalseFormDuration; + public override bool ShowInFreeplay => true; + public override bool HideOnUi => false; + public override bool RemoveOnComplete => true; + private float timer; + private AppearanceBackup oldAppearance; + public override void OnActivate() + { + oldAppearance = new AppearanceBackup + { + PlayerName = Player.Data.PlayerName, + HatId = Player.Data.DefaultOutfit.HatId, + SkinId = Player.Data.DefaultOutfit.SkinId, + PetId = Player.Data.DefaultOutfit.PetId, + ColorId = Player.Data.DefaultOutfit.ColorId + }; + } + public override bool? CanVent() + { + return Player.Data.Role.CanVent; + } + public override string GetDescription() + { + return ModifierName + + $"\nYour appearance changes every {OptionGroupSingleton.Instance.FalseFormAppearanceTimer.Value} seconds."; + } + + public override void FixedUpdate() + { + base.FixedUpdate(); + + timer += Time.fixedDeltaTime; + + if (timer >= OptionGroupSingleton.Instance.FalseFormAppearanceTimer.Value) + { + Player.RpcSetName(Helpers.RandomString(5)); + Player.RpcSetColor((byte)Random.Range(0, Palette.PlayerColors.Count)); + Player.RpcSetHat(HatManager.Instance.AllHats[Random.Range(0, HatManager.Instance.allHats.Count)].ProductId); + Player.RpcSetSkin(HatManager.Instance.AllSkins[Random.Range(0, HatManager.Instance.allSkins.Count)].ProductId); + Player.RpcSetPet(HatManager.Instance.AllPets[Random.Range(0, HatManager.Instance.allPets.Count)].ProductId); + } + } + public override void OnDeactivate() + { + if (OptionGroupSingleton.Instance.RevertAppearance) + { + Player.RpcSetName(oldAppearance.PlayerName); + Player.RpcSetColor((byte)oldAppearance.ColorId); + Player.RpcSetHat(oldAppearance.HatId); + Player.RpcSetSkin(oldAppearance.SkinId); + Player.RpcSetPet(oldAppearance.PetId); + } + } + } + class AppearanceBackup + { + public string PlayerName; + public string HatId, SkinId, PetId; + public int ColorId; + } +} diff --git a/NewMod/Modifiers/StickyModifier.cs b/NewMod/Modifiers/StickyModifier.cs new file mode 100644 index 0000000..3e4ce12 --- /dev/null +++ b/NewMod/Modifiers/StickyModifier.cs @@ -0,0 +1,86 @@ +using System.Collections; +using System.Collections.Generic; +using MiraAPI.GameOptions; +using MiraAPI.Modifiers.Types; +using NewMod.Options.Modifiers; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Modifiers +{ + public class StickyModifier : TimedModifier + { + public override string ModifierName => "Sticky"; + public override bool AutoStart => OptionGroupSingleton.Instance.EnableModifier; + public override float Duration => (int)OptionGroupSingleton.Instance.StickyDuration; + public override bool HideOnUi => false; + public override bool ShowInFreeplay => true; + public override bool RemoveOnComplete => true; + public static List linkedPlayers = new(); + public override bool? CanVent() + { + return Player.Data.Role.CanVent; + } + public override string GetDescription() + { + float distance = OptionGroupSingleton.Instance.StickyDistance.Value; + float duration = OptionGroupSingleton.Instance.StickyDuration.Value; + + return $"{ModifierName}: Pulls nearby players within {distance} units for {duration} seconds."; + } + public override void FixedUpdate() + { + base.FixedUpdate(); + + if (!Player.CanMove) return; + + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player == Player || linkedPlayers.Contains(player)) continue; + + float distance = OptionGroupSingleton.Instance.StickyDistance.Value; + + if (Vector2.Distance(player.GetTruePosition(), Player.GetTruePosition()) < distance) + { + linkedPlayers.Add(player); + Coroutines.Start(CoFollowStickyPlayer(player)); + } + } + } + public IEnumerator CoFollowStickyPlayer(PlayerControl player) + { + float duration = Duration; + + var info = new StickyState + { + StickyOwner = Player, + LinkedPlayer = player, + velocity = Vector3.zero, + }; + + yield return HudManager.Instance.StartCoroutine( + Effects.Overlerp(duration, new System.Action((t) => + { + Vector3 targetPos = info.LinkedPlayer.transform.position; + Vector3 currentPos = info.StickyOwner.transform.position; + + info.LinkedPlayer.transform.position = Vector3.SmoothDamp( + targetPos, + currentPos, + ref info.velocity, + t + ); + }) + )); + + linkedPlayers.Remove(player); + } + } + + class StickyState + { + public PlayerControl StickyOwner; + public PlayerControl LinkedPlayer; + public Vector3 velocity; + } +} diff --git a/NewMod/NewMod.cs b/NewMod/NewMod.cs index 359f9a3..0e3b246 100644 --- a/NewMod/NewMod.cs +++ b/NewMod/NewMod.cs @@ -1,12 +1,10 @@ using System.Linq; -using System.IO; using UnityEngine; using Object = UnityEngine.Object; using BepInEx; using BepInEx.Unity.IL2CPP; using BepInEx.Configuration; using MiraAPI; -using MiraAPI.Networking; using MiraAPI.GameOptions; using MiraAPI.PluginLoading; using MiraAPI.Utilities; @@ -17,95 +15,151 @@ using NewMod.Options; using NewMod.Utilities; using NewMod.Roles.ImpostorRoles; -using Sentry.Unity; +using MiraAPI.Events.Vanilla.Gameplay; +using NewMod.Roles.NeutralRoles; +using MiraAPI.Roles; +using System; +using MiraAPI.Hud; +using UnityEngine.Events; +using NewMod.Options.Roles.OverloadOptions; +using MiraAPI.Events; +using NewMod.Patches.Compatibility; +using NewMod.Buttons.Overload; namespace NewMod; [BepInPlugin(Id, "NewMod", ModVersion)] [BepInDependency(ReactorPlugin.Id)] [BepInDependency(MiraApiPlugin.Id)] +[BepInDependency(ModCompatibility.LaunchpadReloaded_GUID, BepInDependency.DependencyFlags.SoftDependency)] [ReactorModFlags(Reactor.Networking.ModFlags.RequireOnAllClients)] [BepInProcess("Among Us.exe")] public partial class NewMod : BasePlugin, IMiraPlugin { - public const string Id = "com.callofcreator.newmod"; - public const string ModVersion = "1.1.0"; - public Harmony Harmony { get; } = new Harmony(Id); - public static BasePlugin Instance; - public static Minigame minigame; - public const string SupportedAmongUsVersion = "2024.11.26"; - public static ConfigEntry ShouldEnableBepInExConsole {get; set;} - public ConfigFile GetConfigFile() => Config; - public string OptionsTitleText => "NewMod"; - public override void Load() - { - Instance = this; - AddComponent(); - ReactorCredits.Register(ReactorCredits.AlwaysShow); - Harmony.PatchAll(); - CheckVersionCompatibility(); - ShouldEnableBepInExConsole = Config.Bind("NewMod", "Console", false, "Whether to enable BepInEx Console for debugging"); - Instance.Log.LogMessage($"Loaded Successfully NewMod v{ModVersion} With MiraAPI Version : {MiraApiPlugin.Version} with ID : {MiraApiPlugin.Id}"); - if (!ShouldEnableBepInExConsole.Value) ConsoleManager.DetachConsole(); - } - public static void CheckVersionCompatibility() - { - if (Application.version != SupportedAmongUsVersion) - { - Instance.Log.LogError($"Detected unsupported Among Us version. Current version: {Application.version}, Supported version: {SupportedAmongUsVersion}"); - } - } + public const string Id = "com.callofcreator.newmod"; + public const string ModVersion = "1.2.0"; + public Harmony Harmony { get; } = new Harmony(Id); + public static BasePlugin Instance; + public static Minigame minigame; + public const string SupportedAmongUsVersion = "2025.6.10"; + public static ConfigEntry ShouldEnableBepInExConsole { get; set; } + public ConfigFile GetConfigFile() => Config; + public string OptionsTitleText => "NewMod"; + public override void Load() + { + Instance = this; + AddComponent(); + ReactorCredits.Register(ReactorCredits.AlwaysShow); + Harmony.PatchAll(); + CheckVersionCompatibility(); + NewModEventHandler.RegisterEventsLogs(); + + if (ModCompatibility.IsLaunchpadLoaded()) + { + Harmony.PatchAll(typeof(LaunchpadCompatibility)); + Harmony.PatchAll(typeof(LaunchpadHackTextPatch)); + } + ShouldEnableBepInExConsole = Config.Bind("NewMod", "Console", false, "Whether to enable BepInEx Console for debugging"); + if (!ShouldEnableBepInExConsole.Value) ConsoleManager.DetachConsole(); + Instance.Log.LogMessage($"Loaded Successfully NewMod v{ModVersion} With MiraAPI Version : {MiraApiPlugin.Version} with ID : {MiraApiPlugin.Id}"); + } + public static void CheckVersionCompatibility() + { + if (Application.version != SupportedAmongUsVersion) + { + Instance.Log.LogError($"Detected unsupported Among Us version. Current version: {Application.version}, Supported version: {SupportedAmongUsVersion}"); + } + } [HarmonyPatch(typeof(KeyboardJoystick), nameof(KeyboardJoystick.Update))] - public class KeyboardJoystickUpdatePatch - { - public static void Postfix(KeyboardJoystick __instance) - { - InitializeKeyBinds(); - } - } - public static void InitializeKeyBinds() - { - if (AmongUsClient.Instance.GameState != InnerNet.InnerNetClient.GameStates.Started) return; + public class KeyboardJoystickUpdatePatch + { + public static void Postfix(KeyboardJoystick __instance) + { + InitializeKeyBinds(); + } + } + public static void InitializeKeyBinds() + { + if (AmongUsClient.Instance.GameState != InnerNet.InnerNetClient.GameStates.Started) return; + + if (Input.GetKeyDown(KeyCode.F2) && PlayerControl.LocalPlayer.Data.Role.Role is AmongUs.GameOptions.RoleTypes.Crewmate && OptionGroupSingleton.Instance.CanOpenCams) + { + var cam = Object.FindObjectsOfType().FirstOrDefault(x => x.name.Contains("Surv")); + if (Camera.main is not null || cam != null) + { + minigame = Object.Instantiate(cam.MinigamePrefab, Camera.main.transform, false); + minigame.transform.localPosition = new Vector3(0f, 0f, -50f); + minigame.Begin(null); + } + } + if (Input.GetKeyDown(KeyCode.F3) && PlayerControl.LocalPlayer.Data.Role is NecromancerRole && OptionGroupSingleton.Instance.EnableTeleportation) + { + var deadBodies = Helpers.GetNearestDeadBodies(PlayerControl.LocalPlayer.GetTruePosition(), 20f, Helpers.CreateFilter(Constants.NotShipMask)); + if (deadBodies != null && deadBodies.Count > 0) + { + var randomIndex = UnityEngine.Random.Range(0, deadBodies.Count); + var randomBodyPosition = deadBodies[randomIndex].transform.position; + PlayerControl.LocalPlayer.NetTransform.RpcSnapTo(randomBodyPosition); + } + else + { + CoroutinesHelper.CoNotify("No dead bodies nearby to teleport to."); + } + } + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + var source = evt.Source; + var target = evt.Target; + Utils.RecordOnKill(source, target); + + if (target != OverloadRole.chosenPrey) return; - if (Input.GetKeyDown(KeyCode.F2) && PlayerControl.LocalPlayer.Data.Role.Role is AmongUs.GameOptions.RoleTypes.Crewmate && OptionGroupSingleton.Instance.CanOpenCams) - { - var cam = Object.FindObjectsOfType().FirstOrDefault(x => x.name.Contains("Surv")); - if (Camera.main is not null || cam != null) - { - minigame = Object.Instantiate(cam.MinigamePrefab, Camera.main.transform, false); - minigame.transform.localPosition = new Vector3(0f, 0f, -50f); - minigame.Begin(null); - } - } - if (Input.GetKeyDown(KeyCode.F4) && PlayerControl.LocalPlayer.Data.Role is NecromancerRole && OptionGroupSingleton.Instance.EnableTeleportation) - { - var deadBodies = Helpers.GetNearestDeadBodies(PlayerControl.LocalPlayer.GetTruePosition(), 20f, Helpers.CreateFilter(Constants.NotShipMask)); - if (deadBodies != null && deadBodies.Count > 0) - { - var randomIndex = Random.Range(0, deadBodies.Count); - var randomBodyPosition = deadBodies[randomIndex].transform.position; - PlayerControl.LocalPlayer.NetTransform.RpcSnapTo(randomBodyPosition); - } - else - { - CoroutinesHelper.CoNotify("No dead bodies nearby to teleport to."); - } - } - } - - [HarmonyPatch(typeof(CustomMurderRpc), nameof(CustomMurderRpc.RpcCustomMurder))] - public static class CustomMurderPatch - { - public static void Postfix(PlayerControl target, bool didSucceed, bool resetKillTimer, bool createDeadBody, bool teleportMurderer, bool showKillAnim, bool playKillSound) - { - Utils.RecordOnKill(PlayerControl.LocalPlayer, target); - } + foreach (var pc in PlayerControl.AllPlayerControls.ToArray().Where(p => p.AmOwner && p.Data.Role is OverloadRole)) + { + if (target.Data.Role is ICustomRole customRole && Utils.RoleToButtonsMap.TryGetValue(customRole.GetType(), out var buttonsType)) + { + foreach (var buttonType in buttonsType) + { + var button = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == buttonType); + + if (button != null) + { + CustomButtonSingleton.Instance.Absorb(button); + } + } + } + else if (target.Data.Role is not ICustomRole) + { + var btn = Object.Instantiate( + HudManager.Instance.AbilityButton, + HudManager.Instance.AbilityButton.transform.parent); + btn.SetFromSettings(target.Data.Role.Ability); + var pb = btn.GetComponent(); + pb.OnClick.RemoveAllListeners(); + pb.OnClick.AddListener((UnityAction)target.Data.Role.UseAbility); + } + } + OverloadRole.AbsorbedAbilityCount++; + Coroutines.Start(CoroutinesHelper.CoNotify($"Charge {OverloadRole.AbsorbedAbilityCount}/{OptionGroupSingleton.Instance.NeededCharge}")); + OverloadRole.chosenPrey = null; + + if (OverloadRole.AbsorbedAbilityCount >= OptionGroupSingleton.Instance.NeededCharge) + { + OverloadRole.UnlockFinalAbility(); + } + else + { + Coroutines.Start(OverloadRole.CoShowMenu(1f)); + } } - - [HarmonyPatch(typeof(TaskPanelBehaviour), nameof(TaskPanelBehaviour.SetTaskText))] - public static class SetTaskTextPatch - { + + [HarmonyPatch(typeof(TaskPanelBehaviour), nameof(TaskPanelBehaviour.SetTaskText))] + public static class SetTaskTextPatch + { public static void Postfix(TaskPanelBehaviour __instance, [HarmonyArgument(0)] string str) { if (AmongUsClient.Instance.GameState == InnerNet.InnerNetClient.GameStates.Started && PlayerControl.LocalPlayer.Data.Role.Role is AmongUs.GameOptions.RoleTypes.Crewmate) @@ -113,5 +167,5 @@ public static void Postfix(TaskPanelBehaviour __instance, [HarmonyArgument(0)] s __instance.taskText.text += "\n" + (OptionGroupSingleton.Instance.CanOpenCams ? "Press F2 For Open Cams" : "You cannot open cams because the host has disabled this setting"); } } - } + } } diff --git a/NewMod/NewMod.csproj b/NewMod/NewMod.csproj index fba869c..95490e4 100644 --- a/NewMod/NewMod.csproj +++ b/NewMod/NewMod.csproj @@ -1,29 +1,35 @@ - 1.1.0 + 1.2.0 dev - Among Us Mod + NewMod is a mod for Among Us that introduces a variety of new roles, unique abilities CallofCreator - net6.0 - latest - embedded - - + net6.0 + latest + embedded + Debug;Release;ANDROID + + + + TRACE;PC + + + + $(RestoreSources);$(MSBuildProjectDirectory)\..\libs\Android + TRACE;ANDROID_BUILD + + - - - + + + + - - - + + + - - - - - - - - \ No newline at end of file + + + diff --git a/NewMod/NewModAsset.cs b/NewMod/NewModAsset.cs index bea51c4..d5e1666 100644 --- a/NewMod/NewModAsset.cs +++ b/NewMod/NewModAsset.cs @@ -1,7 +1,6 @@ using MiraAPI.Utilities.Assets; namespace NewMod; - public static class NewModAsset { public static LoadableResourceAsset Banner { get; } = new("NewMod.Resources.optionImage.png"); @@ -10,4 +9,10 @@ public static class NewModAsset public static LoadableResourceAsset Arrow { get; } = new("NewMod.Resources.Arrow.png"); public static LoadableResourceAsset ModLogo { get; } = new("NewMod.Resources.Logo.png"); public static LoadableResourceAsset Camera { get; } = new("NewMod.Resources.cam.png"); + 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 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"); } \ No newline at end of file diff --git a/NewMod/NewModEndReasons.cs b/NewMod/NewModEndReasons.cs index 291370a..d051a9e 100644 --- a/NewMod/NewModEndReasons.cs +++ b/NewMod/NewModEndReasons.cs @@ -6,6 +6,8 @@ public enum NewModEndReasons DoubleAgentWin = 111, PranksterWin = 112, SpecialAgentWin = 113, - TheVisionaryWin = 114 + TheVisionaryWin = 114, + OverloadWin = 115, + EgoistWin = 116 } } \ No newline at end of file diff --git a/NewMod/NewModEventHandler.cs b/NewMod/NewModEventHandler.cs new file mode 100644 index 0000000..6d64da1 --- /dev/null +++ b/NewMod/NewModEventHandler.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Usables; +using NewMod.Patches; +using NewMod.Patches.Roles.Visionary; + +namespace NewMod +{ + public static class NewModEventHandler + { + public static void RegisterEventsLogs() + { + var registrations = new List + { + $"{nameof(GameEndEvent)}: {nameof(EndGamePatch.OnGameEnd)}", + $"{nameof(EnterVentEvent)}: {nameof(VisionaryVentPatch.OnEnterVent)}", + $"{nameof(BeforeMurderEvent)}: {nameof(VisionaryMurderPatch.OnBeforeMurder)}", + $"{nameof(AfterMurderEvent)}: {nameof(NewMod.OnAfterMurder)}" + }; + NewMod.Instance.Log.LogInfo("Registered events: " + "\n" + string.Join(", ", registrations)); + } + } +} diff --git a/NewMod/Options/CompatibilityOptions.cs b/NewMod/Options/CompatibilityOptions.cs new file mode 100644 index 0000000..7db18a3 --- /dev/null +++ b/NewMod/Options/CompatibilityOptions.cs @@ -0,0 +1,38 @@ +using System; +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; + +namespace NewMod.Options; +public class CompatibilityOptions : AbstractOptionGroup +{ + public override string GroupName => "Mod Compatibility"; + public override Func GroupVisible => ModCompatibility.IsLaunchpadLoaded; + public ModdedToggleOption AllowRevenantHitmanCombo { get; } = new("Allow Revenant & Hitman in Same Match", false) + { + ChangedEvent = value => + { + HudManager.Instance.ShowPopUp(value + ? "You enabled the Revenant & Hitman combo. This may break game balance!" + : "Revenant & Hitman combo disabled. Only one will be allowed per match."); + } + }; + public ModdedEnumOption Compatibility { get; } = new("Mod Compatibility", ModPriority.PreferNewMod) + { + ChangedEvent = value => + { + HudManager.Instance.ShowPopUp( + value switch + { + ModPriority.PreferNewMod => "You selected 'PreferNewMod'. Medic will be disabled.\n" + + "Switch to 'Prefer LaunchpadReloaded' to enable Medic and disable Necromancer.", + ModPriority.PreferLaunchpadReloaded => "You selected 'PreferLaunchpadReloaded'. Necromancer will be disabled.\n" + + "Switch to 'PreferNewMod' to enable Necromancer and disable Medic.", + }); + } + }; + public enum ModPriority + { + PreferNewMod, + PreferLaunchpadReloaded + } +} \ No newline at end of file diff --git a/NewMod/Options/GeneralOption.cs b/NewMod/Options/GeneralOption.cs index b5376e4..fe4ead2 100644 --- a/NewMod/Options/GeneralOption.cs +++ b/NewMod/Options/GeneralOption.cs @@ -1,23 +1,15 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Utilities; namespace NewMod.Options; - public class GeneralOption : AbstractOptionGroup { public override string GroupName => "NewMod Group"; - + [ModdedToggleOption("Enable Teleportation")] public bool EnableTeleportation { get; set; } = true; [ModdedToggleOption("Can Open Cams")] - public bool CanOpenCams {get; set;} = true; - - [ModdedNumberOption("Kill Distance (used by explosive modifier)", min: 5f, max: 20f, increment: 1f, MiraNumberSuffixes.None)] - public float KillDistance { get; set; } = 10f; - - [ModdedNumberOption("ExplosiveModifier duration", min: 40f, max: 60f, increment: 1f, MiraNumberSuffixes.None)] - public float Duration { get; set; } = 50f; + public bool CanOpenCams { get; set; } = true; } \ No newline at end of file diff --git a/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs b/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs new file mode 100644 index 0000000..06010c3 --- /dev/null +++ b/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs @@ -0,0 +1,19 @@ +using MiraAPI.GameOptions; +using MiraAPI.Utilities; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; +using NewMod.Modifiers; + +namespace NewMod.Options.Modifiers +{ + public class ExplosiveModifierOptions : AbstractOptionGroup + { + public override string GroupName => "Explosive Settings"; + + [ModdedNumberOption("Kill Distance", min: 5f, max: 20f, increment: 1f, MiraNumberSuffixes.None)] + public float KillDistance { get; set; } = 10f; + + [ModdedNumberOption("Explosive duration", min: 40f, max: 60f, increment: 1f, MiraNumberSuffixes.None)] + public float Duration { get; set; } = 50f; + } +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/FalseFormModifierOptions.cs b/NewMod/Options/Modifiers/FalseFormModifierOptions.cs new file mode 100644 index 0000000..78bab9f --- /dev/null +++ b/NewMod/Options/Modifiers/FalseFormModifierOptions.cs @@ -0,0 +1,33 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using NewMod.Modifiers; + +namespace NewMod.Options.Modifiers +{ + public class FalseFormModifierOptions : AbstractOptionGroup + { + public override string GroupName => "FalseForm Settings"; + public ModdedToggleOption EnableModifier { get; } = new("Enable FalseForm", true); + public ModdedNumberOption FalseFormDuration { get; } = + new( + "Duration of the FalseForm effect", + 20f, + min: 10f, + max: 30f, + increment: 1f, + suffixType: MiraNumberSuffixes.Seconds + ); + public ModdedNumberOption FalseFormAppearanceTimer { get; } = + new( + "Appearance Change Delay", + 5f, + min: 1f, + max: 10f, + increment: 0.5f, + suffixType: MiraNumberSuffixes.Seconds + ); + public ModdedToggleOption RevertAppearance { get; } = + new("Revert appearance after FalseForm ends", true); + } +} diff --git a/NewMod/Options/Modifiers/StickyModifierOptions.cs b/NewMod/Options/Modifiers/StickyModifierOptions.cs new file mode 100644 index 0000000..e0410b4 --- /dev/null +++ b/NewMod/Options/Modifiers/StickyModifierOptions.cs @@ -0,0 +1,31 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using NewMod.Modifiers; + +namespace NewMod.Options.Modifiers +{ + public class StickyModifierOptions : AbstractOptionGroup + { + public override string GroupName => "Sticky Settings"; + public ModdedToggleOption EnableModifier { get; } = new("Enable StickyModifier", true); + public ModdedNumberOption StickyDuration { get; } = + new( + "Duration of Sticky Effect", + 15f, + min: 10f, + max: 30f, + increment: 0.5f, + suffixType: MiraNumberSuffixes.Seconds + ); + public ModdedNumberOption StickyDistance { get; } = + new( + "Distance to trigger stickiness", + 1f, + min: 1f, + max: 3f, + increment: 0.5f, + suffixType: MiraNumberSuffixes.None + ); + } +} diff --git a/NewMod/Options/Roles/EgoistOptions/EgoistOptions.cs b/NewMod/Options/Roles/EgoistOptions/EgoistOptions.cs new file mode 100644 index 0000000..5781ba2 --- /dev/null +++ b/NewMod/Options/Roles/EgoistOptions/EgoistOptions.cs @@ -0,0 +1,23 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles; + +namespace NewMod.Options.Roles.EgoistOptions +{ + public class EgoistRoleOptions : AbstractOptionGroup + { + public override string GroupName => "Egoist Settings"; + + public ModdedNumberOption MinimumVotesToWin { get; } = + new( + "Minimum votes on Egoist to trigger win", + 3, + min: 1, + max: 10, + increment: 1, + suffixType: MiraNumberSuffixes.None + ); + } +} diff --git a/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs b/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs index 4451954..c2136e0 100644 --- a/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs +++ b/NewMod/Options/Roles/EnergyThiefOptions/EnergyThiefOptions.cs @@ -1,4 +1,3 @@ -using System; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; @@ -7,14 +6,13 @@ namespace NewMod.Options.Roles.EnergyThiefOptions; -public class EnergyThiefOptions : AbstractOptionGroup +public class EnergyThiefOptions : AbstractOptionGroup { public override string GroupName => "Energy Thief"; - public override Type AdvancedRole => typeof(EnergyThief); - [ModdedNumberOption("Drain Cooldown", min:10, max:20, suffixType:MiraNumberSuffixes.Seconds)] - public float DrainCooldown {get; set;} = 15f; + [ModdedNumberOption("Drain Cooldown", min: 10, max: 20, suffixType: MiraNumberSuffixes.Seconds)] + public float DrainCooldown { get; set; } = 15f; - [ModdedNumberOption("Drain Max Uses", min:3, max:5)] - public float DrainMaxUses {get; set;} = 3f; + [ModdedNumberOption("Drain Max Uses", min: 3, max: 5)] + public float DrainMaxUses { get; set; } = 3f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/NecromancerOptions/NecromancerRoleOption.cs b/NewMod/Options/Roles/NecromancerOptions/NecromancerRoleOption.cs index c87646b..b89b6aa 100644 --- a/NewMod/Options/Roles/NecromancerOptions/NecromancerRoleOption.cs +++ b/NewMod/Options/Roles/NecromancerOptions/NecromancerRoleOption.cs @@ -1,4 +1,3 @@ -using System; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; @@ -7,15 +6,14 @@ namespace NewMod.Options.Roles.NecromancerOptions; -public class NecromancerOption : AbstractOptionGroup +public class NecromancerOption : AbstractOptionGroup { public override string GroupName => "Necromancer Role"; - public override Type AdvancedRole => typeof(NecromancerRole); - [ModdedNumberOption("ButtonCooldown", min:5, max:15, suffixType:MiraNumberSuffixes.Seconds)] - public float ButtonCooldown {get; set;} = 6f; + [ModdedNumberOption("ButtonCooldown", min: 5, max: 15, suffixType: MiraNumberSuffixes.Seconds)] + public float ButtonCooldown { get; set; } = 6f; + + [ModdedNumberOption("AbilityUses", min: 1, max: 6)] + public float AbilityUses { get; set; } = 3f; - [ModdedNumberOption("AbilityUses", min:1, max:6)] - public float AbilityUses {get; set;} = 3f; - } \ No newline at end of file diff --git a/NewMod/Options/Roles/OverloadOptions/OverloadOptions.cs b/NewMod/Options/Roles/OverloadOptions/OverloadOptions.cs new file mode 100644 index 0000000..3d9ab7a --- /dev/null +++ b/NewMod/Options/Roles/OverloadOptions/OverloadOptions.cs @@ -0,0 +1,18 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; +using NewMod.Roles.NeutralRoles; + +namespace NewMod.Options.Roles.OverloadOptions; + +public class OverloadOptions : AbstractOptionGroup +{ + public override string GroupName => "The Overload"; + + [ModdedNumberOption("Needed Charge", min:1, max:3)] + public float NeededCharge { get; set; } = 2f; + + [ModdedNumberOption("Max Uses", min:1, max:2)] + public float MaxUses { get; set; } = 1f; + +} \ No newline at end of file diff --git a/NewMod/Options/Roles/PranksterOptions/PranksterOptions.cs b/NewMod/Options/Roles/PranksterOptions/PranksterOptions.cs index bc55d55..4a94e3a 100644 --- a/NewMod/Options/Roles/PranksterOptions/PranksterOptions.cs +++ b/NewMod/Options/Roles/PranksterOptions/PranksterOptions.cs @@ -1,4 +1,3 @@ -using System; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; @@ -7,14 +6,13 @@ namespace NewMod.Options.Roles.PranksterOptions; -public class PranksterOptions : AbstractOptionGroup +public class PranksterOptions : AbstractOptionGroup { public override string GroupName => "Prankster"; - public override Type AdvancedRole => typeof(Prankster); - [ModdedNumberOption("Prank Cooldown", min:10, max:40, suffixType:MiraNumberSuffixes.Seconds)] - public float PrankCooldown {get; set;} = 20f; + [ModdedNumberOption("Prank Cooldown", min: 10, max: 40, suffixType: MiraNumberSuffixes.Seconds)] + public float PrankCooldown { get; set; } = 20f; - [ModdedNumberOption("Prank Max Uses", min:1, max:3)] - public float PrankMaxUses {get; set;} = 2f; + [ModdedNumberOption("Prank Max Uses", min: 1, max: 3)] + public float PrankMaxUses { get; set; } = 2f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/RevenantOptions/RevenantOptions.cs b/NewMod/Options/Roles/RevenantOptions/RevenantOptions.cs new file mode 100644 index 0000000..e8b6bd6 --- /dev/null +++ b/NewMod/Options/Roles/RevenantOptions/RevenantOptions.cs @@ -0,0 +1,27 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using NewMod.Roles.ImpostorRoles; + +namespace NewMod.Options.Roles.RevenantOptions; + +public class RevenantOptions : AbstractOptionGroup +{ + public override string GroupName => "Revenant"; + + [ModdedNumberOption("Feign Death Cooldown", min: 10, max: 40, suffixType: MiraNumberSuffixes.Seconds)] + public float FeignDeathCooldown { get; set; } = 20f; + + [ModdedNumberOption("Feign Death Max Uses", min: 1, max: 3)] + public float FeignDeathMaxUses { get; set; } = 2f; + + [ModdedNumberOption("Doom Awakening Cooldown", min: 10, max: 20, suffixType: MiraNumberSuffixes.Seconds)] + public float DoomAwakeningCooldown { get; set; } = 10f; + + [ModdedNumberOption("Doom Awakening Max Uses", min:1, max: 1)] + public float DoomAwakeningMaxUses { get; set; } = 1f; + + [ModdedNumberOption("Doom Awakening Duration", min: 10f, max: 30f)] + public float DoomAwakeningDuration { get; set; } = 20f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/SpecialAgentOptions/SpecialAgentOptions.cs b/NewMod/Options/Roles/SpecialAgentOptions/SpecialAgentOptions.cs index daf4c84..9e20dd8 100644 --- a/NewMod/Options/Roles/SpecialAgentOptions/SpecialAgentOptions.cs +++ b/NewMod/Options/Roles/SpecialAgentOptions/SpecialAgentOptions.cs @@ -1,4 +1,3 @@ -using System; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; @@ -6,24 +5,26 @@ namespace NewMod.Options.Roles.SpecialAgentOptions { - public class SpecialAgentOptions : AbstractOptionGroup + public class SpecialAgentOptions : AbstractOptionGroup { public override string GroupName => "Special Agent"; - public override Type AdvancedRole => typeof(SpecialAgent); - [ModdedNumberOption("Assign Mission Cooldown", min:10, max:30)] + [ModdedNumberOption("Assign Mission Cooldown", min: 10, max: 30)] public float AssignCooldown { get; set; } = 20f; - [ModdedNumberOption("Assign Mission Max Uses", min:1, max:3)] + [ModdedNumberOption("Assign Mission Max Uses", min: 1, max: 3)] public float AssignMaxUses { get; set; } = 3f; [ModdedToggleOption("Enable Target Camera Tracking")] public bool TargetCameraTracking { get; set; } = true; - [ModdedNumberOption("Camera Tracking Duration", min:5, max:15)] + [ModdedNumberOption("Camera Tracking Duration", min: 5, max: 15)] public float CameraTrackingDuration { get; set; } = 10f; - [ModdedNumberOption("Required Missions to Win", min:1, max:5)] - public float RequiredMissionsToWin { get; set; } = 3f; + [ModdedNumberOption("Required Missions to Win", min: 1, max: 5)] + public float RequiredMissionsToWin { get; set; } = 3f; + + [ModdedToggleOption("The camera should shake when the timer is close to the target")] + public bool ShouldShakeCamera { get; set; } = false; } } diff --git a/NewMod/Options/Roles/TheVisionaryOptions/TheVisionaryOptions.cs b/NewMod/Options/Roles/TheVisionaryOptions/TheVisionaryOptions.cs index c8a1fb6..39f0f34 100644 --- a/NewMod/Options/Roles/TheVisionaryOptions/TheVisionaryOptions.cs +++ b/NewMod/Options/Roles/TheVisionaryOptions/TheVisionaryOptions.cs @@ -1,4 +1,3 @@ -using System; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; @@ -6,18 +5,17 @@ namespace NewMod.Options.Roles.VisionaryOptions { - public class VisionaryOptions : AbstractOptionGroup + public class VisionaryOptions : AbstractOptionGroup { public override string GroupName => "The Visionary"; - public override Type AdvancedRole => typeof(TheVisionary); - [ModdedNumberOption("Screenshot Cooldown", min:5, max:30)] + [ModdedNumberOption("Screenshot Cooldown", min: 5, max: 30)] public float ScreenshotCooldown { get; set; } = 15f; - [ModdedNumberOption("Max Screenshots", min:1, max:5)] + [ModdedNumberOption("Max Screenshots", min: 1, max: 5)] public float MaxScreenshots { get; set; } = 3f; - [ModdedNumberOption("Max Display Duration", min:5, max:10)] + [ModdedNumberOption("Max Display Duration", min: 5, max: 10)] public float MaxDisplayDuration { get; set; } = 5f; } } \ No newline at end of file diff --git a/NewMod/Patches/ClipboardPatch.cs b/NewMod/Patches/ClipboardPatch.cs new file mode 100644 index 0000000..0c7cd3f --- /dev/null +++ b/NewMod/Patches/ClipboardPatch.cs @@ -0,0 +1,37 @@ +using HarmonyLib; +using UnityEngine; + +namespace NewMod.Patches +{ + /// + /// Allows players to paste clipboard text into chat using Ctrl+V. + /// Inspired by:https://github.com/nyomo/TownOfPlus/blob/origin/TownOfPlus/Patches/ChatPlus.cs#L80-L158 + /// + [HarmonyPatch(typeof(ChatController), nameof(ChatController.Update))] + public static class ClipboardPatch + { + public static void Prefix(ChatController __instance) + { + if (!HudManager.Instance.Chat.IsOpenOrOpening) return; + + bool ctrlPressed = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl); + + if (ctrlPressed && Input.GetKeyDown(KeyCode.V)) + { + string clipboard = GUIUtility.systemCopyBuffer; + + if (!string.IsNullOrWhiteSpace(clipboard)) + { + clipboard = clipboard.Replace("<", "") + .Replace(">", "") + .Replace("\r", ""); + + if (!Input.GetKey(KeyCode.LeftShift) && !Input.GetKey(KeyCode.RightShift)) + clipboard = clipboard.Replace("\n", ""); + + __instance.freeChatField.textArea.SetText(__instance.freeChatField.textArea.text + clipboard); + } + } + } + } +} diff --git a/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs b/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs new file mode 100644 index 0000000..e58a5a7 --- /dev/null +++ b/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs @@ -0,0 +1,60 @@ +using HarmonyLib; +using NewMod.Roles.ImpostorRoles; +using System.Reflection; +using TMPro; +using UnityEngine; + +namespace NewMod.Patches.Compatibility +{ + public static class LaunchpadCompatibility + { + static MethodBase TargetMethod() + { + if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) + return null; + + var type = asm.GetType("LaunchpadReloaded.Modifiers.HackedModifier"); + var method = type?.GetMethod("OnTimerComplete", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return method; + } + + static bool Prefix(object __instance) + { + var playerField = __instance.GetType().GetField("Player", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (playerField == null) return true; + + var player = playerField.GetValue(__instance) as PlayerControl; + + if (player != null && Revenant.FeignDeathStates.ContainsKey(player.PlayerId)) + { + NewMod.Instance.Log.LogInfo($"Blocked Launchpad hack death on Revenant {player.Data.PlayerName}"); + return false; + } + return true; + } + } + public static class LaunchpadHackTextPatch + { + static MethodBase TargetMethod() + { + if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) + return null; + + var type = asm.GetType("LaunchpadReloaded.Modifiers.HackedModifier"); + var method = type?.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return method; + } + + static void Postfix(object __instance) + { + var player = __instance.GetType().GetField("Player", BindingFlags.Instance | BindingFlags.Public)?.GetValue(__instance) as PlayerControl; + var hackedText = __instance.GetType().GetField("_hackedText", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(__instance) as TextMeshPro; + + if (player != null && hackedText != null && Revenant.FeignDeathStates.ContainsKey(player.PlayerId)) + { + hackedText.SetText(""); + Debug.Log($"hackedText: {hackedText.text}"); + } + } + } +} diff --git a/NewMod/Patches/Compatibility/StartGamePatch.cs b/NewMod/Patches/Compatibility/StartGamePatch.cs new file mode 100644 index 0000000..d88e8be --- /dev/null +++ b/NewMod/Patches/Compatibility/StartGamePatch.cs @@ -0,0 +1,44 @@ +using HarmonyLib; +using MiraAPI.GameOptions; +using NewMod.Options; + +namespace NewMod.Patches.Compatibility +{ + [HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.CoStartGame))] + public static class StartGamePatch + { + public static bool Prefix(AmongUsClient __instance) + { + if (!ModCompatibility.IsLaunchpadLoaded()) return true; + + var settings = OptionGroupSingleton.Instance; + + if (!settings.AllowRevenantHitmanCombo) + { + var hitman = ModCompatibility.IsRoleActive("Hitman"); + var revenant = ModCompatibility.IsRoleActive("Revenant"); + + if (hitman && revenant) + { + if (settings.Compatibility == CompatibilityOptions.ModPriority.PreferNewMod) + { + ModCompatibility.DisableRole("Hitman", ModCompatibility.LaunchpadReloaded_GUID); + } + else + { + ModCompatibility.DisableRole("Revenant", NewMod.Id); + } + } + } + if (settings.Compatibility == CompatibilityOptions.ModPriority.PreferNewMod) + { + ModCompatibility.DisableRole("Medic", ModCompatibility.LaunchpadReloaded_GUID); + } + else + { + ModCompatibility.DisableRole("Necromancer", NewMod.Id); + } + return true; + } + } +} \ No newline at end of file diff --git a/NewMod/Patches/EndGamePatch.cs b/NewMod/Patches/EndGamePatch.cs index 1884d5e..80c94a8 100644 --- a/NewMod/Patches/EndGamePatch.cs +++ b/NewMod/Patches/EndGamePatch.cs @@ -1,48 +1,53 @@ using UnityEngine; using HarmonyLib; -using NewMod.Roles.CrewmateRoles; -using NewMod.Roles.NeutralRoles; -using NewMod.Utilities; using System.Linq; +using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Roles; using AmongUs.GameOptions; using Object = UnityEngine.Object; -using MiraAPI.GameOptions; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; using NewMod.Options.Roles.SpecialAgentOptions; +using MiraAPI.GameOptions; +using MiraAPI.Events; namespace NewMod.Patches { - [HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.SetEverythingUp))] - public static class SetEverythingUpPatch + public static class EndGamePatch { - public static void Postfix(EndGameManager __instance) + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) { - foreach (var playerObj in __instance.GetComponentsInChildren()) + EndGameManager endGameManager = evt?.EndGameManager; + + foreach (var playerObj in endGameManager.GetComponentsInChildren()) { GameObject.Destroy(playerObj.gameObject); } - var winningPlayers = EndGameResult.CachedWinners.ToArray().OrderByDescending(p => !p.IsYou).ToList(); - + var winningPlayers = EndGameResult.CachedWinners.ToArray() + .OrderByDescending(p => !p.IsYou) + .ToList(); int num = winningPlayers.Count; for (int i = 0; i < num; i++) { var playerData = winningPlayers[i]; - int num2 = ((i % 2 == 0) ? (-1) : 1); + int num2 = (i % 2 == 0 ? -1 : 1); int num3 = (i + 1) / 2; - float num4 = (float)num3 / (float)num; + float num4 = (float)num3 / num; float num5 = Mathf.Lerp(1f, 0.75f, num4); float num6 = (i == 0) ? -8f : -1f; - PoolablePlayer poolablePlayer = Object.Instantiate(__instance.PlayerPrefab, __instance.transform); + PoolablePlayer poolablePlayer = Object.Instantiate(endGameManager.PlayerPrefab, endGameManager.transform); float xPos = 1f * num2 * num3 * num5 * 0.9f; float yPos = FloatRange.SpreadToEdges(-1.125f, 0f, num3, num) * 0.9f; float zPos = (num6 + num3 * 0.01f) * 0.9f; - poolablePlayer.transform.localPosition = new Vector3(1f * (float)num2 * (float)num3 * num5, FloatRange.SpreadToEdges(-1.125f, 0f, num3, num), num6 + (float)num3 * 0.01f) * 0.9f; + poolablePlayer.transform.localPosition = new Vector3(xPos, yPos, zPos); poolablePlayer.transform.localScale = Vector3.one * num5; if (playerData.IsDead) @@ -54,23 +59,20 @@ public static void Postfix(EndGameManager __instance) { poolablePlayer.SetFlipX(i % 2 == 0); } - poolablePlayer.UpdateFromPlayerOutfit( + poolablePlayer.UpdateFromPlayerOutfit( playerData.Outfit, PlayerMaterial.MaskType.None, playerData.IsDead, true, - null, + null, false ); string roleName = GetRoleName(playerData, out Color roleColor); - string playerNameWithRole = $"{playerData.PlayerName}\n{roleName}"; var nameText = poolablePlayer.cosmetics.nameText; - nameText.transform.localPosition = new Vector3(0f, -1.5f, -15f); - nameText.text = playerNameWithRole; nameText.color = roleColor; nameText.alignment = TMPro.TextAlignmentOptions.Center; @@ -84,23 +86,22 @@ public static void Postfix(EndGameManager __instance) case (GameOverReason)NewModEndReasons.EnergyThiefWin: customWinText = "Energy Thief Win!"; customWinColor = GetRoleColor(GetRoleType()); - __instance.BackgroundBar.material.SetColor("_Color", customWinColor); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); break; - case (GameOverReason)NewModEndReasons.DoubleAgentWin: customWinText = "Double Agent Win!"; customWinColor = GetRoleColor(GetRoleType()); - __instance.BackgroundBar.material.SetColor("_Color", customWinColor); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); break; case (GameOverReason)NewModEndReasons.PranksterWin: customWinText = "Prankster Win!"; customWinColor = GetRoleColor(GetRoleType()); - __instance.BackgroundBar.material.SetColor("_Color", customWinColor); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); break; case (GameOverReason)NewModEndReasons.SpecialAgentWin: customWinText = "Special Agent Victory"; customWinColor = GetRoleColor(GetRoleType()); - __instance.BackgroundBar.material.SetColor("_Color", customWinColor); + endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); break; default: customWinText = string.Empty; @@ -110,17 +111,20 @@ public static void Postfix(EndGameManager __instance) if (!string.IsNullOrEmpty(customWinText)) { - var customWinTextObject = GameObject.Instantiate(__instance.WinText.gameObject, __instance.transform); - customWinTextObject.transform.localPosition = new Vector3(__instance.WinText.transform.position.x, __instance.WinText.transform.position.y - 0.5f, __instance.WinText.transform.position.z); + var customWinTextObject = Object.Instantiate(endGameManager.WinText.gameObject, endGameManager.transform); + customWinTextObject.transform.localPosition = new Vector3( + endGameManager.WinText.transform.position.x, + endGameManager.WinText.transform.position.y - 0.5f, + endGameManager.WinText.transform.position.z); customWinTextObject.transform.localScale = new Vector3(0.7f, 0.7f, 1f); var customWinTextComponent = customWinTextObject.GetComponent(); customWinTextComponent.text = customWinText; customWinTextComponent.color = customWinColor; customWinTextComponent.fontSize = 4f; } - } + } - public static string GetRoleName(CachedPlayerData playerData, out Color roleColor) + private static string GetRoleName(CachedPlayerData playerData, out Color roleColor) { RoleTypes roleType = playerData.RoleWhenAlive; RoleBehaviour roleBehaviour = RoleManager.Instance.GetRole(roleType); @@ -145,13 +149,13 @@ public static string GetRoleName(CachedPlayerData playerData, out Color roleColo } } - public static RoleTypes GetRoleType() where T : RoleBehaviour + private static RoleTypes GetRoleType() where T : ICustomRole { ushort roleId = RoleId.Get(); return (RoleTypes)roleId; } - public static Color GetRoleColor(RoleTypes roleType) + private static Color GetRoleColor(RoleTypes roleType) { RoleBehaviour roleBehaviour = RoleManager.Instance.GetRole(roleType); @@ -173,80 +177,56 @@ public static Color GetRoleColor(RoleTypes roleType) } } - [HarmonyPatch(typeof(LogicGameFlowNormal), nameof(LogicGameFlowNormal.CheckEndCriteria))] + [HarmonyPatch(typeof(LogicGameFlowNormal), nameof(LogicGameFlowNormal.CheckEndCriteria))] public static class CheckGameEndPatch { public static bool Prefix(ShipStatus __instance) { if (DestroyableSingleton.InstanceExists) return true; - if (CheckEndGameForEnergyThief(__instance)) return false; - if (CheckEndGameForDoubleAgent(__instance)) return false; - if (CheckEndGameForPrankster(__instance)) return false; - if (CheckEndGameForSpecialAgent(__instance)) 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 CheckEndGameForEnergyThief(ShipStatus __instance) - { - if (PlayerControl.LocalPlayer != null && PlayerControl.LocalPlayer.Data.Role is EnergyThief) - { - int drainCount = Utils.GetDrainCount(PlayerControl.LocalPlayer.PlayerId); - if (drainCount > 3) - { - GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.EnergyThiefWin, false); - StatsManager.Instance.AddWinReason((GameOverReason)NewModEndReasons.DoubleAgentWin, (int)GameManager.Instance.LogicOptions.MapId, (RoleTypes)RoleId.Get()); - return true; - } - } - return false; } - - public static bool CheckEndGameForDoubleAgent(ShipStatus __instance) + public static bool CheckEndGameForRole(ShipStatus __instance, GameOverReason winReason, int maxCount = 1) where T : RoleBehaviour { - if (PlayerControl.LocalPlayer != null && PlayerControl.LocalPlayer.Data.Role is DoubleAgent) + var rolePlayers = PlayerControl.AllPlayerControls.ToArray() + .Where(p => p.Data.Role is T) + .Take(maxCount) + .ToList(); + + foreach (var player in rolePlayers) + { + bool shouldEndGame = false; + + if (typeof(T) == typeof(DoubleAgent)) { - bool tasksCompleted = PlayerControl.LocalPlayer.AllTasksCompleted(); + bool tasksCompleted = player.AllTasksCompleted(); bool isSabotageActive = Utils.IsSabotage(); - if (tasksCompleted && isSabotageActive) - { - GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.DoubleAgentWin, false); - StatsManager.Instance.AddWinReason((GameOverReason)NewModEndReasons.DoubleAgentWin, (int)GameManager.Instance.LogicOptions.MapId, (RoleTypes)RoleId.Get()); - return true; - } + shouldEndGame = tasksCompleted && isSabotageActive; } - return false; - } - public static bool CheckEndGameForPrankster(ShipStatus __instance) - { - if (PlayerControl.LocalPlayer != null && PlayerControl.LocalPlayer.Data.Role is Prankster) - { - int WinReportCount = 2; - int currentReportCount = PranksterUtilities.GetReportCount(PlayerControl.LocalPlayer.PlayerId); - if (currentReportCount >= WinReportCount) + if (typeof(T) == typeof(EnergyThief)) { - GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.PranksterWin, false); - StatsManager.Instance.AddWinReason((GameOverReason)NewModEndReasons.PranksterWin, (int)GameManager.Instance.LogicOptions.MapId, (RoleTypes)RoleId.Get()); - return true; + int WinReportCount = 2; + int currentReportCount = PranksterUtilities.GetReportCount(player.PlayerId); + shouldEndGame = currentReportCount >= WinReportCount; } - } - return false; - } - public static bool CheckEndGameForSpecialAgent(ShipStatus __instance) - { - if (PlayerControl.LocalPlayer != null && PlayerControl.LocalPlayer.Data.Role is SpecialAgent) - { - int missionSuccessCount = Utils.GetMissionSuccessCount(PlayerControl.LocalPlayer.PlayerId); - int missionFailureCount = Utils.GetMissionFailureCount(PlayerControl.LocalPlayer.PlayerId); - int netScore = missionSuccessCount - missionFailureCount; - - if (netScore >= OptionGroupSingleton.Instance.RequiredMissionsToWin) - { - GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.SpecialAgentWin, false); - StatsManager.Instance.AddWinReason((GameOverReason)NewModEndReasons.SpecialAgentWin, (int)GameManager.Instance.LogicOptions.MapId, (RoleTypes)RoleId.Get()); + if (typeof(T) == typeof(SpecialAgent)) + { + int missionSuccessCount = Utils.GetMissionSuccessCount(player.PlayerId); + int missionFailureCount = Utils.GetMissionFailureCount(player.PlayerId); + int netScore = missionSuccessCount - missionFailureCount; + shouldEndGame = netScore >= OptionGroupSingleton.Instance.RequiredMissionsToWin; + } + if (shouldEndGame) + { + GameManager.Instance.RpcEndGame(winReason, false); + CustomStatsManager.IncrementRoleWin((ICustomRole)player.Data.Role); return true; - } + } } return false; } } -} \ No newline at end of file +} diff --git a/NewMod/Patches/HauntMenuMinigamePatch.cs b/NewMod/Patches/HauntMenuMinigamePatch.cs deleted file mode 100644 index 036a970..0000000 --- a/NewMod/Patches/HauntMenuMinigamePatch.cs +++ /dev/null @@ -1,25 +0,0 @@ -using HarmonyLib; -using MiraAPI.Roles; - -namespace NewMod.Patches -{ - [HarmonyPatch(typeof(HauntMenuMinigame), nameof(HauntMenuMinigame.SetFilterText))] - public static class HauntMenuMinigamePatch - { - public static bool Prefix(HauntMenuMinigame __instance) - { - var targetData = __instance.HauntTarget.Data; - - if (targetData.Role is ICustomRole customRole) - { - __instance.FilterText.text = customRole.RoleName; - return false; - } - else - { - __instance.FilterText.text = targetData.Role.NiceName; - } - return false; - } - } -} diff --git a/NewMod/Patches/LogoPatch.cs b/NewMod/Patches/LogoPatch.cs deleted file mode 100644 index acbbc7a..0000000 --- a/NewMod/Patches/LogoPatch.cs +++ /dev/null @@ -1,23 +0,0 @@ -using UnityEngine; -using HarmonyLib; - -namespace NewMod.Patches -{ - [HarmonyPatch(typeof(MainMenuManager), nameof(MainMenuManager.Start))] - [HarmonyPriority(Priority.High)] - public static class NewModLogoPatch - { - public static SpriteRenderer LogoSprite; - - [HarmonyPostfix] - public static void StartPostfix(MainMenuManager __instance) - { - var newparent = __instance.gameModeButtons.transform.parent; - var Logo = new GameObject("NewmodLogo"); - Logo.transform.parent = newparent; - Logo.transform.localPosition = new(0f, -0.07f, 1f); - LogoSprite = Logo.AddComponent(); - LogoSprite.sprite = NewModAsset.ModLogo.LoadAsset(); - } - } -} diff --git a/NewMod/Patches/MainMenuPatch.cs b/NewMod/Patches/MainMenuPatch.cs new file mode 100644 index 0000000..e4cbe8b --- /dev/null +++ b/NewMod/Patches/MainMenuPatch.cs @@ -0,0 +1,25 @@ +using UnityEngine; +using HarmonyLib; + +namespace NewMod.Patches +{ + [HarmonyPatch(typeof(MainMenuManager), nameof(MainMenuManager.Start))] + [HarmonyPriority(Priority.VeryHigh)] + public static class MainMenuPatch + { + public static SpriteRenderer LogoSprite; + + [HarmonyPostfix] + public static void StartPostfix(MainMenuManager __instance) + { + var newparent = __instance.transform.FindChild("MainCanvas/MainPanel/RightPanel"); + var Logo = new GameObject("NewModLogo"); + Logo.transform.SetParent(newparent, false); + Logo.transform.localPosition = new(2.34f, -0.7136f, 1f); + LogoSprite = Logo.AddComponent(); + LogoSprite.sprite = NewModAsset.ModLogo.LoadAsset(); + + ModCompatibility.Initialize(); + } + } +} diff --git a/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs b/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs index 9a46725..4fa1237 100644 --- a/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs +++ b/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs @@ -1,7 +1,7 @@ using HarmonyLib; using MiraAPI.Hud; using NewMod.Utilities; -using NewMod.Buttons; +using NewMod.Roles.ImpostorRoles; namespace NewMod.Patches.Roles.EnergyThief; @@ -15,6 +15,9 @@ public static void Postfix(AmongUsClient __instance, [HarmonyArgument(0)] EndGam Utils.ResetMissionFailureCount(); PranksterUtilities.ResetReportCount(); VisionaryUtilities.DeleteAllScreenshots(); + Revenant.HasUsedFeignDeath = false; + Revenant.FeignDeathStates.Remove(PlayerControl.LocalPlayer.PlayerId); + Revenant.StalkingStates[PlayerControl.LocalPlayer.PlayerId] = false; NewMod.Instance.Log.LogInfo("Reset Drain Count Successfully"); NewMod.Instance.Log.LogInfo("Reset Clone Report Count Successfully"); NewMod.Instance.Log.LogInfo("Reset Mission Success Count Successfully"); diff --git a/NewMod/Patches/Roles/MeetingHudPatch.cs b/NewMod/Patches/Roles/MeetingHudPatch.cs index 33fe5c1..ad05f64 100644 --- a/NewMod/Patches/Roles/MeetingHudPatch.cs +++ b/NewMod/Patches/Roles/MeetingHudPatch.cs @@ -1,9 +1,17 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; +using UnityEngine.Events; using System.Collections.Generic; using HarmonyLib; using NewMod.Utilities; +using MiraAPI.Roles; +using MiraAPI.Hud; +using Reactor.Utilities; using System.Linq; using Il2CppInterop.Runtime.InteropTypes.Arrays; using AmongUs.GameOptions; +using NewMod.Roles.NeutralRoles; namespace NewMod.Patches.Roles { @@ -30,29 +38,37 @@ public static bool Prefix(ref Il2CppReferenceArray deadBodi return true; } - } - [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.PopulateButtons))] - public static class MeetingHud_PopulateButtons_Patch - { - public static bool Prefix(MeetingHud __instance, byte reporter) + + [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.PopulateButtons))] + public static class MeetingHud_PopulateButtons_Patch { - var fakeBodies = PranksterUtilities.FindAllPranksterBodies(); - var voteArea = GameData.Instance.AllPlayers - .ToArray() - .Where(player => - !player.IsDead && - !fakeBodies.Any(body => body.ParentId == player.PlayerId) - ) - .Select(player => + public static bool Prefix(MeetingHud __instance, byte reporter) + { + var fakeBodies = PranksterUtilities.FindAllPranksterBodies(); + var realPlayers = GameData.Instance.AllPlayers + .ToArray() + .Where(p => !fakeBodies.Any(body => body.ParentId == p.PlayerId)) + .ToList(); + + __instance.playerStates = new Il2CppReferenceArray(realPlayers.Count); + + for (int i = 0; i < realPlayers.Count; i++) { + var player = realPlayers[i]; PlayerVoteArea voteArea = __instance.CreateButton(player); + voteArea.Parent = __instance; voteArea.SetTargetPlayerId(player.PlayerId); - voteArea.SetDead(false, player.Disconnected || player.IsDead, player.Role?.Role == RoleTypes.GuardianAngel); - return voteArea; - }); - __instance.playerStates = new Il2CppReferenceArray(voteArea.ToArray()); - __instance.SortButtons(); - return false; + voteArea.SetDead( + didReport: (player.PlayerId == reporter), + isDead: player.Disconnected || player.IsDead, + isGuardian: player.Role != null && player.Role.Role == RoleTypes.GuardianAngel + ); + __instance.playerStates[i] = voteArea; + } + __instance.SortButtons(); + + return false; + } } } } diff --git a/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs b/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs index a3f74b0..1670578 100644 --- a/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs +++ b/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs @@ -1,4 +1,6 @@ using HarmonyLib; +using MiraAPI.Networking; +using NewMod.Roles.ImpostorRoles; using NewMod.Utilities; namespace NewMod.Patches.Roles.Prankster @@ -8,11 +10,11 @@ public static class DeadBodyOnClickPatch { public static bool Prefix(DeadBody __instance) { + var reporter = PlayerControl.LocalPlayer; + if (!__instance.Reported && PranksterUtilities.IsPranksterBody(__instance)) { - var reporter = PlayerControl.LocalPlayer; - - reporter.RpcMurderPlayer(reporter, true); + reporter.RpcCustomMurder(reporter, true, teleportMurderer:false, showKillAnim:true); byte pranksterId = __instance.ParentId; @@ -20,6 +22,11 @@ public static bool Prefix(DeadBody __instance) return false; } + else if (!__instance.Reported && Revenant.FeignDeathStates.TryGetValue(__instance.ParentId, out var feignInfo)) + { + feignInfo.Reported = true; + return false; + } return true; } } diff --git a/NewMod/Patches/Roles/RevivePatch.cs b/NewMod/Patches/Roles/RevivePatch.cs new file mode 100644 index 0000000..74dcddf --- /dev/null +++ b/NewMod/Patches/Roles/RevivePatch.cs @@ -0,0 +1,27 @@ +using UnityEngine; +using HarmonyLib; + +namespace NewMod.Patches.Roles +{ + [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.Revive))] + public static class RevivePatch + { + public static bool Prefix(PlayerControl __instance) + { + // Thanks to https://github.com/Dolly1016/Nebula-OLD-/blob/master/NebulaPluginNova/Patches/ButtonsPatch.cs#L75 + __instance.Data.IsDead = false; + __instance.gameObject.layer = LayerMask.NameToLayer("Players"); + __instance.MyPhysics.ResetMoveState(true); + __instance.clickKillCollider.enabled = true; + __instance.cosmetics.SetNameMask(true); + + if (__instance.AmOwner) + { + DestroyableSingleton.Instance.ShadowQuad.gameObject.SetActive(true); + DestroyableSingleton.Instance.SetHudActive(true); + DestroyableSingleton.Instance.Chat.SetVisible(false); + } + return false; + } + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs b/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs index 8b66e61..9542554 100644 --- a/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs +++ b/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs @@ -1,48 +1,41 @@ using HarmonyLib; -using MiraAPI.GameOptions; -using MiraAPI.Networking; -using MiraAPI.Roles; -using NewMod.Options.Roles.VisionaryOptions; -using NewMod.Roles.CrewmateRoles; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Usables; using NewMod.Utilities; +using MiraAPI.Utilities; using Reactor.Utilities; -using UnityEngine; +using MiraAPI.Events; namespace NewMod.Patches.Roles.Visionary { - [HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.RpcEnterVent))] public static class VisionaryVentPatch { - public static bool Prefix(PlayerPhysics __instance, int id) + [RegisterEvent] + public static void OnEnterVent(EnterVentEvent evt) { - float chance = 0.3f; - if (Random.Range(0f, 1f) < chance) + PlayerControl player = evt.Player; + var chancePercentage = (int)(0.2f * 100); + if (Helpers.CheckChance(chancePercentage)) { - var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); - string filePath = System.IO.Path.Combine( - VisionaryUtilities.ScreenshotDirectory, - $"screenshot_{timestamp}.png" - ); + string timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); + string filePath = System.IO.Path.Combine(VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png"); Coroutines.Start(Utils.CaptureScreenshot(filePath)); - if (__instance.myPlayer.AmOwner) + if (player.AmOwner) { Coroutines.Start(CoroutinesHelper.CoNotify("Warning: Visionary might have seen you vent!")); } } - return true; } - [HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.RpcExitVent))] - [HarmonyPrefix] - public static bool StartPrefix(PlayerPhysics __instance, int id) + public static void Postfix(PlayerPhysics __instance, int ventId) { - float chance = 0.3f; - if (Random.Range(0f, 1f) < chance) + var chancePercentage = (int)(0.2f * 100); + if (Helpers.CheckChance(chancePercentage)) { var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); string filePath = System.IO.Path.Combine( - VisionaryUtilities.ScreenshotDirectory, + VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png" ); Coroutines.Start(Utils.CaptureScreenshot(filePath)); @@ -52,81 +45,30 @@ public static bool StartPrefix(PlayerPhysics __instance, int id) Coroutines.Start(CoroutinesHelper.CoNotify("Warning: Visionary might have seen you exit vent!")); } } - return true; } } public static class VisionaryMurderPatch { - [HarmonyPatch(typeof(CustomMurderRpc), nameof(CustomMurderRpc.RpcCustomMurder))] - [HarmonyPrefix] - public static bool StartPrefix(PlayerControl target, bool didSucceed, bool resetKillTimer, bool createDeadBody, bool teleportMurderer, bool showKillAnim, bool playKillSound) + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) { - float chance = 0.5f; - if (Random.Range(0f, 1f) < chance) + PlayerControl source = evt.Source; + int chancePercentage = (int)(0.2f * 100); + + if (Helpers.CheckChance(chancePercentage)) { var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); string filePath = System.IO.Path.Combine( - VisionaryUtilities.ScreenshotDirectory, + VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png" ); Coroutines.Start(Utils.CaptureScreenshot(filePath)); - if (PlayerControl.LocalPlayer.AmOwner) + if (source.AmOwner) { Coroutines.Start(CoroutinesHelper.CoNotify("Warning: The Visionary may have captured your crime!")); } } - return true; - } - } - public static class VisionaryChatPatch - { - [HarmonyPatch(typeof(ChatController), nameof(ChatController.SetVisible))] - [HarmonyPrefix] - public static bool StartPrefix(ChatController __instance, bool visible) - { - if (PlayerControl.LocalPlayer.Data.Role is not ICustomRole) return true; - if (MeetingHud.Instance) return true; - - bool allowChat = VisionaryUtilities.CapturedScreenshotPaths.Count > 2; - if (allowChat) - { - __instance.gameObject.SetActive(true); - } - else - { - __instance.gameObject.SetActive(false); - } - return false; - } - [HarmonyPatch(typeof(ChatController), nameof(ChatController.SendChat))] - [HarmonyPrefix] - public static bool StartPrefix(ChatController __instance) - { - if (PlayerControl.LocalPlayer.Data.Role is not ICustomRole) return true; - if (PlayerControl.LocalPlayer.Data.Role is not TheVisionary) return true; - string chatText = __instance.freeChatField.Text; - - if (chatText.ToLower().StartsWith("/") && chatText.Length > 1) - { - string commandPart = chatText[1..].Trim(); - if (int.TryParse(commandPart, out var index)) - { - int zeroBased = index - 1; - - if (zeroBased >= 0 && zeroBased < VisionaryUtilities.CapturedScreenshotPaths.Count) - { - string path = VisionaryUtilities.CapturedScreenshotPaths[zeroBased]; - Coroutines.Start(VisionaryUtilities.ShowScreenshotByPath(path, OptionGroupSingleton.Instance.MaxDisplayDuration)); - Coroutines.Start(CoroutinesHelper.CoNotify($"Showing screenshot #{index}!")); - } - else - { - Coroutines.Start(CoroutinesHelper.CoNotify($"Screenshot #{index} not found.")); - } - } - } - return false; } } } diff --git a/NewMod/Patches/StatsPopupPatch.cs b/NewMod/Patches/StatsPopupPatch.cs index c490935..d2c191f 100644 --- a/NewMod/Patches/StatsPopupPatch.cs +++ b/NewMod/Patches/StatsPopupPatch.cs @@ -3,54 +3,198 @@ using HarmonyLib; using MiraAPI.Roles; using Il2CppSystem.Text; +using System; +using System.Collections.Generic; +using System.IO; + +using System.Linq; +using System.Reflection; +#if PC +using AmongUs.Data.Player; +using AmongUs.Data; +#endif namespace NewMod.Patches { + public static class CustomStatsManager + { + private static readonly string SavePath = Path.Combine(Application.consoleLogPath, "customStats.dat"); + public static Dictionary CustomRoleWins = new(); + public static bool _loaded = false; + public static void SaveCustomStats() + { + try + { + using var fs = new FileStream( + SavePath, + FileMode.Create, + FileAccess.Write, + FileShare.Write + ); + using var writer = new BinaryWriter(fs); + + var allRoles = RoleManager.Instance.AllRoles; + writer.Write(allRoles.Count); + + foreach (var role in allRoles) + { + string key; + int wins; + + if (role is ICustomRole customRole) + { + key = customRole.RoleName; + wins = CustomRoleWins.TryGetValue(key, out var w) ? w : 0; + } + else + { +#if PC + key = role.NiceName; + wins = (int)DataManager.Player.Stats.GetRoleStat(role.Role, RoleStat.Wins); +#else + wins = (int)StatsManager.Instance.GetRoleWinCount(roleType); +#endif + } + writer.Write(key); + writer.Write(wins); + } + } + catch (Exception ex) + { + NewMod.Instance.Log.LogError($"Failed saving custom stats: {ex}"); + } + } + public static void LoadCustomStats() + { + if (_loaded) return; + + if (!File.Exists(SavePath)) + { + return; + } + + using var fs = new FileStream( + SavePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite + ); + using var reader = new BinaryReader(fs); + + int count = reader.ReadInt32(); + for (int i = 0; i < count; i++) + { + var key = reader.ReadString(); + var wins = reader.ReadInt32(); + CustomRoleWins[key] = wins; + } + _loaded = true; + } + public static void IncrementRoleWin(ICustomRole customRole) + { + string roleName = customRole.RoleName; + + if (CustomRoleWins.ContainsKey(roleName)) + { + CustomRoleWins[roleName]++; + } + else + { + CustomRoleWins[roleName] = 1; + } + } + public static int GetRoleWins(ICustomRole customRole) + { + return CustomRoleWins.TryGetValue(customRole.RoleName, out var w) ? w : 0; + } + } + +#if PC + [HarmonyPatch(typeof(PlayerStatsData), nameof(PlayerStatsData.SaveStats))] +#else + [HarmonyPatch(typeof(StatsManager), nameof(StatsManager.SaveStats))] +#endif + public class SaveStatsPatch + { + public static void Postfix() + { + CustomStatsManager.SaveCustomStats(); + } + } + +#if PC + [HarmonyPatch(typeof(PlayerStatsData), nameof(PlayerStatsData.GetRoleStat))] +#else + [HarmonyPatch(typeof(StatsManager), nameof(StatsManager.LoadStats))] +#endif + public class LoadStatsPatch + { +#if PC + public static void Postfix(PlayerStatsData __instance, RoleTypes role, StatID stat) + { + CustomStatsManager.LoadCustomStats(); + } +#else + public static void Postfix(StatsManager __instance) + { + CustomStatsManager.LoadCustomStats(); + } +#endif + } [HarmonyPatch(typeof(StatsPopup), nameof(StatsPopup.DisplayRoleStats))] public class DisplayRoleStatsPatch { public static bool Prefix(StatsPopup __instance) { StringBuilder stringBuilder = new StringBuilder(); - var allRoles = RoleManager.Instance.AllRoles; foreach (var role in allRoles) { RoleTypes roleType = role.Role; - - if (roleType == RoleTypes.ImpostorGhost || roleType == RoleTypes.CrewmateGhost) - { - continue; - } - - var winCount = StatsManager.Instance.GetRoleWinCount(roleType); - string roleName; Color roleColor; + int winCount; if (role is ICustomRole customRole) { roleName = customRole.RoleName; roleColor = customRole.RoleColor; + winCount = CustomStatsManager.GetRoleWins(customRole); } else { roleName = role.NiceName; roleColor = role.NameColor; +#if PC + winCount = (int)DataManager.Player.Stats.GetRoleStat(roleType, RoleStat.Wins); +#else + winCount = (int)StatsManager.Instance.GetRoleWinCount(roleType); +#endif } StatsPopup.AppendStat(stringBuilder, StringNames.StatsRoleWins, winCount, $"{roleName}"); } +#if PC + foreach (var entry in StatsPopup.RoleSpecificStatsToShow) + { + + StatID statID = entry.Key; + StringNames stringNames = entry.Value; + + StatsPopup.AppendStat(stringBuilder, stringNames, DataManager.Player.Stats.GetStat(statID)); + + } +#else foreach (StringNames stringName in StatsPopup.RoleSpecificStatsToShow) { - StatsPopup.AppendStat(stringBuilder, stringName, StatsManager.Instance.GetStat(stringName)); + StatsPopup.AppendStat(stringBuilder, stringName, StatsManager.Instance.GetStat(stringName)); } - +#endif __instance.StatsText.text = stringBuilder.ToString(); return false; } } -} +} \ No newline at end of file diff --git a/NewMod/PendingEffectManager.cs b/NewMod/PendingEffectManager.cs index f3794e6..7826e84 100644 --- a/NewMod/PendingEffectManager.cs +++ b/NewMod/PendingEffectManager.cs @@ -3,9 +3,20 @@ namespace NewMod { + /// + /// Manages a collection of pending effects that can be applied to players. + /// public static class PendingEffectManager { + /// + /// A set of player targets to which effects are awaiting application. + /// public static HashSet pendingEffects = new HashSet(); + + /// + /// Adds a player to the pending effects set if not already present. + /// + /// The player to receive the pending effect. public static void AddPendingEffect(PlayerControl target) { if (target != null && !pendingEffects.Contains(target)) @@ -13,13 +24,22 @@ public static void AddPendingEffect(PlayerControl target) pendingEffects.Add(target); } } + + /// + /// Removes a player from the pending effects set if they are currently included. + /// + /// The player whose pending effect should be removed. public static void RemovePendingEffect(PlayerControl target) { - if (target != null && pendingEffects.Contains(target)) + if (target != null && pendingEffects.Contains(target)) { pendingEffects.Remove(target); } } + + /// + /// Applies all pending effects to their respective players and then clears the lists of pending effects and waiting players. + /// public static void ApplyPendingEffects() { foreach (var target in pendingEffects) diff --git a/NewMod/Resources/Logo.png b/NewMod/Resources/Logo.png index adaa6cd..c7941e9 100644 Binary files a/NewMod/Resources/Logo.png and b/NewMod/Resources/Logo.png differ diff --git a/NewMod/Resources/Sounds/evil_laugh.wav b/NewMod/Resources/Sounds/evil_laugh.wav new file mode 100644 index 0000000..3d19420 Binary files /dev/null and b/NewMod/Resources/Sounds/evil_laugh.wav differ diff --git a/NewMod/Resources/Sounds/gloomy_aura.wav b/NewMod/Resources/Sounds/gloomy_aura.wav new file mode 100644 index 0000000..3cfe87b Binary files /dev/null and b/NewMod/Resources/Sounds/gloomy_aura.wav differ diff --git a/NewMod/Resources/Sounds/revive.wav b/NewMod/Resources/Sounds/revive.wav new file mode 100644 index 0000000..5b26cfa Binary files /dev/null and b/NewMod/Resources/Sounds/revive.wav differ diff --git a/NewMod/Resources/doomawakening.png b/NewMod/Resources/doomawakening.png new file mode 100644 index 0000000..285491b Binary files /dev/null and b/NewMod/Resources/doomawakening.png differ diff --git a/NewMod/Resources/givemission.png b/NewMod/Resources/givemission.png new file mode 100644 index 0000000..43a82fd Binary files /dev/null and b/NewMod/Resources/givemission.png differ diff --git a/NewMod/Resources/showscreenshot.png b/NewMod/Resources/showscreenshot.png new file mode 100644 index 0000000..1162ed4 Binary files /dev/null and b/NewMod/Resources/showscreenshot.png differ diff --git a/NewMod/Roles/CrewmateRoles/DoubleAgent.cs b/NewMod/Roles/CrewmateRoles/DoubleAgent.cs index 94c08f0..ddd534a 100644 --- a/NewMod/Roles/CrewmateRoles/DoubleAgent.cs +++ b/NewMod/Roles/CrewmateRoles/DoubleAgent.cs @@ -4,7 +4,6 @@ namespace NewMod.Roles.CrewmateRoles; -[RegisterCustomRole] public class DoubleAgent : CrewmateRole, ICustomRole { public string RoleName => "Double Agent"; @@ -12,6 +11,7 @@ public class DoubleAgent : CrewmateRole, ICustomRole public string RoleLongDescription => RoleDescription; public Color RoleColor => Palette.ImpostorRed; public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Crewmate; public CustomRoleConfiguration Configuration => new(this) { MaxRoleCount = 1, diff --git a/NewMod/Roles/CrewmateRoles/Specialist.cs b/NewMod/Roles/CrewmateRoles/Specialist.cs new file mode 100644 index 0000000..808352d --- /dev/null +++ b/NewMod/Roles/CrewmateRoles/Specialist.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using MiraAPI.Roles; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; +using MiraAPI.Utilities.Assets; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.Events; + +namespace NewMod.Roles.CrewmateRoles; + +public class Specialist : CrewmateRole, ICustomRole +{ + public string RoleName => "Specialist"; + public string RoleDescription => "Complete tasks, gain power."; + public string RoleLongDescription => "Each task you complete grants you a random ability."; + public Color RoleColor => new(0.0f, 0.8f, 1.0f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Crewmate; + public CustomRoleConfiguration Configuration => new(this) + { + MaxRoleCount = 1, + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + TasksCountForProgress = true, + CanUseSabotage = false, + DefaultChance = 50, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + [RegisterEvent] + public static void OnTaskComplete(CompleteTaskEvent evt) + { + PlayerControl player = evt.Player; + if (!(player.Data.Role is Specialist)) return; + + List abilityAction = new List + { + () => + { + var target = Utils.GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected && p != player); + if (target != null) + { + Utils.RpcRandomDrainActions(player, target); + Coroutines.Start(CoroutinesHelper.CoNotify( + $"Energy Drain activated on {target.Data.PlayerName}!")); + } + }, + () => + { + var closestBody = Utils.GetClosestBody(); + var player = Utils.PlayerById(closestBody.ParentId); + if (closestBody != null) + { + Utils.RpcRevive(closestBody); + Coroutines.Start(CoroutinesHelper.CoNotify( + $"Player {player.Data.PlayerName} has been revived.")); + } + }, + () => + { + PranksterUtilities.CreatePranksterDeadBody(player, player.PlayerId); + Coroutines.Start(CoroutinesHelper.CoNotify( + "Fake Body created!")); + }, + () => + { + var randPlayer = Utils.GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); + if (randPlayer != null && randPlayer.Data.Role is not ICustomRole) + { + var role = randPlayer.Data.Role; + role.UseAbility(); + } + }, + () => + { + Utils.RpcAssignMission(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer); + Coroutines.Start(CoroutinesHelper.CoNotify( + "You have been assigned a mission. Complete it or die.")); + } + }; + + if (abilityAction.Count == 0) + { + return; + } + int randomIndex = UnityEngine.Random.Range(0, abilityAction.Count); + abilityAction[randomIndex].Invoke(); + } + public override bool DidWin(GameOverReason gameOverReason) + { + #if PC + return gameOverReason == GameOverReason.CrewmatesByTask; + #else + return gameOverReason == GameOverReason.HumansByTask; + #endif + } +} diff --git a/NewMod/Roles/CrewmateRoles/TheVisionary.cs b/NewMod/Roles/CrewmateRoles/TheVisionary.cs index 57fff70..de9802d 100644 --- a/NewMod/Roles/CrewmateRoles/TheVisionary.cs +++ b/NewMod/Roles/CrewmateRoles/TheVisionary.cs @@ -4,14 +4,14 @@ namespace NewMod.Roles.CrewmateRoles; -[RegisterCustomRole] public class TheVisionary : CrewmateRole, ICustomRole { - public string RoleName => "TheVisionary"; + public string RoleName => "The Visionary"; public string RoleDescription => $"Take photos during the game"; public string RoleLongDescription => "Capture key moments during the game by taking photos to gather evidence"; public Color RoleColor => new(0.75f, 0.5f, 1.0f); public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public RoleOptionsGroup RoleOptionGroup { get; } = RoleOptionsGroup.Crewmate; public CustomRoleConfiguration Configuration => new(this) { DefaultRoleCount = 2, @@ -23,7 +23,7 @@ public class TheVisionary : CrewmateRole, ICustomRole CanUseVent = false, TasksCountForProgress = true, Icon = MiraAssets.Empty, - OptionsScreenshot = MiraAssets.Empty, + OptionsScreenshot = MiraAssets.Empty, CanModifyChance = true, RoleHintType = RoleHintType.RoleTab }; diff --git a/NewMod/Roles/ImpostorRoles/Necromancer.cs b/NewMod/Roles/ImpostorRoles/Necromancer.cs index b985e9e..57f04fd 100644 --- a/NewMod/Roles/ImpostorRoles/Necromancer.cs +++ b/NewMod/Roles/ImpostorRoles/Necromancer.cs @@ -7,19 +7,24 @@ namespace NewMod.Roles.ImpostorRoles; - [RegisterCustomRole] - public class NecromancerRole : ImpostorRole, ICustomRole - { - public string RoleName => "Necromancer"; - public string RoleDescription => "You can revive dead players who weren't killed by you"; - public string RoleLongDescription => "As the Necromancer, you possess a unique and powerful ability: the power to bring one dead player back to life. However,\nyou can only revive someone who wasn't killed by you" + (OptionGroupSingleton.Instance.EnableTeleportation ? "\nPress F3 for Teleportation" : ""); - public Color RoleColor => Palette.AcceptedGreen.GetAlternateColor(); - public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; - public CustomRoleConfiguration Configuration => new(this) - { - Icon = MiraAssets.Empty, - OptionsScreenshot = NewModAsset.Banner, - MaxRoleCount = 3 - }; +public class NecromancerRole : ImpostorRole, ICustomRole +{ + public string RoleName => "Necromancer"; + public string RoleDescription => "You can revive dead players who weren't killed by you"; + public string RoleLongDescription => "As the Necromancer, you possess a unique and powerful ability: the power to bring one dead player back to life. However,\nyou can only revive someone who wasn't killed by you" + (OptionGroupSingleton.Instance.EnableTeleportation ? "\nPress F3 for Teleportation" : ""); + public Color RoleColor => Palette.AcceptedGreen.FindAlternateColor(); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Impostor; + public CustomRoleConfiguration Configuration => new(this) + { + Icon = MiraAssets.Empty, + OptionsScreenshot = NewModAsset.Banner, + MaxRoleCount = 3, + }; + public TeamIntroConfiguration TeamConfiguration => new() + { + IntroTeamDescription = RoleDescription, + IntroTeamColor = RoleColor + }; } diff --git a/NewMod/Roles/ImpostorRoles/Revenant.cs b/NewMod/Roles/ImpostorRoles/Revenant.cs new file mode 100644 index 0000000..1d067f1 --- /dev/null +++ b/NewMod/Roles/ImpostorRoles/Revenant.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace NewMod.Roles.ImpostorRoles; + +public class Revenant : ImpostorRole, ICustomRole +{ + public string RoleName => "Revenant"; + public string RoleDescription => "Cheat deathโ€”exactly once per match. Time it wisely."; + public string RoleLongDescription => "As the Revenant, activate your ghostly form once per game to evade death for 10 seconds.\nIf a meeting is called during this time, your protection is lost permanentlyโ€”time it wisely!"; + public Color RoleColor => new(0.3f, 0f, 0.5f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Impostor; + public CustomRoleConfiguration Configuration => new(this) + { + MaxRoleCount = 2, + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = true, + TasksCountForProgress = false, + CanUseSabotage = true, + DefaultChance = 50, + DefaultRoleCount = 1, + CanModifyChance = true, + GhostRole = AmongUs.GameOptions.RoleTypes.Crewmate, //Indeed + RoleHintType = RoleHintType.RoleTab + }; + public static Dictionary FeignDeathStates = new Dictionary(); + public static bool HasUsedFeignDeath = false; + public static Dictionary StalkingStates = new Dictionary(); + public class FeignDeathInfo + { + public float Timer; + public DeadBody DeadBody; + public bool Reported; + } + + [RegisterEvent] + public static void OnPlayerExit(PlayerLeaveEvent evt) + { + if (FeignDeathStates.ContainsKey(evt.ClientData.Character.PlayerId)) + { + FeignDeathStates.Remove(evt.ClientData.Character.PlayerId); + } + if (StalkingStates.ContainsKey(evt.ClientData.Character.PlayerId)) + { + StalkingStates.Remove(evt.ClientData.Character.PlayerId); + } + HasUsedFeignDeath = false; + } +} diff --git a/NewMod/Roles/NeutralRoles/Egoist.cs b/NewMod/Roles/NeutralRoles/Egoist.cs new file mode 100644 index 0000000..36fb5dc --- /dev/null +++ b/NewMod/Roles/NeutralRoles/Egoist.cs @@ -0,0 +1,86 @@ +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameOptions; +using MiraAPI.Networking; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using NewMod.Options.Roles.EgoistOptions; +using UnityEngine; + +namespace NewMod.Roles.NeutralRoles +{ + public class EgoistRole : CrewmateRole, ICustomRole + { + public string RoleName => "Egoist"; + public string RoleDescription => "Crave attention. Earn revenge."; + public string RoleLongDescription => + "You are the Egoist, a chaotic neutral entity.\n\n" + + "Your goal is to be ejected โ€” if you are, and enough players vote for you, they die and you win."; + public Color RoleColor => new Color(0.8f, 0.3f, 0.6f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup => RoleOptionsGroup.Neutral; + + public CustomRoleConfiguration Configuration => new(this) + { + AffectedByLightOnAirship = false, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + CanUseSabotage = false, + TasksCountForProgress = false, + ShowInFreeplay = true, + HideSettings = false, + MaxRoleCount = 1, + OptionsScreenshot = null, + Icon = null, + }; + + [RegisterEvent] + public static void OnEjection(EjectionEvent evt) + { + var egoist = PlayerControl + .AllPlayerControls.ToArray() + .FirstOrDefault(p => p.Data.Role is EgoistRole); + if (egoist == null) + return; + + var ejected = evt.ExileController.initData.networkedPlayer.Object; + if (ejected != egoist) + return; + + int minVotes = OptionGroupSingleton.Instance.MinimumVotesToWin; + + var voters = PlayerControl + .AllPlayerControls.ToArray() + .Where(p => + { + var voteData = p.GetVoteData(); + return voteData != null && voteData.VotedFor(egoist.PlayerId); + }) + .ToList(); + + if (voters.Count >= minVotes) + { + foreach (var p in voters) + { + egoist.RpcCustomMurder( + p, + didSucceed: true, + resetKillTimer: false, + createDeadBody: true, + teleportMurderer: false, + showKillAnim: false, + playKillSound: true + ); + } + GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.EgoistWin, false); + } + } + + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == (GameOverReason)NewModEndReasons.EgoistWin; + } + } +} diff --git a/NewMod/Roles/NeutralRoles/EnergyThief.cs b/NewMod/Roles/NeutralRoles/EnergyThief.cs index 546cd47..3b08482 100644 --- a/NewMod/Roles/NeutralRoles/EnergyThief.cs +++ b/NewMod/Roles/NeutralRoles/EnergyThief.cs @@ -5,14 +5,14 @@ namespace NewMod.Roles.NeutralRoles; -[RegisterCustomRole] public class EnergyThief : CrewmateRole, ICustomRole { - public string RoleName => "EnergyThief"; + public string RoleName => "Energy Thief"; public string RoleDescription => "Drains energy from others, making them weak"; public string RoleLongDescription => $"The Energy Thief can drain energy from Crewmates or Impostors, weakening them and gaining temporary buffs\nDrain 3 players to win."; - public Color RoleColor => Color.magenta.GetAlternateColor(); - public ModdedRoleTeams Team => ModdedRoleTeams.Neutral; + public Color RoleColor => Color.magenta.FindAlternateColor(); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; public CustomRoleConfiguration Configuration => new(this) { MaxRoleCount = 5, diff --git a/NewMod/Roles/NeutralRoles/Overload.cs b/NewMod/Roles/NeutralRoles/Overload.cs new file mode 100644 index 0000000..445d9af --- /dev/null +++ b/NewMod/Roles/NeutralRoles/Overload.cs @@ -0,0 +1,84 @@ +using System.Collections; +using MiraAPI.Events; +using MiraAPI.Roles; +using Reactor.Utilities; +using UnityEngine; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Hud; +using UnityEngine.Events; +using NewMod.Utilities; + +namespace NewMod.Roles.NeutralRoles; +public class OverloadRole : ImpostorRole, ICustomRole +{ + public string RoleName => "Overload"; + public string RoleDescription => "Absorb, Consume, Devour, Overload."; + public string RoleLongDescription => "You are the Overload, an impostor who thrives on the abilities of the fallen. Each ejected player fuels your chaos, granting you their power"; + public Color RoleColor => new Color(0.6f, 0.1f, 0.3f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; + public static int AbsorbedAbilityCount = 0; + public static PlayerControl chosenPrey; + public CustomRoleConfiguration Configuration => new(this) + { + AffectedByLightOnAirship = false, + CanGetKilled = true, + UseVanillaKillButton = true, + CanUseVent = true, + CanUseSabotage = false, + TasksCountForProgress = false, + ShowInFreeplay = true, + HideSettings = false, + OptionsScreenshot = null, + Icon = null, + }; + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (PlayerControl.LocalPlayer.Data.Role is not OverloadRole) return; + + if (evt.TriggeredByIntro) + { + AbsorbedAbilityCount = 0; + chosenPrey = null; + + Coroutines.Start(CoShowMenu(1f)); + } + } + public static IEnumerator CoShowMenu(float delay) + { + yield return new WaitForSeconds(delay); + + if (PlayerControl.LocalPlayer.AmOwner && PlayerControl.LocalPlayer.Data.Role is OverloadRole && chosenPrey == null) + { + CustomPlayerMenu menu = CustomPlayerMenu.Create(); + + menu.Begin( + player => !player.Data.IsDead && !player.Data.Disconnected && player.PlayerId != PlayerControl.LocalPlayer.PlayerId, + prey => + { + chosenPrey = prey; + menu.Close(); + Coroutines.Start(CoroutinesHelper.CoNotify($"Chosen prey: {prey?.Data.PlayerName}")); + }); + } + yield return null; + } + 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.OverrideText("OVERLOAD"); + btn.transform.SetAsLastSibling(); + var passive = btn.GetComponent(); + passive.OnClick.RemoveAllListeners(); + passive.OnClick.AddListener((UnityAction)(() => GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.OverloadWin, false))); + } +} diff --git a/NewMod/Roles/NeutralRoles/Prankster.cs b/NewMod/Roles/NeutralRoles/Prankster.cs index 6a088cc..bd9c777 100644 --- a/NewMod/Roles/NeutralRoles/Prankster.cs +++ b/NewMod/Roles/NeutralRoles/Prankster.cs @@ -4,15 +4,14 @@ using UnityEngine; namespace NewMod.Roles.NeutralRoles; - -[RegisterCustomRole] public class Prankster : CrewmateRole, ICustomRole { public string RoleName => "Prankster"; - public string RoleDescription => "Set up fake bodies to trick others. When reported, each fake body triggers a funny or deadly surprise for the reporter"; - public string RoleLongDescription => RoleDescription; + public string RoleDescription => "Set up fake bodies to trick others"; + public string RoleLongDescription => "When reported, each fake body triggers a funny or deadly surprise for the reporter"; public Color RoleColor => new Color(1f, 0.55f, 0f); - public ModdedRoleTeams Team => ModdedRoleTeams.Neutral; + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; public CustomRoleConfiguration Configuration => new(this) { MaxRoleCount = 3, @@ -29,7 +28,6 @@ public class Prankster : CrewmateRole, ICustomRole CanUseVent = false, CanUseSabotage = false, TasksCountForProgress = false, - IsGhostRole = false, HideSettings = false, CanModifyChance = true, }; diff --git a/NewMod/Roles/NeutralRoles/SpecialAgent.cs b/NewMod/Roles/NeutralRoles/SpecialAgent.cs index f9f04d3..93b1f80 100644 --- a/NewMod/Roles/NeutralRoles/SpecialAgent.cs +++ b/NewMod/Roles/NeutralRoles/SpecialAgent.cs @@ -1,11 +1,9 @@ using MiraAPI.Roles; -using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; using UnityEngine; namespace NewMod.Roles.NeutralRoles; -[RegisterCustomRole] public class SpecialAgent : CrewmateRole, ICustomRole { public static PlayerControl AssignedPlayer {get; set;} @@ -13,7 +11,8 @@ public class SpecialAgent : CrewmateRole, ICustomRole public string RoleDescription => "Assigns secret missions to players, who must complete them or face consequences."; public string RoleLongDescription => RoleDescription; public Color RoleColor => Color.gray; - public ModdedRoleTeams Team => ModdedRoleTeams.Neutral; + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; public CustomRoleConfiguration Configuration => new(this) { MaxRoleCount = 1, diff --git a/NewMod/Utilities/CoroutinesHelper.cs b/NewMod/Utilities/CoroutinesHelper.cs index a969949..12a7ca9 100644 --- a/NewMod/Utilities/CoroutinesHelper.cs +++ b/NewMod/Utilities/CoroutinesHelper.cs @@ -6,50 +6,98 @@ using System.Linq; using TMPro; using MiraAPI.Networking; +using NewMod.Roles.NeutralRoles; namespace NewMod.Utilities { + /// + /// Provides helper coroutines and utility methods. + /// public static class CoroutinesHelper { + /// + /// Keeps track of the number of fake bodies created by each player, keyed by their PlayerId. + /// public static Dictionary bodiesCreated = new Dictionary(); + + /// + /// Tracks the number of energy drains performed by each player, keyed by their PlayerId. + /// public static Dictionary drainCount = new Dictionary(); + + /// + /// Reference to a element used for displaying mission-related timers. + /// private static TextMeshPro timerLabel; + /// + /// Displays a temporary notification on the screen using an overlay animation. + /// + /// The message to display. + /// An for coroutine control. public static IEnumerator CoNotify(string message) { + // Play sound if allowed. if (Constants.ShouldPlaySfx()) { SoundManager.Instance.PlaySound(HudManager.Instance.TaskCompleteSound, false, 1f, null); } - HudManager.Instance.TaskCompleteOverlay.gameObject.SetActive(true); + var overlay = HudManager.Instance.TaskCompleteOverlay; + var obj = Object.Instantiate(overlay.gameObject, overlay.transform.parent); - var textComponent = HudManager.Instance.TaskCompleteOverlay.GetComponentInChildren(); + var textComponent = obj.GetComponentInChildren(); + // Adjust font size based on message length if (textComponent != null) { textComponent.text = message; textComponent.fontSize = Mathf.Clamp(3.5f - (message.Length / 20f), 2f, 3.5f); } + obj.gameObject.SetActive(true); - yield return Effects.Slide2D(HudManager.Instance.TaskCompleteOverlay, new Vector2(0f, -8f), Vector2.zero, 0.25f); + yield return new WaitForEndOfFrame(); + + if (textComponent != null) + { + textComponent.text = message; + } + // Animate the overlay into view + yield return Effects.Slide2D(obj.transform, new Vector2(0f, -8f), Vector2.zero, 0.25f); + + // Display for a short duration for (float time = 0f; time < 0.95f; time += Time.deltaTime) { yield return null; } - yield return Effects.Slide2D(HudManager.Instance.TaskCompleteOverlay, Vector2.zero, new Vector2(0f, 8f), 0.25f); + // Animate the overlay out of view + yield return Effects.Slide2D(obj.transform, Vector2.zero, new Vector2(0f, 8f), 0.25f); - HudManager.Instance.TaskCompleteOverlay.gameObject.SetActive(false); + GameObject.Destroy(obj); } - + /// + /// Starts and displays a countdown timer for a mission, then fails the mission if time expires. + /// + /// The player assigned to the mission. + /// The desired duration for the mission timer (clamped to 30 seconds max). + /// An for coroutine control. public static IEnumerator CoMissionTimer(PlayerControl target, float duration) { + // Clamp duration to a maximum of 30 seconds duration = Mathf.Min(duration, 30f); - timerLabel = Helpers.CreateTextLabel("MissionTimerText", HudManager.Instance.transform, AspectPosition.EdgeAlignments.LeftTop, new(10, 1.5f, 0f), fontSize:3f, textAlignment: TextAlignmentOptions.Left); - + // Create a text label for the mission timer + timerLabel = Helpers.CreateTextLabel( + "MissionTimerText", + HudManager.Instance.transform, + AspectPosition.EdgeAlignments.LeftBottom, + new(9.9f, 3.5f, 0f), + fontSize: 3f, + textAlignment: TextAlignmentOptions.BottomLeft + ); + timerLabel!.text = $"Time Remaining: {duration}s"; timerLabel.color = Color.yellow; @@ -57,11 +105,20 @@ public static IEnumerator CoMissionTimer(PlayerControl target, float duration) while (timeRemaining > 0) { + // If the assigned player is unassigned, cancel the timer + if (SpecialAgent.AssignedPlayer == null) + { + if (HudManager.Instance.FullScreen.gameObject.activeSelf) + HudManager.Instance.FullScreen.gameObject.SetActive(false); + Object.Destroy(timerLabel.gameObject); + yield break; + } yield return new WaitForSeconds(1f); timeRemaining -= 1f; timerLabel.text = $"Time Remaining: {Mathf.CeilToInt(timeRemaining)}s"; + // Manage colors and background overlay based on remaining time if (timeRemaining <= 10f) { timerLabel.color = Color.red; @@ -75,61 +132,84 @@ public static IEnumerator CoMissionTimer(PlayerControl target, float duration) else if (timeRemaining <= 20f) { timerLabel.color = Color.yellow; + if (HudManager.Instance.FullScreen.gameObject.activeSelf) + HudManager.Instance.FullScreen.gameObject.SetActive(false); } else { timerLabel.color = Color.green; + if (HudManager.Instance.FullScreen.gameObject.activeSelf) + HudManager.Instance.FullScreen.gameObject.SetActive(false); } } + // Time has expired, destroy the timer and fail the mission Object.Destroy(timerLabel.gameObject); - Utils.MissionFails(target, PlayerControl.LocalPlayer); + SoundManager.Instance.StopSound(ShipStatus.Instance.SabotageSound); + HudManager.Instance.FullScreen.gameObject.SetActive(false); + Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); } - + /// + /// Allows a Prankster to create fake dead bodies by pressing F5, fulfilling a mission if enough bodies are created. + /// + /// The player executing the prankster abilities. + /// An for coroutine control. public static IEnumerator UsePranksterAbilities(PlayerControl target) { + // Initialize dictionary entry for this player if missing if (!bodiesCreated.ContainsKey(target.PlayerId)) { bodiesCreated[target.PlayerId] = 0; } while (true) { + // If the player dies mid-mission, fail the mission if (target.Data.IsDead) { - Utils.MissionFails(target, PlayerControl.LocalPlayer); + Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); yield break; } + // Press F5 to create a fake dead body if (Input.GetKeyDown(KeyCode.F5)) { PranksterUtilities.CreatePranksterDeadBody(target, target.PlayerId); bodiesCreated[target.PlayerId]++; - - Coroutines.Start(CoNotify($"Bodies created: {bodiesCreated[target.PlayerId]}/2")); - + if (target.AmOwner) + { + Coroutines.Start(CoNotify($"Bodies created: {bodiesCreated[target.PlayerId]}/2")); + } + // Once enough bodies are created, succeed the mission if (bodiesCreated[target.PlayerId] >= 2) { - Utils.MissionSuccess(target, PlayerControl.LocalPlayer); + Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); yield break; } } } } - + /// + /// Allows an Energy Thief to drain nearby players' energy by pressing F5, fulfilling a mission after enough drains. + /// + /// The player executing the energy draining abilities. + /// An for coroutine control. public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) { float drainRange = 3.5f; + // Initialize dictionary entry for this player if missing if (!drainCount.ContainsKey(target.PlayerId)) { drainCount[target.PlayerId] = 0; } while (true) { + // If the player dies mid-mission, fail the mission if (target.Data.IsDead) { - Utils.MissionFails(target, PlayerControl.LocalPlayer); + Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); yield break; } + // Press F5 to drain energy from a nearby player if (Input.GetKeyDown(KeyCode.F5)) { var playersInRange = Helpers.GetClosestPlayers( @@ -137,7 +217,9 @@ public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) drainRange, ignoreColliders: true, ignoreSource: true - ).Where(p => !p.Data.IsDead && !p.Data.Disconnected && p != PlayerControl.LocalPlayer).ToList(); + ) + .Where(p => !p.Data.IsDead && !p.Data.Disconnected && p != PlayerControl.LocalPlayer) + .ToList(); if (playersInRange.Count > 0) { @@ -145,6 +227,8 @@ public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) Utils.RpcRandomDrainActions(target, victim); drainCount[target.PlayerId]++; + + // Notify both the drainer and the drained player if (target.AmOwner) { Coroutines.Start(CoNotify($"You have drained energy from {victim.Data.PlayerName}!")); @@ -154,9 +238,10 @@ public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) Coroutines.Start(CoNotify("Your energy has been drained!")); } + // After enough drains, succeed the mission if (drainCount[target.PlayerId] >= 2) { - Utils.MissionSuccess(target, PlayerControl.LocalPlayer); + Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); yield break; } } @@ -170,49 +255,67 @@ public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) } } } + + /// + /// Allows a player to revive a dead player and then kill them again. F5 is used to initiate each action. + /// + /// The player controlling the revive and kill actions. + /// An for coroutine control. public static IEnumerator CoReviveAndKill(PlayerControl target) { bool revived = false; byte revivedParentId = 255; - - Coroutines.Start(CoNotify("Press F5 to revive a dead player!")); + // Prompt the player to press F5 for the initial revive + if (target.AmOwner) + { + Coroutines.Start(CoNotify("Press F5 to revive a dead player!")); + } while (true) { if (target.Data.IsDead) { - Utils.MissionFails(target, PlayerControl.LocalPlayer); + Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); yield break; } if (Input.GetKeyDown(KeyCode.F5)) { + // Perform the revive if not yet done if (!revived) { var deadBody = Utils.GetClosestBody(); - if (deadBody == null) + if (deadBody == null && target.AmOwner) { Coroutines.Start(CoNotify("No dead body found! Move closer and press F5 again.")); } else { - revivedParentId = deadBody.ParentId; + revivedParentId = deadBody.ParentId; - Utils.RpcRevive(deadBody); - - yield return new WaitForSeconds(0.5f); + Utils.RpcRevive(deadBody); - Coroutines.Start(CoNotify("Player revived! Press F5 to kill them again!")); + yield return new WaitForSeconds(0.5f); - revived = true; - } + Coroutines.Start(CoNotify("Player revived! Press F5 to kill them again!")); + + revived = true; + } } + // If revived, press F5 again to kill the revived player else { var revivedData = GameData.Instance.GetPlayerById(revivedParentId); if (revivedData != null && revivedData.Object != null && !revivedData.Object.Data.IsDead) { - PlayerControl.LocalPlayer.RpcCustomMurder(revivedData.Object, createDeadBody: true, didSucceed: true, showKillAnim: false, playKillSound: true, teleportMurderer:false); - Utils.MissionSuccess(target, PlayerControl.LocalPlayer); + PlayerControl.LocalPlayer.RpcCustomMurder( + revivedData.Object, + createDeadBody: true, + didSucceed: true, + showKillAnim: false, + playKillSound: true, + teleportMurderer: false + ); + Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); yield break; } } @@ -220,8 +323,17 @@ public static IEnumerator CoReviveAndKill(PlayerControl target) yield return null; } } + + /// + /// Handles logic for tracking and validating a "most wanted" target using an arrow indicator. + /// + /// An used to point toward the target. + /// The most wanted target player. + /// The player assigned to eliminate the most wanted target. + /// An for coroutine control. public static IEnumerator CoHandleWantedTarget(ArrowBehaviour arrow, PlayerControl mostwantedTarget, PlayerControl target) { + // Keep updating the arrow's position as long as the target is alive while (!mostwantedTarget.Data.IsDead && !mostwantedTarget.Data.Disconnected) { arrow.target = mostwantedTarget.transform.position; @@ -231,14 +343,15 @@ public static IEnumerator CoHandleWantedTarget(ArrowBehaviour arrow, PlayerContr yield return new WaitForSeconds(0.5f); + // If the assigned player was the killer, mission succeeds; otherwise, it fails var killer = Utils.GetKiller(mostwantedTarget); if (killer != null && killer == target) { - Utils.MissionSuccess(target, PlayerControl.LocalPlayer); + Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); } else { - Utils.MissionFails(target, PlayerControl.LocalPlayer); + Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); } yield break; } diff --git a/NewMod/Utilities/PranksterUtilities.cs b/NewMod/Utilities/PranksterUtilities.cs index 328c46d..6a871a0 100644 --- a/NewMod/Utilities/PranksterUtilities.cs +++ b/NewMod/Utilities/PranksterUtilities.cs @@ -2,6 +2,7 @@ using UnityEngine; using System.Collections.Generic; using Reactor.Networking.Attributes; +using MiraAPI.Utilities; namespace NewMod.Utilities { @@ -19,15 +20,14 @@ public static class PranksterUtilities [MethodRpc((uint)CustomRPC.FakeBody, LocalHandling = Reactor.Networking.Rpc.RpcLocalHandling.After)] public static void CreatePranksterDeadBody(PlayerControl player, byte parentId) { + var randPlayer = Utils.GetRandomPlayer(p => p.Data.IsDead); var deadBody = Object.Instantiate(GameManager.Instance.DeadBodyPrefab); deadBody.name = PranksterBodyName; deadBody.ParentId = parentId; - // Shuffle the player colors - var shuffledColors = Utils.ShuffleArrays(Palette.PlayerColors.ToArray()); - for (int i = 0; i < deadBody.bodyRenderers.Length; i++) + foreach (SpriteRenderer renderer in deadBody.bodyRenderers) { - deadBody.bodyRenderers[i].color = shuffledColors[i % shuffledColors.Length]; + randPlayer.SetPlayerMaterialColors(renderer); } deadBody.transform.position = player.GetTruePosition(); } diff --git a/NewMod/Utilities/Utils.cs b/NewMod/Utilities/Utils.cs index 0e355d7..77a8fa9 100644 --- a/NewMod/Utilities/Utils.cs +++ b/NewMod/Utilities/Utils.cs @@ -1,27 +1,66 @@ +using System.Collections; using System.Collections.Generic; using System.Linq; +using AmongUs.GameOptions; using Hazel; +using MiraAPI.Networking; +using MiraAPI.Roles; +using MiraAPI.Utilities; using Reactor.Networking.Attributes; -using UnityEngine; using Reactor.Utilities; -using AmongUs.GameOptions; -using MiraAPI.Networking; +using UnityEngine; +using NewMod.Buttons.EnergyThief; +using NewMod.Buttons.Necromancer; +using NewMod.Buttons.Prankster; +using NewMod.Buttons.Revenant; +using NewMod.Buttons.SpecialAgent; +using NewMod.Buttons.Visionary; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.ImpostorRoles; using NewMod.Roles.NeutralRoles; -using MiraAPI.Roles; -using System.Collections; namespace NewMod.Utilities { + /// + /// Provides various utility methods and fields for the mod. + /// public static class Utils { + /// + /// Tracks the number of drains performed by each Energy Thief, keyed by player ID. + /// public static Dictionary EnergyThiefDrainCounts = new Dictionary(); + + /// + /// Maps a victim player to its killer. + /// public static Dictionary PlayerKiller = new Dictionary(); + + /// + /// Stores the number of successful missions per player, keyed by their ID. + /// public static Dictionary MissionSuccessCount = new Dictionary(); + + /// + /// Stores the number of failed missions per player, keyed by their ID. + /// public static Dictionary MissionFailureCount = new Dictionary(); + + /// + /// Holds a set of players who are currently waiting for an event or action. + /// public static HashSet waitingPlayers = new(); + + /// + /// Maintains saved roles for players, keyed by their ID. + /// public static Dictionary> savedPlayerRoles = new Dictionary>(); - public static Dictionary MissionTimer = new Dictionary(); - + + /// + /// Maps a player ID to a TextMeshPro timer display for missions. + /// + public static Dictionary MissionTimer = new Dictionary(); + /// /// Retrieves a PlayerControl instance by its player ID. /// @@ -30,12 +69,13 @@ public static class Utils // Thanks to: https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/Utils.cs#L219 public static PlayerControl PlayerById(byte id) { - foreach (var player in PlayerControl.AllPlayerControls) + foreach (var player in PlayerControl.AllPlayerControls) if (player.PlayerId == id) return player; return null; } + /// /// Records a kill event by mapping a victim to its killer. /// @@ -62,6 +102,7 @@ public static PlayerControl GetKiller(PlayerControl victim) { return PlayerKiller.TryGetValue(victim, out var killer) ? killer : null; } + /// /// Finds the closest dead body to the local player within their kill distance. /// @@ -92,10 +133,11 @@ public static DeadBody GetClosestBody() } return closestBody; } + // Inspired By : https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/CrewmateRoles/AltruistMod/Coroutine.cs#L57 public static void Revive(DeadBody body) { - if (body == null) return; + if (body == null) return; var parentId = body.ParentId; var player = PlayerById(parentId); @@ -107,16 +149,42 @@ public static void Revive(DeadBody body) if (deadBody.ParentId == body.ParentId) Object.Destroy(deadBody.gameObject); } + player.Revive(); + + if (player.Data.Role is NoisemakerRole role) + { + Object.Destroy(role.deathArrowPrefab.gameObject); + } + player.RpcSetRole(RoleTypes.Impostor, true); + } + } + // Inspired By : https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/CrewmateRoles/AltruistMod/Coroutine.cs#L57 + public static void ReviveV2(DeadBody body) + { + if (body == null) return; + + var parentId = body.ParentId; + var player = PlayerById(parentId); + + if (player != null) + { + foreach (var deadBody in GameObject.FindObjectsOfType()) + { + if (deadBody.ParentId == body.ParentId) + Object.Destroy(deadBody.gameObject); + } player.Revive(); + if (player.Data.Role is NoisemakerRole role) { Object.Destroy(role.deathArrowPrefab.gameObject); } - player.RpcSetRole(AmongUs.GameOptions.RoleTypes.Impostor, true); + player.RpcSetRole((RoleTypes)RoleId.Get(), true); + NewMod.Instance.Log.LogError($"---------------^^^^^^SETTING ROLE TO REVENANT-------------^^^^ NEW ROLE: {player.Data.Role.NiceName}"); } } - + // Thanks to: https://github.com/Rabek009/MoreGamemodes/blob/master/Modules/Utils.cs#L66 /// /// Checks if a particular system type is active on the current map. @@ -126,11 +194,11 @@ public static void Revive(DeadBody body) public static bool IsActive(SystemTypes type) { int mapId = GameOptionsManager.Instance.CurrentGameOptions.MapId; - + if (!ShipStatus.Instance.Systems.ContainsKey(type)) { - return false; - } + return false; + } switch (type) { case SystemTypes.Electrical: @@ -138,7 +206,7 @@ public static bool IsActive(SystemTypes type) var SwitchSystem = ShipStatus.Instance.Systems[type].TryCast(); return SwitchSystem != null && SwitchSystem.IsActive; case SystemTypes.Reactor: - if (mapId == 2) return false; + if (mapId == 2) return false; else { var ReactorSystemType = ShipStatus.Instance.Systems[type].TryCast(); @@ -171,10 +239,11 @@ public static bool IsActive(SystemTypes type) if (mapId != 5) return false; var MushroomMixupSabotageSystem = ShipStatus.Instance.Systems[type].TryCast(); return MushroomMixupSabotageSystem != null && MushroomMixupSabotageSystem.IsActive; - default: - return false; + default: + return false; + } } - } + // Thanks to : https://github.com/Rabek009/MoreGamemodes/blob/master/Modules/Utils.cs#L118 /// /// Checks if any sabotage system is currently active. @@ -190,6 +259,7 @@ public static bool IsSabotage() IsActive(SystemTypes.MushroomMixupSabotage) || IsActive(SystemTypes.HeliSabotage); } + /// /// Records a drain count for the specified player. /// @@ -210,6 +280,7 @@ public static int GetDrainCount(byte playerId) { return EnergyThiefDrainCounts.TryGetValue(playerId, out var count) ? count : 0; } + /// /// Resets all drain counts. /// @@ -217,32 +288,63 @@ public static void ResetDrainCount() { EnergyThiefDrainCounts.Clear(); } + + /// + /// Records a successful mission for the given Special Agent player. + /// + /// The player who successfully completed the mission. public static void RecordMissionSuccess(PlayerControl specialAgent) { - var playerId = specialAgent.PlayerId; - MissionSuccessCount[playerId] = GetMissionSuccessCount(playerId) + 1; + var playerId = specialAgent.PlayerId; + MissionSuccessCount[playerId] = GetMissionSuccessCount(playerId) + 1; } + + /// + /// Retrieves the number of successful missions for a given player. + /// + /// The player's ID. + /// The count of successful missions. public static int GetMissionSuccessCount(byte playerId) { - return MissionSuccessCount.TryGetValue(playerId, out var count) ? count : 0; + return MissionSuccessCount.TryGetValue(playerId, out var count) ? count : 0; } + + /// + /// Resets the count of successful missions for all players. + /// public static void ResetMissionSuccessCount() { - MissionSuccessCount.Clear(); + MissionSuccessCount.Clear(); } + + /// + /// Records a failed mission for the given Special Agent player. + /// + /// The player who failed the mission. public static void RecordMissionFailure(PlayerControl specialAgent) { var playerId = specialAgent.PlayerId; MissionFailureCount[playerId] = GetMissionFailureCount(playerId) + 1; } + + /// + /// Retrieves the number of failed missions for a given player. + /// + /// The player's ID. + /// The count of failed missions. public static int GetMissionFailureCount(byte playerId) { return MissionFailureCount.TryGetValue(playerId, out var count) ? count : 0; } + + /// + /// Resets the count of failed missions for all players. + /// public static void ResetMissionFailureCount() { MissionFailureCount.Clear(); } + /// /// Sends an RPC to revive a player from a dead body. /// @@ -259,6 +361,24 @@ public static void RpcRevive(DeadBody body) writer.Write(body.ParentId); AmongUsClient.Instance.FinishRpcImmediately(writer); } + + /// + /// Sends an RPC to revive a player from a dead body and set their role to Revenant. + /// + /// The DeadBody instance to revive from. + public static void RpcReviveV2(DeadBody body) + { + ReviveV2(body); + var writer = AmongUsClient.Instance.StartRpcImmediately( + PlayerControl.LocalPlayer.NetId, + (byte)CustomRPC.Revive, + SendOption.Reliable + ); + writer.Write(PlayerControl.LocalPlayer.PlayerId); + writer.Write(body.ParentId); + AmongUsClient.Instance.FinishRpcImmediately(writer); + } + // Thanks to: https://github.com/yanpla/yanplaRoles/blob/master/Utils.cs#L55 /// /// Records a player's role in their role history. @@ -273,6 +393,7 @@ public static void SavePlayerRole(byte playerId, RoleBehaviour role) } savedPlayerRoles[playerId].Add(role); } + // Thanks to: https://github.com/yanpla/yanplaRoles/blob/master/Utils.cs#L64 /// /// Retrieves the role history for a specific player. @@ -281,19 +402,21 @@ public static void SavePlayerRole(byte playerId, RoleBehaviour role) /// A list of RoleBehaviour representing the player's role history. public static List GetPlayerRolesHistory(byte playerId) { - if (savedPlayerRoles.ContainsKey(playerId)) - { + if (savedPlayerRoles.ContainsKey(playerId)) + { return savedPlayerRoles[playerId]; - } - return new List(); + } + return new List(); } + /// - /// Retrieves a random player from the game who is alive and not disconnected. + /// Retrieves a random player from the game who meets a specified condition. /// + /// A predicate to filter eligible players. /// A random PlayerControl instance, or null if none are valid. - public static PlayerControl GetRandomPlayer() + public static PlayerControl GetRandomPlayer(System.Predicate match) { - var players = PlayerControl.AllPlayerControls.ToArray().Where(p => !p.Data.IsDead && !p.Data.Disconnected).ToList(); + var players = PlayerControl.AllPlayerControls.ToArray().Where(p => match(p)).ToList(); if (players.Count > 0) { @@ -301,6 +424,23 @@ public static PlayerControl GetRandomPlayer() } return null; } + + /// + /// Checks if there is at least one dead player in the game. + /// + /// A PlayerControl who is dead, or null if none. + public static PlayerControl AnyDeadPlayer() + { + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player.Data.IsDead) + { + return player; + } + } + return null; + } + /// /// Performs a random draining action on a target player as part of a custom RPC. /// @@ -309,7 +449,7 @@ public static PlayerControl GetRandomPlayer() [MethodRpc((uint)CustomRPC.Drain)] public static void RpcRandomDrainActions(PlayerControl source, PlayerControl target) { - List actions = new List + List actions = new() { () => { @@ -349,7 +489,7 @@ public static void RpcRandomDrainActions(PlayerControl source, PlayerControl tar }, () => { - var randomPlayer = GetRandomPlayer(); + var randomPlayer = GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); if (randomPlayer != null) { target.NetTransform.RpcSnapTo(randomPlayer.GetTruePosition()); @@ -360,182 +500,191 @@ public static void RpcRandomDrainActions(PlayerControl source, PlayerControl tar } } }; - int randomIndex = Random.Range(0, actions.Count); actions[randomIndex].Invoke(); } + + /// + /// Selects and processes a mission for the specified target player based on the provided MissionType. + /// + /// The target player receiving the mission. + /// The type of mission assigned. + /// A formatted string describing the selected mission. public static string GetMission(PlayerControl target, MissionType mission) { - var mostwantedTarget = GetRandomPlayer(); + var mostwantedTarget = GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); string selectedMission = mission switch - { - MissionType.KillMostWanted => $"Kill the Most Wanted Target: {mostwantedTarget.Data.PlayerName}", - MissionType.DrainEnergy => "Drain one player using Energy Thief abilities", - MissionType.CreateFakeBodies => "Disguise yourself as a random player and create fake dead bodies around the map using Prankster abilities!", - MissionType.ReviveAndKill => "Revive a dead player using Necromancer powers and kill them again", - _ => "Unknown mission." - }; + { + MissionType.KillMostWanted => $"Kill the Most Wanted Target: {mostwantedTarget.Data.PlayerName}", + MissionType.DrainEnergy => "Drain one player using Energy Thief abilities", + MissionType.CreateFakeBodies => "Disguise yourself as a random player and create fake dead bodies around the map using Prankster abilities!", + MissionType.ReviveAndKill => "Revive a dead player using Necromancer powers and kill them again", + _ => "Unknown mission." + }; switch (mission) { - case MissionType.KillMostWanted: - // Set up arrow for most wanted target - 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; - - // Save the current role of the target - 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; - - case MissionType.CreateFakeBodies: - // Disguise as a random player - var randPlayer = GetRandomPlayer(); - target.RpcShapeshift(randPlayer, false); - - if (target.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to Create Dead Bodies")); - } - Coroutines.Start(CoroutinesHelper.UsePranksterAbilities(target)); - 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.ReviveAndKill: - - Coroutines.Start(CoroutinesHelper.CoReviveAndKill(target)); - break; + 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); + + 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; + + case MissionType.CreateFakeBodies: + if (target.AmOwner) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to Create Dead Bodies")); + } + Coroutines.Start(CoroutinesHelper.UsePranksterAbilities(target)); + 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.ReviveAndKill: + Coroutines.Start(CoroutinesHelper.CoReviveAndKill(target)); + break; } return selectedMission; } - public static void MissionSuccess(PlayerControl target, PlayerControl specialAgent) - { - RecordMissionSuccess(specialAgent); - - if (specialAgent.AmOwner) - { - int currentSuccessCount = GetMissionSuccessCount(specialAgent.PlayerId); - int netScore = currentSuccessCount - GetMissionFailureCount(specialAgent.PlayerId); - Coroutines.Start(CoroutinesHelper.CoNotify($"Target {target.Data.PlayerName} has completed their mission!\nCurrent net score: {netScore}/3")); - } - else - { - Coroutines.Start(CoroutinesHelper.CoNotify("Mission Completed! You are free to go!")); - } - if (savedTasks.ContainsKey(target)) - { - target.myTasks = savedTasks[target]; - savedTasks.Remove(target); - } - if (SpecialAgent.AssignedPlayer = target) - { - SpecialAgent.AssignedPlayer = null; - } + + [MethodRpc((uint)CustomRPC.MissionSuccess)] + public static void RpcMissionSuccess(PlayerControl source, PlayerControl target) + { + RecordMissionSuccess(source); + + if (source.AmOwner) + { + int currentSuccessCount = GetMissionSuccessCount(source.PlayerId); + int netScore = currentSuccessCount - GetMissionFailureCount(source.PlayerId); + Coroutines.Start(CoroutinesHelper.CoNotify($"Target {target.Data.PlayerName} has completed their mission!\nCurrent net score: {netScore}/3")); + } + else + { + Coroutines.Start(CoroutinesHelper.CoNotify("Mission Completed! You are free to go!")); + } + if (savedTasks.ContainsKey(target)) + { + target.myTasks = savedTasks[target]; + savedTasks.Remove(target); + } + if (SpecialAgent.AssignedPlayer == target) + { + SpecialAgent.AssignedPlayer = null; + } + target.Data.Role.buttonManager.SetEnabled(); } - public static void MissionFails(PlayerControl target, PlayerControl specialAgent) - { - RecordMissionFailure(specialAgent); - - if (specialAgent.AmOwner) - { - int currentFailureCount = GetMissionFailureCount(specialAgent.PlayerId); - int netScore = GetMissionSuccessCount(specialAgent.PlayerId) - currentFailureCount; - Coroutines.Start(CoroutinesHelper.CoNotify($"Target {target.Data.PlayerName} has failed their mission! Current net score: {netScore}/3")); - } - else - { - Coroutines.Start(CoroutinesHelper.CoNotify("Mission Failed! You will face the consequences!")); - } - specialAgent.RpcCustomMurder(target, createDeadBody:false, didSucceed:true, showKillAnim:false, playKillSound:true, teleportMurderer:false); + + [MethodRpc((uint)CustomRPC.MissionFails)] + public static void RpcMissionFails(PlayerControl source, PlayerControl target) + { + RecordMissionFailure(source); + + if (source.AmOwner) + { + int currentFailureCount = GetMissionFailureCount(source.PlayerId); + int netScore = GetMissionSuccessCount(source.PlayerId) - currentFailureCount; + Coroutines.Start(CoroutinesHelper.CoNotify($"Target {target.Data.PlayerName} has failed their mission! Current net score: {netScore}/3")); + } + else + { + Coroutines.Start(CoroutinesHelper.CoNotify("Mission Failed! You will face the consequences!")); + } + source.RpcCustomMurder(target, createDeadBody: false, didSucceed: true, showKillAnim: false, playKillSound: true, teleportMurderer: false); if (savedTasks.ContainsKey(target)) { target.myTasks = savedTasks[target]; savedTasks.Remove(target); } - if (SpecialAgent.AssignedPlayer = target) + if (SpecialAgent.AssignedPlayer == target) { SpecialAgent.AssignedPlayer = null; } + target.Data.Role.buttonManager.SetEnabled(); } + + /// + /// Stores tasks that have been saved for a given player, allowing restoration after missions. + /// public static Il2CppSystem.Collections.Generic.Dictionary> savedTasks = new(); - + + /// + /// Assigns a random mission to the target player as a custom RPC. + /// + /// The player initiating the assignment (Special Agent). + /// The player who will receive the mission. [MethodRpc((uint)CustomRPC.AssignMission)] - public static void AssignMission(PlayerControl target) + public static void RpcAssignMission(PlayerControl source, PlayerControl target) { // Save the target's tasks if (!savedTasks.ContainsKey(target)) { - var newTaskList = new Il2CppSystem.Collections.Generic.List(); - - foreach (var task in target.myTasks) - { - newTaskList.Add(task); - } - savedTasks[target] = newTaskList; - } + var newTaskList = new Il2CppSystem.Collections.Generic.List(); - // Clear all assigned tasks for the specified target player - target.myTasks.Clear(); - - // Get all values of the MissionType enum - MissionType[] missions = (MissionType[])System.Enum.GetValues(typeof(MissionType)); - // Pick a random mission - MissionType randomMission = missions[Random.Range(0, missions.Length)]; - - // Add the mission message to the player's tasks - ImportantTextTask Missionmessage = new GameObject("MissionMessage").AddComponent(); - Missionmessage.transform.SetParent(AmongUsClient.Instance.transform, false); - Missionmessage.Text = $"Special Agent has given you a mission!\n" + - $"Mission: {GetMission(target, randomMission)}\n" + - $"Complete it or face the consequences!"; - - target.myTasks.Insert(0, Missionmessage); - - Coroutines.Start(CoroutinesHelper.CoMissionTimer(target, 60f)); - } - public static Color32[] ShuffleArrays(Color32[] array) - { - Color32[] shuffled = array.Clone() as Color32[]; - for (int i = shuffled.Length - 1; i > 0; i--) - { - int j = Random.Range(0, i + 1); - (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + foreach (var task in target.myTasks) + { + newTaskList.Add(task); + } + savedTasks[target] = newTaskList; } - return shuffled; + + // Clear all assigned tasks for the specified target player + target.myTasks.Clear(); + + // Get all values of the MissionType enum + MissionType[] missions = (MissionType[])System.Enum.GetValues(typeof(MissionType)); + // Pick a random mission + MissionType randomMission = missions[Random.Range(0, missions.Length)]; + + // Add the mission message to the player's tasks + ImportantTextTask Missionmessage = new GameObject("MissionMessage").AddComponent(); + Missionmessage.transform.SetParent(AmongUsClient.Instance.transform, false); + Missionmessage.Text = $"Special Agent has given you a mission!\n" + + $"Mission: {GetMission(target, randomMission)}\n" + + $"Complete it or face the consequences!"; + + target.myTasks.Insert(0, Missionmessage); + // Disable the Role Player's Ability + target.Data.Role.buttonManager.SetDisabled(); + + Coroutines.Start(CoroutinesHelper.CoMissionTimer(target, 60f)); } + + /// + /// Captures a screenshot of the current game screen, hides the HUD, and then reactivates it. + /// + /// The path to save the screenshot file. + /// An IEnumerator for coroutine control. public static IEnumerator CaptureScreenshot(string filePath) { - string timestamp = System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); - HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, false); ScreenCapture.CaptureScreenshot(filePath, 4); VisionaryUtilities.CapturedScreenshotPaths.Add(filePath); @@ -545,5 +694,102 @@ public static IEnumerator CaptureScreenshot(string filePath) HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, true); } + + /// + /// Causes the player to feign death, creating a body. If unreported, the player is revived after 10 seconds. + /// + /// The player feigning death. + /// An IEnumerator for coroutine control. + public static IEnumerator StartFeignDeath(PlayerControl player) + { + player.RpcCustomMurder(player, + didSucceed: true, + resetKillTimer: false, + createDeadBody: true, + teleportMurderer: false, + showKillAnim: false, + playKillSound: false); + + if (player.AmOwner) + { + HudManager.Instance.SetHudActive(false); + } + yield return new WaitForSeconds(0.5f); + + var body = player.GetNearestDeadBody(15f); + + var info = new Revenant.FeignDeathInfo + { + Timer = 10f, + DeadBody = body, + Reported = false, + }; + Revenant.FeignDeathStates[player.PlayerId] = info; + + Coroutines.Start(CoroutinesHelper.CoNotify("You are now feigning death.\nYou will be revived in 10 seconds if unreported.")); + + float timer = 10f; + while (timer > 0) + { + timer -= Time.deltaTime; + info.Timer = timer; + yield return null; + + if (info.Reported) + { + yield return CoroutinesHelper.CoNotify("Your feign death has been reported. You remain dead."); + yield break; + } + } + RpcReviveV2(body); + player.transform.position = body.transform.position; + player.RpcShapeshift(GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected), false); + Coroutines.Start(CoroutinesHelper.CoNotify("You have been revived in a new body!")); + Revenant.HasUsedFeignDeath = true; + Revenant.StalkingStates[player.PlayerId] = true; + Revenant.FeignDeathStates.Remove(player.PlayerId); + + if (player.AmOwner) + { + DestroyableSingleton.Instance.SetHudActive(player, player.Data.Role, true); + } + } + + /// + /// Gradually fades out the provided ghost object and then destroys it. + /// + /// The GameObject representing the ghost. + /// The duration of the fade effect. + /// An IEnumerator for coroutine control. + public static IEnumerator FadeAndDestroy(GameObject ghost, float fadeDuration) + { + SpriteRenderer ghostRenderer = ghost.GetComponent(); + float alpha = 0.5f; + while (alpha > 0) + { + alpha -= Time.deltaTime / fadeDuration * 0.5f; + if (ghostRenderer != null) + { + ghostRenderer.color = new Color(1f, 0f, 0f, alpha); + } + yield return null; + } + Object.Destroy(ghost); + } + + /// + /// Maps each role to its associated list of custom action button types. + /// Used by Overload to absorb abilities based on the prey's role. + /// + public static readonly Dictionary> RoleToButtonsMap = new() + { + { typeof(EnergyThief), new() { typeof(DrainButton) } }, + { typeof(NecromancerRole), new() { typeof(ReviveButton) } }, + { typeof(Prankster), new() { typeof(FakeBodyButton) } }, + { typeof(Revenant), new() { typeof(FeignDeathButton), typeof(DoomAwakening) } }, + { typeof(SpecialAgent), new() { typeof(AssignButton) } }, + { typeof(TheVisionary), new() { typeof(CaptureButton), typeof(ShowScreenshotButton) } } + // TODO: Add Launchpad roles and their associated buttons here + }; } } diff --git a/NewMod/Utilities/VisionaryUtilities.cs b/NewMod/Utilities/VisionaryUtilities.cs index 43605f7..56dcb03 100644 --- a/NewMod/Utilities/VisionaryUtilities.cs +++ b/NewMod/Utilities/VisionaryUtilities.cs @@ -56,7 +56,7 @@ public static IEnumerator ShowScreenshots(float displayDuration) var screenshotPanel = new GameObject("Visionary_ScreenshotPanel"); var canvas = screenshotPanel.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; - + var group = screenshotPanel.AddComponent(); group.alpha = 0; @@ -84,10 +84,9 @@ public static IEnumerator ShowScreenshots(float displayDuration) var labelObj = new GameObject("Screenshot Label"); labelObj.transform.SetParent(screenshotPanel.transform, false); - var label = labelObj.AddComponent(); - label.alignment = TextAlignmentOptions.Center; + var label = labelObj.AddComponent(); + label.alignment = TextAnchor.MiddleCenter; label.fontSize = 20; - DateTime captureTime = File.GetCreationTime(latestScreenshot); label.text = $"*Screenshot taken at: {captureTime.ToShortTimeString()}*"; @@ -168,10 +167,9 @@ public static IEnumerator ShowScreenshotByPath(string filePath, float displayDur var labelObj = new GameObject("Screenshot Label"); labelObj.transform.SetParent(screenshotPanel.transform, false); - var label = labelObj.AddComponent(); - label.alignment = TextAlignmentOptions.Center; + var label = labelObj.AddComponent(); + label.alignment = TextAnchor.MiddleCenter; label.fontSize = 20; - DateTime captureTime = File.GetCreationTime(filePath); label.text = $"*Screenshot taken at: {captureTime.ToShortTimeString()}*"; diff --git a/README.md b/README.md index 175f6f5..7409425 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@

NewMod is a mod for Among Us that introduces a variety of new roles, unique abilities, and custom game modes, offering players exciting new ways to enjoy the game.

+

+๐Ÿ“ฑ NewMod now supports Android! +

GitHub stars @@ -23,6 +26,7 @@ - [โœจ Features](#-features) - [๐Ÿ”— Compatibility](#-compatibility) - [๐Ÿค Contributing](#-contributing) +- [๐Ÿ“ฑ Android](#-android) - [๐Ÿ‘ฅ Credits](#-credits) - [โš ๏ธ Disclaimer](#-disclaimer) @@ -32,16 +36,15 @@ | Mod Version | Among Us - Version | Link | |-------------|---------------------|------| -| v1.0.0 | 2024.8.13 & 2024.10.29 | [Download](https://github.com/CallOfCreator/NewMod/releases/download/v1.0.0/NewMod.dll) | +| v1.0.0 | 2024.8.13 & 2024.10.29 | [Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.0.0/NewMod.dll) | | v1.1.0 | 2024.11.26 | [Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.1.0/NewMod.dll) | +| v1.2.0 | v16.1.0 (2025.6.10) | [Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.0/NewMod.zip) --- # ๐Ÿ“ฅ Installation -1. **Download the latest version of NewMod** for your Among Us installation from [here](https://github.com/CallOfCreator/NewMod/releases/latest). -2. Extract the contents into your Among Us folder. -3. Launch the game with `Among Us.exe`. The first run may take 4-5 minutes as the mod initializes. +### For installation instructions, please visit: https://newmod.up.railway.app --- @@ -51,44 +54,20 @@ - **๐Ÿ–ฅ๏ธ Description:** Allows crewmates to access security cameras from anywhere. - **๐Ÿ‘€ Strategic Use:** Monitor other players without needing to go to the security room. -### **2. Necromancer Role** - - **๐Ÿ”ฎ Description:** A special Impostor role that can revive a dead player to join the Impostors. - - **๐Ÿ’จ Keybinds:** Press `F4` for teleport ability. - -### **3. Revival Royale GameMode** *(currently unavailable)* - - **โš”๏ธ Description:** Every player is a Necromancer, competing to revive bodies. The first to reach a set number of revivals wins. - -### **4. Energy Thief Role** - - **๐Ÿ’ก Description:** Drains energy from others, making them weaker. - -### **5. Double Agent Role** - - **๐Ÿ” Description:** Completes tasks but can sabotage neutrals and impostors after task completion. - -### **6. Special Agent Role** - - **๐ŸŽ–๏ธ Description:** A neutral role that assigns missions to other players. - - **๐Ÿ“‹ Mission Mechanics:** The assigned player must complete the mission or face penalties. - - If the target fails the mission, the Special Agent loses a point toward their win condition. - - **๐Ÿ“น Surveillance Option:** The Special Agent can monitor the target through a camera if enabled. - - **๐Ÿ† Win Condition:** Successful mission completions contribute to the Special Agentโ€™s victory. - -### **7. Visionary Role** -- **๐Ÿ“ธ Description:** A unique Crewmate role with the ability to take in-game screenshots to capture key moments like kills, venting, or suspicious activities. -- **๐Ÿ–ฑ๏ธ How It Works:** - - The Visionary can take multiple screenshots, but the limit depends on game settings. - - If multiple screenshots are taken, the Visionary can open the in-game chat to see available screenshots (e.g., `/1`, `/2`) and select which one to display during a meeting. -- **๐Ÿงฉ Strategy:** Use screenshots to catch impostors or prove innocence. - +### For role information, please visit: https://newmod.up.railway.app/roles --- # ๐Ÿ”— Compatibility -NewMod v1.1.0 is compatible with YanplaRoles, allowing for an enhanced experience with combined custom roles. Below is the supported version of YanplaRoles: +NewMod is compatible with the following mods, enabling an enhanced experience with combined custom roles. Below are the supported versions of each mod: + | Mod Name | Mod Version | GitHub Link | |--------------|-------------|------------------------------------------------------| -| YanplaRoles | v0.1.6+ | [Download](https://github.com/yanpla/yanplaRoles) | +| yanplaRoles | v0.1.6+ | [Download](https://github.com/yanpla/yanplaRoles) | +| LaunchpadReloaded | v0.3.4+ | [Download](https://github.com/All-Of-Us-Mods/LaunchpadReloaded) | +| LevelImposter | v0.20.3+ | [Download](https://github.com/DigiWorm0/LevelImposter) | -For more information on YanplaRoles, visit their official [GitHub page](https://github.com/yanpla/yanplaRoles). --- @@ -98,13 +77,23 @@ If youโ€™d like to contribute, feel free to join and improve the project! --- +# ๐Ÿ“ฑ Android + +NewMod now officially supports Android. +Special thanks to [@xtracube](https://github.com/XtraCube) for providing access to the Android game lib, ensuring NewMod is fully compatible with **Starlight** at launch. +For more information about Starlight, please visit: [https://discord.gg/FYYqJU2bvp](https://discord.gg/FYYqJU2bvp) + +--- + # ๐Ÿ‘ฅ Credits -- **MiraAPI**: [MiraAPI GitHub](https://github.com/Alll-Of-Us-Mods/MiraAPI) - Among Us modding API and utility library, with inspiration for the debug window and the derivation of the gold color from MiraAPIExample Mod. +- **MiraAPI**: [MiraAPI GitHub](https://github.com/All-Of-Us-Mods/MiraAPI) - Among Us modding API and utility library, with inspiration for the debug window and the derivation of the gold color from MiraAPIExample Mod. - **Reactor**: [Reactor GitHub](https://github.com/NuclearPowered/Reactor) - Modding API for Among Us. - **TownOfUs-R**: - Portions of code (PlayerById, GetClosestBody) and asset (ReviveSprite) derived from [Town-Of-Us-R](https://github.com/eDonnes124/Town-Of-Us-R). - **MoreGamemodes**: [MoreGamemodes](https://github.com/Rabek009/MoreGamemodes) - Derivation of IsActive and IsSabotage code. - **yanplaRoles**: [yanplaRoles](https://github.com/yanpla/yanplaRoles) - Portions of code (SavePlayerRole, GetPlayerRolesHistory). +- **EloySus**: [EloySus](https://github.com/EloySus) โ€“ for all button sprites used in NewMod +- **Pixabay**: [Pixabay](https://pixabay.com) - For sound effects used in NewMod --- diff --git a/nuget.config b/nuget.config index db78244..784b019 100644 --- a/nuget.config +++ b/nuget.config @@ -3,5 +3,6 @@ + \ No newline at end of file