Feel/Effects.cs

A component that manages a fixed-size pool of small spherical "sparkle" effects emitted when an apple is eaten. It allocates renderers once, launches and updates particle positions, applies gravity, lifetime-based shrinking, and disables/recycles particles when they die or hit the sand.

Native Interop
namespace Coilgarden;

/// <summary>
/// A small pool of sparkles, thrown when an apple is eaten.
/// <para>
/// Hand-rolled rather than driven by the engine's particle system, for two reasons. The first
/// is control: at ten particles an event, every one of them is visible, and being able to tune
/// the exact arc, life and shrink of each is worth more here than any feature a general
/// particle system offers. The second is that the whole visual identity is "two primitives and
/// code", and a sparkle is a small sphere.
/// </para>
/// <para>
/// <b>Budgeted, not unbounded.</b> The pool is fixed and allocated once; a burst that would
/// exceed it simply throws fewer. There is no path by which a fast player fills the screen -
/// which matters, because the one thing an effect must never do here is hide the board.
/// </para>
/// </summary>
public sealed class Effects : Component
{
	[Property] public GameSession Session { get; set; }

	[Property] public float CellSize { get; set; } = GameConfig.CellSize;

	/// <summary>Turn the sparkles off, to check the game still reads without them.</summary>
	[Property] public bool Enable { get; set; } = true;

	/// <summary>
	/// Slows the sparkles down, for looking at them.
	/// <para>
	/// A burst lasts under half a second, which is shorter than a screenshot round trip - so
	/// without this there is no way to inspect the arc, the spread or the shrink at all. Left at
	/// 1 in play; drop it to about 0.08 to study a burst frame by frame.
	/// </para>
	/// </summary>
	[Property, Range( 0.02f, 1f )] public float TimeScale { get; set; } = 1f;

	private struct Sparkle
	{
		public ModelRenderer Renderer;
		public Vector3 Position;
		public Vector3 Velocity;
		public float Age;
		public float Life;
		public bool Alive;
	}

	private GameObject visualRoot;
	private Primitives primitives;
	private ArenaSpace space;
	private Sparkle[] pool;
	private int nextIndex;

	private int builtWidth;
	private int builtHeight;

	/// <summary>Deterministic per-session, so a burst is not a different shape every run.</summary>
	private readonly System.Random random = new( 0x5EED );

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	protected override void OnDisabled() => TearDown();

	/// <summary>How many sparkles are currently in flight. For the debug readout.</summary>
	public int AliveCount
	{
		get
		{
			if ( pool is null ) return -1;

			var alive = 0;

			for ( var i = 0; i < pool.Length; i++ )
			{
				if ( pool[i].Alive ) alive++;
			}

			return alive;
		}
	}

	/// <summary>Where the last burst was asked to happen, and whether the pool was ready for it.</summary>
	public Vector3 LastBurstOrigin { get; private set; }

	/// <summary>Throws a burst of sparkles out of a cell.</summary>
	public void Burst( GridPos cell )
	{
		if ( !Enable || pool is null ) return;

		var origin = space.Above( space.Cell( cell ), GameConfig.AppleDiameter * 0.5f );

		LastBurstOrigin = origin;

		for ( var i = 0; i < GameConfig.SparklesPerApple; i++ )
		{
			Launch( origin );
		}
	}

	private void Launch( Vector3 origin )
	{
		// Round-robin through the pool. The oldest sparkle is the one recycled, so a burst
		// during a burst degrades by dropping the stalest particle rather than by refusing.
		var index = nextIndex;
		nextIndex = (nextIndex + 1) % pool.Length;

		// Outwards in the tray plane, biased towards the viewer so the burst arcs up off the
		// sand rather than sliding along it.
		var angle = (float)(random.NextDouble() * MathF.PI * 2f);
		var speed = GameConfig.SparkleSpeed * CellSize * (0.55f + (float)random.NextDouble() * 0.7f);

		var velocity = new Vector3(
			-speed * (0.5f + (float)random.NextDouble() * 0.5f),
			MathF.Cos( angle ) * speed * 0.55f,
			MathF.Sin( angle ) * speed * 0.55f );

		pool[index].Position = origin;
		pool[index].Velocity = velocity;
		pool[index].Age = 0f;
		pool[index].Life = GameConfig.SparkleLife * (0.7f + (float)random.NextDouble() * 0.6f);
		pool[index].Alive = true;
	}

	protected override void OnUpdate()
	{
		var arena = Session?.Run?.Arena;
		if ( arena is null ) return;

		EnsureBuilt( arena );

		// Sparkles in flight stop where they are while paused, rather than continuing to arc and
		// fall behind the pause card.
		var delta = Session.FeelDelta * TimeScale;

		for ( var i = 0; i < pool.Length; i++ )
		{
			if ( !pool[i].Alive )
			{
				continue;
			}

			pool[i].Age += delta;

			var t = Ease.Progress( pool[i].Age, pool[i].Life );

			if ( t >= 1f )
			{
				pool[i].Alive = false;
				pool[i].Renderer.Enabled = false;
				continue;
			}

			// "Up" out of the tray is -X, so gravity pulls back towards +X.
			pool[i].Velocity += new Vector3( GameConfig.SparkleGravity * CellSize * delta, 0f, 0f );
			pool[i].Position += pool[i].Velocity * delta;

			// A sparkle that has fallen back to the sand is done. Without this they keep going
			// and end up *behind* the sand bed, still alive and invisible - which looks exactly
			// like the particles never having worked.
			if ( pool[i].Position.x >= 0f )
			{
				pool[i].Alive = false;
				pool[i].Renderer.Enabled = false;
				continue;
			}

			var renderer = pool[i].Renderer;

			renderer.GameObject.LocalPosition = pool[i].Position;

			// Shrinking to nothing is what makes them read as sparks rather than as debris that
			// vanishes. InQuad keeps them full-size for most of their life and then goes quickly.
			primitives.Resize( renderer, GameConfig.SparkleSize * CellSize * (1f - Ease.InQuad( t )) );
			renderer.Enabled = true;
		}
	}

	private void EnsureBuilt( Arena arena )
	{
		var built = visualRoot.IsValid()
			&& builtWidth == arena.Width
			&& builtHeight == arena.Height
			&& pool is not null;

		if ( built ) return;

		TearDown();

		builtWidth = arena.Width;
		builtHeight = arena.Height;

		visualRoot = new GameObject( GameObject, true, "Sparkles" );
		visualRoot.Flags |= GameObjectFlags.NotSaved;

		primitives = new Primitives( visualRoot );
		space = new ArenaSpace( arena.Width, arena.Height, CellSize );

		pool = new Sparkle[GameConfig.SparkleBudget];

		for ( var i = 0; i < pool.Length; i++ )
		{
			pool[i].Renderer = primitives.Sphere( $"Sparkle {i}", Vector3.Zero,
				GameConfig.SparkleSize * CellSize, Palette.Sparkle );

			pool[i].Renderer.Enabled = false;
		}
	}

	private void TearDown()
	{
		visualRoot?.Destroy();
		visualRoot = null;
		primitives = null;
		pool = null;
		nextIndex = 0;
		builtWidth = 0;
		builtHeight = 0;
	}
}