EvadeRoundManager.cs

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.

File Access
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}";
	}
}