PaintballCombatant.cs
using Sandbox;

namespace PaintballBaddies;

public sealed class PaintballCombatant : Component
{
    protected override void OnStart()
    {
        GameObject.Tags.Add("paintball_actor");
        if(Components.Get<CharacterVoice>() is null)Components.Create<CharacterVoice>();
        if(Components.Get<CloseCombat>() is null)Components.Create<CloseCombat>();
    }
    [Sync(SyncFlags.FromHost)] public int Team { get; set; }
    [Sync(SyncFlags.FromHost)] public int PaintHits { get; private set; }
    [Sync(SyncFlags.FromHost)] public int HitsLanded { get; private set; }
    [Sync(SyncFlags.FromHost)] public bool StayInMatch { get; set; }
    [Sync(SyncFlags.FromHost)] public bool AcceptHits { get; set; } = true;
    public Vector3 EyePosition => Components.Get<PlayerController>()?.EyePosition
        ?? WorldPosition+Vector3.Up*(Components.Get<ArenaOpponent>()?.CoverCrouched == true ? 35 : 60);
    public int HitLimit { get; set; } = 3;
    public bool Eliminated => !StayInMatch && PaintHits >= HitLimit;
    public float HitFeedbackRemaining { get; private set; }
    [Sync(SyncFlags.FromHost)] public Vector3 LastHitDirection { get; private set; }
    public Vector3 LastHitOrigin { get; private set; }
    public PaintballCombatant LastAttacker { get; private set; }
    private int observedHits;
    protected override void OnUpdate()
    {
        if(!MultiplayerSession.Authority && PaintHits>observedHits)HitFeedbackRemaining=.45f;
        observedHits=PaintHits;
        HitFeedbackRemaining=System.MathF.Max(0,HitFeedbackRemaining-Time.Delta);
        if(Components.Get<CharacterVoice>() is null)Components.Create<CharacterVoice>();
        if(Components.Get<CloseCombat>() is null)Components.Create<CloseCombat>();
    }
    public bool RegisterHit( int attackingTeam, Vector3? sourcePosition = null, PaintballCombatant attacker = null, Vector3? observedOrigin = null )
    {
        if(!MultiplayerSession.Authority)return false;
        if ( !AcceptHits || attackingTeam == Team || Eliminated ) return false;
        PaintHits++;
        LastAttacker=attacker;
        LastHitOrigin=observedOrigin ?? sourcePosition ?? (attacker.IsValid() ? attacker.WorldPosition : WorldPosition);
        if(attacker.IsValid() && attacker!=this)
        {
            attacker.HitsLanded++;
            if(MultiplayerSession.Online)attacker.Components.Get<NetworkPawn>()?.ConfirmHit();
        }
        LastHitDirection = sourcePosition.HasValue ? (sourcePosition.Value-WorldPosition).WithZ(0).Normal : Vector3.Zero;
        HitFeedbackRemaining = 0.45f;
        return true;
    }
    public string HitDirectionLabel(Rotation view)
    {
        if(HitFeedbackRemaining<=0 || LastHitDirection.Length<.01f)return "";
        var forward=Vector3.Dot(LastHitDirection,view.Forward.WithZ(0).Normal);
        var right=Vector3.Dot(LastHitDirection,view.Right.WithZ(0).Normal);
        return System.MathF.Abs(right)>System.MathF.Abs(forward) ? (right>0 ? "RIGHT" : "LEFT") : (forward>0 ? "AHEAD" : "BEHIND");
    }
    public void ResetPaintHits()
    {
        if(!MultiplayerSession.Authority)return;
        Components.Get<CloseCombat>()?.ResetState();
        Components.Get<CharacterPaint>()?.Clear();
        PaintHits = 0;
        HitsLanded = 0;
        AcceptHits = true;
        HitFeedbackRemaining = 0;
        LastHitDirection = Vector3.Zero;
    }
    public static PaintballCombatant Find( GameObject go )
    {
        while ( go is not null )
        {
            var actor = go.Components.Get<PaintballCombatant>();
            if ( actor is not null ) return actor;
            go = go.Parent;
        }
        return null;
    }
}