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