Forráskód Böngészése

Fix MPS energy balls (#416)

* Change game effects to only become visible on TriggerEffect.

Signed-off-by: Mike Balfour <[email protected]>

* Add effect cleanups on Deactivate.

Signed-off-by: Mike Balfour <[email protected]>

* Add more cleanup and add a warning if we're going to leak entities.

Signed-off-by: Mike Balfour <[email protected]>

* Fix debug drawing and clean up a bit.

Signed-off-by: Mike Balfour <[email protected]>

* Make sure we don't leak emitters.

Signed-off-by: Mike Balfour <[email protected]>

* Add the concept of fire-and-forget game effects.

Signed-off-by: Mike Balfour <[email protected]>

* Extend the serialization for HitEvent so it can become a networked property

Signed-off-by: Mike Balfour <[email protected]>

* Simplify energy ball lifetime logic

Signed-off-by: Mike Balfour <[email protected]>

* Remove debugging code.

Signed-off-by: Mike Balfour <[email protected]>

* Add guard against double-initialization.

Signed-off-by: Mike Balfour <[email protected]>

---------

Signed-off-by: Mike Balfour <[email protected]>
Mike Balfour 2 éve
szülő
commit
7955d0913b

+ 1 - 6
Gem/Code/Source/AutoGen/EnergyBallComponent.AutoComponent.xml

@@ -17,18 +17,13 @@
     <ArchetypeProperty Type="GatherParams" Name="GatherParams" Init="" ExposeToEditor="true" Description="Specifies the types of intersections to test for on the projectile" />
     <ArchetypeProperty Type="HitEffect" Name="HitEffect" Init="" ExposeToEditor="true" Description="Specifies the damage effects to apply on hit" />
     <ArchetypeProperty Type="AZ::TimeMs" Name="LifetimeMs" Init="AZ::TimeMs{ 0 }" Container="Object" ExposeToEditor="true" Description="Specifies the duration in milliseconds that the projectile should live for" />
-    <ArchetypeProperty Type="AZ::TimeMs" Name="LingertimeMs" Init="AZ::TimeMs{ 0 }" Container="Object" ExposeToEditor="true" Description="Specifies how many milliseconds the entity should persist after death to allow it to run it's explosion effects" />
 
     <NetworkProperty Type="AZ::Vector3" Name="Velocity" Init="AZ::Vector3::CreateZero()" ReplicateFrom="Authority" ReplicateTo="Client" Container="Object" IsPublic="true" IsRewindable="true" IsPredictable="false" ExposeToScript="true" ExposeToEditor="false" GenerateEventBindings="true" Description="The energy balls current velocity" />
-    <NetworkProperty Type="bool" Name="BallActive" Init="false" ReplicateFrom="Authority" ReplicateTo="Client" Container="Object" IsPublic="true" IsRewindable="true" IsPredictable="false" ExposeToScript="true" ExposeToEditor="false" GenerateEventBindings="true" Description="Track whether or not the energy ball is currently active" />
+    <NetworkProperty Type="HitEvent" Name="HitEvent" Init="{}" ReplicateFrom="Authority" ReplicateTo="Client" Container="Object" IsPublic="true" IsRewindable="false" IsPredictable="false" ExposeToScript="true" ExposeToEditor="false" GenerateEventBindings="true" Description="Contains the hit information when the ball explodes." />
 
     <RemoteProcedure Name="RPC_LaunchBall" InvokeFrom="Server" HandleOn="Authority" IsPublic="true" IsReliable="true" GenerateEventBindings="true" Description="Launch an energy ball from a specified position in a specified direction.">
         <Param Type="AZ::Vector3" Name="StartingPosition"/>
         <Param Type="AZ::Vector3" Name="Direction"/>
         <Param Type="Multiplayer::NetEntityId" Name="OwningNetEntityId" />
     </RemoteProcedure>
-
-    <RemoteProcedure Name="RPC_BallExplosion" InvokeFrom="Authority" HandleOn="Client" IsPublic="true" IsReliable="true" GenerateEventBindings="true" Description="Triggered on clients whenever an energy ball explodes.">
-        <Param Type="HitEvent" Name="HitEvent"/>
-    </RemoteProcedure>
 </Component>

+ 37 - 90
Gem/Code/Source/Components/Multiplayer/EnergyBallComponent.cpp

@@ -25,6 +25,7 @@ namespace MultiplayerSample
 {
     AZ_CVAR(float, sv_EnergyBallImpulseScalar, 500.0f, nullptr, AZ::ConsoleFunctorFlags::Null, "A fudge factor for imparting impulses on rigid bodies due to weapon hits");
     AZ_CVAR(bool, cl_EnergyBallDebugDraw, false, nullptr, AZ::ConsoleFunctorFlags::DontReplicate, "When turned on this will draw the current energy ball location");
+    AZ_CVAR(float, cl_EnergyBallDebugDrawSeconds, 0.0f, nullptr, AZ::ConsoleFunctorFlags::DontReplicate, "The number of seconds of draw history to preserve for the energy ball");
 
     void EnergyBallComponent::Reflect(AZ::ReflectContext* context)
     {
@@ -39,66 +40,41 @@ namespace MultiplayerSample
 
     void EnergyBallComponent::OnActivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
     {
+#if AZ_TRAIT_CLIENT
         m_effect = GetExplosionEffect();
-        m_effect.Initialize();
+        m_effect.Initialize(GameEffect::EmitterType::FireAndForget);
 
-#if AZ_TRAIT_CLIENT
-        BallActiveAddEvent(m_ballActiveHandler);
+        AZ::EntityBus::Handler::BusConnect(GetEntityId());
+        if (cl_EnergyBallDebugDraw)
+        {
+            m_debugDrawEvent.Enqueue(AZ::TimeMs{ 0 }, true);
+        }
 #endif
     }
 
     void EnergyBallComponent::OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
     {
 #if AZ_TRAIT_CLIENT
-        m_ballActiveHandler.Disconnect();
+        m_effect = {};
+        AZ::EntityBus::Handler::BusDisconnect();
+        m_debugDrawEvent.RemoveFromQueue();
 #endif
     }
 
 #if AZ_TRAIT_CLIENT
-    void EnergyBallComponent::OnBallActiveChanged(bool active)
+    void EnergyBallComponent::OnEntityDeactivated([[maybe_unused]] const AZ::EntityId& entityId)
     {
-        if (active)
-        {
-            bool startSuccess = false;
-
-            // Set to true to call "Kill" which is deferred, or false to call "Terminate" which is immediate.
-            constexpr bool KillOnRestart = true;
-
-            PopcornFX::PopcornFXEmitterComponentRequestBus::EventResult(startSuccess,
-                GetEntity()->GetId(), &PopcornFX::PopcornFXEmitterComponentRequestBus::Events::Restart, KillOnRestart);
-
-            AZ_Error("EnergyBall", startSuccess, "Restart call for Energy Ball was unsuccessful.");
-
-            if (cl_EnergyBallDebugDraw)
-            {
-                m_debugDrawEvent.Enqueue(AZ::TimeMs{ 0 }, true);
-            }
-        }
-        else
-        {
-            // Create an explosion effect wherever the ball was last at.
-            m_effect.TriggerEffect(GetEntity()->GetTransform()->GetWorldTM());
-
-            bool killSuccess = false;
-
-            // This would ideally use Kill instead of Terminate, but there is a bug in PopcornFX 2.15.4 that if Kill is
-            // called on the first tick (which can happen), then the effect will get stuck in a permanent waiting-to-die state,
-            // and no amount of Restart calls will ever make it show up again.
-            PopcornFX::PopcornFXEmitterComponentRequestBus::EventResult(killSuccess,
-                GetEntity()->GetId(), &PopcornFX::PopcornFXEmitterComponentRequestBus::Events::Terminate);
-
-            AZ_Error("EnergyBall", killSuccess, "Kill call for Energy Ball was unsuccessful.");
-
-            m_debugDrawEvent.RemoveFromQueue();
-        }
-    }
+        // Perform hit / explosion logic when this entity deactivates, but *before* the deactivation sequence is
+        // actually running. This allows us to call the WeaponsNotificationBus to notify other components (like Script Canvas)
+        // on this entity to perform hit logic. If we waited to run this until OnDeactivate, the other components would no
+        // longer be active and wouldn't have a chance to process the logic.
 
-    void EnergyBallComponent::HandleRPC_BallExplosion([[maybe_unused]] AzNetworking::IConnection* invokingConnection, const HitEvent& hitEvent)
-    {
-        // Create an explosion effect at our current location.
+        // Create an explosion effect wherever the ball was last at before deactivating.
         m_effect.TriggerEffect(GetEntity()->GetTransform()->GetWorldTM());
 
-        // Notify every entity that was hit that they've received a weapon impact, this allows for blast decals.
+        auto hitEvent = GetHitEvent();
+
+        // Notify this entity about the weapon impact for every entity that was hit, this allows for blast decals.
         for (const HitEntity& hitEntity : hitEvent.m_hitEntities)
         {
             const AZ::Transform hitTransform = AZ::Transform::CreateLookAt(hitEntity.m_hitPosition, hitEntity.m_hitPosition + hitEntity.m_hitNormal, AZ::Transform::Axis::ZPositive);
@@ -112,9 +88,6 @@ namespace MultiplayerSample
     {
         if (cl_EnergyBallDebugDraw)
         {
-            // Each draw only lasts one frame.
-            constexpr float DrawDuration = 0.0f;
-
             auto* shapeConfig = GetGatherParams().GetCurrentShapeConfiguration();
             if (shapeConfig->GetShapeType() == Physics::ShapeType::Sphere)
             {
@@ -126,7 +99,7 @@ namespace MultiplayerSample
                     GetEntity()->GetTransform()->GetWorldTM().GetTranslation(),
                     debugRadius,
                     AZ::Colors::Green,
-                    DrawDuration
+                    cl_EnergyBallDebugDrawSeconds
                 );
             }
             else if (shapeConfig->GetShapeType() == Physics::ShapeType::Box)
@@ -142,7 +115,7 @@ namespace MultiplayerSample
                     &DebugDraw::DebugDrawRequestBus::Events::DrawObb,
                     obb,
                     AZ::Colors::Green,
-                    DrawDuration
+                    cl_EnergyBallDebugDrawSeconds
                 );
             }
             else if (shapeConfig->GetShapeType() == Physics::ShapeType::Capsule)
@@ -161,33 +134,26 @@ namespace MultiplayerSample
 
     void EnergyBallComponentController::OnActivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
     {
-#if AZ_TRAIT_SERVER
-        SetBallActive(false);
-#endif
     }
 
     void EnergyBallComponentController::OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
     {
 #if AZ_TRAIT_SERVER
-        SetBallActive(false);
+        m_collisionCheckEvent.RemoveFromQueue();
+        m_killEvent.RemoveFromQueue();
 #endif
     }
 
 #if AZ_TRAIT_SERVER
     void EnergyBallComponentController::HandleRPC_LaunchBall(AzNetworking::IConnection* invokingConnection, const AZ::Vector3& startingPosition, const AZ::Vector3& direction, const Multiplayer::NetEntityId& owningNetEntityId)
     {
-        if (GetBallActive())
-        {
-            return;
-        }
+        AZ_Assert(!m_killEvent.IsScheduled(), "Launching the same ball more than once isn't supported.");
 
         m_collisionCheckEvent.Enqueue(AZ::TimeMs{ 10 }, true);
 
-        SetBallActive(true);
         SetVelocity(direction * GetGatherParams().m_travelSpeed);
 
-        m_hitEvent.m_hitEntities.clear();
-
+        m_shooterNetEntityId = owningNetEntityId;
         m_filteredNetEntityIds.clear();
         m_filteredNetEntityIds.insert(owningNetEntityId);
         m_filteredNetEntityIds.insert(GetNetEntityId());
@@ -205,11 +171,6 @@ namespace MultiplayerSample
 
     void EnergyBallComponentController::CheckForCollisions()
     {
-        if (!GetBallActive())
-        {
-            return;
-        }
-
         const AZ::Vector3& position = GetEntity()->GetTransform()->GetWorldTM().GetTranslation();
         const HitEffect& effect = GetHitEffect();
 
@@ -224,7 +185,7 @@ namespace MultiplayerSample
             for (const IntersectResult& result : results)
             {
                 const HitEntity hitEntity{ result.m_position, result.m_normal, result.m_netEntityId };
-                m_hitEvent.m_hitEntities.emplace_back(hitEntity);
+                ModifyHitEvent().m_hitEntities.emplace_back(hitEntity);
 
                 const Multiplayer::ConstNetworkEntityHandle handle = Multiplayer::GetNetworkEntityManager()->GetEntity(result.m_netEntityId);
                 if (handle.Exists())
@@ -260,35 +221,21 @@ namespace MultiplayerSample
 
     void EnergyBallComponentController::KillEnergyBall()
     {
-        if (!GetBallActive())
-        {
-            return;
-        }
-
-        SetBallActive(false);
         m_collisionCheckEvent.RemoveFromQueue();
+        m_killEvent.RemoveFromQueue();
 
-        m_hitEvent.m_target = GetEntity()->GetTransform()->GetWorldTM().GetTranslation();
-        m_hitEvent.m_shooterNetEntityId = m_shooterNetEntityId;
-        m_hitEvent.m_projectileNetEntityId = GetNetEntityId();
+        SetVelocity(AZ::Vector3::CreateZero());
 
-        RPC_BallExplosion(m_hitEvent);
+        auto& hitEvent = ModifyHitEvent();
 
-        // Wait 5 seconds before cleaning up the entity so that the explosion effect has a chance to play out
-        // Capture just the netEntityId in case we have a level change or some other operation that clears out entities before our lambda triggers
+        hitEvent.m_target = GetEntity()->GetTransform()->GetWorldTM().GetTranslation();
+        hitEvent.m_shooterNetEntityId = m_shooterNetEntityId;
+        hitEvent.m_projectileNetEntityId = GetNetEntityId();
+
+        // Immediately remove the entity.
         const Multiplayer::NetEntityId netEntityId = GetNetEntityId();
-        AZ::Interface<AZ::IEventScheduler>::Get()->AddCallback([netEntityId]
-            {
-                // Fetch the entity handle, ensure it's still valid
-                const Multiplayer::ConstNetworkEntityHandle entityHandle = Multiplayer::GetNetworkEntityManager()->GetEntity(netEntityId);
-                if (entityHandle.Exists())
-                {
-                    Multiplayer::GetNetworkEntityManager()->MarkForRemoval(entityHandle);
-                }
-            },
-            AZ::Name("Cleanup"),
-            GetLingertimeMs()
-        );
+        const Multiplayer::ConstNetworkEntityHandle entityHandle = Multiplayer::GetNetworkEntityManager()->GetEntity(netEntityId);
+        Multiplayer::GetNetworkEntityManager()->MarkForRemoval(entityHandle);
     }
 #endif
 }

+ 3 - 12
Gem/Code/Source/Components/Multiplayer/EnergyBallComponent.h

@@ -7,6 +7,7 @@
 
 #pragma once
 
+#include <AzCore/Component/EntityBus.h>
 #include <Source/AutoGen/EnergyBallComponent.AutoComponent.h>
 #include <Source/Weapons/WeaponGathers.h>
 
@@ -14,6 +15,7 @@ namespace MultiplayerSample
 {
     class EnergyBallComponent
         : public EnergyBallComponentBase
+        , public AZ::EntityBus::Handler
     {
     public:
         AZ_MULTIPLAYER_COMPONENT(MultiplayerSample::EnergyBallComponent, s_energyBallComponentConcreteUuid, MultiplayerSample::EnergyBallComponentBase);
@@ -23,24 +25,15 @@ namespace MultiplayerSample
         void OnActivate(Multiplayer::EntityIsMigrating entityIsMigrating) override;
         void OnDeactivate(Multiplayer::EntityIsMigrating entityIsMigrating) override;
 
-#if AZ_TRAIT_CLIENT
-        void HandleRPC_BallExplosion(AzNetworking::IConnection* invokingConnection, const HitEvent& hitEvent) override;
-#endif
-
     private:
 #if AZ_TRAIT_CLIENT
+        void OnEntityDeactivated(const AZ::EntityId&) override;
         void DebugDraw();
-        void OnBallActiveChanged(bool active);
 
         AZ::ScheduledEvent m_debugDrawEvent{ [this]()
         {
             DebugDraw();
         }, AZ::Name("EnergyBallDebugDraw") };
-
-        AZ::Event<bool>::Handler m_ballActiveHandler{ [this](bool active)
-        {
-            OnBallActiveChanged(active);
-        } };
 #endif
 
         GameEffect m_effect;
@@ -75,8 +68,6 @@ namespace MultiplayerSample
         AZ::Transform m_lastSweepTransform = AZ::Transform::CreateIdentity();
         Multiplayer::NetEntityId m_shooterNetEntityId = Multiplayer::InvalidNetEntityId;
         NetEntityIdSet m_filteredNetEntityIds;
-
-        HitEvent m_hitEvent;
 #endif
     };
 }

+ 8 - 2
Gem/Code/Source/Components/Multiplayer/EnergyCannonComponent.cpp

@@ -34,6 +34,9 @@ namespace MultiplayerSample
 
     void EnergyCannonComponent::OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
     {
+#if AZ_TRAIT_CLIENT
+        m_effect = {};
+#endif
     }
 
 #if AZ_TRAIT_CLIENT
@@ -67,6 +70,7 @@ namespace MultiplayerSample
     void EnergyCannonComponentController::OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
     {
 #if AZ_TRAIT_SERVER
+        m_triggerBuildupEvent.RemoveFromQueue();
         m_firingEvent.RemoveFromQueue();
 #endif
     }
@@ -95,13 +99,15 @@ namespace MultiplayerSample
             Multiplayer::GetNetworkEntityManager()->CreateEntitiesImmediate(prefabEntityId, Multiplayer::NetEntityRole::Authority, transform);
 
         Multiplayer::NetworkEntityHandle spawnedEntity;
-        if (!entityList.empty())
+        if (entityList.size() == 1)
         {
             spawnedEntity = entityList[0];
         }
         else
         {
-            AZLOG_WARN("Attempt to spawn prefab %s failed. Check that prefab is network enabled.", prefabEntityId.m_prefabName.GetCStr());
+            AZLOG_WARN("Attempt to spawn prefab %s failed. Check that prefab is network enabled and only contains a single entity. "
+                "If multiple entities are in the prefab, only the first one will get deleted. Spawn count: %zu", 
+                prefabEntityId.m_prefabName.GetCStr(), entityList.size());
         }
 
         if (EnergyBallComponent* ballComponent = spawnedEntity.FindComponent<EnergyBallComponent>())

+ 8 - 0
Gem/Code/Source/Components/NetworkTeleportCompatibleComponent.cpp

@@ -33,6 +33,14 @@ namespace MultiplayerSample
 #endif
     }
 
+    void NetworkTeleportCompatibleComponent::OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
+    {
+#if AZ_TRAIT_CLIENT
+        // Clean up the teleport effect emitter.
+        m_effect = {};
+#endif
+    }
+
 #if AZ_TRAIT_CLIENT
     void NetworkTeleportCompatibleComponent::HandleNotifyTeleport([[maybe_unused]] AzNetworking::IConnection* invokingConnection, const AZ::Vector3& teleportedLocation)
     {

+ 1 - 1
Gem/Code/Source/Components/NetworkTeleportCompatibleComponent.h

@@ -24,7 +24,7 @@ namespace MultiplayerSample
 
         void OnInit() override {}
         void OnActivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating) override;
-        void OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating) override {};
+        void OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating) override;
 
 #if AZ_TRAIT_CLIENT
         void HandleNotifyTeleport(AzNetworking::IConnection* invokingConnection, const AZ::Vector3& teleportedLocation) override;

+ 8 - 0
Gem/Code/Source/Components/NetworkTeleportComponent.cpp

@@ -40,6 +40,14 @@ namespace MultiplayerSample
 #endif
     }
 
+    void NetworkTeleportComponent::OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating)
+    {
+#if AZ_TRAIT_CLIENT
+        // Clean up the teleport effect emitter.
+        m_effect = {};
+#endif
+    }
+
 #if AZ_TRAIT_CLIENT
     void NetworkTeleportComponent::HandleNotifyTeleport([[maybe_unused]] AzNetworking::IConnection* invokingConnection)
     {

+ 1 - 1
Gem/Code/Source/Components/NetworkTeleportComponent.h

@@ -30,7 +30,7 @@ namespace MultiplayerSample
 
         void OnInit() override {};
         void OnActivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating) override;
-        void OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating) override {};
+        void OnDeactivate([[maybe_unused]] Multiplayer::EntityIsMigrating entityIsMigrating) override;
 
 #if AZ_TRAIT_CLIENT
         void HandleNotifyTeleport(AzNetworking::IConnection* invokingConnection) override;

+ 101 - 31
Gem/Code/Source/Effects/GameEffect.cpp

@@ -15,7 +15,7 @@
 
 namespace MultiplayerSample
 {
-    AZ_CVAR(bool, cl_KillEffectOnRestart, false, nullptr, AZ::ConsoleFunctorFlags::Null, "Controls whether or not to kill current effects on restart");
+    AZ_CVAR(bool, cl_KillEffectOnRestart, false, nullptr, AZ::ConsoleFunctorFlags::Null, "Controls whether to kill or terminate current effects on restart");
 
     void GameEffect::Reflect(AZ::ReflectContext* context)
     {
@@ -44,41 +44,85 @@ namespace MultiplayerSample
     }
 
     GameEffect::~GameEffect()
+    {
+        Destroy();
+    }
+
+    GameEffect::GameEffect(const GameEffect& gameEffect)
+    {
+        *this = gameEffect;
+    }
+
+    GameEffect& GameEffect::operator=(const GameEffect& gameEffect)
+    {
+        // Make sure the current emitter is destroyed before copying new settings over this one.
+        Destroy();
+
+        // Only copy the effect settings, but leave it in an uninitialized state. Each GameEffect instance should
+        // have its own emitter to manipulate and move around.
+        m_particleAssetId = gameEffect.m_particleAssetId;
+        m_audioTrigger = gameEffect.m_audioTrigger;
+        m_effectOffset = gameEffect.m_effectOffset;
+
+        return *this;
+    }
+
+    void GameEffect::Destroy()
     {
 #if AZ_TRAIT_CLIENT
-        if (m_popcornFx != nullptr)
+        if (m_popcornFx && m_emitter)
         {
             if (m_popcornFx->IsEffectAlive(m_emitter))
             {
                 m_popcornFx->DestroyEffect(m_emitter);
             }
-            m_emitter = nullptr;
         }
 
-        if (m_audioSystem != nullptr)
+        if (m_audioSystem && m_audioProxy)
         {
-            m_audioTriggerId = INVALID_AUDIO_CONTROL_ID;
-            if (m_audioProxy != nullptr)
-            {
-                m_audioSystem->RecycleAudioProxy(m_audioProxy);
-                m_audioProxy = nullptr;
-            }
+            m_audioSystem->RecycleAudioProxy(m_audioProxy);
         }
+
+        // Clear all of these out so that we know we need to call Initialize() again.
+        m_popcornFx = nullptr;
+        m_audioSystem = nullptr;
+        m_emitter = nullptr;
+        m_audioProxy = nullptr;
+        m_audioTriggerId = INVALID_AUDIO_CONTROL_ID;
 #endif
     }
 
-    void GameEffect::Initialize()
+    void GameEffect::Initialize([[maybe_unused]] EmitterType emitterType)
     {
 #if AZ_TRAIT_CLIENT
+        AZ_Assert(!IsInitialized(), "Destroy() needs to be called before calling Initialize() for a second time.");
+        if (IsInitialized())
+        {
+            return;
+        }
+
         m_popcornFx = PopcornFX::PopcornFXRequestBus::FindFirstHandler();
         m_audioSystem = AZ::Interface<Audio::IAudioSystem>::Get();
+        m_emitterType = emitterType;
 
         if (m_popcornFx != nullptr)
         {
             if (m_particleAssetId.IsValid())
             {
-                const PopcornFX::SpawnParams params = PopcornFX::SpawnParams(true, false, AZ::Transform::CreateIdentity());
-                m_emitter = m_popcornFx->SpawnEffectById(m_particleAssetId, params);
+                if (m_emitterType == EmitterType::ReusableEmitter)
+                {
+                    // Spawn the emitter in a disabled state, and set it to always exist (i.e. don't auto-remove).
+                    // GameEffect will keep reusing the same emitter.
+                    constexpr bool EffectEnabled = false;
+                    constexpr bool AutoRemove = false;
+                    const PopcornFX::SpawnParams params = PopcornFX::SpawnParams(EffectEnabled, AutoRemove, AZ::Transform::CreateIdentity());
+                    m_emitter = m_popcornFx->SpawnEffectById(m_particleAssetId, params);
+                }
+                else
+                {
+                    // Don't spawn anything, just preload the particle asset.
+                    m_popcornFx->PreloadEffectById(m_particleAssetId);
+                }
             }
         }
 
@@ -92,10 +136,20 @@ namespace MultiplayerSample
 #endif
     }
 
+    bool GameEffect::IsInitialized() const
+    {
+#if AZ_TRAIT_CLIENT
+        return (m_popcornFx && m_emitter);
+#else
+        return true;
+#endif
+    }
+
     bool GameEffect::SetAttribute([[maybe_unused]] const char* attributeName, [[maybe_unused]] float value) const
     {
 #if AZ_TRAIT_CLIENT
-        if (m_popcornFx != nullptr)
+        AZ_Assert(m_emitterType == EmitterType::ReusableEmitter, "SetAttribute only supports reusable emitters.");
+        if (m_popcornFx && m_emitter)
         {
             if (m_popcornFx->IsEffectAlive(m_emitter))
             {
@@ -118,7 +172,8 @@ namespace MultiplayerSample
     bool GameEffect::SetAttribute([[maybe_unused]] const char* attributeName, [[maybe_unused]] const AZ::Vector2& value) const
     {
 #if AZ_TRAIT_CLIENT
-        if (m_popcornFx != nullptr)
+        AZ_Assert(m_emitterType == EmitterType::ReusableEmitter, "SetAttribute only supports reusable emitters.");
+        if (m_popcornFx && m_emitter)
         {
             if (m_popcornFx->IsEffectAlive(m_emitter))
             {
@@ -141,7 +196,8 @@ namespace MultiplayerSample
     bool GameEffect::SetAttribute([[maybe_unused]] const char* attributeName, [[maybe_unused]] const AZ::Vector3& value) const
     {
 #if AZ_TRAIT_CLIENT
-        if (m_popcornFx != nullptr)
+        AZ_Assert(m_emitterType == EmitterType::ReusableEmitter, "SetAttribute only supports reusable emitters.");
+        if (m_popcornFx && m_emitter)
         {
             if (m_popcornFx->IsEffectAlive(m_emitter))
             {
@@ -164,7 +220,8 @@ namespace MultiplayerSample
     bool GameEffect::SetAttribute([[maybe_unused]] const char* attributeName, [[maybe_unused]] const AZ::Vector4& value) const
     {
 #if AZ_TRAIT_CLIENT
-        if (m_popcornFx != nullptr)
+        AZ_Assert(m_emitterType == EmitterType::ReusableEmitter, "SetAttribute only supports reusable emitters.");
+        if (m_popcornFx && m_emitter)
         {
             if (m_popcornFx->IsEffectAlive(m_emitter))
             {
@@ -189,24 +246,40 @@ namespace MultiplayerSample
 #if AZ_TRAIT_CLIENT
         const AZ::Vector3 offsetPosition = transform.TransformPoint(m_effectOffset);
 
-        if (m_emitter != nullptr)
+        if (m_popcornFx)
         {
-            if (PopcornFX::PopcornFXRequests* popcornFx = PopcornFX::PopcornFXRequestBus::FindFirstHandler())
+            AZ::Transform transformOffset = transform;
+            transformOffset.SetTranslation(offsetPosition);
+
+            if (m_emitterType == EmitterType::ReusableEmitter)
             {
-                if (m_popcornFx->IsEffectAlive(m_emitter))
+                if (m_emitter && m_popcornFx->IsEffectAlive(m_emitter))
                 {
-                    AZ::Transform transformOffset = transform;
-                    transformOffset.SetTranslation(offsetPosition);
+                    m_popcornFx->EffectSetTransform(m_emitter, transformOffset);
+                    m_popcornFx->EffectSetTeleportThisFrame(m_emitter);
+
+                    // It's important to do this *after* setting the transform. Otherwise, on the first time we trigger the effect,
+                    // it will spawn the effect briefly at (0, 0, 0) before moving and restarting it.
 
-                    popcornFx->EffectSetTransform(m_emitter, transformOffset);
-                    popcornFx->EffectSetTeleportThisFrame(m_emitter);
-                    popcornFx->EffectRestart(m_emitter, cl_KillEffectOnRestart);
+                    m_popcornFx->EffectEnable(m_emitter, true);
+
+                    // EffectRestart will either Kill or Terminate the effect on restart.
+                    m_popcornFx->EffectRestart(m_emitter, cl_KillEffectOnRestart);
                 }
                 else
                 {
                     AZ_Assert(false, "Triggering an inactive emitter.");
                 }
             }
+            else
+            {
+                // For fire-and-forget, spawn a new effect on each call to TriggerEffect(), and set it to auto-remove.
+                // We won't track it, and it will continue to run even if this GameEffect instance gets destroyed.
+                constexpr bool EffectEnabled = true;
+                constexpr bool AutoRemove = true;
+                const PopcornFX::SpawnParams params = PopcornFX::SpawnParams(EffectEnabled, AutoRemove, transformOffset);
+                m_popcornFx->SpawnEffectById(m_particleAssetId, params);
+            }
         }
 
         if ((m_audioProxy != nullptr) && (m_audioTriggerId != INVALID_AUDIO_CONTROL_ID))
@@ -220,14 +293,11 @@ namespace MultiplayerSample
     void GameEffect::StopEffect() const
     {
 #if AZ_TRAIT_CLIENT
-        if (m_emitter != nullptr)
+        if (m_popcornFx && m_emitter)
         {
-            if (PopcornFX::PopcornFXRequests* popcornFx = PopcornFX::PopcornFXRequestBus::FindFirstHandler())
+            if (m_popcornFx->IsEffectAlive(m_emitter))
             {
-                if (m_popcornFx->IsEffectAlive(m_emitter))
-                {
-                    m_popcornFx->EffectKill(m_emitter);
-                }
+                m_popcornFx->EffectKill(m_emitter);
             }
         }
 #endif

+ 25 - 2
Gem/Code/Source/Effects/GameEffect.h

@@ -31,16 +31,36 @@ namespace MultiplayerSample
     class GameEffect final
     {
     public:
+        enum class EmitterType
+        {
+            // Fire-and-forget emitters will create a new emitter on each TriggerEffect and won't be tracked by GameEffect,
+            // so they will always run until they're done and then get auto-removed by PopcornFX.
+            FireAndForget,
+
+            // Reusable emitters create a single emitter per GameEffect and will reuse the emitter on each TriggerEffect.
+            // The emitter will be immediately destroyed when the GameEffect is destroyed.
+            ReusableEmitter
+        };
+        
         AZ_TYPE_INFO(GameEffect, "{E9A6959E-C52A-4BCF-907A-C880C2BD94F0}");
         static void Reflect(AZ::ReflectContext* context);
 
         GameEffect() = default;
+        GameEffect(const GameEffect& gameEffect);
+        GameEffect& operator=(const GameEffect& gameEffect);
         ~GameEffect();
 
-        //! Initializes the effect.
-        void Initialize();
+        //! Initializes the effect emitter.
+        void Initialize(EmitterType emitterType = EmitterType::ReusableEmitter);
+
+        //! Destroys the effect emitter;
+        void Destroy();
+
+        //! True if the effect is initialized, false if it isn't.
+        bool IsInitialized() const;
 
         //! Setters for setting custom effect attributes.
+        //! These only work for reusable emitters because we don't track the emitter pointer for fire-and-forget emitters.
         //! @{
         bool SetAttribute(const char* attributeName, float value) const;
         bool SetAttribute(const char* attributeName, const AZ::Vector2& value) const;
@@ -64,6 +84,9 @@ namespace MultiplayerSample
         AZStd::string m_audioTrigger; // The name of the audio trigger to use on effect activation
         AZ::Vector3 m_effectOffset = AZ::Vector3::CreateZero(); // The offset to use when triggering an effect
 
+        // Tracks whether to reuse the emitter or to fire-and-forget each effect trigger.
+        EmitterType m_emitterType = EmitterType::ReusableEmitter; 
+
 #if AZ_TRAIT_CLIENT
         PopcornFX::StandaloneEmitter* m_emitter = nullptr;
         Audio::IAudioProxy* m_audioProxy = nullptr;

+ 27 - 0
Gem/Code/Source/Weapons/WeaponTypes.cpp

@@ -379,10 +379,35 @@ namespace MultiplayerSample
         }
     }
 
+    bool HitEvent::operator!=(const HitEvent& rhs) const
+    {
+        if ((m_target != rhs.m_target) ||
+            (m_shooterNetEntityId != rhs.m_shooterNetEntityId) ||
+            (m_projectileNetEntityId != rhs.m_projectileNetEntityId) ||
+            (m_hitEntities.size() != rhs.m_hitEntities.size()))
+        {
+            return true;
+        }
+
+        // We define equality here as having the same entries in the same order.
+        for (size_t index = 0; index < m_hitEntities.size(); index++)
+        {
+            if ((m_hitEntities[index].m_hitNetEntityId != rhs.m_hitEntities[index].m_hitNetEntityId) ||
+                (!m_hitEntities[index].m_hitPosition.IsClose(rhs.m_hitEntities[index].m_hitPosition)) ||
+                (!m_hitEntities[index].m_hitNormal.IsClose(rhs.m_hitEntities[index].m_hitNormal)))
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     bool HitEvent::Serialize(AzNetworking::ISerializer& serializer)
     {
         return serializer.Serialize(m_target, "Target")
             && serializer.Serialize(m_shooterNetEntityId, "ShooterNetEntityId")
+            && serializer.Serialize(m_projectileNetEntityId, "ProjectileNetEntityId")
             && serializer.Serialize(m_hitEntities, "HitEntities");
     }
 
@@ -395,6 +420,7 @@ namespace MultiplayerSample
                 ->Version(1)
                 ->Field("Target", &HitEvent::m_target)
                 ->Field("ShooterNetEntityId", &HitEvent::m_shooterNetEntityId)
+                ->Field("ProjectileNetEntityId", &HitEvent::m_projectileNetEntityId)
                 ->Field("HitEntities", &HitEvent::m_hitEntities);
         }
 
@@ -408,6 +434,7 @@ namespace MultiplayerSample
                 ->Constructor<>()
                 ->Property("Target", BehaviorValueProperty(&HitEvent::m_target))
                 ->Property("ShooterNetEntityId", BehaviorValueProperty(&HitEvent::m_shooterNetEntityId))
+                ->Property("ProjectileNetEntityId", BehaviorValueProperty(&HitEvent::m_projectileNetEntityId))
                 ->Property("HitEntities", BehaviorValueProperty(&HitEvent::m_hitEntities))
                 ;
         }

+ 1 - 0
Gem/Code/Source/Weapons/WeaponTypes.h

@@ -191,6 +191,7 @@ namespace MultiplayerSample
         Multiplayer::NetEntityId m_projectileNetEntityId = Multiplayer::InvalidNetEntityId; // Entity Id of the projectile, InvalidNetEntityId if this was a trace weapon hit
         HitEntities m_hitEntities; // Information about the entities that were hit
 
+        bool operator!=(const HitEvent& rhs) const;
         bool Serialize(AzNetworking::ISerializer& serializer);
         static void Reflect(AZ::ReflectContext* context);
     };