things/player/Player.cs

Player component for the game, a large partial class that controls the player unit. Manages stats, input, aiming, movement, shooting (spawning bullets and VFX), guns/charms/gems setup and swapping, health/regen/damage handling, animations, perks/loadouts and numerous gameplay behaviors and RPCs.

NetworkingFile Access
using Sandbox.Citizen;
using Sandbox.Diagnostics;
using System;
using System.ComponentModel.DataAnnotations;
using System.Numerics;
using static Sandbox.Volumes.VolumeSystem;
using static System.Runtime.InteropServices.JavaScript.JSType;

public enum DamageType
{
	Melee, MeleeRunnerBite, Ranged, Explosion, Fire, Shock, Other, FearVoodoo, FearSharedPain, Self, Poison, PoisonFinish, Bullet, Acid, Shockwave, FrostArmor, Aoe, Boomerang, Spear, DashSlash, Radiation,
	SpikerHead, SpitterProjectile, SpitterProjectileHoming, TossedFireball, Laser, Punch, OrbitingBlade, JumpFinish, LavaPuddle, Thorns, BulletSplash, XpShrapnel, Mark,
}

[Flags]
public enum PlayerDamageFlags
{
	None = 0,
	SelfInflicted = 1 << 0,
}

public enum XpSource { Coin, Passive, Healthpack, }

public partial class Player : Unit
{
	[Property] public Collider PhysicalCollider { get; set; }

	public GameObject Gun { get; set; }
	public Gun CurrentGun { get; set; }
	public List<Charm> CurrentCharms { get; set; } = new();
	public IReadOnlyList<Gem> CurrentGems => CurrentGun?.CurrentGems
		?.Select( go => go?.GetComponent<Gem>() )
		.Where( g => g != null )
		.ToList() ?? (IReadOnlyList<Gem>)Array.Empty<Gem>();
	[Property] public GameObject GunAnchor { get; set; }
	//public GameObject GunBarrel { get; private set; }

	[Sync] public float Health { get; set; }

	public override float BoundsExpand => 15f;
	public override bool ShowHealthbar => true;
	public override float HpPercent => Health / GetSyncStat( PlayerStat.MaxHp );
	public override float HealthbarWidth => GetHealthbarWidth();
	public override float HealthbarOffset => (Height / 57f) * 90f;
	public override float HealthbarOpacity => (this == Manager.Instance.LocalPlayer && Stats[PlayerStat.HpBarHidden] > 0f) ? 1f : (Health < GetSyncStat( PlayerStat.MaxHp ) ? 1f : 0.00001f);
	public override float HealthbarArmorOpacity => Armor > 0f ? 1f : 0.00001f;
	[Property] public GameObject Model { get; set; }
	[Property] public CitizenAnimationHelper AnimationHelper { get; set; }
	public Vector2 MoveVector { get; set; }
	public float MoveInputPercent { get; set; }
	//public bool IsMoving => MoveInputPercent > 0.4f && !IsDashing;
	[Sync] public bool IsMoving { get; set; }
	public const float BASE_MOVE_SPEED = 800f;
	public Vector2 TotalVelocity => Velocity + DashVelocity + ExplosionVelocity + RepelVelocity;

	[Sync] public Vector2 AnimVelocity { get; set; }
	public Vector2 AimDir { get; set; }
	[Sync] public Vector2 FacingDir { get; set; }
	[Sync] public Vector2 CrosshairWorldPos { get; set; }
	[Sync] public Vector2 CrosshairScreenFraction { get; set; }
	[Sync] public int HoveredPerkChoiceSlot { get; set; } = -1;
	[Sync] public bool SyncShowCrosshairRecoilArrow { get; set; }
	[Sync] public float SyncCrosshairRecoilArrowAngle { get; set; }
	public float AimAngleOffset { get; private set; }
	public float BodyAimOffset { get; set; }
	private int _recoilSign = 1;
	public int RecoilSign => _recoilSign;

	[Property] public Material DefaultMaterial { get; set; }
	[Property] public Material DeadMaterial { get; set; }

	public List<ModelRenderer> Renderers = new List<ModelRenderer>();

	public int AmmoCount { get; protected set; }
	public float AttackTimer { get; protected set; }
	[Sync] public bool IsReloading { get; protected set; }
	[Sync] public float ReloadProgress { get; set; }

	[Sync] public bool IsDead { get; private set; }

	public const float MIN_BULLET_DAMAGE = 0.1f;
	public const float BULLET_SPAWN_OFFSET = 25f;

	[Sync] public int Level { get; protected set; }
	public float ExperienceTotal { get; protected set; }
	[Sync] public float ExperienceCurrent { get; protected set; }
	[Sync] public float ExperienceRequired { get; protected set; }
	private float _xpAccumulated;
	[Sync] public bool IsChoosingLevelUpReward { get; protected set; }
	[Sync] public int NumPerkPointsAvailable { get; protected set; }
	public TimeSince TimeSinceLvlUp { get; protected set; }
	[Sync] public RealTimeSince RealTimeSinceLvlUp { get; set; }
	public RealTimeSince RealTimeSinceChosePerk { get; set; }

	/// <summary>Timer tracking how long the swap animation has left. Negative means inactive.</summary>
	private float _swapAnimTimeRemaining = -1f;

	[Sync] public int NumRerollAvailable { get; set; }
	public int NumRerollWaiting { get; set; } // rerolls you will be given next time you make a choice
	[Sync] public int NumBanishAvailable { get; set; }
	[Sync] public bool IsBanishMode { get; protected set; }
	[Sync] public RealTimeSince RealTimeSinceOfferedChoices { get; set; }
	//public RealTimeSince RealTimeSinceRefreshChoices { get; set; }
	public bool CanInteractWithChoices;

	/// <summary>
	/// Whether the player has ever gotten a banish item this run. Used to determine if we should show banish on the UI.
	/// </summary>
	[Sync] public bool HasGottenBanish { get; set; }

	public bool ShouldShowStats { get; set; }

	private float _regenTimer;
	private float _regenHpAccumulated;

	public static Player Local
	{
		get
		{
			if ( !_localPlayer.IsValid() )
			{
				_localPlayer = Game.ActiveScene.GetAllComponents<Player>().FirstOrDefault( p => p.Network.IsOwner );
			}
			return _localPlayer;
		}
	}
	private static Player _localPlayer;

	[Sync] public TimeSince TimeSinceTeleport { get; set; }

	public static bool CanDamageTypeHitBackside( DamageType damageType )
	{
		return damageType != DamageType.Fire && damageType != DamageType.Poison && damageType != DamageType.PoisonFinish && damageType != DamageType.Shock
			&& damageType != DamageType.Self && damageType != DamageType.Acid && damageType != DamageType.SpikerHead;
	}

	public static bool IsDamageTypeMelee( DamageType damageType )
	{
		return damageType == DamageType.Melee || damageType == DamageType.MeleeRunnerBite || damageType == DamageType.DashSlash;
	}

	[Sync] public int IgnorePhysicsAmount { get; set; }

	private bool _isDodgeDucking;
	private Vector2 _controllerAimTarget;
	private TimeSince _timeSinceDodgeDuck;
	private float _dodgeDuckTime;

	private bool _isScaleHeight;
	private RealTimeSince _timeSinceScaleHeight;
	private float _scaleHeightTime;

	private float _capsuleColliderStartRadius = 16f;
	private float _capsuleColliderStartEndZ = 57.6f;

	public bool CombustionActive { get; set; }
	public float CombustionRadius { get; set; }
	public float CombustionDamageFactor { get; set; }

	//public bool HideCursor => Stats[PlayerStat.FpsMode] > 0f && Input.Down( "aim" );

	private Material _flashMaterial;

	private RealTimeSince _realTimeSinceMeleeHurtSfx;
	private Dictionary<Enemy, TimeSince> _timeSinceEnemyMeleeHit = new();
	private List<Enemy> _enemyMeleeHitScratch = new();
	private Dictionary<SpitterProjectile, TimeSince> _timeSinceProjectileHit = new();
	private List<SpitterProjectile> _projectileHitScratch = new();
	private TimeSince _timeSinceEnemyMeleeHitCleanup;

	public TimeSince TimeSinceHurtLava { get; set; }

	public DamageType LastDamageTypeDealt { get; set; }

	public bool HasSubmittedRunPlayedStat { get; set; }

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

		if ( LobbySlotIndex == -1 && Networking.IsHost && Manager.Instance.GameState == GameState.Lobby )
		{
			var availableSlot = Scene.GetAllComponents<LobbyPlayerSlot>()
				.FirstOrDefault( x => !x.IsOccupied );
			LobbySlotIndex = availableSlot.SlotIndex;
		}

		PerksStart();

		PushStrength = 2000f;
		ShouldCheckBounds = true;

		_flashMaterial = Material.Create( "damage_flash", "hiteffect" );

		ResetMaterials();

		var dresser = GetComponent<Dresser>();
		dresser?.Apply();

		//GunBarrel = Gun.Children.First( x => x.Name == "model" ).Children.First( x => x.Name == "barrel" );

		SetupGun();
		ResetHoldType();

		if ( IsProxy )
			return;

		Manager.Instance.LocalPlayer = this;

		CollideWithTags.Add( "enemy" );
		CollideWithTags.Add( "player" );
		CollideWithTags.Add( "obstacle" );
		CollideWithTags.Add( "enemy_projectile" );

		Init();

		if ( Manager.Instance.GameState == GameState.Lobby )
			EnterLobby();
	}

	void SetupGun()
	{
		var gunPrefabPath = ProgressManager.GetPrefabPath( ProgressManager.GetEffectiveSelectedGunId() )
						?? "prefabs/guns/gun_default.prefab";
		var charmIds = ProgressManager.GetEffectiveSelectedCharmIds();
		var charm0 = charmIds.Count > 0 ? ProgressManager.GetPrefabPath( charmIds[0] ) ?? "" : "";
		var charm1 = charmIds.Count > 1 ? ProgressManager.GetPrefabPath( charmIds[1] ) ?? "" : "";
		var (gem0, gem1, gem2) = GetEquippedGemPrefabPaths();

		SpawnGunAndCharm( gunPrefabPath, charm0, gem0, gem1, gem2, "", charm1 );
	}

	static (string, string, string) GetEquippedGemPrefabPaths()
	{
		var equipped = ProgressManager.GetEffectiveEquippedGems();
		string Get( int i ) => i < equipped.Count ? (ProgressManager.GetPrefabPath( equipped[i] ) ?? "") : "";
		return (Get( 0 ), Get( 1 ), Get( 2 ));
	}

	/// <summary>
	/// Spawns a gun prefab and optionally charm and gem prefabs on it. Called locally during setup
	/// and via RPC during live swaps so all clients see the change.
	/// </summary>
	void SpawnGunAndCharm( string gunPrefab, string charm0 = "", string gem0 = "", string gem1 = "", string gem2 = "", string gem3 = "", string charm1 = "" )
	{
		// Destroy existing gun children on the anchor
		foreach ( var child in GunAnchor.Children.ToList() )
			child.Destroy();

		var gunObj = GameObject.Clone( gunPrefab, new CloneConfig { StartEnabled = true } );
		gunObj.SetParent( GunAnchor );
		gunObj.LocalTransform = new Transform( Vector3.Zero, Quaternion.Identity, Vector3.One );
		Gun = gunObj;
		CurrentGun = gunObj.GetComponent<Gun>();
		CurrentCharms.Clear();

		SpawnCharmOnGunObj( gunObj, charm0, charm1 );
		SpawnGemsOnGunObj( gunObj, gem0, gem1, gem2, gem3 );
	}

	/// <summary>
	/// Spawns charm prefabs on the given gun GameObject, one per available charm anchor.
	/// Empty/null paths are treated as "no charm" for that slot.
	/// </summary>
	void SpawnCharmOnGunObj( GameObject gunObj, string charm0 = "", string charm1 = "" )
	{
		var gun = gunObj.GetComponent<Gun>();
		if ( gun == null ) return;

		// Destroy any existing charms
		foreach ( var existing in gun.CurrentCharms.ToList() )
			if ( existing != null && existing.IsValid() ) existing.Destroy();
		gun.CurrentCharms.Clear();
		CurrentCharms.Clear();

		var anchors = gun.GetCharmAnchors();
		var paths = new[] { charm0, charm1 };

		for ( int i = 0; i < anchors.Count && i < paths.Length; i++ )
		{
			if ( string.IsNullOrEmpty( paths[i] ) || anchors[i] == null ) continue;
			var charmObj = GameObject.Clone( paths[i], new CloneConfig { StartEnabled = true } );
			charmObj.SetParent( anchors[i] );
			charmObj.LocalTransform = new Transform( Vector3.Zero, Rotation.Identity, Vector3.One );
			gun.CurrentCharms.Add( charmObj );
			var charmComp = charmObj.GetComponent<Charm>();
			if ( charmComp != null ) CurrentCharms.Add( charmComp );
		}
	}

	/// <summary>
	/// Spawns gem prefabs into the gun's GemSlots. Destroys any previously spawned gems first.
	/// Empty string for a slot means no gem in that slot.
	/// </summary>
	void SpawnGemsOnGunObj( GameObject gunObj, string gem0, string gem1, string gem2, string gem3 = "" )
	{
		var gun = gunObj.GetComponent<Gun>();
		if ( gun == null || gun.GemSlots == null ) return;

		foreach ( var existing in gun.CurrentGems )
			if ( existing != null && existing.IsValid() ) existing.Destroy();
		gun.CurrentGems.Clear();

		var gemPaths = new[] { gem0, gem1, gem2, gem3 };
		var equippedIds = ProgressManager.GetEquippedGems();
		for ( int i = 0; i < gun.GemSlots.Count && i < gemPaths.Length; i++ )
		{
			if ( string.IsNullOrEmpty( gemPaths[i] ) ) continue;
			var slot = gun.GemSlots[i];
			if ( slot == null ) continue;
			var gemObj = GameObject.Clone( gemPaths[i], new CloneConfig { StartEnabled = true } );
			if ( gemObj == null ) { Log.Warning( $"SpawnGemsOnGunObj: prefab not found: {gemPaths[i]}" ); continue; }
			gemObj.SetParent( slot );
			gemObj.LocalTransform = new Transform( Vector3.Zero, Rotation.Identity, Vector3.One );
			var gemComp = gemObj.GetComponent<Gem>();
			if ( gemComp != null && i < equippedIds.Count )
				gemComp.GemId = equippedIds[i];
			gun.CurrentGems.Add( gemObj );
		}
	}

	/// <summary>
	/// Broadcasts a gun swap to all clients. The caller resolves the prefab paths locally
	/// and sends them so every client spawns the correct models.
	/// </summary>
	[Rpc.Broadcast( NetFlags.Reliable )]
	public void SwapGunRpc( string gunPrefabPath, string charm0, string gem0, string gem1, string gem2, string gem3, string charm1 = "" )
	{
		SpawnGunAndCharm( gunPrefabPath, charm0, gem0, gem1, gem2, gem3, charm1 );
		PlaySwapAnimation();
	}

	/// <summary>
	/// Broadcasts a charm swap to all clients. Destroys old charms and spawns new ones.
	/// </summary>
	[Rpc.Broadcast( NetFlags.Reliable )]
	public void SwapCharmRpc( string charm0, string charm1 = "" )
	{
		var gunObj = GunAnchor.Children.FirstOrDefault();
		if ( gunObj == null ) return;

		SpawnCharmOnGunObj( gunObj, charm0, charm1 );
		PlaySwapAnimation();
	}

	/// <summary>
	/// Broadcasts a gem swap to all clients. Destroys existing gems on the current gun and spawns new ones.
	/// Pass empty string for slots that should be empty.
	/// </summary>
	[Rpc.Broadcast( NetFlags.Reliable )]
	public void SwapGemsRpc( string gem0, string gem1, string gem2, string gem3 )
	{
		var gunObj = GunAnchor.Children.FirstOrDefault();
		if ( gunObj == null ) return;
		SpawnGemsOnGunObj( gunObj, gem0, gem1, gem2, gem3 );
		PlaySwapAnimation();
	}

	void HandleGunAndCharm()
	{
		var dt = Time.Delta;
		CurrentGun?.Update( dt );
		foreach ( var charm in CurrentCharms ) charm?.Update( dt );
	}

	/// <summary>
	/// Briefly sets HoldType to Swing then restores it after a timer, giving a visual "swap" flourish.
	/// Re-calling while active resets the timer so the animation lasts longer.
	/// </summary>
	void PlaySwapAnimation()
	{
		AnimationHelper.HoldType = CitizenAnimationHelper.HoldTypes.Swing;
		_swapAnimTimeRemaining = Game.Random.Float( 0.3f, 0.35f );
	}

	/// <summary>
	/// Immediately ends the swap animation and restores the normal hold type.
	/// </summary>
	void ClearSwapAnimation()
	{
		if ( _swapAnimTimeRemaining > 0f )
		{
			_swapAnimTimeRemaining = -1f;
			ResetHoldType();
		}
	}

	public void Init()
	{
		InitStats();
		InitPerks();

		IsDead = false;
		IsDying = false;
		AmmoCount = (int)Stats[PlayerStat.MaxAmmoCount];
		IsReloading = true;
		IsDashing = false;
		DashVelocity = Vector2.Zero;
		AttackTimer = Stats[PlayerStat.ReloadTime] * Game.Random.Float( 0.25f, 1f );
		Level = 0;
		ExperienceRequired = GetExperienceReqForLevel( Level + 1 );
		ExperienceTotal = 0;
		ExperienceCurrent = 0;
		_xpAccumulated = 0f;
		IsChoosingLevelUpReward = false;
		TimeSinceLvlUp = 999f;
		RealTimeSinceLvlUp = 999f;
		RealTimeSinceChosePerk = 999f;
		NumPerkPointsAvailable = 0;
		NumRerollAvailable = 5;
		NumRerollWaiting = 0;
		NumBanishAvailable = 0;
		IsBanishMode = false;
		HasGottenBanish = false;
		//TimeSinceLastDamage = 99f;
		RealTimeSinceOfferedChoices = 0f;
		CanInteractWithChoices = false;
		_regenTimer = 0f;
		_regenHpAccumulated = 0f;
		IgnorePhysicsAmount = 0;
		_isDodgeDucking = false;
		AnimationHelper.DuckLevel = 0f;
		_isScaleHeight = false;
		AnimationHelper.Target.Set( "scale_height", 1f );
		IsFlinching = false;
		Weight = 1f;
		NumDashesAvailable = (int)Stats[PlayerStat.NumDashes];
		NumTempDashesAvailable = 0;
		TimeSinceDash = 0f;
		DashTimer = Stats[PlayerStat.DashCooldown];

		Health = Stats[PlayerStat.MaxHp];
		Armor = 0;

		ResetMaterials();
		ResetHoldType();
		AnimationHelper.IsNoclipping = false;
		AnimSpeedModifier = 1f; // todo: this isn't used by Player
		AnimationHelper.Target.PlaybackRate = 1f;

		Velocity = Vector2.Zero;
		ResetRepelVelocity();
		ExplosionVelocity = Vector2.Zero;

		HitstopActive = false;
		StopShaking();

		SetScale( 1f );

		CombustionActive = false;

		RemoveAllUnitStatuses();

		_camShakeDatas.Clear();

		//IsChoosingLevelUpReward = true;
		//RefreshPerkChoices();

		//for ( int i = 0; i < 7; i++ )
		//{
		//	AddPerk( TypeLibrary.GetType( typeof( PerkShield ) ) );
		//	AddPerk( TypeLibrary.GetType( typeof( PerkDashFire ) ) );
		//	AddPerk( TypeLibrary.GetType( typeof( PerkPoisonBullet ) ) );
		//	AddPerk( TypeLibrary.GetType( typeof( PerkFreezeBullet ) ) );
		//	AddPerk( TypeLibrary.GetType( typeof( PerkLightning ) ) );
		//}

		foreach ( var gem in CurrentGems )
		{
			Log.Info( $"Applying gem {gem.GameObject.Name} on start" );
		}
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void Restart()
	{
		_timeSinceEnemyMeleeHit.Clear();
		_timeSinceProjectileHit.Clear();

		ResetMaterials();

		ResetHoldType();
		AnimationHelper.IsNoclipping = false;
		AnimationHelper.Target.Set( "skid_x", 0f );
		AnimationHelper.Target.Set( "skid_y", 0f );
		AnimationHelper.IsGrounded = true;
		AnimationHelper.DuckLevel = 0f;
		AnimationHelper.Target.Set( "scale_height", 1f );

		var capsuleCollider = Collider as CapsuleCollider;
		capsuleCollider.Radius = _capsuleColliderStartRadius;
		capsuleCollider.End = new Vector3( 0f, 0f, _capsuleColliderStartEndZ );

		PhysicalCollider.Enabled = true;

		WorldPosition = WorldPosition.WithZ( 0f );
		Model.LocalPosition = Vector3.Zero;
		AimDir = new Vector2( -1f, 0f );

		SetTimeScale( 1f );

		PerkRandomRotationSeed = Game.Random.Int( 0, 999 );

		SetGunVisible( true );

		if ( IsProxy )
			return;

		Init();

		var centerTorchPos = new Vector2( -53f, 14.4f );
		var pos = centerTorchPos + Utils.GetRandomVector() * Game.Random.Float( 48f, 100f );
		WorldPosition = new Vector3( pos.x, pos.y, 0f );
		Transform.ClearInterpolation();

		HasSubmittedRunPlayedStat = false;

		RestartAchievements();
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void EnterGame()
	{
		IsInLobby = false;

		// Clear any in-progress swap animation so it doesn't bleed into gameplay
		ClearSwapAnimation();

		if ( IsProxy )
			return;

		RestartCamera();

		if ( CurrentGun != null ) CurrentGun.Player = this;
		foreach ( var charm in CurrentCharms ) if ( charm is not null ) charm.Player = this;
		if ( CurrentGun != null )
		{
			foreach ( var gemObj in CurrentGun.CurrentGems )
			{
				if ( gemObj == null || !gemObj.IsValid() ) continue;
				var gem = gemObj.GetComponent<Gem>();
				if ( gem != null ) gem.Player = this;
			}
		}

		ForEachPerk( perk => perk.OnRunStart() );
		ForEachLoadoutItem( item => item.OnRunStart() );
	}

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

		// Tick swap animation timer
		if ( _swapAnimTimeRemaining > 0f )
		{
			_swapAnimTimeRemaining -= Time.Delta;
			if ( _swapAnimTimeRemaining <= 0f )
			{
				_swapAnimTimeRemaining = -1f;
				ResetHoldType();
			}
		}

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"Model.Enabled: {Model.Enabled}", new global::Transform( WorldPosition + Vector3.Up * 10f ) );
		//Gizmo.Draw.Text( $"CurrLevelsUntilCurseChoice: {CurrLevelsUntilCurseChoice}\nNumCursesToChoose: {NumCursesToChoose}\nIsBeingShownCurseChoices: {IsBeingShownCurseChoices}\nNumPerkPointsAvailable: {NumPerkPointsAvailable}", new global::Transform( WorldPosition + Vector3.Up * 10f ) );

		if ( IsInLobby )
		{
			if ( !IsProxy )
				HandleOptionInput();

			UpdateLobby();
			return;
		}

		//Gizmo.Draw.Color = Color.Blue;
		//Gizmo.Draw.LineSphere( WorldPosition, 400f );

		//if(!IsProxy)
		//{
		//	Gizmo.Draw.Color = Color.White;
		//	Gizmo.Draw.Text( $"BulletDamage: {Stats[PlayerStat.BulletDamage]}", new global::Transform( WorldPosition ) );
		//}

		//int i = 0;
		//var str = "";
		//foreach ( var perk in Perks )
		//{
		//	str += $"{i}: {perk}\n";
		//	i++;
		//}
		//var str = "";
		////foreach ( var pair in _perkCategoryIncludeCounts )
		//foreach (var type in CurrentPerkChoices)
		//{
		//	str += $"{type.Name}\n";
		//}
		//Gizmo.Draw.Text( $"{str}", new global::Transform( WorldPosition ) );

		HandleAnimation();
		HandleAiming();
		HandleFlashing();
		HandleDashClouds();
		HandleDodgeDucking();
		HandleScaleHeight();

		if ( IsProxy )
			return;

		HandleEnemyHitTimeCleanup();

		UpdateStats();

		HandleOptionInput();
		HandleCamShaking();

		HandleCamera();

		if ( Manager.Instance.IsGameOver )
		{
			Mouse.Visibility = MouseVisibility.Auto; // to fix dying while in FPS mode
			return;
		}

		//Gizmo.Draw.Text( $"Stats[PlayerStat.DodgeChance]: {Stats[PlayerStat.DodgeChance]}", new global::Transform( WorldPosition ) );

		HandleMovement();
		HandleShooting();
		HandleDashing();
		HandlePerks();
		HandleGunAndCharm();
		HandleRegen();
		HandleAchievements();

		//if ( Input.Keyboard.Pressed( "C" ) )
		//{
		//	if ( AvailableCurseCount > 0 )
		//	{
		//		GiveRandomPerkItemRpc( Position2D, Utils.GetRandomVectorInCone( -FacingDir ), rarity: Rarity.None, curseSelection: CurseSelection.OnlyCurses, forceToCollect: true );
		//		Manager.Instance.SpawnFloaterTextRpc( WorldPosition.WithZ( 65f ), "CURSED!", new Color( 0.3f, 0.3f, 0.3f ), 1.3f, FloaterType.NegativeMessage );
		//	}
		//	else
		//	{
		//		Manager.Instance.SpawnFloaterTextRpc( WorldPosition.WithZ( 65f ), "NO CURSES LEFT!", new Color( 0.3f, 0.3f, 0.3f ), 1.3f, FloaterType.NegativeMessage );
		//	}
		//}

		if ( Input.Keyboard.Pressed( "E" ) && Game.IsEditor )
		{
			//for ( int i = 0; i < 7; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkBulletCritChance ) ) );
			//for ( int i = 0; i < 5; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkBulletCritMultiplier ) ) );
			//for ( int i = 0; i < 3; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkBulletDamage ) ) );
			//for ( int i = 0; i < 4; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkHealthyEnemyDamage ) ) );
			//for ( int i = 0; i < 4; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkNextShotDamageMult ) ) );
			//for ( int i = 0; i < 4; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkHurtNextShotDamage ) ) );
			//for ( int i = 0; i < 3; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkLowHealthDamage ) ) );

			//for ( int i = 0; i < 7; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkMoveSpeed ) ) );
			//for ( int i = 0; i < 5; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkDashNum) ) );
			//for ( int i = 0; i < 3; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkBulletNegativeKickback ) ) );
			//for ( int i = 0; i < 3; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkDashLength ) ) );

			//for ( int i = 0; i < 2; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkNumChoices) ) );
			//for ( int i = 0; i < 3; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkNumChoicesChance ) ) );
			//for ( int i = 0; i < 2; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkNumChoicesTimed ) ) );
			//for ( int i = 0; i < 2; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkNumChoicesHurt ) ) );
			//for ( int i = 0; i < 3; i++ ) AddPerk( TypeLibrary.GetType( typeof( PerkDashLength ) ) );

			//ProgressManager.IncrementStat( ProgressStat.EnemyKills, 1 );
			//ProgressManager.IncrementStat( ProgressStat.DamageDealt, Game.Random.Int( 1, 5 ) );

			//AddXpRpc( Game.Random.Int( 1, 30 ), XpSource.Passive );
			//AddXpRpc( ExperienceRequired, XpSource.Passive );
			//Manager.Instance.SpawnCoinRpc( Manager.Instance.MouseWorldPos, 1, Utils.GetRandomVector() );
			//Manager.Instance.SpawnPerkItemRpc( PerkManager.TypeToIdentity( TypeLibrary.GetType( typeof( CurseFlyingSkullMultiHit) ) ), Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "health_pack", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "magnet", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "revive_soul", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "bomb", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "reroll_item", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "banish_item", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "armor_item", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );
			//Manager.Instance.SpawnItemRpc( "reload_item", Manager.Instance.MouseWorldPos, Utils.GetRandomVector() );

			//var shockwaveDmg = 10f + Manager.Instance.Difficulty * 4f;
			//var shockwaveRadius = 300f;
			//var shockwaveLifetime = 1.5f;
			//var shockwaveGradient = new Gradient();
			//shockwaveGradient.AddColor( 0.0f, new Color( 0.9f, 0.05f, 0.05f ) );
			//shockwaveGradient.AddColor( 0.4f, new Color( 0.8f, 0.8f, 0f ).WithAlpha( 0.5f ) );
			//shockwaveGradient.AddColor( 0.5f, new Color( 0.4f, 0f, 0f ).WithAlpha( 0.1f ) );
			//shockwaveGradient.AddColor( 0.6f, new Color( 1f, 0.55f, 0.1f ) );
			//shockwaveGradient.AddColor( 0.7f, new Color( 0.4f, 0f, 0f ).WithAlpha( 0.1f ) );
			//shockwaveGradient.AddColor( 1.0f, new Color( 0.9f, 0.05f, 0.05f ) );
			//Manager.Instance.SpawnShockwave( Manager.Instance.MouseWorldPos, shockwaveDmg, shockwaveRadius, shockwaveLifetime, force: 400f, gradient: shockwaveGradient, enemySource: null, enemyType: EnemyType.None );

			//Manager.Instance.SpawnRockRpc( Manager.Instance.MouseWorldPos );

			//Freeze( null, 0.2f, 10f );
			//Ignite( this, 3f, 30f, 0f, false );
			//Fear( null, null, 1f );
			//Shock( null, null, 3f, 0, 3 );
			//GainShield();

			//Manager.Instance.CreateExplosion( Manager.Instance.MouseWorldPos, 100f, 5f, 110f, 5f, null );
			//Manager.Instance.SpawnFireGroundRpc( Manager.Instance.MouseWorldPos, damage: 2f, lifetime: 8f, spreadChance: 50f, canStack: false );
			//Manager.Instance.SpawnAcidPuddleRpc( Manager.Instance.MouseWorldPos, lifetime: Game.Random.Float( 24f, 30f ), damage: 5f, scale: Game.Random.Float( 1.35f, 1.8f ), new Color( 0.5f, 0.3f, 0.6f ), new Color( 0.5f, 0.5f, 0.7f ) );
			//Manager.Instance.SpawnAcidPuddleRpc( Manager.Instance.MouseWorldPos, lifetime: Game.Random.Float( 24f, 30f ), damage: 3f, scale: Game.Random.Float( 1.35f, 1.8f ), new Color( 0.1f, 0.2f, 0.6f ), new Color( 0.3f, 0.3f, 0.9f ),
			//	damagePlayers: false, damageEnemies: true, playerSource: this );
			//Manager.Instance.SpawnHealingZone( Manager.Instance.MouseWorldPos, scale: Game.Random.Float( 0.33f, 1.4f ) ); 
			//Manager.Instance.SpawnLavaPuddleRpc( Manager.Instance.MouseWorldPos, damage: 3f, radius: Game.Random.Float( 150f, 350f ), lifetime: 15f, colorA: new Color(1f, 0f, 0f), colorB: new Color(0.4f, 0.1f, 0f), enemySource: null ); 

			//Manager.Instance.SpawnEnemy( "chest", Manager.Instance.MouseWorldPos, rotAngle: -90f + Game.Random.Float(-30f, 30f) );
			//Manager.Instance.SpawnEnemy( "chest_evil", Manager.Instance.MouseWorldPos, rotAngle: -90f + Game.Random.Float(-30f, 30f) );
			//Manager.Instance.SpawnEnemy( "mushroom", Manager.Instance.MouseWorldPos );

			//Manager.Instance.SpawnFireGroundRpc( Manager.Instance.MouseWorldPos, player: null, damage: 5f, lifetime: Game.Random.Float( 8f, 10f ), spreadChance: 0f, canStack: false, scale: 1f, colorA: Color.Blue, colorB: Color.Blue, hurtPlayers: false, hurtEnemies: true );

			//if ( IsDead )
			//	Revive();
		}

		if ( Input.Keyboard.Pressed( "Q" ) )
		{
			//if ( Perks.Count > 0 )
			//{
			//	var perk = Perks.First().Value;
			//	var type = perk.GetType();
			//	LevelDownPerk( TypeLibrary.GetType( type ) );
			//}
		}

		HandleGameplayInput();
		HandleDpsStat();
	}

	void HandleEnemyHitTimeCleanup()
	{
		if ( _timeSinceEnemyMeleeHit.Count == 0 || _timeSinceEnemyMeleeHitCleanup <= 5f )
			return;

		foreach ( var key in _timeSinceEnemyMeleeHit.Keys )
		{
			if ( !key.IsValid() )
				_enemyMeleeHitScratch.Add( key );
		}

		foreach ( var key in _enemyMeleeHitScratch )
			_timeSinceEnemyMeleeHit.Remove( key );

		_enemyMeleeHitScratch.Clear();

		foreach ( var key in _timeSinceProjectileHit.Keys )
		{
			if ( !key.IsValid() )
				_projectileHitScratch.Add( key );
		}

		foreach ( var key in _projectileHitScratch )
			_timeSinceProjectileHit.Remove( key );

		_projectileHitScratch.Clear();
		_timeSinceEnemyMeleeHitCleanup = 0f;
	}

	void HandleGameplayInput()
	{
		if ( IsChoosingLevelUpReward && !IsDead && !Manager.Instance.IsPaused )
		{
			if ( Input.Pressed( "Slot1" ) ) ChoosePerkHotkey( 0 );
			else if ( Input.Pressed( "Slot2" ) ) ChoosePerkHotkey( 1 );
			else if ( Input.Pressed( "Slot3" ) ) ChoosePerkHotkey( 2 );
			else if ( Input.Pressed( "Slot4" ) ) ChoosePerkHotkey( 3 );
			else if ( Input.Pressed( "Slot5" ) ) ChoosePerkHotkey( 4 );
			else if ( Input.Pressed( "Slot6" ) ) ChoosePerkHotkey( 5 );
			else if ( Input.Pressed( "Slot7" ) ) ChoosePerkHotkey( 6 );
			else if ( Input.Pressed( "Slot8" ) ) ChoosePerkHotkey( 7 );
			else if ( Input.Pressed( "Slot9" ) ) ChoosePerkHotkey( 8 );
			else if ( Input.Pressed( "R" ) ) UseReroll();
			else if ( Input.Pressed( "banish" ) ) ToggleBanish();
		}
	}

	void HandleOptionInput()
	{
		if ( Input.Pressed( "tab" ) )
		{
			ShouldShowStats = !ShouldShowStats;
		}

		var pressedB = Input.UsingController && Input.Pressed( "Back" );
		if ( Input.EscapePressed || pressedB )
		{
			if ( Manager.Instance.IsOptionsMenuOpen )
				Manager.Instance.SetOptionsMenuOpen( false );
			else if ( Manager.Instance.GameState == GameState.Lobby )
				Manager.Instance.SetOptionsMenuOpen( true );
			else if ( Manager.Instance.IsEscMenuOpen || !pressedB )
				Manager.Instance.SetEscMenuOpen( !Manager.Instance.IsEscMenuOpen );

			Input.EscapePressed = false;
		}

		if ( Input.Pressed( "click" ) )
		{
			Manager.Instance.SelectedPlayer = null;
		}
	}

	void HandleRegen()
	{
		if ( IsDead )
			return;

		_regenTimer += Time.Delta * TimeScale;
		if ( _regenTimer >= 1f )
		{
			float amount = GetHpRegenAmount();
			Heal( amount );

			_regenTimer = 0f;
		}
	}

	protected override void HandleFlashing()
	{
		RefreshRenderers();

		base.HandleFlashing();
	}

	public override void Flash( float time, UnitFlashType flashType )
	{
		if ( IsFlashing || IsDead )
			return;

		//RefreshRenderers();

		base.Flash( time, flashType );

		Material mat = Manager.Instance.UnitFlashMaterials[flashType];
		foreach ( var renderer in Renderers )
			renderer.SetMaterial( mat );
	}

	public void Flash( float time, Color color )
	{
		//RefreshRenderers();

		IsFlashing = true;

		_timeSinceFlash = 0f;
		_flashTime = time;

		foreach ( var renderer in Renderers )
		{
			_flashMaterial.Set( "g_vflashcolor", color );
			renderer.SetMaterial( _flashMaterial );
		}
	}

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

		ResetMaterials();
	}

	void HandleAnimation()
	{
		//AnimationHelper.Sitting = CitizenAnimationHelper.SittingStyle.Floor;
		//AnimationHelper.DuckLevel = 0.8f;
		//AnimationHelper.IsSwimming = true;
		//AnimationHelper.TriggerJump();
		//AnimationHelper.Target.Set( "b_firstperson", true );
		AnimationHelper.IsNoclipping = (IsDead || GetSyncStat( PlayerStat.UpsideDown ) > 0f) && !Manager.Instance.IsGameOver;

		if ( IsFlinching )
			HandleFlinching();

		if ( Manager.Instance.IsGameOver )
			AnimVelocity = 0f;

		AnimationHelper.WithWishVelocity( AnimVelocity );
		AnimationHelper.WithVelocity( AnimVelocity );
		AnimationHelper.MoveStyle = CitizenAnimationHelper.MoveStyles.Auto;

		var isArcShot = !IsMoving && GetSyncStat( PlayerStat.ArcBulletsStill ) > 0f;
		var look = isArcShot
			? (Model.LocalRotation.Forward + new Vector3( 0f, 0f, 1f ) * 0.9f).Normal
			: Model.LocalRotation.Forward;

		AnimationHelper.WithLook( look );
	}

	void HandleMovement()
	{
		if ( IsInTheAir )
			return;

		if ( !(Stats[PlayerStat.IsBerserk] > 0f) )
		{
			if ( Stats[PlayerStat.FpsMode] > 0f )
			{
				var right = new Vector2( FacingDir.y, -FacingDir.x );
				MoveVector = (-Input.AnalogMove.y * right) + (Input.AnalogMove.x * FacingDir);
			}
			else if ( Stats[PlayerStat.MovementRelativeToAimDir] > 0f )
			{
				MoveVector = Input.AnalogMove.x * FacingDir + Input.AnalogMove.y * Utils.GetPerpendicularVector( FacingDir );
			}
			else
			{
				MoveVector = new Vector2( -Input.AnalogMove.y, Input.AnalogMove.x ).Normal;
			}

			MoveInputPercent = MoveVector.Length;
		}

		IsMoving = MoveInputPercent > 0.01f;

		if ( Stats[PlayerStat.ReverseMoveControls] > 0f )
			MoveVector *= -1f;

		var moveSpeed = BASE_MOVE_SPEED;

		if ( MathF.Abs( 1f - Stats[PlayerStat.MoveAlignmentForwardMultiplier] ) > 0f || MathF.Abs( 1f - Stats[PlayerStat.MoveAlignmentBackwardMultiplier] ) > 0f )
		{
			var alignment = Vector2.Dot( MoveVector, FacingDir );
			if ( alignment < 0f )
				moveSpeed *= Utils.Map( alignment, 0f, -1f, 1f, Stats[PlayerStat.MoveAlignmentBackwardMultiplier], EasingType.Linear );
			else
				moveSpeed *= Utils.Map( alignment, 0f, 1f, 1f, Stats[PlayerStat.MoveAlignmentForwardMultiplier], EasingType.Linear );
		}

		bool cantMove = Stats[PlayerStat.CantMove] > 0f || (Stats[PlayerStat.CantMoveWhileChoosing] > 0f && IsChoosingLevelUpReward);
		if ( !cantMove && !IsStunned )
		{
			var speed = moveSpeed * GetMoveSpeedMultiplier() * Manager.Instance.GlobalMovespeedModifier;

			if ( IsDead )
				speed *= 2.5f;

			Velocity += MoveVector * speed * Time.Delta * TimeScale;

			//Velocity += Utils.GetPerpendicularVector( FacingDir ) * Utils.FastSin( Time.Now * 5f ) * speed * Time.Delta * TimeScale;

			if ( Stats[PlayerStat.ZigZagWalkFactor] > 0f )
			{
				Velocity += Utils.GetPerpendicularVector( MoveVector ) * Utils.FastSin( Time.Now * 5f ) * speed * Stats[PlayerStat.ZigZagWalkFactor] * Time.Delta * TimeScale;
			}
		}

		if ( Manager.Instance.IsWindActive )
			Velocity += Manager.Instance.GlobalWindForce * 3f * (1f / Weight) * Time.Delta;

		Velocity *= Math.Max( 1f - Time.Delta * Stats[PlayerStat.Friction] * Manager.Instance.GlobalFrictionModifier, 0f );

		Position2D += (Velocity + DashVelocity) * Time.Delta;

		if ( IsDead )
		{
			var zPos = MathX.Lerp( WorldPosition.z, 50f, Time.Delta * 5f );
			WorldPosition = WorldPosition.WithZ( zPos );
		}

		AnimVelocity = Velocity * Stats[PlayerStat.MoveSpeedMultiplier];
	}

	void HandleAiming()
	{
		if ( Manager.Instance.IsGameOver )
			return;

		if ( IsStunned )
			return;

		if ( IsProxy )
		{
			if ( !(FacingDir.LengthSquared > 0f) )
				return;

			var upVector = GetSyncStat( PlayerStat.UpsideDown ) > 0f ? Vector3.Down : Vector3.Up;
			var targetRotation = Rotation.LookAt( (Vector3)FacingDir.Normal, upVector );
			var turnSpeed = Math.Max( GetSyncStat( PlayerStat.TurnSpeed ), 10f );
			Model.LocalRotation = Rotation.Lerp( Model.LocalRotation, targetRotation, turnSpeed * Time.Delta );
		}
		else
		{
			if ( Input.UsingController )
			{
				var m = Manager.Instance;
				bool menuOpen = m.IsPaused || m.IsEscMenuOpen || m.IsOptionsMenuOpen || m.GameState == GameState.Lobby;
				if ( menuOpen )
				{
					Input.EnableVirtualCursor = true;
					Mouse.Visibility = MouseVisibility.Auto;
				}
				else
				{
					if ( Manager.Instance.IsPausedForChoosing )
					{
						Input.EnableVirtualCursor = true;
						Mouse.Visibility = MouseVisibility.Auto;
					}
					else
					{
						Input.EnableVirtualCursor = false;
						Mouse.Visibility = MouseVisibility.Hidden;
					}
				}
			}

			if ( !(Stats[PlayerStat.IsBerserk] > 0f || Stats[PlayerStat.AutoAim] > 0f) )
			{
				if ( Stats[PlayerStat.FpsMode] > 0f )
				{
					if ( !(AimDir.LengthSquared > 0f) )
						AimDir = (Vector2)Model.LocalRotation.Forward;

					//float turnAmount = 0f;
					//if ( Input.Down( "Q" ) ) turnAmount += 1f;
					//if ( Input.Down( "E" ) ) turnAmount -= 1f;
					//AimDir = Utils.RotateVector( AimDir, turnAmount * 100f * Time.Delta );

					// wrap cursor pos
					//float BUFFER = 3f;
					//if ( Mouse.Position.x < BUFFER )
					//	Mouse.Position = Mouse.Position.WithX( Screen.Width - BUFFER );
					//else if ( Mouse.Position.x > Screen.Width - BUFFER )
					//	Mouse.Position = Mouse.Position.WithX( BUFFER );

					if ( Input.UsingController )
					{
						var look = Input.AnalogLook.AsVector3();
						AimDir = Utils.RotateVector( AimDir, look.y * 150f * Time.Delta * (Stats[PlayerStat.ReverseAimControls] > 0f ? -1f : 1f) );
					}
					else if ( Input.Down( "aim" ) )
					{
						Mouse.Visibility = MouseVisibility.Hidden;
						AimDir = Utils.RotateVector( AimDir, Mouse.Delta.x * -20f * Time.Delta * (Stats[PlayerStat.ReverseAimControls] > 0f ? -1f : 1f) );
						Mouse.Position = Screen.Size / 2f;
					}
					else
					{
						Mouse.Visibility = MouseVisibility.Auto;
					}

					//Camera.FieldOfView = 60f * player.GetSyncStat(PlayerStat.CameraDistance) / 1200f;
				}
				else
				{
					if ( Input.UsingController )
					{
						var look = Input.AnalogLook.AsVector3();
						var lookMagnitude = look.Length;
						if ( !look.IsNearZeroLength )
						{
							_controllerAimTarget = new Vector2( -look.y, -look.x ).Normal;
							if ( Stats[PlayerStat.ReverseAimControls] > 0f )
								_controllerAimTarget *= -1f;
						}
						if ( _controllerAimTarget.LengthSquared > 0f )
						{
							var lerpSpeed = Utils.Map( lookMagnitude, 0f, 1f, 0f, 25f );
							AimDir = Vector2.Lerp( AimDir, _controllerAimTarget, Time.Delta * lerpSpeed ).Normal;
						}
					}
					else
					{
						AimDir = (Manager.Instance.MouseWorldPos - Position2D).Normal;
						Mouse.Visibility = MouseVisibility.Auto;
						if ( Stats[PlayerStat.ReverseAimControls] > 0f )
							AimDir *= -1f;
					}
				}
			}

			if ( AimDir.LengthSquared > 0f )
			{
				var upVector = GetSyncStat( PlayerStat.UpsideDown ) > 0f ? Vector3.Down : Vector3.Up;
				var totalOffset = AimAngleOffset + BodyAimOffset;
				var aimTarget = totalOffset != 0f ? Utils.RotateVector( AimDir, totalOffset ) : AimDir;
				Model.LocalRotation = Rotation.Lerp( Model.LocalRotation, Rotation.LookAt( (Vector3)aimTarget, upVector ), Stats[PlayerStat.TurnSpeed] * TimeScale * Time.Delta );
				FacingDir = Model.LocalRotation.Forward;
			}
		}
	}

	void HandleShooting()
	{
		if ( IsDead )
			return;

		if ( Stats[PlayerStat.CantShoot] > 0f )
			return;

		if ( IsReloading )
		{
			AttackTimer -= GetReloadSpeedMultiplier() * Time.Delta * TimeScale;
			ReloadProgress = Utils.Map( AttackTimer, Stats[PlayerStat.ReloadTime], 0f, 0f, 1f );
			if ( AttackTimer <= 0f )
			{
				FinishReloading();
			}
		}
		else
		{
			if ( AmmoCount <= 0 )
			{
				StartReloading();
				return;
			}

			if ( IsMoving && Stats[PlayerStat.CantShootWhileMoving] > 0f )
				return;

			//Log.Info( $"{GameObject.Name} - {Time.Delta} * {TimeScale}" );

			if ( AttackTimer > 0f )
				AttackTimer -= GetAttackSpeedMultiplier() * Time.Delta * TimeScale;

			bool shouldShoot = Stats[PlayerStat.OnlyShootWithMouse1] > 0f ? Input.Down( "click" ) : true;
			if ( AttackTimer <= 0f && shouldShoot )
			{
				Shoot( FacingDir, isFromClip: true, isLastAmmo: AmmoCount == 1 );
				AmmoCount--;

				if ( AmmoCount <= 0 )
				{
					StartReloading();
				}
				else
				{
					AttackTimer += Stats[PlayerStat.AttackTime];
				}
			}
		}
	}

	public int GetNumProjectiles( bool considerNthShotNumBulletsPerk = true )
	{
		int numBullets = (int)Stats[PlayerStat.NumProjectiles];

		int numExtra = (int)Stats[PlayerStat.ExtraProjectileNum];
		for ( int i = 0; i < numExtra; i++ )
		{
			if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ExtraProjectileChance] )
				numBullets++;
		}

		if ( Stats[PlayerStat.NthShotParallelNumBullets] > 0f )
		{
			int numReq = (int)Stats[PlayerStat.NthShotParallelReq];
			if ( (int)Stats[PlayerStat.TotalShotNum] % numReq == 0 )
				numBullets += (int)Stats[PlayerStat.NthShotParallelNumBullets];
		}

		if ( Stats[PlayerStat.FirstShotNumExtraBullets] > 0f && (int)Stats[PlayerStat.ShotNum] == 0 )
		{
			numBullets += (int)Stats[PlayerStat.FirstShotNumExtraBullets];
		}

		// don't shoot anything, let PerkNthShotProjectiles handle it
		if ( considerNthShotNumBulletsPerk && Stats[PlayerStat.NthShotNumBullets] > 0f )
		{
			int numReq = (int)Stats[PlayerStat.NthShotReq];
			if ( (int)Stats[PlayerStat.TotalShotNum] % numReq == 0 )
				numBullets = 0;
		}

		return numBullets;
	}

	public float GetBulletDamage( bool isFromClip, bool isLastAmmo, bool forDisplay = false )
	{
		float damage = Stats[PlayerStat.BulletDamage];

		damage += Stats[PlayerStat.DamagePerEarlierShot] * Stats[PlayerStat.ShotNum];

		if ( Stats[PlayerStat.DamageForSpeed] > 0f )
			damage += Stats[PlayerStat.DamageForSpeed] * TotalVelocity.Length;

		damage += Stats[PlayerStat.BulletHitStreakDmg] * MathF.Min( Stats[PlayerStat.NumShotsWithoutBulletHitGround], 100f );

		if ( isFromClip )
			damage += Stats[PlayerStat.ClipDamageBonus];

		if ( !IsMoving && Stats[PlayerStat.StillBulletDamageAdd] > 0f )
			damage += Stats[PlayerStat.StillBulletDamageAdd];

		damage *= Stats[PlayerStat.BulletDamageMultiplier];

		if ( isLastAmmo )
			damage *= Stats[PlayerStat.LastAmmoDamageMultiplier];

		if ( Stats[PlayerStat.BulletRandomDamagePercentMin] > 0f && !forDisplay )
			damage *= Game.Random.Float( Stats[PlayerStat.BulletRandomDamagePercentMin], Stats[PlayerStat.BulletRandomDamagePercentMax] );

		return MathF.Max( damage, MIN_BULLET_DAMAGE );
	}

	public void Shoot( Vector2 dir, bool isFromClip = false, bool isLastAmmo = false )
	{
		var BULLET_SIDEWAYS_OFFSET = Stats[PlayerStat.PunchBullets] > 0f ? 0f : 8f;
		if ( AnimationHelper.HoldType == CitizenAnimationHelper.HoldTypes.Pistol )
			BULLET_SIDEWAYS_OFFSET = 2f;

		var scale = Stats[PlayerStat.Scale];

		var barrelSidewaysOffset = Utils.GetPerpendicularVector( dir ) * BULLET_SIDEWAYS_OFFSET * scale;
		var shootPos = Position2D + barrelSidewaysOffset * (Stats[PlayerStat.UpsideDown] > 0f ? 1f : -1f);

		var muzzleFlashPos2D = shootPos + dir * (BULLET_SPAWN_OFFSET + 10f) * scale;
		var muzzleFlashZPos = WorldPosition.z + (Stats[PlayerStat.UpsideDown] > 0f ? 25f : 40f) * scale;
		if ( AnimationHelper.HoldType == CitizenAnimationHelper.HoldTypes.Pistol )
			muzzleFlashZPos += 30f * scale;

		var isArcShot = !IsMoving && GetSyncStat( PlayerStat.ArcBulletsStill ) > 0f;
		if ( isArcShot )
			muzzleFlashZPos += 30f;

		ShootVfx( new Vector3( muzzleFlashPos2D.x, muzzleFlashPos2D.y, muzzleFlashZPos ), dir, showMuzzleFlash: !(Stats[PlayerStat.PunchBullets] > 0f) );

		Stats[PlayerStat.TotalShotNum] += 1f;
		_syncStats[PlayerStat.TotalShotNum] = Stats[PlayerStat.TotalShotNum];

		if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootRandomDirChance] )
			dir = Utils.GetRandomVector();

		if ( Stats[PlayerStat.AlternateAimDir] > 0f )
			dir = -dir;

		var bulletType = Stats[PlayerStat.PunchBullets] > 0f ? BulletType.Punch : BulletType.Normal;

		int numBullets = GetNumProjectiles();
		bool parallelShots = Stats[PlayerStat.ShootMultipleProjectilesParallel] > 0f || (Stats[PlayerStat.NthShotParallelReq] > 0f && (int)Stats[PlayerStat.TotalShotNum] % (int)Stats[PlayerStat.NthShotParallelReq] == 0);

		ShootBullets( shootPos, dir, numBullets, parallelShots, isFromClip, isLastAmmo, bulletType, countsAsShot: true, spreadModifier: 1f );

		Velocity += -dir * Stats[PlayerStat.Kickback];

		if ( bulletType == BulletType.Punch )
		{
			float pitch = isFromClip
				? Utils.Map( Stats[PlayerStat.ShotNum], 0f, (float)Stats[PlayerStat.MaxAmmoCount], 1f, 1.25f )
				: Game.Random.Float( 0.95f, 1.05f );

			if ( Stats[PlayerStat.ShotDamageMult] > 1f )
				pitch *= Utils.Map( Stats[PlayerStat.ShotDamageMult], 1f, 2.5f, 0.9f, 0.8f );

			Manager.Instance.PlaySfxNearbyRpc( "player.shoot", Position2D + dir * 40f, pitch * 1.5f, volume: 0.8f * (IsProxy ? 0.7f : 1f), maxDist: 350f );
		}
		else
		{
			float pitch = isFromClip
				? Utils.Map( Stats[PlayerStat.ShotNum], 0f, (float)Stats[PlayerStat.MaxAmmoCount], 0.8f, 1.05f ) * Game.Random.Float( 0.8f, 1.2f )
				: Game.Random.Float( 0.9f, 1.1f );

			var gunshotName = "gunshot_b_1";

			if ( (int)Stats[PlayerStat.ShotNum] == (int)Stats[PlayerStat.MaxAmmoCount] - 1 )
			{
				gunshotName = "gunshot_b_2";
				pitch *= Game.Random.Float( 0.7f, 1.3f );
			}

			var volume = Utils.MapReturn( Stats[PlayerStat.ShotNum], 0f, (float)Stats[PlayerStat.MaxAmmoCount], 0.75f, 0.55f, EasingType.SineOut );

			if ( Stats[PlayerStat.ShotDamageMult] > 1f )
			{
				pitch *= Utils.Map( Stats[PlayerStat.ShotDamageMult], 1f, 2.5f, 0.9f, 0.8f );
				volume *= Utils.Map( Stats[PlayerStat.ShotDamageMult], 1f, 2.5f, 1.1f, 1.2f );
			}

			Manager.Instance.PlaySfxNearbyRpc( gunshotName, Position2D + dir * 40f, pitch, volume * (IsProxy ? 0.7f : 1f), maxDist: 400f );
		}

		Stats[PlayerStat.ShotNum] += 1f;
		Stats[PlayerStat.NumShotsWithoutBulletHitGround] += 1f;
		AimAngleOffset += _recoilSign * Stats[PlayerStat.RotationalRecoil];

		ForEachPerk( perk => perk.OnShoot() );
		ForEachLoadoutItem( item => item.OnShoot() );
	}

	[Rpc.Broadcast]
	public void ShootVfx( Vector3 muzzleFlashPos, Vector2 dir, bool showMuzzleFlash )
	{
		AnimationHelper.HoldType = GetSyncStat( PlayerStat.PunchBullets ) > 0f
			? CitizenAnimationHelper.HoldTypes.Punch
			: CitizenAnimationHelper.HoldTypes.Shotgun;

		AnimationHelper.Target.Set( "b_attack", true );

		if ( showMuzzleFlash )
		{
			var rot = Rotation.LookAt( dir );
			GameObject.Clone( "prefabs/effects/muzzle_flash.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( muzzleFlashPos, rot ) } );
			GameObject.Clone( "prefabs/effects/muzzle_smoke.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( muzzleFlashPos, rot ) } );
			GameObject.Clone( "prefabs/effects/muzzle_smoke_2.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( muzzleFlashPos, rot ) } );
		}
	}

	public void ShootBullets( Vector2 startPos, Vector2 dir, int numBullets, bool parallelShots, bool isFromClip = false, bool isLastAmmo = false, BulletType bulletType = BulletType.Normal, bool countsAsShot = true, float spreadModifier = 1f )
	{
		float damage = GetBulletDamage( isFromClip, isLastAmmo );

		if ( countsAsShot )
		{
			damage += Stats[PlayerStat.ShotDamageAdd];
			damage *= Stats[PlayerStat.ShotDamageMult];
		}

		var forwardOffset = BULLET_SPAWN_OFFSET;
		if ( AnimationHelper.HoldType == CitizenAnimationHelper.HoldTypes.Pistol )
			forwardOffset += 30f;

		bool randomAngleInSpread = Stats[PlayerStat.RandomBulletAngleInSpread] > 0f;

		var spread = Stats[PlayerStat.BulletSpread] * spreadModifier;
		var scale = Stats[PlayerStat.Scale];

		if ( randomAngleInSpread )
		{
			for ( int i = 0; i < numBullets; i++ )
			{
				Vector2 currDir = parallelShots
					? dir
					: Utils.RotateVector( dir, Game.Random.Float( -spread * 0.5f, spread * 0.5f ) ); // todo: make sure bullets dont spawn overlapping?

				var pos = startPos + currDir * forwardOffset * scale;

				if ( parallelShots )
				{
					pos += Utils.GetPerpendicularVector( currDir ) * Game.Random.Float( -spread * 0.5f, spread * 0.5f );

					// prevent z-fighting with overlapping bullets
					pos += dir * Game.Random.Float( -0.1f, 0.1f );
				}

				SpawnBullet( pos, currDir, damage, isFromClip, countsAsShot: true, bulletType );
			}
		}
		else
		{
			if ( parallelShots )
			{
				float currPosOffset = -Stats[PlayerStat.BulletSpread] * 0.5f;
				float increment = numBullets == 1 ? 0f : spread / (float)(numBullets - 1);

				for ( int i = 0; i < numBullets; i++ )
				{
					var pos = startPos + dir * forwardOffset * scale
						+ Utils.GetPerpendicularVector( dir ) * (currPosOffset + increment * i);

					// prevent z-fighting with overlapping bullets
					pos += dir * Game.Random.Float( -0.1f, 0.1f );

					SpawnBullet( pos, dir, damage, isFromClip, countsAsShot: true, bulletType, bulletsInShot: 1 );
				}
			}
			else
			{
				float start_angle = MathF.Sin( -Stats[PlayerStat.ShotNum] * 2f ) * Stats[PlayerStat.ShotInaccuracy];

				float currAngleOffset = numBullets == 1 ? 0f : -spread * 0.5f;
				float increment = numBullets == 1 ? 0f : spread / (float)(numBullets - 1);

				if ( numBullets % 2 == 0 && Stats[PlayerStat.ExtraProjectileChance] > 0f && (int)Stats[PlayerStat.NumProjectiles] == 1 )
					start_angle += (increment * 0.5f) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);

				for ( int i = 0; i < numBullets; i++ )
				{
					Vector2 currDir = Utils.RotateVector( dir, start_angle + currAngleOffset + increment * i );
					var pos = startPos + currDir * forwardOffset * scale;
					SpawnBullet( pos, currDir, damage, isFromClip, countsAsShot: true, bulletType, bulletsInShot: 1 );
				}
			}
		}
	}

	public Bullet SpawnBullet( Vector2 pos, Vector2 dir, float damage, bool isFromClip = false, bool countsAsShot = false, BulletType bulletType = BulletType.Normal, int bulletsInShot = 1 )
	{
		var scale = Stats[PlayerStat.Scale];
		var scaleFactor = 1f;
		if ( scale > 1f )
			scaleFactor = float.Lerp( scale, 1f, 0.5f ); // don't allow bullets to spawn too high up, or they'll shoot over top of barrels etc
		else if ( scale < 1f )
			scaleFactor = scale;

		var zOffset = (Stats[PlayerStat.UpsideDown] > 0f ? 26f : 41f);
		if ( AnimationHelper.HoldType == CitizenAnimationHelper.HoldTypes.Pistol )
			zOffset += 10f;

		var zPos = WorldPosition.z + zOffset * scaleFactor;

		if ( isFromClip )
		{
			var isArcShot = !IsMoving && GetSyncStat( PlayerStat.ArcBulletsStill ) > 0f;
			if ( isArcShot )
				zPos += 30f;
		}

		return SpawnBullet( pos, zPos, dir, damage, isFromClip, countsAsShot, bulletType, bulletsInShot );
	}

	public Bullet SpawnBullet( Vector2 pos, float zPos, Vector2 dir, float damage, bool isFromClip = false, bool countsAsShot = false, BulletType bulletType = BulletType.Normal, int bulletsInShot = 1 )
	{
		var bulletGo = GameObject.Clone( "prefabs/bullet.prefab", new CloneConfig
		{
			StartEnabled = true,
			Transform = new Transform(
				position: new Vector3( pos.x, pos.y, zPos ),
				rotation: Rotation.From( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f ),
				scale: 1f
			)
		}
		);
		var bullet = bulletGo.Components.Get<Bullet>( true );

		var speed = Stats[PlayerStat.BulletSpeed];
		float arcHeight = isFromClip && (!IsMoving && Stats[PlayerStat.ArcBulletsStill] > 0f) ? Stats[PlayerStat.BulletArcHeight] : 0f;
		if ( arcHeight > 0f )
		{
			damage += Stats[PlayerStat.ArcBulletDamageAdd];
			speed *= Stats[PlayerStat.ArcBulletSpeedMult];
		}

		bullet.BaseZPos = zPos;
		bullet.Velocity = dir * (bulletType == BulletType.Punch ? PerkPunch.PUNCH_SPEED : speed);
		bullet.Shooter = this;
		bullet.BulletType = bulletType;

		if ( bulletType != BulletType.Normal )
			bulletGo.Name = $"bullet ({bulletType})";

		if ( Stats[PlayerStat.NextBulletDamageMult] > 0f )
		{
			damage *= Stats[PlayerStat.NextBulletDamageMult];
			Stats[PlayerStat.NextBulletDamageMult] = 0f;
		}

		bullet.Stats[BulletStat.Explosive] = 0f;
		bullet.Stats[BulletStat.LifestealPercent] = 0f;
		var isPunchLifesteal = false;

		if ( bulletType == BulletType.Punch )
		{
			damage *= Stats[PlayerStat.PunchDamagePercent];

			bullet.Stats[BulletStat.NumPunchExtraHits] = Stats[PlayerStat.PunchNumExtraHits];

			var punchForce = 15f;
			if ( Stats[PlayerStat.LastPunchForceMultiplier] > 0f && AmmoCount == 1 )
			{
				punchForce *= Stats[PlayerStat.LastPunchForceMultiplier];
				bullet.Stats[BulletStat.IsForcePunch] = 1f;
			}
			else
			{
				bullet.Stats[BulletStat.IsForcePunch] = 0f;
			}
			bullet.Stats[BulletStat.Force] = punchForce;

			bullet.Stats[BulletStat.Lifetime] = PerkPunch.PUNCH_LIFETIME;
			bullet.Stats[BulletStat.NumPiercing] = 0f;
			bullet.Stats[BulletStat.NumBouncing] = 0f;
			bullet.Stats[BulletStat.ApplyFire] = 0f;
			bullet.Stats[BulletStat.ApplyFreeze] = 0f;
			bullet.Stats[BulletStat.ApplyPoison] = 0f;
			bullet.Stats[BulletStat.GrowDamageAmount] = 0f;
			bullet.Stats[BulletStat.DistanceDamageAmount] = 0f;
			bullet.Stats[BulletStat.HealTeammateAmount] = 0f;
			bullet.Stats[BulletStat.CriticalChance] = Stats[PlayerStat.CritChance];
			bullet.Stats[BulletStat.CriticalMultiplier] = Stats[PlayerStat.CritMultiplier];
			bullet.Stats[BulletStat.MoveRandomly] = 0f;
			bullet.Stats[BulletStat.IsReturning] = 0f;
			bullet.Stats[BulletStat.SplashDamagePercent] = 0f;
			bullet.Stats[BulletStat.BounceDamageIncrease] = 0f;
			bullet.Stats[BulletStat.BounceResetLifetime] = 0f;
			bullet.Stats[BulletStat.CanHitShooter] = 0f;
			bullet.Stats[BulletStat.FriendlyFire] = 0f;
			bullet.Stats[BulletStat.BounceTarget] = 0f;
			bullet.Stats[BulletStat.HomingRadius] = 0f;
			bullet.Stats[BulletStat.OverflowPercent] = 0f;
			bullet.Stats[BulletStat.ArcHeight] = 0f;
			bullet.Stats[BulletStat.AimAtCursorProgress] = 0f;

			if ( Stats[PlayerStat.PunchLifestealPercent] > 0f && (int)Stats[PlayerStat.TotalShotNum] % PerkPunchLifesteal.PunchesForLifesteal == 0 )
			{
				bullet.Stats[BulletStat.LifestealPercent] = Stats[PlayerStat.PunchLifestealPercent];
				isPunchLifesteal = true;
			}
		}
		else
		{
			bullet.Stats[BulletStat.Force] = Stats[PlayerStat.BulletForce];
			bullet.Stats[BulletStat.Lifetime] = Stats[PlayerStat.BulletLifetime];
			bullet.Stats[BulletStat.NumPiercing] = Stats[PlayerStat.BulletNumPiercing] + (Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.BulletExtraPierceChance] ? 1 : 0);
			bullet.Stats[BulletStat.NumBouncing] = Stats[PlayerStat.BulletNumBouncing] + (Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.BulletExtraBounceChance] ? (int)Stats[PlayerStat.BulletExtraBounceAmount] : 0);
			bullet.Stats[BulletStat.ApplyFire] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootFireIgniteChance] ? 1f : 0f;
			bullet.Stats[BulletStat.ApplyFreeze] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootFreezeChance] ? 1f : 0f;
			bullet.Stats[BulletStat.ApplyPoison] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootPoisonChance] ? 1f : 0f;
			bullet.Stats[BulletStat.GrowDamageAmount] = Stats[PlayerStat.BulletDamageGrow];
			bullet.Stats[BulletStat.DistanceDamageAmount] = Stats[PlayerStat.BulletDistanceDamage];
			bullet.Stats[BulletStat.CriticalChance] = Stats[PlayerStat.CritChance];
			bullet.Stats[BulletStat.CriticalMultiplier] = Stats[PlayerStat.CritMultiplier];
			bullet.Stats[BulletStat.MoveRandomly] = Stats[PlayerStat.BulletMoveRandomly];
			bullet.Stats[BulletStat.IsReturning] = Stats[PlayerStat.BulletReturning];
			bullet.Stats[BulletStat.SplashDamagePercent] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.BulletSplashChance] ? Stats[PlayerStat.BulletSplashDamagePercent] : 0f;
			bullet.Stats[BulletStat.BounceDamageIncrease] = Stats[PlayerStat.BulletBounceDamageIncrease];
			bullet.Stats[BulletStat.BounceResetLifetime] = Stats[PlayerStat.BulletBounceResetLifetime];
			bullet.Stats[BulletStat.CanHitShooter] = Stats[PlayerStat.BulletCanHitShooter];
			bullet.Stats[BulletStat.FriendlyFire] = Manager.Instance.FriendlyFireEnabledAmount > 0 ? 1f : 0f;
			bullet.Stats[BulletStat.BounceTarget] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.BulletBounceTargetChance] ? 1f : 0f;
			bullet.Stats[BulletStat.HomingRadius] = Stats[PlayerStat.BulletHomingRadius];
			bullet.Stats[BulletStat.OverflowPercent] = Stats[PlayerStat.BulletOverflowPercent];
			bullet.Stats[BulletStat.ArcHeight] = arcHeight;
			if ( arcHeight > 0f )
				bullet.Stats[BulletStat.NumBouncing] += Stats[PlayerStat.ArcBulletBounces];
			bullet.Stats[BulletStat.NumPunchExtraHits] = 0f;
			bullet.Stats[BulletStat.IsForcePunch] = 0f;
			bullet.Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.BulletAimAtCursorChance] ? Game.Random.Float( 0.2f, 0.4f ) : 0f;

			// shoot explosive bullets
			if ( Stats[PlayerStat.ShootExplosiveBullets] > 0f && countsAsShot )
			{
				bullet.Stats[BulletStat.Explosive] = 1f;

				if ( bulletsInShot > 1 )
					bullet.Stats[BulletStat.Lifetime] *= Game.Random.Float( 0.9f, 1.1f ); // prevent multiple explosion sfx at same time
			}

			if ( Game.IsEditor )
			{
				if ( Manager.Instance.DebugBulletBounce )
					bullet.Stats[BulletStat.NumBouncing] = 1;
				if ( Manager.Instance.DebugBulletPierce )
					bullet.Stats[BulletStat.NumPiercing] = 1;
				if ( Manager.Instance.DebugBulletSplash )
					bullet.Stats[BulletStat.SplashDamagePercent] = 0.5f;
			}

			if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.BulletHealTeammateChance] )
			{
				bullet.Stats[BulletStat.HealTeammateAmount] = Stats[PlayerStat.BulletHealTeammateAmount];
				damage *= Stats[PlayerStat.BulletHealTeammateDmgMult];
			}
			else
			{
				bullet.Stats[BulletStat.HealTeammateAmount] = 0f;
			}
		}

		if ( Game.IsEditor && Manager.Instance.PlayerMaxDmg )
		{
			damage *= 10f;
		}

		bullet.Stats[BulletStat.Damage] = damage;
		bullet.Stats[BulletStat.StartFromGround] = 0f;
		bullet.Stats[BulletStat.NumGroundHops] = 0f;
		bullet.Stats[BulletStat.ForceRandomDir] = Stats[PlayerStat.BulletForceRandomDir];

		// hide for 1st frame
		//bullet.Model.Enabled = false;
		//bullet.Model.Tint = Color.White.WithAlpha( 0f );

		bullet.Init();

		bulletGo.NetworkSpawn( Network.Owner );

		if ( bulletType == BulletType.Punch )
		{
			SpawnPunchEffect( new Vector3( pos.x, pos.y, zPos ), dir, isPunchLifesteal );
		}

		AddResultsStat( ResultStat.BulletCreated, 1 );

		return bullet;
	}

	[Rpc.Broadcast]
	public void SpawnPunchEffect( Vector3 pos, Vector2 dir, bool lifesteal = false )
	{
		var punchGo = GameObject.Clone( "prefabs/effects/punch_effect.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos, new Angles( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f ) ) } );

		if ( lifesteal )
		{
			var particleEffect = punchGo.Components.Get<ParticleEffect>();
			particleEffect.Tint = new Color( 0f, 0.3f, 0f );
		}
	}

	[Rpc.Owner]
	public void SpawnBulletRingRpc( Vector2 pos, int numBullets, Vector2 shootDir, bool isFromClip = false, BulletType bulletType = BulletType.Normal, float damageMultMin = 1f, float damageMultMax = 1f, float sfxPitch = 1f, float spawnOffset = BULLET_SPAWN_OFFSET )
	{
		SpawnBulletRing( pos, numBullets, shootDir, isFromClip, bulletType, damageMultMin, damageMultMax, sfxPitch, BULLET_SPAWN_OFFSET );
	}

	public List<Bullet> SpawnBulletRing( Vector2 pos, int numBullets, Vector2 shootDir, bool isFromClip = false, BulletType bulletType = BulletType.Normal, float damageMultMin = 1f, float damageMultMax = 1f, float sfxPitch = 1f, float spawnOffset = BULLET_SPAWN_OFFSET )
	{
		if ( numBullets < 1 )
			return null;

		float damage = GetBulletDamage( isFromClip: false, isLastAmmo: false );
		float increment = 360f / numBullets;

		List<Bullet> bullets = new();

		for ( int i = 0; i < numBullets; i++ )
		{
			var dir = Stats[PlayerStat.RandomBulletAngleInSpread] > 0f
				? Utils.GetRandomVector() // todo: make sure bullets dont spawn overlapping?
				: Utils.RotateVector( shootDir, i * increment );

			var dmg = damage * Game.Random.Float( damageMultMin, damageMultMax );
			var bullet = SpawnBullet( pos + dir * spawnOffset, dir, dmg, isFromClip, countsAsShot: true, bulletType );

			bullets.Add( bullet );
		}

		return bullets;
	}

	public void BulletHitGround( Bullet bullet )
	{
		Assert.True( !IsProxy );

		Stats[PlayerStat.NumShotsWithoutBulletHitGround] = 0f;

		ForEachPerk( perk => perk.OnBulletHitGround( bullet ) );
		ForEachLoadoutItem( item => item.OnBulletHitGround( bullet ) );

		Stats[PlayerStat.NumBulletsHitGround] += 1f;
	}

	public void BulletPierce( Bullet bullet, Thing other )
	{
		Assert.True( !IsProxy );

		ProgressManager.IncrementStat( ProgressStat.BulletPierces, 1 );

		ForEachPerk( perk => perk.OnBulletPierce( bullet, other ) );
		ForEachLoadoutItem( item => item.OnBulletPierce( bullet, other ) );
	}

	public void BulletBounce( Bullet bullet, Thing other )
	{
		Assert.True( !IsProxy );

		ProgressManager.IncrementStat( ProgressStat.BulletBounces, 1 );

		ForEachPerk( perk => perk.OnBulletBounce( bullet, other ) );
		ForEachLoadoutItem( item => item.OnBulletBounce( bullet, other ) );
	}

	void RefreshRenderers( bool force = false )
	{
		var currentRenderers = GetComponentsInChildren<ModelRenderer>();
		if ( force || Renderers.Count != currentRenderers.Count() )
		{
			Renderers.Clear();
			foreach ( var renderer in currentRenderers )
				Renderers.Add( renderer );
		}
	}

	void ResetMaterials()
	{
		RefreshRenderers( force: true );

		foreach ( var renderer in Renderers )
		{
			//renderer.SceneObject.ClearMaterialOverride();
			//renderer.SceneObject.SetMaterialGroup( "default" );

			renderer.ClearMaterialOverrides();
			//renderer.MaterialOverride = DefaultMaterial;
		}
	}

	void ResetRespawnVisualState()
	{
		IsFlashing = false;
		DashInvulnTimer = 0f;
		ResetMaterials();

		if ( ModelRenderer.IsValid() )
		{
			var outline = ModelRenderer.GetComponent<HighlightOutline>( includeDisabled: true );
			if ( outline.IsValid() )
				outline.Destroy();
		}
	}

	[Rpc.Owner]
	public void DamageRpc( float damage, DamageType damageType, Vector2 hitPos, Vector2 dir, float upwardAmount, float force, float ragdollForce, Enemy enemySource, EnemyType enemyType, EnemyProjectileType enemyProjectileType = EnemyProjectileType.Normal, PlayerDamageFlags damageFlags = PlayerDamageFlags.None )
	{
		Damage( damage, damageType, hitPos, dir, upwardAmount, force, ragdollForce, enemySource, enemyType, cantKill: false, enemyProjectileType: enemyProjectileType, damageFlags: damageFlags );
	}

	// returns damage amount dealt
	// todo: when damaged by self or other player, need to apply DamageMultiplier
	public float Damage( float damage, DamageType damageType, Vector2 hitPos, Vector2 dir, float upwardAmount, float force, float ragdollForce, Enemy enemySource, EnemyType enemyType, bool cantKill = false, EnemyProjectileType enemyProjectileType = EnemyProjectileType.Normal, PlayerDamageFlags damageFlags = PlayerDamageFlags.None )
	{
		Assert.True( !IsProxy );

		if ( Manager.Instance.GodMode )
		{
			Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), "GOD MODE", color: new Color( 0.75f, 0.7f, 0f, 0.7f ), size: 1.35f, floaterType: FloaterType.Dodge );
			return 0f;
		}

		if ( damage <= 0f || IsDead || Manager.Instance.IsGameOver )
			return 0f;

		damage += Stats[PlayerStat.FlatDamageTakenIncrease];

		var flatReductionThreshold = Stats[PlayerStat.FlatDmgReductionThreshold];
		if ( flatReductionThreshold > 0f && damage >= flatReductionThreshold )
		{
			damage = MathF.Max( 0f, damage - 1f );

			if ( GetPerk( TypeLibrary.GetType<PerkReduceDamageFlat>() ) is PerkReduceDamageFlat flatReducePerk )
				flatReducePerk.TriggeredThisHit = true;
		}

		bool isSelfInflicted = damageFlags.HasFlag( PlayerDamageFlags.SelfInflicted );

		if ( damageType != DamageType.Self )
		{
			if ( IsInvincible )
			{
				DamageVfx( 0f, damageType, hitPos, dir );
				return 0f;
			}

			// allows self-inflicted damage as long as the type isn't DamageType.SelfDmg - so direct self-dmg can't be dodged, but self-created explosions etc can
			if ( TryDodge() )
			{
				DamageVfx( 0f, damageType, hitPos, dir, isDodge: true );
				ForEachPerk( perk => perk.OnDodged( damage, damageType, dir, enemySource ) );
				ForEachLoadoutItem( item => item.OnDodged( damage, damageType, dir, enemySource ) );
				AddResultsStat( ResultStat.TimesDodged, 1 );
				ProgressManager.IncrementStat( ProgressStat.TimesDodged, 1 );
				return 0f;
			}

			if ( damageType != DamageType.Self )
			{
				// Apply status effects from enemy projectiles (only if not dodged and not shielded)
				if ( enemyProjectileType != EnemyProjectileType.Normal && !IsShielded )
				{
					ApplyEnemyProjectileStatusEffect( enemySource, enemyType, enemyProjectileType );
				}

				if ( Stats[PlayerStat.BasicZombieDmgMod] > 0f && enemySource != null && damageType == DamageType.Melee && enemySource is Zombie )
				{
					damage *= (1f + Stats[PlayerStat.BasicZombieDmgMod]);
				}

				damage *= Manager.Instance.GetLateDamageMultiplier();
			}
		}
		else
		{
			if ( IsInvincible && Stats[PlayerStat.IgnoreSelfDamageWhenInvuln] > 0f )
			{
				return 0f;
			}

			if ( MathF.Abs( Stats[PlayerStat.SelfDmgReductionPercent] ) > 0f )
				damage *= (1f - Stats[PlayerStat.SelfDmgReductionPercent]);
		}

		if ( MathF.Abs( Stats[PlayerStat.DamageReductionPercent] ) > 0f )
			damage *= (1f - Stats[PlayerStat.DamageReductionPercent]);

		//if ( damageType == DamageType.Explosion && MathF.Abs( Stats[PlayerStat.ExplosionDamageReductionPercent] ) > 0f )
		//	damage *= (1f - Stats[PlayerStat.ExplosionDamageReductionPercent]);

		if ( damageType == DamageType.Explosion && Stats[PlayerStat.ExplosionDamageTakenMultiplier] != 0f )
			damage *= (1f + Stats[PlayerStat.ExplosionDamageTakenMultiplier]);

		// take less explosion damage on Normal difficulty
		if ( damageType == DamageType.Explosion && Manager.Instance.Difficulty == 0 )
			damage *= 0.5f;

		if ( damageType != DamageType.Explosion && MathF.Abs( Stats[PlayerStat.NonExplosionDamageIncreasePercent] ) > 0f )
			damage *= (1f + Stats[PlayerStat.NonExplosionDamageIncreasePercent]);

		if ( MathF.Abs( Stats[PlayerStat.BacksideDamageReductionPercent] ) > 0f && CanDamageTypeHitBackside( damageType ) )
		{
			if ( Vector2.Dot( dir, (Vector2)Model.LocalRotation.Forward ) > 0.1f )
			{
				damage *= (1f - Stats[PlayerStat.BacksideDamageReductionPercent]);
				// todo: sfx (blocking sound if dmg decreased, pain sound if dmg increased)
			}
		}

		if ( enemySource != null && MathF.Abs( Stats[PlayerStat.FearDamageReductionPercent] ) > 0f && enemySource.IsFearful )
			damage *= (1f - Stats[PlayerStat.FearDamageReductionPercent]);

		if ( MathF.Abs( Stats[PlayerStat.FullHpDamageReductionPercent] ) > 0f && !(Health < Stats[PlayerStat.MaxHp]) )
			damage *= (1f - Stats[PlayerStat.FullHpDamageReductionPercent]);

		if ( Player.IsDamageTypeMelee( damageType ) && MathF.Abs( Stats[PlayerStat.FearArmorActive] ) > 0f )
			damage *= 0.5f;

		if ( Armor > 0 && Stats[PlayerStat.ArmorDamageReductionPercent] > 0f )
			damage *= (1f - Stats[PlayerStat.ArmorDamageReductionPercent]);

		if ( !IsMoving && Stats[PlayerStat.StillDamageReductionPercent] > 0f )
			damage *= (1f - Stats[PlayerStat.StillDamageReductionPercent]);

		bool isCrit = false;
		if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.SelfCritChance] && damageType != DamageType.Self )
		{
			isCrit = true;
			damage *= 2f;
		}

		if ( damageType != DamageType.Self && IsShielded )
		{
			if ( damage < Stats[PlayerStat.ShieldMinDmg] )
			{
				HighlightPerk( TypeLibrary.GetType( typeof( PerkShieldMinDmg ) ) );
			}
			else
			{
				DamageVfx( 0f, damageType, hitPos, dir );

				ForEachPerk( perk => perk.OnLoseShield() );
				ForEachLoadoutItem( item => item.OnLoseShield() );

				for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
					UnitStatuses.Values.ElementAt( i ).OnHurt( damage, playerSource: null, enemySource, damageType, isSelfInflicted ); // removes player's UnitStatusShield

				AddResultsStat( ResultStat.ShieldsLost, 1 );
				ProgressManager.IncrementStat( ProgressStat.ShieldsLost, 1 );

				return 0f;
			}
		}

		float totalDamage = damage;

		if ( damageType != DamageType.Self && Armor > 0 ) 
		{
			//int armorAmountUsed = (int)MathF.Min( Armor, MathF.Floor( damage ) );
			int armorAmountUsed = (int)MathF.Min( Armor, damage < 1f ? MathF.Ceiling( damage ) : MathF.Floor( damage ) );

			if ( Stats[PlayerStat.LoseHpBeforeArmorAt1Hp] > 0f )
			{
				float hpLostInstead = Math.Min( damage, Health - 1f );
				float remainingDamage = damage - hpLostInstead;
				armorAmountUsed = (int)MathF.Min( Armor, remainingDamage < 1f ? MathF.Ceiling( remainingDamage ) : MathF.Floor( remainingDamage ) );
			}

			if ( armorAmountUsed > 0 )
			{
				damage -= armorAmountUsed;
				if ( damage < 0f )
					damage = 0f;

				LoseArmor( armorAmountUsed, -dir );
				ProgressManager.IncrementStat( ProgressStat.ArmorDamageBlocked, armorAmountUsed );

				ForEachPerk( perk => perk.OnLoseArmor( armorAmountUsed, damageType, isSelfInflicted, dir, enemySource ) );
				ForEachLoadoutItem( item => item.OnLoseArmor( armorAmountUsed, damageType, isSelfInflicted, dir, enemySource ) );

				if ( damage <= 0f )
				{
					if ( armorAmountUsed > 2f )
						ShakeCam( Utils.Map( armorAmountUsed, 2f, 10f, 3f, 5f ), 0.125f, EasingType.QuadOut );

					ForEachPerk( perk => perk.OnHit( totalDamage, damageType, isSelfInflicted, dir, force, enemySource, enemyType, previousHealth: Health ) );
					ForEachLoadoutItem( item => item.OnHit( totalDamage, damageType, isSelfInflicted, dir, force, enemySource, enemyType, Health ) );
					for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
						UnitStatuses.Values.ElementAt( i ).OnHit( totalDamage, playerSource: null, enemySource, damageType, isSelfInflicted );

					//Shake( startStrength: 4f, endStrength: 4f, time: 0.25f );

					return 0f;
				}
			}
		}

		if ( cantKill && damage > Health - 1f )
		{
			damage = Health - 1f;
		}

		if ( Stats[PlayerStat.PreventDeathOver1Hp] > 0f && Health > 1f && damage > Health - 1f )
		{
			var preventDeath = GetPerk( TypeLibrary.GetType( typeof( PerkPreventDeathOver1Hp ) ) ) as PerkPreventDeathOver1Hp;
			if ( preventDeath != null && preventDeath.IsReady )
			{
				damage = Health - 1f;
				preventDeath.Activate();
			}
		}

		// todo: should damage be limited to whole numbers, so Health doesn't have hidden decimals?

		var previousHealth = Health;

		Health -= damage;
		//var pos = new Vector3( Position.x + 18f + Game.Random.Float( -1f, 1f ) * 5f, Position.y + Game.Random.Float( -1f, 1f ) * 5f, 64f );
		//float size = Utils.Map( damage, 1f, 20f, 1.5f, 1.8f, EasingType.Linear ) * Utils.Map( damage, 20f, 100f, 1f, 1.5f, EasingType.Linear );
		//var color = isSelfDmg ? new Color( 1f, 0f, 0.18f ) : new Color( 1f, 0f, 0f );
		DamageVfx( damage, damageType, hitPos, dir, isDodge: false, isCrit );

		if ( damage > 0f )
			HasLostHp = true;

		if ( force > 0f )
		{
			if ( IsDamageTypeMelee( damageType ) )
				force += Stats[PlayerStat.EnemyMeleeForceAdd];

			//ExplosionVelocity += dir * force;
			Velocity += dir * force;
		}

		//TimeSinceLastDamage = 0f;
		//SetDamageFlashMaterial( true );

		if ( damage > 3f )
		{
			ShakeCam( strength: Utils.Map( damage, 3f, 25f, 2f, 6f ), time: Utils.Map( damage, 3f, 30f, 0.1f, 0.35f ), EasingType.QuadOut );

			CameraHurtZoomAmount = Math.Max( Utils.Map( damage, 3f, 25f, 1f, 10f, EasingType.QuadIn ), CameraHurtZoomAmount );
		}

		ForEachPerk( perk => perk.OnHurt( damage, damageType, isSelfInflicted, dir, enemySource, enemyType, previousHealth ) );
		ForEachLoadoutItem( item => item.OnHurt( damage, damageType, isSelfInflicted, dir, enemySource, enemyType, previousHealth ) );
		ForEachPerk( perk => perk.OnHit( totalDamage, damageType, isSelfInflicted, dir, force, enemySource, enemyType, previousHealth ) );
		ForEachLoadoutItem( item => item.OnHit( totalDamage, damageType, isSelfInflicted, dir, force, enemySource, enemyType, previousHealth ) );

		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
			UnitStatuses.Values.ElementAt( i ).OnHurt( damage, playerSource: null, enemySource, damageType, isSelfInflicted );
		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
			UnitStatuses.Values.ElementAt( i ).OnHit( totalDamage, playerSource: null, enemySource, damageType, isSelfInflicted );

		//Stun( playerSource: null, enemySource, 0.5f );

		// enemySource.OnDamagePlayer RPC

		AddResultsStat( ResultStat.DmgTakenTotal, totalDamage );
		var resultsType = GetResultsStatType( damageType, dealt: false );
		AddResultsStat( resultsType, totalDamage );

		ProgressManager.IncrementStat( ProgressStat.DamageTaken, totalDamage );
		if ( damageType == DamageType.Self )
			ProgressManager.IncrementStat( ProgressStat.SelfDamageTaken, totalDamage );
		if ( damageType == DamageType.SpitterProjectile || damageType == DamageType.SpitterProjectileHoming || damageType == DamageType.TossedFireball )
			ProgressManager.IncrementStat( ProgressStat.ProjectileHits, 1 );
		AddEnemyDamageResultStat( enemyType, totalDamage );

		if ( Health <= 0f )
			TryDie( dir, upwardAmount, ragdollForce, enemyType, isSelfInflicted: isSelfInflicted, damageType: damageType );

		Health = Math.Max( Health, 0f );

		return damage;
	}

	[Rpc.Broadcast]
	public void DamageVfx( float damage, DamageType damageType, Vector2 hitPos, Vector2 dir, bool isDodge = false, bool isCrit = false )
	{
		if ( damage > 0f )
		{
			Color floaterColor;
			if ( damageType == DamageType.Self )
				floaterColor = new Color( 1f, 0f, 0.6f );
			else if ( damageType == DamageType.Poison )
				floaterColor = new Color( 0.5f, 0.7f, 0.5f );
			else if ( damageType == DamageType.PoisonFinish )
				floaterColor = new Color( 0.45f, 0.65f, 0.45f );
			else if ( damageType == DamageType.Fire )
				floaterColor = new Color( 1f, 0.4f, 0.1f );
			else
				floaterColor = new Color( 1f, 0f, 0f );

			if ( isCrit )
				floaterColor = Color.Lerp( floaterColor, Color.Yellow, 0.55f );

			float size = Utils.Map( damage, 1f, 20f, 1.75f, 2.5f, EasingType.SineOut ) * Utils.Map( damage, 20f, 100f, 1f, 1.5f, EasingType.Linear );
			FloaterType floaterType = FloaterType.Damage;
			if ( damageType == DamageType.Poison ) floaterType = FloaterType.Poison;
			else if ( damageType == DamageType.PoisonFinish ) floaterType = FloaterType.PoisonFinish;
			else if ( damageType == DamageType.Fire ) floaterType = FloaterType.Fire;
			else if ( damageType == DamageType.Shock ) floaterType = FloaterType.Shock;
			Manager.Instance.SpawnDamageNumber( WorldPosition.WithZ( 65f ), damage, floaterColor, size, floaterType );

			Flash( 0.12f, flashType: damageType == DamageType.Self ? UnitFlashType.SelfDmg : UnitFlashType.PlayerDmg );

			Flinch( 0f, dir );

			//if ( damageType == DamageType.Bullet )
			{
				var scaleMultiplier = Utils.Map( damage, 1f, 5f, 0.4f, 1f, EasingType.Linear ) * Utils.Map( damage, 5f, 30f, 1f, 1.5f, EasingType.Linear );// * (isCrit ? 1.2f : 1f);
				Manager.Instance.SpawnBulletImpactParticles( new Vector3( hitPos.x, hitPos.y, z: Game.Random.Float( 10f, 30f ) ), -dir, floaterColor, scaleMultiplier );
			}

			if ( damageType == DamageType.Melee )
			{
				if ( _realTimeSinceMeleeHurtSfx > 0.075f )
					Manager.Instance.PlaySfxNearby( "zombie.attack.player", hitPos, pitch: Utils.Map( Health, GetSyncStat( PlayerStat.MaxHp ), 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 1f, maxDist: 500f );

				_realTimeSinceMeleeHurtSfx = 0f;
			}
			else if ( damageType == DamageType.MeleeRunnerBite ) { Manager.Instance.PlaySfxNearby( "runner.bite", hitPos, pitch: Utils.Map( Health, GetSyncStat( PlayerStat.MaxHp ), 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 1f, maxDist: 500f ); }
			else if ( damageType == DamageType.Shockwave ) { Manager.Instance.PlaySfxNearby( "zombie.attack.player", hitPos, pitch: Game.Random.Float( 1.15f, 1.2f ), volume: 0.9f, maxDist: 500f ); }
			else if ( damageType == DamageType.Fire ) { Manager.Instance.PlaySfxNearby( "burn_2", hitPos, pitch: Game.Random.Float( 1.15f, 1.25f ), volume: 0.7f, maxDist: 350f ); }
			else if ( damageType == DamageType.Poison ) { Manager.Instance.PlaySfxNearby( "poisoned", hitPos, pitch: Game.Random.Float( 1.55f, 1.65f ), volume: 0.35f, maxDist: 350f ); }
			else if ( damageType == DamageType.Acid ) { Manager.Instance.PlaySfxNearby( "puddle_splat", hitPos, pitch: Game.Random.Float( 1.2f, 1.3f ), volume: 1.4f, maxDist: 350f ); }
			else if ( damageType == DamageType.LavaPuddle ) { Manager.Instance.PlaySfxNearby( "puddle_splat", hitPos, pitch: Game.Random.Float( 1f, 1.1f ), volume: 1.3f, maxDist: 350f ); }
			else if ( damageType == DamageType.Bullet ) { Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, GetSyncStat( PlayerStat.MaxHp ), 0f, 0.9f, 1.25f, EasingType.SineIn ), volume: 1f, maxDist: 350f ); }
			else if ( damageType == DamageType.SpikerHead ) { Manager.Instance.PlaySfxNearby( "spike.stab", hitPos, pitch: Game.Random.Float( 0.85f, 0.9f ), volume: 1f, maxDist: 350f ); }
			else if ( damageType == DamageType.SpitterProjectile || damageType == DamageType.SpitterProjectileHoming ) { Manager.Instance.PlaySfxNearby( "splash", hitPos, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 350f ); }
			else if ( damageType == DamageType.Shock ) { /* no sfx */ }
			else if ( damageType == DamageType.Laser ) { Manager.Instance.PlaySfxNearby( "laser_zap", hitPos, pitch: Utils.Map( Health, GetSyncStat( PlayerStat.MaxHp ), 0f, 0.9f, 1.15f, EasingType.QuadIn ), volume: 1f, maxDist: 350f ); }
			else if ( damageType == DamageType.Self ) { Manager.Instance.PlaySfxNearby( "puddle_splat", hitPos, pitch: Game.Random.Float( 1.55f, 1.6f ), volume: 0.5f, maxDist: 300f ); }
			else { Manager.Instance.PlaySfxNearby( "zombie.attack.player", hitPos, pitch: Game.Random.Float( 1.15f, 1.2f ), volume: 0.9f, maxDist: 500f ); }
			// todo: hit by explosion sfx
		}
		else
		{
			if ( isDodge )
			{
				Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), "DODGE", color: new Color( 1f, 1f, 1f, 0.6f ), size: 1.5f, floaterType: FloaterType.Dodge );
				Manager.Instance.PlaySfxNearby( "player.dash", hitPos, pitch: Game.Random.Float( 1.5f, 3f ), volume: 0.8f, maxDist: 300f );
				DodgeDuck( dir, time: Game.Random.Float( 0.05f, 0.15f ), shouldFlinch: true );
			}
			else
			{
				var color = damageType == DamageType.Self ? new Color( 1f, 0f, 0.6f ) : new Color( 1f, 0.35f, 0.35f );
				float size = Utils.Map( damage, 1f, 20f, 1.75f, 2.5f, EasingType.SineOut ) * Utils.Map( damage, 20f, 100f, 1f, 1.5f, EasingType.Linear );
				Manager.Instance.SpawnDamageNumber( WorldPosition.WithZ( 65f ), damage, color, size, FloaterType.Damage );

				if ( damageType != DamageType.Self )
				{
					var invincibleShakeStrength = 4f;
					invincibleShakeStrength *= (1f / WorldScale.x);
					Shake( startStrength: invincibleShakeStrength, endStrength: invincibleShakeStrength, time: 0.05f );
				}
			}
		}
	}

	bool TryDodge()
	{
		if ( (int)Stats[PlayerStat.DodgeGuaranteedNum] > 0 )
		{
			Stats[PlayerStat.DodgeGuaranteedNum]--;
			return true;
		}

		if ( Stats[PlayerStat.DodgeNextAttack] > 0f )
			return true;

		int numAttempts = 1 + (int)Stats[PlayerStat.DodgeExtraAttempts];
		float chance = GetDodgeChance();

		for ( int i = 0; i < numAttempts; i++ )
		{
			if ( Game.Random.Float( 0f, 1f ) < chance )
				return true;
		}

		return false;
	}

	public float GetDodgeChance()
	{
		float chance = Stats[PlayerStat.DodgeChance];

		if ( Stats[PlayerStat.LowHealthDodgeChance] > 0f )
			chance += Utils.Map( Health, Stats[PlayerStat.MaxHp], 0f, 0f, Stats[PlayerStat.LowHealthDodgeChance] );

		return chance;
	}

	void ApplyEnemyProjectileStatusEffect( Enemy enemySource, EnemyType enemyType, EnemyProjectileType projectileType )
	{
		switch ( projectileType )
		{
			case EnemyProjectileType.Fire:
				var fireDmg = Utils.Select( Manager.Instance.Difficulty, 2f, 3f, 3f );
				var fireTime = Utils.Select( Manager.Instance.Difficulty, 4f, 4f, 5f );
				Ignite( playerSource: null, enemySource: enemySource, enemyType: enemyType, damage: fireDmg, lifetime: fireTime, spreadChance: 0.05f, canStack: false );
				break;
			case EnemyProjectileType.Freeze:
				var freezeScale = Utils.Select( Manager.Instance.Difficulty, 0.7f, 0.65f, 0.55f );
				var freezeTime = Utils.Select( Manager.Instance.Difficulty, 2f, 2.3f, 3f );
				Freeze( playerSource: null, enemySource: enemySource, timeScale: freezeScale, lifetime: freezeTime );
				break;
			case EnemyProjectileType.Poison:
				var poisonDmg = Utils.Select( Manager.Instance.Difficulty, 1f, 2f, 2f );
				Poison( playerSource: null, enemySource: enemySource, enemyType: enemyType, damage: poisonDmg, finishDmgPercent: 0f, dieSpreadChance: 0f, radiusMultiplier: 1f, flammable: false );
				break;
			case EnemyProjectileType.Acid:
				break;
			case EnemyProjectileType.Curse:
				if ( AvailableCurseCount > 0 )
				{
					GiveRandomPerkItemRpc( Position2D, Utils.GetRandomVectorInCone( -FacingDir ), rarity: Rarity.None, curseSelection: CurseSelection.OnlyCurses, forceToCollect: true );
					Manager.Instance.SpawnFloaterTextRpc( WorldPosition.WithZ( 65f ), "CURSED!", new Color( 0.3f, 0.3f, 0.3f ), 1.3f, FloaterType.NegativeMessage );
				}
				else
				{
					Manager.Instance.SpawnFloaterTextRpc( WorldPosition.WithZ( 65f ), "NO CURSES LEFT!", new Color( 0.3f, 0.3f, 0.3f ), 1.3f, FloaterType.NegativeMessage );
				}
				break;
		}
	}

	[Rpc.Broadcast]
	public void DodgeDuckRpc( Vector2 dir, float time, bool shouldFlinch = false )
	{
		DodgeDuck( dir, time, shouldFlinch );
	}

	public void DodgeDuck( Vector2 dir, float time, bool shouldFlinch = false )
	{
		_isDodgeDucking = true;
		_timeSinceDodgeDuck = 0f;
		_dodgeDuckTime = time;
		AnimationHelper.DuckLevel = 1f;

		if ( shouldFlinch )
			Flinch( time, dir );
	}

	void HandleDodgeDucking()
	{
		if ( !_isDodgeDucking )
			return;

		if ( _timeSinceDodgeDuck > _dodgeDuckTime )
		{
			_isDodgeDucking = false;
			AnimationHelper.DuckLevel = 0f;
		}
	}

	[Rpc.Broadcast]
	public void ScaleHeightRpc( float amount, float time )
	{
		ScaleHeight( amount, time );
	}

	public void ScaleHeight( float amount, float time )
	{
		_isScaleHeight = true;
		_timeSinceScaleHeight = 0f;
		_scaleHeightTime = time;
		AnimationHelper.Target.Set( "scale_height", amount );
	}

	void HandleScaleHeight()
	{
		if ( !_isScaleHeight )
			return;

		if ( _timeSinceScaleHeight > _scaleHeightTime )
		{
			_isScaleHeight = false;
			AnimationHelper.Target.Set( "scale_height", 1f );
		}
	}

	[Rpc.Broadcast]
	public void CollectItemEffect()
	{
		ScaleHeight( amount: 1.5f, time: Game.Random.Float( 0.07f, 0.1f ) );

		if ( IsProxy )
			return;

		NumItemsCollected++;
		ProgressManager.IncrementStat( ProgressStat.ItemsCollected, 1 );
	}

	public bool TryDie( Vector2 dir, float upwardAmount, float force, EnemyType enemyType, bool isSelfInflicted, DamageType damageType, bool playSfx = true )
	{
		var perkSnapshot = Perks.Values.ToList();
		for ( int i = perkSnapshot.Count - 1; i >= 0; i-- )
			if ( perkSnapshot[i].TryPreventDeath() ) return false;

		if ( CurrentGun != null && CurrentGun.TryPreventDeath() ) return false;
		foreach ( var charm in CurrentCharms )
			if ( charm != null && charm.TryPreventDeath() ) return false;
		foreach ( var gem in CurrentGems )
			if ( gem.TryPreventDeath() ) return false;

		Die( dir, upwardAmount, force, enemyType, isSelfInflicted, TotalVelocity, damageType, playSfx );
		return true;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	void Die( Vector2 dir, float upwardAmount, float force, EnemyType enemyType, bool isSelfInflicted, Vector2 velocity, DamageType damageType, bool playSfx = true )
	{
		_timeSinceEnemyMeleeHit.Clear();
		IsFlashing = false;
		ResetMaterials();

		PhysicalCollider.Enabled = false;

		SpawnDeathRagdoll( dir, upwardAmount, force, velocity );

		if( ShouldDeathHaveBlood( damageType ) )
			CreateDeathBlood();

		foreach ( var renderer in Renderers )
		{
			renderer.MaterialOverride = Manager.Instance.PlayerDeadMaterial;
		}

		if ( playSfx )
			Manager.Instance.PlaySfxNearby( "player.die", Position2D, pitch: Game.Random.Float( 1f, 1.2f ), volume: 1f, maxDist: 1200f );

		AnimationHelper.IsNoclipping = true;
		AnimationHelper.HoldType = CitizenAnimationHelper.HoldTypes.None;

		if ( IsProxy || IsDead )
			return;

		IsDead = true;
		IsDying = true;
		IsInTheAir = false;

		if ( Manager.Instance.IsMultiplayer )
			Manager.Instance.Chat.AddText( "has died!", systemText: true, clearOnRestart: true );

		IsDashing = false;
		AnimationHelper.Target.Set( "skid_x", 0f );
		AnimationHelper.Target.Set( "skid_y", 0f );
		DashVelocity = Vector2.Zero;

		AttackTimer = 0f;
		ReloadProgress = 0f;

		Velocity = Vector2.Zero;
		ExplosionVelocity = Vector2.Zero;

		ForEachPerk( perk => perk.OnDie() );
		ForEachLoadoutItem( item => item.OnDie() );

		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
			UnitStatuses.Values.ElementAt( i ).StartDying( null );

		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
			UnitStatuses.Values.ElementAt( i ).Die( null );

		RemoveAllUnitStatuses();

		ShakeCam( strength: 4f, time: 0.4f, EasingType.QuadOut );
		Manager.Instance.DeathFlash();

		//if( enemyType != EnemyType.None )
		//	Sandbox.Services.Stats.Increment( $"enemy_{enemyType}_died_to", 1 );

		Stats[PlayerStat.NumTimesDied]++;
		AddResultsStat( ResultStat.NumDeaths, 1 );
		ProgressManager.IncrementStat( ProgressStat.Deaths, 1 );

		// todo: should your own explosions/fires etc show Self or Explosion/Fire?
		var resultStatKilledBy = isSelfInflicted ? ResultStat.KilledBySelf : GetEnemyKilledByResultStat( enemyType );
		LastKilledBy = resultStatKilledBy;
		AddResultsStat( resultStatKilledBy, 1 );

		if ( enemyType == EnemyType.Boss && (int)Stats[PlayerStat.NumEnemiesKilled] == 0 )
			Manager.Instance.UnlockAchievement( "pacifist" );

		Manager.Instance.PlayerDied( this );
	}

	bool ShouldDeathHaveBlood( DamageType damageType )
	{
		if ( damageType == DamageType.Acid
			|| damageType == DamageType.Fire
			|| damageType == DamageType.LavaPuddle
			|| damageType == DamageType.Poison
			|| damageType == DamageType.PoisonFinish
			|| damageType == DamageType.Radiation )
			return false;

		return true;
	}

	void CreateDeathBlood()
	{
		var bloodExplosionGo = GameObject.Clone( "prefabs/effects/blood_explosion.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( WorldPosition.z + Game.Random.Float( 10f, 20f ) ), Rotation.Identity ) } );
		var bloodSprayer = bloodExplosionGo.GetComponentInChildren<BloodSprayer>();
		bloodSprayer.StopSprayingBlood();

		var emitter = bloodSprayer.GetComponent<ParticleEmitter>();
		var particleEffect = bloodSprayer.GetComponent<ParticleEffect>();

		int numSpray = Game.Random.Int( 5, 15 );

		for ( int i = 0; i < numSpray; i++ )
			emitter.Emit( particleEffect );
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void Revive()
	{
		ResetMaterials();

		Manager.Instance.PlaySfxNearby( "heal", Position2D, pitch: Game.Random.Float( 0.7f, 0.75f ), volume: 1.2f, maxDist: 450f );

		ResetHoldType();
		AnimationHelper.IsNoclipping = false;

		WorldPosition = WorldPosition.WithZ( 0f );

		PhysicalCollider.Enabled = true;

		if ( IsProxy || !IsDead )
			return;

		IsDead = false;
		IsDying = false;

		Health = Stats[PlayerStat.MaxHp] * Utils.Select( Manager.Instance.Difficulty, 0.66f, 0.4f, 0.25f );
		IsReloading = true;
		//AttackTimer = Stats[PlayerStat.ReloadTime] * Game.Random.Float( 0.6f, 1f );
		AttackTimer = Stats[PlayerStat.ReloadTime];
		ReloadProgress = 0f;

		ForEachPerk( perk => perk.OnRevive() );
		ForEachLoadoutItem( item => item.OnRevive() );
		ProgressManager.IncrementStat( ProgressStat.Revives, 1 );

		Manager.Instance.Chat.AddText( "has revived.", systemText: true, clearOnRestart: true );

		if ( IsChoosingLevelUpReward )
			RealTimeSinceOfferedChoices = 0f;

		if ( Manager.Instance.Difficulty == 0 )
		{
			BecomeInvincible( 1.5f );
		}
	}

	public float GetDamageMultiplierDisplay()
	{
		// todo: add the mults together?

		float mult = Stats[PlayerStat.OverallDamageMultiplier];

		if ( LastDamageTypeDealt != DamageType.Bullet )
			mult *= Stats[PlayerStat.NonBulletDamageMultiplier];

		if ( Stats[PlayerStat.LowHealthDamageMultiplier] > 1f )
			mult *= Utils.Map( Health, Stats[PlayerStat.MaxHp], 0f, 1f, Stats[PlayerStat.LowHealthDamageMultiplier] );

		if ( Stats[PlayerStat.FullHealthDamageMultiplier] > 1f && !(Health < Stats[PlayerStat.MaxHp]) )
			mult *= Stats[PlayerStat.FullHealthDamageMultiplier];

		if ( Stats[PlayerStat.DamagePercentPerBanished] > 0f )
			mult *= (1f + Stats[PlayerStat.DamagePercentPerBanished] * BanishedPerkIdentities.Count);

		if ( Stats[PlayerStat.ZeroRerollDmgMult] > 0f && NumRerollAvailable == 0 )
			mult *= (1f + Stats[PlayerStat.ZeroRerollDmgMult]);

		return mult;
	}

	//// todo: need to check this in more places?
	//public void GetAdditionalDamageToEnemy( ref float damage, Enemy enemy, DamageType damageType, Vector2 dir, bool canBackstab = false )
	//{
	//	Assert.True( !IsProxy );

	//	if ( !enemy.IsValid() )

	//	float dmgMult = 1f;
	//	float dmgAdd = 0f;
	//	GetAdditionalDamageToEnemy( ref dmgMult, ref dmgAdd, enemy, damageType, dir, canBackstab: true );
	//	damage += dmgAdd;
	//	damage *= dmgMult;
	//}

	//void GetAdditionalDamageToEnemy( ref float mult, ref float add, Enemy enemy, DamageType damageType, Vector2 dir, bool canBackstab = false )
	//	if ( enemy.IsFrozen )
	//	{
	//		if ( damageType == DamageType.Fire )
	//		{
	//			mult *= Stats[PlayerStat.FreezeFireDamageMultiplier];
	//		}
	//	}

	//	if ( enemy.IsFearful )
	//		mult *= Stats[PlayerStat.FearDamageMultiplier]; // todo: these multipliers will stack too much?

	//	if ( canBackstab && enemy.CanBeBackstabbed && Stats[PlayerStat.BackstabBonusDamagePercent] > 0f )
	//	{
	//		if ( dir.LengthSquared > 0f && Vector2.Dot( dir, (Vector2)enemy.WorldRotation.Forward ) > 0.1f )
	//			mult *= (1f + Stats[PlayerStat.BackstabBonusDamagePercent]);
	//	}

	//	if ( !(enemy.Health < enemy.MaxHealth) )
	//		mult *= Stats[PlayerStat.HealthyUnitDamagePercent];

	//	if ( Stats[PlayerStat.AlternatePlayerDamagePercent] > 0f && enemy.PrevPlayerDamagedBy.IsValid() && enemy.PrevPlayerDamagedBy != this )
	//		mult *= (1f + Stats[PlayerStat.AlternatePlayerDamagePercent]);

	//	if ( Stats[PlayerStat.CoupDeGracePercent] > 0f )
	//	{
	//		var coupDeGracePerk = GetPerk( TypeLibrary.GetType( typeof( PerkCoupDeGrace ) ) ) as PerkCoupDeGrace;

	//		if ( !coupDeGracePerk.HasDamagedEnemy( enemy ) )
	//		{
	//			float dmgAdd = (enemy.MaxHealth - enemy.Health) * Stats[PlayerStat.CoupDeGracePercent];

	//			if ( dmgAdd > 0f )
	//			{
	//				add += dmgAdd * GetDamageMultiplier();
	//				// todo: sfx / vfx
	//			}
	//		}
	//	}
	//}

	//// call when a non-proxy source (eg. a UnitStatus) created by a proxy player needs to deal damage to an enemy
	//[Rpc.Owner]
	//public void DamageEnemyRpc( float damage, Enemy enemy, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool canBackstab = false, bool shouldFlinch = true )
	//{
	//	var dir = force.Normal;

	//	GetAdditionalDamageToEnemy( ref damage, enemy, DamageType.Melee, dir, canBackstab );

	//	enemy.DamageRpc( damage, this, damageType, hitPos, force, isCrit, shouldFlinch );

	//	DamageEnemy( enemy, damage, damageType, dir, isCrit );
	//}

	public float GetAttackSpeedMultiplier()
	{
		if ( Stats[PlayerStat.UpsideDown] > 0f )
			return Stats[PlayerStat.MoveSpeedMultiplier] * (IsReloading ? Stats[PlayerStat.ReloadingMovespeedMultiplier] : Stats[PlayerStat.ShootingMovespeedMultiplier]);
		else
			return Stats[PlayerStat.AttackSpeed] * (IsMoving ? 1f : Stats[PlayerStat.AttackSpeedStill]);
	}

	public float GetReloadSpeedMultiplier()
	{
		return Stats[PlayerStat.ReloadSpeed] * (IsMoving ? 1f : Stats[PlayerStat.ReloadSpeedStill]);
	}

	public float GetMoveSpeedMultiplier()
	{
		if ( Stats[PlayerStat.UpsideDown] > 0f )
			return Stats[PlayerStat.AttackSpeed] * (IsMoving ? 1f : Stats[PlayerStat.AttackSpeedStill]);
		else
			return Stats[PlayerStat.MoveSpeedMultiplier] * (IsReloading ? Stats[PlayerStat.ReloadingMovespeedMultiplier] : Stats[PlayerStat.ShootingMovespeedMultiplier]);
	}

	public float GetHpRegenAmount( bool forDisplay = false )
	{
		var regen = GetSyncStat( PlayerStat.HealthRegen ) + (IsMoving ? 0f : GetSyncStat( PlayerStat.HealthRegenStill ));

		if ( forDisplay )
		{
			regen += GetSyncStat( PlayerStat.HpRegenDisplay );
			regen *= GetHealEffectiveness();
		}

		regen += GetSyncStat( PlayerStat.HealthDrain );

		return regen;
	}

	public float GetHealEffectiveness()
	{
		var amount = GetSyncStat( PlayerStat.HealEffectiveness );

		var lowEffectiveness = GetSyncStat( PlayerStat.LowHealthHealEffectiveness );
		if ( lowEffectiveness > 1f )
			amount *= Utils.Map( Health, GetSyncStat( PlayerStat.MaxHp ), 0f, 1f, lowEffectiveness );

		return amount;
	}

	[Rpc.Broadcast]
	public void StartReloading()
	{
		ResetHoldType();

		if( GetSyncStat( PlayerStat.PunchBullets ) > 0f || (int)GetSyncStat( PlayerStat.MaxAmmoCount ) <= 0 )
			AnimationHelper.Target.Set( "b_reload", true );

		if ( IsProxy )
			return;

		StartReloadingOwner();
	}

	void StartReloadingOwner()
	{
		AttackTimer += Stats[PlayerStat.ReloadTime];
		IsReloading = true;
		ReloadProgress = 0f;
		Stats[PlayerStat.ShotNum] = 0f;
		Stats[PlayerStat.ClipDamageBonus] = 0f;
		AimAngleOffset = 0f;
		_recoilSign *= -1;

		ForEachPerk( perk => perk.OnStartReload() );
		ForEachLoadoutItem( item => item.OnStartReload() );
	}

	[Rpc.Broadcast]
	public void FinishReloading()
	{
		AnimationHelper.HoldType = GetSyncStat( PlayerStat.PunchBullets ) > 0f
			? CitizenAnimationHelper.HoldTypes.Punch
			: CitizenAnimationHelper.HoldTypes.Shotgun;

		if ( IsProxy )
			return;

		FinishReloadingOwner();
	}

	void FinishReloadingOwner()
	{
		AmmoCount = Math.Max( (int)Stats[PlayerStat.MaxAmmoCount], 0 );
		IsReloading = false;
		ReloadProgress = 1f;
		Stats[PlayerStat.ShotNum] = 0f;
		Stats[PlayerStat.ClipNum] += 1f;
		AttackTimer = 0f;
		//SS2Game.PlaySfx("reload.end", Position + Vector3.Up * 10);

		ForEachPerk( perk => perk.OnFinishReload() );
		ForEachLoadoutItem( item => item.OnFinishReload() );

		AddResultsStat( ResultStat.NumTimesReloaded, 1 );
	}

	[Rpc.Broadcast]
	public void ReloadInstantly()
	{
		AnimationHelper.HoldType = GetSyncStat( PlayerStat.PunchBullets ) > 0f
			? CitizenAnimationHelper.HoldTypes.Punch
			: CitizenAnimationHelper.HoldTypes.Shotgun;

		Manager.Instance.PlaySfxNearby( "reload.end", Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 1.1f, maxDist: 300f );

		if ( IsProxy )
			return;

		if ( !IsReloading )
			StartReloadingOwner();

		FinishReloadingOwner();
	}


	[Rpc.Broadcast]
	public void ReloadAmmoAmount( int amount )
	{
		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), $"+{amount} AMMO", new Color( 0.7f, 0.75f, 0.85f ), 1.3f, FloaterType.Reload1 );

		Manager.Instance.PlaySfxNearby( "reload.end", Position2D, pitch: Game.Random.Float( 2f, 2.1f ), volume: 1f, maxDist: 250f );

		if ( IsProxy )
			return;

		if ( IsReloading )
		{
			int maxAmmo = (int)Stats[PlayerStat.MaxAmmoCount];
			AttackTimer -= (float)amount / maxAmmo * Stats[PlayerStat.ReloadTime];
			ReloadProgress = Utils.Map( AttackTimer, Stats[PlayerStat.ReloadTime], 0f, 0f, 1f );
		}
		else
		{
			AmmoCount = Math.Min( AmmoCount + amount, (int)Stats[PlayerStat.MaxAmmoCount] );
		}
	}

	public override void Colliding( Thing other, float percent, float dt )
	{
		base.Colliding( other, percent, dt );

		if ( IsDead )
			return;

		ForEachPerk( perk => perk.Colliding( other, percent, dt ) );
		ForEachLoadoutItem( item => item.Colliding( other, percent, dt ) );

		if ( IsDashing && other is SpitterProjectileHoming homingSkull )
		{
			homingSkull.RemoveRpc( shouldSpawnEffects: true );
			ProgressManager.IncrementStat( ProgressStat.FlyingSkullsDashDestroyed, 1 );
		}
		else if ( other is SpitterProjectile proj )
		{
			if ( percent >= proj.RequiredPlayerCollisionPercent )
			{
				bool multiHit = proj is SpitterProjectileHoming && GetSyncStat( PlayerStat.FlyingSkullMultiHit ) > 0f;
				float hitCooldown = multiHit ? 0.33f : 99f;

				if ( !_timeSinceProjectileHit.TryGetValue( proj, out var projTimeSince ) || projTimeSince > hitCooldown )
				{
					var dir = (Vector2)proj.WorldRotation.Forward;
					var hitPos = Position2D - dir * Radius;
					var damageType = proj is SpitterProjectileHoming ? DamageType.SpitterProjectileHoming : DamageType.SpitterProjectile;

					Damage( proj.Damage, damageType, hitPos, dir, upwardAmount: Game.Random.Float(0f, 0.1f), proj.HitForce, ragdollForce: proj.HitForce * 0.01f, proj.Shooter, proj.EnemyType, enemyProjectileType: proj.ProjectileType );
					_timeSinceProjectileHit[proj] = 0;
					proj.PlayerHitRpc( shouldSpawnEffects: !IsInvincible, multiHit, Position2D );
				}
			}
		}

		if ( other is Sword sword )
		{
			if ( sword.IsInSpawnDamagePhase )
			{
				if ( !_timeSinceEnemyMeleeHit.TryGetValue( sword, out var swordTimeSince ) || swordTimeSince > sword.DamageTargetDelay )
				{
					var dir = (Position2D - sword.Position2D).Normal;
					var hitPos = Position2D - dir * Radius;
					Damage( sword.MeleeDamage, sword.MeleeAttackDamageType, hitPos, dir, upwardAmount: Game.Random.Float( 0f, 1f ), force: 1f, ragdollForce: Game.Random.Float( 0f, 2f ), sword, EnemyType.Boss );
					_timeSinceEnemyMeleeHit[sword] = 0;
					sword.NotifyMeleeHitPlayerRpc( this );
				}
			}
		}
		else if ( other is Enemy touchEnemy && touchEnemy.SyncedCanDamageByTouch )
		{
			if ( !_timeSinceEnemyMeleeHit.TryGetValue( touchEnemy, out var timeSince ) || timeSince > touchEnemy.DamageTargetDelay )
			{
				var dir = (Position2D - touchEnemy.Position2D).Normal;
				var enemyFacingDir = ((Vector2)touchEnemy.WorldRotation.Forward).Normal;
				var hitDir = Vector2.Lerp( dir, enemyFacingDir, Game.Random.Float( 0f, 1f ) ).Normal;

				var hitPos = Position2D - dir * Radius;
				Damage( touchEnemy.MeleeDamage, touchEnemy.MeleeAttackDamageType, hitPos, hitDir, touchEnemy.MeleeUpwardForceAmount, touchEnemy.MeleeForce, touchEnemy.MeleeRagdollForce, touchEnemy, touchEnemy.EnemyType );
				_timeSinceEnemyMeleeHit[touchEnemy] = 0;
				touchEnemy.NotifyMeleeHitPlayerRpc( this );
			}
		}

		if ( IgnorePhysicsAmount > 0 )
			return;

		if ( other is Enemy enemy )
		{
			if ( !Position2D.Equals( enemy.Position2D ) )
			{
				Velocity += (Position2D - enemy.Position2D).Normal * enemy.PushStrength * percent * enemy.SpawnProgress * (1f / Weight) * dt;// (enemy.Weight / Weight) * dt;
																																			 //Velocity += (Position2D - other.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 100f ) * dt;// * (1f + other.TempWeight) * spawnFactor * dt;
			}
		}
		else if ( other is Player player )
		{
			if ( !player.IsDead && !Position2D.Equals( other.Position2D ) )
			{
				Velocity += (Position2D - other.Position2D).Normal * other.PushStrength * percent * (other.Weight / Weight) * dt;

				//Velocity += (Position2D - other.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 100f ) * dt;// * (1f + other.TempWeight) * dt;
			}
		}
		else
		{
			// obstacle
			AddRepelVelocity( (Position2D - other.Position2D).Normal * other.PushStrength * percent * (other.Weight / Weight) * dt );
		}
	}

	public void OnDamageEnemy( Enemy enemy, float damage, DamageType damageType, Vector2 dir, bool isCrit = false )
	{
		// todo: only call ForEachPerk if perk flag for "OnDamageUnit Needed" is true? so don't call it on every perk, every time you damage something
		ForEachPerk( perk => perk.OnDamageEnemy( enemy, damage, damageType, dir, isCrit ) );
		ForEachLoadoutItem( item => item.OnDamageEnemy( enemy, damage, damageType, dir, isCrit ) );

		_dpsTimes.Enqueue( (Time.Now, damage) );

		LastDamageTypeDealt = damageType;

		AddResultsStat( ResultStat.DmgDealtTotal, damage );
		ProgressManager.IncrementStat( ProgressStat.DamageDealt, damage );
		if ( damageType == DamageType.Fire )
			ProgressManager.IncrementStat( ProgressStat.FireDamageDealt, damage );
		if ( damageType == DamageType.Poison || damageType == DamageType.PoisonFinish )
			ProgressManager.IncrementStat( ProgressStat.PoisonDamageDealt, damage );
		if ( damageType == DamageType.OrbitingBlade )
			ProgressManager.IncrementStat( ProgressStat.BuzzsawDamageDealt, damage );
		if ( damageType == DamageType.Radiation )
			ProgressManager.IncrementStat( ProgressStat.RadiationDamageDealt, damage );
		if ( isCrit )
			ProgressManager.IncrementStat( ProgressStat.CriticalHits, 1 );

		var resultsType = GetResultsStatType( damageType, dealt: true );
		AddResultsStat( resultsType, damage );

		if( enemy is Boss )
			AddResultsStat( ResultStat.DmgDealtToBoss, damage );

		if ( !ResultStats.ContainsKey( ResultStat.LargestDmgDealt ) )
			ResultStats.Add( ResultStat.LargestDmgDealt, 0f );
		if ( damage > ResultStats[ResultStat.LargestDmgDealt] )
			ResultStats[ResultStat.LargestDmgDealt] = damage;

		float BIG_DAMAGE_ACHIEVEMENT_REQ = 500f;
		var damageInt = Math.Ceiling( damage );
		if( damageInt >= BIG_DAMAGE_ACHIEVEMENT_REQ )
			Manager.Instance.UnlockAchievement( "big_damage" );
	}

	public override void Flinch( float time, Vector2 dir )
	{
		base.Flinch( time, dir );

		AnimationHelper.Target.Set( "hit", true );
		AnimationHelper.Target.Set( "hit_direction", dir );
		AnimationHelper.Target.Set( "hit_strength", 1f );
	}

	public float GetExperienceReqForLevel( int level )
	{
		if ( level <= 50 )
			return MathF.Round( Utils.Map( level, 1, 150, 3f, 380f, EasingType.SineIn ) );

		return MathF.Round( 52f * MathF.Pow( 1.03f, level - 50 ) );
	}

	[Rpc.Broadcast]
	public void AddXpRpc( float xp, XpSource source, bool spawnFloater = true, bool playSfx = true )
	{
		xp *= GetSyncStat( PlayerStat.XpGainMultiplier );

		if ( spawnFloater )
		{
			var pos = WorldPosition.WithZ( 60f );
			var color = new Color( 0.3f, 0.3f, 1f );
			float size = Utils.Map( xp, 1f, 30f, 1.3f, 2f );

			if ( Manager.Instance.ShowPreciseNumbers || Manager.Instance.IsCommunismActive )
			{
				string str = xp % 1 == 0 ? xp.ToString( "0" ) : xp.ToString( "0.#" );
				Manager.Instance.SpawnFloaterText( pos, str + " XP", color, size, FloaterType.Xp );
			}
			else
			{
				Manager.Instance.SpawnFloaterText( pos, $"{Manager.Instance.GetFloaterNumber( xp )} XP", color, size, FloaterType.Xp );
			}
		}

		if ( IsProxy )
			return;

		AddXp( xp, source, playSfx );
	}

	public void AddXp( float xp, XpSource source, bool playSfx = false )
	{
		_xpAccumulated += xp;
		//if(_xpAccumulated < 1f)
		//	return;

		ExperienceTotal += _xpAccumulated;
		ExperienceCurrent += _xpAccumulated;

		if ( playSfx )
			Manager.Instance.PlaySfxNearbyRpc( "xp", Position2D, pitch: Utils.Map( ExperienceCurrent, 1, ExperienceRequired + 1, 0.65f, 1.5f, EasingType.QuadIn ), volume: 0.6f, maxDist: 300f );

		if ( source == XpSource.Coin )
		{
			if ( (Stats[PlayerStat.CoinHealAmount]) > 0f )
				Heal( _xpAccumulated * Stats[PlayerStat.CoinHealAmount] );

			ForEachPerk( perk => perk.OnGainXpCoin( _xpAccumulated ) );
			ForEachLoadoutItem( item => item.OnGainXpCoin( _xpAccumulated ) );

			ProgressManager.IncrementStat( ProgressStat.XpCoinsCollected, 1 );
		}

		//if(!IsChoosingLevelUpReward)
		CheckForLevelUp();

		_xpAccumulated = 0f;
	}

	public void LoseXp( float xp )
	{
		var amountToLose = Math.Min( xp, ExperienceCurrent );

		ExperienceCurrent -= amountToLose;
		ExperienceTotal -= amountToLose;
	}

	public void CheckForLevelUp()
	{
		if ( ExperienceCurrent >= ExperienceRequired && !Manager.Instance.IsGameOver )
			LevelUp();
	}

	[Rpc.Broadcast]
	public void LevelUp()
	{
		if ( RealTimeSinceLvlUp > 0.05f )
			Manager.Instance.PlaySfxNearby( "levelup", Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ) * (IsProxy ? 0.85f : 1f), volume: 0.8f, maxDist: 500f );

		//Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 35f ), "LVL 🡅", new Color( 0.66f, 0.66f, 0f ), size: 1.4f, floaterType: FloaterType.PositiveMessage );
		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( Game.Random.Float( 20f, 50f ) ), $"LVL {Level + 1}", new Color( 0.66f, 0.66f, 0f ), size: 1.4f, floaterType: FloaterType.PositiveMessage );

		if ( IsProxy )
			return;

		ExperienceCurrent -= ExperienceRequired;

		Level++;

		NumPerkPointsAvailable++;
		ExperienceRequired = GetExperienceReqForLevel( Level + 1 );
		TimeSinceLvlUp = 0f;
		RealTimeSinceLvlUp = 0f;

		ForEachPerk( perk => perk.OnPlayerLevelUp() );
		ForEachLoadoutItem( item => item.OnPlayerLevelUp() );

		if ( !HasSubmittedRunPlayedStat )
		{
			// todo: can submit this as 1player run, but then more players join. 
			// either decrement and update stat at end, or make new players only spectators
			Sandbox.Services.Stats.Increment( Manager.GetStatString( StatType.NumRuns, Manager.Instance.NumPlayersThisRun, Manager.Instance.Difficulty ), 1 );
			HasSubmittedRunPlayedStat = true;
		}

		if ( IsChoosingLevelUpReward )
		{
			NumRerollWaiting += (int)Stats[PlayerStat.NumRerollsPerLevel];
		}
		else
		{
			int rerollAmount = (int)Stats[PlayerStat.NumRerollsPerLevel];
			if ( rerollAmount > 0 )
			{
				if ( Manager.Instance.IsUnpausedChoosing )
				{
					var dropDir = -FacingDir;

					for ( int i = 0; i < rerollAmount; i++ )
						Manager.Instance.SpawnItemRpc( "reroll_item", Position2D, Utils.GetRandomVectorInCone( dropDir, coneDegrees: 160f ) );

					DodgeDuckRpc( dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 160f ), time: Game.Random.Float( 0.1f, 0.125f ) );
				}
				else
				{
					AddRerolls( rerollAmount );
				}
			}
		}

		if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.LevelUpExistingPerkChance] && !IsBeingShownCurseChoices )
		{
			bool success = GiveRandomExistingPerk( showMessage: true, perkType: TypeLibrary.GetType( typeof( PerkLevelUpExisting ) ) );
			if ( success )
			{
				NumPerkPointsAvailable--;
				HighlightPerk( TypeLibrary.GetType( typeof( PerkLevelUpExisting ) ) );
			}
			else
			{
				if ( !IsChoosingLevelUpReward )
				{
					ShowPerkChoices();
				}
			}
		}
		else
		{
			if ( !IsChoosingLevelUpReward )
			{
				ShowPerkChoices();
			}
		}

		CheckForLevelUp();
	}

	int DetermineNumPerkChoicesToSee()
	{
		int numChoices = Stats[PlayerStat.OverrideNumPerkChoices] > 0f ? (int)Stats[PlayerStat.OverrideNumPerkChoices] : (int)Stats[PlayerStat.NumPerkChoices];

		if ( Stats[PlayerStat.MorePerkChoicesChance] > 0f )
		{
			var numChances = (int)Stats[PlayerStat.MorePerkChoicesNumChances];
			for ( int i = 0; i < numChances; i++ )
			{
				if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.MorePerkChoicesChance] )
					numChoices++;
			}
		}

		if ( Stats[PlayerStat.GemPerkChoiceChance] > 0f )
		{
			if ( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.GemPerkChoiceChance] )
				numChoices++;
		}

		numChoices += (int)Stats[PlayerStat.NumChoicesHurt];

		return numChoices;
	}

	void ShowPerkChoices()
	{
		IsBeingShownCurseChoices = NumCursesToChoose > 0 && AvailableCurseCount > 0;

		IsChoosingLevelUpReward = true;
		RefreshPerkChoices();
		RealTimeSinceOfferedChoices = 0f;

		if ( IsBeingShownCurseChoices )
			ShowCurseMessage();
	}

	public void AddPerkPoints( int amount )
	{
		if ( amount <= 0 )
			return;

		NumPerkPointsAvailable += amount;

		if ( !IsChoosingLevelUpReward )
		{
			IsChoosingLevelUpReward = true;
			RefreshPerkChoices();
			RealTimeSinceOfferedChoices = 0f;
		}
	}

	public void ChoosePerkHotkey( int num )
	{
		if ( !IsChoosingLevelUpReward || CurrentPerkChoices == null || num >= CurrentPerkChoices.Count || !CanInteractWithChoices )
			return;

		if ( Manager.Instance.HoveredPerkType != null && Manager.Instance.IsHoveredPerkAChoice )
		{
			Manager.Instance.HoveredPerkType = null;
			Manager.Instance.HoveredPerkViewedPlayer = null;
			Manager.Instance.HoveredPerkChoiceSlot = -1;
		}

		HoveredPerkChoiceSlot = -1;

		CanInteractWithChoices = false;

		var type = CurrentPerkChoices[num];
		var typeIdent = PerkManager.TypeToIdentity( type );

		if ( IsBanishMode )
		{
			BanishPerkUIChoice( type );
			Manager.Instance.PlaySfxUI( "click", pitch: Utils.Map( NumBanishAvailable, 0, 5, 0.7f, 0.6f, EasingType.QuadIn ), volume: 0.75f );
		}
		else
		{
			//if ( Input.Down( "Shift" ) )
			//{
			//	GivePerkItemUIChoice( type, FacingDir );
			//}
			//else
			//{
			AddPerkUIChoice( type );
			//SS2Game.Current.SelectedPlayer = null;
			//}

			Manager.Instance.PlaySfxUI( "click", pitch: Utils.Map( NumPerkPointsAvailable, 0, 10, 1f, 0.8f, EasingType.QuadIn ), volume: 0.75f );
		}
	}

	public bool CanReroll()
	{
		bool isFree = Stats[PlayerStat.FreeRerollTime] > 0f && RealTimeSinceLvlUp < Stats[PlayerStat.FreeRerollTime];
		bool canUseArmor = Stats[PlayerStat.ArmorRerollCost] > 0f && Armor >= (int)Stats[PlayerStat.ArmorRerollCost];
		return NumRerollAvailable > 0 || isFree || canUseArmor;
	}

	public void UseReroll( bool free = false )
	{
		if ( !IsChoosingLevelUpReward )
			return;

		bool isFree = free || (Stats[PlayerStat.FreeRerollTime] > 0f && RealTimeSinceLvlUp < Stats[PlayerStat.FreeRerollTime]);
		bool canUseArmor = Stats[PlayerStat.ArmorRerollCost] > 0f && Armor >= (int)Stats[PlayerStat.ArmorRerollCost];

		var canReroll = NumRerollAvailable > 0 || isFree || canUseArmor;

		if ( canReroll )
		{
			var pitch = isFree
				? Utils.Map( RealTimeSinceLvlUp, 0f, Stats[PlayerStat.FreeRerollTime], 1.45f, 1.9f )
				: (canUseArmor ? 0.6f : Utils.Map( NumRerollAvailable, 0, 20, 0.9f, 1.2f, EasingType.QuadIn ));

			Manager.Instance.PlaySfxUI( "reroll", pitch, volume: 1f );
		}
		else
		{
			Manager.Instance.PlaySfxUI( "error2", pitch: 0.8f, volume: 0.6f );
		}

		if ( isFree )
		{
			// costs nothing
		}
		else if ( canUseArmor )
		{
			LoseArmor( (int)Stats[PlayerStat.ArmorRerollCost], Utils.GetRandomVector() );
			HighlightPerk( TypeLibrary.GetType( typeof( PerkArmorRerollCost ) ) );
		}
		else if ( NumRerollAvailable > 0 )
		{
			NumRerollAvailable--;
		}
		else
		{
			return;
		}

		SubmitPerkStatsOnReroll();

		RefreshAvailableCurseCount();

		ForEachPerk( perk => perk.OnRerollBefore() );
		ForEachLoadoutItem( item => item.OnRerollBefore() );

		if ( Stats[PlayerStat.RerollOnlyOnePerkChoice] > 0f && CurrentPerkChoices.Count > 0 )
		{
			var randomChoice = CurrentPerkChoices[Game.Random.Int( CurrentPerkChoices.Count - 1 )];
			RerollSinglePerkChoice( randomChoice );
		}
		else
		{
			RefreshPerkChoices();
		}
		//TimeSinceOfferedChoices = 0f;

		IsBanishMode = false;
		SyncPerkChoiceState();

		AddResultsStat( ResultStat.NumTimesReroll, 1 );
		ProgressManager.IncrementStat( ProgressStat.Rerolls, 1 );

		ForEachPerk( perk => perk.OnRerollAfter() );
		ForEachLoadoutItem( item => item.OnRerollAfter() );
	}

	void ReimburseWaitingRerolls()
	{
		if ( NumRerollWaiting > 0 )
		{
			int transfer = Math.Min( NumRerollWaiting, (int)Stats[PlayerStat.NumRerollsPerLevel] );

			//if ( Manager.Instance.IsUnpausedChoosing )
			//{
			//	var dropDir = -FacingDir;

			//	for ( int i = 0; i < transfer; i++ )
			//		Manager.Instance.SpawnItemRpc( "reroll_item", Position2D, Utils.GetRandomVectorInCone( dropDir, coneDegrees: 160f ) );
			//}
			//else
			//{
				AddRerolls( transfer );
			//}

			NumRerollWaiting -= transfer;
		}
	}

	public void ToggleBanish()
	{
		if ( NumBanishAvailable <= 0 )
		{
			// todo: sfx
			return;
		}

		IsBanishMode = !IsBanishMode;
		Manager.Instance.PlaySfxUI( "reroll", pitch: Utils.Map( NumBanishAvailable, 0, 4, 0.6f, 0.7f, EasingType.QuadIn ), volume: 0.8f );
		SyncPerkChoiceState();
	}

	[Rpc.Owner]
	public void KillEnemy( Enemy enemy, DamageType damageType )
	{
		var isMiniboss = enemy.IsBoss && enemy is not Boss;

		if ( enemy.CountsAsKill )
		{
			Stats[PlayerStat.NumEnemiesKilled] += 1f;
			Sandbox.Services.Stats.Increment( Manager.GetStatString( StatType.NumKills, Manager.Instance.NumPlayersThisRun, Manager.Instance.Difficulty ), 1 );

			if(	isMiniboss )
				Sandbox.Services.Stats.Increment( Manager.GetStatString( StatType.NumMinibossKills, Manager.Instance.NumPlayersThisRun, Manager.Instance.Difficulty ), 1 );

			AddResultsStat( ResultStat.NumKills, 1 );

			if ( !isMiniboss && enemy is not Boss )
				ProgressManager.IncrementStat( ProgressStat.EnemyKills, 1 );

			if ( damageType == DamageType.Explosion )
				ProgressManager.IncrementStat( ProgressStat.ExplosionKills, 1 );

			if ( damageType == DamageType.Boomerang )
				ProgressManager.IncrementStat( ProgressStat.BoomerangKills, 1 );

			if ( damageType == DamageType.Shock )
				ProgressManager.IncrementStat( ProgressStat.ShockKills, 1 );

			switch ( enemy.EnemyType )
			{
				case EnemyType.ZombieElite:    ProgressManager.IncrementStat( ProgressStat.ZombieEliteKills, 1 ); break;
				case EnemyType.ChargerElite:   ProgressManager.IncrementStat( ProgressStat.ChargerEliteKills, 1 ); break;
				case EnemyType.SpikerElite:    ProgressManager.IncrementStat( ProgressStat.SpikerEliteKills, 1 ); break;
				case EnemyType.SpitterElite:   ProgressManager.IncrementStat( ProgressStat.SpitterEliteKills, 1 ); break;
				case EnemyType.RunnerElite:    ProgressManager.IncrementStat( ProgressStat.RunnerEliteKills, 1 ); break;
				case EnemyType.ExploderElite:  ProgressManager.IncrementStat( ProgressStat.ExploderEliteKills, 1 ); break;
				case EnemyType.ExploderMini:   ProgressManager.IncrementStat( ProgressStat.ExploderMiniKills, 1 ); break;
			}
		}

		ForEachPerk( perk => perk.OnKill( enemy, damageType, enemy.CountsAsKill ) );
		ForEachLoadoutItem( item => item.OnKill( enemy, damageType, enemy.CountsAsKill ) );

		if( isMiniboss )
		{
			AddResultsStat( ResultStat.NumMinibossKills, 1 );
			ProgressManager.IncrementStat( ProgressStat.MinibossKills, 1 );
		}
		else if( enemy is Barrel || enemy is BarrelExploding )
		{
			AddResultsStat( ResultStat.BarrelsDestroyed, 1 );
			ProgressManager.IncrementStat( ProgressStat.BarrelsDestroyed, 1 );
		}
		else if ( enemy is ChestEvil )
		{
			AddResultsStat( ResultStat.EvilChestsOpened, 1 );
			ProgressManager.IncrementStat( ProgressStat.EvilChestsOpened, 1 );
		}
		else if ( enemy is Chest )
		{
			AddResultsStat( ResultStat.ChestsOpened, 1 );
			ProgressManager.IncrementStat( ProgressStat.ChestsOpened, 1 );
		}
	}

	[Rpc.Owner]
	public void GainHealthPack( float amount )
	{
		amount += Stats[PlayerStat.HealthPackExtraHp];

		if ( Stats[PlayerStat.HealthpackXpPercent] > 0f )
			amount *= (1f - Stats[PlayerStat.HealthpackXpPercent]);

		if ( amount > 0f && Stats[PlayerStat.HealthPacksHurt] > 0f )
			amount *= -1f;

		ForEachPerk( perk => perk.OnGainHealthpack( amount ) );
		ForEachLoadoutItem( item => item.OnGainHealthpack( amount ) );

		if ( amount < 0f )
		{
			Damage( Math.Abs( amount ), DamageType.Other, Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: 0f, ragdollForce: 0.1f, enemySource: null, enemyType: EnemyType.None );
		}
		else
		{
			Heal( amount, playSfx: true, otherPlayerHealer: null );
		}

		AddResultsStat( ResultStat.HealthpacksGot, 1 );
		ProgressManager.IncrementStat( ProgressStat.HealthPacksCollected, 1 );
	}

	public void GetMagnet()
	{
		if ( GetPerk( TypeLibrary.GetType<PerkMagnetItems>() ) != null )
			Manager.Instance.MagnetizeAllItems( this );
		else
			Manager.Instance.MagnetizeAllCoins( this );

		AddResultsStat( ResultStat.MagnetsGot, 1 );
		ProgressManager.IncrementStat( ProgressStat.MagnetsCollected, 1 );
	}

	[Rpc.Owner]
	public void OnDisturbMushroom()
	{
		ProgressManager.IncrementStat( ProgressStat.MushroomsDisturbed, 1 );
	}

	[Rpc.Owner]
	public void HealRpc( float amount, bool playSfx = false, Player otherPlayerHealer = null )
	{
		Heal( amount, playSfx, otherPlayerHealer );
	}

	public void Heal( float amount, bool playSfx = false, Player otherPlayerHealer = null )
	{
		Assert.True( !IsProxy );

		if ( amount == 0f )
			return;

		float maxHp = Stats[PlayerStat.MaxHp];
		float hpMissing = maxHp - Health;

		if ( amount > 0f )
		{
			if ( hpMissing <= 0f )
			{
				//Manager.Instance.SpawnFloaterNumberRpc( WorldPosition.WithZ( 65f ), 0, new Color( 0.3f, 1f, 0.3f ), 1.5f, FloaterType.Heal );
				return;
			}

			amount *= GetHealEffectiveness();

			_regenHpAccumulated += amount;
			//if ( _regenHpAccumulated < 0.85f && otherPlayerHealer == null && hpMissing > _regenHpAccumulated )
			if ( _regenHpAccumulated < 1f && otherPlayerHealer == null && hpMissing > _regenHpAccumulated )
				return;

			float hpToRecover = Math.Min( _regenHpAccumulated, hpMissing );
			_regenHpAccumulated = hpToRecover; // discard overheal amount

			//float hpRecovered = MathF.Floor( hpToRecover ); // only recover whole numbers
			//_regenHpAccumulated -= hpRecovered; // leftover amount from rounding

			_regenHpAccumulated -= hpToRecover;

			Health += hpToRecover;
			HealVfx( hpToRecover, playSfx );
			ForEachPerk( perk => perk.OnHeal( hpToRecover ) );
			ForEachLoadoutItem( item => item.OnHeal( hpToRecover ) );

			if ( otherPlayerHealer.IsValid() && otherPlayerHealer != this )
				otherPlayerHealer.HealOtherPlayer( hpToRecover, this );

			Stats[PlayerStat.TotalHealed] += hpToRecover;
			AddResultsStat( ResultStat.HpHealed, hpToRecover );
			ProgressManager.IncrementStat( ProgressStat.HpHealed, hpToRecover );
		}
		else
		{
			_regenHpAccumulated += amount;
			if ( _regenHpAccumulated < -1f )
			{
				var dmgAmount = MathF.Truncate( _regenHpAccumulated );

				if ( Stats[PlayerStat.DrainUntil1Hp] > 0f && Health > 1f && (Math.Abs( dmgAmount ) > Health - 1f) )
				{
					dmgAmount = -(Health - 1f);
					_regenHpAccumulated = 0f;
				}
				else
				{
					_regenHpAccumulated -= dmgAmount;
				}

				//Log.Info( $"dmgAmount: {dmgAmount} Health: {Health}" );

				Damage( MathF.Abs( dmgAmount ), DamageType.Self, Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: 0f, ragdollForce: 0.1f, enemySource: null, enemyType: EnemyType.None );
			}
		}
	}

	[Rpc.Broadcast]
	public void HealVfx( float amount, bool playSfx = false )
	{
		float size = Utils.Map( amount, 1, 30, 1.5f, 3f, EasingType.QuadOut );
		Manager.Instance.SpawnFloaterNumber( WorldPosition.WithZ( 65f ), amount, new Color( 0.3f, 1f, 0.3f ), size, FloaterType.Heal );

		Flash( 0.09f, UnitFlashType.Heal );

		if ( playSfx )
			Manager.Instance.PlaySfxNearby( "heal", Position2D, pitch: Utils.Map( Health / GetSyncStat( PlayerStat.MaxHp ), 0f, 1f, 1.5f, 1f ), volume: 1.1f, maxDist: 240f );
	}

	[Rpc.Owner]
	public void HealOtherPlayer( float amount, Player healedPlayer )
	{
		ForEachPerk( perk => perk.OnHealOther( amount, healedPlayer ) );
		ForEachLoadoutItem( item => item.OnHealOther( amount, healedPlayer ) );

		AddResultsStat( ResultStat.HpHealedOther, amount );
		ProgressManager.IncrementStat( ProgressStat.HpHealedOthers, amount );
	}

	protected override void Jump( Vector2 targetPos, float height, float lifetime )
	{
		base.Jump( targetPos, height, lifetime );

		AnimationHelper.IsGrounded = false;

		if ( IsProxy )
			return;
	}

	public override void CancelJump()
	{
		base.CancelJump();

		AnimationHelper.IsGrounded = true;
	}

	public override void JumpFinish()
	{
		base.JumpFinish();

		AnimationHelper.IsGrounded = true;

		// todo: sfx (b/c charger miniboss might fling you, and PerkDashToJump "slam" won't play

		if ( IsProxy )
			return;

		ForEachPerk( perk => perk.OnLand() );
		ForEachLoadoutItem( item => item.OnLand() );
	}

	protected override void OnOutOfBounds( Direction direction )
	{
		base.OnOutOfBounds( direction );

		if ( direction == Direction.Left )
			DashVelocity = new Vector2( Math.Abs( DashVelocity.x ), DashVelocity.y );
		else if ( direction == Direction.Right )
			DashVelocity = new Vector2( -Math.Abs( DashVelocity.x ), DashVelocity.y );
		else if ( direction == Direction.Down )
			DashVelocity = new Vector2( DashVelocity.x, Math.Abs( DashVelocity.y ) );
		else if ( direction == Direction.Up )
			DashVelocity = new Vector2( DashVelocity.x, -Math.Abs( DashVelocity.y ) );
	}

	[Rpc.Broadcast]
	public void AddRerolls( int amount )
	{
		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 70f ), amount > 1 ? $"+{amount} REROLLS" : "+REROLL", new Color( 0f, 0.75f, 0.5f ), size: 1.45f, floaterType: FloaterType.PositiveMessage );
		// todo: sfx

		if ( IsProxy )
			return;

		NumRerollAvailable += amount;
		ForEachPerk( perk => perk.OnGainReroll( amount ) );
		ForEachLoadoutItem( item => item.OnGainReroll( amount ) );
	}

	[Rpc.Broadcast]
	public void AddBanish()
	{
		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 70f ), "+BANISH", new Color( 0.5f, 0.25f, 0.5f ), size: 1.45f, floaterType: FloaterType.PositiveMessage );
		// todo: sfx

		if ( IsProxy )
			return;

		NumBanishAvailable++;
		HasGottenBanish = true;
	}

	[Rpc.Broadcast]
	public void SpawnBombRpc( Vector2 pos, Vector2 velocity, float damage = 40f )
	{
		// todo: sfx

		if ( IsProxy )
			return;

		SpawnBomb( pos, velocity, damage );
	}

	public void SpawnBomb( Vector2 pos, Vector2 velocity, float damage = 40f )
	{
		Assert.True( !IsProxy );

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

		var bomb = bombGo.GetComponent<Bomb>();
		bomb.Velocity = velocity;
		bomb.StickyStrength = Stats[PlayerStat.BombStickyStrength];

		bomb.Player = this;
		bomb.Damage = damage;

		bombGo.NetworkSpawn( Network.Owner );
	}

	[Rpc.Broadcast]
	public void CantShootStart()
	{
		AnimationHelper.HoldType = CitizenAnimationHelper.HoldTypes.None;
	}

	[Rpc.Broadcast]
	public void CantShootEnd()
	{
		ResetHoldType();
	}

	void ResetHoldType()
	{
		AnimationHelper.HoldType = GetSyncStat( PlayerStat.PunchBullets ) > 0f
			? Game.Random.Int( 0, 1 ) == 0 ? CitizenAnimationHelper.HoldTypes.Rifle : CitizenAnimationHelper.HoldTypes.RPG
			: CitizenAnimationHelper.HoldTypes.Shotgun;
	}

	public void RestartAllBullets( Vector2 dir )
	{
		Assert.True( !IsProxy );

		var bullets = Scene.GetAll<Bullet>();
		foreach ( var bullet in bullets )
		{
			if ( bullet.IsProxy || bullet.Shooter != this )
				continue;

			bullet.SetDirection( dir );
			bullet.Restart();
		}
	}

	[Rpc.Broadcast]
	public void SetScale( float scale )
	{
		if ( Collider == null )
			Collider = GetComponent<Collider>();

		var capsuleCollider = Collider as CapsuleCollider;

		capsuleCollider.Radius = _capsuleColliderStartRadius * scale;
		capsuleCollider.End = new Vector3( 0f, 0f, _capsuleColliderStartEndZ * scale );

		Model.LocalScale = new Vector3( scale );

		Height = (capsuleCollider.End.z - capsuleCollider.Start.z) * SpawnScale.z;

		if ( IsProxy )
			return;

		Radius = capsuleCollider.Radius * SpawnScale.x;
	}

	[Rpc.Broadcast]
	public void SetWeight( float weight )
	{
		Weight = weight;
	}

	[Rpc.Broadcast]
	public void EnableCombustion( float radius, float damageFactor )
	{
		CombustionActive = true;
		CombustionRadius = radius;
		CombustionDamageFactor = damageFactor;
	}

	[Rpc.Broadcast]
	public void DisableCombustion()
	{
		CombustionActive = false;
	}

	[Rpc.Owner]
	public void IgniteEnemy( Enemy enemy )
	{
		ForEachPerk( perk => perk.OnIgnite( enemy ) );
		ForEachLoadoutItem( item => item.OnIgnite( enemy ) );
	}

	[Rpc.Owner]
	public void FreezeEnemy( Enemy enemy )
	{
		AddResultsStat( ResultStat.EnemiesFrozen, 1 );
		ProgressManager.IncrementStat( ProgressStat.EnemiesFrozen, 1 );

		ForEachPerk( perk => perk.OnFreeze( enemy ) );
		ForEachLoadoutItem( item => item.OnFreeze( enemy ) );
	}

	[Rpc.Owner]
	public void FearEnemy( Enemy enemy )
	{
		ProgressManager.IncrementStat( ProgressStat.EnemiesScared, 1 );

		ForEachPerk( perk => perk.OnFear( enemy ) );
		ForEachLoadoutItem( item => item.OnFear( enemy ) );
	}

	[Rpc.Owner]
	public void PoisonEnemy( Enemy enemy )
	{
		ForEachPerk( perk => perk.OnPoison( enemy ) );
		ForEachLoadoutItem( item => item.OnPoison( enemy ) );
	}

	public override void SetTimeScale( float timeScale )
	{
		base.SetTimeScale( timeScale );

		AnimationHelper.Target.PlaybackRate = timeScale;
	}

	public override void OnGainShield()
	{
		base.OnGainShield();

		ForEachPerk( perk => perk.OnGainShield() );
		ForEachLoadoutItem( item => item.OnGainShield() );
	}

	float GetHealthbarWidth()
	{
		var maxHp = GetSyncStat( PlayerStat.MaxHp );
		return maxHp < 100f
			? Utils.Map( maxHp, 100f, 30f, 650f, 400f, EasingType.QuadOut )
			: Utils.Map( maxHp, 100f, 230f, 650f, 1300f, EasingType.QuadOut );
	}

	public override void OnStun()
	{
		base.OnStun();

		AnimationHelper.Target.PlaybackRate = 0f;
	}

	public override void OnStunFinish()
	{
		base.OnStunFinish();

		// todo: what if frozen?
		AnimationHelper.Target.PlaybackRate = 1f;
	}

	protected override void UpdateShaking( float strength )
	{
		Model.LocalPosition = Rotation.Random.Forward * strength;
	}

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

		Model.LocalPosition = Vector3.Zero;
	}

	public void LevelUpDebug()
	{
		AddXpRpc( ExperienceRequired, XpSource.Passive );
	}

	[Rpc.Owner]
	public void AddLocalChatMessageRpc( string message, string from, int startPerkIdent = -1, List<int> endPerkIdents = null )
	{
		var startToken = "";
		if ( startPerkIdent != -1 )
		{
			var startType = PerkManager.IdentityToType( startPerkIdent );
			if ( startType != null ) startToken = $"{Perk.GetRichTextToken( startType )} ";
		}

		var endTokens = "";
		if ( endPerkIdents != null )
			foreach ( var ident in endPerkIdents )
			{
				var type = PerkManager.IdentityToType( ident );
				if ( type != null ) endTokens += $" {Perk.GetRichTextToken( type )}";
			}

		Manager.Instance.Chat.AddLocalChatMessage( $"{startToken}{message}{endTokens}", from );
	}

	public override void OnChain()
	{
		base.OnChain();

		Assert.True( !IsProxy );

		AddResultsStat( ResultStat.TimesChained, 1 );

		Manager.Instance.PlaySfxNearbyRpc( "chain_hit_2", Position2D, pitch: Game.Random.Float( 1.5f, 1.55f ), volume: 1.8f, maxDist: 350f );

		ShakeCam( strength: 5f, time: 0.4f, EasingType.QuadOut );
	}

	protected override void ArmorFlinch( int amount, Vector2 flinchDir )
	{
		Flinch( 0f, flinchDir );

		ScaleHeight( 1.5f, time: Utils.Map( amount, 3f, 20f, 0.05f, 0.15f ) );
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void SetGunVisible( bool visible )
	{
		Gun?.Enabled = visible;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void GiveItemRpc( string name, Vector2 dir )
	{
		DodgeDuck( dir, time: Game.Random.Float( 0.04f, 0.06f ) );

		if ( IsProxy )
			return;

		Manager.Instance.SpawnItemRpc( name, Position2D + dir * Game.Random.Float( 1f, 2f ), dir );
	}

	[Rpc.Broadcast]
	public void SetHoldType( CitizenAnimationHelper.HoldTypes holdType )
	{
		AnimationHelper.HoldType = holdType;
	}

	private TimeSince _timeSinceTouchLeftSide;
	private float _lastXPos;
	private const float SPRINTER_TIME_LIMIT = 7f;

	private TimeSince _timeSinceMoveInput;
	private bool _hasUnlockedNoMoveAchievement;

	public bool HasLostHp { get; private set; }

	public int NumItemsCollected { get; set;  }

	void RestartAchievements()
	{
		_timeSinceMoveInput = 0f;
		_hasUnlockedNoMoveAchievement = false;
		HasLostHp = false;
		NumItemsCollected = 0;
		_stillTimeAccumulator = 0f;
		_bossTimeAccumulator = 0f;
	}

	void HandleAchievements()
	{
		Assert.True( !IsProxy );

		// sprinter
		var amount = 1.05f;
		var minX = Manager.Instance.BOUNDS_MIN.x - BoundsExpand + Radius * amount;
		var maxX = Manager.Instance.BOUNDS_MAX.x + BoundsExpand - Radius * amount;

		if ( _lastXPos < minX && Position2D.x >= minX )
		{
			_timeSinceTouchLeftSide = 0f;
			//Log.Info( $"Touched left side: {_timeSinceTouchLeftSide}" );
		}

		if ( _lastXPos < maxX && Position2D.x >= maxX )
		{
			//Log.Info( $"_timeSinceTouchLeftSide: {_timeSinceTouchLeftSide}" );

			if ( _timeSinceTouchLeftSide < SPRINTER_TIME_LIMIT )
			{
				Manager.Instance.UnlockAchievement( "sprinter" );
			}
		}

		_lastXPos = Position2D.x;

		// no moving
		if( !_hasUnlockedNoMoveAchievement )
		{
			if ( Input.AnalogMove.LengthSquared > 0.01f )
				_timeSinceMoveInput = 0f;

			if ( _timeSinceMoveInput > 10f * 60f )
			{
				_hasUnlockedNoMoveAchievement = true;
				Manager.Instance.UnlockAchievement( "stand_your_ground" );
			}
		}
	}
}