PlayerHealth.cs
using Sandbox;
using System;
using System.Linq;
public sealed class PlayerHealth : Component, Component.IDamageable
{
private OMRCombatTuning Tuning =>
OMRCombatResources.DefaultTuning;
/// <summary>
/// The host snapshots the configured maximum health whenever the player is
/// reset for a round/respawn. Keeping this synchronized means HUD and timeout
/// rules agree on the same value even if the tuning asset changes later.
/// </summary>
[Sync( SyncFlags.FromHost )]
public float MaxHealth { get; private set; } = 180f;
[Sync( SyncFlags.FromHost )]
public float Health { get; private set; } = 180f;
/// <summary>
/// During the special full-health sudden death phase,
/// any hit caused by a BaseCombatWeapon is lethal.
/// </summary>
[Sync( SyncFlags.FromHost )]
public bool WeaponOneShotMode { get; private set; }
/// <summary>
/// Modular round rule: ordinary weapon hits outside the resolved head
/// hitgroup are ignored. Non-weapon damage paths are left untouched.
/// </summary>
[Sync( SyncFlags.FromHost )]
public bool WeaponHeadshotOnlyMode { get; private set; }
// Victim-side combat feedback intentionally rides on PlayerHealth's proven
// host-authoritative sync path. Health/MaxHealth already replicate correctly
// to the owning client in OMR, so keeping the transient payload here avoids
// depending on RPC routing for a client-owned player object. The sequence is
// the event edge; the remaining properties are the latest payload.
[Sync( SyncFlags.FromHost )]
public float DamageFeedbackAmount { get; private set; }
[Sync( SyncFlags.FromHost )]
public bool DamageFeedbackKilled { get; private set; }
[Sync( SyncFlags.FromHost )]
public Vector3 DamageFeedbackSourcePosition { get; private set; }
[Sync( SyncFlags.FromHost )]
public bool DamageFeedbackHasDirectionalSource { get; private set; }
[Sync( SyncFlags.FromHost ), Change( nameof(OnDamageFeedbackSequenceChanged) )]
public int DamageFeedbackSequence { get; private set; }
private int _pendingDamageFeedbackSequence = -1;
private int _lastConsumedDamageFeedbackSequence;
public bool IsDead =>
Health <= 0f;
protected override void OnStart()
{
_lastConsumedDamageFeedbackSequence =
DamageFeedbackSequence;
_pendingDamageFeedbackSequence =
-1;
// The Citizen model contains authored model hitboxes, but a
// SkinnedModelRenderer does not make them traceable by itself in the
// Scene system. Every peer that can resolve a client-side weapon trace
// needs a ModelHitboxes component bound to the animated body renderer.
// Keep the trace target on the player root so BaseCombatWeapon damage
// resolves to this PlayerHealth/IDamageable rather than the visual Body
// child.
EnsureCombatHitboxes();
if ( !Networking.IsHost )
return;
ResetHealth();
}
/// <summary>
/// Materializes the hitboxes authored in citizen.vmdl into scene hitboxes
/// that SceneTrace.UseHitboxes() can actually hit. Movement capsule/box
/// collision stays separate and continues to be filtered from firearm traces
/// through the "playercontroller" tag.
/// </summary>
private void EnsureCombatHitboxes()
{
PlayerController controller =
Components.Get<PlayerController>();
SkinnedModelRenderer renderer =
controller?.Renderer;
if (
renderer is null ||
!renderer.IsValid ||
renderer.Model is null ||
!renderer.Model.IsValid
)
{
Log.Warning(
"[OMR HITBOX] Player body renderer/model is unavailable; combat hitboxes could not be built."
);
return;
}
int authoredHitboxCount =
renderer.Model.HitboxSet?.All?.Count ?? 0;
if ( authoredHitboxCount <= 0 )
{
Log.Warning(
$"[OMR HITBOX] Model '{renderer.Model.Name}' exposes no authored hitboxes; firearm traces would pass through once movement colliders are filtered."
);
return;
}
ModelHitboxes hitboxes =
Components.GetOrCreate<ModelHitboxes>();
hitboxes.Renderer =
renderer;
hitboxes.Target =
GameObject;
hitboxes.Rebuild();
}
protected override void OnUpdate()
{
if ( !Networking.IsHost )
{
// [Change] is the normal edge trigger. Polling the same sequence here is
// an intentional safety net: even if a hotload/codegen edge ever misses
// the callback, the replicated value itself still drives presentation.
if (
!IsProxy &&
DamageFeedbackSequence != _lastConsumedDamageFeedbackSequence &&
_pendingDamageFeedbackSequence < 0
)
{
_pendingDamageFeedbackSequence =
DamageFeedbackSequence;
}
ConsumePendingDamageFeedback();
return;
}
// Gameplay tuning lives in a resource specifically so it can be changed
// during development without hot-reloading the network player prefab.
// Preserve health percentage if MaxHealth is edited while a player exists.
float configuredMaxHealth =
MathF.Max(
Tuning?.MaxHealth ?? 180f,
1f
);
if ( MathF.Abs( configuredMaxHealth - MaxHealth ) < 0.01f )
return;
float healthFraction =
MaxHealth > 0f
? MathX.Clamp( Health / MaxHealth, 0f, 1f )
: 1f;
MaxHealth =
configuredMaxHealth;
if ( !IsDead )
{
Health =
MaxHealth * healthFraction;
}
}
public void ResetHealth()
{
if ( !Networking.IsHost )
return;
MaxHealth =
MathF.Max(
Tuning?.MaxHealth ?? 180f,
1f
);
Health =
MaxHealth;
WeaponOneShotMode =
false;
WeaponHeadshotOnlyMode =
false;
DamageFeedbackAmount = 0f;
DamageFeedbackKilled = false;
DamageFeedbackSourcePosition = Vector3.Zero;
DamageFeedbackHasDirectionalSource = false;
PlayerState state =
Components.Get<PlayerState>();
if ( state is not null )
{
state.IsAlive =
true;
}
}
/// <summary>
/// Host-authoritative elimination used by round rules whose winner is
/// resolved outside ordinary damage, such as High Noon reaction timing.
/// </summary>
public void EliminateForRoundRule()
{
if (
!Networking.IsHost ||
IsDead
)
return;
Health = 0f;
PlayerState state =
Components.Get<PlayerState>();
if ( state is not null )
{
state.IsAlive = false;
}
}
/// <summary>
/// Enables or disables the weapon-only sudden death rule.
/// </summary>
public void SetWeaponOneShotMode(
bool enabled
)
{
if ( !Networking.IsHost )
return;
WeaponOneShotMode =
enabled;
}
/// <summary>
/// Enables/disables the modular Headshot Only damage gate for this player.
/// </summary>
public void SetWeaponHeadshotOnlyMode(
bool enabled
)
{
if ( !Networking.IsHost )
return;
WeaponHeadshotOnlyMode =
enabled;
}
void Component.IDamageable.OnDamage(
in DamageInfo damage
)
{
if ( !Networking.IsHost )
return;
if ( IsDead )
return;
GameFlowManager flow =
Scene?
.GetAllComponents<GameFlowManager>()
.FirstOrDefault();
if (
flow is not null &&
flow.OnlineState ==
OnlineFlowState.Lobby
)
return;
float appliedDamage =
damage.Damage;
bool isWeaponHit =
damage.Weapon is not null &&
damage.Weapon.Components
.Get<BaseCombatWeapon>() is not null;
// Prefer the engine-provided hitgroup tag. Client shot claims intentionally
// carry hitgroups as damage tags (for example "head") rather than a Hitbox
// object. Normal OMR firearm traces now exclude the PlayerController movement
// colliders and resolve against model hitboxes; the height fallback remains a
// conservative compatibility path for any other weapon/damage path that does
// not provide a hitgroup tag.
bool taggedHeadshot;
bool heightFallbackHeadshot;
float normalizedHitHeight;
bool isHeadshot =
ResolveHeadshot(
damage,
isWeaponHit,
out taggedHeadshot,
out heightFallbackHeadshot,
out normalizedHitHeight
);
if (
WeaponHeadshotOnlyMode &&
isWeaponHit &&
!isHeadshot
)
{
if ( Tuning?.LogDamageResolution == true )
{
Log.Info(
$"[OMR DAMAGE] {GameObject.Name} | HEADSHOT ONLY IGNORED | Tags {damage.Tags} | Origin {damage.Origin} | Hit {damage.Position}"
);
}
return;
}
if ( isHeadshot )
{
appliedDamage *=
MathF.Max(
Tuning?.HeadshotDamageMultiplier ?? 1.5f,
1f
);
}
if (
WeaponOneShotMode &&
isWeaponHit
)
{
appliedDamage =
MathF.Max(
MaxHealth,
Health
);
}
Health =
MathF.Max(
0f,
Health - appliedDamage
);
bool killed =
Health <= 0f;
NotifyCombatFeedback(
damage,
appliedDamage,
killed,
isHeadshot
);
if ( Tuning?.LogDamageResolution == true )
{
Log.Info(
$"[OMR DAMAGE] {GameObject.Name} | Damage {appliedDamage:0.##} | Health {Health:0.##}/{MaxHealth:0.##} | Weapon {isWeaponHit} | Head {isHeadshot} | TaggedHead {taggedHeadshot} | HeightHead {heightFallbackHeadshot} | HitHeight {normalizedHitHeight:0.###} | Tags {damage.Tags} | Origin {damage.Origin} | Hit {damage.Position}" +
(WeaponOneShotMode && isWeaponHit ? " | SUDDEN DEATH" : "")
);
}
if ( killed )
{
Die(
damage
);
}
}
/// <summary>
/// Resolves headshots without making OMR dependent on one specific trace
/// implementation.
///
/// The engine's preferred path is the "head" damage tag generated from a
/// model hitgroup. OMR firearm traces are configured to use those model
/// hitboxes instead of locomotion colliders. A conservative vertical fallback
/// remains for compatibility with non-standard damage paths that omit hitgroup
/// tags.
/// </summary>
private bool ResolveHeadshot(
in DamageInfo damage,
bool isWeaponHit,
out bool taggedHeadshot,
out bool heightFallbackHeadshot,
out float normalizedHitHeight
)
{
taggedHeadshot =
isWeaponHit &&
damage.Tags is not null &&
damage.Tags.Contains( "head" );
heightFallbackHeadshot = false;
normalizedHitHeight = -1f;
if ( !isWeaponHit )
return false;
if ( taggedHeadshot )
return true;
if ( Tuning?.EnableHeadshotHeightFallback == false )
return false;
PlayerController controller =
Components.Get<PlayerController>();
if ( controller is null )
return false;
// Use the same standing/ducking dimensions already configured on our
// PlayerController instead of depending on a newer convenience property.
// This keeps the fallback compatible with the engine API version this
// project is actually compiling against.
float currentHeight =
MathF.Max(
controller.IsDucking
? controller.DuckedHeight
: controller.BodyHeight,
1f
);
float verticalHitOffset =
damage.Position.z -
controller.GameObject.WorldPosition.z;
normalizedHitHeight =
verticalHitOffset / currentHeight;
float threshold =
MathX.Clamp(
Tuning?.HeadshotHeightFraction ?? 0.78f,
0.60f,
0.95f
);
// Ignore obviously invalid positions. The small allowance above 1.0 keeps
// the fallback tolerant of hit-radius/contact-point differences at the top
// of the controller without letting arbitrary world damage become a headshot.
heightFallbackHeadshot =
normalizedHitHeight >= threshold &&
normalizedHitHeight <= 1.20f;
return heightFallbackHeadshot;
}
private void PublishVictimDamageFeedback(
PlayerCombatFeedback feedback,
float damage,
bool killed,
Vector3 sourcePosition,
bool hasDirectionalSource
)
{
if ( !Networking.IsHost )
return;
DamageFeedbackAmount =
MathF.Max( 0f, damage );
DamageFeedbackKilled =
killed;
DamageFeedbackSourcePosition =
sourcePosition;
DamageFeedbackHasDirectionalSource =
hasDirectionalSource;
// Sequence is deliberately written last so the receiver can treat its
// change as the commit marker for the payload above. The client defers
// consumption until OnUpdate, after the network update has completed.
DamageFeedbackSequence =
DamageFeedbackSequence == int.MaxValue
? 1
: DamageFeedbackSequence + 1;
Connection owner =
GameObject.Network.OwnerConnection;
if ( Tuning?.LogCombatFeedbackRpc == true )
{
Log.Info(
$"[OMR FEEDBACK] Publish health-sync victim feedback | Player {GameObject.Name} | Owner {(owner is null ? "<host/unowned>" : owner.DisplayName)} | Sequence {DamageFeedbackSequence} | Damage {DamageFeedbackAmount:0.##} | Kill {killed} | Directional {hasDirectionalSource}"
);
}
// The host-owned player needs immediate local presentation. A remote
// client's player is a proxy on the host, so it waits for replication.
if (
!IsProxy &&
Components.Get<PlayerState>()?.IsBot != true
)
{
feedback?.ReceiveLocalDamageFeedback(
DamageFeedbackAmount,
DamageFeedbackKilled,
DamageFeedbackSourcePosition,
DamageFeedbackHasDirectionalSource
);
_lastConsumedDamageFeedbackSequence =
DamageFeedbackSequence;
}
}
private void OnDamageFeedbackSequenceChanged(
int oldValue,
int newValue
)
{
if ( Networking.IsHost )
return;
if ( IsProxy )
return;
if ( newValue == oldValue )
return;
_pendingDamageFeedbackSequence =
newValue;
if ( Tuning?.LogCombatFeedbackRpc == true )
{
Log.Info(
$"[OMR FEEDBACK] Observe health-sync victim feedback | Player {GameObject.Name} | Sequence {newValue} | Damage {DamageFeedbackAmount:0.##} | Kill {DamageFeedbackKilled} | Directional {DamageFeedbackHasDirectionalSource}"
);
}
}
private void ConsumePendingDamageFeedback()
{
if ( IsProxy )
return;
if ( _pendingDamageFeedbackSequence < 0 )
return;
if (
_pendingDamageFeedbackSequence ==
_lastConsumedDamageFeedbackSequence
)
{
_pendingDamageFeedbackSequence = -1;
return;
}
// Initial snapshots or round resets may carry an old sequence with an
// intentionally cleared payload. Never turn that into a minimum-strength
// damage cue.
if ( DamageFeedbackAmount <= 0f )
{
_lastConsumedDamageFeedbackSequence =
_pendingDamageFeedbackSequence;
_pendingDamageFeedbackSequence = -1;
return;
}
PlayerCombatFeedback feedback =
Components.Get<PlayerCombatFeedback>();
if ( feedback is null )
{
if ( Tuning?.LogCombatFeedbackRpc == true )
{
Log.Warning(
"[OMR FEEDBACK] Health-sync event reached the local player but PlayerCombatFeedback was missing."
);
}
return;
}
feedback.ReceiveLocalDamageFeedback(
DamageFeedbackAmount,
DamageFeedbackKilled,
DamageFeedbackSourcePosition,
DamageFeedbackHasDirectionalSource
);
_lastConsumedDamageFeedbackSequence =
_pendingDamageFeedbackSequence;
_pendingDamageFeedbackSequence =
-1;
if ( Tuning?.LogCombatFeedbackRpc == true )
{
Log.Info(
$"[OMR FEEDBACK] Consume health-sync victim feedback | Player {GameObject.Name} | Sequence {_lastConsumedDamageFeedbackSequence} | Damage {DamageFeedbackAmount:0.##} | Kill {DamageFeedbackKilled} | Directional {DamageFeedbackHasDirectionalSource}"
);
}
}
private void NotifyCombatFeedback(
in DamageInfo damage,
float appliedDamage,
bool killed,
bool headshot
)
{
PlayerCombatFeedback victimFeedback =
Components.Get<PlayerCombatFeedback>();
PublishVictimDamageFeedback(
victimFeedback,
appliedDamage,
killed,
damage.Origin,
damage.Attacker is not null
);
if ( damage.Attacker is null )
return;
PlayerCombatFeedback attackerFeedback =
damage.Attacker.Components
.Get<PlayerCombatFeedback>(
FindMode.InSelf |
FindMode.InDescendants
);
attackerFeedback ??=
damage.Attacker.Components
.Get<PlayerCombatFeedback>(
FindMode.InAncestors
);
if ( attackerFeedback is null )
return;
if ( attackerFeedback == victimFeedback )
return;
attackerFeedback.NotifyHitConfirmed(
appliedDamage,
killed,
headshot
);
}
private void Die(
in DamageInfo damage
)
{
PlayerState state =
Components.Get<PlayerState>();
if ( state is not null )
{
state.IsAlive =
false;
}
}
}