PlayerViewModel.cs
using Sandbox;
public sealed class PlayerViewModel : Component
{
public static PlayerViewModel Instance { get; private set; }
[Property] private SkinnedModelRenderer Arms { get; set; }
[Property] public CameraComponent Camera { get; set; }
/// <summary>Degrees of viewmodel kick per normalized recoil unit.</summary>
[Property] public float RecoilAnimScale { get; set; } = 40f;
/// <summary>Seconds the viewmodel muzzle flash stays visible per shot.</summary>
[Property] public float MuzzleFlashDuration { get; set; } = 0.05f;
/// <summary>When true, draws a desktop HUD crosshair at the aim/recoil point while a weapon is held (hidden while ADS).</summary>
[Property] public bool DrawCrosshair { get; set; }
private ViewModel _viewModel;
private GameObject _viewModelGO;
private SkinnedModelRenderer _modelRenderer;
private GameObject _muzzleFlash;
private float _muzzleHideTime;
private SoundPointComponent _shotSound;
private float _ironsightsBlend;
private Vector2 _recoilOffset;
public bool Ironsights;
/// <summary>Active viewmodel barrel transform (child named "muzzle"), if present.</summary>
public GameObject Muzzle { get; private set; }
public bool HasActiveWeapon => _modelRenderer is not null;
/// <summary>Normalized screen point used for desktop aim, crosshair, and projectile spawn (recoil Y is up).</summary>
public static Vector2 GetAimScreenNormal( Vector2 recoilOffset )
{
return new Vector2( 0.5f + recoilOffset.x, 0.5f - recoilOffset.y );
}
/// <summary>Camera ray through <see cref="GetAimScreenNormal"/>.</summary>
public Ray GetAimRay( Vector2 recoilOffset )
{
return Camera.ScreenNormalToRay( GetAimScreenNormal( recoilOffset ) );
}
protected override void OnStart()
{
Instance = this;
if ( _viewModelGO == null )
Hide();
}
protected override void OnDestroy()
{
if ( Instance == this )
Instance = null;
}
protected override void OnUpdate()
{
UpdateMuzzleFlash();
UpdateIronsights();
UpdateCrosshair();
}
private void UpdateMuzzleFlash()
{
if ( _muzzleFlash is null || !_muzzleFlash.Enabled )
return;
if ( Time.Now >= _muzzleHideTime )
_muzzleFlash.Enabled = false;
}
private void UpdateCrosshair()
{
if ( !DrawCrosshair || Game.IsRunningInVR || Camera is null )
return;
var aim = GetAimScreenNormal( _recoilOffset );
var pos = new Vector2( Screen.Width * aim.x, Screen.Height * aim.y );
Camera.Hud.DrawCircle( pos, 5f, Color.Red );
}
private void UpdateIronsights()
{
if ( _viewModelGO is null || _viewModel is null )
return;
if ( Input.Pressed( "attack2" ) )
ToggleIronsights();
UpdateIronsightsBlend();
if ( _viewModel.AnimationIronsights )
UpdateAnimationIronsightsPose();
else
UpdateCodeIronsightsPose();
}
private void ToggleIronsights()
{
Ironsights = !Ironsights;
if ( _viewModel.AnimationIronsights )
_modelRenderer?.Set( "ironsights", Ironsights ? 1 : 0 );
}
private void UpdateAnimationIronsightsPose()
{
_viewModelGO.LocalPosition = Vector3.Lerp( Vector3.Zero, _viewModel.IronsightsOffset, _ironsightsBlend );
_viewModelGO.LocalRotation = _ironsightsBlend <= 0f
? Rotation.Identity
: GetRecoilLookRotation( _recoilOffset * _ironsightsBlend );
}
private void UpdateCodeIronsightsPose()
{
// Keep animgraph ADS off so it doesn't fight the transform blend.
_modelRenderer?.Set( "ironsights", 0 );
var fullAdsRotation = Rotation.From( _viewModel.IronsightsAngle );
var adsRotation = Rotation.Slerp( Rotation.Identity, fullAdsRotation, _ironsightsBlend );
var adsPosition = GetCenteredIronsightsOffset( fullAdsRotation ) + _viewModel.IronsightsOffset;
_viewModelGO.LocalPosition = Vector3.Lerp( Vector3.Zero, adsPosition, _ironsightsBlend );
_viewModelGO.LocalRotation = _ironsightsBlend <= 0f
? Rotation.Identity
: adsRotation * GetRecoilLookRotation( _recoilOffset * _ironsightsBlend );
}
private void UpdateIronsightsBlend()
{
var duration = _viewModel.IronsightsDuration > 0.01f ? _viewModel.IronsightsDuration : 0.01f;
var target = Ironsights ? 1f : 0f;
if ( _ironsightsBlend < target )
_ironsightsBlend = MathX.Clamp( _ironsightsBlend + Time.Delta / duration, 0f, target );
else if ( _ironsightsBlend > target )
_ironsightsBlend = MathX.Clamp( _ironsightsBlend - Time.Delta / duration, target, 1f );
}
private Rotation GetRecoilLookRotation( Vector2 recoilOffset )
{
var localDirection = Camera.WorldRotation.Inverse * GetAimRay( recoilOffset ).Forward;
return Rotation.LookAt( localDirection );
}
private Vector3 GetCenteredIronsightsOffset( Rotation adsRotation )
{
if ( Muzzle is null || Camera is null || _viewModelGO is null )
return Vector3.Zero;
// Muzzle in viewmodel-local space, ignoring the root's current ADS transform.
var muzzleInCamera = Camera.WorldTransform.PointToLocal( Muzzle.WorldPosition );
var muzzleLocal = _viewModelGO.LocalRotation.Inverse * (muzzleInCamera - _viewModelGO.LocalPosition);
var rotated = adsRotation * muzzleLocal;
return new Vector3( 0f, -rotated.y, -rotated.z );
}
private void ResetIronsightsState()
{
Ironsights = false;
_ironsightsBlend = 0f;
_recoilOffset = Vector2.Zero;
}
public void Show( ViewModel viewModel )
{
ResetIronsightsState();
_viewModel = viewModel;
_viewModelGO = viewModel.ModelPrefab.Clone( transform: global::Transform.Zero, parent: Camera.GameObject );
_modelRenderer = _viewModelGO.GetComponent<SkinnedModelRenderer>();
_modelRenderer.RenderType = ModelRenderer.ShadowRenderType.Off;
Arms.BoneMergeTarget = _modelRenderer;
Arms.GameObject.Enabled = true;
Muzzle = _viewModelGO.Children.Find( go => go.Name == "muzzle" );
_muzzleFlash = Muzzle?.Children.Find( go => go.Name == "flash" );
_shotSound = Muzzle?.Children.Find( go => go.Name == "sound" )?.GetComponent<SoundPointComponent>();
}
public void Hide()
{
Arms.GameObject.Enabled = false;
_modelRenderer = null;
_viewModelGO?.Destroy();
_viewModelGO = null;
_viewModel = null;
ResetIronsightsState();
Muzzle = null;
_muzzleFlash = null;
_shotSound = null;
}
public void PlayAttack()
{
_modelRenderer?.Set( "b_attack", true );
}
public void PlayReload()
{
_modelRenderer?.Set( "b_reload", true );
}
public void PlayReloadBolt()
{
_modelRenderer?.Set( "b_reload_bolt", true );
}
/// <summary>Opens the shell reload session on the viewmodel (b_reload + b_reloading).</summary>
public void BeginShellReload()
{
if ( _modelRenderer is null )
return;
_modelRenderer.Set( "b_reloading", true );
}
/// <summary>Pulses a single shell insert on the viewmodel animgraph.</summary>
public void InsertShell()
{
_modelRenderer?.Set( "b_reloading_shell", true );
}
/// <summary>Closes the shell reload session on the viewmodel.</summary>
public void EndShellReload()
{
if ( _modelRenderer is null )
return;
_modelRenderer.Set( "b_reloading", false );
}
public void PlayCharge()
{
_modelRenderer?.Set( "b_charge", true );
}
/// <summary>
/// Drives first-person weapon animgraph params. Call every frame while the weapon is held.
/// b_attack is pulsed separately on each shot; attack_hold tracks sustained fire.
/// </summary>
public void UpdateWeaponAnimation( float attackHold, Vector2 recoilOffset )
{
if ( _modelRenderer is null || Camera is null )
return;
var rot = Camera.WorldRotation.Angles();
var animRecoil = Ironsights ? Vector2.Zero : recoilOffset;
_modelRenderer.Set( "aim_pitch", rot.pitch );
_modelRenderer.Set( "aim_yaw", rot.yaw );
_modelRenderer.Set( "aim_pitch_inertia", animRecoil.y * RecoilAnimScale );
_modelRenderer.Set( "aim_yaw_inertia", animRecoil.x * RecoilAnimScale );
_modelRenderer.Set( "attack_hold", attackHold );
_recoilOffset = recoilOffset;
}
public void ShowMuzzleFlash()
{
if ( _muzzleFlash is null )
return;
// Toggle off first so particle emitters restart on each shot.
_muzzleFlash.Enabled = false;
_muzzleFlash.Enabled = true;
_muzzleHideTime = Time.Now + MuzzleFlashDuration;
}
public void HideMuzzleFlash()
{
if ( _muzzleFlash is null )
return;
_muzzleFlash.Enabled = false;
}
public void PlayShotSound()
{
_shotSound?.StartSound();
}
public void SetLoadedState(bool loaded)
{
_modelRenderer?.Set( "b_loaded", loaded );
}
}