PopRiotTarget.Threats.cs
using Sandbox;
using System;
namespace PaintballBaddies;

public sealed partial class PopRiotTarget
{
    [Sync(SyncFlags.FromHost)] public Vector3 GlobVelocity {get;set;}
    [Sync(SyncFlags.FromHost)] public bool Charging {get;private set;}
    [Sync(SyncFlags.FromHost)] public float ChargeRemaining {get;private set;}
    [Sync(SyncFlags.FromHost)] public Vector3 AttackAim {get;private set;}
    [Sync(SyncFlags.FromHost)] public PaintballCombatant AttackTarget {get;private set;}
    [Sync(SyncFlags.FromHost)] public bool Entering {get;internal set;}
    [Sync(SyncFlags.FromHost)] public Vector3 FlightVelocity {get;private set;}
    [Sync(SyncFlags.FromHost)] public bool WeakSpotsExposed {get;private set;}=true;
    [Sync(SyncFlags.FromHost)] public int VolleyShots {get;private set;}
    private float attackClock,globLife,blockedFlight;
    private readonly List<Vector3> flightPath=new();
    private int nextWaypoint,routeNumber;
    private bool touring;
    private float routeRetry;
    public int FlightWaypoints=>flightPath.Count;
    public int WaypointIndex=>nextWaypoint;
    private static readonly BBox FlightBounds=new(new Vector3(-36,-36,-28),new Vector3(36,36,26));
    public const float ChargeDuration=1.4f;
    public const float AimLockDuration=.5f;
    public const float MinimumAttackDistance=170f;
    public const float GlobInterceptRadius=7f;
    public static float AttackCooldown(int wave,int seed)=>MathF.Max(2.4f,4.2f-wave*.25f)+(seed%3)*.2f;
    // Keep close shots interceptable rather than making the entire arena's shots slow.
    public static float GlobSpeed(float flightDistance,int wave,int seed)
        =>MathF.Min(MathF.Min(480,340+(seed*37%115)+wave*7),MathF.Max(180,flightDistance/.75f));
    private void SetupThreat()
    {
        attackClock=1.6f+(Seed%5)*.25f;
        if(Kind!=PopKind.Glob)return;
        var trail=Components.Create<TrailRenderer>();trail.LifeTime=.15f;trail.MaxPoints=16;
        trail.PointDistance=4;trail.CastShadows=false;trail.Opaque=false;
        trail.Face=SceneLineObject.FaceMode.Camera;trail.BlendMode=BlendMode.Normal;
        trail.Color=Gradient.FromColors(PaintTint.WithAlpha(.65f),PaintTint.WithAlpha(0));
        var width=new Curve();width.AddPoint(0,2.5f);width.AddPoint(1,0);trail.Width=width;
    }
    private bool FlightLaneClear(Vector3 from,Vector3 to)=>!Scene.Trace.Box(FlightBounds,from,to)
        .IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit;
    private void BuildFlightPath()
    {
        flightPath.Clear();flightPath.Add(WorldPosition);nextWaypoint=0;touring=false;
        if(routeRetry<=0 && TryArenaCourse())return;
        routeRetry=2;
        // Build a closed, collision-checked patrol polygon around this roof entry.
        // Reverse alternate lanes so the wave does not orbit in perfect unison.
        for(int i=0;i<12 && flightPath.Count<5;i++)
        {
            float angle=Seed*37+i*30*(Seed%2==0 ? 1 : -1);
            var candidate=Anchor+Rotation.FromYaw(angle).Forward*(i%2==0 ? 220 : 150);
            if(FlightLaneClear(flightPath.Last(),candidate) && FlightLaneClear(candidate,flightPath[0]))flightPath.Add(candidate);
        }
        if(flightPath.Count>1)nextWaypoint=1;
    }
    private void AdvanceHover(MultiplayerSession session)
    {
        var before=WorldPosition;routeRetry-=Time.Delta;
        if(Entering)
        {
            var delta=Anchor-WorldPosition;
            var desired=WorldPosition+delta.Normal*MathF.Min(delta.Length,105*Time.Delta);
            if(FlightLaneClear(WorldPosition,desired))WorldPosition=desired;
            else {Entering=false;Anchor=WorldPosition;BuildFlightPath();}
            if(delta.Length<5){Entering=false;attackClock=.9f;BuildFlightPath();}
        }
        else
        {
            if(flightPath.Count<2)BuildFlightPath();
            var goal=flightPath[nextWaypoint];
            if((goal-WorldPosition).Length<12 && flightPath.Count>1)
            {
                if(touring && nextWaypoint==flightPath.Count-1)BuildFlightPath();
                else nextWaypoint=(nextWaypoint+1)%flightPath.Count;
                goal=flightPath[nextWaypoint];
            }
            float speed=MathF.Min(140,78+session.RiotWave*7)*(Charging ? .65f : 1);
            var velocity=Vector3.Lerp(FlightVelocity,(goal-WorldPosition).Normal*speed,1-MathF.Exp(-4*Time.Delta));
            var desired=WorldPosition+velocity.Normal*MathF.Min(velocity.Length*Time.Delta,(goal-WorldPosition).Length);
            if(FlightLaneClear(WorldPosition,desired))WorldPosition=desired;
            else {BuildFlightPath();FlightVelocity=Vector3.Zero;}
            blockedFlight=(WorldPosition-before).Length<.15f ? blockedFlight+Time.Delta : 0;
            if(blockedFlight>1.5f)
            {
                // A changed lane gets a new patrol from the current position, never a teleport.
                Anchor=WorldPosition;BuildFlightPath();blockedFlight=0;
            }
        }
        FlightVelocity=(WorldPosition-before)/MathF.Max(Time.Delta,.001f);
    }
    private void AdvanceThreat(MultiplayerSession session)
    {
        if(Kind is not (PopKind.Blob or PopKind.Giant) || session.RiotReviewPaused || Entering || session.Remaining<=0)return;
        attackClock-=Time.Delta;
        if(Charging)
        {
            ChargeRemaining=MathF.Max(0,ChargeRemaining-Time.Delta);
            if(!AttackTarget.IsValid() || !AttackTarget.AcceptHits || AttackTarget.HitSlowSeconds>0)
            {CancelCharge();return;}
            if(ChargeRemaining>AimLockDuration)
            {
                if(!TryAttackAim(AttackTarget,out var aim)){CancelCharge();return;}
                AttackAim=aim;
            }
            if(ChargeRemaining>0)return;
            Charging=false;bool fired=LaunchGlob(session,WorldPosition);AttackTarget=null;
            attackClock=fired ? AttackCooldown(session.RiotWave,Seed) : .25f;return;
        }
        if(attackClock>0)return;
        // Stagger telegraphs so a whole wave never fires an unavoidable simultaneous volley.
        if(session.RiotTargets.Count(t=>t.Charging)>=2){attackClock=.25f;return;}
        var candidates=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.AcceptHits && a.HitSlowSeconds<=0
            && (a.EyePosition-WorldPosition).Length<1800).OrderBy(a=>(a.EyePosition-WorldPosition).Length
            *(a.Components.Get<NetworkPawn>().IsValid() ? .7f : 1)).ToArray();
        var target=candidates.FirstOrDefault(a=>!session.RiotTargets.Any(t=>t!=this && t.Charging && t.AttackTarget==a)
            && TryAttackAim(a,out _));
        if(!target.IsValid()){attackClock=.25f;return;}
        // Track with restrained lead, then visibly lock before the straight shot.
        AttackTarget=target;TryAttackAim(target,out var targetAim);AttackAim=targetAim;
        Charging=true;ChargeRemaining=ChargeDuration;
    }
    private void CancelCharge(){Charging=false;ChargeRemaining=0;AttackTarget=null;attackClock=.25f;}
    private bool ClearShot(Vector3 from,Vector3 to)=>!Scene.Trace.Sphere(SizeFor(PopKind.Glob),from,to)
        .IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit;
    private bool TryAttackAim(PaintballCombatant target,out Vector3 aim)
    {
        var origin=WorldPosition+Vector3.Down*12;
        aim=target.EyePosition-Vector3.Up*12;
        float distance=(aim-origin).Length;
        if(distance<MinimumAttackDistance || distance>1800 || !ClearShot(origin,aim))return false;
        var player=target.Components.Get<PlayerController>();
        var velocity=player.IsValid() ? player.Velocity : target.Components.Get<ArenaOpponent>()?.NavigationVelocity ?? Vector3.Zero;
        var lead=velocity.WithZ(0)*MathF.Min(.2f,distance/2400f);
        if(lead.Length>36)lead=lead.Normal*36;
        if(ClearShot(origin,aim+lead))aim+=lead;
        return true;
    }
    private bool LaunchGlob(MultiplayerSession session,Vector3 origin)
    {
        origin+=Vector3.Down*12;
        if((AttackAim-origin).Length<MinimumAttackDistance || !ClearShot(origin,AttackAim))return false;
        var direction=(AttackAim-origin).Normal;
        var start=origin+direction*28;
        var glob=session.SpawnRiotTarget(start,PopKind.Glob);
        if(!glob.IsValid())return false;
        glob.GlobVelocity=direction*GlobSpeed((AttackAim-start).Length,session.RiotWave,Seed);VolleyShots++;
        return true;
    }
    private void AdvanceGlob(MultiplayerSession session)
    {
        if(session.Remaining<=0){GameObject.Destroy();return;}
        globLife+=Time.Delta;
        var end=WorldPosition+GlobVelocity*Time.Delta;
        var trace=Scene.Trace.Sphere(Radius,WorldPosition,end).UseHitPosition(true)
            .IgnoreGameObjectHierarchy(GameObject).WithoutTags("pop_target","paintball_debris").Run();
        if(trace.Hit)
        {
            if(!ArenaShield.TryAbsorbImpact(trace,WorldPosition-GlobVelocity.Normal*40,PaintTint))
            {
                PaintballCombatant.Find(trace.GameObject)?.ReceiveRiotGlob(WorldPosition-GlobVelocity.Normal*40);
                PaintImpactSystem.Find(Scene)?.Spawn(trace,PaintTint);
            }
            NetworkEffects.RiotBurst(trace.HitPosition,Radius,PaintTint,false,false,Round);
            GameObject.Destroy();return;
        }
        if(globLife>7){GameObject.Destroy();return;}
        WorldPosition=end;
    }
}