OneMoreRoundWeapon.ViewModelPresentation.cs
using Sandbox;
using System;

/// <summary>
/// Stable owner-local first-person presentation for OneMoreRoundWeapon.
///
/// Movement, sprint, jump and look inertia remain authored through documented
/// Facepunch AnimGraph parameters. BaseCombatWeapon still establishes the
/// canonical viewmodel root transform from the composed camera every frame.
///
/// Attachment-specific ADS correction (for example centering the M700 scope
/// instead of its authored default ironsight line) is layered afterwards by
/// OneMoreRoundWeapon.ViewModelAlignment.cs from that stable per-frame baseline.
/// The correction never accumulates root translation from previous frames.
/// </summary>
public partial class OneMoreRoundWeapon
{
	private bool _viewModelPresentationInitialized;
	private bool _viewModelWasGrounded = true;
	private Rotation _viewModelLagRotation = Rotation.Identity;
	private float _viewModelMoveBob;
	private float _viewModelPitchInertia;
	private float _viewModelYawInertia;
	private GameObject _presentationViewModel;
	private float _nextViewModelDiagnosticAt;

	//
	// Facepunch viewmodels expose b_sprint as a persistent stance and b_attack
	// as a one-frame trigger. If both remain active at the instant of a shot,
	// the authored graph can keep the lowered sprint pose dominant while the
	// projectile still fires forward from gameplay aim.
	//
	// OMR therefore suppresses ONLY the first-person sprint pose for a brief
	// presentation window whenever a real shot is fired. Physical sprint speed
	// and sprint intent are intentionally unchanged.
	//
	private float _viewModelSprintSuppressedUntil;

	/// <summary>
	/// Real gameplay delay applied only to the FIRST shot when firing from the
	/// authored sprint pose. The input is buffered, the sprint pose is released
	/// immediately, and the shot happens once the weapon has had time to rise.
	///
	/// This intentionally does not alter the weapon's normal PrimaryDelay.
	/// </summary>
	[Property, Group( "Viewmodel Presentation" )]
	[Range( 0f, 0.40f )]
	[Title( "Sprint To Fire Delay" )]
	[Description( "Delay before the first shot when firing from the lowered sprint pose. The fire input is buffered while the weapon raises. Set to 0 to disable." )]
	public float SprintToFireDelay { get; set; } = 0.15f;

	[Property, Group( "Viewmodel Presentation" )]
	[Range( 0.05f, 0.50f )]
	[Title( "Sprint Fire Pose Hold" )]
	[Description( "How long after each shot the first-person sprint pose stays suppressed so the authored fire animation can finish before the weapon lowers again. Presentation only." )]
	public float SprintFirePoseHoldTime { get; set; } = 0.22f;

	private bool _sprintFireQueued;
	private float _sprintFireReadyAt;

	/// <summary>
	/// Called by the weapon lifecycle whenever the weapon/viewmodel is reset.
	/// This clears only OMR's animation-driver state. ADS itself is reset by the
	/// weapon's existing ResetAimState path.
	/// </summary>
	private void ResetViewModelPresentationState()
	{
		ResetViewModelAnimationParameters();
		ResetViewModelFireScaleGuard();

		_viewModelPresentationInitialized = false;
		_viewModelWasGrounded = true;
		_viewModelLagRotation = Rotation.Identity;
		_viewModelMoveBob = 0f;
		_viewModelPitchInertia = 0f;
		_viewModelYawInertia = 0f;
		_presentationViewModel = null;
		_nextViewModelDiagnosticAt = 0f;
		_viewModelSprintSuppressedUntil = 0f;
		_sprintFireQueued = false;
		_sprintFireReadyAt = 0f;
	}

	/// <summary>
	/// Updates documented Facepunch first-person weapon AnimGraph parameters.
	/// Called from OneMoreRoundWeapon.OnUpdate after BaseCombatWeapon has updated.
	/// </summary>
	private void UpdateViewModelPresentationAnimations()
	{
		GameObject viewModel = ViewModel;
		PlayerController owner = Owner;
		OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;

		if (
			viewModel is null ||
			!viewModel.IsValid ||
			owner is null ||
			tuning is null
		)
		{
			return;
		}

		SkinnedModelRenderer renderer = GetViewModelRenderer( viewModel );
		if ( renderer is null )
			return;

		UpdateViewModelFireScaleGuard( renderer );

		if ( viewModel != _presentationViewModel )
		{
			_presentationViewModel = viewModel;
			_viewModelPresentationInitialized = false;

			// A newly-created owner viewmodel has fresh AnimGraph state.
			// Re-apply ADS parameters immediately instead of waiting for
			// the aiming state itself to change.
			_viewModelAimStateDirty = true;
			EnsureViewModelAimState();
		}

		if ( !tuning.EnableViewModelAnimationFeel )
		{
			SetViewModelAnimationNeutral( renderer, owner );
			return;
		}

		if (
			!TryGetLocomotionSnapshot(
				out WeaponLocomotionSnapshot locomotion
			)
		)
		{
			return;
		}

		float dt = MathX.Clamp( Time.Delta, 0.001f, 0.05f );
		Rotation lookRotation = owner.EyeAngles.ToRotation();

		if ( !_viewModelPresentationInitialized )
		{
			_viewModelPresentationInitialized = true;
			_viewModelLagRotation = lookRotation;
			_viewModelWasGrounded = locomotion.IsGrounded;
			_viewModelMoveBob = 0f;
			_viewModelPitchInertia = 0f;
			_viewModelYawInertia = 0f;
		}

		Vector3 velocity = locomotion.Velocity;
		float horizontalSpeed = locomotion.HorizontalSpeed;
		float referenceSpeed = MathF.Max( locomotion.ReferenceMoveSpeed, 1f );
		bool sliding = locomotion.IsSliding;
		bool sprinting = locomotion.IsSprinting;

		float adsAmount = IsAiming ? 1f : 0f;

		// ---------------------------------------------------------
		// Authored locomotion animation
		// ---------------------------------------------------------

		float speed01 = MathX.Clamp(
			horizontalSpeed / referenceSpeed,
			0f,
			1f
		);

		float bobTarget =
			sliding
				? 0f
				: speed01 *
					tuning.ViewModelMovementBobScale *
					MathX.Lerp( 1f, tuning.ViewModelAdsBobScale, adsAmount );

		bobTarget = MathX.Clamp( bobTarget, 0f, 1f );

		_viewModelMoveBob = MathX.ExponentialDecay(
			_viewModelMoveBob,
			bobTarget,
			MathF.Max( tuning.ViewModelMovementSmoothing, 0.001f ),
			dt
		);

		Vector3 localVelocity = new(
			Vector3.Dot( velocity, lookRotation.Forward ),
			Vector3.Dot( velocity, lookRotation.Right ),
			velocity.z
		);

		float moveX = MathX.Clamp( localVelocity.x / referenceSpeed, -1f, 1f );
		float moveY = MathX.Clamp( localVelocity.y / referenceSpeed, -1f, 1f );
		float moveZ = MathX.Clamp( localVelocity.z / referenceSpeed, -1f, 1f );

		renderer.Set( "b_grounded", locomotion.IsGrounded );
		bool sprintPoseSuppressed =
			RealTime.Now <
				_viewModelSprintSuppressedUntil;

		renderer.Set(
			"b_sprint",
			tuning.EnableViewModelSprintAnimation &&
			sprinting &&
			!IsAiming &&
			!sprintPoseSuppressed
		);
		renderer.Set( "move_bob", _viewModelMoveBob );
		renderer.Set( "move_x", moveX );
		renderer.Set( "move_y", moveY );
		renderer.Set( "move_z", moveZ );

		if (
			tuning.EnableViewModelJumpAnimation &&
			_viewModelWasGrounded &&
			!locomotion.IsGrounded &&
			velocity.z > 0f
		)
		{
			// b_jump is self-resetting in the Facepunch viewmodel AnimGraphs.
			renderer.Set( "b_jump", true );
		}

		_viewModelWasGrounded = locomotion.IsGrounded;

		// ---------------------------------------------------------
		// Authored look inertia
		// ---------------------------------------------------------

		float targetPitchInertia = 0f;
		float targetYawInertia = 0f;

		if ( tuning.EnableViewModelLookInertia )
		{
			float follow = 1f - MathF.Exp(
				-MathF.Max( tuning.ViewModelLookFollowSpeed, 0.01f ) * dt
			);

			_viewModelLagRotation = Rotation.Slerp(
				_viewModelLagRotation,
				lookRotation,
				follow
			);

			Angles current = lookRotation.Angles();
			Angles lagged = _viewModelLagRotation.Angles();

			float maxInertia = MathF.Max( tuning.ViewModelMaximumLookInertia, 0f );
			float adsScale = MathX.Lerp( 1f, tuning.ViewModelAdsInertiaScale, adsAmount );

			targetPitchInertia = MathX.Clamp(
				MathX.DeltaDegrees( current.pitch, lagged.pitch ) *
					tuning.ViewModelLookInertiaStrength *
					adsScale,
				-maxInertia,
				maxInertia
			);

			targetYawInertia = MathX.Clamp(
				MathX.DeltaDegrees( current.yaw, lagged.yaw ) *
					tuning.ViewModelLookInertiaStrength *
					adsScale,
				-maxInertia,
				maxInertia
			);
		}
		else
		{
			_viewModelLagRotation = lookRotation;
		}

		_viewModelPitchInertia = MathX.ExponentialDecay(
			_viewModelPitchInertia,
			targetPitchInertia,
			MathF.Max( tuning.ViewModelInertiaSmoothing, 0.001f ),
			dt
		);

		_viewModelYawInertia = MathX.ExponentialDecay(
			_viewModelYawInertia,
			targetYawInertia,
			MathF.Max( tuning.ViewModelInertiaSmoothing, 0.001f ),
			dt
		);

		renderer.Set( "aim_pitch_inertia", _viewModelPitchInertia );
		renderer.Set( "aim_yaw_inertia", _viewModelYawInertia );

		if (
			tuning.LogViewModelState &&
			RealTime.Now >= _nextViewModelDiagnosticAt
		)
		{
			_nextViewModelDiagnosticAt = RealTime.Now + 1f;

			Log.Info(
				$"[OMR FP VM] {GameObject.Name} | ADS:{IsAiming} Sprint:{sprinting} Slide:{sliding} Ground:{locomotion.IsGrounded} Speed:{horizontalSpeed:0.0}/{referenceSpeed:0.0} Bob:{_viewModelMoveBob:0.00} Inertia(P/Y):{_viewModelPitchInertia:0.00}/{_viewModelYawInertia:0.00}"
			);
		}
	}

	/// <summary>
	/// Immediately leaves the authored sprint stance before BaseCombatWeapon
	/// triggers b_attack, then keeps sprint presentation suppressed briefly.
	///
	/// Automatic fire naturally extends the window because every real shot
	/// calls this again. This is deliberately presentation-only.
	/// </summary>
	private void InterruptViewModelSprintForAttack()
	{
		float holdTime =
			MathF.Max(
				SprintFirePoseHoldTime,
				0f
			);

		SuppressViewModelSprintPoseUntil(
			RealTime.Now + holdTime
		);
	}

	/// <summary>
	/// Runs before BaseCombatWeapon handles input each frame.
	///
	/// When a shot is requested from the authored sprint pose, immediately
	/// release that pose, buffer the input, and fire through BaseCombatWeapon's
	/// documented FirePrimary() path once the short sprint-exit window expires.
	/// FirePrimary still performs the normal ammo/reload/cooldown checks.
	/// </summary>
	private void UpdateSprintToFireGate()
	{
		if ( _sprintFireQueued )
		{
			SuppressViewModelSprintPoseUntil(
				_sprintFireReadyAt
			);

			if (
				RealTime.Now <
					_sprintFireReadyAt
			)
			{
				return;
			}

			_sprintFireQueued =
				false;

			_sprintFireReadyAt =
				0f;

			//
			// Use FirePrimary rather than calling PrimaryAttack directly:
			// FirePrimary is the engine-supported "pull trigger from code" path
			// and preserves normal cooldown/ammo gating.
			//
			FirePrimary();

			return;
		}

		float delay =
			MathF.Max(
				SprintToFireDelay,
				0f
			);

		if ( delay <= 0f )
			return;

		bool wantsInitialShot =
			PrimaryAutomatic
				? Input.Down( "attack1" )
				: Input.Pressed( "attack1" );

		if ( !wantsInitialShot )
			return;

		if ( !ShouldDelayShotForSprintPose() )
			return;

		//
		// Only queue a shot that could genuinely fire now. This avoids creating
		// a delayed shot when the magazine is empty, a reload is active, or the
		// weapon is still on its ordinary fire-rate cooldown.
		//
		if ( !base.CanPrimaryAttack() )
			return;

		_sprintFireQueued =
			true;

		_sprintFireReadyAt =
			RealTime.Now + delay;

		SuppressViewModelSprintPoseUntil(
			_sprintFireReadyAt
		);
	}

	/// <summary>
	/// True only when the player is physically sprinting AND the first-person
	/// weapon is actually allowed to be in the lowered authored sprint stance.
	/// A recently-fired weapon whose pose is already being held up should not
	/// pay the sprint-to-fire delay again.
	/// </summary>
	private bool ShouldDelayShotForSprintPose()
	{
		if (
			IsAiming ||
			RealTime.Now <
				_viewModelSprintSuppressedUntil
		)
		{
			return false;
		}

		OMRFirstPersonTuning tuning =
			OMRFirstPersonResources.DefaultTuning;

		if (
			tuning is null ||
			!tuning.EnableViewModelSprintAnimation
		)
		{
			return false;
		}

		return
			TryGetLocomotionSnapshot(
				out WeaponLocomotionSnapshot locomotion
			) &&
			locomotion.IsSprinting;
	}

	/// <summary>
	/// Keeps only the authored first-person sprint pose disabled until a given
	/// real-time timestamp. Physical movement/sprint state is untouched.
	/// </summary>
	private void SuppressViewModelSprintPoseUntil(
		float until
	)
	{
		_viewModelSprintSuppressedUntil =
			MathF.Max(
				_viewModelSprintSuppressedUntil,
				until
			);

		GameObject viewModel =
			ViewModel;

		if (
			viewModel is null ||
			!viewModel.IsValid
		)
		{
			return;
		}

		SkinnedModelRenderer renderer =
			GetViewModelRenderer(
				viewModel
			);

		if ( renderer is null )
			return;

		//
		// Clear the persistent sprint stance immediately. This starts the
		// authored sprint-exit transition before the buffered bullet exists.
		//
		renderer.Set(
			"b_sprint",
			false
		);
	}


	private SkinnedModelRenderer GetViewModelRenderer( GameObject viewModel )
	{
		BaseWeaponModel weaponModel =
			viewModel.Components.Get<BaseWeaponModel>();

		SkinnedModelRenderer renderer = weaponModel?.Renderer;

		renderer ??=
			viewModel.Components.Get<SkinnedModelRenderer>();

		return renderer;
	}

	private void SetViewModelAnimationNeutral(
		SkinnedModelRenderer renderer,
		PlayerController owner
	)
	{
		_viewModelLagRotation = owner.EyeAngles.ToRotation();
		_viewModelMoveBob = 0f;
		_viewModelPitchInertia = 0f;
		_viewModelYawInertia = 0f;
		_viewModelWasGrounded = owner.IsOnGround;

		renderer.Set( "b_grounded", owner.IsOnGround );
		renderer.Set( "b_sprint", false );
		renderer.Set( "move_bob", 0f );
		renderer.Set( "move_x", 0f );
		renderer.Set( "move_y", 0f );
		renderer.Set( "move_z", 0f );
		renderer.Set( "aim_pitch_inertia", 0f );
		renderer.Set( "aim_yaw_inertia", 0f );
	}

	private void ResetViewModelAnimationParameters()
	{
		GameObject viewModel = ViewModel;

		if (
			viewModel is null ||
			!viewModel.IsValid
		)
		{
			return;
		}

		SkinnedModelRenderer renderer = GetViewModelRenderer( viewModel );
		if ( renderer is null )
			return;

		//
		// Do NOT resolve BaseCombatWeapon.Owner here.
		//
		// This reset can run during the owner-side round-spawn RPC while the
		// inventory/weapon hierarchy is still settling on a joining client.
		// BaseCombatWeapon.Owner is derived from that hierarchy, and the engine
		// getter can transiently throw before the hierarchy is fully attached.
		//
		// The reset only needs to neutralize AnimGraph parameters. Internal
		// presentation state is reset immediately afterwards by
		// ResetViewModelPresentationState(), and the next stable update rebuilds
		// grounded/look state from the real owner.
		//
		renderer.Set( "b_grounded", true );
		renderer.Set( "b_sprint", false );
		renderer.Set( "move_bob", 0f );
		renderer.Set( "move_x", 0f );
		renderer.Set( "move_y", 0f );
		renderer.Set( "move_z", 0f );
		renderer.Set( "aim_pitch_inertia", 0f );
		renderer.Set( "aim_yaw_inertia", 0f );
	}
}