FxboxParticleSystem.cs
using System;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
namespace fxbox;
/// <summary>
/// Component that integrates FXBox particle systems wit s&box native particle components
/// </summary>
public class FXBoxNativeParticleSystem : Component, Component.ExecuteInEditor, ResourceLibrary.IEventListener, Component.ITemporaryEffect
{
[Property] public ParticleResource ParticleSystem { get; set; }
[Property, Hide] private List<GameObject> Emitters { get; set; } = new();
[Property] public bool PlayOnStart { get; set; } = true;
[Property] public bool DestroyOnEnd { get; set; }
public Vector3 Velocity;
/// <summary>
/// Instance-specific float parameter overrides
/// </summary>
[Property, Group("Parameters"), Title("Float Parameters")]
public Dictionary<string, float> ParameterOverrides { get; set; } = new();
/// <summary>
/// Instance-specific vector parameter overrides
/// </summary>
[Property, Group("Parameters"), Title("Vector Parameters")]
public Dictionary<string, Vector3> VectorParameterOverrides { get; set; } = new();
/// <summary>
/// Instance-specific color parameter overrides
/// </summary>
[Property, Group("Parameters"), Title("Color Parameters")]
public Dictionary<string, ParticleGradient> ColorParameterOverrides { get; set; } = new();
private int _version = 0;
private Vector3 _lastPosition;
protected override void OnFixedUpdate()
{
Velocity = (WorldPosition - _lastPosition);
_lastPosition = WorldPosition;
if (ParticleSystem != null && ParticleSystem.Version != _version)
{
_version = ParticleSystem.Version;
UpdateEmitters();
}
// Editor previews always loop once every emitter is done, regardless of the
// resource's own Looping setting - same convention TracerEffect uses ("Editor
// previews loop instead"). This used to happen by accident: SpawnBurstModule.Execute
// re-firing every tick meant a "finished" burst kept re-triggering itself forever in
// the editor. Now that it's fixed to only fire once (see SpawnBurstModule), a
// finished one-shot system just stays finished with nothing to restart it - so that
// restart needs to be explicit here instead. IsActive is only checked once ALL
// emitters are done (not per-emitter), matching "loop the particles after all of
// them are done emitting" - restarting one on its own the moment IT finishes would
// desync multiple emitters from each other on every subsequent loop.
if ( Scene.IsEditor && !IsActive )
{
RestartAllEmitters();
}
}
private void RestartAllEmitters()
{
foreach ( var emitter in Emitters )
{
if ( !emitter.IsValid() ) continue;
emitter.GetComponent<FXBoxParticleController>()?.ResetTimer();
// Same mechanism FXBoxNativeParticleSystem.Burst() already uses to manually
// re-fire an emitter - re-evaluates StartDelay/Burst/Rate from scratch.
foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>() )
{
particleEmitter.ResetEmitter();
}
}
}
// ==================== FLOAT PARAMETERS ====================
/// <summary>
/// Set a float parameter value for this specific instance
/// </summary>
public void Set(string name, float value)
{
if (ParticleSystem == null) return;
var param = ParticleSystem.FloatParameters?.FirstOrDefault(p => p.Name == name);
if (param == null)
{
//Log.Warning($"Float parameter '{name}' not found in particle system");
return;
}
ParameterOverrides[name] = value;
UpdateParameterValues();
}
public void Burst()
{
foreach ( var emitter in Emitters )
{
foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>( ) )
{
var target = particleEmitter.GetComponent<ParticleEffect>();
particleEmitter.ResetEmitter();
}
}
}
public void Burst( Vector3 worldPosition )
{
foreach ( var emitter in Emitters )
{
foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>( ) )
{
var target = particleEmitter.GetComponent<ParticleEffect>();
for(int i=0; i< particleEmitter.Burst.Evaluate( Time.Delta, Time.Delta ); i++)
{
particleEmitter.Emit( target );
}
}
}
foreach ( var emitter in Emitters )
{
foreach ( var particleEffect in emitter.GetComponentsInChildren<Sandbox.ParticleEffect>( ) )
{
foreach ( var particle in particleEffect.Particles )
{
if ( particle.Age <= 0.01f )
{
particle.Position += worldPosition - particleEffect.WorldTransform.Position;
}
}
}
}
}
/// <summary>
/// Get a float parameter value (override or default)
/// </summary>
public float GetFloatParameter(string name)
{
if (ParameterOverrides.TryGetValue(name, out float overrideValue))
{
return overrideValue;
}
return ParticleSystem?.GetParameterDefault(name) ?? 0f;
}
/// <summary>
/// Reset a float parameter to its default value
/// </summary>
public void ResetParameter(string name)
{
ParameterOverrides.Remove(name);
UpdateParameterValues();
}
// ==================== VECTOR PARAMETERS ====================
/// <summary>
/// Set a vector parameter value for this specific instance
/// </summary>
public void Set(string name, Vector3 value)
{
if (ParticleSystem == null) return;
var param = ParticleSystem.VectorParameters?.FirstOrDefault(p => p.Name == name);
if (param == null)
{
//Log.Warning($"Vector parameter '{name}' not found in particle system");
return;
}
VectorParameterOverrides[name] = value;
UpdateParameterValues();
}
public int GetAliveParticles()
{
var particles = 0;
foreach ( var emitter in Emitters )
{
var target = emitter.GetComponent<ParticleEffect>();
particles += target.ParticleCount;
}
return particles;
}
/// <summary>
/// Get a vector parameter value (override or default)
/// </summary>
public Vector3 GetVectorParameter(string name)
{
if (VectorParameterOverrides.TryGetValue(name, out Vector3 overrideValue))
{
return overrideValue;
}
return ParticleSystem?.GetVectorParameterDefault(name) ?? Vector3.Zero;
}
/// <summary>
/// Reset a vector parameter to its default value
/// </summary>
public void ResetVectorParameter(string name)
{
VectorParameterOverrides.Remove(name);
UpdateParameterValues();
}
// ==================== COLOR PARAMETERS ====================
/// <summary>
/// Set a color parameter value for this specific instance
/// </summary>
public void Set(string name, Color value)
{
if (ParticleSystem == null) return;
var param = ParticleSystem.ColorParameters?.FirstOrDefault(p => p.Name == name);
if (param == null)
{
//Log.Warning($"Color parameter '{name}' not found in particle system");
return;
}
ColorParameterOverrides[name] = value;
UpdateParameterValues();
}
/// <summary>
/// Get a color parameter value (override or default)
/// </summary>
public ParticleGradient GetColorParameter(string name)
{
if (ColorParameterOverrides.TryGetValue(name, out ParticleGradient overrideValue))
{
return overrideValue;
}
return ParticleSystem?.GetColorParameterDefault(name) ?? Color.White;
}
/// <summary>
/// Reset a color parameter to its default value
/// </summary>
public void ResetColorParameter(string name)
{
ColorParameterOverrides.Remove(name);
UpdateParameterValues();
}
// ==================== GENERAL ====================
/// <summary>
/// Reset all parameters to their default values
/// </summary>
public void ResetAllParameters()
{
ParameterOverrides.Clear();
VectorParameterOverrides.Clear();
ColorParameterOverrides.Clear();
UpdateParameterValues();
}
/// <summary>
/// Helper buttons for the inspector
/// </summary>
[Button("Reset All Parameters")]
[Group("Parameters")]
public void ResetAllParametersButton()
{
ResetAllParameters();
}
[Button("Initialize All Parameters")]
[Group("Parameters")]
[Description("Copy all parameters from the resource as overrides")]
public void InitializeParametersFromResource()
{
if (ParticleSystem == null) return;
ParameterOverrides.Clear();
if (ParticleSystem.FloatParameters != null)
{
foreach (var param in ParticleSystem.FloatParameters)
{
ParameterOverrides[param.Name] = param.DefaultValue;
}
}
VectorParameterOverrides.Clear();
if (ParticleSystem.VectorParameters != null)
{
foreach (var param in ParticleSystem.VectorParameters)
{
VectorParameterOverrides[param.Name] = param.DefaultValue;
}
}
ColorParameterOverrides.Clear();
if (ParticleSystem.ColorParameters != null)
{
foreach (var param in ParticleSystem.ColorParameters)
{
ColorParameterOverrides[param.Name] = param.DefaultValue;
}
}
}
/// <summary>
/// Update parameter values without rebuilding emitters
/// </summary>
private void UpdateParameterValues()
{
if (ParticleSystem?.Emitters == null) return;
foreach (var emitterObject in Emitters)
{
if (!emitterObject.IsValid()) continue;
var controller = emitterObject.GetComponent<FXBoxParticleController>();
if (controller != null)
{
UpdateModulesWithParameters(emitterObject, controller.EmitterData);
}
}
}
public void OnSave(GameResource resource)
{
Log.Info("The scene has stopped");
}
protected override void OnStart()
{
UpdateEmitters();
// TemporaryEffect is what actually destroys this GameObject once IsActive (below)
// goes false - without one present, nothing ever would, since the per-emitter
// controllers no longer destroy the root themselves (see FXBoxParticleController.
// OnUpdate). DestroyAfterSeconds is 0 since the wait for "actually finished" is
// already handled by WaitForChildEffects walking into our own IsActive below, not by
// this timer - only DestroyOnEnd opts in at all, and editor previews never destroy
// themselves (they loop, same as TracerEffect's editor behavior).
if ( DestroyOnEnd && !Scene.IsEditor )
{
var temporaryEffect = GetOrAddComponent<TemporaryEffect>();
temporaryEffect.DestroyAfterSeconds = 0f;
temporaryEffect.WaitForChildEffects = true;
}
}
protected override void OnEnabled()
{
UpdateEmitters();
base.OnEnabled();
}
protected override void DrawGizmos()
{
Gizmo.Hitbox.Sprite( 0, 50, false );
if ( Gizmo.IsHovered || Gizmo.IsSelected)
{
Gizmo.Draw.Color = Color.White;
if ( Gizmo.IsSelected )
{
Gizmo.Draw.Color = Color.Yellow;
}
}
else
{
Gizmo.Draw.Color = Color.Gray;
}
Gizmo.Draw.Sprite( 0, 50, Texture.Load( "images/particlehover.vtex" ), false );
}
public void UpdateEmitters()
{
// Clean up existing emitters
foreach (var emitter in Emitters)
{
emitter?.DestroyImmediate();
}
Emitters.Clear();
if (ParticleSystem?.Emitters == null) return;
// Create emitters from ParticleResource
foreach (var emitterData in ParticleSystem.Emitters)
{
if (!emitterData.Enabled) continue;
var emitterObject = new GameObject(GameObject);
emitterObject.Flags = emitterObject.Flags.WithFlag( GameObjectFlags.Hidden, true );
emitterObject.Name = emitterData.Name;
Emitters.Add(emitterObject);
// Add ParticleEffect component
var particleEffect = emitterObject.GetOrAddComponent<ParticleEffect>();
particleEffect.MaxParticles = emitterData.MaxParticles;
particleEffect.StartDelay = emitterData.Delay;
// Create native components from modules
CreateModuleComponents(emitterObject, emitterData);
// Add controller to handle particle updates
var controller = emitterObject.GetOrAddComponent<FXBoxParticleController>();
controller.EmitterData = emitterData;
controller.ParticleEffect = particleEffect;
controller.InitializeModules = emitterData.InitializeModules;
controller.ParticleSystemComponent = this;
controller.ParticleEffect.ResetEmitters();
}
}
private void CreateModuleComponents(GameObject go, ParticleEmitter emitterData)
{
// Create components from all modules that implement IParticleComponentCreator
var allModules = emitterData.SpawnModules
.Concat(emitterData.InitializeModules)
.Concat(emitterData.UpdateModules)
.Concat(emitterData.RenderModules);
var particleModules = allModules.ToList();
foreach (var module in particleModules.OfType<IParticleComponentCreator>())
{
if (module is ParticleModule pm && pm.Enabled)
{
module.CreateComponent(go);
}
}
var context = new ParticleExecutionContext();
context.Effect = go.GetComponent<ParticleEffect>();
context.Emitter = go.GetComponent<Sandbox.ParticleEmitter>();
context.SystemComponent = this;
foreach (var module in particleModules)
{
module.Initialize(context);
}
// emitterData.Duration (0 by default) overrides the system-wide Duration for THIS
// emitter, same as FXBoxParticleController.EffectiveDuration - kept in sync so the
// native emitter's own duration behavior matches what our controller thinks it does.
var effectiveDuration = emitterData.Duration > 0f ? emitterData.Duration : ParticleSystem.Duration;
// Ensure we have at least a basic emitter if none was created
if (!go.GetComponent<Sandbox.ParticleEmitter>().IsValid())
{
var emitter = go.AddComponent<ParticleSphereEmitter>();
emitter.Duration = effectiveDuration;
emitter.Loop = ParticleSystem.Looping;
emitter.DestroyOnEnd = DestroyOnEnd;
}
var emitters = go.GetComponentsInChildren<Sandbox.ParticleEmitter>();
foreach ( var emit in emitters )
{
emit.Duration = effectiveDuration;
emit.Loop = ParticleSystem.Looping;
emit.DestroyOnEnd = DestroyOnEnd;
}
}
private void UpdateModulesWithParameters(GameObject go, ParticleEmitter emitterData)
{
var context = new ParticleExecutionContext();
context.Effect = go.GetComponent<ParticleEffect>();
context.Emitter = go.GetComponent<Sandbox.ParticleEmitter>();
context.SystemComponent = this;
var allModules = emitterData.SpawnModules
.Concat(emitterData.InitializeModules)
.Concat(emitterData.UpdateModules)
.Concat(emitterData.RenderModules);
foreach (var module in allModules)
{
module.Initialize(context);
}
}
protected override void OnDisabled()
{
// Clean up existing emitters
foreach (var emitter in Emitters)
{
emitter?.Destroy();
}
Emitters.Clear();
base.OnDisabled();
}
// ITemporaryEffect.IsActive - computed live from the child emitters rather than a flag
// someone has to remember to flip, so it can never go stale relative to what's actually
// still emitting/alive. True as soon as ANY emitter hasn't finished yet (see
// FXBoxParticleController.IsFinished); false only once every one of them has.
public bool IsActive
{
get
{
foreach ( var emitter in Emitters )
{
if ( !emitter.IsValid() ) continue;
var controller = emitter.GetComponent<FXBoxParticleController>();
if ( controller.IsValid() && !controller.IsFinished )
return true;
}
return false;
}
}
}
/// <summary>
/// Controller that executes particle update modules on each particle
/// </summary>
public class FXBoxParticleController : ParticleController
{
[Property, Hide] public ParticleEmitter EmitterData { get; set; }
[Property, Hide] public new ParticleEffect ParticleEffect { get; set; }
[Property, Hide] public FXBoxNativeParticleSystem ParticleSystemComponent { get; set; }
private TimeSince _timeSinceCreated = 0;
public List<ParticleModule> InitializeModules { get; set; } = new List<ParticleModule>();
// This emitter is done - past its duration, not looping, and nothing left alive.
// FXBoxNativeParticleSystem.IsActive (the ITemporaryEffect this whole system exposes)
// is true as long as ANY emitter's controller reports false here; a TemporaryEffect on
// the root is what actually destroys things once every one of them finally does, rather
// than this controller destroying the root itself the moment ITS OWN emitter finishes -
// that was the bug: a multi-emitter system got torn down as soon as the FIRST emitter
// to finish was done, not once every emitter actually was.
//
// EmitterData.Duration (0 by default) overrides the system-wide Duration for THIS
// emitter specifically - lets one emitter run longer/shorter than the rest of the
// system without changing anything system-wide.
private float EffectiveDuration => EmitterData != null && EmitterData.Duration > 0f
? EmitterData.Duration
: (ParticleSystemComponent?.ParticleSystem?.Duration ?? 0f);
public bool IsFinished =>
_timeSinceCreated > EffectiveDuration
&& !(ParticleSystemComponent?.ParticleSystem?.Looping ?? false)
&& ParticleEffect.Particles.Count <= 0;
// Called by FXBoxNativeParticleSystem.RestartAllEmitters (editor-only looping of a
// non-looping system, once every emitter's finished) alongside the native
// ParticleEmitter.ResetEmitter() call - IsFinished depends on _timeSinceCreated, so
// without also resetting this, it would immediately re-evaluate as finished again next
// tick regardless of the native emitter actually having restarted.
public void ResetTimer() => _timeSinceCreated = 0;
protected override void OnUpdate()
{
var context = new ParticleExecutionContext
{
Particle = null,
Effect = ParticleEffect,
Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),
SystemComponent = ParticleSystemComponent
};
foreach ( var init in EmitterData.InitializeModules )
{
init.Execute( context );
}
foreach ( var spawn in EmitterData.SpawnModules )
{
spawn.Execute( context );
}
}
protected override void OnParticleStep(Particle particle, float delta)
{
base.OnParticleStep(particle, delta);
if (EmitterData == null) return;
var context = new ParticleExecutionContext
{
Particle = particle,
Effect = ParticleEffect,
Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),
SystemComponent = ParticleSystemComponent
};
// Execute all update modules that implement IParticleUpdater
foreach (var module in EmitterData.UpdateModules.OfType<IParticleUpdater>())
{
if (module is ParticleModule pm && pm.Enabled)
{
module.UpdateParticle(context, delta);
}
}
}
protected override void OnParticleCreated(Particle p)
{
p.Position = ParticleEffect.WorldTransform.Position;
InitializeModules ??= EmitterData?.InitializeModules ?? new List<ParticleModule>();
var context = new ParticleExecutionContext
{
Particle = p,
Effect = ParticleEffect,
Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),
SystemComponent = ParticleSystemComponent
};
foreach (var module in InitializeModules)
{
module.Initialize(context);
}
}
}
/// <summary>
/// Interface for modules that can create native components
/// </summary>
public interface IParticleComponentCreator
{
void CreateComponent(GameObject go);
}
/// <summary>
/// Interface for modules that update particles
/// </summary>
public interface IParticleUpdater
{
void UpdateParticle(ParticleExecutionContext particle, float delta);
}
[Flags]
public enum FXCopyFlags
{
Rotation = 1,
Scale = 2,
}