CoverController.cs
using Sandbox;
using System;
namespace PaintballBaddies;
/// <summary>Physical cover attachment; native player collision remains responsible for movement.</summary>
public sealed class CoverController : Component
{
private bool remoteAttached;
public bool Attached => IsProxy && Components.Get<NetworkPawn>().IsValid() ? remoteAttached : surface is not null;
public CoverSurface ReservedSurface => Attached ? surface : Sliding ? slideSurface : null;
internal void ApplyNetwork(NetworkPawn state)
{
remoteAttached=state.Covered;LowCover=state.Low;Peeking=state.Peek;Sliding=state.Sliding;
SlideDirection=state.SlideDirection;Normal=state.CoverNormal;
surface=Scene.GetAllComponents<CoverSurface>().FirstOrDefault(x=>x.GameObject.Name==state.CoverName);
}
public bool Sliding { get; private set; }
public Vector3 SlideDirection { get; private set; }
private CoverSurface slideSurface;
private Vector3 slideTarget;
private float slideElapsed;
private bool slideInput;
public bool TrySlide()
{
if(!Enabled || Attached || Sliding || !MovementAllowsEntry || !player.UseInputControls || weapon?.AcceptInput!=true || player.Velocity.WithZ(0).Length<135)return false;
var hit=Probe(WorldPosition+Vector3.Up*28,Rotation.FromYaw(player.EyeAngles.yaw).Forward,190);
var found=hit.Hit ? hit.GameObject.Components.Get<CoverSurface>() : null;
if(!found.IsValid() || !found.Enabled || MathF.Abs(hit.Normal.z)>.35f || hit.Distance<=70)return false;
var destination=(hit.EndPosition+hit.Normal.WithZ(0).Normal*StandOff).WithZ(WorldPosition.z);
if(!CornerClear(destination))return false;
slideSurface=found;slideTarget=destination;SlideDirection=(destination-WorldPosition).WithZ(0).Normal;
slideInput=player.UseInputControls;slideElapsed=0;Sliding=true;
player.UseInputControls=false;weapon.CancelReload();player.UpdateDucking(true);
return true;
}
public void CancelSlide(bool keepCrouched=false)
{
if(!Sliding)return;
Sliding=false;slideSurface=null;
if(!player.IsValid())return;
player.UseInputControls=slideInput;player.WishVelocity=Vector3.Zero;if(!keepCrouched)player.UpdateDucking(false);
if(player.Body.IsValid())player.Body.Velocity=Vector3.Zero;
}
private void UpdateSlide()
{
slideElapsed+=Time.Delta;
if(!slideSurface.IsValid() || !slideSurface.Enabled || !slideSurface.GameObject.Active || slideElapsed>1.5f || player.IsAirborne){CancelSlide();return;}
var offset=(slideTarget-WorldPosition).WithZ(0);
if(offset.Length<28)
{
CancelSlide(true);
if(TryEnter(SlideDirection))player.UpdateDucking(LowCover);
else player.UpdateDucking(false);
return;
}
var step=WorldPosition+offset.Normal*MathF.Min(offset.Length,240*Time.Delta+2);
if(!CornerClear(step)){CancelSlide();return;}
player.UpdateDucking(true);
player.WishVelocity=offset.Normal*MathF.Min(240,80+offset.Length*1.4f);
}
public bool LowCover { get; private set; }
public bool Peeking { get; private set; }
private bool candidateAvailable;
private bool MovementAllowsEntry => player is not null && !player.IsAirborne && Components.Get<CloseCombat>()?.Incapacitated!=true && Components.Get<VaultController>()?.IsVaulting != true;
public bool CanEnter => Enabled && candidateAvailable && !Attached && MovementAllowsEntry && (TestInput || weapon?.AcceptInput == true);
public bool CornerAvailable { get; private set; }
public bool TestInput { get; set; }
public Vector3 TestMovement { get; set; }
public bool TestAim { get; set; }
public Vector3 Normal { get; private set; }
public string Hint => Sliding ? "SLIDING INTO COVER" : Attached ? (LowCover
? $"{Key("cover")} LEAVE · {Key("vault")} VAULT · {Key("attack2")} PEEK"
: CornerAvailable ? $"{Key("cover")} LEAVE COVER · {Key("attack2")} PEEK · {Key("shoulder")} SHOULDER"
: $"{Key("cover")} LEAVE COVER · {Key("left")}/{Key("right")} SLIDE · {Key("shoulder")} SHOULDER") : CanEnter ? $"{Key("cover")} TAKE COVER" : "";
private string Key(string action)=>PaintballControls.Display(Scene,action);
private PlayerController player;
private PaintballMarker weapon;
private CoverSurface surface;
private Vector3 facePoint;
private float originalDuckHeight;
private bool originalInput;
private float probeTime;
private Vector3 edgeDirection;
private Vector3? cornerOrigin;
private Vector3 cornerTarget;
private Vector3 cachedPeek;
private float cornerProbeTime;
private const float StandOff = 20;
// Camera follows the exposed edge; the marker always remains right-handed.
public float PeekCameraSide => !LowCover && cornerOrigin.HasValue && Peeking
? (Vector3.Dot(edgeDirection,Rotation.FromYaw(player.EyeAngles.yaw).Right)>=0 ? 1 : -1) : 0;
protected override void OnStart()
{
player = Components.Get<PlayerController>();
player.JumpSpeed = 0;
weapon = Components.Get<PaintballMarker>();
}
private SceneTraceResult Probe( Vector3 from, Vector3 direction, float distance ) => Scene.Trace.Ray( from, from + direction * distance ).IgnoreGameObjectHierarchy( GameObject ).Run();
private CoverSurface Candidate( out SceneTraceResult hit, Vector3? direction=null )
{
hit = Probe( WorldPosition + Vector3.Up * 28, direction ?? Rotation.FromYaw( player.EyeAngles.yaw ).Forward, 70 );
// Authored Jersey barriers lean slightly; the attachment uses their
// horizontal normal. Still reject tops and steeply inclined surfaces.
if ( !hit.Hit || MathF.Abs( hit.Normal.z ) > .35f ) return null;
var found = hit.GameObject.Components.Get<CoverSurface>();
return found.IsValid() && found.Enabled ? found : null;
}
public bool TryEnter(Vector3? direction=null)
{
if ( !Enabled || Attached || Sliding || !MovementAllowsEntry ) return false;
var candidate = Candidate( out var hit, direction );
if ( candidate is null ) return false;
surface = candidate;
CornerAvailable = false;
cornerProbeTime=0;
edgeDirection = Vector3.Zero;
cornerOrigin = null;
Normal = hit.Normal.WithZ( 0 ).Normal;
facePoint = hit.EndPosition;
LowCover = surface.Top - WorldPosition.z < player.BodyHeight - 3;
originalInput = player.UseInputControls;
originalDuckHeight = player.DuckedHeight;
player.UseInputControls = false;
if ( LowCover ) player.DuckedHeight = MathF.Min( originalDuckHeight, MathF.Max( 30, surface.Top - WorldPosition.z - 5 ) );
return true;
}
public void Leave()
{
if(IsProxy)return;
CancelSlide();
if ( !Attached ) return;
surface = null;
LowCover = false;
Peeking = false;
CornerAvailable = false;
cornerOrigin = null;
if ( !player.IsValid() ) return;
player.DuckedHeight = originalDuckHeight;
player.UpdateDucking( false );
player.WishVelocity = Vector3.Zero;
player.UseInputControls = originalInput;
}
// Cover and vault are separate deliberate actions. Forward intent only
// chooses the running slide approach; it never starts a vault.
public bool Activate(bool forward)
{
if(!Enabled || Sliding || !MovementAllowsEntry || (!TestInput && weapon?.AcceptInput!=true))return false;
if(forward && TrySlide())return true;
if(Attached)
{
Leave();return true;
}
return TryEnter();
}
protected override void OnUpdate()
{
if(IsProxy)return;
if ( player is null ) return;
if ( weapon?.AcceptInput != true && !TestInput ) { Leave(); return; }
if ( !TestInput && Input.Pressed( "cover" ) )
{
Activate(Input.AnalogMove.x>.5f && !Input.Down("attack2"));
}
probeTime -= Time.Delta;
if ( probeTime <= 0 )
{
candidateAvailable = !Attached && MovementAllowsEntry && Candidate( out _ ) is not null;
probeTime = .1f;
}
}
protected override void OnFixedUpdate()
{
if(IsProxy)return;
if(Sliding){UpdateSlide();return;}
if ( !Attached || player is null ) return;
if ( !surface.IsValid() || !surface.Enabled || !surface.GameObject.Active )
{
Leave(); return;
}
if ( surface.Curved && !cornerOrigin.HasValue )
{
// Follow the actual hull instead of retaining the tangent plane at entry.
var inward = (surface.WorldPosition - WorldPosition).WithZ( 0 ).Normal;
var face = Probe( WorldPosition + Vector3.Up * 28, inward, 85 );
if ( !face.Hit || face.GameObject != surface.GameObject || MathF.Abs( face.Normal.z ) > .15f )
{
Leave(); return;
}
Normal = face.Normal.WithZ( 0 ).Normal;
facePoint = face.EndPosition;
edgeDirection = Vector3.Zero;
}
var movement = TestInput ? TestMovement : Rotation.FromYaw( player.EyeAngles.yaw ) * Input.AnalogMove;
// Moving away cancels immediately; movement into the wall never clips the body through it.
if ( Vector3.Dot( movement, Normal ) > .55f ) { Leave(); return; }
var aim = TestInput ? TestAim : Input.Down( "attack2" );
player.UpdateDucking( LowCover && !aim );
Peeking = LowCover && aim && !player.IsDucking;
var tangent = Vector3.Cross( Vector3.Up, Normal ).Normal;
var slide = tangent * Vector3.Dot( movement, tangent ) * 55;
if ( !LowCover && cornerOrigin.HasValue )
{
// Recheck after an aim change, moving farther along this edge only.
// Never switch to the other side of the obstacle while holding aim.
cornerProbeTime-=Time.Delta;
if(aim && cornerProbeTime<=0)
{
cornerProbeTime=.12f;
bool settled=(cornerTarget-WorldPosition).WithZ(0).Length<6 && weapon.Aiming
&& weapon.PresentationAnchor.IsValid() && Vector3.Dot(weapon.PresentationAnchor.WorldRotation.Forward,player.EyeAngles.ToRotation().Forward)>.9f;
if(!StandingLaneClear(cornerTarget) || settled&&!StandingMarkerClear(cornerTarget))
for(float extra=4;extra<=32;extra+=4)
{
var next=cornerTarget+edgeDirection*extra;
if((next-cornerOrigin.Value).Length>136)break;
if(CornerClear(next)&&StandingLaneClear(next)&&(!settled||StandingMarkerClear(next))){cornerTarget=next;break;}
}
}
var destination = aim ? cornerTarget : cornerOrigin.Value;
var offset = (destination - WorldPosition).WithZ( 0 );
Peeking = aim && offset.Length < 4;
player.WishVelocity = offset.Normal * MathF.Min( 180, offset.Length * 16 );
if ( !aim && offset.Length < 1 ) cornerOrigin = null;
return;
}
// Probe beyond the body radius so an unprompted slide stops before the end of the face.
if ( slide.Length > 1 && !surface.Curved )
{
var edge = Probe( WorldPosition + Vector3.Up * 28 + slide.Normal * 22, -Normal, 80 );
if ( !edge.Hit || edge.GameObject != surface.GameObject || Vector3.Dot( edge.Normal, Normal ) < .9f )
{
edgeDirection = slide.Normal;
slide = Vector3.Zero;
}
else edgeDirection = Vector3.Zero;
}
// Detect reachable exposure even when entering directly beside an edge.
// Rounded bunkers have no hard edge, so find a clear tangent position.
cornerProbeTime-=Time.Delta;
if(cornerProbeTime<=0)
{
CornerAvailable = !LowCover && FindStandingPeek(tangent, out cachedPeek);
cornerProbeTime=.08f;
}
if ( aim && CornerAvailable )
{
cornerOrigin = WorldPosition;
cornerTarget = cachedPeek;
}
var gap = Vector3.Dot( WorldPosition - facePoint, Normal );
if ( gap > 85 || gap < 0 ) { Leave(); return; }
var correction = Normal * ((StandOff - gap) * 14).Clamp( -180, 180 );
player.WishVelocity = slide + correction;
}
private bool FindStandingPeek(Vector3 tangent, out Vector3 destination)
{
destination=WorldPosition;
var preferred=edgeDirection.Length>.5f ? edgeDirection : tangent*(weapon?.LeftShoulder==true ? 1 : -1);
for(float distance=24;distance<=112;distance+=8)
{
// Choose the nearest reachable edge, using shoulder preference only
// as a tie breaker. A long wall still cannot force a distant excursion.
foreach(var side in new[]{preferred,-preferred})
{
var target=WorldPosition+side*distance;
if(!CornerClear(target))continue;
if(!StandingLaneClear(target))continue;
destination=target;edgeDirection=side;return true;
}
}
return false;
}
private bool StandingLaneClear(Vector3 position)
{
var facing=player.EyeAngles.ToRotation();
// Test the right-hand grip and complete barrel corridor, rather than
// a ray from the character's centre. This matters at a left corner.
foreach(float height in new[]{46f,58f})
{
var eye=position+Vector3.Up*height;
var grip=eye+facing.Right*16;
var muzzle=grip+facing.Forward*36;
foreach(var segment in new[]{(eye,grip),(grip,muzzle),(muzzle,muzzle+facing.Forward*160)})
if(Scene.Trace.Sphere(2,segment.Item1,segment.Item2).IgnoreGameObjectHierarchy(GameObject)
.WithoutTags("paintball_debris","paintball_actor").WithSurfaceMeshes().Run().Hit)return false;
}
return true;
}
private bool StandingMarkerClear(Vector3 position)
{
var offset=position-WorldPosition;
var muzzle=weapon.Muzzle+offset;
foreach(var segment in new[]{(player.EyePosition+offset,muzzle),(muzzle,muzzle+player.EyeAngles.ToRotation().Forward*160)})
if(Scene.Trace.Sphere(1.4f,segment.Item1,segment.Item2).IgnoreGameObjectHierarchy(GameObject)
.WithoutTags("paintball_debris","paintball_actor").WithSurfaceMeshes().Run().Hit)return false;
return true;
}
private bool CornerClear( Vector3 destination )
{
// Sweep a body-sized volume at both torso heights before committing to exposure.
foreach ( var height in new[] { 18f, 50f } )
{
var hit = Scene.Trace.Sphere( 16, WorldPosition + Vector3.Up * height, destination + Vector3.Up * height )
.IgnoreGameObjectHierarchy( GameObject ).Run();
if ( hit.Hit ) return false;
}
return Probe( destination + Vector3.Up * 12, Vector3.Down, 24 ).Hit;
}
protected override void OnDisabled() => Leave();
protected override void OnDestroy() => Leave();
}