Code/ParticleField.cs

A lightweight CPU particle system for UI. It defines Particle struct and ParticleField class that spawns (Burst/Emit/Add), steps (Step) and draws particles via a configurable ParticleDraw callback.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace Goo;

/// <summary>Per-particle draw callback for <see cref="Hud.Particles"/> (named, not Action, because Canvas is a ref struct).</summary>
public delegate void ParticleDraw( Canvas canvas, ParticleField.Particle p );

/// <summary>Per-particle force run each <see cref="ParticleField.Step"/> before integration: steer Vel, or set anything on the particle.</summary>
public delegate void ParticleForce( ref ParticleField.Particle p, float dt );

/// <summary>
/// A tiny CPU particle simulation for UI.
/// You own it as a field, spawn into it (<see cref="Burst"/> for a radial pop, <see cref="Emit"/> for any pattern),
/// and mount it with <see cref="Hud.Particles"/> which steps and renders it from the paint callback (no Tick).
/// Any KIND of particle is the same three knobs: where it spawns (Emit/Burst), how it moves (<see cref="Gravity"/>,
/// <see cref="Drag"/>, <see cref="Force"/>), how it looks (<see cref="Draw"/>).
/// </summary>
public sealed class ParticleField
{
    public struct Particle
    {
        public Vector2 Pos;
        public Vector2 Vel;
        public float   Age;
        public float   Life;
        public float   Size;
        /// <summary>Stable per-particle random 0..1, set at spawn. Phase for desynced sway/twinkle/flicker.</summary>
        public float   Seed;
        public Color   Color;
    }

    readonly List<Particle> _live = new();
    readonly Random _rng = new();

    /// <summary>Downward acceleration, px/s^2. default(Vector2) = drifting particles (no fall).</summary>
    public Vector2 Gravity = new( 0f, 900f );

    /// <summary>Per-second velocity damping (0 = none).</summary>
    public float Drag = 0f;

    /// <summary>Optional custom force, applied per particle before gravity/drag/integration. Null = plain ballistic.</summary>
    public ParticleForce? Force;

    /// <summary>Per-particle look. Defaults to a fading, shrinking dot; reassign for confetti, smoke, sparks, etc.</summary>
    public ParticleDraw Draw = DefaultDot;

    public int Count => _live.Count;

    /// <summary>Read-only view of the live particles (for rendering / tests / inspection).</summary>
    public IReadOnlyList<Particle> Live => _live;

    /// <summary>Spawn <paramref name="count"/> particles from <paramref name="origin"/> with random omnidirectional spread.</summary>
    public void Burst( Vector2 origin, int count, float speed, Color color, float life = 1.2f, float size = 6f )
    {
        for ( int i = 0; i < count; i++ )
        {
            float ang = (float)( _rng.NextDouble() * Math.Tau );
            float spd = speed * ( 0.4f + 0.6f * (float)_rng.NextDouble() );
            Add( new Particle
            {
                Pos   = origin,
                Vel   = new Vector2( MathF.Cos( ang ), MathF.Sin( ang ) ) * spd,
                Age   = 0f,
                Life  = life * ( 0.7f + 0.6f * (float)_rng.NextDouble() ),
                Size  = size * ( 0.6f + 0.8f * (float)_rng.NextDouble() ),
                Seed  = (float)_rng.NextDouble(),
                Color = color,
            } );
        }
    }

    /// <summary>General spawn: add <paramref name="count"/> particles, each built by <paramref name="make"/> (index in). Any pattern - a row, a line, a jet, a ring.</summary>
    public void Emit( int count, Func<int, Particle> make )
    {
        for ( int i = 0; i < count; i++ ) Add( make( i ) );
    }

    /// <summary>Add a single fully-specified particle.</summary>
    public void Add( Particle p ) => _live.Add( p );

    /// <summary>Advance the simulation by <paramref name="dt"/> seconds: age, cull dead, run Force, then gravity/drag/integrate.</summary>
    public void Step( float dt )
    {
        for ( int i = _live.Count - 1; i >= 0; i-- )
        {
            var p = _live[i];
            p.Age += dt;
            if ( p.Age >= p.Life ) { _live.RemoveAt( i ); continue; }
            Force?.Invoke( ref p, dt );
            p.Vel += Gravity * dt;
            if ( Drag > 0f ) p.Vel *= MathF.Max( 0f, 1f - Drag * dt );
            p.Pos += p.Vel * dt;
            _live[i] = p;
        }
    }

    /// <summary>Default <see cref="Draw"/>: a dot that fades and shrinks over its life.</summary>
    static void DefaultDot( Canvas canvas, Particle p )
    {
        float t = p.Age / p.Life;
        float r = p.Size * ( 1f - 0.4f * t );
        canvas.Circle( p.Pos, r, new Color( p.Color.r, p.Color.g, p.Color.b, p.Color.a * ( 1f - t ) ) );
    }
}