EnemySpawner.cs
using System;
using Sandbox;
using Sandbox.Diagnostics;

interface IEnemySpawnerEvents : ISceneEvent<IEnemySpawnerEvents>
{
	void FinishedWave(int count) { }
}

public class EnemyPrefabOption
{
	public int Chance { get; set; } = 1;
	public GameObject Prefab { get; set; }

	public override string ToString()
	{
		return $"{Chance}x for {Prefab.Name}";
	}
}

public class Sector
{
	public int Min { get; set; }
	public int Max { get; set; }

	public Sector()
	{ }

	public Sector( int min, int max )
	{
		Min = min;
		Max = max;
	}

	public override string ToString()
	{
		return $"{Min}..{Max}";
	}
}

public class WaveQueueItem
{
	public int WaitAfter { get; set; }
	public IWaveEvent Event { get; set; }

	public WaveQueueItem( int waitAfter, IWaveEvent @event )
	{
		WaitAfter = waitAfter;
		Event = @event;
	}

	public override string ToString()
	{
		var waitTime = WaitAfter > 0 ? $" then wait {WaitAfter} seconds." : ".";
		return $"{Event}{waitTime}";
	}
}

public interface IWaveEvent
{
	void Invoke( EnemySpawner spawner );
}

public class SpawnEnemies : IWaveEvent
{
	public int EnemyCount { get; set; }

	public SpawnEnemies( int count )
	{
		EnemyCount = count;
	}

	void IWaveEvent.Invoke( EnemySpawner spawner )
	{
		spawner.LastSpawn = -0.2f;
		spawner.SpawnQueue += EnemyCount;
	}

	public override string ToString()
	{
		return $"Spawn {EnemyCount} enemies";
	}
}

public class ActivateSectors : IWaveEvent
{
	public List<Sector> NewSectors { get; set; }

	public ActivateSectors( Sector sector )
	{
		NewSectors = [sector];
	}
	public ActivateSectors( List<Sector> sectors )
	{
		NewSectors = sectors;
	}

	void IWaveEvent.Invoke( EnemySpawner spawner )
	{
		spawner.ActiveSectors = NewSectors;
	}

	public override string ToString()
	{
		return $"Activate sectors {String.Join( ", ", NewSectors )}";
	}
}

public class SetRareEnemyChance : IWaveEvent
{
	public float NewChance { get; set; }

	public SetRareEnemyChance( float newChance )
	{
		NewChance = newChance;
	}

	void IWaveEvent.Invoke( EnemySpawner spawner )
	{
		spawner.RareEnemyChance = NewChance;
	}

	public override string ToString()
	{
		return $"Set rare enemy chance to {NewChance:P0}";
	}
}

public class SetMaxRareEnemies : IWaveEvent
{
	public int NewCap { get; set; }

	public SetMaxRareEnemies( int newCap )
	{
		NewCap = newCap;
	}

	void IWaveEvent.Invoke( EnemySpawner spawner )
	{
		spawner.MaxRareEnemies = NewCap;
	}

	public override string ToString()
	{
		return $"Set max rare enemies to {NewCap}";
	}
}

public sealed class EnemySpawner : Component
{
	[Property]
	public List<EnemyPrefabOption> EnemyPrefabs { get; set; }

	[Property]
	public float SpawnRange { get; set; } = 1000f;

	[Property]
	public List<Sector> ActiveSectors { get; set; } = [new Sector( 0, 90 )];

	[Property]
	public List<WaveQueueItem> WaveQueue { get; set; } = [];

	[Property]
	public int WaveQueueIndex { get; set; }

	[Property]
	public int WaitToAct { get; set; } = 5;

	[Property]
	public TimeSince LastAction { get; private set; }

	[Property]
	public int WaveCounter { get; set; }

	[Property]
	public float RareEnemyChance { get; set; }

	[Property]
	public int MaxRareEnemies { get; set; }

	[Property]
	public bool WaitingForEnemiesToDie { get; set; }

	public TimeSince LastSpawn;
	public int SpawnQueue;

	protected override void OnEnabled()
	{
		base.OnEnabled();

		LastAction = 0;
		LastSpawn = 0;
		SpawnQueue = 0;
	}

	protected override void OnFixedUpdate()
	{
		if (LastSpawn > 0 && SpawnQueue > 0)
		{
			SpawnOne();
			LastSpawn = Game.Random.Float( -0.2f, -0.05f );
			SpawnQueue--;
		}

		if ( WaitingForEnemiesToDie )
		{
			if ( !Scene.GetAll<BulletEnemy>().Any() )
			{
				WaitingForEnemiesToDie = false;

				WaitToAct = 15;
				LastAction = 0;
			}
			return;
		}

		if ( WaveQueue.Count == 0 && LastAction >= WaitToAct )
		{
			if ( WaveCounter > 0 )
			{
				IEnemySpawnerEvents.Post( x => x.FinishedWave( WaveCounter ) );
				Scene.Get<ShopSystem>().OpenShop();
			}

			GenerateWave();

			return;
		}

		if ( WaveQueue.Count > 0 && WaveQueueIndex >= WaveQueue.Count )
		{
			if (SpawnQueue == 0)
			{
				WaveQueue.Clear();
				WaitingForEnemiesToDie = true;
			}
			return;
		}
		else
		{
			if ( LastAction < WaitToAct ) return;

			var todo = WaveQueue[WaveQueueIndex++];

			todo.Event.Invoke( this );
			WaitToAct = todo.WaitAfter;
		}

		LastAction = 0;
	}

	void GenerateWave()
	{
		WaveCounter++;
		Log.Info( $"Wave {WaveCounter}" );

		WaveQueueIndex = 0;

		if ( WaveCounter == 1 )
		{
			GenerateFirstWave();
			return;
		}
		if ( WaveCounter == 2 )
		{
			GenerateSecondWave();
			return;
		}
		if ( WaveCounter < 8 )
		{
			GenerateTier1Wave();
			return;
		}
		if (WaveCounter == 8)
		{
			GenerateCrossoverWave();
			return;
		}

		GenerateTier2Wave();
	}

	Sector OneRandomSector()
	{
		return Game.Random.FromList( AllSectors() );
	}

	void GenerateFirstWave()
	{
		WaveQueue = [
			new WaveQueueItem(0, new ActivateSectors(OneRandomSector())),
			new WaveQueueItem(15, new SpawnEnemies(3)),
			new WaveQueueItem(0, new SpawnEnemies(2)),
		];
	}

	List<Sector> TwoOpposingSectors()
	{
		bool coin = Game.Random.Int( 1 ) == 1;

		if ( coin )
		{
			return [
				new Sector(100, 170),
				new Sector(-80, -10)
			];
		}
		else
		{
			return [
				new Sector(10, 80),
				new Sector(-170, -100)
			];
		}
	}

	void GenerateSecondWave()
	{
		WaveQueue = [
			new WaveQueueItem(0, new ActivateSectors(TwoOpposingSectors())),
			new WaveQueueItem(0, new SetMaxRareEnemies(1)),
			new WaveQueueItem(0, new SetRareEnemyChance(0.2f)),
			new WaveQueueItem(40, new SpawnEnemies(7)),
			new WaveQueueItem(0, new SpawnEnemies(8)),
		];
	}

	// 3 to 7
	void GenerateTier1Wave()
	{
		var enemiesToSpawn = 15 + (WaveCounter) * 3;
		enemiesToSpawn += Game.Random.Int( WaveCounter ) - 2;

		WaveQueue = [
			new WaveQueueItem(0, new ActivateSectors(TwoOpposingSectors())),
			new WaveQueueItem(0, new SetMaxRareEnemies(WaveCounter - 1)),
			new WaveQueueItem(0, new SetRareEnemyChance(0.2f)),
			new WaveQueueItem(40, new SpawnEnemies((int)(enemiesToSpawn * 0.6))),
			new WaveQueueItem(0, new SpawnEnemies((int)(enemiesToSpawn * 0.4))),
		];
	}

	List<Sector> AllSectors()
	{
		return [
			new Sector(10, 80),
			new Sector(100, 170),
			new Sector(-80, -10),
			new Sector(-170, -100)
		];
	}

	// half of the wave should be 2 sides, then 4
	void GenerateCrossoverWave()
	{
		var enemiesToSpawn = 15 + (WaveCounter) * 3;
		enemiesToSpawn += Game.Random.Int( WaveCounter ) - 2;

		WaveQueue = [
			new WaveQueueItem(0, new ActivateSectors(TwoOpposingSectors())),
			new WaveQueueItem(0, new SetMaxRareEnemies(WaveCounter - 1)),
			new WaveQueueItem(0, new SetRareEnemyChance(0.2f)),
			new WaveQueueItem(40, new SpawnEnemies((int)(enemiesToSpawn * 0.6))),
			new WaveQueueItem(0, new ActivateSectors(AllSectors())),
			new WaveQueueItem(0, new SetMaxRareEnemies(2)),
			new WaveQueueItem(0, new SetRareEnemyChance(0.2f)),
			new WaveQueueItem(0, new SpawnEnemies((int)(enemiesToSpawn * 0.4))),
		];
	}

	// 9 to ???
	void GenerateTier2Wave()
	{
		var cnt = WaveCounter - 8;
		var enemiesToSpawn = 20 + (WaveCounter) * 3;
		enemiesToSpawn += (Game.Random.Int( WaveCounter ) - 2) * 2;

		WaveQueue = [
			new WaveQueueItem(0, new ActivateSectors(AllSectors())),
			new WaveQueueItem(0, new SetMaxRareEnemies((cnt+1)*5)),
			new WaveQueueItem(0, new SetRareEnemyChance(0.2f)),
		];

		while (enemiesToSpawn > 0)
		{
			var enemiesInSubWave = Game.Random.Int( 20, 36 );

			WaveQueue.Add( new WaveQueueItem( Game.Random.Int( 40, 50 ) - cnt, new SpawnEnemies( enemiesInSubWave ) ) );

			enemiesToSpawn -= enemiesInSubWave;
		}
	}

	protected override void DrawGizmos()
	{
		base.DrawGizmos();

		Gizmo.Draw.Color = Color.Orange;
		Gizmo.Draw.LineCircle( Vector3.Zero, Vector3.Up, Vector3.Forward, SpawnRange, 0, 360, 64 );
	}

	GameObject GetRandomEnemyPrefab()
	{
		List<GameObject> list = [];

		foreach ( var option in EnemyPrefabs )
		{
			for ( var i = 0; i < option.Chance; i++ )
			{
				list.Add( option.Prefab );
			}
		}

		return Game.Random.FromList( list );
	}

	public void SpawnOne()
	{
		var sector = Game.Random.FromList( ActiveSectors );
		var degrees = Game.Random.Int( sector.Min, sector.Max );

		var rot = Rotation.FromYaw( degrees );

		var position = new Vector3( SpawnRange, 0, 0 ).RotateAround( Vector3.Zero, rot );
		var prefab = GetRandomEnemyPrefab();

		Assert.IsValid( prefab );

		var instance = prefab.Clone( position );

		if ( Game.Random.Float() < RareEnemyChance )
		{
			var rare = instance.GetComponent<IRareEnemy>();

			if ( rare is not null )
			{
				rare.MakeRare();

				if ( MaxRareEnemies > 0 )
				{
					MaxRareEnemies--;
					if ( MaxRareEnemies == 0 )
					{
						RareEnemyChance = 0;
					}
				}
			}
		}
	}
}