diff --git a/.gitignore b/.gitignore index 9e0fc70..932e476 100644 --- a/.gitignore +++ b/.gitignore @@ -341,3 +341,4 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb +/LocalOverrides.targets diff --git a/HkmpAddon/AddonIdentifier.cs b/HkmpAddon/AddonIdentifier.cs index b1aed3e..cedf738 100644 --- a/HkmpAddon/AddonIdentifier.cs +++ b/HkmpAddon/AddonIdentifier.cs @@ -3,5 +3,5 @@ namespace TheHuntIsOn.HkmpAddon; public static class AddonIdentifier { public const string Name = "TheHuntIsOn"; - public const string Version = "1.2.0"; + public const string Version = "1.3.0"; } \ No newline at end of file diff --git a/HuntGlobalSaveData.cs b/HuntGlobalSaveData.cs index 2f8cfd6..a54f02a 100644 --- a/HuntGlobalSaveData.cs +++ b/HuntGlobalSaveData.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using TheHuntIsOn.Modules.PauseModule; +using TheHuntIsOn.Modules.PauseTimerModule; namespace TheHuntIsOn; @@ -16,5 +18,15 @@ public class HuntGlobalSaveData public int SpellCost { get; set; } = 33; + public bool DisableEnemies { get; set; } + + public bool InvincibleBosses { get; set; } + + public bool DreamBossAccess { get; set; } + + public PauseTimerPosition PauseTimerPosition { get; set; } = PauseTimerPosition.BottomCenter; + + public PauseTimerSize PauseTimerSize { get; set; } = PauseTimerSize.Normal; + #endregion } diff --git a/HuntLocalSaveData.cs b/HuntLocalSaveData.cs new file mode 100644 index 0000000..76405f2 --- /dev/null +++ b/HuntLocalSaveData.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TheHuntIsOn.Modules.PauseModule; + +namespace TheHuntIsOn; + +public class HuntLocalSaveData +{ + #region Properties + + // Whether the server is currently paused. + public bool ServerPaused; + + // If paused, when the server should be unpaused. + public long UnpauseTimeTicks; + + // Time to wait to respawn after a death. + public int RespawnTimerSeconds; + + // Globally broadcast countdowns. + public List GlobalCountdowns = []; + + #endregion + + #region Methods + + public void UpdateCountdowns(DateTime now, UpdateCountdownsPacket packet) + { + GlobalCountdowns.Clear(); + GlobalCountdowns.AddRange([.. packet.Countdowns.Where(c => !c.IsCompleted(now))]); + } + + public void UpdatePauseState(UpdatePauseStatePacket packet) + { + ServerPaused = packet.ServerPaused; + UnpauseTimeTicks = packet.UnpauseTimeTicks; + } + + public bool IsServerPaused(out float? remainingSeconds) + { + remainingSeconds = null; + if (!ServerPaused) return false; + if (UnpauseTimeTicks == long.MaxValue) return true; + + var now = DateTime.UtcNow.Ticks; + if (now >= UnpauseTimeTicks) return false; + + TimeSpan span = new(UnpauseTimeTicks - now); + remainingSeconds = (float)span.TotalSeconds; + return true; + } + + #endregion +} diff --git a/Module.cs b/Module.cs index 8f523a3..120e057 100644 --- a/Module.cs +++ b/Module.cs @@ -14,8 +14,8 @@ internal abstract class Module public ModuleAffection Affection { get; set; } - public bool IsModuleUsed => Affection == ModuleAffection.All || (TheHuntIsOn.SaveData.IsHunter && Affection == ModuleAffection.OnlyHunter) - || (!TheHuntIsOn.SaveData.IsHunter && Affection == ModuleAffection.OnlySpeedrunner); + public bool IsModuleUsed => Affection == ModuleAffection.All || (TheHuntIsOn.GlobalSaveData.IsHunter && Affection == ModuleAffection.OnlyHunter) + || (!TheHuntIsOn.GlobalSaveData.IsHunter && Affection == ModuleAffection.OnlySpeedrunner); public abstract string MenuDescription { get; } @@ -39,6 +39,8 @@ internal void Unload() _active = false; } + internal virtual void Initialize() { } + internal abstract void Enable(); internal abstract void Disable(); diff --git a/Modules/ArenaModule.cs b/Modules/ArenaModule.cs index 4628bbb..8d86966 100644 --- a/Modules/ArenaModule.cs +++ b/Modules/ArenaModule.cs @@ -1,5 +1,4 @@ using GlobalEnums; -using KorzUtils.Helper; using UnityEngine; using UnityEngine.SceneManagement; diff --git a/Modules/AutoTriggerBossModule.cs b/Modules/AutoTriggerBossModule.cs index 888f134..f6fcb3a 100644 --- a/Modules/AutoTriggerBossModule.cs +++ b/Modules/AutoTriggerBossModule.cs @@ -1,5 +1,4 @@ using KorzUtils.Helper; -using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/BaldurModule.cs b/Modules/BaldurModule.cs index 2dca9df..eed5f6c 100644 --- a/Modules/BaldurModule.cs +++ b/Modules/BaldurModule.cs @@ -1,14 +1,5 @@ -using HutongGames.PlayMaker; -using HutongGames.PlayMaker.Actions; -using KorzUtils.Helper; -using Modding; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using TheHuntIsOn.Modules.HealthModules; +using Modding; using UnityEngine; -using UnityEngine.SceneManagement; namespace TheHuntIsOn.Modules; diff --git a/Modules/CutsceneSkipModule.cs b/Modules/CutsceneSkipModule.cs index ab4e524..a97fae8 100644 --- a/Modules/CutsceneSkipModule.cs +++ b/Modules/CutsceneSkipModule.cs @@ -1,6 +1,5 @@ using HutongGames.PlayMaker.Actions; using KorzUtils.Helper; -using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/DreamEntranceModule.cs b/Modules/DreamEntranceModule.cs index c246651..d544968 100644 --- a/Modules/DreamEntranceModule.cs +++ b/Modules/DreamEntranceModule.cs @@ -1,6 +1,5 @@ using HutongGames.PlayMaker.Actions; using KorzUtils.Helper; -using TheHuntIsOn.Modules.HealthModules; using UnityEngine; using UnityEngine.SceneManagement; @@ -94,7 +93,7 @@ private void SceneManager_activeSceneChanged(Scene arg0, Scene newScene) // To prevent a softlock we spawn the portal anyway, even if the module is not used, if the player is in the room with dream nail already. if (newScene.name == "Dream_Nailcollection" && PlayerData.instance.GetBool(nameof(PlayerData.hasDreamNail))) { - GameObject teleporterSprite = GameObject.Instantiate(BossModule.DreamGate); + GameObject teleporterSprite = GameObject.Instantiate(EnemyModule.DreamGate); teleporterSprite.transform.position = new(272.88f, 51.3f); GameObject teleporter = GameObject.Instantiate(ElevatorModule.Door); teleporter.name = "Dream Nail Escape"; diff --git a/Modules/ElevatorModule.cs b/Modules/ElevatorModule.cs index 4652825..29df080 100644 --- a/Modules/ElevatorModule.cs +++ b/Modules/ElevatorModule.cs @@ -1,11 +1,5 @@ using KorzUtils.Helper; -using Mono.Security.Protocol.Tls; -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/EnemyModule.cs b/Modules/EnemyModule.cs index 454188d..1d29239 100644 --- a/Modules/EnemyModule.cs +++ b/Modules/EnemyModule.cs @@ -1,12 +1,7 @@ -using HutongGames.PlayMaker; -using HutongGames.PlayMaker.Actions; +using HutongGames.PlayMaker.Actions; using KorzUtils.Helper; using Modding; -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; -using TheHuntIsOn.Modules.HealthModules; using UnityEngine; using UnityEngine.SceneManagement; @@ -16,7 +11,7 @@ internal class EnemyModule : Module { #region Properties - public override string MenuDescription => "Disables all non-boss enemies. Makes bosses invincible."; + public override string MenuDescription => "Adjusts enemy spawns depending on toggles enabled below."; #endregion @@ -55,42 +50,60 @@ private bool ModHooks_OnEnableEnemyHook(GameObject enemy, bool isAlreadyDead) HealthManager healthManager = enemy.GetComponent(); - if (healthManager.hp > 200 || - enemy.name == "Mega Moss Charger" || - enemy.name == "Giant Fly" || - enemy.name == "False Knight New" || - enemy.name == "Mage Knight" || - enemy.name == "Mage Lord Phase2" || - enemy.name == "Head" || - enemy.name == "Mantis Lord S1" || - enemy.name == "Mantis Lord S2" || - enemy.name == "Ghost Warrior Xero") + if (TheHuntIsOn.GlobalSaveData.InvincibleBosses) { - healthManager.hp = 9999; - return false; + if (healthManager.hp > 200 || + enemy.name == "Mega Moss Charger" || + enemy.name == "Giant Fly" || + enemy.name == "False Knight New" || + enemy.name == "Mage Knight" || + enemy.name == "Mage Lord Phase2" || + enemy.name == "Head" || + enemy.name == "Mantis Lord S1" || + enemy.name == "Mantis Lord S2" || + enemy.name == "Ghost Warrior Xero") + { + healthManager.hp = 9999; + return false; + } + } + + if (TheHuntIsOn.GlobalSaveData.DisableEnemies) + { + if (healthManager.hp > 200 || + enemy.name == "Mega Moss Charger" || + enemy.name == "Giant Fly" || + enemy.name == "False Knight New" || + enemy.name == "Mage Knight" || + enemy.name == "Mage Lord Phase2" || + enemy.name == "Head" || + enemy.name == "Mantis Lord S1" || + enemy.name == "Mantis Lord S2" || + enemy.name == "Ghost Warrior Xero" || + (enemy.name.Contains("Fly") && enemy.scene.name == "Crossroads_04") || + enemy.scene.name == "Fungus3_23_boss" || + enemy.scene.name == "Ruins2_11_boss" || + enemy.name.StartsWith("Acid Walker") || + enemy.scene.name.StartsWith("Room_Colosseum") || + enemy.name == "Radiance") + return false; + else + return true; } - else if ((enemy.name.Contains("Fly") && enemy.scene.name == "Crossroads_04") || - enemy.scene.name == "Fungus3_23_boss" || - enemy.scene.name == "Ruins2_11_boss") - return false; - else if (enemy.name.StartsWith("Acid Walker") || - enemy.scene.name.StartsWith("Room_Colosseum") || - enemy.name == "Radiance") - return false; - - return true; + + return isAlreadyDead; } private void DeactivateIfPlayerdataTrue_OnEnable(On.DeactivateIfPlayerdataTrue.orig_OnEnable orig, DeactivateIfPlayerdataTrue self) { - if (IsModuleUsed && self.gameObject.name == "Dung Defender_Sleep") + if (IsModuleUsed && TheHuntIsOn.GlobalSaveData.DreamBossAccess && self.gameObject.name == "Dung Defender_Sleep") return; orig(self); } private void DeactivateIfPlayerdataFalse_OnEnable(On.DeactivateIfPlayerdataFalse.orig_OnEnable orig, DeactivateIfPlayerdataFalse self) { - if (IsModuleUsed && self.gameObject.name == "Dung Defender_Sleep") + if (IsModuleUsed && TheHuntIsOn.GlobalSaveData.DreamBossAccess && self.gameObject.name == "Dung Defender_Sleep") return; orig(self); } @@ -98,7 +111,7 @@ private void DeactivateIfPlayerdataFalse_OnEnable(On.DeactivateIfPlayerdataFalse private void PlayMakerFSM_OnEnable(On.PlayMakerFSM.orig_OnEnable orig, PlayMakerFSM self) { - if (IsModuleUsed) + if (IsModuleUsed && TheHuntIsOn.GlobalSaveData.DreamBossAccess) { if (self.gameObject.name == "Fk Break Wall" && self.FsmName == "Control") self.GetState("Pause").AdjustTransitions("Initial"); @@ -121,7 +134,7 @@ private void PlayMakerFSM_OnEnable(On.PlayMakerFSM.orig_OnEnable orig, PlayMaker private void PlayerDataBoolTest_OnEnter(On.HutongGames.PlayMaker.Actions.PlayerDataBoolTest.orig_OnEnter orig, HutongGames.PlayMaker.Actions.PlayerDataBoolTest self) { - if (IsModuleUsed && + if (IsModuleUsed && TheHuntIsOn.GlobalSaveData.DreamBossAccess && ((self.IsCorrectContext("Control", "IK Remains", "Check") && self.boolName.Value == "infectedKnightDreamDefeated") || (self.IsCorrectContext("Control", "Mage Lord Remains", "Check") && self.boolName.Value == "mageLordDreamDefeated") || (self.IsCorrectContext("Control", "FK Corpse", "Check") && self.boolName.Value == "falseKnightDreamDefeated") || @@ -135,7 +148,7 @@ private void PlayerDataBoolTest_OnEnter(On.HutongGames.PlayMaker.Actions.PlayerD private void SceneManager_activeSceneChanged(Scene arg0, Scene newScene) { - if (IsModuleUsed) + if (IsModuleUsed && TheHuntIsOn.GlobalSaveData.DreamBossAccess) { switch (newScene.name) { diff --git a/Modules/EventNetworkModule.cs b/Modules/EventNetworkModule.cs index d341be6..954ff13 100644 --- a/Modules/EventNetworkModule.cs +++ b/Modules/EventNetworkModule.cs @@ -1,12 +1,9 @@ using System; -using System.Diagnostics.Eventing.Reader; using System.Linq; using Hkmp.Api.Client; -using Hkmp.Api.Command.Client; using Hkmp.Api.Server; using KorzUtils.Helper; using Modding; -using MonoMod.Cil; using Satchel; using TheHuntIsOn.HkmpAddon; @@ -37,7 +34,7 @@ internal class EventNetworkModule : Module /// This way if module affection is set to none and the game is restarted, players can connect to server that /// do not have the TheHuntIsOn server addon. /// - public void Initialize() + internal override void Initialize() { if (Affection == ModuleAffection.None) { @@ -60,7 +57,7 @@ public void Initialize() private int ModHooks_OnSetPlayerIntHook(string name, int orig) { // Make sure that the player causing changes is the speedrunner - if (!IsModuleUsed || TheHuntIsOn.SaveData.IsHunter) + if (!IsModuleUsed || TheHuntIsOn.GlobalSaveData.IsHunter) { return orig; } @@ -192,7 +189,7 @@ or nameof(PlayerData.killsGhostMarkoth)) private bool ModHooks_OnSetPlayerBoolHook(string name, bool orig) { // Make sure that the player causing changes is the speedrunner - if (!IsModuleUsed || TheHuntIsOn.SaveData.IsHunter) + if (!IsModuleUsed || TheHuntIsOn.GlobalSaveData.IsHunter) { return orig; } @@ -322,7 +319,7 @@ private void PlayMakerFSM_OnEnable(On.PlayMakerFSM.orig_OnEnable orig, PlayMaker orig(self); // Make sure that the player causing changes is the speedrunner - if (!IsModuleUsed || TheHuntIsOn.SaveData.IsHunter) return; + if (!IsModuleUsed || TheHuntIsOn.GlobalSaveData.IsHunter) return; if (self.name.Equals("Stag") && self.Fsm.Name.Equals("Stag Control")) { @@ -492,7 +489,7 @@ private void PlayMakerFSM_OnEnable(On.PlayMakerFSM.orig_OnEnable orig, PlayMaker void UsedStag(NetEvent stagEvent) { - if (TheHuntIsOn.SaveData.IsHunter) return; + if (TheHuntIsOn.GlobalSaveData.IsHunter) return; if (!_clientApi.ClientManager.Players.Any(p => (p.Team != _clientApi.ClientManager.Team) && p.IsInLocalScene)) return; SendEvent(stagEvent); @@ -501,7 +498,7 @@ void UsedStag(NetEvent stagEvent) private void NetManager_OnGrantItemsEvent(NetItem[] netItems) { // Check if the player is not the speedrunner - if (!IsModuleUsed || !TheHuntIsOn.SaveData.IsHunter) + if (!IsModuleUsed || !TheHuntIsOn.GlobalSaveData.IsHunter) return; LogHelper.Write("OnGrantItems:"); diff --git a/Modules/HealthModules/BenchModule.cs b/Modules/HealthModules/BenchModule.cs index 3daf11c..4cbe105 100644 --- a/Modules/HealthModules/BenchModule.cs +++ b/Modules/HealthModules/BenchModule.cs @@ -1,10 +1,5 @@ using HutongGames.PlayMaker.Actions; using KorzUtils.Helper; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace TheHuntIsOn.Modules.HealthModules; diff --git a/Modules/HealthModules/MaskModule.cs b/Modules/HealthModules/MaskModule.cs index 58fe571..4b59242 100644 --- a/Modules/HealthModules/MaskModule.cs +++ b/Modules/HealthModules/MaskModule.cs @@ -1,12 +1,6 @@ using KorzUtils.Helper; using MonoMod.Cil; using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using UnityEngine; namespace TheHuntIsOn.Modules.HealthModules; diff --git a/Modules/HealthModules/NotchModule.cs b/Modules/HealthModules/NotchModule.cs index d499523..dc46b31 100644 --- a/Modules/HealthModules/NotchModule.cs +++ b/Modules/HealthModules/NotchModule.cs @@ -1,10 +1,4 @@ -using KorzUtils.Data; -using KorzUtils.Helper; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using KorzUtils.Helper; namespace TheHuntIsOn.Modules.HealthModules; diff --git a/Modules/HelperPlatformModule.cs b/Modules/HelperPlatformModule.cs index d3ed051..6c87cda 100644 --- a/Modules/HelperPlatformModule.cs +++ b/Modules/HelperPlatformModule.cs @@ -1,6 +1,4 @@ -using IL.InControl.NativeDeviceProfiles; -using System.Collections.Generic; -using UnityEngine; +using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/IntangibleGatesModule.cs b/Modules/IntangibleGatesModule.cs index 26412d2..73c3528 100644 --- a/Modules/IntangibleGatesModule.cs +++ b/Modules/IntangibleGatesModule.cs @@ -1,6 +1,5 @@ using KorzUtils.Helper; using TheHuntIsOn.Components; -using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/InvisibleGatesModule.cs b/Modules/InvisibleGatesModule.cs index 55a0fd0..2a746fd 100644 --- a/Modules/InvisibleGatesModule.cs +++ b/Modules/InvisibleGatesModule.cs @@ -1,14 +1,4 @@ -using HutongGames.PlayMaker.Actions; -using KorzUtils; -using KorzUtils.Data; -using KorzUtils.Helper; -using Modding; -using MonoMod.Cil; -using MonoMod.RuntimeDetour; -using MonoMod.Utils; -using System; -using System.Reflection; -using UnityEngine; +using UnityEngine; using UnityEngine.SceneManagement; namespace TheHuntIsOn.Modules; diff --git a/Modules/PauseTimerModule/AddonIdentifier.cs b/Modules/PauseTimerModule/AddonIdentifier.cs new file mode 100644 index 0000000..88b564c --- /dev/null +++ b/Modules/PauseTimerModule/AddonIdentifier.cs @@ -0,0 +1,7 @@ +namespace TheHuntIsOn.Modules.PauseModule; + +public static class AddonIdentifier +{ + public const string Name = "TheHuntIsOn-PauseTimer"; + public const string Version = "1.0.0"; +} \ No newline at end of file diff --git a/Modules/PauseTimerModule/ClientNetManager.cs b/Modules/PauseTimerModule/ClientNetManager.cs new file mode 100644 index 0000000..de041fb --- /dev/null +++ b/Modules/PauseTimerModule/ClientNetManager.cs @@ -0,0 +1,32 @@ +using System; +using Hkmp.Api.Client; +using Hkmp.Api.Client.Networking; +using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Data; + +namespace TheHuntIsOn.Modules.PauseModule; + +public class ClientNetManager +{ + public event Action SetRespawnTimerEvent; + public event Action UpdateCountdownsEvent; + public event Action UpdatePauseStateEvent; + + public ClientNetManager(ClientAddon addon, INetClient netClient) + { + var netReceiver = netClient.GetNetworkReceiver(addon, InstantiatePacket); + + netReceiver.RegisterPacketHandler(ClientPacketId.SetRespawnTimer, packet => SetRespawnTimerEvent?.Invoke(packet)); + netReceiver.RegisterPacketHandler(ClientPacketId.UpdateCountdowns, packet => UpdateCountdownsEvent?.Invoke(packet)); + netReceiver.RegisterPacketHandler(ClientPacketId.UpdatePauseState, packet => UpdatePauseStateEvent?.Invoke(packet)); + } + + private static IPacketData InstantiatePacket(ClientPacketId packetId) + => packetId switch + { + ClientPacketId.SetRespawnTimer => new PacketDataCollection(), + ClientPacketId.UpdateCountdowns => new PacketDataCollection(), + ClientPacketId.UpdatePauseState => new PacketDataCollection(), + _ => null, + }; +} \ No newline at end of file diff --git a/Modules/PauseTimerModule/ClientPacketId.cs b/Modules/PauseTimerModule/ClientPacketId.cs new file mode 100644 index 0000000..46d9737 --- /dev/null +++ b/Modules/PauseTimerModule/ClientPacketId.cs @@ -0,0 +1,8 @@ +namespace TheHuntIsOn.Modules.PauseModule; + +internal enum ClientPacketId +{ + UpdatePauseState, + UpdateCountdowns, + SetRespawnTimer +} diff --git a/Modules/PauseTimerModule/Countdown.cs b/Modules/PauseTimerModule/Countdown.cs new file mode 100644 index 0000000..b2b1cef --- /dev/null +++ b/Modules/PauseTimerModule/Countdown.cs @@ -0,0 +1,87 @@ +using Hkmp.Networking.Packet; +using System; +using TheHuntIsOn.Modules.PauseTimerModule; + +namespace TheHuntIsOn.Modules.PauseModule; + +// A representation of a pausable Countdown timer. +// +// A timer can be in three different states, depending on values: +// - Actively ticking down to a deadline. +// - Indefinitely frozen. +// - Temporarily frozen, with a known un-freeze time. +public record Countdown +{ + // Scheduled time for the countdown to expire. + public long FinishTimeTicks; + // If set, always show this amount as the remaining time. + public long? FrozenRemainder; + // If set, override FrozenRemainder and start counting down again after this time. + public long? UnfreezeTimeTicks; + // Message to accompany the countdown. + public string Message = ""; + + public bool IsFrozen(DateTime now) => FrozenRemainder.HasValue && (!UnfreezeTimeTicks.HasValue || UnfreezeTimeTicks.Value > now.Ticks); + + public bool IsCompleted(DateTime now) => !IsFrozen(now) && now.Ticks >= FinishTimeTicks; + + public bool GetDisplayTime(out float seconds) + { + var now = DateTime.UtcNow; + if (IsFrozen(now) || !IsCompleted(now)) + { + TimeSpan span = new(IsFrozen(now) ? FrozenRemainder.Value : (FinishTimeTicks - now.Ticks)); + seconds = (float)span.TotalSeconds; + return true; + } + + seconds = 0; + return false; + } + + public Countdown Pause(DateTime now) + { + if (IsCompleted(now)) return this; + if (IsFrozen(now)) return this with { UnfreezeTimeTicks = null }; + + return this with + { + FrozenRemainder = FinishTimeTicks - now.Ticks, + UnfreezeTimeTicks = null + }; + } + + public Countdown UnpauseAt(DateTime now, DateTime unpauseWhen) + { + if (IsCompleted(now)) return this; + if (IsFrozen(now)) return this with + { + FinishTimeTicks = unpauseWhen.Ticks + FrozenRemainder.Value, + UnfreezeTimeTicks = unpauseWhen.Ticks + }; + + var remainder = FinishTimeTicks - now.Ticks; + return this with + { + FinishTimeTicks = unpauseWhen.Ticks + remainder, + FrozenRemainder = remainder, + UnfreezeTimeTicks = unpauseWhen.Ticks + }; + } + + public void WriteData(IPacket packet) + { + packet.Write(FinishTimeTicks); + packet.WriteOptional(FrozenRemainder); + packet.WriteOptional(UnfreezeTimeTicks); + packet.Write(Message); + } + + public void ReadData(IPacket packet) + { + FinishTimeTicks = packet.ReadLong(); + FrozenRemainder = packet.ReadOptionalLong(); + UnfreezeTimeTicks = packet.ReadOptionalLong(); + Message = packet.ReadString(); + } +} diff --git a/Modules/PauseTimerModule/CountdownsDisplayer.cs b/Modules/PauseTimerModule/CountdownsDisplayer.cs new file mode 100644 index 0000000..a81a663 --- /dev/null +++ b/Modules/PauseTimerModule/CountdownsDisplayer.cs @@ -0,0 +1,192 @@ +using GlobalEnums; +using Hkmp.Api.Client; +using Modding; +using Modding.Utils; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using TheHuntIsOn.Modules.PauseTimerModule; +using TMPro; +using UnityEngine; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class CountdownsDisplayer +{ + private GameObject parent; + private List textMeshProCache = []; + + private IClientApi clientApi; + private float respawnTimer; + + private bool EnsureParent() + { + if (parent != null) return true; + + var camera = GameObject.Find("_GameCameras/HudCamera"); + if (camera == null) return false; + + parent = new("CountdownsDisplayer"); + Object.DontDestroyOnLoad(parent); + parent.transform.SetParent(camera.transform); + parent.transform.position = Vector3.zero; + return true; + } + + internal void SetClientApi(IClientApi clientApi) => this.clientApi = clientApi; + + internal void Enable() + { + On.GameManager.Update += OnGMUpdate; + On.GameManager.BeginSceneTransitionRoutine += OnBeginSceneTransitionRoutine; + ModHooks.BeforePlayerDeadHook += BeforeHeroDeath; + } + + internal void Disable() + { + if (parent != null) + { + Object.Destroy(parent); + parent = null; + textMeshProCache.Clear(); + } + + On.GameManager.Update -= OnGMUpdate; + On.GameManager.BeginSceneTransitionRoutine -= OnBeginSceneTransitionRoutine; + ModHooks.BeforePlayerDeadHook -= BeforeHeroDeath; + } + + private static string FormatTime(float timeSeconds) + { + if (timeSeconds <= 0) return "0.00"; + if (timeSeconds >= 3600) + { + int hours = Mathf.FloorToInt(timeSeconds / 3600); + int minutes = Mathf.FloorToInt((timeSeconds % 3600) / 60); + if (minutes >= 60) minutes = 59; + + string status = $"{hours} {(hours > 1 ? "hours" : "hour")}"; + if (minutes > 0) status = $"{status} and {minutes} {(minutes > 1 ? "minutes" : "minute")}"; + return status; + } + if (timeSeconds >= 60) + { + int minutes = Mathf.FloorToInt(timeSeconds / 60); + int seconds = Mathf.FloorToInt(timeSeconds % 60); + if (seconds >= 60) seconds = 59; + return $"{minutes}:{seconds:00}"; + } + if (timeSeconds >= 10) return $"{timeSeconds:00.0}"; + return $"{timeSeconds:0.00}"; + } + + private List ComputeStatuses() + { + List statuses = []; + + var saveData = TheHuntIsOn.LocalSaveData; + if (saveData.IsServerPaused(out var unpauseSeconds)) + { + if (!unpauseSeconds.HasValue) statuses.Add("Server Paused"); + else statuses.Add($"Unpausing in: {FormatTime(unpauseSeconds.Value)}"); + } + if (respawnTimer > 0) statuses.Add($"Respawn in: {FormatTime(respawnTimer)}"); + + foreach (var countdown in saveData.GlobalCountdowns) + { + if (countdown.GetDisplayTime(out float seconds)) statuses.Add($"{countdown.Message}: {FormatTime(seconds)}"); + } + + return statuses; + } + + private static TMP_FontAsset font; + private static TMP_FontAsset LoadFontAsset() => Resources.FindObjectsOfTypeAll().FirstOrDefault(font => font.name == "trajan_bold_tmpro"); + + private void CreateTextMesh() + { + GameObject obj = new("Display"); + obj.transform.SetParent(parent.transform); + + var mesh = obj.AddComponent(); + mesh.font = (font ??= LoadFontAsset()); + mesh.color = Color.white; + mesh.enableWordWrapping = false; + mesh.autoSizeTextContainer = true; + + obj.layer = (int)PhysLayers.UI; + var renderer = obj.GetOrAddComponent(); + renderer.sortingLayerName = "HUD"; + renderer.sortingOrder = 11; + + obj.SetActive(false); + textMeshProCache.Add(mesh); + } + + private void UpdateStatuses(List statuses) + { + var saveData = TheHuntIsOn.GlobalSaveData; + var spacingParameters = saveData.PauseTimerPosition.SpacingParameters(); + + for (int i = 0; i < textMeshProCache.Count; i++) + { + var mesh = textMeshProCache[i]; + var status = i < statuses.Count ? statuses[i] : ""; + if (status == "") + { + mesh.gameObject.SetActive(false); + mesh.text = ""; + mesh.transform.localPosition = new(-100, -100); + continue; + } + + mesh.gameObject.SetActive(true); + mesh.text = status; + mesh.fontSize = 24; + + var scale = saveData.PauseTimerSize.FontScale(); + mesh.transform.localScale = new(scale, scale, 1); + + mesh.ForceMeshUpdate(); + var bounds = mesh.textBounds; + mesh.transform.localPosition = spacingParameters.GetPosition(i, statuses.Count, saveData.PauseTimerSize.Spacing(), scale, bounds); + } + } + + private bool IsConnected() => clientApi != null && clientApi.NetClient.IsConnected; + + private void OnGMUpdate(On.GameManager.orig_Update orig, GameManager self) + { + orig(self); + + if (respawnTimer > 0 && IsConnected() && !TheHuntIsOn.LocalSaveData.IsServerPaused(out _)) + { + respawnTimer -= Time.unscaledDeltaTime; + if (respawnTimer < 0) respawnTimer = 0; + } + + if (!EnsureParent()) return; + + List statuses = []; + if (IsConnected()) statuses = ComputeStatuses(); + while (textMeshProCache.Count < statuses.Count) CreateTextMesh(); + UpdateStatuses(statuses); + } + + private IEnumerator OnBeginSceneTransitionRoutine(On.GameManager.orig_BeginSceneTransitionRoutine orig, GameManager self, GameManager.SceneLoadInfo sceneLoadInfo) + { + var src = orig(self, sceneLoadInfo); + if (respawnTimer > 0 && IsConnected()) + { + IEnumerator Modified() + { + yield return new WaitUntil(() => respawnTimer <= 0 || !IsConnected()); + while (src.MoveNext()) yield return src.Current; + } + return Modified(); + } + else return src; + } + + private void BeforeHeroDeath() => respawnTimer = Mathf.Max(respawnTimer, TheHuntIsOn.LocalSaveData.RespawnTimerSeconds); +} diff --git a/Modules/PauseTimerModule/IPacketExtensions.cs b/Modules/PauseTimerModule/IPacketExtensions.cs new file mode 100644 index 0000000..c237c2a --- /dev/null +++ b/Modules/PauseTimerModule/IPacketExtensions.cs @@ -0,0 +1,14 @@ +using Hkmp.Networking.Packet; + +namespace TheHuntIsOn.Modules.PauseTimerModule; + +internal static class IPacketExtensions +{ + internal static long? ReadOptionalLong(this IPacket self) => self.ReadBool() ? self.ReadLong() : null; + + internal static void WriteOptional(this IPacket self, long? value) + { + self.Write(value.HasValue); + if (value.HasValue) self.Write(value.Value); + } +} diff --git a/Modules/PauseTimerModule/PauseController.cs b/Modules/PauseTimerModule/PauseController.cs new file mode 100644 index 0000000..ef53186 --- /dev/null +++ b/Modules/PauseTimerModule/PauseController.cs @@ -0,0 +1,110 @@ +using Hkmp.Api.Client; +using System.Collections; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class PauseController +{ + private float baseGameTimescale = 1; + private float hkmpTimescale = 1; + + private bool enabled; + private IClientApi clientApi; + + private void HookClientApi() + { + var mgr = clientApi.ClientManager; + mgr.ConnectEvent += FixTimeScale; + mgr.DisconnectEvent += FixTimeScale; + mgr.PauseManager.SetTimeScaleEvent += OnHKMPSetTimeScale; + } + + private void UnhookClientApi() + { + var mgr = clientApi.ClientManager; + mgr.ConnectEvent -= FixTimeScale; + mgr.DisconnectEvent -= FixTimeScale; + mgr.PauseManager.SetTimeScaleEvent -= OnHKMPSetTimeScale; + } + + internal void Enable() + { + enabled = true; + baseGameTimescale = hkmpTimescale = TimeController.GenericTimeScale; + + On.GameManager.SetTimeScale_float += OnGMSetTimeScaleF; + On.GameManager.SetTimeScale_float_float += OnGMSetTimeScaleFF; + On.GameManager.Update += OnGMUpdate; + if (clientApi != null) HookClientApi(); + } + + internal void SetClientApi(IClientApi clientApi) + { + if (this.clientApi != null) throw new System.ArgumentException("Cannot set clientApi twice"); + + this.clientApi = clientApi; + if (enabled) HookClientApi(); + } + + internal void Disable() + { + enabled = false; + + On.GameManager.SetTimeScale_float -= OnGMSetTimeScaleF; + On.GameManager.SetTimeScale_float_float -= OnGMSetTimeScaleFF; + On.GameManager.Update -= OnGMUpdate; + + if (clientApi != null) UnhookClientApi(); + clientApi = null; + } + + private void OnGMSetTimeScaleF(On.GameManager.orig_SetTimeScale_float orig, GameManager self, float timeScale) + { + TimeController.GenericTimeScale = baseGameTimescale; + orig(self, timeScale); + baseGameTimescale = timeScale; + hkmpTimescale = timeScale; + FixTimeScale(); + } + + private IEnumerator OnGMSetTimeScaleFF(On.GameManager.orig_SetTimeScale_float_float orig, GameManager self, float timeScale, float duration) + { + var original = orig(self, timeScale, duration); + + IEnumerator Altered() + { + while (true) + { + TimeController.GenericTimeScale = baseGameTimescale; + if (original.MoveNext()) + { + baseGameTimescale = TimeController.GenericTimeScale; + hkmpTimescale = TimeController.GenericTimeScale; + FixTimeScale(); + + yield return original.Current; + } + else break; + } + } + return Altered(); + } + + private void OnGMUpdate(On.GameManager.orig_Update orig, GameManager self) + { + FixTimeScale(); + orig(self); + } + + private void OnHKMPSetTimeScale(float timeScale) + { + hkmpTimescale = timeScale; + FixTimeScale(); + } + + internal bool IsServerPaused() => enabled && clientApi != null && clientApi.NetClient.IsConnected && TheHuntIsOn.LocalSaveData.IsServerPaused(out _); + + internal static bool IsGameplayPausable() => GameManager.instance.gameState == GlobalEnums.GameState.PAUSED || (GameManager.instance.gameState == GlobalEnums.GameState.PLAYING && HeroController.instance.acceptingInput); + + private void FixTimeScale() => TimeController.GenericTimeScale = (IsServerPaused() && IsGameplayPausable()) ? 0f : hkmpTimescale; +} diff --git a/Modules/PauseTimerModule/PauseTimerClientAddon.cs b/Modules/PauseTimerModule/PauseTimerClientAddon.cs new file mode 100644 index 0000000..5cb53ac --- /dev/null +++ b/Modules/PauseTimerModule/PauseTimerClientAddon.cs @@ -0,0 +1,23 @@ +using Hkmp.Api.Client; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class PauseTimerClientAddon : ClientAddon +{ + protected override string Name => AddonIdentifier.Name; + + protected override string Version => AddonIdentifier.Version; + + public override bool NeedsNetwork => true; + + private readonly PauseTimerModule pauseModule; + public ClientNetManager NetManager; + + internal PauseTimerClientAddon(PauseTimerModule pauseModule) => this.pauseModule = pauseModule; + + public override void Initialize(IClientApi clientApi) + { + NetManager = new ClientNetManager(this, clientApi.NetClient); + pauseModule.SetClientApi(clientApi); + } +} diff --git a/Modules/PauseTimerModule/PauseTimerCommand.cs b/Modules/PauseTimerModule/PauseTimerCommand.cs new file mode 100644 index 0000000..85630df --- /dev/null +++ b/Modules/PauseTimerModule/PauseTimerCommand.cs @@ -0,0 +1,306 @@ +using Hkmp.Api.Command.Server; +using Hkmp.Api.Server; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class PauseTimerCommand(IServerApi serverApi, ServerNetManager netManager) : IServerCommand +{ + public string Trigger => "/pausetimer"; + + public string[] Aliases => ["/pt"]; + + public bool AuthorizedOnly => true; + + internal HuntLocalSaveData ServerState = new(); + + internal ServerNetManager NetManager => netManager; + + internal void BroadcastMessage(string message) => serverApi.ServerManager.BroadcastMessage(message); + + private static readonly List subcommands = + [ + new PauseSubcommand(), + new UnpauseSubcommand(), + new CountdownSubcommand(), + new ClearCountdownsSubcommand(), + new SetRespawnTimerSubcommand() + ]; + + private static string AllSubcommands() => string.Join("|", [.. subcommands.Select(s => s.Name()).OrderBy(s => s)]); + + private static bool TryGetSubcommand(string name, out PauseTimerSubcommand subcommand) + { + foreach (var item in subcommands) + { + if (item.Name() == name || item.Aliases().Any(a => a == name)) + { + subcommand = item; + return true; + } + } + + subcommand = null; + return false; + } + + internal static bool MinArguments(ICommandSender commandSender, string[] arguments, int min) + { + if (arguments.Length >= min) return true; + + commandSender.SendMessage("Missing arguments."); + return false; + } + + internal static bool MaxArguments(ICommandSender commandSender, string[] arguments, int max) + { + if (arguments.Length <= max) return true; + + commandSender.SendMessage("Too many arguments."); + return false; + } + + internal static bool ParseInt(ICommandSender commandSender, string arg, out int value) + { + if (int.TryParse(arg, out value) && value >= 0) return true; + + commandSender.SendMessage($"Invalid integer '{arg}'"); + return false; + } + + private void UpdateCountdowns(DateTime now, Func map) + { + UpdateCountdownsPacket packet = new() { Countdowns = [.. ServerState.GlobalCountdowns.Select(map)] }; + ServerState.UpdateCountdowns(now, packet); + netManager.BroadcastPacket(packet); + } + + internal void PauseCountdowns(DateTime now) => UpdateCountdowns(now, c => c.Pause(now)); + + internal void UnpauseCountdowns(DateTime now, DateTime unpauseWhen) => UpdateCountdowns(now, c => c.UnpauseAt(now, unpauseWhen)); + + public void Execute(ICommandSender commandSender, string[] arguments) + { + if (arguments.Length <= 1) + { + commandSender.SendMessage($"Usage: '/pt <{AllSubcommands()}>'"); + commandSender.SendMessage("Use '/pt help ' for details"); + return; + } + + PauseTimerSubcommand subcommand; + string name = arguments[1].ToLower(); + if (name == "help") + { + if (arguments.Length == 2) commandSender.SendMessage($"Usage: '/pt help <{AllSubcommands()}>'"); + else if (TryGetSubcommand(arguments[2].ToLower(), out subcommand)) + { + commandSender.SendMessage($"Usage: {subcommand.Usage()}"); + + List aliases = [.. subcommand.Aliases()]; + if (aliases.Count > 0) + { + aliases.Sort(); + commandSender.SendMessage($"Aliases: {string.Join("|", aliases)}"); + } + } + else + { + commandSender.SendMessage($"Unrecognized command '{name}'."); + commandSender.SendMessage($"Usage: '/pt help <{AllSubcommands()}>'"); + } + return; + } + + if (!TryGetSubcommand(name, out subcommand)) + { + commandSender.SendMessage($"Unrecognized command '{name}'."); + commandSender.SendMessage($"Usage: '/pt <{AllSubcommands()}>'"); + return; + } + + if (!subcommand.Execute(this, commandSender, [.. arguments.Skip(2)])) commandSender.SendMessage($"Usage: {subcommand.Usage()}"); + } +} + +internal abstract class PauseTimerSubcommand +{ + public abstract string Name(); + + public virtual IEnumerable Aliases() => []; + + public abstract string Usage(); + + public abstract bool Execute(PauseTimerCommand parent, ICommandSender send, string[] arguments); +} + +internal class PauseSubcommand : PauseTimerSubcommand +{ + public override string Name() => "pause"; + + public override string Usage() => "'/pt pause [X]': Pause the game for all players. If X is specified, unpause after X seconds."; + + public override bool Execute(PauseTimerCommand parent, ICommandSender commandSender, string[] arguments) + { + if (!PauseTimerCommand.MaxArguments(commandSender, arguments, 1)) return false; + + UpdatePauseStatePacket packet = new() { ServerPaused = true, UnpauseTimeTicks = long.MaxValue }; + var now = DateTime.UtcNow; + int seconds = 0; + if (arguments.Length == 1) + { + if (!PauseTimerCommand.ParseInt(commandSender, arguments[0], out seconds)) return false; + + var unpauseAt = now.AddSeconds(seconds); + packet.UnpauseTimeTicks = unpauseAt.Ticks; + parent.PauseCountdowns(now); + parent.UnpauseCountdowns(now, unpauseAt); + } + else parent.PauseCountdowns(now); + + parent.ServerState.UpdatePauseState(packet); + parent.NetManager.BroadcastPacket(packet); + commandSender.SendMessage(seconds == 0 ? "Paused server." : $"Paused server for {seconds} seconds."); + parent.BroadcastMessage(seconds == 0 ? "Server paused." : $"Server paused for {seconds} seconds."); + return true; + } +} + +internal class UnpauseSubcommand : PauseTimerSubcommand +{ + public override string Name() => "unpause"; + + public override string Usage() => "'/pt unpause [X]': Unpause the game for all players. If X is specified, unpause after X seconds."; + + public override bool Execute(PauseTimerCommand parent, ICommandSender commandSender, string[] arguments) + { + if (!PauseTimerCommand.MaxArguments(commandSender, arguments, 1)) return false; + + var isPaused = parent.ServerState.ServerPaused; + if (!isPaused) + { + commandSender.SendMessage("Server is already unpaused."); + return true; + } + + var now = DateTime.UtcNow; + UpdatePauseStatePacket packet = new(); + int seconds = 0; + if (arguments.Length == 1) + { + if (!PauseTimerCommand.ParseInt(commandSender, arguments[0], out seconds)) return false; + + var unpauseAt = now.AddSeconds(seconds); + packet.ServerPaused = true; + packet.UnpauseTimeTicks = unpauseAt.Ticks; + parent.UnpauseCountdowns(now, unpauseAt); + } + else + { + packet.ServerPaused = false; + packet.UnpauseTimeTicks = 0; + parent.UnpauseCountdowns(now, now); + } + + parent.ServerState.UpdatePauseState(packet); + parent.NetManager.BroadcastPacket(packet); + commandSender.SendMessage(seconds == 0 ? "Unpaused server." : $"Scheduled unpause in {seconds} seconds."); + parent.BroadcastMessage(seconds == 0 ? "Server unpaused." : $"Server unpausing in {seconds} seconds."); + return true; + } +} + +internal class CountdownSubcommand : PauseTimerSubcommand +{ + public override string Name() => "countdown"; + + public override string Usage() => "'/pt countdown X [msg...]': Start a countdown for all players on the server lasting X seconds, with an optional message attached."; + + public override bool Execute(PauseTimerCommand parent, ICommandSender commandSender, string[] arguments) + { + if (!PauseTimerCommand.MinArguments(commandSender, arguments, 1)) return false; + + if (!PauseTimerCommand.ParseInt(commandSender, arguments[0], out var seconds)) return false; + + var now = DateTime.UtcNow; + Countdown countdown = new() { FinishTimeTicks = now.AddSeconds(seconds).Ticks }; + + // Respect any active pauses or timed unpauses. + if (parent.ServerState.IsServerPaused(out var remaining)) + { + if (remaining.HasValue) countdown = countdown.UnpauseAt(now, now.AddSeconds(remaining.Value)); + else countdown = countdown.Pause(now); + } + + if (arguments.Length > 1) + { + countdown.Message = string.Join(" ", arguments.Skip(1)); + if (countdown.Message.Length > UpdateCountdownsPacket.MaxMessageLength) + { + commandSender.SendMessage("Countdown message is too long."); + return false; + } + } + + UpdateCountdownsPacket packet = new() { Countdowns = [.. parent.ServerState.GlobalCountdowns.Concat([countdown])] }; + if (packet.Countdowns.Count > UpdateCountdownsPacket.MaxCountdowns) + { + commandSender.SendMessage("Too many countdowns. Try '/pt clearcountdowns'."); + return true; + } + + parent.ServerState.UpdateCountdowns(now, packet); + parent.NetManager.BroadcastPacket(packet); + commandSender.SendMessage($"Broadcasted new {seconds} second countdown."); + return true; + } +} + +internal class ClearCountdownsSubcommand : PauseTimerSubcommand +{ + public override string Name() => "clearcountdowns"; + + public override string Usage() => "'/pt clearcountdowns': clear all outstanding countdowns"; + + public override bool Execute(PauseTimerCommand parent, ICommandSender commandSender, string[] arguments) + { + if (!PauseTimerCommand.MaxArguments(commandSender, arguments, 0)) return false; + + UpdateCountdownsPacket packet = new(); + parent.ServerState.UpdateCountdowns(DateTime.UtcNow, packet); + parent.NetManager.BroadcastPacket(packet); + commandSender.SendMessage("Cleared all active countdowns."); + return true; + } +} + +internal class SetRespawnTimerSubcommand : PauseTimerSubcommand +{ + public override string Name() => "respawntimer"; + + public override IEnumerable Aliases() => ["deathtimer"]; + + public override string Usage() => "'/pt respawntimer [X]': get the current respawn delay on death, or else set it"; + + public override bool Execute(PauseTimerCommand parent, ICommandSender commandSender, string[] arguments) + { + if (!PauseTimerCommand.MaxArguments(commandSender, arguments, 1)) return false; + + if (arguments.Length == 0) + { + var time = parent.ServerState.RespawnTimerSeconds; + if (time == 0) commandSender.SendMessage("Respawn timer is not set."); + else commandSender.SendMessage($"Respawn timer is set to {time} seconds."); + return true; + } + + if (!PauseTimerCommand.ParseInt(commandSender, arguments[0], out int seconds)) return false; + + parent.ServerState.RespawnTimerSeconds = seconds; + parent.NetManager.BroadcastPacket(new SetRespawnTimerPacket() { DeathTimer = seconds }); + parent.BroadcastMessage($"Respawn timer updated to {seconds} seconds."); + return true; + } +} diff --git a/Modules/PauseTimerModule/PauseTimerModule.cs b/Modules/PauseTimerModule/PauseTimerModule.cs new file mode 100644 index 0000000..532c220 --- /dev/null +++ b/Modules/PauseTimerModule/PauseTimerModule.cs @@ -0,0 +1,88 @@ +using Hkmp.Api.Client; +using Hkmp.Api.Server; +using System; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class PauseTimerModule : Module +{ + #region Properties + + public override string MenuDescription => "Enable server-wide pauses, timed unpauses, and respawn timers."; + + private PauseTimerClientAddon PauseTimerClientAddon; + private PauseTimerServerAddon PauseTimerServerAddon; + + private bool AreAddonsLoaded; + + private readonly PauseController pauseController = new(); + private readonly CountdownsDisplayer countdownsDisplayer = new(); + + #endregion + + #region Constructors + + /// + /// Initialize method used to register HKMP client and server addons after the module affections has been set. + /// If the module should not affect any players, we do not register the addons. Otherwise, we do. + /// This way if module affection is set to none and the game is restarted, players can connect to server that + /// do not have the PauseTimer server addon. + /// + internal override void Initialize() + { + if (Affection == ModuleAffection.None) + { + AreAddonsLoaded = false; + return; + } + + PauseTimerClientAddon = new(this); + PauseTimerServerAddon = new(); + ClientAddon.RegisterAddon(PauseTimerClientAddon); + ServerAddon.RegisterAddon(PauseTimerServerAddon); + + AreAddonsLoaded = true; + } + + #endregion + + #region Methods + + internal override void Enable() + { + if (!AreAddonsLoaded || !IsModuleUsed) return; + + pauseController.Enable(); + countdownsDisplayer.Enable(); + + PauseTimerClientAddon.NetManager.SetRespawnTimerEvent += OnSetDeathTimer; + PauseTimerClientAddon.NetManager.UpdateCountdownsEvent += OnUpdateCountdowns; + PauseTimerClientAddon.NetManager.UpdatePauseStateEvent += OnUpdatePauseState; + } + + internal override void Disable() + { + if (!AreAddonsLoaded || !IsModuleUsed) return; + + pauseController.Disable(); + countdownsDisplayer.Disable(); + + PauseTimerClientAddon.NetManager.SetRespawnTimerEvent -= OnSetDeathTimer; + PauseTimerClientAddon.NetManager.UpdateCountdownsEvent -= OnUpdateCountdowns; + PauseTimerClientAddon.NetManager.UpdatePauseStateEvent -= OnUpdatePauseState; + } + + internal void SetClientApi(IClientApi clientApi) + { + pauseController.SetClientApi(clientApi); + countdownsDisplayer.SetClientApi(clientApi); + } + + private void OnSetDeathTimer(SetRespawnTimerPacket packet) => TheHuntIsOn.LocalSaveData.RespawnTimerSeconds = packet.DeathTimer; + + private void OnUpdateCountdowns(UpdateCountdownsPacket packet) => TheHuntIsOn.LocalSaveData.UpdateCountdowns(DateTime.UtcNow, packet); + + private void OnUpdatePauseState(UpdatePauseStatePacket packet) => TheHuntIsOn.LocalSaveData.UpdatePauseState(packet); + + #endregion +} \ No newline at end of file diff --git a/Modules/PauseTimerModule/PauseTimerPosition.cs b/Modules/PauseTimerModule/PauseTimerPosition.cs new file mode 100644 index 0000000..99c40a7 --- /dev/null +++ b/Modules/PauseTimerModule/PauseTimerPosition.cs @@ -0,0 +1,53 @@ +using UnityEngine; + +namespace TheHuntIsOn.Modules.PauseTimerModule; + +public enum PauseTimerPosition +{ + BottomLeft, + BottomCenter, + BottomRight, + CenterLeft, + CenterRight, + TopCenter, + TopRight, + BelowUI, +} + +public record PauseTimerSpacingParameters(PauseTimerPosition position, Vector2 anchorPos, Vector2 forwardSpace, Vector2 reverseSpace) +{ + public Vector2 GetPosition(int i, int count, float spacing, float scale, Bounds localBounds) + { + Vector2 pos = anchorPos + spacing * (forwardSpace * i + reverseSpace * (count - 1 - i)); + Vector2 pivot = new( + position.IsLeft() ? -localBounds.min.x : (position.IsRight() ? -localBounds.max.x : -localBounds.center.x), + position.IsBottom() ? -localBounds.min.y : (position.IsTop() ? -localBounds.max.y : -localBounds.center.y)); + return pos + pivot * scale; + } +} + +public static class PauseTimerPositionExtensions +{ + internal static bool IsLeft(this PauseTimerPosition self) => self == PauseTimerPosition.BelowUI || self == PauseTimerPosition.CenterLeft || self == PauseTimerPosition.BottomLeft; + + internal static bool IsRight(this PauseTimerPosition self) => self == PauseTimerPosition.BottomRight || self == PauseTimerPosition.CenterRight || self == PauseTimerPosition.TopRight; + + internal static bool IsTop(this PauseTimerPosition self) => self == PauseTimerPosition.BelowUI || self == PauseTimerPosition.TopCenter || self == PauseTimerPosition.TopRight; + + internal static bool IsBottom(this PauseTimerPosition self) => self == PauseTimerPosition.BottomLeft || self == PauseTimerPosition.BottomCenter || self == PauseTimerPosition.BottomRight; + + internal static bool IsVCenter(this PauseTimerPosition self) => self == PauseTimerPosition.CenterLeft || self == PauseTimerPosition.CenterRight; + + public static PauseTimerSpacingParameters SpacingParameters(this PauseTimerPosition self) + { + Vector2 anchorPos; + anchorPos.x = self.IsLeft() ? -14.5f : (self.IsRight() ? 14.5f : 0); + anchorPos.y = self.IsTop() ? 7.5f : (self.IsBottom() ? -8.25f : 0); + if (self == PauseTimerPosition.BelowUI) anchorPos.y = 4.5f; + if (self == PauseTimerPosition.BottomLeft) anchorPos.y = -4.5f; + + Vector2 forwardSpace = new(0, self.IsTop() ? -1 : (self.IsVCenter() ? -0.5f : 0)); + Vector2 reverseSpace = new(0, self.IsBottom() ? 1 : (self.IsVCenter() ? 0.5f : 0)); + return new(self, anchorPos, forwardSpace, reverseSpace); + } +} diff --git a/Modules/PauseTimerModule/PauseTimerServerAddon.cs b/Modules/PauseTimerModule/PauseTimerServerAddon.cs new file mode 100644 index 0000000..41c8118 --- /dev/null +++ b/Modules/PauseTimerModule/PauseTimerServerAddon.cs @@ -0,0 +1,14 @@ +using Hkmp.Api.Server; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class PauseTimerServerAddon : ServerAddon +{ + protected override string Name => AddonIdentifier.Name; + + protected override string Version => AddonIdentifier.Version; + + public override bool NeedsNetwork => true; + + public override void Initialize(IServerApi serverApi) => serverApi.CommandManager.RegisterCommand(new PauseTimerCommand(serverApi, new ServerNetManager(this, serverApi.NetServer))); +} diff --git a/Modules/PauseTimerModule/PauseTimerSize.cs b/Modules/PauseTimerModule/PauseTimerSize.cs new file mode 100644 index 0000000..aee1b79 --- /dev/null +++ b/Modules/PauseTimerModule/PauseTimerSize.cs @@ -0,0 +1,15 @@ +namespace TheHuntIsOn.Modules.PauseTimerModule; + +public enum PauseTimerSize +{ + Normal, + Small, + Large, +} + +public static class PauseTimerSizeExtensions +{ + public static float FontScale(this PauseTimerSize size) => size switch { PauseTimerSize.Normal => 0.3f, PauseTimerSize.Small => 0.2f, PauseTimerSize.Large => 0.4f }; + + public static float Spacing(this PauseTimerSize size) => size switch { PauseTimerSize.Normal => 0.95f, PauseTimerSize.Small => 0.6f, PauseTimerSize.Large => 1.3f }; +} diff --git a/Modules/PauseTimerModule/README.md b/Modules/PauseTimerModule/README.md new file mode 100644 index 0000000..d1de222 --- /dev/null +++ b/Modules/PauseTimerModule/README.md @@ -0,0 +1,41 @@ +# PauseTimer Module + +PauseTimer is a Module for TheHuntIsOn, which enables various time control features. + +Like the EventNetworkModule, it requires initialization to function. To use the PauseTimer module as server _or_ client, it needs to be enabled in mod options during mod setup, so you will need to restart your game after turning it on for the first time. + +## Pause Controls + +The pause timer module enables server-wide pauses, which can be lifted at will or with a synchronized delay. When the server is paused, all connected players will have their worlds frozen, unable to move or be interacted with. + +Unpauses, and countdowns, are synchronized through UTC system time. If any clients have desynchronized system clocks relative to the host, they may experience buggy behavior. + +## Respawn Timer + +Authorized users can configure a respawn timer of X seconds, which forces any player upon death to wait X seconds before respawning. The clock starts at the instant of player death, so countdowns of ~5 seconds or less have next to no material effect, due to the duration of the death animation. + +## /pausetimer command + +Server controls are managed through the `/pausetimer`, or `/pt` command. Only authorized users can use this command. + +`/pt help []` shows command instructions ingame. + +`/pt pause [X]` pauses the server for all players, either indefinitely, or for `X` seconds. + +`/pt unpause [X]` unpauses the server for all players, either immediately, or after `X` seconds. + +`/pt countdown X [msg...]` broadcasts an info-only countdown lasting `X` seconds. + +`/pt clearcountdowns` clears out all info-only countdowns. + +`/pt respawntimer|deathtimer X` sets a respawn timer on death of `X` seconds for all players. Set to 0 to clear. + +## Mod Options + +Users can control where and how largely countdowns are displayed through the "Timer Position" and "Timer Size" options on TheHuntIsOn page. These settings are localized and do not affect other players. + +## Disconnects + +PauseTimer lifts its restrictions in the event of a disconnect to facilitate offline play and is not currently suitable for truly competitive, client-hostile play. + +Players who disconnect during a pause should avoid significant movement before the server is unpaused. Players who disconnect while respawning will immediately respawn, but can still choose to honor the on-screen respawn timer and sit still until it finishes. diff --git a/Modules/PauseTimerModule/ServerNetManager.cs b/Modules/PauseTimerModule/ServerNetManager.cs new file mode 100644 index 0000000..b7d12e6 --- /dev/null +++ b/Modules/PauseTimerModule/ServerNetManager.cs @@ -0,0 +1,13 @@ +using Hkmp.Api.Server.Networking; +using Hkmp.Api.Server; + +namespace TheHuntIsOn.Modules.PauseModule; + +internal class ServerNetManager(ServerAddon addon, INetServer netServer) +{ + private readonly IServerAddonNetworkSender _netSender = netServer.GetNetworkSender(addon); + + public void BroadcastPacket(UpdatePauseStatePacket packet) => _netSender.BroadcastCollectionData(ClientPacketId.UpdatePauseState, packet); + public void BroadcastPacket(UpdateCountdownsPacket packet) => _netSender.BroadcastCollectionData(ClientPacketId.UpdateCountdowns, packet); + public void BroadcastPacket(SetRespawnTimerPacket packet) => _netSender.BroadcastCollectionData(ClientPacketId.SetRespawnTimer, packet); +} \ No newline at end of file diff --git a/Modules/PauseTimerModule/SetRespawnTimerPacket.cs b/Modules/PauseTimerModule/SetRespawnTimerPacket.cs new file mode 100644 index 0000000..66bc769 --- /dev/null +++ b/Modules/PauseTimerModule/SetRespawnTimerPacket.cs @@ -0,0 +1,24 @@ +using Hkmp.Networking.Packet; + +namespace TheHuntIsOn.Modules.PauseModule; + +public class SetRespawnTimerPacket : IPacketData +{ + #region Properties + + public int DeathTimer; + + public bool IsReliable => true; + + public bool DropReliableDataIfNewerExists => true; + + #endregion + + #region Methods + + public void WriteData(IPacket packet) => packet.Write(DeathTimer); + + public void ReadData(IPacket packet) => DeathTimer = packet.ReadInt(); + + #endregion +} diff --git a/Modules/PauseTimerModule/UpdateCountdownsPacket.cs b/Modules/PauseTimerModule/UpdateCountdownsPacket.cs new file mode 100644 index 0000000..b3d21ba --- /dev/null +++ b/Modules/PauseTimerModule/UpdateCountdownsPacket.cs @@ -0,0 +1,43 @@ +using Hkmp.Networking.Packet; +using System.Collections.Generic; + +namespace TheHuntIsOn.Modules.PauseModule; + +public class UpdateCountdownsPacket : IPacketData +{ + public const byte MaxMessageLength = byte.MaxValue; + + public const int MaxCountdowns = 10; + + #region Properties + + public List Countdowns = []; + + public bool IsReliable => true; + + public bool DropReliableDataIfNewerExists => true; + + #endregion + + #region Methods + + public void WriteData(IPacket packet) + { + packet.Write(Countdowns.Count); + Countdowns.ForEach(c => c.WriteData(packet)); + } + + public void ReadData(IPacket packet) + { + int size = packet.ReadInt(); + Countdowns.Clear(); + for (int i = 0; i < size; i++) + { + Countdown countdown = new(); + countdown.ReadData(packet); + Countdowns.Add(countdown); + } + } + + #endregion +} diff --git a/Modules/PauseTimerModule/UpdatePauseStatePacket.cs b/Modules/PauseTimerModule/UpdatePauseStatePacket.cs new file mode 100644 index 0000000..86eb325 --- /dev/null +++ b/Modules/PauseTimerModule/UpdatePauseStatePacket.cs @@ -0,0 +1,26 @@ +using Hkmp.Networking.Packet; + +namespace TheHuntIsOn.Modules.PauseModule; + +public class UpdatePauseStatePacket : IPacketData +{ + public bool ServerPaused; + + public long UnpauseTimeTicks; + + public bool IsReliable => true; + + public bool DropReliableDataIfNewerExists => true; + + public void ReadData(IPacket packet) + { + ServerPaused = packet.ReadBool(); + UnpauseTimeTicks = packet.ReadLong(); + } + + public void WriteData(IPacket packet) + { + packet.Write(ServerPaused); + packet.Write(UnpauseTimeTicks); + } +} diff --git a/Modules/RespawnModule.cs b/Modules/RespawnModule.cs index 1fba828..4640e24 100644 --- a/Modules/RespawnModule.cs +++ b/Modules/RespawnModule.cs @@ -1,5 +1,4 @@ -using KorzUtils; -using KorzUtils.Data; +using KorzUtils.Data; using KorzUtils.Helper; using Modding; using MonoMod.Cil; diff --git a/Modules/ShadeModule.cs b/Modules/ShadeModule.cs index 74cb234..6abb613 100644 --- a/Modules/ShadeModule.cs +++ b/Modules/ShadeModule.cs @@ -1,6 +1,4 @@ -using KorzUtils.Helper; -using Modding; -using UnityEngine; +using Modding; namespace TheHuntIsOn.Modules; diff --git a/Modules/ShadeSkipModule.cs b/Modules/ShadeSkipModule.cs index 3b02d3d..d7e87c9 100644 --- a/Modules/ShadeSkipModule.cs +++ b/Modules/ShadeSkipModule.cs @@ -1,8 +1,4 @@ -using KorzUtils.Data; -using KorzUtils.Helper; -using Modding; -using System; -using UnityEngine; +using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/StartingItemsModule.cs b/Modules/StartingItemsModule.cs index b140d4c..251397b 100644 --- a/Modules/StartingItemsModule.cs +++ b/Modules/StartingItemsModule.cs @@ -1,5 +1,4 @@ using Modding; -using UnityEngine; namespace TheHuntIsOn.Modules; diff --git a/Modules/TramModule.cs b/Modules/TramModule.cs index f1fdd03..c5ad1e9 100644 --- a/Modules/TramModule.cs +++ b/Modules/TramModule.cs @@ -1,5 +1,4 @@ -using KorzUtils; -using KorzUtils.Data; +using KorzUtils.Data; using KorzUtils.Helper; namespace TheHuntIsOn.Modules; diff --git a/README.md b/README.md index 5074f6c..9c3afd4 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,6 @@ Reduces Elder Baldur (at Greenpath entrance and Ancestral Mound only) HP to 5. ### Bench Module Benches no longer provide healing. *As the code for healing in the game is a bit sketchy, the hud is disabled and enabled quickly to sync it properly.* -### Boss Module -Increases boss HP to 9999. Adds teleporters so that players can enter and exit Dream Boss arenas. - ### Charm Nerf Module Increases the notch cost of powerful PvP charms by 1. This includes Baldur Shell, Spore Shroom, Heart, and Nailmaster's Glory. @@ -52,6 +49,9 @@ Exiting a dream sequence, like a boss fight or a dreamer does not longer provide ### Elevator Module Removes all small CoT elevators and places platform to help climbing them (if the cannot be climbed already by having claw for example). Also removes the lever in the big elevator and adds a door transition there to enter the other half more quickly. +### Enemy Module +Causes different changes to enemy spawning behavior based upon the Enemy Module toggles enabled. + ### EventNetworkModule The EventNetworkModule networks certain events, such as obtained items by the speedrunner and then grants the hunters items based on this and sends a message. @@ -124,11 +124,14 @@ Removes the full heal provided by completing a mask. The one extra hp by the mas ### Notch Module Picking up new charm notches will no longer fully heal you. +### PauseTimer Module +See the [README](Modules/PauseTimerModule/README.md). + ### Respawn Module Causes the respawn after dying to always locate to KP regardless of the last bench used. ### Shade Module -Removes the shade and all it's effect (removing geo and breaking the soul vessel). +Removes the shade and all its effects (removing geo and breaking the soul vessel). ### Shade Skip Module Allows navigation (creates platforms) through skip locations requiring the shade. diff --git a/TheHuntIsOn.cs b/TheHuntIsOn.cs index 8215c26..64bb936 100644 --- a/TheHuntIsOn.cs +++ b/TheHuntIsOn.cs @@ -1,16 +1,19 @@ using KorzUtils.Helper; using Modding; using Satchel.BetterMenus; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text; using TheHuntIsOn.Modules; using TheHuntIsOn.Modules.HealthModules; +using TheHuntIsOn.Modules.PauseModule; using UnityEngine; namespace TheHuntIsOn; -public class TheHuntIsOn : Mod, IGlobalSettings, ICustomMenuMod +public class TheHuntIsOn : Mod, IGlobalSettings, ILocalSettings, ICustomMenuMod { #region Constructors @@ -27,7 +30,9 @@ public TheHuntIsOn() public static TheHuntIsOn Instance { get; set; } - public static HuntGlobalSaveData SaveData { get; set; } = new(); + public static HuntGlobalSaveData GlobalSaveData { get; set; } = new(); + + public static HuntLocalSaveData LocalSaveData { get; set; } = new(); public bool ToggleButtonInsideMenu { get; } @@ -38,13 +43,13 @@ public TheHuntIsOn() new AutoTriggerBossModule(), new BaldurModule(), new BenchModule(), - new BossModule(), new CharmNerfModule(), new CompletionModule(), new CutsceneSkipModule(), new DisableSoulGainModule(), new DreamHealModule(), new ElevatorModule(), + new EnemyModule(), new EventNetworkModule(), new HelperPlatformModule(), new IntangibleGatesModule(), @@ -52,6 +57,7 @@ public TheHuntIsOn() new LifeseedModule(), new MaskModule(), new NotchModule(), + new PauseTimerModule(), new RespawnModule(), new ShadeModule(), new ShadeSkipModule(), @@ -79,24 +85,18 @@ public TheHuntIsOn() public override void Initialize(Dictionary> preloadedObjects) { - SetupHKMP(); + Modules.ForEach(m => m.Initialize()); + On.UIManager.StartNewGame += UIManager_StartNewGame; On.UIManager.ContinueGame += UIManager_ContinueGame; On.UIManager.ReturnToMainMenu += UIManager_ReturnToMainMenu; ShadeSkipModule.PlatformPrefab = preloadedObjects["Crossroads_04"]["_Scenery/plat_float_01"]; - BossModule.TeleporterPrefab = preloadedObjects["White_Palace_03_hub"]["doorWarp"]; + EnemyModule.TeleporterPrefab = preloadedObjects["White_Palace_03_hub"]["doorWarp"]; ElevatorModule.Door = preloadedObjects["Crossroads_01"]["_Transition Gates/door1"]; - BossModule.FKDreamEnter = preloadedObjects["Crossroads_10_boss_defeated"]["Prayer Room/FK Corpse/Dream Enter"]; - BossModule.STDreamEnter = preloadedObjects["Ruins1_24_boss_defeated"]["Mage Lord Remains/Dream Enter"]; - BossModule.HKDreamEnter = preloadedObjects["Room_Final_Boss_Core"]["Boss Control/Hollow Knight Boss/Dream Enter"]; - BossModule.DreamTree = preloadedObjects["Crossroads_07"]["Dream Plant"]; - } - - private void SetupHKMP() - { - // Some funky code to get the EventNetworkModule from the list of modules - // Alternatively, the EventNetworkModule could be stored in the class - ((EventNetworkModule)Modules.Find(module => module.GetType() == typeof(EventNetworkModule))).Initialize(); + EnemyModule.FKDreamEnter = preloadedObjects["Crossroads_10_boss_defeated"]["Prayer Room/FK Corpse/Dream Enter"]; + EnemyModule.STDreamEnter = preloadedObjects["Ruins1_24_boss_defeated"]["Mage Lord Remains/Dream Enter"]; + EnemyModule.HKDreamEnter = preloadedObjects["Room_Final_Boss_Core"]["Boss Control/Hollow Knight Boss/Dream Enter"]; + EnemyModule.DreamTree = preloadedObjects["Crossroads_07"]["Dream Plant"]; } #endregion @@ -129,14 +129,14 @@ private void UIManager_StartNewGame(On.UIManager.orig_StartNewGame orig, UIManag private void PlayerDataBoolTest_OnEnter(On.HutongGames.PlayMaker.Actions.PlayerDataBoolTest.orig_OnEnter orig, HutongGames.PlayMaker.Actions.PlayerDataBoolTest self) { if (self.IsCorrectContext("Spell Control", "Knight", "Slug?")) - if (SaveData.IsHunter) - self.Fsm.Variables.FindFsmFloat("Time Per MP Drain").Value *= SaveData.FocusSpeed; + if (GlobalSaveData.IsHunter) + self.Fsm.Variables.FindFsmFloat("Time Per MP Drain").Value *= GlobalSaveData.FocusSpeed; else self.Fsm.Variables.FindFsmFloat("Time Per MP Drain").Value *= 1; - else if (SaveData.IsHunter && + else if (GlobalSaveData.IsHunter && ((self.IsCorrectContext("Spell Control", "Knight", "Spore Cloud") || self.IsCorrectContext("Spell Control", "Knight", "Spore Cloud 2")))) - HeroController.instance.TakeMP(SaveData.FocusCost - 33); - else if (SaveData.IsHunter && + HeroController.instance.TakeMP(GlobalSaveData.FocusCost - 33); + else if (GlobalSaveData.IsHunter && (self.IsCorrectContext("Spell Control", "Knight", "Fireball 1") || self.IsCorrectContext("Spell Control", "Knight", "Fireball 2") || self.IsCorrectContext("Spell Control", "Knight", "Level Check 2") || @@ -144,9 +144,9 @@ private void PlayerDataBoolTest_OnEnter(On.HutongGames.PlayMaker.Actions.PlayerD self.IsCorrectContext("Spell Control", "Knight", "Scream Burst 2"))) { if (!PlayerData.instance.equippedCharm_33) - HeroController.instance.TakeMP(SaveData.SpellCost - 33); + HeroController.instance.TakeMP(GlobalSaveData.SpellCost - 33); else - HeroController.instance.TakeMP(SaveData.SpellCost - 24); + HeroController.instance.TakeMP(GlobalSaveData.SpellCost - 24); } orig(self); @@ -156,14 +156,14 @@ private void IntCompare_OnEnter(On.HutongGames.PlayMaker.Actions.IntCompare.orig { if (self.IsCorrectContext("Spell Control", "Knight", null) && self.integer1.Name == "MP" && (self.State.Name == "Can Focus?" || self.State.Name == "Full HP?" || self.State.Name == "Full HP? 2")) - if (SaveData.IsHunter) - self.integer2.Value = SaveData.FocusCost; + if (GlobalSaveData.IsHunter) + self.integer2.Value = GlobalSaveData.FocusCost; else self.integer2.Value = 33; else if (self.IsCorrectContext("Spell Control", "Knight", "Can Cast? QC") || self.IsCorrectContext("Spell Control", "Knight", "Can Cast?")) { - if (SaveData.IsHunter) - self.Fsm.Variables.FindFsmInt("MP Cost").Value = SaveData.SpellCost; + if (GlobalSaveData.IsHunter) + self.Fsm.Variables.FindFsmInt("MP Cost").Value = GlobalSaveData.SpellCost; else self.Fsm.Variables.FindFsmInt("MP Cost").Value = 33; } @@ -178,6 +178,28 @@ private void IntCompare_OnEnter(On.HutongGames.PlayMaker.Actions.IntCompare.orig internal static bool IsModuleUsed() where T : Module => Instance.Modules.FirstOrDefault(x => x is T)?.IsModuleUsed ?? false; + private static string MenuName(string str) + { + if (str.Length == 0) return str; + + StringBuilder sb = new(); + sb.Append(char.ToUpper(str[0])); + bool prevUpper = true; + for (int i = 1; i < str.Length; i++) + { + if (char.IsUpper(str[i]) && !prevUpper) sb.Append(' '); + prevUpper = char.IsUpper(str[i]); + sb.Append(str[i]); + } + return sb.ToString(); + } + + private static HorizontalOption CreateEnumOption(string header, string description, Action setter, Func getter) where E : Enum + { + List enums = [.. Enum.GetValues(typeof(E))]; + return new HorizontalOption(header, description, [.. enums.Select(e => MenuName(Enum.GetName(typeof(E), e)))], x => setter((E)enums[x]), () => enums.IndexOf(getter())); + } + #endregion #region Interfaces @@ -186,12 +208,16 @@ public MenuScreen GetMenuScreen(MenuScreen modListMenu, ModToggleDelegates? togg { List elements = new() { - new HorizontalOption("Role:", "Flag that indicates if player is a hunter or speedrunner.", new string[]{"Hunter", "Speedrunner" }, - x => SaveData.IsHunter = x == 0, - () => SaveData.IsHunter ? 0 : 1), - new CustomSlider("Hunter Focus Cost:", x => SaveData.FocusCost = (int)x, () => SaveData.FocusCost, 33, 99, true), - new CustomSlider("Hunter Focus Speed:", x => SaveData.FocusSpeed = x, () => SaveData.FocusSpeed, 1, 3), - new CustomSlider("Hunter Spell Cost:", x => SaveData.SpellCost = (int)x, () => SaveData.SpellCost, 33, 99, true), + new HorizontalOption("Role:", "Flag that indicates if player is a hunter or speedrunner.", ["Hunter", "Speedrunner"], + x => GlobalSaveData.IsHunter = x == 0, + () => GlobalSaveData.IsHunter ? 0 : 1), + new CustomSlider("Hunter Focus Cost:", x => GlobalSaveData.FocusCost = (int)x, () => GlobalSaveData.FocusCost, 33, 99, true), + new CustomSlider("Hunter Focus Speed:", x => GlobalSaveData.FocusSpeed = x, () => GlobalSaveData.FocusSpeed, 1, 3), + new CustomSlider("Hunter Spell Cost:", x => GlobalSaveData.SpellCost = (int)x, () => GlobalSaveData.SpellCost, 33, 99, true), + CreateEnumOption("Timer position:", "Where to display pause, death, and custom countdown timers.", + x => GlobalSaveData.PauseTimerPosition = x, () => GlobalSaveData.PauseTimerPosition), + CreateEnumOption("Timer size:", "Size of pause, death, and custom countdown timers.", + x => GlobalSaveData.PauseTimerSize = x, () => GlobalSaveData.PauseTimerSize) }; foreach (Module module in Modules) { @@ -199,33 +225,50 @@ public MenuScreen GetMenuScreen(MenuScreen modListMenu, ModToggleDelegates? togg x => module.Affection = (ModuleAffection)x, () => (int)module.Affection)); } - MenuRef ??= new("The Hunt is on", elements.ToArray()); + elements.Add(new TextPanel("EnemyModule Toggles:", 1000, 35)); + elements.Add(new HorizontalOption("Disable Enemies", "Disables basic enemy spawns (with exceptions).", new string[] { "Off", "On" }, + x => GlobalSaveData.DisableEnemies = x == 1, + () => GlobalSaveData.DisableEnemies ? 1 : 0)); + elements.Add(new HorizontalOption("Invincible Bosses", "Sets boss HP to 9999.", new string[] { "Off", "On" }, + x => GlobalSaveData.InvincibleBosses = x == 1, + () => GlobalSaveData.InvincibleBosses ? 1 : 0)); + elements.Add(new HorizontalOption("Dream Boss Access", "Creates new entrances and exits for dream bosses.", new string[] { "Off", "On" }, + x => GlobalSaveData.DreamBossAccess = x == 1, + () => GlobalSaveData.DreamBossAccess ? 1 : 0)); + MenuRef ??= new("The Hunt Is On", elements.ToArray()); return MenuRef.GetMenuScreen(modListMenu); } public void OnLoadGlobal(HuntGlobalSaveData saveData) { - SaveData = saveData ?? new(); - SaveData.AffectionTable ??= []; + GlobalSaveData = saveData ?? new(); + GlobalSaveData.AffectionTable ??= []; foreach (Module module in Modules) - if (SaveData.AffectionTable.ContainsKey(module.GetType().Name)) - module.Affection = SaveData.AffectionTable[module.GetType().Name]; + if (GlobalSaveData.AffectionTable.ContainsKey(module.GetType().Name)) + module.Affection = GlobalSaveData.AffectionTable[module.GetType().Name]; } public HuntGlobalSaveData OnSaveGlobal() { HuntGlobalSaveData globalData = new HuntGlobalSaveData(); - globalData.FocusCost = SaveData.FocusCost; - globalData.FocusSpeed = SaveData.FocusSpeed; - globalData.SpellCost = SaveData.SpellCost; - globalData.IsHunter = SaveData.IsHunter; + globalData.FocusCost = GlobalSaveData.FocusCost; + globalData.FocusSpeed = GlobalSaveData.FocusSpeed; + globalData.SpellCost = GlobalSaveData.SpellCost; + globalData.IsHunter = GlobalSaveData.IsHunter; + globalData.DisableEnemies = GlobalSaveData.DisableEnemies; + globalData.InvincibleBosses = GlobalSaveData.InvincibleBosses; + globalData.DreamBossAccess = GlobalSaveData.DreamBossAccess; foreach (Module module in Modules) globalData.AffectionTable.Add(module.GetType().Name, module.Affection); return globalData; } + public void OnLoadLocal(HuntLocalSaveData saveData) => LocalSaveData = saveData ?? new(); + + public HuntLocalSaveData OnSaveLocal() => LocalSaveData; + #endregion } \ No newline at end of file diff --git a/TheHuntIsOn.csproj b/TheHuntIsOn.csproj index 953c930..3411267 100644 --- a/TheHuntIsOn.csproj +++ b/TheHuntIsOn.csproj @@ -5,20 +5,19 @@ net472 TheHuntIsOn TheHuntIsOn - 1.0.0.0 + 1.0.1 true false latest - E:/Program Files/Steam/steamapps/common/Hollow Knight/hollow_knight_Data/Managed/ bin\Publish True - 1.0.0.0 - E:\SteamLibrary\steamapps\common\Hollow Knight HKMP SRvH\hollow_knight_Data\Managed\Mods\TheHuntIsOn + 1.0.2.0 +