Code/ParticleResource.cs
using System;
using Sandbox;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace fxbox;

/// <summary>
/// Particle system resource containing multiple emitters
/// </summary>
[AssetType(Name = "Particle System", Extension = "fx", Category = "FX", Flags = AssetTypeFlags.NoEmbedding)]
public class ParticleResource : GameResource
{
    public bool IsDirty { get; set; } = false;
    
    /// <summary>
    /// All emitters in this particle system
    /// </summary>
    public List<ParticleEmitter> Emitters { get; set; } = new();

    /// <summary>
    /// Named float parameters
    /// </summary>
    [InlineEditor, DisplayName("FloatParameters")] public List<FloatParameter> FloatParameters { get; set; } = new();
    
    /// <summary>
    /// Named vector parameters
    /// </summary>
    [InlineEditor] public List<VectorParameter> VectorParameters { get; set; } = new();
    
    /// <summary>
    /// Named color parameters
    /// </summary>
    [InlineEditor] public List<ColorParameter> ColorParameters { get; set; } = new();

    /// <summary>
    /// Global system properties
    /// </summary>
    public float Duration { get; set; } = 5.0f;
    public bool Looping { get; set; } = true;

    public int Version { get; set; } = 0;
    
    /// <summary>
    /// Preview settings for the editor
    /// </summary>
    public ParticlePreviewSettings PreviewSettings { get; set; } = new();
    
    /// <summary>
    /// Get a float parameter's default value by name
    /// </summary>
    public float GetParameterDefault(string name)
    {
        var param = FloatParameters.FirstOrDefault(p => p.Name == name);
        return param?.DefaultValue ?? 0f;
    }
    
    /// <summary>
    /// Get a vector parameter's default value by name
    /// </summary>
    public Vector3 GetVectorParameterDefault(string name)
    {
        var param = VectorParameters.FirstOrDefault(p => p.Name == name);
        return param?.DefaultValue ?? Vector3.Zero;
    }
    
    /// <summary>
    /// Get a color parameter's default value by name
    /// </summary>
    public ParticleGradient GetColorParameterDefault(string name)
    {
        var param = ColorParameters.FirstOrDefault(p => p.Name == name);
        return param?.DefaultValue ?? Color.White;
    }
    
    /// <summary>
    /// Add a new float parameter
    /// </summary>
    public FloatParameter AddParameter(string name, float defaultValue = 1.0f)
    {
        var param = new FloatParameter
        {
            Name = name,
            DefaultValue = defaultValue
        };
        FloatParameters.Add(param);
        return param;
    }
    
    /// <summary>
    /// Add a new vector parameter
    /// </summary>
    public VectorParameter AddVectorParameter(string name, Vector3 defaultValue)
    {
        var param = new VectorParameter
        {
            Name = name,
            DefaultValue = defaultValue
        };
        VectorParameters.Add(param);
        return param;
    }
    
    /// <summary>
    /// Add a new color parameter
    /// </summary>
    public ColorParameter AddColorParameter(string name, Color defaultValue)
    {
        var param = new ColorParameter
        {
            Name = name,
            DefaultValue = defaultValue
        };
        ColorParameters.Add(param);
        return param;
    }
}

/// <summary>
/// Preview settings for the particle editor
/// </summary>
public class ParticlePreviewSettings
{
    public bool ShowGround { get; set; } = true;
    public bool ShowGrid { get; set; } = true;
    public Color BackgroundColor { get; set; } = new Color(0.1f, 0.1f, 0.15f);
    public float PlaybackSpeed { get; set; } = 1.0f;
}
/// <summary>
/// A single particle emitter with its own spawn and update logic
/// </summary>
public class ParticleEmitter
{
	public string Name { get; set; } = "Emitter";
	[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
	public bool Enabled { get; set; } = true;
	public int MaxParticles { get; set; } = 1000;

	/// <summary>
	/// Modules that run when spawning particles
	/// </summary>
	[JsonConverter(typeof(ParticleModuleListConverter)), Hide]
	public List<ParticleModule> SpawnModules { get; set; } = new();

	/// <summary>
	/// Modules that run once when a particle is created
	/// </summary>
	[JsonConverter(typeof(ParticleModuleListConverter)), Hide]
	public List<ParticleModule> InitializeModules { get; set; } = new();

	/// <summary>
	/// Modules that run every frame for each particle
	/// </summary>
	[JsonConverter(typeof(ParticleModuleListConverter)), Hide]
	public List<ParticleModule> UpdateModules { get; set; } = new();

	/// <summary>
	/// Modules that control how particles are rendered
	/// </summary>
	[JsonConverter(typeof(ParticleModuleListConverter)), Hide]
	public List<ParticleModule> RenderModules { get; set; } = new();
}
/// <summary>
/// Base class for all particle modules
/// </summary>
public abstract class ParticleModule
{
    [Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
    [Hide] public string Name { get; set; }
    [Hide] public bool Enabled { get; set; } = true;

    /// <summary>
    /// What stage this module belongs to
    /// </summary>
    [JsonIgnore]
    public abstract ModuleStage Stage { get; }

    /// <summary>
    /// Execute this module
    /// </summary>
    public abstract void Execute(ParticleExecutionContext context);

    public abstract void Initialize( ParticleExecutionContext context );
}

/// <summary>
/// JSON converter for List of ParticleModule
/// </summary>
public class ParticleModuleListConverter : JsonConverter<List<ParticleModule>>
{
    public override List<ParticleModule> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var list = new List<ParticleModule>();
        
        if (reader.TokenType != JsonTokenType.StartArray)
            throw new JsonException("Expected start of array");
        
        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndArray)
                break;
                
            using (var doc = JsonDocument.ParseValue(ref reader))
            {
                var root = doc.RootElement;
                
                // Get the type name
                if (!root.TryGetProperty("$type", out var typeProperty))
                {
                    Log.Warning("Missing $type property for ParticleModule");
                    continue;
                }
                
                var typeName = typeProperty.GetString();
                var type = TypeLibrary.GetType(typeName)?.TargetType;
                
                if (type == null)
                {
                    Log.Warning($"Unknown module type: {typeName}");
                    continue;
                }
                
                // Deserialize to the specific type
                var json = root.GetRawText();
                var module = (ParticleModule)JsonSerializer.Deserialize(json, type, options);
                if (module != null)
                {
                    list.Add(module);
                }
            }
        }
        
        return list;
    }

    public override void Write(Utf8JsonWriter writer, List<ParticleModule> value, JsonSerializerOptions options)
    {
        writer.WriteStartArray();
        
        foreach (var module in value)
        {
            if (module == null) continue;
            
            writer.WriteStartObject();
            
            // Write the type information
            writer.WriteString("$type", module.GetType().FullName);
            
            // Serialize the module
            var json = JsonSerializer.Serialize(module, module.GetType(), options);
            using (var doc = JsonDocument.Parse(json))
            {
                foreach (var property in doc.RootElement.EnumerateObject())
                {
                    property.WriteTo(writer);
                }
            }
            
            writer.WriteEndObject();
        }
        
        writer.WriteEndArray();
    }
}