s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=stellawisps.fxbox&take=20
Showing code results for query:
*
(17 total matches found)
Game
library
using System;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace fxbox;
/// <summary>
/// Stage when a particle module executes
/// </summary>
public enum ModuleStage
{
Spawn, // Controls when/how particles spawn
Initialize, // Runs once when particle is created
Update, // Runs every frame for each particle
Render // Controls how particles are rendered
}
/// <summary>
/// Context passed to particle modules during execution
/// </summary>
public class ParticleExecutionContext
{
public Particle Particle;
public ParticleEffect Effect;
public Sandbox.ParticleEmitter Emitter;
public FXBoxNativeParticleSystem SystemComponent; // Changed from Resource to SystemComponent
}
// ==================== SPAWN MODULES ====================
/// <summary>
/// Controls spawn rate over time
/// </summary>
[Title("Spawn Rate"), Category("Spawn"), Icon("speed")]
public partial class SpawnRateModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Spawn;
[Property, Range(0.1f, 1000f)]
public FXParticleFloat SpawnRate { get; set; } = 10.0f;
public override void Execute(ParticleExecutionContext context)
{
var rate = SpawnRate;
context.Emitter.Rate = rate.GetValue( context.SystemComponent );
}
public override void Initialize( ParticleExecutionContext context )
{
context.Emitter.Rate = SpawnRate.GetValue( context.SystemComponent );
}
}
/// <summary>
/// Sets initial particle stretch
/// </summary>
[Title("Particle Stretch"), Category("Initialize"), Icon("photo_size_select_small")]
public partial class ParticleStretchModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property, Range(0.1f, 100f)]
public FXParticleFloat Size { get; set; } = 1.0f;
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.ApplyShape = true;
context.Effect.Stretch = Size.ToParticleFloat( context.SystemComponent );
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Controls spawn rate per unit
/// </summary>
[Title("Spawn Rate Over Distance"), Category("Spawn"), Icon("speed")]
public partial class SpawnRateOverDistanceModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Spawn;
[Property, Range(0.1f, 1000f)]
public FXParticleFloat SpawnRate { get; set; } = 10.0f;
public override void Execute(ParticleExecutionContext context)
{
context.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );
}
public override void Initialize( ParticleExecutionContext context )
{
context.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );
}
}
/// <summary>
/// Spawns particles in a burst
/// </summary>
[Title("Spawn Burst"), Category("Spawn"), Icon("auto_awesome")]
public partial class SpawnBurstModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Spawn;
[Property, Range(1, 1000)]
public int ParticleCount { get; set; } = 50;
public override void Initialize( ParticleExecutionContext context )
{
context.Emitter.Burst = ParticleCount;
}
// Burst is a one-shot "spawn this many now" value, not a continuous per-tick one like
// SpawnRateModule's Rate - Execute() runs every single frame (see
// FXBoxParticleController.OnUpdate), so reassigning Burst here too kept re-arming/
// re-firing it every tick instead of once, spawning far more than ParticleCount actually
// configured. Same "Initialize-only, empty Execute" shape ParticleStretchModule already
// uses above for its own one-shot value.
public override void Execute(ParticleExecutionContext context)
{
}
}
// ==================== INITIALIZE MODULES ====================
/// <summary>
/// Sets initial position based on shape
/// </summary>
[Title("Initialize Position"), Category("Initialize"), Icon("place")]
public partial class InitializePositionModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
public override void Execute( ParticleExecutionContext context )
{
}
public enum SpawnShape { Point, Sphere, Box, Cone, Circle, Line }
[Property]
public FXCopyFlags CopyFlags { get; set; } = FXCopyFlags.Rotation | FXCopyFlags.Scale;
[Property]
public SpawnShape Shape { get; set; } = SpawnShape.Sphere;
[Property, Range(0f, 1000f), ShowIf(nameof(ShowRadius), true)]
public float Radius { get; set; } = 50.0f;
[Property, ShowIf(nameof(ShowBoxSize), true)]
public Vector3 BoxSize { get; set; } = new Vector3(100, 100, 100);
[Property, Range(0f, 180f), ShowIf(nameof(ShowConeAngle), true)]
public float ConeAngle { get; set; } = 45.0f;
[Property]
public bool EmitFromShell { get; set; } = false;
[Property, ShowIf(nameof(ShowLine), true)]
public Vector3 LineStart { get; set; } = Vector3.Zero;
[Property, ShowIf(nameof(ShowLine), true)]
public Vector3 LineEnd { get; set; } = Vector3.Up * 100;
[Hide] public bool ShowRadius => Shape == SpawnShape.Sphere || Shape == SpawnShape.Circle || Shape == SpawnShape.Cone;
[Hide] public bool ShowBoxSize => Shape == SpawnShape.Box;
[Hide] public bool ShowConeAngle => Shape == SpawnShape.Cone;
[Hide] public bool ShowLine => Shape == SpawnShape.Line;
public override void Initialize( ParticleExecutionContext context )
{
if ( context.Particle != null )
{
var pos = Shape switch
{
SpawnShape.Point => Vector3.Zero,
SpawnShape.Sphere => GetSpherePosition(),
SpawnShape.Box => GetBoxPosition(),
SpawnShape.Cone => GetConePosition(),
SpawnShape.Circle => GetCirclePosition(),
SpawnShape.Line => GetLinePosition(),
_ => Vector3.Zero
};
if ( CopyFlags.HasFlag( FXCopyFlags.Scale ) )
{
pos = pos * context.Emitter.WorldScale;
}
if ( CopyFlags.HasFlag( FXCopyFlags.Rotation ) )
{
pos = pos.RotateAround( 0, context.SystemComponent.WorldRotation );
}
context.Particle.Position += pos;
}
}
public void CreateComponent(GameObject go)
{
var pointEmitter = go.AddComponent<ParticleSphereEmitter>();
pointEmitter.Radius = 0;
pointEmitter.Velocity = 0;
pointEmitter.Burst = 0;
pointEmitter.Rate = 0;
}
private Vector3 GetSpherePosition()
{
var direction = Random.Shared.VectorInSphere().Normal;
var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);
return direction * radius;
}
private Vector3 GetBoxPosition()
{
if (EmitFromShell)
{
var face = Random.Shared.Int(0, 5);
var u = Random.Shared.Float(0, 1);
var v = Random.Shared.Float(0, 1);
var halfSize = BoxSize / 2f;
return face switch
{
0 => new Vector3(-halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),
1 => new Vector3(halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),
2 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), -halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),
3 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),
4 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), -halfSize.z),
_ => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), halfSize.z)
};
}
return new Vector3(
Random.Shared.Float(-BoxSize.x / 2, BoxSize.x / 2),
Random.Shared.Float(-BoxSize.y / 2, BoxSize.y / 2),
Random.Shared.Float(-BoxSize.z / 2, BoxSize.z / 2)
);
}
private Vector3 GetConePosition()
{
var angle = Random.Shared.Float(0, 360);
var distance = Random.Shared.Float(0, Radius);
var coneRadius = MathF.Tan(ConeAngle.DegreeToRadian()) * distance;
var radius = EmitFromShell ? coneRadius : Random.Shared.Float(0, coneRadius);
return new Vector3(
MathF.Cos(angle.DegreeToRadian()) * radius,
MathF.Sin(angle.DegreeToRadian()) * radius,
distance
);
}
private Vector3 GetCirclePosition()
{
var angle = Random.Shared.Float(0, 360);
var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);
return new Vector3(
MathF.Cos(angle.DegreeToRadian()) * radius,
MathF.Sin(angle.DegreeToRadian()) * radius,
0
);
}
private Vector3 GetLinePosition()
{
return Vector3.Lerp(LineStart, LineEnd, Random.Shared.Float(0, 1));
}
}
/// <summary>
/// Controls how strongly particles follow the emitter in local space
/// </summary>
[Title("Initialize Local Space"), Category("Initialize"), Icon("transform")]
public partial class InitializeLocalSpaceModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property, Range(0f, 1f)]
public FXParticleFloat LocalSpace { get; set; } = 0f;
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.LocalSpace = LocalSpace.ToParticleFloat( context.SystemComponent );
}
public override void Execute( ParticleExecutionContext context )
{
}
}
/// <summary>
/// Sets initial velocity
/// </summary>
[Title("Initialize Velocity"), Category("Initialize"), Icon("air")]
public partial class InitializeVelocityModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property]
public FXParticleVector Velocity { get; set; } = Vector3.Up * 100;
[Property] public FXParticleFloat RandomVelocity { get; set; } = 0;
[Property] public bool LocalSpace { get; set; } = false;
[Property]
public bool InheritEmitterVelocity { get; set; } = false;
[Property,ShowIf("InheritEmitterVelocity",true)] public float EmitterVelocityScale { get; set; } = 1.0f;
public override void Initialize( ParticleExecutionContext context )
{
var startVelocity = Velocity;
if ( LocalSpace )
{
startVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent ).RotateAround( 0,context.Emitter.WorldRotation );
}
context.Effect.StartVelocity = RandomVelocity.ToParticleFloat();
context.Effect.InitialVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent );
if ( InheritEmitterVelocity )
{
context.Effect.InitialVelocity = (context.SystemComponent.Velocity*EmitterVelocityScale) + startVelocity.GetValue( context.Particle,context.SystemComponent );
}
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Sets initial color
/// </summary>
[Title("Collision"), Category("Initialize"), Icon("palette")]
public partial class ParticleCollisionModule : ParticleModule
{
[Property] public TagSet CollisionIgnore { get; set; } = new TagSet();
[Property] public List<GameObject> CollisionPrefabs { get; set; } = new List<GameObject>();
[Property] public FXParticleFloat CollisionRadius { get; set; } = 5;
[Property] public FXParticleFloat CollisionPrefabChance { get; set; } = 1;
[Property] public FXParticleFloat CollisionPrefabRotation { get; set; } = 0;
[Property] public FXParticleFloat DieOnCollisionChance { get; set; } = 0;
[Property] public bool CollisionPrefabAlign { get; set; } = false;
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.Collision = true;
context.Effect.CollisionIgnore = CollisionIgnore;
context.Effect.CollisionRadius = CollisionRadius.GetValue( context.SystemComponent );
context.Effect.CollisionPrefabChance = CollisionPrefabChance.GetValue( context.SystemComponent );
context.Effect.CollisionPrefabRotation = CollisionPrefabRotation.ToParticleFloat( context.SystemComponent );
if ( CollisionPrefabs.Any() )
{
context.Effect.UsePrefabFeature = true;
}
context.Effect.CollisionPrefab = CollisionPrefabs;
context.Effect.DieOnCollisionChance = DieOnCollisionChance.GetValue( context.SystemComponent );
context.Effect.CollisionPrefabAlign = CollisionPrefabAlign;
}
}
/// <summary>
/// Sets initial velocity
/// </summary>
[Title("Initialize Rotation"), Category("Initialize"), Icon("air")]
public partial class InitializeRotationModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property]
public ParticleVector3 InitialRotation { get; set; } = Vector3.Up;
public override void Initialize( ParticleExecutionContext context )
{
if ( context.Particle != null )
{
context.Particle.Angles = new Angles( InitialRotation.Evaluate( Time.Delta,context.Particle.Rand( ),context.Particle.Rand( ),context.Particle.Rand( ) ) );
}
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Sets initial lifetime
/// </summary>
[Title("Initialize Lifetime"), Category("Initialize"), Icon("schedule")]
public partial class InitializeLifetimeModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property, Range(0.1f, 100f)]
public FXParticleFloat Lifetime { get; set; } = 2.0f;
public override void Initialize(ParticleExecutionContext context)
{
context.Effect.Lifetime = Lifetime.ToParticleFloat( context.SystemComponent );
}
public override void Execute(ParticleExecutionContext context)
{
// Not used
}
}
/// <summary>
/// Sets initial size
/// </summary>
[Title("Initialize Size"), Category("Initialize"), Icon("photo_size_select_small")]
public partial class InitializeSizeModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property] public bool InheritEmitterScale { get; set; } = true;
[Property, Range(0.1f, 100f)]
public FXParticleFloat Size { get; set; } = 10.0f;
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.ApplyShape = true;
if ( InheritEmitterScale )
{
context.Effect.Scale = Size.ToParticleFloat( context.SystemComponent );
}
else
{
context.Effect.Scale = (Size / context.SystemComponent.WorldScale.x).ToParticleFloat( context.SystemComponent );
}
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Sets initial color
/// </summary>
[Title("Initialize Color"), Category("Initialize"), Icon("palette")]
public partial class InitializeColorModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property] public FXParticleColor Color { get; set; } = global::Color.Red;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.ApplyColor = true;
context.Effect.ApplyAlpha = true;
if ( Color != null )
{
context.Effect.Gradient = Color.GetValue( context.SystemComponent );
}
else
{
Color = new FXParticleColor( global::Color.Red );
}
}
}
/// <summary>
/// Sets initial color
/// </summary>
[Title("Sprite Flipbook"), Category("Initialize"), Icon("palette")]
public partial class SpriteFlipbookModule : ParticleModule
{
[Property] public FXParticleFloat SequenceTime { get; set; } = 0;
[Property] public FXParticleFloat SequenceSpeed { get; set; } = 1;
[Property] public int SequenceId { get; set; } = 0;
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.SheetSequence = true;
context.Effect.SequenceId = SequenceId;
context.Effect.SequenceSpeed = SequenceSpeed.ToParticleFloat( context.SystemComponent );
context.Effect.SequenceTime = SequenceTime.ToParticleFloat( context.SystemComponent );
}
}
/// <summary>
/// Randomly Kill a particle to spawn less
/// </summary>
[Title("RandomKill"), Category("Initialize"), Icon("arrow_downward")]
public partial class RandomKill : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property]
public float Chance { get; set; } = 0.5f;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
if ( context.Particle == null ) return;
if ( Random.Shared.Float( 0, 1 ) < Chance )
{
context.Particle.Age = 100000;
}
}
}
// ==================== UPDATE MODULES ====================
/// <summary>
/// Applies gravity force
/// </summary>
[Title("Gravity Force"), Category("Update"), Icon("arrow_downward")]
public partial class GravityForceModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property]
public FXParticleVector Force { get; set; } = new Vector3(0, 0, -980);
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
context.Particle.Velocity += Force.GetValue(context.Particle, context.SystemComponent ) * Time.Delta;
}
}
/// <summary>
/// Make a mesh follow it's velocity
/// </summary>
[Title("Follow Velocity"), Category("Update"), Icon("arrow_downward")]
public partial class FollowVelocity : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
context.Particle.Angles = Rotation.LookAt( context.Particle.Velocity ).Angles();
}
}
/// <summary>
/// Applies drag/air resistance
/// </summary>
[Title("Drag Force"), Category("Update"), Icon("air")]
public partial class DragForceModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property, Range(0f, 10f)]
public float Damping { get; set; } = 0.1f;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
context.Particle.Velocity *= (1.0f - Damping * Time.Delta);
}
}
/// <summary>
/// Makes particles rotate
/// </summary>
[Title("Rotation"), Category("Update"), Icon("rotate_right")]
public partial class RotationModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property] public FXParticleVector RotationSpeed { get; set; } = Vector3.Zero;
public override void Execute(ParticleExecutionContext context)
{
/*context.Particle.Rotation += RotationSpeed * context.DeltaTime;*/
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
if ( context.Particle != null )
{
context.Particle.Angles += RotationSpeed.GetValue( context.Particle ) * Time.Delta;
}
}
}
public enum PositionType
{
Local,
World
}
/// <summary>
/// Attracts particles to a point. Full strength inside AttractorSize, falling off beyond it.
/// </summary>
[Title("Point Attractor"), Category("Update"), Icon("my_location")]
public partial class PointAttractorModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property] public PositionType PositionType { get; set; } = PositionType.Local;
[Property]
public FXParticleVector AttractorPosition { get; set; } = Vector3.Zero;
[Property, Range(0f, 10000f)]
public FXParticleFloat Strength { get; set; } = 500.0f;
[Property, Range(0.01f, 10000f)]
public float AttractorSize { get; set; } = 50.0f;
[Property] public bool Invert { get; set; } = false;
/// <summary>
/// How quickly strength falls off beyond AttractorSize.
/// 1 = linear, 2 = inverse square, higher = sharper falloff.
/// </summary>
[Property, Range(0.1f, 8f)]
public float Falloff { get; set; } = 2.0f;
public override void Execute(ParticleExecutionContext context) { }
public override void Initialize(ParticleExecutionContext context) { }
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
var attractorPos = PositionType == PositionType.Local ? AttractorPosition.GetValue( context.Particle, context.SystemComponent ) + context.Emitter.WorldPosition : AttractorPosition.GetValue( context.Particle, context.SystemComponent );
var toAttractor = attractorPos - context.Particle.Position;
var distance = toAttractor.Length;
if (distance < 0.01f) return;
// Inside the attractor: full strength.
// Outside: strength falls off based on normalised excess distance.
float strengthMultiplier;
if (distance <= AttractorSize)
{
strengthMultiplier = 1f;
}
else
{
// How many radii past the edge are we? 0 at the surface, grows outward.
var excess = (distance - AttractorSize) / AttractorSize;
strengthMultiplier = 1f / MathF.Pow(1f + excess, Falloff);
}
if ( Invert )
{
strengthMultiplier = 1 - strengthMultiplier;
}
context.Particle.Velocity += toAttractor.Normal * Strength.GetValue( context.SystemComponent ) * strengthMultiplier * Time.Delta;
}
}
/// <summary>
/// Creates orbital motion
/// </summary>
[Title("Vortex Force"), Category("Update"), Icon("cyclone")]
public partial class VortexForceModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property]
public Vector3 Center { get; set; } = Vector3.Zero;
[Property]
public FXParticleVector Axis { get; set; } = Vector3.Up;
[Property, Range(0f, 1000f)]
public float Strength { get; set; } = 100.0f;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
var toCenter = context.Particle.Position - (Center + context.Emitter.WorldPosition);
var distance = toCenter.Length;
if (distance > 0.01f)
{
var tangent = Vector3.Cross(Axis.GetValue( context.Particle,context.SystemComponent ).Normal, toCenter.Normal);
var force = tangent * (Strength / distance);
context.Particle.Velocity += force * Time.Delta * 10000;
}
}
}
// ==================== RENDER MODULES ====================
/// <summary>
/// Basic sprite renderer
/// </summary>
[Title("Sprite Renderer"), Category("Render"), Icon("image")]
public partial class SpriteRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Render;
[Property]
public Sprite Sprite { get; set; }
[Property] public FXParticleFloat SpriteScale { get; set; } = 1f;
[Property]
public ParticleSpriteRenderer.BillboardAlignment Alignment { get; set; } =
ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
[Property] public bool FaceVelocity { get; set; } = false;
[Property] public bool Additive { get; set; } = false;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleSpriteRenderer>();
renderer.Alignment = Alignment;
renderer.FaceVelocity = FaceVelocity;
renderer.Sprite = Sprite;
renderer.Additive = Additive;
renderer.Scale = SpriteScale.GetValue();
}
}
/// <summary>
/// Basic light renderer
/// </summary>
[Title("Light Renderer"), Category("Render"), Icon("image")]
public partial class LightRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Render;
[Property] public FXParticleColor LightColor { get; set; } = new FXParticleColor( Color.White );
[Property] public FXParticleFloat Brightness { get; set; } = 10f;
[Property] public FXParticleFloat MaxLights { get; set; } = 10f;
[Property] public FXParticleFloat LightSize { get; set; } = 10f;
[Property] public FXParticleFloat Attenuation { get; set; } = 1;
[Property] public bool CastShadows { get; set; } = false;
[Property] public bool UseParticleColor { get; set; } = true;
[Property] public FXParticleFloat Ratio { get; set; } = 1;
public override void Execute(ParticleExecutionContext context)
{
// Rendering is handled externally, this just stores render properties
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleLightRenderer>();
var fxbox=go.GetComponentInParent<FXBoxNativeParticleSystem>( );
renderer.LightColor = LightColor.GetValue( fxbox );
renderer.Brightness = Brightness.GetValue( fxbox );
renderer.MaximumLights = (int)MaxLights.GetValue( fxbox );
renderer.Scale = LightSize.GetValue( fxbox );
renderer.Attenuation = Attenuation.GetValue( fxbox );
renderer.Ratio = Ratio.GetValue( fxbox );
renderer.CastShadows = CastShadows;
renderer.UseParticleColor = UseParticleColor;
}
}
/// <summary>
/// Basic model renderer
/// </summary>
[Title("Model Renderer"), Category("Render"), Icon("image")]
public partial class ModelRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Render;
[Property]
public List<ParticleModelRenderer.ModelEntry> Models { get; set; }
[Property]
public bool FaceCamera { get; set; } = true;
public override void Execute(ParticleExecutionContext context)
{
// Rendering is handled externally, this just stores render properties
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleModelRenderer>();
renderer.Choices = Models;
}
}
/// <summary>
/// Basic Trail Renderer
/// </summary>
[Title( "Trail Renderer" ), Category( "Render" ), Icon( "image" )]
public partial class TrailRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide] public override ModuleStage Stage => ModuleStage.Render;
[Property] public bool Game { get; set; } = true;
[Property] public bool Overlay { get; set; } = false;
[Property] public bool Bloom { get; set; } = false;
[Property] public bool AfterUi { get; set; } = false;
[Property] public Material Material { get; set; }
[Property] public FXParticleFloat UnitsPerTexture { get; set; } = 10f;
[Property] public FXParticleFloat Scroll { get; set; } = 0f;
[Property] public FXParticleFloat Width { get; set; } = 1f;
[Property] public bool Opaque { get; set; } = true;
[Property, ShowIf( "Opaque", false )] public BlendMode BlendMode { get; set; } = BlendMode.Normal;
[Property] public int MaxPoints { get; set; } = 32;
[Property] public float PointDistance { get; set; } = 8;
[Property] public float LifeTime { get; set; } = 2f;
[Property] public FXParticleColor Color { get; set; } = new FXParticleColor( );
public override void Execute(ParticleExecutionContext context)
{
// Rendering is handled externally, this just stores render properties
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleTrailRenderer>();
var appearance = renderer.Texturing;
appearance.Material = Material;
appearance.UnitsPerTexture = UnitsPerTexture.GetValue( );
appearance.Scroll = Scroll.GetValue();
var widthCurve = Width.ToParticleFloat();
if ( widthCurve.Type == ParticleFloat.ValueType.Curve )
{
renderer.Width = widthCurve.CurveA;
} else if ( widthCurve.Type == ParticleFloat.ValueType.Range )
{
var point1 = new Curve.Frame( 0, widthCurve.ConstantA );
var point2 = new Curve.Frame( 1, widthCurve.ConstantB );
renderer.Width = new Curve( point1, point2 );
} else if ( widthCurve.Type == ParticleFloat.ValueType.Constant )
{
renderer.Width = widthCurve.ConstantA;
}
else
{
renderer.Width = widthCurve.CurveA;
}
renderer.Opaque = Opaque;
renderer.BlendMode = BlendMode;
renderer.MaxPoints = MaxPoints;
renderer.PointDistance = PointDistance;
renderer.LifeTime = LifeTime;
var colorParam = Color.GetValue();
if ( colorParam.Type == ParticleGradient.ValueType.Constant )
{
renderer.Color = colorParam.ConstantA;
} else if ( colorParam.Type == ParticleGradient.ValueType.Range )
{
var point1 = new Gradient.ColorFrame( 0, colorParam.ConstantA );
var point2 = new Gradient.ColorFrame( 1, colorParam.ConstantB );
renderer.Color = new Gradient( point1, point2 );
} else if ( colorParam.Type == ParticleGradient.ValueType.Gradient )
{
renderer.Color = colorParam.GradientA;
}
renderer.RenderOptions.Game = Game;
renderer.RenderOptions.Overlay = Overlay;
renderer.RenderOptions.Bloom = Bloom;
renderer.RenderOptions.AfterUI = AfterUi;
renderer.Texturing = appearance;
}
}
/// <summary>
/// Applies curl noise force for organic, swirling motion
/// </summary>
[Title("Curl Noise"), Category("Update"), Icon("air")]
public partial class CurlNoiseModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property, Range(0f, 1000f)]
[Description("Strength of the curl noise effect")]
public FXParticleFloat Strength { get; set; } = 1.0f;
[Property, Range(0.01f, 10f)]
[Description("Scale of the noise pattern - smaller values create tighter curls")]
public FXParticleFloat Scale { get; set; } = 1.0f;
[Property, Range(0f, 10f)]
[Description("Speed at which the noise pattern evolves over time")]
public FXParticleFloat TimeScale { get; set; } = 1.0f;
[Property]
[Description("Offset in the noise field")]
public Vector3 Offset { get; set; } = Vector3.Zero;
public override void Execute(ParticleExecutionContext context)
{
// Not used - handled in UpdateParticle
}
public override void Initialize(ParticleExecutionContext context)
{
// No initialization needed
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
var particle = context.Particle;
// Sample position in noise field
var samplePos = (particle.Position + Offset) * Scale.GetValue( context.SystemComponent );
var time = context.Particle.Age * TimeScale;
// Calculate curl noise using the curl of a 3D noise field
var curl = CalculateCurl(samplePos, time.GetValue( context.SystemComponent ));
// Apply force
particle.Velocity += curl * Strength.GetValue( context.SystemComponent ) * delta * 10;
}
/// <summary>
/// Calculate curl noise by taking the curl of a potential field
/// This creates divergence-free flow fields that look organic
/// </summary>
private Vector3 CalculateCurl(Vector3 pos, float time)
{
const float epsilon = 0.001f;
// Sample the potential field at offset positions
// We need 6 samples to calculate the curl (derivatives in all directions)
// dPz/dy - dPy/dz
float curlX =
(SamplePotential(pos + new Vector3(0, epsilon, 0), time).z -
SamplePotential(pos - new Vector3(0, epsilon, 0), time).z) -
(SamplePotential(pos + new Vector3(0, 0, epsilon), time).y -
SamplePotential(pos - new Vector3(0, 0, epsilon), time).y);
// dPx/dz - dPz/dx
float curlY =
(SamplePotential(pos + new Vector3(0, 0, epsilon), time).x -
SamplePotential(pos - new Vector3(0, 0, epsilon), time).x) -
(SamplePotential(pos + new Vector3(epsilon, 0, 0), time).z -
SamplePotential(pos - new Vector3(epsilon, 0, 0), time).z);
// dPy/dx - dPx/dy
float curlZ =
(SamplePotential(pos + new Vector3(epsilon, 0, 0), time).y -
SamplePotential(pos - new Vector3(epsilon, 0, 0), time).y) -
(SamplePotential(pos + new Vector3(0, epsilon, 0), time).x -
SamplePotential(pos - new Vector3(0, epsilon, 0), time).x);
return new Vector3(curlX, curlY, curlZ) / (2.0f * epsilon);
}
/// <summary>
/// Sample a 3D potential field using Perlin-like noise
/// </summary>
private Vector3 SamplePotential(Vector3 pos, float time)
{
// Create three offset noise samples for each component
// This creates a vector field from scalar noise functions
return new Vector3(
Noise3D(pos + new Vector3(0, 0, 0), time),
Noise3D(pos + new Vector3(31.416f, -47.853f, 12.793f), time),
Noise3D(pos + new Vector3(-17.737f, 86.214f, -59.482f), time)
);
}
/// <summary>
/// Simple 3D noise function using sine waves
/// You could replace this with proper Perlin/Simplex noise for better results
/// </summary>
private float Noise3D(Vector3 pos, float time)
{
// Combine multiple sine waves at different frequencies for pseudo-noise
var p = pos + new Vector3(time, time * 0.7f, time * 0.5f);
float noise = 0;
noise += MathF.Sin(p.x * 1.0f + p.y * 1.3f) * 0.5f;
noise += MathF.Sin(p.y * 1.7f + p.z * 0.9f) * 0.3f;
noise += MathF.Sin(p.z * 2.1f + p.x * 1.1f) * 0.2f;
noise += MathF.Sin(p.x * 3.7f + p.y * 2.3f + p.z * 1.9f) * 0.15f;
return noise;
}
}
UnitTest
library
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
[AssemblyInitialize]
public static void ClassInitialize( TestContext context )
{
Sandbox.Application.InitUnitTest();
}
}
Editor
library
using Editor;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace fxbox.Graph;
/// <summary>
/// Main particle system editor - ShaderGraph style with DockManager
/// </summary>
[EditorForAssetType("fx")]
[EditorApp("FXBox", "auto_awesome", "Create and edit particle systems")]
public class FXBoxEditor : DockWindow, IAssetEditor
{
public bool CanOpenMultipleAssets => true;
public static ParticleResource CurrentEditingResource { get; private set; }
private ParticleResource _resource; // Original resource from asset
private ParticleResource _workingCopy; // Copy we actually edit
private Asset _asset;
private bool _isDirty = false;
// UI Components
private EmitterList _emitterList;
private ParticlePreview _preview;
private PropertiesWidget _properties;
private string _defaultDockState;
public FXBoxEditor()
{
DeleteOnClose = true;
Title = "FXBox - Particle System Editor";
Size = new Vector2(1800, 1000);
CreateToolBar();
CreateUI();
Show();
}
// StateCookie is set here, AFTER base.Show(), rather than inside CreateUI() before the
// window is even shown - same ordering as the working RectEditor reference
// (HotspotEditorWindow.Show() / Window.CreateUI()'s "if (Visible) RestoreLayout()").
// Setting it too early was part of why a layout you'd closed with panels open never came
// back - restoring (or building the default layout) needs the window's dock widget
// hierarchy to actually exist and be visible first.
public override void Show()
{
base.Show();
StateCookie = "FXBoxEditor_v6";
}
private void CreateToolBar()
{
var toolbar = new ToolBar(this, "FXBoxToolbar");
AddToolBar(toolbar, ToolbarPosition.Top);
toolbar.AddOption("Save", "save", Save).StatusTip = "Save Particle System (Ctrl+S)";
toolbar.AddSeparator();
toolbar.AddOption("Add Emitter", "add_circle", AddEmitter).StatusTip = "Add new emitter";
// Add parameter menu
toolbar.AddSeparator();
toolbar.AddOption("Float Parameter", "looks_one", AddFloatParameter);
toolbar.AddOption("Vector Parameter", "3d_rotation", AddVectorParameter);
toolbar.AddOption("Color Parameter", "palette", AddColorParameter);
toolbar.AddSeparator();
toolbar.AddOption("Play", "play_arrow", () => _preview?.TogglePlayback()).StatusTip = "Play/Pause";
toolbar.AddOption("Restart", "replay", () => _preview?.Restart()).StatusTip = "Restart";
}
private void CreateUI()
{
BuildMenuBar();
BuildDock();
DockManager.Update();
_defaultDockState = DockManager.State;
}
// Builds the REAL widget instances and docks them - unlike the previous
// RegisterDockType+CreateAction version, this runs eagerly every time CreateUI runs
// (every open, every hotload), so _emitterList/_preview/_properties are never null by
// the time anything else (AssetOpen, LoadResource, etc.) tries to push data into them.
// The old lazy version only created a widget instance whenever the dock manager itself
// got around to invoking CreateAction - which, on a restored/default layout, could
// happen well after AssetOpen already tried (and silently no-op'd via ?.) to hand the
// resource to _emitterList/_preview/_properties, matching the exact "not hooked up until
// you add a parameter or emitter" symptom (that next SetResource/LoadParticleSystem call
// was the first one to land after CreateAction had finally run).
//
// DockManager.AddDock(title, icon, widget, area, relativeTo) both registers the dock
// TYPE under that title/icon (same as RegisterDockType did, for the View menu and
// BuildDefaultLayout's OpenDock below to find it by name) AND places this exact instance
// on screen - no separate CreateDockWidget/AddDock two-step needed.
private void BuildDock()
{
_preview = CreatePreview();
_emitterList = CreateEmitterList();
_properties = CreateProperties();
var preview = DockManager.AddDock( "Preview", "visibility", _preview, DockArea.Center );
DockManager.AddDock( "Emitters", "list", _emitterList, DockArea.Left, relativeTo: preview );
DockManager.AddDock( "Properties", "tune", _properties, DockArea.Right, relativeTo: preview );
}
// Called by the base class on a genuinely first-ever open (nothing saved under
// StateCookie yet - see the Show() override above) to decide default POSITIONS/
// proportions only. OpenDock resolves the SAME dock types BuildDock() already
// registered+placed above by Title, it doesn't build a second set of widgets.
protected override void BuildDefaultLayout()
{
var preview = DockManager.OpenDock( "Preview", DockArea.Center );
var emitters = DockManager.OpenDock( "Emitters", DockArea.Left, preview );
var properties = DockManager.OpenDock( "Properties", DockArea.Right, preview );
DockManager.SetSplitterProportions( emitters, 0.20f, 0.80f );
DockManager.SetSplitterProportions( properties, 0.80f, 0.20f );
}
private EmitterList CreateEmitterList()
{
var widget = new EmitterList(this);
widget.Name = "Emitters";
widget.WindowTitle = "Emitters & Modules";
widget.SetWindowIcon("list");
widget.MinimumWidth = 300;
widget.OnSelectionChanged = OnSelectionChanged;
widget.OnEmitterDeleted = OnEmitterDeleted;
widget.OnModuleDeleted = OnModuleDeleted;
widget.OnAddModule = OnAddModule;
widget.OnSystemChanged = MarkDirty;
widget.OnEmitterDuplicated = OnEmitterDuplicated;
widget.OnModuleDuplicated = OnModuleDuplicated;
return widget;
}
private void OnEmitterDuplicated(ParticleEmitter emitter)
{
if (_workingCopy == null) return;
var index = _workingCopy.Emitters.IndexOf(emitter);
if (index == -1) return;
// Round-trip the entire resource through JSON, then pull out
// the emitter at the same index — gives us a full deep clone
// without needing Serialize/Deserialize on ParticleEmitter itself.
var resourceCopy = DeepCopyResource(_workingCopy);
var clone = resourceCopy.Emitters[index];
clone.Name = $"{emitter.Name} (Copy)";
_workingCopy.Emitters.Insert(index + 1, clone);
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
MarkDirty();
}
private ParticlePreview CreatePreview()
{
var widget = new ParticlePreview(this);
widget.Name = "Preview";
widget.WindowTitle = "Preview";
widget.SetWindowIcon("visibility");
return widget;
}
private PropertiesWidget CreateProperties()
{
var widget = new PropertiesWidget(this);
widget.Name = "Properties";
widget.WindowTitle = "Properties";
widget.SetWindowIcon("tune");
widget.MinimumWidth = 300;
widget.OnPropertyChanged = OnPropertyChanged;
return widget;
}
private void BuildMenuBar()
{
var file = MenuBar.AddMenu("File");
file.AddOption("New", "add", New, "editor.new").StatusTip = "New Particle System";
file.AddOption("Open", "folder_open", Open, "editor.open").StatusTip = "Open Particle System";
file.AddOption("Save", "save", Save, "editor.save").StatusTip = "Save Particle System";
file.AddSeparator();
file.AddOption("Close", null, () => Close(), "editor.quit").StatusTip = "Close Editor";
var edit = MenuBar.AddMenu("Edit");
edit.AddOption("Add Emitter", "add_circle", AddEmitter);
edit.AddSeparator();
edit.AddOption("Add Float Parameter", "looks_one", AddFloatParameter);
edit.AddOption("Add Vector Parameter", "3d_rotation", AddVectorParameter);
edit.AddOption("Add Color Parameter", "palette", AddColorParameter);
var view = MenuBar.AddMenu("View");
view.AboutToShow += () => OnViewMenu(view);
}
private void OnViewMenu(Menu view)
{
view.Clear();
//view.AddOption("Restore To Default", "settings_backup_restore", RestoreDefaultDockLayout);
view.AddSeparator();
foreach (var dock in DockManager.DockTypes)
{
var o = view.AddOption(dock.Title, dock.Icon);
o.Checkable = true;
o.Checked = DockManager.IsDockOpen(dock.Title);
o.Toggled += (b) => DockManager.SetDockState(dock.Title, b);
}
}
// protected override void RestoreDefaultDockLayout()
// {
//DockManager.State = _defaultDockState;
// SaveToStateCookie();
//}
private void AddFloatParameter()
{
if (_workingCopy == null) return;
var param = new FloatParameter
{
Name = $"FloatParam{_workingCopy.FloatParameters.Count + 1}",
DefaultValue = 1.0f
};
_workingCopy.FloatParameters.Add(param);
_properties?.ShowSystemProperties(_workingCopy);
MarkDirty();
}
private void AddVectorParameter()
{
if (_workingCopy == null) return;
var param = new VectorParameter
{
Name = $"VectorParam{_workingCopy.VectorParameters.Count + 1}",
DefaultValue = Vector3.One
};
_workingCopy.VectorParameters.Add(param);
_properties?.ShowSystemProperties(_workingCopy);
MarkDirty();
}
private void AddColorParameter()
{
if (_workingCopy == null) return;
var param = new ColorParameter
{
Name = $"ColorParam{_workingCopy.ColorParameters.Count + 1}",
DefaultValue = Color.White
};
_workingCopy.ColorParameters.Add(param);
_properties?.ShowSystemProperties(_workingCopy);
MarkDirty();
}
public void AssetOpen(Asset asset)
{
_asset = asset;
_resource = asset.LoadResource<ParticleResource>();
if (_resource == null)
{
_resource = new ParticleResource();
var defaultEmitter = new ParticleEmitter { Name = "Emitter 1" };
AddDefaultModules(defaultEmitter);
_resource.Emitters.Add(defaultEmitter);
}
// Create a deep copy for editing
_workingCopy = DeepCopyResource(_resource);
Title = $"FXBox - {asset.Name}";
LoadResource();
Focus();
}
private ParticleResource DeepCopyResource(ParticleResource source)
{
if (source == null) return null;
var json = source.Serialize().ToJsonString();
var copy = new ParticleResource();
copy.Deserialize(Json.ParseToJsonObject(json));
copy.IsDirty = false;
return copy;
}
private void LoadResource()
{
CurrentEditingResource = _workingCopy;
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
_properties?.ShowSystemProperties(_workingCopy);
_isDirty = false;
if (_asset != null)
_asset.HasUnsavedChanges = false;
}
[Shortcut("editor.new", "CTRL+N", ShortcutType.Window)]
private void New()
{
PromptSave(() => CreateNew());
}
private void CreateNew()
{
_asset = null;
_resource = new ParticleResource();
var defaultEmitter = new ParticleEmitter { Name = "Emitter 1" };
AddDefaultModules(defaultEmitter);
_resource.Emitters.Add(defaultEmitter);
_workingCopy = DeepCopyResource(_resource);
Title = "FXBox - Untitled";
LoadResource();
}
[Shortcut("editor.open", "CTRL+O", ShortcutType.Window)]
private void Open()
{
var fd = new FileDialog(null)
{
Title = "Open Particle System",
DefaultSuffix = ".fx"
};
fd.SetNameFilter("Particle System (*.fx)");
if (!fd.Execute())
return;
PromptSave(() => OpenFile(fd.SelectedFile));
}
private void OpenFile(string path)
{
var asset = AssetSystem.FindByPath(path);
if (asset != null)
{
AssetOpen(asset);
}
}
private void AddEmitter()
{
if (_workingCopy == null) return;
var emitter = new ParticleEmitter
{
Name = $"Emitter {_workingCopy.Emitters.Count + 1}"
};
AddDefaultModules(emitter);
_workingCopy.Emitters.Add(emitter);
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
MarkDirty();
}
private void AddDefaultModules(ParticleEmitter emitter)
{
emitter.SpawnModules.Add(new SpawnRateModule { Name = "Spawn Rate" });
emitter.InitializeModules.Add(new InitializePositionModule { Name = "Initialize Position" });
emitter.InitializeModules.Add(new InitializeLocalSpaceModule { Name = "Initialize Local Space" });
emitter.InitializeModules.Add(new InitializeVelocityModule { Name = "Initialize Velocity" });
emitter.InitializeModules.Add(new InitializeLifetimeModule { Name = "Initialize Lifetime" });
emitter.InitializeModules.Add(new InitializeSizeModule { Name = "Initialize Size" });
emitter.InitializeModules.Add(new InitializeColorModule { Name = "Initialize Color" });
emitter.UpdateModules.Add(new GravityForceModule { Name = "Gravity" });
emitter.UpdateModules.Add(new DragForceModule { Name = "Drag" });
emitter.RenderModules.Add(new SpriteRendererModule { Name = "Sprite Renderer" });
}
private void OnAddModule(ParticleEmitter emitter, ModuleStage stage)
{
var menu = new Menu(this);
var moduleTypes = EditorTypeLibrary.GetTypes<ParticleModule>()
.Where(t => !t.IsAbstract)
.Select(t => t.TargetType)
.Where(t => {
var instance = System.Activator.CreateInstance(t) as ParticleModule;
return instance?.Stage == stage;
});
foreach (var moduleType in moduleTypes.OrderBy(t => t.Name))
{
var displayInfo = DisplayInfo.ForType(moduleType);
menu.AddOption(displayInfo.Name, displayInfo.Icon ?? "extension", () => {
var module = System.Activator.CreateInstance(moduleType) as ParticleModule;
if (module != null)
{
module.Name = displayInfo.Name;
var targetList = stage switch
{
ModuleStage.Spawn => emitter.SpawnModules,
ModuleStage.Initialize => emitter.InitializeModules,
ModuleStage.Update => emitter.UpdateModules,
ModuleStage.Render => emitter.RenderModules,
_ => null
};
targetList?.Add(module);
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
MarkDirty();
}
});
}
menu.OpenAtCursor();
}
private void OnSelectionChanged(object target)
{
if (target is ParticleResource resource)
{
_properties?.ShowSystemProperties(resource);
}
else if (target is ParticleEmitter emitter)
{
_properties?.ShowEmitterProperties(emitter);
}
else if (target is ParticleModule module)
{
_properties?.ShowModuleProperties(module);
}
}
private void OnEmitterDeleted(ParticleEmitter emitter)
{
_workingCopy.Emitters.Remove(emitter);
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
_properties?.ShowSystemProperties(_workingCopy);
MarkDirty();
}
private void OnModuleDeleted(ParticleModule module)
{
foreach (var emitter in _workingCopy.Emitters)
{
emitter.SpawnModules.Remove(module);
emitter.InitializeModules.Remove(module);
emitter.UpdateModules.Remove(module);
emitter.RenderModules.Remove(module);
}
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
MarkDirty();
}
private void OnPropertyChanged()
{
_preview?.LoadParticleSystem(_workingCopy);
MarkDirty();
}
private void MarkDirty()
{
_preview?.LoadParticleSystem(_workingCopy);
if (!_isDirty)
{
if (_workingCopy != null)
_workingCopy.IsDirty = true;
if (_resource != null)
_resource.IsDirty = true;
_isDirty = true;
if (_asset != null)
_asset.HasUnsavedChanges = true;
Title = $"FXBox - {_asset?.Name ?? "Untitled"}*";
}
}
[Shortcut("editor.save", "CTRL+S", ShortcutType.Window)]
private void Save()
{
if (_asset != null && _workingCopy != null)
{
_workingCopy.IsDirty = false;
_workingCopy.Version++;
var json = _workingCopy.Serialize().ToJsonString();
System.IO.File.WriteAllText(_asset.AbsolutePath, json);
_resource = DeepCopyResource(_workingCopy);
_resource.IsDirty = false;
_isDirty = false;
_asset.HasUnsavedChanges = false;
Title = $"FXBox - {_asset.Name}";
Log.Info($"Saved: {_asset.Name}");
}
else if (_workingCopy != null)
{
// No asset yet, prompt for save location
SaveAs();
}
}
private void SaveAs()
{
var fd = new FileDialog(null)
{
Title = "Save Particle System",
DefaultSuffix = ".fx"
};
fd.SelectFile("untitled.fx");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Particle System (*.fx)");
if (!fd.Execute())
return;
var savePath = fd.SelectedFile;
_workingCopy.IsDirty = false;
_workingCopy.Version++;
var json = _workingCopy.Serialize().ToJsonString();
System.IO.File.WriteAllText(savePath, json);
_asset = AssetSystem.RegisterFile(savePath);
_resource = DeepCopyResource(_workingCopy);
_resource.IsDirty = false;
_isDirty = false;
if (_asset != null)
_asset.HasUnsavedChanges = false;
Title = $"FXBox - {_asset.Name}";
Log.Info($"Saved: {_asset.Name}");
}
private void OnModuleDuplicated(ParticleModule module, ModuleStage stage)
{
if (_workingCopy == null) return;
foreach (var emitter in _workingCopy.Emitters)
{
var moduleList = stage switch
{
ModuleStage.Spawn => emitter.SpawnModules,
ModuleStage.Initialize => emitter.InitializeModules,
ModuleStage.Update => emitter.UpdateModules,
ModuleStage.Render => emitter.RenderModules,
_ => null
};
if (moduleList == null) continue;
var index = moduleList.IndexOf(module);
if (index == -1) continue;
// Round-trip the whole resource; the module will be at the same
// emitter index + stage index in the cloned copy.
var emitterIndex = _workingCopy.Emitters.IndexOf(emitter);
var resourceCopy = DeepCopyResource(_workingCopy);
var clonedList = stage switch
{
ModuleStage.Spawn => resourceCopy.Emitters[emitterIndex].SpawnModules,
ModuleStage.Initialize => resourceCopy.Emitters[emitterIndex].InitializeModules,
ModuleStage.Update => resourceCopy.Emitters[emitterIndex].UpdateModules,
ModuleStage.Render => resourceCopy.Emitters[emitterIndex].RenderModules,
_ => null
};
if (clonedList == null) break;
var clone = clonedList[index];
clone.Name = $"{module.Name} (Copy)";
moduleList.Insert(index + 1, clone);
break; // modules are unique instances, no need to keep iterating
}
_emitterList?.SetResource(_workingCopy);
_preview?.LoadParticleSystem(_workingCopy);
MarkDirty();
}
private void PromptSave(System.Action action)
{
if (!_isDirty)
{
action?.Invoke();
return;
}
var confirm = new PopupWindow(
"Save Current Particle System",
"The particle system has unsaved changes. Would you like to save now?",
"Cancel",
new Dictionary<string, System.Action>()
{
{ "No", () => { _isDirty = false; action?.Invoke(); } },
{ "Yes", () => { Save(); if (!_isDirty) action?.Invoke(); } }
}
);
confirm.Show();
}
public void SelectMember(string memberName) { }
protected override bool OnClose()
{
CurrentEditingResource = null;
if (_isDirty)
{
PromptSave(() => { _isDirty = false; Close(); });
return false;
}
return base.OnClose();
}
}
/// <summary>
/// Left panel showing emitters and their modules in a tree structure
/// </summary>
public class EmitterList : Widget
{
private TreeView _tree;
private ParticleResource _resource;
public System.Action<object> OnSelectionChanged;
public System.Action<ParticleEmitter> OnEmitterDeleted;
public System.Action<ParticleModule> OnModuleDeleted;
public System.Action<ParticleEmitter, ModuleStage> OnAddModule;
public System.Action<ParticleEmitter> OnEmitterDuplicated;
public System.Action<ParticleModule, ModuleStage> OnModuleDuplicated;
public System.Action OnSystemChanged;
public EmitterList(Widget parent) : base(parent)
{
Layout = Layout.Column();
Layout.Spacing = 0;
// Header
var header = new Widget(this);
header.Layout = Layout.Row();
header.Layout.Spacing = 8;
header.Layout.Margin = 8;
header.MinimumHeight = 32;
header.SetStyles("background-color: #1e1e1e;");
var label = new Label("Emitters & Modules", header);
label.SetStyles("font-weight: bold; font-size: 14px;");
header.Layout.Add(label, 1);
Layout.Add(header);
_tree = new TreeView(this);
_tree.AcceptDrops = true;
_tree.ItemClicked = OnItemActivated;
_tree.ItemContextMenu = OnItemContextMenu;
_tree.ItemSelected = OnTreeMousePress;
Layout.Add(_tree, 1);
}
private void OnTreeMousePress(object item)
{
TryHandleStageButtonClick(item);
}
private bool TryHandleStageButtonClick(object item)
{
if (item is not StageNode stageNode)
return false;
if (!_tree.TryGetItemRect(stageNode, out var itemRect))
return false;
var buttonRect = new Rect(itemRect.Right - 24, itemRect.Top, 24, itemRect.Height);
if (itemRect.IsInside(buttonRect))
{
OnAddModule?.Invoke(stageNode.Emitter, stageNode.Stage);
return true;
}
return false;
}
public void SetResource(ParticleResource resource)
{
_resource = resource;
RebuildTree();
}
private void RebuildTree()
{
_tree.Clear();
if (_resource == null) return;
var systemNode = new SystemNode(_resource);
_tree.AddItem(systemNode);
_tree.Open(systemNode);
foreach (var emitter in _resource.Emitters)
{
var emitterNode = new EmitterNode(emitter);
systemNode.AddItem(emitterNode);
_tree.Open(emitterNode);
AddStageNode(emitterNode, "Spawn", ModuleStage.Spawn, emitter.SpawnModules, emitter);
AddStageNode(emitterNode, "Initialize", ModuleStage.Initialize, emitter.InitializeModules, emitter);
AddStageNode(emitterNode, "Update", ModuleStage.Update, emitter.UpdateModules, emitter);
AddStageNode(emitterNode, "Render", ModuleStage.Render, emitter.RenderModules, emitter);
}
}
private void AddStageNode(TreeNode parent, string name, ModuleStage stage, List<ParticleModule> modules, ParticleEmitter emitter)
{
var stageNode = new StageNode(name, stage, emitter, modules.Count);
parent.AddItem(stageNode);
_tree.Open(stageNode);
foreach (var module in modules)
{
var moduleNode = new ModuleNode(module, stage);
stageNode.AddItem(moduleNode);
}
}
private void OnItemActivated(object item)
{
var selected = item;
if (selected == null) return;
if (selected is SystemNode systemNode)
OnSelectionChanged?.Invoke(systemNode.Resource);
else if (selected is EmitterNode emitterNode)
OnSelectionChanged?.Invoke(emitterNode.Emitter);
else if (selected is ModuleNode moduleNode)
OnSelectionChanged?.Invoke(moduleNode.Module);
}
private void OnItemContextMenu(object item)
{
var selected = item;
if (selected == null) return;
var menu = new Menu(this);
if (selected is EmitterNode emitterNode)
{
menu.AddOption("Duplicate", "content_copy", () => OnEmitterDuplicated?.Invoke(emitterNode.Emitter));
menu.AddSeparator();
menu.AddOption("Delete Emitter", "delete", () => OnEmitterDeleted?.Invoke(emitterNode.Emitter));
}
else if (selected is ModuleNode moduleNode)
{
var module = moduleNode.Module;
menu.AddOption("Duplicate", "content_copy", () => OnModuleDuplicated?.Invoke(module, moduleNode.Stage));
menu.AddOption(module.Enabled ? "Disable" : "Enable",
module.Enabled ? "visibility_off" : "visibility",
() => {
module.Enabled = !module.Enabled;
OnSystemChanged?.Invoke();
RebuildTree();
});
menu.AddSeparator();
menu.AddOption("Delete Module", "delete", () => OnModuleDeleted?.Invoke(module));
}
else if (selected is StageNode stageNode)
{
menu.AddOption("Add Module", "add", () => OnAddModule?.Invoke(stageNode.Emitter, stageNode.Stage));
}
menu.OpenAtCursor();
}
// TreeNode classes
private class SystemNode : TreeNode
{
public ParticleResource Resource { get; }
public SystemNode(ParticleResource resource)
{
Resource = resource;
}
public override void OnPaint(VirtualWidget item)
{
PaintSelection(item);
var rect = item.Rect.Shrink(4, 2);
Paint.SetDefaultFont();
Paint.SetPen(Theme.Text);
Paint.DrawIcon(rect, "auto_awesome", 16, TextFlag.LeftCenter);
rect.Left += 24;
Paint.DrawText(rect, "Particle System", TextFlag.LeftCenter);
}
}
private class EmitterNode : TreeNode
{
public ParticleEmitter Emitter { get; }
public EmitterNode(ParticleEmitter emitter)
{
Emitter = emitter;
}
public override void OnPaint(VirtualWidget item)
{
PaintSelection(item);
var rect = item.Rect.Shrink(4, 2);
Paint.SetDefaultFont();
Paint.SetPen(Theme.Green);
Paint.DrawText(rect, "● ", TextFlag.LeftCenter);
rect.Left += 20;
Paint.SetPen(Theme.Text);
Paint.DrawText(rect, Emitter.Name, TextFlag.LeftCenter);
}
}
private class StageNode : TreeNode
{
public new string Name { get; }
public ModuleStage Stage { get; }
public ParticleEmitter Emitter { get; }
public int Count { get; }
public StageNode(string name, ModuleStage stage, ParticleEmitter emitter, int count)
{
Name = name;
Stage = stage;
Emitter = emitter;
Count = count;
}
public override void OnPaint(VirtualWidget item)
{
PaintSelection(item);
var rect = item.Rect.Shrink(4, 2);
Paint.SetDefaultFont();
var stageColor = Stage switch
{
ModuleStage.Spawn => new Color(1f, 0.6f, 0.2f),
ModuleStage.Initialize => new Color(0.3f, 0.8f, 0.3f),
ModuleStage.Update => new Color(0.3f, 0.6f, 1f),
ModuleStage.Render => new Color(0.9f, 0.3f, 0.9f),
_ => Theme.Text
};
Paint.SetPen(stageColor);
Paint.DrawText(rect, $"{Name} ({Count})", TextFlag.LeftCenter);
var buttonRect = new Rect(rect.Right - 24, rect.Top, 24, rect.Height);
if (buttonRect.IsInside(item.Rect))
{
Paint.ClearPen();
Paint.SetBrush(Theme.ControlBackground.Lighten(0.2f));
Paint.DrawRect(buttonRect.Shrink(2), 2);
}
Paint.SetPen(stageColor);
Paint.DrawIcon(buttonRect, "add", 16, TextFlag.Center);
if (item.Dropping)
{
Paint.ClearPen();
Paint.SetBrush(Theme.Blue.WithAlpha(0.2f));
Paint.DrawRect(item.Rect, 2);
}
}
public override DropAction OnDragDrop(BaseItemWidget.ItemDragEvent e)
{
if (e.Data.Object is not ModuleNode draggedNode)
return DropAction.Ignore;
var draggedModule = draggedNode.Module;
if (draggedNode.Stage != Stage)
return DropAction.Ignore;
if (e.IsDrop)
{
var targetList = Stage switch
{
ModuleStage.Spawn => Emitter.SpawnModules,
ModuleStage.Initialize => Emitter.InitializeModules,
ModuleStage.Update => Emitter.UpdateModules,
ModuleStage.Render => Emitter.RenderModules,
_ => null
};
if (targetList == null) return DropAction.Ignore;
targetList.Remove(draggedModule);
targetList.Add(draggedModule);
if (TreeView.Parent is EmitterList list)
{
list.OnSystemChanged?.Invoke();
list.RebuildTree();
}
}
return DropAction.Move;
}
}
private class ModuleNode : TreeNode
{
public ParticleModule Module { get; }
public ModuleStage Stage { get; }
public ModuleNode(ParticleModule module, ModuleStage stage)
{
Module = module;
Stage = stage;
}
public override void OnPaint(VirtualWidget item)
{
PaintSelection(item);
var displayInfo = DisplayInfo.ForType(Module.GetType());
var rect = item.Rect.Shrink(4, 2);
Paint.SetDefaultFont();
Paint.SetPen(Module.Enabled ? Theme.Text : Theme.Text.WithAlpha(0.5f));
if (!string.IsNullOrEmpty(displayInfo.Icon))
{
Paint.DrawIcon(rect, displayInfo.Icon, 16, TextFlag.LeftCenter);
rect.Left += 24;
}
Paint.DrawText(rect, Module.Name ?? displayInfo.Name, TextFlag.LeftCenter);
if (item.Dropping)
{
Paint.ClearPen();
Paint.SetBrush(Theme.Blue.WithAlpha(0.2f));
if (TreeView.CurrentItemDragEvent.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Top))
{
var droprect = item.Rect;
droprect.Top -= 1;
droprect.Height = 2;
Paint.DrawRect(droprect, 2);
}
else if (TreeView.CurrentItemDragEvent.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Bottom))
{
var droprect = item.Rect;
droprect.Top = droprect.Bottom - 1;
droprect.Height = 2;
Paint.DrawRect(droprect, 2);
}
else
{
Paint.DrawRect(item.Rect, 2);
}
}
}
public override bool OnDragStart()
{
var drag = new Drag(TreeView);
drag.Data.Object = this;
drag.Execute();
return true;
}
public override DropAction OnDragDrop(BaseItemWidget.ItemDragEvent e)
{
if (e.Data.Object is not ModuleNode draggedNode)
return DropAction.Ignore;
var draggedModule = draggedNode.Module;
var targetModule = Module;
if (draggedNode.Stage != Stage)
return DropAction.Ignore;
var emitterList = TreeView.Parent as EmitterList;
if (emitterList?._resource == null) return DropAction.Ignore;
foreach (var emitter in emitterList._resource.Emitters)
{
var moduleList = Stage switch
{
ModuleStage.Spawn => emitter.SpawnModules,
ModuleStage.Initialize => emitter.InitializeModules,
ModuleStage.Update => emitter.UpdateModules,
ModuleStage.Render => emitter.RenderModules,
_ => null
};
if (moduleList == null) continue;
var draggedIndex = moduleList.IndexOf(draggedModule);
var targetIndex = moduleList.IndexOf(targetModule);
if (draggedIndex == -1 || targetIndex == -1) continue;
if (e.IsDrop)
{
moduleList.RemoveAt(draggedIndex);
if (draggedIndex < targetIndex)
targetIndex--;
if (e.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Top))
{
moduleList.Insert(targetIndex, draggedModule);
}
else if (e.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Bottom))
{
moduleList.Insert(targetIndex + 1, draggedModule);
}
else
{
moduleList.Insert(targetIndex, draggedModule);
}
emitterList.OnSystemChanged?.Invoke();
emitterList.RebuildTree();
}
return DropAction.Move;
}
return DropAction.Ignore;
}
}
}
/// <summary>
/// Right panel showing properties - ShaderGraph style
/// </summary>
public class PropertiesWidget : Widget
{
private Label _titleLabel;
private Layout _contentLayout;
private object _currentTarget;
private SerializedObject _currentSerializedObject;
public System.Action OnPropertyChanged;
public PropertiesWidget(Widget parent) : base(parent)
{
Layout = Layout.Column();
Layout.Spacing = 0;
var header = new Widget(this);
header.Layout = Layout.Column();
header.Layout.Margin = 8;
header.MinimumHeight = 32;
header.SetStyles("background-color: #1e1e1e;");
_titleLabel = new Label("Properties", header);
_titleLabel.SetStyles("font-weight: bold; font-size: 14px;");
header.Layout.Add(_titleLabel);
Layout.Add(header);
_contentLayout = Layout.AddColumn(1);
}
public void ShowSystemProperties(ParticleResource resource)
{
_currentTarget = resource;
_titleLabel.Text = "System Properties";
RebuildContent(() => {
if (resource != null)
{
var so = resource.GetSerialized();
so.OnPropertyChanged += OnSerializedPropertyChanged;
return so;
}
return null;
});
}
public void ShowEmitterProperties(ParticleEmitter emitter)
{
_currentTarget = emitter;
_titleLabel.Text = $"Emitter: {emitter.Name}";
RebuildContent(() => {
if (emitter != null)
{
var so = emitter.GetSerialized();
so.OnPropertyChanged += OnSerializedPropertyChanged;
return so;
}
return null;
});
}
public void ShowModuleProperties(ParticleModule module)
{
_currentTarget = module;
var displayInfo = DisplayInfo.ForType(module.GetType());
_titleLabel.Text = displayInfo.Name;
RebuildContent(() => {
if (module != null)
{
var so = module.GetSerialized();
so.OnPropertyChanged += OnSerializedPropertyChanged;
return so;
}
return null;
});
}
private void OnSerializedPropertyChanged(SerializedProperty property)
{
if (_currentSerializedObject != null && _currentTarget != null)
{
_currentSerializedObject.NoteFinishEdit(property);
}
OnPropertyChanged?.Invoke();
}
private void RebuildContent(System.Func<SerializedObject> getSerializedObject)
{
_contentLayout.Clear(true);
if (_currentSerializedObject != null)
{
_currentSerializedObject.OnPropertyChanged -= OnSerializedPropertyChanged;
}
var scroll = new ScrollArea(this);
scroll.Canvas = new Widget(scroll);
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 8;
scroll.Canvas.Layout.Spacing = 4;
scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
var so = getSerializedObject();
if (so != null)
{
_currentSerializedObject = so;
var sheet = new ControlSheet();
sheet.AddObject(so);
scroll.Canvas.Layout.Add(sheet);
scroll.Canvas.Layout.AddStretchCell();
}
else
{
_currentSerializedObject = null;
}
_contentLayout.Add(scroll);
}
}
UnitTest
library
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
Game
library
using System;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace fxbox;
/// <summary>
/// A named float parameter that can be used to control particle values
/// </summary>
public class FloatParameter
{
[Property] public string Name { get; set; } = "Parameter";
[Property] public float DefaultValue { get; set; } = 1.0f;
[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
}
/// <summary>
/// A named vector parameter that can be used to control particle values
/// </summary>
public class VectorParameter
{
[Property] public string Name { get; set; } = "VectorParameter";
[Property] public Vector3 DefaultValue { get; set; } = Vector3.One;
[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
}
/// <summary>
/// A named color parameter that can be used to control particle values
/// </summary>
public class ColorParameter
{
[Property] public string Name { get; set; } = "ColorParameter";
[Property] public ParticleGradient DefaultValue { get; set; } = Color.White;
[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
}
Game
library
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>
/// Seconds to wait before this emitter starts spawning - lets one emitter in the same
/// system kick off a few seconds after another. Maps straight onto the native
/// ParticleEffect.StartDelay, so it's handled by the engine's own particle system rather
/// than anything FXBox has to gate itself.
/// </summary>
public float Delay { get; set; } = 0f;
/// <summary>
/// Overrides the system's own Duration for deciding when THIS emitter is finished
/// (see FXBoxParticleController.IsFinished) - 0 means "use the particle system's own
/// Duration instead", the same as before this existed. Lets one emitter in a system run
/// longer or shorter than the rest without touching the system-wide Duration.
/// </summary>
public float Duration { get; set; } = 0f;
/// <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();
}
}
Game
library
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,
}
Game
library
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,
}
Game
library
using System;
using Sandbox;
using System.Linq;
namespace fxbox;
public class FXParticleFloat
{
[Property] public bool UseParameter { get; set; } = false;
[Property, ShowIf(nameof(UseParameter), false)]
public ParticleFloat Value { get; set; }
[Property, ShowIf(nameof(UseParameter), true)]
[Description("Select a parameter from the system")]
public string ParameterName { get; set; }
[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]
[Description("Multiplier applied to the parameter value")]
public float Multiplier { get; set; } = 1.0f;
public float GetValue(FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
return systemComponent.GetFloatParameter(ParameterName) * Multiplier;
}
var result = Value.Evaluate(Random.Shared.Float(), 3f);
return result;
}
public ParticleFloat ToParticleFloat(FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
// Get the instance-specific value (override or default)
float value = systemComponent.GetFloatParameter(ParameterName) * Multiplier;
return new ParticleFloat()
{
Type = ParticleFloat.ValueType.Constant,
ConstantValue = value,
Evaluation = ParticleFloat.EvaluationType.Seed
};
}
return Value;
}
// ==================== OPERATORS ====================
public static FXParticleFloat operator *(float a, FXParticleFloat b)
{
return b * a; // Commutative
}
// Division operators
public static FXParticleFloat operator /(FXParticleFloat a, float b)
{
if (a == null)
{
Log.Warning("Division: a is null");
return null;
}
if (b == 0 || MathF.Abs(b) < 0.0001f)
{
Log.Warning($"Division by zero or very small number ({b}) in FXParticleFloat");
return a;
}
var result = a * (1.0f / b);
return result;
}
public static FXParticleFloat operator *(FXParticleFloat a, float b)
{
if (a == null)
{
Log.Warning("Multiplication: a is null");
return null;
}
var result = new FXParticleFloat();
if (a.UseParameter)
{
result.UseParameter = true;
result.ParameterName = a.ParameterName;
result.Multiplier = a.Multiplier * b;
result.Value = new ParticleFloat()
{
Type = ParticleFloat.ValueType.Constant,
ConstantValue = 0f,
Evaluation = ParticleFloat.EvaluationType.Seed
};
}
else
{
result.UseParameter = false;
result.Value = ScaleParticleFloat(a.Value, b);
}
return result;
}
private static ParticleFloat ScaleParticleFloat(ParticleFloat pf, float scale)
{
var result = new ParticleFloat();
result.Type = pf.Type;
result.Evaluation = pf.Evaluation;
// Copy Constants FIRST, before setting individual values
// (or don't copy it at all since we're setting the values manually)
// result.Constants = pf.Constants;
switch (pf.Type)
{
case ParticleFloat.ValueType.Constant:
result.ConstantValue = pf.ConstantValue * scale;
break;
case ParticleFloat.ValueType.Range:
result.ConstantA = pf.ConstantA * scale;
result.ConstantB = pf.ConstantB * scale;
break;
case ParticleFloat.ValueType.Curve:
result.CurveA = ScaleCurve(pf.CurveA, scale);
result.CurveB = ScaleCurve(pf.CurveB, scale);
break;
case ParticleFloat.ValueType.CurveRange:
result.CurveRange = ScaleCurveRange(pf.CurveRange, scale);
break;
}
// DON'T copy Constants here - it overwrites our values!
// result.Constants = pf.Constants;
return result;
}
private static ParticleFloat OffsetParticleFloat(ParticleFloat pf, float offset)
{
var result = new ParticleFloat();
result.Type = pf.Type;
result.Evaluation = pf.Evaluation;
// DON'T copy Constants - it will overwrite our values
// result.Constants = pf.Constants;
switch (pf.Type)
{
case ParticleFloat.ValueType.Constant:
result.ConstantValue = pf.ConstantValue + offset;
break;
case ParticleFloat.ValueType.Range:
result.ConstantA = pf.ConstantA + offset;
result.ConstantB = pf.ConstantB + offset;
break;
case ParticleFloat.ValueType.Curve:
result.CurveA = OffsetCurve(pf.CurveA, offset);
result.CurveB = OffsetCurve(pf.CurveB, offset);
break;
case ParticleFloat.ValueType.CurveRange:
result.CurveRange = OffsetCurveRange(pf.CurveRange, offset);
break;
}
// DON'T copy Constants here!
// result.Constants = pf.Constants;
return result;
}
private static Curve ScaleCurve(Curve curve, float scale)
{
var newFrames = curve.Frames.Select(frame =>
new Curve.Frame(frame.Time, frame.Value * scale)).ToArray();
return new Curve(newFrames);
}
private static Curve OffsetCurve(Curve curve, float offset)
{
var newFrames = curve.Frames.Select(frame =>
new Curve.Frame(frame.Time, frame.Value + offset)).ToArray();
return new Curve(newFrames);
}
private static CurveRange ScaleCurveRange(CurveRange range, float scale)
{
return new CurveRange
(
ScaleCurve(range.A, scale),
ScaleCurve(range.B, scale)
);
}
private static CurveRange OffsetCurveRange(CurveRange range, float offset)
{
return new CurveRange
(
OffsetCurve(range.A, offset),
OffsetCurve(range.B, offset)
);
}
// ==================== IMPLICIT CONVERSIONS ====================
public static implicit operator FXParticleFloat(float v)
{
var fxParticle = new FXParticleFloat();
fxParticle.Value = new ParticleFloat()
{
Type = ParticleFloat.ValueType.Constant,
ConstantValue = v,
Evaluation = ParticleFloat.EvaluationType.Seed
};
return fxParticle;
}
// ==================== CONSTRUCTORS ====================
public FXParticleFloat()
{
var particleFloat = new ParticleFloat();
particleFloat.Type = ParticleFloat.ValueType.Constant;
particleFloat.ConstantValue = 0.0f;
particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;
particleFloat.CurveA = new Curve();
particleFloat.CurveB = new Curve();
particleFloat.Constants = new Vector4();
this.Value = particleFloat;
}
public FXParticleFloat(float a, float b)
{
var particleFloat = new ParticleFloat();
particleFloat.Type = ParticleFloat.ValueType.Range;
particleFloat.ConstantA = a;
particleFloat.ConstantB = b;
particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;
particleFloat.CurveA = new Curve();
particleFloat.CurveB = new Curve();
particleFloat.Constants = new Vector4();
this.Value = particleFloat;
}
}
public class FXParticleVector
{
[Property] public bool UseParameter { get; set; } = false;
[Property, ShowIf(nameof(UseParameter), false)]
public ParticleVector3 Value { get; set; } = Vector3.Zero;
[Property, ShowIf(nameof(UseParameter), true)]
[Description("Select a vector parameter from the system")]
public string ParameterName { get; set; }
[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]
[Description("Multiplier applied to the parameter value")]
public float Multiplier { get; set; } = 1.0f;
public Vector3 GetValue(Particle particle,FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
return systemComponent.GetVectorParameter(ParameterName) * Multiplier;
}
if ( particle == null )
{
return Value.Evaluate( Time.Delta, 0, 0, 0 );
}
return Value.Evaluate( Time.Delta,particle.Rand(1),particle.Rand(2),particle.Rand(3) );
}
public static implicit operator FXParticleVector(Vector3 v)
{
return new FXParticleVector { Value = v };
}
public FXParticleVector()
{
Value = Vector3.Zero;
}
public FXParticleVector(Vector3 value)
{
Value = value;
}
public FXParticleVector(float x, float y, float z)
{
Value = new Vector3(x, y, z);
}
}
public class FXParticleColor
{
[Property] public bool UseParameter { get; set; } = false;
[Property, ShowIf(nameof(UseParameter), false)]
public ParticleGradient Value { get; set; } = Color.White;
[Property, ShowIf(nameof(UseParameter), true)]
[Description("Select a color parameter from the system")]
public string ParameterName { get; set; }
[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]
[Description("Multiplier applied to the parameter value (affects RGB)")]
public float Multiplier { get; set; } = 1.0f;
public ParticleGradient GetValue(FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
var color = systemComponent.GetColorParameter(ParameterName);
// Apply multiplier to RGB components
if (Multiplier != 1.0f)
{
return color;
}
return color;
}
return Value;
}
public static implicit operator FXParticleColor(Color c)
{
return new FXParticleColor { Value = c };
}
public FXParticleColor()
{
Value = Color.White;
}
public FXParticleColor(Color value)
{
Value = value;
}
public FXParticleColor(float r, float g, float b, float a = 1.0f)
{
Value = new Color(r, g, b, a);
}
}
Game
library
using System;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace fxbox;
/// <summary>
/// A named float parameter that can be used to control particle values
/// </summary>
public class FloatParameter
{
[Property] public string Name { get; set; } = "Parameter";
[Property] public float DefaultValue { get; set; } = 1.0f;
[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
}
/// <summary>
/// A named vector parameter that can be used to control particle values
/// </summary>
public class VectorParameter
{
[Property] public string Name { get; set; } = "VectorParameter";
[Property] public Vector3 DefaultValue { get; set; } = Vector3.One;
[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
}
/// <summary>
/// A named color parameter that can be used to control particle values
/// </summary>
public class ColorParameter
{
[Property] public string Name { get; set; } = "ColorParameter";
[Property] public ParticleGradient DefaultValue { get; set; } = Color.White;
[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();
}
Editor
library
using System;
using Editor;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace fxbox.Graph;
/// <summary>
/// Widget that renders a real-time preview of the particle system using native components
/// </summary>
public class ParticlePreview : SceneRenderingWidget
{
private ParticleResource _resource;
private GameObject _particleSystemObject;
private FXBoxNativeParticleSystem _particleSystem;
private bool _isPlaying = true;
private float _playbackSpeed = 1.0f;
private Vector2 _lastCursorPos;
private Vector2 _angles = new Vector2(45, 30);
private float _distance = 500f;
private float _actualDistance = 500f;
private Vector3 _targetPosition = Vector3.Zero;
private bool _isOrbiting = false;
public float PlaybackSpeed
{
get => _playbackSpeed;
set => _playbackSpeed = value;
}
public ParticlePreview(Widget parent) : base(parent)
{
MouseTracking = true;
FocusMode = FocusMode.Click;
Scene = Scene.CreateEditorScene();
using (Scene.Push())
{
// Setup camera
var cameraGo = new GameObject(true, "camera");
var camera = cameraGo.GetOrAddComponent<CameraComponent>();
camera.BackgroundColor = new Color(0.1f, 0.1f, 0.15f);
camera.ZFar = 10000;
camera.FieldOfView = 60;
Camera = camera;
// Add lighting
var sunGo = new GameObject(true, "sun");
var sun = sunGo.GetOrAddComponent<DirectionalLight>();
sun.WorldRotation = Rotation.FromPitch(50);
sun.LightColor = Color.White;
var ambientGo = new GameObject(true, "ambient");
var ambient = ambientGo.GetOrAddComponent<AmbientLight>();
ambient.Color = Color.Gray * 0.3f;
}
UpdateCameraPosition();
}
public void LoadParticleSystem(ParticleResource resource)
{
_resource = resource;
// Clean up old system
if (_particleSystemObject.IsValid())
{
_particleSystemObject.Destroy();
}
if (_resource != null)
{
using (Scene.Push())
{
// Create new particle system object
_particleSystemObject = new GameObject(true, "ParticleSystem");
_particleSystem = _particleSystemObject.AddComponent<FXBoxNativeParticleSystem>();
_particleSystem.ParticleSystem = _resource;
_particleSystem.PlayOnStart = true;
// Initialize the system
_particleSystem.UpdateEmitters();
}
Log.Info($"Loaded particle system with {_resource.Emitters?.Count ?? 0} emitters");
}
}
public void SetPlaying(bool playing)
{
_isPlaying = playing;
if (_particleSystemObject.IsValid())
{
// Enable/disable all particle effects
foreach (var effect in _particleSystemObject.Children)
{
var particleEffect = effect.GetComponent<ParticleEffect>();
if (particleEffect.IsValid())
{
particleEffect.Enabled = playing;
}
}
}
}
public void TogglePlayback()
{
_isPlaying = !_isPlaying;
SetPlaying(_isPlaying);
Log.Info($"Playback: {(_isPlaying ? "Playing" : "Paused")}");
}
public void Restart()
{
if (_particleSystemObject.IsValid())
{
// Restart by rebuilding the entire system
LoadParticleSystem(_resource);
Log.Info("Particle system restarted");
}
}
protected override void PreFrame()
{
Scene.EditorTick(RealTime.Now, RealTime.Delta);
DrawGizmos();
// Force continuous updates
Update();
}
private void DrawGizmos()
{
if (_resource == null) return;
// Draw spawn shape for first emitter
if (_resource.Emitters.Count > 0)
{
var emitter = _resource.Emitters[0];
var posModule = emitter.InitializeModules.OfType<InitializePositionModule>().FirstOrDefault();
if (posModule != null)
{
Gizmo.Draw.Color = Color.Yellow.WithAlpha(0.3f);
Gizmo.Draw.LineThickness = 2;
switch (posModule.Shape)
{
case InitializePositionModule.SpawnShape.Sphere:
Gizmo.Draw.LineSphere(new Sphere(Vector3.Zero, posModule.Radius));
break;
case InitializePositionModule.SpawnShape.Box:
Gizmo.Draw.LineBBox(BBox.FromPositionAndSize(Vector3.Zero, posModule.BoxSize));
break;
case InitializePositionModule.SpawnShape.Cone:
DrawConeGizmo(posModule);
break;
case InitializePositionModule.SpawnShape.Circle:
Gizmo.Draw.LineCircle(Vector3.Zero, Vector3.Forward, posModule.Radius);
break;
case InitializePositionModule.SpawnShape.Line:
Gizmo.Draw.Line(posModule.LineStart, posModule.LineEnd);
break;
}
}
}
DrawGrid();
}
private void DrawConeGizmo(InitializePositionModule module)
{
var height = module.Radius;
var radius = MathF.Tan(module.ConeAngle.DegreeToRadian()) * height;
// Draw cone base
Gizmo.Draw.LineCircle(Vector3.Forward * height, Vector3.Forward, radius);
// Draw cone lines
var points = 8;
for (int i = 0; i < points; i++)
{
var angle = (i / (float)points) * 360f;
var dir = new Vector3(
MathF.Cos(angle.DegreeToRadian()) * radius,
MathF.Sin(angle.DegreeToRadian()) * radius,
height
);
Gizmo.Draw.Line(Vector3.Zero, dir);
}
}
private void DrawGrid()
{
if (_resource?.PreviewSettings?.ShowGrid ?? true)
{
Gizmo.Draw.Color = Color.White.WithAlpha(0.1f);
Gizmo.Draw.LineThickness = 1;
// Draw XY grid
for (int x = -500; x <= 500; x += 100)
{
Gizmo.Draw.Line(new Vector3(x, -500, 0), new Vector3(x, 500, 0));
}
for (int y = -500; y <= 500; y += 100)
{
Gizmo.Draw.Line(new Vector3(-500, y, 0), new Vector3(500, y, 0));
}
}
if (_resource?.PreviewSettings?.ShowGround ?? true)
{
Gizmo.Draw.Color = Color.Gray.WithAlpha(0.2f);
Gizmo.Draw.LineBBox(new BBox(new Vector3(-500, -500, -1), new Vector3(500, 500, 0)));
}
UpdateCameraPosition();
}
private void UpdateCameraPosition()
{
if (!Camera.IsValid()) return;
Camera.WorldRotation = new Angles(_angles.y, -_angles.x, 0);
_actualDistance = _actualDistance.LerpTo( _distance, Time.Delta * 15f );
Camera.WorldPosition = _targetPosition + Camera.WorldRotation.Backward * _actualDistance;
}
protected override void OnMousePress(MouseEvent e)
{
base.OnMousePress(e);
if (e.LeftMouseButton)
{
_isOrbiting = true;
_lastCursorPos = e.ScreenPosition;
}
}
protected override void OnMouseReleased(MouseEvent e)
{
base.OnMouseReleased(e);
if (e.LeftMouseButton)
{
_isOrbiting = false;
}
}
protected override void OnMouseMove(MouseEvent e)
{
base.OnMouseMove(e);
if (_isOrbiting)
{
var delta = e.ScreenPosition - _lastCursorPos;
_angles.x += delta.x * 0.3f;
_angles.y += delta.y * 0.3f;
_angles.y = _angles.y.Clamp(-90, 90);
//UpdateCameraPosition();
_lastCursorPos = e.ScreenPosition;
}
}
protected override void OnMouseWheel( WheelEvent e )
{
base.OnMouseWheel(e);
_distance -= e.Delta * 1f;
_distance = _distance.Clamp(50, 5000);
}
protected override void OnPaint()
{
base.OnPaint();
// Draw overlay info
Paint.SetPen(Theme.Text);
Paint.SetDefaultFont();
// Count particles from all emitter objects
int totalParticles = 0;
int totalMax = _resource?.Emitters.Sum(e => e.MaxParticles) ?? 0;
if (_particleSystemObject.IsValid())
{
foreach (var child in _particleSystemObject.Children)
{
var effect = child.GetComponent<ParticleEffect>();
if (effect.IsValid())
{
totalParticles += effect.Particles.Count;
}
}
}
var text = $"Particles: {totalParticles} / {totalMax}";
Paint.DrawText(new Rect(10, 10, 200, 30), text, TextFlag.LeftTop);
var stateText = _isPlaying ? "PLAYING" : "PAUSED";
Paint.DrawText(new Rect(10, 40, 200, 30), stateText, TextFlag.LeftTop);
// Draw emitter count
var emitterText = $"Emitters: {_resource?.Emitters.Count ?? 0}";
Paint.DrawText(new Rect(10, 70, 200, 30), emitterText, TextFlag.LeftTop);
}
}
Game
library
using System;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace fxbox;
/// <summary>
/// Stage when a particle module executes
/// </summary>
public enum ModuleStage
{
Spawn, // Controls when/how particles spawn
Initialize, // Runs once when particle is created
Update, // Runs every frame for each particle
Render // Controls how particles are rendered
}
/// <summary>
/// Context passed to particle modules during execution
/// </summary>
public class ParticleExecutionContext
{
public Particle Particle;
public ParticleEffect Effect;
public Sandbox.ParticleEmitter Emitter;
public FXBoxNativeParticleSystem SystemComponent; // Changed from Resource to SystemComponent
}
// ==================== SPAWN MODULES ====================
/// <summary>
/// Controls spawn rate over time
/// </summary>
[Title("Spawn Rate"), Category("Spawn"), Icon("speed")]
public partial class SpawnRateModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Spawn;
[Property, Range(0.1f, 1000f)]
public FXParticleFloat SpawnRate { get; set; } = 10.0f;
public override void Execute(ParticleExecutionContext context)
{
var rate = SpawnRate;
context.Emitter.Rate = rate.GetValue( context.SystemComponent );
}
public override void Initialize( ParticleExecutionContext context )
{
context.Emitter.Rate = SpawnRate.GetValue( context.SystemComponent );
}
}
/// <summary>
/// Sets initial particle stretch
/// </summary>
[Title("Particle Stretch"), Category("Initialize"), Icon("photo_size_select_small")]
public partial class ParticleStretchModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property, Range(0.1f, 100f)]
public FXParticleFloat Size { get; set; } = 1.0f;
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.ApplyShape = true;
context.Effect.Stretch = Size.ToParticleFloat( context.SystemComponent );
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Controls spawn rate per unit
/// </summary>
[Title("Spawn Rate Over Distance"), Category("Spawn"), Icon("speed")]
public partial class SpawnRateOverDistanceModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Spawn;
[Property, Range(0.1f, 1000f)]
public FXParticleFloat SpawnRate { get; set; } = 10.0f;
public override void Execute(ParticleExecutionContext context)
{
context.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );
}
public override void Initialize( ParticleExecutionContext context )
{
context.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );
}
}
/// <summary>
/// Spawns particles in a burst
/// </summary>
[Title("Spawn Burst"), Category("Spawn"), Icon("auto_awesome")]
public partial class SpawnBurstModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Spawn;
[Property, Range(1, 1000)]
public int ParticleCount { get; set; } = 50;
public override void Initialize( ParticleExecutionContext context )
{
context.Emitter.Burst = ParticleCount;
}
// Burst is a one-shot "spawn this many now" value, not a continuous per-tick one like
// SpawnRateModule's Rate - Execute() runs every single frame (see
// FXBoxParticleController.OnUpdate), so reassigning Burst here too kept re-arming/
// re-firing it every tick instead of once, spawning far more than ParticleCount actually
// configured. Same "Initialize-only, empty Execute" shape ParticleStretchModule already
// uses above for its own one-shot value.
public override void Execute(ParticleExecutionContext context)
{
}
}
// ==================== INITIALIZE MODULES ====================
/// <summary>
/// Sets initial position based on shape
/// </summary>
[Title("Initialize Position"), Category("Initialize"), Icon("place")]
public partial class InitializePositionModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
public override void Execute( ParticleExecutionContext context )
{
}
public enum SpawnShape { Point, Sphere, Box, Cone, Circle, Line }
[Property]
public FXCopyFlags CopyFlags { get; set; } = FXCopyFlags.Rotation | FXCopyFlags.Scale;
[Property]
public SpawnShape Shape { get; set; } = SpawnShape.Sphere;
[Property, Range(0f, 1000f), ShowIf(nameof(ShowRadius), true)]
public float Radius { get; set; } = 50.0f;
[Property, ShowIf(nameof(ShowBoxSize), true)]
public Vector3 BoxSize { get; set; } = new Vector3(100, 100, 100);
[Property, Range(0f, 180f), ShowIf(nameof(ShowConeAngle), true)]
public float ConeAngle { get; set; } = 45.0f;
[Property]
public bool EmitFromShell { get; set; } = false;
[Property, ShowIf(nameof(ShowLine), true)]
public Vector3 LineStart { get; set; } = Vector3.Zero;
[Property, ShowIf(nameof(ShowLine), true)]
public Vector3 LineEnd { get; set; } = Vector3.Up * 100;
[Hide] public bool ShowRadius => Shape == SpawnShape.Sphere || Shape == SpawnShape.Circle || Shape == SpawnShape.Cone;
[Hide] public bool ShowBoxSize => Shape == SpawnShape.Box;
[Hide] public bool ShowConeAngle => Shape == SpawnShape.Cone;
[Hide] public bool ShowLine => Shape == SpawnShape.Line;
public override void Initialize( ParticleExecutionContext context )
{
if ( context.Particle != null )
{
var pos = Shape switch
{
SpawnShape.Point => Vector3.Zero,
SpawnShape.Sphere => GetSpherePosition(),
SpawnShape.Box => GetBoxPosition(),
SpawnShape.Cone => GetConePosition(),
SpawnShape.Circle => GetCirclePosition(),
SpawnShape.Line => GetLinePosition(),
_ => Vector3.Zero
};
if ( CopyFlags.HasFlag( FXCopyFlags.Scale ) )
{
pos = pos * context.Emitter.WorldScale;
}
if ( CopyFlags.HasFlag( FXCopyFlags.Rotation ) )
{
pos = pos.RotateAround( 0, context.SystemComponent.WorldRotation );
}
context.Particle.Position += pos;
}
}
public void CreateComponent(GameObject go)
{
var pointEmitter = go.AddComponent<ParticleSphereEmitter>();
pointEmitter.Radius = 0;
pointEmitter.Velocity = 0;
pointEmitter.Burst = 0;
pointEmitter.Rate = 0;
}
private Vector3 GetSpherePosition()
{
var direction = Random.Shared.VectorInSphere().Normal;
var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);
return direction * radius;
}
private Vector3 GetBoxPosition()
{
if (EmitFromShell)
{
var face = Random.Shared.Int(0, 5);
var u = Random.Shared.Float(0, 1);
var v = Random.Shared.Float(0, 1);
var halfSize = BoxSize / 2f;
return face switch
{
0 => new Vector3(-halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),
1 => new Vector3(halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),
2 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), -halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),
3 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),
4 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), -halfSize.z),
_ => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), halfSize.z)
};
}
return new Vector3(
Random.Shared.Float(-BoxSize.x / 2, BoxSize.x / 2),
Random.Shared.Float(-BoxSize.y / 2, BoxSize.y / 2),
Random.Shared.Float(-BoxSize.z / 2, BoxSize.z / 2)
);
}
private Vector3 GetConePosition()
{
var angle = Random.Shared.Float(0, 360);
var distance = Random.Shared.Float(0, Radius);
var coneRadius = MathF.Tan(ConeAngle.DegreeToRadian()) * distance;
var radius = EmitFromShell ? coneRadius : Random.Shared.Float(0, coneRadius);
return new Vector3(
MathF.Cos(angle.DegreeToRadian()) * radius,
MathF.Sin(angle.DegreeToRadian()) * radius,
distance
);
}
private Vector3 GetCirclePosition()
{
var angle = Random.Shared.Float(0, 360);
var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);
return new Vector3(
MathF.Cos(angle.DegreeToRadian()) * radius,
MathF.Sin(angle.DegreeToRadian()) * radius,
0
);
}
private Vector3 GetLinePosition()
{
return Vector3.Lerp(LineStart, LineEnd, Random.Shared.Float(0, 1));
}
}
/// <summary>
/// Controls how strongly particles follow the emitter in local space
/// </summary>
[Title("Initialize Local Space"), Category("Initialize"), Icon("transform")]
public partial class InitializeLocalSpaceModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property, Range(0f, 1f)]
public FXParticleFloat LocalSpace { get; set; } = 0f;
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.LocalSpace = LocalSpace.ToParticleFloat( context.SystemComponent );
}
public override void Execute( ParticleExecutionContext context )
{
}
}
/// <summary>
/// Sets initial velocity
/// </summary>
[Title("Initialize Velocity"), Category("Initialize"), Icon("air")]
public partial class InitializeVelocityModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property]
public FXParticleVector Velocity { get; set; } = Vector3.Up * 100;
[Property] public FXParticleFloat RandomVelocity { get; set; } = 0;
[Property] public bool LocalSpace { get; set; } = false;
[Property]
public bool InheritEmitterVelocity { get; set; } = false;
[Property,ShowIf("InheritEmitterVelocity",true)] public float EmitterVelocityScale { get; set; } = 1.0f;
public override void Initialize( ParticleExecutionContext context )
{
var startVelocity = Velocity;
if ( LocalSpace )
{
startVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent ).RotateAround( 0,context.Emitter.WorldRotation );
}
context.Effect.StartVelocity = RandomVelocity.ToParticleFloat();
context.Effect.InitialVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent );
if ( InheritEmitterVelocity )
{
context.Effect.InitialVelocity = (context.SystemComponent.Velocity*EmitterVelocityScale) + startVelocity.GetValue( context.Particle,context.SystemComponent );
}
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Sets initial color
/// </summary>
[Title("Collision"), Category("Initialize"), Icon("palette")]
public partial class ParticleCollisionModule : ParticleModule
{
[Property] public TagSet CollisionIgnore { get; set; } = new TagSet();
[Property] public List<GameObject> CollisionPrefabs { get; set; } = new List<GameObject>();
[Property] public FXParticleFloat CollisionRadius { get; set; } = 5;
[Property] public FXParticleFloat CollisionPrefabChance { get; set; } = 1;
[Property] public FXParticleFloat CollisionPrefabRotation { get; set; } = 0;
[Property] public FXParticleFloat DieOnCollisionChance { get; set; } = 0;
[Property] public bool CollisionPrefabAlign { get; set; } = false;
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.Collision = true;
context.Effect.CollisionIgnore = CollisionIgnore;
context.Effect.CollisionRadius = CollisionRadius.GetValue( context.SystemComponent );
context.Effect.CollisionPrefabChance = CollisionPrefabChance.GetValue( context.SystemComponent );
context.Effect.CollisionPrefabRotation = CollisionPrefabRotation.ToParticleFloat( context.SystemComponent );
if ( CollisionPrefabs.Any() )
{
context.Effect.UsePrefabFeature = true;
}
context.Effect.CollisionPrefab = CollisionPrefabs;
context.Effect.DieOnCollisionChance = DieOnCollisionChance.GetValue( context.SystemComponent );
context.Effect.CollisionPrefabAlign = CollisionPrefabAlign;
}
}
/// <summary>
/// Sets initial velocity
/// </summary>
[Title("Initialize Rotation"), Category("Initialize"), Icon("air")]
public partial class InitializeRotationModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property]
public ParticleVector3 InitialRotation { get; set; } = Vector3.Up;
public override void Initialize( ParticleExecutionContext context )
{
if ( context.Particle != null )
{
context.Particle.Angles = new Angles( InitialRotation.Evaluate( Time.Delta,context.Particle.Rand( ),context.Particle.Rand( ),context.Particle.Rand( ) ) );
}
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Sets initial lifetime
/// </summary>
[Title("Initialize Lifetime"), Category("Initialize"), Icon("schedule")]
public partial class InitializeLifetimeModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property, Range(0.1f, 100f)]
public FXParticleFloat Lifetime { get; set; } = 2.0f;
public override void Initialize(ParticleExecutionContext context)
{
context.Effect.Lifetime = Lifetime.ToParticleFloat( context.SystemComponent );
}
public override void Execute(ParticleExecutionContext context)
{
// Not used
}
}
/// <summary>
/// Sets initial size
/// </summary>
[Title("Initialize Size"), Category("Initialize"), Icon("photo_size_select_small")]
public partial class InitializeSizeModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property] public bool InheritEmitterScale { get; set; } = true;
[Property, Range(0.1f, 100f)]
public FXParticleFloat Size { get; set; } = 10.0f;
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.ApplyShape = true;
if ( InheritEmitterScale )
{
context.Effect.Scale = Size.ToParticleFloat( context.SystemComponent );
}
else
{
context.Effect.Scale = (Size / context.SystemComponent.WorldScale.x).ToParticleFloat( context.SystemComponent );
}
}
public override void Execute(ParticleExecutionContext context)
{
}
}
/// <summary>
/// Sets initial color
/// </summary>
[Title("Initialize Color"), Category("Initialize"), Icon("palette")]
public partial class InitializeColorModule : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property] public FXParticleColor Color { get; set; } = global::Color.Red;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.ApplyColor = true;
context.Effect.ApplyAlpha = true;
if ( Color != null )
{
context.Effect.Gradient = Color.GetValue( context.SystemComponent );
}
else
{
Color = new FXParticleColor( global::Color.Red );
}
}
}
/// <summary>
/// Sets initial color
/// </summary>
[Title("Sprite Flipbook"), Category("Initialize"), Icon("palette")]
public partial class SpriteFlipbookModule : ParticleModule
{
[Property] public FXParticleFloat SequenceTime { get; set; } = 0;
[Property] public FXParticleFloat SequenceSpeed { get; set; } = 1;
[Property] public int SequenceId { get; set; } = 0;
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
context.Effect.SheetSequence = true;
context.Effect.SequenceId = SequenceId;
context.Effect.SequenceSpeed = SequenceSpeed.ToParticleFloat( context.SystemComponent );
context.Effect.SequenceTime = SequenceTime.ToParticleFloat( context.SystemComponent );
}
}
/// <summary>
/// Randomly Kill a particle to spawn less
/// </summary>
[Title("RandomKill"), Category("Initialize"), Icon("arrow_downward")]
public partial class RandomKill : ParticleModule
{
[Hide]
public override ModuleStage Stage => ModuleStage.Initialize;
[Property]
public float Chance { get; set; } = 0.5f;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
if ( context.Particle == null ) return;
if ( Random.Shared.Float( 0, 1 ) < Chance )
{
context.Particle.Age = 100000;
}
}
}
// ==================== UPDATE MODULES ====================
/// <summary>
/// Applies gravity force
/// </summary>
[Title("Gravity Force"), Category("Update"), Icon("arrow_downward")]
public partial class GravityForceModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property]
public FXParticleVector Force { get; set; } = new Vector3(0, 0, -980);
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
context.Particle.Velocity += Force.GetValue(context.Particle, context.SystemComponent ) * Time.Delta;
}
}
/// <summary>
/// Make a mesh follow it's velocity
/// </summary>
[Title("Follow Velocity"), Category("Update"), Icon("arrow_downward")]
public partial class FollowVelocity : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
context.Particle.Angles = Rotation.LookAt( context.Particle.Velocity ).Angles();
}
}
/// <summary>
/// Applies drag/air resistance
/// </summary>
[Title("Drag Force"), Category("Update"), Icon("air")]
public partial class DragForceModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property, Range(0f, 10f)]
public float Damping { get; set; } = 0.1f;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
context.Particle.Velocity *= (1.0f - Damping * Time.Delta);
}
}
/// <summary>
/// Makes particles rotate
/// </summary>
[Title("Rotation"), Category("Update"), Icon("rotate_right")]
public partial class RotationModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property] public FXParticleVector RotationSpeed { get; set; } = Vector3.Zero;
public override void Execute(ParticleExecutionContext context)
{
/*context.Particle.Rotation += RotationSpeed * context.DeltaTime;*/
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
if ( context.Particle != null )
{
context.Particle.Angles += RotationSpeed.GetValue( context.Particle ) * Time.Delta;
}
}
}
public enum PositionType
{
Local,
World
}
/// <summary>
/// Attracts particles to a point. Full strength inside AttractorSize, falling off beyond it.
/// </summary>
[Title("Point Attractor"), Category("Update"), Icon("my_location")]
public partial class PointAttractorModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property] public PositionType PositionType { get; set; } = PositionType.Local;
[Property]
public FXParticleVector AttractorPosition { get; set; } = Vector3.Zero;
[Property, Range(0f, 10000f)]
public FXParticleFloat Strength { get; set; } = 500.0f;
[Property, Range(0.01f, 10000f)]
public float AttractorSize { get; set; } = 50.0f;
[Property] public bool Invert { get; set; } = false;
/// <summary>
/// How quickly strength falls off beyond AttractorSize.
/// 1 = linear, 2 = inverse square, higher = sharper falloff.
/// </summary>
[Property, Range(0.1f, 8f)]
public float Falloff { get; set; } = 2.0f;
public override void Execute(ParticleExecutionContext context) { }
public override void Initialize(ParticleExecutionContext context) { }
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
var attractorPos = PositionType == PositionType.Local ? AttractorPosition.GetValue( context.Particle, context.SystemComponent ) + context.Emitter.WorldPosition : AttractorPosition.GetValue( context.Particle, context.SystemComponent );
var toAttractor = attractorPos - context.Particle.Position;
var distance = toAttractor.Length;
if (distance < 0.01f) return;
// Inside the attractor: full strength.
// Outside: strength falls off based on normalised excess distance.
float strengthMultiplier;
if (distance <= AttractorSize)
{
strengthMultiplier = 1f;
}
else
{
// How many radii past the edge are we? 0 at the surface, grows outward.
var excess = (distance - AttractorSize) / AttractorSize;
strengthMultiplier = 1f / MathF.Pow(1f + excess, Falloff);
}
if ( Invert )
{
strengthMultiplier = 1 - strengthMultiplier;
}
context.Particle.Velocity += toAttractor.Normal * Strength.GetValue( context.SystemComponent ) * strengthMultiplier * Time.Delta;
}
}
/// <summary>
/// Creates orbital motion
/// </summary>
[Title("Vortex Force"), Category("Update"), Icon("cyclone")]
public partial class VortexForceModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property]
public Vector3 Center { get; set; } = Vector3.Zero;
[Property]
public FXParticleVector Axis { get; set; } = Vector3.Up;
[Property, Range(0f, 1000f)]
public float Strength { get; set; } = 100.0f;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
var toCenter = context.Particle.Position - (Center + context.Emitter.WorldPosition);
var distance = toCenter.Length;
if (distance > 0.01f)
{
var tangent = Vector3.Cross(Axis.GetValue( context.Particle,context.SystemComponent ).Normal, toCenter.Normal);
var force = tangent * (Strength / distance);
context.Particle.Velocity += force * Time.Delta * 10000;
}
}
}
// ==================== RENDER MODULES ====================
/// <summary>
/// Basic sprite renderer
/// </summary>
[Title("Sprite Renderer"), Category("Render"), Icon("image")]
public partial class SpriteRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Render;
[Property]
public Sprite Sprite { get; set; }
[Property] public FXParticleFloat SpriteScale { get; set; } = 1f;
[Property]
public ParticleSpriteRenderer.BillboardAlignment Alignment { get; set; } =
ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
[Property] public bool FaceVelocity { get; set; } = false;
[Property] public bool Additive { get; set; } = false;
public override void Execute(ParticleExecutionContext context)
{
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleSpriteRenderer>();
renderer.Alignment = Alignment;
renderer.FaceVelocity = FaceVelocity;
renderer.Sprite = Sprite;
renderer.Additive = Additive;
renderer.Scale = SpriteScale.GetValue();
}
}
/// <summary>
/// Basic light renderer
/// </summary>
[Title("Light Renderer"), Category("Render"), Icon("image")]
public partial class LightRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Render;
[Property] public FXParticleColor LightColor { get; set; } = new FXParticleColor( Color.White );
[Property] public FXParticleFloat Brightness { get; set; } = 10f;
[Property] public FXParticleFloat MaxLights { get; set; } = 10f;
[Property] public FXParticleFloat LightSize { get; set; } = 10f;
[Property] public FXParticleFloat Attenuation { get; set; } = 1;
[Property] public bool CastShadows { get; set; } = false;
[Property] public bool UseParticleColor { get; set; } = true;
[Property] public FXParticleFloat Ratio { get; set; } = 1;
public override void Execute(ParticleExecutionContext context)
{
// Rendering is handled externally, this just stores render properties
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleLightRenderer>();
var fxbox=go.GetComponentInParent<FXBoxNativeParticleSystem>( );
renderer.LightColor = LightColor.GetValue( fxbox );
renderer.Brightness = Brightness.GetValue( fxbox );
renderer.MaximumLights = (int)MaxLights.GetValue( fxbox );
renderer.Scale = LightSize.GetValue( fxbox );
renderer.Attenuation = Attenuation.GetValue( fxbox );
renderer.Ratio = Ratio.GetValue( fxbox );
renderer.CastShadows = CastShadows;
renderer.UseParticleColor = UseParticleColor;
}
}
/// <summary>
/// Basic model renderer
/// </summary>
[Title("Model Renderer"), Category("Render"), Icon("image")]
public partial class ModelRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide]
public override ModuleStage Stage => ModuleStage.Render;
[Property]
public List<ParticleModelRenderer.ModelEntry> Models { get; set; }
[Property]
public bool FaceCamera { get; set; } = true;
public override void Execute(ParticleExecutionContext context)
{
// Rendering is handled externally, this just stores render properties
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleModelRenderer>();
renderer.Choices = Models;
}
}
/// <summary>
/// Basic Trail Renderer
/// </summary>
[Title( "Trail Renderer" ), Category( "Render" ), Icon( "image" )]
public partial class TrailRendererModule : ParticleModule, IParticleComponentCreator
{
[Hide] public override ModuleStage Stage => ModuleStage.Render;
[Property] public bool Game { get; set; } = true;
[Property] public bool Overlay { get; set; } = false;
[Property] public bool Bloom { get; set; } = false;
[Property] public bool AfterUi { get; set; } = false;
[Property] public Material Material { get; set; }
[Property] public FXParticleFloat UnitsPerTexture { get; set; } = 10f;
[Property] public FXParticleFloat Scroll { get; set; } = 0f;
[Property] public FXParticleFloat Width { get; set; } = 1f;
[Property] public bool Opaque { get; set; } = true;
[Property, ShowIf( "Opaque", false )] public BlendMode BlendMode { get; set; } = BlendMode.Normal;
[Property] public int MaxPoints { get; set; } = 32;
[Property] public float PointDistance { get; set; } = 8;
[Property] public float LifeTime { get; set; } = 2f;
[Property] public FXParticleColor Color { get; set; } = new FXParticleColor( );
public override void Execute(ParticleExecutionContext context)
{
// Rendering is handled externally, this just stores render properties
}
public override void Initialize( ParticleExecutionContext context )
{
}
public void CreateComponent(GameObject go)
{
var renderer = go.AddComponent<ParticleTrailRenderer>();
var appearance = renderer.Texturing;
appearance.Material = Material;
appearance.UnitsPerTexture = UnitsPerTexture.GetValue( );
appearance.Scroll = Scroll.GetValue();
var widthCurve = Width.ToParticleFloat();
if ( widthCurve.Type == ParticleFloat.ValueType.Curve )
{
renderer.Width = widthCurve.CurveA;
} else if ( widthCurve.Type == ParticleFloat.ValueType.Range )
{
var point1 = new Curve.Frame( 0, widthCurve.ConstantA );
var point2 = new Curve.Frame( 1, widthCurve.ConstantB );
renderer.Width = new Curve( point1, point2 );
} else if ( widthCurve.Type == ParticleFloat.ValueType.Constant )
{
renderer.Width = widthCurve.ConstantA;
}
else
{
renderer.Width = widthCurve.CurveA;
}
renderer.Opaque = Opaque;
renderer.BlendMode = BlendMode;
renderer.MaxPoints = MaxPoints;
renderer.PointDistance = PointDistance;
renderer.LifeTime = LifeTime;
var colorParam = Color.GetValue();
if ( colorParam.Type == ParticleGradient.ValueType.Constant )
{
renderer.Color = colorParam.ConstantA;
} else if ( colorParam.Type == ParticleGradient.ValueType.Range )
{
var point1 = new Gradient.ColorFrame( 0, colorParam.ConstantA );
var point2 = new Gradient.ColorFrame( 1, colorParam.ConstantB );
renderer.Color = new Gradient( point1, point2 );
} else if ( colorParam.Type == ParticleGradient.ValueType.Gradient )
{
renderer.Color = colorParam.GradientA;
}
renderer.RenderOptions.Game = Game;
renderer.RenderOptions.Overlay = Overlay;
renderer.RenderOptions.Bloom = Bloom;
renderer.RenderOptions.AfterUI = AfterUi;
renderer.Texturing = appearance;
}
}
/// <summary>
/// Applies curl noise force for organic, swirling motion
/// </summary>
[Title("Curl Noise"), Category("Update"), Icon("air")]
public partial class CurlNoiseModule : ParticleModule, IParticleUpdater
{
[Hide]
public override ModuleStage Stage => ModuleStage.Update;
[Property, Range(0f, 1000f)]
[Description("Strength of the curl noise effect")]
public FXParticleFloat Strength { get; set; } = 1.0f;
[Property, Range(0.01f, 10f)]
[Description("Scale of the noise pattern - smaller values create tighter curls")]
public FXParticleFloat Scale { get; set; } = 1.0f;
[Property, Range(0f, 10f)]
[Description("Speed at which the noise pattern evolves over time")]
public FXParticleFloat TimeScale { get; set; } = 1.0f;
[Property]
[Description("Offset in the noise field")]
public Vector3 Offset { get; set; } = Vector3.Zero;
public override void Execute(ParticleExecutionContext context)
{
// Not used - handled in UpdateParticle
}
public override void Initialize(ParticleExecutionContext context)
{
// No initialization needed
}
public void UpdateParticle(ParticleExecutionContext context, float delta)
{
var particle = context.Particle;
// Sample position in noise field
var samplePos = (particle.Position + Offset) * Scale.GetValue( context.SystemComponent );
var time = context.Particle.Age * TimeScale;
// Calculate curl noise using the curl of a 3D noise field
var curl = CalculateCurl(samplePos, time.GetValue( context.SystemComponent ));
// Apply force
particle.Velocity += curl * Strength.GetValue( context.SystemComponent ) * delta * 10;
}
/// <summary>
/// Calculate curl noise by taking the curl of a potential field
/// This creates divergence-free flow fields that look organic
/// </summary>
private Vector3 CalculateCurl(Vector3 pos, float time)
{
const float epsilon = 0.001f;
// Sample the potential field at offset positions
// We need 6 samples to calculate the curl (derivatives in all directions)
// dPz/dy - dPy/dz
float curlX =
(SamplePotential(pos + new Vector3(0, epsilon, 0), time).z -
SamplePotential(pos - new Vector3(0, epsilon, 0), time).z) -
(SamplePotential(pos + new Vector3(0, 0, epsilon), time).y -
SamplePotential(pos - new Vector3(0, 0, epsilon), time).y);
// dPx/dz - dPz/dx
float curlY =
(SamplePotential(pos + new Vector3(0, 0, epsilon), time).x -
SamplePotential(pos - new Vector3(0, 0, epsilon), time).x) -
(SamplePotential(pos + new Vector3(epsilon, 0, 0), time).z -
SamplePotential(pos - new Vector3(epsilon, 0, 0), time).z);
// dPy/dx - dPx/dy
float curlZ =
(SamplePotential(pos + new Vector3(epsilon, 0, 0), time).y -
SamplePotential(pos - new Vector3(epsilon, 0, 0), time).y) -
(SamplePotential(pos + new Vector3(0, epsilon, 0), time).x -
SamplePotential(pos - new Vector3(0, epsilon, 0), time).x);
return new Vector3(curlX, curlY, curlZ) / (2.0f * epsilon);
}
/// <summary>
/// Sample a 3D potential field using Perlin-like noise
/// </summary>
private Vector3 SamplePotential(Vector3 pos, float time)
{
// Create three offset noise samples for each component
// This creates a vector field from scalar noise functions
return new Vector3(
Noise3D(pos + new Vector3(0, 0, 0), time),
Noise3D(pos + new Vector3(31.416f, -47.853f, 12.793f), time),
Noise3D(pos + new Vector3(-17.737f, 86.214f, -59.482f), time)
);
}
/// <summary>
/// Simple 3D noise function using sine waves
/// You could replace this with proper Perlin/Simplex noise for better results
/// </summary>
private float Noise3D(Vector3 pos, float time)
{
// Combine multiple sine waves at different frequencies for pseudo-noise
var p = pos + new Vector3(time, time * 0.7f, time * 0.5f);
float noise = 0;
noise += MathF.Sin(p.x * 1.0f + p.y * 1.3f) * 0.5f;
noise += MathF.Sin(p.y * 1.7f + p.z * 0.9f) * 0.3f;
noise += MathF.Sin(p.z * 2.1f + p.x * 1.1f) * 0.2f;
noise += MathF.Sin(p.x * 3.7f + p.y * 2.3f + p.z * 1.9f) * 0.15f;
return noise;
}
}
Editor
library
using Editor;
using fxbox;
using Sandbox;
namespace Editor;
[CustomEditor(typeof(FXParticleFloat))]
public class FXParticleFloatControlWidget : ControlWidget
{
public Color HighlightColor { get; set; }
public string Label { get; set; }
Layout ControlArea;
SerializedObject Target;
Button ModeSwitchButton;
Button ToggleButton;
SerializedProperty ValueProperty;
SerializedObject ValueTarget;
public FXParticleFloatControlWidget(SerializedProperty property) : this(property, "f", Theme.Green)
{
}
public FXParticleFloatControlWidget(SerializedProperty property, string label, Color color) : base(property)
{
SetSizeMode(SizeMode.Ignore, SizeMode.Default);
if (!property.TryGetAsObject(out Target))
return;
Label = label;
HighlightColor = color;
Layout = Layout.Row();
Layout.Spacing = 3;
Layout.AddStretchCell();
// Add toggle button for UseParameter
ToggleButton = new Button();
ToggleButton.Text = "P";
ToggleButton.ToolTip = "Toggle Parameter Mode";
ToggleButton.FixedWidth = Theme.RowHeight;
ToggleButton.Pressed = () => ToggleParameterMode();
ToggleButton.OnPaintOverride = PaintToggleButton;
Layout.Add(ToggleButton);
ModeSwitchButton = new Button();
ModeSwitchButton.Text = "Mode";
ModeSwitchButton.OnPaintOverride = PaintButton;
ModeSwitchButton.Pressed = () => OpenPopup(ModeSwitchButton.ScreenRect);
ModeSwitchButton.FixedWidth = Theme.RowHeight;
Layout.Add(ModeSwitchButton);
ControlArea = Layout.AddRow(1);
ControlArea.Spacing = 2;
Target.OnPropertyChanged += (p) =>
{
if (p.Name == "UseParameter")
{
Rebuild();
return;
}
if (!Target.GetProperty("UseParameter").GetValue<bool>())
{
// Only rebuild for Value changes when not using parameters
ValueProperty = Target.GetProperty("Value");
if (ValueProperty.TryGetAsObject(out ValueTarget))
{
if (p.Name == "Type" || p.Name == "Evaluation")
{
Rebuild();
}
}
}
};
Rebuild();
}
private void ToggleParameterMode()
{
var useParam = Target.GetProperty("UseParameter");
useParam.SetValue(!useParam.GetValue<bool>());
Rebuild();
}
private bool PaintToggleButton()
{
Paint.Antialiasing = true;
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
if (Paint.HasPressed)
{
Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));
Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);
Paint.Pen = useParam ? Theme.Blue.Lighten(0.4f) : Theme.TextControl.Lighten(0.4f);
}
else if (Paint.HasMouseOver)
{
Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));
Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);
Paint.Pen = useParam ? Theme.Blue.Lighten(0.5f) : Theme.TextControl.Lighten(0.5f);
}
else
{
Paint.Pen = useParam ? Theme.Blue : Theme.TextControl.WithAlpha(0.5f);
}
Paint.DrawIcon(Paint.LocalRect, "tune", 15, TextFlag.Center);
return true;
}
private void OpenPopup(Rect parentRect)
{
// Only show popup if not in parameter mode
if (Target.GetProperty("UseParameter").GetValue<bool>())
return;
ValueProperty = Target.GetProperty("Value");
if (!ValueProperty.TryGetAsObject(out ValueTarget))
return;
var popup = new FXParticleFloatConfigPopup(ValueTarget, this);
popup.Position = parentRect.BottomRight;
popup.AdjustSize();
popup.Position -= new Vector2(popup.Width, 0);
popup.Show();
popup.ConstrainToScreen();
}
bool PaintButton()
{
Paint.Antialiasing = true;
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
// Disable button appearance if in parameter mode
if (useParam)
{
Paint.Pen = Theme.TextControl.WithAlpha(0.2f);
Paint.DrawIcon(Paint.LocalRect, "block", 11, TextFlag.Center);
return true;
}
ValueProperty = Target.GetProperty("Value");
if (!ValueProperty.TryGetAsObject(out ValueTarget))
return true;
if (Paint.HasPressed)
{
Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));
Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);
Paint.Pen = Theme.TextControl.Lighten(0.4f);
}
else if (Paint.HasMouseOver)
{
Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));
Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);
Paint.Pen = Theme.TextControl.Lighten(0.5f);
}
else
{
Paint.Pen = Theme.TextControl.WithAlpha(0.5f);
}
var type = ValueTarget.GetProperty("Type").GetValue<ParticleFloat.ValueType>();
var eval = ValueTarget.GetProperty("Evaluation").GetValue<ParticleFloat.EvaluationType>();
var icon = "people";
float iconSize = 15;
if (type == ParticleFloat.ValueType.Constant)
{
icon = "radio_button_unchecked";
Paint.Pen = Paint.Pen.WithAlpha(0.3f);
iconSize = 11;
}
else
{
if (eval == ParticleFloat.EvaluationType.Seed)
{
icon = "scatter_plot";
}
if (eval == ParticleFloat.EvaluationType.Life)
{
icon = "play_arrow";
}
if (eval == ParticleFloat.EvaluationType.Frame)
{
icon = "casino";
}
}
Paint.DrawIcon(Paint.LocalRect, icon, iconSize, TextFlag.Center);
return true;
}
void Rebuild()
{
ControlArea.Clear(true);
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
if (useParam)
{
// Parameter mode - show parameter selector and multiplier
var paramName = Target.GetProperty("ParameterName");
var multiplier = Target.GetProperty("Multiplier");
var paramControl = new ParameterNameControlWidget(paramName);
ControlArea.Add(paramControl, 1);
var multControl = new FloatControlWidget(multiplier)
{
HighlightColor = Theme.Highlight,
Label = "×"
};
ControlArea.Add(multControl);
}
else
{
// Normal mode - show particle float controls
ValueProperty = Target.GetProperty("Value");
if (ValueProperty.TryGetAsObject(out ValueTarget))
{
var type = ValueTarget.GetProperty("Type").GetValue<ParticleFloat.ValueType>();
var eval = ValueTarget.GetProperty("Evaluation").GetValue<ParticleFloat.EvaluationType>();
RebuildForType(type, eval);
}
}
Update();
}
void RebuildForType(ParticleFloat.ValueType type, ParticleFloat.EvaluationType eval)
{
ModeSwitchButton.ToolTip = GetTypeName(type, eval);
if (type == ParticleFloat.ValueType.Constant)
{
var control = new FloatControlWidget(ValueTarget.GetProperty("ConstantValue"))
{
HighlightColor = HighlightColor,
Label = Label
};
ControlArea.Add(control);
}
if (type == ParticleFloat.ValueType.Range)
{
var controlA = new FloatControlWidget(ValueTarget.GetProperty("ConstantA"))
{
HighlightColor = HighlightColor,
Label = Label
};
ControlArea.Add(controlA);
var controlB = new FloatControlWidget(ValueTarget.GetProperty("ConstantB"))
{
HighlightColor = HighlightColor,
Label = Label
};
ControlArea.Add(controlB);
}
if (type == ParticleFloat.ValueType.Curve)
{
var controlA = new CurveControlWidget(ValueTarget.GetProperty("CurveA"))
{
HighlightColor = HighlightColor
};
ControlArea.Add(controlA);
}
if (type == ParticleFloat.ValueType.CurveRange)
{
var controlA = new CurveRangeControlWidget(ValueTarget.GetProperty("CurveRange"))
{
HighlightColor = HighlightColor
};
ControlArea.Add(controlA);
}
}
string GetTypeName(ParticleFloat.ValueType type, ParticleFloat.EvaluationType eval)
{
switch (type)
{
case ParticleFloat.ValueType.Constant:
return "Constant value";
case ParticleFloat.ValueType.Curve:
{
switch (eval)
{
case ParticleFloat.EvaluationType.Seed:
return "Random from curve per particle";
case ParticleFloat.EvaluationType.Frame:
return "Random from curve";
case ParticleFloat.EvaluationType.Life:
return "Curve over lifetime";
default:
return "Unknown";
}
}
case ParticleFloat.ValueType.CurveRange:
switch (eval)
{
case ParticleFloat.EvaluationType.Seed:
return "Random from range, per particle";
case ParticleFloat.EvaluationType.Frame:
return "Random from range (per frame)";
case ParticleFloat.EvaluationType.Life:
return "path between curve over lifetime, per particle";
default:
return "Unknown";
}
case ParticleFloat.ValueType.Range:
{
switch (eval)
{
case ParticleFloat.EvaluationType.Seed:
return "Between range, per particle";
case ParticleFloat.EvaluationType.Frame:
return "Random between range (per frame)";
case ParticleFloat.EvaluationType.Life:
return "Lerp between range over lifetime";
default:
return "Unknown";
}
}
}
return "Unknown Combo";
}
protected override void OnPaint()
{
}
}
file class FXParticleFloatConfigPopup : PopupWidget
{
SerializedObject SerializedObject;
SerializedProperty Type;
SerializedProperty Eval;
public FXParticleFloatConfigPopup(SerializedObject target, Widget parent) : base(parent)
{
Layout = Layout.Column();
Layout.Spacing = 8;
Layout.Margin = 16;
SerializedObject = target;
Type = target.GetProperty("Type");
Eval = target.GetProperty("Evaluation");
AddQuickModes(Type.GetValue<ParticleFloat.ValueType>(), Eval.GetValue<ParticleFloat.EvaluationType>());
}
void AddQuickModes(ParticleFloat.ValueType type, ParticleFloat.EvaluationType eval)
{
var grid = new GridLayout();
grid.Spacing = 8;
grid.AddCell(0, 0, MakeQuickMode("radio_button_unchecked", "Constant", "Value is constant. It stays the same. It doesn't change", ParticleFloat.ValueType.Constant, ParticleFloat.EvaluationType.Life));
grid.AddCell(1, 0, MakeQuickMode("casino", "Random", "Choose a value between two constants every frame", ParticleFloat.ValueType.Range, ParticleFloat.EvaluationType.Frame));
grid.AddCell(0, 1, MakeQuickMode("hdr_strong", "Range", "Choose a value between two constants", ParticleFloat.ValueType.Range, ParticleFloat.EvaluationType.Seed));
grid.AddCell(1, 1, MakeQuickMode("animation", "Lerp", "Lerp between two values over the lifetime of the particle", ParticleFloat.ValueType.Range, ParticleFloat.EvaluationType.Life));
grid.AddCell(0, 2, MakeQuickMode("show_chart", "Curve", "Get the value by querying a curve over the particle's lifetime", ParticleFloat.ValueType.Curve, ParticleFloat.EvaluationType.Life));
grid.AddCell(1, 2, MakeQuickMode("area_chart", "Curve with Range", "Choose a path between two curves - over the lifetime of the particle", ParticleFloat.ValueType.CurveRange, ParticleFloat.EvaluationType.Life));
Layout.Add(grid);
}
private Widget MakeQuickMode(string icon, string label, string description, ParticleFloat.ValueType t, ParticleFloat.EvaluationType e)
{
var b = new Widget();
b.Cursor = CursorShape.Finger;
b.Layout = new IconTitleDescriptionLayout(icon, label, description);
b.Layout.Margin = new Sandbox.UI.Margin(8, 4);
b.SetStyles("color: #ffffff;");
bool isCurrent = t == Type.GetValue<ParticleFloat.ValueType>() && e == Eval.GetValue<ParticleFloat.EvaluationType>();
b.MouseClick += () =>
{
Type.SetValue(t);
Eval.SetValue(e);
Close();
};
b.OnPaintOverride = () =>
{
if (isCurrent || Paint.HasMouseOver)
{
Paint.SetBrushAndPen(Theme.Blue.Darken(0.5f).WithAlpha(0.5f));
Paint.DrawRect(Paint.LocalRect, 4);
}
return true;
};
return b;
}
}
file class IconTitleDescriptionLayout : GridLayout
{
public IconTitleDescriptionLayout(string icon, string title, string description)
{
VerticalSpacing = 0;
HorizontalSpacing = 8;
var iconLabel = AddCell(0, 0, new IconButton(icon) { Background = Color.Transparent, IconSize = 33, FixedSize = 40, TransparentForMouseEvents = true }, ySpan: 2);
var titleLabel = AddCell(1, 0, new Label(title));
var descLabel = AddCell(1, 1, new Label(description) { WordWrap = true });
titleLabel.SetStyles("font-size: 13px; font-family: Poppins; font-weight: bold;");
descLabel.SetStyles("font-size: 9px; font-family: Poppins;");
descLabel.SetEffectOpacity(0.5f);
}
}
[CustomEditor(typeof(FXParticleVector))]
public class FXParticleVectorControlWidget : ControlWidget
{
private SerializedObject Target;
private Button ToggleButton;
private Layout ControlArea;
public FXParticleVectorControlWidget(SerializedProperty property) : base(property)
{
SetSizeMode(SizeMode.Ignore, SizeMode.Default);
if (!property.TryGetAsObject(out Target))
return;
Layout = Layout.Row();
Layout.Spacing = 3;
Layout.AddStretchCell();
// Add toggle button for UseParameter
ToggleButton = new Button();
ToggleButton.Text = "P";
ToggleButton.ToolTip = "Toggle Parameter Mode";
ToggleButton.FixedWidth = Theme.RowHeight;
ToggleButton.Pressed = () => ToggleParameterMode();
ToggleButton.OnPaintOverride = PaintToggleButton;
Layout.Add(ToggleButton);
ControlArea = Layout.AddRow(1);
ControlArea.Spacing = 2;
Target.OnPropertyChanged += (p) =>
{
if (p.Name == "UseParameter")
{
Rebuild();
}
};
Rebuild();
}
private void ToggleParameterMode()
{
var useParam = Target.GetProperty("UseParameter");
useParam.SetValue(!useParam.GetValue<bool>());
Rebuild();
}
private bool PaintToggleButton()
{
Paint.Antialiasing = true;
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
if (Paint.HasPressed)
{
Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));
Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);
Paint.Pen = useParam ? Theme.Blue.Lighten(0.4f) : Theme.TextControl.Lighten(0.4f);
}
else if (Paint.HasMouseOver)
{
Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));
Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);
Paint.Pen = useParam ? Theme.Blue.Lighten(0.5f) : Theme.TextControl.Lighten(0.5f);
}
else
{
Paint.Pen = useParam ? Theme.Blue : Theme.TextControl.WithAlpha(0.5f);
}
Paint.DrawIcon(Paint.LocalRect, "tune", 15, TextFlag.Center);
return true;
}
void Rebuild()
{
ControlArea.Clear(true);
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
if (useParam)
{
// Parameter mode - show parameter selector and multiplier
var paramName = Target.GetProperty("ParameterName");
var multiplier = Target.GetProperty("Multiplier");
var paramControl = new VectorParameterNameControlWidget(paramName);
ControlArea.Add(paramControl, 1);
var multControl = new FloatControlWidget(multiplier)
{
HighlightColor = Theme.Highlight,
Label = "×"
};
ControlArea.Add(multControl);
}
else
{
// Normal mode - show vector control
var valueProp = Target.GetProperty("Value");
var control = ControlWidget.Create(valueProp);
if (control != null)
{
ControlArea.Add(control, 1);
}
}
Update();
}
protected override void OnPaint()
{
}
}
[CustomEditor(typeof(FXParticleColor))]
public class FXParticleColorControlWidget : ControlWidget
{
private SerializedObject Target;
private Button ToggleButton;
private Layout ControlArea;
public FXParticleColorControlWidget(SerializedProperty property) : base(property)
{
SetSizeMode(SizeMode.Ignore, SizeMode.Default);
if (!property.TryGetAsObject(out Target))
return;
Layout = Layout.Row();
Layout.Spacing = 3;
Layout.AddStretchCell();
// Add toggle button for UseParameter
ToggleButton = new Button();
ToggleButton.Text = "P";
ToggleButton.ToolTip = "Toggle Parameter Mode";
ToggleButton.FixedWidth = Theme.RowHeight;
ToggleButton.Pressed = () => ToggleParameterMode();
ToggleButton.OnPaintOverride = PaintToggleButton;
Layout.Add(ToggleButton);
ControlArea = Layout.AddRow(1);
ControlArea.Spacing = 2;
Target.OnPropertyChanged += (p) =>
{
if (p.Name == "UseParameter")
{
Rebuild();
}
};
Rebuild();
}
private void ToggleParameterMode()
{
var useParam = Target.GetProperty("UseParameter");
useParam.SetValue(!useParam.GetValue<bool>());
Rebuild();
}
private bool PaintToggleButton()
{
Paint.Antialiasing = true;
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
if (Paint.HasPressed)
{
Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));
Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);
Paint.Pen = useParam ? Theme.Blue.Lighten(0.4f) : Theme.TextControl.Lighten(0.4f);
}
else if (Paint.HasMouseOver)
{
Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));
Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);
Paint.Pen = useParam ? Theme.Blue.Lighten(0.5f) : Theme.TextControl.Lighten(0.5f);
}
else
{
Paint.Pen = useParam ? Theme.Blue : Theme.TextControl.WithAlpha(0.5f);
}
Paint.DrawIcon(Paint.LocalRect, "tune", 15, TextFlag.Center);
return true;
}
void Rebuild()
{
ControlArea.Clear(true);
var useParam = Target.GetProperty("UseParameter").GetValue<bool>();
if (useParam)
{
// Parameter mode - show parameter selector and multiplier
var paramName = Target.GetProperty("ParameterName");
var multiplier = Target.GetProperty("Multiplier");
var paramControl = new ColorParameterNameControlWidget(paramName);
ControlArea.Add(paramControl, 1);
var multControl = new FloatControlWidget(multiplier)
{
HighlightColor = Theme.Highlight,
Label = "×"
};
ControlArea.Add(multControl);
}
else
{
// Normal mode - show color control
var valueProp = Target.GetProperty("Value");
var control = ControlWidget.Create(valueProp);
if (control != null)
{
ControlArea.Add(control, 1);
}
}
Update();
}
protected override void OnPaint()
{
}
}
Game
library
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>
/// Seconds to wait before this emitter starts spawning - lets one emitter in the same
/// system kick off a few seconds after another. Maps straight onto the native
/// ParticleEffect.StartDelay, so it's handled by the engine's own particle system rather
/// than anything FXBox has to gate itself.
/// </summary>
public float Delay { get; set; } = 0f;
/// <summary>
/// Overrides the system's own Duration for deciding when THIS emitter is finished
/// (see FXBoxParticleController.IsFinished) - 0 means "use the particle system's own
/// Duration instead", the same as before this existed. Lets one emitter in a system run
/// longer or shorter than the rest without touching the system-wide Duration.
/// </summary>
public float Duration { get; set; } = 0f;
/// <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();
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "FXBox" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "fxbox" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "stellawisps" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "stellawisps.fxbox" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "29" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-09-10T16:47:44.5430399Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.212.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.212.0")]
Game
library
using System;
using Sandbox;
using System.Linq;
namespace fxbox;
public class FXParticleFloat
{
[Property] public bool UseParameter { get; set; } = false;
[Property, ShowIf(nameof(UseParameter), false)]
public ParticleFloat Value { get; set; }
[Property, ShowIf(nameof(UseParameter), true)]
[Description("Select a parameter from the system")]
public string ParameterName { get; set; }
[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]
[Description("Multiplier applied to the parameter value")]
public float Multiplier { get; set; } = 1.0f;
public float GetValue(FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
return systemComponent.GetFloatParameter(ParameterName) * Multiplier;
}
var result = Value.Evaluate(Random.Shared.Float(), 3f);
return result;
}
public ParticleFloat ToParticleFloat(FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
// Get the instance-specific value (override or default)
float value = systemComponent.GetFloatParameter(ParameterName) * Multiplier;
return new ParticleFloat()
{
Type = ParticleFloat.ValueType.Constant,
ConstantValue = value,
Evaluation = ParticleFloat.EvaluationType.Seed
};
}
return Value;
}
// ==================== OPERATORS ====================
public static FXParticleFloat operator *(float a, FXParticleFloat b)
{
return b * a; // Commutative
}
// Division operators
public static FXParticleFloat operator /(FXParticleFloat a, float b)
{
if (a == null)
{
Log.Warning("Division: a is null");
return null;
}
if (b == 0 || MathF.Abs(b) < 0.0001f)
{
Log.Warning($"Division by zero or very small number ({b}) in FXParticleFloat");
return a;
}
var result = a * (1.0f / b);
return result;
}
public static FXParticleFloat operator *(FXParticleFloat a, float b)
{
if (a == null)
{
Log.Warning("Multiplication: a is null");
return null;
}
var result = new FXParticleFloat();
if (a.UseParameter)
{
result.UseParameter = true;
result.ParameterName = a.ParameterName;
result.Multiplier = a.Multiplier * b;
result.Value = new ParticleFloat()
{
Type = ParticleFloat.ValueType.Constant,
ConstantValue = 0f,
Evaluation = ParticleFloat.EvaluationType.Seed
};
}
else
{
result.UseParameter = false;
result.Value = ScaleParticleFloat(a.Value, b);
}
return result;
}
private static ParticleFloat ScaleParticleFloat(ParticleFloat pf, float scale)
{
var result = new ParticleFloat();
result.Type = pf.Type;
result.Evaluation = pf.Evaluation;
// Copy Constants FIRST, before setting individual values
// (or don't copy it at all since we're setting the values manually)
// result.Constants = pf.Constants;
switch (pf.Type)
{
case ParticleFloat.ValueType.Constant:
result.ConstantValue = pf.ConstantValue * scale;
break;
case ParticleFloat.ValueType.Range:
result.ConstantA = pf.ConstantA * scale;
result.ConstantB = pf.ConstantB * scale;
break;
case ParticleFloat.ValueType.Curve:
result.CurveA = ScaleCurve(pf.CurveA, scale);
result.CurveB = ScaleCurve(pf.CurveB, scale);
break;
case ParticleFloat.ValueType.CurveRange:
result.CurveRange = ScaleCurveRange(pf.CurveRange, scale);
break;
}
// DON'T copy Constants here - it overwrites our values!
// result.Constants = pf.Constants;
return result;
}
private static ParticleFloat OffsetParticleFloat(ParticleFloat pf, float offset)
{
var result = new ParticleFloat();
result.Type = pf.Type;
result.Evaluation = pf.Evaluation;
// DON'T copy Constants - it will overwrite our values
// result.Constants = pf.Constants;
switch (pf.Type)
{
case ParticleFloat.ValueType.Constant:
result.ConstantValue = pf.ConstantValue + offset;
break;
case ParticleFloat.ValueType.Range:
result.ConstantA = pf.ConstantA + offset;
result.ConstantB = pf.ConstantB + offset;
break;
case ParticleFloat.ValueType.Curve:
result.CurveA = OffsetCurve(pf.CurveA, offset);
result.CurveB = OffsetCurve(pf.CurveB, offset);
break;
case ParticleFloat.ValueType.CurveRange:
result.CurveRange = OffsetCurveRange(pf.CurveRange, offset);
break;
}
// DON'T copy Constants here!
// result.Constants = pf.Constants;
return result;
}
private static Curve ScaleCurve(Curve curve, float scale)
{
var newFrames = curve.Frames.Select(frame =>
new Curve.Frame(frame.Time, frame.Value * scale)).ToArray();
return new Curve(newFrames);
}
private static Curve OffsetCurve(Curve curve, float offset)
{
var newFrames = curve.Frames.Select(frame =>
new Curve.Frame(frame.Time, frame.Value + offset)).ToArray();
return new Curve(newFrames);
}
private static CurveRange ScaleCurveRange(CurveRange range, float scale)
{
return new CurveRange
(
ScaleCurve(range.A, scale),
ScaleCurve(range.B, scale)
);
}
private static CurveRange OffsetCurveRange(CurveRange range, float offset)
{
return new CurveRange
(
OffsetCurve(range.A, offset),
OffsetCurve(range.B, offset)
);
}
// ==================== IMPLICIT CONVERSIONS ====================
public static implicit operator FXParticleFloat(float v)
{
var fxParticle = new FXParticleFloat();
fxParticle.Value = new ParticleFloat()
{
Type = ParticleFloat.ValueType.Constant,
ConstantValue = v,
Evaluation = ParticleFloat.EvaluationType.Seed
};
return fxParticle;
}
// ==================== CONSTRUCTORS ====================
public FXParticleFloat()
{
var particleFloat = new ParticleFloat();
particleFloat.Type = ParticleFloat.ValueType.Constant;
particleFloat.ConstantValue = 0.0f;
particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;
particleFloat.CurveA = new Curve();
particleFloat.CurveB = new Curve();
particleFloat.Constants = new Vector4();
this.Value = particleFloat;
}
public FXParticleFloat(float a, float b)
{
var particleFloat = new ParticleFloat();
particleFloat.Type = ParticleFloat.ValueType.Range;
particleFloat.ConstantA = a;
particleFloat.ConstantB = b;
particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;
particleFloat.CurveA = new Curve();
particleFloat.CurveB = new Curve();
particleFloat.Constants = new Vector4();
this.Value = particleFloat;
}
}
public class FXParticleVector
{
[Property] public bool UseParameter { get; set; } = false;
[Property, ShowIf(nameof(UseParameter), false)]
public ParticleVector3 Value { get; set; } = Vector3.Zero;
[Property, ShowIf(nameof(UseParameter), true)]
[Description("Select a vector parameter from the system")]
public string ParameterName { get; set; }
[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]
[Description("Multiplier applied to the parameter value")]
public float Multiplier { get; set; } = 1.0f;
public Vector3 GetValue(Particle particle,FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
return systemComponent.GetVectorParameter(ParameterName) * Multiplier;
}
if ( particle == null )
{
return Value.Evaluate( Time.Delta, 0, 0, 0 );
}
return Value.Evaluate( Time.Delta,particle.Rand(1),particle.Rand(2),particle.Rand(3) );
}
public static implicit operator FXParticleVector(Vector3 v)
{
return new FXParticleVector { Value = v };
}
public FXParticleVector()
{
Value = Vector3.Zero;
}
public FXParticleVector(Vector3 value)
{
Value = value;
}
public FXParticleVector(float x, float y, float z)
{
Value = new Vector3(x, y, z);
}
}
public class FXParticleColor
{
[Property] public bool UseParameter { get; set; } = false;
[Property, ShowIf(nameof(UseParameter), false)]
public ParticleGradient Value { get; set; } = Color.White;
[Property, ShowIf(nameof(UseParameter), true)]
[Description("Select a color parameter from the system")]
public string ParameterName { get; set; }
[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]
[Description("Multiplier applied to the parameter value (affects RGB)")]
public float Multiplier { get; set; } = 1.0f;
public ParticleGradient GetValue(FXBoxNativeParticleSystem systemComponent = null)
{
if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)
{
var color = systemComponent.GetColorParameter(ParameterName);
// Apply multiplier to RGB components
if (Multiplier != 1.0f)
{
return color;
}
return color;
}
return Value;
}
public static implicit operator FXParticleColor(Color c)
{
return new FXParticleColor { Value = c };
}
public FXParticleColor()
{
Value = Color.White;
}
public FXParticleColor(Color value)
{
Value = value;
}
public FXParticleColor(float r, float g, float b, float a = 1.0f)
{
Value = new Color(r, g, b, a);
}
}
Editor
library
using Editor;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using fxbox;
using fxbox.Graph;
namespace Editor;
/// <summary>
/// Custom control for parameter name selection with dropdown
/// </summary>
public class ParameterNameControlWidget : ControlWidget
{
private SerializedProperty _property;
private ComboBox _dropdown;
private List<FloatParameter> _availableParameters;
public ParameterNameControlWidget(SerializedProperty property) : base(property)
{
_property = property;
SetSizeMode(SizeMode.Ignore, SizeMode.Default);
Layout = Layout.Row();
Layout.Spacing = 3;
// Get available parameters from the currently editing resource
_availableParameters = FXBoxEditor.CurrentEditingResource?.FloatParameters ?? new List<FloatParameter>();
_dropdown = new ComboBox(this);
_dropdown.MinimumWidth = 150;
PopulateDropdown();
// Set current value
var currentValue = property.GetValue<string>();
if (!string.IsNullOrEmpty(currentValue))
{
SelectParameter(currentValue);
}
_dropdown.ItemChanged += OnValueChanged;
//_dropdown.On += OnValueChanged;
Layout.Add(_dropdown, 1);
}
private void PopulateDropdown()
{
_dropdown.Clear();
if (_availableParameters == null || _availableParameters.Count == 0)
{
_dropdown.AddItem("(No Parameters Available)");
_dropdown.Enabled = false;
return;
}
_dropdown.AddItem("(None)");
foreach (var param in _availableParameters.OrderBy(p => p.Name))
{
_dropdown.AddItem($"{param.Name} (default: {param.DefaultValue})");
}
_dropdown.Enabled = true;
}
private void SelectParameter(string parameterName)
{
if (string.IsNullOrEmpty(parameterName))
{
_dropdown.CurrentIndex = 0;
return;
}
// Find the index by matching parameter name
var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();
for (int i = 0; i < orderedParams.Count; i++)
{
if (orderedParams[i].Name == parameterName)
{
_dropdown.CurrentIndex = i + 1; // +1 because of "(None)" at index 0
return;
}
}
}
private new void OnValueChanged()
{
var selectedIndex = _dropdown.CurrentIndex;
if (selectedIndex <= 0)
{
// "(None)" selected
_property.SetValue("");
return;
}
// Get the parameter name from the selected item
var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();
if (selectedIndex - 1 < orderedParams.Count)
{
var selectedParam = orderedParams[selectedIndex - 1];
_property.SetValue(selectedParam.Name);
}
}
protected override void OnPaint()
{
// No custom painting
}
}
public class VectorParameterNameControlWidget : ControlWidget
{
private SerializedProperty _property;
private ComboBox _dropdown;
private List<VectorParameter> _availableParameters;
public VectorParameterNameControlWidget(SerializedProperty property) : base(property)
{
_property = property;
SetSizeMode(SizeMode.Ignore, SizeMode.Default);
Layout = Layout.Row();
Layout.Spacing = 3;
_availableParameters = FXBoxEditor.CurrentEditingResource?.VectorParameters ?? new List<VectorParameter>();
_dropdown = new ComboBox(this);
_dropdown.MinimumWidth = 150;
PopulateDropdown();
var currentValue = property.GetValue<string>();
if (!string.IsNullOrEmpty(currentValue))
{
SelectParameter(currentValue);
}
_dropdown.ItemChanged += OnValueChanged;
Layout.Add(_dropdown, 1);
}
private void PopulateDropdown()
{
_dropdown.Clear();
if (_availableParameters == null || _availableParameters.Count == 0)
{
_dropdown.AddItem("(No Vector Parameters Available)");
_dropdown.Enabled = false;
return;
}
_dropdown.AddItem("(None)");
foreach (var param in _availableParameters.OrderBy(p => p.Name))
{
_dropdown.AddItem($"{param.Name} (default: {param.DefaultValue})");
}
_dropdown.Enabled = true;
}
private void SelectParameter(string parameterName)
{
if (string.IsNullOrEmpty(parameterName))
{
_dropdown.CurrentIndex = 0;
return;
}
var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();
for (int i = 0; i < orderedParams.Count; i++)
{
if (orderedParams[i].Name == parameterName)
{
_dropdown.CurrentIndex = i + 1;
return;
}
}
}
private new void OnValueChanged()
{
var selectedIndex = _dropdown.CurrentIndex;
if (selectedIndex <= 0)
{
_property.SetValue("");
return;
}
var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();
if (selectedIndex - 1 < orderedParams.Count)
{
var selectedParam = orderedParams[selectedIndex - 1];
_property.SetValue(selectedParam.Name);
}
}
protected override void OnPaint()
{
}
}
public class ColorParameterNameControlWidget : ControlWidget
{
private SerializedProperty _property;
private ComboBox _dropdown;
private List<ColorParameter> _availableParameters;
public ColorParameterNameControlWidget(SerializedProperty property) : base(property)
{
_property = property;
SetSizeMode(SizeMode.Ignore, SizeMode.Default);
Layout = Layout.Row();
Layout.Spacing = 3;
_availableParameters = FXBoxEditor.CurrentEditingResource?.ColorParameters ?? new List<ColorParameter>();
_dropdown = new ComboBox(this);
_dropdown.MinimumWidth = 150;
PopulateDropdown();
var currentValue = property.GetValue<string>();
if (!string.IsNullOrEmpty(currentValue))
{
SelectParameter(currentValue);
}
_dropdown.ItemChanged += OnValueChanged;
Layout.Add(_dropdown, 1);
}
private void PopulateDropdown()
{
_dropdown.Clear();
if (_availableParameters == null || _availableParameters.Count == 0)
{
_dropdown.AddItem("(No Color Parameters Available)");
_dropdown.Enabled = false;
return;
}
_dropdown.AddItem("(None)");
foreach (var param in _availableParameters.OrderBy(p => p.Name))
{
_dropdown.AddItem($"{param.Name}");
}
_dropdown.Enabled = true;
}
private void SelectParameter(string parameterName)
{
if (string.IsNullOrEmpty(parameterName))
{
_dropdown.CurrentIndex = 0;
return;
}
var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();
for (int i = 0; i < orderedParams.Count; i++)
{
if (orderedParams[i].Name == parameterName)
{
_dropdown.CurrentIndex = i + 1;
return;
}
}
}
private new void OnValueChanged()
{
var selectedIndex = _dropdown.CurrentIndex;
if (selectedIndex <= 0)
{
_property.SetValue("");
return;
}
var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();
if (selectedIndex - 1 < orderedParams.Count)
{
var selectedParam = orderedParams[selectedIndex - 1];
_property.SetValue(selectedParam.Name);
}
}
protected override void OnPaint()
{
}
}
Debug: View Raw JSON Response
{
"TotalCount": 17,
"Files": [
{
"Ident": "stellawisps.fxbox",
"Path": "Code/ParticleModules.cs",
"FileName": "ParticleModules.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// Stage when a particle module executes\r\n/// </summary>\r\npublic enum ModuleStage\r\n{\r\n Spawn, // Controls when/how particles spawn\r\n Initialize, // Runs once when particle is created\r\n Update, // Runs every frame for each particle\r\n Render // Controls how particles are rendered\r\n}\r\n\r\n/// <summary>\r\n/// Context passed to particle modules during execution\r\n/// </summary>\r\npublic class ParticleExecutionContext\r\n{\r\n\tpublic Particle Particle;\r\n\tpublic ParticleEffect Effect;\r\n\tpublic Sandbox.ParticleEmitter Emitter;\r\n\tpublic FXBoxNativeParticleSystem SystemComponent; // Changed from Resource to SystemComponent\r\n}\r\n\r\n// ==================== SPAWN MODULES ====================\r\n\r\n/// <summary>\r\n/// Controls spawn rate over time\r\n/// </summary>\r\n[Title(\"Spawn Rate\"), Category(\"Spawn\"), Icon(\"speed\")]\r\npublic partial class SpawnRateModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Spawn;\r\n \r\n [Property, Range(0.1f, 1000f)] \r\n public FXParticleFloat SpawnRate { get; set; } = 10.0f;\r\n \r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n\t var rate = SpawnRate;\r\n context.Emitter.Rate = rate.GetValue( context.SystemComponent );\r\n }\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Emitter.Rate = SpawnRate.GetValue( context.SystemComponent );\r\n }\r\n}\r\n/// <summary>\r\n/// Sets initial particle stretch\r\n/// </summary>\r\n[Title(\"Particle Stretch\"), Category(\"Initialize\"), Icon(\"photo_size_select_small\")]\r\npublic partial class ParticleStretchModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\t\r\n \r\n\t[Property, Range(0.1f, 100f)]\r\n\tpublic FXParticleFloat Size { get; set; } = 1.0f;\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.ApplyShape = true;\r\n\t\tcontext.Effect.Stretch = Size.ToParticleFloat( context.SystemComponent );\r\n\t}\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n}\r\n/// <summary>\r\n/// Controls spawn rate per unit\r\n/// </summary>\r\n[Title(\"Spawn Rate Over Distance\"), Category(\"Spawn\"), Icon(\"speed\")]\r\npublic partial class SpawnRateOverDistanceModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Spawn;\r\n \r\n\t[Property, Range(0.1f, 1000f)] \r\n\tpublic FXParticleFloat SpawnRate { get; set; } = 10.0f;\r\n \r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\tcontext.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );\r\n\t}\r\n \r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );\r\n\t}\r\n}\r\n/// <summary>\r\n/// Spawns particles in a burst\r\n/// </summary>\r\n[Title(\"Spawn Burst\"), Category(\"Spawn\"), Icon(\"auto_awesome\")]\r\npublic partial class SpawnBurstModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Spawn;\r\n \r\n [Property, Range(1, 1000)]\r\n public int ParticleCount { get; set; } = 50;\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Emitter.Burst = ParticleCount;\r\n }\r\n\r\n // Burst is a one-shot \"spawn this many now\" value, not a continuous per-tick one like\r\n // SpawnRateModule's Rate - Execute() runs every single frame (see\r\n // FXBoxParticleController.OnUpdate), so reassigning Burst here too kept re-arming/\r\n // re-firing it every tick instead of once, spawning far more than ParticleCount actually\r\n // configured. Same \"Initialize-only, empty Execute\" shape ParticleStretchModule already\r\n // uses above for its own one-shot value.\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n }\r\n}\r\n\r\n// ==================== INITIALIZE MODULES ====================\r\n\r\n/// <summary>\r\n/// Sets initial position based on shape\r\n/// </summary>\r\n[Title(\"Initialize Position\"), Category(\"Initialize\"), Icon(\"place\")]\r\npublic partial class InitializePositionModule : ParticleModule, IParticleComponentCreator\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n public override void Execute( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n\r\n public enum SpawnShape { Point, Sphere, Box, Cone, Circle, Line }\r\n \r\n [Property]\r\n public FXCopyFlags CopyFlags { get; set; } = FXCopyFlags.Rotation | FXCopyFlags.Scale;\r\n \r\n [Property]\r\n public SpawnShape Shape { get; set; } = SpawnShape.Sphere;\r\n \r\n [Property, Range(0f, 1000f), ShowIf(nameof(ShowRadius), true)]\r\n public float Radius { get; set; } = 50.0f;\r\n \r\n [Property, ShowIf(nameof(ShowBoxSize), true)]\r\n public Vector3 BoxSize { get; set; } = new Vector3(100, 100, 100);\r\n \r\n [Property, Range(0f, 180f), ShowIf(nameof(ShowConeAngle), true)]\r\n public float ConeAngle { get; set; } = 45.0f;\r\n \r\n [Property]\r\n public bool EmitFromShell { get; set; } = false;\r\n \r\n [Property, ShowIf(nameof(ShowLine), true)]\r\n public Vector3 LineStart { get; set; } = Vector3.Zero;\r\n \r\n [Property, ShowIf(nameof(ShowLine), true)]\r\n public Vector3 LineEnd { get; set; } = Vector3.Up * 100;\r\n\r\n [Hide] public bool ShowRadius => Shape == SpawnShape.Sphere || Shape == SpawnShape.Circle || Shape == SpawnShape.Cone;\r\n [Hide] public bool ShowBoxSize => Shape == SpawnShape.Box;\r\n [Hide] public bool ShowConeAngle => Shape == SpawnShape.Cone;\r\n [Hide] public bool ShowLine => Shape == SpawnShape.Line;\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t if ( context.Particle != null )\r\n\t {\r\n\t\t \r\n\t\t var pos = Shape switch\r\n\t\t {\r\n\t\t\t SpawnShape.Point => Vector3.Zero,\r\n\t\t\t SpawnShape.Sphere => GetSpherePosition(),\r\n\t\t\t SpawnShape.Box => GetBoxPosition(),\r\n\t\t\t SpawnShape.Cone => GetConePosition(),\r\n\t\t\t SpawnShape.Circle => GetCirclePosition(),\r\n\t\t\t SpawnShape.Line => GetLinePosition(),\r\n\t\t\t _ => Vector3.Zero\r\n\t\t };\r\n\r\n\t\t if ( CopyFlags.HasFlag( FXCopyFlags.Scale ) )\r\n\t\t {\r\n\t\t\t pos = pos * context.Emitter.WorldScale;\r\n\t\t }\r\n\r\n\t\t if ( CopyFlags.HasFlag( FXCopyFlags.Rotation ) )\r\n\t\t {\r\n\t\t\t pos = pos.RotateAround( 0, context.SystemComponent.WorldRotation );\r\n\t\t }\r\n\r\n\t\r\n\t\t context.Particle.Position += pos;\r\n\t }\r\n }\r\n public void CreateComponent(GameObject go)\r\n {\r\n\t var pointEmitter = go.AddComponent<ParticleSphereEmitter>();\r\n\t pointEmitter.Radius = 0;\r\n\t pointEmitter.Velocity = 0;\r\n\t pointEmitter.Burst = 0;\r\n\t pointEmitter.Rate = 0;\r\n }\r\n\r\n private Vector3 GetSpherePosition()\r\n {\r\n var direction = Random.Shared.VectorInSphere().Normal;\r\n var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);\r\n \r\n return direction * radius;\r\n }\r\n\r\n private Vector3 GetBoxPosition()\r\n {\r\n if (EmitFromShell)\r\n {\r\n var face = Random.Shared.Int(0, 5);\r\n var u = Random.Shared.Float(0, 1);\r\n var v = Random.Shared.Float(0, 1);\r\n var halfSize = BoxSize / 2f;\r\n \r\n return face switch\r\n {\r\n 0 => new Vector3(-halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 1 => new Vector3(halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 2 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), -halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 3 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 4 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), -halfSize.z),\r\n _ => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), halfSize.z)\r\n };\r\n }\r\n \r\n return new Vector3(\r\n Random.Shared.Float(-BoxSize.x / 2, BoxSize.x / 2),\r\n Random.Shared.Float(-BoxSize.y / 2, BoxSize.y / 2),\r\n Random.Shared.Float(-BoxSize.z / 2, BoxSize.z / 2)\r\n );\r\n }\r\n\r\n private Vector3 GetConePosition()\r\n {\r\n var angle = Random.Shared.Float(0, 360);\r\n var distance = Random.Shared.Float(0, Radius);\r\n var coneRadius = MathF.Tan(ConeAngle.DegreeToRadian()) * distance;\r\n var radius = EmitFromShell ? coneRadius : Random.Shared.Float(0, coneRadius);\r\n \r\n return new Vector3(\r\n MathF.Cos(angle.DegreeToRadian()) * radius,\r\n MathF.Sin(angle.DegreeToRadian()) * radius,\r\n distance\r\n );\r\n }\r\n\r\n private Vector3 GetCirclePosition()\r\n {\r\n var angle = Random.Shared.Float(0, 360);\r\n var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);\r\n return new Vector3(\r\n MathF.Cos(angle.DegreeToRadian()) * radius,\r\n MathF.Sin(angle.DegreeToRadian()) * radius,\r\n 0\r\n );\r\n }\r\n\r\n private Vector3 GetLinePosition()\r\n {\r\n return Vector3.Lerp(LineStart, LineEnd, Random.Shared.Float(0, 1));\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Controls how strongly particles follow the emitter in local space\r\n/// </summary>\r\n[Title(\"Initialize Local Space\"), Category(\"Initialize\"), Icon(\"transform\")]\r\npublic partial class InitializeLocalSpaceModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n\t[Property, Range(0f, 1f)]\r\n\tpublic FXParticleFloat LocalSpace { get; set; } = 0f;\r\n\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.LocalSpace = LocalSpace.ToParticleFloat( context.SystemComponent );\r\n\t}\r\n\r\n\tpublic override void Execute( ParticleExecutionContext context )\r\n\t{\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial velocity\r\n/// </summary>\r\n[Title(\"Initialize Velocity\"), Category(\"Initialize\"), Icon(\"air\")]\r\npublic partial class InitializeVelocityModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n \r\n [Property]\r\n public FXParticleVector Velocity { get; set; } = Vector3.Up * 100;\r\n\r\n [Property] public FXParticleFloat RandomVelocity { get; set; } = 0;\r\n\r\n [Property] public bool LocalSpace { get; set; } = false;\r\n \r\n [Property]\r\n public bool InheritEmitterVelocity { get; set; } = false;\r\n \r\n \r\n [Property,ShowIf(\"InheritEmitterVelocity\",true)] public float EmitterVelocityScale { get; set; } = 1.0f;\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t var startVelocity = Velocity;\r\n\t if ( LocalSpace )\r\n\t {\r\n\t\t startVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent ).RotateAround( 0,context.Emitter.WorldRotation );\r\n\t }\r\n\r\n\t context.Effect.StartVelocity = RandomVelocity.ToParticleFloat();\r\n\t context.Effect.InitialVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent );\r\n\t if ( InheritEmitterVelocity )\r\n\t {\r\n\t\t context.Effect.InitialVelocity = (context.SystemComponent.Velocity*EmitterVelocityScale) + startVelocity.GetValue( context.Particle,context.SystemComponent );\r\n\t }\r\n\t \r\n }\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n\r\n }\r\n}\r\n/// <summary>\r\n/// Sets initial color\r\n/// </summary>\r\n[Title(\"Collision\"), Category(\"Initialize\"), Icon(\"palette\")]\r\npublic partial class ParticleCollisionModule : ParticleModule\r\n{\r\n\r\n\t[Property] public TagSet CollisionIgnore { get; set; } = new TagSet();\r\n\t[Property] public List<GameObject> CollisionPrefabs { get; set; } = new List<GameObject>();\r\n\t[Property] public FXParticleFloat CollisionRadius { get; set; } = 5;\r\n\t[Property] public FXParticleFloat CollisionPrefabChance { get; set; } = 1;\r\n\t[Property] public FXParticleFloat CollisionPrefabRotation { get; set; } = 0;\r\n\t[Property] public FXParticleFloat DieOnCollisionChance { get; set; } = 0;\r\n\t[Property] public bool CollisionPrefabAlign { get; set; } = false;\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n \r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.Collision = true;\r\n\t\tcontext.Effect.CollisionIgnore = CollisionIgnore;\r\n\t\tcontext.Effect.CollisionRadius = CollisionRadius.GetValue( context.SystemComponent );\r\n\t\tcontext.Effect.CollisionPrefabChance = CollisionPrefabChance.GetValue( context.SystemComponent );\r\n\t\tcontext.Effect.CollisionPrefabRotation = CollisionPrefabRotation.ToParticleFloat( context.SystemComponent );\r\n\t\tif ( CollisionPrefabs.Any() )\r\n\t\t{\r\n\t\t\tcontext.Effect.UsePrefabFeature = true;\r\n\t\t}\r\n\t\tcontext.Effect.CollisionPrefab = CollisionPrefabs;\r\n\t\tcontext.Effect.DieOnCollisionChance = DieOnCollisionChance.GetValue( context.SystemComponent );\r\n\t\tcontext.Effect.CollisionPrefabAlign = CollisionPrefabAlign;\r\n\t}\r\n}\r\n/// <summary>\r\n/// Sets initial velocity\r\n/// </summary>\r\n[Title(\"Initialize Rotation\"), Category(\"Initialize\"), Icon(\"air\")]\r\npublic partial class InitializeRotationModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n \r\n\t[Property]\r\n\tpublic ParticleVector3 InitialRotation { get; set; } = Vector3.Up;\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tif ( context.Particle != null )\r\n\t\t{\r\n\t\t\tcontext.Particle.Angles = new Angles( InitialRotation.Evaluate( Time.Delta,context.Particle.Rand( ),context.Particle.Rand( ),context.Particle.Rand( ) ) );\r\n\t\t}\r\n\t \r\n\t}\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial lifetime\r\n/// </summary>\r\n[Title(\"Initialize Lifetime\"), Category(\"Initialize\"), Icon(\"schedule\")]\r\npublic partial class InitializeLifetimeModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n \r\n\t[Property, Range(0.1f, 100f)]\r\n\tpublic FXParticleFloat Lifetime { get; set; } = 2.0f;\r\n \r\n\tpublic override void Initialize(ParticleExecutionContext context)\r\n\t{\r\n\t\tcontext.Effect.Lifetime = Lifetime.ToParticleFloat( context.SystemComponent );\r\n\t}\r\n \r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Not used\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial size\r\n/// </summary>\r\n[Title(\"Initialize Size\"), Category(\"Initialize\"), Icon(\"photo_size_select_small\")]\r\npublic partial class InitializeSizeModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n [Property] public bool InheritEmitterScale { get; set; } = true;\r\n \r\n [Property, Range(0.1f, 100f)]\r\n public FXParticleFloat Size { get; set; } = 10.0f;\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Effect.ApplyShape = true;\r\n\t if ( InheritEmitterScale )\r\n\t {\r\n\t\t context.Effect.Scale = Size.ToParticleFloat( context.SystemComponent );\r\n\t }\r\n\t else\r\n\t {\r\n\t\t context.Effect.Scale = (Size / context.SystemComponent.WorldScale.x).ToParticleFloat( context.SystemComponent );\r\n\t }\r\n\t \r\n\t \r\n\t \r\n }\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial color\r\n/// </summary>\r\n[Title(\"Initialize Color\"), Category(\"Initialize\"), Icon(\"palette\")]\r\npublic partial class InitializeColorModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n [Property] public FXParticleColor Color { get; set; } = global::Color.Red;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Effect.ApplyColor = true;\r\n\t context.Effect.ApplyAlpha = true;\r\n\t if ( Color != null )\r\n\t {\r\n\t\t context.Effect.Gradient = Color.GetValue( context.SystemComponent );\r\n\t }\r\n\t else\r\n\t {\r\n\t\t Color = new FXParticleColor( global::Color.Red );\r\n\t }\r\n\t \r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial color\r\n/// </summary>\r\n[Title(\"Sprite Flipbook\"), Category(\"Initialize\"), Icon(\"palette\")]\r\npublic partial class SpriteFlipbookModule : ParticleModule\r\n{\r\n\t[Property] public FXParticleFloat SequenceTime { get; set; } = 0;\r\n\t[Property] public FXParticleFloat SequenceSpeed { get; set; } = 1;\r\n\t[Property] public int SequenceId { get; set; } = 0;\r\n\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n \r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.SheetSequence = true;\r\n\t\tcontext.Effect.SequenceId = SequenceId;\r\n\t\tcontext.Effect.SequenceSpeed = SequenceSpeed.ToParticleFloat( context.SystemComponent );\r\n\t\tcontext.Effect.SequenceTime = SequenceTime.ToParticleFloat( context.SystemComponent );\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Randomly Kill a particle to spawn less\r\n/// </summary>\r\n[Title(\"RandomKill\"), Category(\"Initialize\"), Icon(\"arrow_downward\")]\r\npublic partial class RandomKill : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\t\r\n\t[Property]\r\n\tpublic float Chance { get; set; } = 0.5f;\r\n\t\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tif ( context.Particle == null ) return;\r\n\t\tif ( Random.Shared.Float( 0, 1 ) < Chance )\r\n\t\t{\r\n\t\t\tcontext.Particle.Age = 100000;\r\n\t\t}\r\n\t}\r\n\t\r\n}\r\n\r\n\r\n// ==================== UPDATE MODULES ====================\r\n\r\n/// <summary>\r\n/// Applies gravity force\r\n/// </summary>\r\n[Title(\"Gravity Force\"), Category(\"Update\"), Icon(\"arrow_downward\")]\r\npublic partial class GravityForceModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property]\r\n public FXParticleVector Force { get; set; } = new Vector3(0, 0, -980);\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n context.Particle.Velocity += Force.GetValue(context.Particle, context.SystemComponent ) * Time.Delta;\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Make a mesh follow it's velocity\r\n/// </summary>\r\n[Title(\"Follow Velocity\"), Category(\"Update\"), Icon(\"arrow_downward\")]\r\npublic partial class FollowVelocity : ParticleModule, IParticleUpdater\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Update;\r\n\t\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void UpdateParticle(ParticleExecutionContext context, float delta)\r\n\t{\r\n\t\tcontext.Particle.Angles = Rotation.LookAt( context.Particle.Velocity ).Angles();\r\n\t}\r\n}\r\n/// <summary>\r\n/// Applies drag/air resistance\r\n/// </summary>\r\n[Title(\"Drag Force\"), Category(\"Update\"), Icon(\"air\")]\r\npublic partial class DragForceModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property, Range(0f, 10f)]\r\n public float Damping { get; set; } = 0.1f;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n context.Particle.Velocity *= (1.0f - Damping * Time.Delta);\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Makes particles rotate\r\n/// </summary>\r\n[Title(\"Rotation\"), Category(\"Update\"), Icon(\"rotate_right\")]\r\npublic partial class RotationModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n\r\n [Property] public FXParticleVector RotationSpeed { get; set; } = Vector3.Zero;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n /*context.Particle.Rotation += RotationSpeed * context.DeltaTime;*/\r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n\t if ( context.Particle != null )\r\n\t {\r\n\t\t context.Particle.Angles += RotationSpeed.GetValue( context.Particle ) * Time.Delta;\r\n\t }\r\n \r\n }\r\n}\r\n\r\npublic enum PositionType\r\n{\r\n\tLocal,\r\n\tWorld\r\n}\r\n\r\n/// <summary>\r\n/// Attracts particles to a point. Full strength inside AttractorSize, falling off beyond it.\r\n/// </summary>\r\n[Title(\"Point Attractor\"), Category(\"Update\"), Icon(\"my_location\")]\r\npublic partial class PointAttractorModule : ParticleModule, IParticleUpdater\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Update;\r\n\t[Property] public PositionType PositionType { get; set; } = PositionType.Local;\r\n\r\n\t[Property]\r\n\tpublic FXParticleVector AttractorPosition { get; set; } = Vector3.Zero;\r\n\r\n\t[Property, Range(0f, 10000f)]\r\n\tpublic FXParticleFloat Strength { get; set; } = 500.0f;\r\n\r\n\t[Property, Range(0.01f, 10000f)]\r\n\tpublic float AttractorSize { get; set; } = 50.0f;\r\n\r\n\t[Property] public bool Invert { get; set; } = false;\r\n\r\n\t/// <summary>\r\n\t/// How quickly strength falls off beyond AttractorSize.\r\n\t/// 1 = linear, 2 = inverse square, higher = sharper falloff.\r\n\t/// </summary>\r\n\t[Property, Range(0.1f, 8f)]\r\n\tpublic float Falloff { get; set; } = 2.0f;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context) { }\r\n\tpublic override void Initialize(ParticleExecutionContext context) { }\r\n\r\n\tpublic void UpdateParticle(ParticleExecutionContext context, float delta)\r\n\t{\r\n\t\tvar attractorPos = PositionType == PositionType.Local ? AttractorPosition.GetValue( context.Particle, context.SystemComponent ) + context.Emitter.WorldPosition : AttractorPosition.GetValue( context.Particle, context.SystemComponent );\r\n\t\t\r\n\t\t\r\n\t\tvar toAttractor = attractorPos - context.Particle.Position;\r\n\t\tvar distance = toAttractor.Length;\r\n\r\n\t\tif (distance < 0.01f) return;\r\n\r\n\t\t// Inside the attractor: full strength.\r\n\t\t// Outside: strength falls off based on normalised excess distance.\r\n\t\tfloat strengthMultiplier;\r\n\t\tif (distance <= AttractorSize)\r\n\t\t{\r\n\t\t\tstrengthMultiplier = 1f;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t// How many radii past the edge are we? 0 at the surface, grows outward.\r\n\t\t\tvar excess = (distance - AttractorSize) / AttractorSize;\r\n\t\t\tstrengthMultiplier = 1f / MathF.Pow(1f + excess, Falloff);\r\n\t\t}\r\n\t\t\r\n\t\tif ( Invert )\r\n\t\t{\r\n\t\t\tstrengthMultiplier = 1 - strengthMultiplier;\r\n\t\t}\r\n\r\n\t\tcontext.Particle.Velocity += toAttractor.Normal * Strength.GetValue( context.SystemComponent ) * strengthMultiplier * Time.Delta;\r\n\t}\r\n}\r\n/// <summary>\r\n/// Creates orbital motion\r\n/// </summary>\r\n[Title(\"Vortex Force\"), Category(\"Update\"), Icon(\"cyclone\")]\r\npublic partial class VortexForceModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property]\r\n public Vector3 Center { get; set; } = Vector3.Zero;\r\n \r\n [Property]\r\n public FXParticleVector Axis { get; set; } = Vector3.Up;\r\n \r\n [Property, Range(0f, 1000f)]\r\n public float Strength { get; set; } = 100.0f;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n var toCenter = context.Particle.Position - (Center + context.Emitter.WorldPosition);\r\n var distance = toCenter.Length;\r\n \r\n \r\n if (distance > 0.01f)\r\n {\r\n var tangent = Vector3.Cross(Axis.GetValue( context.Particle,context.SystemComponent ).Normal, toCenter.Normal);\r\n var force = tangent * (Strength / distance);\r\n context.Particle.Velocity += force * Time.Delta * 10000;\r\n }\r\n }\r\n}\r\n\r\n// ==================== RENDER MODULES ====================\r\n\r\n/// <summary>\r\n/// Basic sprite renderer\r\n/// </summary>\r\n[Title(\"Sprite Renderer\"), Category(\"Render\"), Icon(\"image\")]\r\npublic partial class SpriteRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n [Hide] \r\n public override ModuleStage Stage => ModuleStage.Render;\r\n\r\n [Property] \r\n public Sprite Sprite { get; set; }\r\n\r\n [Property] public FXParticleFloat SpriteScale { get; set; } = 1f;\r\n\r\n [Property]\r\n public ParticleSpriteRenderer.BillboardAlignment Alignment { get; set; } =\r\n\t ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;\r\n\r\n [Property] public bool FaceVelocity { get; set; } = false;\r\n\r\n [Property] public bool Additive { get; set; } = false;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void CreateComponent(GameObject go)\r\n {\r\n var renderer = go.AddComponent<ParticleSpriteRenderer>();\r\n\r\n renderer.Alignment = Alignment;\r\n renderer.FaceVelocity = FaceVelocity;\r\n renderer.Sprite = Sprite;\r\n\t\trenderer.Additive = Additive;\r\n\t\trenderer.Scale = SpriteScale.GetValue();\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Basic light renderer\r\n/// </summary>\r\n[Title(\"Light Renderer\"), Category(\"Render\"), Icon(\"image\")]\r\npublic partial class LightRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n\t[Hide] \r\n\tpublic override ModuleStage Stage => ModuleStage.Render;\r\n\t\r\n\t[Property] public FXParticleColor LightColor { get; set; } = new FXParticleColor( Color.White );\r\n\t[Property] public FXParticleFloat Brightness { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat MaxLights { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat LightSize { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat Attenuation { get; set; } = 1;\r\n\t[Property] public bool CastShadows { get; set; } = false;\r\n\t[Property] public bool UseParticleColor { get; set; } = true;\r\n\t[Property] public FXParticleFloat Ratio { get; set; } = 1;\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Rendering is handled externally, this just stores render properties\r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void CreateComponent(GameObject go)\r\n\t{\r\n\t\tvar renderer = go.AddComponent<ParticleLightRenderer>();\r\n\t\t\r\n\t\tvar fxbox=go.GetComponentInParent<FXBoxNativeParticleSystem>( );\r\n\t\trenderer.LightColor = LightColor.GetValue( fxbox );\r\n\t\trenderer.Brightness = Brightness.GetValue( fxbox );\r\n\t\t\r\n\t\trenderer.MaximumLights = (int)MaxLights.GetValue( fxbox );\r\n\t\trenderer.Scale = LightSize.GetValue( fxbox );\r\n\t\trenderer.Attenuation = Attenuation.GetValue( fxbox );\r\n\t\trenderer.Ratio = Ratio.GetValue( fxbox );\r\n\t\trenderer.CastShadows = CastShadows;\r\n\t\trenderer.UseParticleColor = UseParticleColor;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Basic model renderer\r\n/// </summary>\r\n[Title(\"Model Renderer\"), Category(\"Render\"), Icon(\"image\")]\r\npublic partial class ModelRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n\t[Hide] \r\n\tpublic override ModuleStage Stage => ModuleStage.Render;\r\n\r\n\t[Property] \r\n\tpublic List<ParticleModelRenderer.ModelEntry> Models { get; set; }\r\n\r\n\t[Property] \r\n\tpublic bool FaceCamera { get; set; } = true;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Rendering is handled externally, this just stores render properties\r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void CreateComponent(GameObject go)\r\n\t{\r\n\t\tvar renderer = go.AddComponent<ParticleModelRenderer>();\r\n\r\n\t\trenderer.Choices = Models;\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Basic Trail Renderer\r\n/// </summary>\r\n[Title( \"Trail Renderer\" ), Category( \"Render\" ), Icon( \"image\" )]\r\npublic partial class TrailRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n\t[Hide] public override ModuleStage Stage => ModuleStage.Render;\r\n\r\n\t[Property] public bool Game { get; set; } = true;\r\n\t[Property] public bool Overlay { get; set; } = false;\r\n\t[Property] public bool Bloom { get; set; } = false;\r\n\t[Property] public bool AfterUi { get; set; } = false;\r\n\t[Property] public Material Material { get; set; }\r\n\t[Property] public FXParticleFloat UnitsPerTexture { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat Scroll { get; set; } = 0f;\r\n\t[Property] public FXParticleFloat Width { get; set; } = 1f;\r\n\t[Property] public bool Opaque { get; set; } = true;\r\n\t[Property, ShowIf( \"Opaque\", false )] public BlendMode BlendMode { get; set; } = BlendMode.Normal;\r\n\t[Property] public int MaxPoints { get; set; } = 32;\r\n\t[Property] public float PointDistance { get; set; } = 8;\r\n\t[Property] public float LifeTime { get; set; } = 2f;\r\n\t[Property] public FXParticleColor Color { get; set; } = new FXParticleColor( );\r\n\r\npublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Rendering is handled externally, this just stores render properties\r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void CreateComponent(GameObject go)\r\n\t{\r\n\t\tvar renderer = go.AddComponent<ParticleTrailRenderer>();\r\n\r\n\t\tvar appearance = renderer.Texturing;\r\n\t\tappearance.Material = Material;\r\n\t\tappearance.UnitsPerTexture = UnitsPerTexture.GetValue( );\r\n\t\tappearance.Scroll = Scroll.GetValue();\r\n\r\n\t\tvar widthCurve = Width.ToParticleFloat();\r\n\r\n\t\tif ( widthCurve.Type == ParticleFloat.ValueType.Curve )\r\n\t\t{\r\n\t\t\trenderer.Width = widthCurve.CurveA;\r\n\t\t} else if ( widthCurve.Type == ParticleFloat.ValueType.Range )\r\n\t\t{\r\n\t\t\tvar point1 = new Curve.Frame( 0, widthCurve.ConstantA );\r\n\t\t\tvar point2 = new Curve.Frame( 1, widthCurve.ConstantB );\r\n\t\t\trenderer.Width = new Curve( point1, point2 );\r\n\t\t} else if ( widthCurve.Type == ParticleFloat.ValueType.Constant )\r\n\t\t{\r\n\t\t\trenderer.Width = widthCurve.ConstantA;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\trenderer.Width = widthCurve.CurveA;\r\n\t\t}\r\n\t\t\r\n\t\trenderer.Opaque = Opaque;\r\n\t\trenderer.BlendMode = BlendMode;\r\n\t\trenderer.MaxPoints = MaxPoints;\r\n\t\trenderer.PointDistance = PointDistance;\r\n\t\trenderer.LifeTime = LifeTime;\r\n\t\tvar colorParam = Color.GetValue();\r\n\t\tif ( colorParam.Type == ParticleGradient.ValueType.Constant )\r\n\t\t{\r\n\t\t\trenderer.Color = colorParam.ConstantA;\r\n\t\t} else if ( colorParam.Type == ParticleGradient.ValueType.Range )\r\n\t\t{\r\n\t\t\tvar point1 = new Gradient.ColorFrame( 0, colorParam.ConstantA );\r\n\t\t\tvar point2 = new Gradient.ColorFrame( 1, colorParam.ConstantB );\r\n\t\t\trenderer.Color = new Gradient( point1, point2 );\r\n\t\t} else if ( colorParam.Type == ParticleGradient.ValueType.Gradient )\r\n\t\t{\r\n\t\t\trenderer.Color = colorParam.GradientA;\r\n\t\t}\r\n\r\n\t\trenderer.RenderOptions.Game = Game;\r\n\t\trenderer.RenderOptions.Overlay = Overlay;\r\n\t\trenderer.RenderOptions.Bloom = Bloom;\r\n\t\trenderer.RenderOptions.AfterUI = AfterUi;\r\n\r\n\t\trenderer.Texturing = appearance;\r\n\r\n\t}\r\n}\r\n/// <summary>\r\n/// Applies curl noise force for organic, swirling motion\r\n/// </summary>\r\n[Title(\"Curl Noise\"), Category(\"Update\"), Icon(\"air\")]\r\npublic partial class CurlNoiseModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property, Range(0f, 1000f)]\r\n [Description(\"Strength of the curl noise effect\")]\r\n public FXParticleFloat Strength { get; set; } = 1.0f;\r\n \r\n [Property, Range(0.01f, 10f)]\r\n [Description(\"Scale of the noise pattern - smaller values create tighter curls\")]\r\n public FXParticleFloat Scale { get; set; } = 1.0f;\r\n \r\n [Property, Range(0f, 10f)]\r\n [Description(\"Speed at which the noise pattern evolves over time\")]\r\n public FXParticleFloat TimeScale { get; set; } = 1.0f;\r\n \r\n [Property]\r\n [Description(\"Offset in the noise field\")]\r\n public Vector3 Offset { get; set; } = Vector3.Zero;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n // Not used - handled in UpdateParticle\r\n }\r\n\r\n public override void Initialize(ParticleExecutionContext context)\r\n {\r\n // No initialization needed\r\n }\r\n\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n var particle = context.Particle;\r\n \r\n // Sample position in noise field\r\n var samplePos = (particle.Position + Offset) * Scale.GetValue( context.SystemComponent );\r\n var time = context.Particle.Age * TimeScale;\r\n \r\n // Calculate curl noise using the curl of a 3D noise field\r\n var curl = CalculateCurl(samplePos, time.GetValue( context.SystemComponent ));\r\n \r\n // Apply force\r\n particle.Velocity += curl * Strength.GetValue( context.SystemComponent ) * delta * 10;\r\n }\r\n\r\n /// <summary>\r\n /// Calculate curl noise by taking the curl of a potential field\r\n /// This creates divergence-free flow fields that look organic\r\n /// </summary>\r\n private Vector3 CalculateCurl(Vector3 pos, float time)\r\n {\r\n const float epsilon = 0.001f;\r\n \r\n // Sample the potential field at offset positions\r\n // We need 6 samples to calculate the curl (derivatives in all directions)\r\n \r\n // dPz/dy - dPy/dz\r\n float curlX = \r\n (SamplePotential(pos + new Vector3(0, epsilon, 0), time).z - \r\n SamplePotential(pos - new Vector3(0, epsilon, 0), time).z) -\r\n (SamplePotential(pos + new Vector3(0, 0, epsilon), time).y - \r\n SamplePotential(pos - new Vector3(0, 0, epsilon), time).y);\r\n \r\n // dPx/dz - dPz/dx\r\n float curlY = \r\n (SamplePotential(pos + new Vector3(0, 0, epsilon), time).x - \r\n SamplePotential(pos - new Vector3(0, 0, epsilon), time).x) -\r\n (SamplePotential(pos + new Vector3(epsilon, 0, 0), time).z - \r\n SamplePotential(pos - new Vector3(epsilon, 0, 0), time).z);\r\n \r\n // dPy/dx - dPx/dy\r\n float curlZ = \r\n (SamplePotential(pos + new Vector3(epsilon, 0, 0), time).y - \r\n SamplePotential(pos - new Vector3(epsilon, 0, 0), time).y) -\r\n (SamplePotential(pos + new Vector3(0, epsilon, 0), time).x - \r\n SamplePotential(pos - new Vector3(0, epsilon, 0), time).x);\r\n \r\n return new Vector3(curlX, curlY, curlZ) / (2.0f * epsilon);\r\n }\r\n\r\n /// <summary>\r\n /// Sample a 3D potential field using Perlin-like noise\r\n /// </summary>\r\n private Vector3 SamplePotential(Vector3 pos, float time)\r\n {\r\n // Create three offset noise samples for each component\r\n // This creates a vector field from scalar noise functions\r\n return new Vector3(\r\n Noise3D(pos + new Vector3(0, 0, 0), time),\r\n Noise3D(pos + new Vector3(31.416f, -47.853f, 12.793f), time),\r\n Noise3D(pos + new Vector3(-17.737f, 86.214f, -59.482f), time)\r\n );\r\n }\r\n\r\n /// <summary>\r\n /// Simple 3D noise function using sine waves\r\n /// You could replace this with proper Perlin/Simplex noise for better results\r\n /// </summary>\r\n private float Noise3D(Vector3 pos, float time)\r\n {\r\n // Combine multiple sine waves at different frequencies for pseudo-noise\r\n var p = pos + new Vector3(time, time * 0.7f, time * 0.5f);\r\n \r\n float noise = 0;\r\n noise += MathF.Sin(p.x * 1.0f + p.y * 1.3f) * 0.5f;\r\n noise += MathF.Sin(p.y * 1.7f + p.z * 0.9f) * 0.3f;\r\n noise += MathF.Sin(p.z * 2.1f + p.x * 1.1f) * 0.2f;\r\n noise += MathF.Sin(p.x * 3.7f + p.y * 2.3f + p.z * 1.9f) * 0.15f;\r\n \r\n return noise;\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "UnitTests/UnitTest.cs",
"FileName": "UnitTest.cs",
"PackageType": "library",
"CodeKind": "UnitTest",
"AssetVersionId": 379493,
"Code": "global using Microsoft.VisualStudio.TestTools.UnitTesting;\r\n\r\n[TestClass]\r\npublic class TestInit\r\n{\r\n\t[AssemblyInitialize]\r\n\tpublic static void ClassInitialize( TestContext context )\r\n\t{\r\n\t\tSandbox.Application.InitUnitTest();\r\n\t}\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Editor/Graph/FXEditor.cs",
"FileName": "FXEditor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 379493,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace fxbox.Graph;\r\n\r\n/// <summary>\r\n/// Main particle system editor - ShaderGraph style with DockManager\r\n/// </summary>\r\n[EditorForAssetType(\"fx\")]\r\n[EditorApp(\"FXBox\", \"auto_awesome\", \"Create and edit particle systems\")]\r\npublic class FXBoxEditor : DockWindow, IAssetEditor\r\n{\r\n public bool CanOpenMultipleAssets => true;\r\n public static ParticleResource CurrentEditingResource { get; private set; }\r\n \r\n private ParticleResource _resource; // Original resource from asset\r\n private ParticleResource _workingCopy; // Copy we actually edit\r\n private Asset _asset;\r\n private bool _isDirty = false;\r\n\r\n // UI Components\r\n private EmitterList _emitterList;\r\n private ParticlePreview _preview;\r\n private PropertiesWidget _properties;\r\n \r\n private string _defaultDockState;\r\n\r\n public FXBoxEditor()\r\n {\r\n DeleteOnClose = true;\r\n Title = \"FXBox - Particle System Editor\";\r\n Size = new Vector2(1800, 1000);\r\n\r\n CreateToolBar();\r\n CreateUI();\r\n Show();\r\n }\r\n\r\n // StateCookie is set here, AFTER base.Show(), rather than inside CreateUI() before the\r\n // window is even shown - same ordering as the working RectEditor reference\r\n // (HotspotEditorWindow.Show() / Window.CreateUI()'s \"if (Visible) RestoreLayout()\").\r\n // Setting it too early was part of why a layout you'd closed with panels open never came\r\n // back - restoring (or building the default layout) needs the window's dock widget\r\n // hierarchy to actually exist and be visible first.\r\n public override void Show()\r\n {\r\n base.Show();\r\n StateCookie = \"FXBoxEditor_v6\";\r\n }\r\n\r\n private void CreateToolBar()\r\n {\r\n var toolbar = new ToolBar(this, \"FXBoxToolbar\");\r\n AddToolBar(toolbar, ToolbarPosition.Top);\r\n\r\n toolbar.AddOption(\"Save\", \"save\", Save).StatusTip = \"Save Particle System (Ctrl+S)\";\r\n toolbar.AddSeparator();\r\n toolbar.AddOption(\"Add Emitter\", \"add_circle\", AddEmitter).StatusTip = \"Add new emitter\";\r\n \r\n // Add parameter menu\r\n toolbar.AddSeparator();\r\n toolbar.AddOption(\"Float Parameter\", \"looks_one\", AddFloatParameter);\r\n toolbar.AddOption(\"Vector Parameter\", \"3d_rotation\", AddVectorParameter);\r\n toolbar.AddOption(\"Color Parameter\", \"palette\", AddColorParameter);\r\n \r\n toolbar.AddSeparator();\r\n toolbar.AddOption(\"Play\", \"play_arrow\", () => _preview?.TogglePlayback()).StatusTip = \"Play/Pause\";\r\n toolbar.AddOption(\"Restart\", \"replay\", () => _preview?.Restart()).StatusTip = \"Restart\";\r\n }\r\n\r\n private void CreateUI()\r\n {\r\n BuildMenuBar();\r\n BuildDock();\r\n\r\n DockManager.Update();\r\n\r\n _defaultDockState = DockManager.State;\r\n }\r\n\r\n // Builds the REAL widget instances and docks them - unlike the previous\r\n // RegisterDockType+CreateAction version, this runs eagerly every time CreateUI runs\r\n // (every open, every hotload), so _emitterList/_preview/_properties are never null by\r\n // the time anything else (AssetOpen, LoadResource, etc.) tries to push data into them.\r\n // The old lazy version only created a widget instance whenever the dock manager itself\r\n // got around to invoking CreateAction - which, on a restored/default layout, could\r\n // happen well after AssetOpen already tried (and silently no-op'd via ?.) to hand the\r\n // resource to _emitterList/_preview/_properties, matching the exact \"not hooked up until\r\n // you add a parameter or emitter\" symptom (that next SetResource/LoadParticleSystem call\r\n // was the first one to land after CreateAction had finally run).\r\n //\r\n // DockManager.AddDock(title, icon, widget, area, relativeTo) both registers the dock\r\n // TYPE under that title/icon (same as RegisterDockType did, for the View menu and\r\n // BuildDefaultLayout's OpenDock below to find it by name) AND places this exact instance\r\n // on screen - no separate CreateDockWidget/AddDock two-step needed.\r\n private void BuildDock()\r\n {\r\n _preview = CreatePreview();\r\n _emitterList = CreateEmitterList();\r\n _properties = CreateProperties();\r\n\r\n var preview = DockManager.AddDock( \"Preview\", \"visibility\", _preview, DockArea.Center );\r\n DockManager.AddDock( \"Emitters\", \"list\", _emitterList, DockArea.Left, relativeTo: preview );\r\n DockManager.AddDock( \"Properties\", \"tune\", _properties, DockArea.Right, relativeTo: preview );\r\n }\r\n\r\n // Called by the base class on a genuinely first-ever open (nothing saved under\r\n // StateCookie yet - see the Show() override above) to decide default POSITIONS/\r\n // proportions only. OpenDock resolves the SAME dock types BuildDock() already\r\n // registered+placed above by Title, it doesn't build a second set of widgets.\r\n protected override void BuildDefaultLayout()\r\n {\r\n var preview = DockManager.OpenDock( \"Preview\", DockArea.Center );\r\n var emitters = DockManager.OpenDock( \"Emitters\", DockArea.Left, preview );\r\n var properties = DockManager.OpenDock( \"Properties\", DockArea.Right, preview );\r\n\r\n DockManager.SetSplitterProportions( emitters, 0.20f, 0.80f );\r\n DockManager.SetSplitterProportions( properties, 0.80f, 0.20f );\r\n }\r\n\r\n private EmitterList CreateEmitterList()\r\n {\r\n var widget = new EmitterList(this);\r\n widget.Name = \"Emitters\";\r\n widget.WindowTitle = \"Emitters & Modules\";\r\n widget.SetWindowIcon(\"list\");\r\n widget.MinimumWidth = 300;\r\n widget.OnSelectionChanged = OnSelectionChanged;\r\n widget.OnEmitterDeleted = OnEmitterDeleted;\r\n widget.OnModuleDeleted = OnModuleDeleted;\r\n widget.OnAddModule = OnAddModule;\r\n widget.OnSystemChanged = MarkDirty;\r\n widget.OnEmitterDuplicated = OnEmitterDuplicated;\r\n widget.OnModuleDuplicated = OnModuleDuplicated;\r\n\r\n return widget;\r\n }\r\n\r\n private void OnEmitterDuplicated(ParticleEmitter emitter)\r\n {\r\n\t if (_workingCopy == null) return;\r\n\r\n\t var index = _workingCopy.Emitters.IndexOf(emitter);\r\n\t if (index == -1) return;\r\n\r\n\t // Round-trip the entire resource through JSON, then pull out\r\n\t // the emitter at the same index \u2014 gives us a full deep clone\r\n\t // without needing Serialize/Deserialize on ParticleEmitter itself.\r\n\t var resourceCopy = DeepCopyResource(_workingCopy);\r\n\t var clone = resourceCopy.Emitters[index];\r\n\t clone.Name = $\"{emitter.Name} (Copy)\";\r\n\r\n\t _workingCopy.Emitters.Insert(index + 1, clone);\r\n\r\n\t _emitterList?.SetResource(_workingCopy);\r\n\t _preview?.LoadParticleSystem(_workingCopy);\r\n\t MarkDirty();\r\n }\r\n\r\n private ParticlePreview CreatePreview()\r\n {\r\n var widget = new ParticlePreview(this);\r\n widget.Name = \"Preview\";\r\n widget.WindowTitle = \"Preview\";\r\n \r\n widget.SetWindowIcon(\"visibility\");\r\n return widget;\r\n }\r\n\r\n private PropertiesWidget CreateProperties()\r\n {\r\n var widget = new PropertiesWidget(this);\r\n widget.Name = \"Properties\";\r\n widget.WindowTitle = \"Properties\";\r\n widget.SetWindowIcon(\"tune\");\r\n widget.MinimumWidth = 300;\r\n widget.OnPropertyChanged = OnPropertyChanged;\r\n return widget;\r\n }\r\n\r\n private void BuildMenuBar()\r\n {\r\n var file = MenuBar.AddMenu(\"File\");\r\n file.AddOption(\"New\", \"add\", New, \"editor.new\").StatusTip = \"New Particle System\";\r\n file.AddOption(\"Open\", \"folder_open\", Open, \"editor.open\").StatusTip = \"Open Particle System\";\r\n file.AddOption(\"Save\", \"save\", Save, \"editor.save\").StatusTip = \"Save Particle System\";\r\n file.AddSeparator();\r\n file.AddOption(\"Close\", null, () => Close(), \"editor.quit\").StatusTip = \"Close Editor\";\r\n\r\n var edit = MenuBar.AddMenu(\"Edit\");\r\n edit.AddOption(\"Add Emitter\", \"add_circle\", AddEmitter);\r\n edit.AddSeparator();\r\n edit.AddOption(\"Add Float Parameter\", \"looks_one\", AddFloatParameter);\r\n edit.AddOption(\"Add Vector Parameter\", \"3d_rotation\", AddVectorParameter);\r\n edit.AddOption(\"Add Color Parameter\", \"palette\", AddColorParameter);\r\n\r\n var view = MenuBar.AddMenu(\"View\");\r\n view.AboutToShow += () => OnViewMenu(view);\r\n }\r\n\r\n private void OnViewMenu(Menu view)\r\n {\r\n view.Clear();\r\n //view.AddOption(\"Restore To Default\", \"settings_backup_restore\", RestoreDefaultDockLayout);\r\n view.AddSeparator();\r\n\r\n foreach (var dock in DockManager.DockTypes)\r\n {\r\n var o = view.AddOption(dock.Title, dock.Icon);\r\n o.Checkable = true;\r\n o.Checked = DockManager.IsDockOpen(dock.Title);\r\n o.Toggled += (b) => DockManager.SetDockState(dock.Title, b);\r\n }\r\n }\r\n\r\n // protected override void RestoreDefaultDockLayout()\r\n // {\r\n //DockManager.State = _defaultDockState;\r\n // SaveToStateCookie();\r\n //}\r\n\r\n private void AddFloatParameter()\r\n {\r\n if (_workingCopy == null) return;\r\n \r\n var param = new FloatParameter\r\n {\r\n Name = $\"FloatParam{_workingCopy.FloatParameters.Count + 1}\",\r\n DefaultValue = 1.0f\r\n };\r\n \r\n _workingCopy.FloatParameters.Add(param);\r\n _properties?.ShowSystemProperties(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n private void AddVectorParameter()\r\n {\r\n if (_workingCopy == null) return;\r\n \r\n var param = new VectorParameter\r\n {\r\n Name = $\"VectorParam{_workingCopy.VectorParameters.Count + 1}\",\r\n DefaultValue = Vector3.One\r\n };\r\n \r\n _workingCopy.VectorParameters.Add(param);\r\n _properties?.ShowSystemProperties(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n private void AddColorParameter()\r\n {\r\n if (_workingCopy == null) return;\r\n \r\n var param = new ColorParameter\r\n {\r\n Name = $\"ColorParam{_workingCopy.ColorParameters.Count + 1}\",\r\n DefaultValue = Color.White\r\n };\r\n \r\n _workingCopy.ColorParameters.Add(param);\r\n _properties?.ShowSystemProperties(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n public void AssetOpen(Asset asset)\r\n {\r\n _asset = asset;\r\n _resource = asset.LoadResource<ParticleResource>();\r\n\r\n if (_resource == null)\r\n {\r\n _resource = new ParticleResource();\r\n var defaultEmitter = new ParticleEmitter { Name = \"Emitter 1\" };\r\n AddDefaultModules(defaultEmitter);\r\n _resource.Emitters.Add(defaultEmitter);\r\n }\r\n\r\n // Create a deep copy for editing\r\n _workingCopy = DeepCopyResource(_resource);\r\n\r\n Title = $\"FXBox - {asset.Name}\";\r\n LoadResource();\r\n Focus();\r\n }\r\n\r\n private ParticleResource DeepCopyResource(ParticleResource source)\r\n {\r\n if (source == null) return null;\r\n\r\n var json = source.Serialize().ToJsonString();\r\n var copy = new ParticleResource();\r\n copy.Deserialize(Json.ParseToJsonObject(json));\r\n copy.IsDirty = false;\r\n \r\n return copy;\r\n }\r\n\r\n private void LoadResource()\r\n {\r\n CurrentEditingResource = _workingCopy;\r\n \r\n _emitterList?.SetResource(_workingCopy);\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n _properties?.ShowSystemProperties(_workingCopy);\r\n _isDirty = false;\r\n if (_asset != null)\r\n _asset.HasUnsavedChanges = false;\r\n }\r\n\r\n [Shortcut(\"editor.new\", \"CTRL+N\", ShortcutType.Window)]\r\n private void New()\r\n {\r\n PromptSave(() => CreateNew());\r\n }\r\n\r\n private void CreateNew()\r\n {\r\n _asset = null;\r\n _resource = new ParticleResource();\r\n var defaultEmitter = new ParticleEmitter { Name = \"Emitter 1\" };\r\n AddDefaultModules(defaultEmitter);\r\n _resource.Emitters.Add(defaultEmitter);\r\n \r\n _workingCopy = DeepCopyResource(_resource);\r\n \r\n Title = \"FXBox - Untitled\";\r\n LoadResource();\r\n }\r\n\r\n [Shortcut(\"editor.open\", \"CTRL+O\", ShortcutType.Window)]\r\n private void Open()\r\n {\r\n var fd = new FileDialog(null)\r\n {\r\n Title = \"Open Particle System\",\r\n DefaultSuffix = \".fx\"\r\n };\r\n\r\n fd.SetNameFilter(\"Particle System (*.fx)\");\r\n\r\n if (!fd.Execute())\r\n return;\r\n\r\n PromptSave(() => OpenFile(fd.SelectedFile));\r\n }\r\n\r\n private void OpenFile(string path)\r\n {\r\n var asset = AssetSystem.FindByPath(path);\r\n if (asset != null)\r\n {\r\n AssetOpen(asset);\r\n }\r\n }\r\n\r\n private void AddEmitter()\r\n {\r\n if (_workingCopy == null) return;\r\n \r\n var emitter = new ParticleEmitter \r\n { \r\n Name = $\"Emitter {_workingCopy.Emitters.Count + 1}\"\r\n };\r\n\r\n AddDefaultModules(emitter);\r\n _workingCopy.Emitters.Add(emitter);\r\n \r\n _emitterList?.SetResource(_workingCopy);\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n private void AddDefaultModules(ParticleEmitter emitter)\r\n {\r\n emitter.SpawnModules.Add(new SpawnRateModule { Name = \"Spawn Rate\" });\r\n emitter.InitializeModules.Add(new InitializePositionModule { Name = \"Initialize Position\" });\r\n emitter.InitializeModules.Add(new InitializeLocalSpaceModule { Name = \"Initialize Local Space\" });\r\n emitter.InitializeModules.Add(new InitializeVelocityModule { Name = \"Initialize Velocity\" });\r\n emitter.InitializeModules.Add(new InitializeLifetimeModule { Name = \"Initialize Lifetime\" });\r\n emitter.InitializeModules.Add(new InitializeSizeModule { Name = \"Initialize Size\" });\r\n emitter.InitializeModules.Add(new InitializeColorModule { Name = \"Initialize Color\" });\r\n emitter.UpdateModules.Add(new GravityForceModule { Name = \"Gravity\" });\r\n emitter.UpdateModules.Add(new DragForceModule { Name = \"Drag\" });\r\n emitter.RenderModules.Add(new SpriteRendererModule { Name = \"Sprite Renderer\" });\r\n }\r\n\r\n private void OnAddModule(ParticleEmitter emitter, ModuleStage stage)\r\n {\r\n var menu = new Menu(this);\r\n\r\n var moduleTypes = EditorTypeLibrary.GetTypes<ParticleModule>()\r\n .Where(t => !t.IsAbstract)\r\n .Select(t => t.TargetType)\r\n .Where(t => {\r\n var instance = System.Activator.CreateInstance(t) as ParticleModule;\r\n return instance?.Stage == stage;\r\n });\r\n\r\n foreach (var moduleType in moduleTypes.OrderBy(t => t.Name))\r\n {\r\n var displayInfo = DisplayInfo.ForType(moduleType);\r\n menu.AddOption(displayInfo.Name, displayInfo.Icon ?? \"extension\", () => {\r\n var module = System.Activator.CreateInstance(moduleType) as ParticleModule;\r\n if (module != null)\r\n {\r\n module.Name = displayInfo.Name;\r\n \r\n var targetList = stage switch\r\n {\r\n ModuleStage.Spawn => emitter.SpawnModules,\r\n ModuleStage.Initialize => emitter.InitializeModules,\r\n ModuleStage.Update => emitter.UpdateModules,\r\n ModuleStage.Render => emitter.RenderModules,\r\n _ => null\r\n };\r\n\r\n targetList?.Add(module);\r\n _emitterList?.SetResource(_workingCopy);\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n MarkDirty();\r\n }\r\n });\r\n }\r\n\r\n menu.OpenAtCursor();\r\n }\r\n\r\n private void OnSelectionChanged(object target)\r\n {\r\n if (target is ParticleResource resource)\r\n {\r\n _properties?.ShowSystemProperties(resource);\r\n }\r\n else if (target is ParticleEmitter emitter)\r\n {\r\n _properties?.ShowEmitterProperties(emitter);\r\n }\r\n else if (target is ParticleModule module)\r\n {\r\n _properties?.ShowModuleProperties(module);\r\n }\r\n }\r\n\r\n private void OnEmitterDeleted(ParticleEmitter emitter)\r\n {\r\n _workingCopy.Emitters.Remove(emitter);\r\n _emitterList?.SetResource(_workingCopy);\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n _properties?.ShowSystemProperties(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n private void OnModuleDeleted(ParticleModule module)\r\n {\r\n foreach (var emitter in _workingCopy.Emitters)\r\n {\r\n emitter.SpawnModules.Remove(module);\r\n emitter.InitializeModules.Remove(module);\r\n emitter.UpdateModules.Remove(module);\r\n emitter.RenderModules.Remove(module);\r\n }\r\n\r\n _emitterList?.SetResource(_workingCopy);\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n private void OnPropertyChanged()\r\n {\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n MarkDirty();\r\n }\r\n\r\n private void MarkDirty()\r\n {\r\n _preview?.LoadParticleSystem(_workingCopy);\r\n \r\n if (!_isDirty)\r\n {\r\n if (_workingCopy != null)\r\n _workingCopy.IsDirty = true;\r\n if (_resource != null)\r\n _resource.IsDirty = true;\r\n \r\n _isDirty = true;\r\n \r\n if (_asset != null)\r\n _asset.HasUnsavedChanges = true;\r\n \r\n Title = $\"FXBox - {_asset?.Name ?? \"Untitled\"}*\";\r\n }\r\n }\r\n\r\n [Shortcut(\"editor.save\", \"CTRL+S\", ShortcutType.Window)]\r\n private void Save()\r\n {\r\n if (_asset != null && _workingCopy != null)\r\n {\r\n _workingCopy.IsDirty = false;\r\n _workingCopy.Version++;\r\n var json = _workingCopy.Serialize().ToJsonString();\r\n System.IO.File.WriteAllText(_asset.AbsolutePath, json);\r\n \r\n _resource = DeepCopyResource(_workingCopy);\r\n _resource.IsDirty = false;\r\n \r\n _isDirty = false;\r\n _asset.HasUnsavedChanges = false;\r\n \r\n Title = $\"FXBox - {_asset.Name}\";\r\n Log.Info($\"Saved: {_asset.Name}\");\r\n }\r\n else if (_workingCopy != null)\r\n {\r\n // No asset yet, prompt for save location\r\n SaveAs();\r\n }\r\n }\r\n\r\n private void SaveAs()\r\n {\r\n var fd = new FileDialog(null)\r\n {\r\n Title = \"Save Particle System\",\r\n DefaultSuffix = \".fx\"\r\n };\r\n\r\n fd.SelectFile(\"untitled.fx\");\r\n fd.SetFindFile();\r\n fd.SetModeSave();\r\n fd.SetNameFilter(\"Particle System (*.fx)\");\r\n \r\n if (!fd.Execute())\r\n return;\r\n\r\n var savePath = fd.SelectedFile;\r\n \r\n _workingCopy.IsDirty = false;\r\n _workingCopy.Version++;\r\n var json = _workingCopy.Serialize().ToJsonString();\r\n System.IO.File.WriteAllText(savePath, json);\r\n \r\n _asset = AssetSystem.RegisterFile(savePath);\r\n _resource = DeepCopyResource(_workingCopy);\r\n _resource.IsDirty = false;\r\n \r\n _isDirty = false;\r\n if (_asset != null)\r\n _asset.HasUnsavedChanges = false;\r\n \r\n Title = $\"FXBox - {_asset.Name}\";\r\n Log.Info($\"Saved: {_asset.Name}\");\r\n }\r\n\r\n \r\n private void OnModuleDuplicated(ParticleModule module, ModuleStage stage)\r\n {\r\n\t if (_workingCopy == null) return;\r\n\r\n\t foreach (var emitter in _workingCopy.Emitters)\r\n\t {\r\n\t\t var moduleList = stage switch\r\n\t\t {\r\n\t\t\t ModuleStage.Spawn => emitter.SpawnModules,\r\n\t\t\t ModuleStage.Initialize => emitter.InitializeModules,\r\n\t\t\t ModuleStage.Update => emitter.UpdateModules,\r\n\t\t\t ModuleStage.Render => emitter.RenderModules,\r\n\t\t\t _ => null\r\n\t\t };\r\n\r\n\t\t if (moduleList == null) continue;\r\n\r\n\t\t var index = moduleList.IndexOf(module);\r\n\t\t if (index == -1) continue;\r\n\r\n\t\t // Round-trip the whole resource; the module will be at the same\r\n\t\t // emitter index + stage index in the cloned copy.\r\n\t\t var emitterIndex = _workingCopy.Emitters.IndexOf(emitter);\r\n\t\t var resourceCopy = DeepCopyResource(_workingCopy);\r\n\r\n\t\t var clonedList = stage switch\r\n\t\t {\r\n\t\t\t ModuleStage.Spawn => resourceCopy.Emitters[emitterIndex].SpawnModules,\r\n\t\t\t ModuleStage.Initialize => resourceCopy.Emitters[emitterIndex].InitializeModules,\r\n\t\t\t ModuleStage.Update => resourceCopy.Emitters[emitterIndex].UpdateModules,\r\n\t\t\t ModuleStage.Render => resourceCopy.Emitters[emitterIndex].RenderModules,\r\n\t\t\t _ => null\r\n\t\t };\r\n\r\n\t\t if (clonedList == null) break;\r\n\r\n\t\t var clone = clonedList[index];\r\n\t\t clone.Name = $\"{module.Name} (Copy)\";\r\n\t\t moduleList.Insert(index + 1, clone);\r\n\r\n\t\t break; // modules are unique instances, no need to keep iterating\r\n\t }\r\n\r\n\t _emitterList?.SetResource(_workingCopy);\r\n\t _preview?.LoadParticleSystem(_workingCopy);\r\n\t MarkDirty();\r\n }\r\n\r\n private void PromptSave(System.Action action)\r\n {\r\n if (!_isDirty)\r\n {\r\n action?.Invoke();\r\n return;\r\n }\r\n\r\n var confirm = new PopupWindow(\r\n \"Save Current Particle System\", \r\n \"The particle system has unsaved changes. Would you like to save now?\", \r\n \"Cancel\",\r\n new Dictionary<string, System.Action>()\r\n {\r\n { \"No\", () => { _isDirty = false; action?.Invoke(); } },\r\n { \"Yes\", () => { Save(); if (!_isDirty) action?.Invoke(); } }\r\n }\r\n );\r\n\r\n confirm.Show();\r\n }\r\n\r\n public void SelectMember(string memberName) { }\r\n\r\n protected override bool OnClose()\r\n {\r\n CurrentEditingResource = null;\r\n \r\n if (_isDirty)\r\n {\r\n PromptSave(() => { _isDirty = false; Close(); });\r\n return false;\r\n }\r\n\r\n return base.OnClose();\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Left panel showing emitters and their modules in a tree structure\r\n/// </summary>\r\npublic class EmitterList : Widget\r\n{\r\n private TreeView _tree;\r\n private ParticleResource _resource;\r\n\r\n public System.Action<object> OnSelectionChanged;\r\n public System.Action<ParticleEmitter> OnEmitterDeleted;\r\n public System.Action<ParticleModule> OnModuleDeleted;\r\n public System.Action<ParticleEmitter, ModuleStage> OnAddModule;\r\n public System.Action<ParticleEmitter> OnEmitterDuplicated;\r\n public System.Action<ParticleModule, ModuleStage> OnModuleDuplicated;\r\n public System.Action OnSystemChanged;\r\n \r\n public EmitterList(Widget parent) : base(parent)\r\n {\r\n Layout = Layout.Column();\r\n Layout.Spacing = 0;\r\n\r\n // Header\r\n var header = new Widget(this);\r\n header.Layout = Layout.Row();\r\n header.Layout.Spacing = 8;\r\n header.Layout.Margin = 8;\r\n header.MinimumHeight = 32;\r\n header.SetStyles(\"background-color: #1e1e1e;\");\r\n\r\n var label = new Label(\"Emitters & Modules\", header);\r\n label.SetStyles(\"font-weight: bold; font-size: 14px;\");\r\n header.Layout.Add(label, 1);\r\n\r\n Layout.Add(header);\r\n\r\n _tree = new TreeView(this);\r\n _tree.AcceptDrops = true;\r\n _tree.ItemClicked = OnItemActivated;\r\n _tree.ItemContextMenu = OnItemContextMenu;\r\n _tree.ItemSelected = OnTreeMousePress;\r\n Layout.Add(_tree, 1);\r\n }\r\n\r\n private void OnTreeMousePress(object item)\r\n {\r\n TryHandleStageButtonClick(item);\r\n }\r\n\r\n private bool TryHandleStageButtonClick(object item)\r\n {\r\n if (item is not StageNode stageNode)\r\n return false;\r\n\r\n if (!_tree.TryGetItemRect(stageNode, out var itemRect))\r\n return false;\r\n\r\n var buttonRect = new Rect(itemRect.Right - 24, itemRect.Top, 24, itemRect.Height);\r\n \r\n if (itemRect.IsInside(buttonRect))\r\n {\r\n OnAddModule?.Invoke(stageNode.Emitter, stageNode.Stage);\r\n return true;\r\n }\r\n\r\n return false;\r\n }\r\n\r\n public void SetResource(ParticleResource resource)\r\n {\r\n _resource = resource;\r\n RebuildTree();\r\n }\r\n\r\n private void RebuildTree()\r\n {\r\n _tree.Clear();\r\n\r\n if (_resource == null) return;\r\n\r\n var systemNode = new SystemNode(_resource);\r\n _tree.AddItem(systemNode);\r\n _tree.Open(systemNode);\r\n\r\n foreach (var emitter in _resource.Emitters)\r\n {\r\n var emitterNode = new EmitterNode(emitter);\r\n systemNode.AddItem(emitterNode);\r\n _tree.Open(emitterNode);\r\n\r\n AddStageNode(emitterNode, \"Spawn\", ModuleStage.Spawn, emitter.SpawnModules, emitter);\r\n AddStageNode(emitterNode, \"Initialize\", ModuleStage.Initialize, emitter.InitializeModules, emitter);\r\n AddStageNode(emitterNode, \"Update\", ModuleStage.Update, emitter.UpdateModules, emitter);\r\n AddStageNode(emitterNode, \"Render\", ModuleStage.Render, emitter.RenderModules, emitter);\r\n }\r\n }\r\n\r\n private void AddStageNode(TreeNode parent, string name, ModuleStage stage, List<ParticleModule> modules, ParticleEmitter emitter)\r\n {\r\n var stageNode = new StageNode(name, stage, emitter, modules.Count);\r\n parent.AddItem(stageNode);\r\n _tree.Open(stageNode);\r\n\r\n foreach (var module in modules)\r\n {\r\n var moduleNode = new ModuleNode(module, stage);\r\n stageNode.AddItem(moduleNode);\r\n }\r\n }\r\n\r\n private void OnItemActivated(object item)\r\n {\r\n var selected = item;\r\n if (selected == null) return;\r\n \r\n if (selected is SystemNode systemNode)\r\n OnSelectionChanged?.Invoke(systemNode.Resource);\r\n else if (selected is EmitterNode emitterNode)\r\n OnSelectionChanged?.Invoke(emitterNode.Emitter);\r\n else if (selected is ModuleNode moduleNode)\r\n OnSelectionChanged?.Invoke(moduleNode.Module);\r\n }\r\n\r\n private void OnItemContextMenu(object item)\r\n {\r\n var selected = item;\r\n if (selected == null) return;\r\n\r\n var menu = new Menu(this);\r\n\r\n if (selected is EmitterNode emitterNode)\r\n {\r\n\t menu.AddOption(\"Duplicate\", \"content_copy\", () => OnEmitterDuplicated?.Invoke(emitterNode.Emitter));\r\n\t menu.AddSeparator();\r\n\t menu.AddOption(\"Delete Emitter\", \"delete\", () => OnEmitterDeleted?.Invoke(emitterNode.Emitter));\r\n }\r\n else if (selected is ModuleNode moduleNode)\r\n {\r\n\t var module = moduleNode.Module;\r\n\r\n\t menu.AddOption(\"Duplicate\", \"content_copy\", () => OnModuleDuplicated?.Invoke(module, moduleNode.Stage));\r\n\r\n\t menu.AddOption(module.Enabled ? \"Disable\" : \"Enable\",\r\n\t\t module.Enabled ? \"visibility_off\" : \"visibility\",\r\n\t\t () => {\r\n\t\t\t module.Enabled = !module.Enabled;\r\n\t\t\t OnSystemChanged?.Invoke();\r\n\t\t\t RebuildTree();\r\n\t\t });\r\n\r\n\t menu.AddSeparator();\r\n\t menu.AddOption(\"Delete Module\", \"delete\", () => OnModuleDeleted?.Invoke(module));\r\n }\r\n else if (selected is StageNode stageNode)\r\n {\r\n menu.AddOption(\"Add Module\", \"add\", () => OnAddModule?.Invoke(stageNode.Emitter, stageNode.Stage));\r\n }\r\n\r\n menu.OpenAtCursor();\r\n }\r\n\r\n // TreeNode classes\r\n private class SystemNode : TreeNode\r\n {\r\n public ParticleResource Resource { get; }\r\n \r\n public SystemNode(ParticleResource resource)\r\n {\r\n Resource = resource;\r\n }\r\n\r\n public override void OnPaint(VirtualWidget item)\r\n {\r\n PaintSelection(item);\r\n \r\n var rect = item.Rect.Shrink(4, 2);\r\n Paint.SetDefaultFont();\r\n Paint.SetPen(Theme.Text);\r\n Paint.DrawIcon(rect, \"auto_awesome\", 16, TextFlag.LeftCenter);\r\n \r\n rect.Left += 24;\r\n Paint.DrawText(rect, \"Particle System\", TextFlag.LeftCenter);\r\n }\r\n }\r\n\r\n private class EmitterNode : TreeNode\r\n {\r\n public ParticleEmitter Emitter { get; }\r\n \r\n public EmitterNode(ParticleEmitter emitter)\r\n {\r\n Emitter = emitter;\r\n }\r\n\r\n public override void OnPaint(VirtualWidget item)\r\n {\r\n PaintSelection(item);\r\n \r\n var rect = item.Rect.Shrink(4, 2);\r\n Paint.SetDefaultFont();\r\n Paint.SetPen(Theme.Green);\r\n Paint.DrawText(rect, \"\u25cf \", TextFlag.LeftCenter);\r\n \r\n rect.Left += 20;\r\n Paint.SetPen(Theme.Text);\r\n Paint.DrawText(rect, Emitter.Name, TextFlag.LeftCenter);\r\n }\r\n }\r\n\r\n private class StageNode : TreeNode\r\n {\r\n public new string Name { get; }\r\n public ModuleStage Stage { get; }\r\n public ParticleEmitter Emitter { get; }\r\n public int Count { get; }\r\n \r\n public StageNode(string name, ModuleStage stage, ParticleEmitter emitter, int count)\r\n {\r\n Name = name;\r\n Stage = stage;\r\n Emitter = emitter;\r\n Count = count;\r\n }\r\n\r\n public override void OnPaint(VirtualWidget item)\r\n {\r\n PaintSelection(item);\r\n \r\n var rect = item.Rect.Shrink(4, 2);\r\n Paint.SetDefaultFont();\r\n \r\n var stageColor = Stage switch\r\n {\r\n ModuleStage.Spawn => new Color(1f, 0.6f, 0.2f),\r\n ModuleStage.Initialize => new Color(0.3f, 0.8f, 0.3f),\r\n ModuleStage.Update => new Color(0.3f, 0.6f, 1f),\r\n ModuleStage.Render => new Color(0.9f, 0.3f, 0.9f),\r\n _ => Theme.Text\r\n };\r\n \r\n Paint.SetPen(stageColor);\r\n Paint.DrawText(rect, $\"{Name} ({Count})\", TextFlag.LeftCenter);\r\n \r\n var buttonRect = new Rect(rect.Right - 24, rect.Top, 24, rect.Height);\r\n \r\n if (buttonRect.IsInside(item.Rect))\r\n {\r\n Paint.ClearPen();\r\n Paint.SetBrush(Theme.ControlBackground.Lighten(0.2f));\r\n Paint.DrawRect(buttonRect.Shrink(2), 2);\r\n }\r\n \r\n Paint.SetPen(stageColor);\r\n Paint.DrawIcon(buttonRect, \"add\", 16, TextFlag.Center);\r\n \r\n if (item.Dropping)\r\n {\r\n Paint.ClearPen();\r\n Paint.SetBrush(Theme.Blue.WithAlpha(0.2f));\r\n Paint.DrawRect(item.Rect, 2);\r\n }\r\n }\r\n\r\n public override DropAction OnDragDrop(BaseItemWidget.ItemDragEvent e)\r\n {\r\n if (e.Data.Object is not ModuleNode draggedNode)\r\n return DropAction.Ignore;\r\n\r\n var draggedModule = draggedNode.Module;\r\n\r\n if (draggedNode.Stage != Stage)\r\n return DropAction.Ignore;\r\n\r\n if (e.IsDrop)\r\n {\r\n var targetList = Stage switch\r\n {\r\n ModuleStage.Spawn => Emitter.SpawnModules,\r\n ModuleStage.Initialize => Emitter.InitializeModules,\r\n ModuleStage.Update => Emitter.UpdateModules,\r\n ModuleStage.Render => Emitter.RenderModules,\r\n _ => null\r\n };\r\n \r\n if (targetList == null) return DropAction.Ignore;\r\n\r\n targetList.Remove(draggedModule);\r\n targetList.Add(draggedModule);\r\n \r\n if (TreeView.Parent is EmitterList list)\r\n {\r\n list.OnSystemChanged?.Invoke();\r\n list.RebuildTree();\r\n }\r\n }\r\n\r\n return DropAction.Move;\r\n }\r\n }\r\n\r\n private class ModuleNode : TreeNode\r\n {\r\n public ParticleModule Module { get; }\r\n public ModuleStage Stage { get; }\r\n \r\n public ModuleNode(ParticleModule module, ModuleStage stage)\r\n {\r\n Module = module;\r\n Stage = stage;\r\n }\r\n\r\n public override void OnPaint(VirtualWidget item)\r\n {\r\n PaintSelection(item);\r\n \r\n var displayInfo = DisplayInfo.ForType(Module.GetType());\r\n var rect = item.Rect.Shrink(4, 2);\r\n \r\n Paint.SetDefaultFont();\r\n Paint.SetPen(Module.Enabled ? Theme.Text : Theme.Text.WithAlpha(0.5f));\r\n \r\n if (!string.IsNullOrEmpty(displayInfo.Icon))\r\n {\r\n Paint.DrawIcon(rect, displayInfo.Icon, 16, TextFlag.LeftCenter);\r\n rect.Left += 24;\r\n }\r\n \r\n Paint.DrawText(rect, Module.Name ?? displayInfo.Name, TextFlag.LeftCenter);\r\n \r\n if (item.Dropping)\r\n {\r\n Paint.ClearPen();\r\n Paint.SetBrush(Theme.Blue.WithAlpha(0.2f));\r\n \r\n if (TreeView.CurrentItemDragEvent.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Top))\r\n {\r\n var droprect = item.Rect;\r\n droprect.Top -= 1;\r\n droprect.Height = 2;\r\n Paint.DrawRect(droprect, 2);\r\n }\r\n else if (TreeView.CurrentItemDragEvent.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Bottom))\r\n {\r\n var droprect = item.Rect;\r\n droprect.Top = droprect.Bottom - 1;\r\n droprect.Height = 2;\r\n Paint.DrawRect(droprect, 2);\r\n }\r\n else\r\n {\r\n Paint.DrawRect(item.Rect, 2);\r\n }\r\n }\r\n }\r\n\r\n public override bool OnDragStart()\r\n {\r\n var drag = new Drag(TreeView);\r\n drag.Data.Object = this;\r\n drag.Execute();\r\n return true;\r\n }\r\n\r\n public override DropAction OnDragDrop(BaseItemWidget.ItemDragEvent e)\r\n {\r\n if (e.Data.Object is not ModuleNode draggedNode)\r\n return DropAction.Ignore;\r\n\r\n var draggedModule = draggedNode.Module;\r\n var targetModule = Module;\r\n\r\n if (draggedNode.Stage != Stage)\r\n return DropAction.Ignore;\r\n\r\n var emitterList = TreeView.Parent as EmitterList;\r\n if (emitterList?._resource == null) return DropAction.Ignore;\r\n\r\n foreach (var emitter in emitterList._resource.Emitters)\r\n {\r\n var moduleList = Stage switch\r\n {\r\n ModuleStage.Spawn => emitter.SpawnModules,\r\n ModuleStage.Initialize => emitter.InitializeModules,\r\n ModuleStage.Update => emitter.UpdateModules,\r\n ModuleStage.Render => emitter.RenderModules,\r\n _ => null\r\n };\r\n\r\n if (moduleList == null) continue;\r\n\r\n var draggedIndex = moduleList.IndexOf(draggedModule);\r\n var targetIndex = moduleList.IndexOf(targetModule);\r\n\r\n if (draggedIndex == -1 || targetIndex == -1) continue;\r\n\r\n if (e.IsDrop)\r\n {\r\n moduleList.RemoveAt(draggedIndex);\r\n \r\n if (draggedIndex < targetIndex)\r\n targetIndex--;\r\n \r\n if (e.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Top))\r\n {\r\n moduleList.Insert(targetIndex, draggedModule);\r\n }\r\n else if (e.DropEdge.HasFlag(BaseItemWidget.ItemEdge.Bottom))\r\n {\r\n moduleList.Insert(targetIndex + 1, draggedModule);\r\n }\r\n else\r\n {\r\n moduleList.Insert(targetIndex, draggedModule);\r\n }\r\n\r\n emitterList.OnSystemChanged?.Invoke();\r\n emitterList.RebuildTree();\r\n }\r\n \r\n return DropAction.Move;\r\n }\r\n\r\n return DropAction.Ignore;\r\n }\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Right panel showing properties - ShaderGraph style\r\n/// </summary>\r\npublic class PropertiesWidget : Widget\r\n{\r\n private Label _titleLabel;\r\n private Layout _contentLayout;\r\n private object _currentTarget;\r\n private SerializedObject _currentSerializedObject;\r\n\r\n public System.Action OnPropertyChanged;\r\n\r\n public PropertiesWidget(Widget parent) : base(parent)\r\n {\r\n Layout = Layout.Column();\r\n Layout.Spacing = 0;\r\n\r\n var header = new Widget(this);\r\n header.Layout = Layout.Column();\r\n header.Layout.Margin = 8;\r\n header.MinimumHeight = 32;\r\n header.SetStyles(\"background-color: #1e1e1e;\");\r\n\r\n _titleLabel = new Label(\"Properties\", header);\r\n _titleLabel.SetStyles(\"font-weight: bold; font-size: 14px;\");\r\n header.Layout.Add(_titleLabel);\r\n\r\n Layout.Add(header);\r\n\r\n _contentLayout = Layout.AddColumn(1);\r\n }\r\n\r\n public void ShowSystemProperties(ParticleResource resource)\r\n {\r\n _currentTarget = resource;\r\n _titleLabel.Text = \"System Properties\";\r\n \r\n RebuildContent(() => {\r\n if (resource != null)\r\n {\r\n var so = resource.GetSerialized();\r\n so.OnPropertyChanged += OnSerializedPropertyChanged;\r\n return so;\r\n }\r\n return null;\r\n });\r\n }\r\n\r\n public void ShowEmitterProperties(ParticleEmitter emitter)\r\n {\r\n _currentTarget = emitter;\r\n _titleLabel.Text = $\"Emitter: {emitter.Name}\";\r\n \r\n RebuildContent(() => {\r\n if (emitter != null)\r\n {\r\n var so = emitter.GetSerialized();\r\n so.OnPropertyChanged += OnSerializedPropertyChanged;\r\n return so;\r\n }\r\n return null;\r\n });\r\n }\r\n\r\n public void ShowModuleProperties(ParticleModule module)\r\n {\r\n _currentTarget = module;\r\n \r\n var displayInfo = DisplayInfo.ForType(module.GetType());\r\n _titleLabel.Text = displayInfo.Name;\r\n \r\n RebuildContent(() => {\r\n if (module != null)\r\n {\r\n var so = module.GetSerialized();\r\n so.OnPropertyChanged += OnSerializedPropertyChanged;\r\n return so;\r\n }\r\n return null;\r\n });\r\n }\r\n\r\n private void OnSerializedPropertyChanged(SerializedProperty property)\r\n {\r\n if (_currentSerializedObject != null && _currentTarget != null)\r\n {\r\n _currentSerializedObject.NoteFinishEdit(property);\r\n }\r\n \r\n OnPropertyChanged?.Invoke();\r\n }\r\n\r\n private void RebuildContent(System.Func<SerializedObject> getSerializedObject)\r\n {\r\n _contentLayout.Clear(true);\r\n\r\n if (_currentSerializedObject != null)\r\n {\r\n _currentSerializedObject.OnPropertyChanged -= OnSerializedPropertyChanged;\r\n }\r\n\r\n var scroll = new ScrollArea(this);\r\n scroll.Canvas = new Widget(scroll);\r\n scroll.Canvas.Layout = Layout.Column();\r\n scroll.Canvas.Layout.Margin = 8;\r\n scroll.Canvas.Layout.Spacing = 4;\r\n scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;\r\n scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;\r\n\r\n var so = getSerializedObject();\r\n if (so != null)\r\n {\r\n _currentSerializedObject = so;\r\n \r\n var sheet = new ControlSheet();\r\n sheet.AddObject(so);\r\n \r\n scroll.Canvas.Layout.Add(sheet);\r\n scroll.Canvas.Layout.AddStretchCell();\r\n }\r\n else\r\n {\r\n _currentSerializedObject = null;\r\n }\r\n\r\n _contentLayout.Add(scroll);\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "UnitTests/LibraryTest.cs",
"FileName": "LibraryTest.cs",
"PackageType": "library",
"CodeKind": "UnitTest",
"AssetVersionId": 379493,
"Code": "using Sandbox;\r\n\r\n[TestClass]\r\npublic partial class LibraryTests\r\n{\r\n\t[TestMethod]\r\n\tpublic void SceneTest()\r\n\t{\r\n\t\tvar scene = new Scene();\r\n\t\tusing ( scene.Push() )\r\n\t\t{\r\n\t\t\tvar go = new GameObject();\r\n\r\n\t\t\tAssert.AreEqual( 1, scene.Directory.GameObjectCount );\r\n\t\t}\r\n\t}\r\n\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "FXParameter.cs",
"FileName": "FXParameter.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// A named float parameter that can be used to control particle values\r\n/// </summary>\r\npublic class FloatParameter\r\n{\r\n\t[Property] public string Name { get; set; } = \"Parameter\";\r\n\t[Property] public float DefaultValue { get; set; } = 1.0f;\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n}\r\n\r\n/// <summary>\r\n/// A named vector parameter that can be used to control particle values\r\n/// </summary>\r\npublic class VectorParameter\r\n{\r\n\t[Property] public string Name { get; set; } = \"VectorParameter\";\r\n\t[Property] public Vector3 DefaultValue { get; set; } = Vector3.One;\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n}\r\n\r\n/// <summary>\r\n/// A named color parameter that can be used to control particle values\r\n/// </summary>\r\npublic class ColorParameter\r\n{\r\n\t[Property] public string Name { get; set; } = \"ColorParameter\";\r\n\t[Property] public ParticleGradient DefaultValue { get; set; } = Color.White;\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "ParticleResource.cs",
"FileName": "ParticleResource.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.ComponentModel;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// Particle system resource containing multiple emitters\r\n/// </summary>\r\n[AssetType(Name = \"Particle System\", Extension = \"fx\", Category = \"FX\", Flags = AssetTypeFlags.NoEmbedding)]\r\npublic class ParticleResource : GameResource\r\n{\r\n public bool IsDirty { get; set; } = false;\r\n \r\n /// <summary>\r\n /// All emitters in this particle system\r\n /// </summary>\r\n public List<ParticleEmitter> Emitters { get; set; } = new();\r\n\r\n /// <summary>\r\n /// Named float parameters\r\n /// </summary>\r\n [InlineEditor, DisplayName(\"FloatParameters\")] public List<FloatParameter> FloatParameters { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Named vector parameters\r\n /// </summary>\r\n [InlineEditor] public List<VectorParameter> VectorParameters { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Named color parameters\r\n /// </summary>\r\n [InlineEditor] public List<ColorParameter> ColorParameters { get; set; } = new();\r\n\r\n /// <summary>\r\n /// Global system properties\r\n /// </summary>\r\n public float Duration { get; set; } = 5.0f;\r\n public bool Looping { get; set; } = true;\r\n\r\n public int Version { get; set; } = 0;\r\n \r\n /// <summary>\r\n /// Preview settings for the editor\r\n /// </summary>\r\n public ParticlePreviewSettings PreviewSettings { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Get a float parameter's default value by name\r\n /// </summary>\r\n public float GetParameterDefault(string name)\r\n {\r\n var param = FloatParameters.FirstOrDefault(p => p.Name == name);\r\n return param?.DefaultValue ?? 0f;\r\n }\r\n \r\n /// <summary>\r\n /// Get a vector parameter's default value by name\r\n /// </summary>\r\n public Vector3 GetVectorParameterDefault(string name)\r\n {\r\n var param = VectorParameters.FirstOrDefault(p => p.Name == name);\r\n return param?.DefaultValue ?? Vector3.Zero;\r\n }\r\n \r\n /// <summary>\r\n /// Get a color parameter's default value by name\r\n /// </summary>\r\n public ParticleGradient GetColorParameterDefault(string name)\r\n {\r\n var param = ColorParameters.FirstOrDefault(p => p.Name == name);\r\n return param?.DefaultValue ?? Color.White;\r\n }\r\n \r\n /// <summary>\r\n /// Add a new float parameter\r\n /// </summary>\r\n public FloatParameter AddParameter(string name, float defaultValue = 1.0f)\r\n {\r\n var param = new FloatParameter\r\n {\r\n Name = name,\r\n DefaultValue = defaultValue\r\n };\r\n FloatParameters.Add(param);\r\n return param;\r\n }\r\n \r\n /// <summary>\r\n /// Add a new vector parameter\r\n /// </summary>\r\n public VectorParameter AddVectorParameter(string name, Vector3 defaultValue)\r\n {\r\n var param = new VectorParameter\r\n {\r\n Name = name,\r\n DefaultValue = defaultValue\r\n };\r\n VectorParameters.Add(param);\r\n return param;\r\n }\r\n \r\n /// <summary>\r\n /// Add a new color parameter\r\n /// </summary>\r\n public ColorParameter AddColorParameter(string name, Color defaultValue)\r\n {\r\n var param = new ColorParameter\r\n {\r\n Name = name,\r\n DefaultValue = defaultValue\r\n };\r\n ColorParameters.Add(param);\r\n return param;\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Preview settings for the particle editor\r\n/// </summary>\r\npublic class ParticlePreviewSettings\r\n{\r\n public bool ShowGround { get; set; } = true;\r\n public bool ShowGrid { get; set; } = true;\r\n public Color BackgroundColor { get; set; } = new Color(0.1f, 0.1f, 0.15f);\r\n public float PlaybackSpeed { get; set; } = 1.0f;\r\n}\r\n/// <summary>\r\n/// A single particle emitter with its own spawn and update logic\r\n/// </summary>\r\npublic class ParticleEmitter\r\n{\r\n\tpublic string Name { get; set; } = \"Emitter\";\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n\tpublic bool Enabled { get; set; } = true;\r\n\tpublic int MaxParticles { get; set; } = 1000;\r\n\r\n\t/// <summary>\r\n\t/// Seconds to wait before this emitter starts spawning - lets one emitter in the same\r\n\t/// system kick off a few seconds after another. Maps straight onto the native\r\n\t/// ParticleEffect.StartDelay, so it's handled by the engine's own particle system rather\r\n\t/// than anything FXBox has to gate itself.\r\n\t/// </summary>\r\n\tpublic float Delay { get; set; } = 0f;\r\n\r\n\t/// <summary>\r\n\t/// Overrides the system's own Duration for deciding when THIS emitter is finished\r\n\t/// (see FXBoxParticleController.IsFinished) - 0 means \"use the particle system's own\r\n\t/// Duration instead\", the same as before this existed. Lets one emitter in a system run\r\n\t/// longer or shorter than the rest without touching the system-wide Duration.\r\n\t/// </summary>\r\n\tpublic float Duration { get; set; } = 0f;\r\n\r\n\t/// <summary>\r\n\t/// Modules that run when spawning particles\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> SpawnModules { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Modules that run once when a particle is created\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> InitializeModules { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Modules that run every frame for each particle\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> UpdateModules { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Modules that control how particles are rendered\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> RenderModules { get; set; } = new();\r\n}\r\n/// <summary>\r\n/// Base class for all particle modules\r\n/// </summary>\r\npublic abstract class ParticleModule\r\n{\r\n [Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n [Hide] public string Name { get; set; }\r\n [Hide] public bool Enabled { get; set; } = true;\r\n\r\n /// <summary>\r\n /// What stage this module belongs to\r\n /// </summary>\r\n [JsonIgnore]\r\n public abstract ModuleStage Stage { get; }\r\n\r\n /// <summary>\r\n /// Execute this module\r\n /// </summary>\r\n public abstract void Execute(ParticleExecutionContext context);\r\n\r\n public abstract void Initialize( ParticleExecutionContext context );\r\n}\r\n\r\n/// <summary>\r\n/// JSON converter for List of ParticleModule\r\n/// </summary>\r\npublic class ParticleModuleListConverter : JsonConverter<List<ParticleModule>>\r\n{\r\n public override List<ParticleModule> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\r\n {\r\n var list = new List<ParticleModule>();\r\n \r\n if (reader.TokenType != JsonTokenType.StartArray)\r\n throw new JsonException(\"Expected start of array\");\r\n \r\n while (reader.Read())\r\n {\r\n if (reader.TokenType == JsonTokenType.EndArray)\r\n break;\r\n \r\n using (var doc = JsonDocument.ParseValue(ref reader))\r\n {\r\n var root = doc.RootElement;\r\n \r\n // Get the type name\r\n if (!root.TryGetProperty(\"$type\", out var typeProperty))\r\n {\r\n Log.Warning(\"Missing $type property for ParticleModule\");\r\n continue;\r\n }\r\n \r\n var typeName = typeProperty.GetString();\r\n var type = TypeLibrary.GetType(typeName)?.TargetType;\r\n \r\n if (type == null)\r\n {\r\n Log.Warning($\"Unknown module type: {typeName}\");\r\n continue;\r\n }\r\n \r\n // Deserialize to the specific type\r\n var json = root.GetRawText();\r\n var module = (ParticleModule)JsonSerializer.Deserialize(json, type, options);\r\n if (module != null)\r\n {\r\n list.Add(module);\r\n }\r\n }\r\n }\r\n \r\n return list;\r\n }\r\n\r\n public override void Write(Utf8JsonWriter writer, List<ParticleModule> value, JsonSerializerOptions options)\r\n {\r\n writer.WriteStartArray();\r\n \r\n foreach (var module in value)\r\n {\r\n if (module == null) continue;\r\n \r\n writer.WriteStartObject();\r\n \r\n // Write the type information\r\n writer.WriteString(\"$type\", module.GetType().FullName);\r\n \r\n // Serialize the module\r\n var json = JsonSerializer.Serialize(module, module.GetType(), options);\r\n using (var doc = JsonDocument.Parse(json))\r\n {\r\n foreach (var property in doc.RootElement.EnumerateObject())\r\n {\r\n property.WriteTo(writer);\r\n }\r\n }\r\n \r\n writer.WriteEndObject();\r\n }\r\n \r\n writer.WriteEndArray();\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Code/FxboxParticleSystem.cs",
"FileName": "FxboxParticleSystem.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Runtime.CompilerServices;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// Component that integrates FXBox particle systems wit s&box native particle components\r\n/// </summary>\r\npublic class FXBoxNativeParticleSystem : Component, Component.ExecuteInEditor, ResourceLibrary.IEventListener, Component.ITemporaryEffect\r\n{\r\n [Property] public ParticleResource ParticleSystem { get; set; }\r\n \r\n [Property, Hide] private List<GameObject> Emitters { get; set; } = new();\r\n \r\n [Property] public bool PlayOnStart { get; set; } = true;\r\n [Property] public bool DestroyOnEnd { get; set; }\r\n\r\n public Vector3 Velocity;\r\n \r\n /// <summary>\r\n /// Instance-specific float parameter overrides\r\n /// </summary>\r\n [Property, Group(\"Parameters\"), Title(\"Float Parameters\")]\r\n public Dictionary<string, float> ParameterOverrides { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Instance-specific vector parameter overrides\r\n /// </summary>\r\n [Property, Group(\"Parameters\"), Title(\"Vector Parameters\")]\r\n public Dictionary<string, Vector3> VectorParameterOverrides { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Instance-specific color parameter overrides\r\n /// </summary>\r\n [Property, Group(\"Parameters\"), Title(\"Color Parameters\")]\r\n public Dictionary<string, ParticleGradient> ColorParameterOverrides { get; set; } = new();\r\n private int _version = 0;\r\n\r\n private Vector3 _lastPosition;\r\n\r\n protected override void OnFixedUpdate()\r\n {\r\n\t Velocity = (WorldPosition - _lastPosition);\r\n\t _lastPosition = WorldPosition;\r\n if (ParticleSystem != null && ParticleSystem.Version != _version)\r\n {\r\n _version = ParticleSystem.Version;\r\n UpdateEmitters();\r\n }\r\n\r\n // Editor previews always loop once every emitter is done, regardless of the\r\n // resource's own Looping setting - same convention TracerEffect uses (\"Editor\r\n // previews loop instead\"). This used to happen by accident: SpawnBurstModule.Execute\r\n // re-firing every tick meant a \"finished\" burst kept re-triggering itself forever in\r\n // the editor. Now that it's fixed to only fire once (see SpawnBurstModule), a\r\n // finished one-shot system just stays finished with nothing to restart it - so that\r\n // restart needs to be explicit here instead. IsActive is only checked once ALL\r\n // emitters are done (not per-emitter), matching \"loop the particles after all of\r\n // them are done emitting\" - restarting one on its own the moment IT finishes would\r\n // desync multiple emitters from each other on every subsequent loop.\r\n if ( Scene.IsEditor && !IsActive )\r\n {\r\n RestartAllEmitters();\r\n }\r\n }\r\n\r\n private void RestartAllEmitters()\r\n {\r\n foreach ( var emitter in Emitters )\r\n {\r\n if ( !emitter.IsValid() ) continue;\r\n\r\n emitter.GetComponent<FXBoxParticleController>()?.ResetTimer();\r\n\r\n // Same mechanism FXBoxNativeParticleSystem.Burst() already uses to manually\r\n // re-fire an emitter - re-evaluates StartDelay/Burst/Rate from scratch.\r\n foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>() )\r\n {\r\n particleEmitter.ResetEmitter();\r\n }\r\n }\r\n }\r\n \r\n // ==================== FLOAT PARAMETERS ====================\r\n \r\n /// <summary>\r\n /// Set a float parameter value for this specific instance\r\n /// </summary>\r\n public void Set(string name, float value)\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n var param = ParticleSystem.FloatParameters?.FirstOrDefault(p => p.Name == name);\r\n if (param == null)\r\n {\r\n //Log.Warning($\"Float parameter '{name}' not found in particle system\");\r\n return;\r\n }\r\n \r\n ParameterOverrides[name] = value;\r\n UpdateParameterValues();\r\n }\r\n\r\n public void Burst()\r\n {\r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>( ) )\r\n\t\t {\r\n\t\t\t var target = particleEmitter.GetComponent<ParticleEffect>();\r\n\t\t\t particleEmitter.ResetEmitter();\r\n\t\t }\r\n\t }\r\n }\r\n\r\n public void Burst( Vector3 worldPosition )\r\n {\r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>( ) )\r\n\t\t {\r\n\t\t\t var target = particleEmitter.GetComponent<ParticleEffect>();\r\n\t\t\t for(int i=0; i< particleEmitter.Burst.Evaluate( Time.Delta, Time.Delta ); i++)\r\n\t\t\t {\r\n\t\t\t\t particleEmitter.Emit( target );\r\n\t\t\t }\r\n\t\t }\r\n\t }\r\n\t \r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t foreach ( var particleEffect in emitter.GetComponentsInChildren<Sandbox.ParticleEffect>( ) )\r\n\t\t {\r\n\t\t\t foreach ( var particle in particleEffect.Particles )\r\n\t\t\t {\r\n\t\t\t\t if ( particle.Age <= 0.01f )\r\n\t\t\t\t {\r\n\t\t\t\t\t\tparticle.Position += worldPosition - particleEffect.WorldTransform.Position;\r\n\t\t\t\t }\r\n\r\n\t\t\t }\r\n\t\t }\r\n\t }\r\n }\r\n \r\n /// <summary>\r\n /// Get a float parameter value (override or default)\r\n /// </summary>\r\n public float GetFloatParameter(string name)\r\n {\r\n if (ParameterOverrides.TryGetValue(name, out float overrideValue))\r\n {\r\n return overrideValue;\r\n }\r\n \r\n return ParticleSystem?.GetParameterDefault(name) ?? 0f;\r\n }\r\n \r\n /// <summary>\r\n /// Reset a float parameter to its default value\r\n /// </summary>\r\n public void ResetParameter(string name)\r\n {\r\n ParameterOverrides.Remove(name);\r\n UpdateParameterValues();\r\n }\r\n \r\n // ==================== VECTOR PARAMETERS ====================\r\n \r\n /// <summary>\r\n /// Set a vector parameter value for this specific instance\r\n /// </summary>\r\n public void Set(string name, Vector3 value)\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n var param = ParticleSystem.VectorParameters?.FirstOrDefault(p => p.Name == name);\r\n if (param == null)\r\n {\r\n //Log.Warning($\"Vector parameter '{name}' not found in particle system\");\r\n return;\r\n }\r\n \r\n VectorParameterOverrides[name] = value;\r\n UpdateParameterValues();\r\n }\r\n \r\n public int GetAliveParticles()\r\n {\r\n\t var particles = 0;\r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t var target = emitter.GetComponent<ParticleEffect>();\r\n\t\t particles += target.ParticleCount;\r\n\t }\r\n\r\n\t return particles;\r\n }\r\n /// <summary>\r\n /// Get a vector parameter value (override or default)\r\n /// </summary>\r\n public Vector3 GetVectorParameter(string name)\r\n {\r\n if (VectorParameterOverrides.TryGetValue(name, out Vector3 overrideValue))\r\n {\r\n return overrideValue;\r\n }\r\n \r\n return ParticleSystem?.GetVectorParameterDefault(name) ?? Vector3.Zero;\r\n }\r\n \r\n /// <summary>\r\n /// Reset a vector parameter to its default value\r\n /// </summary>\r\n public void ResetVectorParameter(string name)\r\n {\r\n VectorParameterOverrides.Remove(name);\r\n UpdateParameterValues();\r\n }\r\n \r\n // ==================== COLOR PARAMETERS ====================\r\n \r\n /// <summary>\r\n /// Set a color parameter value for this specific instance\r\n /// </summary>\r\n public void Set(string name, Color value)\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n var param = ParticleSystem.ColorParameters?.FirstOrDefault(p => p.Name == name);\r\n if (param == null)\r\n {\r\n //Log.Warning($\"Color parameter '{name}' not found in particle system\");\r\n return;\r\n }\r\n \r\n ColorParameterOverrides[name] = value;\r\n UpdateParameterValues();\r\n }\r\n \r\n /// <summary>\r\n /// Get a color parameter value (override or default)\r\n /// </summary>\r\n public ParticleGradient GetColorParameter(string name)\r\n {\r\n if (ColorParameterOverrides.TryGetValue(name, out ParticleGradient overrideValue))\r\n {\r\n return overrideValue;\r\n }\r\n \r\n return ParticleSystem?.GetColorParameterDefault(name) ?? Color.White;\r\n }\r\n \r\n /// <summary>\r\n /// Reset a color parameter to its default value\r\n /// </summary>\r\n public void ResetColorParameter(string name)\r\n {\r\n ColorParameterOverrides.Remove(name);\r\n UpdateParameterValues();\r\n }\r\n \r\n // ==================== GENERAL ====================\r\n \r\n /// <summary>\r\n /// Reset all parameters to their default values\r\n /// </summary>\r\n public void ResetAllParameters()\r\n {\r\n ParameterOverrides.Clear();\r\n VectorParameterOverrides.Clear();\r\n ColorParameterOverrides.Clear();\r\n UpdateParameterValues();\r\n }\r\n \r\n /// <summary>\r\n /// Helper buttons for the inspector\r\n /// </summary>\r\n [Button(\"Reset All Parameters\")]\r\n [Group(\"Parameters\")]\r\n public void ResetAllParametersButton()\r\n {\r\n ResetAllParameters();\r\n }\r\n \r\n [Button(\"Initialize All Parameters\")]\r\n [Group(\"Parameters\")]\r\n [Description(\"Copy all parameters from the resource as overrides\")]\r\n public void InitializeParametersFromResource()\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n ParameterOverrides.Clear();\r\n if (ParticleSystem.FloatParameters != null)\r\n {\r\n foreach (var param in ParticleSystem.FloatParameters)\r\n {\r\n ParameterOverrides[param.Name] = param.DefaultValue;\r\n }\r\n }\r\n \r\n VectorParameterOverrides.Clear();\r\n if (ParticleSystem.VectorParameters != null)\r\n {\r\n foreach (var param in ParticleSystem.VectorParameters)\r\n {\r\n VectorParameterOverrides[param.Name] = param.DefaultValue;\r\n }\r\n }\r\n \r\n ColorParameterOverrides.Clear();\r\n if (ParticleSystem.ColorParameters != null)\r\n {\r\n foreach (var param in ParticleSystem.ColorParameters)\r\n {\r\n ColorParameterOverrides[param.Name] = param.DefaultValue;\r\n }\r\n }\r\n }\r\n \r\n /// <summary>\r\n /// Update parameter values without rebuilding emitters\r\n /// </summary>\r\n private void UpdateParameterValues()\r\n {\r\n if (ParticleSystem?.Emitters == null) return;\r\n \r\n foreach (var emitterObject in Emitters)\r\n {\r\n if (!emitterObject.IsValid()) continue;\r\n \r\n var controller = emitterObject.GetComponent<FXBoxParticleController>();\r\n if (controller != null)\r\n {\r\n UpdateModulesWithParameters(emitterObject, controller.EmitterData);\r\n }\r\n }\r\n }\r\n \r\n public void OnSave(GameResource resource)\r\n {\r\n Log.Info(\"The scene has stopped\");\r\n }\r\n \r\n protected override void OnStart()\r\n {\r\n UpdateEmitters();\r\n\r\n // TemporaryEffect is what actually destroys this GameObject once IsActive (below)\r\n // goes false - without one present, nothing ever would, since the per-emitter\r\n // controllers no longer destroy the root themselves (see FXBoxParticleController.\r\n // OnUpdate). DestroyAfterSeconds is 0 since the wait for \"actually finished\" is\r\n // already handled by WaitForChildEffects walking into our own IsActive below, not by\r\n // this timer - only DestroyOnEnd opts in at all, and editor previews never destroy\r\n // themselves (they loop, same as TracerEffect's editor behavior).\r\n if ( DestroyOnEnd && !Scene.IsEditor )\r\n {\r\n var temporaryEffect = GetOrAddComponent<TemporaryEffect>();\r\n temporaryEffect.DestroyAfterSeconds = 0f;\r\n temporaryEffect.WaitForChildEffects = true;\r\n }\r\n }\r\n\r\n protected override void OnEnabled()\r\n {\r\n UpdateEmitters();\r\n base.OnEnabled();\r\n }\r\n\r\n protected override void DrawGizmos()\r\n {\r\n\t Gizmo.Hitbox.Sprite( 0, 50, false );\r\n\t if ( Gizmo.IsHovered || Gizmo.IsSelected)\r\n\t {\r\n\t\t Gizmo.Draw.Color = Color.White;\r\n\t\t if ( Gizmo.IsSelected )\r\n\t\t {\r\n\t\t\t Gizmo.Draw.Color = Color.Yellow;\r\n\t\t }\r\n\t }\r\n\t else\r\n\t {\r\n\t\t Gizmo.Draw.Color = Color.Gray;\r\n\t }\r\n\t \r\n\t Gizmo.Draw.Sprite( 0, 50, Texture.Load( \"images/particlehover.vtex\" ), false ); \r\n }\r\n\r\n public void UpdateEmitters()\r\n {\r\n // Clean up existing emitters\r\n foreach (var emitter in Emitters)\r\n {\r\n emitter?.DestroyImmediate();\r\n }\r\n Emitters.Clear();\r\n\r\n if (ParticleSystem?.Emitters == null) return;\r\n\r\n // Create emitters from ParticleResource\r\n foreach (var emitterData in ParticleSystem.Emitters)\r\n {\r\n if (!emitterData.Enabled) continue;\r\n\r\n var emitterObject = new GameObject(GameObject);\r\n emitterObject.Flags = emitterObject.Flags.WithFlag( GameObjectFlags.Hidden, true );\r\n emitterObject.Name = emitterData.Name;\r\n Emitters.Add(emitterObject);\r\n\r\n // Add ParticleEffect component\r\n var particleEffect = emitterObject.GetOrAddComponent<ParticleEffect>();\r\n particleEffect.MaxParticles = emitterData.MaxParticles;\r\n particleEffect.StartDelay = emitterData.Delay;\r\n\r\n // Create native components from modules\r\n CreateModuleComponents(emitterObject, emitterData);\r\n\r\n // Add controller to handle particle updates\r\n var controller = emitterObject.GetOrAddComponent<FXBoxParticleController>();\r\n controller.EmitterData = emitterData;\r\n controller.ParticleEffect = particleEffect;\r\n controller.InitializeModules = emitterData.InitializeModules;\r\n controller.ParticleSystemComponent = this;\r\n controller.ParticleEffect.ResetEmitters();\r\n \r\n }\r\n }\r\n\r\n private void CreateModuleComponents(GameObject go, ParticleEmitter emitterData)\r\n {\r\n // Create components from all modules that implement IParticleComponentCreator\r\n var allModules = emitterData.SpawnModules\r\n .Concat(emitterData.InitializeModules)\r\n .Concat(emitterData.UpdateModules)\r\n .Concat(emitterData.RenderModules);\r\n\r\n var particleModules = allModules.ToList();\r\n foreach (var module in particleModules.OfType<IParticleComponentCreator>())\r\n {\r\n if (module is ParticleModule pm && pm.Enabled)\r\n {\r\n module.CreateComponent(go);\r\n }\r\n }\r\n\r\n var context = new ParticleExecutionContext();\r\n context.Effect = go.GetComponent<ParticleEffect>();\r\n context.Emitter = go.GetComponent<Sandbox.ParticleEmitter>();\r\n context.SystemComponent = this;\r\n\r\n foreach (var module in particleModules)\r\n {\r\n module.Initialize(context);\r\n }\r\n\r\n // emitterData.Duration (0 by default) overrides the system-wide Duration for THIS\r\n // emitter, same as FXBoxParticleController.EffectiveDuration - kept in sync so the\r\n // native emitter's own duration behavior matches what our controller thinks it does.\r\n var effectiveDuration = emitterData.Duration > 0f ? emitterData.Duration : ParticleSystem.Duration;\r\n\r\n // Ensure we have at least a basic emitter if none was created\r\n if (!go.GetComponent<Sandbox.ParticleEmitter>().IsValid())\r\n {\r\n var emitter = go.AddComponent<ParticleSphereEmitter>();\r\n emitter.Duration = effectiveDuration;\r\n emitter.Loop = ParticleSystem.Looping;\r\n emitter.DestroyOnEnd = DestroyOnEnd;\r\n }\r\n\r\n var emitters = go.GetComponentsInChildren<Sandbox.ParticleEmitter>();\r\n foreach ( var emit in emitters )\r\n {\r\n\t emit.Duration = effectiveDuration;\r\n\t emit.Loop = ParticleSystem.Looping;\r\n\t emit.DestroyOnEnd = DestroyOnEnd;\r\n }\r\n }\r\n \r\n private void UpdateModulesWithParameters(GameObject go, ParticleEmitter emitterData)\r\n {\r\n var context = new ParticleExecutionContext();\r\n context.Effect = go.GetComponent<ParticleEffect>();\r\n context.Emitter = go.GetComponent<Sandbox.ParticleEmitter>();\r\n context.SystemComponent = this;\r\n\r\n var allModules = emitterData.SpawnModules\r\n .Concat(emitterData.InitializeModules)\r\n .Concat(emitterData.UpdateModules)\r\n .Concat(emitterData.RenderModules);\r\n\r\n foreach (var module in allModules)\r\n {\r\n module.Initialize(context);\r\n }\r\n }\r\n \r\n protected override void OnDisabled()\r\n {\r\n\t // Clean up existing emitters\r\n\t foreach (var emitter in Emitters)\r\n\t {\r\n\t\t emitter?.Destroy();\r\n\t }\r\n\t \r\n\t Emitters.Clear();\r\n\t base.OnDisabled();\r\n }\r\n\r\n // ITemporaryEffect.IsActive - computed live from the child emitters rather than a flag\r\n // someone has to remember to flip, so it can never go stale relative to what's actually\r\n // still emitting/alive. True as soon as ANY emitter hasn't finished yet (see\r\n // FXBoxParticleController.IsFinished); false only once every one of them has.\r\n public bool IsActive\r\n {\r\n get\r\n {\r\n foreach ( var emitter in Emitters )\r\n {\r\n if ( !emitter.IsValid() ) continue;\r\n\r\n var controller = emitter.GetComponent<FXBoxParticleController>();\r\n if ( controller.IsValid() && !controller.IsFinished )\r\n return true;\r\n }\r\n\r\n return false;\r\n }\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Controller that executes particle update modules on each particle\r\n/// </summary>\r\npublic class FXBoxParticleController : ParticleController\r\n{\r\n [Property, Hide] public ParticleEmitter EmitterData { get; set; }\r\n [Property, Hide] public new ParticleEffect ParticleEffect { get; set; }\r\n [Property, Hide] public FXBoxNativeParticleSystem ParticleSystemComponent { get; set; }\r\n private TimeSince _timeSinceCreated = 0;\r\n public List<ParticleModule> InitializeModules { get; set; } = new List<ParticleModule>();\r\n\r\n // This emitter is done - past its duration, not looping, and nothing left alive.\r\n // FXBoxNativeParticleSystem.IsActive (the ITemporaryEffect this whole system exposes)\r\n // is true as long as ANY emitter's controller reports false here; a TemporaryEffect on\r\n // the root is what actually destroys things once every one of them finally does, rather\r\n // than this controller destroying the root itself the moment ITS OWN emitter finishes -\r\n // that was the bug: a multi-emitter system got torn down as soon as the FIRST emitter\r\n // to finish was done, not once every emitter actually was.\r\n //\r\n // EmitterData.Duration (0 by default) overrides the system-wide Duration for THIS\r\n // emitter specifically - lets one emitter run longer/shorter than the rest of the\r\n // system without changing anything system-wide.\r\n private float EffectiveDuration => EmitterData != null && EmitterData.Duration > 0f\r\n\t ? EmitterData.Duration\r\n\t : (ParticleSystemComponent?.ParticleSystem?.Duration ?? 0f);\r\n\r\n public bool IsFinished =>\r\n\t _timeSinceCreated > EffectiveDuration\r\n\t && !(ParticleSystemComponent?.ParticleSystem?.Looping ?? false)\r\n\t && ParticleEffect.Particles.Count <= 0;\r\n\r\n // Called by FXBoxNativeParticleSystem.RestartAllEmitters (editor-only looping of a\r\n // non-looping system, once every emitter's finished) alongside the native\r\n // ParticleEmitter.ResetEmitter() call - IsFinished depends on _timeSinceCreated, so\r\n // without also resetting this, it would immediately re-evaluate as finished again next\r\n // tick regardless of the native emitter actually having restarted.\r\n public void ResetTimer() => _timeSinceCreated = 0;\r\n\r\n protected override void OnUpdate()\r\n {\r\n\t var context = new ParticleExecutionContext\r\n\t {\r\n\t\t Particle = null,\r\n\t\t Effect = ParticleEffect,\r\n\t\t Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),\r\n\t\t SystemComponent = ParticleSystemComponent\r\n\t };\r\n\t foreach ( var init in EmitterData.InitializeModules )\r\n\t {\r\n\t\t init.Execute( context );\r\n\t }\r\n\t foreach ( var spawn in EmitterData.SpawnModules )\r\n\t {\r\n\t\t spawn.Execute( context );\r\n\t }\r\n }\r\n\r\n protected override void OnParticleStep(Particle particle, float delta)\r\n {\r\n base.OnParticleStep(particle, delta);\r\n\r\n if (EmitterData == null) return;\r\n \r\n var context = new ParticleExecutionContext\r\n {\r\n Particle = particle,\r\n Effect = ParticleEffect,\r\n Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),\r\n SystemComponent = ParticleSystemComponent\r\n };\r\n \r\n // Execute all update modules that implement IParticleUpdater\r\n foreach (var module in EmitterData.UpdateModules.OfType<IParticleUpdater>())\r\n {\r\n if (module is ParticleModule pm && pm.Enabled)\r\n {\r\n module.UpdateParticle(context, delta);\r\n }\r\n }\r\n }\r\n \r\n protected override void OnParticleCreated(Particle p)\r\n {\r\n p.Position = ParticleEffect.WorldTransform.Position;\r\n InitializeModules ??= EmitterData?.InitializeModules ?? new List<ParticleModule>();\r\n \r\n var context = new ParticleExecutionContext\r\n {\r\n Particle = p,\r\n Effect = ParticleEffect,\r\n Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),\r\n SystemComponent = ParticleSystemComponent\r\n };\r\n \r\n foreach (var module in InitializeModules)\r\n {\r\n module.Initialize(context);\r\n }\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Interface for modules that can create native components\r\n/// </summary>\r\npublic interface IParticleComponentCreator\r\n{\r\n void CreateComponent(GameObject go);\r\n}\r\n\r\n/// <summary>\r\n/// Interface for modules that update particles\r\n/// </summary>\r\npublic interface IParticleUpdater\r\n{\r\n void UpdateParticle(ParticleExecutionContext particle, float delta);\r\n}\r\n\r\n[Flags]\r\npublic enum FXCopyFlags\r\n{\r\n\tRotation = 1,\r\n\tScale = 2,\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "FxboxParticleSystem.cs",
"FileName": "FxboxParticleSystem.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Runtime.CompilerServices;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// Component that integrates FXBox particle systems wit s&box native particle components\r\n/// </summary>\r\npublic class FXBoxNativeParticleSystem : Component, Component.ExecuteInEditor, ResourceLibrary.IEventListener, Component.ITemporaryEffect\r\n{\r\n [Property] public ParticleResource ParticleSystem { get; set; }\r\n \r\n [Property, Hide] private List<GameObject> Emitters { get; set; } = new();\r\n \r\n [Property] public bool PlayOnStart { get; set; } = true;\r\n [Property] public bool DestroyOnEnd { get; set; }\r\n\r\n public Vector3 Velocity;\r\n \r\n /// <summary>\r\n /// Instance-specific float parameter overrides\r\n /// </summary>\r\n [Property, Group(\"Parameters\"), Title(\"Float Parameters\")]\r\n public Dictionary<string, float> ParameterOverrides { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Instance-specific vector parameter overrides\r\n /// </summary>\r\n [Property, Group(\"Parameters\"), Title(\"Vector Parameters\")]\r\n public Dictionary<string, Vector3> VectorParameterOverrides { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Instance-specific color parameter overrides\r\n /// </summary>\r\n [Property, Group(\"Parameters\"), Title(\"Color Parameters\")]\r\n public Dictionary<string, ParticleGradient> ColorParameterOverrides { get; set; } = new();\r\n private int _version = 0;\r\n\r\n private Vector3 _lastPosition;\r\n\r\n protected override void OnFixedUpdate()\r\n {\r\n\t Velocity = (WorldPosition - _lastPosition);\r\n\t _lastPosition = WorldPosition;\r\n if (ParticleSystem != null && ParticleSystem.Version != _version)\r\n {\r\n _version = ParticleSystem.Version;\r\n UpdateEmitters();\r\n }\r\n\r\n // Editor previews always loop once every emitter is done, regardless of the\r\n // resource's own Looping setting - same convention TracerEffect uses (\"Editor\r\n // previews loop instead\"). This used to happen by accident: SpawnBurstModule.Execute\r\n // re-firing every tick meant a \"finished\" burst kept re-triggering itself forever in\r\n // the editor. Now that it's fixed to only fire once (see SpawnBurstModule), a\r\n // finished one-shot system just stays finished with nothing to restart it - so that\r\n // restart needs to be explicit here instead. IsActive is only checked once ALL\r\n // emitters are done (not per-emitter), matching \"loop the particles after all of\r\n // them are done emitting\" - restarting one on its own the moment IT finishes would\r\n // desync multiple emitters from each other on every subsequent loop.\r\n if ( Scene.IsEditor && !IsActive )\r\n {\r\n RestartAllEmitters();\r\n }\r\n }\r\n\r\n private void RestartAllEmitters()\r\n {\r\n foreach ( var emitter in Emitters )\r\n {\r\n if ( !emitter.IsValid() ) continue;\r\n\r\n emitter.GetComponent<FXBoxParticleController>()?.ResetTimer();\r\n\r\n // Same mechanism FXBoxNativeParticleSystem.Burst() already uses to manually\r\n // re-fire an emitter - re-evaluates StartDelay/Burst/Rate from scratch.\r\n foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>() )\r\n {\r\n particleEmitter.ResetEmitter();\r\n }\r\n }\r\n }\r\n \r\n // ==================== FLOAT PARAMETERS ====================\r\n \r\n /// <summary>\r\n /// Set a float parameter value for this specific instance\r\n /// </summary>\r\n public void Set(string name, float value)\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n var param = ParticleSystem.FloatParameters?.FirstOrDefault(p => p.Name == name);\r\n if (param == null)\r\n {\r\n //Log.Warning($\"Float parameter '{name}' not found in particle system\");\r\n return;\r\n }\r\n \r\n ParameterOverrides[name] = value;\r\n UpdateParameterValues();\r\n }\r\n\r\n public void Burst()\r\n {\r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>( ) )\r\n\t\t {\r\n\t\t\t var target = particleEmitter.GetComponent<ParticleEffect>();\r\n\t\t\t particleEmitter.ResetEmitter();\r\n\t\t }\r\n\t }\r\n }\r\n\r\n public void Burst( Vector3 worldPosition )\r\n {\r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t foreach ( var particleEmitter in emitter.GetComponentsInChildren<Sandbox.ParticleEmitter>( ) )\r\n\t\t {\r\n\t\t\t var target = particleEmitter.GetComponent<ParticleEffect>();\r\n\t\t\t for(int i=0; i< particleEmitter.Burst.Evaluate( Time.Delta, Time.Delta ); i++)\r\n\t\t\t {\r\n\t\t\t\t particleEmitter.Emit( target );\r\n\t\t\t }\r\n\t\t }\r\n\t }\r\n\t \r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t foreach ( var particleEffect in emitter.GetComponentsInChildren<Sandbox.ParticleEffect>( ) )\r\n\t\t {\r\n\t\t\t foreach ( var particle in particleEffect.Particles )\r\n\t\t\t {\r\n\t\t\t\t if ( particle.Age <= 0.01f )\r\n\t\t\t\t {\r\n\t\t\t\t\t\tparticle.Position += worldPosition - particleEffect.WorldTransform.Position;\r\n\t\t\t\t }\r\n\r\n\t\t\t }\r\n\t\t }\r\n\t }\r\n }\r\n \r\n /// <summary>\r\n /// Get a float parameter value (override or default)\r\n /// </summary>\r\n public float GetFloatParameter(string name)\r\n {\r\n if (ParameterOverrides.TryGetValue(name, out float overrideValue))\r\n {\r\n return overrideValue;\r\n }\r\n \r\n return ParticleSystem?.GetParameterDefault(name) ?? 0f;\r\n }\r\n \r\n /// <summary>\r\n /// Reset a float parameter to its default value\r\n /// </summary>\r\n public void ResetParameter(string name)\r\n {\r\n ParameterOverrides.Remove(name);\r\n UpdateParameterValues();\r\n }\r\n \r\n // ==================== VECTOR PARAMETERS ====================\r\n \r\n /// <summary>\r\n /// Set a vector parameter value for this specific instance\r\n /// </summary>\r\n public void Set(string name, Vector3 value)\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n var param = ParticleSystem.VectorParameters?.FirstOrDefault(p => p.Name == name);\r\n if (param == null)\r\n {\r\n //Log.Warning($\"Vector parameter '{name}' not found in particle system\");\r\n return;\r\n }\r\n \r\n VectorParameterOverrides[name] = value;\r\n UpdateParameterValues();\r\n }\r\n \r\n public int GetAliveParticles()\r\n {\r\n\t var particles = 0;\r\n\t foreach ( var emitter in Emitters )\r\n\t {\r\n\t\t var target = emitter.GetComponent<ParticleEffect>();\r\n\t\t particles += target.ParticleCount;\r\n\t }\r\n\r\n\t return particles;\r\n }\r\n /// <summary>\r\n /// Get a vector parameter value (override or default)\r\n /// </summary>\r\n public Vector3 GetVectorParameter(string name)\r\n {\r\n if (VectorParameterOverrides.TryGetValue(name, out Vector3 overrideValue))\r\n {\r\n return overrideValue;\r\n }\r\n \r\n return ParticleSystem?.GetVectorParameterDefault(name) ?? Vector3.Zero;\r\n }\r\n \r\n /// <summary>\r\n /// Reset a vector parameter to its default value\r\n /// </summary>\r\n public void ResetVectorParameter(string name)\r\n {\r\n VectorParameterOverrides.Remove(name);\r\n UpdateParameterValues();\r\n }\r\n \r\n // ==================== COLOR PARAMETERS ====================\r\n \r\n /// <summary>\r\n /// Set a color parameter value for this specific instance\r\n /// </summary>\r\n public void Set(string name, Color value)\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n var param = ParticleSystem.ColorParameters?.FirstOrDefault(p => p.Name == name);\r\n if (param == null)\r\n {\r\n //Log.Warning($\"Color parameter '{name}' not found in particle system\");\r\n return;\r\n }\r\n \r\n ColorParameterOverrides[name] = value;\r\n UpdateParameterValues();\r\n }\r\n \r\n /// <summary>\r\n /// Get a color parameter value (override or default)\r\n /// </summary>\r\n public ParticleGradient GetColorParameter(string name)\r\n {\r\n if (ColorParameterOverrides.TryGetValue(name, out ParticleGradient overrideValue))\r\n {\r\n return overrideValue;\r\n }\r\n \r\n return ParticleSystem?.GetColorParameterDefault(name) ?? Color.White;\r\n }\r\n \r\n /// <summary>\r\n /// Reset a color parameter to its default value\r\n /// </summary>\r\n public void ResetColorParameter(string name)\r\n {\r\n ColorParameterOverrides.Remove(name);\r\n UpdateParameterValues();\r\n }\r\n \r\n // ==================== GENERAL ====================\r\n \r\n /// <summary>\r\n /// Reset all parameters to their default values\r\n /// </summary>\r\n public void ResetAllParameters()\r\n {\r\n ParameterOverrides.Clear();\r\n VectorParameterOverrides.Clear();\r\n ColorParameterOverrides.Clear();\r\n UpdateParameterValues();\r\n }\r\n \r\n /// <summary>\r\n /// Helper buttons for the inspector\r\n /// </summary>\r\n [Button(\"Reset All Parameters\")]\r\n [Group(\"Parameters\")]\r\n public void ResetAllParametersButton()\r\n {\r\n ResetAllParameters();\r\n }\r\n \r\n [Button(\"Initialize All Parameters\")]\r\n [Group(\"Parameters\")]\r\n [Description(\"Copy all parameters from the resource as overrides\")]\r\n public void InitializeParametersFromResource()\r\n {\r\n if (ParticleSystem == null) return;\r\n \r\n ParameterOverrides.Clear();\r\n if (ParticleSystem.FloatParameters != null)\r\n {\r\n foreach (var param in ParticleSystem.FloatParameters)\r\n {\r\n ParameterOverrides[param.Name] = param.DefaultValue;\r\n }\r\n }\r\n \r\n VectorParameterOverrides.Clear();\r\n if (ParticleSystem.VectorParameters != null)\r\n {\r\n foreach (var param in ParticleSystem.VectorParameters)\r\n {\r\n VectorParameterOverrides[param.Name] = param.DefaultValue;\r\n }\r\n }\r\n \r\n ColorParameterOverrides.Clear();\r\n if (ParticleSystem.ColorParameters != null)\r\n {\r\n foreach (var param in ParticleSystem.ColorParameters)\r\n {\r\n ColorParameterOverrides[param.Name] = param.DefaultValue;\r\n }\r\n }\r\n }\r\n \r\n /// <summary>\r\n /// Update parameter values without rebuilding emitters\r\n /// </summary>\r\n private void UpdateParameterValues()\r\n {\r\n if (ParticleSystem?.Emitters == null) return;\r\n \r\n foreach (var emitterObject in Emitters)\r\n {\r\n if (!emitterObject.IsValid()) continue;\r\n \r\n var controller = emitterObject.GetComponent<FXBoxParticleController>();\r\n if (controller != null)\r\n {\r\n UpdateModulesWithParameters(emitterObject, controller.EmitterData);\r\n }\r\n }\r\n }\r\n \r\n public void OnSave(GameResource resource)\r\n {\r\n Log.Info(\"The scene has stopped\");\r\n }\r\n \r\n protected override void OnStart()\r\n {\r\n UpdateEmitters();\r\n\r\n // TemporaryEffect is what actually destroys this GameObject once IsActive (below)\r\n // goes false - without one present, nothing ever would, since the per-emitter\r\n // controllers no longer destroy the root themselves (see FXBoxParticleController.\r\n // OnUpdate). DestroyAfterSeconds is 0 since the wait for \"actually finished\" is\r\n // already handled by WaitForChildEffects walking into our own IsActive below, not by\r\n // this timer - only DestroyOnEnd opts in at all, and editor previews never destroy\r\n // themselves (they loop, same as TracerEffect's editor behavior).\r\n if ( DestroyOnEnd && !Scene.IsEditor )\r\n {\r\n var temporaryEffect = GetOrAddComponent<TemporaryEffect>();\r\n temporaryEffect.DestroyAfterSeconds = 0f;\r\n temporaryEffect.WaitForChildEffects = true;\r\n }\r\n }\r\n\r\n protected override void OnEnabled()\r\n {\r\n UpdateEmitters();\r\n base.OnEnabled();\r\n }\r\n\r\n protected override void DrawGizmos()\r\n {\r\n\t Gizmo.Hitbox.Sprite( 0, 50, false );\r\n\t if ( Gizmo.IsHovered || Gizmo.IsSelected)\r\n\t {\r\n\t\t Gizmo.Draw.Color = Color.White;\r\n\t\t if ( Gizmo.IsSelected )\r\n\t\t {\r\n\t\t\t Gizmo.Draw.Color = Color.Yellow;\r\n\t\t }\r\n\t }\r\n\t else\r\n\t {\r\n\t\t Gizmo.Draw.Color = Color.Gray;\r\n\t }\r\n\t \r\n\t Gizmo.Draw.Sprite( 0, 50, Texture.Load( \"images/particlehover.vtex\" ), false ); \r\n }\r\n\r\n public void UpdateEmitters()\r\n {\r\n // Clean up existing emitters\r\n foreach (var emitter in Emitters)\r\n {\r\n emitter?.DestroyImmediate();\r\n }\r\n Emitters.Clear();\r\n\r\n if (ParticleSystem?.Emitters == null) return;\r\n\r\n // Create emitters from ParticleResource\r\n foreach (var emitterData in ParticleSystem.Emitters)\r\n {\r\n if (!emitterData.Enabled) continue;\r\n\r\n var emitterObject = new GameObject(GameObject);\r\n emitterObject.Flags = emitterObject.Flags.WithFlag( GameObjectFlags.Hidden, true );\r\n emitterObject.Name = emitterData.Name;\r\n Emitters.Add(emitterObject);\r\n\r\n // Add ParticleEffect component\r\n var particleEffect = emitterObject.GetOrAddComponent<ParticleEffect>();\r\n particleEffect.MaxParticles = emitterData.MaxParticles;\r\n particleEffect.StartDelay = emitterData.Delay;\r\n\r\n // Create native components from modules\r\n CreateModuleComponents(emitterObject, emitterData);\r\n\r\n // Add controller to handle particle updates\r\n var controller = emitterObject.GetOrAddComponent<FXBoxParticleController>();\r\n controller.EmitterData = emitterData;\r\n controller.ParticleEffect = particleEffect;\r\n controller.InitializeModules = emitterData.InitializeModules;\r\n controller.ParticleSystemComponent = this;\r\n controller.ParticleEffect.ResetEmitters();\r\n \r\n }\r\n }\r\n\r\n private void CreateModuleComponents(GameObject go, ParticleEmitter emitterData)\r\n {\r\n // Create components from all modules that implement IParticleComponentCreator\r\n var allModules = emitterData.SpawnModules\r\n .Concat(emitterData.InitializeModules)\r\n .Concat(emitterData.UpdateModules)\r\n .Concat(emitterData.RenderModules);\r\n\r\n var particleModules = allModules.ToList();\r\n foreach (var module in particleModules.OfType<IParticleComponentCreator>())\r\n {\r\n if (module is ParticleModule pm && pm.Enabled)\r\n {\r\n module.CreateComponent(go);\r\n }\r\n }\r\n\r\n var context = new ParticleExecutionContext();\r\n context.Effect = go.GetComponent<ParticleEffect>();\r\n context.Emitter = go.GetComponent<Sandbox.ParticleEmitter>();\r\n context.SystemComponent = this;\r\n\r\n foreach (var module in particleModules)\r\n {\r\n module.Initialize(context);\r\n }\r\n\r\n // emitterData.Duration (0 by default) overrides the system-wide Duration for THIS\r\n // emitter, same as FXBoxParticleController.EffectiveDuration - kept in sync so the\r\n // native emitter's own duration behavior matches what our controller thinks it does.\r\n var effectiveDuration = emitterData.Duration > 0f ? emitterData.Duration : ParticleSystem.Duration;\r\n\r\n // Ensure we have at least a basic emitter if none was created\r\n if (!go.GetComponent<Sandbox.ParticleEmitter>().IsValid())\r\n {\r\n var emitter = go.AddComponent<ParticleSphereEmitter>();\r\n emitter.Duration = effectiveDuration;\r\n emitter.Loop = ParticleSystem.Looping;\r\n emitter.DestroyOnEnd = DestroyOnEnd;\r\n }\r\n\r\n var emitters = go.GetComponentsInChildren<Sandbox.ParticleEmitter>();\r\n foreach ( var emit in emitters )\r\n {\r\n\t emit.Duration = effectiveDuration;\r\n\t emit.Loop = ParticleSystem.Looping;\r\n\t emit.DestroyOnEnd = DestroyOnEnd;\r\n }\r\n }\r\n \r\n private void UpdateModulesWithParameters(GameObject go, ParticleEmitter emitterData)\r\n {\r\n var context = new ParticleExecutionContext();\r\n context.Effect = go.GetComponent<ParticleEffect>();\r\n context.Emitter = go.GetComponent<Sandbox.ParticleEmitter>();\r\n context.SystemComponent = this;\r\n\r\n var allModules = emitterData.SpawnModules\r\n .Concat(emitterData.InitializeModules)\r\n .Concat(emitterData.UpdateModules)\r\n .Concat(emitterData.RenderModules);\r\n\r\n foreach (var module in allModules)\r\n {\r\n module.Initialize(context);\r\n }\r\n }\r\n \r\n protected override void OnDisabled()\r\n {\r\n\t // Clean up existing emitters\r\n\t foreach (var emitter in Emitters)\r\n\t {\r\n\t\t emitter?.Destroy();\r\n\t }\r\n\t \r\n\t Emitters.Clear();\r\n\t base.OnDisabled();\r\n }\r\n\r\n // ITemporaryEffect.IsActive - computed live from the child emitters rather than a flag\r\n // someone has to remember to flip, so it can never go stale relative to what's actually\r\n // still emitting/alive. True as soon as ANY emitter hasn't finished yet (see\r\n // FXBoxParticleController.IsFinished); false only once every one of them has.\r\n public bool IsActive\r\n {\r\n get\r\n {\r\n foreach ( var emitter in Emitters )\r\n {\r\n if ( !emitter.IsValid() ) continue;\r\n\r\n var controller = emitter.GetComponent<FXBoxParticleController>();\r\n if ( controller.IsValid() && !controller.IsFinished )\r\n return true;\r\n }\r\n\r\n return false;\r\n }\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Controller that executes particle update modules on each particle\r\n/// </summary>\r\npublic class FXBoxParticleController : ParticleController\r\n{\r\n [Property, Hide] public ParticleEmitter EmitterData { get; set; }\r\n [Property, Hide] public new ParticleEffect ParticleEffect { get; set; }\r\n [Property, Hide] public FXBoxNativeParticleSystem ParticleSystemComponent { get; set; }\r\n private TimeSince _timeSinceCreated = 0;\r\n public List<ParticleModule> InitializeModules { get; set; } = new List<ParticleModule>();\r\n\r\n // This emitter is done - past its duration, not looping, and nothing left alive.\r\n // FXBoxNativeParticleSystem.IsActive (the ITemporaryEffect this whole system exposes)\r\n // is true as long as ANY emitter's controller reports false here; a TemporaryEffect on\r\n // the root is what actually destroys things once every one of them finally does, rather\r\n // than this controller destroying the root itself the moment ITS OWN emitter finishes -\r\n // that was the bug: a multi-emitter system got torn down as soon as the FIRST emitter\r\n // to finish was done, not once every emitter actually was.\r\n //\r\n // EmitterData.Duration (0 by default) overrides the system-wide Duration for THIS\r\n // emitter specifically - lets one emitter run longer/shorter than the rest of the\r\n // system without changing anything system-wide.\r\n private float EffectiveDuration => EmitterData != null && EmitterData.Duration > 0f\r\n\t ? EmitterData.Duration\r\n\t : (ParticleSystemComponent?.ParticleSystem?.Duration ?? 0f);\r\n\r\n public bool IsFinished =>\r\n\t _timeSinceCreated > EffectiveDuration\r\n\t && !(ParticleSystemComponent?.ParticleSystem?.Looping ?? false)\r\n\t && ParticleEffect.Particles.Count <= 0;\r\n\r\n // Called by FXBoxNativeParticleSystem.RestartAllEmitters (editor-only looping of a\r\n // non-looping system, once every emitter's finished) alongside the native\r\n // ParticleEmitter.ResetEmitter() call - IsFinished depends on _timeSinceCreated, so\r\n // without also resetting this, it would immediately re-evaluate as finished again next\r\n // tick regardless of the native emitter actually having restarted.\r\n public void ResetTimer() => _timeSinceCreated = 0;\r\n\r\n protected override void OnUpdate()\r\n {\r\n\t var context = new ParticleExecutionContext\r\n\t {\r\n\t\t Particle = null,\r\n\t\t Effect = ParticleEffect,\r\n\t\t Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),\r\n\t\t SystemComponent = ParticleSystemComponent\r\n\t };\r\n\t foreach ( var init in EmitterData.InitializeModules )\r\n\t {\r\n\t\t init.Execute( context );\r\n\t }\r\n\t foreach ( var spawn in EmitterData.SpawnModules )\r\n\t {\r\n\t\t spawn.Execute( context );\r\n\t }\r\n }\r\n\r\n protected override void OnParticleStep(Particle particle, float delta)\r\n {\r\n base.OnParticleStep(particle, delta);\r\n\r\n if (EmitterData == null) return;\r\n \r\n var context = new ParticleExecutionContext\r\n {\r\n Particle = particle,\r\n Effect = ParticleEffect,\r\n Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),\r\n SystemComponent = ParticleSystemComponent\r\n };\r\n \r\n // Execute all update modules that implement IParticleUpdater\r\n foreach (var module in EmitterData.UpdateModules.OfType<IParticleUpdater>())\r\n {\r\n if (module is ParticleModule pm && pm.Enabled)\r\n {\r\n module.UpdateParticle(context, delta);\r\n }\r\n }\r\n }\r\n \r\n protected override void OnParticleCreated(Particle p)\r\n {\r\n p.Position = ParticleEffect.WorldTransform.Position;\r\n InitializeModules ??= EmitterData?.InitializeModules ?? new List<ParticleModule>();\r\n \r\n var context = new ParticleExecutionContext\r\n {\r\n Particle = p,\r\n Effect = ParticleEffect,\r\n Emitter = ParticleEffect.GetComponent<Sandbox.ParticleEmitter>(),\r\n SystemComponent = ParticleSystemComponent\r\n };\r\n \r\n foreach (var module in InitializeModules)\r\n {\r\n module.Initialize(context);\r\n }\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Interface for modules that can create native components\r\n/// </summary>\r\npublic interface IParticleComponentCreator\r\n{\r\n void CreateComponent(GameObject go);\r\n}\r\n\r\n/// <summary>\r\n/// Interface for modules that update particles\r\n/// </summary>\r\npublic interface IParticleUpdater\r\n{\r\n void UpdateParticle(ParticleExecutionContext particle, float delta);\r\n}\r\n\r\n[Flags]\r\npublic enum FXCopyFlags\r\n{\r\n\tRotation = 1,\r\n\tScale = 2,\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "ParticleValues.cs",
"FileName": "ParticleValues.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Linq;\r\n\r\nnamespace fxbox;\r\npublic class FXParticleFloat\r\n{\r\n [Property] public bool UseParameter { get; set; } = false;\r\n \r\n [Property, ShowIf(nameof(UseParameter), false)]\r\n public ParticleFloat Value { get; set; }\r\n \r\n [Property, ShowIf(nameof(UseParameter), true)]\r\n [Description(\"Select a parameter from the system\")]\r\n public string ParameterName { get; set; }\r\n \r\n [Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]\r\n [Description(\"Multiplier applied to the parameter value\")]\r\n public float Multiplier { get; set; } = 1.0f;\r\n \r\n public float GetValue(FXBoxNativeParticleSystem systemComponent = null)\r\n {\r\n\t if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n\t {\r\n\t\t return systemComponent.GetFloatParameter(ParameterName) * Multiplier;\r\n\t }\r\n\t \r\n\t var result = Value.Evaluate(Random.Shared.Float(), 3f);\r\n\t \r\n\t return result;\r\n }\r\n \r\n public ParticleFloat ToParticleFloat(FXBoxNativeParticleSystem systemComponent = null)\r\n {\r\n if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n {\r\n // Get the instance-specific value (override or default)\r\n float value = systemComponent.GetFloatParameter(ParameterName) * Multiplier;\r\n \r\n return new ParticleFloat()\r\n {\r\n Type = ParticleFloat.ValueType.Constant,\r\n ConstantValue = value,\r\n Evaluation = ParticleFloat.EvaluationType.Seed\r\n };\r\n }\r\n \r\n return Value;\r\n }\r\n \r\n // ==================== OPERATORS ====================\r\n \r\n \r\n public static FXParticleFloat operator *(float a, FXParticleFloat b)\r\n {\r\n return b * a; // Commutative\r\n }\r\n \r\n // Division operators\r\n public static FXParticleFloat operator /(FXParticleFloat a, float b)\r\n{\r\n if (a == null)\r\n {\r\n Log.Warning(\"Division: a is null\");\r\n return null;\r\n }\r\n \r\n if (b == 0 || MathF.Abs(b) < 0.0001f)\r\n {\r\n Log.Warning($\"Division by zero or very small number ({b}) in FXParticleFloat\");\r\n return a;\r\n }\r\n \r\n \r\n var result = a * (1.0f / b);\r\n \r\n return result;\r\n}\r\n\r\npublic static FXParticleFloat operator *(FXParticleFloat a, float b)\r\n{\r\n if (a == null)\r\n {\r\n Log.Warning(\"Multiplication: a is null\");\r\n return null;\r\n }\r\n \r\n var result = new FXParticleFloat();\r\n \r\n if (a.UseParameter)\r\n {\r\n result.UseParameter = true;\r\n result.ParameterName = a.ParameterName;\r\n result.Multiplier = a.Multiplier * b;\r\n result.Value = new ParticleFloat()\r\n {\r\n Type = ParticleFloat.ValueType.Constant,\r\n ConstantValue = 0f,\r\n Evaluation = ParticleFloat.EvaluationType.Seed\r\n };\r\n }\r\n else\r\n {\r\n result.UseParameter = false;\r\n result.Value = ScaleParticleFloat(a.Value, b);\r\n }\r\n \r\n return result;\r\n}\r\n\r\nprivate static ParticleFloat ScaleParticleFloat(ParticleFloat pf, float scale)\r\n{\r\n \r\n var result = new ParticleFloat();\r\n result.Type = pf.Type;\r\n result.Evaluation = pf.Evaluation;\r\n \r\n // Copy Constants FIRST, before setting individual values\r\n // (or don't copy it at all since we're setting the values manually)\r\n // result.Constants = pf.Constants;\r\n \r\n switch (pf.Type)\r\n {\r\n case ParticleFloat.ValueType.Constant:\r\n result.ConstantValue = pf.ConstantValue * scale;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Range:\r\n result.ConstantA = pf.ConstantA * scale;\r\n result.ConstantB = pf.ConstantB * scale;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Curve:\r\n result.CurveA = ScaleCurve(pf.CurveA, scale);\r\n result.CurveB = ScaleCurve(pf.CurveB, scale);\r\n break;\r\n \r\n case ParticleFloat.ValueType.CurveRange:\r\n result.CurveRange = ScaleCurveRange(pf.CurveRange, scale);\r\n break;\r\n }\r\n \r\n // DON'T copy Constants here - it overwrites our values!\r\n // result.Constants = pf.Constants;\r\n \r\n return result;\r\n}\r\n\r\nprivate static ParticleFloat OffsetParticleFloat(ParticleFloat pf, float offset)\r\n{\r\n var result = new ParticleFloat();\r\n result.Type = pf.Type;\r\n result.Evaluation = pf.Evaluation;\r\n \r\n // DON'T copy Constants - it will overwrite our values\r\n // result.Constants = pf.Constants;\r\n \r\n switch (pf.Type)\r\n {\r\n case ParticleFloat.ValueType.Constant:\r\n result.ConstantValue = pf.ConstantValue + offset;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Range:\r\n result.ConstantA = pf.ConstantA + offset;\r\n result.ConstantB = pf.ConstantB + offset;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Curve:\r\n result.CurveA = OffsetCurve(pf.CurveA, offset);\r\n result.CurveB = OffsetCurve(pf.CurveB, offset);\r\n break;\r\n \r\n case ParticleFloat.ValueType.CurveRange:\r\n result.CurveRange = OffsetCurveRange(pf.CurveRange, offset);\r\n break;\r\n }\r\n \r\n // DON'T copy Constants here!\r\n // result.Constants = pf.Constants;\r\n \r\n return result;\r\n}\r\n \r\n private static Curve ScaleCurve(Curve curve, float scale)\r\n {\r\n var newFrames = curve.Frames.Select(frame => \r\n new Curve.Frame(frame.Time, frame.Value * scale)).ToArray();\r\n \r\n return new Curve(newFrames);\r\n }\r\n \r\n private static Curve OffsetCurve(Curve curve, float offset)\r\n {\r\n var newFrames = curve.Frames.Select(frame => \r\n new Curve.Frame(frame.Time, frame.Value + offset)).ToArray();\r\n \r\n return new Curve(newFrames);\r\n }\r\n \r\n private static CurveRange ScaleCurveRange(CurveRange range, float scale)\r\n {\r\n return new CurveRange\r\n (\r\n ScaleCurve(range.A, scale),\r\n ScaleCurve(range.B, scale)\r\n );\r\n }\r\n \r\n private static CurveRange OffsetCurveRange(CurveRange range, float offset)\r\n {\r\n return new CurveRange\r\n (\r\n OffsetCurve(range.A, offset),\r\n OffsetCurve(range.B, offset)\r\n );\r\n }\r\n \r\n // ==================== IMPLICIT CONVERSIONS ====================\r\n \r\n public static implicit operator FXParticleFloat(float v)\r\n {\r\n var fxParticle = new FXParticleFloat();\r\n fxParticle.Value = new ParticleFloat()\r\n {\r\n Type = ParticleFloat.ValueType.Constant,\r\n ConstantValue = v,\r\n Evaluation = ParticleFloat.EvaluationType.Seed\r\n };\r\n return fxParticle;\r\n }\r\n\r\n // ==================== CONSTRUCTORS ====================\r\n \r\n public FXParticleFloat()\r\n {\r\n var particleFloat = new ParticleFloat();\r\n particleFloat.Type = ParticleFloat.ValueType.Constant;\r\n particleFloat.ConstantValue = 0.0f;\r\n particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;\r\n particleFloat.CurveA = new Curve();\r\n particleFloat.CurveB = new Curve();\r\n particleFloat.Constants = new Vector4();\r\n this.Value = particleFloat;\r\n }\r\n\r\n public FXParticleFloat(float a, float b)\r\n {\r\n var particleFloat = new ParticleFloat();\r\n particleFloat.Type = ParticleFloat.ValueType.Range;\r\n particleFloat.ConstantA = a;\r\n particleFloat.ConstantB = b;\r\n particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;\r\n particleFloat.CurveA = new Curve();\r\n particleFloat.CurveB = new Curve();\r\n particleFloat.Constants = new Vector4();\r\n this.Value = particleFloat;\r\n }\r\n}\r\n\r\npublic class FXParticleVector\r\n{\r\n\t[Property] public bool UseParameter { get; set; } = false;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), false)]\r\n\tpublic ParticleVector3 Value { get; set; } = Vector3.Zero;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true)]\r\n\t[Description(\"Select a vector parameter from the system\")]\r\n\tpublic string ParameterName { get; set; }\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]\r\n\t[Description(\"Multiplier applied to the parameter value\")]\r\n\tpublic float Multiplier { get; set; } = 1.0f;\r\n \r\n\tpublic Vector3 GetValue(Particle particle,FXBoxNativeParticleSystem systemComponent = null)\r\n\t{\r\n\t\tif (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n\t\t{\r\n\t\t\treturn systemComponent.GetVectorParameter(ParameterName) * Multiplier;\r\n\t\t}\r\n\r\n\t\tif ( particle == null )\r\n\t\t{\r\n\t\t\treturn Value.Evaluate( Time.Delta, 0, 0, 0 );\r\n\t\t}\r\n\t\treturn Value.Evaluate( Time.Delta,particle.Rand(1),particle.Rand(2),particle.Rand(3) );\r\n\t}\r\n \r\n\tpublic static implicit operator FXParticleVector(Vector3 v)\r\n\t{\r\n\t\treturn new FXParticleVector { Value = v };\r\n\t}\r\n\r\n\tpublic FXParticleVector()\r\n\t{\r\n\t\tValue = Vector3.Zero;\r\n\t}\r\n\r\n\tpublic FXParticleVector(Vector3 value)\r\n\t{\r\n\t\tValue = value;\r\n\t}\r\n \r\n\tpublic FXParticleVector(float x, float y, float z)\r\n\t{\r\n\t\tValue = new Vector3(x, y, z);\r\n\t}\r\n}\r\n\r\npublic class FXParticleColor\r\n{\r\n\t[Property] public bool UseParameter { get; set; } = false;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), false)]\r\n\tpublic ParticleGradient Value { get; set; } = Color.White;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true)]\r\n\t[Description(\"Select a color parameter from the system\")]\r\n\tpublic string ParameterName { get; set; }\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]\r\n\t[Description(\"Multiplier applied to the parameter value (affects RGB)\")]\r\n\tpublic float Multiplier { get; set; } = 1.0f;\r\n \r\n\tpublic ParticleGradient GetValue(FXBoxNativeParticleSystem systemComponent = null)\r\n\t{\r\n\t\tif (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n\t\t{\r\n\t\t\tvar color = systemComponent.GetColorParameter(ParameterName);\r\n \r\n\t\t\t// Apply multiplier to RGB components\r\n\t\t\tif (Multiplier != 1.0f)\r\n\t\t\t{\r\n\t\t\t\treturn color;\r\n\t\t\t}\r\n \r\n\t\t\treturn color;\r\n\t\t}\r\n \r\n\t\treturn Value;\r\n\t}\r\n \r\n\tpublic static implicit operator FXParticleColor(Color c)\r\n\t{\r\n\t\treturn new FXParticleColor { Value = c };\r\n\t}\r\n\r\n\tpublic FXParticleColor()\r\n\t{\r\n\t\tValue = Color.White;\r\n\t}\r\n\r\n\tpublic FXParticleColor(Color value)\r\n\t{\r\n\t\tValue = value;\r\n\t}\r\n \r\n\tpublic FXParticleColor(float r, float g, float b, float a = 1.0f)\r\n\t{\r\n\t\tValue = new Color(r, g, b, a);\r\n\t}\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Code/FXParameter.cs",
"FileName": "FXParameter.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// A named float parameter that can be used to control particle values\r\n/// </summary>\r\npublic class FloatParameter\r\n{\r\n\t[Property] public string Name { get; set; } = \"Parameter\";\r\n\t[Property] public float DefaultValue { get; set; } = 1.0f;\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n}\r\n\r\n/// <summary>\r\n/// A named vector parameter that can be used to control particle values\r\n/// </summary>\r\npublic class VectorParameter\r\n{\r\n\t[Property] public string Name { get; set; } = \"VectorParameter\";\r\n\t[Property] public Vector3 DefaultValue { get; set; } = Vector3.One;\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n}\r\n\r\n/// <summary>\r\n/// A named color parameter that can be used to control particle values\r\n/// </summary>\r\npublic class ColorParameter\r\n{\r\n\t[Property] public string Name { get; set; } = \"ColorParameter\";\r\n\t[Property] public ParticleGradient DefaultValue { get; set; } = Color.White;\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Editor/Graph/ParticlePreview.cs",
"FileName": "ParticlePreview.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Editor;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace fxbox.Graph;\r\n\r\n/// <summary>\r\n/// Widget that renders a real-time preview of the particle system using native components\r\n/// </summary>\r\npublic class ParticlePreview : SceneRenderingWidget\r\n{\r\n private ParticleResource _resource;\r\n private GameObject _particleSystemObject;\r\n private FXBoxNativeParticleSystem _particleSystem;\r\n private bool _isPlaying = true;\r\n private float _playbackSpeed = 1.0f;\r\n\r\n private Vector2 _lastCursorPos;\r\n private Vector2 _angles = new Vector2(45, 30);\r\n private float _distance = 500f;\r\n private float _actualDistance = 500f;\r\n private Vector3 _targetPosition = Vector3.Zero;\r\n private bool _isOrbiting = false;\r\n\r\n public float PlaybackSpeed\r\n {\r\n get => _playbackSpeed;\r\n set => _playbackSpeed = value;\r\n }\r\n\r\n public ParticlePreview(Widget parent) : base(parent)\r\n {\r\n MouseTracking = true;\r\n FocusMode = FocusMode.Click;\r\n\r\n Scene = Scene.CreateEditorScene();\r\n\r\n using (Scene.Push())\r\n {\r\n // Setup camera\r\n var cameraGo = new GameObject(true, \"camera\");\r\n var camera = cameraGo.GetOrAddComponent<CameraComponent>();\r\n camera.BackgroundColor = new Color(0.1f, 0.1f, 0.15f);\r\n camera.ZFar = 10000;\r\n camera.FieldOfView = 60;\r\n Camera = camera;\r\n\r\n // Add lighting\r\n var sunGo = new GameObject(true, \"sun\");\r\n var sun = sunGo.GetOrAddComponent<DirectionalLight>();\r\n sun.WorldRotation = Rotation.FromPitch(50);\r\n sun.LightColor = Color.White;\r\n\r\n var ambientGo = new GameObject(true, \"ambient\");\r\n var ambient = ambientGo.GetOrAddComponent<AmbientLight>();\r\n ambient.Color = Color.Gray * 0.3f;\r\n }\r\n\r\n UpdateCameraPosition();\r\n }\r\n\r\n public void LoadParticleSystem(ParticleResource resource)\r\n {\r\n _resource = resource;\r\n \r\n // Clean up old system\r\n if (_particleSystemObject.IsValid())\r\n {\r\n _particleSystemObject.Destroy();\r\n }\r\n\r\n if (_resource != null)\r\n {\r\n using (Scene.Push())\r\n {\r\n // Create new particle system object\r\n _particleSystemObject = new GameObject(true, \"ParticleSystem\");\r\n _particleSystem = _particleSystemObject.AddComponent<FXBoxNativeParticleSystem>();\r\n _particleSystem.ParticleSystem = _resource;\r\n _particleSystem.PlayOnStart = true;\r\n \r\n // Initialize the system\r\n _particleSystem.UpdateEmitters();\r\n }\r\n \r\n Log.Info($\"Loaded particle system with {_resource.Emitters?.Count ?? 0} emitters\");\r\n }\r\n }\r\n\r\n public void SetPlaying(bool playing)\r\n {\r\n _isPlaying = playing;\r\n \r\n if (_particleSystemObject.IsValid())\r\n {\r\n // Enable/disable all particle effects\r\n foreach (var effect in _particleSystemObject.Children)\r\n {\r\n var particleEffect = effect.GetComponent<ParticleEffect>();\r\n if (particleEffect.IsValid())\r\n {\r\n particleEffect.Enabled = playing;\r\n }\r\n }\r\n }\r\n }\r\n\r\n public void TogglePlayback()\r\n {\r\n _isPlaying = !_isPlaying;\r\n SetPlaying(_isPlaying);\r\n Log.Info($\"Playback: {(_isPlaying ? \"Playing\" : \"Paused\")}\");\r\n }\r\n\r\n public void Restart()\r\n {\r\n if (_particleSystemObject.IsValid())\r\n {\r\n // Restart by rebuilding the entire system\r\n LoadParticleSystem(_resource);\r\n Log.Info(\"Particle system restarted\");\r\n }\r\n }\r\n\r\n protected override void PreFrame()\r\n {\r\n Scene.EditorTick(RealTime.Now, RealTime.Delta);\r\n\r\n DrawGizmos();\r\n \r\n // Force continuous updates\r\n Update();\r\n }\r\n\r\n private void DrawGizmos()\r\n {\r\n if (_resource == null) return;\r\n\r\n // Draw spawn shape for first emitter\r\n if (_resource.Emitters.Count > 0)\r\n {\r\n var emitter = _resource.Emitters[0];\r\n var posModule = emitter.InitializeModules.OfType<InitializePositionModule>().FirstOrDefault();\r\n \r\n if (posModule != null)\r\n {\r\n Gizmo.Draw.Color = Color.Yellow.WithAlpha(0.3f);\r\n Gizmo.Draw.LineThickness = 2;\r\n \r\n switch (posModule.Shape)\r\n {\r\n case InitializePositionModule.SpawnShape.Sphere:\r\n Gizmo.Draw.LineSphere(new Sphere(Vector3.Zero, posModule.Radius));\r\n break;\r\n case InitializePositionModule.SpawnShape.Box:\r\n Gizmo.Draw.LineBBox(BBox.FromPositionAndSize(Vector3.Zero, posModule.BoxSize));\r\n break;\r\n case InitializePositionModule.SpawnShape.Cone:\r\n DrawConeGizmo(posModule);\r\n break;\r\n case InitializePositionModule.SpawnShape.Circle:\r\n Gizmo.Draw.LineCircle(Vector3.Zero, Vector3.Forward, posModule.Radius);\r\n break;\r\n case InitializePositionModule.SpawnShape.Line:\r\n Gizmo.Draw.Line(posModule.LineStart, posModule.LineEnd);\r\n break;\r\n }\r\n }\r\n }\r\n\r\n DrawGrid();\r\n }\r\n\r\n private void DrawConeGizmo(InitializePositionModule module)\r\n {\r\n var height = module.Radius;\r\n var radius = MathF.Tan(module.ConeAngle.DegreeToRadian()) * height;\r\n \r\n // Draw cone base\r\n Gizmo.Draw.LineCircle(Vector3.Forward * height, Vector3.Forward, radius);\r\n \r\n // Draw cone lines\r\n var points = 8;\r\n for (int i = 0; i < points; i++)\r\n {\r\n var angle = (i / (float)points) * 360f;\r\n var dir = new Vector3(\r\n MathF.Cos(angle.DegreeToRadian()) * radius,\r\n MathF.Sin(angle.DegreeToRadian()) * radius,\r\n height\r\n );\r\n Gizmo.Draw.Line(Vector3.Zero, dir);\r\n }\r\n }\r\n\r\n private void DrawGrid()\r\n {\r\n if (_resource?.PreviewSettings?.ShowGrid ?? true)\r\n {\r\n Gizmo.Draw.Color = Color.White.WithAlpha(0.1f);\r\n Gizmo.Draw.LineThickness = 1;\r\n \r\n // Draw XY grid\r\n for (int x = -500; x <= 500; x += 100)\r\n {\r\n Gizmo.Draw.Line(new Vector3(x, -500, 0), new Vector3(x, 500, 0));\r\n }\r\n for (int y = -500; y <= 500; y += 100)\r\n {\r\n Gizmo.Draw.Line(new Vector3(-500, y, 0), new Vector3(500, y, 0));\r\n }\r\n }\r\n\r\n if (_resource?.PreviewSettings?.ShowGround ?? true)\r\n {\r\n Gizmo.Draw.Color = Color.Gray.WithAlpha(0.2f);\r\n Gizmo.Draw.LineBBox(new BBox(new Vector3(-500, -500, -1), new Vector3(500, 500, 0)));\r\n }\r\n UpdateCameraPosition();\r\n }\r\n\r\n private void UpdateCameraPosition()\r\n {\r\n if (!Camera.IsValid()) return;\r\n\r\n Camera.WorldRotation = new Angles(_angles.y, -_angles.x, 0);\r\n _actualDistance = _actualDistance.LerpTo( _distance, Time.Delta * 15f );\r\n Camera.WorldPosition = _targetPosition + Camera.WorldRotation.Backward * _actualDistance;\r\n }\r\n\r\n protected override void OnMousePress(MouseEvent e)\r\n {\r\n base.OnMousePress(e);\r\n\r\n if (e.LeftMouseButton)\r\n {\r\n _isOrbiting = true;\r\n _lastCursorPos = e.ScreenPosition;\r\n }\r\n }\r\n\r\n protected override void OnMouseReleased(MouseEvent e)\r\n {\r\n base.OnMouseReleased(e);\r\n\r\n if (e.LeftMouseButton)\r\n {\r\n _isOrbiting = false;\r\n }\r\n }\r\n\r\n protected override void OnMouseMove(MouseEvent e)\r\n {\r\n base.OnMouseMove(e);\r\n\r\n if (_isOrbiting)\r\n {\r\n var delta = e.ScreenPosition - _lastCursorPos;\r\n _angles.x += delta.x * 0.3f;\r\n _angles.y += delta.y * 0.3f;\r\n _angles.y = _angles.y.Clamp(-90, 90);\r\n \r\n //UpdateCameraPosition();\r\n _lastCursorPos = e.ScreenPosition;\r\n }\r\n }\r\n\r\n protected override void OnMouseWheel( WheelEvent e )\r\n {\r\n\t base.OnMouseWheel(e);\r\n\t _distance -= e.Delta * 1f;\r\n\t _distance = _distance.Clamp(50, 5000);\r\n }\r\n \r\n protected override void OnPaint()\r\n {\r\n base.OnPaint();\r\n\r\n // Draw overlay info\r\n Paint.SetPen(Theme.Text);\r\n Paint.SetDefaultFont();\r\n // Count particles from all emitter objects\r\n int totalParticles = 0;\r\n int totalMax = _resource?.Emitters.Sum(e => e.MaxParticles) ?? 0;\r\n \r\n if (_particleSystemObject.IsValid())\r\n {\r\n foreach (var child in _particleSystemObject.Children)\r\n {\r\n var effect = child.GetComponent<ParticleEffect>();\r\n if (effect.IsValid())\r\n {\r\n totalParticles += effect.Particles.Count;\r\n }\r\n }\r\n }\r\n \r\n var text = $\"Particles: {totalParticles} / {totalMax}\";\r\n Paint.DrawText(new Rect(10, 10, 200, 30), text, TextFlag.LeftTop);\r\n \r\n var stateText = _isPlaying ? \"PLAYING\" : \"PAUSED\";\r\n Paint.DrawText(new Rect(10, 40, 200, 30), stateText, TextFlag.LeftTop);\r\n \r\n // Draw emitter count\r\n var emitterText = $\"Emitters: {_resource?.Emitters.Count ?? 0}\";\r\n Paint.DrawText(new Rect(10, 70, 200, 30), emitterText, TextFlag.LeftTop);\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "ParticleModules.cs",
"FileName": "ParticleModules.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// Stage when a particle module executes\r\n/// </summary>\r\npublic enum ModuleStage\r\n{\r\n Spawn, // Controls when/how particles spawn\r\n Initialize, // Runs once when particle is created\r\n Update, // Runs every frame for each particle\r\n Render // Controls how particles are rendered\r\n}\r\n\r\n/// <summary>\r\n/// Context passed to particle modules during execution\r\n/// </summary>\r\npublic class ParticleExecutionContext\r\n{\r\n\tpublic Particle Particle;\r\n\tpublic ParticleEffect Effect;\r\n\tpublic Sandbox.ParticleEmitter Emitter;\r\n\tpublic FXBoxNativeParticleSystem SystemComponent; // Changed from Resource to SystemComponent\r\n}\r\n\r\n// ==================== SPAWN MODULES ====================\r\n\r\n/// <summary>\r\n/// Controls spawn rate over time\r\n/// </summary>\r\n[Title(\"Spawn Rate\"), Category(\"Spawn\"), Icon(\"speed\")]\r\npublic partial class SpawnRateModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Spawn;\r\n \r\n [Property, Range(0.1f, 1000f)] \r\n public FXParticleFloat SpawnRate { get; set; } = 10.0f;\r\n \r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n\t var rate = SpawnRate;\r\n context.Emitter.Rate = rate.GetValue( context.SystemComponent );\r\n }\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Emitter.Rate = SpawnRate.GetValue( context.SystemComponent );\r\n }\r\n}\r\n/// <summary>\r\n/// Sets initial particle stretch\r\n/// </summary>\r\n[Title(\"Particle Stretch\"), Category(\"Initialize\"), Icon(\"photo_size_select_small\")]\r\npublic partial class ParticleStretchModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\t\r\n \r\n\t[Property, Range(0.1f, 100f)]\r\n\tpublic FXParticleFloat Size { get; set; } = 1.0f;\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.ApplyShape = true;\r\n\t\tcontext.Effect.Stretch = Size.ToParticleFloat( context.SystemComponent );\r\n\t}\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n}\r\n/// <summary>\r\n/// Controls spawn rate per unit\r\n/// </summary>\r\n[Title(\"Spawn Rate Over Distance\"), Category(\"Spawn\"), Icon(\"speed\")]\r\npublic partial class SpawnRateOverDistanceModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Spawn;\r\n \r\n\t[Property, Range(0.1f, 1000f)] \r\n\tpublic FXParticleFloat SpawnRate { get; set; } = 10.0f;\r\n \r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\tcontext.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );\r\n\t}\r\n \r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Emitter.RateOverDistance = SpawnRate.GetValue( context.SystemComponent );\r\n\t}\r\n}\r\n/// <summary>\r\n/// Spawns particles in a burst\r\n/// </summary>\r\n[Title(\"Spawn Burst\"), Category(\"Spawn\"), Icon(\"auto_awesome\")]\r\npublic partial class SpawnBurstModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Spawn;\r\n \r\n [Property, Range(1, 1000)]\r\n public int ParticleCount { get; set; } = 50;\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Emitter.Burst = ParticleCount;\r\n }\r\n\r\n // Burst is a one-shot \"spawn this many now\" value, not a continuous per-tick one like\r\n // SpawnRateModule's Rate - Execute() runs every single frame (see\r\n // FXBoxParticleController.OnUpdate), so reassigning Burst here too kept re-arming/\r\n // re-firing it every tick instead of once, spawning far more than ParticleCount actually\r\n // configured. Same \"Initialize-only, empty Execute\" shape ParticleStretchModule already\r\n // uses above for its own one-shot value.\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n }\r\n}\r\n\r\n// ==================== INITIALIZE MODULES ====================\r\n\r\n/// <summary>\r\n/// Sets initial position based on shape\r\n/// </summary>\r\n[Title(\"Initialize Position\"), Category(\"Initialize\"), Icon(\"place\")]\r\npublic partial class InitializePositionModule : ParticleModule, IParticleComponentCreator\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n public override void Execute( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n\r\n public enum SpawnShape { Point, Sphere, Box, Cone, Circle, Line }\r\n \r\n [Property]\r\n public FXCopyFlags CopyFlags { get; set; } = FXCopyFlags.Rotation | FXCopyFlags.Scale;\r\n \r\n [Property]\r\n public SpawnShape Shape { get; set; } = SpawnShape.Sphere;\r\n \r\n [Property, Range(0f, 1000f), ShowIf(nameof(ShowRadius), true)]\r\n public float Radius { get; set; } = 50.0f;\r\n \r\n [Property, ShowIf(nameof(ShowBoxSize), true)]\r\n public Vector3 BoxSize { get; set; } = new Vector3(100, 100, 100);\r\n \r\n [Property, Range(0f, 180f), ShowIf(nameof(ShowConeAngle), true)]\r\n public float ConeAngle { get; set; } = 45.0f;\r\n \r\n [Property]\r\n public bool EmitFromShell { get; set; } = false;\r\n \r\n [Property, ShowIf(nameof(ShowLine), true)]\r\n public Vector3 LineStart { get; set; } = Vector3.Zero;\r\n \r\n [Property, ShowIf(nameof(ShowLine), true)]\r\n public Vector3 LineEnd { get; set; } = Vector3.Up * 100;\r\n\r\n [Hide] public bool ShowRadius => Shape == SpawnShape.Sphere || Shape == SpawnShape.Circle || Shape == SpawnShape.Cone;\r\n [Hide] public bool ShowBoxSize => Shape == SpawnShape.Box;\r\n [Hide] public bool ShowConeAngle => Shape == SpawnShape.Cone;\r\n [Hide] public bool ShowLine => Shape == SpawnShape.Line;\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t if ( context.Particle != null )\r\n\t {\r\n\t\t \r\n\t\t var pos = Shape switch\r\n\t\t {\r\n\t\t\t SpawnShape.Point => Vector3.Zero,\r\n\t\t\t SpawnShape.Sphere => GetSpherePosition(),\r\n\t\t\t SpawnShape.Box => GetBoxPosition(),\r\n\t\t\t SpawnShape.Cone => GetConePosition(),\r\n\t\t\t SpawnShape.Circle => GetCirclePosition(),\r\n\t\t\t SpawnShape.Line => GetLinePosition(),\r\n\t\t\t _ => Vector3.Zero\r\n\t\t };\r\n\r\n\t\t if ( CopyFlags.HasFlag( FXCopyFlags.Scale ) )\r\n\t\t {\r\n\t\t\t pos = pos * context.Emitter.WorldScale;\r\n\t\t }\r\n\r\n\t\t if ( CopyFlags.HasFlag( FXCopyFlags.Rotation ) )\r\n\t\t {\r\n\t\t\t pos = pos.RotateAround( 0, context.SystemComponent.WorldRotation );\r\n\t\t }\r\n\r\n\t\r\n\t\t context.Particle.Position += pos;\r\n\t }\r\n }\r\n public void CreateComponent(GameObject go)\r\n {\r\n\t var pointEmitter = go.AddComponent<ParticleSphereEmitter>();\r\n\t pointEmitter.Radius = 0;\r\n\t pointEmitter.Velocity = 0;\r\n\t pointEmitter.Burst = 0;\r\n\t pointEmitter.Rate = 0;\r\n }\r\n\r\n private Vector3 GetSpherePosition()\r\n {\r\n var direction = Random.Shared.VectorInSphere().Normal;\r\n var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);\r\n \r\n return direction * radius;\r\n }\r\n\r\n private Vector3 GetBoxPosition()\r\n {\r\n if (EmitFromShell)\r\n {\r\n var face = Random.Shared.Int(0, 5);\r\n var u = Random.Shared.Float(0, 1);\r\n var v = Random.Shared.Float(0, 1);\r\n var halfSize = BoxSize / 2f;\r\n \r\n return face switch\r\n {\r\n 0 => new Vector3(-halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 1 => new Vector3(halfSize.x, MathX.Lerp(-halfSize.y, halfSize.y, u), MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 2 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), -halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 3 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), halfSize.y, MathX.Lerp(-halfSize.z, halfSize.z, v)),\r\n 4 => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), -halfSize.z),\r\n _ => new Vector3(MathX.Lerp(-halfSize.x, halfSize.x, u), MathX.Lerp(-halfSize.y, halfSize.y, v), halfSize.z)\r\n };\r\n }\r\n \r\n return new Vector3(\r\n Random.Shared.Float(-BoxSize.x / 2, BoxSize.x / 2),\r\n Random.Shared.Float(-BoxSize.y / 2, BoxSize.y / 2),\r\n Random.Shared.Float(-BoxSize.z / 2, BoxSize.z / 2)\r\n );\r\n }\r\n\r\n private Vector3 GetConePosition()\r\n {\r\n var angle = Random.Shared.Float(0, 360);\r\n var distance = Random.Shared.Float(0, Radius);\r\n var coneRadius = MathF.Tan(ConeAngle.DegreeToRadian()) * distance;\r\n var radius = EmitFromShell ? coneRadius : Random.Shared.Float(0, coneRadius);\r\n \r\n return new Vector3(\r\n MathF.Cos(angle.DegreeToRadian()) * radius,\r\n MathF.Sin(angle.DegreeToRadian()) * radius,\r\n distance\r\n );\r\n }\r\n\r\n private Vector3 GetCirclePosition()\r\n {\r\n var angle = Random.Shared.Float(0, 360);\r\n var radius = EmitFromShell ? Radius : Random.Shared.Float(0, Radius);\r\n return new Vector3(\r\n MathF.Cos(angle.DegreeToRadian()) * radius,\r\n MathF.Sin(angle.DegreeToRadian()) * radius,\r\n 0\r\n );\r\n }\r\n\r\n private Vector3 GetLinePosition()\r\n {\r\n return Vector3.Lerp(LineStart, LineEnd, Random.Shared.Float(0, 1));\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Controls how strongly particles follow the emitter in local space\r\n/// </summary>\r\n[Title(\"Initialize Local Space\"), Category(\"Initialize\"), Icon(\"transform\")]\r\npublic partial class InitializeLocalSpaceModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n\t[Property, Range(0f, 1f)]\r\n\tpublic FXParticleFloat LocalSpace { get; set; } = 0f;\r\n\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.LocalSpace = LocalSpace.ToParticleFloat( context.SystemComponent );\r\n\t}\r\n\r\n\tpublic override void Execute( ParticleExecutionContext context )\r\n\t{\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial velocity\r\n/// </summary>\r\n[Title(\"Initialize Velocity\"), Category(\"Initialize\"), Icon(\"air\")]\r\npublic partial class InitializeVelocityModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n \r\n [Property]\r\n public FXParticleVector Velocity { get; set; } = Vector3.Up * 100;\r\n\r\n [Property] public FXParticleFloat RandomVelocity { get; set; } = 0;\r\n\r\n [Property] public bool LocalSpace { get; set; } = false;\r\n \r\n [Property]\r\n public bool InheritEmitterVelocity { get; set; } = false;\r\n \r\n \r\n [Property,ShowIf(\"InheritEmitterVelocity\",true)] public float EmitterVelocityScale { get; set; } = 1.0f;\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t var startVelocity = Velocity;\r\n\t if ( LocalSpace )\r\n\t {\r\n\t\t startVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent ).RotateAround( 0,context.Emitter.WorldRotation );\r\n\t }\r\n\r\n\t context.Effect.StartVelocity = RandomVelocity.ToParticleFloat();\r\n\t context.Effect.InitialVelocity = startVelocity.GetValue( context.Particle,context.SystemComponent );\r\n\t if ( InheritEmitterVelocity )\r\n\t {\r\n\t\t context.Effect.InitialVelocity = (context.SystemComponent.Velocity*EmitterVelocityScale) + startVelocity.GetValue( context.Particle,context.SystemComponent );\r\n\t }\r\n\t \r\n }\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n\r\n }\r\n}\r\n/// <summary>\r\n/// Sets initial color\r\n/// </summary>\r\n[Title(\"Collision\"), Category(\"Initialize\"), Icon(\"palette\")]\r\npublic partial class ParticleCollisionModule : ParticleModule\r\n{\r\n\r\n\t[Property] public TagSet CollisionIgnore { get; set; } = new TagSet();\r\n\t[Property] public List<GameObject> CollisionPrefabs { get; set; } = new List<GameObject>();\r\n\t[Property] public FXParticleFloat CollisionRadius { get; set; } = 5;\r\n\t[Property] public FXParticleFloat CollisionPrefabChance { get; set; } = 1;\r\n\t[Property] public FXParticleFloat CollisionPrefabRotation { get; set; } = 0;\r\n\t[Property] public FXParticleFloat DieOnCollisionChance { get; set; } = 0;\r\n\t[Property] public bool CollisionPrefabAlign { get; set; } = false;\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n \r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.Collision = true;\r\n\t\tcontext.Effect.CollisionIgnore = CollisionIgnore;\r\n\t\tcontext.Effect.CollisionRadius = CollisionRadius.GetValue( context.SystemComponent );\r\n\t\tcontext.Effect.CollisionPrefabChance = CollisionPrefabChance.GetValue( context.SystemComponent );\r\n\t\tcontext.Effect.CollisionPrefabRotation = CollisionPrefabRotation.ToParticleFloat( context.SystemComponent );\r\n\t\tif ( CollisionPrefabs.Any() )\r\n\t\t{\r\n\t\t\tcontext.Effect.UsePrefabFeature = true;\r\n\t\t}\r\n\t\tcontext.Effect.CollisionPrefab = CollisionPrefabs;\r\n\t\tcontext.Effect.DieOnCollisionChance = DieOnCollisionChance.GetValue( context.SystemComponent );\r\n\t\tcontext.Effect.CollisionPrefabAlign = CollisionPrefabAlign;\r\n\t}\r\n}\r\n/// <summary>\r\n/// Sets initial velocity\r\n/// </summary>\r\n[Title(\"Initialize Rotation\"), Category(\"Initialize\"), Icon(\"air\")]\r\npublic partial class InitializeRotationModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n \r\n\t[Property]\r\n\tpublic ParticleVector3 InitialRotation { get; set; } = Vector3.Up;\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tif ( context.Particle != null )\r\n\t\t{\r\n\t\t\tcontext.Particle.Angles = new Angles( InitialRotation.Evaluate( Time.Delta,context.Particle.Rand( ),context.Particle.Rand( ),context.Particle.Rand( ) ) );\r\n\t\t}\r\n\t \r\n\t}\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial lifetime\r\n/// </summary>\r\n[Title(\"Initialize Lifetime\"), Category(\"Initialize\"), Icon(\"schedule\")]\r\npublic partial class InitializeLifetimeModule : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n \r\n\t[Property, Range(0.1f, 100f)]\r\n\tpublic FXParticleFloat Lifetime { get; set; } = 2.0f;\r\n \r\n\tpublic override void Initialize(ParticleExecutionContext context)\r\n\t{\r\n\t\tcontext.Effect.Lifetime = Lifetime.ToParticleFloat( context.SystemComponent );\r\n\t}\r\n \r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Not used\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial size\r\n/// </summary>\r\n[Title(\"Initialize Size\"), Category(\"Initialize\"), Icon(\"photo_size_select_small\")]\r\npublic partial class InitializeSizeModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n [Property] public bool InheritEmitterScale { get; set; } = true;\r\n \r\n [Property, Range(0.1f, 100f)]\r\n public FXParticleFloat Size { get; set; } = 10.0f;\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Effect.ApplyShape = true;\r\n\t if ( InheritEmitterScale )\r\n\t {\r\n\t\t context.Effect.Scale = Size.ToParticleFloat( context.SystemComponent );\r\n\t }\r\n\t else\r\n\t {\r\n\t\t context.Effect.Scale = (Size / context.SystemComponent.WorldScale.x).ToParticleFloat( context.SystemComponent );\r\n\t }\r\n\t \r\n\t \r\n\t \r\n }\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial color\r\n/// </summary>\r\n[Title(\"Initialize Color\"), Category(\"Initialize\"), Icon(\"palette\")]\r\npublic partial class InitializeColorModule : ParticleModule\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n [Property] public FXParticleColor Color { get; set; } = global::Color.Red;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n \r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t context.Effect.ApplyColor = true;\r\n\t context.Effect.ApplyAlpha = true;\r\n\t if ( Color != null )\r\n\t {\r\n\t\t context.Effect.Gradient = Color.GetValue( context.SystemComponent );\r\n\t }\r\n\t else\r\n\t {\r\n\t\t Color = new FXParticleColor( global::Color.Red );\r\n\t }\r\n\t \r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Sets initial color\r\n/// </summary>\r\n[Title(\"Sprite Flipbook\"), Category(\"Initialize\"), Icon(\"palette\")]\r\npublic partial class SpriteFlipbookModule : ParticleModule\r\n{\r\n\t[Property] public FXParticleFloat SequenceTime { get; set; } = 0;\r\n\t[Property] public FXParticleFloat SequenceSpeed { get; set; } = 1;\r\n\t[Property] public int SequenceId { get; set; } = 0;\r\n\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n \r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tcontext.Effect.SheetSequence = true;\r\n\t\tcontext.Effect.SequenceId = SequenceId;\r\n\t\tcontext.Effect.SequenceSpeed = SequenceSpeed.ToParticleFloat( context.SystemComponent );\r\n\t\tcontext.Effect.SequenceTime = SequenceTime.ToParticleFloat( context.SystemComponent );\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Randomly Kill a particle to spawn less\r\n/// </summary>\r\n[Title(\"RandomKill\"), Category(\"Initialize\"), Icon(\"arrow_downward\")]\r\npublic partial class RandomKill : ParticleModule\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Initialize;\r\n\t\r\n\t[Property]\r\n\tpublic float Chance { get; set; } = 0.5f;\r\n\t\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t\tif ( context.Particle == null ) return;\r\n\t\tif ( Random.Shared.Float( 0, 1 ) < Chance )\r\n\t\t{\r\n\t\t\tcontext.Particle.Age = 100000;\r\n\t\t}\r\n\t}\r\n\t\r\n}\r\n\r\n\r\n// ==================== UPDATE MODULES ====================\r\n\r\n/// <summary>\r\n/// Applies gravity force\r\n/// </summary>\r\n[Title(\"Gravity Force\"), Category(\"Update\"), Icon(\"arrow_downward\")]\r\npublic partial class GravityForceModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property]\r\n public FXParticleVector Force { get; set; } = new Vector3(0, 0, -980);\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n context.Particle.Velocity += Force.GetValue(context.Particle, context.SystemComponent ) * Time.Delta;\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Make a mesh follow it's velocity\r\n/// </summary>\r\n[Title(\"Follow Velocity\"), Category(\"Update\"), Icon(\"arrow_downward\")]\r\npublic partial class FollowVelocity : ParticleModule, IParticleUpdater\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Update;\r\n\t\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n \r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void UpdateParticle(ParticleExecutionContext context, float delta)\r\n\t{\r\n\t\tcontext.Particle.Angles = Rotation.LookAt( context.Particle.Velocity ).Angles();\r\n\t}\r\n}\r\n/// <summary>\r\n/// Applies drag/air resistance\r\n/// </summary>\r\n[Title(\"Drag Force\"), Category(\"Update\"), Icon(\"air\")]\r\npublic partial class DragForceModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property, Range(0f, 10f)]\r\n public float Damping { get; set; } = 0.1f;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n context.Particle.Velocity *= (1.0f - Damping * Time.Delta);\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Makes particles rotate\r\n/// </summary>\r\n[Title(\"Rotation\"), Category(\"Update\"), Icon(\"rotate_right\")]\r\npublic partial class RotationModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n\r\n [Property] public FXParticleVector RotationSpeed { get; set; } = Vector3.Zero;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n /*context.Particle.Rotation += RotationSpeed * context.DeltaTime;*/\r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n\t if ( context.Particle != null )\r\n\t {\r\n\t\t context.Particle.Angles += RotationSpeed.GetValue( context.Particle ) * Time.Delta;\r\n\t }\r\n \r\n }\r\n}\r\n\r\npublic enum PositionType\r\n{\r\n\tLocal,\r\n\tWorld\r\n}\r\n\r\n/// <summary>\r\n/// Attracts particles to a point. Full strength inside AttractorSize, falling off beyond it.\r\n/// </summary>\r\n[Title(\"Point Attractor\"), Category(\"Update\"), Icon(\"my_location\")]\r\npublic partial class PointAttractorModule : ParticleModule, IParticleUpdater\r\n{\r\n\t[Hide]\r\n\tpublic override ModuleStage Stage => ModuleStage.Update;\r\n\t[Property] public PositionType PositionType { get; set; } = PositionType.Local;\r\n\r\n\t[Property]\r\n\tpublic FXParticleVector AttractorPosition { get; set; } = Vector3.Zero;\r\n\r\n\t[Property, Range(0f, 10000f)]\r\n\tpublic FXParticleFloat Strength { get; set; } = 500.0f;\r\n\r\n\t[Property, Range(0.01f, 10000f)]\r\n\tpublic float AttractorSize { get; set; } = 50.0f;\r\n\r\n\t[Property] public bool Invert { get; set; } = false;\r\n\r\n\t/// <summary>\r\n\t/// How quickly strength falls off beyond AttractorSize.\r\n\t/// 1 = linear, 2 = inverse square, higher = sharper falloff.\r\n\t/// </summary>\r\n\t[Property, Range(0.1f, 8f)]\r\n\tpublic float Falloff { get; set; } = 2.0f;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context) { }\r\n\tpublic override void Initialize(ParticleExecutionContext context) { }\r\n\r\n\tpublic void UpdateParticle(ParticleExecutionContext context, float delta)\r\n\t{\r\n\t\tvar attractorPos = PositionType == PositionType.Local ? AttractorPosition.GetValue( context.Particle, context.SystemComponent ) + context.Emitter.WorldPosition : AttractorPosition.GetValue( context.Particle, context.SystemComponent );\r\n\t\t\r\n\t\t\r\n\t\tvar toAttractor = attractorPos - context.Particle.Position;\r\n\t\tvar distance = toAttractor.Length;\r\n\r\n\t\tif (distance < 0.01f) return;\r\n\r\n\t\t// Inside the attractor: full strength.\r\n\t\t// Outside: strength falls off based on normalised excess distance.\r\n\t\tfloat strengthMultiplier;\r\n\t\tif (distance <= AttractorSize)\r\n\t\t{\r\n\t\t\tstrengthMultiplier = 1f;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t// How many radii past the edge are we? 0 at the surface, grows outward.\r\n\t\t\tvar excess = (distance - AttractorSize) / AttractorSize;\r\n\t\t\tstrengthMultiplier = 1f / MathF.Pow(1f + excess, Falloff);\r\n\t\t}\r\n\t\t\r\n\t\tif ( Invert )\r\n\t\t{\r\n\t\t\tstrengthMultiplier = 1 - strengthMultiplier;\r\n\t\t}\r\n\r\n\t\tcontext.Particle.Velocity += toAttractor.Normal * Strength.GetValue( context.SystemComponent ) * strengthMultiplier * Time.Delta;\r\n\t}\r\n}\r\n/// <summary>\r\n/// Creates orbital motion\r\n/// </summary>\r\n[Title(\"Vortex Force\"), Category(\"Update\"), Icon(\"cyclone\")]\r\npublic partial class VortexForceModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property]\r\n public Vector3 Center { get; set; } = Vector3.Zero;\r\n \r\n [Property]\r\n public FXParticleVector Axis { get; set; } = Vector3.Up;\r\n \r\n [Property, Range(0f, 1000f)]\r\n public float Strength { get; set; } = 100.0f;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n \r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n var toCenter = context.Particle.Position - (Center + context.Emitter.WorldPosition);\r\n var distance = toCenter.Length;\r\n \r\n \r\n if (distance > 0.01f)\r\n {\r\n var tangent = Vector3.Cross(Axis.GetValue( context.Particle,context.SystemComponent ).Normal, toCenter.Normal);\r\n var force = tangent * (Strength / distance);\r\n context.Particle.Velocity += force * Time.Delta * 10000;\r\n }\r\n }\r\n}\r\n\r\n// ==================== RENDER MODULES ====================\r\n\r\n/// <summary>\r\n/// Basic sprite renderer\r\n/// </summary>\r\n[Title(\"Sprite Renderer\"), Category(\"Render\"), Icon(\"image\")]\r\npublic partial class SpriteRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n [Hide] \r\n public override ModuleStage Stage => ModuleStage.Render;\r\n\r\n [Property] \r\n public Sprite Sprite { get; set; }\r\n\r\n [Property] public FXParticleFloat SpriteScale { get; set; } = 1f;\r\n\r\n [Property]\r\n public ParticleSpriteRenderer.BillboardAlignment Alignment { get; set; } =\r\n\t ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;\r\n\r\n [Property] public bool FaceVelocity { get; set; } = false;\r\n\r\n [Property] public bool Additive { get; set; } = false;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n }\r\n public override void Initialize( ParticleExecutionContext context )\r\n {\r\n\t \r\n }\r\n public void CreateComponent(GameObject go)\r\n {\r\n var renderer = go.AddComponent<ParticleSpriteRenderer>();\r\n\r\n renderer.Alignment = Alignment;\r\n renderer.FaceVelocity = FaceVelocity;\r\n renderer.Sprite = Sprite;\r\n\t\trenderer.Additive = Additive;\r\n\t\trenderer.Scale = SpriteScale.GetValue();\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Basic light renderer\r\n/// </summary>\r\n[Title(\"Light Renderer\"), Category(\"Render\"), Icon(\"image\")]\r\npublic partial class LightRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n\t[Hide] \r\n\tpublic override ModuleStage Stage => ModuleStage.Render;\r\n\t\r\n\t[Property] public FXParticleColor LightColor { get; set; } = new FXParticleColor( Color.White );\r\n\t[Property] public FXParticleFloat Brightness { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat MaxLights { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat LightSize { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat Attenuation { get; set; } = 1;\r\n\t[Property] public bool CastShadows { get; set; } = false;\r\n\t[Property] public bool UseParticleColor { get; set; } = true;\r\n\t[Property] public FXParticleFloat Ratio { get; set; } = 1;\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Rendering is handled externally, this just stores render properties\r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void CreateComponent(GameObject go)\r\n\t{\r\n\t\tvar renderer = go.AddComponent<ParticleLightRenderer>();\r\n\t\t\r\n\t\tvar fxbox=go.GetComponentInParent<FXBoxNativeParticleSystem>( );\r\n\t\trenderer.LightColor = LightColor.GetValue( fxbox );\r\n\t\trenderer.Brightness = Brightness.GetValue( fxbox );\r\n\t\t\r\n\t\trenderer.MaximumLights = (int)MaxLights.GetValue( fxbox );\r\n\t\trenderer.Scale = LightSize.GetValue( fxbox );\r\n\t\trenderer.Attenuation = Attenuation.GetValue( fxbox );\r\n\t\trenderer.Ratio = Ratio.GetValue( fxbox );\r\n\t\trenderer.CastShadows = CastShadows;\r\n\t\trenderer.UseParticleColor = UseParticleColor;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Basic model renderer\r\n/// </summary>\r\n[Title(\"Model Renderer\"), Category(\"Render\"), Icon(\"image\")]\r\npublic partial class ModelRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n\t[Hide] \r\n\tpublic override ModuleStage Stage => ModuleStage.Render;\r\n\r\n\t[Property] \r\n\tpublic List<ParticleModelRenderer.ModelEntry> Models { get; set; }\r\n\r\n\t[Property] \r\n\tpublic bool FaceCamera { get; set; } = true;\r\n\r\n\tpublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Rendering is handled externally, this just stores render properties\r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void CreateComponent(GameObject go)\r\n\t{\r\n\t\tvar renderer = go.AddComponent<ParticleModelRenderer>();\r\n\r\n\t\trenderer.Choices = Models;\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Basic Trail Renderer\r\n/// </summary>\r\n[Title( \"Trail Renderer\" ), Category( \"Render\" ), Icon( \"image\" )]\r\npublic partial class TrailRendererModule : ParticleModule, IParticleComponentCreator\r\n{\r\n\t[Hide] public override ModuleStage Stage => ModuleStage.Render;\r\n\r\n\t[Property] public bool Game { get; set; } = true;\r\n\t[Property] public bool Overlay { get; set; } = false;\r\n\t[Property] public bool Bloom { get; set; } = false;\r\n\t[Property] public bool AfterUi { get; set; } = false;\r\n\t[Property] public Material Material { get; set; }\r\n\t[Property] public FXParticleFloat UnitsPerTexture { get; set; } = 10f;\r\n\t[Property] public FXParticleFloat Scroll { get; set; } = 0f;\r\n\t[Property] public FXParticleFloat Width { get; set; } = 1f;\r\n\t[Property] public bool Opaque { get; set; } = true;\r\n\t[Property, ShowIf( \"Opaque\", false )] public BlendMode BlendMode { get; set; } = BlendMode.Normal;\r\n\t[Property] public int MaxPoints { get; set; } = 32;\r\n\t[Property] public float PointDistance { get; set; } = 8;\r\n\t[Property] public float LifeTime { get; set; } = 2f;\r\n\t[Property] public FXParticleColor Color { get; set; } = new FXParticleColor( );\r\n\r\npublic override void Execute(ParticleExecutionContext context)\r\n\t{\r\n\t\t// Rendering is handled externally, this just stores render properties\r\n\t}\r\n\tpublic override void Initialize( ParticleExecutionContext context )\r\n\t{\r\n\t \r\n\t}\r\n\tpublic void CreateComponent(GameObject go)\r\n\t{\r\n\t\tvar renderer = go.AddComponent<ParticleTrailRenderer>();\r\n\r\n\t\tvar appearance = renderer.Texturing;\r\n\t\tappearance.Material = Material;\r\n\t\tappearance.UnitsPerTexture = UnitsPerTexture.GetValue( );\r\n\t\tappearance.Scroll = Scroll.GetValue();\r\n\r\n\t\tvar widthCurve = Width.ToParticleFloat();\r\n\r\n\t\tif ( widthCurve.Type == ParticleFloat.ValueType.Curve )\r\n\t\t{\r\n\t\t\trenderer.Width = widthCurve.CurveA;\r\n\t\t} else if ( widthCurve.Type == ParticleFloat.ValueType.Range )\r\n\t\t{\r\n\t\t\tvar point1 = new Curve.Frame( 0, widthCurve.ConstantA );\r\n\t\t\tvar point2 = new Curve.Frame( 1, widthCurve.ConstantB );\r\n\t\t\trenderer.Width = new Curve( point1, point2 );\r\n\t\t} else if ( widthCurve.Type == ParticleFloat.ValueType.Constant )\r\n\t\t{\r\n\t\t\trenderer.Width = widthCurve.ConstantA;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\trenderer.Width = widthCurve.CurveA;\r\n\t\t}\r\n\t\t\r\n\t\trenderer.Opaque = Opaque;\r\n\t\trenderer.BlendMode = BlendMode;\r\n\t\trenderer.MaxPoints = MaxPoints;\r\n\t\trenderer.PointDistance = PointDistance;\r\n\t\trenderer.LifeTime = LifeTime;\r\n\t\tvar colorParam = Color.GetValue();\r\n\t\tif ( colorParam.Type == ParticleGradient.ValueType.Constant )\r\n\t\t{\r\n\t\t\trenderer.Color = colorParam.ConstantA;\r\n\t\t} else if ( colorParam.Type == ParticleGradient.ValueType.Range )\r\n\t\t{\r\n\t\t\tvar point1 = new Gradient.ColorFrame( 0, colorParam.ConstantA );\r\n\t\t\tvar point2 = new Gradient.ColorFrame( 1, colorParam.ConstantB );\r\n\t\t\trenderer.Color = new Gradient( point1, point2 );\r\n\t\t} else if ( colorParam.Type == ParticleGradient.ValueType.Gradient )\r\n\t\t{\r\n\t\t\trenderer.Color = colorParam.GradientA;\r\n\t\t}\r\n\r\n\t\trenderer.RenderOptions.Game = Game;\r\n\t\trenderer.RenderOptions.Overlay = Overlay;\r\n\t\trenderer.RenderOptions.Bloom = Bloom;\r\n\t\trenderer.RenderOptions.AfterUI = AfterUi;\r\n\r\n\t\trenderer.Texturing = appearance;\r\n\r\n\t}\r\n}\r\n/// <summary>\r\n/// Applies curl noise force for organic, swirling motion\r\n/// </summary>\r\n[Title(\"Curl Noise\"), Category(\"Update\"), Icon(\"air\")]\r\npublic partial class CurlNoiseModule : ParticleModule, IParticleUpdater\r\n{\r\n [Hide]\r\n public override ModuleStage Stage => ModuleStage.Update;\r\n \r\n [Property, Range(0f, 1000f)]\r\n [Description(\"Strength of the curl noise effect\")]\r\n public FXParticleFloat Strength { get; set; } = 1.0f;\r\n \r\n [Property, Range(0.01f, 10f)]\r\n [Description(\"Scale of the noise pattern - smaller values create tighter curls\")]\r\n public FXParticleFloat Scale { get; set; } = 1.0f;\r\n \r\n [Property, Range(0f, 10f)]\r\n [Description(\"Speed at which the noise pattern evolves over time\")]\r\n public FXParticleFloat TimeScale { get; set; } = 1.0f;\r\n \r\n [Property]\r\n [Description(\"Offset in the noise field\")]\r\n public Vector3 Offset { get; set; } = Vector3.Zero;\r\n\r\n public override void Execute(ParticleExecutionContext context)\r\n {\r\n // Not used - handled in UpdateParticle\r\n }\r\n\r\n public override void Initialize(ParticleExecutionContext context)\r\n {\r\n // No initialization needed\r\n }\r\n\r\n public void UpdateParticle(ParticleExecutionContext context, float delta)\r\n {\r\n var particle = context.Particle;\r\n \r\n // Sample position in noise field\r\n var samplePos = (particle.Position + Offset) * Scale.GetValue( context.SystemComponent );\r\n var time = context.Particle.Age * TimeScale;\r\n \r\n // Calculate curl noise using the curl of a 3D noise field\r\n var curl = CalculateCurl(samplePos, time.GetValue( context.SystemComponent ));\r\n \r\n // Apply force\r\n particle.Velocity += curl * Strength.GetValue( context.SystemComponent ) * delta * 10;\r\n }\r\n\r\n /// <summary>\r\n /// Calculate curl noise by taking the curl of a potential field\r\n /// This creates divergence-free flow fields that look organic\r\n /// </summary>\r\n private Vector3 CalculateCurl(Vector3 pos, float time)\r\n {\r\n const float epsilon = 0.001f;\r\n \r\n // Sample the potential field at offset positions\r\n // We need 6 samples to calculate the curl (derivatives in all directions)\r\n \r\n // dPz/dy - dPy/dz\r\n float curlX = \r\n (SamplePotential(pos + new Vector3(0, epsilon, 0), time).z - \r\n SamplePotential(pos - new Vector3(0, epsilon, 0), time).z) -\r\n (SamplePotential(pos + new Vector3(0, 0, epsilon), time).y - \r\n SamplePotential(pos - new Vector3(0, 0, epsilon), time).y);\r\n \r\n // dPx/dz - dPz/dx\r\n float curlY = \r\n (SamplePotential(pos + new Vector3(0, 0, epsilon), time).x - \r\n SamplePotential(pos - new Vector3(0, 0, epsilon), time).x) -\r\n (SamplePotential(pos + new Vector3(epsilon, 0, 0), time).z - \r\n SamplePotential(pos - new Vector3(epsilon, 0, 0), time).z);\r\n \r\n // dPy/dx - dPx/dy\r\n float curlZ = \r\n (SamplePotential(pos + new Vector3(epsilon, 0, 0), time).y - \r\n SamplePotential(pos - new Vector3(epsilon, 0, 0), time).y) -\r\n (SamplePotential(pos + new Vector3(0, epsilon, 0), time).x - \r\n SamplePotential(pos - new Vector3(0, epsilon, 0), time).x);\r\n \r\n return new Vector3(curlX, curlY, curlZ) / (2.0f * epsilon);\r\n }\r\n\r\n /// <summary>\r\n /// Sample a 3D potential field using Perlin-like noise\r\n /// </summary>\r\n private Vector3 SamplePotential(Vector3 pos, float time)\r\n {\r\n // Create three offset noise samples for each component\r\n // This creates a vector field from scalar noise functions\r\n return new Vector3(\r\n Noise3D(pos + new Vector3(0, 0, 0), time),\r\n Noise3D(pos + new Vector3(31.416f, -47.853f, 12.793f), time),\r\n Noise3D(pos + new Vector3(-17.737f, 86.214f, -59.482f), time)\r\n );\r\n }\r\n\r\n /// <summary>\r\n /// Simple 3D noise function using sine waves\r\n /// You could replace this with proper Perlin/Simplex noise for better results\r\n /// </summary>\r\n private float Noise3D(Vector3 pos, float time)\r\n {\r\n // Combine multiple sine waves at different frequencies for pseudo-noise\r\n var p = pos + new Vector3(time, time * 0.7f, time * 0.5f);\r\n \r\n float noise = 0;\r\n noise += MathF.Sin(p.x * 1.0f + p.y * 1.3f) * 0.5f;\r\n noise += MathF.Sin(p.y * 1.7f + p.z * 0.9f) * 0.3f;\r\n noise += MathF.Sin(p.z * 2.1f + p.x * 1.1f) * 0.2f;\r\n noise += MathF.Sin(p.x * 3.7f + p.y * 2.3f + p.z * 1.9f) * 0.15f;\r\n \r\n return noise;\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Editor/FXParticleControlWidget.cs",
"FileName": "FXParticleControlWidget.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 379493,
"Code": "using Editor;\r\nusing fxbox;\r\nusing Sandbox;\r\n\r\nnamespace Editor;\r\n\r\n[CustomEditor(typeof(FXParticleFloat))]\r\npublic class FXParticleFloatControlWidget : ControlWidget\r\n{\r\n public Color HighlightColor { get; set; }\r\n public string Label { get; set; }\r\n\r\n Layout ControlArea;\r\n SerializedObject Target;\r\n Button ModeSwitchButton;\r\n Button ToggleButton;\r\n \r\n SerializedProperty ValueProperty;\r\n SerializedObject ValueTarget;\r\n\r\n public FXParticleFloatControlWidget(SerializedProperty property) : this(property, \"f\", Theme.Green)\r\n {\r\n }\r\n\r\n public FXParticleFloatControlWidget(SerializedProperty property, string label, Color color) : base(property)\r\n {\r\n SetSizeMode(SizeMode.Ignore, SizeMode.Default);\r\n\r\n if (!property.TryGetAsObject(out Target))\r\n return;\r\n\r\n Label = label;\r\n HighlightColor = color;\r\n\r\n Layout = Layout.Row();\r\n Layout.Spacing = 3;\r\n\r\n Layout.AddStretchCell();\r\n\r\n // Add toggle button for UseParameter\r\n ToggleButton = new Button();\r\n ToggleButton.Text = \"P\";\r\n ToggleButton.ToolTip = \"Toggle Parameter Mode\";\r\n ToggleButton.FixedWidth = Theme.RowHeight;\r\n ToggleButton.Pressed = () => ToggleParameterMode();\r\n ToggleButton.OnPaintOverride = PaintToggleButton;\r\n Layout.Add(ToggleButton);\r\n\r\n ModeSwitchButton = new Button();\r\n ModeSwitchButton.Text = \"Mode\";\r\n ModeSwitchButton.OnPaintOverride = PaintButton;\r\n ModeSwitchButton.Pressed = () => OpenPopup(ModeSwitchButton.ScreenRect);\r\n ModeSwitchButton.FixedWidth = Theme.RowHeight;\r\n\r\n Layout.Add(ModeSwitchButton);\r\n\r\n ControlArea = Layout.AddRow(1);\r\n ControlArea.Spacing = 2;\r\n\r\n Target.OnPropertyChanged += (p) =>\r\n {\r\n if (p.Name == \"UseParameter\")\r\n {\r\n Rebuild();\r\n return;\r\n }\r\n \r\n if (!Target.GetProperty(\"UseParameter\").GetValue<bool>())\r\n {\r\n // Only rebuild for Value changes when not using parameters\r\n ValueProperty = Target.GetProperty(\"Value\");\r\n if (ValueProperty.TryGetAsObject(out ValueTarget))\r\n {\r\n if (p.Name == \"Type\" || p.Name == \"Evaluation\")\r\n {\r\n Rebuild();\r\n }\r\n }\r\n }\r\n };\r\n\r\n Rebuild();\r\n }\r\n\r\n private void ToggleParameterMode()\r\n {\r\n var useParam = Target.GetProperty(\"UseParameter\");\r\n useParam.SetValue(!useParam.GetValue<bool>());\r\n Rebuild();\r\n }\r\n\r\n private bool PaintToggleButton()\r\n {\r\n Paint.Antialiasing = true;\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n\r\n if (Paint.HasPressed)\r\n {\r\n Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);\r\n Paint.Pen = useParam ? Theme.Blue.Lighten(0.4f) : Theme.TextControl.Lighten(0.4f);\r\n }\r\n else if (Paint.HasMouseOver)\r\n {\r\n Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);\r\n Paint.Pen = useParam ? Theme.Blue.Lighten(0.5f) : Theme.TextControl.Lighten(0.5f);\r\n }\r\n else\r\n {\r\n Paint.Pen = useParam ? Theme.Blue : Theme.TextControl.WithAlpha(0.5f);\r\n }\r\n\r\n Paint.DrawIcon(Paint.LocalRect, \"tune\", 15, TextFlag.Center);\r\n\r\n return true;\r\n }\r\n\r\n private void OpenPopup(Rect parentRect)\r\n {\r\n // Only show popup if not in parameter mode\r\n if (Target.GetProperty(\"UseParameter\").GetValue<bool>())\r\n return;\r\n\r\n ValueProperty = Target.GetProperty(\"Value\");\r\n if (!ValueProperty.TryGetAsObject(out ValueTarget))\r\n return;\r\n\r\n var popup = new FXParticleFloatConfigPopup(ValueTarget, this);\r\n popup.Position = parentRect.BottomRight;\r\n popup.AdjustSize();\r\n popup.Position -= new Vector2(popup.Width, 0);\r\n\r\n popup.Show();\r\n popup.ConstrainToScreen();\r\n }\r\n\r\n bool PaintButton()\r\n {\r\n Paint.Antialiasing = true;\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n \r\n // Disable button appearance if in parameter mode\r\n if (useParam)\r\n {\r\n Paint.Pen = Theme.TextControl.WithAlpha(0.2f);\r\n Paint.DrawIcon(Paint.LocalRect, \"block\", 11, TextFlag.Center);\r\n return true;\r\n }\r\n\r\n ValueProperty = Target.GetProperty(\"Value\");\r\n if (!ValueProperty.TryGetAsObject(out ValueTarget))\r\n return true;\r\n\r\n if (Paint.HasPressed)\r\n {\r\n Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);\r\n Paint.Pen = Theme.TextControl.Lighten(0.4f);\r\n }\r\n else if (Paint.HasMouseOver)\r\n {\r\n Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);\r\n Paint.Pen = Theme.TextControl.Lighten(0.5f);\r\n }\r\n else\r\n {\r\n Paint.Pen = Theme.TextControl.WithAlpha(0.5f);\r\n }\r\n\r\n var type = ValueTarget.GetProperty(\"Type\").GetValue<ParticleFloat.ValueType>();\r\n var eval = ValueTarget.GetProperty(\"Evaluation\").GetValue<ParticleFloat.EvaluationType>();\r\n\r\n var icon = \"people\";\r\n float iconSize = 15;\r\n\r\n if (type == ParticleFloat.ValueType.Constant)\r\n {\r\n icon = \"radio_button_unchecked\";\r\n Paint.Pen = Paint.Pen.WithAlpha(0.3f);\r\n iconSize = 11;\r\n }\r\n else\r\n {\r\n if (eval == ParticleFloat.EvaluationType.Seed)\r\n {\r\n icon = \"scatter_plot\";\r\n }\r\n\r\n if (eval == ParticleFloat.EvaluationType.Life)\r\n {\r\n icon = \"play_arrow\";\r\n }\r\n\r\n if (eval == ParticleFloat.EvaluationType.Frame)\r\n {\r\n icon = \"casino\";\r\n }\r\n }\r\n\r\n Paint.DrawIcon(Paint.LocalRect, icon, iconSize, TextFlag.Center);\r\n\r\n return true;\r\n }\r\n\r\n void Rebuild()\r\n {\r\n ControlArea.Clear(true);\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n\r\n if (useParam)\r\n {\r\n // Parameter mode - show parameter selector and multiplier\r\n var paramName = Target.GetProperty(\"ParameterName\");\r\n var multiplier = Target.GetProperty(\"Multiplier\");\r\n\r\n var paramControl = new ParameterNameControlWidget(paramName);\r\n ControlArea.Add(paramControl, 1);\r\n\r\n var multControl = new FloatControlWidget(multiplier)\r\n {\r\n HighlightColor = Theme.Highlight,\r\n Label = \"\u00d7\"\r\n };\r\n ControlArea.Add(multControl);\r\n }\r\n else\r\n {\r\n // Normal mode - show particle float controls\r\n ValueProperty = Target.GetProperty(\"Value\");\r\n if (ValueProperty.TryGetAsObject(out ValueTarget))\r\n {\r\n var type = ValueTarget.GetProperty(\"Type\").GetValue<ParticleFloat.ValueType>();\r\n var eval = ValueTarget.GetProperty(\"Evaluation\").GetValue<ParticleFloat.EvaluationType>();\r\n RebuildForType(type, eval);\r\n }\r\n }\r\n\r\n Update();\r\n }\r\n\r\n void RebuildForType(ParticleFloat.ValueType type, ParticleFloat.EvaluationType eval)\r\n {\r\n ModeSwitchButton.ToolTip = GetTypeName(type, eval);\r\n\r\n if (type == ParticleFloat.ValueType.Constant)\r\n {\r\n var control = new FloatControlWidget(ValueTarget.GetProperty(\"ConstantValue\"))\r\n {\r\n HighlightColor = HighlightColor,\r\n Label = Label\r\n };\r\n ControlArea.Add(control);\r\n }\r\n\r\n if (type == ParticleFloat.ValueType.Range)\r\n {\r\n var controlA = new FloatControlWidget(ValueTarget.GetProperty(\"ConstantA\"))\r\n {\r\n HighlightColor = HighlightColor,\r\n Label = Label\r\n };\r\n ControlArea.Add(controlA);\r\n\r\n var controlB = new FloatControlWidget(ValueTarget.GetProperty(\"ConstantB\"))\r\n {\r\n HighlightColor = HighlightColor,\r\n Label = Label\r\n };\r\n ControlArea.Add(controlB);\r\n }\r\n\r\n if (type == ParticleFloat.ValueType.Curve)\r\n {\r\n var controlA = new CurveControlWidget(ValueTarget.GetProperty(\"CurveA\"))\r\n {\r\n HighlightColor = HighlightColor\r\n };\r\n ControlArea.Add(controlA);\r\n }\r\n\r\n if (type == ParticleFloat.ValueType.CurveRange)\r\n {\r\n var controlA = new CurveRangeControlWidget(ValueTarget.GetProperty(\"CurveRange\"))\r\n {\r\n HighlightColor = HighlightColor\r\n };\r\n ControlArea.Add(controlA);\r\n }\r\n }\r\n\r\n string GetTypeName(ParticleFloat.ValueType type, ParticleFloat.EvaluationType eval)\r\n {\r\n switch (type)\r\n {\r\n case ParticleFloat.ValueType.Constant:\r\n return \"Constant value\";\r\n case ParticleFloat.ValueType.Curve:\r\n {\r\n switch (eval)\r\n {\r\n case ParticleFloat.EvaluationType.Seed:\r\n return \"Random from curve per particle\";\r\n case ParticleFloat.EvaluationType.Frame:\r\n return \"Random from curve\";\r\n case ParticleFloat.EvaluationType.Life:\r\n return \"Curve over lifetime\";\r\n default:\r\n return \"Unknown\";\r\n }\r\n }\r\n case ParticleFloat.ValueType.CurveRange:\r\n switch (eval)\r\n {\r\n case ParticleFloat.EvaluationType.Seed:\r\n return \"Random from range, per particle\";\r\n case ParticleFloat.EvaluationType.Frame:\r\n return \"Random from range (per frame)\";\r\n case ParticleFloat.EvaluationType.Life:\r\n return \"path between curve over lifetime, per particle\";\r\n default:\r\n return \"Unknown\";\r\n }\r\n case ParticleFloat.ValueType.Range:\r\n {\r\n switch (eval)\r\n {\r\n case ParticleFloat.EvaluationType.Seed:\r\n return \"Between range, per particle\";\r\n case ParticleFloat.EvaluationType.Frame:\r\n return \"Random between range (per frame)\";\r\n case ParticleFloat.EvaluationType.Life:\r\n return \"Lerp between range over lifetime\";\r\n default:\r\n return \"Unknown\";\r\n }\r\n }\r\n }\r\n\r\n return \"Unknown Combo\";\r\n }\r\n\r\n protected override void OnPaint()\r\n {\r\n }\r\n}\r\n\r\nfile class FXParticleFloatConfigPopup : PopupWidget\r\n{\r\n SerializedObject SerializedObject;\r\n SerializedProperty Type;\r\n SerializedProperty Eval;\r\n\r\n public FXParticleFloatConfigPopup(SerializedObject target, Widget parent) : base(parent)\r\n {\r\n Layout = Layout.Column();\r\n Layout.Spacing = 8;\r\n Layout.Margin = 16;\r\n\r\n SerializedObject = target;\r\n Type = target.GetProperty(\"Type\");\r\n Eval = target.GetProperty(\"Evaluation\");\r\n\r\n AddQuickModes(Type.GetValue<ParticleFloat.ValueType>(), Eval.GetValue<ParticleFloat.EvaluationType>());\r\n }\r\n\r\n void AddQuickModes(ParticleFloat.ValueType type, ParticleFloat.EvaluationType eval)\r\n {\r\n var grid = new GridLayout();\r\n grid.Spacing = 8;\r\n\r\n grid.AddCell(0, 0, MakeQuickMode(\"radio_button_unchecked\", \"Constant\", \"Value is constant. It stays the same. It doesn't change\", ParticleFloat.ValueType.Constant, ParticleFloat.EvaluationType.Life));\r\n grid.AddCell(1, 0, MakeQuickMode(\"casino\", \"Random\", \"Choose a value between two constants every frame\", ParticleFloat.ValueType.Range, ParticleFloat.EvaluationType.Frame));\r\n\r\n grid.AddCell(0, 1, MakeQuickMode(\"hdr_strong\", \"Range\", \"Choose a value between two constants\", ParticleFloat.ValueType.Range, ParticleFloat.EvaluationType.Seed));\r\n grid.AddCell(1, 1, MakeQuickMode(\"animation\", \"Lerp\", \"Lerp between two values over the lifetime of the particle\", ParticleFloat.ValueType.Range, ParticleFloat.EvaluationType.Life));\r\n\r\n grid.AddCell(0, 2, MakeQuickMode(\"show_chart\", \"Curve\", \"Get the value by querying a curve over the particle's lifetime\", ParticleFloat.ValueType.Curve, ParticleFloat.EvaluationType.Life));\r\n grid.AddCell(1, 2, MakeQuickMode(\"area_chart\", \"Curve with Range\", \"Choose a path between two curves - over the lifetime of the particle\", ParticleFloat.ValueType.CurveRange, ParticleFloat.EvaluationType.Life));\r\n\r\n Layout.Add(grid);\r\n }\r\n\r\n private Widget MakeQuickMode(string icon, string label, string description, ParticleFloat.ValueType t, ParticleFloat.EvaluationType e)\r\n {\r\n var b = new Widget();\r\n\r\n b.Cursor = CursorShape.Finger;\r\n b.Layout = new IconTitleDescriptionLayout(icon, label, description);\r\n b.Layout.Margin = new Sandbox.UI.Margin(8, 4);\r\n b.SetStyles(\"color: #ffffff;\");\r\n\r\n bool isCurrent = t == Type.GetValue<ParticleFloat.ValueType>() && e == Eval.GetValue<ParticleFloat.EvaluationType>();\r\n\r\n b.MouseClick += () =>\r\n {\r\n Type.SetValue(t);\r\n Eval.SetValue(e);\r\n Close();\r\n };\r\n\r\n b.OnPaintOverride = () =>\r\n {\r\n if (isCurrent || Paint.HasMouseOver)\r\n {\r\n Paint.SetBrushAndPen(Theme.Blue.Darken(0.5f).WithAlpha(0.5f));\r\n Paint.DrawRect(Paint.LocalRect, 4);\r\n }\r\n\r\n return true;\r\n };\r\n\r\n return b;\r\n }\r\n}\r\n\r\nfile class IconTitleDescriptionLayout : GridLayout\r\n{\r\n public IconTitleDescriptionLayout(string icon, string title, string description)\r\n {\r\n VerticalSpacing = 0;\r\n HorizontalSpacing = 8;\r\n\r\n var iconLabel = AddCell(0, 0, new IconButton(icon) { Background = Color.Transparent, IconSize = 33, FixedSize = 40, TransparentForMouseEvents = true }, ySpan: 2);\r\n var titleLabel = AddCell(1, 0, new Label(title));\r\n var descLabel = AddCell(1, 1, new Label(description) { WordWrap = true });\r\n\r\n titleLabel.SetStyles(\"font-size: 13px; font-family: Poppins; font-weight: bold;\");\r\n descLabel.SetStyles(\"font-size: 9px; font-family: Poppins;\");\r\n descLabel.SetEffectOpacity(0.5f);\r\n }\r\n}\r\n\r\n[CustomEditor(typeof(FXParticleVector))]\r\npublic class FXParticleVectorControlWidget : ControlWidget\r\n{\r\n private SerializedObject Target;\r\n private Button ToggleButton;\r\n private Layout ControlArea;\r\n\r\n public FXParticleVectorControlWidget(SerializedProperty property) : base(property)\r\n {\r\n SetSizeMode(SizeMode.Ignore, SizeMode.Default);\r\n\r\n if (!property.TryGetAsObject(out Target))\r\n return;\r\n\r\n Layout = Layout.Row();\r\n Layout.Spacing = 3;\r\n\r\n Layout.AddStretchCell();\r\n\r\n // Add toggle button for UseParameter\r\n ToggleButton = new Button();\r\n ToggleButton.Text = \"P\";\r\n ToggleButton.ToolTip = \"Toggle Parameter Mode\";\r\n ToggleButton.FixedWidth = Theme.RowHeight;\r\n ToggleButton.Pressed = () => ToggleParameterMode();\r\n ToggleButton.OnPaintOverride = PaintToggleButton;\r\n Layout.Add(ToggleButton);\r\n\r\n ControlArea = Layout.AddRow(1);\r\n ControlArea.Spacing = 2;\r\n\r\n Target.OnPropertyChanged += (p) =>\r\n {\r\n if (p.Name == \"UseParameter\")\r\n {\r\n Rebuild();\r\n }\r\n };\r\n\r\n Rebuild();\r\n }\r\n\r\n private void ToggleParameterMode()\r\n {\r\n var useParam = Target.GetProperty(\"UseParameter\");\r\n useParam.SetValue(!useParam.GetValue<bool>());\r\n Rebuild();\r\n }\r\n\r\n private bool PaintToggleButton()\r\n {\r\n Paint.Antialiasing = true;\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n\r\n if (Paint.HasPressed)\r\n {\r\n Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);\r\n Paint.Pen = useParam ? Theme.Blue.Lighten(0.4f) : Theme.TextControl.Lighten(0.4f);\r\n }\r\n else if (Paint.HasMouseOver)\r\n {\r\n Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);\r\n Paint.Pen = useParam ? Theme.Blue.Lighten(0.5f) : Theme.TextControl.Lighten(0.5f);\r\n }\r\n else\r\n {\r\n Paint.Pen = useParam ? Theme.Blue : Theme.TextControl.WithAlpha(0.5f);\r\n }\r\n\r\n Paint.DrawIcon(Paint.LocalRect, \"tune\", 15, TextFlag.Center);\r\n\r\n return true;\r\n }\r\n\r\n void Rebuild()\r\n {\r\n ControlArea.Clear(true);\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n\r\n if (useParam)\r\n {\r\n // Parameter mode - show parameter selector and multiplier\r\n var paramName = Target.GetProperty(\"ParameterName\");\r\n var multiplier = Target.GetProperty(\"Multiplier\");\r\n\r\n var paramControl = new VectorParameterNameControlWidget(paramName);\r\n ControlArea.Add(paramControl, 1);\r\n\r\n var multControl = new FloatControlWidget(multiplier)\r\n {\r\n HighlightColor = Theme.Highlight,\r\n Label = \"\u00d7\"\r\n };\r\n ControlArea.Add(multControl);\r\n }\r\n else\r\n {\r\n // Normal mode - show vector control\r\n var valueProp = Target.GetProperty(\"Value\");\r\n var control = ControlWidget.Create(valueProp);\r\n if (control != null)\r\n {\r\n ControlArea.Add(control, 1);\r\n }\r\n }\r\n\r\n Update();\r\n }\r\n\r\n protected override void OnPaint()\r\n {\r\n }\r\n}\r\n\r\n[CustomEditor(typeof(FXParticleColor))]\r\npublic class FXParticleColorControlWidget : ControlWidget\r\n{\r\n private SerializedObject Target;\r\n private Button ToggleButton;\r\n private Layout ControlArea;\r\n\r\n public FXParticleColorControlWidget(SerializedProperty property) : base(property)\r\n {\r\n SetSizeMode(SizeMode.Ignore, SizeMode.Default);\r\n\r\n if (!property.TryGetAsObject(out Target))\r\n return;\r\n\r\n Layout = Layout.Row();\r\n Layout.Spacing = 3;\r\n\r\n Layout.AddStretchCell();\r\n\r\n // Add toggle button for UseParameter\r\n ToggleButton = new Button();\r\n ToggleButton.Text = \"P\";\r\n ToggleButton.ToolTip = \"Toggle Parameter Mode\";\r\n ToggleButton.FixedWidth = Theme.RowHeight;\r\n ToggleButton.Pressed = () => ToggleParameterMode();\r\n ToggleButton.OnPaintOverride = PaintToggleButton;\r\n Layout.Add(ToggleButton);\r\n\r\n ControlArea = Layout.AddRow(1);\r\n ControlArea.Spacing = 2;\r\n\r\n Target.OnPropertyChanged += (p) =>\r\n {\r\n if (p.Name == \"UseParameter\")\r\n {\r\n Rebuild();\r\n }\r\n };\r\n\r\n Rebuild();\r\n }\r\n\r\n private void ToggleParameterMode()\r\n {\r\n var useParam = Target.GetProperty(\"UseParameter\");\r\n useParam.SetValue(!useParam.GetValue<bool>());\r\n Rebuild();\r\n }\r\n\r\n private bool PaintToggleButton()\r\n {\r\n Paint.Antialiasing = true;\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n\r\n if (Paint.HasPressed)\r\n {\r\n Paint.SetBrushAndPen(Theme.ControlBackground.Lighten(0.3f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(0.5f), Theme.ControlRadius);\r\n Paint.Pen = useParam ? Theme.Blue.Lighten(0.4f) : Theme.TextControl.Lighten(0.4f);\r\n }\r\n else if (Paint.HasMouseOver)\r\n {\r\n Paint.SetBrushAndPen(Theme.TextControl.Lighten(0.2f).WithAlpha(0.1f));\r\n Paint.DrawRect(Paint.LocalRect.Shrink(1), Theme.ControlRadius);\r\n Paint.Pen = useParam ? Theme.Blue.Lighten(0.5f) : Theme.TextControl.Lighten(0.5f);\r\n }\r\n else\r\n {\r\n Paint.Pen = useParam ? Theme.Blue : Theme.TextControl.WithAlpha(0.5f);\r\n }\r\n\r\n Paint.DrawIcon(Paint.LocalRect, \"tune\", 15, TextFlag.Center);\r\n\r\n return true;\r\n }\r\n\r\n void Rebuild()\r\n {\r\n ControlArea.Clear(true);\r\n\r\n var useParam = Target.GetProperty(\"UseParameter\").GetValue<bool>();\r\n\r\n if (useParam)\r\n {\r\n // Parameter mode - show parameter selector and multiplier\r\n var paramName = Target.GetProperty(\"ParameterName\");\r\n var multiplier = Target.GetProperty(\"Multiplier\");\r\n\r\n var paramControl = new ColorParameterNameControlWidget(paramName);\r\n ControlArea.Add(paramControl, 1);\r\n\r\n var multControl = new FloatControlWidget(multiplier)\r\n {\r\n HighlightColor = Theme.Highlight,\r\n Label = \"\u00d7\"\r\n };\r\n ControlArea.Add(multControl);\r\n }\r\n else\r\n {\r\n // Normal mode - show color control\r\n var valueProp = Target.GetProperty(\"Value\");\r\n var control = ControlWidget.Create(valueProp);\r\n if (control != null)\r\n {\r\n ControlArea.Add(control, 1);\r\n }\r\n }\r\n\r\n Update();\r\n }\r\n\r\n protected override void OnPaint()\r\n {\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Code/ParticleResource.cs",
"FileName": "ParticleResource.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.ComponentModel;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\n\r\nnamespace fxbox;\r\n\r\n/// <summary>\r\n/// Particle system resource containing multiple emitters\r\n/// </summary>\r\n[AssetType(Name = \"Particle System\", Extension = \"fx\", Category = \"FX\", Flags = AssetTypeFlags.NoEmbedding)]\r\npublic class ParticleResource : GameResource\r\n{\r\n public bool IsDirty { get; set; } = false;\r\n \r\n /// <summary>\r\n /// All emitters in this particle system\r\n /// </summary>\r\n public List<ParticleEmitter> Emitters { get; set; } = new();\r\n\r\n /// <summary>\r\n /// Named float parameters\r\n /// </summary>\r\n [InlineEditor, DisplayName(\"FloatParameters\")] public List<FloatParameter> FloatParameters { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Named vector parameters\r\n /// </summary>\r\n [InlineEditor] public List<VectorParameter> VectorParameters { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Named color parameters\r\n /// </summary>\r\n [InlineEditor] public List<ColorParameter> ColorParameters { get; set; } = new();\r\n\r\n /// <summary>\r\n /// Global system properties\r\n /// </summary>\r\n public float Duration { get; set; } = 5.0f;\r\n public bool Looping { get; set; } = true;\r\n\r\n public int Version { get; set; } = 0;\r\n \r\n /// <summary>\r\n /// Preview settings for the editor\r\n /// </summary>\r\n public ParticlePreviewSettings PreviewSettings { get; set; } = new();\r\n \r\n /// <summary>\r\n /// Get a float parameter's default value by name\r\n /// </summary>\r\n public float GetParameterDefault(string name)\r\n {\r\n var param = FloatParameters.FirstOrDefault(p => p.Name == name);\r\n return param?.DefaultValue ?? 0f;\r\n }\r\n \r\n /// <summary>\r\n /// Get a vector parameter's default value by name\r\n /// </summary>\r\n public Vector3 GetVectorParameterDefault(string name)\r\n {\r\n var param = VectorParameters.FirstOrDefault(p => p.Name == name);\r\n return param?.DefaultValue ?? Vector3.Zero;\r\n }\r\n \r\n /// <summary>\r\n /// Get a color parameter's default value by name\r\n /// </summary>\r\n public ParticleGradient GetColorParameterDefault(string name)\r\n {\r\n var param = ColorParameters.FirstOrDefault(p => p.Name == name);\r\n return param?.DefaultValue ?? Color.White;\r\n }\r\n \r\n /// <summary>\r\n /// Add a new float parameter\r\n /// </summary>\r\n public FloatParameter AddParameter(string name, float defaultValue = 1.0f)\r\n {\r\n var param = new FloatParameter\r\n {\r\n Name = name,\r\n DefaultValue = defaultValue\r\n };\r\n FloatParameters.Add(param);\r\n return param;\r\n }\r\n \r\n /// <summary>\r\n /// Add a new vector parameter\r\n /// </summary>\r\n public VectorParameter AddVectorParameter(string name, Vector3 defaultValue)\r\n {\r\n var param = new VectorParameter\r\n {\r\n Name = name,\r\n DefaultValue = defaultValue\r\n };\r\n VectorParameters.Add(param);\r\n return param;\r\n }\r\n \r\n /// <summary>\r\n /// Add a new color parameter\r\n /// </summary>\r\n public ColorParameter AddColorParameter(string name, Color defaultValue)\r\n {\r\n var param = new ColorParameter\r\n {\r\n Name = name,\r\n DefaultValue = defaultValue\r\n };\r\n ColorParameters.Add(param);\r\n return param;\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// Preview settings for the particle editor\r\n/// </summary>\r\npublic class ParticlePreviewSettings\r\n{\r\n public bool ShowGround { get; set; } = true;\r\n public bool ShowGrid { get; set; } = true;\r\n public Color BackgroundColor { get; set; } = new Color(0.1f, 0.1f, 0.15f);\r\n public float PlaybackSpeed { get; set; } = 1.0f;\r\n}\r\n/// <summary>\r\n/// A single particle emitter with its own spawn and update logic\r\n/// </summary>\r\npublic class ParticleEmitter\r\n{\r\n\tpublic string Name { get; set; } = \"Emitter\";\r\n\t[Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n\tpublic bool Enabled { get; set; } = true;\r\n\tpublic int MaxParticles { get; set; } = 1000;\r\n\r\n\t/// <summary>\r\n\t/// Seconds to wait before this emitter starts spawning - lets one emitter in the same\r\n\t/// system kick off a few seconds after another. Maps straight onto the native\r\n\t/// ParticleEffect.StartDelay, so it's handled by the engine's own particle system rather\r\n\t/// than anything FXBox has to gate itself.\r\n\t/// </summary>\r\n\tpublic float Delay { get; set; } = 0f;\r\n\r\n\t/// <summary>\r\n\t/// Overrides the system's own Duration for deciding when THIS emitter is finished\r\n\t/// (see FXBoxParticleController.IsFinished) - 0 means \"use the particle system's own\r\n\t/// Duration instead\", the same as before this existed. Lets one emitter in a system run\r\n\t/// longer or shorter than the rest without touching the system-wide Duration.\r\n\t/// </summary>\r\n\tpublic float Duration { get; set; } = 0f;\r\n\r\n\t/// <summary>\r\n\t/// Modules that run when spawning particles\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> SpawnModules { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Modules that run once when a particle is created\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> InitializeModules { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Modules that run every frame for each particle\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> UpdateModules { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Modules that control how particles are rendered\r\n\t/// </summary>\r\n\t[JsonConverter(typeof(ParticleModuleListConverter)), Hide]\r\n\tpublic List<ParticleModule> RenderModules { get; set; } = new();\r\n}\r\n/// <summary>\r\n/// Base class for all particle modules\r\n/// </summary>\r\npublic abstract class ParticleModule\r\n{\r\n [Hide] public string Identifier { get; set; } = Guid.NewGuid().ToString();\r\n [Hide] public string Name { get; set; }\r\n [Hide] public bool Enabled { get; set; } = true;\r\n\r\n /// <summary>\r\n /// What stage this module belongs to\r\n /// </summary>\r\n [JsonIgnore]\r\n public abstract ModuleStage Stage { get; }\r\n\r\n /// <summary>\r\n /// Execute this module\r\n /// </summary>\r\n public abstract void Execute(ParticleExecutionContext context);\r\n\r\n public abstract void Initialize( ParticleExecutionContext context );\r\n}\r\n\r\n/// <summary>\r\n/// JSON converter for List of ParticleModule\r\n/// </summary>\r\npublic class ParticleModuleListConverter : JsonConverter<List<ParticleModule>>\r\n{\r\n public override List<ParticleModule> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\r\n {\r\n var list = new List<ParticleModule>();\r\n \r\n if (reader.TokenType != JsonTokenType.StartArray)\r\n throw new JsonException(\"Expected start of array\");\r\n \r\n while (reader.Read())\r\n {\r\n if (reader.TokenType == JsonTokenType.EndArray)\r\n break;\r\n \r\n using (var doc = JsonDocument.ParseValue(ref reader))\r\n {\r\n var root = doc.RootElement;\r\n \r\n // Get the type name\r\n if (!root.TryGetProperty(\"$type\", out var typeProperty))\r\n {\r\n Log.Warning(\"Missing $type property for ParticleModule\");\r\n continue;\r\n }\r\n \r\n var typeName = typeProperty.GetString();\r\n var type = TypeLibrary.GetType(typeName)?.TargetType;\r\n \r\n if (type == null)\r\n {\r\n Log.Warning($\"Unknown module type: {typeName}\");\r\n continue;\r\n }\r\n \r\n // Deserialize to the specific type\r\n var json = root.GetRawText();\r\n var module = (ParticleModule)JsonSerializer.Deserialize(json, type, options);\r\n if (module != null)\r\n {\r\n list.Add(module);\r\n }\r\n }\r\n }\r\n \r\n return list;\r\n }\r\n\r\n public override void Write(Utf8JsonWriter writer, List<ParticleModule> value, JsonSerializerOptions options)\r\n {\r\n writer.WriteStartArray();\r\n \r\n foreach (var module in value)\r\n {\r\n if (module == null) continue;\r\n \r\n writer.WriteStartObject();\r\n \r\n // Write the type information\r\n writer.WriteString(\"$type\", module.GetType().FullName);\r\n \r\n // Serialize the module\r\n var json = JsonSerializer.Serialize(module, module.GetType(), options);\r\n using (var doc = JsonDocument.Parse(json))\r\n {\r\n foreach (var property in doc.RootElement.EnumerateObject())\r\n {\r\n property.WriteTo(writer);\r\n }\r\n }\r\n \r\n writer.WriteEndObject();\r\n }\r\n \r\n writer.WriteEndArray();\r\n }\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"FXBox\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"fxbox\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"stellawisps\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"stellawisps.fxbox\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"29\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-09-10T16:47:44.5430399Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.212.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.212.0\")]"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Code/ParticleValues.cs",
"FileName": "ParticleValues.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 379493,
"Code": "using System;\r\nusing Sandbox;\r\nusing System.Linq;\r\n\r\nnamespace fxbox;\r\npublic class FXParticleFloat\r\n{\r\n [Property] public bool UseParameter { get; set; } = false;\r\n \r\n [Property, ShowIf(nameof(UseParameter), false)]\r\n public ParticleFloat Value { get; set; }\r\n \r\n [Property, ShowIf(nameof(UseParameter), true)]\r\n [Description(\"Select a parameter from the system\")]\r\n public string ParameterName { get; set; }\r\n \r\n [Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]\r\n [Description(\"Multiplier applied to the parameter value\")]\r\n public float Multiplier { get; set; } = 1.0f;\r\n \r\n public float GetValue(FXBoxNativeParticleSystem systemComponent = null)\r\n {\r\n\t if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n\t {\r\n\t\t return systemComponent.GetFloatParameter(ParameterName) * Multiplier;\r\n\t }\r\n\t \r\n\t var result = Value.Evaluate(Random.Shared.Float(), 3f);\r\n\t \r\n\t return result;\r\n }\r\n \r\n public ParticleFloat ToParticleFloat(FXBoxNativeParticleSystem systemComponent = null)\r\n {\r\n if (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n {\r\n // Get the instance-specific value (override or default)\r\n float value = systemComponent.GetFloatParameter(ParameterName) * Multiplier;\r\n \r\n return new ParticleFloat()\r\n {\r\n Type = ParticleFloat.ValueType.Constant,\r\n ConstantValue = value,\r\n Evaluation = ParticleFloat.EvaluationType.Seed\r\n };\r\n }\r\n \r\n return Value;\r\n }\r\n \r\n // ==================== OPERATORS ====================\r\n \r\n \r\n public static FXParticleFloat operator *(float a, FXParticleFloat b)\r\n {\r\n return b * a; // Commutative\r\n }\r\n \r\n // Division operators\r\n public static FXParticleFloat operator /(FXParticleFloat a, float b)\r\n{\r\n if (a == null)\r\n {\r\n Log.Warning(\"Division: a is null\");\r\n return null;\r\n }\r\n \r\n if (b == 0 || MathF.Abs(b) < 0.0001f)\r\n {\r\n Log.Warning($\"Division by zero or very small number ({b}) in FXParticleFloat\");\r\n return a;\r\n }\r\n \r\n \r\n var result = a * (1.0f / b);\r\n \r\n return result;\r\n}\r\n\r\npublic static FXParticleFloat operator *(FXParticleFloat a, float b)\r\n{\r\n if (a == null)\r\n {\r\n Log.Warning(\"Multiplication: a is null\");\r\n return null;\r\n }\r\n \r\n var result = new FXParticleFloat();\r\n \r\n if (a.UseParameter)\r\n {\r\n result.UseParameter = true;\r\n result.ParameterName = a.ParameterName;\r\n result.Multiplier = a.Multiplier * b;\r\n result.Value = new ParticleFloat()\r\n {\r\n Type = ParticleFloat.ValueType.Constant,\r\n ConstantValue = 0f,\r\n Evaluation = ParticleFloat.EvaluationType.Seed\r\n };\r\n }\r\n else\r\n {\r\n result.UseParameter = false;\r\n result.Value = ScaleParticleFloat(a.Value, b);\r\n }\r\n \r\n return result;\r\n}\r\n\r\nprivate static ParticleFloat ScaleParticleFloat(ParticleFloat pf, float scale)\r\n{\r\n \r\n var result = new ParticleFloat();\r\n result.Type = pf.Type;\r\n result.Evaluation = pf.Evaluation;\r\n \r\n // Copy Constants FIRST, before setting individual values\r\n // (or don't copy it at all since we're setting the values manually)\r\n // result.Constants = pf.Constants;\r\n \r\n switch (pf.Type)\r\n {\r\n case ParticleFloat.ValueType.Constant:\r\n result.ConstantValue = pf.ConstantValue * scale;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Range:\r\n result.ConstantA = pf.ConstantA * scale;\r\n result.ConstantB = pf.ConstantB * scale;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Curve:\r\n result.CurveA = ScaleCurve(pf.CurveA, scale);\r\n result.CurveB = ScaleCurve(pf.CurveB, scale);\r\n break;\r\n \r\n case ParticleFloat.ValueType.CurveRange:\r\n result.CurveRange = ScaleCurveRange(pf.CurveRange, scale);\r\n break;\r\n }\r\n \r\n // DON'T copy Constants here - it overwrites our values!\r\n // result.Constants = pf.Constants;\r\n \r\n return result;\r\n}\r\n\r\nprivate static ParticleFloat OffsetParticleFloat(ParticleFloat pf, float offset)\r\n{\r\n var result = new ParticleFloat();\r\n result.Type = pf.Type;\r\n result.Evaluation = pf.Evaluation;\r\n \r\n // DON'T copy Constants - it will overwrite our values\r\n // result.Constants = pf.Constants;\r\n \r\n switch (pf.Type)\r\n {\r\n case ParticleFloat.ValueType.Constant:\r\n result.ConstantValue = pf.ConstantValue + offset;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Range:\r\n result.ConstantA = pf.ConstantA + offset;\r\n result.ConstantB = pf.ConstantB + offset;\r\n break;\r\n \r\n case ParticleFloat.ValueType.Curve:\r\n result.CurveA = OffsetCurve(pf.CurveA, offset);\r\n result.CurveB = OffsetCurve(pf.CurveB, offset);\r\n break;\r\n \r\n case ParticleFloat.ValueType.CurveRange:\r\n result.CurveRange = OffsetCurveRange(pf.CurveRange, offset);\r\n break;\r\n }\r\n \r\n // DON'T copy Constants here!\r\n // result.Constants = pf.Constants;\r\n \r\n return result;\r\n}\r\n \r\n private static Curve ScaleCurve(Curve curve, float scale)\r\n {\r\n var newFrames = curve.Frames.Select(frame => \r\n new Curve.Frame(frame.Time, frame.Value * scale)).ToArray();\r\n \r\n return new Curve(newFrames);\r\n }\r\n \r\n private static Curve OffsetCurve(Curve curve, float offset)\r\n {\r\n var newFrames = curve.Frames.Select(frame => \r\n new Curve.Frame(frame.Time, frame.Value + offset)).ToArray();\r\n \r\n return new Curve(newFrames);\r\n }\r\n \r\n private static CurveRange ScaleCurveRange(CurveRange range, float scale)\r\n {\r\n return new CurveRange\r\n (\r\n ScaleCurve(range.A, scale),\r\n ScaleCurve(range.B, scale)\r\n );\r\n }\r\n \r\n private static CurveRange OffsetCurveRange(CurveRange range, float offset)\r\n {\r\n return new CurveRange\r\n (\r\n OffsetCurve(range.A, offset),\r\n OffsetCurve(range.B, offset)\r\n );\r\n }\r\n \r\n // ==================== IMPLICIT CONVERSIONS ====================\r\n \r\n public static implicit operator FXParticleFloat(float v)\r\n {\r\n var fxParticle = new FXParticleFloat();\r\n fxParticle.Value = new ParticleFloat()\r\n {\r\n Type = ParticleFloat.ValueType.Constant,\r\n ConstantValue = v,\r\n Evaluation = ParticleFloat.EvaluationType.Seed\r\n };\r\n return fxParticle;\r\n }\r\n\r\n // ==================== CONSTRUCTORS ====================\r\n \r\n public FXParticleFloat()\r\n {\r\n var particleFloat = new ParticleFloat();\r\n particleFloat.Type = ParticleFloat.ValueType.Constant;\r\n particleFloat.ConstantValue = 0.0f;\r\n particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;\r\n particleFloat.CurveA = new Curve();\r\n particleFloat.CurveB = new Curve();\r\n particleFloat.Constants = new Vector4();\r\n this.Value = particleFloat;\r\n }\r\n\r\n public FXParticleFloat(float a, float b)\r\n {\r\n var particleFloat = new ParticleFloat();\r\n particleFloat.Type = ParticleFloat.ValueType.Range;\r\n particleFloat.ConstantA = a;\r\n particleFloat.ConstantB = b;\r\n particleFloat.Evaluation = ParticleFloat.EvaluationType.Seed;\r\n particleFloat.CurveA = new Curve();\r\n particleFloat.CurveB = new Curve();\r\n particleFloat.Constants = new Vector4();\r\n this.Value = particleFloat;\r\n }\r\n}\r\n\r\npublic class FXParticleVector\r\n{\r\n\t[Property] public bool UseParameter { get; set; } = false;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), false)]\r\n\tpublic ParticleVector3 Value { get; set; } = Vector3.Zero;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true)]\r\n\t[Description(\"Select a vector parameter from the system\")]\r\n\tpublic string ParameterName { get; set; }\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]\r\n\t[Description(\"Multiplier applied to the parameter value\")]\r\n\tpublic float Multiplier { get; set; } = 1.0f;\r\n \r\n\tpublic Vector3 GetValue(Particle particle,FXBoxNativeParticleSystem systemComponent = null)\r\n\t{\r\n\t\tif (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n\t\t{\r\n\t\t\treturn systemComponent.GetVectorParameter(ParameterName) * Multiplier;\r\n\t\t}\r\n\r\n\t\tif ( particle == null )\r\n\t\t{\r\n\t\t\treturn Value.Evaluate( Time.Delta, 0, 0, 0 );\r\n\t\t}\r\n\t\treturn Value.Evaluate( Time.Delta,particle.Rand(1),particle.Rand(2),particle.Rand(3) );\r\n\t}\r\n \r\n\tpublic static implicit operator FXParticleVector(Vector3 v)\r\n\t{\r\n\t\treturn new FXParticleVector { Value = v };\r\n\t}\r\n\r\n\tpublic FXParticleVector()\r\n\t{\r\n\t\tValue = Vector3.Zero;\r\n\t}\r\n\r\n\tpublic FXParticleVector(Vector3 value)\r\n\t{\r\n\t\tValue = value;\r\n\t}\r\n \r\n\tpublic FXParticleVector(float x, float y, float z)\r\n\t{\r\n\t\tValue = new Vector3(x, y, z);\r\n\t}\r\n}\r\n\r\npublic class FXParticleColor\r\n{\r\n\t[Property] public bool UseParameter { get; set; } = false;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), false)]\r\n\tpublic ParticleGradient Value { get; set; } = Color.White;\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true)]\r\n\t[Description(\"Select a color parameter from the system\")]\r\n\tpublic string ParameterName { get; set; }\r\n \r\n\t[Property, ShowIf(nameof(UseParameter), true), Range(0f, 10f)]\r\n\t[Description(\"Multiplier applied to the parameter value (affects RGB)\")]\r\n\tpublic float Multiplier { get; set; } = 1.0f;\r\n \r\n\tpublic ParticleGradient GetValue(FXBoxNativeParticleSystem systemComponent = null)\r\n\t{\r\n\t\tif (UseParameter && !string.IsNullOrEmpty(ParameterName) && systemComponent != null)\r\n\t\t{\r\n\t\t\tvar color = systemComponent.GetColorParameter(ParameterName);\r\n \r\n\t\t\t// Apply multiplier to RGB components\r\n\t\t\tif (Multiplier != 1.0f)\r\n\t\t\t{\r\n\t\t\t\treturn color;\r\n\t\t\t}\r\n \r\n\t\t\treturn color;\r\n\t\t}\r\n \r\n\t\treturn Value;\r\n\t}\r\n \r\n\tpublic static implicit operator FXParticleColor(Color c)\r\n\t{\r\n\t\treturn new FXParticleColor { Value = c };\r\n\t}\r\n\r\n\tpublic FXParticleColor()\r\n\t{\r\n\t\tValue = Color.White;\r\n\t}\r\n\r\n\tpublic FXParticleColor(Color value)\r\n\t{\r\n\t\tValue = value;\r\n\t}\r\n \r\n\tpublic FXParticleColor(float r, float g, float b, float a = 1.0f)\r\n\t{\r\n\t\tValue = new Color(r, g, b, a);\r\n\t}\r\n}\r\n"
},
{
"Ident": "stellawisps.fxbox",
"Path": "Editor/FXParticlePropertyControlWidget.cs",
"FileName": "FXParticlePropertyControlWidget.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 379493,
"Code": "using Editor;\r\nusing Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing fxbox;\r\nusing fxbox.Graph;\r\n\r\nnamespace Editor;\r\n\r\n/// <summary>\r\n/// Custom control for parameter name selection with dropdown\r\n/// </summary>\r\npublic class ParameterNameControlWidget : ControlWidget\r\n{\r\n private SerializedProperty _property;\r\n private ComboBox _dropdown;\r\n private List<FloatParameter> _availableParameters;\r\n\r\n public ParameterNameControlWidget(SerializedProperty property) : base(property)\r\n {\r\n _property = property;\r\n \r\n SetSizeMode(SizeMode.Ignore, SizeMode.Default);\r\n\r\n Layout = Layout.Row();\r\n Layout.Spacing = 3;\r\n\r\n // Get available parameters from the currently editing resource\r\n _availableParameters = FXBoxEditor.CurrentEditingResource?.FloatParameters ?? new List<FloatParameter>();\r\n\r\n _dropdown = new ComboBox(this);\r\n _dropdown.MinimumWidth = 150;\r\n \r\n PopulateDropdown();\r\n \r\n // Set current value\r\n var currentValue = property.GetValue<string>();\r\n if (!string.IsNullOrEmpty(currentValue))\r\n {\r\n SelectParameter(currentValue);\r\n }\r\n\r\n _dropdown.ItemChanged += OnValueChanged;\r\n //_dropdown.On += OnValueChanged;\r\n\r\n Layout.Add(_dropdown, 1);\r\n }\r\n\r\n private void PopulateDropdown()\r\n {\r\n _dropdown.Clear();\r\n \r\n if (_availableParameters == null || _availableParameters.Count == 0)\r\n {\r\n _dropdown.AddItem(\"(No Parameters Available)\");\r\n _dropdown.Enabled = false;\r\n return;\r\n }\r\n\r\n _dropdown.AddItem(\"(None)\");\r\n \r\n foreach (var param in _availableParameters.OrderBy(p => p.Name))\r\n {\r\n _dropdown.AddItem($\"{param.Name} (default: {param.DefaultValue})\");\r\n }\r\n \r\n _dropdown.Enabled = true;\r\n }\r\n\r\n private void SelectParameter(string parameterName)\r\n {\r\n if (string.IsNullOrEmpty(parameterName))\r\n {\r\n _dropdown.CurrentIndex = 0;\r\n return;\r\n }\r\n\r\n // Find the index by matching parameter name\r\n var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();\r\n for (int i = 0; i < orderedParams.Count; i++)\r\n {\r\n if (orderedParams[i].Name == parameterName)\r\n {\r\n _dropdown.CurrentIndex = i + 1; // +1 because of \"(None)\" at index 0\r\n return;\r\n }\r\n }\r\n }\r\n\r\n private new void OnValueChanged()\r\n {\r\n var selectedIndex = _dropdown.CurrentIndex;\r\n \r\n if (selectedIndex <= 0)\r\n {\r\n // \"(None)\" selected\r\n _property.SetValue(\"\");\r\n return;\r\n }\r\n \r\n // Get the parameter name from the selected item\r\n var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();\r\n if (selectedIndex - 1 < orderedParams.Count)\r\n {\r\n var selectedParam = orderedParams[selectedIndex - 1];\r\n _property.SetValue(selectedParam.Name);\r\n }\r\n }\r\n\r\n protected override void OnPaint()\r\n {\r\n // No custom painting\r\n }\r\n}\r\n\r\npublic class VectorParameterNameControlWidget : ControlWidget\r\n{\r\n private SerializedProperty _property;\r\n private ComboBox _dropdown;\r\n private List<VectorParameter> _availableParameters;\r\n\r\n public VectorParameterNameControlWidget(SerializedProperty property) : base(property)\r\n {\r\n _property = property;\r\n \r\n SetSizeMode(SizeMode.Ignore, SizeMode.Default);\r\n\r\n Layout = Layout.Row();\r\n Layout.Spacing = 3;\r\n\r\n _availableParameters = FXBoxEditor.CurrentEditingResource?.VectorParameters ?? new List<VectorParameter>();\r\n\r\n _dropdown = new ComboBox(this);\r\n _dropdown.MinimumWidth = 150;\r\n \r\n PopulateDropdown();\r\n \r\n var currentValue = property.GetValue<string>();\r\n if (!string.IsNullOrEmpty(currentValue))\r\n {\r\n SelectParameter(currentValue);\r\n }\r\n\r\n _dropdown.ItemChanged += OnValueChanged;\r\n\r\n Layout.Add(_dropdown, 1);\r\n }\r\n\r\n private void PopulateDropdown()\r\n {\r\n _dropdown.Clear();\r\n \r\n if (_availableParameters == null || _availableParameters.Count == 0)\r\n {\r\n _dropdown.AddItem(\"(No Vector Parameters Available)\");\r\n _dropdown.Enabled = false;\r\n return;\r\n }\r\n\r\n _dropdown.AddItem(\"(None)\");\r\n \r\n foreach (var param in _availableParameters.OrderBy(p => p.Name))\r\n {\r\n _dropdown.AddItem($\"{param.Name} (default: {param.DefaultValue})\");\r\n }\r\n \r\n _dropdown.Enabled = true;\r\n }\r\n\r\n private void SelectParameter(string parameterName)\r\n {\r\n if (string.IsNullOrEmpty(parameterName))\r\n {\r\n _dropdown.CurrentIndex = 0;\r\n return;\r\n }\r\n\r\n var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();\r\n for (int i = 0; i < orderedParams.Count; i++)\r\n {\r\n if (orderedParams[i].Name == parameterName)\r\n {\r\n _dropdown.CurrentIndex = i + 1;\r\n return;\r\n }\r\n }\r\n }\r\n\r\n private new void OnValueChanged()\r\n {\r\n var selectedIndex = _dropdown.CurrentIndex;\r\n \r\n if (selectedIndex <= 0)\r\n {\r\n _property.SetValue(\"\");\r\n return;\r\n }\r\n \r\n var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();\r\n if (selectedIndex - 1 < orderedParams.Count)\r\n {\r\n var selectedParam = orderedParams[selectedIndex - 1];\r\n _property.SetValue(selectedParam.Name);\r\n }\r\n }\r\n\r\n protected override void OnPaint()\r\n {\r\n }\r\n}\r\n\r\npublic class ColorParameterNameControlWidget : ControlWidget\r\n{\r\n private SerializedProperty _property;\r\n private ComboBox _dropdown;\r\n private List<ColorParameter> _availableParameters;\r\n\r\n public ColorParameterNameControlWidget(SerializedProperty property) : base(property)\r\n {\r\n _property = property;\r\n \r\n SetSizeMode(SizeMode.Ignore, SizeMode.Default);\r\n\r\n Layout = Layout.Row();\r\n Layout.Spacing = 3;\r\n\r\n _availableParameters = FXBoxEditor.CurrentEditingResource?.ColorParameters ?? new List<ColorParameter>();\r\n\r\n _dropdown = new ComboBox(this);\r\n _dropdown.MinimumWidth = 150;\r\n \r\n PopulateDropdown();\r\n \r\n var currentValue = property.GetValue<string>();\r\n if (!string.IsNullOrEmpty(currentValue))\r\n {\r\n SelectParameter(currentValue);\r\n }\r\n\r\n _dropdown.ItemChanged += OnValueChanged;\r\n\r\n Layout.Add(_dropdown, 1);\r\n }\r\n\r\n private void PopulateDropdown()\r\n {\r\n _dropdown.Clear();\r\n \r\n if (_availableParameters == null || _availableParameters.Count == 0)\r\n {\r\n _dropdown.AddItem(\"(No Color Parameters Available)\");\r\n _dropdown.Enabled = false;\r\n return;\r\n }\r\n\r\n _dropdown.AddItem(\"(None)\");\r\n \r\n foreach (var param in _availableParameters.OrderBy(p => p.Name))\r\n {\r\n _dropdown.AddItem($\"{param.Name}\");\r\n }\r\n \r\n _dropdown.Enabled = true;\r\n }\r\n\r\n private void SelectParameter(string parameterName)\r\n {\r\n if (string.IsNullOrEmpty(parameterName))\r\n {\r\n _dropdown.CurrentIndex = 0;\r\n return;\r\n }\r\n\r\n var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();\r\n for (int i = 0; i < orderedParams.Count; i++)\r\n {\r\n if (orderedParams[i].Name == parameterName)\r\n {\r\n _dropdown.CurrentIndex = i + 1;\r\n return;\r\n }\r\n }\r\n }\r\n\r\n private new void OnValueChanged()\r\n {\r\n var selectedIndex = _dropdown.CurrentIndex;\r\n \r\n if (selectedIndex <= 0)\r\n {\r\n _property.SetValue(\"\");\r\n return;\r\n }\r\n \r\n var orderedParams = _availableParameters.OrderBy(p => p.Name).ToList();\r\n if (selectedIndex - 1 < orderedParams.Count)\r\n {\r\n var selectedParam = orderedParams[selectedIndex - 1];\r\n _property.SetValue(selectedParam.Name);\r\n }\r\n }\r\n\r\n protected override void OnPaint()\r\n {\r\n }\r\n}\r\n"
}
]
}