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;
+}