PlayerCameraFeel.cs
using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// Stable owner-local first-person camera presentation.
///
/// Architectural boundary:
/// PlayerController builds the base eye camera first (order 0), OMR adds
/// locomotion feel here (order 150), and the active weapon applies its ADS FOV
/// afterwards. This component never repositions the weapon viewmodel.
///
/// All user-facing feel values live in gameplay/default.omrfp. The hidden
/// properties retained below exist only so older player.prefab snapshots keep
/// deserializing cleanly while Revision 21 moves tuning out of the networked
/// prefab.
/// </summary>
public sealed class PlayerCameraFeel :
Component,
ICameraModifier,
PlayerController.IEvents
{
// =========================================================
// CAMERA ORDER
// =========================================================
int ICameraModifier.CameraOrder => 150;
// =========================================================
// REFERENCES
// =========================================================
[Property]
public PlayerController Controller { get; set; }
[Property]
public PlayerSprint Sprint { get; set; }
[Property]
public PlayerSlide Slide { get; set; }
[Property]
public BaseInventoryComponent Inventory { get; set; }
// =========================================================
// LEGACY PREFAB DATA (REVISION 16-20 COMPATIBILITY)
// =========================================================
// These are intentionally no longer used for runtime tuning. Keeping them
// lets the existing player.prefab deserialize without a structural edit.
[Property, Hide] public bool EnableMovementBob { get; set; } = true;
[Property, Hide] public float BobFrequency { get; set; } = 2f;
[Property, Hide] public float BobVerticalAmount { get; set; } = 1.2f;
[Property, Hide] public float BobHorizontalAmount { get; set; } = 0.8f;
[Property, Hide] public bool EnableStrafeRoll { get; set; } = true;
[Property, Hide] public float MaxStrafeRoll { get; set; } = 1.5f;
[Property, Hide] public bool EnableSpeedFov { get; set; } = true;
[Property, Hide] public float MaxMovementFovBoost { get; set; } = 7f;
[Property, Hide] public float SprintFovBoost { get; set; } = 4f;
[Property, Hide] public float SprintBobMultiplier { get; set; } = 1.25f;
[Property, Hide] public bool EnableSlideCamera { get; set; } = true;
[Property, Hide] public float SlideFovBoost { get; set; } = 2.5f;
[Property, Hide] public float SlideCameraDrop { get; set; } = 1.3f;
[Property, Hide] public float SlideSwayAmount { get; set; } = 0.5f;
[Property, Hide] public float SlideSwayFrequency { get; set; } = 0.85f;
[Property, Hide] public float SlideStrafeRollReduction { get; set; } = 0.65f;
[Property, Hide] public float SlideSmoothing { get; set; } = 0.07f;
[Property, Hide] public float LandingSlidePunchMultiplier { get; set; } = 0.35f;
[Property, Hide] public bool EnableAdsCameraStabilization { get; set; } = true;
[Property, Hide] public float AdsCameraSmoothing { get; set; } = 0.08f;
[Property, Hide] public float AdsBobScale { get; set; } = 0.18f;
[Property, Hide] public float AdsStrafeRollScale { get; set; } = 0.20f;
[Property, Hide] public float AdsMovementFovScale { get; set; } = 0f;
[Property, Hide] public float AdsSlideCameraScale { get; set; } = 0.35f;
[Property, Hide] public bool EnableZoomMatchedSensitivity { get; set; } = true;
[Property, Hide] public float MinimumZoomSensitivityScale { get; set; } = 0.20f;
[Property, Hide] public float SensitivitySmoothing { get; set; } = 0.04f;
[Property, Hide] public float MovementSmoothing { get; set; } = 0.08f;
[Property, Hide] public float SprintSmoothing { get; set; } = 0.12f;
[Property, Hide] public bool EnableLandingResponse { get; set; } = true;
[Property, Hide] public float LandingPitchAmount { get; set; } = 2f;
// =========================================================
// RUNTIME STATE
// =========================================================
private float _bobPhase;
private float _slidePhase;
private float _smoothedMovement;
private float _smoothedRoll;
private float _smoothedSprint;
private float _smoothedSlide;
private float _smoothedAim;
private float _smoothedSensitivityScale = 1f;
private float _baseLookSensitivity = 1f;
private bool _wasSliding;
private bool _cameraHookConfirmed;
private float _nextCameraDiagnosticAt;
// Owner-local full-body shadow proxy.
private GameObject _firstPersonShadowObject;
private SkinnedModelRenderer _firstPersonShadowRenderer;
private SkinnedModelRenderer _shadowSourceRenderer;
private GameObject _shadowedViewModel;
private readonly Dictionary<ModelRenderer, ModelRenderer.ShadowRenderType>
_originalViewModelShadowModes = new();
private bool _presentationSetupLogged;
// =========================================================
// LIFECYCLE
// =========================================================
protected override void OnStart()
{
Controller ??= Components.Get<PlayerController>();
Sprint ??= Components.Get<PlayerSprint>();
Slide ??= Components.Get<PlayerSlide>();
Inventory ??= Components.Get<BaseInventoryComponent>();
if ( Controller is null )
{
Log.Warning( "PlayerCameraFeel could not find PlayerController." );
return;
}
_baseLookSensitivity = Controller.LookSensitivity;
_smoothedSensitivityScale = 1f;
_smoothedAim = 0f;
_smoothedMovement = 0f;
_smoothedSprint = 0f;
_smoothedSlide = 0f;
_smoothedRoll = 0f;
OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;
if ( tuning?.LogPresentationSetup == true )
{
Log.Info(
$"[OMR FP] PlayerCameraFeel active | Proxy:{IsProxy} | CameraOrder:150"
);
}
}
protected override void OnUpdate()
{
if ( IsProxy || Components.Get<PlayerState>()?.IsBot == true || Controller is null )
return;
UpdateAimBlend();
UpdateLookSensitivity();
}
protected override void OnDisabled()
{
RestoreLookSensitivity();
RestoreViewModelShadows();
DestroyFirstPersonShadowProxy();
base.OnDisabled();
}
protected override void OnPreRender()
{
base.OnPreRender();
if ( IsProxy || Components.Get<PlayerState>()?.IsBot == true || Controller is null )
return;
UpdateFirstPersonShadowPresentation();
}
// =========================================================
// CAMERA MODIFIER
// =========================================================
public void ModifyCamera(
CameraComponent camera,
ref CameraView view
)
{
if ( IsProxy || Components.Get<PlayerState>()?.IsBot == true || Controller is null || camera is null )
return;
if ( Scene.Camera is not null && camera != Scene.Camera )
return;
if ( !_cameraHookConfirmed )
{
_cameraHookConfirmed = true;
OMRFirstPersonTuning hookTuning = OMRFirstPersonResources.DefaultTuning;
if ( hookTuning?.LogPresentationSetup == true )
{
Log.Info( "[OMR FP] Camera modifier hook confirmed | CameraOrder:150" );
}
}
// OMR's local gameplay FOV preference is the base for all subsequent
// movement and weapon modifiers.
view.FieldOfView = OMRPlayerSettings.FieldOfView;
OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;
if ( tuning is null || !tuning.EnableCameraFeel )
return;
bool isSliding =
Slide is not null &&
Slide.Enabled &&
Slide.IsSliding;
if ( isSliding && !_wasSliding )
_slidePhase = 0f;
_wasSliding = isSliding;
Vector3 horizontalVelocity = Controller.Velocity.WithZ( 0f );
float horizontalSpeed = horizontalVelocity.Length;
float referenceSpeed = GetReferenceMoveSpeed();
float movementTarget = MathX.Clamp(
horizontalSpeed / referenceSpeed,
0f,
1f
);
// Sprint FOV deliberately uses a discrete physically-resolved state.
// This avoids the old ambiguity where both walk and sprint collapsed into
// the same normalized speed band.
bool physicallySprinting = IsPhysicallySprinting(
horizontalSpeed,
referenceSpeed
);
float sprintTarget = physicallySprinting ? 1f : 0f;
float slideTarget = isSliding ? 1f : 0f;
_smoothedMovement = MathX.ExponentialDecay(
_smoothedMovement,
movementTarget,
MathF.Max( tuning.MovementSmoothing, 0.001f ),
Time.Delta
);
_smoothedSprint = MathX.ExponentialDecay(
_smoothedSprint,
sprintTarget,
MathF.Max( tuning.SprintSmoothing, 0.001f ),
Time.Delta
);
_smoothedSlide = MathX.ExponentialDecay(
_smoothedSlide,
slideTarget,
MathF.Max( tuning.SlideSmoothing, 0.001f ),
Time.Delta
);
ApplyMovementFov( tuning, ref view );
ApplyMovementBob( tuning, ref view );
ApplySlideCamera( tuning, ref view );
ApplyStrafeRoll(
tuning,
ref view,
horizontalVelocity,
referenceSpeed
);
if (
tuning.LogCameraState &&
RealTime.Now >= _nextCameraDiagnosticAt
)
{
_nextCameraDiagnosticAt = RealTime.Now + 1f;
float adsFovScale = GetAdsScale(
tuning,
tuning.AdsMovementFovScale
);
float movementBoost =
tuning.MovementFovBoost * _smoothedMovement;
float sprintBoost =
tuning.SprintFovBoost * _smoothedSprint * (1f - _smoothedSlide);
float slideBoost =
tuning.EnableSlideCamera
? tuning.SlideFovBoost * _smoothedSlide
: 0f;
Log.Info(
$"[OMR FP CAM] Speed:{horizontalSpeed:0.0}/{referenceSpeed:0.0} Move:{_smoothedMovement:0.00} SprintState:{physicallySprinting} Sprint:{_smoothedSprint:0.00} Slide:{_smoothedSlide:0.00} ADS:{_smoothedAim:0.00} FovBoost:{(movementBoost + sprintBoost + slideBoost) * adsFovScale:0.00}"
);
}
}
// =========================================================
// STATE RESOLUTION
// =========================================================
private float GetReferenceMoveSpeed()
{
if ( Sprint is not null && Sprint.Enabled )
return MathF.Max( Sprint.BaseMoveSpeed, 1f );
return MathF.Max( Controller.RunSpeed, 1f );
}
private bool IsPhysicallySprinting(
float horizontalSpeed,
float referenceSpeed
)
{
if ( Sprint is null || !Sprint.Enabled )
return false;
if ( Sprint.IsSprinting )
return true;
// Ordering-safe fallback. PlayerLocomotion raises WalkSpeed and RunSpeed
// to SprintSpeed when sprint is physically resolved, so presentation can
// still identify the state even if IsSprinting is observed one tick late.
return
Controller.WalkSpeed > referenceSpeed + 1f &&
horizontalSpeed > 10f;
}
private void UpdateAimBlend()
{
OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;
if ( tuning is null || !tuning.EnableAdsCameraStabilization )
{
_smoothedAim = 0f;
return;
}
float target = GetActiveWeaponAimState()?.IsAiming == true ? 1f : 0f;
_smoothedAim = MathX.ExponentialDecay(
_smoothedAim,
target,
MathF.Max( tuning.AdsCameraSmoothing, 0.001f ),
Time.Delta
);
}
private float GetAdsScale(
OMRFirstPersonTuning tuning,
float fullAdsValue
)
{
if ( !tuning.EnableAdsCameraStabilization )
return 1f;
return MathX.Lerp(
1f,
fullAdsValue,
_smoothedAim
);
}
// =========================================================
// MOVEMENT FOV
// =========================================================
private void ApplyMovementFov(
OMRFirstPersonTuning tuning,
ref CameraView view
)
{
if ( !tuning.EnableMovementFov )
return;
float movementBoost =
tuning.MovementFovBoost *
_smoothedMovement;
float sprintBoost =
tuning.SprintFovBoost *
_smoothedSprint *
(1f - _smoothedSlide);
float slideBoost =
tuning.EnableSlideCamera
? tuning.SlideFovBoost * _smoothedSlide
: 0f;
float adsScale = GetAdsScale(
tuning,
tuning.AdsMovementFovScale
);
view.FieldOfView +=
(movementBoost + sprintBoost + slideBoost) *
adsScale;
}
// =========================================================
// CAMERA BOB
// =========================================================
private void ApplyMovementBob(
OMRFirstPersonTuning tuning,
ref CameraView view
)
{
if ( !tuning.EnableCameraBob )
return;
if ( _smoothedMovement <= 0.02f || !Controller.IsOnGround )
return;
float sprintBobScale = MathX.Lerp(
1f,
tuning.CameraSprintBobMultiplier,
_smoothedSprint
);
float slideBobScale =
tuning.EnableSlideCamera
? 1f - _smoothedSlide
: 1f;
float adsBobScale = GetAdsScale(
tuning,
tuning.AdsCameraBobScale
);
float frequency =
tuning.CameraBobFrequency *
(0.35f + _smoothedMovement * 0.65f) *
sprintBobScale;
_bobPhase +=
Time.Delta *
frequency *
MathF.PI *
2f;
float verticalBob =
MathF.Sin( _bobPhase * 2f ) *
tuning.CameraBobVerticalAmount *
_smoothedMovement *
sprintBobScale *
slideBobScale *
adsBobScale;
float horizontalBob =
MathF.Sin( _bobPhase ) *
tuning.CameraBobHorizontalAmount *
_smoothedMovement *
sprintBobScale *
slideBobScale *
adsBobScale;
view.Position += view.Rotation.Up * verticalBob;
view.Position += view.Rotation.Right * horizontalBob;
}
// =========================================================
// SLIDE CAMERA
// =========================================================
private void ApplySlideCamera(
OMRFirstPersonTuning tuning,
ref CameraView view
)
{
if ( !tuning.EnableSlideCamera || _smoothedSlide <= 0.001f )
return;
float adsScale = GetAdsScale(
tuning,
tuning.AdsSlideCameraScale
);
view.Position +=
view.Rotation.Up *
(-tuning.SlideCameraDrop * _smoothedSlide * adsScale);
_slidePhase +=
Time.Delta *
tuning.SlideSwayFrequency *
MathF.PI *
2f;
float slideSway =
MathF.Sin( _slidePhase ) *
tuning.SlideSwayAmount *
_smoothedSlide *
adsScale;
view.Position += view.Rotation.Right * slideSway;
}
// =========================================================
// STRAFE ROLL
// =========================================================
private void ApplyStrafeRoll(
OMRFirstPersonTuning tuning,
ref CameraView view,
Vector3 horizontalVelocity,
float referenceSpeed
)
{
if ( !tuning.EnableStrafeRoll )
return;
float lateralVelocity = Vector3.Dot(
horizontalVelocity,
view.Rotation.Right
);
float lateralFraction = MathX.Clamp(
lateralVelocity / referenceSpeed,
-1f,
1f
);
float slideScale = tuning.EnableSlideCamera
? 1f - tuning.SlideStrafeRollReduction * _smoothedSlide
: 1f;
float adsScale = GetAdsScale(
tuning,
tuning.AdsStrafeRollScale
);
float targetRoll =
-lateralFraction *
tuning.MaximumStrafeRoll *
slideScale *
adsScale;
_smoothedRoll = MathX.ExponentialDecay(
_smoothedRoll,
targetRoll,
MathF.Max( tuning.MovementSmoothing, 0.001f ),
Time.Delta
);
view.Rotation *= Rotation.FromRoll( _smoothedRoll );
}
// =========================================================
// ZOOM-MATCHED SENSITIVITY
// =========================================================
private void UpdateLookSensitivity()
{
if ( Controller is null || IsProxy || Components.Get<PlayerState>()?.IsBot == true )
return;
OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;
float userSensitivity = OMRPlayerSettings.Sensitivity;
IWeaponOpticState opticState = GetActiveWeaponOpticState();
float opticScale =
opticState?.CurrentOpticSensitivityScale ?? 1f;
opticScale = MathX.Clamp( opticScale, 0.1f, 2f );
if ( tuning is null || !tuning.EnableZoomMatchedSensitivity )
{
_smoothedSensitivityScale = opticScale;
Controller.LookSensitivity =
_baseLookSensitivity *
userSensitivity *
opticScale;
return;
}
CameraComponent camera = Scene.Camera;
float targetScale = 1f;
float baseFov = OMRPlayerSettings.FieldOfView;
if (
camera is not null &&
baseFov > 1f &&
camera.View.FieldOfView > 1f
)
{
float sensitivityFov = camera.View.FieldOfView;
// PiP optics may now apply a small presentation-only zoom to the ordinary
// peripheral camera. Sensitivity matching should still be based on the
// authored optic projection rather than becoming accidentally slower just
// because the surrounding world was also narrowed a little. Recover the
// pre-peripheral FOV first, then resolve the scope's sensitivity FOV.
OMROpticProfile profile = opticState?.ActiveOpticProfile;
if (
profile is not null &&
profile.IsValid &&
profile.Mode == OMROpticMode.MagnifiedScope &&
(
profile.PresentationMode == OMROpticPresentationMode.PictureInPicture ||
profile.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture
)
)
{
float peripheralScale = MathX.Clamp(
opticState.CurrentPeripheralAimFovScale,
0.5f,
1.5f
);
float opticalReferenceFov = MathX.Clamp(
sensitivityFov / MathF.Max( peripheralScale, 0.0001f ),
1f,
179f
);
sensitivityFov = OMROpticMath.ResolveSensitivityFieldOfView(
opticalReferenceFov,
profile,
opticState.AimFraction
);
}
targetScale = CalculateZoomSensitivityScale(
baseFov,
sensitivityFov,
tuning.MinimumZoomSensitivityScale
);
}
targetScale *= opticScale;
targetScale = MathX.Clamp( targetScale, 0.05f, 2f );
_smoothedSensitivityScale = MathX.ExponentialDecay(
_smoothedSensitivityScale,
targetScale,
MathF.Max( tuning.SensitivitySmoothing, 0.001f ),
Time.Delta
);
Controller.LookSensitivity =
_baseLookSensitivity *
userSensitivity *
_smoothedSensitivityScale;
}
private float CalculateZoomSensitivityScale(
float baseFov,
float composedFov,
float minimumScale
)
{
if ( composedFov >= baseFov )
return 1f;
float baseRadians = baseFov * (MathF.PI / 180f);
float composedRadians = composedFov * (MathF.PI / 180f);
float baseTangent = MathF.Tan( baseRadians * 0.5f );
float composedTangent = MathF.Tan( composedRadians * 0.5f );
if ( baseTangent <= 0.0001f )
return 1f;
return MathX.Clamp(
composedTangent / baseTangent,
minimumScale,
1f
);
}
private void RestoreLookSensitivity()
{
if ( Controller is null || IsProxy || Components.Get<PlayerState>()?.IsBot == true )
return;
Controller.LookSensitivity = _baseLookSensitivity;
_smoothedSensitivityScale = 1f;
}
// =========================================================
// LANDING RESPONSE
// =========================================================
public void OnLanded(
float distance,
Vector3 impactVelocity
)
{
if ( IsProxy || Components.Get<PlayerState>()?.IsBot == true )
return;
OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;
if (
tuning is null ||
!tuning.EnableCameraFeel ||
!tuning.EnableLandingResponse
)
{
return;
}
CameraComponent camera = Scene.Camera;
if ( camera is null )
return;
float impactSpeed = MathF.Abs( impactVelocity.z );
float strength = MathX.Clamp(
MathX.Remap( impactSpeed, 120f, 600f, 0f, 1f ),
0f,
1f
);
if ( strength <= 0.05f )
return;
bool landingIntoSlide =
Slide is not null &&
(Slide.IsSliding || Slide.LandingSlideQueued);
if ( landingIntoSlide )
strength *= tuning.LandingSlidePunchMultiplier;
camera.AddPunch(
new Angles(
tuning.LandingPitchAmount * strength,
0f,
0f
)
);
}
// =========================================================
// FIRST-PERSON BODY / VIEWMODEL SHADOWS
// =========================================================
private void UpdateFirstPersonShadowPresentation()
{
OMRFirstPersonTuning tuning = OMRFirstPersonResources.DefaultTuning;
if ( tuning is null )
{
RestoreViewModelShadows();
if ( _firstPersonShadowRenderer is not null )
_firstPersonShadowRenderer.Enabled = false;
return;
}
bool firstPerson = !Controller.ThirdPerson;
if ( tuning.UseFullBodyShadow && firstPerson )
{
EnsureFirstPersonShadowProxy();
if ( _firstPersonShadowRenderer is not null )
{
_firstPersonShadowRenderer.Enabled = true;
_firstPersonShadowRenderer.RenderType =
ModelRenderer.ShadowRenderType.ShadowsOnly;
}
}
else if ( _firstPersonShadowRenderer is not null )
{
_firstPersonShadowRenderer.Enabled = false;
}
UpdateViewModelShadowMode(
tuning.DisableViewModelShadows && firstPerson
);
if ( tuning.LogPresentationSetup && !_presentationSetupLogged )
{
_presentationSetupLogged = true;
Log.Info(
$"[OMR FP] Presentation | FullBodyShadow:{tuning.UseFullBodyShadow} ViewModelShadowsOff:{tuning.DisableViewModelShadows} RootViewModelMotion:false"
);
}
}
private void EnsureFirstPersonShadowProxy()
{
SkinnedModelRenderer body = Controller.Renderer;
if ( body is null || body.Model is null )
return;
if (
_firstPersonShadowRenderer is not null &&
_shadowSourceRenderer == body &&
_firstPersonShadowRenderer.Model == body.Model
)
{
_firstPersonShadowRenderer.BodyGroups = body.BodyGroups;
return;
}
DestroyFirstPersonShadowProxy();
_firstPersonShadowObject = new GameObject( "_OMR First Person Shadow" );
_firstPersonShadowObject.NetworkMode = NetworkMode.Never;
_firstPersonShadowObject.Flags |=
GameObjectFlags.NotNetworked |
GameObjectFlags.NotSaved |
GameObjectFlags.Hidden;
_firstPersonShadowObject.SetParent( GameObject, false );
_firstPersonShadowRenderer =
_firstPersonShadowObject.AddComponent<SkinnedModelRenderer>();
_firstPersonShadowRenderer.CopyFrom( body );
_firstPersonShadowRenderer.Model = body.Model;
_firstPersonShadowRenderer.BodyGroups = body.BodyGroups;
_firstPersonShadowRenderer.BoneMergeTarget = body;
_firstPersonShadowRenderer.UseAnimGraph = false;
_firstPersonShadowRenderer.CreateBoneObjects = false;
_firstPersonShadowRenderer.RenderType =
ModelRenderer.ShadowRenderType.ShadowsOnly;
_shadowSourceRenderer = body;
}
private void DestroyFirstPersonShadowProxy()
{
if (
_firstPersonShadowObject is not null &&
_firstPersonShadowObject.IsValid
)
{
_firstPersonShadowObject.Destroy();
}
_firstPersonShadowObject = null;
_firstPersonShadowRenderer = null;
_shadowSourceRenderer = null;
}
private void UpdateViewModelShadowMode( bool disableShadows )
{
GameObject viewModel = GetActiveCombatWeapon()?.ViewModel;
if ( viewModel != _shadowedViewModel )
{
RestoreViewModelShadows();
_shadowedViewModel = viewModel;
}
if ( viewModel is null || !viewModel.IsValid )
return;
if ( !disableShadows )
{
RestoreViewModelShadows();
_shadowedViewModel = viewModel;
return;
}
foreach (
ModelRenderer renderer in
viewModel.Components.GetAll<ModelRenderer>(
FindMode.InSelf | FindMode.InDescendants
)
)
{
if ( !_originalViewModelShadowModes.ContainsKey( renderer ) )
{
_originalViewModelShadowModes[renderer] = renderer.RenderType;
}
renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
}
}
private void RestoreViewModelShadows()
{
foreach (
KeyValuePair<ModelRenderer, ModelRenderer.ShadowRenderType> pair in
_originalViewModelShadowModes
)
{
ModelRenderer renderer = pair.Key;
if (
renderer is null ||
renderer.GameObject is null ||
!renderer.GameObject.IsValid
)
{
continue;
}
renderer.RenderType = pair.Value;
}
_originalViewModelShadowModes.Clear();
_shadowedViewModel = null;
}
// =========================================================
// ACTIVE WEAPON
// =========================================================
private IWeaponAimState GetActiveWeaponAimState()
{
Inventory ??= Components.Get<BaseInventoryComponent>();
return Inventory?.ActiveItem as IWeaponAimState;
}
private IWeaponOpticState GetActiveWeaponOpticState()
{
Inventory ??= Components.Get<BaseInventoryComponent>();
return Inventory?.ActiveItem as IWeaponOpticState;
}
private BaseCombatWeapon GetActiveCombatWeapon()
{
Inventory ??= Components.Get<BaseInventoryComponent>();
return Inventory?.ActiveItem as BaseCombatWeapon;
}
}