diff --git a/SSMP/Animation/AnimationClip.cs b/SSMP/Animation/AnimationClip.cs index 0f6a9604..724cb698 100644 --- a/SSMP/Animation/AnimationClip.cs +++ b/SSMP/Animation/AnimationClip.cs @@ -768,4 +768,9 @@ internal enum AnimationClip { WitchTentacles, ShamanCancel, BindInterrupt, + MagnetiteDice, + FleaBrew, + FracturedMask, + MagmaBell, + Bench } diff --git a/SSMP/Animation/AnimationEffect.cs b/SSMP/Animation/AnimationEffect.cs index 1a7db54a..e3306b28 100644 --- a/SSMP/Animation/AnimationEffect.cs +++ b/SSMP/Animation/AnimationEffect.cs @@ -1,5 +1,6 @@ using SSMP.Game.Settings; using SSMP.Internals; +using SSMP.Util; using UnityEngine; namespace SSMP.Animation; @@ -25,7 +26,7 @@ public void SetServerSettings(ServerSettings serverSettings) { } /// - /// Locate the damages_enemy FSM and change the attack direction to the given direciton. This will ensure that + /// Locate the damages_enemy FSM and change the attack direction to the given direction. This will ensure that /// enemies are getting knocked back in the correct direction from remote player's attacks. /// /// The target GameObject to change. @@ -52,4 +53,54 @@ protected static void HidePlayer(GameObject playerObject) { playerObject.GetComponent().Stop(); playerObject.GetComponent().SetSprite("wall_puff0004"); } + + /// + /// Gets the Effects object for a given player object. If the player object does not have an Effects object, it + /// will be created. + /// + /// The player object for the player using the effect. + /// The player's effects object. + protected static GameObject GetPlayerEffects(GameObject playerObject) { + var effects = playerObject.FindGameObjectInChildren("Effects"); + if (effects == null) { + effects = new GameObject("Effects"); + effects.transform.SetParentReset(playerObject.transform); + } + + return effects; + } + + /// + /// Attempts to get or create an effect from the Effects sub-object. + /// + /// The player object for the player using the effect. + /// The name of the effect object. + /// The effect, if found or created. + /// True if created, false otherwise. + protected static bool TryGetEffect(GameObject playerObject, string effectName, out GameObject? effect) { + // Find or create effects for player + var effects = GetPlayerEffects(playerObject); + + // Find existing effect + effect = effects.FindGameObjectInChildren(effectName); + if (effect != null) { + return false; + } + + // Create new effect + var localEffects = HeroController.instance.gameObject.FindGameObjectInChildren("Effects"); + if (localEffects == null) { + return false; + } + + var localEffect = localEffects.FindGameObjectInChildren(effectName); + if (localEffect == null) { + return false; + } + + effect = Object.Instantiate(localEffect, effects.transform); + effect.name = effectName; + + return true; + } } diff --git a/SSMP/Animation/AnimationManager.cs b/SSMP/Animation/AnimationManager.cs index ef4bbfaf..049d0b17 100644 --- a/SSMP/Animation/AnimationManager.cs +++ b/SSMP/Animation/AnimationManager.cs @@ -2,7 +2,9 @@ using System.Linq; using HutongGames.PlayMaker.Actions; using SSMP.Animation.Effects; +using SSMP.Animation.Effects.Movement; using SSMP.Animation.Effects.SilkSkills; +using SSMP.Animation.Effects.Tools; using SSMP.Collection; using SSMP.Fsm; using SSMP.Game; @@ -621,9 +623,15 @@ internal class AnimationManager { { "Wound Double Strike", AnimationClip.WoundDoubleStrike }, { "Wound Zap", AnimationClip.WoundZap }, + { "Bench", AnimationClip.Bench }, + { "Witch Tentacles!", AnimationClip.WitchTentacles }, { "Shaman Cancel", AnimationClip.ShamanCancel }, - { "Bind Fail Burst", AnimationClip.BindInterrupt } + { "Bind Fail Burst", AnimationClip.BindInterrupt }, + { "Magnetite Dice", AnimationClip.MagnetiteDice }, + { "Flea Brew", AnimationClip.FleaBrew }, + { "Fractured Mask", AnimationClip.FracturedMask }, + { "Magma Bell", AnimationClip.MagmaBell } }; /// @@ -662,6 +670,8 @@ internal class AnimationManager { { AnimationClip.BindBurstAir, BindBurst.Instance }, { AnimationClip.RageBindBurst, BindBurst.Instance }, { AnimationClip.Death, new Death() }, + { AnimationClip.DoubleJump, new FaydownCloak() }, + { AnimationClip.UmbrellaInflate, new DriftersCloak() }, // Silk Skills { AnimationClip.NeedleThrowThrowing, new SilkSpear() }, @@ -679,9 +689,18 @@ internal class AnimationManager { { AnimationClip.WitchTentacles, BindBurst.Instance }, { AnimationClip.ShamanCancel, new Bind { BindState = Bind.State.ShamanCancel } }, { AnimationClip.BindInterrupt, BindInterrupt.Instance }, + { AnimationClip.Bench, new Bench() }, + + // Silk Skills { AnimationClip.AirSphereRefresh, new ThreadStorm() }, { AnimationClip.SilkBombLocations, new RuneRage() }, - { AnimationClip.SilkBossNeedleFire, new PaleNails() } + { AnimationClip.SilkBossNeedleFire, new PaleNails() }, + + // Tools + { AnimationClip.MagnetiteDice, new MagnetiteDice() }, + { AnimationClip.FleaBrew, FleaBrew.Instance }, + { AnimationClip.FracturedMask, new FracturedMask() }, + { AnimationClip.MagmaBell, new MagmaBell() } }; /// @@ -814,6 +833,7 @@ public void RegisterHooks() { CreateHeroHooks(HeroController.instance); } + EventHooks.UseLavaBell += OnMagmaBell; // Register a callback so we know when the dash has finished // On.HeroController.CancelDash += HeroControllerOnCancelDash; @@ -838,6 +858,17 @@ public void DeregisterHooks() { HeroController.OnHeroInstanceSet -= CreateHeroHooks; FsmStateActionInjector.UninjectAll(); + + MagnetiteDice.Unhook(); + + EventHooks.HeroControllerDie -= OnDeath; + EventHooks.UseLavaBell -= OnMagmaBell; + + // Remove listener for benching + var eventRegister = HeroController.SilentInstance?.gameObject.GetComponents().FirstOrDefault(r => r.SubscribedEvent == "BENCHREST START"); + if (eventRegister) { + eventRegister.ReceivedEvent -= OnBench; + } // On.HeroAnimationController.Play -= HeroAnimationControllerOnPlay; // On.HeroAnimationController.PlayFromFrame -= HeroAnimationControllerOnPlayFromFrame; @@ -1099,6 +1130,9 @@ private void CreateHeroHooks(HeroController hc) { bellFsm.Init(); } + CreateSkillHooks(); + CreateToolHooks(); + // Find bind FSM var heroFsms = hc.GetComponents(); @@ -1117,8 +1151,53 @@ private void CreateHeroHooks(HeroController hc) { Logger.Warn("Unable to find Bind FSM to hook."); } + // Add listener for benching + var eventRegister = hc.gameObject.GetComponents().FirstOrDefault(r => r.SubscribedEvent == "BENCHREST START"); + if (eventRegister) { + eventRegister.ReceivedEvent += OnBench; + } + } + + /// + /// Animation subanimation hook for the Witch Tentacles FSM state. + /// + private void OnWitchTentacles(PlayMakerFSM fsm) { + var dummyClip = new tk2dSpriteAnimationClip { + name = "Witch Tentacles!", + wrapMode = tk2dSpriteAnimationClip.WrapMode.Once + }; + OnAnimationEvent(dummyClip); + } + + /// + /// Animation subanimation hook for the Shaman Air Cancel FSM state. + /// + + private void OnShamanCancel(PlayMakerFSM fsm) { + var dummyClip = new tk2dSpriteAnimationClip { + name = "Shaman Cancel", + wrapMode = tk2dSpriteAnimationClip.WrapMode.Once + }; + OnAnimationEvent(dummyClip); + } + + /// + /// Animation subanimation hook for interrupted binds. + /// + private void OnBindInterrupt(PlayMakerFSM fsm) { + var dummyClip = new tk2dSpriteAnimationClip { + name = "Bind Fail Burst", + wrapMode = tk2dSpriteAnimationClip.WrapMode.Once + }; + OnAnimationEvent(dummyClip); + } + + /// + /// Creates hooks for silk skills. + /// + private void CreateSkillHooks() { // Silk skill injections - var silkSkillFsm = hc.silkSpecialFSM; + var silkSkillFsm = HeroController.instance.silkSpecialFSM; if (silkSkillFsm == null) { Logger.Warn("Unable to find Silk Skill FSM to hook."); return; @@ -1191,39 +1270,6 @@ private void CreateHeroHooks(HeroController hc) { } } - /// - /// Animation subanimation hook for the Witch Tentacles FSM state. - /// - private void OnWitchTentacles(PlayMakerFSM fsm) { - var dummyClip = new tk2dSpriteAnimationClip { - name = "Witch Tentacles!", - wrapMode = tk2dSpriteAnimationClip.WrapMode.Once - }; - OnAnimationEvent(dummyClip); - } - - /// - /// Animation subanimation hook for the Shaman Air Cancel FSM state. - /// - private void OnShamanCancel(PlayMakerFSM fsm) { - var dummyClip = new tk2dSpriteAnimationClip { - name = "Shaman Cancel", - wrapMode = tk2dSpriteAnimationClip.WrapMode.Once - }; - OnAnimationEvent(dummyClip); - } - - /// - /// Animation subanimation hook for interrupted binds. - /// - private void OnBindInterrupt(PlayMakerFSM fsm) { - var dummyClip = new tk2dSpriteAnimationClip { - name = "Bind Fail Burst", - wrapMode = tk2dSpriteAnimationClip.WrapMode.Once - }; - OnAnimationEvent(dummyClip); - } - /// /// Animation subanimation hook for extending a thread storm. /// @@ -1409,6 +1455,101 @@ private void OnPaleNailFire(PlayMakerFSM fsm) { _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.SilkBossNeedleFire, 0, effectInfo); } + /// + /// Creates hooks for tools. + /// + private void CreateToolHooks() { + MagnetiteDice.Hook(OnDiceEnable); + MagmaBell.HookRecharge(OnMagmaBellRecharge); + + var toolFsm = HeroController.instance.toolsFSM; + var brewBurst = toolFsm.GetState("Flea Brew Burst"); + FsmStateActionInjector.Inject(brewBurst, OnFleaBrew, 0, "Flea Brew"); + + var maskFsm = HeroController.instance.gameObject + .FindGameObjectInChildren("Charm Effects")? + .FindGameObjectInChildren("Fractured Mask Break")? + .LocateMyFSM("Spawn Effect"); + + if (maskFsm) { + var maskEffect = maskFsm.GetState("Instantiate Effect"); + FsmStateActionInjector.Inject(maskEffect, OnFracturedMaskBreak, 0, "Fractured Mask Break"); + } + } + + /// + /// Hook for the magnetite dice being triggered. + /// + private void OnDiceEnable() { + // If we are not connected, there is nothing to send to + if (!_netClient.IsConnected) { + return; + } + + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.MagnetiteDice); + } + + /// + /// Hook for the flea brew being triggered. + /// + private void OnFleaBrew(PlayMakerFSM fsm) { + // If we are not connected, there is nothing to send to + if (!_netClient.IsConnected) { + return; + } + + var effectInfo = FleaBrew.Instance.GetEffectInfo(); + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.FleaBrew, 0, effectInfo); + } + + /// + /// Hook for the fractured mask being triggered. + /// + private void OnFracturedMaskBreak(PlayMakerFSM fsm) { + // If we are not connected, there is nothing to send to + if (!_netClient.IsConnected) { + return; + } + + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.FracturedMask); + } + + /// + /// Hook for the magma bell being triggered. + /// + private void OnMagmaBell() { + // If we are not connected, there is nothing to send to + if (!_netClient.IsConnected) { + return; + } + + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.MagmaBell, 0, [0]); + } + + /// + /// Hook for the magma bell being triggered. + /// + private void OnMagmaBellRecharge() { + // If we are not connected, there is nothing to send to + if (!_netClient.IsConnected) { + return; + } + + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.MagmaBell, 0, [1]); + } + + /// + /// Hook for resting on a bench. + /// + private void OnBench() { + // If we are not connected, there is nothing to send to + if (!_netClient.IsConnected) { + return; + } + + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.Bench); + } + // /// // /// Callback method on the HeroAnimationController#Play method. // /// diff --git a/SSMP/Animation/DamageAnimationEffect.cs b/SSMP/Animation/DamageAnimationEffect.cs index be519ea4..1866cffc 100644 --- a/SSMP/Animation/DamageAnimationEffect.cs +++ b/SSMP/Animation/DamageAnimationEffect.cs @@ -30,7 +30,8 @@ public void SetShouldDoDamage(bool shouldDoDamage) { /// /// Adds a component to the given game object that deals the given damage when the player - /// collides with it. + /// collides with it. Also adds a component that indicates the owner of this + /// object. /// /// The target game object to attach the component to. /// The number of mask of damage it should deal. @@ -40,6 +41,9 @@ protected static DamageHero AddDamageHeroComponent(GameObject target, int damage damageHero.damageDealt = damage; damageHero.OnDamagedHero = new UnityEvent(); + var identifier = target.AddComponentIfNotPresent(); + identifier.Owner = target; + return damageHero; } @@ -47,7 +51,7 @@ protected static DamageHero AddDamageHeroComponent(GameObject target, int damage /// Removes a component from the given game object. /// /// The target game object to detach the component from. - protected static void RemoveDamageHeroComponent(GameObject target) { + private static void RemoveDamageHeroComponent(GameObject target) { target.DestroyComponent(); } @@ -59,7 +63,19 @@ protected static void RemoveDamageHeroComponent(GameObject target) { /// The number of mask of damage it should deal. /// The component that was added if PVP was turned on protected DamageHero? SetDamageHeroState(GameObject target, int damage = 1) { - if (ServerSettings.IsPvpEnabled && ShouldDoDamage) { + return SetDamageHeroState(target, ServerSettings.IsPvpEnabled && ShouldDoDamage, damage); + } + + /// + /// Adds or removes a component from the given game object, + /// depending on the PVP and team settings. + /// + /// The target game object to attach or remove the component from. + /// The number of mask of damage it should deal. + /// If the damager should be enabled or not + /// The component that was added if PVP was turned on + public static DamageHero? SetDamageHeroState(GameObject target, bool doDamage, int damage = 1) { + if (doDamage && damage > 0) { return AddDamageHeroComponent(target, damage); } diff --git a/SSMP/Animation/Effects/Bench.cs b/SSMP/Animation/Effects/Bench.cs new file mode 100644 index 00000000..e59933c2 --- /dev/null +++ b/SSMP/Animation/Effects/Bench.cs @@ -0,0 +1,20 @@ +using SSMP.Animation.Effects.SilkSkills; +using SSMP.Animation.Effects.Tools; +using SSMP.Internals; +using UnityEngine; + +namespace SSMP.Animation.Effects; + +internal class Bench : AnimationEffect { + /// + public override byte[]? GetEffectInfo() { + return null; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + // Stop all tool/silk skill effects + PaleNails.DespawnAllPlayerNails(playerObject); + FleaBrew.StopBrew(playerObject); + } +} diff --git a/SSMP/Animation/Effects/Movement/DriftersCloak.cs b/SSMP/Animation/Effects/Movement/DriftersCloak.cs new file mode 100644 index 00000000..1f8d913f --- /dev/null +++ b/SSMP/Animation/Effects/Movement/DriftersCloak.cs @@ -0,0 +1,46 @@ +using SSMP.Animation.Effects.Tools; +using SSMP.Internals; +using SSMP.Util; +using UnityEngine; + +namespace SSMP.Animation.Effects.Movement; + +/// +/// Class for the animation effect of Drifter's Cloak (slow fall, ride updrafts). +/// +internal class DriftersCloak : DamageAnimationEffect { + + /// + public override byte[] GetEffectInfo() { + return [ + (byte)(ToolItemManager.IsToolEquipped("Brolly Spike") ? 1 : 0) + ]; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + // Get or create effect + var created = TryGetEffect(playerObject, "umbrella_inflate_effect", out var effect); + if (!effect) { + return; + } + + // Set up effect if created + if (!created) { + effect.transform.localPosition = new Vector3(0, -0.24f, 0); + effect.transform.localScale = Vector3.one; + + effect.DestroyGameObjectInChildren("umbrella_float_fx_burst0002"); + } + + // Refresh particles + effect.SetActive(false); + effect.SetActive(true); + + // Enable sawtooth circlet if appropriate + if (effectInfo is [1]) { + SawtoothCirclet.PlayCirclet(playerObject, ShouldDoDamage && ServerSettings.IsPvpEnabled, ServerSettings); + } + + } +} diff --git a/SSMP/Animation/Effects/Movement/FaydownCloak.cs b/SSMP/Animation/Effects/Movement/FaydownCloak.cs new file mode 100644 index 00000000..28d7aa06 --- /dev/null +++ b/SSMP/Animation/Effects/Movement/FaydownCloak.cs @@ -0,0 +1,53 @@ +using SSMP.Animation.Effects.Tools; +using SSMP.Internals; +using SSMP.Util; +using UnityEngine; + +namespace SSMP.Animation.Effects.Movement; + +/// +/// Class for the animation effect of Faydown Cloak (double jump). +/// +internal class FaydownCloak : DamageAnimationEffect { + /// + /// The name of the game object for the effect for checking if it exists on a player object already. + /// + private const string JumpEffectName = "double_jump_feathers"; + + /// + public override byte[] GetEffectInfo() { + return [ + (byte)(ToolItemManager.IsToolEquipped("Brolly Spike") ? 1 : 0) + ]; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + // Find or create effects + var effects = GetPlayerEffects(playerObject); + + // Find or create jump effect + var effect = effects.FindGameObjectInChildren(JumpEffectName); + if (effect == null) { + var localEffect = HeroController.instance.doubleJumpEffectPrefab; + effect = EffectUtils.SpawnGlobalPoolObject(localEffect, effects.transform, 0, true); + if (effect == null) return; + effect.name = JumpEffectName; + + // Remove components from newly created object + effect.DestroyGameObjectInChildren("haze flash"); + effect.DestroyGameObjectInChildren("jump_double_glow"); + effect.DestroyComponent(); + effect.SetActiveChildren(true); + } + + // Refresh effect + effect.SetActive(false); + effect.SetActive(true); + + // Play sawtooth circlet if appropriate + if (effectInfo is [1]) { + SawtoothCirclet.PlayCirclet(playerObject, ShouldDoDamage && ServerSettings.IsPvpEnabled, ServerSettings); + } + } +} diff --git a/SSMP/Animation/Effects/SilkSkills/PaleNails.cs b/SSMP/Animation/Effects/SilkSkills/PaleNails.cs index e70ef343..59287007 100644 --- a/SSMP/Animation/Effects/SilkSkills/PaleNails.cs +++ b/SSMP/Animation/Effects/SilkSkills/PaleNails.cs @@ -145,6 +145,29 @@ private static void PlayNailFireUnguided(GameObject[] nails, bool isVolt, int pl PlayerNails[playerId] = []; } + /// + /// De-spawns all sets of nails belonging to a given player. + /// + /// The player object with nails to de-spawn. + public static void DespawnAllPlayerNails(GameObject playerObject) { + // Get existing nails + var id = playerObject.GetInstanceID(); + if (!PlayerNails.TryGetValue(id, out var nails) || nails.Length == 0) { + return; + } + + // Send bench event to all nails + foreach (var nail in nails) { + if (nail == null) continue; + + var fsm = nail.LocateMyFSM("Control"); + fsm.Fsm.Event("BENCHREST"); + } + + // Clear nails + PlayerNails[id] = []; + } + /// /// Plays the nail summoning antic effect. /// @@ -370,14 +393,16 @@ private static void FixFsmForUse(PlayMakerFSM fsm, GameObject playerObject, bool // Remove transitions var left = fsm.GetState(followLeftName); left.Transitions = [ - left.Transitions[0], - left.Transitions[5] + left.Transitions[0], // TURN + left.Transitions[3], // BENCHREST + left.Transitions[5] // FOLLOW BUDDY ]; var right = fsm.GetState(followRightName); right.Transitions = [ - right.Transitions[0], - right.Transitions[5], + right.Transitions[0], // TURN + right.Transitions[3], // BENCHREST + right.Transitions[5] // FOLLOW BUDDY ]; // Set volt state diff --git a/SSMP/Animation/Effects/Tools/BaseTool.cs b/SSMP/Animation/Effects/Tools/BaseTool.cs new file mode 100644 index 00000000..ee24af3c --- /dev/null +++ b/SSMP/Animation/Effects/Tools/BaseTool.cs @@ -0,0 +1,16 @@ +using GlobalSettings; + +namespace SSMP.Animation.Effects.Tools; + +/// +/// Base class for animations effects of tools that can deal damage to other players. +/// +internal abstract class BaseTool : DamageAnimationEffect { + /// + /// Determines whether the players tools have the Pollip Pouch poison effect. + /// + /// True if the player has the Pollip Pouch equipped, otherwise false. + protected static bool HasPoison() { + return Gameplay.PoisonPouchTool.IsEquipped; + } +} diff --git a/SSMP/Animation/Effects/Tools/FleaBrew.cs b/SSMP/Animation/Effects/Tools/FleaBrew.cs new file mode 100644 index 00000000..131cbf1c --- /dev/null +++ b/SSMP/Animation/Effects/Tools/FleaBrew.cs @@ -0,0 +1,193 @@ +using System.Collections; +using System.Collections.Generic; +using GlobalSettings; +using SSMP.Internals; +using SSMP.Util; +using UnityEngine; + +namespace SSMP.Animation.Effects.Tools; + +/// +/// Class for the tool effect of Flea Brew (attack buff). +/// +internal class FleaBrew : BaseTool { + private const string ParticlesName = "Flea Brew Particles"; + + /// + /// Cached reference to a modified version of the poisoned flea brew trail. + /// + private static GameObject? _modifiedPoisonTrail; + + /// + /// Cached values of sprite flashes for Flea Brews. + /// + private static readonly Dictionary BrewFlashes = []; + + /// + /// Instance of the effect class. + /// + public static readonly FleaBrew Instance = new(); + + /// + public override byte[] GetEffectInfo() { + return [ + (byte) (HasPoison() ? 1 : 0) + ]; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + var hc = HeroController.instance; + var isPoison = effectInfo is [1]; + + // Play audio + var fsm = hc.toolsFSM; + if (fsm != null) { + var audio = fsm.GetFirstAction("Flea Brew Burst"); + AudioUtil.PlayAudio(audio, playerObject); + } + + // Start particles + var duration = hc.QUICKENING_DURATION; + var localPrefab = isPoison ? hc.quickeningPoisonEffectPrefab : hc.quickeningEffectPrefab; + + var particles = EffectUtils.SpawnGlobalPoolObject( + localPrefab.gameObject, + playerObject.transform, + duration, + true + ); + if (particles == null) return; + + particles.name = ParticlesName; + + // Set up poison clouds + if (isPoison && ShouldDoDamage && ServerSettings.IsPvpEnabled) { + SetPoisonTrail(particles); + } + + // Play sprite flash + if (!playerObject.TryGetComponent(out var flash)) { + return; + } + + // See if previous effect is playing + var id = playerObject.GetInstanceID(); + if (BrewFlashes.TryGetValue(id, out var prevHandle)) { + if (flash.IsFlashing(true, prevHandle)) { + flash.CancelRepeatingFlash(prevHandle); + } + } + + // Start new flash + var color = isPoison ? Gameplay.PoisonPouchHeroTintColour : new Color(1f, 0.85f, 0.47f, 1f); + var flashHandle = flash.Flash( + color, + 0.7f, + 0.2f, + 0.01f, + 0.22f, + 0f, + repeating: true, + 0, + 1, + requireExplicitCancel: true + ); + BrewFlashes[id] = flashHandle; + + MonoBehaviourUtil.Instance.StartCoroutine(StopBrewFlashAfterDelay(playerObject, flashHandle)); + + } + + /// + /// Stops the Flea Brew flashing and particles. + /// + /// The player object with the Flea Brew animation. + public static void StopBrew(GameObject playerObject) { + var id = playerObject.GetInstanceID(); + if (!BrewFlashes.TryGetValue(id, out var handle)) { + return; + } + + StopBrew(playerObject, handle); + } + + /// + /// Stops the Flea Brew flashing and particles. + /// + /// The player object with the Flea Brew animation. + /// The current sprite flash handle. + private static void StopBrew(GameObject playerObject, SpriteFlash.FlashHandle handle) { + // Stop sprite flash + if (playerObject.TryGetComponent(out var flash)) { + flash.CancelRepeatingFlash(handle); + } + + // Stop particles + var particles = playerObject.FindGameObjectInChildren(ParticlesName); + if (particles) { + Object.Destroy(particles); + } + } + + /// + /// Stops the Flea Brew sprite flash after a delay. + /// + /// The player that used the tool. + /// The flash's handle. + private static IEnumerator StopBrewFlashAfterDelay(GameObject playerObject, SpriteFlash.FlashHandle handle) { + // Wait for effect to end + yield return new WaitForSeconds(HeroController.instance.QUICKENING_DURATION); + + // Cancel flash + StopBrew(playerObject, handle); + } + + /// + /// Sets up a poison trail that deals damage. + /// + /// The poisoned Flea Brew particle spawner. + private void SetPoisonTrail(GameObject particles) { + // Find the prefab spawner + var spawnerObj = particles.FindGameObjectInChildren("Trail Spawner"); + if (!spawnerObj) return; + + if (!spawnerObj.TryGetComponent(out var spawner)) { + return; + } + + // Set up a modified version of the prefab + if (!_modifiedPoisonTrail) { + var prefab = spawner.prefab; + if (!prefab) return; + + _modifiedPoisonTrail = EffectUtils.SpawnGlobalPoolObject(prefab, particles.transform, 0); + if (!_modifiedPoisonTrail) return; + + _modifiedPoisonTrail.SetActive(false); + _modifiedPoisonTrail.name = "Hornet Poison Trail Modified"; + + // Re-add recycler so that it de-spawns + // Since this is a new object, it won't override the other pool + var recycler = _modifiedPoisonTrail.AddComponent(); + recycler.afterEvent = GlobalEnums.AfterEvent.TIME; + recycler.timeToWait = 1.1f; + + // Set the damager + var damager = _modifiedPoisonTrail.FindGameObjectInChildren("damager"); + if (damager) { + AddDamageHeroComponent(damager, ServerSettings.PoisonBrewDamage); + damager.layer = 17; + } + } else { + // Ensure the damage amount is correct every time the brew is used + var damagerObj = _modifiedPoisonTrail.FindGameObjectInChildren("damager"); + if (damagerObj && damagerObj.TryGetComponent(out var damager)) { + damager.damageDealt = ServerSettings.PoisonBrewDamage; + } + } + + // Set the spawner's prefab + spawner.prefab = _modifiedPoisonTrail; + } +} diff --git a/SSMP/Animation/Effects/Tools/FracturedMask.cs b/SSMP/Animation/Effects/Tools/FracturedMask.cs new file mode 100644 index 00000000..b4ca4ea9 --- /dev/null +++ b/SSMP/Animation/Effects/Tools/FracturedMask.cs @@ -0,0 +1,37 @@ +using HutongGames.PlayMaker.Actions; +using SSMP.Internals; +using SSMP.Util; +using UnityEngine; + +namespace SSMP.Animation.Effects.Tools; + +/// +/// Class for the tool effect of Fractured Mask (extra health point). +/// +internal class FracturedMask : BaseTool { + /// + public override byte[]? GetEffectInfo() { + return null; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + // Find effect + var fsm = HeroController.instance.gameObject + .FindGameObjectInChildren("Charm Effects")? + .FindGameObjectInChildren("Fractured Mask Break")? + .LocateMyFSM("Spawn Effect"); + + if (fsm == null) return; + + // Spawn in the shatter particles + var localMaskShatter = fsm.GetFirstAction("Instantiate Effect"); + var mask = EffectUtils.SpawnGlobalPoolObject( + localMaskShatter.gameObject.Value, + playerObject.transform, + 5 + ); + + mask?.DestroyComponent(); + } +} diff --git a/SSMP/Animation/Effects/Tools/MagmaBell.cs b/SSMP/Animation/Effects/Tools/MagmaBell.cs new file mode 100644 index 00000000..2e712ed4 --- /dev/null +++ b/SSMP/Animation/Effects/Tools/MagmaBell.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections; +using SSMP.Internals; +using SSMP.Util; +using UnityEngine; + +namespace SSMP.Animation.Effects.Tools; + +/// +/// Class for the tool effect of Magma Bell (fire protection). +/// +internal class MagmaBell : BaseTool { + /// + /// Name of the magma bell starting object name. + /// + private const string MagmaBellStartName = "Magma Bell Start"; + + /// + /// Name of the magma bell recharging object name. + /// + private const string MagmaBellRechargeName = "Magma Bell Recharge"; + + public static readonly MagmaBell Instance = new(); + + /// + public override byte[]? GetEffectInfo() { + return null; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + // Two parts: 1. when hit and 2. when recovering after some delay + if (effectInfo is [1]) { + PlayRecharge(playerObject); + return; + } + + + // Find existing effect + var effects = GetPlayerEffects(playerObject); + var magmaStart = effects.FindGameObjectInChildren(MagmaBellStartName); + + // Create effect if needed + if (!magmaStart) { + var prefab = HeroController.instance.lavaBellEffectPrefab; + + magmaStart = EffectUtils.SpawnGlobalPoolObject(prefab, effects.transform, 0, true); + if (!magmaStart) return; + + magmaStart.transform.localPosition = Vector3.zero; + magmaStart.transform.localScale = new Vector3(0.5f, 0.5f, 1); + magmaStart.name = MagmaBellStartName; + magmaStart.DestroyComponent(); + } + + // Toggle effect + magmaStart.SetActive(false); + magmaStart.SetActive(true); + } + + /// + /// Plays the recharge animation. + /// + /// The player to use the animation on. + private static void PlayRecharge(GameObject playerObject) { + // Find existing effect + var effects = GetPlayerEffects(playerObject); + var magmaRecharge = effects.FindGameObjectInChildren(MagmaBellRechargeName); + + // Create effect if needed + if (!magmaRecharge) { + var prefab = HeroController.instance.lavaBellRechargeEffectPrefab; + + magmaRecharge = EffectUtils.SpawnGlobalPoolObject(prefab, effects.transform, 0, true); + if (!magmaRecharge) return; + + magmaRecharge.transform.localPosition = Vector3.zero; + magmaRecharge.name = MagmaBellRechargeName; + magmaRecharge.DestroyComponent(); + } + + // Toggle effect + magmaRecharge.SetActive(false); + magmaRecharge.SetActive(true); + } + + /// + /// Adds a hook for when the bell is recharged. + /// + /// The hook to run. + public static void HookRecharge(Action onTrigger) { + // Create coroutine since we have to wait for the prefab to be set + static IEnumerator DoHook(Action onTrigger) { + // Wait for prefab to be spawned + yield return null; + + var prefab = HeroController.instance.spawnedLavaBellRechargeEffect; + if (prefab == null) { + yield break; + } + + // Add enable hook + var hook = prefab.AddComponent(); + + hook.Enabled += onTrigger; + } + + MonoBehaviourUtil.Instance.StartCoroutine(DoHook(onTrigger)); + } +} diff --git a/SSMP/Animation/Effects/Tools/MagnetiteDice.cs b/SSMP/Animation/Effects/Tools/MagnetiteDice.cs new file mode 100644 index 00000000..e6199a91 --- /dev/null +++ b/SSMP/Animation/Effects/Tools/MagnetiteDice.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections; +using SSMP.Internals; +using SSMP.Util; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace SSMP.Animation.Effects.Tools; + +/// +/// Class for the tool effect of Magnetite Dice (chance to negate damage). +/// +internal class MagnetiteDice : BaseTool { + /// + /// Name of the magnetite dice effect object. + /// + private const string DiceName = "dice_shield_effect"; + + /// + public override byte[]? GetEffectInfo() { + return null; + } + + /// + public override void Play(GameObject playerObject, CrestType crestType, byte[]? effectInfo) { + // Get existing effect + var effects = GetPlayerEffects(playerObject); + var dice = effects.FindGameObjectInChildren(DiceName); + + // Set up effect if needed + if (dice == null) { + var localDice = HeroController.instance.spawnedLuckyDiceShieldEffect; + if (localDice == null) return; + + dice = Object.Instantiate(localDice, effects.transform); + dice.transform.localPosition = new Vector3(0, 0, -0.02f); + dice.transform.localScale = new Vector3(0.5f, 0.5f, 1); + + dice.DestroyComponent(); + dice.DestroyGameObjectInChildren("Vignette Cutout"); + } + + // Toggle effect + dice.SetActive(false); + dice.SetActive(true); + } + + /// + /// Adds a hook for when the dice are enabled. + /// + /// The hook to run. + public static void Hook(Action onTrigger) { + // Create coroutine since we have to wait for the prefab to be set + static IEnumerator DoHook(Action onTrigger) { + // Wait for prefab to be spawned + yield return null; + + var prefab = HeroController.instance.spawnedLuckyDiceShieldEffect; + if (prefab == null) { + yield break; + } + + // Add enable hook + var hook = prefab.AddComponent(); + + hook.Enabled += onTrigger; + } + + MonoBehaviourUtil.Instance.StartCoroutine(DoHook(onTrigger)); + } + + /// + /// Removes the hook from the dice. + /// + public static void Unhook() { + var prefab = HeroController.SilentInstance?.spawnedLuckyDiceShieldEffect; + if (prefab == null) { + return; + } + + prefab.DestroyComponent(); + } +} diff --git a/SSMP/Animation/Effects/Tools/SawtoothCirclet.cs b/SSMP/Animation/Effects/Tools/SawtoothCirclet.cs new file mode 100644 index 00000000..01157ec9 --- /dev/null +++ b/SSMP/Animation/Effects/Tools/SawtoothCirclet.cs @@ -0,0 +1,102 @@ +using System.Diagnostics.CodeAnalysis; +using HutongGames.PlayMaker.Actions; +using SSMP.Game.Settings; +using SSMP.Util; +using UnityEngine; + +namespace SSMP.Animation.Effects.Tools; + +/// +/// Class for the tool effect of Sawtooth Circlet (damage when double jumping or gliding). +/// This is a static class because it is only statically called from other effects (Faydown Cloak and Drifter's Cloak). +/// +internal static class SawtoothCirclet { + /// + /// The name of the Sawtooth Circlet object. + /// + private const string SpikedCircletName = "Tool_brolly_spike"; + + /// + /// Cached reference to the local Sawtooth Circlet object. + /// + private static GameObject? _localCirclet; + + /// + /// Plays the Sawtooth Circlet animation. + /// + /// The player using the circlet. + /// If the circlet should do damage. + /// The server settings for retrieving the damage it should deal. + public static void PlayCirclet(GameObject playerObject, bool doDamage, ServerSettings serverSettings) { + // Get the circlet + if (!TryGetCirclet(playerObject, out var circlet)) { + return; + } + + // Set the damager + var damagerParent = circlet + .FindGameObjectInChildren("Brolly_spike_position")? + .FindGameObjectInChildren("brolly_slash_enemy_damager"); + + var damagerRight = damagerParent?.FindGameObjectInChildren("Damager R"); + var damagerLeft = damagerParent?.FindGameObjectInChildren("Damager L"); + + var damage = serverSettings.SawtoothCircletDamage; + if (damagerRight != null) DamageAnimationEffect.SetDamageHeroState(damagerRight, doDamage, damage); + if (damagerLeft != null) DamageAnimationEffect.SetDamageHeroState(damagerLeft, doDamage, damage); + + // Refresh the circlet + circlet.SetActive(false); + circlet.SetActive(true); + + // Play audio + var audioFsm = _localCirclet.LocateMyFSM("brolly_spike_cooldown_check").FsmTemplate.fsm; + + var audioAction = audioFsm.GetState("Check").Actions[4]; + if (audioAction is PlayAudioEvent audio) { + AudioUtil.PlayAudio(audio, playerObject); + } + } + + /// + /// Attempts to find or create the Sawtooth Circlet object. + /// + /// The player using the circlet. + /// The circlet, if found. + /// True if the circlet was found, otherwise false. + private static bool TryGetCirclet(GameObject playerObject, [MaybeNullWhen(false)] out GameObject circlet) { + // Find or create effects + var effects = playerObject.FindGameObjectInChildren("Effects"); + if (effects == null) { + effects = new GameObject("Effects"); + effects.transform.SetParentReset(playerObject.transform); + } + + // Find existing circlet + circlet = effects.FindGameObjectInChildren(SpikedCircletName); + if (circlet != null) { + return true; + } + + // Locate circlet + if (_localCirclet == null) { + var brollyFsm = HeroController.instance.fsm_brollyControl; + var spawner = brollyFsm.GetFirstAction("Damager?"); + _localCirclet = spawner.gameObject.Value; + } + + // Spawn in a new circlet + circlet = EffectUtils.SpawnGlobalPoolObject(_localCirclet, effects.transform, 0, true); + if (circlet == null) { + return false; + } + + circlet.name = SpikedCircletName; + circlet.transform.localPosition = new Vector3(0, 0, -0.02f); + circlet.transform.localScale = new Vector3(-1, -1, 1); + circlet.DestroyComponent(); + circlet.tag = "Untagged"; + + return true; + } +} diff --git a/SSMP/Api/Server/IServerSettings.cs b/SSMP/Api/Server/IServerSettings.cs index 28d8c361..fab4e5bb 100644 --- a/SSMP/Api/Server/IServerSettings.cs +++ b/SSMP/Api/Server/IServerSettings.cs @@ -105,4 +105,14 @@ public interface IServerSettings { /// The number of masks of damage that the upgraded Claw Mirror deals. /// byte ClawMirrorUpgradedDamage { get; } + + /// + /// The number of masks of damage that Sawtooth Circlet deals. + /// + byte SawtoothCircletDamage { get; } + + /// + /// The number of masks of damage that a cloud from the poisoned Flea Brew deals. + /// + byte PoisonBrewDamage { get; } } diff --git a/SSMP/Game/Client/PlayerManager.cs b/SSMP/Game/Client/PlayerManager.cs index 13d77718..f3f48a5b 100644 --- a/SSMP/Game/Client/PlayerManager.cs +++ b/SSMP/Game/Client/PlayerManager.cs @@ -202,6 +202,12 @@ private void TryCreatePlayerPool() { rigidbody.gravityScale = 0; rigidbody.bodyType = RigidbodyType2D.Kinematic; + // Set up sprite flash. Must be deactivated beforehand to fill properties in awake + playerPrefab.SetActive(false); + var flash = playerPrefab.AddComponent(); + flash.parents = []; + flash.children = []; + // Add some extra gameObjects related to animation effects new GameObject("Attacks").transform.SetParent(playerPrefab.transform); new GameObject("Bind Effects").transform.SetParent(playerPrefab.transform); diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index 93a608d5..e15c95f6 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -84,6 +84,11 @@ public bool AllowSkins { ChangeEvent?.Invoke(nameof(AllowSkins)); } } = true; + + // /// + // [SettingAlias("parries")] + // [ModMenuSetting("Parries", "Whether parrying certain player attacks is possible")] + // public bool AllowParries { get; set; } = true; /// [SettingAlias("needledmg")] @@ -241,6 +246,30 @@ public byte ClawMirrorUpgradedDamage { } } = 1; + /// + [SettingAlias("sawtoothcircletdmg", "circletdmg", "umbrelladmg")] + [ModMenuSetting("Sawtooth Circlet Damage", "The number of masks of damage that Sawtooth Circlet deals")] + public byte SawtoothCircletDamage { + get; + init { + if (field == value) return; + field = value; + ChangeEvent?.Invoke(nameof(SawtoothCircletDamage)); + } + } = 1; + + /// + [SettingAlias("poisonbrewdmg", "brewdmg", "fleabrewdmg")] + [ModMenuSetting("Poisoned Flea Brew Damage", "The number of masks of damage that a cloud from the poisoned Flea Brew deals")] + public byte PoisonBrewDamage { + get; + init { + if (field == value) return; + field = value; + ChangeEvent?.Invoke(nameof(PoisonBrewDamage)); + } + } = 1; + /// /// Set all properties in this instance to the values from the given /// instance. diff --git a/SSMP/Hooks/EventHooks.cs b/SSMP/Hooks/EventHooks.cs index a1954aa7..e70485a5 100644 --- a/SSMP/Hooks/EventHooks.cs +++ b/SSMP/Hooks/EventHooks.cs @@ -81,6 +81,11 @@ public static class EventHooks { /// private static Hook? _heroControllerDieHook; + /// + /// Hook for HeroControllerUseLavaBell + /// + private static Hook? _heroControllerUseLavaBellHook; + /// /// Hook for GameMap.PositionCompassAndCorpse. /// @@ -173,6 +178,11 @@ public static class EventHooks { /// public static event Action? HeroControllerDie; + /// + /// Event that is called when HeroController.UseLavaBell is called. + /// + public static event Action? UseLavaBell; + /// /// Event that is called when GameMap.PositionCompassAndCorpse is called. /// @@ -303,6 +313,11 @@ internal static void Initialize() { OnHeroControllerDie ); + _heroControllerUseLavaBellHook = new Hook( + typeof(HeroController).GetMethod(nameof(HeroController.UseLavaBell), BindingFlags), + OnUseLavaBell + ); + _gameMapPositionCompassAndCorpseHook = new Hook( typeof(GameMap).GetMethod(nameof(GameMap.PositionCompassAndCorpse)), OnGameMapPositionCompassAndCorpse @@ -475,6 +490,12 @@ bool frostDeath return orig(self, nonLethal, frostDeath); } + private static void OnUseLavaBell(Action orig, HeroController self) { + orig(self); + + UseLavaBell?.Invoke(); + } + private static void OnGameMapPositionCompassAndCorpse(Action orig, GameMap self) { orig(self); diff --git a/SSMP/Util/EffectOwnerComponent.cs b/SSMP/Util/EffectOwnerComponent.cs new file mode 100644 index 00000000..36a6c928 --- /dev/null +++ b/SSMP/Util/EffectOwnerComponent.cs @@ -0,0 +1,13 @@ +using UnityEngine; + +namespace SSMP.Util; + +/// +/// Associates an effect object with the player object it belongs to. +/// +public class EffectOwnerComponent : MonoBehaviour { + /// + /// The owner of the effect. + /// + public GameObject? Owner; +}