using System; using System.Collections.Generic; using System.Linq; using AmongUs.GameOptions; using EHR.Modules; using Hazel; using UnityEngine; using static EHR.Translator; namespace EHR.Gamemodes; internal static class DoomTag { private static Dictionary TargetMap = []; private static HashSet LowerVisionList = []; private static bool FrenzyMode; private static float DefaultSpeed; public static OptionItem BaseKillCooldown; private static OptionItem PunishmentMode; private static OptionItem LowerVisionMultiplier; private static OptionItem FreezeDuration; private static OptionItem FrenzyPlayerCount; private static OptionItem FrenzyKillCooldown; private static OptionItem FrenzySpeedMultiplier; private static OptionItem ShowTargetArrow; private static readonly string[] PunishmentModeOptions = ["DoomTag.PunishSuicide", "DoomTag.PunishLowerVision", "DoomTag.PunishFreeze"]; public static void SetupCustomOption() { var id = 69_226_001; Color color = Utils.GetRoleColor(CustomRoles.Tagger); const CustomGameMode gameMode = CustomGameMode.DoomTag; const TabGroup tab = TabGroup.GameSettings; BaseKillCooldown = new FloatOptionItem(id++, "DoomTag.BaseKillCooldown", new(2.5f, 60f, 2.5f), 15f, tab) .SetHeader(true) .SetGameMode(gameMode) .SetColor(color) .SetValueFormat(OptionFormat.Seconds); PunishmentMode = new StringOptionItem(id++, "DoomTag.PunishmentMode", PunishmentModeOptions, 0, tab) .SetGameMode(gameMode) .SetColor(color) .RegisterUpdateValueEvent((_, _, newValue) => { LowerVisionMultiplier.SetHidden(newValue != 1); FreezeDuration.SetHidden(newValue != 2); }) .SetRunEventOnLoad(true); LowerVisionMultiplier = new FloatOptionItem(id++, "DoomTag.LowerVisionMultiplier", new(0f, 10f, 0.5f), 0.25f, tab) .SetParent(PunishmentMode) .SetGameMode(gameMode) .SetColor(color) .SetValueFormat(OptionFormat.Multiplier); FreezeDuration = new FloatOptionItem(id++, "DoomTag.FreezeDuration", new(0f, 120f, 0.5f), 10f, tab) .SetParent(PunishmentMode) .SetGameMode(gameMode) .SetColor(color) .SetValueFormat(OptionFormat.Seconds); FrenzyPlayerCount = new IntegerOptionItem(id++, "DoomTag.FrenzyPlayerCount", new(1, 127, 1), 3, tab) .SetGameMode(gameMode) .SetColor(color) .SetValueFormat(OptionFormat.Players); FrenzyKillCooldown = new FloatOptionItem(id++, "DoomTag.FrenzyKillCooldown", new(2.5f, 60f, 2.5f), 5f, tab) .SetGameMode(gameMode) .SetColor(color) .SetValueFormat(OptionFormat.Seconds); FrenzySpeedMultiplier = new FloatOptionItem(id++, "DoomTag.FrenzySpeedMultiplier", new(0.25f, 10f, 0.25f), 2.0f, tab) .SetGameMode(gameMode) .SetColor(color) .SetValueFormat(OptionFormat.Multiplier); ShowTargetArrow = new BooleanOptionItem(id, "DoomTag.ShowTargetArrow", false, tab) .SetGameMode(gameMode) .SetColor(color); } public static void Init() { Suffix.Clear(); TargetMap = []; LowerVisionList = []; FrenzyMode = false; if (Options.CurrentGameMode != CustomGameMode.DoomTag) return; DefaultSpeed = Main.RealOptionsData.GetFloat(FloatOptionNames.PlayerSpeedMod); Utils.SendRPC(CustomRPC.DoomTagSync, 1, false); } public static void OnGameStart() { if (Options.CurrentGameMode != CustomGameMode.DoomTag) return; AssignTargets(); } private static void AssignTargets() { if (!AmongUsClient.Instance.AmHost) return; TargetMap = []; var allPlayers = Main.EnumerateAlivePlayerControls(); if (Main.GM.Value) allPlayers = allPlayers.Without(PlayerControl.LocalPlayer); allPlayers = allPlayers.Where(x => !ChatCommands.Spectators.Contains(x.PlayerId)); List playerIds = allPlayers.Select(x => x.PlayerId).Shuffle(); if (playerIds.Count < 2) { Logger.Warn("Not enough players for DoomTag", "DoomTag"); return; } for (int i = 0; i < playerIds.Count; i++) { byte targetId = playerIds[(i + 1) % playerIds.Count]; byte hunterId = playerIds[i]; if (hunterId == targetId) continue; // should never happen, but guard if (TargetMap.ContainsValue(targetId)) TargetMap.Remove(TargetMap.GetKeyByValue(targetId)); TargetMap[hunterId] = targetId; if (ShowTargetArrow.GetBool()) TargetArrow.Add(hunterId, targetId); Logger.Info($"{Main.AllPlayerNames.GetValueOrDefault(hunterId, string.Empty)} targets {Main.AllPlayerNames.GetValueOrDefault(targetId, string.Empty)}", "DoomTag"); } SyncTargetMapRPC(); FrenzyMode = false; } private static byte GetTarget(byte playerId) { return TargetMap.GetValueOrDefault(playerId, byte.MaxValue); } public static void OnCheckMurder(PlayerControl killer, PlayerControl target) { if (!AmongUsClient.Instance.AmHost) return; byte killerId = killer.PlayerId; byte targetId = target.PlayerId; byte expectedTarget = GetTarget(killerId); if (expectedTarget == targetId) { Logger.Info($"{killer.GetRealName()} correctly killed their target {target.GetRealName()}", "DoomTag"); byte nextTarget = GetTarget(targetId); TargetArrow.RemoveAllTarget(targetId); RemoveTargetArrowsTo(targetId); if (nextTarget != byte.MaxValue && nextTarget != killerId) { if (TargetMap.ContainsValue(nextTarget)) TargetMap.Remove(TargetMap.GetKeyByValue(nextTarget)); TargetMap[killerId] = nextTarget; if (ShowTargetArrow.GetBool()) TargetArrow.Add(killerId, nextTarget); Logger.Info($"{killer.GetRealName()}'s new target is {Main.AllPlayerNames.GetValueOrDefault(nextTarget, string.Empty)}", "DoomTag"); } else { TargetMap.Remove(killerId); TargetArrow.RemoveAllTarget(killerId); } TargetMap.Remove(targetId); CheckFrenzyMode(); Utils.NotifyRoles(SpecifySeer: killer, SpecifyTarget: killer); SyncTargetMapRPC(); Utils.SendRPC(CustomRPC.DoomTagSync, 1, FrenzyMode); killer.Kill(target); } else { Logger.Info($"{killer.GetRealName()} killed wrong target {target.GetRealName()} (expected: {Main.AllPlayerNames.GetValueOrDefault(expectedTarget, string.Empty)})", "DoomTag"); if (PunishmentMode.GetInt() == 0) killer.Kill(target); ApplyPunishment(killer); } } private static void RemoveTargetArrowsTo(byte targetId) { foreach (var kvp in TargetMap) { if (kvp.Value == targetId) TargetArrow.RemoveAllTarget(kvp.Key); } } private static void ApplyPunishment(PlayerControl killer) { switch (PunishmentMode.GetInt()) { case 0: Logger.Info($"{killer.GetRealName()} is committing suicide for wrong kill", "DoomTag"); killer.Notify(GetString("DoomTag.WrongKillSuicide")); LateTask.New(() => { if (!killer || !killer.IsAlive() || GameStates.IsEnded) return; killer.Suicide(); HandleDeadPlayer(killer.PlayerId); }, 0.5f, "DoomTag Suicide Punishment"); break; case 1: float visionMultiplier = LowerVisionMultiplier.GetFloat(); LowerVisionList.Add(killer.PlayerId); killer.MarkDirtySettings(); killer.Notify(string.Format(GetString("DoomTag.WrongKillLowerVision"), visionMultiplier)); break; case 2: Main.AllPlayerSpeed[killer.PlayerId] = Main.MinSpeed; killer.MarkDirtySettings(); float time = FreezeDuration.GetFloat(); killer.Notify(GetString("DoomTag.WrongKillFreeze"), time - 1f); LateTask.New(() => { if (!killer || !killer.IsAlive() || GameStates.IsEnded) return; Main.AllPlayerSpeed[killer.PlayerId] = DefaultSpeed; killer.MarkDirtySettings(); }, time); break; } } private static void HandleDeadPlayer(byte deadPlayerId) { if (!AmongUsClient.Instance.AmHost) return; byte hunterId = TargetMap.GetKeyByValue(deadPlayerId, byte.MaxValue); byte nextTarget = GetTarget(deadPlayerId); TargetMap.Remove(deadPlayerId); TargetArrow.RemoveAllTarget(deadPlayerId); LowerVisionList.Remove(deadPlayerId); if (hunterId != byte.MaxValue) { TargetArrow.RemoveAllTarget(hunterId); if (nextTarget != byte.MaxValue && nextTarget != hunterId && Main.PlayerStates.TryGetValue(nextTarget, out var state) && !state.IsDead) { if (TargetMap.ContainsValue(nextTarget)) TargetMap.Remove(TargetMap.GetKeyByValue(nextTarget)); TargetMap[hunterId] = nextTarget; if (ShowTargetArrow.GetBool()) TargetArrow.Add(hunterId, nextTarget); LateTask.New(() => { PlayerControl hunter = Utils.GetPlayerById(hunterId); if (hunter) Utils.NotifyRoles(SpecifySeer: hunter, SpecifyTarget: hunter); }, 0.3f); } else { TargetMap.Remove(hunterId); } } CheckFrenzyMode(); SyncTargetMapRPC(); } private static void CheckFrenzyMode() { int aliveCount = Main.AllAlivePlayerControlsCount; int threshold = FrenzyPlayerCount.GetInt(); if (!FrenzyMode && aliveCount <= threshold && aliveCount > 1) { FrenzyMode = true; Utils.SendRPC(CustomRPC.DoomTagSync, 1, true); foreach (PlayerControl pc in Main.CachedAlivePlayerControls()) { Main.AllPlayerSpeed[pc.PlayerId] = DefaultSpeed * FrenzySpeedMultiplier.GetFloat(); Main.AllPlayerKillCooldown[pc.PlayerId] = FrenzyKillCooldown.GetFloat(); if (!pc.AmOwner) pc.ReactorFlash(); pc.MarkDirtySettings(); pc.Notify(GetString("DoomTag.FrenzyModeActivated")); } SoundManager.Instance.PlaySound(HudManager.Instance.LobbyTimerExtensionUI.lobbyTimerPopUpSound, false); Utils.FlashColor(new(1f, 0.5f, 0f, 0.4f), 1.4f); Logger.Info($"Frenzy Mode activated! {aliveCount} players remaining.", "DoomTag"); } } public static bool CheckForGameEnd(out GameOverReason reason) { reason = GameOverReason.ImpostorsByKill; if (!Main.IntroDestroyed || GameStates.IsEnded) return false; var alivePlayers = Main.CachedAlivePlayerControls(); switch (alivePlayers.Count) { case 1: PlayerControl winner = alivePlayers[0]; CustomWinnerHolder.WinnerIds = [winner.PlayerId]; Logger.Info($"DoomTag Winner: {winner.GetRealName()}", "DoomTag"); return true; case 0: CustomWinnerHolder.ResetAndSetWinner(CustomWinner.None); Logger.Info("No players alive, force ending the game", "DoomTag"); return true; default: return false; } } private static readonly StringBuilder Suffix = new(); public static string GetSuffix(PlayerControl seer, PlayerControl target, bool hud = false) { if (seer.PlayerId != target.PlayerId || GameStates.IsMeeting || seer.IsModdedClient() && !hud) return string.Empty; byte targetId = GetTarget(seer.PlayerId); if (targetId == byte.MaxValue) return string.Empty; Suffix.Clear(); Suffix.Append("<#D9BAA5>"); Suffix.Append(hud ? GetString("DoomTag.CurrentTarget") : GetString("Target")); Suffix.Append(": "); Suffix.Append(targetId.ColoredPlayerName()); Suffix.Append(""); if (ShowTargetArrow.GetBool()) { Suffix.Append(' '); Suffix.Append(TargetArrow.GetArrows(seer, targetId)); } Suffix.Append(""); if (FrenzyMode) { Suffix.Append("\n<#ff6600>"); Suffix.Append(GetString("DoomTag.FrenzyModeHUD")); Suffix.Append(""); } return Suffix.ToString(); } private static void SyncTargetMapRPC() { if (!AmongUsClient.Instance.AmHost) return; var writer = Utils.CreateRPC(CustomRPC.DoomTagSync); writer.WritePacked(2); writer.Write(TargetMap.Count); foreach (var kvp in TargetMap) { writer.Write(kvp.Key); writer.Write(kvp.Value); } Utils.EndRPC(writer); } public static void ReceiveRPC(MessageReader reader) { switch (reader.ReadPackedInt32()) { case 1: FrenzyMode = reader.ReadBoolean(); break; case 2: TargetMap = []; int count = reader.ReadInt32(); for (int i = 0; i < count; i++) TargetMap[reader.ReadByte()] = reader.ReadByte(); break; } } public static void ApplyGameOptions(IGameOptions opt, byte playerId) { AURoleOptions.PhantomCooldown = 0.1f; try { AURoleOptions.GuardianAngelCooldown = 900f; } catch (Exception e) { Utils.ThrowException(e); } if (LowerVisionList.Contains(playerId)) { float vision = LowerVisionMultiplier.GetFloat(); opt.SetVision(false); opt.SetFloat(FloatOptionNames.CrewLightMod, vision); opt.SetFloat(FloatOptionNames.ImpostorLightMod, vision); } else { opt.SetVision(true); opt.SetFloat(FloatOptionNames.CrewLightMod, 1.3f); opt.SetFloat(FloatOptionNames.ImpostorLightMod, 1.3f); } } public static class FixedUpdatePatch { private static long LastFixedUpdate; public static void Postfix() { if (!Main.IntroDestroyed || !GameStates.IsInTask || ExileController.Instance || Options.CurrentGameMode != CustomGameMode.DoomTag || !AmongUsClient.Instance.AmHost) return; long now = Utils.TimeStamp; if (IntroCutsceneDestroyPatch.IntroDestroyTS + 10 > now || LastFixedUpdate == now) return; LastFixedUpdate = now; List deadPlayersInMap = null; foreach (var kvp in TargetMap) { CheckDead(kvp.Key); CheckDead(kvp.Value); continue; void CheckDead(byte id) { if (!Main.PlayerStates.TryGetValue(id, out var state) || state.IsDead) { deadPlayersInMap ??= []; deadPlayersInMap.Add(id); } } } deadPlayersInMap?.ForEach(HandleDeadPlayer); CheckFrenzyMode(); } } }