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.
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;
}
}