DeathmatchGame.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
public enum MatchState
{
IN_PROGRESS,
ENDED,
}
public sealed class DeathmatchGame : Component
{
public static DeathmatchGame Instance { get; private set; }
/// <summary>Match duration in minutes.</summary>
[ConVar( "match_time", ConVarFlags.GameSetting ), Range( 5, 45 ), Step( 1 )] public static int MatchTime { get; private set; } = 15;
/// <summary> Time it takes for the match to start in seconds </summary>
[Property] public float StartTime { get; private set; } = 30f;
/// <summary>Map package idents available for voting (e.g. sugmagaming.nuketown). Current map is always offered too.</summary>
[Property] public List<string> MapPool { get; private set; } = [];
[Property] public SoundEvent HeadshotSound { get; private set; }
/// <summary>Sounds for multi-kills in quick succession. Index 0 = double kill, 1 = triple kill, etc.</summary>
[Property] public List<SoundEvent> MultiKillSounds { get; private set; }
/// <summary>Sounds for killing spree milestones without dying. Index 0 = first milestone, 1 = second, etc.</summary>
[Property] public List<SoundEvent> KillingSpreeSounds { get; private set; }
/// <summary>Max seconds between kills to keep a multi-kill chain going.</summary>
[Property] public float MultiKillWindow { get; set; } = 3f;
/// <summary>Announce a killing spree every this many kills without dying (e.g. 5 = at 5, 10, 15…).</summary>
[Property] public int KillingSpreeInterval { get; set; } = 5;
public float TimeLeftToEnd => MatchEndTime - Time.Now;
public float TimeLeftToStart => MatchStartTime - Time.Now;
[Sync] private float MatchEndTime { get; set; }
[Sync] private float MatchStartTime { get; set; }
[Sync, Change( nameof( OnStateChange ) )] public MatchState State { get; private set; } = MatchState.ENDED;
[Sync] public string VoteOption0 { get; private set; } = "";
[Sync] public string VoteOption1 { get; private set; } = "";
[Sync] public string VoteOption2 { get; private set; } = "";
private bool _waitingForMapLoad;
private bool _intermissionResolved;
public string GetVoteOption( int index ) => index switch
{
0 => VoteOption0,
1 => VoteOption1,
2 => VoteOption2,
_ => ""
};
public int GetVoteCount( int index )
{
if ( index < 0 || index > 2 || string.IsNullOrEmpty( GetVoteOption( index ) ) )
return 0;
return NetworkManager.Instance.PlayerSessions.Count( s => s.IsValid() && s.MapVote == index );
}
protected override void OnStart()
{
Instance = this;
}
protected override void OnDestroy()
{
if ( Instance == this )
Instance = null;
}
protected override void OnUpdate()
{
if ( IsProxy )
return;
UpdateMatchState();
}
public void UpdateMatchState()
{
if ( State == MatchState.ENDED && TimeLeftToStart <= 0 )
{
if ( !_waitingForMapLoad && !_intermissionResolved )
_ = ResolveIntermissionAsync();
}
else if ( State == MatchState.IN_PROGRESS && TimeLeftToEnd <= 0 )
{
EndMatch();
}
}
public void StartMatch()
{
_waitingForMapLoad = false;
_intermissionResolved = false;
ClearVoteOptions();
State = MatchState.IN_PROGRESS;
MatchEndTime = Time.Now + MatchTime * 60f;
//ClearItems();
NetworkManager.Instance.RespawnAllPlayers();
}
public void EndMatch()
{
_waitingForMapLoad = false;
_intermissionResolved = false;
PickVoteOptions();
State = MatchState.ENDED;
MatchStartTime = Time.Now + StartTime;
}
private void PickVoteOptions()
{
var current = NetworkManager.Instance?.MapName ?? "";
var options = new List<string>();
if ( !string.IsNullOrWhiteSpace( current ) )
options.Add( current );
var pool = (MapPool ?? [])
.Where( m => !string.IsNullOrWhiteSpace( m ) )
.Select( m => m.Trim() )
.Where( m => !string.Equals( m, current, StringComparison.OrdinalIgnoreCase ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
while ( options.Count < 3 && pool.Count > 0 )
{
var pickIndex = Game.Random.Int( 0, pool.Count - 1 );
options.Add( pool[pickIndex] );
pool.RemoveAt( pickIndex );
}
VoteOption0 = options.ElementAtOrDefault( 0 ) ?? "";
VoteOption1 = options.ElementAtOrDefault( 1 ) ?? "";
VoteOption2 = options.ElementAtOrDefault( 2 ) ?? "";
}
private async Task ResolveIntermissionAsync()
{
if ( _intermissionResolved || _waitingForMapLoad )
return;
_intermissionResolved = true;
var winner = TallyVoteWinner();
ClearVoteOptions();
var current = NetworkManager.Instance?.MapName ?? "";
if ( string.IsNullOrWhiteSpace( winner )
|| string.Equals( winner, current, StringComparison.OrdinalIgnoreCase ) )
{
StartMatch();
return;
}
_waitingForMapLoad = true;
var ok = await NetworkManager.Instance.SetMap( winner );
if ( !ok )
{
_waitingForMapLoad = false;
StartMatch();
}
}
private string TallyVoteWinner()
{
var options = new[] { VoteOption0, VoteOption1, VoteOption2 };
var counts = new int[3];
foreach ( var session in NetworkManager.Instance.PlayerSessions )
{
if ( !session.IsValid() )
continue;
var vote = session.MapVote;
if ( vote < 0 || vote > 2 || string.IsNullOrEmpty( options[vote] ) )
continue;
counts[vote]++;
}
if ( counts.Sum() == 0 )
return NetworkManager.Instance?.MapName ?? "";
var max = counts.Max();
var tied = new List<int>();
for ( var i = 0; i < 3; i++ )
{
if ( counts[i] == max && !string.IsNullOrEmpty( options[i] ) )
tied.Add( i );
}
return options[tied[Game.Random.Int( 0, tied.Count - 1 )]];
}
private void ClearVoteOptions()
{
VoteOption0 = "";
VoteOption1 = "";
VoteOption2 = "";
}
private void ClearItems()
{
var items = Scene.GetAll<InventoryItem>();
foreach ( var item in items )
{
item.GameObject.Destroy();
}
}
public void HandleSuicide()
{
var victimSession = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
if ( !victimSession.IsValid() )
return;
victimSession.Deaths++;
victimSession.ResetKillStreaks();
}
public void HandleKill(bool headshot)
{
var attackerSession = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
if ( !attackerSession.IsValid() )
return;
attackerSession.Kills++;
attackerSession.KillStreak++;
Sandbox.Services.Stats.Increment( "kills", 1 );
if ( headshot )
PlayHeadshotSound();
var multiKillCount = attackerSession.RegisterMultiKill( MultiKillWindow );
if ( multiKillCount >= 2 )
{
PlayMultiKillSound( multiKillCount );
Sandbox.Services.Achievements.Unlock( $"multikill_{multiKillCount}" );
}
if ( KillingSpreeInterval > 0 && attackerSession.KillStreak % KillingSpreeInterval == 0 )
{
PlayKillingSpreeSound( attackerSession.KillStreak / KillingSpreeInterval );
Sandbox.Services.Achievements.Unlock( $"killstreak_{attackerSession.KillStreak}" );
}
}
public void HandleDeath( bool isSuicide )
{
var victimSession = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
if ( !victimSession.IsValid() )
return;
victimSession.Deaths++;
victimSession.ResetKillStreaks();
if ( isSuicide )
victimSession.Kills--;
Sandbox.Services.Stats.Increment( "deaths", 1 );
}
[Rpc.Broadcast]
private void PlayHeadshotSound()
{
Sound.Play( HeadshotSound );
}
[Rpc.Broadcast]
private void PlayMultiKillSound( int multiKillCount )
{
var index = multiKillCount - 2;
if ( index < 0 || index >= MultiKillSounds.Count )
return;
Sound.Play( MultiKillSounds[index] );
}
[Rpc.Broadcast]
private void PlayKillingSpreeSound( int milestoneIndex )
{
var index = milestoneIndex - 1;
if ( index < 0 || index >= KillingSpreeSounds.Count )
return;
Sound.Play( KillingSpreeSounds[index] );
}
[Rpc.Broadcast]
public void PlayBulletImpactSound( SoundEvent sound, Vector3 position )
{
if ( sound is null )
return;
Sound.Play( sound, position );
}
/// <summary>
/// Relays a projectile impulse to every peer; only the peer controlling the
/// target's nearest physics body applies it.
/// </summary>
[Rpc.Broadcast]
public void ApplyProjectileImpulse( GameObject target, Vector3 hitPosition, Vector3 impulse )
{
if ( !target.IsValid() )
return;
// This is a cosmetic interaction requested by clients. Clamp it so a
// malformed request cannot launch shared physics objects excessively.
const float maxImpulse = 500f;
if ( impulse.Length > maxImpulse )
impulse = impulse.Normal * maxImpulse;
var body = FindClosestPhysicsBody( target, hitPosition );
if ( body is null )
return;
// Every peer receives this RPC, but the object's simulator is the only
// peer allowed to change its physics. Unnetworked fallback is handled by
// Projectile locally and never reaches this RPC.
if ( body.Network.Active && body.IsProxy )
return;
body.ApplyImpulseAt( body.FindClosestPoint( hitPosition ), impulse );
}
private static Rigidbody FindClosestPhysicsBody( GameObject target, Vector3 hitPosition )
{
Rigidbody closestBody = null;
float closestDistanceSquared = float.MaxValue;
void Consider( Rigidbody candidate )
{
if ( !candidate.IsValid() || !candidate.PhysicsBody.IsValid() )
return;
var closestPoint = candidate.FindClosestPoint( hitPosition );
float distanceSquared = closestPoint.DistanceSquared( hitPosition );
if ( distanceSquared >= closestDistanceSquared )
return;
closestBody = candidate;
closestDistanceSquared = distanceSquared;
}
foreach ( var rigidbody in target.Components.GetAll<Rigidbody>( FindMode.EverythingInSelfAndDescendants ) )
Consider( rigidbody );
// ModelPhysics keeps ragdoll bodies in an internal collection rather than
// exposing them as normal descendants.
foreach ( var modelPhysics in target.Components.GetAll<ModelPhysics>( FindMode.EverythingInSelfAndDescendants ) )
{
if ( modelPhysics.Bodies is null )
continue;
foreach ( var modelBody in modelPhysics.Bodies )
Consider( modelBody.Component );
}
return closestBody;
}
private void OnStateChange( MatchState oldValue, MatchState newValue )
{
if ( newValue == MatchState.ENDED)
{
var localSession = NetworkManager.Instance?.GetPlayerSession( Connection.Local.Id );
if ( localSession.IsValid() && !localSession.IsProxy )
localSession.MapVote = -1;
if ( NetworkManager.Instance.PlayerSessions.Count > 1 )
{
Sandbox.Services.Stats.Increment( "matches", 1 );
var winner = NetworkManager.Instance.PlayerSessions.OrderByDescending( p => p.Kills ).First();
if ( winner?.Network.OwnerId == Connection.Local.Id )
Sandbox.Services.Stats.Increment( "matches_won", 1 );
}
}
else if ( newValue == MatchState.IN_PROGRESS )
{
var session = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
session.ClearStats();
}
}
}