manager/Manager.Spawning.cs

Manager spawning and item/effect utilities. Implements enemy types and spawn configuration, selects and spawns enemies/items/particles/floating text, handles boss/miniboss timing, weighted spawn logic and various RPC entry points for networked spawning.

NetworkingFile Access
using Sandbox.Diagnostics;
using System;

public enum EnemyType
{
	None, Barrel, BarrelExploding, BarrelMystery, Tree, Zombie, ZombieElite, Exploder, ExploderElite, Spitter, SpitterElite, Spiker, SpikerElite, Charger, ChargerElite, Runner, RunnerElite, Boss, Snaker,
	MinibossAbsorber, MinibossCharger, MinibossCharger2, MinibossExploder, MinibossRunner, MinibossShielder, MinibossSlammer, MinibossSniper, MinibossSpiker, MinibossSpiker2, MinibossSpitter, MinibossTroll, MinibossZapper, MinibossZapper2, MinibossZoner,
	Mushroom, Chest, ChestEvil, Turret, BossInvincibleGenerator, ExploderMini, Sword, ZombieTemporary,
}

public enum EnemyProjectileType { Normal, Fire, Freeze, Poison, Acid, Curse, }

public class EnemySpawnConfig
{
	// Base weight curve (progress = _minutes / BOSS_SPAWN_MINUTES_DEFAULT)

	/// <summary>Minimum progress (0-1) before this enemy can spawn.</summary>
	public float ProgressThreshold;
	/// <summary>Progress value where base weight reaches WeightMax.</summary>
	public float ProgressEnd = 1f;
	/// <summary>Base spawn weight at ProgressThreshold.</summary>
	public float WeightMin;
	/// <summary>Base spawn weight at ProgressEnd.</summary>
	public float WeightMax;
	/// <summary>Easing curve for base weight interpolation.</summary>
	public EasingType BaseEasing = EasingType.Linear;
	/// <summary>Per-difficulty WeightMin values [easy, normal, hard]. Overrides WeightMin if set.</summary>
	public float[] WeightMinByDifficulty;

	// Early spawn incentive (adds weight if few have spawned)

	/// <summary>Minutes after which early spawn bonus starts applying.</summary>
	public float EarlySpawnMinutes;
	/// <summary>Minutes at which early spawn bonus reaches max value.</summary>
	public float EarlySpawnMinutesEnd;
	/// <summary>Max spawned count to still receive early spawn bonus.</summary>
	public int EarlySpawnCountThreshold;
	/// <summary>Bonus weight added at EarlySpawnMinutes.</summary>
	public float EarlySpawnBonusMin;
	/// <summary>Bonus weight added at EarlySpawnMinutesEnd.</summary>
	public float EarlySpawnBonusMax;
	/// <summary>Easing curve for early spawn bonus interpolation.</summary>
	public EasingType EarlySpawnEasing = EasingType.Linear;
	/// <summary>Minimum difficulty for early spawn bonus to apply (0 = all).</summary>
	public int EarlySpawnMinDifficulty;

	// Secondary catch-up bonus (adds weight scaled by how few have spawned)

	/// <summary>Minutes after which catch-up bonus starts applying.</summary>
	public float CatchupMinutes;
	/// <summary>Minutes at which catch-up bonus reaches max value.</summary>
	public float CatchupMinutesEnd;
	/// <summary>Spawned count at which catch-up bonus becomes 0 (spawn factor maps from 0 to this).</summary>
	public int CatchupCountThreshold;
	/// <summary>Catch-up bonus at CatchupMinutes (usually 0).</summary>
	public float CatchupBonusMin;
	/// <summary>Maximum catch-up bonus (multiplied by spawn count factor).</summary>
	public float CatchupBonusMax;
	/// <summary>Minimum difficulty for catch-up bonus to apply (0 = all).</summary>
	public int CatchupMinDifficulty;

	// Late-game multiplier

	/// <summary>Minutes after which late-game multiplier starts applying.</summary>
	public float LateGameStartMinutes = 15f;
	/// <summary>Minutes at which late-game multiplier reaches full value.</summary>
	public float LateGameEndMinutes = 40f;
	/// <summary>Weight multiplier at LateGameEndMinutes (1 = no change).</summary>
	public float LateGameMultiplier = 1f;

	// Population cap

	/// <summary>Max existing count per difficulty [easy, normal, hard] before weight drops.</summary>
	public int[] MaxCountByDifficulty;
	/// <summary>Minimum weight ratio when at population cap (0 = no spawns at cap).</summary>
	public float PopCapMinRatio = 0f;
	/// <summary>Easing curve for population cap weight reduction.</summary>
	public EasingType PopCapEasing = EasingType.Linear;

	// Early threat boost (adds weight when total enemy threat is low)

	/// <summary>Minutes after which threat boost starts applying.</summary>
	public float ThreatBoostMinMinutes;
	/// <summary>Minutes at which threat boost reaches max value.</summary>
	public float ThreatBoostMaxMinutes;
	/// <summary>Bonus weight added at ThreatBoostMinMinutes.</summary>
	public float ThreatBoostWeightMin;
	/// <summary>Bonus weight added at ThreatBoostMaxMinutes.</summary>
	public float ThreatBoostWeightMax;
	/// <summary>Minimum difficulty for threat boost to apply (0 = all).</summary>
	public int ThreatBoostMinDifficulty;

	/// <summary>Final weight multiplier per difficulty [easy, normal, hard].</summary>
	public float[] DifficultyMult = new[] { 1f, 1f, 1f };

	// Special flags

	/// <summary>Minimum difficulty level required to spawn (0 = all difficulties).</summary>
	public int MinDifficulty = 0;
}

public partial class Manager
{
	private const float ITEM_SPAWN_VELOCITY_MIN = 40f;
	private const float ITEM_SPAWN_VELOCITY_MAX = 80f;

	[Sync] public float BossSpawnTime { get; private set; }
	public float BossSpawnTimeOriginal => BOSS_SPAWN_MINUTES_DEFAULT * 60f;
	public const float BOSS_SPAWN_MINUTES_DEFAULT = 13f;

	[Sync] public bool HasSpawnedBoss { get; set; }
	public Boss Boss { get; set; }

	public Dictionary<EnemyType, int> EnemyExistingCounts = new();
	public Dictionary<EnemyType, int> EnemySpawnedCounts = new();
	private float _enemyThreatTotal;
	private float _maxEarlyThreatLevel;

	public int NumEnemies { get; set; }

	public const float MAX_ENEMY_COUNT = 250;
	//public const float MAX_BARREL_COUNT = 6;

	Dictionary<EnemyType, float> _weightedValues = new();

	// Post-boss miniboss spawning
	private TimeSince _timeSinceMinibossCheck;
	private bool _shouldSpawnMinibossNext;

	// Starting Enemy Configuration
	private const float STARTING_ENEMY_CHANCE_EXPERT = 0.5f;  // 50% chance on Expert
	private const float STARTING_ENEMY_CHANCE_CURSED = 0.95f; // 95% chance on Cursed

	static Dictionary<EnemyType, float> StartingEnemyWeightsExpert = CreateStartingEnemyWeightsExpert();
	static Dictionary<EnemyType, float> StartingEnemyWeightsCursed = CreateStartingEnemyWeightsCursed();

	// Weights for Expert difficulty (Difficulty 1)
	static Dictionary<EnemyType, float> CreateStartingEnemyWeightsExpert() => new()
	{
		{ EnemyType.ZombieElite, 15f },
		{ EnemyType.Exploder, 8f },
		{ EnemyType.ExploderElite, 3f },
		{ EnemyType.Spitter, 8f },
		{ EnemyType.SpitterElite, 3f },
		{ EnemyType.Spiker, 6f },
		{ EnemyType.SpikerElite, 2f },
		{ EnemyType.Charger, 5f },
		{ EnemyType.ChargerElite, 2f },
		{ EnemyType.Runner, 7f },
		{ EnemyType.RunnerElite, 2f },
		{ EnemyType.ExploderMini, 6f },
		{ EnemyType.Mushroom, 4f },
		{ EnemyType.ChestEvil, 3f },
	};

	// Weights for Cursed difficulty (Difficulty 2)
	static Dictionary<EnemyType, float> CreateStartingEnemyWeightsCursed() => new()
	{
		{ EnemyType.ZombieElite, 8f },
		{ EnemyType.Exploder, 6f },
		{ EnemyType.ExploderElite, 5f },
		{ EnemyType.Spitter, 6f },
		{ EnemyType.SpitterElite, 5f },
		{ EnemyType.Spiker, 5f },
		{ EnemyType.SpikerElite, 4f },
		{ EnemyType.Charger, 4f },
		{ EnemyType.ChargerElite, 4f },
		{ EnemyType.Runner, 5f },
		{ EnemyType.RunnerElite, 4f },
		{ EnemyType.ExploderMini, 5f },
		{ EnemyType.Mushroom, 5f },
		{ EnemyType.ChestEvil, 5f },
	};

	[ConCmd( "reload_starting_enemy_weights" )]
	public static void ReloadStartingEnemyWeights()
	{
		StartingEnemyWeightsExpert = CreateStartingEnemyWeightsExpert();
		StartingEnemyWeightsCursed = CreateStartingEnemyWeightsCursed();
		Log.Info( "Starting enemy weights reloaded" );
	}

	static Dictionary<EnemyType, EnemySpawnConfig> SpawnConfigs = CreateSpawnConfigs();

	[ConCmd( "reload_spawn_configs" )]
	public static void ReloadSpawnConfigs()
	{
		SpawnConfigs = CreateSpawnConfigs();
		Log.Info( "Spawn configs reloaded" );
	}

	static Dictionary<EnemyType, EnemySpawnConfig> CreateSpawnConfigs() => new()
	{
		[EnemyType.Zombie] = new()
		{
			ProgressThreshold = 0f, ProgressEnd = 1f,
			WeightMin = 100f, WeightMax = 100f,
			LateGameStartMinutes = 1f, LateGameEndMinutes = 30f, LateGameMultiplier = 0.01f,
			DifficultyMult = new[] { 1.05f, 1f, 0.75f },
		},
		[EnemyType.ZombieElite] = new()
		{
			ProgressThreshold = 0.4f, ProgressEnd = 1.4f,
			WeightMinByDifficulty = new[] { 0.1f, 0.15f, 0.175f }, WeightMax = 42f,
			BaseEasing = EasingType.QuadIn,
			EarlySpawnMinutes = 0.1f, EarlySpawnMinutesEnd = 2f,
			EarlySpawnCountThreshold = 4, EarlySpawnBonusMin = 0.075f, EarlySpawnBonusMax = 0.25f,
			ThreatBoostMinMinutes = 0.1f, ThreatBoostMaxMinutes = 1.5f, ThreatBoostWeightMin = 0.05f, ThreatBoostWeightMax = 2f,
			DifficultyMult = new[] { 0.9f, 1.1f, 2f },
		},
		[EnemyType.Exploder] = new()
		{
			ProgressThreshold = 0.04f, ProgressEnd = 0.75f,
			WeightMin = 2.8f, WeightMax = 6.8f,
			EarlySpawnMinutes = 0.1f, EarlySpawnMinutesEnd = 0.1f,
			EarlySpawnCountThreshold = 0, EarlySpawnBonusMin = 0.05f, EarlySpawnBonusMax = 0.05f,
			LateGameStartMinutes = 18f, LateGameEndMinutes = 50f, LateGameMultiplier = 0.01f,
			CatchupMinutes = 1f, CatchupMinutesEnd = 3f, CatchupCountThreshold = 3, CatchupBonusMax = 2f,
			MaxCountByDifficulty = new[] { 12, 12, 12 }, PopCapMinRatio = 0.1f,
		},
		[EnemyType.ExploderElite] = new()
		{
			ProgressThreshold = 0.4f, ProgressEnd = 1.4f,
			WeightMin = 0.025f, WeightMax = 0.8f,
			BaseEasing = EasingType.SineIn,
			EarlySpawnMinutes = 1.5f, EarlySpawnMinutesEnd = 4f,
			EarlySpawnCountThreshold = 1, EarlySpawnBonusMin = 0.05f, EarlySpawnBonusMax = 0.25f,
			EarlySpawnMinDifficulty = 1,
			LateGameStartMinutes = 18f, LateGameEndMinutes = 40f, LateGameMultiplier = 7f,
			MaxCountByDifficulty = new[] { 2, 6, 8 }, PopCapMinRatio = 0f,
			ThreatBoostMinMinutes = 0.5f, ThreatBoostMaxMinutes = 3.5f, ThreatBoostWeightMin = 0.05f, ThreatBoostWeightMax = 0.5f,
			DifficultyMult = new[] { 0.7f, 1.1f, 1.3f },
		},
		[EnemyType.Spitter] = new()
		{
			ProgressThreshold = 0.045f, ProgressEnd = 1f,
			WeightMin = 0.08f, WeightMax = 4.3f,
			EarlySpawnMinutes = 0.1f, EarlySpawnMinutesEnd = 2f,
			EarlySpawnCountThreshold = 2, EarlySpawnBonusMin = 0.05f, EarlySpawnBonusMax = 0.8f,
			EarlySpawnEasing = EasingType.SineIn,
			CatchupMinutes = 2f, CatchupMinutesEnd = 7f, CatchupCountThreshold = 4, CatchupBonusMax = 0.8f,
			LateGameStartMinutes = 15.5f, LateGameEndMinutes = 50f, LateGameMultiplier = 12f,
			MaxCountByDifficulty = new[] { 6, 11, 13 }, PopCapMinRatio = 0.075f,
			ThreatBoostMinMinutes = 0.1f, ThreatBoostMaxMinutes = 2f, ThreatBoostWeightMin = 0.05f, ThreatBoostWeightMax = 2f,
			DifficultyMult = new[] { 0.9f, 1f, 1.2f },
		},
		[EnemyType.SpitterElite] = new()
		{
			ProgressThreshold = 0.25f, ProgressEnd = 1f,
			WeightMin = 0.1f, WeightMax = 1.3f,
			BaseEasing = EasingType.SineIn,
			EarlySpawnMinutes = 1.3f, EarlySpawnMinutesEnd = 5f,
			EarlySpawnCountThreshold = 3, EarlySpawnBonusMin = 0.025f, EarlySpawnBonusMax = 0.05f,
			EarlySpawnMinDifficulty = 1,
			CatchupMinutes = 4f, CatchupMinutesEnd = 10f, CatchupCountThreshold = 5, CatchupBonusMax = 2f,
			CatchupMinDifficulty = 1,
			LateGameStartMinutes = 20f, LateGameEndMinutes = 90f, LateGameMultiplier = 40f,
			MaxCountByDifficulty = new[] { 3, 7, 10 }, PopCapMinRatio = 0.0075f,
			ThreatBoostMinMinutes = 0.3f, ThreatBoostMaxMinutes = 3f, ThreatBoostWeightMin = 0.05f, ThreatBoostWeightMax = 1f,
			ThreatBoostMinDifficulty = 1,
			DifficultyMult = new[] { 0.52f, 1.05f, 1.25f },
		},
		[EnemyType.Spiker] = new()
		{
			ProgressThreshold = 0.3f, ProgressEnd = 1f,
			WeightMin = 0.08f, WeightMax = 3.8f,
			BaseEasing = EasingType.SineIn,
			EarlySpawnMinutes = 1f, EarlySpawnMinutesEnd = 6f,
			EarlySpawnCountThreshold = 2, EarlySpawnBonusMin = 0f, EarlySpawnBonusMax = 0.06f,
			CatchupMinutes = 4f, CatchupMinutesEnd = 7f, CatchupCountThreshold = 4, CatchupBonusMax = 0.5f,
			LateGameStartMinutes = 16f, LateGameEndMinutes = 40f, LateGameMultiplier = 5f,
			MaxCountByDifficulty = new[] { 5, 12, 16 }, PopCapMinRatio = 0.1f,
			ThreatBoostMinMinutes = 0.5f, ThreatBoostMaxMinutes = 2.5f, ThreatBoostWeightMin = 0.05f, ThreatBoostWeightMax = 1f,
			DifficultyMult = new[] { 0.8f, 1.1f, 1.3f },
		},
		[EnemyType.SpikerElite] = new()
		{
			ProgressThreshold = 0.5f, ProgressEnd = 1f,
			WeightMin = 0.027f, WeightMax = 1.7f,
			BaseEasing = EasingType.SineIn,
			EarlySpawnMinutes = 5f, EarlySpawnMinutesEnd = 8f,
			EarlySpawnCountThreshold = 1, EarlySpawnBonusMin = 0.02f, EarlySpawnBonusMax = 0.04f,
			LateGameStartMinutes = 16.5f, LateGameEndMinutes = 40f, LateGameMultiplier = 15f,
			MaxCountByDifficulty = new[] { 3, 6, 10 }, PopCapMinRatio = 0f,
			ThreatBoostMinMinutes = 0.5f, ThreatBoostMaxMinutes = 3.5f, ThreatBoostWeightMin = 0f, ThreatBoostWeightMax = 0.5f,
			DifficultyMult = new[] { 0.65f, 1.1f, 1.3f },
		},
		[EnemyType.Charger] = new()
		{
			ProgressThreshold = 0.45f, ProgressEnd = 1f,
			WeightMin = 0.055f, WeightMax = 1.9f,
			BaseEasing = EasingType.SineIn,
			CatchupMinutes = 4f, CatchupMinutesEnd = 7f, CatchupCountThreshold = 2, CatchupBonusMax = 0.25f,
			LateGameStartMinutes = 16f, LateGameEndMinutes = 40f, LateGameMultiplier = 15f,
			MaxCountByDifficulty = new[] { 3, 5, 7 }, PopCapMinRatio = 0f,
			ThreatBoostMinMinutes = 0.3f, ThreatBoostMaxMinutes = 2.5f, ThreatBoostWeightMin = 0f, ThreatBoostWeightMax = 1f,
			DifficultyMult = new[] { 0.9f, 1.05f, 1.3f },
		},
		[EnemyType.ChargerElite] = new()
		{
			ProgressThreshold = 0.66f, ProgressEnd = 1f,
			WeightMin = 0.045f, WeightMax = 0.95f,
			BaseEasing = EasingType.SineIn,
			EarlySpawnMinutes = 7f, EarlySpawnMinutesEnd = 11f,
			EarlySpawnCountThreshold = 0, EarlySpawnBonusMin = 0f, EarlySpawnBonusMax = 0.025f,
			LateGameStartMinutes = 17f, LateGameEndMinutes = 60f, LateGameMultiplier = 25f,
			MaxCountByDifficulty = new[] { 2, 4, 6 }, PopCapMinRatio = 0f,
			ThreatBoostMinMinutes = 0.3f, ThreatBoostMaxMinutes = 4.5f, ThreatBoostWeightMin = 0f, ThreatBoostWeightMax = 0.35f,
			DifficultyMult = new[] { 0.5f, 1.05f, 1.3f },
		},
		[EnemyType.Runner] = new()
		{
			ProgressThreshold = 0.4f, ProgressEnd = 1f,
			WeightMin = 0.055f, WeightMax = 4.5f,
			BaseEasing = EasingType.SineIn,
			CatchupMinutes = 4f, CatchupMinutesEnd = 8f, CatchupCountThreshold = 4,
			CatchupBonusMin = 0.01f, CatchupBonusMax = 1f, CatchupMinDifficulty = 1,
			LateGameStartMinutes = 16f, LateGameEndMinutes = 40f, LateGameMultiplier = 5f,
			MaxCountByDifficulty = new[] { 13, 30, 40 }, PopCapMinRatio = 0f,
			PopCapEasing = EasingType.QuadIn,
			ThreatBoostMinMinutes = 0.2f, ThreatBoostMaxMinutes = 3f, ThreatBoostWeightMin = 0f, ThreatBoostWeightMax = 1f,
			DifficultyMult = new[] { 0.85f, 1.1f, 1.3f },
		},
		[EnemyType.RunnerElite] = new()
		{
			ProgressThreshold = 0.75f, ProgressEnd = 1f,
			WeightMin = 0.04f, WeightMax = 2.1f,
			BaseEasing = EasingType.SineIn,
			EarlySpawnMinutes = 8f, EarlySpawnMinutesEnd = 8f,
			EarlySpawnCountThreshold = 0, EarlySpawnBonusMin = 0.01f, EarlySpawnBonusMax = 0.01f,
			CatchupMinutes = 7f, CatchupMinutesEnd = 12f, CatchupCountThreshold = 1, CatchupBonusMax = 0.25f,
			LateGameStartMinutes = 17.5f, LateGameEndMinutes = 60f, LateGameMultiplier = 15f,
			MaxCountByDifficulty = new[] { 3, 6, 8 }, PopCapMinRatio = 0f,
			ThreatBoostMinMinutes = 0.5f, ThreatBoostMaxMinutes = 4f, ThreatBoostWeightMin = 0f, ThreatBoostWeightMax = 0.25f,
			DifficultyMult = new[] { 0.6f, 1f, 1.25f },
		},
		[EnemyType.ExploderMini] = new()
		{
			ProgressThreshold = 0.045f, ProgressEnd = 1f,
			WeightMin = 0.055f, WeightMax = 2.1f,
			EarlySpawnMinutes = 0.1f, EarlySpawnMinutesEnd = 2f,
			EarlySpawnCountThreshold = 2, EarlySpawnBonusMin = 0.05f, EarlySpawnBonusMax = 0.3f,
			EarlySpawnEasing = EasingType.SineIn, 
			EarlySpawnMinDifficulty = 1,
			CatchupMinutes = 2f, CatchupMinutesEnd = 7f, CatchupCountThreshold = 4, CatchupBonusMax = 0.6f, CatchupMinDifficulty = 1,
			LateGameStartMinutes = 16.5f, LateGameEndMinutes = 60f, LateGameMultiplier = 13f,
			MaxCountByDifficulty = new[] { 10, 11, 12 }, PopCapMinRatio = 0.1f,
			ThreatBoostMinMinutes = 0.3f, ThreatBoostMaxMinutes = 2.5f, ThreatBoostWeightMin = 0f, ThreatBoostWeightMax = 1f, ThreatBoostMinDifficulty = 1,
			DifficultyMult = new[] { 0.5f, 1.05f, 1.3f },
		},
	};

	public bool ShowPreciseNumbers { get; set; }

	private float _minutes;

	void SpawnStartingThings()
	{
		EnemyExistingCounts.Clear();
		EnemySpawnedCounts.Clear();
		_enemyThreatTotal = 0f;

		switch( Difficulty )
		{
			case 0: default: _maxEarlyThreatLevel = 1.5f; break;
			case 1: _maxEarlyThreatLevel = 5f; break;
			case 2: _maxEarlyThreatLevel = 15f; break;
		}

		_minutes = 0f;

		foreach ( EnemyType value in Enum.GetValues( typeof( EnemyType ) ) )
		{
			if ( (int)value == 0 ) continue;
			EnemyExistingCounts[value] = 0;
			EnemySpawnedCounts[value] = 0;
		}

		for ( int i = 0; i < 3; i++ )
			SpawnEnemyAtRandomPos( EnemyType.Barrel, spawnInstantly: true );

		for ( int i = 0; i < 2; i++ )
			SpawnEnemyAtRandomPos( EnemyType.BarrelExploding, spawnInstantly: true );

		for ( int i = 0; i < 10; i++ )
			SpawnEnemyAtRandomPos( EnemyType.Tree, spawnInstantly: true );

		// Spawn starting enemy for Expert and Cursed difficulties
		SpawnStartingEnemy();

		for ( int i = 0; i < 5; i++ )
		{
			//float dist = 720f;
			//SpawnEnemy( "zombie", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "zombie_elite", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//	SpawnEnemy( "barrel", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ), spawnInstantly: true );
			//	SpawnEnemy( "tree", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ), spawnInstantly: true );
		}

		for ( int i = 0; i < 2; i++ )
		{
			//float dist = 720f;
			//SpawnEnemy( "runner", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "runner_elite", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
		}

		for ( int i = 0; i < 1; i++ )
		{
			//float dist = 720f;
			//SpawnEnemy( "exploder", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "exploder_elite", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "spitter", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "spitter_elite", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "spiker", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "spiker_elite", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
		}

		for ( int i = 0; i < 1; i++ )
		{
			//float dist = 720f;
			//SpawnEnemy( "charger", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
			//SpawnEnemy( "charger_elite", new Vector2( Game.Random.Float( -1f, 1f ) * dist, Game.Random.Float( -1f, 1f ) * dist ) );
		}

		//SpawnEnemy( "miniboss_spitter", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_troll", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_runner", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_charger", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_slammer", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_shielder", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_spiker", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_exploder", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_zapper", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );
		//SpawnEnemy( "miniboss_zapper_2", new Vector2( Game.Random.Float( -1f, 1f ) * 33, Game.Random.Float( -1f, 1f ) * 33 ) );

		//SpawnBoss();

		//SpawnSnaker( pos: new Vector2( Game.Random.Float( -300f, 300f ), Game.Random.Float( -300f, 300f ) ), numSegments: 12 );
	}

	/// <summary>
	/// Attempts to spawn a starting enemy for Expert and Cursed difficulties.
	/// Returns true if an enemy was spawned.
	/// </summary>
	private bool SpawnStartingEnemy()
	{
		if ( DontSpawnRandomEnemies )
			return false;

		// Only spawn on Expert (1) or Cursed (2) difficulty
		if ( Difficulty < 1 )
			return false;

		// Get spawn chance based on difficulty
		float spawnChance = Difficulty == 1 ? STARTING_ENEMY_CHANCE_EXPERT : STARTING_ENEMY_CHANCE_CURSED;

		// Roll for spawn chance
		if ( Game.Random.Float( 0f, 1f ) > spawnChance )
			return false;

		// Get weights based on difficulty
		var weights = Difficulty == 1 ? StartingEnemyWeightsExpert : StartingEnemyWeightsCursed;

		// Select random enemy type using weighted random
		var enemyType = Utils.GetWeightedRandom( weights );

		if ( enemyType == EnemyType.None )
			return false;

		// Spawn the enemy at a random position
		SpawnEnemyAtRandomPos( enemyType, spawnInstantly: false );

		return true;
	}

	Player SpawnPlayer( Connection channel, int lobbySlot = -1 )
	{
		Assert.True( Networking.IsHost, $"Client tried to SpawnPlayer: {channel.DisplayName}" );

		var pos = new Vector3( Game.Random.Float( -30f, 30f ), Game.Random.Float( -30f, 30f ), 0f );
		var playerGo = GameObject.Clone( "prefabs/player.prefab", new CloneConfig { Name = channel.DisplayName, StartEnabled = true, Transform = new Transform( pos ) } );
		var player = playerGo.Components.Get<Player>( true );

		playerGo.NetworkSpawn( channel );

		if(lobbySlot != -1)
		{
			player.LobbySlotIndex = lobbySlot;
		}

		return player;
	}

	void HandleEnemySpawn()
	{
		Assert.True( Networking.IsHost );

		var t = ElapsedTime;
		var minutes = t / 60f;

		var spawnTime = Utils.Map( NumEnemies, 0, MAX_ENEMY_COUNT, 0.025f, 0.33f, EasingType.QuadOut )
			* Utils.Map( minutes, 0f, 1.25f, 1.6f, 1f )
			* Utils.Map( minutes, 0f, 4f, 3.2f, 1f )
			* Utils.Map( minutes, 10f, 15f, 1f, 0.75f )
			* Utils.Map( minutes, 15f, 40f, 1f, 0.5f );

		spawnTime *= Utils.Map( AlivePlayers.Count, 1, 3, 1f, 0.6f );

		if ( HasSpawnedBoss )
		{
			// slow spawning right after boss spawn, then speed back up
			var lerpAmount = Utils.Map( t, BossSpawnTime, BossSpawnTime + Utils.Select( Difficulty, 2f, 1.5f, 1.25f ), 1f, 0f, EasingType.SineIn );
			var slowSpawnFactor = Utils.Select( Difficulty, 7f, 3.5f, 2.2f );
			spawnTime *= MathX.Lerp( 1f, slowSpawnFactor, lerpAmount );

			//Log.Info( $"spawnTime after boss adjustment: {spawnTime} slowSpawnFactor: {slowSpawnFactor} lerpAmount: {lerpAmount}" );
		}

		if ( _timeSinceSpawnEnemy > spawnTime )
		{
			SpawnRandomEnemy();
			_timeSinceSpawnEnemy = 0f;
		}

		HandleBossSpawn();
	}

	void HandleBossSpawn()
	{
		if ( HasSpawnedBoss )
			return;

		BossSpawnTime = BossSpawnTimeOriginal;

		// todo: only calculate this when a player changes boss spawn time
		foreach ( Player player in Players )
			BossSpawnTime -= player.GetSyncStat( PlayerStat.BossSpawnSecondsEarly );

		if ( ElapsedTime > BossSpawnTime )
			SpawnBoss();
	}

	void UpdateMinibossSpawnFlag()
	{
		if ( !HasSpawnedBoss )
			return;

		// Time since boss (in minutes) determines how frequent miniboss replacements are
		float minutesSinceBoss = (ElapsedTime - BossSpawnTime) / 60f;

		// Interval decreases over time
		var lerpTimeMinutes = Utils.Select( Difficulty, 55f, 45f, 40f );
		var startIntervalSeconds = Utils.Select( Difficulty, 150f, 80f, 55f );
		float interval = Utils.Map( minutesSinceBoss, 0f, lerpTimeMinutes, startIntervalSeconds, 0f, EasingType.Linear );

		if ( _timeSinceMinibossCheck > interval )
		{
			_shouldSpawnMinibossNext = true;
			_timeSinceMinibossCheck = 0f;
		}

		if ( ShowDebug )
		{
			var str = $"_timeSinceMinibossCheck: {_timeSinceMinibossCheck}\nlerpTimeMinutes: {lerpTimeMinutes}\nstartIntervalSeconds: {startIntervalSeconds}\nminutesSinceBoss: {minutesSinceBoss}interval: {interval}_shouldSpawnMinibossNext: {_shouldSpawnMinibossNext}";

			Gizmo.Draw.Color = Color.White.WithAlpha( 0.5f );
			Gizmo.Draw.ScreenText( str, new Vector2( 140, 90 ) );
		}
	}

	void SpawnRandomEnemy()
	{
		if ( DontSpawnRandomEnemies )
			return;

		if ( NumEnemies >= MAX_ENEMY_COUNT )
			return;

		// Check for miniboss replacement
		UpdateMinibossSpawnFlag();
		if ( _shouldSpawnMinibossNext )
		{
			_shouldSpawnMinibossNext = false;
			SpawnMiniboss( GetRandomSpawnPos() );
			return;
		}

		_weightedValues.Clear();

		var t = ElapsedTime;
		t *= GetTimeFactor( t );

		switch ( Difficulty )
		{
			case 0:
			default:
				if ( HasSpawnedBoss && t > BossSpawnTime )
					t = BossSpawnTime + (t - BossSpawnTime) * 0.45f;
				else
					t *= 0.65f;
				break;
			case 1:
				break;
		}

		_minutes = t / 60f;

		AddEnemySpawnWeight( EnemyType.Barrel );
		AddEnemySpawnWeight( EnemyType.BarrelExploding );
		AddEnemySpawnWeight( EnemyType.Tree );
		AddEnemySpawnWeight( EnemyType.Zombie );
		AddEnemySpawnWeight( EnemyType.ZombieElite );
		AddEnemySpawnWeight( EnemyType.Exploder );
		AddEnemySpawnWeight( EnemyType.ExploderElite );
		AddEnemySpawnWeight( EnemyType.Spitter );
		AddEnemySpawnWeight( EnemyType.SpitterElite );
		AddEnemySpawnWeight( EnemyType.Spiker );
		AddEnemySpawnWeight( EnemyType.SpikerElite );
		AddEnemySpawnWeight( EnemyType.Charger );
		AddEnemySpawnWeight( EnemyType.ChargerElite );
		AddEnemySpawnWeight( EnemyType.Runner );
		AddEnemySpawnWeight( EnemyType.RunnerElite );

		if ( Difficulty >= 1 || _minutes >= 17f )
			AddEnemySpawnWeight( EnemyType.ExploderMini );

		var total = 0f;
		foreach ( var pair in _weightedValues )
			total += pair.Value;

		var rand = Game.Random.Float( 0f, total );

		//Log.Info( $"-------------------- total: {total} rand: {rand}" );

		var currentTotal = 0f;
		foreach ( var pair in _weightedValues )
		{
			var enemyType = pair.Key;
			currentTotal += pair.Value;
			//Log.Info( $"{enemyType} currentTotal: {currentTotal}" );
			if ( rand < currentTotal )
			{
				//Log.Info( $"spawning {enemyType}!" );
				SpawnEnemyNearPlayerOrRandom( enemyType );
				break;
			}
		}

		// todo: randomize chances over time for each enemy, depending on the run? (to encourage picking counter perks)
		// or at least favor some types
	}

	float GetTimeFactor( float t )
	{
		var timeFactor = 1f;

		var numEnemyKills = 0;
		foreach ( var player in Players )
			numEnemyKills += (int)player.GetSyncStat( PlayerStat.NumEnemiesKilled );

		var minutes = t / 60f;
		var expectedKills = Utils.Map( minutes, 0f, BOSS_SPAWN_MINUTES_DEFAULT, 0f, 1000 ) * Utils.Map( Players.Count, 1, MAX_PLAYERS, 1f, 2f );

		if ( numEnemyKills > expectedKills )
			timeFactor *= numEnemyKills / expectedKills;

		return timeFactor;
	}

	void AddEnemySpawnWeight( EnemyType enemyType )
	{
		var chance = GetEnemySpawnWeight( enemyType );
		if ( chance > 0f )
			_weightedValues.Add( enemyType, chance );
	}

	float GetEnemySpawnWeight( EnemyType enemyType )
	{
		// Special cases with unique logic
		if ( enemyType == EnemyType.Barrel )
			return GetBarrelSpawnWeight( _minutes, exploding: false );
		if ( enemyType == EnemyType.BarrelExploding )
			return GetBarrelSpawnWeight( _minutes, exploding: true );
		if ( enemyType == EnemyType.Tree )
			return Utils.Map( EnemyExistingCounts[enemyType], 0, 10, 0.03f, 0f, EasingType.QuadOut );

		if ( !SpawnConfigs.TryGetValue( enemyType, out var cfg ) )
			return 0f;
		if ( Difficulty < cfg.MinDifficulty )
			return 0f;

		return CalculateSpawnWeight( cfg, enemyType );
	}

	float CalculateSpawnWeight( EnemySpawnConfig cfg, EnemyType enemyType )
	{
		var progress = _minutes / BOSS_SPAWN_MINUTES_DEFAULT;

		// Base weight from progress (0 if below threshold, but continue to apply bonuses)
		float weight;
		if ( progress < cfg.ProgressThreshold )
		{
			weight = 0f;
		}
		else
		{
			var weightMin = cfg.WeightMinByDifficulty != null
				? cfg.WeightMinByDifficulty[Difficulty]
				: cfg.WeightMin;

			weight = Utils.Map( progress, cfg.ProgressThreshold, cfg.ProgressEnd,
				weightMin, cfg.WeightMax, cfg.BaseEasing );
		}

		// ZombieElite mid-progress boost
		if ( enemyType == EnemyType.ZombieElite && progress > 0.8f )
			weight *= Utils.Map( progress, 0.8f, 1f, 1f, 1.1f, EasingType.Linear );

		// Early spawn incentive
		if ( cfg.EarlySpawnBonusMax > 0 && Difficulty >= cfg.EarlySpawnMinDifficulty
			&& _minutes > cfg.EarlySpawnMinutes && EnemySpawnedCounts[enemyType] <= cfg.EarlySpawnCountThreshold )
		{
			weight += Utils.Map( _minutes, cfg.EarlySpawnMinutes, cfg.EarlySpawnMinutesEnd,
				cfg.EarlySpawnBonusMin, cfg.EarlySpawnBonusMax, cfg.EarlySpawnEasing );
		}

		// Charger has difficulty-dependent early spawn (special case)
		if ( enemyType == EnemyType.Charger )
		{
			var earlyMinutes = Utils.Select( Difficulty, 5f, 3f, 1.5f );
			var earlyCount = Utils.Select( Difficulty, 0, 2, 4 );
			if ( _minutes > earlyMinutes && EnemySpawnedCounts[enemyType] <= earlyCount )
				weight += Utils.Select( Difficulty, 0.02f, 0.25f, 0.5f );
		}

		// Secondary catch-up bonus (spawn factor naturally goes to 0 at threshold)
		if ( cfg.CatchupBonusMax > 0 && Difficulty >= cfg.CatchupMinDifficulty && _minutes > cfg.CatchupMinutes )
		{
			var catchupBonus = Utils.Map( _minutes, cfg.CatchupMinutes, cfg.CatchupMinutesEnd, cfg.CatchupBonusMin, cfg.CatchupBonusMax );
			var spawnFactor = Utils.Map( EnemySpawnedCounts[enemyType], 0, cfg.CatchupCountThreshold, 1f, 0f );
			weight += catchupBonus * MathF.Max( 0f, spawnFactor );
		}

		// Late-game multiplier
		if ( cfg.LateGameMultiplier != 1f && _minutes > cfg.LateGameStartMinutes )
		{
			weight *= Utils.Map( _minutes, cfg.LateGameStartMinutes, cfg.LateGameEndMinutes,
				1f, cfg.LateGameMultiplier, EasingType.Linear );
		}

		// Population cap
		if ( cfg.MaxCountByDifficulty != null )
		{
			var maxCount = cfg.MaxCountByDifficulty[Difficulty];
			weight *= Utils.Map( EnemyExistingCounts[enemyType], 0, maxCount,
				1f, cfg.PopCapMinRatio, cfg.PopCapEasing );
		}

		// Early threat boost
		if ( cfg.ThreatBoostWeightMax > 0 && Difficulty >= cfg.ThreatBoostMinDifficulty
			&& _minutes > cfg.ThreatBoostMinMinutes && _enemyThreatTotal < _maxEarlyThreatLevel )
		{
			weight += Utils.Map( _minutes, cfg.ThreatBoostMinMinutes, cfg.ThreatBoostMaxMinutes,
				cfg.ThreatBoostWeightMin, cfg.ThreatBoostWeightMax );
		}

		// Difficulty multiplier
		weight *= cfg.DifficultyMult[Difficulty];

		return weight;
	}

	float GetBarrelSpawnWeight( float minutes, bool exploding )
	{
		var numBarrels = EnemyExistingCounts[EnemyType.Barrel] + EnemyExistingCounts[EnemyType.BarrelExploding];
		//if ( numBarrels >= MAX_BARREL_COUNT )
		//	return 0f;

		float barrelWeight = minutes < 0.3f ? 0f : Utils.Map( minutes, 0.3f, 10f, 0.45f, 8f );
		float additionalBarrelChance = 0f;
		foreach ( var player in AlivePlayers )
			additionalBarrelChance += player.GetSyncStat( PlayerStat.BarrelChanceAdditional );

		barrelWeight *= (1f + additionalBarrelChance);

		if ( exploding )
			barrelWeight *= 0.3f;

		barrelWeight *= Utils.Map( numBarrels, 0, 12, 1f, 0.05f, EasingType.SineIn );

		return barrelWeight;
	}

	public Vector2 GetRandomSpawnPos( float buffer = 0f )
	{
		return new Vector2( Game.Random.Float( BOUNDS_MIN_SPAWN.x + buffer, BOUNDS_MAX_SPAWN.x - buffer ), Game.Random.Float( BOUNDS_MIN_SPAWN.y + buffer, BOUNDS_MAX_SPAWN.y - buffer ) );
	}

	public void SpawnEnemyAtRandomPos( EnemyType enemyType, bool spawnInstantly = false )
	{
		SpawnEnemy( enemyType, GetRandomSpawnPos(), spawnInstantly );
	}

	void SpawnEnemyNearPlayerOrRandom( EnemyType enemyType )
	{
		var cursedPlayers = AlivePlayers.Where( p => p.GetSyncStat( PlayerStat.EnemySpawnNearPlayer ) > 0f ).ToList();
		var spawnNearbyChance = 0.5f;
		if ( cursedPlayers.Count > 0 && Game.Random.Float( 0f, 1f ) < spawnNearbyChance )
		{
			var target = Game.Random.FromList( cursedPlayers );
			var dir = Utils.GetRandomVector();
			var dist = Game.Random.Float( 0f, 100f );
			var pos = target.Position2D + dir * dist + target.Velocity * Game.Random.Float( 0f, 3f );
			SpawnEnemy( enemyType, pos );
		}
		else
		{
			SpawnEnemyAtRandomPos( enemyType );
		}
	}

	[Rpc.Host( NetFlags.Reliable )]
	public void SpawnEnemyRpc( EnemyType enemyType, Vector2 pos, bool spawnInstantly = false, float rotAngle = 0f, bool playSfx = true, Player playerCreator = null )
	{
		SpawnEnemy( enemyType, pos, spawnInstantly, rotAngle, playSfx, playerCreator );
	}

	public void SpawnEnemy( EnemyType enemyType, Vector2 pos, bool spawnInstantly = false, float rotAngle = 0f, bool playSfx = true, Player playerCreator = null )
	{
		if ( !IsProxy )
		{
			EnemyExistingCounts[enemyType]++;
			EnemySpawnedCounts[enemyType]++;
			_enemyThreatTotal += GetThreatLevelForEnemyType( enemyType );
		}

		SpawnEnemy( GetStringForEnemyType( enemyType ), pos, spawnInstantly, rotAngle, playSfx, playerCreator );
	}

	[Rpc.Broadcast]
	public void SpawnEnemy( string name, Vector2 pos, bool spawnInstantly = false, float rotAngle = 0f, bool playSfx = true, Player playerCreator = null )
	{
		if ( name != "boss" ) // boss spawns out of bounds and then moves in, so don't clamp its spawn position
			pos = ClampPosToBounds( pos );

		//if ( IsProxy )
		if ( !Networking.IsHost )
		{
			if ( name == "chest" )
				Log.Warning( $"[ChestSpawn] Non-host received chest SpawnEnemy RPC — skipping (IsProxy={IsProxy})" );
			return;
		}

		if( name == "boss" )
		{
			if(HasSpawnedBoss)
			{
				Log.Warning("Spawning a boss when one is already active!");
			}
		}

		if( name == "tree" )
		{
			if( Manager.Instance.Difficulty == 1 )
				name = "tree_red";
			else if ( Manager.Instance.Difficulty == 2 )
				name = "tree_snow";
		}
		else if( name == "spitter" )
		{
			var t = ElapsedTime;
			var minutes = t / 60f;
			var projectileType = GetRandomEnemyProjectileType( minutes );
			name = GetEnemyNameForProjectileType( projectileType, elite: false );
		}
		else if ( name == "spitter_elite" )
		{
			var t = ElapsedTime;
			var minutes = t / 60f;
			var projectileType = GetRandomEnemyProjectileType( minutes );
			name = GetEnemyNameForProjectileType( projectileType, elite: true );
		}

		var zPos = spawnInstantly ? 0f : -150f;
		var yaw = MathF.Abs( rotAngle ) > 0f ? rotAngle : Game.Random.Float( 0f, 360f );
		var enemyGo = GameObject.Clone( $"prefabs/enemies/{name}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( position: new Vector3( pos.x, pos.y, zPos ), rotation: new Angles( 0f, yaw, 0f ) ) } );

		if ( enemyGo == null )
		{
			Log.Error( $"Failed to spawn enemy '{name}' - prefab not found!" );
			return;
		}

		//enemyGo.Transform.ClearInterpolation();

		var enemy = enemyGo.GetComponent<Enemy>();
		enemy.Health = enemy.MaxHealth;

		enemy.ShouldSpawnInstantly = spawnInstantly;

		if ( playerCreator.IsValid() )
			enemy.PlayerCreator = playerCreator;

		if ( !spawnInstantly && enemy.ShouldCreateSpawnClouds )
		{
			var cloudPos = new Vector3( pos.x + Game.Random.Float( -5f, 5f ), pos.y + Game.Random.Float( -5f, 5f ), 15f );
			GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( cloudPos ) } );

			if ( playSfx )
				PlaySfxNearby( "zombie.dirt", pos, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.4f, maxDist: 350f );
		}

		enemyGo.NetworkSpawn();

		if ( name == "chest" )
			Log.Info( $"[ChestSpawn] Chest NetworkSpawned successfully at {pos}" );

		NumEnemies++;
	}

	public EnemyProjectileType GetRandomEnemyProjectileType( float minutes )
	{
		float startAmount;
		float endAmount;

		switch ( Difficulty )
		{
			case 0:
			default:
				startAmount = 0f;
				endAmount = 0.25f;
				break;
			case 1:
				startAmount = 0.1f;
				endAmount = 1f;
				break;
			case 2:
				startAmount = 0.25f;
				endAmount = 1.1f;
				break;
		}

		var specialWeight = Utils.Map( minutes, 0f, BOSS_SPAWN_MINUTES_DEFAULT, startAmount, endAmount, EasingType.Linear );

		if ( minutes > BOSS_SPAWN_MINUTES_DEFAULT )
			specialWeight *= Utils.Map( minutes, BOSS_SPAWN_MINUTES_DEFAULT, 60f, 1f, 10f, EasingType.Linear );

		var weights = new Dictionary<EnemyProjectileType, float>
		{
			{ EnemyProjectileType.Normal, 1f },
			{ EnemyProjectileType.Acid, specialWeight * Utils.Select( Difficulty, 0f, 0.6f, 0.75f ) },
			{ EnemyProjectileType.Fire, specialWeight },
			{ EnemyProjectileType.Freeze, specialWeight },
			{ EnemyProjectileType.Poison, specialWeight },
		};

		var canSpawnCurse = (Difficulty == 0 && minutes > 20f) || (Difficulty == 1 && minutes > 15f) || (Difficulty >= 2 && minutes > 10f);
		if ( canSpawnCurse )
		{
			var curseWeight = specialWeight * Utils.Select( Difficulty, 0.25f, 0.5f, 0.8f )
				* Utils.Map( minutes, Utils.Select( Difficulty, 22f, 19f, 18f ), 60f, 1f, 10f )
				* Utils.Map( minutes, 60f, 120f, 1f, 30f );

			weights.Add( EnemyProjectileType.Curse, curseWeight );
		}
			
		var validWeights = weights.Where( kv => kv.Value > 0f ).ToList();

		if ( validWeights.Count == 0 )
			return EnemyProjectileType.Normal;

		float totalWeight = validWeights.Sum( kv => kv.Value );
		float rand = Game.Random.Float( 0f, totalWeight );

		float cumulative = 0f;
		foreach ( var kv in validWeights )
		{
			cumulative += kv.Value;

			if ( rand < cumulative )
				return kv.Key;
		}

		return validWeights.Last().Key;
	}

	public static string GetEnemyNameForProjectileType( EnemyProjectileType projectileType, bool elite )
	{
		var name = elite ? "spitter_elite" : "spitter";

		switch ( projectileType )
		{
			case EnemyProjectileType.Normal: default: break;
			case EnemyProjectileType.Fire: name += "_fire"; break;
			case EnemyProjectileType.Freeze: name += "_freeze"; break;
			case EnemyProjectileType.Poison: name += "_poison"; break;
			case EnemyProjectileType.Acid: name += "_acid"; break;
			case EnemyProjectileType.Curse: name += "_curse"; break;
		}

		return name;
	}

	public void RemoveEnemy( EnemyType enemyType )
	{
		EnemyExistingCounts[enemyType]--;

		NumEnemies--;

		if ( !HasSpawnedBoss )
		{
			var minutes = ElapsedTime / 60f;
			if ( Game.Random.Float( 0f, 1f ) < Utils.Map( NumEnemies, 0, MAX_ENEMY_COUNT, 1f, 0f, EasingType.SineIn ) * Utils.Map( minutes, 0f, 10f, 0f, 1f ) )
				SpawnRandomEnemy();
		}
	}

	public void SpawnBoss()
	{
		var spawnPos = new Vector2( Game.Random.Float( -400f, 400f ), 2500f );
		SpawnEnemy( EnemyType.Boss, spawnPos );
		PlaySfxNearbyRpcImportant( "boss.fanfare", Vector2.Zero, pitch: 1f, volume: 1.4f, maxDist: 2000f );

		ShakeCamRpc( strength: 2.5f, 1f, EasingType.Linear, useRealTime: true );

		HasSpawnedBoss = true;
		_timeSinceMinibossCheck = 0f;
	}

	public void SpawnMiniboss( Vector2 pos, bool spawnInstantly = false )
	{
		EnemyType enemyType;

		int rand = Game.Random.Int( 0, 14 );
		switch ( rand )
		{
			case 0: default: enemyType = EnemyType.MinibossAbsorber; break;
			case 1: enemyType = EnemyType.MinibossCharger; break;
			case 2: enemyType = EnemyType.MinibossCharger2; break;
			case 3: enemyType = EnemyType.MinibossExploder; break;
			case 4: enemyType = EnemyType.MinibossRunner; break;
			case 5: enemyType = EnemyType.MinibossShielder; break;
			case 6: enemyType = EnemyType.MinibossSlammer; break;
			case 7: enemyType = EnemyType.MinibossSniper; break;
			case 8: enemyType = EnemyType.MinibossSpiker; break;
			case 9: enemyType = EnemyType.MinibossSpiker2; break;
			case 10: enemyType = EnemyType.MinibossSpitter; break;
			case 11: enemyType = EnemyType.MinibossTroll; break;
			case 12: enemyType = EnemyType.MinibossZapper; break;
			case 13: enemyType = EnemyType.MinibossZapper2; break;
			case 14: enemyType = EnemyType.MinibossZoner; break;
		}

		SpawnEnemy( enemyType, pos, spawnInstantly );
	}

	[Rpc.Broadcast]
	public void SpawnSnaker( Vector2 pos, int numSegments )
	{
		var cloudPos = new Vector3( pos.x + Game.Random.Float( -15f, 15f ), pos.y + Game.Random.Float( -15f, 15f ), 15f );
		GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( cloudPos ) } );

		PlaySfxNearby( "zombie.dirt", pos, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f, maxDist: 350f );

		Snaker curr, prev = null;
		Vector2 startPos = new Vector2( Game.Random.Float( -300f, 300f ), Game.Random.Float( -300f, 300f ) );
		float angle = Game.Random.Float( 0f, 360f );
		Vector2 angleVec = Utils.GetVector2FromAngleDegrees( angle );
		for ( int i = 0; i < numSegments; i++ )
		{
			curr = SpawnSnakerSegment( pos - angleVec * 25f * i, angle );
			if ( prev != null )
				curr.AssignLeader( prev );

			curr.NumSegments = curr.NumSegmentsStart = numSegments;

			prev = curr;
		}
	}

	public Snaker SpawnSnakerSegment( Vector2 pos, float rotAngle = 0f )
	{
		pos = ClampPosToBounds( pos );

		//if ( !spawnInstantly )
		//{
		//	var cloudPos = new Vector3( pos.x + Game.Random.Float( -15f, 15f ), pos.y + Game.Random.Float( -15f, 15f ), 10f );
		//	GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( cloudPos ) } );

		//	if ( playSfx )
		//		PlaySfxNearby( "zombie.dirt", pos, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f, maxDist: 350f );
		//}

		var zPos = -100f;
		var yaw = MathF.Abs( rotAngle ) > 0f ? rotAngle : Game.Random.Float( 0f, 360f );
		var enemyGo = GameObject.Clone( $"prefabs/enemies/snaker.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( position: new Vector3( pos.x, pos.y, zPos ), rotation: new Angles( 0f, yaw, 0f ) ) } );
		var enemy = enemyGo.GetComponent<Enemy>();

		enemyGo.NetworkSpawn();

		NumEnemies++;

		return enemy as Snaker;
	}

	[Rpc.Host]
	public void SpawnCoinRpc( Vector2 pos, int value, Vector2 dir, Player magnetizePlayer = null )
	{
		SpawnCoin( pos, value, dir, magnetizePlayer );
	}

	public void SpawnCoin( Vector2 pos, int value, Vector2 dir, Player magnetizePlayer = null )
	{
		Assert.True( Networking.IsHost );

		var coinGo = GameObject.Clone( "prefabs/items/coin.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 0f ) ) } );

		var coin = coinGo.GetComponent<Coin>();
		coin.Velocity = dir * Game.Random.Float( ITEM_SPAWN_VELOCITY_MIN, ITEM_SPAWN_VELOCITY_MAX );
		coin.SetValue( value );

		coin.Fly(
			targetPos: pos + dir * Game.Random.Float( 30f, 70f ),
			time: Game.Random.Float( 0.4f, 0.55f ),
			height: Game.Random.Float( 50f, 120f )
		);

		coinGo.NetworkSpawn();

		if ( magnetizePlayer.IsValid() )
			coin.MagnetizeRpc( magnetizePlayer );
	}

	[Rpc.Host]
	public void SpawnCoinRpc( Vector2 pos, int value, Player targetPlayer, float time, float height )
	{
		SpawnCoin( pos, value, targetPlayer, time, height );
	}

	public void SpawnCoin( Vector2 pos, int value, Player targetPlayer, float time, float height )
	{
		Assert.True( Networking.IsHost );

		var coinGo = GameObject.Clone( "prefabs/items/coin.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 0f ) ) } );

		var coin = coinGo.GetComponent<Coin>();

		coin.FlyToPlayer( targetPlayer, time, height );

		var dir = targetPlayer.IsValid
			? (targetPlayer.Position2D - pos).Normal
			: Utils.GetRandomVector();

		coin.Velocity = dir * Game.Random.Float( ITEM_SPAWN_VELOCITY_MIN, ITEM_SPAWN_VELOCITY_MAX );
		coin.SetValue( value );

		coinGo.NetworkSpawn();
	}

	// todo: needs to support zPos, so if player spawns a perk item while in the air, it'll look right

	[Rpc.Host]
	public void SpawnPerkItemRpc( int typeIdentity, Vector2 pos, Vector2 dir, Player playerToTarget = null )
	{
		SpawnPerkItem( PerkManager.IdentityToType( typeIdentity ), pos, dir, playerToTarget );
	}

	[Rpc.Host]
	public void SpawnPerkItemAtRpc( int typeIdentity, Vector2 startPos, Vector2 endPos, Player playerToTarget = null )
	{
		SpawnPerkItemAt( PerkManager.IdentityToType( typeIdentity ), startPos, endPos, playerToTarget );
	}

	public void SpawnPerkItemAt( TypeDescription perkType, Vector2 startPos, Vector2 endPos, Player playerToTarget = null )
	{
		Assert.True( Networking.IsHost );

		var perkGo = GameObject.Clone( "prefabs/items/perk_item.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( startPos.x, startPos.y, 0f ) ) } );

		var perkItem = perkGo.GetComponent<PerkItem>();

		var dist = (endPos - startPos).Length;
		perkItem.Fly(
			targetPos: endPos,
			time: Math.Clamp( dist / 600f, 0.4f, 1.5f ),
			height: Game.Random.Float( dist * 0.4f, dist * 0.6f )
		);

		perkItem.PlayerToTarget = playerToTarget;

		perkGo.NetworkSpawn();

		perkItem.Setup( PerkManager.TypeToIdentity( perkType ) );
	}

	public void SpawnPerkItem( TypeDescription perkType, Vector2 pos, Vector2 dir, Player playerToTarget = null )
	{
		Assert.True( Networking.IsHost );

		var perkGo = GameObject.Clone( "prefabs/items/perk_item.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 0f ) ) } );

		var perkItem = perkGo.GetComponent<PerkItem>();
		perkItem.Velocity = dir * Game.Random.Float( ITEM_SPAWN_VELOCITY_MIN, ITEM_SPAWN_VELOCITY_MAX );

		perkItem.Fly(
			targetPos: pos + dir * Game.Random.Float( 60f, 80f ),
			time: Game.Random.Float( 0.4f, 0.55f ),
			height: Game.Random.Float( 50f, 120f )
		);

		perkItem.PlayerToTarget = playerToTarget;

		perkGo.NetworkSpawn();

		perkItem.Setup( PerkManager.TypeToIdentity( perkType ) );
	}

	public Boomerang SpawnBoomerang( Vector2 pos, Vector2 velocity, float damage, Player shooter )
	{
		var boomerangGo = GameObject.Clone( "prefabs/boomerang.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 40f ) ) } );
		var boomerang = boomerangGo.Components.Get<Boomerang>( true );

		boomerang.Velocity = velocity;
		boomerang.Damage = damage;
		boomerang.Shooter = shooter;

		boomerangGo.NetworkSpawn( shooter.Network.Owner );

		return boomerang;
	}

	[Rpc.Host]
	public void SpawnItemRpc( string name, Vector2 pos, Vector2 dir, bool fly = true, bool addVel = true )
	{
		SpawnItem( name, pos, dir );
	}

	public void SpawnItem( string name, Vector2 pos, Vector2 dir, bool fly = true, bool addVel = true )
	{
		Assert.True( Networking.IsHost );

		var itemGo = GameObject.Clone( $"prefabs/items/{name}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, -100f ) ) } );

		var item = itemGo.GetComponent<Item>();

		if ( dir.LengthSquared > 0f )
		{
			if ( addVel )
				item.Velocity = dir * Game.Random.Float( ITEM_SPAWN_VELOCITY_MIN, ITEM_SPAWN_VELOCITY_MAX );

			if ( fly )
			{
				item.Fly(
					targetPos: pos + dir * Game.Random.Float( 70f, 120f ),
					time: Game.Random.Float( 0.4f, 0.55f ),
					height: Game.Random.Float( 50f, 120f )
				);
			}
		}

		if ( name == "magnet" )
			TimeSinceMagnet = 0f;

		itemGo.NetworkSpawn();
	}

	[Rpc.Host]
	public void SpawnItemFlyToRpc( string name, Vector2 spawnPos, Vector2 flyTargetPos, float time, float height)
	{
		var itemGo = GameObject.Clone( $"prefabs/items/{name}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( spawnPos.x, spawnPos.y, -100f ) ) } );
		var item = itemGo.GetComponent<Item>();
		item.Fly( flyTargetPos, time, height );
		itemGo.NetworkSpawn();
	}

	[Rpc.Host]
	public void SpawnItemRpc( string name, Vector2 pos, Player targetPlayer, float time, float height )
	{
		SpawnItem( name, pos, targetPlayer, time, height );
	}

	public void SpawnItem( string name, Vector2 pos, Player targetPlayer, float time, float height )
	{
		Assert.True( Networking.IsHost );

		var itemGo = GameObject.Clone( $"prefabs/items/{name}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, -100f ) ) } );

		var item = itemGo.GetComponent<Item>();

		item.FlyToPlayer( targetPlayer, time, height );

		var dir = (targetPlayer.Position2D - pos).Normal;
		item.Velocity = dir * Game.Random.Float( ITEM_SPAWN_VELOCITY_MIN, ITEM_SPAWN_VELOCITY_MAX );

		itemGo.NetworkSpawn();
	}

	[Rpc.Broadcast]
	public void SpawnBulletImpactParticlesRpc( Vector3 pos, Vector3 normal, Color color, float scaleMultiplier = 1f )
	{
		SpawnBulletImpactParticles( pos, normal, color, scaleMultiplier );
	}

	public void SpawnBulletImpactParticles( Vector3 pos, Vector3 normal, Color color, float scaleMultiplier = 1f )
	{
		var particleGo = GameObject.Clone( "prefabs/effects/bullet_impact.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
		var particleEffect = particleGo.GetComponent<ParticleEffect>();
		particleEffect.Tint = color;
		var particleSpriteRenderer = particleGo.GetComponent<ParticleSpriteRenderer>();
		particleSpriteRenderer.Scale *= scaleMultiplier;

		//PlaySfxNearbyRpc( "bullet.impact", (Vector2)pos, pitch: Game.Random.Float(1.05f, 1.15f), volume: 0.7f, maxDist: 120f );
	}

	public void SpawnDamageNumber( Vector3 pos, float damage, Color color, float size, FloaterType floaterType = FloaterType.Damage, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		string str;
		if ( ShowPreciseNumbers )
		{
			str = damage % 1 == 0 ? damage.ToString( "0" ) : damage.ToString( "0.#" );
		}
		else
		{
			int amount = GetFloaterNumber( damage );
			str = amount.ToString();
		}

		str += GetDamageResultExtraText( damageFlags );

		SpawnFloaterText( pos, str, color, size, floaterType, damageFlags );
	}

	public static string GetDamageResultExtraText( DamageResultFlags flags )
	{
		string extraText = "";
		if ( flags.HasFlag( DamageResultFlags.AlternateDmg ) )
			extraText += "!";
		if ( flags.HasFlag( DamageResultFlags.CoupDeGrace ) )
			extraText += "!";
		return extraText;
	}

	public int GetFloaterNumber( float amount )
	{
		if ( amount < 1f )
		{
			amount = MathF.Ceiling( amount );
		}
		else
		{
			float fractional = amount - MathF.Floor( amount );
			if ( fractional > 0f && Game.Random.Float( 0f, 1f ) > fractional ) // the higher the fractional, the lower the chance of rolling higher than it, so Floor when doing so
				amount = MathF.Floor( amount );
			else
				amount = MathF.Ceiling( amount );
		}

		return (int)amount;
	}

	[Rpc.Broadcast]
	public void SpawnFloaterNumberRpc( Vector3 pos, float amount, Color color, float size, FloaterType floaterType = FloaterType.Damage, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		SpawnFloaterNumber( pos, amount, color, size, floaterType, damageFlags );
	}

	public void SpawnFloaterNumber( Vector3 pos, float amount, Color color, float size, FloaterType floaterType = FloaterType.Damage, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		if ( ShowPreciseNumbers )
		{
			string str = amount % 1 == 0 ? amount.ToString( "0" ) : amount.ToString( "0.#" );
			SpawnFloaterText( pos, str, color, size, floaterType, damageFlags );
			return;
		}

		SpawnFloaterText( pos, GetFloaterNumber( amount ).ToString(), color, size, floaterType, damageFlags );
	}

	[Rpc.Broadcast]
	public void SpawnFloaterTextRpc( Vector3 pos, string message, Color color, float size, FloaterType floaterType = FloaterType.Damage, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		SpawnFloaterText( pos, message, color, size, floaterType, damageFlags );
	}

	public void SpawnFloaterText( Vector3 pos, string message, Color color, float size, FloaterType floaterType = FloaterType.Damage, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		if ( color == default )
			color = Color.White;

		if ( IsOrthoCamera )
			size *= 1.2f;

		var localPlayer = Manager.Instance.LocalPlayer;
		bool fpsMode = localPlayer.IsValid() && localPlayer.GetSyncStat( PlayerStat.FpsMode ) > 0f;
		if ( !fpsMode )
		{
			// Bring floater toward camera, but ignore x-pos so it doesn't get clamped near arena edges.
			pos += (CameraContainer.WorldPosition - pos).Normal.WithX( 0f ) * 100f;
		}
		else
		{
			// FPS camera: just nudge upward slightly so numbers appear above the hit point.
			pos += Vector3.Up * 20f;
		}

		var particle = GameObject.Clone( "prefabs/dmg_number.prefab", new CloneConfig()
		{
			Transform = new Transform( pos, new Angles( -90f, 0, 0 ) ),
			StartEnabled = true
		} );

		var particleEffect = particle.Components.Get<ParticleEffect>();
		var textRenderer = particle.Components.Get<ParticleTextRenderer>();

		//particleEffect.Timing = IsPaused ? ParticleEffect.TimingMode.GameTime : ParticleEffect.TimingMode.RealTime;
		particleEffect.Timing = ParticleEffect.TimingMode.RealTime;

		if ( floaterType == FloaterType.PoisonFinish )
			message += "!";

		if ( textRenderer != null )
		{
			var text = new TextRendering.Scope( message, color, 250f, weight: 800 );

			text.Outline.Enabled = true;
			text.Outline.Color = Color.Black;
			text.Outline.Size = 50;

			if ( floaterType == FloaterType.Poison )
			{
				text.Outline.Color = new Color( 0f, 0.25f, 0f );
				text.Outline.Size = 60;
			}
			else if ( floaterType == FloaterType.PoisonFinish )
			{
				text.Outline.Color = new Color( 0f, 0.25f, 0f );
				text.Outline.Size = 60;

				text.FontItalic = true;
				size *= 1.2f;
			}
			else if ( floaterType == FloaterType.Shock )
			{
				//text.Outline.Color = new Color( 0.4f, 0.4f, 0f );
				//text.Outline.Size = 70;
				//size *= 1.1f;

				text.Shadow.Enabled = true;
				text.Shadow.Size = 20f;
				text.Shadow.Color = Color.Yellow;
				text.Shadow.Offset = new Vector2( 0f, 50f );
			}
			else if ( floaterType == FloaterType.Fire )
			{
				text.Outline.Color = new Color( 0.7f, 0f, 0f );
				text.Outline.Size = 70;
			}
			else if ( floaterType == FloaterType.FrostDmg )
			{
				text.Outline.Color = new Color( 0f, 0f, 0.5f );
				text.Outline.Size = 70;
			}
			else if ( floaterType == FloaterType.OrbiterBlade )
			{
				text.Outline.Color = new Color( 0.45f, 0.45f, 0.45f );
				text.Outline.Size = 70;
				//text.FontItalic = true;
			}
			else if ( floaterType == FloaterType.Thorns )
			{
				text.Outline.Color = new Color( 0f, 0.25f, 0f );
				text.Outline.Size = 40;

				text.Shadow.Enabled = true;
				text.Shadow.Size = 20f;
				text.Shadow.Color = new Color(0f, 0.4f, 0f);
				text.Shadow.Offset = new Vector2( 0f, -50f );
			}
			else if ( floaterType == FloaterType.Backstab )
			{
				text.Outline.Color = new Color( 0.4f, 0.2f, 0f );
				text.Outline.Size = 60;

				text.FontItalic = true;
				size *= 1.1f;
			}
			else if ( floaterType == FloaterType.DodgeHpDamage )
			{
				text.Shadow.Enabled = true;
				text.Shadow.Size = 30f;
				text.Shadow.Color = new Color( 0.8f, 0f, 0f );
				//text.Shadow.Offset = new Vector2( 0f, -50f );

				//text.FontItalic = true;
				size *= 1.15f;
			}
			else if ( floaterType == FloaterType.Radiation )
			{
				text.Outline.Color = new Color( 0.2f, 0.35f, 0f );
				text.Outline.Size = 60;

				text.Shadow.Enabled = true;
				text.Shadow.Size = 45f;
				text.Shadow.Color = new Color( 0.5f, 0.9f, 0.1f );
				text.Shadow.Offset = new Vector2( 0f, 40f );
			}
			else if ( floaterType == FloaterType.Mark )
			{
				text.Outline.Color = new Color( 0.2f, 0f, 0.4f );
				text.Outline.Size = 65;

				text.Shadow.Enabled = true;
				text.Shadow.Size = 40f;
				text.Shadow.Color = new Color( 0.6f, 0.1f, 1f );
				text.Shadow.Offset = new Vector2( 0f, -40f );
			}

			if ( damageFlags.HasFlag( DamageResultFlags.FullHealthEnemy ) )
			{
				text.Outline.Size = text.Outline.Size + 50;
				text.FontWeight = 900;
			}

			if ( damageFlags.HasFlag( DamageResultFlags.PoisonIncreaseNearby ) )
			{
				text.Outline.Size = text.Outline.Size + 20;
				text.FontWeight = 900;
				text.Outline.Color = new Color( 0f, 0.6f, 0.4f );

				text.Shadow.Enabled = true;
				text.Shadow.Size = 45f;
				text.Shadow.Color = new Color( 0f, 0.6f, 0.4f );
				text.Shadow.Offset = new Vector2( 0f, 50f );
			}

			//text.WordSpacing = 100f;
			//text.LetterSpacing = -50f;

			textRenderer.Text = text;
			textRenderer.Scale = size * 10f;
		}

		if ( floaterType == FloaterType.Heal )
		{
			particleEffect.StartVelocity = Game.Random.Float( 20f, 40f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 50f );
			particleEffect.Damping = 0.35f;
			particleEffect.Stretch = 0f;
		}
		else if ( floaterType == FloaterType.Xp )
		{
			particleEffect.StartVelocity = Game.Random.Float( 20f, 40f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 30f );
			particleEffect.Damping = 0.2f;
			particleEffect.Stretch = 0f;
			particleEffect.Lifetime = 0.66f;
			particleEffect.Scale = 1f;
		}
		else if ( floaterType == FloaterType.XpLoss )
		{
			particleEffect.StartVelocity = Game.Random.Float( -20f, -40f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, -30f );
			particleEffect.Damping = 0.2f;
			particleEffect.Stretch = 0f;
			particleEffect.Lifetime = 0.66f;
			particleEffect.Scale = 1f;
		}
		else if ( floaterType == FloaterType.PositiveMessage )
		{
			particleEffect.StartVelocity = Game.Random.Float( 20f, 45f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 10f );
			particleEffect.Damping = 0.2f;
			particleEffect.Stretch = 0f;
			particleEffect.Scale = 1.05f;
		}
		else if ( floaterType == FloaterType.NegativeMessage )
		{
			particleEffect.StartVelocity = Game.Random.Float( -20f, -30f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, -50f );
			particleEffect.Damping = 0.2f;
			particleEffect.Stretch = 0f;
			particleEffect.Scale = 1f;
		}
		else if ( floaterType == FloaterType.Dodge )
		{
			particleEffect.StartVelocity = Game.Random.Float( 50f, 60f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 30f );
			particleEffect.Damping = 0.1f;
			particleEffect.Stretch = 0f;
			particleEffect.Lifetime = 0.3f;
		}
		else if ( floaterType == FloaterType.ArmorGain )
		{
			particleEffect.StartVelocity = Game.Random.Float( 40f, 60f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 50f );
			particleEffect.Damping = 0.45f;
			particleEffect.Stretch = 0f;
		}
		else if ( floaterType == FloaterType.ArmorLose )
		{
			particleEffect.StartVelocity = Game.Random.Float( 10f, 20f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 0f );
			particleEffect.Damping = 0.85f;
			particleEffect.Stretch = 0f;
		}
		else if ( floaterType == FloaterType.Reload1 )
		{
			particleEffect.StartVelocity = Game.Random.Float( 50f, 60f );
			particleEffect.ForceDirection = new Vector3( 0f, 0f, 30f );
			particleEffect.Damping = 1.5f;
			particleEffect.Stretch = 0f;
			particleEffect.Lifetime = 0.3f;
		}
	}

	[Rpc.Broadcast]
	public void SpawnPerkFloaterRpc( Vector3 pos, string iconPath, Rarity rarity, bool curse, float progress, bool wasRemoved = false )
	{
		SpawnPerkFloater( pos, iconPath, rarity, curse, progress, wasRemoved );
	}

	public void SpawnPerkFloater( Vector3 pos, string iconPath, Rarity rarity, bool curse, float progress, bool wasRemoved = false )
	{
		var floaterGo = GameObject.Clone( "prefabs/perk_floater.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
		var perkFloater = floaterGo.GetComponent<PerkFloater>();
		perkFloater.IconPath = iconPath;
		perkFloater.Rarity = rarity;
		perkFloater.Curse = curse;
		perkFloater.Progress = progress;
		perkFloater.WasRemoved = wasRemoved;
		perkFloater.Timing = ParticleEffect.TimingMode.RealTime;

		if ( wasRemoved )
			perkFloater.RemovedRotateSpeed = Game.Random.Float( 8f, 30f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);

		perkFloater.Velocity = wasRemoved
			? new Vector3( Game.Random.Float( -40f, 40f ), 0f, -150f )
			: new Vector3( Game.Random.Float( -40f, 40f ), 0f, 150f );
	}

	public const float RING_HEIGHT = 10f;
	public const float SCORCH_HEIGHT = 1f;

	[Rpc.Broadcast]
	public void SpawnRingRpc( Vector2 pos, float radius, Color color, float lifetime, string path = "ring", bool useRealtime = false )
	{
		SpawnRing( pos, radius, color, lifetime, path, useRealtime );
	}

	public void SpawnRing( Vector2 pos, float radius, Color color, float lifetime, string path = "ring", bool useRealtime = false )
	{
		var zPos = RING_HEIGHT * Game.Random.Float( 0.95f, 1.05f );
		var particleGo = GameObject.Clone( $"prefabs/effects/{path}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), new Angles( 90f, Game.Random.Float( 0f, 360f ), 0f ) ) } );
		var particleEffect = particleGo.GetComponent<ParticleEffect>();
		particleEffect.Tint = color;
		particleEffect.Lifetime = lifetime;
		var particleSpriteRenderer = particleGo.GetComponent<ParticleSpriteRenderer>();
		particleSpriteRenderer.Scale = radius;

		particleEffect.Timing = useRealtime ? ParticleEffect.TimingMode.RealTime : ParticleEffect.TimingMode.GameTime;
	}

	//[Rpc.Broadcast]
	//public void SpawnParticleRpc( string name, Vector3 pos )
	//{
	//	GameObject.Clone( $"prefabs/effects/{name}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
	//}

	public void SpawnParticle( string name, Vector3 pos )
	{
		GameObject.Clone( $"prefabs/effects/{name}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
	}

	[Rpc.Host]
	public void SpawnAcidPuddleRpc( Vector2 pos, float lifetime, float damage, float scale, Color colorA, Color colorB, Player playerSource, Enemy enemySource, EnemyType enemyType, bool damagePlayers = true, bool damageEnemies = false )
	{
		SpawnAcidPuddle( pos, lifetime, damage, scale, colorA, colorB, playerSource, enemySource, enemyType, damagePlayers, damageEnemies );
	}

	public void SpawnAcidPuddle( Vector2 pos, float lifetime, float damage, float scale, Color colorA, Color colorB, Player playerSource, Enemy enemySource, EnemyType enemyType, bool damagePlayers = true, bool damageEnemies = false )
	{
		var puddleGo = GameObject.Clone( $"prefabs/acid_puddle.prefab", new CloneConfig
		{
			StartEnabled = true,
			Transform = new Transform(
				position: new Vector3( pos.x, pos.y, 2f ),
				rotation: new Angles( 0f, Game.Random.Float( 0f, 360f ), 0f ),
				scale: new Vector3( scale )
			)
		} );

		var puddle = puddleGo.GetComponent<AcidPuddle>();
		puddle.Lifetime = lifetime;
		puddle.Damage = damage;
		puddle.ColorA = colorA;
		puddle.ColorB = colorB;
		puddle.EnemySource = enemySource;
		puddle.EnemyType = enemyType;
		puddle.PlayerSource = playerSource;

		if ( damagePlayers )
			puddle.CollideWithTags.Add( "player" );

		if ( damageEnemies )
			puddle.CollideWithTags.Add( "enemy" );

		puddleGo.NetworkSpawn();
	}

	[Rpc.Host]
	public void SpawnAcidPuddleRingRpc( Vector2 centerPos, float radius, int num, float scale, float lifetime, float damage, Player playerSource, Enemy enemySource, EnemyType enemyType, float startDegrees = 0f, bool damagePlayers = true, bool damageEnemies = false )
	{
		float interval = 360f / num;

		float degrees = startDegrees;

		for ( int i = 0; i < num; i++ )
		{
			var pos = centerPos + Utils.GetVector2FromAngleDegrees( degrees ) * radius;

			if ( IsInBounds( pos ) )
			{
				SpawnAcidPuddle( pos, lifetime * Game.Random.Float( 0.95f, 1.05f ), damage, scale, colorA: new Color( 1f, 0.3f, 0.4f ), colorB: new Color( 1f, 0.5f, 0.6f ), playerSource, enemySource, enemyType, damagePlayers, damageEnemies );
			}

			degrees += interval;
		}
	}

	[Rpc.Host]
	public void SpawnFireGroundRpc( Vector2 pos, Player player, Enemy enemySource, EnemyType enemyType, float damage, float lifetime, float spreadChance, bool canStack, float scale, Color colorA, Color colorB, bool hurtPlayers = true, bool hurtEnemies = true )
	{
		SpawnFireGround( pos, player, enemySource, enemyType, damage, lifetime, spreadChance, canStack, scale, colorA, colorB, hurtPlayers, hurtEnemies );
	}

	public void SpawnFireGround( Vector2 pos, Player player, Enemy enemySource, EnemyType enemyType, float damage, float lifetime, float spreadChance, bool canStack, float scale, Color colorA, Color colorB, bool hurtPlayers = true, bool hurtEnemies = true )
	{
		var fireGo = GameObject.Clone( $"prefabs/fire_ground.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 10f ) ) } );

		var fire = fireGo.GetComponent<FireGround>();
		fire.PlayerSource = player;
		fire.EnemySource = enemySource;
		fire.EnemyType = enemyType;
		fire.Lifetime = lifetime;
		fire.Damage = damage;
		fire.SpreadChance = spreadChance;
		fire.CanStack = canStack;
		fire.HurtPlayers = hurtPlayers;
		fire.HurtEnemies = hurtEnemies;

		fire.SetScale( scale );

		var particleEffect = fire.GetComponent<ParticleEffect>();
		particleEffect.Gradient = new ParticleGradient() { Type = ParticleGradient.ValueType.Range, ConstantA = colorA, ConstantB = colorB };

		fireGo.NetworkSpawn();
	}

	[Rpc.Host]
	public void SpawnFireRingRpc( Vector2 centerPos, float radius, int num, float damage, float lifetime, float startDegrees, float scale, Color colorA, Color colorB, Player playerSource, Enemy enemySource, EnemyType enemyType, bool hurtPlayers = true, bool hurtEnemies = true )
	{
		SpawnFireRing( centerPos, radius, num, damage, lifetime, startDegrees, scale, colorA, colorB, playerSource, enemySource, enemyType, hurtPlayers, hurtEnemies );
	}

	public void SpawnFireRing( Vector2 centerPos, float radius, int num, float damage, float lifetime, float startDegrees, float scale, Color colorA, Color colorB, Player playerSource, Enemy enemySource, EnemyType enemyType, bool hurtPlayers = true, bool hurtEnemies = true, float spreadChance = 0f )
	{
		float interval = 360f / num;

		float degrees = startDegrees;

		for ( int i = 0; i < num; i++ )
		{
			var pos = centerPos + Utils.GetVector2FromAngleDegrees( degrees ) * radius;

			if ( IsInBounds( pos ) )
			{
				SpawnFireGround( pos, playerSource, enemySource, enemyType, damage, lifetime * Game.Random.Float( 0.95f, 1.05f ), spreadChance: spreadChance, canStack: false, scale, colorA, colorB, hurtPlayers, hurtEnemies );
			}

			degrees += interval;
		}

		PlaySfxNearbyRpc( "burn", centerPos, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.9f, maxDist: 400f );
	}

	[Rpc.Host]
	public void SpawnEnemyRingRpc( EnemyType enemyType, Vector2 centerPos, float radius, int num, float startDegrees = 0f )
	{
		float interval = 360f / num;

		float degrees = startDegrees;

		for ( int i = 0; i < num; i++ )
		{
			var pos = centerPos + Utils.GetVector2FromAngleDegrees( degrees ) * radius;

			if ( IsInBounds( pos ) )
			{
				SpawnEnemy( enemyType, pos, spawnInstantly: false, rotAngle: Utils.GetAngleDegreesFromVectorAlt( (centerPos - pos).Normal ), playSfx: false );
			}

			degrees += interval;
		}

		PlaySfxNearbyRpc( "zombie.dirt", centerPos, pitch: Game.Random.Float( 0.6f, 0.7f ), volume: 0.8f, maxDist: 450f );
	}

	[Rpc.Host]
	public void SpawnBombRingRpc( Vector2 centerPos, float radius, int num, float startDegrees = 0f )
	{
		float interval = 360f / num;

		float degrees = startDegrees;

		for ( int i = 0; i < num; i++ )
		{
			var pos = centerPos + Utils.GetVector2FromAngleDegrees( degrees ) * radius;

			if ( IsInBounds( pos ) )
			{
				SpawnItem( "bomb", pos, (centerPos - pos).Normal, fly: false );
				SpawnCloudRpc( pos: new Vector3( pos.x, pos.y, 10f ), velocity: Vector2.Zero, deceleration: 4f );
			}

			degrees += interval;
		}
	}

	[Rpc.Host]
	public void SpawnLandmineRingRpc( Vector2 centerPos, float radius, int num, float startDegrees = 0f )
	{
		float interval = 360f / num;

		float degrees = startDegrees;

		for ( int i = 0; i < num; i++ )
		{
			var pos = centerPos + Utils.GetVector2FromAngleDegrees( degrees ) * radius;

			if ( IsInBounds( pos ) )
			{
				var landmineGo = GameObject.Clone( "prefabs/landmine.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 0f ) ) } );
				var landmine = landmineGo.Components.Get<Landmine>( true );

				landmine.Damage = 15;
				landmine.Shooter = null;

				landmineGo.NetworkSpawn();

				// todo: cant really see this cloud... give a better effect when spawning landmine
				SpawnCloudRpc( pos: new Vector3( pos.x, pos.y, 10f ), velocity: Vector2.Zero, deceleration: 4f );
			}

			degrees += interval;
		}
	}

	[Rpc.Host]
	public void SpawnEnemyProjectileRing( Vector2 centerPos, int numProjectiles, Enemy shooter, EnemyType enemyType, float startVelMin, float startVelMax, float lifetimeModMin, float lifetimeModMax, EnemyProjectileType projectileType = EnemyProjectileType.Normal, float zPos = 50f )
	{
		var dir = Utils.GetRandomVector();
		float increment = 360f / numProjectiles;

		for ( int i = 0; i < numProjectiles; i++ )
		{
			var currDir = Utils.RotateVector( dir, increment * i );

			var pos = centerPos + currDir * 20f;
			var startVel = Game.Random.Float( startVelMin, startVelMax );
			var lifetimeMod = Game.Random.Float( lifetimeModMin, lifetimeModMax );

			SpawnEnemyProjectile( pos, currDir, shooter, enemyType, startVel, projectileType, zPos, lifetimeMod );
		}
	}

	[Rpc.Host]
	public void SpawnEnemyHomingProjectileRing( Vector2 centerPos, int numProjectiles, Enemy shooter, EnemyType enemyType, float startVelMin, float startVelMax, float lifetimeModMin, float lifetimeModMax, EnemyProjectileType projectileType = EnemyProjectileType.Normal, float zPos = 50f )
	{
		var dir = Utils.GetRandomVector();
		float increment = 360f / numProjectiles;

		for ( int i = 0; i < numProjectiles; i++ )
		{
			var currDir = Utils.RotateVector( dir, increment * i );

			var pos = centerPos + currDir * 20f;
			var startVel = Game.Random.Float( startVelMin, startVelMax );
			var lifetimeMod = Game.Random.Float( lifetimeModMin, lifetimeModMax );

			SpawnEnemyHomingProjectile( pos, currDir, shooter, enemyType, startVel, projectileType, zPos, lifetimeMod );
		}
	}

	public void SpawnCloud( Vector3 pos, Vector2 velocity, float deceleration, float lifetime, bool bright = false )
	{
		var cloudGo = GameObject.Clone( $"prefabs/effects/{(bright ? "cloud_bright" : "cloud")}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
		var cloud = cloudGo.GetComponent<Cloud>();
		cloud.Velocity = velocity;
		cloud.Deceleration = deceleration;

		cloud.GetComponent<ParticleEffect>().Lifetime = lifetime;
	}

	public void SpawnCloud( Vector3 pos, Vector2 velocity, float deceleration, bool bright = false )
	{
		var lifetime = Game.Random.Float( 1.5f, 2.5f );
		//var zPos = 0f;
		SpawnCloud( pos, velocity, deceleration, lifetime, bright );
	}

	[Rpc.Broadcast]
	public void SpawnCloudRpc( Vector3 pos, Vector2 velocity, float deceleration, float lifetime, bool bright = false )
	{
		SpawnCloud( pos, velocity, deceleration, lifetime, bright );
	}

	[Rpc.Broadcast]
	public void SpawnCloudRpc( Vector3 pos, Vector2 velocity, float deceleration, bool bright = false )
	{
		SpawnCloud( pos, velocity, deceleration, bright );
	}

	public const float SHOCKWAVE_HEIGHT = 10f;

	public void SpawnShockwave( Vector2 pos, float damage, float radius, float lifetime, float force, Gradient gradient, Enemy enemySource, EnemyType enemyType )
	{
		var shockwaveGo = GameObject.Clone( "prefabs/shockwave.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, SHOCKWAVE_HEIGHT ) ) } );
		var shockwave = shockwaveGo.GetComponent<Shockwave>();
		shockwave.Damage = damage;
		shockwave.Radius = radius;
		shockwave.Lifetime = lifetime;
		shockwave.Force = force;
		shockwave.Gradient = gradient;
		shockwave.EnemySource = enemySource;
		shockwave.EnemyType = enemyType;
	}

	public bool GetLavaBlobEndPos( Vector2 startPos, out Vector2 endPos )
	{
		endPos = ClampPosToBounds( startPos + Utils.GetRandomVector() * Game.Random.Float( 0f, 500f ), buffer: 40f );
		int NUM_TRIES = 40;
		int count = 0;
		while ( IsInLava( endPos ) && count < NUM_TRIES )
		{
			endPos = ClampPosToBounds( startPos + Utils.GetRandomVector() * Game.Random.Float( 0f, 500f ), buffer: 40f );
			count++;
		}

		count = 0;
		while ( IsInLava( endPos ) && count < NUM_TRIES )
		{
			endPos = ClampPosToBounds( startPos + Utils.GetRandomVector() * Game.Random.Float( 5f, 650f ), buffer: 40f );
			count++;
		}

		count = 0;
		while ( IsInLava( endPos ) && count < NUM_TRIES )
		{
			endPos = ClampPosToBounds( startPos + Utils.GetRandomVector() * Game.Random.Float( 6.5f, 750f ), buffer: 40f );
			count++;
		}

		return !IsInLava( endPos );
	}

	public bool IsInLava( Vector2 pos )
	{
		float radiusIncrease = 1.3f;

		foreach ( var lavaPuddle in Scene.GetAllComponents<LavaPuddle>() )
		{
			if ( lavaPuddle.TimeSinceSpawn > lavaPuddle.Lifetime - 0.85f )
				continue;

			if ( ((Vector2)lavaPuddle.WorldPosition - pos).LengthSquared < MathF.Pow( lavaPuddle.Radius * radiusIncrease, 2f ) )
				return true;
		}

		foreach ( var lavaBlob in Scene.GetAllComponents<TossedLavaBlob>() )
		{
			if ( (lavaBlob.TargetPos2D - pos).LengthSquared < MathF.Pow( lavaBlob.PuddleRadius * radiusIncrease, 2f ) )
				return true;
		}

		foreach ( var spikerHeadMiniboss2 in Scene.GetAllComponents<SpikerHeadMiniboss2>() )
		{
			if ( (spikerHeadMiniboss2.Position2D - pos).LengthSquared < MathF.Pow( 160f * radiusIncrease, 2f ) )
				return true;
		}

		foreach ( var particles in Scene.FindAllWithTag( "spiker_miniboss_2_particles" ) )
		{
			if ( ((Vector2)particles.WorldPosition - pos).LengthSquared < MathF.Pow( 160f * radiusIncrease, 2f ) )
				return true;
		}

		return false;
	}

	[Rpc.Broadcast]
	public void SpawnLavaBlob( Vector2 pos, Vector2 targetPos, float puddleRadius, Enemy enemySource, EnemyType enemyType )
	{
		PlaySfxNearby( "lava_blob_01", pos, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 1.4f, maxDist: 550f );

		GameObject.Clone( "prefabs/effects/lava_warning.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( targetPos.x, targetPos.y, 0f ) ) } );

		if ( IsProxy )
			return;

		var zPos = 50f;
		var blobGo = GameObject.Clone( "prefabs/enemyProjectiles/tossed_lava_blob.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), Rotation.Random ) } );
		var blob = blobGo.Components.Get<TossedLavaBlob>( true );

		blob.Shooter = enemySource;
		blob.EnemyType = enemyType;
		blob.Lifetime = Game.Random.Float( 1.75f, 2.25f );
		blob.StartPos2D = pos;
		blob.TargetPos2D = Manager.Instance.ClampPosToBounds( targetPos );
		blob.PuddleRadius = puddleRadius;

		blobGo.NetworkSpawn();
	}

	[Rpc.Broadcast]
	public void SpawnLavaPuddleRpc( Vector2 pos, float damage, float radius, float lifetime, Color colorA, Color colorB, float alphaMax, Enemy enemySource, EnemyType enemyType )
	{
		SpawnLavaPuddle( pos, damage, radius, lifetime, colorA, colorB, alphaMax, enemySource, enemyType );
	}

	public void SpawnLavaPuddle( Vector2 pos, float damage, float radius, float lifetime, Color colorA, Color colorB, float alphaMax, Enemy enemySource, EnemyType enemyType )
	{
		var lavaPuddleGo = GameObject.Clone( "prefabs/lava_puddle.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 1f ), new Angles(0f, Game.Random.Float(0f, 360f), 0f) ) } );
		var lavaPuddle = lavaPuddleGo.GetComponent<LavaPuddle>();
		lavaPuddle.Damage = damage;
		lavaPuddle.Radius = radius;
		lavaPuddle.Lifetime = lifetime;
		lavaPuddle.ColorA = colorA;
		lavaPuddle.ColorB = colorB;
		lavaPuddle.AlphaMax = alphaMax;
		lavaPuddle.EnemySource = enemySource;
		lavaPuddle.EnemyType = enemyType;
	}

	[Rpc.Host]
	public void SpawnLavaSafeSpot( Vector2 pos, float radius, Vector2 direction )
	{
		var safeSpotGo = GameObject.Clone( "prefabs/lava_safe_spot.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, -99f ) ) } );
		var safeSpot = safeSpotGo.GetComponent<LavaSafeSpot>();
		safeSpot.MoveDir = direction;
		float scale = radius / 32f;
		safeSpot.SetScale( scale );
		safeSpot.Speed = Utils.Map( radius, 64f, 192f, 50f, 3f );

		safeSpotGo.NetworkSpawn();
	}

	[Rpc.Host]
	public void SpawnHealingZone( Vector2 pos, float scale )
	{
		var healingZoneGo = GameObject.Clone( "prefabs/healing_zone.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 150f ) ) } );
		var healingZone = healingZoneGo.GetComponent<HealingZone>();

		healingZone.SetScale( scale );

		healingZoneGo.NetworkSpawn();
	}

	public void SpawnEnemyProjectile( Vector2 pos, Vector2 dir, Enemy shooter, EnemyType enemyType, float startVel, EnemyProjectileType projectileType = EnemyProjectileType.Normal, float zPos = 50f, float lifetimeModifier = 1f )
	{
		var prefabPath = projectileType switch
		{
			EnemyProjectileType.Fire   => "prefabs/enemyProjectiles/spitter_projectile_fire.prefab",
			EnemyProjectileType.Freeze => "prefabs/enemyProjectiles/spitter_projectile_freeze.prefab",
			EnemyProjectileType.Poison => "prefabs/enemyProjectiles/spitter_projectile_poison.prefab",
			EnemyProjectileType.Acid   => "prefabs/enemyProjectiles/spitter_projectile_acid.prefab",
			EnemyProjectileType.Curse  => "prefabs/enemyProjectiles/spitter_projectile_curse.prefab",
			_                          => "prefabs/enemyProjectiles/spitter_projectile_normal.prefab",
		};
		var projectileGo = GameObject.Clone( prefabPath, new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), Rotation.From( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f ) ) } );
		var projectile = projectileGo.Components.Get<SpitterProjectile>( true );

		projectile.BaseZPos = zPos;
		projectile.Velocity = dir * startVel;
		projectile.Shooter = shooter;
		projectile.EnemyType = enemyType;

		projectile.SetProjectileType( projectileType );

		projectile.Lifetime *= lifetimeModifier;

		projectileGo.NetworkSpawn();
	}

	public void SpawnEnemyHomingProjectile( Vector2 pos, Vector2 dir, Enemy shooter, EnemyType enemyType, float startVel, EnemyProjectileType projectileType = EnemyProjectileType.Normal, float zPos = 50f, float lifetimeModifier = 1f )
	{
		var homingPrefabPath = projectileType switch
		{
			EnemyProjectileType.Fire   => "prefabs/enemyProjectiles/homing_spitter_projectile_fire.prefab",
			EnemyProjectileType.Freeze => "prefabs/enemyProjectiles/homing_spitter_projectile_freeze.prefab",
			EnemyProjectileType.Poison => "prefabs/enemyProjectiles/homing_spitter_projectile_poison.prefab",
			EnemyProjectileType.Acid   => "prefabs/enemyProjectiles/homing_spitter_projectile_acid.prefab",
			EnemyProjectileType.Curse  => "prefabs/enemyProjectiles/homing_spitter_projectile_curse.prefab",
			_                          => "prefabs/enemyProjectiles/homing_spitter_projectile_normal.prefab",
		};
		var projectileGo = GameObject.Clone( homingPrefabPath, new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), Rotation.From( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f ) ) } );
		var projectile = projectileGo.Components.Get<SpitterProjectile>( true );

		projectile.BaseZPos = zPos;
		projectile.Velocity = dir * startVel;
		projectile.Shooter = shooter;
		projectile.EnemyType = enemyType;

		projectile.SetProjectileType( projectileType );

		projectile.Lifetime *= lifetimeModifier;

		projectileGo.NetworkSpawn();
	}

	public static string GetStringForEnemyType( EnemyType enemyType )
	{
		switch ( enemyType )
		{
			case EnemyType.Barrel: return "barrel";
			case EnemyType.BarrelExploding: return "barrel_exploding";
			case EnemyType.BarrelMystery: return "barrel_mystery";
			case EnemyType.Tree: return "tree";
			case EnemyType.Zombie: return "zombie";
			case EnemyType.ZombieElite: return "zombie_elite";
			case EnemyType.Exploder: return "exploder";
			case EnemyType.ExploderElite: return "exploder_elite";
			case EnemyType.Spitter: return "spitter";
			case EnemyType.SpitterElite: return "spitter_elite";
			case EnemyType.Spiker: return "spiker";
			case EnemyType.SpikerElite: return "spiker_elite";
			case EnemyType.Charger: return "charger";
			case EnemyType.ChargerElite: return "charger_elite";
			case EnemyType.Runner: return "runner";
			case EnemyType.RunnerElite: return "runner_elite";
			case EnemyType.Boss: return "boss";
			case EnemyType.MinibossAbsorber: return "miniboss_absorber";
			case EnemyType.MinibossCharger: return "miniboss_charger";
			case EnemyType.MinibossCharger2: return "miniboss_charger_2";
			case EnemyType.MinibossExploder: return "miniboss_exploder";
			case EnemyType.MinibossRunner: return "miniboss_runner";
			case EnemyType.MinibossShielder: return "miniboss_shielder";
			case EnemyType.MinibossSlammer: return "miniboss_slammer";
			case EnemyType.MinibossSniper: return "miniboss_sniper";
			case EnemyType.MinibossSpiker: return "miniboss_spiker";
			case EnemyType.MinibossSpiker2: return "miniboss_spiker_2";
			case EnemyType.MinibossSpitter: return "miniboss_spitter";
			case EnemyType.MinibossTroll: return "miniboss_troll";
			case EnemyType.MinibossZapper: return "miniboss_zapper";
			case EnemyType.MinibossZapper2: return "miniboss_zapper_2";
			case EnemyType.MinibossZoner: return "miniboss_zoner";
			case EnemyType.Snaker: return "snaker";
			case EnemyType.Mushroom: return "mushroom";
			case EnemyType.Chest: return "chest";
			case EnemyType.ChestEvil: return "chest_evil";
			case EnemyType.Turret: return "turret";
			case EnemyType.BossInvincibleGenerator: return "boss_invincible_generator";
			case EnemyType.ExploderMini: return "exploder_mini";
			case EnemyType.ZombieTemporary: return "zombie_temporary";
		}

		return "";
	}

	public float GetThreatLevelForEnemyType( EnemyType enemyType )
	{
		switch ( enemyType )
		{
			case EnemyType.ZombieElite: return 0.5f;
			case EnemyType.ExploderElite: return 1.75f;
			case EnemyType.Spitter: return 1f;
			case EnemyType.SpitterElite: return 1.5f;
			case EnemyType.Spiker: return 1.5f;
			case EnemyType.SpikerElite: return 2f;
			case EnemyType.Charger: return 1.25f;
			case EnemyType.ChargerElite: return 2f;
			case EnemyType.Runner: return 0.66f;
			case EnemyType.RunnerElite: return 2.2f;
			case EnemyType.ExploderMini: return 0.9f;
		}

		return 0f;
	}

	public bool ShouldCreateSpawnClouds( EnemyType enemyType )
	{
		switch ( enemyType )
		{
			case EnemyType.Sword:
				return false;
		}

		return true;
	}

	[Rpc.Broadcast]
	public void SpawnSpikerHeadCracksRpc( Vector2 pos, Color color )
	{
		PlaySfxNearby( "spike.prepare", pos, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.85f, maxDist: 250f );
		var particlesObj = GameObject.Clone( $"prefabs/effects/spiker_head_particles.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 3f ) ) } );
		var particleEffect = particlesObj.GetComponent<ParticleEffect>();
		particleEffect.Tint = color;
	}

	/// <summary>
	/// For CurseSpawnSpikerHead curse.
	/// </summary>
	/// <param name="pos"></param>
	[Rpc.Host]
	public void SpawnSpikerHeadRpc( Vector2 pos)
	{
		var zPos = 0f;
		var headGo = GameObject.Clone( $"prefabs/spiker_head.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), new Angles( 0f, Game.Random.Float( -60f, -120f ), 0f ) ) } );
		var head = headGo.Components.Get<SpikerHead>( true );
		head.ModelRenderer.Tint = new Color( 0.33f, 0.33f, 0f );

		headGo.NetworkSpawn();
	}

	public void AvoidObstacles( ref Vector2 pos, float radius, bool andSpikerHeads = false )
	{
		if ( andSpikerHeads )
		{
			foreach ( var spikerHeadArea in Scene.GetAll<SpikerHeadArea>() )
			{
				var distSqr = (pos - spikerHeadArea.Position2D).LengthSquared;
				if ( distSqr < MathF.Pow( radius + spikerHeadArea.Radius, 2f ) )
				{
					pos = spikerHeadArea.Position2D + (pos - spikerHeadArea.Position2D).Normal * (radius + spikerHeadArea.Radius);
				}
			}
		}

		foreach ( var obstacle in Scene.GetAll<Obstacle>() )
		{
			var distSqr = (pos - obstacle.Position2D).LengthSquared;
			if ( distSqr < MathF.Pow( radius + obstacle.Radius, 2f ) )
			{
				pos = obstacle.Position2D + (pos - obstacle.Position2D).Normal * (radius + obstacle.Radius);
			}
		}
	}

	public void SpawnSpikerHeadArea( Vector2 pos, float radius, float lifetime )
	{
		var spikerHeadAreaObj = GameObject.Clone( $"prefabs/spiker_head_area.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 0f ) ) } );
		var spikerHeadArea = spikerHeadAreaObj.GetComponent<SpikerHeadArea>();

		spikerHeadArea.Radius = radius;
		spikerHeadArea.Lifetime = lifetime;
	}

	public static bool IsEnemyTypeMiniboss(EnemyType enemyType)
	{
		switch ( enemyType )
		{
			case EnemyType.MinibossAbsorber:
			case EnemyType.MinibossCharger:
			case EnemyType.MinibossCharger2:
			case EnemyType.MinibossExploder:
			case EnemyType.MinibossRunner:
			case EnemyType.MinibossShielder:
			case EnemyType.MinibossSlammer:
			case EnemyType.MinibossSniper:
			case EnemyType.MinibossSpiker:
			case EnemyType.MinibossSpiker2:
			case EnemyType.MinibossSpitter:
			case EnemyType.MinibossTroll:
			case EnemyType.MinibossZapper:
			case EnemyType.MinibossZapper2:
			case EnemyType.MinibossZoner:
				return true;
		}
		return false;
	}
}