From 24cea3a630ba57e0a6344d4844ad88f5454e9f59 Mon Sep 17 00:00:00 2001 From: PHGaming121 <76562427+PHGaming121@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:02:13 -0300 Subject: [PATCH] add 2 roles made by dechis (#592) * add 3 roles made by dechis * remove solarist dechis asked me to remove this one since it wasnt FULLY working(causing some issues in online games) * fix some option menu stuff --- CTA/CustomTeamAssigner/CustomRoles.cs | 3 + CustomRoles.cs | 2 + Main.cs | 2 + Modules/CustomRolesHelper.cs | 7 + Modules/GameState.cs | 3 +- Resources/Lang/en_US.jsonc | 50 ++- Resources/Lang/pt_BR.jsonc | 2 +- .../Standard/Impostor/Support/Chainbinder.cs | 292 ++++++++++++++++ Roles/Standard/Neutral/Killing/Jackpot.cs | 314 ++++++++++++++++++ 9 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 Roles/Standard/Impostor/Support/Chainbinder.cs create mode 100644 Roles/Standard/Neutral/Killing/Jackpot.cs diff --git a/CTA/CustomTeamAssigner/CustomRoles.cs b/CTA/CustomTeamAssigner/CustomRoles.cs index ff9289d64..8248a8748 100644 --- a/CTA/CustomTeamAssigner/CustomRoles.cs +++ b/CTA/CustomTeamAssigner/CustomRoles.cs @@ -57,6 +57,7 @@ public enum CustomRoles EvilGuesser, EvilTracker, Exclusionary, + Exorcist, Fabricator, Fakeshifter, Fireworker, @@ -64,6 +65,8 @@ public enum CustomRoles Framer, Freezer, Frightener, + Chainbinder, + Jackpot, Gambler, Gangster, Generator, diff --git a/CustomRoles.cs b/CustomRoles.cs index be2c15ab0..8248a8748 100644 --- a/CustomRoles.cs +++ b/CustomRoles.cs @@ -65,6 +65,8 @@ public enum CustomRoles Framer, Freezer, Frightener, + Chainbinder, + Jackpot, Gambler, Gangster, Generator, diff --git a/Main.cs b/Main.cs index fbfff8838..728e957f2 100644 --- a/Main.cs +++ b/Main.cs @@ -547,6 +547,7 @@ public class Main : BasePlugin { CustomRoles.Opportunist, "#4dff4d" }, { CustomRoles.Vector, "#ff6201" }, { CustomRoles.Jackal, "#00b4eb" }, + { CustomRoles.Jackpot, "#00fd50" }, { CustomRoles.Sidekick, "#00b4eb" }, { CustomRoles.Innocent, "#8f815e" }, { CustomRoles.Pelican, "#34c84b" }, @@ -1075,6 +1076,7 @@ public enum CustomWinner Revolutionist = CustomRoles.Revolutionist, Technician = CustomRoles.Technician, Jackal = CustomRoles.Jackal, + Jackpot = CustomRoles.Jackpot, God = CustomRoles.God, Vector = CustomRoles.Vector, Innocent = CustomRoles.Innocent, diff --git a/Modules/CustomRolesHelper.cs b/Modules/CustomRolesHelper.cs index d64515fdd..e2fb0ba6c 100644 --- a/Modules/CustomRolesHelper.cs +++ b/Modules/CustomRolesHelper.cs @@ -165,6 +165,8 @@ internal static class CustomRolesHelper CustomRoles.Venerer => UsePets ? CustomRoles.Impostor : CustomRoles.Shapeshifter, CustomRoles.Kidnapper => CustomRoles.Shapeshifter, CustomRoles.Frightener => CustomRoles.Shapeshifter, + CustomRoles.Jackpot => CustomRoles.Shapeshifter, + CustomRoles.Chainbinder => UsePets ? CustomRoles.Impostor : CustomRoles.Shapeshifter, CustomRoles.Exorcist => CustomRoles.Impostor, CustomRoles.Obstructer => CustomRoles.Impostor, CustomRoles.Ambusher => UsePets ? CustomRoles.Impostor : CustomRoles.Shapeshifter, @@ -523,6 +525,7 @@ internal static class CustomRolesHelper CustomRoles.Glitch => RoleTypes.Shapeshifter, CustomRoles.Jailor => UsePets && Jailor.UsePet.GetBool() ? RoleTypes.GuardianAngel : RoleTypes.Impostor, CustomRoles.Juggernaut => RoleTypes.Impostor, + CustomRoles.Jackpot => RoleTypes.Shapeshifter, CustomRoles.Jinx => RoleTypes.Impostor, CustomRoles.Stalker => RoleTypes.Impostor, CustomRoles.Provocateur => RoleTypes.Impostor, @@ -643,6 +646,7 @@ internal static class CustomRolesHelper { return (role == CustomRoles.Arsonist && (check || !CanCheck || !Options.IsLoaded || Arsonist.ArsonistKeepsGameGoing == null || Arsonist.ArsonistKeepsGameGoing.GetBool() || (Arsonist.ArsonistCanIgniteAnytime != null && Arsonist.ArsonistCanIgniteAnytime.GetBool()))) || role is CustomRoles.Jackal or + CustomRoles.Jackpot or CustomRoles.Glitch or CustomRoles.Sidekick or CustomRoles.HexMaster or @@ -1128,6 +1132,7 @@ internal static class CustomRolesHelper or CustomRoles.Thanos or CustomRoles.Tiger or CustomRoles.Bargainer + or CustomRoles.Jackpot or CustomRoles.Chemist or CustomRoles.Simon or CustomRoles.Sharpshooter @@ -1175,6 +1180,7 @@ internal static class CustomRolesHelper CustomRoles.Jester => RoleOptionType.Neutral_Evil, CustomRoles.Gaslighter => RoleOptionType.Neutral_Evil, CustomRoles.God => RoleOptionType.Neutral_Evil, + CustomRoles.Jackpot => RoleOptionType.Neutral_Killing, CustomRoles.Executioner => RoleOptionType.Neutral_Evil, CustomRoles.Doomsayer => RoleOptionType.Neutral_Evil, CustomRoles.Vector => RoleOptionType.Neutral_Evil, @@ -1255,6 +1261,7 @@ internal static class CustomRolesHelper CustomRoles.Godfather => RoleOptionType.Impostor_Support, CustomRoles.Hypnotist => RoleOptionType.Impostor_Support, CustomRoles.Librarian => RoleOptionType.Impostor_Support, + CustomRoles.Chainbinder => RoleOptionType.Impostor_Support, CustomRoles.Nullifier => RoleOptionType.Impostor_Support, CustomRoles.Occultist => RoleOptionType.Impostor_Support, CustomRoles.Perplexer => RoleOptionType.Impostor_Support, diff --git a/Modules/GameState.cs b/Modules/GameState.cs index c557faa8b..92c2748c3 100644 --- a/Modules/GameState.cs +++ b/Modules/GameState.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using AmongUs.GameOptions; @@ -72,6 +72,7 @@ public class PlayerState(byte playerId) Misguess, LossOfBlood, Frightened, + Bankrupt, // Natural Disasters Meteor, diff --git a/Resources/Lang/en_US.jsonc b/Resources/Lang/en_US.jsonc index 64ffe900b..bff117772 100644 --- a/Resources/Lang/en_US.jsonc +++ b/Resources/Lang/en_US.jsonc @@ -626,7 +626,7 @@ "DeputyHandcuffText": "Handcuff", "DeputyHandcuffedPlayer": "Handcuffed target", "DeputyInvalidTarget": "Target cannot be handcuffed", - "Detective": "Detective", + "Detective": "Detective", "DetectiveSuspectLimit": "Suspects Per Case", "DetectiveEHR": "Detective", "DetectiveEHRInfo": "Gather Info, Solve Murders", @@ -1898,6 +1898,19 @@ "FrightenerInfo": "Prevent Players From Reporting", "FrightenerInfoLong": "(Impostors):\\nAs the Frightener, you can shapeshift into a player to alarm them for some time. Alarmed players can't report bodies (or even call meetings), else they die.", "Frightener.KillOnEmergencyMeetingCall": "Alarmed Players Die When Calling an Emergency Meeting", + "Chainbinder": "Chainbinder", + "ChainbinderInfo": "Chain nearby players together", + "ChainbinderInfoLong": "(Impostors):\\nAs the Chainbinder, use your ability to chain two nearby players together for a short time. When they move too far apart, they are pulled back toward each other. With shapeshift basis, the selected player becomes part of the chain; with pets enabled, the two nearest valid players around you are chained instead. You can still kill normally.", + "Chainbinder.NotEnoughTargets": "Not enough valid players nearby to form a chain", + "Chainbinder.PairBound": "Chain formed: {0} and {1}", + "Chainbinder.PairEnded": "Your chain ended", + "Chainbinder.PairSuffix": "Chain: {0} <> {1}", + "ChainbinderButtonText": "Chain", + "ChainbinderCooldown": "Chain Cooldown", + "ChainbinderDuration": "Chain Duration", + "ChainbinderBindRange": "Chain Range", + "ChainbinderMaxDistance": "Max Distance Before Pull", + "ChainbinderCanBindImpostors": "Can Chain Fellow Impostors", "Gambler": "Gambler", "GamblerInfo": "Each Kill gets you an Effect", "GamblerInfoLong": "(Impostors):\\nAs the Gambler, a random event happens after each kill you get. These effects can be anywhere between a self-report and an extra sniper shot.", @@ -2763,6 +2776,38 @@ "JackalCanRecruitMadmates": "Can recruit Madmates", "JackalCanRecruitSidekick": "Can recruit Sidekick", "JackalSidekick": "This Jackal-Sidekick interaction is not allowed", + "Jackpot": "Jackpot", + "JackpotInfo": "Open a gambling domain and spin for a jackpot", + "JackpotInfoLong": "(Neutrals):\\nAs Jackpot, use the Jackpot button to open a domain where spins happen automatically every few seconds. A miss costs money and raises your next jackpot chance. A near miss gives you a short buff and raises the next jackpot chance even more. Hitting the jackpot gives you a short burst of overwhelming power: near-instant kills, boosted speed, boosted vision, and temporary kill immunity. If you run out of money before you hit, you die.", + "JackpotButtonText": "Jackpot", + "Jackpot.StartingMoney": "Starting Money", + "Jackpot.MoneyPerKill": "Money Gained Per Kill", + "Jackpot.MissCost": "Money Lost On Miss", + "Jackpot.SpinInterval": "Spin Interval", + "Jackpot.BaseChance": "Base Jackpot Chance", + "Jackpot.MissChanceIncrease": "Jackpot Chance Increase Per Miss", + "Jackpot.NearChance": "Near Miss Chance", + "Jackpot.NearChanceIncrease": "Jackpot Chance Increase On Near Miss", + "Jackpot.NearDuration": "Near Miss Buff Duration", + "Jackpot.NearSpeed": "Near Miss Speed", + "Jackpot.NearVision": "Near Miss Vision", + "Jackpot.Duration": "Jackpot Duration", + "Jackpot.Speed": "Jackpot Speed", + "Jackpot.Vision": "Jackpot Vision", + "Jackpot.GameRulesTransmitted": "GAME RULES HAVE BEEN TRANSMITTED", + "Jackpot.DomainStarted": "The domain is live. Spins will roll every {0}s.", + "Jackpot.DomainAlreadyActive": "Your jackpot sequence is already active", + "Jackpot.KillReward": "+${0} from that kill. Money: ${1}", + "Jackpot.Spin.Miss": "MISS\\nLost ${0}. Money: ${1}. Next jackpot chance: {2}%", + "Jackpot.Spin.Near": "NEAR MISS\\nBuffed for {0}s. Money: ${1}. Next jackpot chance: {2}%", + "Jackpot.Spin.Jackpot": "JACKPOT!", + "Jackpot.JackpotState": "Unlimited pressure for {0}s.", + "Jackpot.OutOfMoney": "You ran out of money and busted.", + "Jackpot.JackpotEnded": "The jackpot ended. You can start spinning again.", + "Jackpot.Progress.Ready": "${0} | {1}% | READY", + "Jackpot.Progress.Spin": "${0} | {1}% | SPIN", + "Jackpot.Progress.Near": "${0} | {1}% | NEAR", + "Jackpot.Progress.Jackpot": "${0} | JACKPOT {1}s", "Jester": "Jester", "JesterInfo": "Get voted out", "JesterInfoLong": "(Neutrals):\\nJester wins alone if voted out but loses if still alive at the end. Jester, Executioner, and Innocent can win together.", @@ -9179,7 +9224,7 @@ "CommandArgs.Vote.Id": "The player's ID to vote for", "CommandArgs.Whisper.Ids": "The players' IDs to whisper to, separated by commas (no space)", "CommandArgs.Whisper.Message": "The message to whisper", - "CommandArgs.XOR.Role": "A main role that will never spawn in the same game with the other role", + "CommandArgs.XOR.Role": "A main role that will never spawn in the same game with the other role", // Command Descriptions "CommandDescription.AFKExempt": "Exempt a player from being checked by the AFK detector", "CommandDescription.Achievements": "See which achievements you have unlocked and which you haven't", @@ -9599,6 +9644,7 @@ "DeathReason.Fall": "Fall", "DeathReason.FollowingSuicide": "Heartbroken", "DeathReason.Frightened": "Frightened", + "DeathReason.Bankrupt": "Bankrupted", "DeathReason.Gambled": "Guessed", "DeathReason.Hack": "Hacked", "DeathReason.Hex": "Hexed", diff --git a/Resources/Lang/pt_BR.jsonc b/Resources/Lang/pt_BR.jsonc index 295b4fb2d..7903bab70 100644 --- a/Resources/Lang/pt_BR.jsonc +++ b/Resources/Lang/pt_BR.jsonc @@ -1,4 +1,4 @@ -{ +{ /* ============================== CORE & FREQUENTLY USED STRINGS ============================== */ diff --git a/Roles/Standard/Impostor/Support/Chainbinder.cs b/Roles/Standard/Impostor/Support/Chainbinder.cs new file mode 100644 index 000000000..3759efeb3 --- /dev/null +++ b/Roles/Standard/Impostor/Support/Chainbinder.cs @@ -0,0 +1,292 @@ +using System.Collections.Generic; +using System.Linq; +using AmongUs.GameOptions; +using EHR.Modules; +using Hazel; +using UnityEngine; + +namespace EHR.Roles; + +using static EHR.Translator; + +public class Chainbinder : RoleBase +{ + public static bool On; + + private const float PullInterval = 0.1f; + + private static OptionItem AbilityCooldown; + private static OptionItem LinkDuration; + private static OptionItem BindRange; + private static OptionItem MaxDistance; + private static OptionItem CanBindImpostors; + private static List PlayerIdList = []; + + private byte ChainbinderId; + private byte FirstTarget = byte.MaxValue; + private byte SecondTarget = byte.MaxValue; + private float RemainingDuration; + private float PullTimer; + + public override bool IsEnable => On; + + private bool HasLink => FirstTarget != byte.MaxValue && SecondTarget != byte.MaxValue; + + public override void SetupCustomOption() + { + StartSetup(658380) + .AutoSetupOption(ref AbilityCooldown, 25f, new FloatValueRule(5f, 60f, 0.5f), OptionFormat.Seconds, overrideName: "ChainbinderCooldown") + .AutoSetupOption(ref LinkDuration, 10f, new FloatValueRule(2.5f, 60f, 0.5f), OptionFormat.Seconds, overrideName: "ChainbinderDuration") + .AutoSetupOption(ref BindRange, 3f, new FloatValueRule(1f, 15f, 0.5f), OptionFormat.Multiplier, overrideName: "ChainbinderBindRange") + .AutoSetupOption(ref MaxDistance, 2f, new FloatValueRule(0.5f, 10f, 0.5f), OptionFormat.Multiplier, overrideName: "ChainbinderMaxDistance") + .AutoSetupOption(ref CanBindImpostors, false, overrideName: "ChainbinderCanBindImpostors"); + } + + public override void Init() + { + On = false; + PlayerIdList = []; + ClearLocalState(); + } + + public override void Add(byte playerId) + { + On = true; + PlayerIdList.Add(playerId); + ChainbinderId = playerId; + ClearLocalState(); + } + + public override void Remove(byte playerId) + { + ClearLink(sync: true, refresh: false); + PlayerIdList.Remove(playerId); + On = PlayerIdList.Count > 0; + } + + public override void ApplyGameOptions(IGameOptions opt, byte playerId) + { + if (Options.UsePhantomBasis.GetBool()) + { + AURoleOptions.PhantomCooldown = AbilityCooldown.GetFloat(); + AURoleOptions.PhantomDuration = 1f; + } + else + { + AURoleOptions.ShapeshifterCooldown = AbilityCooldown.GetFloat(); + AURoleOptions.ShapeshifterDuration = 1f; + } + } + + public override void SetButtonTexts(HudManager hud, byte id) + { + if (Options.UsePets.GetBool() && !Options.UsePhantomBasis.GetBool()) + hud.PetButton?.OverrideText(GetString("ChainbinderButtonText")); + else + hud.AbilityButton?.OverrideText(GetString("ChainbinderButtonText")); + } + + public override void OnPet(PlayerControl pc) + { + TryChainPlayers(pc); + } + + public override bool OnVanish(PlayerControl pc) + { + TryChainPlayers(pc); + return false; + } + + public override bool OnShapeshift(PlayerControl shapeshifter, PlayerControl target, bool shapeshifting) + { + if (shapeshifting) TryChainPlayers(shapeshifter, target); + return false; + } + + public override void OnFixedUpdate(PlayerControl pc) + { + if (pc == null || pc.PlayerId != ChainbinderId || !HasLink) return; + + if (!pc.IsAlive() || !GameStates.IsInTask) + { + ClearLink(sync: true, refresh: true); + return; + } + + RemainingDuration -= UnityEngine.Time.fixedDeltaTime; + if (RemainingDuration <= 0f) + { + ClearLink(notifyBinder: true); + return; + } + + PlayerControl first = Utils.GetPlayerById(FirstTarget); + PlayerControl second = Utils.GetPlayerById(SecondTarget); + + if (!AreLinkedPlayersValid(first, second)) + { + ClearLink(notifyBinder: true); + return; + } + + if (first.onLadder || first.inMovingPlat || first.inVent || second.onLadder || second.inMovingPlat || second.inVent) return; + + PullTimer -= UnityEngine.Time.fixedDeltaTime; + if (PullTimer > 0f) return; + + float currentDistance = Vector2.Distance(first.Pos(), second.Pos()); + float maxDistance = MaxDistance.GetFloat(); + + if (currentDistance <= maxDistance) return; + + Vector2 direction = (second.Pos() - first.Pos()).normalized; + if (direction == Vector2.zero) return; + + PullTimer = PullInterval; + Vector2 adjustment = direction * ((currentDistance - maxDistance) / 2f); + + first.TP(first.Pos() + adjustment, log: false); + second.TP(second.Pos() - adjustment, log: false); + } + + public override void OnReportDeadBody() + { + ClearLink(sync: true, refresh: true); + } + + public override void AfterMeetingTasks() + { + ClearLink(sync: true, refresh: true); + } + + public override string GetSuffix(PlayerControl seer, PlayerControl target, bool hud = false, bool meeting = false) + { + if (seer.PlayerId != target.PlayerId || seer.PlayerId != ChainbinderId || (seer.IsModdedClient() && !hud) || meeting) return string.Empty; + if (!HasLink) return string.Empty; + + return string.Format(GetString("Chainbinder.PairSuffix"), FirstTarget.ColoredPlayerName(), SecondTarget.ColoredPlayerName()); + } + + public void ReceiveRPC(MessageReader reader) + { + FirstTarget = reader.ReadByte(); + SecondTarget = reader.ReadByte(); + RemainingDuration = HasLink ? LinkDuration.GetFloat() : 0f; + PullTimer = 0f; + } + + private void TryChainPlayers(PlayerControl binder, PlayerControl requestedTarget = null) + { + if (binder == null || binder.PlayerId != ChainbinderId || !binder.IsAlive()) return; + + if (!TryGetTargets(binder, requestedTarget, out PlayerControl first, out PlayerControl second)) + { + binder.Notify(GetString("Chainbinder.NotEnoughTargets")); + binder.RpcResetAbilityCooldown(); + return; + } + + FirstTarget = first.PlayerId; + SecondTarget = second.PlayerId; + RemainingDuration = LinkDuration.GetFloat(); + PullTimer = 0f; + + SyncState(); + + binder.Notify(string.Format(GetString("Chainbinder.PairBound"), FirstTarget.ColoredPlayerName(), SecondTarget.ColoredPlayerName())); + Utils.NotifyRoles(SpecifySeer: binder, SpecifyTarget: binder); + + Logger.Info($"{binder.GetNameWithRole().RemoveHtmlTags()} chained {first.GetNameWithRole().RemoveHtmlTags()} to {second.GetNameWithRole().RemoveHtmlTags()}", "Chainbinder"); + } + + private bool TryGetTargets(PlayerControl binder, PlayerControl requestedTarget, out PlayerControl first, out PlayerControl second) + { + first = null; + second = null; + + List validTargets = Main.AllAlivePlayerControls + .Where(x => IsValidTarget(binder, x)) + .ToList(); + + if (requestedTarget != null) + { + if (!IsValidTarget(binder, requestedTarget)) return false; + + first = requestedTarget; + byte firstId = first.PlayerId; + Vector2 firstPosition = first.Pos(); + second = validTargets + .Where(x => x.PlayerId != firstId) + .OrderBy(x => Vector2.Distance(x.Pos(), firstPosition)) + .FirstOrDefault(); + + return second != null; + } + + PlayerControl[] closestTargets = validTargets + .OrderBy(x => Vector2.Distance(x.Pos(), binder.Pos())) + .Take(2) + .ToArray(); + + if (closestTargets.Length < 2) return false; + + first = closestTargets[0]; + second = closestTargets[1]; + return true; + } + + private bool IsValidTarget(PlayerControl binder, PlayerControl target) + { + if (binder == null || target == null || target.PlayerId == binder.PlayerId || !target.IsAlive()) return false; + if (Pelican.IsEaten(target.PlayerId) || target.onLadder || target.inMovingPlat || target.inVent) return false; + if (!FastVector2.DistanceWithinRange(binder.Pos(), target.Pos(), BindRange.GetFloat())) return false; + if (!CanBindImpostors.GetBool() && target.GetCustomRole().IsImpostor()) return false; + return !IsLinkedByAnotherChainbinder(target.PlayerId); + } + + private bool IsLinkedByAnotherChainbinder(byte targetId) + { + foreach (byte playerId in PlayerIdList) + { + if (playerId == ChainbinderId || !Main.PlayerStates.TryGetValue(playerId, out PlayerState state) || state.Role is not Chainbinder cb || !cb.HasLink) continue; + if (cb.FirstTarget == targetId || cb.SecondTarget == targetId) return true; + } + + return false; + } + + private static bool AreLinkedPlayersValid(PlayerControl first, PlayerControl second) + { + return first != null && second != null && first.IsAlive() && second.IsAlive() && !Pelican.IsEaten(first.PlayerId) && !Pelican.IsEaten(second.PlayerId); + } + + private void ClearLink(bool notifyBinder = false, bool sync = true, bool refresh = true) + { + bool hadLink = HasLink; + ClearLocalState(); + + if (sync) SyncState(); + if (!hadLink) return; + + PlayerControl binder = Utils.GetPlayerById(ChainbinderId); + if (notifyBinder && binder != null && binder.IsAlive()) + binder.Notify(GetString("Chainbinder.PairEnded")); + + if (refresh && binder != null) + Utils.NotifyRoles(SpecifySeer: binder, SpecifyTarget: binder); + } + + private void ClearLocalState() + { + FirstTarget = byte.MaxValue; + SecondTarget = byte.MaxValue; + RemainingDuration = 0f; + PullTimer = 0f; + } + + private void SyncState() + { + if (!Utils.DoRPC) return; + Utils.SendRPC(CustomRPC.SyncRoleData, ChainbinderId, FirstTarget, SecondTarget); + } +} diff --git a/Roles/Standard/Neutral/Killing/Jackpot.cs b/Roles/Standard/Neutral/Killing/Jackpot.cs new file mode 100644 index 000000000..bc69d51a1 --- /dev/null +++ b/Roles/Standard/Neutral/Killing/Jackpot.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using AmongUs.GameOptions; +using EHR.Modules; +using UnityEngine; + +namespace EHR.Roles; + +using static EHR.Translator; + +public class Jackpot : RoleBase +{ + private const int Id = 658620; + + public static bool On; + private static List PlayerIdList = []; + + private static OptionItem KillCooldown; + private static OptionItem StartingMoney; + private static OptionItem MoneyPerKill; + private static OptionItem MissCost; + private static OptionItem SpinInterval; + private static OptionItem BaseJackpotChance; + private static OptionItem MissChanceIncrease; + private static OptionItem NearMissChance; + private static OptionItem NearMissChanceIncrease; + private static OptionItem NearMissDuration; + private static OptionItem NearMissSpeed; + private static OptionItem NearMissVision; + private static OptionItem JackpotDuration; + private static OptionItem JackpotSpeed; + private static OptionItem JackpotVision; + + private byte JackpotId; + private int Money; + private int CurrentJackpotChanceValue; + private float SpinTimer; + private float NearMissTimer; + private float JackpotTimer; + private bool DomainActive; + private bool LastNearMissState; + private bool LastJackpotState; + + public override bool IsEnable => On; + + private bool IsNearMissActive => NearMissTimer > 0f; + private bool IsJackpotActive => JackpotTimer > 0f; + + public override void SetupCustomOption() + { + StartSetup(Id) + .AutoSetupOption(ref KillCooldown, 22.5f, new FloatValueRule(0f, 180f, 0.5f), OptionFormat.Seconds) + .AutoSetupOption(ref StartingMoney, 4, new IntegerValueRule(1, 30, 1), overrideName: "Jackpot.StartingMoney") + .AutoSetupOption(ref MoneyPerKill, 2, new IntegerValueRule(0, 10, 1), overrideName: "Jackpot.MoneyPerKill") + .AutoSetupOption(ref MissCost, 1, new IntegerValueRule(1, 10, 1), overrideName: "Jackpot.MissCost") + .AutoSetupOption(ref SpinInterval, 5f, new FloatValueRule(1f, 30f, 0.5f), OptionFormat.Seconds, overrideName: "Jackpot.SpinInterval") + .AutoSetupOption(ref BaseJackpotChance, 5, new IntegerValueRule(1, 100, 1), OptionFormat.Percent, overrideName: "Jackpot.BaseChance") + .AutoSetupOption(ref MissChanceIncrease, 2, new IntegerValueRule(1, 25, 1), OptionFormat.Percent, overrideName: "Jackpot.MissChanceIncrease") + .AutoSetupOption(ref NearMissChance, 15, new IntegerValueRule(0, 100, 1), OptionFormat.Percent, overrideName: "Jackpot.NearChance") + .AutoSetupOption(ref NearMissChanceIncrease, 5, new IntegerValueRule(1, 50, 1), OptionFormat.Percent, overrideName: "Jackpot.NearChanceIncrease") + .AutoSetupOption(ref NearMissDuration, 4f, new FloatValueRule(0.5f, 20f, 0.5f), OptionFormat.Seconds, overrideName: "Jackpot.NearDuration") + .AutoSetupOption(ref NearMissSpeed, 1.35f, new FloatValueRule(0.25f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.NearSpeed") + .AutoSetupOption(ref NearMissVision, 1.25f, new FloatValueRule(0.1f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.NearVision") + .AutoSetupOption(ref JackpotDuration, 15f, new FloatValueRule(10f, 20f, 0.5f), OptionFormat.Seconds, overrideName: "Jackpot.Duration") + .AutoSetupOption(ref JackpotSpeed, 1.8f, new FloatValueRule(0.25f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.Speed") + .AutoSetupOption(ref JackpotVision, 2.5f, new FloatValueRule(0.1f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.Vision"); + } + + public override void Init() + { + On = false; + PlayerIdList = []; + } + + public override void Add(byte playerId) + { + On = true; + PlayerIdList.Add(playerId); + JackpotId = playerId; + Money = StartingMoney.GetInt(); + CurrentJackpotChanceValue = BaseJackpotChance.GetInt(); + SpinTimer = 0f; + NearMissTimer = 0f; + JackpotTimer = 0f; + DomainActive = false; + LastNearMissState = false; + LastJackpotState = false; + } + + public override void Remove(byte playerId) + { + PlayerIdList.Remove(playerId); + On = PlayerIdList.Count > 0; + } + + public override void SetKillCooldown(byte id) + { + Main.AllPlayerKillCooldown[id] = IsJackpotActive ? 0.1f : KillCooldown.GetFloat(); + } + + public override bool CanUseImpostorVentButton(PlayerControl pc) + { + return false; + } + + public override bool CanUseSabotage(PlayerControl pc) + { + return false; + } + + public override void ApplyGameOptions(IGameOptions opt, byte id) + { + AURoleOptions.ShapeshifterCooldown = 1f; + AURoleOptions.ShapeshifterDuration = 1f; + + if (!IsNearMissActive && !IsJackpotActive) return; + + opt.SetVision(false); + float vision = IsJackpotActive ? JackpotVision.GetFloat() : NearMissVision.GetFloat(); + opt.SetFloat(FloatOptionNames.CrewLightMod, vision); + opt.SetFloat(FloatOptionNames.ImpostorLightMod, vision); + } + + public override void SetButtonTexts(HudManager hud, byte id) + { + hud.AbilityButton?.OverrideText(GetString("JackpotButtonText")); + } + + public override bool OnShapeshift(PlayerControl shapeshifter, PlayerControl target, bool shapeshifting) + { + if (!shapeshifting) return false; + + StartDomain(shapeshifter); + return false; + } + + public override void OnMurder(PlayerControl killer, PlayerControl target) + { + if (killer == null || killer.PlayerId != JackpotId || !target) return; + + Money += MoneyPerKill.GetInt(); + killer.Notify(string.Format(GetString("Jackpot.KillReward"), MoneyPerKill.GetInt(), Money)); + } + + public override bool OnCheckMurderAsTarget(PlayerControl killer, PlayerControl target) + { + return !IsJackpotActive; + } + + public override void OnFixedUpdate(PlayerControl pc) + { + if (pc == null || pc.PlayerId != JackpotId) return; + + if (!GameStates.IsInTask || !pc.IsAlive()) + { + if (DomainActive || IsNearMissActive || IsJackpotActive) + StopSequence(pc, resetChance: true, notifyEnd: false, refreshCooldown: false); + + return; + } + + if (IsNearMissActive) + NearMissTimer = Mathf.Max(0f, NearMissTimer - Time.fixedDeltaTime); + + if (IsJackpotActive) + JackpotTimer = Mathf.Max(0f, JackpotTimer - Time.fixedDeltaTime); + + if (LastJackpotState && !IsJackpotActive) + { + StopSequence(pc, resetChance: true, notifyEnd: true, refreshCooldown: true); + return; + } + + if (DomainActive && !IsJackpotActive) + { + SpinTimer -= Time.fixedDeltaTime; + + while (SpinTimer <= 0f && DomainActive && !IsJackpotActive) + { + SpinTimer += SpinInterval.GetFloat(); + ResolveSpin(pc); + } + } + + RefreshTemporaryBuffs(pc, refreshCooldown: false); + } + + public override void OnReportDeadBody() + { + PlayerControl jackpot = JackpotId.GetPlayer(); + StopSequence(jackpot, resetChance: true, notifyEnd: false, refreshCooldown: false); + } + + public override void AfterMeetingTasks() + { + PlayerControl jackpot = JackpotId.GetPlayer(); + StopSequence(jackpot, resetChance: true, notifyEnd: false, refreshCooldown: true); + } + + public override string GetProgressText(byte playerId, bool comms) + { + if (playerId != JackpotId) return string.Empty; + + string text = IsJackpotActive + ? string.Format(GetString("Jackpot.Progress.Jackpot"), Money, Mathf.CeilToInt(JackpotTimer)) + : DomainActive + ? string.Format(GetString(IsNearMissActive ? "Jackpot.Progress.Near" : "Jackpot.Progress.Spin"), Money, CurrentJackpotChanceValue) + : string.Format(GetString("Jackpot.Progress.Ready"), Money, CurrentJackpotChanceValue); + + return Utils.ColorString(Utils.GetRoleColor(CustomRoles.Jackpot), $" {text}"); + } + + private void StartDomain(PlayerControl player) + { + if (player == null || player.PlayerId != JackpotId || !player.IsAlive()) return; + + if (DomainActive || IsJackpotActive) + { + player.Notify(GetString("Jackpot.DomainAlreadyActive")); + player.RpcResetAbilityCooldown(); + return; + } + + DomainActive = true; + SpinTimer = SpinInterval.GetFloat(); + player.Notify($"{GetString("Jackpot.GameRulesTransmitted")}\n{string.Format(GetString("Jackpot.DomainStarted"), SpinInterval.GetFloat())}", 6f); + RefreshTemporaryBuffs(player, refreshCooldown: false); + } + + // Domain phase: rolls keep happening until the player jackpots, dies, or a meeting interrupts the sequence. + private void ResolveSpin(PlayerControl player) + { + if (player == null || !player.IsAlive()) return; + + int roll = IRandom.Instance.Next(100); + + if (roll < CurrentJackpotChanceValue) + { + DomainActive = false; + NearMissTimer = 0f; + JackpotTimer = JackpotDuration.GetFloat(); + CurrentJackpotChanceValue = BaseJackpotChance.GetInt(); + + player.Notify($"{GetString("Jackpot.Spin.Jackpot")}\n{string.Format(GetString("Jackpot.JackpotState"), JackpotDuration.GetFloat())}", JackpotDuration.GetFloat()); + RefreshTemporaryBuffs(player, refreshCooldown: true); + return; + } + + if (roll < CurrentJackpotChanceValue + NearMissChance.GetInt()) + { + NearMissTimer = NearMissDuration.GetFloat(); + CurrentJackpotChanceValue = Mathf.Clamp(CurrentJackpotChanceValue + NearMissChanceIncrease.GetInt(), 0, 100); + player.Notify(string.Format(GetString("Jackpot.Spin.Near"), NearMissDuration.GetFloat(), Money, CurrentJackpotChanceValue), NearMissDuration.GetFloat()); + RefreshTemporaryBuffs(player, refreshCooldown: false); + return; + } + + Money -= MissCost.GetInt(); + CurrentJackpotChanceValue = Mathf.Clamp(CurrentJackpotChanceValue + MissChanceIncrease.GetInt(), 0, 100); + + if (Money <= 0) + { + Money = 0; + StopSequence(player, resetChance: true, notifyEnd: false, refreshCooldown: false); + player.Notify(GetString("Jackpot.OutOfMoney")); + player.Suicide(PlayerState.DeathReason.Bankrupt); + return; + } + + player.Notify(string.Format(GetString("Jackpot.Spin.Miss"), MissCost.GetInt(), Money, CurrentJackpotChanceValue)); + } + + // Near Miss and Jackpot both use the same buff refresh path so we only touch speed/settings when the phase changes. + private void RefreshTemporaryBuffs(PlayerControl player, bool refreshCooldown) + { + bool nearMissActive = IsNearMissActive; + bool jackpotActive = IsJackpotActive; + + if (LastNearMissState == nearMissActive && LastJackpotState == jackpotActive && !refreshCooldown) return; + + LastNearMissState = nearMissActive; + LastJackpotState = jackpotActive; + + float speed = Main.RealOptionsData.GetFloat(FloatOptionNames.PlayerSpeedMod); + + if (jackpotActive) speed = JackpotSpeed.GetFloat(); + else if (nearMissActive) speed = NearMissSpeed.GetFloat(); + + Main.AllPlayerSpeed[JackpotId] = speed; + + if (player == null || !player.IsAlive()) return; + + if (refreshCooldown) player.ResetKillCooldown(); + else player.MarkDirtySettings(); + } + + // Jackpot is the payoff window: once it ends, the chance resets and the button is ready again immediately. + private void StopSequence(PlayerControl player, bool resetChance, bool notifyEnd, bool refreshCooldown) + { + bool wasJackpotActive = IsJackpotActive; + + DomainActive = false; + SpinTimer = 0f; + NearMissTimer = 0f; + JackpotTimer = 0f; + + if (resetChance) + CurrentJackpotChanceValue = BaseJackpotChance.GetInt(); + + RefreshTemporaryBuffs(player, refreshCooldown); + + if (notifyEnd && wasJackpotActive && player != null && player.IsAlive()) + player.Notify(GetString("Jackpot.JackpotEnded")); + } +}