NextbotSpawner.cs

Component that spawns and manages nextbot NPCs. It tracks active bots, handles an initial grace period, spawns waves at intervals, chooses safe spawn positions (using configured spawn points or randomized positions with trace checks), and optionally destroys/respawns bots based on settings.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

public sealed class NextbotSpawner : Component
{
    [Property] public PrefabFile BotPrefab { get; set; }
    [Property] public int BotCount { get; set; } = 4;
    [Property] public float SpawnRadius { get; set; } = 2500f;
    [Property] public float MinDistanceFromPlayer { get; set; } = 1100f;
    [Property] public List<GameObject> SpawnPoints { get; set; } = new();
    [Property] public float WaveInterval { get; set; } = 8f;
    [Property] public int BotsPerWave { get; set; } = 1;
    [Property] public float BotSpeed { get; set; } = 410f;
    [Property] public string ModelPath { get; set; } = "models/terrybot/terryrobot.vmdl";
    [Property] public bool RespawnDeadBots { get; set; } = true;
    [Property] public float GracePeriodDuration { get; set; } = 6f;

    public static NextbotSpawner Instance { get; private set; }
    public static bool IsGracePeriodActive { get; private set; } = false;
    public static float GracePeriodRemaining { get; private set; } = 0f;

    private readonly List<GameObject> _activeBots = new();
    [Sync] private TimeSince _lastCheck { get; set; } = 0;
    [Sync] private TimeSince _timeSinceStart { get; set; } = 0;
    private bool _initialWaveSpawned = false;

    protected override void OnAwake()
    {
        Instance = this;
    }

    protected override void OnStart()
    {
        _timeSinceStart = 0;
        _initialWaveSpawned = false;

        if ( GracePeriodDuration > 0f )
        {
            IsGracePeriodActive = true;
            GracePeriodRemaining = GracePeriodDuration;
        }
        else
        {
            IsGracePeriodActive = false;
            GracePeriodRemaining = 0f;
            if ( !IsProxy && BotSpawner.EnableBots )
            {
                _initialWaveSpawned = true;
                SpawnAllBots();
            }
        }
    }

    protected override void OnUpdate()
    {
        if ( IsProxy ) return;

        if ( !BotSpawner.EnableBots )
        {
            foreach ( var bot in _activeBots )
            {
                if ( bot.IsValid() ) bot.Destroy();
            }
            _activeBots.Clear();
            return;
        }

        if ( IsGracePeriodActive )
        {
            float rem = GracePeriodDuration - (float)_timeSinceStart;
            if ( rem > 0f )
            {
                GracePeriodRemaining = rem;
                return;
            }

            IsGracePeriodActive = false;
            GracePeriodRemaining = 0f;

            if ( !_initialWaveSpawned )
            {
                _initialWaveSpawned = true;
                SpawnAllBots();
                _lastCheck = 0;

                CameraJuice.Instance?.Shake( 14f );
                Sound.Play( "music/buttonhover.sound" );

                if ( Killfeed.Instance != null )
                {
                    Killfeed.Instance.AddEntry( "SYSTEM", "ALL PLAYERS", "NEXTBOTS HUNTING" );
                }

                Log.Info( "[NextbotSpawner] Период подготовки завершён! Некстботы выпущены." );
            }
        }

        if ( _lastCheck > WaveInterval )
        {
            _lastCheck = 0;

            _activeBots.RemoveAll( b => !b.IsValid() || b.WorldPosition.z < -2000f );

            if ( RespawnDeadBots && _activeBots.Count < BotCount )
            {
                int waveSize = Math.Min( Math.Max( BotsPerWave, 1 ), BotCount - _activeBots.Count );
                for ( int i = 0; i < waveSize; i++ )
                {
                    SpawnSingleBot( _activeBots.Count + 1 );
                }
            }
        }
    }

    public void SpawnAllBots()
    {
        if ( IsProxy || !BotSpawner.EnableBots ) return;

        for ( int i = 0; i < BotCount; i++ )
        {
            SpawnSingleBot( i + 1 );
        }
    }

    private Vector3 CalculateSafeSpawnPosition()
    {
        var playerPositions = Scene.GetAllComponents<PlayerController>()
            .Where( p => p.IsValid() && p.GameObject.IsValid() && p.GameObject.Enabled )
            .Select( p => p.WorldPosition )
            .ToList();

        var validSpawnPoints = SpawnPoints?.FindAll( point => point.IsValid() ) ?? new List<GameObject>();

        if ( validSpawnPoints.Count > 0 )
        {
            if ( playerPositions.Count > 0 )
            {
                var bestPoint = validSpawnPoints
                    .OrderByDescending( pt => playerPositions.Min( pl => Vector3.DistanceBetween( pt.WorldPosition, pl ) ) )
                    .First();
                return bestPoint.WorldPosition;
            }
            return validSpawnPoints[Random.Shared.Next( validSpawnPoints.Count )].WorldPosition;
        }

        Vector3 bestCandidate = WorldPosition;
        float bestMinDist = -1f;

        for ( int attempt = 0; attempt < 16; attempt++ )
        {
            float angle = Random.Shared.Float( 0f, MathF.PI * 2f );
            float dist = Random.Shared.Float( MinDistanceFromPlayer, Math.Max( MinDistanceFromPlayer + 300f, SpawnRadius ) );
            Vector3 candidate = WorldPosition + new Vector3( MathF.Cos( angle ) * dist, MathF.Sin( angle ) * dist, 60f );

            var tr = Scene.Trace.Ray( candidate + Vector3.Up * 600f, candidate + Vector3.Down * 1500f )
                .Run();

            if ( tr.Hit )
            {
                candidate = tr.EndPosition + Vector3.Up * 5f;
            }

            if ( playerPositions.Count == 0 )
            {
                return candidate;
            }

            float minDistToAnyPlayer = playerPositions.Min( pl => Vector3.DistanceBetween( candidate, pl ) );
            if ( minDistToAnyPlayer >= MinDistanceFromPlayer )
            {
                return candidate;
            }

            if ( minDistToAnyPlayer > bestMinDist )
            {
                bestMinDist = minDistToAnyPlayer;
                bestCandidate = candidate;
            }
        }

        return bestCandidate;
    }

    public void SpawnSingleBot( int index )
    {
        Vector3 spawnPos = CalculateSafeSpawnPosition();

        GameObject botObj;

        if ( BotPrefab != null )
        {
            botObj = GameObject.Clone( BotPrefab, new Transform( spawnPos ) );
            botObj.Name = $"Nextbot_{index}";
        }
        else
        {
            botObj = Scene.CreateObject();
            botObj.Name = $"Nextbot_{index}";
            botObj.WorldPosition = spawnPos;

            var model = Model.Load( ModelPath ) ?? Model.Load( "models/citizen/citizen.vmdl" ) ?? Model.Load( "models/dev/box.vmdl" );
            if ( model != null )
            {
                var renderer = botObj.Components.Create<ModelRenderer>();
                renderer.Model = model;
            }

            var col = botObj.Components.Create<BoxCollider>();
            col.Scale = new Vector3( 40f, 40f, 75f );
            col.Center = new Vector3( 0, 0, 37.5f );
        }

        var ai = botObj.Components.Get<NextbotAI>() ?? botObj.Components.Create<NextbotAI>();
        ai.MoveSpeed = BotSpeed + Random.Shared.Float( -20f, 40f );
        MapEventManager.Instance?.RegisterPhysics( botObj );

        _activeBots.Add( botObj );
        if ( Network.Active )
        {
            botObj.NetworkSpawn();
        }

        Log.Info( $"[NextbotSpawner] Создан {botObj.Name} на безопасной позиции {spawnPos}" );
    }

    public void DespawnAllBots()
    {
        foreach ( var bot in _activeBots )
        {
            if ( bot.IsValid() ) bot.Destroy();
        }
        _activeBots.Clear();
    }
}