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

// A personal headshot reward: one visible, host-swept explosive projectile.
// The separate grenade action consumes it; clients cannot claim blast victims.
public sealed class SausageGrenade : Component
{
    [Sync(SyncFlags.FromHost)] public Vector3 Velocity {get;set;}
    [Sync(SyncFlags.FromHost)] public GameObject Shooter {get;set;}
    [Sync(SyncFlags.FromHost)] public bool Freezing {get;set;}
    [Sync(SyncFlags.FromHost)] public int Round {get;set;}
    public const float BlastRadius=190;
    public const float FuseSeconds=2;
    private bool resting;
    private float age;
    private bool resolved;
    public static int Launched {get;private set;}
    public static int Exploded {get;private set;}
    public static void Fire(PaintballCombatant actor,Vector3 origin,Vector3 aim,bool freeze=false)
    {
        if(!MultiplayerSession.Authority)return;
        _=OverdriveAssets.LoadGrenade();
        var go=new GameObject(false,"Headshot reward grenade"){WorldPosition=origin};go.Tags.Add("paintball_debris");
        var grenade=go.Components.Create<SausageGrenade>();grenade.Shooter=actor.GameObject;grenade.Freezing=freeze;
        grenade.Round=MultiplayerSession.Find(actor.Scene).RoundId;
        grenade.Velocity=(aim-origin).Normal*850+Vector3.Up*70;
        go.Enabled=true;Launched++;
        if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
        SausageBolt.Cue(origin,"bow",grenade.Round,actor.GameObject);
    }
    protected override void OnStart()
    {
        var go=new GameObject(GameObject,true,"Grenade mesh"){NetworkMode=NetworkMode.Never};
        var model=OverdriveAssets.GrenadeModel;
        if(model is null || model.IsError)model=Model.Load("models/dev/sphere.vmdl");
        float scale=7/MathF.Max(.01f,model.Bounds.Size.Length);
        go.LocalScale=new Vector3(scale);go.LocalPosition=-model.Bounds.Center*scale;
        var r=go.Components.Create<ModelRenderer>();r.Model=model;r.Tint=Freezing ? new Color(.35f,.85f,1) : Color.White;
    }
    protected override void OnFixedUpdate()
    {
        if(!MultiplayerSession.Authority || resolved)return;
        var s=MultiplayerSession.Find(Scene);
        if(s is not {IsOverdrive:true,InRound:true} || s.RoundId!=Round){GameObject.Destroy();return;}
        age+=Time.Delta;
        if(age>=FuseSeconds){Detonate();return;}
        if(resting)return;
        Velocity+=Vector3.Down*320*Time.Delta;
        var end=WorldPosition+Velocity*Time.Delta;
        var ray=Scene.Trace.Sphere(3,WorldPosition,end).WithSurfaceMeshes().WithoutTags("paintball_actor","paintball_debris");
        foreach(var shield in Scene.GetAllComponents<ArenaShield>())ray=ray.IgnoreGameObjectHierarchy(shield.GameObject);
        var hit=ray.Run();
        WorldRotation*=Rotation.From(new Angles(180,110,70)*Time.Delta);
        if(hit.Hit)
        {
            WorldPosition=hit.EndPosition+hit.Normal*.5f;
            Velocity=(Velocity-2*Vector3.Dot(Velocity,hit.Normal)*hit.Normal)*.42f;
            if(hit.Normal.z>.65f && Velocity.Length<75){Velocity=Vector3.Zero;resting=true;}
        }
        else WorldPosition=end;
    }
    internal void Detonate()
    {
        if(!MultiplayerSession.Authority || resolved)return;
        resolved=true;Exploded++;
        var actor=Shooter.IsValid() ? Shooter.Components.Get<PaintballCombatant>() : null;
        var s=MultiplayerSession.Find(Scene);int kills=0;
        if(s is not {IsOverdrive:true,InRound:true} || s.RoundId!=Round){GameObject.Destroy();return;}
        foreach(var enemy in s.OverdriveEnemies.Where(e=>(e.AimPoint-WorldPosition).Length<=BlastRadius).ToArray())
        {
            var wall=Scene.Trace.Ray(WorldPosition,enemy.AimPoint).WithSurfaceMeshes()
                .WithoutTags("paintball_actor","paintball_debris","overdrive_enemy","pop_target").Run();
            if(!wall.Hit && (Freezing ? enemy.FlashFreeze() : enemy.GrenadeHit((enemy.AimPoint-WorldPosition).Normal,actor)))kills++;
        }
        Burst(WorldPosition,Round,Freezing);
        if(actor.IsValid() && kills>0)HitConfirm.Send(actor.GameObject,true,WorldPosition,Round);
        GameObject.Destroy();
    }
    [Rpc.Broadcast(NetFlags.HostOnly)] private static async void Burst(Vector3 point,int round,bool freezing)
    {
        var scene=Game.ActiveScene;await GameTask.MainThread();
        if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
        if(freezing){CryoBurst.Spawn(scene,point,false);return;}
        SausageExplosionEffect.Spawn(scene,point,true);
        KitchenCombatAudio.Play("concrete",point);OverdriveAudio.Play("burst",point);
    }
}