ArenaMatch.cs
using Sandbox;
using System;

namespace PaintballBaddies;

/// <summary>Elimination round against the five other roster characters.</summary>
public sealed class ArenaMatch : Component
{
    internal static float? RoundAfterReload;
    private float startupTime;
    [Property] public bool TimedContest { get; set; } = true;
    [Property] public float RoundDuration { get; set; } = 180;
    public sealed record Standing(string Name,int Landed,int Received,bool IsPlayer)
    {
        public int Points => Landed-Received;
    }
    private MultiplayerSession OnlineSession=>MultiplayerSession.Online ? MultiplayerSession.Find(Scene) : null;
    public Standing[] Standings => OnlineSession.IsValid() ? OnlineSession.Standings : new[]{new Standing(Components.Get<PlayerProfile>()?.DisplayName ?? "Player",PlayerState?.HitsLanded ?? 0,PlayerState?.PaintHits ?? 0,true)}
        .Concat(opponents.Where(x=>x.IsValid()).Select(x=>new Standing(RosterSelection.Names[x.Components.Get<ArenaOpponent>().CharacterIndex],
            x.Components.Get<PaintballCombatant>()?.HitsLanded ?? 0,x.Components.Get<PaintballCombatant>()?.PaintHits ?? 0,false)))
        .OrderByDescending(x=>x.Points).ThenBy(x=>x.Received).ThenBy(x=>x.Name).ToArray();
    public int RankOf(Standing row) => 1+Standings.Count(x=>x.Points>row.Points || (x.Points==row.Points && x.Received<row.Received));
    public int StandingsHash => string.Join("|",Standings.Select(x=>$"{x.Name}:{x.Landed}:{x.Received}")).GetHashCode();
    public bool SetRoundMinutes(int minutes)
    {
        if(minutes<1 || minutes>3)return false;
        if(OnlineSession.IsValid()){OnlineSession.SetMinutes(minutes);return true;}
        RoundDuration=minutes*60;return true;
    }
    private bool inRound;
    public bool InRound { get=>OnlineSession.IsValid() ? OnlineSession.InRound : inRound; private set=>inRound=value; }
    private string status="TRAINING";
    public string Status { get=>OnlineSession.IsValid() ? OnlineSession.Status : status; private set=>status=value; }
    private float remaining;
    public float Remaining { get=>OnlineSession.IsValid() ? OnlineSession.Remaining : remaining; private set=>remaining=value; }
    public int OpponentsRemaining => InRound ? opponents.Count( x => x.IsValid() && x.Components.Get<PaintballCombatant>()?.Eliminated != true ) : finalOpponentsRemaining;
    public int OpponentsEliminated => Math.Max( 0, opponents.Count - OpponentsRemaining );
    public int OpponentCount => OnlineSession.IsValid() ? Math.Max(0,OnlineSession.Standings.Length-1) : opponents.Count;
    private int finalOpponentsRemaining;
    public PaintballCombatant PlayerState { get; private set; }
    private readonly List<GameObject> opponents = new();
    private PlayerController player;
    public string EliminationNotice { get; private set; } = "";
    private float noticeRemaining;
    private readonly HashSet<GameObject> announcedEliminations = new();

    protected override void OnStart()
    {
        player = Components.Get<PlayerController>();
        PlayerState = Components.Get<PaintballCombatant>() ?? Components.Create<PaintballCombatant>();
        if(OnlineSession.IsValid())return;
        PlayerState.Team = 0;
        PlayerState.HitLimit = 5;
        PlayerState.StayInMatch = TimedContest;
    }
    protected override void OnUpdate()
    {
        if(OnlineSession.IsValid())
        {
            RoundDuration=OnlineSession.Minutes*60;
            if(!IsProxy && Networking.IsHost)
            {
                if(Input.Pressed("flashlight"))OnlineSession.StartRound();
                if(!InRound && Input.Pressed("roundlength"))OnlineSession.SetMinutes((int)(RoundDuration/60)%3+1);
            }
            return;
        }
        startupTime+=Time.Delta;
        if(RoundAfterReload is float duration && startupTime>.5f)
        {
            RoundAfterReload=null;
            RoundDuration=duration;
            StartRound();
        }
        noticeRemaining = MathF.Max( 0, noticeRemaining - Time.Delta );
        if ( noticeRemaining == 0 ) EliminationNotice = "";
        if(!InRound && Input.Pressed("roundlength"))SetRoundMinutes((int)(RoundDuration/60)%3+1);
        if (!InRound && Status != "TRAINING" && Input.Pressed("use")) ReturnToTraining();
        else if ( !InRound && Input.Pressed( "flashlight" ) ) StartRound();
        if ( !InRound ) return;
        var eliminated = opponents.Where( x => x.IsValid() && x.Components.Get<PaintballCombatant>()?.Eliminated == true && !announcedEliminations.Contains( x ) ).ToArray();
        if ( eliminated.Length > 0 )
        {
            foreach ( var enemy in eliminated ) announcedEliminations.Add( enemy );
            EliminationNotice = string.Join( " / ", eliminated.Select( x => x.Components.Get<ArenaOpponent>().Character.ToUpperInvariant() ) ) + " — OUT";
            noticeRemaining = 3;
        }
        var previousRemaining = Remaining;
        Remaining = MathF.Max( 0, Remaining - Time.Delta );
        if ( !TimedContest && PlayerState.Eliminated ) Finish( "ELIMINATED" );
        else if ( !TimedContest && OpponentsRemaining == 0 ) Finish( "VICTORY" );
        else if ( Remaining <= 0 ) Finish( "TIME UP" );
        else if ( CountdownCue(previousRemaining, Remaining) is { } cue )
            Sound.Play($"sounds/paintball/{cue}.sound", WorldPosition);
    }
    // One cue per crossed boundary; a long frame must never queue a burst of ticks.
    internal static string CountdownCue(float previous, float current)
    {
        if (current <= 0 || current >= previous) return null;
        if (current <= 5 && MathF.Ceiling(current) < MathF.Ceiling(previous)) return "countdown_tick";
        return previous > 10 && current <= 10 ? "time_warning" : null;
    }
    public bool ReturnToTraining(bool endCurrentRound = false)
    {
        if(OnlineSession.IsValid())return false;
        if ((InRound && !endCurrentRound) || player is null) return false;
        InRound=false;
        ArenaShield.ResetArena(Scene);
        Components.Get<VaultController>()?.Cancel();
        Components.Get<CoverController>()?.Leave();
        foreach(var enemy in opponents) if(enemy.IsValid()) enemy.Destroy();
        opponents.Clear();
        foreach(var pod in Scene.GetAllComponents<DroppedReloadPod>().ToArray())pod.GameObject.Destroy();
        foreach(var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray()) ball.GameObject.Destroy();
        announcedEliminations.Clear();EliminationNotice="";noticeRemaining=0;
        finalOpponentsRemaining=0;Remaining=0;Status="TRAINING";
        PlayerState.ResetPaintHits();PlayerState.HitLimit=5;
        PlayerState.StayInMatch=TimedContest;
        player.WorldPosition=ArenaMap.PlayerSpawn(Scene,0,new Vector3(-630,0,4));player.Body.Velocity=Vector3.Zero;
        player.WishVelocity=Vector3.Zero;
        player.UseInputControls=true;
        var weapon=Components.Get<PaintballMarker>();weapon.ResetTraining();weapon.AcceptInput=true;
        Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible(true);
        return true;
    }
    public void StartRound()
    {
        if(OnlineSession.IsValid()){OnlineSession.StartRound();return;}
        Components.Get<VaultController>()?.Cancel();
        Components.Get<CoverController>()?.Leave();
        if ( player is null ) return;
        ArenaShield.ResetArena(Scene);
        foreach ( var enemy in opponents ) if ( enemy.IsValid() ) enemy.Destroy();
        opponents.Clear();
        foreach(var pod in Scene.GetAllComponents<DroppedReloadPod>().ToArray())pod.GameObject.Destroy();
        announcedEliminations.Clear(); EliminationNotice = ""; noticeRemaining = 0;
        foreach ( var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray() ) ball.GameObject.Destroy();
        PlayerState.ResetPaintHits();
        PlayerState.HitLimit = 5;
        PlayerState.StayInMatch = TimedContest;
        player.WorldPosition = ArenaMap.PlayerSpawn(Scene,0,new Vector3( -630, 0, 4 ));
        player.Body.Velocity = Vector3.Zero;
        player.WishVelocity = Vector3.Zero;
        player.UseInputControls = true;
        var weapon = Components.Get<PaintballMarker>();
        weapon.ResetTraining();
        weapon.AcceptInput = true;
        Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible( false );
        Scene.NavMesh.IsEnabled = true;
        Scene.NavMesh.IncludeStaticBodies = true;
        Scene.NavMesh.IncludeKeyframedBodies = false;
        Scene.NavMesh.AgentHeight = 67;
        Scene.NavMesh.AgentRadius = 17;
        Scene.NavMesh.SetDirty();
        var selected = Components.Get<RosterSelection>()?.Selected ?? 0;
        var names = RosterSelection.Names.Where( (name, index) => index != selected ).Select( name => name.ToLowerInvariant() ).ToArray();
        var spawns = new[] { new Vector3(570,-400,1), new Vector3(570,0,1), new Vector3(570,400,1), new Vector3(-650,1250,1), new Vector3(650,1250,1) };
        for ( int i = 0; i < names.Length; i++ )
        {
            var go = new GameObject( true, $"Opponent {names[i]}" );
            go.WorldPosition = ArenaMap.PlayerSpawn(Scene,i+1,spawns[i]);
            var bot = go.Components.Create<ArenaOpponent>();
            bot.Character = names[i];
            bot.CombatEnabled = true;
            bot.ContestMode = TimedContest;
            if (Components.Get<RosterSelection>()?.UseCitizenCharacters == true)
                go.Components.Create<CitizenOpponentPresentation>();
            opponents.Add( go );
        }
        Remaining = TimedContest ? Math.Clamp(RoundDuration,60f,180f) : Math.Clamp(RoundDuration, .25f, 1800f);
        Status = TimedContest ? "PAINT RUSH" : "ELIMINATION";
        InRound = true;
        Sound.Play("sounds/paintball/round_start.sound", WorldPosition);
    }
    private void Finish( string result )
    {
        finalOpponentsRemaining = OpponentsRemaining;
        Components.Get<VaultController>()?.Cancel();
        Components.Get<CoverController>()?.Leave();
        InRound = false;
        Status = result;
        Components.Get<CloseCombat>()?.ResetState();
        PlayerState.AcceptHits=false;
        var won=TimedContest ? RankOf(Standings.First(x=>x.IsPlayer))==1 : result=="VICTORY";
        Sound.Play(won ? "sounds/paintball/round_win.sound" : "sounds/paintball/round_loss.sound", WorldPosition);
        Components.Get<PaintballMarker>().EndRound();
        player.UseInputControls = false;
        player.WishVelocity = Vector3.Zero;
        // Stop horizontal carry-over while allowing an airborne player to land.
        player.Body.Velocity = player.Body.Velocity.WithX(0).WithY(0);
        foreach ( var enemy in opponents )
            if ( enemy.IsValid() )
            {
                if(enemy.Components.Get<PaintballCombatant>() is {} state)state.AcceptHits=false;
                if(TimedContest)
                {
                    enemy.Components.Get<NavMeshAgent>()?.Stop();
                    enemy.Components.Get<NavMeshAgent>().MaxSpeed=0;
                    enemy.Components.Get<ArenaOpponent>().CombatEnabled=false;
                }
                else if(enemy.Components.Get<ArenaOpponent>()?.ShowingOutSignal != true)enemy.Enabled = false;
            }
        foreach ( var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray() ) ball.GameObject.Destroy();
    }
}