FunFeatures.cs
using Sandbox;
using System;
namespace PaintballBaddies;

/// <summary>Local-player callouts: multi-kills inside a short window, and
/// one-off moments such as smashing a sausage's plate stack.</summary>
public static class KillStreak
{
    const float Window=3f,Show=1.7f;
    private static int count;
    private static float last=-100,shownAt=-100;
    public static string Callout {get;private set;}="";
    public static int Serial {get;private set;}
    public static bool Visible=>Now-shownAt<Show;
    private static float Now=>(float)RealTime.Now;
    public static void AddKills(int kills)
    {
        if(kills<=0)return;
        count=Now-last<Window ? count+kills : kills;last=Now;
        string text=count switch {2=>"DOUBLE KILL!",3=>"TRIPLE KILL!",4=>"SAUSAGE PARTY!",>=5=>"FULL ENGLISH RAMPAGE!",_=>""};
        if(text!="")Say(text);
    }
    public static void Say(string text){Callout=text;shownAt=Now;Serial++;}
}

/// <summary>Single player only: the last sausage of an encounter dies in slow
/// motion for a moment, then time eases back to normal.</summary>
public sealed class FinalKillSlowMo : Component
{
    const float Hold=.75f,Ease=.35f,Scale=.3f;
    private float started;
    public static int Plays {get;private set;}
    public static void Start(Scene scene)
    {
        if(MultiplayerSession.Online || scene.GetAllComponents<FinalKillSlowMo>().Any())return;
        new GameObject(true,"Final kill slow motion"){NetworkMode=NetworkMode.Never}.Components.Create<FinalKillSlowMo>();
        Plays++;KillStreak.Say("LAST SAUSAGE!");
    }
    protected override void OnStart(){started=(float)RealTime.Now;Scene.TimeScale=Scale;}
    protected override void OnUpdate()
    {
        float t=(float)RealTime.Now-started;
        if(t>=Hold+Ease){Scene.TimeScale=1;GameObject.Destroy();return;}
        Scene.TimeScale=t<Hold ? Scale : Scale+(1-Scale)*((t-Hold)/Ease);
    }
    protected override void OnDestroy(){if(Scene.IsValid())Scene.TimeScale=1;}
}

/// <summary>Steam venting off an enraged sausage's head: soft, see-through
/// wisps that rise, spread and fade (camera-facing sprites, not solid balls).</summary>
public sealed class RagePuff : Component
{
    private static Texture cloud;
    private float age;
    const float Life=.9f;
    // Soft round cloud, generated once: bright core, feathered, slightly lumpy edge.
    internal static Texture Cloud()
    {
        if(cloud is {IsValid:true})return cloud;
        const int N=96;using var bmp=new Bitmap(N,N);var px=new Color[N*N];
        for(int y=0;y<N;y++)for(int x=0;x<N;x++)
        {
            float dx=(x-N/2f)/(N/2f),dy=(y-N/2f)/(N/2f);float r=MathF.Sqrt(dx*dx+dy*dy);
            float a=MathF.Atan2(dy,dx);float wobble=.08f*MathF.Sin(a*5+1.3f)+.05f*MathF.Sin(a*9+.4f);
            float t=Math.Clamp(1-(r/(.95f+wobble)),0,1);
            px[y*N+x]=new Color(1,1,1,t*t*(3-2*t)*.85f);
        }
        bmp.SetPixels(px);cloud=bmp.ToTexture();return cloud;
    }
    public static void Spawn(Scene scene,Vector3 at,Vector3 side)
    {
        var go=new GameObject(true,"Rage steam"){NetworkMode=NetworkMode.Never,WorldPosition=at};go.Tags.Add("paintball_debris");
        go.Components.Create<RagePuff>();
        var p=go.Components.Create<ParticleEffect>();p.MaxParticles=2;p.Lifetime=Life;p.LocalSpace=0;
        p.ApplyAlpha=false;p.ApplyColor=false;p.ApplyRotation=false;p.ApplyShape=false;p.Collision=false;
        p.Force=true;p.ForceDirection=Vector3.Up*30;p.ForceScale=1;p.ForceSpace=ParticleEffect.SimulationSpace.World;p.Damping=1.5f;
        var r=go.Components.Create<ParticleSpriteRenderer>();
        r.Sprite=new Sprite{Animations=[new Sprite.Animation{Name="Default",Frames=[new Sprite.Frame{Texture=Cloud()}]}]};
        r.Scale=1;r.Additive=false;r.Lighting=true;r.Shadows=false;
        // Grow as they rise, fade out; puffs are small near the head and swell.
        p.OnStep=(part,dt)=>{float t=(part.Age/Life).Clamp(0,1);part.Size=new Vector3(2.5f+t*7);part.Alpha=MathF.Sin(t*MathF.PI)*.23f;};
        for(int i=0;i<2;i++)
        {
            var part=p.Emit(at+side*i*1.5f,0);part.Size=new Vector3(3);part.Alpha=0;
            part.Color=new Color(.95f,.95f,.97f);
            part.Velocity=side*Game.Random.Float(14,26)+Vector3.Up*Game.Random.Float(28,44);
        }
    }
    protected override void OnUpdate(){age+=Time.Delta;if(age>Life+.1f)GameObject.Destroy();}
}