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();
}
}