ParticleEffectsManager.cs

Editor component that gathers and manages ParticleEffect and ParticleEmitter children. It exposes controls for time scaling, pause/restart, playback scrubbing, emitting, and drives IPhaseDriven components from a chosen emitter.

File Access
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Sandbox;

[Title( "Particle Effects Manager" )]
[Category( "Particle" )]
[Description(
	"This component let's you manage the TimeScale , the pause and restart of every particle effect component present in the childrens of this component gameobject so it is easier to preview the whole system" )]
public sealed class ParticleEffectsManager : Component, Component.ExecuteInEditor
{
	[Property] public List<ParticleEffect> ParticleEffects { get; set; }

	[Property] public List<ParticleEmitter> ParticleEmitters { get; set; }

	[Property] List<IResetable> Resetables {get; set;}

	[Property] List<IPhaseDriven> PhaseDriven { get; set; }

	[Property, Group( "Parameters" )]
	public bool DrivePhaseFromEmitter { get; set; } = true;

	[Property, Group( "Parameters" ), ShowIf( nameof( DrivePhaseFromEmitter ), true )]
	public ParticleEmitter PhaseMaster { get; set; }

	[Property, Group( "Parameters" ), Range( -1f, 1f ), Step( 0.1f )]
	float TimeScale { get; set; } = 1f;

	[JsonIgnore] public bool IsPaused { get; set; }
	[JsonIgnore] public bool IsRestart { get; set; }
	
	

	[JsonIgnore]
	public float LongestDuration
	{
		get
		{
			var duration = 0f;
			foreach ( var emitter in ParticleEmitters )
			{
				if ( emitter.Duration.Evaluate(0,0) + emitter.Delay.Evaluate(0,0) > duration )
				{
					duration = emitter.Duration.Evaluate(0,0) + emitter.Delay.Evaluate(0,0);
				}
			}

			// foreach (var resetable in Resetables )
			// {
			// 	if(resetable.Duration > duration )
			// 	{
			// 		duration = resetable.Duration;
			// 	}
			// }

			return duration;
		}
	}

	[Property, Group( "Actions" )] private bool Emit { get; set; }

	[JsonIgnore, Range( 0f, 1f ), Step( 0.001f )]
	public float PlayBack { get; set; }

	[JsonIgnore] public bool IsPlayBack { get; set; }

	[Property, Range( 0, 10 ), Step( 0.001f )]
	public float DelayEffect { get; set; }

	public int ParticleCount
	{
		get
		{
			int count = 0;
			foreach ( var particleEffect in ParticleEffects )
			{
				count += particleEffect.ParticleCount;
			}

			return count;
		}
	}

	public float PlaybackTime
	{
		get
		{
			float playbackTime = 0;
			foreach ( var emitter in ParticleEmitters )
			{
				if ( emitter.IsValid() && emitter.time > playbackTime )
				{
					playbackTime = emitter.time;
				}
			}

			return playbackTime;
		}
	}
	
	protected override void OnEnabled()
	{
		SetParticleSystems();
		if ( Scene.IsEditor ) return;
		foreach ( var particleEmitter in ParticleEmitters )
		{
			particleEmitter.Delay = particleEmitter.Delay.Evaluate(0,0) + DelayEffect;
		}
	}


	protected override void OnUpdate()
	{
		PushPhase();

		if ( !Scene.IsEditor ) return;
		CheckForNullEmitters();
		if ( Emit ) EmitParticles();
		if ( IsRestart ) Restart();
		foreach ( var particleEffect in ParticleEffects )
		{
			CheckForNullEffect( particleEffect );
			if ( IsPlayBack )
			{
				PlayBacking( particleEffect );
			}
			else
			{
				TimeScaling( particleEffect );
			}
		}
	}

	[Button]
	[Group( "Actions" )]
	[Description( "Gathers all ParticleEffects and ParticleEmitters in this gameobject hierarchy" )]
	private void SetParticleSystems()
	{
		ParticleEffects = GameObject.GetComponentsInChildren<ParticleEffect>().ToList();
		ParticleEmitters = GameObject.GetComponentsInChildren<ParticleEmitter>().ToList();
		Resetables = GameObject.GetComponentsInChildren<IResetable>().ToList();
		PhaseDriven = GameObject.GetComponentsInChildren<IPhaseDriven>().ToList();
		_resolvedPhaseMaster = null;
	}

	private ParticleEmitter _resolvedPhaseMaster;

	// Single clock for the whole effect. The engine clamps the particle step to
	// 1/30s (ParticleGameSystem) and zeroes the emitter's time on each loop
	// instead of wrapping it, so anything keeping its own accumulator slides out
	// of phase a little every cycle - visibly, after a handful of loops in the
	// editor where frame hitches are routine. Slaving those components to the
	// emitter's own Delta makes that drift structurally impossible.
	private void PushPhase()
	{
		if ( !DrivePhaseFromEmitter ) return;
		if ( PhaseDriven is null || PhaseDriven.Count == 0 ) return;

		var master = ResolvePhaseMaster();
		if ( master is null ) return;

		var phase = master.Delta;

		foreach ( var driven in PhaseDriven )
		{
			if ( driven is null ) continue;
			driven.SetPhase( phase );
		}
	}

	private ParticleEmitter ResolvePhaseMaster()
	{
		if ( PhaseMaster.IsValid() ) return PhaseMaster;
		if ( _resolvedPhaseMaster.IsValid() ) return _resolvedPhaseMaster;
		if ( ParticleEmitters is null ) return null;

		var longest = -1f;
		foreach ( var emitter in ParticleEmitters )
		{
			if ( !emitter.IsValid() ) continue;

			var cycle = emitter.Duration.Evaluate( 0, 0 ) + emitter.Delay.Evaluate( 0, 0 );
			if ( cycle <= longest ) continue;

			longest = cycle;
			_resolvedPhaseMaster = emitter;
		}

		return _resolvedPhaseMaster;
	}

	private void EmitParticles()
	{
		Emit = false;
		foreach ( var effect in ParticleEffects )
		{
			effect.Emit( 0, 0 );
		}
	}


	private void TimeScaling( ParticleEffect particleEffect )
	{
		particleEffect.TimeScale = TimeScale;
		var particleEmitter = particleEffect.Components.Get<ParticleEmitter>();
		if ( TimeScale < 0 && particleEmitter.time <= 0 && !IsPaused )
		{
			var timing = particleEmitter.Duration.Evaluate(0,0) - 0.01f;
			SetParticulesTiming( particleEffect, timing );
			particleEmitter.time = timing;
		}
		else
		{
			particleEffect.Paused = IsPaused;
		}
	}

	private void PlayBacking( ParticleEffect particleEffect )
	{
		particleEffect.TimeScale = 0;
		var particleEmitter = particleEffect.Components.Get<ParticleEmitter>();
		var remappedTiming = PlayBack.Remap( 0, 1f, 0, particleEmitter.Duration.Evaluate(0,0) + particleEmitter.Delay.Evaluate(0,0) );
		particleEmitter.time = remappedTiming;
		SetParticulesTiming( particleEffect, remappedTiming );
	}

	private void Restart()
	{
		IsPaused = false;
		IsRestart = false;
		foreach ( var particleEffect in ParticleEffects )
		{
			particleEffect.Clear();
			particleEffect.ResetEmitters();
		}

		foreach( var resetable in Resetables )
		{
			resetable.ResetTime();
		}
	}

	private void SetParticulesTiming( ParticleEffect particleEffect, float timing )
	{
		var particleEmitter = particleEffect.Components.Get<ParticleEmitter>();
		particleEmitter.time = timing;
		foreach ( var particule in particleEffect.Particles )
		{
			particule.Age = timing;
		}
	}

	private void CheckForNullEffect( ParticleEffect particleEffect )
	{
		if ( !particleEffect.IsValid ) ParticleEffects.Remove( particleEffect );
	}
	
	private void CheckForNullEmitters(  )
	{
		ParticleEmitters.RemoveAll( e => !e.IsValid() );
	}
}