OpponentPaintball.cs
using Sandbox;

namespace PaintballBaddies;

/// <summary>Physical AI paintball using the same speed, gravity and sweep radius as the player.</summary>
public sealed class OpponentPaintball : Component
{
    [Property] public GameObject Shooter { get; set; }
    [Property] public Vector3 Velocity { get; set; }
    [Property] public int Team { get; set; } = 1;
    private Vector3 position,previous;
    private GameObject bead;
    private float renderClock,simulationClock,physicsStep=.02f;
    private float life = PaintballFlight.Lifetime;
    private bool initialized;
    private Vector3 launchOrigin;
    private readonly HashSet<ArenaOpponent> alerted=new();
    public Color PaintColor { get; private set; }
    private void InitializePosition()
    {
        if(initialized)return;
        position=previous=WorldPosition;
        launchOrigin=WorldPosition;
        PaintColor=CharacterPaintColor.For(Shooter);
        initialized=true;
    }
    protected override void OnStart()
    {
        GameObject.Tags.Add("paintball_debris");
        InitializePosition();
        WorldScale=Vector3.One;
        // The joining shooter already renders a local prediction. Do not create
        // a second bead/trail or look up a replicated trail that may not exist.
        if(!MultiplayerSession.Authority && Shooter?.Components.Get<NetworkPawn>() is {IsProxy:false})
            return;
        bead=PaintballFlight.CreateBead(GameObject,PaintColor);
    }
    protected override void OnUpdate()=>renderClock+=Time.Delta;
    protected override void OnPreRender()
    {
        if(IsProxy){PaintballFlight.UpdateBead(bead);return;}
        InitializePosition();
        WorldPosition=Vector3.Lerp(previous,position,((renderClock-simulationClock)/physicsStep).Clamp(0,1));
        PaintballFlight.UpdateBead(bead);
    }
    protected override void OnFixedUpdate()
    {
        if(IsProxy)return;
        InitializePosition();
        simulationClock+=Time.Delta;physicsStep=System.MathF.Max(Time.Delta,.001f);previous=position;
        var end = position + Velocity * Time.Delta + Vector3.Down * (PaintballFlight.Gravity * .5f * Time.Delta * Time.Delta);
        var trace = PaintballFlight.Trace(Scene,position,end,Shooter);
        IncomingPaintAwareness.Report(Scene,Shooter,launchOrigin,position,trace.Hit ? trace.EndPosition : end,trace.GameObject,alerted);
        life -= Time.Delta;
        if ( trace.Hit )
        {
            var source=trace.EndPosition-Velocity.Normal*100;
            var tint=PaintColor;
            if(!ArenaShield.TryAbsorbImpact(trace,source,tint))
            {
                PaintballCombatant.Find(trace.GameObject)?.RegisterHit(Team,source,PaintballCombatant.Find(Shooter),launchOrigin);
                PaintImpactSystem.Find(Scene)?.Spawn(trace,tint);
            }
        }
        if ( trace.Hit || life <= 0 ) { GameObject.Destroy(); return; }
        position = end;
        Velocity += Vector3.Down * PaintballFlight.Gravity * Time.Delta;
    }
}