Code/WeaponImporter/WeaponViewmodel.cs
using Sandbox;

namespace WeaponImporter;

/// <summary>
/// The first-person view of a weapon: the imported arms and weapon, placed so the rig's camera
/// sits at the player's eye, and shown only to the player who holds it. It plays the same action
/// as the <see cref="WeaponHold"/> beside it on the same clock, so first and third person never
/// drift apart: <c>WeaponHold.Play( "reload" )</c> (or the character's own reload) drives both.
/// </summary>
[Title( "Weapon Viewmodel" )]
[Category( "Weapons" )]
[Icon( "videocam" )]
public sealed class WeaponViewmodel : Component
{
    /// <summary>The first-person model (arms and weapon); default: the renderer on this object.</summary>
    [Property] public SkinnedModelRenderer Renderer { get; set; }

    /// <summary>The third-person hold whose actions this view follows; default: the one above this object.</summary>
    [Property] public WeaponHold Hold { get; set; }

    /// <summary>Show the first-person view (turn off while the player uses a third-person camera).</summary>
    [Property] public bool FirstPerson { get; set; } = true;

    /// <summary>While in first person, the owner sees only the shadow of the third-person weapon.</summary>
    [Property] public bool HideWorldWeapon { get; set; } = true;

    /// <summary>Bone of the rig's camera ("" = none: <see cref="EyeInModel"/> is used as is).</summary>
    [Property] public string CameraBone { get; set; } = "";

    /// <summary>
    /// Turns the camera bone's axes into s&amp;box's view axes (forward +X, up +Z); exporters
    /// disagree on how a camera bone is oriented.
    /// </summary>
    [Property] public Rotation CameraAxes { get; set; } = Rotation.Identity;

    /// <summary>Eye in model space, used when the rig has no camera bone.</summary>
    [Property] public global::Transform EyeInModel { get; set; } = global::Transform.Zero;

    /// <summary>Extra offset of the view, in eye space (fine-tune the weapon's place on screen).</summary>
    [Property] public global::Transform Offset { get; set; } = global::Transform.Zero;

    /// <summary>
    /// Strength of the kick when firing while aimed with a weapon that has no aimed-fire
    /// animation (its graph keeps the sights up; this gives each shot some feedback). 0 = none.
    /// </summary>
    [Property, Range( 0, 3 )] public float AimKick { get; set; } = 1f;

    private const float KickBack = 0.6f;       // inches toward the eye
    private const float KickPitch = 1.5f;      // degrees muzzle up
    private const float KickRecover = 0.15f;   // seconds to settle
    private float _kick;

    /// <summary>Camera near clip while the view shows (the arms sit closer than the default 10).</summary>
    [Property] public float NearClip { get; set; } = 1f;

    private string _sequence = "";
    private WeaponHold _listening;

    /// <summary>The viewmodel plays its own animgraph (the importer generates one when the file has an idle).</summary>
    public bool UsesGraph => Renderer.IsValid() && Renderer.Model?.AnimGraph is not null;
    private bool _detached;
    private bool _showing;
    private CameraComponent _nearCamera;
    private float _savedNear;

    // Visibility is decided every update (not only when a frame is drawn), the pose and place
    // right before rendering, from the camera's final transform.
    protected override void OnUpdate()
    {
        if ( !Renderer.IsValid() )
            Renderer = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelf );
        if ( !Hold.IsValid() && !_detached )
            Hold = Components.GetInAncestorsOrSelf<WeaponHold>();
        // The view is placed in world space every frame, so it doesn't need its parents. Player
        // controllers hide the body (and everything under it) from the owner's camera in first
        // person, so it leaves the body's hierarchy and follows its weapon from the scene root.
        // Other players' copies (proxies) never show the view: leave them untouched and hidden.
        if ( Hold.IsValid() && Hold.IsProxy )
        {
            if ( Renderer.IsValid() )
                Renderer.Enabled = false;
            _showing = false;
            return;
        }
        if ( !_detached && Hold.IsValid() )
        {
            _detached = true;
            GameObject.SetParent( null, true );
        }
        if ( _detached && !Hold.IsValid() )
        {
            GameObject.Destroy();
            return;
        }
        var renderer = Renderer;
        if ( !renderer.IsValid() )
            return;
        Listen( Hold );
        DriveShellReload( renderer );
        if ( UsesGraph && renderer.UseAnimGraph && Hold.IsValid() )
        {
            renderer.Set( "ironsights", Hold.Aiming ? 1 : 0 );
            renderer.Set( "b_empty", Hold.Empty );
        }
        var camera = Scene.Camera;
        if ( _kick > 0f )
            _kick = MathF.Max( 0f, _kick - Time.Delta / KickRecover );
        _showing = FirstPerson && Hold.IsValid() && Hold.Active && !Hold.IsProxy && camera.IsValid();
        renderer.Enabled = _showing;
        SetNearClip( _showing ? camera : null );
        if ( HideWorldWeapon && Hold.IsValid() && Hold.WeaponRenderer is { } world && world.IsValid() && world != renderer )
            world.RenderType = _showing ? ModelRenderer.ShadowRenderType.ShadowsOnly : ModelRenderer.ShadowRenderType.On;
    }

    protected override void OnPreRender()
    {
        if ( _showing && Scene.Camera.IsValid() )
            UpdateView( Scene.Camera.WorldTransform );
    }

    /// <summary>Hears the hold's actions (the graph plays them on the viewmodel).</summary>
    private void Listen( WeaponHold hold )
    {
        if ( _listening == hold )
            return;
        if ( _listening.IsValid() )
            _listening.ActionStarted -= OnActionStarted;
        _listening = hold;
        if ( hold.IsValid() )
            hold.ActionStarted += OnActionStarted;
    }

    /// <summary>
    /// The hold started an action: the viewmodel's graph gets the matching parameter (the
    /// importer's generated graphs use these names; so do Facepunch's first-person graphs).
    /// </summary>
    private void OnActionStarted( string role )
    {
        var renderer = Renderer;
        if ( !UsesGraph || !renderer.IsValid() )
            return;
        renderer.UseAnimGraph = true;
        switch ( role )
        {
            case "fire":
            case "adsfire":
            case "fireempty":
                renderer.Set( "b_attack", true );
                // Aimed without an aimed-fire clip: the graph holds the sights up; kick instead.
                if ( Hold.IsValid() && Hold.Aiming && string.IsNullOrEmpty( Hold.SequenceFor( "adsfire" ) ) )
                    _kick = MathF.Min( 1.5f, _kick + 1f );
                break;
            case "reload":
            case "tacticalreload":
            case "emptyreload":
                renderer.Set( "b_empty", role == "emptyreload" || Hold.IsValid() && Hold.Empty );
                renderer.Set( "b_reload", true );
                break;
            case "reloadstart":
            case "reloadinsert":
                // Shell reload: DriveShellReload keeps b_reload on while more shells follow.
                renderer.Set( "b_reload", true );
                break;
            case "inspect":
                renderer.Set( "b_inspect", true );
                break;
            case "holster":
                renderer.Set( "b_holster", true );
                break;
            case "draw":
                renderer.Set( "b_holster", false );
                renderer.Set( "b_deploy", true );
                break;
        }
    }

    protected override void OnDestroy() => Listen( null );

    private bool _drivingShells;

    /// <summary>
    /// Shell-by-shell reloads: the graph loops its insert while b_reload stays on, so it is held
    /// exactly while another shell follows the current one, then released for the end part.
    /// </summary>
    private void DriveShellReload( SkinnedModelRenderer renderer )
    {
        if ( !UsesGraph || !Hold.IsValid() )
            return;
        if ( Hold.ShellReloading )
        {
            _drivingShells = true;
            // Held at the start of each insert too, so the graph always enters the reload; it only
            // reads b_reload again when the insert finishes.
            var more = Hold.CurrentRole == "reloadstart" ? Hold.ShellsRemaining > 0 : Hold.ShellsRemaining > 1 || Hold.CurrentTime < 0.25f;
            renderer.Set( "b_reload", more );
        }
        else if ( _drivingShells )
        {
            _drivingShells = false;
            renderer.Set( "b_reload", false );
        }
    }

    /// <summary>Lowers the camera's near clip while the view shows, and restores it afterwards.</summary>
    private void SetNearClip( CameraComponent camera )
    {
        if ( _nearCamera.IsValid() && _nearCamera != camera )
        {
            _nearCamera.ZNear = _savedNear;
            _nearCamera = null;
        }
        if ( camera.IsValid() && _nearCamera != camera )
        {
            _nearCamera = camera;
            _savedNear = camera.ZNear;
        }
        if ( _nearCamera.IsValid() )
            _nearCamera.ZNear = MathF.Min( _savedNear, NearClip );
    }

    protected override void OnDisabled()
    {
        SetNearClip( null );
        if ( Hold.IsValid() && Hold.WeaponRenderer is { } world && world.IsValid() )
            world.RenderType = ModelRenderer.ShadowRenderType.On;
    }

    /// <summary>Poses and places the view for an eye (the player's camera); called every frame it shows.</summary>
    public void UpdateView( global::Transform eye )
    {
        var renderer = Renderer;
        if ( !renderer.IsValid() )
            return;
        renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
        Animate( renderer );
        Place( renderer, eye );
    }

    /// <summary>The sequence the view plays (follows the hold's current action).</summary>
    public string PlayingSequence => _sequence;

    /// <summary>
    /// Without a graph: plays the hold's current action at the hold's own time. With one, the
    /// graph plays (actions arrive through <see cref="OnActionStarted"/>).
    /// </summary>
    private void Animate( SkinnedModelRenderer renderer )
    {
        if ( UsesGraph )
        {
            renderer.UseAnimGraph = true;
            return;
        }
        if ( !Hold.IsValid() )
            return;
        var sequence = Hold.SequenceFor( Hold.CurrentRole );
        if ( string.IsNullOrEmpty( sequence ) )
            sequence = Hold.SequenceFor( "idle" );
        var model = renderer.Model;
        if ( string.IsNullOrEmpty( sequence ) || model is null || !model.AnimationNames.Contains( sequence ) )
            return;
        renderer.UseAnimGraph = false;
        var player = renderer.Sequence;
        if ( _sequence != sequence )
        {
            _sequence = sequence;
            player.Name = sequence;
        }
        player.PlaybackRate = 0f;
        player.Time = Hold.CurrentTime * player.Duration;
    }

    /// <summary>Puts the model where its camera (as animated) meets the player's eye.</summary>
    private void Place( SkinnedModelRenderer renderer, global::Transform eye )
    {
        var eyeInModel = EyeInModel;
        if ( !string.IsNullOrEmpty( CameraBone ) && renderer.TryGetBoneTransform( CameraBone, out var boneWorld ) )
        {
            // Follow the camera's animation (recoil, reload sway).
            var bone = renderer.WorldTransform.ToLocal( boneWorld );
            eyeInModel = new global::Transform( bone.Position, bone.Rotation * CameraAxes );
        }
        var view = eye.ToWorld( Offset );
        if ( _kick > 0f && AimKick > 0f )
        {
            // Eased: a sharp push that settles smoothly.
            var k = _kick * _kick * AimKick;
            view = view.ToWorld( new global::Transform( new Vector3( -KickBack * k, 0f, 0f ), Rotation.FromPitch( -KickPitch * k ) ) );
        }
        // root * eyeInModel = view  =>  root = view * eyeInModel⁻¹
        renderer.WorldTransform = view.ToWorld( eyeInModel.ToLocal( global::Transform.Zero ) );
    }
}