test/EnemySpawner.cs
public sealed class EnemySpawner : Component
{
	[Property] public List<EnemyEntry> Enemies { get; set; } = new();

	/// <summary>
	/// Curve that controls spawn interval (in seconds) evaluated over normalized game time (0-1).
	/// </summary>
	[Property] public Curve SpawnInterval { get; set; }

	/// <summary>
	/// Total duration of the game in seconds. Defaults to 30 minutes.
	/// </summary>
	[Property] public float MaxTime { get; set; } = 1800f;

	/// <summary>
	/// Radius around this component's position to find a NavMesh spawn point.
	/// </summary>
	[Property] public float SpawnRadius { get; set; } = 500f;

	private TimeSince TimeSinceLastSpawn { get; set; }

	protected override void OnStart()
	{
		TimeSinceLastSpawn = 0f;
	}

	protected override void OnUpdate()
	{
		var normalizedTime = MathF.Min( (GameManager.Instance?.Timer ?? 0) / MaxTime, 1f );
		var interval = SpawnInterval.Evaluate( normalizedTime );

		if ( TimeSinceLastSpawn >= interval )
		{
			SpawnEnemy( normalizedTime );
			TimeSinceLastSpawn = 0f;
		}
	}

	private void SpawnEnemy( float normalizedTime )
	{
		var available = Enemies.Where( e => normalizedTime >= e.MinGameTimeNormalized ).ToList();
		if ( available.Count == 0 )
			return;

		var totalWeight = available.Sum( e => e.SpawnWeight );
		var roll = Random.Shared.Float( 0f, totalWeight );

		float accumulated = 0f;
		foreach ( var entry in available )
		{
			accumulated += entry.SpawnWeight;
			if ( roll <= accumulated )
			{
				var spawnPos = Scene.NavMesh.GetRandomPoint( Transform.Position, SpawnRadius ) ?? Transform.Position;
				entry.Prefab.Clone( spawnPos + Vector3.Up * 32f );
				break;
			}
		}
	}

	protected override void DrawGizmos()
	{
		Gizmo.Draw.Color = Color.Orange.WithAlpha( 0.3f );
		Gizmo.Draw.LineSphere( Vector3.Zero, SpawnRadius );
	}
}

public class EnemyEntry
{
	[Property] public GameObject Prefab { get; set; }

	/// <summary>
	/// Normalized game time (0-1) at which this enemy becomes available to spawn.
	/// </summary>
	[Property, Range( 0f, 1f )] public float MinGameTimeNormalized { get; set; }

	[Property] public float SpawnWeight { get; set; } = 1f;
}