manager/Manager.cs

Central game Manager component for a 2D/3D hybrid game. It handles lobby and game state transitions, player spawning and selection, spectator camera, HUD/chat references, audio/mixer control, explosions and area effects, object destruction on restart/return-to-lobby, pausing/time scale, various gameplay helpers (closest unit/enemy/player queries), UI hovering (perks/items), music and minute logic, and miscellaneous utility effects (fade, death flash, camera shake, SFX playback).

NetworkingFile Access
using Sandbox.Audio;
using Sandbox.Diagnostics;
using System;
using Sandbox.UI;

public enum FloaterType { Damage, Heal, Xp, XpLoss, PositiveMessage, NegativeMessage, Dodge, ArmorGain, ArmorLose, Poison, PoisonFinish, Shock, Fire, Reload1, FrostDmg, OrbiterBlade, Thorns, Backstab, DodgeHpDamage, Radiation, Mark }
public enum Direction { Left, Right, Down, Up }
public enum GameState { Lobby, Playing }

[Flags]
public enum RepelOptions
{
	None = 0,
	RepelPlayers = 1 << 0,
	DamagePlayers = 1 << 1,
	RepelEnemies = 1 << 2,
	DamageEnemies = 1 << 3,
	RepelItems = 1 << 4
}

public enum OverlayEffectsType { Berserk, ReverseControls, HealthPackReverseControls, DeathFlash, Fade, Obscure, }

public partial class Manager : Component, Component.INetworkListener
{
	[Property] public CameraComponent Camera { get; set; }
	[Property] public GameObject CameraContainer { get; set; }
	[Property, Group( "Models" )] public Model ArmorBulletModel { get; set; }
	[Property, Group( "Models" )] public Model FrozenShardBulletModel { get; set; }
	[Property, Group( "Materials" )] public Dictionary<UnitFlashType, Material> UnitFlashMaterials { get; set; }
	[Property, Group( "Materials" )] public Material EnemyExplodeFlashMaterial { get; set; }
	[Property, Group( "Materials" )] public Material PlayerDeadMaterial { get; set; }
	[Property] public Dictionary<OverlayEffectsType, GameObject> OverlayEffects { get; set; }
	[Property] public List<GameObject> Maps { get; set; }
	[Property] public List<GameObject> MapObjects { get; set; }
	[Property] public List<GameObject> LobbyMaps { get; set; }
	[Property] public List<GameObject> LobbyMapObjects { get; set; }
	[Property] public GameObject IcyGroundFog { get; set; }

	public static Manager Instance { get; private set; }

	public Hud Hud { get; set; }
	public Chat Chat { get; set; }

	public static Vector3 LOBBY_CAMERA_POS = new Vector3( 2000f, -10000f, 1500f );

	public static readonly HashSet<long> HiddenLeaderboardSteamIds = new HashSet<long>()
	{
		// Add Steam IDs to hide from leaderboard display
		 76561198028428998,
		 76561198336518700,
	};

	public readonly struct HiddenLeaderboardEntry
	{
		public readonly long SteamId;
		public readonly int Difficulty;
		public readonly float ValueMin;
		public readonly float ValueMax;
		public HiddenLeaderboardEntry( long steamId, int difficulty, float valueMin, float valueMax )
		{
			SteamId = steamId;
			Difficulty = difficulty;
			ValueMin = valueMin;
			ValueMax = valueMax;
		}
	}

	/// <summary>
	/// Specific leaderboard entries to hide, identified by SteamId + Difficulty + score value range.
	/// Use this when a player has one invalid submission but other valid scores that should remain visible.
	/// Score for a victory at time t (seconds): VICTORY_OFFSET - t.
	/// </summary>
	public static readonly List<HiddenLeaderboardEntry> HiddenLeaderboardEntries = new()
	{
		// 76561198213155196 — Diff 2 victory, ~21:23 (1283s), score ≈ VICTORY_OFFSET - 1283 = 1,998,717
		new( 76561198213155196L, 2, 1998716f, 1998717f ),
	};

	public Vector2 MouseWorldPos { get; private set; }
	public Vector2 CrosshairScreenPos { get; private set; }
	public bool ShowCrosshairRecoilArrow { get; private set; }
	public float CrosshairRecoilArrowAngle { get; private set; }

	private TimeSince _timeSinceSpawnEnemy;

	public Vector2 BOUNDS_MIN;
	public Vector2 BOUNDS_MAX;
	public Vector2 BOUNDS_MIN_SPAWN;
	public Vector2 BOUNDS_MAX_SPAWN;
	public float BOUNDS_CHECK_SIZE_SQR;

	public const float TOUCH_DIST_REQUIRED_SQR = 0.01f;

	public Player LocalPlayer { get; set; }
	public bool IsSpectator => !LocalPlayer.IsValid() && GameState == GameState.Playing;
	public Player SelectedPlayer { get; set; }
	private readonly List<CamShakeData> _spectatorCamShakeDatas = new();

	[Sync] public GameState GameState { get; set; }
	[Sync] public TimeSince ElapsedTime { get; set; }
	public int CurrMinute { get; set; }
	[Sync] public bool IsGameOver { get; private set; }
	public RealTimeSince RealTimeSinceGameOver { get; private set; }
	public bool ShouldShowGameOverScreen { get; private set; }
	public bool ShouldShowQuestProgressPanel { get; private set; }
	public bool ShouldShowPerkUnlockProgressPanel { get; private set; }

	public const float GAME_OVER_UI_DELAY = 2.0f;
	public const bool HideProgressionSystem = true;
	[Sync] public bool IsBossDead { get; set; }
	[Sync] public float GameOverTime { get; set; }
	public bool ShowBossHealthbar => HasSpawnedBoss && Boss.IsValid() && !IsBossDead;

	public TypeDescription HoveredPerkType { get; set; }
	public Panel HoveredPerkPanel { get; set; }
	public int HoveredPerkLevel { get; set; }
	public Player HoveredPerkViewedPlayer { get; set; }
	public int HoveredPerkChoiceSlot { get; set; } = -1;
	public bool IsHoveredPerkBanished { get; set; }
	public bool IsHoveredPerkAChoice { get; set; }
	public bool IsHoveredPerkHidden { get; set; }
	public bool IsHoveringPerkChoicePanel { get; set; }
	public bool IsHoveringStatsTab { get; set; }

	public bool DashReminderActive { get; set; }
	public bool DashReminderShownThisRun { get; set; }
	public Vector2 DashReminderScreenPos { get; set; }
	public float DashReminderIdleTime { get; set; }

	public ShopItemDef? HoveredShopItem { get; set; }
	public Panel HoveredShopItemPanel { get; set; }

	public Player HoveredPlayer { get; set; }
	private float _hoverPlayerTimer;
	public Player HoveredPlayerIcon { get; set; }
	public string LobbyHoveredPlayerName { get; set; }
	public int SkipShowingLeaderboardFrames { get; set; }

	public bool IsEscMenuOpen { get; private set; }
	public bool IsOptionsMenuOpen { get; private set; }

	public int SkipShowingChoicesFrames { get; set; }

	public List<Player> Players = new();
	public List<Player> AlivePlayers = new();

	public const int MAX_PLAYERS = 3;
	public const int MAX_EXTRA_SPECTATORS = 12;

	public bool IsMultiplayer => Players.Count > 1;
	public bool IsUnpausedChoosing => IsMultiplayer || Difficulty >= FirstDifficultyWithoutPauseChoosing
		|| (LocalPlayer.IsValid() && (LocalPlayer.Stats[PlayerStat.UnpausedChoosing] > 0f || LocalPlayer.Stats[PlayerStat.PreventPausing] > 0f ));

	/// <summary>
	/// Keeps track of how long the run has had X number of players, for scoring purposes.
	/// Host only.
	/// </summary>
	public Dictionary<int, float> NumPlayersDuration { get; private set; } = new();
	[Sync] public int NumPlayersThisRun { get; set; }

	public int NumGibs { get; set; }
	public int NumDecals { get; set; }

	/// <summary>
	/// Number of enemies killed by explosions, so not too many gibs spawn from explosions - quickly degrades
	/// </summary>
	public int NumExplosionKilledEnemies { get; set; }

	public TimeSince TimeSinceMagnet { get; set; }

	public int FriendlyFireEnabledAmount { get; set; }

	private int _numEnemyDeathSfxs;

	private const float SFX_PITCH_MODIFIER = 0.775f;
	private const float SFX_VOLUME_MODIFIER = 0.7f;

	public const float GLOBAL_VOLUME = 1f;

	public float GlobalMovespeedModifier { get; set; }
	public float GlobalFrictionModifier { get; set; }
	public bool IsWindActive { get; set; }
	public Vector2 GlobalWindForce { get; set; }
	public float EnemySpawnTimeModifier { get; set; }

	public string MouseDebugText { get; set; }

	public Mixer MasterMixer { get; set; }
	public Mixer MusicMixer { get; set; }
	public Mixer SfxMixer { get; set; }

	[Sync] public int CommunismLevel { get; set; } = 0;
	public bool IsCommunismActive => CommunismLevel > 0;

	public bool DontConfirmRestart { get; set; }
	public int LobbyPrivacy { get; set; }

	public bool GodMode { get; set; }
	public bool DontSpawnRandomEnemies { get; set; }
	public bool LaunchEnemies { get; set; }
	public bool PlayerMaxDmg { get; set; }
	public bool DebugBulletBounce { get; set; }
	public bool DebugBulletPierce { get; set; }
	public bool DebugBulletSplash { get; set; }
	public bool ShowAllPerks { get; set; }

	public bool IsOrthoCamera { get; set; }

	public bool DontSubmitScore { get; set; }
	public bool DontFinishGameOnBossKill { get; set; }

	public int EnemyProjectileBounceFenceLevel { get; set; }

	private bool _isDeathFlashActive;
	private RealTimeSince _realTimeSinceDeathFlash;

	bool _isFadingIn;
	RealTimeSince _realTimeSinceFade;

	public bool ShowDebug { get; set; }

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

		Instance = this;
	}

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

		Hud = Components.Get<Hud>();
		Chat = Components.Get<Chat>();

		MasterMixer = Mixer.FindMixerByName( "Master" );
		MusicMixer = Mixer.FindMixerByName( "Music" );
		SfxMixer = Mixer.FindMixerByName( "Game" );

		RefreshMasterMixerVolume();
		RefreshMusicMixerVolume();
		RefreshSfxMixerVolume();

		var crosshair = Components.Get<CrosshairComponent>();
		if ( crosshair != null )
		{
			var crosshairScale = Math.Clamp( GameSettingsSystem.Current.CrosshairScale, 0.5f, 3f );
			GameSettingsSystem.Current.CrosshairScale = crosshairScale;
			crosshair.Scale = crosshairScale;
		}

		float bounds = 1200f;
		BOUNDS_MIN = new Vector2( -bounds, -bounds );
		BOUNDS_MAX = new Vector2( bounds, bounds );
		BOUNDS_MIN_SPAWN = BOUNDS_MIN + new Vector2( 100f, 100f );
		BOUNDS_MAX_SPAWN = BOUNDS_MAX - new Vector2( 100f, 100f );
		BOUNDS_CHECK_SIZE_SQR = (bounds * bounds) * 0.95f;

		GlobalMovespeedModifier = 1f;
		GlobalFrictionModifier = 1f;

		Difficulty = Math.Clamp( GameSettingsSystem.Current.Difficulty, MinDifficulty, MaxDifficulty );
		//SetDifficultyEffect( Difficulty );

		LobbyMaps[Difficulty]?.Enabled = true;
		LobbyMapObjects[Difficulty]?.Enabled = true;

		DontConfirmRestart = GameSettingsSystem.Current.DontConfirmRestart;
		LobbyPrivacy = GameSettingsSystem.Current.LobbyPrivacy;
		LeaderboardShowFriends = GameSettingsSystem.Current.LeaderboardShowFriends;

		ShowAllPerks = false;
		DontSpawnRandomEnemies = false;//GameSettingsSystem.Current.DontSpawnRandomEnemies;
		GodMode = false;//GameSettingsSystem.Current.GodMode;
		LaunchEnemies = false;// GameSettingsSystem.Current.LaunchEnemies;
		PlayerMaxDmg = false;//GameSettingsSystem.Current.PlayerMaxDmg;
		DebugBulletBounce = false;//GameSettingsSystem.Current.DebugBulletBounce;
		DebugBulletPierce = false;//GameSettingsSystem.Current.DebugBulletPierce;
		DebugBulletSplash = false;//GameSettingsSystem.Current.DebugBulletSplash;
		IsOrthoCamera = true;// GameSettingsSystem.Current.IsOrthoCamera;

		//DontSpawnRandomEnemies = false;
		//GodMode = false;
		//LaunchEnemies = false;
		//PlayerMaxDmg = false;
		//DebugBulletBounce = false;
		//DebugBulletPierce = false;
		//DebugBulletSplash = false;
		//IIsOrthoCamera = false;

		NumPlayersLeaderboardToShow = 1;

		ProgressManager.Load();

		LoadDifficultyVictories();

		//Fade( fadeIn: true );

		if ( !Networking.IsHost )
			return;

		CreateLobby();

		GameState = GameState.Lobby;
	}

	public void CreateLobby()
	{
		Networking.CreateLobby( new Sandbox.Network.LobbyConfig()
		{
			//Privacy = (Sandbox.Network.LobbyPrivacy)LobbyPrivacy,
			Privacy = Sandbox.Network.LobbyPrivacy.Public,
			MaxPlayers = MAX_PLAYERS + MAX_EXTRA_SPECTATORS,
			Name = "Sausage Survivors 2 - " + Connection.Local.DisplayName,
			DestroyWhenHostLeaves = true
		} );
	}

	public void SetGameState( GameState gameState )
	{
		Assert.True( Networking.IsHost );

		if ( GameState == gameState )
			return;

		if ( gameState == GameState.Lobby )
		{
			ReturnToLobby();

			DestroyStuffHost();
		}
		else
		{
			Restart();
		}

		GameState = gameState;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void ReturnToLobby()
	{
		for ( int i = 0; i <= MaxDifficulty; i++ )
		{
			LobbyMaps[i]?.Enabled = Difficulty == i;
			LobbyMapObjects[i]?.Enabled = Difficulty == i;
		}

		DestroyStuffClient();
		DisableOverlayEffects();

		Chat.Restart();

		StopMusic();

		Scene.TimeScale = 1f;
		TimeScaleSync = 1f;
		_timeScaleRequests.Clear();
		IsPaused = false;

		//SubmitGameOverStats();

		RunEntryToShow = null;
		PlayerProfileToShow = null;

		DashReminderActive = false;
		DashReminderShownThisRun = false;
		DashReminderIdleTime = 0f;
		SelectedPlayer = null;
		_spectatorCamShakeDatas.Clear();

		Camera.Orthographic = false;

		Fade( fadeIn: true );

		if ( IsProxy )
			return;

		var existingPlayers = Scene.GetAll<Player>().ToList();
		foreach ( var connection in Connection.All )
		{
			if ( existingPlayers.Count >= MAX_PLAYERS ) break;
			if ( existingPlayers.Any( x => x.Network.Owner.Id == connection.Id ) ) continue;
			var newPlayer = SpawnPlayer( connection );
			existingPlayers.Add( newPlayer );
		}

		foreach ( var player in existingPlayers )
		{
			player.Restart();
			player.EnterLobby();
		}
	}

	void Component.INetworkListener.OnActive( Connection channel )
	{
		Log.Info( $"INetworkListener.OnActive - channel: {channel}" );

		var existingPlayers = Scene.GetAll<Player>().ToList();
		if ( existingPlayers.Any( x => x.Network.Owner.Id == channel.Id ) )
			return;

		if ( GameState == GameState.Playing )
		{
			return;
		}
		else if ( existingPlayers.Count < MAX_PLAYERS )
		{
			SpawnPlayer( channel );
		}
	}

	/// <summary>
	/// Called when someone leaves the server. This will only be called for the host.
	/// </summary>
	void Component.INetworkListener.OnDisconnected( Connection channel )
	{
		Log.Info( $"INetworkListener.OnDisconnected - channel: {channel}" );

	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void Restart()
	{
		DestroyStuffClient();
		DisableOverlayEffects();

		Chat.Restart();

		SelectedPlayer = null;
		_spectatorCamShakeDatas.Clear();

		DashReminderActive = false;
		DashReminderShownThisRun = false;
		DashReminderIdleTime = 0f;

		CurrMinute = 0;
		RestartEvents();
		RestartStats();

		GlobalMovespeedModifier = 1f;
		GlobalFrictionModifier = 1f;
		IsWindActive = false;
		GlobalWindForce = Vector2.Zero;
		EnemySpawnTimeModifier = 1f;
		IcyGroundFog.Enabled = false;

		TimeScaleSync = Scene.TimeScale = 1f;
		_timeScaleRequests.Clear();
		IsPaused = false;
		IsGameOver = false;
		ShouldShowGameOverScreen = false;
		ShouldShowQuestProgressPanel = false;
		ShouldShowPerkUnlockProgressPanel = false;
		ShowShopPanel = false;
		ShowLoadoutPanel = false;
		RealTimeSinceGameOver = 0f;
		ProgressManager.TakeRunSnapshot();

		RunEntryToShow = null;

		DontSubmitScore = false;
		DontFinishGameOnBossKill = false;

		IsOrthoCamera = true;
		Camera.Orthographic = true;
		Camera.OrthographicHeight = 500f;

		foreach ( var river in Scene.GetAll<River>() )
			river.Restart();

		for( int i = 0; i <= MaxDifficulty; i++ )
		{
			Maps[i]?.Enabled = Difficulty == i;
			MapObjects[i]?.Enabled = Difficulty == i;
			LobbyMaps[i]?.Enabled = false;
			LobbyMapObjects[i]?.Enabled = false;
		}

		Fade( fadeIn: true );

		if ( IsProxy )
			return;

		HasSpawnedBoss = false;
		IsBossDead = false;
		ElapsedTime = 0f; // 778f;
		RestartMusic();
		TimeSinceMagnet = 0f;
		FriendlyFireEnabledAmount = 0;
		ShowPreciseNumbers = false;

		NumGibs = 0;
		NumDecals = 0;
		NumEnemies = 0;
		NumExplosionKilledEnemies = 0;

		var existingPlayers = Scene.GetAll<Player>().ToList();

		DestroyStuffHost();

		foreach ( var player in existingPlayers )
		{
			player.Restart();
			player.EnterGame();
		}

		AlivePlayers = existingPlayers.Where( x => !x.IsDead ).ToList();

		SpawnStartingThings();

		GenerateEvents();

		NumPlayersDuration.Clear();
		NumPlayersThisRun = Players.Count;

		CommunismLevel = 0;
		EnemyProjectileBounceFenceLevel = 0;

		//LogPerkCounts();
	}

	void LogPerkCounts()
	{
		var rarityCounts = new Dictionary<Rarity, int>();
		int curseCount = 0;
		int disabledCount = 0;

		foreach ( var type in TypeLibrary.GetTypes<Perk>() )
		{
			var attrib = type.GetAttribute<PerkAttribute>();
			if ( attrib == null ) continue;

			if ( attrib.Disabled ) { disabledCount++; continue; }
			if ( attrib.Curse ) { curseCount++; continue; }

			if ( !rarityCounts.ContainsKey( attrib.Rarity ) )
				rarityCounts[attrib.Rarity] = 0;
			rarityCounts[attrib.Rarity]++;
		}

		Log.Info( "=== Perk Counts ===" );
		foreach ( var rarity in Enum.GetValues<Rarity>() )
		{
			if ( rarityCounts.TryGetValue( rarity, out int count ) )
				Log.Info( $"  {rarity}: {count}" );
		}
		Log.Info( $"  Curses: {curseCount}" );
		Log.Info( $"  Disabled: {disabledCount}" );
	}

	void DestroyStuffClient()
	{
		// todo: loop over things with a destroy tag instead

		foreach ( var particleEffect in Scene.GetAll<ParticleEffect>().Where( x => !x.Tags.Has( "dont_destroy" ) ) )
			particleEffect.GameObject.Destroy();

		foreach ( var particleEffect in Scene.GetAll<PerkFloater>() )
			particleEffect.GameObject.Destroy();

		foreach ( var gib in Scene.GetAll<GibFader>() )
			gib.GameObject.Destroy();

		foreach ( var corpse in Scene.GetAll<PlayerDeathRagdoll>() )
			corpse.GameObject.Destroy();

		foreach ( var decal in Scene.GetAll<BloodDecal>() )
			decal.GameObject.Destroy();

		foreach ( var decal in Scene.GetAll<BloodSplatDecal>() )
			decal.GameObject.Destroy();

		foreach ( var decal in Scene.GetAll<ScorchDecal>() )
			decal.GameObject.Destroy();

		foreach ( var shockwave in Scene.GetAll<Shockwave>() )
			shockwave.GameObject.Destroy();

		foreach ( var lavaPuddle in Scene.GetAll<LavaPuddle>() )
			lavaPuddle.GameObject.Destroy();

		foreach ( var lavaPuddle in Scene.GetAll<GroundWarning>() )
			lavaPuddle.GameObject.Destroy();

		foreach ( var spitterBlinkEffect in Scene.GetAll<SpitterBlinkEffect>() )
			spitterBlinkEffect.GameObject.Destroy();

		//foreach ( var playerLights in Scene.GetAll<PlayerLights>() )
		//	playerLights.GameObject.Destroy();
	}

	void DestroyStuffHost()
	{
		foreach ( var thing in Scene.GetAll<Thing>() )
		{
			if ( thing is Player )
				continue;

			if ( thing.GameObject.Tags.Has( "dont_destroy" ) )
				continue;

			thing.GameObject.Destroy();
		}

		foreach ( var artilleryShell in Scene.GetAll<ArtilleryShell>() )
			artilleryShell.GameObject.Destroy();

		foreach ( var spikerHeadArea in Scene.GetAll<SpikerHeadArea>() )
			spikerHeadArea.GameObject.Destroy();
	}

	void DisableOverlayEffects()
	{
		foreach( var pair in OverlayEffects )
			pair.Value.Enabled = false;
	}

	private Player GetDefaultSelectedPlayer()
	{
		foreach ( var player in Players )
		{
			if ( player.IsValid() && !player.IsDead )
				return player;
		}

		foreach ( var player in Players )
		{
			if ( player.IsValid() )
				return player;
		}

		return null;
	}

	private Player _lastMaintainedSelectedPlayer;
	private bool _lastMaintainedSelectedPlayerWasDead;

	private void MaintainSelectedPlayer()
	{
		if ( !IsSpectator )
			return;

		if ( !SelectedPlayer.IsValid() )
		{
			SelectedPlayer = GetDefaultSelectedPlayer();
			_lastMaintainedSelectedPlayer = SelectedPlayer;
			_lastMaintainedSelectedPlayerWasDead = SelectedPlayer.IsValid() && SelectedPlayer.IsDead;
			return;
		}

		bool selectedPlayerJustDied =
			SelectedPlayer == _lastMaintainedSelectedPlayer &&
			!_lastMaintainedSelectedPlayerWasDead &&
			SelectedPlayer.IsDead;

		if ( selectedPlayerJustDied )
		{
			var livePlayer = Players.FirstOrDefault( player => player.IsValid() && !player.IsDead && player != SelectedPlayer );
			if ( livePlayer.IsValid() )
				SelectedPlayer = livePlayer;
		}

		_lastMaintainedSelectedPlayer = SelectedPlayer;
		_lastMaintainedSelectedPlayerWasDead = SelectedPlayer.IsValid() && SelectedPlayer.IsDead;
	}

	private void HandleSpectatorCamShaking()
	{
		var shakeAmount = 0f;

		for ( int i = _spectatorCamShakeDatas.Count - 1; i >= 0; i-- )
		{
			var data = _spectatorCamShakeDatas[i];
			var time = data.useRealTime ? RealTime.Now : Time.Now;

			if ( time > data.startTime + data.time )
			{
				_spectatorCamShakeDatas.RemoveAt( i );
			}
			else
			{
				float amount = Utils.Map( time, data.startTime, data.startTime + data.time, data.strength, 0f, data.easingType );
				shakeAmount = MathF.Max( amount, shakeAmount );
			}
		}

		Camera.LocalPosition = shakeAmount > 0f
			? Rotation.Random.Forward * shakeAmount
			: Vector3.Zero;
	}

	private void ApplySpectatorCamera()
	{
		if ( !IsSpectator )
			return;

		MaintainSelectedPlayer();

		if ( SelectedPlayer.IsValid() )
			SelectedPlayer.ApplyCameraView( allowHurtZoomDecay: false );

		HandleSpectatorCamShaking();
	}

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

		ProgressManager.Tick();

		//if( Input.Keyboard.Pressed( "P" ) )
		//	ShowDebug = !ShowDebug;

		// todo: cache these instead of GetAll every frame
		Players = Scene.GetAll<Player>().ToList();
		MaintainSelectedPlayer();

		HandleFadingIn();

		//Log.Info( $"_isFadingIn: {_isFadingIn} _realTimeSinceFade: {_realTimeSinceFade}" );	

		//if ( Input.Keyboard.Down( "B" ) )
		//DrawDebugText();

		if ( !string.IsNullOrEmpty( MouseDebugText ) )
		{
			Gizmo.Draw.Color = Color.White;
			Gizmo.Draw.ScreenText( MouseDebugText, Mouse.Position + new Vector2( -50, -10 ), flags: TextFlag.Center );
		}

		if ( GameState == GameState.Lobby )
		{
			Scene.TimeScale = 1f;
			Camera.LocalPosition = Vector3.Zero;
			if ( !LocalPlayer.IsValid() )
			{
				CameraContainer.WorldPosition = Vector3.Lerp( CameraContainer.WorldPosition, LOBBY_CAMERA_POS, 9f * RealTime.Delta, true );
				CameraContainer.WorldRotation = new Angles( 0f, 0f, 0f );
			}

			HandleMusic();
			return;
		}

		if ( IsSpectator && Input.EscapePressed )
		{
			if ( IsOptionsMenuOpen )
				SetOptionsMenuOpen( false );
			else
				SetEscMenuOpen( !IsEscMenuOpen );

			Input.EscapePressed = false;
		}

		ApplySpectatorCamera();

		HandleHovering();
		HandleMinute();
		HandleMusic();
		HandleActiveEvents();

		AlivePlayers = Players.Where( x => !x.IsDead ).ToList();

		if ( _numEnemyDeathSfxs > 0 )
			_numEnemyDeathSfxs--;

		HandleTimeScale();

		if ( IsGameOver && !ShouldShowPerkUnlockProgressPanel && !ShouldShowQuestProgressPanel && !ShouldShowGameOverScreen )
		{
			if ( RealTimeSinceGameOver > GAME_OVER_UI_DELAY )
			{
				if ( HideProgressionSystem )
					ShouldShowGameOverScreen = true;
				else if ( ProgressManager.AreAllLockedPerksUnlocked() || ProgressManager.RunXpEarned <= 0f )
					ShouldShowQuestProgressPanel = true;
				else
					ShouldShowPerkUnlockProgressPanel = true;
				IsEscMenuOpen = false;
				IsOptionsMenuOpen = false;
			}
		}

		if ( NumExplosionKilledEnemies > 0 )
			NumExplosionKilledEnemies--;

		if ( SkipShowingChoicesFrames > 0 )
			SkipShowingChoicesFrames--;

		HandleDeathFlash();

		if ( !Networking.IsHost )
			return;

		if ( IsGameOver )
			return;

		HandleNumPlayersScoring();

		if ( AlivePlayers.Count == 0 )
			GameOverRpc( victory: false );

		HandleEnemySpawn();

		GetEnemySpawnTimeModifier();
	}

	void DrawDebugText()
	{
		var minutes = ElapsedTime / 60f;
		var progress = minutes / BOSS_SPAWN_MINUTES_DEFAULT;
		var str = $"------------ NumEnemies: {NumEnemies}  ------------ minutes: {minutes.ToString( "0.##" )} progress: {progress.ToString( "0.##" )} timeFactor: {GetTimeFactor( ElapsedTime )}\n\n";
		str += $" ------------- threat level: {_enemyThreatTotal} -------------\n\n";
		str += " ------------- Existing: -------------\n";
		foreach ( var pair in EnemyExistingCounts )
		{
			var weight = GetEnemySpawnWeight( pair.Key );
			str += $"{pair.Key}: {pair.Value}/{EnemySpawnedCounts[pair.Key]}  {(weight > 0f ? $"  --- weight: ({weight.ToString( "0.###" )})" : "")}\n";
		}

		str += "\n";
		str += $"_timeSinceMinibossCheck: {_timeSinceMinibossCheck}";

		//str += "\n";
		//str += " ------------- Spawned: -------------\n";
		//foreach ( var pair in EnemySpawnedCounts )
		//	str += $"{pair.Key}: {pair.Value}\n";

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

		//Gizmo.Draw.ScreenText( $"ElapsedTime: {ElapsedTime}\nIsPaused: {IsPaused}\nNumGibs: {NumGibs}\nNumDecals: {NumDecals}\nNumEnemies: {NumEnemies}", new Vector2( 50, 50 ) );
		//Gizmo.Draw.ScreenText( $"CameraHurtZoomAmount: {LocalPlayer.CameraHurtZoomAmount}", new Vector2( 50, 50 ) );

		//Gizmo.Draw.ScreenText( $"Mouse.Visibility: {Mouse.Visibility}", new Vector2( 50, 50 ) );
	}

	void GetEnemySpawnTimeModifier()
	{
		EnemySpawnTimeModifier = 1f;
		foreach ( var player in Players )
		{
			if ( player is null || !player.IsValid() )
				continue;

			EnemySpawnTimeModifier *= player.GetSyncStat( PlayerStat.EnemySpawnTimeModifier );
		}
	}

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

		int numPlayers = Players.Count;

		if ( NumPlayersDuration.ContainsKey( numPlayers ) )
			NumPlayersDuration[numPlayers] += Time.Delta;
		else
			NumPlayersDuration[numPlayers] = Time.Delta;

		// Find the number of players with the highest accumulated duration
		int longestNumPlayers = numPlayers;
		float maxDuration = 0f;
		foreach ( var pair in NumPlayersDuration )
		{
			if ( pair.Value > maxDuration )
			{
				maxDuration = pair.Value;
				longestNumPlayers = pair.Key;
			}
		}
		NumPlayersThisRun = longestNumPlayers;
	}

	// How high above the ground (in world units) the aim reference plane sits.
	// At 50° camera pitch, each unit here shifts the aim point ~0.84 units toward the player in Y.
	// Raise this if shots feel like they land behind enemies; lower it if they land in front.
	const float AimPlaneHeight = 40f;

	void HandleHovering()
	{
		var tr = Scene.Trace.Ray( Scene.Camera.ScreenPixelToRay( Mouse.Position ), 9999f ).WithAllTags( "bg_ground" ).Run();
		if ( tr.Hit )
		{
			// The ground trace lands at z≈0 (enemy feet). Shift the hit backward along
			// the camera ray to AimPlaneHeight so cursor-on-enemy-body gives accurate aim.
			var camFwd = Scene.Camera.WorldRotation.Forward; // all ortho rays are parallel
			float tDiff = AimPlaneHeight / camFwd.z; // camFwd.z is negative, so tDiff is negative
			MouseWorldPos = new Vector2(
				tr.HitPosition.x + camFwd.x * tDiff,
				tr.HitPosition.y + camFwd.y * tDiff
			);
		}

		// Compute the screen position where the crosshair should appear.
		var localPlayer = LocalPlayer;
		if ( localPlayer.IsValid() )
		{
			localPlayer.CrosshairScreenFraction = new Vector2(
				Screen.Width > 0f ? Mouse.Position.x / Screen.Width : 0.5f,
				Screen.Height > 0f ? Mouse.Position.y / Screen.Height : 0.5f
			);

			if ( Input.UsingController )
			{
				var aimDir = localPlayer.AimDir.LengthSquared > 0f ? localPlayer.AimDir : Vector2.One.Normal;
				if ( localPlayer.AimAngleOffset != 0f )
					aimDir = Utils.RotateVector( aimDir, localPlayer.AimAngleOffset );
				localPlayer.CrosshairWorldPos = localPlayer.Position2D + aimDir * 100f;
			}
			else if ( localPlayer.AimAngleOffset != 0f )
			{
				var toMouse = MouseWorldPos - localPlayer.Position2D;
				var dist = toMouse.Length;
				if ( dist > 0.01f )
				{
					var rotatedDir = Utils.RotateVector( toMouse.Normal, localPlayer.AimAngleOffset );
					localPlayer.CrosshairWorldPos = localPlayer.Position2D + rotatedDir * dist;
				}
				else
				{
					localPlayer.CrosshairWorldPos = MouseWorldPos;
				}
			}
			else
			{
				localPlayer.CrosshairWorldPos = MouseWorldPos;
			}
		}

		// Compute the recoil arrow angle for the crosshair indicator.
		// Projects a world-space perpendicular point through the camera to get the screen-space direction.
		if ( localPlayer.IsValid() )
		{
			localPlayer.SyncShowCrosshairRecoilArrow = false;

			if ( localPlayer.Stats[PlayerStat.RotationalRecoil] > 0f )
			{
				Vector2 aimBase, aimNormal;
				bool validAim;
				if ( Input.UsingController )
				{
					aimNormal = localPlayer.AimDir.LengthSquared > 0f ? localPlayer.AimDir : Vector2.One.Normal;
					if ( localPlayer.AimAngleOffset != 0f )
						aimNormal = Utils.RotateVector( aimNormal, localPlayer.AimAngleOffset );
					aimBase = localPlayer.Position2D + aimNormal * 100f;
					validAim = true;
				}
				else
				{
					var toAim = localPlayer.CrosshairWorldPos - localPlayer.Position2D;
					validAim = toAim.LengthSquared > 0.01f;
					aimNormal = validAim ? toAim.Normal : Vector2.Zero;
					aimBase = localPlayer.CrosshairWorldPos;
				}

				if ( validAim )
				{
					var perpWorld = Utils.RotateVector( aimNormal, localPlayer.RecoilSign * 90f );
					var arrowEndWorld = aimBase + perpWorld * 100f;
					var crosshairScreenPos = Scene.Camera.PointToScreenPixels( new Vector3( localPlayer.CrosshairWorldPos.x, localPlayer.CrosshairWorldPos.y, AimPlaneHeight ) );
					var arrowEndScreen = Scene.Camera.PointToScreenPixels( new Vector3( arrowEndWorld.x, arrowEndWorld.y, AimPlaneHeight ) );
					var arrowDirScreen = (arrowEndScreen - crosshairScreenPos).Normal;
					localPlayer.SyncCrosshairRecoilArrowAngle = MathF.Atan2( arrowDirScreen.x, -arrowDirScreen.y ) * (180f / MathF.PI);
					localPlayer.SyncShowCrosshairRecoilArrow = true;
				}
			}
		}

		var viewedCrosshairPlayer = IsSpectator && SelectedPlayer.IsValid() ? SelectedPlayer : localPlayer;
		if ( viewedCrosshairPlayer.IsValid() )
		{
			if ( viewedCrosshairPlayer.IsChoosingLevelUpReward )
			{
				CrosshairScreenPos = new Vector2(
					viewedCrosshairPlayer.CrosshairScreenFraction.x * Screen.Width,
					viewedCrosshairPlayer.CrosshairScreenFraction.y * Screen.Height
				);
			}
			else
			{
				var crosshairWorldPos = viewedCrosshairPlayer.CrosshairWorldPos;
				if ( crosshairWorldPos.LengthSquared <= 0.0001f )
				{
					var fallbackFacingDir = viewedCrosshairPlayer.FacingDir.LengthSquared > 0f ? viewedCrosshairPlayer.FacingDir.Normal : Vector2.One.Normal;
					crosshairWorldPos = viewedCrosshairPlayer.Position2D + fallbackFacingDir * 100f;
				}

				CrosshairScreenPos = Scene.Camera.PointToScreenPixels( new Vector3( crosshairWorldPos.x, crosshairWorldPos.y, AimPlaneHeight ) );
			}

			CrosshairRecoilArrowAngle = viewedCrosshairPlayer.SyncCrosshairRecoilArrowAngle;
			ShowCrosshairRecoilArrow = viewedCrosshairPlayer.SyncShowCrosshairRecoilArrow;
		}
		else
		{
			CrosshairScreenPos = Mouse.Position;
			ShowCrosshairRecoilArrow = false;
		}

		tr = Scene.Trace.Sphere( 4f, Scene.Camera.ScreenPixelToRay( Mouse.Position ), 9999f ).HitTriggersOnly().WithAllTags( "player" ).Run();
		var hoveredPlayer = tr.Hit ? tr.GameObject?.GetComponent<Player>() : null;
		if ( hoveredPlayer.IsValid() && hoveredPlayer != LocalPlayer )
		{
			_hoverPlayerTimer += Time.Delta;
			if ( _hoverPlayerTimer > 0.25f || IsPaused )
				HoveredPlayer = hoveredPlayer;
		}
		else
		{
			_hoverPlayerTimer = 0f;
			HoveredPlayer = null;
		}

		HandlePerkItemHovering();
	}

	public bool IsHoveredPerkFromWorldItem { get; private set; }

	void HandlePerkItemHovering()
	{
		PerkItem hoveredItem = null;

		bool canHover = GameState == GameState.Playing
			&& LocalPlayer.IsValid()
			&& (HoveredPerkPanel == null || IsHoveredPerkFromWorldItem);

		if ( canHover )
		{
			var tr = Scene.Trace.Sphere( radius: 1f, Scene.Camera.ScreenPixelToRay( CrosshairScreenPos ), 9999f )
				.HitTriggersOnly().WithAllTags( "item" ).Run();
			if ( tr.Hit )
			{
				var candidate = tr.GameObject?.GetComponent<PerkItem>();
				if ( candidate.IsValid() && !candidate.ShouldHidePanel )
					hoveredItem = candidate;
			}
		}

		if ( hoveredItem.IsValid() )
		{
			var type = PerkManager.IdentityToType( hoveredItem.PerkTypeIdentity );
			if ( type != null )
			{
				var attrib = type.GetAttribute<PerkAttribute>();
				var maxLevel = PerkManager.GetMaxLevelForRarity( attrib.Rarity );
				var currentLevel = LocalPlayer.GetPerkLevel( type );
				var nextLevel = Math.Min( currentLevel + 1, maxLevel );

				HoveredPerkType = type;
				HoveredPerkLevel = nextLevel;
				HoveredPerkPanel = null;
				HoveredPerkViewedPlayer = null;
				HoveredPerkChoiceSlot = -1;
				IsHoveredPerkAChoice = false;
				IsHoveredPerkBanished = false;
				IsHoveredPerkHidden = false;
				IsHoveredPerkFromWorldItem = true;
			}
		}
		else if ( IsHoveredPerkFromWorldItem )
		{
			if ( HoveredPerkPanel == null )
			{
				HoveredPerkType = null;
				HoveredPerkViewedPlayer = null;
				HoveredPerkChoiceSlot = -1;
				IsHoveredPerkAChoice = false;
				IsHoveredPerkBanished = false;
				IsHoveredPerkHidden = false;
			}
			IsHoveredPerkFromWorldItem = false;
		}
	}

	void HandleMinute()
	{
		if ( IsGameOver )
			return;

		int minute = MathX.FloorToInt( ElapsedTime / 60f );
		if ( CurrMinute != minute )
		{
			SetMinute( minute );
		}
	}

	void SetMinute( int minute )
	{
		CurrMinute = minute;

		SetMusicMinute( minute );

		if ( !Networking.IsHost )
			return;

		foreach ( var player in Players )
		{
			if ( player.IsValid() )
				player.SetMinute( minute );
		}

		CheckForNewMinuteEvent();
	}

	public Player GetClosestPlayer( Vector2 pos, float radius = 0f, Thing except = null, bool onlyAlive = true )
	{
		Player closestPlayer = null;
		var closestDistSqr = float.MaxValue;

		var players = onlyAlive ? AlivePlayers : Players;
		foreach ( var player in players )
		{
			if ( player == except )
				continue;

			var distSqr = (player.Position2D - pos).LengthSquared;

			if ( radius > 0f && distSqr > MathF.Pow( radius, 2f ) )
				continue;

			if ( distSqr < closestDistSqr )
			{
				closestPlayer = player;
				closestDistSqr = distSqr;
			}
		}

		return closestPlayer;
	}

	// todo: use sphere raycast or something instead
	public Enemy GetClosestEnemy( Vector2 pos, float radius = 0f, bool onlyCountsAsKill = true, Thing except = null )
	{
		float closestDistSqr = float.MaxValue;
		Enemy closestEnemy = null;
		foreach ( var enemy in Scene.GetAll<Enemy>() )
		{
			if ( !enemy.IsValid() || enemy.IsDying || (onlyCountsAsKill && !enemy.CountsAsKill) )
				continue;

			if ( except != null && enemy == except )
				continue;

			if ( enemy.IsSpawning && !enemy.AlmostFinishedSpawning )
				continue;

			var distSqr = (pos - enemy.Position2D).LengthSquared;

			if ( radius > 0f && distSqr > MathF.Pow( radius, 2f ) )
				continue;

			if ( distSqr < closestDistSqr )
			{
				closestDistSqr = distSqr;
				closestEnemy = enemy;
			}
		}

		return closestEnemy;
	}

	public IEnumerable<Enemy> GetNearbyEnemies( Vector2 pos, float radius, Thing except = null )
	{
		List<Enemy> enemies = new();

		foreach ( var enemy in Scene.GetAll<Enemy>() )
		{
			if ( !enemy.IsValid() || enemy.IsDying || enemy == except )
				continue;

			var dist_sqr = (enemy.Position2D - pos).LengthSquared;
			if ( dist_sqr < MathF.Pow( radius, 2f ) )
				enemies.Add( enemy );
		}

		return enemies;
	}

	public IEnumerable<Unit> GetNearbyUnits( Vector2 pos, float radius, Thing except = null )
	{
		List<Unit> units = new();

		foreach ( var unit in Scene.GetAll<Unit>() )
		{
			if ( !unit.IsValid() || unit.IsDying || unit == except )
				continue;

			var dist_sqr = (unit.Position2D - pos).LengthSquared;
			if ( dist_sqr < MathF.Pow( radius, 2f ) )
				units.Add( unit );
		}

		return units;
	}

	public Unit GetClosestUnit( Vector2 pos, float radius = 0f, bool includeUnitRadius = false, Thing except = null, Func<Unit, bool> condition = null, float playerDistanceMult = 1f )
	{
		float closestDistSqr = float.MaxValue;
		Unit closestUnit = null;
		foreach ( var unit in Scene.GetAll<Unit>() ) // todo: use sphere cast
		{
			if ( !unit.IsValid() )
				continue;

			if ( except != null && unit == except )
				continue;

			var distSqr = (pos - unit.Position2D).LengthSquared;

			if ( radius > 0f && distSqr > MathF.Pow( radius + (includeUnitRadius ? unit.Radius : 0f), 2f ) )
				continue;

			if ( condition != null && !condition( unit ) )
				continue;

			if ( unit is Player player )
				distSqr *= playerDistanceMult;

			if ( distSqr < closestDistSqr )
			{
				closestDistSqr = distSqr;
				closestUnit = unit;
			}
		}

		return closestUnit;
	}

	//public int GetHighestPlayerLevel()
	//{
	//	int highestPlayerLvl = 0;
	//	foreach ( var player in Players )
	//	{
	//		if ( player.IsValid() && player.Level > highestPlayerLvl )
	//			highestPlayerLvl = player.Level;
	//	}

	//	return highestPlayerLvl;
	//}

	public void SetEscMenuOpen( bool open )
	{
		IsEscMenuOpen = open;
		if ( open )
		{
			Input.EnableVirtualCursor = true;
			Mouse.Visibility = MouseVisibility.Auto;
			Mouse.CursorType = "";
		}

		if ( !IsMultiplayer && !IsProxy && !IsGameOver && !(LocalPlayer.IsValid() && LocalPlayer.Stats[PlayerStat.PreventPausing] > 0) )
			SetPaused( open );
	}

	public void SetOptionsMenuOpen( bool open )
	{
		IsOptionsMenuOpen = open;
	}

	[Rpc.Broadcast]
	public void MagnetizeAllCoins( Player player )
	{
		PlaySfxNearby( "heal", player.Position2D, pitch: 0.8f, volume: 1.2f, maxDist: 400f );

		if ( IsProxy || !player.IsValid() )
			return;

		foreach ( var coin in Scene.GetAll<Coin>() )
		{
			coin.MagnetizeRpc( player );
		}
	}

	[Rpc.Broadcast]
	public void MagnetizeAllItems( Player player )
	{
		PlaySfxNearby( "heal", player.Position2D, pitch: 0.8f, volume: 1.2f, maxDist: 400f );

		if ( IsProxy || !player.IsValid() )
			return;

		foreach ( var item in Scene.GetAll<Item>() )
		{
			if( item is Bomb || item is ReviveSoul )
				continue;

			item.MagnetizeRpc( player );
		}
	}

	[Rpc.Host]
	public void PlayerDied( Player player )
	{
		//Log.Info( $"PlayerDied: {player} IsGameOver: {IsGameOver} AlivePlayers: {Scene.GetAll<Player>().Where( x => !x.IsDead && x != player ).ToList().Count}" );

		//foreach ( var p in Scene.GetAll<Player>().ToList() )
		//{
		//	Log.Info( $"{p} IsDead: {p.IsDead}" );
		//}

		if ( IsGameOver )
			return;

		AlivePlayers = Scene.GetAll<Player>().Where( x => !x.IsDead && x != player ).ToList();
		if ( AlivePlayers.Count == 0 )
			GameOverRpc( victory: false );
	}

	public void ContinueToQuestPanel()
	{
		ShouldShowPerkUnlockProgressPanel = false;
		ShouldShowQuestProgressPanel = true;
	}

	public void ContinueToGameOverScreen()
	{
		ShouldShowQuestProgressPanel = false;
		ShouldShowGameOverScreen = true;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void GameOverRpc( bool victory )
	{
		IsGameOver = true;
		GameOverTime = ElapsedTime;
		RealTimeSinceGameOver = 0f;

		//Scene.TimeScale = 0.1f;

		NumPlayersLeaderboardToShow = NumPlayersThisRun;

		foreach ( var enemy in Scene.GetAll<Enemy>() )
		{
			enemy.OnGameOver( victory );
		}

		if ( victory )
			RegisterVictoryForDifficulty( Difficulty );

		if ( victory && ElapsedTime < 120f )
			DontSubmitScore = true;

		// don't submit score if killing boss for testing
		if( !DontSubmitScore && !Game.IsEditor )
		{
			SubmitScore( victory );
			SubmitGameOverStats();
		}

		if ( !IsProxy )
		{
			ProgressManager.IncrementStat( ProgressStat.TotalRuns, 1 );
			if ( victory )
				ProgressManager.IncrementStat( ProgressStat.RunsWon, 1 );
			if ( LocalPlayer.IsValid() )
			{
				ProgressManager.ProcessRunXp( LocalPlayer.ExperienceTotal );
				Log.Info( $"Run XP earned: {LocalPlayer.ExperienceTotal}" );
			}
			ProgressManager.Save();
		}

		if ( IsProxy )
			return;

		TimeScaleSync = 0.1f;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void SetPaused( bool paused )
	{
		IsPaused = paused;

		if ( paused )
			_timeScaleBeforePause = TimeScaleSync;

		var timeScale = paused
			? 0f
			: (_timeScaleBeforePause > 0f ? _timeScaleBeforePause : 1f);

		TimeScaleSync = timeScale;
		Scene.TimeScale = timeScale;
		//Log.Info( $"Set Paused: {paused} TimeScaleSync: {TimeScaleSync} Scene.TimeScale: {Scene.TimeScale}" );

		foreach ( var particleEffect in Scene.GetAll<ParticleEffect>() )
		{
			if( particleEffect.GameObject.Tags.Has( "always_gametime" ) )
				particleEffect.Timing = ParticleEffect.TimingMode.GameTime;
			else
				particleEffect.Timing = paused ? ParticleEffect.TimingMode.GameTime : ParticleEffect.TimingMode.RealTime;
		}
			
		foreach ( var perkFloater in Scene.GetAll<PerkFloater>() )
			perkFloater.Timing = paused ? ParticleEffect.TimingMode.GameTime : ParticleEffect.TimingMode.RealTime;
	}

	[Rpc.Broadcast]
	public void CreateExplosionRpc( Vector2 pos, float radius, float damage, float repelRadius, float repelForce, Player playerSource, Enemy enemySource, EnemyType enemyType, Color color, RepelOptions options = RepelOptions.RepelEnemies | RepelOptions.RepelPlayers | RepelOptions.RepelItems | RepelOptions.DamagePlayers | RepelOptions.DamageEnemies )
	{
		GameObject.Clone( "prefabs/effects/explosion_small.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, Game.Random.Float( 40f, 60f ) ) ) } );

		var scorchDecalGo = GameObject.Clone( "prefabs/effects/scorch_decal.prefab", new global::Transform( new Vector3( pos.x, pos.y, SCORCH_HEIGHT ), new Angles( 90f, Game.Random.Float( 0f, 360f ), 0f ) ) );
		var scorch = scorchDecalGo.GetComponent<ScorchDecal>();
		scorch.StartTime = Game.Random.Float( 0.15f, 0.25f );
		scorch.Lifetime = Game.Random.Float( 7f, 10f ) * Utils.Map( Manager.Instance.NumDecals, 0, 80, 1f, 0.5f );
		scorch.Size = radius * Game.Random.Float( 0.2f, 0.3f );

		foreach ( var gib in Scene.GetAll<GibFader>() )
		{
			if ( !gib.IsValid() )
				continue;

			var distSqr = ((Vector3)pos - gib.WorldPosition).LengthSquared;
			var radiusSqr = MathF.Pow( repelRadius, 2f );
			if ( distSqr < radiusSqr )
			{
				var rigidBody = gib.GetComponent<Rigidbody>();
				if( rigidBody != null ) // todo: why getting a nullref here, which gib doesn't have a rigidbody?
				{
					var dir = (gib.WorldPosition - (Vector3)pos).Normal;

					var percent = Utils.Map( distSqr, 0f, radiusSqr, 1f, 0f );
					rigidBody.Velocity += dir * percent * repelForce;
				}
			}
		}

		var RING_RADIUS = radius * 1.1f;
		SpawnRing( pos, RING_RADIUS, color, 0.3f, path: "ring_explosion" );

		ShakeCamsNearby( pos, radius: radius * 3f, maxStrength: Utils.Map( damage, 5f, 60f, 2f, 5f ), time: Utils.Map( damage, 5f, 60f, 0.15f, 0.4f ) );

		PlaySfxNearby( "enemy.explode", pos, pitch: Game.Random.Float( 0.8f, 1.2f ), volume: 0.9f, maxDist: 750f );

		if ( IsProxy )
			return;

		AffectInRadius( pos, radius, damage, repelRadius, repelForce, playerSource, enemySource, enemyType, DamageType.Explosion, options );

		if ( playerSource.IsValid() )
			playerSource.AddResultsStatRpc( ResultStat.ExplosionsCaused, 1 );
	}

	[Rpc.Host]
	public void AffectInRadiusHost( Vector2 pos, float radius, float damage, float repelRadius, float repelForce, Player playerSource = null, Enemy enemySource = null, EnemyType enemyType = EnemyType.None, DamageType damageType = DamageType.Explosion, RepelOptions options = RepelOptions.RepelEnemies | RepelOptions.RepelPlayers | RepelOptions.RepelItems | RepelOptions.DamagePlayers | RepelOptions.DamageEnemies )
	{
		AffectInRadius( pos, radius, damage, repelRadius, repelForce, playerSource, enemySource, enemyType, damageType, options );
	}

	public void AffectInRadius( Vector2 pos, float radius, float damage, float repelRadius, float repelForce, Player playerSource = null, Enemy enemySource = null, EnemyType enemyType = EnemyType.None, DamageType damageType = DamageType.Explosion, RepelOptions options = RepelOptions.RepelEnemies | RepelOptions.RepelPlayers | RepelOptions.RepelItems | RepelOptions.DamagePlayers | RepelOptions.DamageEnemies )
	{
		var innerForce = damageType == DamageType.Explosion && playerSource.IsValid() && playerSource.GetSyncStat( PlayerStat.ExplosionInward ) > 0f;

		if ( (options & (RepelOptions.RepelEnemies | RepelOptions.DamageEnemies)) != 0 )
		{
			foreach ( var tr in Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "enemy" ).HitTriggersOnly().RunAll().ToList() )
			{
				var gameObject = tr.GameObject;
				var enemy = gameObject.GetComponent<Enemy>();
				if ( !enemy.IsValid() || enemy.IsInTheAir || (enemy.IsSpawning && !enemy.AlmostFinishedSpawning) )
					continue;

				Vector2 dir = (enemy.Position2D - pos).Normal;
				if ( innerForce )
					dir = -dir;

				var distSqr = (pos - enemy.Position2D).LengthSquared;
				var radiusSqr = MathF.Pow( radius + enemy.Radius, 2f );

				if ( options.HasFlag( RepelOptions.RepelEnemies ) )
				{
					var percent = Utils.Map( distSqr, 0f, radiusSqr, 1f, 0f );
					enemy.ExplosionVelocity += dir * percent * repelForce;
				}

				if ( options.HasFlag( RepelOptions.DamageEnemies ) )
				{
					var hitPos = enemy.WorldPosition - (Vector3)dir * enemy.Radius;
					enemy.DamageRpc( damage, playerSource, damageType, new Vector3( hitPos.x, hitPos.y, enemy.Height ), force: Vector2.Zero, isCrit: false, shouldFlinch: true );
				}
			}
		}

		if ( (options & (RepelOptions.RepelPlayers | RepelOptions.DamagePlayers)) != 0 )
		{
			foreach ( var tr in Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "player" ).HitTriggersOnly().RunAll().ToList() )
			{
				var gameObject = tr.GameObject;
				var player = gameObject.GetComponent<Player>();
				if ( player.IsDead || player.IsInTheAir )
					continue;

				Vector2 dir = (player.Position2D - pos).Normal;
				if ( innerForce )
					dir = -dir;

				var distSqr = (pos - player.Position2D).LengthSquared;
				var radiusSqr = MathF.Pow( radius + player.Radius, 2f );

				var percent = Utils.Map( distSqr, 0f, radiusSqr, 1f, 0f );

				if ( options.HasFlag( RepelOptions.RepelPlayers ) )
				{
					player.AddExplosionVelocity( dir * percent * repelForce * (1f / player.Weight) );
				}

				if ( options.HasFlag( RepelOptions.DamagePlayers ) )
				{
					var isSelfInflicted = playerSource.IsValid() && playerSource == player;

					var damageFlags = PlayerDamageFlags.None;
					if ( isSelfInflicted )
						damageFlags |= PlayerDamageFlags.SelfInflicted;

					var hitPos = player.WorldPosition - (Vector3)dir * player.Radius;
					var upwardAmount = percent * Game.Random.Float( 0.8f, 1.2f );
					var ragdollForce = repelForce * 0.005f;
					player.DamageRpc( damage, damageType, hitPos, dir, upwardAmount, force: 0f, ragdollForce, enemySource, enemyType, damageFlags: damageFlags ); // todo: enemy source isn't needed is it?
				}
			}

			foreach ( var corpse in Scene.GetAll<PlayerDeathRagdoll>() )
			{
				if ( !corpse.IsValid() )
					continue;

				var ragdollForce = repelForce * 0.005f;
				corpse.ApplyExplosionImpulse( pos, repelRadius, ragdollForce, innerForce );
			}
		}

		if ( options.HasFlag( RepelOptions.RepelItems ) )
		{
			foreach ( var tr in Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "item" ).HitTriggersOnly().RunAll().ToList() )
			{
				var gameObject = tr.GameObject;
				var item = gameObject.GetComponent<Item>();
				if ( !item.IsValid() || item.IsInTheAir )
					continue;

				Vector2 dir = (item.Position2D - pos).Normal;
				if ( innerForce )
					dir = -dir;

				var distSqr = (pos - item.Position2D).LengthSquared;
				var radiusSqr = MathF.Pow( radius + item.Radius, 2f );

				var percent = Utils.Map( distSqr, 0f, radiusSqr, 1f, 0f );
				item.Velocity += dir * percent * repelForce * 0.5f * (1f / item.Weight);
			}
		}

		// todo: should explode landmines and bombs?
	}

	[Rpc.Host]
	public void DamageNearbyEnemies( Vector2 pos, float radius, float damage, float force, DamageType damageType, Player playerSource, Enemy except = null )
	{
		var numHits = 0;
		var averagePos = Vector2.Zero;

		foreach ( var enemy in GetNearbyEnemies( pos, radius, except ) )
		{
			if ( enemy.IsDying || enemy.IsInTheAir || (enemy.IsSpawning && !enemy.AlmostFinishedSpawning) )
				continue;

			Vector2 dir = (enemy.Position2D - pos).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
						? (enemy.Position2D - pos).Normal
						: Utils.GetRandomVector();

			var hitPos = enemy.Position2D - dir * enemy.Radius;

			var shouldFlinch = damage < enemy.MaxHealth * 0.05f ? false : true;
			enemy.DamageRpc( damage, playerSource, damageType, new Vector3( hitPos.x, hitPos.y, enemy.Height ), dir * force, isCrit: false, shouldFlinch );

			numHits++;
			averagePos += hitPos;
		}

		if ( numHits > 0 )
			PlaySfxNearbyRpc( "enemy.hit", averagePos / numHits, pitch: Game.Random.Float( 1f, 1.3f ), volume: 0.7f, maxDist: 350f );
	}

	[Rpc.Host]
	public void DamageAllScaredEnemies( float damage, Player playerSource )
	{
		foreach ( var enemy in Scene.GetAll<Enemy>() )
		{
			if ( !enemy.IsValid() || enemy.IsDying || !enemy.IsFearful )
				continue;

			var playerPos = playerSource.Position2D;

			var dir = (enemy.Position2D - playerPos).Normal;
			var hitPos = enemy.WorldPosition - (Vector3)dir * enemy.Radius;

			enemy.DamageRpc( damage, playerSource, DamageType.Other, new Vector3( hitPos.x, hitPos.y, enemy.Height ), force: dir, isCrit: false, shouldFlinch: true );
		}
	}

	public Vector2 ClampPosToBounds( Vector2 pos, float buffer = 0f )
	{
		if ( pos.x < BOUNDS_MIN.x + buffer )
			pos = new Vector2( BOUNDS_MIN.x + buffer, pos.y );
		else if ( pos.x > BOUNDS_MAX.x - buffer )
			pos = new Vector2( BOUNDS_MAX.x - buffer, pos.y );

		if ( pos.y < BOUNDS_MIN.y + buffer )
			pos = new Vector2( pos.x, BOUNDS_MIN.y + buffer );
		else if ( pos.y > BOUNDS_MAX.y - buffer )
			pos = new Vector2( pos.x, BOUNDS_MAX.y - buffer );

		return pos;
	}

	public bool IsInBounds( Vector2 pos )
	{
		if ( pos.x < BOUNDS_MIN.x || pos.x > BOUNDS_MAX.x || pos.y < BOUNDS_MIN.y || pos.y > BOUNDS_MAX.y )
			return false;

		return true;
	}

	public void BossDied()
	{
		Assert.True( !IsProxy );

		if( DontFinishGameOnBossKill )
			return;

		IsBossDead = true;
		ProgressManager.IncrementStat( ProgressStat.BossKills, 1 );
		GameOverRpc( victory: true );
	}

	[Rpc.Host]
	public void AlertAllEnemies( Player player )
	{
		var numSfx = 0;

		foreach ( var enemy in Scene.GetAll<Enemy>() )
		{
			if ( !enemy.IsValid() || enemy.IsDying )
				continue;

			var playSfx = numSfx < 5 && Game.Random.Float( 0f, 1f ) < 1f;
			if ( playSfx )
				numSfx++;

			enemy.GainTarget( player, playSfx );
		}
	}

	[Rpc.Broadcast]
	public void EnableFriendlyFire()
	{
		FriendlyFireEnabledAmount++;
	}

	[Rpc.Broadcast]
	public void DisableFriendlyFire()
	{
		FriendlyFireEnabledAmount--;
	}

	[Rpc.Broadcast( NetFlags.Unreliable )]
	public void PlaySfxNearbyRpc( string name, Vector2 pos, float pitch, float volume, float maxDist )
	{
		PlaySfxNearby( name, pos, pitch, volume, maxDist );
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void PlaySfxNearbyRpcImportant( string name, Vector2 pos, float pitch, float volume, float maxDist )
	{
		PlaySfxNearby( name, pos, pitch, volume, maxDist );
	}

	public void PlaySfxNearby( string name, Vector2 pos, float pitch, float volume, float maxDist )
	{
		Vector2 playerPos;
		if ( LocalPlayer.IsValid() )
		{
			playerPos = LocalPlayer.Position2D;
		}
		else
		{
			if ( !SelectedPlayer.IsValid() )
				return;
			playerPos = SelectedPlayer.Position2D;
		}

		//Log.Info( $"PlaySfxNearby: {name} pos: {pos} playerPos: {playerPos} pitch: {pitch} volume: {volume} maxDist: {maxDist}" );

		var distSqr = (playerPos - pos).LengthSquared;
		if ( distSqr < maxDist * maxDist )
		{
			var dist = (playerPos - pos).Length;
			var falloff = Utils.Map( dist, 0f, maxDist, 1f, 0f, EasingType.SineIn );
			//var soundPos = playerPos + (pos - playerPos) * 10f;

			PlaySfx( name, pos, pitch, volume * falloff );
		}
	}

	// todo: set mixer
	// Voice Limits: Mixers can define the maximum amount of sounds that are allowed to play on them at one time.If more voices than are allowed want to play, it'll only play the newest.
	// This is a performance strategy.You can divide your sounds to go to different mixers, so you can limit particularly common and inconsequential sounds (like impact sounds ), while always allowing high priority sounds( like gun shots, speech).
	public void PlaySfx( string name, Vector2 pos, float pitch, float volume )
	{
		var zPos = Scene.Camera.WorldPosition.z - 600f;

		var sfx = Sound.Play( name, new Vector3( pos.x, pos.y, zPos ) );
		if ( sfx != null )
		{
			sfx.Pitch = pitch * SFX_PITCH_MODIFIER;
			sfx.Volume = volume * SFX_VOLUME_MODIFIER;
		}
	}

	[Rpc.Broadcast]
	public void PlaySfxUIRpc( string name, float pitch, float volume )
	{
		PlaySfxUI( name, pitch, volume );
	}

	public void PlaySfxUI( string name, float pitch, float volume )
	{
		//Log.Info( $"PlaySfxUI: {name} pitch: {pitch} volume: {volume}" );

		var sfx = Sound.Play( name, new Vector2( 0.1f, 0f ) );
		if ( sfx != null )
		{
			sfx.Pitch = pitch * SFX_PITCH_MODIFIER;
			sfx.Volume = volume * SFX_VOLUME_MODIFIER;
		}
	}

	public void PlayEnemyDeathSfx( Vector2 pos )
	{
		if ( _numEnemyDeathSfxs >= 3 )
			return;

		PlaySfxNearby( "enemy.die", pos, pitch: Game.Random.Float( 0.75f, 1.3f ), volume: 0.95f, maxDist: 500f );

		_numEnemyDeathSfxs++;
	}

	[Rpc.Broadcast]
	public void ShakeCamRpc( float strength, float time, EasingType easingType = EasingType.Linear, bool useRealTime = true )
	{
		if ( LocalPlayer.IsValid() )
			LocalPlayer.ShakeCam( strength, time, easingType, useRealTime );
		else if ( IsSpectator )
		{
			var timeNow = useRealTime ? RealTime.Now : Time.Now;
			_spectatorCamShakeDatas.Add( new CamShakeData( strength, timeNow, time, easingType, useRealTime ) );
		}
	}

	[Rpc.Host]
	public void ShakeCamsNearbyRpc( Vector2 pos, float radius, float maxStrength, float time )
	{
		ShakeCamsNearby( pos, radius, maxStrength, time );
	}

	public void ShakeCamsNearby( Vector2 pos, float radius, float maxStrength, float time )
	{
		var radiusSqr = MathF.Pow( radius, 2f );

		foreach ( var player in Players )
		{
			var distSqr = (player.Position2D - pos).LengthSquared;
			if ( distSqr < radiusSqr )
			{
				player.ShakeCamRpc( strength: Utils.Map( distSqr, 0f, radiusSqr, maxStrength, 0f, EasingType.Linear ), time, EasingType.Linear );
			}
		}
	}

	public void RefreshMasterMixerVolume()
	{
		MasterMixer.Volume = Math.Clamp( GameSettingsSystem.Current.MasterVolume / 100f, 0f, 1f );
	}

	public void RefreshMusicMixerVolume()
	{
		MusicMixer.Volume = Math.Clamp( GameSettingsSystem.Current.MusicVolume / 100f, 0f, 1f );
	}

	public void RefreshSfxMixerVolume()
	{
		SfxMixer.Volume = Math.Clamp( GameSettingsSystem.Current.SfxVolume / 100f, 0f, 1f );
	}

	[Rpc.Host]
	public void AdjustCommunism( int amount )
	{
		CommunismLevel += amount;
	}

	[Rpc.Host]
	public void AddCommunismXp( float xp, XpSource source, bool spawnFloater = true, bool playSfx = false )
	{
		if ( AlivePlayers.Count == 0 )
			return;

		var sharedXp = xp / AlivePlayers.Count;

		// todo: does multiple sfx play and sound bad?

		foreach ( var player in AlivePlayers )
			player.AddXpRpc( sharedXp, source, spawnFloater, playSfx );
	}

	[Rpc.Host]
	public void AdjustEnemyProjectileBounceFenceLevel( int amount )
	{
		EnemyProjectileBounceFenceLevel += amount;
	}

	[Rpc.Broadcast]
	public void SpawnArtilleryWarning( Vector2 targetPos )
	{
		GameObject.Clone( "prefabs/effects/warning_artillery.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( targetPos.x, targetPos.y, 0f ) ) } );
	}

	public void SetOrthoCamera( bool ortho )
	{
		if ( ortho )
		{
			IsOrthoCamera = true;
			Camera.Orthographic = true;
			//Camera.OrthographicHeight = 500f;
		}
		else
		{
			IsOrthoCamera = false;
			Camera.Orthographic = false;
		}

		//GameSettingsSystem.Save();
	}

	public void DeathFlash()
	{
		_isDeathFlashActive = true;
		_realTimeSinceDeathFlash = 0f;

		var deathFlash = OverlayEffects[OverlayEffectsType.DeathFlash];
		deathFlash.Enabled = true;

		var vignette = deathFlash.GetComponent<Vignette>();
		vignette.Color = new Color( 1f, 0f, 0f, 0f );
	}

	void HandleDeathFlash()
	{
		if ( !_isDeathFlashActive )
			return;

		var TOTAL_TIME = 0.5f;

		var deathFlash = OverlayEffects[OverlayEffectsType.DeathFlash];
		var vignette = deathFlash.GetComponent<Vignette>();

		if ( _realTimeSinceDeathFlash > TOTAL_TIME )
		{
			deathFlash.Enabled = false;
			_isDeathFlashActive = false;
		}
		else
		{
			var FADE_IN_TIME = 0.1f;
			var OFF_COLOR = new Color( 1f, 0f, 0f, 0f );
			var ON_COLOR = new Color( 4f, 0f, 0f, 1f );

			if ( vignette == null )
			{
				Log.Info( $"vignette null in HandleDeathFlash. deathFlash: {deathFlash}" );
				return;
			}

			if ( _realTimeSinceDeathFlash < FADE_IN_TIME )
				vignette.Color = Color.Lerp( OFF_COLOR, ON_COLOR, Utils.Map( _realTimeSinceDeathFlash, 0f, FADE_IN_TIME, 0f, 1f, EasingType.SineOut ) );
			else
				vignette.Color = Color.Lerp( ON_COLOR, OFF_COLOR, Utils.Map( _realTimeSinceDeathFlash, FADE_IN_TIME, TOTAL_TIME, 0f, 1f, EasingType.SineIn ) );
		}
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void FadeRpc( bool fadeIn )
	{
		Fade( fadeIn );
	}

	public void Fade( bool fadeIn )
	{
		_isFadingIn = fadeIn;
		_realTimeSinceFade = 0f;

		var fadeEffect = OverlayEffects[OverlayEffectsType.Fade];
		fadeEffect.Enabled = true;

		var vignette = fadeEffect.GetComponent<Vignette>();
		
		if( fadeIn )
		{
			vignette.Intensity = 1f;
			vignette.Smoothness = 1.5f;
		}
		else
		{
			vignette.Intensity = 1f;
			vignette.Smoothness = 1.5f;
		}
	}

	void HandleFadingIn()
	{
		if( !_isFadingIn )
			return;

		var FADE_IN_TIME = 0.5f;

		if ( _realTimeSinceFade > FADE_IN_TIME )
		{
			var fadeEffect = OverlayEffects[OverlayEffectsType.Fade];
			fadeEffect.Enabled = false;

			_isFadingIn = false;
		}
		else
		{
			var fadeEffect = OverlayEffects[OverlayEffectsType.Fade];
			var vignette = fadeEffect.GetComponent<Vignette>();

			vignette.Intensity = Utils.Map( _realTimeSinceFade, 0f, FADE_IN_TIME, 1f, 0f, EasingType.SineOut );
			vignette.Smoothness = Utils.Map( _realTimeSinceFade, 0f, FADE_IN_TIME, 1.5f, 0f, EasingType.SineOut );
		}
	}

	public static void DestroyParticlesWhenFinished( GameObject obj )
	{
		obj.SetParent( null );
		var emitter = obj.GetComponent<ParticleEmitter>();

		if(  emitter != null )
		{
			emitter.Rate = 0f;
			emitter.DestroyOnEnd = true;
			emitter.Loop = false;
			emitter.Duration = 0.1f;
		}
	}
}