Bot/BotSpawner.cs
/// <summary>
/// Scene component that spawns and manages AI bots on the host.
/// Add to any GameObject in the scene and configure BotCount + ShipPool.
/// </summary>
[Title( "Bot Spawner" ), Icon( "groups" )]
public sealed class BotSpawner : Component
{
/// <summary>Number of bots to maintain in the game.</summary>
[Property] public int BotCount { get; set; } = 4;
/// <summary>Ships to randomly assign to bots. Leave empty to use all available ships.</summary>
[Property] public List<ShipData> ShipPool { get; set; } = new();
/// <summary>Seconds before a dead bot respawns.</summary>
[Property] public float RespawnDelay { get; set; } = 3f;
/// <summary>Optional: tuning overrides applied to every spawned BotController.</summary>
[Property] public float AttackRange { get; set; } = 1800f;
[Property] public float ChaseRange { get; set; } = 5000f;
[Property] public float TurnSpeed { get; set; } = 6f;
[Property, Range( 0f, 45f )] public float AimError { get; set; } = 8f;
// Track (pawn, respawn timer) pairs for all managed bots
private readonly List<(PlayerPawn Pawn, float? RespawnAt)> _bots = new();
private List<ShipData> _resolvedPool;
protected override void OnStart()
{
if ( !Networking.IsHost ) return;
_resolvedPool = BuildShipPool();
// Use GameSettings if available, fall back to inspector property
BotCount = GameSettings.BotCount > 0 ? GameSettings.BotCount : BotCount;
SpawnAllBots();
}
protected override void OnFixedUpdate()
{
if ( !Networking.IsHost ) return;
// Sync AI tuning from GameSettings every tick (allows live changes)
var targetCount = GameSettings.BotCount;
while ( _bots.Count < targetCount ) SpawnBot();
while ( _bots.Count > targetCount ) RemoveBot();
for ( int i = _bots.Count - 1; i >= 0; i-- )
{
var (pawn, respawnAt) = _bots[i];
if ( pawn == null || !pawn.IsValid() )
{
_bots.RemoveAt( i );
SpawnBot();
continue;
}
if ( !pawn.IsAlive )
{
if ( respawnAt == null )
{
// Death just detected — start timer
_bots[i] = (pawn, Time.Now + RespawnDelay);
}
else if ( Time.Now >= respawnAt.Value )
{
RespawnBot( pawn );
_bots[i] = (pawn, null);
}
}
else
{
// Reset timer when alive
_bots[i] = (pawn, null);
}
ApplyBotSkillPreset( pawn?.Components.Get<BotController>( FindMode.InSelf ) );
}
}
/// <summary>Spawns one additional bot and increments BotCount.</summary>
public void AddBot()
{
if ( !Networking.IsHost ) return;
BotCount++;
SpawnBot();
}
/// <summary>Removes the most recently spawned bot.</summary>
public void RemoveBot()
{
if ( !Networking.IsHost ) return;
if ( _bots.Count == 0 ) return;
var (pawn, _) = _bots[_bots.Count - 1];
_bots.RemoveAt( _bots.Count - 1 );
BotCount = Math.Max( 0, BotCount - 1 );
if ( pawn != null && pawn.IsValid() )
{
_usedBotNames.Remove( pawn.BotName );
pawn.GameObject.Destroy();
}
}
/// <summary>Returns how many bots are currently alive or managed.</summary>
public int ActiveBotCount => _bots.Count;
// ── Spawning ──────────────────────────────────────────────────────────────
private void SpawnAllBots()
{
for ( int i = 0; i < BotCount; i++ )
SpawnBot();
}
private void SpawnBot()
{
var prefab = ResourceLibrary.Get<PrefabFile>( "prefabs/player/player.prefab" );
if ( prefab == null )
{
Log.Error( "[BotSpawner] player.prefab not found!" );
return;
}
var go = SceneUtility.GetPrefabScene( prefab )?.Clone();
if ( go == null )
{
Log.Error( "[BotSpawner] Failed to clone player prefab!" );
return;
}
var pawn = go.Components.Get<PlayerPawn>( true );
if ( pawn == null )
{
Log.Error( "[BotSpawner] No PlayerPawn in player prefab!" );
go.Destroy();
return;
}
// Assign random ship before NetworkSpawn so clients receive it immediately
var ship = PickRandomShip();
pawn.Data = ship;
pawn.IsBot = true;
pawn.BotName = PickRandomBotName();
pawn.HasSelectedShip = true;
// Add AI brain
var bot = go.Components.Create<BotController>();
ApplyBotSkillPreset( bot );
var spawn = PilotGame.GetRandomSpawnpoint();
go.WorldPosition = spawn.Position;
go.WorldRotation = spawn.Rotation;
go.Name = $"Bot ({ship?.ShipName ?? "Unknown"})";
go.NetworkSpawn(); // Host owns — no connection
pawn.Respawn( spawn );
_bots.Add( (pawn, null) );
Log.Info( $"[BotSpawner] Spawned bot '{go.Name}'" );
}
private void RespawnBot( PlayerPawn pawn )
{
if ( pawn == null || !pawn.IsValid() ) return;
var ship = PickRandomShip();
if ( ship != null ) pawn.Data = ship;
pawn.Respawn();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static readonly string[] BotNames = new[]
{
// Short punchy handles
"Ryker", "Voss", "Nyx", "Zara", "Kael", "Mira", "Sable",
"Juno", "Cass", "Oryn", "Rael", "Neve", "Kira", "Dusk",
"Syn", "Lex", "Vex", "Pax", "Ash", "Cade",
"Lyra", "Sera", "Zion", "Brix", "Nova", "Hex", "Jinx", "Raze",
"Volt", "Neon", "Glitch", "Zero", "Cipher", "Pulse", "Static",
"Revik", "Prynn", "Zhoryn", "Batrix", "Tyrel", "Dekarr",
"Luxx", "Joiv", "Frayne", "Saphir", "Galfr", "Nexis",
"Kobren", "Zaryn", "Rachyn", "Leonne", "Sperr",
"Jacklyn", "Jonah", "Rivka", "Evyn", "Takeru", "Mist",
"Wakko", "Gorr", "Plaid", "Sonbird", "Kumari", "Yori",
"Andrex", "T-Bex", "Nixx", "Dex", "Evoka",
"Hanara", "Sabrix", "Roguen", "Jude", "Birdsong",
"Ramsay", "Soren", "Vanya", "Kestrel", "Zel", "Mako", "Riven"
};
private static readonly HashSet<string> _usedBotNames = new();
private static string PickRandomBotName()
{
// Try to pick a name not currently in use
var available = BotNames.Where( n => !_usedBotNames.Contains( n ) ).ToArray();
var pool = available.Length > 0 ? available : BotNames;
var name = pool[Game.Random.Int( 0, pool.Length - 1 )];
_usedBotNames.Add( name );
return name;
}
protected override void OnDestroy()
{
foreach ( var (pawn, _) in _bots )
if ( pawn != null && pawn.IsValid() )
_usedBotNames.Remove( pawn.BotName );
}
private List<ShipData> BuildShipPool()
{
if ( ShipPool != null && ShipPool.Count > 0 )
return ShipPool.Where( s => s != null ).ToList();
return ResourceLibrary.GetAll<ShipData>().Where( s => s != null ).ToList();
}
private ShipData PickRandomShip()
{
if ( PilotGame.Gamemode == FPGameMode.Instagib )
return ResourceLibrary.Get<ShipData>( "ships/wingsship.ship" );
if ( _resolvedPool == null || _resolvedPool.Count == 0 )
_resolvedPool = BuildShipPool();
if ( _resolvedPool.Count == 0 ) return null;
return _resolvedPool[Game.Random.Int( 0, _resolvedPool.Count - 1 )];
}
private void ApplyBotSkillPreset( BotController bot )
{
if ( bot == null ) return;
switch ( GameSettings.BotSkill )
{
case FPBotSkill.Easy:
bot.AttackRange = AttackRange * 0.75f;
bot.ChaseRange = ChaseRange * 0.80f;
bot.TurnSpeed = TurnSpeed * 0.75f;
bot.AimError = MathF.Max( AimError, 14f );
bot.FireCone = 14f;
bot.ReactionTime = 0.65f;
bot.CounterAttackChanceOnHit = 0.15f;
bot.UseBoostOnAttack = false;
break;
case FPBotSkill.Hard:
bot.AttackRange = AttackRange * 1.05f;
bot.ChaseRange = ChaseRange * 1.05f;
bot.TurnSpeed = TurnSpeed * 1.08f;
bot.AimError = MathF.Min( AimError, 3.5f );
bot.FireCone = 24f;
bot.ReactionTime = 0.20f;
bot.CounterAttackChanceOnHit = 0.55f;
bot.UseBoostOnAttack = true;
break;
default: // Normal
bot.AttackRange = AttackRange;
bot.ChaseRange = ChaseRange;
bot.TurnSpeed = TurnSpeed;
bot.AimError = AimError;
bot.FireCone = 22f;
bot.ReactionTime = 0.28f;
bot.CounterAttackChanceOnHit = 0.35f;
bot.UseBoostOnAttack = true;
break;
}
}
}