PaintballMarker.cs
using System;
using Sandbox;

namespace PaintballBaddies;

/// <summary>Finite-ammo training marker with swept, gravity-driven paintballs.</summary>
public sealed class PaintballMarker : Component
{
    public const float ReloadDuration = 1.65f;
    // Source barrel end is -Y 0.434m with its centre at Z 0.09519m.
    public static readonly Vector3 MuzzleOffset = new(17.1f,0,3.75f);
    public int Ammo { get; private set; } = 40;
    public int Reserve { get; private set; } = 160;
    public bool UnlimitedAmmo => Components.Get<ArenaMatch>()?.TimedContest == true;
    public int ActivePaintballs => balls.Count;
    public int Shots { get; private set; }
    public int Hits { get; private set; }
    public int Score => Hits * 100;
    public float ReloadRemaining { get; private set; }
    public float ActiveReloadDuration { get; private set; } = ReloadDuration;
    public float ReloadProgress => 1 - (ReloadRemaining / ActiveReloadDuration).Clamp( 0, 1 );
    public float HitFlash { get; private set; }
    public float EliminationFlash { get; private set; }
    public bool Aiming { get; private set; }
    public bool? ReviewAimOverride { get; set; }
    public bool LeftShoulder { get; private set; }
    public bool AcceptInput { get; set; } = true;
    public Vector3 Muzzle { get; private set; }
    // Optional renderer-owned pose; firing, collision and ammunition remain owned here.
    public GameObject PresentationAnchor { get; set; }
    private PlayerController player;
    private GameObject marker;
    private float cooldown;
    private int networkSequence;
    internal void NetworkHit()
    {
        Hits++;HitFlash=.15f;Sound.Play("sounds/paintball/hit_confirm.sound",WorldPosition);
    }
    private float renderClock, simulationClock, physicsStep=.02f;
    private float coveredAimPitch;
    private ReloadPresentationTrack reloadPresentation;
    private string presentationModel;
    private readonly List<Ball> balls = new();
    private sealed class Ball { public GameObject Visual,Bead; public Vector3 Velocity, Position, Previous, Origin; public float Life; public bool VisualOnly; public Color Tint; public readonly HashSet<ArenaOpponent> Alerted=new(); }

    protected override void OnStart()
    {
        player = Components.Get<PlayerController>();
        marker = new GameObject( GameObject, true, "VX-9 training marker" ){NetworkMode=NetworkMode.Never};
        var renderer = marker.Components.Create<ModelRenderer>();
        renderer.Model = Model.Load( "models/weapons/vx9_full_frame/vx9_full_frame.vmdl" );
    }

    protected override void OnUpdate()
    {
        if ( player is null ) return;
        renderClock+=Time.Delta;
        var markerVisual = marker?.Components.Get<ModelRenderer>();
        if (markerVisual.IsValid()) markerVisual.Enabled = !PresentationAnchor.IsValid();
        cooldown = MathF.Max( 0, cooldown - Time.Delta );
        HitFlash = MathF.Max( 0, HitFlash - Time.Delta );
        EliminationFlash = MathF.Max( 0, EliminationFlash - Time.Delta );
        if ( ReloadRemaining > 0 )
        {
            ReloadRemaining = MathF.Max( 0, ReloadRemaining - Time.Delta );
            if ( ReloadRemaining == 0 )
            {
                int count = Math.Min( 40 - Ammo, Reserve );
                Ammo += count;
                Reserve -= count;
                Sound.Play("sounds/paintball/reload_ready.sound",WorldPosition);
            }
        }
        Aiming = IsProxy ? Components.Get<NetworkPawn>()?.Aiming==true : !RoundEnded && Components.Get<CloseCombat>()?.Incapacitated!=true && Components.Get<CloseCombat>()?.Attacking!=true && Components.Get<VaultController>()?.IsVaulting != true
            && (ReviewAimOverride ?? (AcceptInput && !IsProxy && Input.Down( "attack2" )));
        var pitchTarget = RoundEnded || ReloadRemaining > 0 || Components.Get<VaultController>()?.IsVaulting == true
            ? 0 : player.EyeAngles.pitch;
        coveredAimPitch += (pitchTarget.Clamp( -45, 45 ) - coveredAimPitch) * (1 - MathF.Exp( -12 * Time.Delta ));
        if ( AcceptInput && !IsProxy && Input.Pressed( "shoulder" ) ) LeftShoulder = !LeftShoulder;
        var shoulder = LeftShoulder ? -1f : 1f;
        var peekCamera=Components.Get<CoverController>()?.PeekCameraSide ?? 0;
        if(Aiming && peekCamera!=0)shoulder=peekCamera;
        var cameraTarget = Aiming ? new Vector3( 55, 22 * shoulder, 8 ) : new Vector3( 82, 28 * shoulder, 10 );
        player.CameraOffset = Vector3.Lerp( player.CameraOffset, cameraTarget, 1 - MathF.Exp( -16 * Time.Delta ) );
        var rotation = player.EyeAngles.ToRotation();
        var grip = player.EyePosition + rotation.Forward * 12 + rotation.Right * 9 - Vector3.Up * 16;
        if ( player.Renderer.TryGetBoneTransform( "RightHand", out var hand ) )
        {
            grip = hand.Position;
            if ( CoveredRosterAssets.TryMarkerOffset( player.Renderer.Model?.Name, out var offset ) )
                grip += player.Renderer.WorldRotation * offset;
        }
        marker.WorldPosition = grip;
        marker.WorldRotation = rotation;
        Muzzle = grip + rotation * MuzzleOffset;
        UpdateCoveredMarkerPose( false );
        if ( AcceptInput && !IsProxy && Input.Pressed( "reload" ) ) Reload();
        if ( AcceptInput && !IsProxy && Input.Down( "attack1" ) ) Fire();
        if ( AcceptInput && !IsProxy && Input.Pressed( "use" ) && Components.Get<ArenaMatch>()?.InRound != true ) ResetTraining();
    }

    private bool RoundEnded => Components.Get<ArenaMatch>() is { InRound: false } match && match.Status == "TIME UP";

    public event Action ReloadStarted;
    public void Reload()
    {
        if (UnlimitedAmmo || RoundEnded || Components.Get<VaultController>()?.IsVaulting == true) return;
        if ( ReloadRemaining <= 0 && Ammo < 40 && Reserve > 0 )
        {
            ActiveReloadDuration = CoveredRosterAssets.ReloadTrack( player?.Renderer?.Model?.Name ) is not null ? 2.4f : ReloadDuration;
            ReloadRemaining = ActiveReloadDuration;
            ReloadStarted?.Invoke();
            Sound.Play("sounds/paintball/reload_start.sound",WorldPosition);
        }
    }

    // Ammunition transfers only on completion, so cancelling preserves both counts.
    public event Action ReloadCancelled;
    public void CancelReload()
    {
        if (ReloadRemaining <= 0) return;
        ReloadRemaining = 0;
        ReloadCancelled?.Invoke();
    }

    protected override void OnPreRender()
    {
        foreach(var ball in balls)
            if(ball.Visual.IsValid())
            {
                ball.Visual.WorldPosition=Vector3.Lerp(ball.Previous,ball.Position,((renderClock-simulationClock)/physicsStep).Clamp(0,1));
                PaintballFlight.UpdateBead(ball.Bead);
            }
        UpdateReloadPresentation();
        UpdateCoveredMarkerPose( true );
        CoveredSuitMotion.Apply( player?.Renderer );
        PoseApplied?.Invoke(player?.Renderer);
    }
    public event System.Action<SkinnedModelRenderer> PoseApplied;

    private void UpdateReloadPresentation()
    {
        if(PresentationAnchor.IsValid())
        {
            if(reloadPresentation.IsValid()) { reloadPresentation.Destroy(); reloadPresentation=null; }
            return;
        }
        if ( player?.Renderer is not { } body || !marker.IsValid() ) return;
        var modelName = body.Model?.Name;
        var trackPath = CoveredRosterAssets.ReloadTrack( modelName );
        var weaponModel = trackPath is null ? "models/weapons/vx9_full_frame/vx9_full_frame.vmdl"
            : "models/weapons/vx9_speed_feed/vx9_speed_feed.vmdl";
        var visual = marker.Components.Get<ModelRenderer>();
        // Scene teardown/hotload can remove the renderer before its owning object.
        if (!visual.IsValid()) return;
        if ( visual.Model?.Name != weaponModel ) visual.Model = Model.Load( weaponModel );
        var playing = trackPath is not null && ReloadRemaining > 0 && Components.Get<VaultController>()?.IsVaulting != true;
        if ( reloadPresentation.IsValid() && (trackPath is null || presentationModel != modelName) )
        {
            reloadPresentation.Destroy(); reloadPresentation = null;
        }
        if ( trackPath is not null )
        {
            if ( !reloadPresentation.IsValid() )
            {
                reloadPresentation = GameObject.Components.Create<ReloadPresentationTrack>();
                reloadPresentation.Body = body; reloadPresentation.TrackPath = trackPath;
                presentationModel = modelName;
            }
            reloadPresentation.Phase = ReloadProgress;
            reloadPresentation.Docked = !playing;
            reloadPresentation.BodyOffset = Vector3.Zero;
            if(playing && body.Sequence.Name!="reload_standing" && CoveredRosterAssets.TryMarkerOffset(modelName,out var reloadOffset)
                && ReloadUpperBodyData.Apply(body,ReloadProgress,reloadOffset,out _,out var translation,false))
                reloadPresentation.BodyOffset=translation;
            reloadPresentation.ApplyFrame();
        }
    }

    private void UpdateCoveredMarkerPose( bool writeBones )
    {
        if(PresentationAnchor.IsValid() && marker.IsValid())
        {
            marker.WorldPosition=PresentationAnchor.WorldPosition;
            marker.WorldRotation=PresentationAnchor.WorldRotation;
            Muzzle=PresentationAnchor.WorldTransform.PointToWorld(MuzzleOffset);
            return;
        }
        if ( player?.Renderer is not { } body || !marker.IsValid()
            || !CoveredRosterAssets.TryMarkerOffset( body.Model?.Name, out var offset ) ) return;
        var pitch = ReloadRemaining > 0 || Components.Get<VaultController>()?.IsVaulting == true ? 0 : coveredAimPitch;
        if(ReloadRemaining>0 && body.Sequence.Name=="reload_standing")
            offset=ReloadUpperBodyData.MarkerOffset(body.Model?.Name,offset,ReloadProgress);
        if(ReloadRemaining>0 && Components.Get<VaultController>()?.IsVaulting!=true && body.Sequence.Name!="reload_standing"
            && ReloadUpperBodyData.Apply(body,ReloadProgress,offset,out var reloadPose,out _,writeBones))
        {
            marker.WorldPosition=reloadPose.Position;marker.WorldRotation=reloadPose.Rotation;
            Muzzle=reloadPose.Position+reloadPose.Rotation*MuzzleOffset;
            return;
        }
        if ( CoveredAimPose.Apply( body, pitch, offset, out var pose, writeBones ) )
        {
            marker.WorldPosition = pose.Position;
            marker.WorldRotation = pose.Rotation;
            Muzzle = pose.Position + pose.Rotation * MuzzleOffset;
        }
    }

    public bool Fire()
    {
        if ( cooldown > 0 || ReloadRemaining > 0 || (!UnlimitedAmmo && Ammo <= 0) || balls.Count >= 64 || Scene.Camera is null ) return false;
        var camera = Scene.Camera;
        var sight = PaintballFlight.Trace(Scene,camera.WorldPosition,camera.WorldPosition+camera.WorldRotation.Forward*(PaintballFlight.Speed*PaintballFlight.Lifetime),GameObject);
        return FireAt( sight.EndPosition );
    }

    // Shared with native integration checks; target selection is separate from projectile physics.
    public bool FireAt( Vector3 aimPoint )
    {
        if(Components.Get<CloseCombat>() is { } contact && (contact.Incapacitated || contact.Attacking))return false;
        if (RoundEnded || Components.Get<CoverController>()?.Sliding==true) return false;
        if ( Components.Get<VaultController>()?.IsVaulting == true ) return false;
        var cover = Components.Get<CoverController>();
        if ( cover?.Attached == true && !cover.Peeking ) return false;
        if ( cooldown > 0 || ReloadRemaining > 0 || (!UnlimitedAmmo && Ammo <= 0) || balls.Count >= 64 ) return false;
        UpdateCoveredMarkerPose( false );
        if(!UnlimitedAmmo)Ammo--;
        Shots++; cooldown = PaintballFlight.ShotInterval;
        Sound.Play(CharacterMarkerSound.For(Components.Get<RosterSelection>()?.Selected ?? 0),Muzzle);
        if(MultiplayerSession.Online)
        {
            Components.Get<NetworkPawn>()?.RequestShot(Muzzle,aimPoint,++networkSequence);
            if(!MultiplayerSession.Authority)CreateLocalBall(aimPoint,true);
            return true;
        }
        ArenaOpponent.ReportMarkerSound(Scene,GameObject,Muzzle);
        // A barrel clipping through cover must not let a shot emerge on its far side.
        var obstruction = PaintballFlight.Trace(Scene,player.EyePosition,Muzzle,GameObject);
        var tint=CharacterPaintColor.For(GameObject);
        if ( obstruction.Hit ) { Impact( obstruction,tint ); return true; }
        CreateLocalBall(aimPoint,false);
        return true;
    }
    private void CreateLocalBall(Vector3 aimPoint,bool visualOnly)
    {
        if(visualOnly && PaintballFlight.Trace(Scene,player.EyePosition,Muzzle,GameObject).Hit)return;
        var tint=CharacterPaintColor.For(GameObject);
        var visual = new GameObject(false,"Paintball"){NetworkMode=NetworkMode.Never};
        visual.Tags.Add("paintball_debris");visual.WorldPosition=Muzzle;
        var bead=PaintballFlight.CreateBead(visual,tint);visual.Enabled=true;
        balls.Add(new Ball {Visual=visual,Bead=bead,Tint=tint,VisualOnly=visualOnly,Position=Muzzle,Previous=Muzzle,Origin=Muzzle,
            Velocity=PaintballFlight.LaunchVelocity(Muzzle,aimPoint),Life=PaintballFlight.Lifetime});
    }

    protected override void OnFixedUpdate()
    {
        simulationClock+=Time.Delta;physicsStep=System.MathF.Max(Time.Delta,.001f);
        for ( int i = balls.Count - 1; i >= 0; i-- )
        {
            var b = balls[i];
            var start = b.Position;
            b.Previous=start;
            var end = start + b.Velocity * Time.Delta + Vector3.Down * (PaintballFlight.Gravity * .5f * Time.Delta * Time.Delta);
            var trace = PaintballFlight.Trace(Scene,start,end,GameObject);
            if(!b.VisualOnly)IncomingPaintAwareness.Report(Scene,GameObject,b.Origin,start,trace.Hit ? trace.EndPosition : end,trace.GameObject,b.Alerted);
            b.Life -= Time.Delta;
            if ( trace.Hit || b.Life <= 0 )
            {
                if ( trace.Hit && !b.VisualOnly ) Impact( trace,b.Tint,b.Origin );
                b.Visual.Destroy(); balls.RemoveAt( i ); continue;
            }
            b.Position = end;
            b.Velocity += Vector3.Down * PaintballFlight.Gravity * Time.Delta;
        }
    }

    public string LastImpactObject { get; private set; }
    private void Impact( SceneTraceResult trace,Color tint,Vector3? launchOrigin=null )
    {
        LastImpactObject=trace.GameObject?.Name;
        var source=launchOrigin ?? Muzzle;
        if(ArenaShield.TryAbsorbImpact(trace,source,tint))return;
        var target = trace.GameObject?.Components.Get<PaintballTarget>();
        if ( target is not null ) { target.Hit(); Hits++; HitFlash = 0.15f; }
        var combatant = PaintballCombatant.Find( trace.GameObject );
        if ( combatant?.RegisterHit( 0, source, Components.Get<PaintballCombatant>() ) == true )
        {
            Hits++; HitFlash = .15f;
            if(combatant.Eliminated) EliminationFlash = .4f;
            Sound.Play("sounds/paintball/hit_confirm.sound", WorldPosition);
        }
        PaintImpactSystem.Find(Scene)?.Spawn(trace,tint);
    }

    public void EndRound()
    {
        AcceptInput = false;
        CancelReload();
        Aiming = false;
        foreach (var ball in balls) if (ball.Visual.IsValid()) ball.Visual.Destroy();
        balls.Clear();
        if (reloadPresentation.IsValid()) reloadPresentation.Destroy();
    }

    public void ResetTraining()
    {
        CancelReload();
        Ammo = 40; Reserve = 160; Shots = 0; Hits = 0; cooldown = 0;
        HitFlash = EliminationFlash = 0;
        foreach ( var b in balls ) b.Visual.Destroy();
        balls.Clear();
        PaintImpactSystem.Find(Scene)?.Clear();
    }
}