ArcadePlateAssist.cs
using Sandbox;
using System;
namespace PaintballBaddies;
// Local view assistance only. Firing, occlusion and interception remain host-authoritative.
public sealed class ArcadePlateAssist
{
public SausageProjectile Target {get;private set;}
public int Acquisitions {get;private set;}
private float releaseCooldown;
public bool Locked=>Target.IsValid() && !Target.Resolved;
public void Clear(){Target=null;}
public void Update(PlayerController player,bool allowed,float delta)
{
releaseCooldown=MathF.Max(0,releaseCooldown-delta);
if(!allowed || !player.IsValid() || player.IsProxy || MultiplayerSession.Find(player.Scene)?.IsOverdrive!=true){Clear();return;}
// Moving the mouse away releases assistance, without immediately
// snapping back on the next frame to the same incoming plate.
if(MathF.Abs(Input.AnalogLook.pitch)+MathF.Abs(Input.AnalogLook.yaw)>10){Clear();releaseCooldown=.6f;return;}
if(releaseCooldown>0){Clear();return;}
if(!Locked || !Eligible(Target,player,false)){Clear();}
if(!Locked)
{
Target=player.Scene.GetAllComponents<SausageProjectile>().Where(p=>Eligible(p,player,true))
.OrderBy(p=>(p.WorldPosition-player.EyePosition).Length/MathF.Max(1,p.Velocity.Length)).FirstOrDefault();
if(!Target.IsValid())return;
Acquisitions++;
}
// Track the threat until it is broken, passes the player, or is occluded.
var origin=player.Scene.Camera?.WorldPosition ?? player.EyePosition;
var desired=Rotation.LookAt(Target.WorldPosition-origin);
player.EyeAngles=Rotation.Slerp(player.EyeAngles.ToRotation(),desired,1-MathF.Exp(-24*delta)).Angles().WithRoll(0);
}
public static bool Eligible(SausageProjectile p,PlayerController player,bool acquiring)
{
if(!player.IsValid() || !p.IsValid() || p.Resolved || !(p.Plate || p.Egg) || p.IntendedTarget!=player.GameObject)return false;
var to=player.EyePosition-p.WorldPosition;
// Plates and eggs: lock on earlier and from a wider angle so incoming
// throws can be shot out of the air before they arrive.
if(to.Length>820 || (to.Length>340 && p.JourneyFraction<.3f))return false;
if(Vector3.Dot(p.Velocity.Normal,to.Normal)<.5f)return false;
if(Vector3.Dot(player.EyeAngles.ToRotation().Forward,-to.Normal)<(acquiring ? -.1f : -.35f))return false;
var ray=player.Scene.Trace.Ray(player.EyePosition,p.WorldPosition).WithSurfaceMeshes()
.IgnoreGameObjectHierarchy(player.GameObject).IgnoreGameObjectHierarchy(p.GameObject).WithoutTags("paintball_debris","paintball_actor").Run();
return !ray.Hit;
}
// Reconcile the short network delay of an explicitly locked plate. The host
// accepts only a nearby reported point on this player's incoming target,
// then traces the normal shot toward its current position; cover still wins.
public static Vector3 Reconcile(PlayerController player,GameObject target,Vector3 origin,Vector3 reportedAim)
{
var plate=target.IsValid() ? target.Components.Get<SausageProjectile>() : null;
if(!MultiplayerSession.Authority || !player.IsValid() || !Eligible(plate,player,true))return reportedAim;
float tolerance=12+plate.Velocity.Length*.2f;
if((reportedAim-plate.WorldPosition).Length>tolerance)return reportedAim;
var lane=player.Scene.Trace.Ray(origin,plate.WorldPosition).WithSurfaceMeshes()
.IgnoreGameObjectHierarchy(player.GameObject).IgnoreGameObjectHierarchy(plate.GameObject)
.WithoutTags("paintball_debris","paintball_actor").Run();
return lane.Hit ? reportedAim : AimPoint(player,plate,origin);
}
public static Vector3 AimPoint(PlayerController player,SausageProjectile plate,Vector3 origin)
{
if(player.Components.Get<PaintballCombatant>()?.HasCrossbow!=true)return plate.WorldPosition;
// Lead a moving plate by the bolt's physical flight time; never teleport
// the arrow or grant an interception through intervening geometry.
float flight=(plate.WorldPosition-origin).Length/2400;
for(int i=0;i<3;i++)flight=(plate.WorldPosition+plate.Velocity*flight-origin).Length/2400;
return plate.WorldPosition+plate.Velocity*MathF.Min(.3f,flight);
}
}