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;
}
}