Code/ParticleEffectsManager.cs

Editor component that collects ParticleEffect, ParticleEmitter and related interfaces from child GameObjects and provides editor controls for time scaling, pausing, restarting, emitting and scrubbing playback. It also optionally drives phase of IPhaseDriven components from a chosen ParticleEmitter.

Native Interop
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() );
	}
}