Gun.cs
using Sandbox;
using Sandbox.Citizen;
using System;
using System.Diagnostics;
using System.Linq;
using System.Net.WebSockets;
using static Sandbox.Component;
public enum GunType
{
Magazine,
Shotgun,
Bolt,
}
public sealed class Gun : Component, INetworkListener
{
/// <summary>Magazine = timed full reload; Shotgun = shell-by-shell reload stance.</summary>
[Property] public GunType Type { get; set; } = GunType.Magazine;
/// <summary>Seconds between shots. Lower = faster fire rate.</summary>
[Property] private float FireRate { get; set; } = 0.1f;
[Property] private GameObject Muzzleflash { get; set; }
[Property] private SoundPointComponent SoundPoint { get; set; }
/// <summary>Seconds the world muzzle flash stays visible for other players.</summary>
[Property] private float MuzzleFlashDuration { get; set; } = 0.05f;
/// <summary>Prefab cloned for each pellet on every shot.</summary>
[Property] public GameObject BulletPrefab { get; set; }
/// <summary>Transform at the gun barrel; pellets spawn here in VR.</summary>
[Property] public GameObject MuzzleTransform { get; set; }
/// <summary>Projectile speed in world units per second.</summary>
[Property] public float BulletSpeed { get; set; } = 3000f;
/// <summary>Projectiles spawned per shot. 1 = single bullet (default).</summary>
[Property] public int PelletCount { get; set; } = 1;
/// <summary>Max random cone half-angle in degrees per pellet. 0 = no spread.</summary>
[Property] public float SpreadAngle { get; set; } = 0f;
/// <summary>Maximum bullets in the magazine; refilled on each reload.</summary>
[Property] private int MagazineSize { get; set; } = 30;
/// <summary>Seconds before firing is allowed again after a magazine reload.</summary>
[Property, Group("Reload"), HideIf( nameof( Type ), GunType.Shotgun )] private float ReloadTime { get; set; } = 2f;
[Property, Group( "Reload" ), HideIf( nameof( Type ), GunType.Shotgun )] private float MagOutTime { get; set; }
[Property, Group( "Reload" ), HideIf( nameof( Type ), GunType.Shotgun )] private SoundEvent MagOutSound { get; set; }
[Property, Group( "Reload" ), HideIf( nameof( Type ), GunType.Shotgun )] private float MagInTime { get; set; }
[Property, Group( "Reload" ), HideIf( nameof( Type ), GunType.Shotgun )] private SoundEvent MagInSound { get; set; }
/// <summary>Seconds between each shell insert during a shotgun reload.</summary>
[Property, Group( "Reload" ), ShowIf(nameof(Type), GunType.Shotgun)] private float ShellInsertInterval { get; set; } = 0.6f;
/// <summary>Seconds into each shell insert interval when the insert sound plays.</summary>
[Property, Group( "Reload" ), ShowIf( nameof( Type ), GunType.Shotgun )] private float ShellInsertSoundTime { get; set; } = 0.4f;
[Property, Group( "Reload" ), ShowIf( nameof( Type ), GunType.Shotgun )] private SoundEvent ShellInsertSound { get; set; }
/// <summary> If true, the weapon will play the unloaded deploy/idle animation when the magazine is empty. </summary>
[Property, Group( "Reload" )] private bool HasUnloadedAnimation { get; set; } = false;
/// <summary>Seconds before firing is allowed after pickup (desktop draw animation). 0 disables.</summary>
[Property] private float EquipTime { get; set; } = 1f;
/// <summary>Per-shot aim/crosshair recoil pattern. Leave empty for no recoil.</summary>
[Property, Group( "Recoil" )] public RecoilPattern Recoil { get; set; }
/// <summary>Tick for weapons designed for two hands (e.g. rifles); firing one-handed in VR amplifies recoil.</summary>
[Property, Group( "Recoil" )] public bool IsTwoHanded { get; set; } = false;
/// <summary>Degrees of VR gun kick per normalized recoil unit. Higher = stronger visible kick.</summary>
[Property, Group( "Recoil" )] private float VrRecoilScale { get; set; } = 60f;
/// <summary>Recoil magnitude multiplier applied when a two-handed gun is held with only one hand in VR.</summary>
[Property, Group( "Recoil" )] private float OneHandedRecoilMultiplier { get; set; } = 2f;
/// <summary>Recoil magnitude multiplier when firing from the hip (not aiming down sights). 1 = same as ironsights.</summary>
[Property, Group( "Recoil" )] private float HipFireRecoilMultiplier { get; set; } = 1.75f;
/// <summary>Scales how long each shot's VR rumble lasts. 1 is the default HardImpact length.</summary>
[Property, Group( "Haptics" )] private float HapticLengthScale { get; set; } = 1f;
/// <summary>Scales VR rumble frequency. Higher feels sharper.</summary>
[Property, Group( "Haptics" )] private float HapticFrequencyScale { get; set; } = 1f;
/// <summary>Scales VR rumble intensity. 1 is full strength.</summary>
[Property, Group( "Haptics" )] private float HapticAmplitudeScale { get; set; } = 1f;
[Sync] private bool Firing { get; set; } = false;
[Sync] private int FireSequence { get; set; }
[Sync] private int CurrentBullets { get; set; }
[Sync] private bool ShellReloadActive { get; set; }
private bool IsReloading { get; set; }
private bool IsEquipping { get; set; }
private bool _lastShellReloadActive;
private float _nextShellAmmoTime;
private bool _shellInsertSoundPlayed;
private bool _shellReloadCancelPending;
private bool _fireWhenShellReloadEnds;
// Scan from this weapon's own object, not GameObject.Root: a stowed weapon is
// parented to the player, making Root resolve to the player and match other weapons.
private bool IsHeld =>
GameObject.Components.GetAll<GrabPoint>( FindMode.InDescendants ).Any( g => g.IsGrabbed );
private int HeldGripCount =>
GameObject.Components.GetAll<GrabPoint>( FindMode.InDescendants ).Count( g => g.IsGrabbed );
private bool BlocksFireFromReload => Type != GunType.Shotgun && IsReloading;
/// <summary>
/// Current recoil expressed as a gun-local rotation kick for VR. Amplified when a
/// two-handed weapon is gripped with only one hand.
/// </summary>
public Rotation VrRecoilRotation
{
get
{
float multiplier = (IsTwoHanded && HeldGripCount == 1) ? OneHandedRecoilMultiplier : 1f;
return Rotation.From(
-_currentRecoil.y * VrRecoilScale * multiplier,
_currentRecoil.x * VrRecoilScale * multiplier,
0f );
}
}
/// <summary>
/// Aim/viewmodel recoil after hip-fire scaling. Ironsights use the raw pattern; hip fire is amplified.
/// </summary>
private Vector2 EffectiveRecoil
{
get
{
bool inIronsights = !Game.IsRunningInVR && PlayerViewModel.Instance is { Ironsights: true };
return _currentRecoil * (inIronsights ? 1f : HipFireRecoilMultiplier);
}
}
private bool CanFire => Time.Now >= _nextFireTime && !BlocksFireFromReload && !IsEquipping && CurrentBullets > 0;
private float _nextFireTime = 0f;
private float _reloadEndTime;
private float _equipEndTime;
private bool _wasHeld;
private int _recoilIndex;
private Vector2 _currentRecoil;
private float _lastFireTime = float.NegativeInfinity;
private float _burstStartTime = float.NegativeInfinity;
private int _lastProcessedFireSequence;
private float _worldMuzzleHideTime;
private ReloadBar _reloadBar;
private float _reloadBoltTime;
private bool _magOutPlayed;
private bool _magInPlayed;
protected override void OnStart()
{
if ( !IsProxy )
CurrentBullets = MagazineSize;
_lastShellReloadActive = ShellReloadActive;
}
protected override void OnUpdate()
{
UpdateShellReloadVisuals();
if ( !IsProxy )
{
UpdateEquipState();
UpdateReloadingState();
UpdateRecoil();
if ( !Game.IsRunningInVR )
{
if ( IsHeld )
{
UpdateViewModelAnimation();
}
}
HandleFiring();
if ( Game.IsRunningInVR )
{
UpdateProxyShotEffects();
}
else
{
HandleReloadBolt();
}
if (PlayerViewModel.Instance is not null && IsHeld)
{
PlayerViewModel.Instance?.SetLoadedState( !HasUnloadedAnimation || CurrentBullets > 0 );
}
}
else
{
UpdateProxyShotEffects();
}
}
private void UpdateShellReloadVisuals()
{
if ( ShellReloadActive == _lastShellReloadActive )
return;
_lastShellReloadActive = ShellReloadActive;
}
private void HandleReloadBolt()
{
if ( _reloadBoltTime > 0 && Time.Now >= _reloadBoltTime )
{
_reloadBoltTime = 0;
PlayerViewModel.Instance.PlayReloadBolt();
}
}
private void UpdateRecoil()
{
if ( Recoil is null || !Recoil.HasPoints )
{
_currentRecoil = Vector2.Lerp( _currentRecoil, Vector2.Zero, Recoil?.RecoverySpeed ?? 8f * Time.Delta );
return;
}
if ( Time.Now - _lastFireTime > FireRate * 2f )
{
_recoilIndex = 0;
_currentRecoil = Vector2.Lerp( _currentRecoil, Vector2.Zero, Recoil.RecoverySpeed * Time.Delta );
return;
}
var target = _recoilIndex > 0 ? Recoil.GetPoint( _recoilIndex - 1 ) : Vector2.Zero;
_currentRecoil = Vector2.Lerp( _currentRecoil, target, Recoil.ApproachSpeed * Time.Delta );
}
private void UpdateViewModelAnimation()
{
if ( PlayerViewModel.Instance is null || !PlayerViewModel.Instance.HasActiveWeapon )
return;
bool inBurst = Time.Now - _lastFireTime <= FireRate * 2f;
float attackHold = inBurst ? Math.Clamp( (Time.Now - _burstStartTime) / 0.3f, 0f, 1f ) : 0f;
PlayerViewModel.Instance.UpdateWeaponAnimation( attackHold, EffectiveRecoil );
}
private void HandleFiring()
{
if ( Firing && CanFire )
{
PlayerViewModel.Instance?.ShowMuzzleFlash();
PlayerViewModel.Instance?.PlayShotSound();
SpawnProjectiles();
CurrentBullets--;
PlayerViewModel.Instance?.PlayAttack();
PlayerAnimationBroadcast.CurrentPlayer.TriggerAttack();
bool startingBurst = Time.Now - _lastFireTime > FireRate * 2f;
_lastFireTime = Time.Now;
if ( startingBurst )
_burstStartTime = Time.Now;
if ( Recoil is not null && Recoil.HasPoints )
_recoilIndex++;
if ( Type == GunType.Bolt )
_reloadBoltTime = Time.Now + 0.5f;
FireSequence++;
_nextFireTime = Time.Now + FireRate;
if ( Game.IsRunningInVR )
TriggerHeldHandRumble();
}
Firing = false;
}
private void TriggerHeldHandRumble()
{
foreach ( var grab in GameObject.Components.GetAll<GrabPoint>( FindMode.InDescendants ) )
{
if ( grab.IsGrabbed )
grab.TriggerHaptic( HapticEffect.HardImpact, HapticLengthScale, HapticFrequencyScale, HapticAmplitudeScale );
}
}
private void UpdateProxyShotEffects()
{
if ( FireSequence != _lastProcessedFireSequence )
{
_lastProcessedFireSequence = FireSequence;
TriggerWorldMuzzleFlash();
SoundPoint?.StartSound();
}
UpdateWorldMuzzleFlashVisibility();
}
private void TriggerWorldMuzzleFlash()
{
if ( Muzzleflash is null )
return;
Muzzleflash.Enabled = false;
Muzzleflash.Enabled = true;
_worldMuzzleHideTime = Time.Now + MuzzleFlashDuration;
}
private void UpdateWorldMuzzleFlashVisibility()
{
if ( Muzzleflash is null || !Muzzleflash.Enabled )
return;
if ( Time.Now >= _worldMuzzleHideTime )
Muzzleflash.Enabled = false;
}
private void SpawnProjectiles()
{
if ( IsProxy || BulletPrefab is null )
return;
Vector3 spawnPosition;
Vector3 shootDirection;
if ( Game.IsRunningInVR )
{
spawnPosition = MuzzleTransform.WorldPosition;
shootDirection = MuzzleTransform.WorldRotation.Forward;
}
else
{
var ray = PlayerViewModel.Instance.GetAimRay( EffectiveRecoil );
spawnPosition = ray.Position;
shootDirection = ray.Forward;
}
int pelletCount = Math.Max( 1, PelletCount );
for ( int i = 0; i < pelletCount; i++ )
{
var pelletDirection = ApplySpread( shootDirection, SpreadAngle );
var bullet = BulletPrefab.Clone( spawnPosition, Rotation.LookAt( pelletDirection ) );
var projectileScript = bullet.Components.Get<Projectile>();
if ( projectileScript is not null )
{
projectileScript.Velocity = pelletDirection * BulletSpeed;
projectileScript.WeaponObject = GameObject;
}
if ( projectileScript.StayInWorld )
{
bullet.NetworkSpawn();
}
}
}
private static Vector3 ApplySpread( Vector3 direction, float spreadAngle )
{
if ( spreadAngle <= 0f )
return direction.Normal;
var angles = Rotation.LookAt( direction.Normal ).Angles();
angles.pitch += Game.Random.Float( -spreadAngle, spreadAngle );
angles.yaw += Game.Random.Float( -spreadAngle, spreadAngle );
return Rotation.From( angles ).Forward;
}
public void Fire()
{
if ( ShellReloadActive )
{
_shellReloadCancelPending = true;
_fireWhenShellReloadEnds = true;
return;
}
if ( BlocksFireFromReload || IsEquipping || CurrentBullets <= 0 )
return;
Firing = true;
}
public void Reload()
{
if ( IsReloading || CurrentBullets >= MagazineSize || IsEquipping || ShellReloadActive )
return;
if ( Type == GunType.Shotgun )
{
BeginShellReload();
return;
}
IsReloading = true;
_reloadEndTime = Time.Now + ReloadTime;
_magOutPlayed = false;
_magInPlayed = false;
PlayerViewModel.Instance?.PlayReload();
PlayerAnimationBroadcast.CurrentPlayer.TriggerReload();
if ( Game.IsRunningInVR )
CreateVrReloadBar();
}
private void BeginShellReload()
{
ShellReloadActive = true;
_shellReloadCancelPending = false;
_fireWhenShellReloadEnds = false;
PlayerViewModel.Instance?.BeginShellReload();
BeginNextShellInsert();
if ( Game.IsRunningInVR )
CreateVrReloadBar();
}
private void BeginNextShellInsert()
{
PlayShellInsertAnimation();
_nextShellAmmoTime = Time.Now + ShellInsertInterval;
_shellInsertSoundPlayed = false;
}
private void PlayShellInsertAnimation()
{
PlayerViewModel.Instance?.InsertShell();
}
private void EndShellReload()
{
if ( !ShellReloadActive )
return;
ShellReloadActive = false;
_shellReloadCancelPending = false;
PlayerViewModel.Instance?.EndShellReload();
DestroyVrReloadBar();
}
private void FinishShellReload( bool tryFire )
{
bool shouldFire = tryFire && _fireWhenShellReloadEnds && CurrentBullets > 0;
_fireWhenShellReloadEnds = false;
EndShellReload();
if ( shouldFire && !IsEquipping )
Firing = true;
}
private void UpdateShellReload()
{
if ( Type != GunType.Shotgun || !ShellReloadActive )
return;
if ( !IsHeld )
{
_fireWhenShellReloadEnds = false;
EndShellReload();
return;
}
if ( CurrentBullets >= MagazineSize )
{
FinishShellReload( tryFire: false );
return;
}
var shellInsertStartTime = _nextShellAmmoTime - ShellInsertInterval;
var elapsed = Time.Now - shellInsertStartTime;
if ( !_shellInsertSoundPlayed && elapsed >= ShellInsertSoundTime )
{
_shellInsertSoundPlayed = true;
PlayReloadSound( ShellInsertSound );
}
if ( Time.Now < _nextShellAmmoTime )
return;
CurrentBullets++;
if ( _shellReloadCancelPending || CurrentBullets >= MagazineSize )
{
FinishShellReload( tryFire: true );
return;
}
BeginNextShellInsert();
}
private void UpdateEquipState()
{
bool isHeld = IsHeld;
if ( isHeld && !_wasHeld )
BeginEquip();
if ( !isHeld && _wasHeld )
CancelEquip();
_wasHeld = isHeld;
if ( IsEquipping && Time.Now >= _equipEndTime )
IsEquipping = false;
}
private void UpdateReloadingState()
{
UpdateShellReload();
UpdateShellReloadBar();
if ( !IsReloading )
return;
if ( !IsHeld )
{
IsReloading = false;
DestroyVrReloadBar();
return;
}
var reloadStartTime = _reloadEndTime - ReloadTime;
var elapsed = Time.Now - reloadStartTime;
if ( !_magOutPlayed && elapsed >= MagOutTime )
{
_magOutPlayed = true;
PlayReloadSound( MagOutSound );
}
if ( !_magInPlayed && elapsed >= MagInTime )
{
_magInPlayed = true;
PlayReloadSound( MagInSound );
}
if ( _reloadBar is not null )
_reloadBar.Progress = elapsed / ReloadTime;
if ( Time.Now >= _reloadEndTime )
{
IsReloading = false;
CurrentBullets = MagazineSize;
DestroyVrReloadBar();
if ( !Game.IsRunningInVR )
{
PlayerAnimationBroadcast.CurrentPlayer.DisableSecondaryGrip = false;
}
}
}
[Rpc.Broadcast]
private void PlayReloadSound( SoundEvent sound )
{
if ( sound is null )
return;
var isWorldSound = IsProxy || Game.IsRunningInVR;
sound.UI = !isWorldSound;
if (!sound.UI)
{
sound.Distance = 2000f;
}
Sound.Play( sound, WorldPosition );
}
private void CreateVrReloadBar()
{
DestroyVrReloadBar();
var worldPanelGO = new GameObject( parent: GameObject, name: "ReloadBar" )
{
NetworkMode = NetworkMode.Never
};
var worldPanel = worldPanelGO.AddComponent<WorldPanel>();
worldPanel.PanelSize = new Vector2( 300f, 100f );
worldPanel.LookAtCamera = true;
_reloadBar = worldPanelGO.AddComponent<ReloadBar>();
_reloadBar.Progress = 0f;
}
private void DestroyVrReloadBar()
{
_reloadBar?.Destroy();
_reloadBar = null;
}
private void UpdateShellReloadBar()
{
if ( !Game.IsRunningInVR || _reloadBar is null || !ShellReloadActive )
return;
var shellInsertStartTime = _nextShellAmmoTime - ShellInsertInterval;
_reloadBar.Progress = Math.Clamp( (Time.Now - shellInsertStartTime) / ShellInsertInterval, 0f, 1f );
}
private void BeginEquip()
{
if ( Game.IsRunningInVR || EquipTime <= 0f )
return;
IsEquipping = true;
_equipEndTime = Time.Now + EquipTime;
}
private void CancelEquip()
{
IsEquipping = false;
}
}