Round manager component for an Evade-style game mode. It tracks phase (intermission, survival, round end), timers, player counts, spectating logic, triggers nextbot spawns and escalation waves, awards scores/credits on events, and controls respawn and camera when spectating.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
public enum EvadePhase
{
Intermission,
Survival,
RoundEnd
}
public sealed class EvadeRoundManager : Component
{
public static EvadeRoundManager Instance { get; private set; }
[Property] public float IntermissionDuration { get; set; } = 10f;
[Property] public float SurvivalDuration { get; set; } = 150f;
[Property] public float RoundEndDuration { get; set; } = 8f;
[Property] public float EscalationInterval { get; set; } = 45f;
[Sync] public EvadePhase CurrentPhase { get; private set; } = EvadePhase.Intermission;
[Sync] public float PhaseTimeRemaining { get; private set; } = 10f;
[Sync] public int AliveCount { get; private set; } = 1;
[Sync] public int DownedCount { get; private set; } = 0;
[Sync] public int DeadCount { get; private set; } = 0;
[Sync] public int TotalRevives { get; private set; } = 0;
[Sync] public string RoundOutcome { get; private set; } = "";
public bool IsSpectating { get; private set; } = false;
public GameObject SpectateTarget { get; private set; }
public string SpectateTargetName => SpectateTarget.IsValid() ? SpectateTarget.Name : "Nobody";
private readonly List<GameObject> _aliveTargets = new();
private int _currentSpectateIndex = 0;
[Sync] private TimeSince _timeSinceEscalation { get; set; } = 0f;
private TimeSince _timeSinceStatsUpdate = 0f;
protected override void OnAwake()
{
Instance = this;
}
protected override void OnStart()
{
StartIntermission();
}
protected override void OnUpdate()
{
if ( !IsProxy )
{
UpdatePhase();
}
if ( _timeSinceStatsUpdate > 0.25f )
{
_timeSinceStatsUpdate = 0f;
RefreshPlayerCounts();
}
UpdateSpectator();
}
private void UpdatePhase()
{
PhaseTimeRemaining -= Time.Delta;
switch ( CurrentPhase )
{
case EvadePhase.Intermission:
if ( PhaseTimeRemaining <= 0f )
{
StartSurvival();
}
break;
case EvadePhase.Survival:
// Проверка эскалации волн (каждые 45 сек)
if ( _timeSinceEscalation >= EscalationInterval )
{
_timeSinceEscalation = 0f;
TriggerEscalationWave();
}
// Проверка окончания раунда: все погибли/нокаутированы или время вышло
if ( AliveCount == 0 && DownedCount == 0 && DeadCount > 0 )
{
EndRound( false );
}
else if ( PhaseTimeRemaining <= 0f )
{
EndRound( true );
}
break;
case EvadePhase.RoundEnd:
if ( PhaseTimeRemaining <= 0f )
{
StartIntermission();
}
break;
}
}
public void StartIntermission()
{
CurrentPhase = EvadePhase.Intermission;
PhaseTimeRemaining = IntermissionDuration;
RoundOutcome = "";
TotalRevives = 0;
_timeSinceEscalation = 0f;
IsSpectating = false;
SpectateTarget = null;
// Сброс и возрождение всех игроков
RespawnPlayers();
// Уведомление
if ( Killfeed.Instance != null )
{
Killfeed.Instance.AddEntry( "SYSTEM", "ALL RUNNERS", "INTERMISSION (10s) - PREPARE!" );
}
Log.Info( "[EvadeRoundManager] Интермиссия начата!" );
}
public void StartSurvival()
{
CurrentPhase = EvadePhase.Survival;
PhaseTimeRemaining = SurvivalDuration;
_timeSinceEscalation = 0f;
// Активируем спавн Некстботов
if ( NextbotSpawner.Instance.IsValid() )
{
NextbotSpawner.Instance.SpawnAllBots();
}
Sound.Play( "music/buttonhover.sound" );
CameraJuice.Instance?.Shake( 18f, 6f );
if ( Killfeed.Instance != null )
{
Killfeed.Instance.AddEntry( "SYSTEM", "NEXTBOTS", "HUNT STARTED! SURVIVE!" );
}
Log.Info( "[EvadeRoundManager] Фаза выживания начата!" );
}
private void TriggerEscalationWave()
{
if ( NextbotSpawner.Instance.IsValid() )
{
NextbotSpawner.Instance.SpawnSingleBot( 999 );
}
CameraJuice.Instance?.Shake( 15f );
Sound.Play( "music/buttonhover.sound" );
if ( Killfeed.Instance != null )
{
Killfeed.Instance.AddEntry( "ALERT", "ARENA", "ANOTHER NEXTBOT ENTERED THE MAP!" );
}
Log.Info( "[EvadeRoundManager] Дополнительный некстбот выпущен на карту!" );
}
public void EndRound( bool victory )
{
CurrentPhase = EvadePhase.RoundEnd;
PhaseTimeRemaining = RoundEndDuration;
RoundOutcome = victory ? "VICTORY" : "DEFEAT";
if ( victory )
{
ScoreManager.Instance?.AddScore( 100 );
var meta = Scene.GetAllComponents<MetaProgression>().FirstOrDefault();
meta?.AddCredits( 50 );
if ( Killfeed.Instance != null )
{
Killfeed.Instance.AddEntry( "SYSTEM", "SURVIVORS", "VICTORY! RUN COMPLETED!" );
}
}
else
{
if ( Killfeed.Instance != null )
{
Killfeed.Instance.AddEntry( "SYSTEM", "NEXTBOTS", "DEFEAT! ALL RUNNERS PERISHED!" );
}
}
Log.Info( $"[EvadeRoundManager] Раунд завершён: {RoundOutcome}" );
}
public void OnPlayerRevived( PlayerHealth revivedPlayer )
{
TotalRevives++;
ScoreManager.Instance?.AddScore( 25 );
var meta = Scene.GetAllComponents<MetaProgression>().FirstOrDefault();
meta?.AddCredits( 25 );
if ( Killfeed.Instance != null && revivedPlayer.IsValid() )
{
Killfeed.Instance.AddEntry( "RESCUE", revivedPlayer.GameObject.Name, "REVIVED" );
}
}
private void RefreshPlayerCounts()
{
int alive = 0;
int downed = 0;
int dead = 0;
_aliveTargets.Clear();
var players = Scene.GetAllComponents<PlayerHealth>();
foreach ( var p in players )
{
if ( !p.IsValid() || !p.GameObject.IsValid() || !p.GameObject.Enabled ) continue;
if ( p.IsDead )
{
dead++;
}
else if ( p.IsDowned )
{
downed++;
}
else
{
alive++;
_aliveTargets.Add( p.GameObject );
}
}
// Если компонентов PlayerHealth пока нет, считаем PlayerController
if ( alive == 0 && downed == 0 && dead == 0 )
{
var controllers = Scene.GetAllComponents<PlayerController>();
alive = controllers.Count( c => c.IsValid() && c.GameObject.IsValid() && c.GameObject.Enabled );
}
AliveCount = alive;
DownedCount = downed;
DeadCount = dead;
}
private void RespawnPlayers()
{
var spawnPoint = Scene.GetAllComponents<SpawnPoint>().FirstOrDefault();
Vector3 spawnPos = spawnPoint.IsValid() ? spawnPoint.WorldPosition : new Vector3( 0, 0, 100 );
var players = Scene.GetAllComponents<PlayerHealth>();
foreach ( var p in players )
{
if ( !p.IsValid() ) continue;
p.Respawn();
}
}
private void UpdateSpectator()
{
var localHealth = PlayerHealth.LocalInstance;
bool shouldSpectate = localHealth.IsValid() && localHealth.IsDead && CurrentPhase == EvadePhase.Survival;
if ( !shouldSpectate )
{
IsSpectating = false;
SpectateTarget = null;
return;
}
IsSpectating = true;
if ( Input.Pressed( "attack1" ) || Input.Pressed( "run" ) )
{
CycleSpectator( 1 );
}
else if ( Input.Pressed( "attack2" ) || Input.Pressed( "duck" ) )
{
CycleSpectator( -1 );
}
if ( (!SpectateTarget.IsValid() || !SpectateTarget.Enabled) && _aliveTargets.Count > 0 )
{
CycleSpectator( 0 );
}
// Позиционируем камеру за спиной цели
if ( SpectateTarget.IsValid() && Scene.Camera.IsValid() )
{
Vector3 targetPos = SpectateTarget.WorldPosition + Vector3.Up * 64f;
Vector3 camBack = -SpectateTarget.WorldRotation.Forward * 120f + Vector3.Up * 25f;
Vector3 desiredCamPos = targetPos + camBack;
var tr = Scene.Trace.Ray( targetPos, desiredCamPos )
.IgnoreGameObjectHierarchy( SpectateTarget )
.Run();
Scene.Camera.WorldPosition = tr.Hit ? tr.EndPosition + tr.Normal * 5f : desiredCamPos;
Scene.Camera.WorldRotation = Rotation.LookAt( (targetPos - Scene.Camera.WorldPosition).Normal );
}
}
private void CycleSpectator( int direction )
{
if ( _aliveTargets.Count == 0 )
{
SpectateTarget = null;
return;
}
_currentSpectateIndex = (_currentSpectateIndex + direction) % _aliveTargets.Count;
if ( _currentSpectateIndex < 0 ) _currentSpectateIndex += _aliveTargets.Count;
SpectateTarget = _aliveTargets[_currentSpectateIndex];
}
public string GetFormattedPhaseTime()
{
int seconds = Math.Max( 0, (int)PhaseTimeRemaining );
return $"{seconds / 60:00}:{seconds % 60:00}";
}
}