CitizenOpponentPresentation.cs
using Sandbox;
using Sandbox.Citizen;

namespace PaintballBaddies;

/// <summary>Opt-in native opponent presentation driven by existing AI movement and magazine state.</summary>
public sealed class CitizenOpponentPresentation : Component
{
    [Property] public string ModelPath { get; set; } = "models/characters/citizen_out_signal_review/character.vmdl";
    [Property] public string OutfitPath { get; set; } = "models/gear/citizen_curvy_suit_review/suit.vmdl";
    [Property] public bool UseRosterSuit { get; set; } = true;
    [Property] public string GlovesPath { get; set; } = "models/gear/citizen_female_gloves/gloves.vmdl";
    [Property] public string BootsPath { get; set; } = "models/citizen_clothes/shoes/boots/models/boots_m_human.vmdl";
    private ArenaOpponent opponent;
    private OpponentMagazine weapon;
    [Property] public string HelmetPath { get; set; } = "models/gear/citizen_closed_helmet_review/helmet.vmdl";
    private GameObject subject, marker, support, previousAnchor;
    private SkinnedModelRenderer body, previousBody;
    private bool previousVisible;
    private CitizenLocomotion locomotion;
    private CitizenMarkerGrip grip;
    private CitizenPodReload pod;
    private float shieldPoseWeight;
    private CharacterEquipment equipment;
    public string EquippedSuit => equipment?.Outfit?.Model?.Name;
    public string EquippedHelmet => equipment?.Helmet?.Model?.Name;
    public bool ProtectiveGearReady => equipment.IsValid()
        && GearReady(equipment.Outfit) && GearReady(equipment.Helmet)
        && GearReady(equipment.Gloves) && GearReady(equipment.Boots);
    private bool GearReady(SkinnedModelRenderer piece) => piece.IsValid()
        && piece.Model.IsValid() && piece.Enabled && piece.GameObject.Active
        && piece.BoneMergeTarget==body;
    public bool VisualActive => subject.IsValid() && subject.Active;
    private readonly CitizenReloadClock clock = new();
    private bool reloading, cancelled, gesture;
    private float time, posture;
    private float outTime;
    private float stationaryAimTime;
    public float HeadHeight => body.IsValid() && body.TryGetBoneTransform("head",out var head) ? head.Position.z-subject.WorldPosition.z : -1;
    public float AnimationSpeed { get; private set; }
    public float PeakAnimationSpeed { get; private set; }
    public bool ShowingOutSignal => outTime > 0 && outTime < 1.5f;
    public float OutSignalWeight { get; private set; }
    public float SignalHandAboveHead => body.IsValid()
        && body.TryGetBoneTransform("hand_R",out var hand)
        && body.TryGetBoneTransform("head",out var head) ? hand.Position.z-head.Position.z : -999;
    private Transform cancelHand;
    private Rotation cancelPod;
    public bool IsReady => body.IsValid() && body.Model.IsValid();
    public SkinnedModelRenderer FootstepRenderer => body;
    public bool PodHeld => pod.IsValid() && pod.podHeld;
    public bool ReloadPropsVisible => pod.IsValid() && pod.HasActiveReloadProps;

    protected override void OnStart()
    {
        TryInitialize();
    }

    private void TryInitialize()
    {
        if (subject.IsValid()) return;
        opponent = Components.Get<ArenaOpponent>();
        if (!opponent.IsValid() || !opponent.Body.IsValid()) return;
        weapon = opponent.Magazine;
        previousBody = opponent.Body;
        previousVisible = previousBody.IsValid() && previousBody.Enabled;
        subject = new GameObject(GameObject) { Name = "Citizen opponent presentation", NetworkMode=NetworkMode.Never };
        subject.WorldPosition = opponent.WorldPosition;
        subject.WorldRotation = opponent.WorldRotation;
        body = subject.Components.Create<SkinnedModelRenderer>();
        body.Model = Model.Load(ModelPath);
        body.UseAnimGraph = true;
        equipment=subject.Components.Create<CharacterEquipment>();
        equipment.Configure(body,
            UseRosterSuit ? CitizenRosterAssets.Suit(opponent.CharacterIndex) : OutfitPath,
            GlovesPath, BootsPath, UseRosterSuit ? CitizenRosterAssets.Helmet(opponent.CharacterIndex) : HelmetPath);
        subject.Components.Create<CitizenSuitFollowThrough>().Configure(body,equipment.Outfit);
        locomotion = subject.Components.Create<CitizenLocomotion>();
        locomotion.Configure(body, CitizenAnimationHelper.HoldTypes.Rifle);
        grip = subject.Components.Create<CitizenMarkerGrip>();
        pod = subject.Components.Create<CitizenPodReload>();
        pod.CreateGear();
        marker = new GameObject(subject) { Name = "VX-9 native opponent presentation" };
        marker.Components.Create<ModelRenderer>().Model = Model.Load("models/weapons/vx9_speed_feed/vx9_speed_feed.vmdl");
        support = new GameObject(subject) { Name = "Marker support hand" };
        previousAnchor = opponent.PresentationAnchor;
        opponent.PresentationAnchor = marker;
        weapon.ReloadStarted += StartReload;
        weapon.ReloadCancelled += CancelReload;
        if (previousBody.IsValid()) previousBody.Enabled = false;
        if (weapon.ReloadRemaining > 0) StartReload();
    }

    private void StartReload()
    {
        reloading=true;cancelled=false;gesture=false;time=.8f;
        clock.Reset();grip.ResetRecovery();pod.ResetCycle();
    }

    private void CancelReload()
    {
        if (!reloading || cancelled || !body.IsValid()) return;
        cancelled=true;
        if (body.TryGetBoneTransform("hand_L",out var hand)) cancelHand=subject.WorldTransform.ToLocal(hand);
        if (pod.refillPod.IsValid()) cancelPod=subject.WorldRotation.Inverse*pod.refillPod.WorldRotation;
    }

    protected override void OnUpdate()
    {
        // Scene components can start before ArenaOpponent creates its renderer.
        if (!subject.IsValid()) TryInitialize();
        if (!IsReady || !opponent.IsValid() || weapon is null) return;
        if (previousBody.IsValid()) previousBody.Enabled=false;
        var heldShield=Scene.GetAllComponents<ArenaShield>().FirstOrDefault(x=>x.Owner.IsValid()&&x.Owner.GameObject==GameObject);
        bool shieldActive=heldShield is not null && !heldShield.Stowed;
        shieldPoseWeight+=((shieldActive ? 1 : 0)-shieldPoseWeight)*(1-System.MathF.Exp(-12*Time.Delta));
        body.Set("pb_shield_carry",shieldPoseWeight);
        locomotion.Animation.HoldType=!shieldActive ? CitizenAnimationHelper.HoldTypes.Rifle : CitizenAnimationHelper.HoldTypes.Pistol;
        locomotion.Animation.Handedness=!shieldActive ? CitizenAnimationHelper.Hand.Both : CitizenAnimationHelper.Hand.Right;
        var eliminated=opponent.Components.Get<PaintballCombatant>()?.Eliminated==true;
        if(eliminated)
        {
            outTime+=Time.Delta;
            subject.Enabled=ShowingOutSignal;
            marker.Enabled=false;
            pod.DropHeldPod(opponent.NavigationVelocity*.5f);
            pod.StopPresentation();
            pod.Enabled=false;
            locomotion.Animation.IkLeftHand=null;
            locomotion.Animation.IkRightHand=null;
            locomotion.UpdateMotion(Vector3.Zero,Vector3.Zero,true,opponent.CoverCrouched ? 1 : 0,
                0,subject.WorldRotation,0,true,Time.Delta);
            grip.UpdateGrip(body,0,true,Time.Delta);
            var progress=(outTime/.45f).Clamp(0,1);
            OutSignalWeight=progress*progress*(3-2*progress);
            body.Set("pb_out_weight",OutSignalWeight);
            return;
        }
        var sheltered=opponent.UsingCover;
        var duck=opponent.CoverCrouched ? 1f : 0f;
        var facing=opponent.WorldRotation;
        subject.WorldRotation=Rotation.Slerp(subject.WorldRotation,facing,1-System.MathF.Exp(-9*Time.Delta));
        if (reloading) time=clock.Sample(time,true,cancelled,grip.Returning,pod.podHeld,weapon.ReloadRemaining,(1-weapon.ReloadRemaining/weapon.ActiveReloadDuration),Time.Delta);
        var release=reloading && time>=.8f && time<3.2f;
        // Native agent velocity excludes position corrections and avoids measuring
        // movement against a child that already follows the parent this frame.
        var velocity=opponent.NavigationVelocity;
        AnimationSpeed=velocity.WithZ(0).Length;
        PeakAnimationSpeed=System.MathF.Max(PeakAnimationSpeed,AnimationSpeed);
        var look=Rotation.LookAt(opponent.CombatAimPoint-(subject.WorldPosition+Vector3.Up*(sheltered ? 35 : 60)));
        // While following a route, look with the body instead of craning all
        // the way back toward a threat that is no longer visible.
        if(velocity.WithZ(0).Length>30 || (!opponent.SeesPlayer&&!opponent.CoverPeeking))look=facing;
        stationaryAimTime=velocity.WithZ(0).Length>20 || System.MathF.Abs(locomotion.RotationSpeed)>12 ? 0 : stationaryAimTime+Time.Delta;
        var torsoAim=((stationaryAimTime-.12f)/.18f).Clamp(0,1);
        locomotion.UpdateMotion(velocity,opponent.NavigationWishVelocity,true,duck,0,Rotation.FromYaw(look.Angles().yaw),
            release || sheltered ? 0 : look.Angles().pitch,sheltered,Time.Delta,torsoAim,rootPosition:opponent.WorldPosition);
        var recovery=((time-3.2f)/.6f).Clamp(0,1);
        recovery=recovery*recovery*(3-2*recovery);
        posture+=((reloading ? duck*(1-recovery) : 0)-posture)*(1-System.MathF.Exp(-24*Time.Delta));
        body.Set("pb_reload_posture",posture);
        grip.UpdateGrip(body,1,release,Time.Delta);
        if (reloading && !cancelled && !gesture && time>=1) { body.Set("b_reload",true);gesture=true; }
        if (grip.Attach(body,marker,CitizenMarkerGrip.MarkerOffset,Angles.Zero))
        {
            grip.UpdateSupport(body,locomotion.Animation,marker,support,new Vector3(1.5f,1,0),release,
                reloading && time>=3.2f ? (time-3.2f)/.4f : -1);
            pod.Apply(body,locomotion.Animation,marker,support,grip.SupportRotation,grip.Grip,time,
                duck,release,cancelled,clock.CancellationElapsed,cancelHand,cancelPod,Time.Delta);
        }
        subject.WorldPosition=opponent.WorldPosition;
        Components.Get<CloseCombat>()?.ApplyPresentation(body,locomotion.Animation,marker);
        pod.SetCarryVisible(!weapon.UnlimitedAmmo || reloading);
        heldShield?.ApplyGrip(body,locomotion.Animation);
        if (reloading)
        {
            time+=Time.Delta;
            if (time>=4.5f && !pod.podHeld) reloading=false;
        }
    }

    protected override void OnDestroy()
    {
        if (weapon is not null)
        {
            weapon.ReloadStarted-=StartReload;weapon.ReloadCancelled-=CancelReload;
            if (opponent.IsValid() && opponent.PresentationAnchor==marker) opponent.PresentationAnchor=previousAnchor;
        }
        if (previousBody.IsValid()) previousBody.Enabled=previousVisible
            && opponent.IsValid() && opponent.Components.Get<PaintballCombatant>()?.Eliminated!=true;
        if (subject.IsValid()) subject.Destroy();
    }
}