OneMoreRoundWeapon.cs
using Sandbox;
using Sandbox.Rendering;
using System;
/// <summary>
/// Marker component placed on a world-model GameObject that represents
/// where the holder's left hand should be positioned and oriented.
///
/// Put this component on a child such as "left_hand_ik" inside the
/// weapon's world-model prefab. The weapon discovers the runtime clone
/// automatically and feeds its world transform into the holder AnimGraph.
/// </summary>
public sealed class WeaponLeftHandIkTarget : Component
{
}
public partial class OneMoreRoundWeapon : BaseCombatWeapon
{
private const string AimAction = "aim";
// =========================================================
// OMR IDENTITY
// =========================================================
/// <summary>
/// Stable, code-facing identity for this exact weapon.
///
/// Do not derive gameplay rules from the prefab filename. Prefabs can be
/// renamed; WeaponId is the durable key future round presets/mutators use.
/// Examples: "usp", "mp5", "m4a1".
/// </summary>
[Property, Group( "OMR Identity" )]
public string WeaponId { get; set; } = string.Empty;
/// <summary>
/// Broad category used by future loadout rules such as "any shotgun".
/// Exact-weapon rules should use WeaponId instead.
/// </summary>
[Property, Group( "OMR Identity" )]
public OMRWeaponArchetype WeaponArchetype { get; set; } =
OMRWeaponArchetype.Unknown;
/// <summary>
/// Mechanical traits should come from BaseCombatWeapon's real settings
/// rather than duplicated inspector booleans that can drift out of sync.
/// </summary>
public bool IsAutomaticWeapon =>
PrimaryAutomatic;
public bool IsPelletWeapon =>
Ballistics.Pellets > 1;
[Property, Group( "Diagnostics" )]
public bool EnableDebugLogging { get; set; } = false;
// =========================================================
// WORLD MODEL RECOVERY
// =========================================================
[Property]
public bool EnableWorldModelRecovery { get; set; } = true;
[Property]
public float WorldModelRecoveryDelay { get; set; } = 0.75f;
// =========================================================
// REUSABLE CAPABILITY GATES
// =========================================================
[Property, Group( "Capabilities" ), Title( "Allow Aim" )]
public bool AllowAim { get; set; } = true;
[Property, Group( "Capabilities" ), Title( "Allow Reload" )]
public bool AllowReload { get; set; } = true;
// =========================================================
// AIM DOWN SIGHTS
// =========================================================
[Property, Group( "ADS" )]
[Range( 0f, 1f )]
public float IronSightsFireScale { get; set; } = 0.2f;
[Property, Group( "ADS" )]
[Range( 0.1f, 1f )]
public float AimFovScale { get; set; } = 0.8f;
[Property, Group( "ADS" )]
[Range( 0f, 1f )]
public float AimFovTransitionTime { get; set; } = 0.15f;
/// <summary>
/// Speed multiplier for the authored Facepunch ironsight transition.
/// This drives the documented speed_ironsights AnimGraph parameter and
/// does not move the viewmodel root in code.
/// </summary>
[Property, Group( "ADS" )]
[Range( 0.1f, 4f )]
public float AimAnimationSpeed { get; set; } = 1.0f;
[Sync]
public bool IsAiming { get; set; }
// =========================================================
// CROSSHAIR
// =========================================================
[Property, Group( "Crosshair" )]
[Range( 0.1f, 10f )]
public float CrosshairSpreadVisualScale { get; set; } = 2.5f;
[Property, Group( "Crosshair" )]
public float CrosshairMinimumGap { get; set; } = 4f;
[Property, Group( "Crosshair" )]
public float CrosshairMaximumGap { get; set; } = 70f;
[Property, Group( "Crosshair" )]
public float CrosshairLineLength { get; set; } = 9f;
[Property, Group( "Crosshair" )]
public float CrosshairLineWidth { get; set; } = 2f;
[Property, Group( "Crosshair" )]
public Color CrosshairColor { get; set; } = Color.White;
// =========================================================
// INTERNAL STATE
// =========================================================
private float _timeAlive;
private bool _worldModelRecoveryAttempted;
private float _aimFovBlend;
private GameObject _lastAimViewModel;
private bool _viewModelAimStateDirty = true;
private bool _sprintSuppressesAim;
/// <summary>
/// Host-owned bots are non-proxy just like the host's real player. Never use
/// IsProxy alone to decide whether this weapon should read local human input
/// or create first-person presentation.
/// </summary>
public bool IsBotControlled
{
get
{
PlayerController owner = Owner;
return owner?.Components.Get<PlayerState>()?.IsBot == true;
}
}
private GameObject _ikWorldModel;
private WeaponLeftHandIkTarget _leftHandIkTarget;
// =========================================================
// LIFECYCLE
// =========================================================
protected override void OnStart()
{
// Definition values must be resolved before BaseCombatWeapon initializes
// magazines, models and other engine-owned weapon state.
ApplyWeaponDefinitionIfAssigned();
base.OnStart();
_timeAlive =
0f;
_worldModelRecoveryAttempted =
false;
_aimFovBlend =
0f;
_lastAimViewModel =
null;
_viewModelAimStateDirty =
true;
_sprintSuppressesAim =
false;
ResetAccuracyState();
ResetRecoilState();
ResetCycleState( chambered: true );
ResetCyclePresentationState();
ResetWeaponActionState();
ResetShotValidationState();
_ikWorldModel =
null;
_leftHandIkTarget =
null;
ResetViewModelPresentationState();
InitializePresentationEventPipeline();
// If the inventory made this item active before OnStart ran, OnEquipped
// has already fired. Re-check model presence now that all component
// initialization is complete instead of waiting for a weapon switch.
if ( IsActive )
{
SuppressBotViewModel();
EnsureEquippedPresentationModels();
}
if ( EnableDebugLogging )
{
Log.Info(
$"[OMR WEAPON] Started | Object: {GameObject.Name} | Proxy: {IsProxy}"
);
}
}
protected override void OnUpdate()
{
base.OnUpdate();
// Safety net for host-owned bots. If an engine lifecycle or a future
// weapon change recreates an owner-local viewmodel after equip, remove it
// before any OMR first-person presentation code can update/render it.
SuppressBotViewModel();
UpdateAimTransitionState();
UpdateCycleRuntimeState();
UpdateCyclePresentationState();
UpdateRecoilRuntimeState();
UpdateWorldModelRecovery();
UpdateViewModelPresentationAnimations();
UpdateWeaponModelPresentation();
}
protected override void OnDestroy()
{
// World/view models are local, non-networked presentation clones.
// A replicated inventory item can be destroyed on a remote peer before
// BaseInventoryComponent gets a chance to call OnHolstered() locally.
// In that ordering, BaseCombatWeapon's normal holster cleanup is skipped
// and the world model (parented to the holder bone, not this weapon item)
// would survive as an orphan. Always tear presentation down here as the
// final lifecycle safety net.
_worldModelRecoveryAttempted = true;
_timeAlive = 0f;
ClearBodyIk();
ResetWeaponModelPresentationState();
DestroyWorldModel();
DestroyViewModel();
base.OnDestroy();
}
protected override void OnDisabled()
{
_worldModelRecoveryAttempted = true;
_timeAlive = 0f;
ClearBodyIk();
ResetAimState();
ResetAccuracyState();
ResetRecoilState();
ResetCycleState( chambered: true );
ResetCyclePresentationState();
ResetWeaponActionState();
ResetViewModelPresentationState();
ResetWeaponModelPresentationState();
CancelReloadAudio();
CancelCycleAudio();
base.OnDisabled();
}
protected override void OnHolstered()
{
// BaseCombatWeapon destroys the local view/world models on holster.
// Prevent our delayed recovery path from resurrecting the world model
// while this inactive inventory item is still parented under the player.
_worldModelRecoveryAttempted = true;
_timeAlive = 0f;
ClearBodyIk();
ResetAimState();
ResetAccuracyState();
ResetRecoilState();
// Holstering resolves an unfinished manual cycle as ready. This avoids
// returning to a permanently unchambered weapon after inventory switching.
ResetCycleState( chambered: true );
ResetCyclePresentationState();
ResetWeaponActionState();
ResetViewModelPresentationState();
ResetWeaponModelPresentationState();
CancelCycleAudio();
base.OnHolstered();
RaisePresentationEvent( WeaponPresentationEventKind.Holstered );
}
// =========================================================
// THIRD-PERSON BODY IK
// =========================================================
/// <summary>
/// Extends BaseCombatWeapon's normal holder animation update.
///
/// The base implementation still owns HoldType/Handedness. We only
/// add an optional left-hand IK target supplied by the weapon's
/// world-model prefab. Because UpdateBodyAnimations runs on every
/// peer while the weapon is deployed, each peer drives the hand
/// against its own local WorldModel clone.
/// </summary>
protected override void UpdateBodyAnimations(
SkinnedModelRenderer body
)
{
base.UpdateBodyAnimations(
body
);
if ( body is null )
return;
ResolveBodyIkTarget();
if (
_leftHandIkTarget is null ||
_leftHandIkTarget.GameObject is null ||
!_leftHandIkTarget.GameObject.IsValid
)
{
body.ClearIk(
"hand_left"
);
return;
}
GameObject target =
_leftHandIkTarget.GameObject;
body.SetIk(
"hand_left",
new Transform(
target.WorldPosition,
target.WorldRotation
)
);
}
private void ResolveBodyIkTarget()
{
GameObject currentWorldModel =
WorldModel;
if (
currentWorldModel ==
_ikWorldModel
)
return;
_ikWorldModel =
currentWorldModel;
_leftHandIkTarget =
null;
if ( currentWorldModel is null )
return;
_leftHandIkTarget =
currentWorldModel
.Components
.Get<WeaponLeftHandIkTarget>(
FindMode.InSelf |
FindMode.InDescendants
);
}
private void ClearBodyIk()
{
HolderRenderer?.ClearIk(
"hand_left"
);
_ikWorldModel =
null;
_leftHandIkTarget =
null;
}
// =========================================================
// WEAPON CONTROL
// =========================================================
protected override void OnControl()
{
UpdateAccuracyState();
// Bot combat will call the same authored weapon actions explicitly in a
// later phase. Until then, and permanently for raw-input sampling, never
// let a host-owned bot weapon mirror the host's mouse/buttons.
if ( IsBotControlled )
{
return;
}
UpdateTemporaryPrimaryGate();
//
// This must happen BEFORE BaseCombatWeapon handles attack1 so the first
// sprint shot can be buffered while the authored sprint-exit animation
// raises the weapon.
//
UpdateSprintToFireGate();
base.OnControl();
if (
Input.Pressed(
AimAction
)
)
{
_sprintSuppressesAim =
false;
}
//
// ADS has its own input action and therefore no longer
// touches attack2 / secondary fire. While reloading we
// deliberately keep ADS out of the AnimGraph; holding aim
// queues it naturally and ADS begins as soon as the reload
// has actually finished.
//
bool wantsAim =
AllowAim &&
RoundAllowsAim &&
Input.Down(
AimAction
) &&
!_sprintSuppressesAim &&
!IsReloading &&
CanAimDuringCurrentCycle();
if (
wantsAim !=
IsAiming
)
{
SetAiming(
wantsAim
);
}
EnsureViewModelAimState();
}
// =========================================================
// CROSSHAIR
// =========================================================
public override void DrawHud(
HudPainter painter,
Vector2 crosshair
)
{
if ( IsAiming && HideCrosshairWhileAiming )
return;
base.DrawHud(
painter,
crosshair
);
}
public override void DrawCrosshair(
HudPainter painter,
Vector2 center
)
{
Vector2 gap =
CalculateCrosshairGap(
CurrentSpread
);
float horizontalGap =
gap.x;
float verticalGap =
gap.y;
float length =
MathF.Max(
CrosshairLineLength,
0f
);
float width =
MathF.Max(
CrosshairLineWidth,
0.5f
);
painter.DrawLine(
center +
Vector2.Left *
(
horizontalGap +
length
),
center +
Vector2.Left *
horizontalGap,
width,
CrosshairColor
);
painter.DrawLine(
center +
Vector2.Right *
horizontalGap,
center +
Vector2.Right *
(
horizontalGap +
length
),
width,
CrosshairColor
);
painter.DrawLine(
center +
Vector2.Up *
(
verticalGap +
length
),
center +
Vector2.Up *
verticalGap,
width,
CrosshairColor
);
painter.DrawLine(
center +
Vector2.Down *
verticalGap,
center +
Vector2.Down *
(
verticalGap +
length
),
width,
CrosshairColor
);
}
private Vector2 CalculateCrosshairGap(
Vector2 spread
)
{
CameraComponent camera =
Scene.Camera;
if (
camera is null ||
Screen.Width <= 1f ||
Screen.Height <= 1f
)
{
return new Vector2(
CrosshairMinimumGap,
CrosshairMinimumGap
);
}
float fov =
camera.View.FieldOfView;
if ( fov <= 1f )
{
fov =
camera.FieldOfView;
}
fov =
MathX.Clamp(
fov,
1f,
179f
);
float aspect =
Screen.Aspect;
if ( aspect <= 0.01f )
{
aspect =
Screen.Width /
Screen.Height;
}
float horizontalFovRadians;
float verticalFovRadians;
float sourceFovRadians =
DegreesToRadians(
fov
);
if (
camera.FovAxis ==
CameraComponent.Axis.Horizontal
)
{
horizontalFovRadians =
sourceFovRadians;
verticalFovRadians =
2f *
MathF.Atan(
MathF.Tan(
horizontalFovRadians *
0.5f
) /
aspect
);
}
else
{
verticalFovRadians =
sourceFovRadians;
horizontalFovRadians =
2f *
MathF.Atan(
MathF.Tan(
verticalFovRadians *
0.5f
) *
aspect
);
}
float spreadXRadians =
DegreesToRadians(
MathF.Abs(
spread.x
)
);
float spreadYRadians =
DegreesToRadians(
MathF.Abs(
spread.y
)
);
float horizontalProjection =
MathF.Tan(
horizontalFovRadians *
0.5f
);
float verticalProjection =
MathF.Tan(
verticalFovRadians *
0.5f
);
if (
horizontalProjection <= 0.0001f ||
verticalProjection <= 0.0001f
)
{
return new Vector2(
CrosshairMinimumGap,
CrosshairMinimumGap
);
}
float gapX =
MathF.Tan(
spreadXRadians
) /
horizontalProjection *
(
Screen.Width *
0.5f
);
float gapY =
MathF.Tan(
spreadYRadians
) /
verticalProjection *
(
Screen.Height *
0.5f
);
gapX *=
CrosshairSpreadVisualScale;
gapY *=
CrosshairSpreadVisualScale;
gapX =
MathX.Clamp(
gapX,
CrosshairMinimumGap,
CrosshairMaximumGap
);
gapY =
MathX.Clamp(
gapY,
CrosshairMinimumGap,
CrosshairMaximumGap
);
return new Vector2(
gapX,
gapY
);
}
private static float DegreesToRadians(
float degrees
)
{
return degrees *
(
MathF.PI /
180f
);
}
// =========================================================
// ROUND RESET
// =========================================================
/// <summary>
/// Clears owner-local transient weapon state when a new round
/// is prepared.
///
/// BaseCombatWeapon owns reload cancellation and ammo on the
/// host. This method only resets One More Round presentation
/// and accuracy state without trying to call the protected
/// CreateViewModel/DestroyViewModel methods from another
/// component.
/// </summary>
public void ResetForRoundPresentation()
{
_sprintSuppressesAim =
false;
ResetAimState();
ResetAccuracyState();
ResetRecoilState();
ResetCycleState( chambered: true );
ResetWeaponActionState();
ResetShotValidationState();
ResetViewModelPresentationState();
}
// =========================================================
// ADS / SPRINT NEGOTIATION
// =========================================================
public void SuppressAimForSprint()
{
if ( _sprintSuppressesAim )
return;
_sprintSuppressesAim =
true;
if ( IsAiming )
{
SetAiming(
false
);
}
}
public void ReleaseAimSuppressionForSprint()
{
_sprintSuppressesAim =
false;
}
// =========================================================
// ADS STATE
// =========================================================
private void SetAiming(
bool aiming
)
{
if ( aiming && !AllowAim )
aiming = false;
if (
IsAiming ==
aiming
)
return;
IsAiming =
aiming;
_viewModelAimStateDirty =
true;
if ( EnableDebugLogging )
{
Log.Info(
$"[OMR ADS] {(IsAiming ? "ON" : "OFF")} | Weapon: {GameObject.Name}"
);
}
EnsureViewModelAimState();
}
private void ResetAimState()
{
IsAiming =
false;
_aimFovBlend =
0f;
_sprintSuppressesAim =
false;
_viewModelAimStateDirty =
true;
EnsureViewModelAimState();
_lastAimViewModel =
null;
}
// =========================================================
// VIEW MODEL ADS
// =========================================================
private void EnsureViewModelAimState()
{
GameObject currentViewModel =
ViewModel;
if (
currentViewModel is null
)
{
_lastAimViewModel =
null;
_viewModelAimStateDirty =
true;
return;
}
if (
currentViewModel !=
_lastAimViewModel
)
{
_lastAimViewModel =
currentViewModel;
_viewModelAimStateDirty =
true;
}
if (
!_viewModelAimStateDirty
)
return;
BaseWeaponModel weaponModel =
currentViewModel
.Components
.Get<BaseWeaponModel>();
SkinnedModelRenderer renderer =
weaponModel?.
Renderer;
renderer ??=
currentViewModel
.Components
.Get<SkinnedModelRenderer>();
if (
renderer is null
)
return;
renderer.Set(
"ironsights",
IsAiming ? 1 : 0
);
renderer.Set(
"ironsights_fire_scale",
GetEffectiveViewModelFireScale()
);
renderer.Set(
"speed_ironsights",
MathF.Max( AimAnimationSpeed, 0.1f )
);
_viewModelAimStateDirty =
false;
}
// =========================================================
// ADS CAMERA
// =========================================================
protected override void ModifyCamera(
CameraComponent camera,
ref CameraView view
)
{
// Only the weapon held by the actual local human may modify the local
// gameplay camera. Host-owned bots are non-proxy on the host, so an
// IsProxy-only check is insufficient: without this gate, ROOK's ADS state
// multiplies the host player's FOV whenever the bot aims.
PlayerState ownerState =
Owner?.Components.Get<PlayerState>();
if ( ownerState?.IsLocalHuman != true )
return;
float clampedAimFovScale =
MathX.Clamp(
AimFovScale,
0.1f,
1f
);
float finalFovScale =
MathX.Lerp(
1f,
clampedAimFovScale,
_aimFovBlend
);
// Weapon ADS projection belongs only to the real gameplay camera. Secondary
// cameras (viewmodel, PiP optics, mirrors, future RT cameras) own their own
// projection and must not inherit this modifier accidentally.
//
// PiP scopes keep their optical magnification in OMRScopePiPCamera. The
// gameplay camera may optionally receive only a separate, presentation-only
// peripheral focus scale from the optic profile.
bool isGameplayCamera = camera == Scene?.Camera;
if ( isGameplayCamera )
{
if ( UsesPictureInPictureScope )
{
// PiP optics own their real magnification in OMRScopePiPCamera, but
// may optionally apply a small presentation-only focus zoom to the
// surrounding world. Keep this separate from AimFovScale so the
// authored optical magnification remains independent.
view.FieldOfView *= CurrentPeripheralAimFovScale;
}
else
{
view.FieldOfView *= finalFovScale;
}
view.FieldOfView = MathX.Clamp(
view.FieldOfView,
1f,
179f
);
}
base.ModifyCamera(
camera,
ref view
);
}
// =========================================================
// WORLD MODEL RECOVERY
// =========================================================
private void UpdateWorldModelRecovery()
{
if (
!EnableWorldModelRecovery
)
return;
// World models belong only to the deployed weapon. BaseCombatWeapon
// destroys them in OnHolstered(); IsHeld alone is not sufficient here
// because inactive inventory items can remain parented to the player.
if ( !IsActive || !IsHeld )
return;
if (
_worldModelRecoveryAttempted
)
return;
_timeAlive +=
Time.Delta;
if (
_timeAlive <
WorldModelRecoveryDelay
)
return;
_worldModelRecoveryAttempted =
true;
TryRecoverWorldModel();
}
private void TryRecoverWorldModel()
{
if (
WorldModel is not null
)
return;
if ( !IsActive || !IsHeld )
return;
if (
WorldModelPrefab is null
)
{
Log.Warning(
$"[OMR WEAPON] Recovery failed | {GameObject.Name} | WorldModelPrefab NULL"
);
return;
}
if (
HolderRenderer is null
)
{
Log.Warning(
$"[OMR WEAPON] Recovery failed | {GameObject.Name} | HolderRenderer NULL"
);
return;
}
if (
string.IsNullOrWhiteSpace(
HoldBone
)
)
{
Log.Warning(
$"[OMR WEAPON] Recovery failed | {GameObject.Name} | HoldBone empty"
);
return;
}
bool hasBone =
HolderRenderer.TryGetBoneTransform(
HoldBone,
out _
);
if (
!hasBone
)
{
Log.Warning(
$"[OMR WEAPON] Recovery failed | {GameObject.Name} | Hold bone '{HoldBone}' unavailable"
);
return;
}
if ( EnableDebugLogging )
{
Log.Info(
$"[OMR WEAPON] Recovering WorldModel | {GameObject.Name} | Proxy: {IsProxy}"
);
}
CreateWorldModel();
if (
WorldModel is not null
)
{
if ( EnableDebugLogging )
{
Log.Info(
$"[OMR WEAPON] Recovery SUCCESS | WorldModel: {WorldModel.Name}"
);
}
}
else
{
Log.Warning(
"[OMR WEAPON] Recovery attempt completed but WorldModel is still NULL"
);
}
}
}