SprintDust.cs

Dust and turf particle system for the soccer match presentation. Reads MatchSimulation player and ball state (impacts, falls, kicks, slides, footsteps, saves/posts) and spawns short-lived puffs that a renderer samples into flat billboard poses, with no effect on the simulation.

using System;
using System.Collections.Generic;

namespace Socer;

// Presentation only. Spawn from actual contacts; store initial conditions so the
// replay can sample the same short ballistic motion without advancing the match.
public sealed class SprintDust
{
    public enum ParticleKind { Grass, Soil, Glove, Frame }
    public readonly record struct Puff(Point Position,float Born,Point Velocity=default,
        float Height=.035f,float Lift=.7f,float Size=.055f,ParticleKind Kind=ParticleKind.Grass,int Variation=0);
    public readonly record struct ParticlePose(bool Visible,Point A,float ZA,Point B,float ZB,float Width,string Color,float Opacity);
    public const int MaximumParticles=48;
    public const float Lifetime=.60f;
    readonly List<Puff> particles=new(MaximumParticles);
    readonly Dictionary<int,FootState> feet=new();
    sealed class FootState
    {
        public int Foot=-1,SlideSample=-1;
        public float Impact=-10,Landing=-10,Kick=-10,Control=-10,Slide=-10;
    }
    MatchSimulation match;
    float lastTime=-1,lastBallHeight;
    Point lastBall;
    int lastEffect;

    public static float Duration(Puff p)=>p.Kind is ParticleKind.Glove or ParticleKind.Frame?.18f:Lifetime;
    public static ParticlePose Sample(Puff p,float seconds)
    {
        float age=seconds-p.Born,duration=Duration(p);
        if(age<0 || age>=duration)return default;
        float t=age/duration;
        bool accent=p.Kind is ParticleKind.Glove or ParticleKind.Frame;
        // Clumps slow in the grass instead of drifting like smoke. The small
        // contact accent lasts 5 frames at 30 fps; its silhouette never covers the ball.
        float travel=accent?age:(1-MathF.Exp(-age*4.6f))/4.6f;
        var at=p.Position+p.Velocity*travel;
        float z=Math.Max(.018f,p.Height+p.Lift*age-(accent?0:4.905f*age*age));
        var axis=p.Velocity.Length>.01f?p.Velocity.Unit:new Point(1,0);
        float fade=1-Smooth(Math.Clamp((t-.40f)/.60f,0,1));
        float width=p.Size*(accent?(1-t)*.55f:MathF.Sqrt(fade));
        float length=accent?.045f+.10f*MathF.Sin(t*MathF.PI):p.Size*(1.15f+.5f*Noise(p.Variation,2));
        string color=p.Kind switch
        {
            ParticleKind.Glove=>p.Variation%2==0?"#bce0d6":"#f4e8ba",
            ParticleKind.Frame=>"#efe3b6",
            ParticleKind.Soil=>p.Variation%2==0?"#77654b":"#92835c",
            _=>p.Variation%3==0?"#b7ba78":p.Variation%3==1?"#829653":"#566d3e"
        };
        return new(width>.001f,at-axis*(length*.5f),z,at+axis*(length*.5f),
            z+(accent?p.Lift*.015f:width*.28f),width,color,accent?fade*(1-t)*.85f:1);
    }

    public IReadOnlyList<Puff> Update(MatchSimulation current)
    {
        if(match!=current || current.Seconds<lastTime)
        {
            particles.Clear();feet.Clear();match=current;lastTime=-1;lastEffect=0;
            lastBall=current.Ball;lastBallHeight=current.BallHeight;
        }
        if(current.Seconds==lastTime)return particles;
        float now=current.Seconds;
        particles.RemoveAll(p=>now-p.Born>=Duration(p));
        foreach(var p in current.Players)
        {
            if(p.InjuryRemovedFromField || p.SentOff && now>=p.ImpactUntil)continue;
            if(!feet.TryGetValue(p.Id,out var state))feet[p.Id]=state=new();
            var forward=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
            var travel=p.VisualVelocity.Length>.1f?p.VisualVelocity.Unit:forward;
            var side=new Point(-travel.Y,travel.X);
            int seed=p.Id*1009;
            if(Recent(p.ImpactAt,state.Impact,now))
            {
                state.Impact=p.ImpactAt;
                Burst(p.ImpactPoint,p.ImpactAt,p.ImpactDirection*-1,5,.8f,seed+13);
            }
            float landed=p.FallAt+(p.FallVariant==4?.58f:.44f);
            if(now<p.FallUntil && Recent(landed,state.Landing,now))
            {
                state.Landing=landed;
                Burst(p.Position+p.FallDirection*.22f,landed,p.FallDirection,6,
                    .75f+p.FallSeverity*.55f,seed+37);
            }
            if(Recent(p.LastKickAt,state.Kick,now))
            {
                state.Kick=p.LastKickAt;
                if(p.LastKickKind!=StrikeKind.Header && now-p.LastPuntAt>.16f)
                {
                    var strike=new Point(MathF.Cos(p.LastKickFacing),MathF.Sin(p.LastKickFacing));
                    var across=new Point(-strike.Y,strike.X);
                    var planted=p.Position-across*(p.KickFoot<0?-.17f:.17f);
                    Burst(planted,p.LastKickAt,strike*-1,p.LastKickWasShot?5:2,
                        p.LastKickWasShot?.9f:.38f,seed+71);
                }
            }
            if(Recent(p.LastControlAt,state.Control,now))
            {
                state.Control=p.LastControlAt;
                if(!p.Keeper && p.ChestUntil<=now && (p.ControlPoint-p.Position).Length<1.3f)
                    Burst(p.ControlPoint,p.LastControlAt,travel*-1,2,.30f,seed+89);
            }
            // A low, directional ribbon of dislodged turf follows the leading
            // hip/boot during the slide. Emission stops before the get-up begins.
            float slideAge=now-p.LastSlideAt;
            if(state.Slide!=p.LastSlideAt){state.Slide=p.LastSlideAt;state.SlideSample=-1;}
            if(slideAge>=.06f && slideAge<.66f)
            {
                int sample=(int)((slideAge-.06f)/.075f);
                if(sample>state.SlideSample)
                {
                    state.SlideSample=sample;
                    var direction=p.SlideDirection.Length>.1f?p.SlideDirection:forward;
                    var lateral=new Point(-direction.Y,direction.X);
                    var origin=p.Position-direction*.15f+lateral*(p.SlideVariant==1?-.20f:.20f);
                    Burst(origin,now,direction*-1,3,Math.Clamp(.55f+p.SlideSpeed*.07f,.55f,1.1f),seed+sample*23);
                }
            }
            int foot=(int)(p.GaitPhase*2);
            bool stepped=state.Foot>=0 && state.Foot!=foot;
            state.Foot=foot;
            bool down=now<p.FallUntil || p.DiveRemaining>0 || slideAge>=0 && slideAge<2.1f;
            if(!stepped || down || p.VisualSpeed<3.2f)continue;
            float braking=-Point.Dot(p.VisualAcceleration,travel);
            float cutting=Math.Abs(Point.Dot(p.VisualAcceleration,side));
            bool hardPlant=braking>4 || cutting>5;
            if(!p.Sprinting && !hardPlant)continue;
            var boot=p.Position-travel*.16f+side*(foot==0?.16f:-.16f);
            Burst(boot,now,travel*-1,hardPlant?3:2,hardPlant?.65f:.42f,seed+foot*31+(int)(p.GaitPhase*100));
        }
        // The event IDs are monotonic and no per-event dictionary is retained.
        // Match/replay reset therefore has a fixed memory budget as well.
        foreach(var effect in current.Effects)
        {
            if(effect.Id<=lastEffect)continue;
            lastEffect=effect.Id;
            if(effect.Age<0 || effect.Age>=.16f)continue;
            if(effect.Kind is not ("post" or "save" or "block"))continue;
            bool frame=effect.Kind=="post";
            var origin=(current.Ball-effect.Position).Length<1.8f?current.Ball:effect.Position;
            float height=frame?Math.Clamp(current.BallHeight,.14f,2.44f):Math.Max(.12f,current.BallHeight);
            Contact(origin,height,now-effect.Age,current.BallVelocity.Unit,
                frame?ParticleKind.Frame:ParticleKind.Glove,effect.Id);
        }
        if(lastTime>=0 && now-lastTime<.12f && current.Owner<0 &&
            lastBallHeight>.11f && current.BallHeight<.10f && current.VerticalSpeed>0 &&
            (current.Ball-lastBall).Length<4)
            Burst(current.Ball,now,current.BallVelocity.Unit,3,.42f,337+(int)(now*100));
        if(particles.Count>MaximumParticles)particles.RemoveRange(0,particles.Count-MaximumParticles);
        lastTime=now;lastBall=current.Ball;lastBallHeight=current.BallHeight;
        return particles;
    }

    static bool Recent(float at,float previous,float now)=>at>=0 && at!=previous && now>=at && now-at<Lifetime;
    void Burst(Point origin,float born,Point direction,int count,float strength,int seed)
    {
        direction=direction.Length>.01f?direction.Unit:new Point(1,0);
        var side=new Point(-direction.Y,direction.X);
        for(int n=0;n<count;n++)
        {
            int variation=seed+n*41+(int)(born*97);
            float scatter=Noise(variation,0)*2-1;
            var velocity=direction*((.35f+Noise(variation,1)*.8f)*strength)+side*(scatter*.8f*strength);
            var at=origin+side*(scatter*.07f);
            particles.Add(new(at,born,velocity,.025f,(.65f+Noise(variation,2)*1.1f)*strength,
                (.035f+Noise(variation,3)*.036f)*Math.Clamp(strength,.5f,1.1f),
                n%3==2?ParticleKind.Soil:ParticleKind.Grass,Math.Abs(variation)));
        }
    }
    void Contact(Point at,float height,float born,Point direction,ParticleKind kind,int seed)
    {
        float facing=MathF.Atan2(direction.Y,direction.X);
        for(int n=0;n<5;n++)
        {
            float angle=facing+(n-2)*.75f;
            var axis=new Point(MathF.Cos(angle),MathF.Sin(angle));
            particles.Add(new(at+axis*.05f,born,axis*(.75f+Noise(seed,n)*.7f),height,
                (n-2)*.6f,.028f,kind,n));
        }
    }
    static float Smooth(float t)=>t*t*(3-2*t);
    static float Noise(int seed,int lane)
    {
        uint n=unchecked((uint)seed*747796405u+(uint)lane*2891336453u+277803737u);
        n=(n^(n>>16))*2246822519u;n^=n>>13;
        return (n&65535)/65535f;
    }
}