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