Code/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,
}