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

/// <summary>Host-owned kitchen throws with readable flight and shootable interception.</summary>
public sealed class SausageProjectile : Component
{
    [Sync(SyncFlags.FromHost)] public bool Plate {get;set;}
    [Sync(SyncFlags.FromHost)] public bool Egg {get;set;}
    [Sync(SyncFlags.FromHost)] public Vector3 Velocity {get;set;}
    [Sync(SyncFlags.FromHost)] public GameObject Thrower {get;set;}
    [Sync(SyncFlags.FromHost)] public int Round {get;set;}
    [Sync(SyncFlags.FromHost)] public Vector3 LaunchPosition {get;set;}
    [Sync(SyncFlags.FromHost)] public float LaunchDistance {get;set;}
    [Sync(SyncFlags.FromHost)] public GameObject IntendedTarget {get;set;}
    public float JourneyFraction=>LaunchDistance>1 ? (WorldPosition-LaunchPosition).Length/LaunchDistance : 0;
    public bool Resolved {get;private set;}
    public static int Interceptions {get;private set;}
    public static int PlayerHits {get;private set;}
    public static int ShieldBlocks {get;private set;}
    public static int PlateThrows {get;private set;}
    public static int KnifeThrows {get;private set;}
    public static int EggThrows {get;private set;}
    public static string LastCollision {get;private set;}="";
    private GameObject visual;
    private float age;
    public static bool Launch(OverdriveEnemy enemy,Vector3 aim,bool plate,GameObject intendedTarget=null,bool egg=false)
    {
        if(!MultiplayerSession.Authority || enemy.Dead || (enemy.MissingParts&6)==6 || enemy.Scene.GetAllComponents<SausageProjectile>().Count()>11)return false;
        var origin=enemy.Components.Get<OverdriveRobotRig>()?.ThrowOrigin ?? enemy.AimPoint+enemy.WorldRotation.Forward*24;
        // A hand extending through nearby geometry must not throw through it.
        var clearance=enemy.Scene.Trace.Sphere(egg ? 2.3f : plate ? 3.6f : 1.4f,enemy.AimPoint,origin).WithSurfaceMeshes()
            .IgnoreGameObjectHierarchy(enemy.GameObject).WithoutTags("paintball_debris","overdrive_enemy").Run();
        if(clearance.Hit || clearance.StartedSolid)return false;
        var distance=(aim-origin).Length;
        float speed=MathF.Min(plate ? 610 : 760,distance/(plate ? .95f : .65f)).Clamp(plate ? 120 : 220,760);
        var go=new GameObject(false,egg ? "Flying egg" : plate ? "Flying ceramic plate" : "Flying kitchen knife"){WorldPosition=origin};
        go.Tags.Add("paintball_debris");
        var shot=go.Components.Create<SausageProjectile>();shot.Plate=plate && !egg;shot.Egg=egg;shot.Thrower=enemy.GameObject;shot.Round=enemy.Round;
        shot.LaunchPosition=origin;shot.LaunchDistance=distance;shot.IntendedTarget=intendedTarget;
        shot.Velocity=(aim-origin).Normal*(egg ? MathF.Min(500,distance/.85f).Clamp(120,500) : speed);go.Enabled=true;
        if(MultiplayerSession.Online)
        {
            go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);
            // A delayed character-style interpolation buffer leaves a fast,
            // interceptable plate visually behind its authoritative hit point.
            go.Network.Interpolation=false;go.NetworkSpawn();
        }
        if(egg)EggThrows++;else if(plate)PlateThrows++;else KnifeThrows++;
        return true;
    }
    internal static GameObject CreateVisual(GameObject parent,bool plate,bool egg=false)
    {
        // A real egg shape (blunt and pointed ends, speckled shell) when the
        // project model has compiled; otherwise the old stretched sphere.
        var realEgg=egg ? Model.Load(OverdriveAssets.RealEggPath) : null;
        bool shaped=realEgg is not null && !realEgg.IsError;
        var model=egg ? (shaped ? realEgg : Model.Load(OverdriveAssets.EggPath)) : Model.Load(plate ? OverdriveAssets.PlatePath : OverdriveAssets.KnifePath);
        var pivot=new GameObject(parent,true,"Kitchen prop spin"){NetworkMode=NetworkMode.Never};
        var mesh=new GameObject(pivot,true,"Kitchen prop mesh"){NetworkMode=NetworkMode.Never,LocalPosition=-model.Bounds.Center,LocalScale=Vector3.One};
        var renderer=mesh.Components.Create<ModelRenderer>();renderer.Model=model;
        if(plate){pivot.LocalScale=new Vector3(2.7f);renderer.MaterialOverride=Material.Load("materials/dev/primary_white.vmat");renderer.Tint=new Color(.92f,.9f,.84f);}
        if(egg)
        {
            pivot.LocalScale=new Vector3(4.4f/model.Bounds.Size.x,4.4f/model.Bounds.Size.y,5.8f/model.Bounds.Size.z);
            if(shaped)renderer.Tint=new Color(.99f,.94f,.86f);
            else{renderer.MaterialOverride=Material.Load("materials/dev/primary_white.vmat");renderer.Tint=new Color(.96f,.91f,.78f);}
        }
        return pivot;
    }
    protected override void OnStart(){_=OverdriveAssets.Load();}
    protected override void OnUpdate()
    {
        age+=Time.Delta;
        if(!visual.IsValid() && OverdriveAssets.Ready)visual=CreateVisual(GameObject,Plate,Egg);
        if(visual.IsValid())visual.WorldRotation=Rotation.LookAt(Velocity.Normal)*(Plate ? Rotation.From(new Angles(18,age*900,0)) : Rotation.FromPitch(age*1150));
        if(!MultiplayerSession.Authority)return;
        var session=MultiplayerSession.Find(Scene);
        if(session?.IsOverdrive!=true || !session.InRound || session.RoundId!=Round || age>4){GameObject.Destroy();return;}
        var end=WorldPosition+Velocity*Time.Delta;
        var hit=Scene.Trace.Sphere(Plate ? 3.6f : Egg ? 2.3f : 1.4f,WorldPosition,end).WithSurfaceMeshes()
            .IgnoreGameObjectHierarchy(GameObject).IgnoreGameObjectHierarchy(Thrower).WithoutTags("paintball_debris","overdrive_enemy").Run();
        if(!hit.Hit){WorldPosition=end;return;}
        LastCollision=hit.GameObject?.Name ?? "world";
        if(ArenaShield.TryAbsorbKitchen(hit,WorldPosition-Velocity.Normal*30))ShieldBlocks++;
        else if(PaintballCombatant.Find(hit.GameObject) is {} actor)
        {
            actor.ReceiveOverdriveDamage(Egg ? 14 : Plate ? 18 : 24,WorldPosition);PlayerHits++;
            if(Egg)(actor.Components.Get<CharacterPaint>() ?? actor.Components.Create<CharacterPaint>()).Add(hit,YolkBurst.Yellow);
        }
        Resolve(hit.HitPosition);
    }
    public static bool TryIntercept(Scene scene,Vector3 origin,Vector3 direction,float distance,PaintballCombatant shooter,out Vector3 point)
    {
        point=default;
        if(!MultiplayerSession.Authority)return false;
        var candidate=scene.GetAllComponents<SausageProjectile>().Where(p=>!p.Resolved)
            .Select(p=>new {Shot=p,Along=Vector3.Dot(p.WorldPosition-origin,direction)})
            .Where(p=>p.Along>0 && p.Along<distance && (p.Shot.WorldPosition-(origin+direction*p.Along)).Length<(p.Shot.Plate ? 12 : 6))
            .OrderBy(p=>p.Along).FirstOrDefault();
        if(candidate is null)return false;
        point=candidate.Shot.WorldPosition;candidate.Shot.Resolve(point);Interceptions++;
        MultiplayerSession.Find(scene)?.OverdriveKill(shooter,10,point);
        return true;
    }
    private void Resolve(Vector3 point)
    {
        if(Resolved)return;Resolved=true;
        if(Egg)SausageEggSlick.Spawn(Scene,point,Round);
        BreakEffect(point,Plate,Egg,Round);GameObject.Destroy();
    }
    [Rpc.Broadcast(NetFlags.HostOnly)]
    private static async void BreakEffect(Vector3 point,bool plate,bool egg,int round)
    {
        var scene=Game.ActiveScene;await GameTask.MainThread();
        if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {} s || !s.IsOverdrive || s.RoundId!=round)return;
        if(egg){YolkBurst.Spawn(point);KitchenCombatAudio.Play("egg",point);return;}
        if(plate)SausageCeramicBurst.Spawn(point);
        else DroneDamageFlare.Spawn(scene,point,Color.White,false,round,.2f);
        SausageKitchenAudio.Play(plate,point);
    }
}

public static class SausageKitchenAudio
{
    public static int CeramicBreaks {get;private set;}
    public static int MetalImpacts {get;private set;}
    private static SoundEvent Cue(string[] paths,float volume,float pitch)=>new(){Sounds=paths.Select(SoundFile.Load).ToList(),Volume=volume,Pitch=pitch,Distance=950,DistanceAttenuation=true,OcclusionEnabled=false,AirAbsorption=false};
    private static readonly SoundEvent metal=Cue(["sounds/physics/phys-impact-metal-spring-001.vsnd","sounds/physics/phys-impact-metal-spring-003.vsnd"],.5f,1.3f);
    public static void Play(bool plate,Vector3 point)
    {if(plate){CeramicBreaks++;SausageAudio.Play("plate",point);}else{MetalImpacts++;Sound.Play(metal,point);}}
}

public sealed class SausageCeramicBurst : Component
{
    private float age;
    public static void Spawn(Vector3 point)
    {
        var go=new GameObject(true,"Ceramic plate shards"){NetworkMode=NetworkMode.Never,WorldPosition=point};go.Tags.Add("paintball_debris");go.Components.Create<SausageCeramicBurst>();
    }
    protected override void OnStart()
    {
        foreach(var old in Scene.GetAllComponents<SausageCeramicBurst>().OrderByDescending(x=>x.age).SkipLast(5).ToArray())old.GameObject.Destroy();
        var layout=Json.Deserialize<ShieldBreakEffect.Layout>(FileSystem.Mounted.ReadAllText("models/gear/clear_shield/fracture/layout.json"));
        var p=Components.Create<ParticleEffect>();p.MaxParticles=12;p.Lifetime=1.4f;p.LocalSpace=0;
        p.ApplyShape=p.ApplyRotation=p.ApplyColor=p.ApplyAlpha=false;p.Force=true;p.ForceDirection=Vector3.Down*500;p.ForceScale=1;p.ForceSpace=ParticleEffect.SimulationSpace.World;
        p.Collision=true;p.CollisionRadius=.5f;p.Bounce=.25f;p.PushStrength=0;
        var render=Components.Create<ParticleModelRenderer>();render.Choices=layout.Pieces.Take(6).Select(x=>new ParticleModelRenderer.ModelEntry{Model=Model.Load(x.Model)}).ToList();
        render.MaterialOverride=Material.Load("materials/dev/primary_white.vmat");render.CastShadows=false;render.Scale=1;render.RotateWithGameObject=false;
        p.OnStep=(part,dt)=>{part.Angles+=new Angles(240,390,150)*dt;part.Alpha=((1.4f-part.Age)/.35f).Clamp(0,1);};
        for(int i=0;i<12;i++){var part=p.Emit(WorldPosition,0);part.Size=new Vector3(.12f);part.Color=new Color(.92f,.9f,.84f);part.Velocity=Rotation.FromYaw(i*137).Forward*130+Vector3.Up*(70+i*5);}
    }
    protected override void OnUpdate(){age+=Time.Delta;if(age>1.6f)GameObject.Destroy();}
}