Game/Weapon/ViewModel/ViewModel.cs
using System.Threading;
public sealed partial class ViewModel : Sandbox.BaseWeaponModel
{
[ConVar( "sandbox.hideviewmodel", ConVarFlags.Cheat )]
private static bool HideViewModel { get; set; } = false;
/// <summary>
/// A sound to play at a specific time during reload.
/// </summary>
public record struct ReloadSoundEntry
{
/// <summary>
/// Seconds after reload starts to play this sound.
/// </summary>
[KeyProperty] public float Time { get; set; }
/// <summary>
/// The sound to play.
/// </summary>
[Property, KeyProperty] public SoundEvent Sound { get; set; }
}
/// <summary>
/// Timed sound events to play during reload.
/// </summary>
[Property, Group( "Reload Sounds" )]
public List<ReloadSoundEntry> ReloadSoundEvents { get; set; } = new();
/// <summary>
/// Timed sound events to play during each incremental reload cycle.
/// </summary>
[Property, Group( "Reload Sounds" )]
public List<ReloadSoundEntry> IncrementalReloadSoundEvents { get; set; } = new();
/// <summary>
/// Timed sound events played when starting an incremental reload sequence.
/// </summary>
[Property, Group( "Reload Sounds" )]
public List<ReloadSoundEntry> IncrementalReloadStartSounds { get; set; } = new();
/// <summary>
/// Timed sound events played when finishing an incremental reload sequence.
/// </summary>
[Property, Group( "Reload Sounds" )]
public List<ReloadSoundEntry> IncrementalReloadFinishSounds { get; set; } = new();
private CancellationTokenSource _reloadSoundCts;
private CancellationTokenSource _reloadFinishSoundCts;
/// <summary>
/// Turns on incremental reloading parameters.
/// </summary>
[Property, Group( "Animation" )]
public bool IsIncremental { get; set; } = false;
/// <summary>
/// Animation speed in general.
/// </summary>
[Property, Group( "Animation" )]
public float AnimationSpeed { get; set; } = 1.0f;
/// <summary>
/// Animation speed for incremental reload sections.
/// </summary>
[Property, Group( "Animation" )]
public float IncrementalAnimationSpeed { get; set; } = 1.0f;
/// <summary>
/// How much inertia should this weapon have?
/// </summary>
[Property, Group( "Inertia" )]
Vector2 InertiaScale { get; set; } = new Vector2( 2, 2 );
/// <summary>
/// Swing the viewmodel around based on the player's look direction, with a springy lag. This is purely cosmetic and does not affect the actual camera.
/// </summary>
[Property, Group( "Inertia" )]
public bool LookInertia { get; set; } = false;
/// <summary>
/// Spring stiffness - how quickly it pulls back towards your actual look direction.
/// </summary>
[Property, Group( "Inertia" ), ShowIf( nameof( LookInertia ), true )]
public float LookInertiaFrequency { get; set; } = 3.0f;
/// <summary>
/// Spring damping. 1 settles with no overshoot, below 1 wobbles before settling - lower
/// for a springier, bouncier feel.
/// </summary>
[Property, Group( "Inertia" ), ShowIf( nameof( LookInertia ), true )]
public float LookInertiaDamping { get; set; } = 0.35f;
/// <summary>
/// How far out in front of the eye the gun pivots from. Larger values make the same angle
/// lag produce a bigger visible swing, since the gun is swinging on a longer arm.
/// </summary>
[Property, Group( "Inertia" ), ShowIf( nameof( LookInertia ), true )]
public float LookInertiaPivotDistance { get; set; } = 10.0f;
/// <summary>
/// Overall strength of the effect, applied after the spring - 0 is off, 1 is the spring's
/// natural lag, above 1 exaggerates it. Use this to dial the whole thing up or down without
/// touching how snappy or bouncy it feels (that's Frequency/Damping).
/// </summary>
[Property, Group( "Inertia" ), ShowIf( nameof( LookInertia ), true )]
public float LookInertiaAmount { get; set; } = 1.0f;
/// <summary>
/// Further multiplies <see cref="LookInertiaAmount"/> while aiming down sights (on weapons
/// with an <see cref="IronSightsWeapon"/>), so the gun holds steadier while aiming - below 1
/// calms the sway down, above 1 makes it worse.
/// </summary>
[Property, Title( "ADS Inertia Scale" ), Group( "Inertia" ), ShowIf( nameof( LookInertia ), true )]
public float ADSInertiaScale { get; set; } = 0.3f;
public bool IsAttacking { get; set; }
TimeSince AttackDuration;
bool _reloadFinishing;
TimeSince _reloadFinishTimer;
Vector2 lastInertia;
Vector2 currentInertia;
bool isFirstUpdate = true;
Vector2 _lookAngles;
Vector2 _lookVelocity;
bool _lookInertiaFirstUpdate = true;
protected override void OnStart()
{
foreach ( var renderer in GetComponentsInChildren<ModelRenderer>() )
{
// Don't render shadows for viewmodels
renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
}
}
protected override void OnUpdate()
{
UpdateAnimation();
}
void ApplyInertia( Rotation rotation )
{
var rot = rotation.Angles();
// Need to fetch data from the camera for the first frame
if ( isFirstUpdate )
{
lastInertia = new Vector2( rot.pitch, rot.yaw );
currentInertia = Vector2.Zero;
isFirstUpdate = false;
}
var newPitch = rot.pitch;
var newYaw = rot.yaw;
currentInertia = new Vector2( Angles.NormalizeAngle( newPitch - lastInertia.x ), Angles.NormalizeAngle( lastInertia.y - newYaw ) );
lastInertia = new( newPitch, newYaw );
}
/// <summary>
/// Called by the weapon while the camera composes - feeds the aim inertia and lets the
/// animation's camera bone drive the view (reload sway and kicks authored in the anim).
/// This mutates the real camera view, not the viewmodel - the viewmodel itself is placed
/// separately from the pre-bone snapshot, see <see cref="Place"/>.
/// </summary>
public void UpdateCameraBone( ref CameraView view )
{
if ( !Renderer.IsValid() ) return;
Renderer.Enabled = !HideViewModel;
if ( !GamePreferences.ViewBobbing )
{
currentInertia = Vector2.Zero;
isFirstUpdate = true;
return;
}
ApplyInertia( view.Rotation );
if ( Renderer.TryGetBoneTransformLocal( "camera", out var bone ) )
{
var scale = 0.5f;
view.Position += view.Rotation * bone.Position * scale;
view.Rotation *= bone.Rotation * scale;
}
}
/// <summary>
/// Place the view model - called by the weapon's <c>PlaceViewModel</c> with the pre-bone view,
/// so the camera bone moves the camera around the gun rather than dragging the gun with it.
/// </summary>
public void Place( in CameraView view )
{
var rotation = view.Rotation;
var pivotOffset = Vector3.Zero;
if ( LookInertia && GamePreferences.ViewBobbing )
rotation = ApplyLookInertia( view.Rotation, out pivotOffset );
else
{
_lookInertiaFirstUpdate = true;
_lookVelocity = Vector2.Zero;
}
WorldPosition = view.Position + pivotOffset;
WorldRotation = rotation;
}
Rotation ApplyLookInertia( Rotation rotation, out Vector3 pivotOffset )
{
var angles = rotation.Angles();
var target = new Vector2( angles.pitch, angles.yaw );
if ( _lookInertiaFirstUpdate )
{
_lookAngles = target;
_lookVelocity = Vector2.Zero;
_lookInertiaFirstUpdate = false;
}
else
{
// Follow yaw the short way round so crossing the -180/180 seam doesn't fling the spring.
target.y = _lookAngles.y + Angles.NormalizeAngle( target.y - _lookAngles.y );
}
_lookAngles = Vector2.SpringDamp( _lookAngles, target, ref _lookVelocity, Time.Delta, LookInertiaFrequency, LookInertiaDamping );
var laggedFull = new Angles( _lookAngles.x, _lookAngles.y, angles.roll ).ToRotation();
// Scale the effect's strength here, separately from the spring's own timing/feel above.
var amount = LookInertiaAmount;
var ironSights = GetComponentInParent<IronSightsWeapon>();
if ( ironSights.IsValid() && ironSights.IsAiming )
amount *= ADSInertiaScale;
var delta = laggedFull * rotation.Inverse;
var lagged = Rotation.Slerp( Rotation.Identity, delta, amount, clamp: false ) * rotation;
var pivotLocal = Vector3.Forward * LookInertiaPivotDistance;
pivotOffset = (rotation * pivotLocal) - (lagged * pivotLocal);
return lagged;
}
void UpdateAnimation()
{
var playerController = GetComponentInParent<PlayerController>();
if ( !playerController.IsValid() ) return;
// Eye angles, not the camera - the camera's transform is composed later in the frame.
var rot = playerController.EyeAngles;
Renderer.Set( "b_twohanded", true );
Renderer.Set( "deploy_type", 1 );
Renderer.Set( "reload_type", 1 );
Renderer.Set( "b_grounded", playerController.IsOnGround );
Renderer.Set( "move_bob", GamePreferences.ViewBobbing ? playerController.Velocity.Length.Remap( 0, playerController.RunSpeed * 2f ) : 0 );
Renderer.Set( "aim_pitch", rot.pitch );
Renderer.Set( "aim_pitch_inertia", GamePreferences.ViewBobbing ? currentInertia.x * InertiaScale.x : 0 );
Renderer.Set( "aim_yaw", rot.yaw );
Renderer.Set( "aim_yaw_inertia", GamePreferences.ViewBobbing ? currentInertia.y * InertiaScale.y : 0 );
Renderer.Set( "attack_hold", IsAttacking ? AttackDuration.Relative.Clamp( 0f, 1f ) : 0f );
if ( _reloadFinishing && _reloadFinishTimer >= 0.5f )
{
_reloadFinishing = false;
Renderer.Set( "speed_reload", AnimationSpeed );
Renderer.Set( "b_reloading", false );
}
var velocity = playerController.Velocity;
var dir = velocity;
var eyeRotation = rot.ToRotation();
var forward = eyeRotation.Forward.Dot( dir );
var sideward = eyeRotation.Right.Dot( dir );
var angle = MathF.Atan2( sideward, forward ).RadianToDegree().NormalizeDegrees();
Renderer.Set( "move_direction", angle );
Renderer.Set( "move_speed", velocity.Length );
Renderer.Set( "move_groundspeed", velocity.WithZ( 0 ).Length );
Renderer.Set( "move_y", sideward );
Renderer.Set( "move_x", forward );
Renderer.Set( "move_z", velocity.z );
}
public override void OnAttack( Vector3? hitPoint = null, Vector3? origin = null )
{
base.OnAttack( hitPoint, origin );
// The engine parents the flash to the muzzle, but its particles default to
// world space. Keep first-person flashes attached through recoil and sway.
if ( MuzzleGameObject.IsValid() )
{
foreach ( var effect in MuzzleGameObject.GetComponentsInChildren<ParticleEffect>( true ) )
effect.LocalSpace = 1.0f;
}
if ( IsThrowable )
{
Renderer?.Set( "b_throw", true );
Invoke( 0.5f, () =>
{
Renderer?.Set( "b_deploy_new", true );
Renderer?.Set( "b_pull", false );
} );
}
}
/// <summary>
/// Called when starting to reload a weapon.
/// </summary>
public override void OnReloadStart()
{
_reloadFinishing = false; // cancel any pending incremental finish from a previous reload
Renderer?.Set( "speed_reload", AnimationSpeed );
Renderer?.Set( IsIncremental ? "b_reloading" : "b_reload", true );
if ( IsIncremental )
StartSounds( IncrementalReloadStartSounds, ref _reloadFinishSoundCts );
else
StartSounds( ReloadSoundEvents, ref _reloadSoundCts );
}
/// <summary>
/// Called when incrementally reloading a weapon.
/// </summary>
public override void OnIncrementalReload()
{
Renderer?.Set( "speed_reload", IncrementalAnimationSpeed );
Renderer?.Set( "b_reloading_shell", true );
StartSounds( IncrementalReloadSoundEvents, ref _reloadSoundCts );
}
public override void OnReloadFinish()
{
CancelSounds( ref _reloadSoundCts );
if ( IsIncremental )
{
StartSounds( IncrementalReloadFinishSounds, ref _reloadFinishSoundCts );
_reloadFinishing = true;
_reloadFinishTimer = 0;
}
else
{
Renderer?.Set( "b_reload", false );
}
}
public override void OnReloadCancel()
{
CancelSounds( ref _reloadSoundCts );
CancelSounds( ref _reloadFinishSoundCts );
}
private void StartSounds( List<ReloadSoundEntry> events, ref CancellationTokenSource cts )
{
CancelSounds( ref cts );
if ( events.Count == 0 )
return;
cts = new CancellationTokenSource();
_ = PlaySoundsAsync( events, cts.Token );
}
private void CancelSounds( ref CancellationTokenSource cts )
{
if ( cts is null ) return;
cts.Cancel();
cts.Dispose();
cts = null;
}
private async Task PlaySoundsAsync( List<ReloadSoundEntry> events, CancellationToken ct )
{
var sorted = events.OrderBy( e => e.Time ).ToList();
var elapsed = 0f;
foreach ( var entry in sorted )
{
var delay = entry.Time - elapsed;
if ( delay > 0f )
await Task.DelaySeconds( delay, ct );
if ( ct.IsCancellationRequested )
return;
if ( entry.Sound is not null )
GameObject.PlaySound( entry.Sound );
elapsed = entry.Time;
}
}
}