things/Player.cs
using SpriteTools;
using static Manager;

public enum ModifierType { Set, Add, Mult }
public class ModifierData
{
	public float value;
	public ModifierType type;
	public float priority;

	public ModifierData( float _value, ModifierType _type, float _priority = 0f )
	{
		value = _value;
		type = _type;
		priority = _priority;
	}
}

public enum PlayerStat
{
	AttackTime, AttackSpeed, ReloadTime, ReloadSpeed, MaxAmmoCount, BulletDamage, BulletForce, Recoil, MoveSpeed, NumProjectiles, BulletSpread, BulletInaccuracy, BulletSpeed, BulletLifetime,
	BulletNumPiercing, CritChance, CritMultiplier, LowHealthDamageMultiplier, NumUpgradeChoices, HealthRegen, HealthRegenStill, DamageReductionPercent, PushStrength, CoinAttractRange, CoinAttractStrength, Luck, MaxHp,
	NumDashes, DashInvulnTime, DashCooldown, DashProgress, DashStrength, ThornsPercent, ShootFireIgniteChance, FireDamage, FireLifetime, FireSpreadChance, ShootFreezeChance, FreezeLifetime,
	FreezeTimeScale, FreezeOnMeleeChance, FreezeFireDamageMultiplier, LastAmmoDamageMultiplier, FearLifetime, FearDamageMultiplier, FearOnMeleeChance, BulletDamageGrow, BulletDamageShrink,
	BulletDistanceDamage, NumRerollsPerLevel, FullHealthDamageMultiplier, DamagePerEarlierShot, DamageForSpeed, OverallDamageMultiplier, ExplosionSizeMultiplier, GrenadeVelocity, ExplosionDamageMultiplier,
	BulletDamageMultiplier, ExplosionDamageReductionPercent, NonExplosionDamageIncreasePercent, GrenadeStickyPercent, GrenadeFearChance, FearDrainPercent, FearPainPercent, CrateChanceAdditional,
	AttackSpeedStill, FearDropGrenadeChance, FrozenShardsNum, NoDashInvuln, BulletFlatDamageAddition, GrenadesCanCrit, BulletHealTeammateAmount, HomingBulletChance, PauseWhileChoosing,
	BulletNumBouncing, DashCharm, CharmedEnemyDmgTakenMultiplier, CharmedEnemyDmgDealtMultiplier, BossArrivalTime, RadiusMultiplier, SpecialistStatusAmount, ZoomAmount, MaxFireStacks, XpRepel, HealthPackAmount,
	MaxBulletSpread, ShootRandomDirChance, DashRandomDirChance, MoveSelfDmg, MoveSelfDmgReqDist, MoveSelfDmgAmount, SelfDmgDistanceMoved, IncreasedDmgTaken, ReverseControls, SelfCritChance,
}

public enum DamageType { Melee, Ranged, Explosion, Fire, PlayerBullet, Self, Generic, LavaPuddle, FearPain, }
public enum PlayerDamageType { Enemy, Self, Grenade }

public struct CamShakeData
{
	public float strength;
	public float startTime;
	public float time;
	public EasingType easingType;
	public bool useRealTime;

	public CamShakeData( float _strength, float _startTime, float _time, EasingType _easingType, bool _useRealTime )
	{
		strength = _strength;
		startTime = _startTime;
		time = _time;
		easingType = _easingType;
		useRealTime = _useRealTime;
	}
}

public class Player : Thing
{
	[Property] public GameObject Body { get; set; }
	[Property] public GameObject ArrowAimerPrefab { get; set; }
	[Property] public GameObject BulletPrefab { get; set; }
	[Property] public Sprite EasySprite { get; set; }
	[Property] public Sprite Difficulty1Sprite { get; set; }
	[Property] public Sprite Difficulty2Sprite { get; set; }
	[Property] public Sprite Difficulty3Sprite { get; set; }
	[Property] public Sprite Difficulty4Sprite { get; set; }
	[Property] public Sprite Difficulty5Sprite { get; set; }
	[Property] public Sprite Difficulty6Sprite { get; set; }
	[Property] public Sprite Difficulty7Sprite { get; set; }
	[Property] public Sprite Difficulty8Sprite { get; set; }
	[Property] public Sprite Difficulty9Sprite { get; set; }
	[Property] public Sprite Difficulty10Sprite { get; set; }
	[Property] public Sprite Difficulty11Sprite { get; set; }
	[Property] public Sprite Difficulty12Sprite { get; set; }
	[Property] public Sprite Difficulty13Sprite { get; set; }
	[Property] public Sprite Difficulty14Sprite { get; set; }
	[Property] public Sprite Difficulty15Sprite { get; set; }


	[Sync] public float Health { get; set; }
	private float _regenHpAccumulated;
	private float _drainHpAccumulated;

	[Sync] public Vector2 InputVector { get; set; }

	public GameObject ArrowAimer { get; private set; }
	public SpriteRendererLayer ArrowSprite { get; private set; }
	public Vector2 AimDir { get; private set; }

	[Sync] public bool IsDead { get; private set; }
	public float Timer { get; protected set; }
	[Sync] public bool IsReloading { get; protected set; }
	[Sync] public float ReloadProgress { get; protected set; }

	public const float BASE_MOVE_SPEED = 15f;
	private int _shotNum;

	[Sync] public int Level { get; protected set; }
	public int ExperienceTotal { get; protected set; }
	public int ExperienceCurrent { get; protected set; }
	public int ExperienceRequired { get; protected set; }
	public bool IsChoosingLevelUpReward { get; protected set; }
	public TimeSince TimeSinceLevelUp { get; set; }
	public RealTimeSince RealTimeSinceChoseUpgrade { get; set; }
	public List<Status> LevelUpChoices { get; private set; }

	[Sync] public float DashTimer { get; private set; }
	[Sync] public bool IsDashing { get; private set; }
	[Sync] public Vector2 DashVelocity { get; private set; }
	[Sync] public float DashInvulnTimer { get; private set; }
	private TimeSince _dashCloudTime;
	public float DashProgress { get; protected set; }
	[Sync] public float DashRechargeProgress { get; protected set; }
	[Sync] public int NumDashesAvailable { get; set; }
	public int AmmoCount { get; protected set; }

	public bool IsMoving => Velocity.LengthSquared > 0.05f && !IsDashing;
	//public bool IsMoving => (Position2D - _lastPos2D).LengthSquared > 0.000001f;
	public bool IsInputtingMove => Input.AnalogMove.LengthSquared > 0.01f;
	public bool IsInvulnerable => IsDashing && Stats[PlayerStat.NoDashInvuln] <= 0f;
	public bool IsTimePausedForChoosing => IsChoosingLevelUpReward && Stats[PlayerStat.PauseWhileChoosing] > 0f;


	private float _flashTimer;
	private bool _isFlashing;
	public TimeSince TimeSinceHurt { get; private set; }
	public TimeSince TimeSinceChangeHP { get; set; }

	private GameObject _shieldVfx;

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

	[Sync] public NetDictionary<PlayerStat, float> Stats { get; private set; } = new();

	public Dictionary<int, Status> Statuses { get; private set; }

	private Dictionary<Status, Dictionary<PlayerStat, ModifierData>> _modifiers_stat = new Dictionary<Status, Dictionary<PlayerStat, ModifierData>>();
	private Dictionary<PlayerStat, float> _original_properties_stat = new Dictionary<PlayerStat, float>();

	private bool _doneFirstUpdate;
	private TimeSince _timeSinceShoot;
	private TimeSince _timeSinceSpawn;

	private Vector2 _lastPos2D;
	private TimeSince _timeSinceTouchLeftSide;
	private bool _hasUnlockedSprinterAchievement;

	private bool _hasUnlockedExperiencedAchievement;

	public TimeSince TimeSinceInputMove { get; set; }
	private bool _hasUnlockedNoMoveAchievement;

	private List<CamShakeData> _camShakeDatas = new();
	public float CamShakeAmount { get; set; }

	public int ChoiceHash { get; set; }

	public TimeSince TimeSinceHurtLava { get; set; }

	public RealTimeSince RealTimeSinceDeath { get; set; }
	private float _arrowDeathAlphaStart;
	private bool _hasPlayedDeathSfx;

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

		Scale = 1f;

		ShadowOpacity = 0.8f;
		ShadowScale = 1.12f;

		Statuses = new Dictionary<int, Status>();
		LevelUpChoices = new List<Status>();
		InitializeStats();

		if ( IsProxy )
			return;

		CollideWith.Add( typeof( Enemy ) );
		CollideWith.Add( typeof( Player ) );

		ArrowAimer = ArrowAimerPrefab.Clone( WorldPosition );
		ArrowAimer.SetParent( GameObject );
		ArrowAimer.NetworkMode = NetworkMode.Never;
		ArrowSprite = ArrowAimer.Components.Get<SpriteRendererLayer>();
		ArrowSprite.Tint = Color.White.WithAlpha( 0f );

		_timeSinceShoot = 999f;
		_timeSinceSpawn = 0f;

		if ( Manager.Instance.Difficulty < 0 ) Sprite.Sprite = EasySprite;
		else if ( Manager.Instance.Difficulty == 1 ) Sprite.Sprite = Difficulty1Sprite;
		else if ( Manager.Instance.Difficulty == 2 ) Sprite.Sprite = Difficulty2Sprite;
		else if ( Manager.Instance.Difficulty == 3 ) Sprite.Sprite = Difficulty3Sprite;
		else if ( Manager.Instance.Difficulty == 4 ) Sprite.Sprite = Difficulty4Sprite;
		else if ( Manager.Instance.Difficulty == 5 ) Sprite.Sprite = Difficulty5Sprite;
		else if ( Manager.Instance.Difficulty == 6 ) Sprite.Sprite = Difficulty6Sprite;
		else if ( Manager.Instance.Difficulty == 7 ) Sprite.Sprite = Difficulty7Sprite;
		else if ( Manager.Instance.Difficulty == 8 ) Sprite.Sprite = Difficulty8Sprite;
		else if ( Manager.Instance.Difficulty == 9 ) Sprite.Sprite = Difficulty9Sprite;
		else if ( Manager.Instance.Difficulty == 10 ) Sprite.Sprite = Difficulty10Sprite;
		else if ( Manager.Instance.Difficulty == 11 ) Sprite.Sprite = Difficulty11Sprite;
		else if ( Manager.Instance.Difficulty == 12 ) Sprite.Sprite = Difficulty12Sprite;
		else if ( Manager.Instance.Difficulty == 13 ) Sprite.Sprite = Difficulty13Sprite;
		else if ( Manager.Instance.Difficulty == 14 ) Sprite.Sprite = Difficulty14Sprite;
		else if ( Manager.Instance.Difficulty == 15 ) Sprite.Sprite = Difficulty15Sprite;

		//Sprite.LocalScale = 0.5f * Globals.SPRITE_SCALE;
	}

	public void InitializeStats()
	{
		_original_properties_stat.Clear();

		if ( Network.Active )
		{
			RemoveShieldVfx();
		}
		else
		{
			if ( _shieldVfx != null )
			{
				_shieldVfx.Destroy();
				_shieldVfx = null;
			}
		}

		Level = 0;
		ExperienceRequired = GetExperienceReqForLevel( Level + 1 );
		ExperienceTotal = 0;
		ExperienceCurrent = 0;
		Stats[PlayerStat.AttackTime] = 0.15f;
		AmmoCount = 5;
		Stats[PlayerStat.MaxAmmoCount] = AmmoCount;
		Stats[PlayerStat.ReloadTime] = 1.5f;
		Stats[PlayerStat.ReloadSpeed] = 1f;
		Stats[PlayerStat.AttackSpeed] = 1f;
		Stats[PlayerStat.BulletDamage] = 5f;
		Stats[PlayerStat.BulletForce] = 0.55f;
		Stats[PlayerStat.Recoil] = 0f;
		Stats[PlayerStat.MoveSpeed] = 1f;
		Stats[PlayerStat.NumProjectiles] = 1f;
		Stats[PlayerStat.BulletSpread] = 35f;
		Stats[PlayerStat.BulletInaccuracy] = 5f;
		Stats[PlayerStat.BulletSpeed] = 4.5f;
		Stats[PlayerStat.BulletLifetime] = 0.8f;
		Stats[PlayerStat.Luck] = 1f;
		Stats[PlayerStat.CritChance] = 0.05f;
		Stats[PlayerStat.CritMultiplier] = 1.5f;
		Stats[PlayerStat.LowHealthDamageMultiplier] = 1f;
		Stats[PlayerStat.FullHealthDamageMultiplier] = 1f;
		Stats[PlayerStat.ThornsPercent] = 0f;

		Stats[PlayerStat.NumDashes] = 1f;
		NumDashesAvailable = (int)MathF.Round( Stats[PlayerStat.NumDashes] );
		Stats[PlayerStat.DashCooldown] = 3f;
		Stats[PlayerStat.DashInvulnTime] = 0.25f;
		Stats[PlayerStat.DashStrength] = 3f;
		Stats[PlayerStat.BulletNumPiercing] = 0f;
		Stats[PlayerStat.BulletNumBouncing] = 0f;
		Stats[PlayerStat.DashCharm] = 0f;
		Stats[PlayerStat.CharmedEnemyDmgTakenMultiplier] = 1f;
		Stats[PlayerStat.CharmedEnemyDmgDealtMultiplier] = 1f;
		Stats[PlayerStat.BossArrivalTime] = 15 * 60f;
		//Stats[PlayerStat.BossArrivalTime] = (Manager.Instance.Difficulty >= 5 ? 10 : 15) * 60f;
		Stats[PlayerStat.RadiusMultiplier] = 1f;
		Stats[PlayerStat.SpecialistStatusAmount] = 0f;
		Stats[PlayerStat.ZoomAmount] = 0f;
		Stats[PlayerStat.MaxFireStacks] = 0f;
		Stats[PlayerStat.XpRepel] = 0f;
		Stats[PlayerStat.HealthPackAmount] = 20f;
		Stats[PlayerStat.MaxBulletSpread] = 0f;
		Stats[PlayerStat.ShootRandomDirChance] = 0f;
		Stats[PlayerStat.DashRandomDirChance] = 0f;
		Stats[PlayerStat.MoveSelfDmg] = 0f;
		Stats[PlayerStat.MoveSelfDmgReqDist] = 0f;
		Stats[PlayerStat.MoveSelfDmgAmount] = 0f;
		Stats[PlayerStat.SelfDmgDistanceMoved] = 0f;
		Stats[PlayerStat.IncreasedDmgTaken] = 0f;
		Stats[PlayerStat.ReverseControls] = 0f;
		Stats[PlayerStat.SelfCritChance] = 0f;

		Health = 100f;
		Stats[PlayerStat.MaxHp] = 100f;
		//Health = 1f;
		_regenHpAccumulated = 0f;
		_drainHpAccumulated = 0f;

		IsDead = false;
		Radius = 0.10f;
		GridPos = Manager.Instance.GetGridSquareForPos( Position2D );
		AimDir = new Vector2( 0f, 1f );
		NumRerollAvailable = Manager.Instance.Difficulty < 0 ? 4 : (Manager.Instance.Difficulty >= 3 ? 3 : 2);

		Stats[PlayerStat.FireDamage] = 1f;
		Stats[PlayerStat.FireLifetime] = 3f;
		Stats[PlayerStat.ShootFireIgniteChance] = 0f;
		Stats[PlayerStat.FireSpreadChance] = 0f;
		Stats[PlayerStat.ShootFreezeChance] = 0f;
		Stats[PlayerStat.FreezeLifetime] = 4f;
		Stats[PlayerStat.FreezeTimeScale] = 0.55f;
		Stats[PlayerStat.FreezeOnMeleeChance] = 0f;
		Stats[PlayerStat.FreezeFireDamageMultiplier] = 1f;
		Stats[PlayerStat.FearLifetime] = 4.5f;
		Stats[PlayerStat.FearDamageMultiplier] = 1f;
		Stats[PlayerStat.FearOnMeleeChance] = 0f;

		Stats[PlayerStat.CoinAttractRange] = 1.7f;
		Stats[PlayerStat.CoinAttractStrength] = 3.5f;

		Stats[PlayerStat.NumUpgradeChoices] = 3f;
		Stats[PlayerStat.HealthRegen] = Manager.Instance.Difficulty == -1 ? 0.4f : 0f;
		Stats[PlayerStat.HealthRegenStill] = 0f;
		//Stats[PlayerStat.HealthDrain] = 0f;
		Stats[PlayerStat.DamageReductionPercent] = 0f;
		Stats[PlayerStat.PushStrength] = 30f;
		Stats[PlayerStat.LastAmmoDamageMultiplier] = 1f;
		Stats[PlayerStat.BulletDamageGrow] = 0f;
		Stats[PlayerStat.BulletDamageShrink] = 0f;
		Stats[PlayerStat.BulletDistanceDamage] = 0f;
		Stats[PlayerStat.NumRerollsPerLevel] = 1f;
		Stats[PlayerStat.DamagePerEarlierShot] = 0f;
		Stats[PlayerStat.DamageForSpeed] = 0f;
		Stats[PlayerStat.OverallDamageMultiplier] = 1f;
		Stats[PlayerStat.ExplosionSizeMultiplier] = 1f;
		Stats[PlayerStat.GrenadeVelocity] = 8f;
		Stats[PlayerStat.ExplosionDamageMultiplier] = 1f;
		Stats[PlayerStat.BulletDamageMultiplier] = 1f;
		Stats[PlayerStat.ExplosionDamageReductionPercent] = 0f;
		Stats[PlayerStat.NonExplosionDamageIncreasePercent] = 0f;
		Stats[PlayerStat.GrenadeStickyPercent] = 0f;
		Stats[PlayerStat.GrenadeFearChance] = 0f;
		Stats[PlayerStat.FearDrainPercent] = 0f;
		Stats[PlayerStat.FearPainPercent] = 0f;
		Stats[PlayerStat.CrateChanceAdditional] = 0f;
		Stats[PlayerStat.AttackSpeedStill] = 1f;
		Stats[PlayerStat.FearDropGrenadeChance] = 0f;
		Stats[PlayerStat.FrozenShardsNum] = 0f;
		Stats[PlayerStat.NoDashInvuln] = 0f;
		Stats[PlayerStat.BulletFlatDamageAddition] = 0f;
		Stats[PlayerStat.GrenadesCanCrit] = 0f;
		Stats[PlayerStat.BulletHealTeammateAmount] = 0f;
		Stats[PlayerStat.HomingBulletChance] = 0f;
		Stats[PlayerStat.PauseWhileChoosing] = 0f;

		Statuses.Clear();
		_modifiers_stat.Clear();

		_isFlashing = false;
		Sprite.FlashTint = Color.White.WithAlpha( 0f );
		Sprite.Tint = Color.White;
		IsChoosingLevelUpReward = false;
		IsDashing = false;
		IsReloading = true;
		Timer = Stats[PlayerStat.ReloadTime];
		ReloadProgress = 0f;
		DashProgress = 0f;
		DashRechargeProgress = 1f;
		TempWeight = 0f;
		_shotNum = 0;
		TimeSinceHurt = 999f;
		TimeSinceChangeHP = 999f;
		TimeSinceHurtLava = 999f;
		ShadowOpacity = 0.8f;
		ShadowSpriteDirty = true;

		_timeSinceTouchLeftSide = 999f;
		TimeSinceInputMove = 0f;

		TimeSinceLevelUp = 999f;
		RealTimeSinceChoseUpgrade = 999f;

		_camShakeDatas.Clear();

		ChoiceHash = 0;
	}

	public Vector2 AverageVelocity { get; private set; }

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

		AverageVelocity += Velocity * Time.Delta * 8f;
		//AverageVelocity = Utils.DynamicEaseTo( AverageVelocity, Vector2.Zero, 0.02f, Time.Delta );
		AverageVelocity *= (1f - Time.Delta * 2.7f);

		//Gizmo.Draw.Color = Color.Red.WithAlpha( 0.5f );
		//Gizmo.Draw.Line( Position2D, Position2D + AverageVelocity );

		//Gizmo.Draw.Color = Color.Red.WithAlpha( 0.5f );
		//Gizmo.Draw.Line( Position2D, Position2D + AverageVelocity );

		Vector2 anchor = Position2D + AverageVelocity * 0.1f;
		Vector2 perp = Utils.GetPerpendicularVector( AverageVelocity ).Normal;
		var perpA = anchor - perp * 10f;
		var perpB = anchor + perp * 10f;

		//Gizmo.Draw.Color = Color.Green.WithAlpha( 0.3f );
		//Gizmo.Draw.Line( perpA, perpB );

		//Gizmo.Draw.Color = Color.White.WithAlpha( 0.03f );
		//Gizmo.Draw.LineSphere( (Vector3)Position2D, 2, 16 );

		//Gizmo.Draw.Color = Color.White.WithAlpha( 0.5f );
		//Gizmo.Draw.Text( $"{Stats[PlayerStat.FearPainPercent]}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.4f, 0f ) ) );

		if ( !_doneFirstUpdate )
		{
			SpawnShadow( ShadowScale, ShadowOpacity );
			Manager.Instance.Camera2D.SetPos( Position2D );

			_doneFirstUpdate = true;
		}

		InputVector = new Vector2( -Input.AnalogMove.y, Input.AnalogMove.x );

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

		//if ( Input.Pressed( "Menu" ) )
		//{
		//	Manager.Instance.Restart();
		//	return;
		//}

		HandleCamShaking();

		if ( IsDead )
		{
			Sprite.FlashTint = RealTimeSinceDeath < 0.1f
				? Color.Red
				: Color.White.WithAlpha( 0f );

			ShadowOpacity = Utils.Map( RealTimeSinceDeath, 0f, Manager.FINAL_PANEL_WAIT_TIME, 0.8f, 0f, EasingType.Linear );
			ShadowSprite.Tint = Color.Black.WithAlpha( ShadowOpacity );

			ArrowSprite.Tint = Color.White.WithAlpha( Utils.Map( RealTimeSinceDeath, 0f, 0.4f, _arrowDeathAlphaStart, 0f, EasingType.SineOut ) );

			if ( !_hasPlayedDeathSfx && RealTimeSinceDeath > 0.7f )
			{
				Manager.Instance.PlaySfxNearby( "zombie.attack.player", Position2D, pitch: Game.Random.Float( 0.55f, 0.65f ), volume: 1f, maxDist: 5.5f );
				_hasPlayedDeathSfx = true;
			}
		}

		if ( !Manager.Instance.ShouldUpdatePlayer )
			return;

		float dt = Time.Delta;

		if ( MathF.Abs( Velocity.x ) > 0.01f )
			Sprite.SpriteFlags = Velocity.x > 0f ? SpriteFlags.HorizontalFlip : SpriteFlags.None;

		bool hurting = TimeSinceHurt < 0.25f;
		bool attacking = !IsReloading;
		bool moving = Velocity.LengthSquared > 0.01f && InputVector.LengthSquared > 0.1f;

		string stateStr = "";
		if ( IsDead )
			stateStr = "ghost_";
		else if ( hurting && attacking )
			stateStr = "hurt_attack_";
		else if ( hurting )
			stateStr = "hurt_";
		else if ( attacking )
			stateStr = "attack_";

		Sprite.PlayAnimation( $"{stateStr}{(moving ? "walk" : "idle")}" );
		Sprite.PlaybackSpeed = moving ? Utils.Map( Velocity.Length, 0f, 2f, 1.5f, 2f ) : 0.66f;

		Sprite.LocalRotation = new Angles( 0f, -90f + (Velocity.Length * Utils.FastSin( Time.Now * MathF.PI * 6f ) * 1.6f) * (Sprite.SpriteFlags.HasFlag( SpriteFlags.HorizontalFlip ) ? -1f : 1f), 0f );

		if ( !IsDead )
		{
			HandleFlashing( dt );
		}

		if ( IsProxy )
			return;

		// ACHIEVEMENTS
		if ( Manager.Instance.Difficulty >= 0 )
		{
			if ( _lastPos2D.x < -15.7f && WorldPosition.x >= -15.7f )
			{
				_timeSinceTouchLeftSide = 0f;
			}

			if ( _lastPos2D.x < 15.7f && WorldPosition.x >= 15.7f )
			{
				Log.Info( $"_timeSinceTouchLeftSide: {_timeSinceTouchLeftSide}" );

				if ( _timeSinceTouchLeftSide < 5.5f )
				{
					if ( !_hasUnlockedSprinterAchievement )
						Sandbox.Services.Achievements.Unlock( "sprinter" );

					_hasUnlockedSprinterAchievement = true;
				}
			}

			if ( !_hasUnlockedNoMoveAchievement )
			{
				if ( IsInputtingMove )
				{
					TimeSinceInputMove = 0f;
				}
				else if ( TimeSinceInputMove > 60f * 10f )
				{
					Sandbox.Services.Achievements.Unlock( "stand_ground" );
					_hasUnlockedNoMoveAchievement = true;
				}
			}
		}

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"ArrowAimer: {ArrowAimer}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.7f, 0f ) ) );

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

		_lastPos2D = Position2D;

		var velocity = Velocity + (IsDashing ? DashVelocity : Vector2.Zero);
		Position2D += velocity * dt;

		if ( Stats[PlayerStat.MoveSelfDmg] > 0f )
		{
			Stats[PlayerStat.SelfDmgDistanceMoved] += velocity.Length * dt;

			if ( !IsInvulnerable && Stats[PlayerStat.SelfDmgDistanceMoved] > Stats[PlayerStat.MoveSelfDmgReqDist] )
			{
				Stats[PlayerStat.SelfDmgDistanceMoved] -= Stats[PlayerStat.MoveSelfDmgReqDist];
				Damage( Stats[PlayerStat.MoveSelfDmgAmount], PlayerDamageType.Self );
				Manager.Instance.PlaySfxNearby( "zombie.attack.player", Position2D, pitch: Game.Random.Float( 1.25f, 1.45f ), volume: 0.9f, maxDist: 3f );
			}
		}

		WorldPosition = WorldPosition.WithZ( Globals.GetZPos( Position2D.y ) );

		//Velocity = Utils.DynamicEaseTo( Velocity, Vector2.Zero, 0.2f, dt );
		Velocity *= Math.Max( 1f - dt * 12.9f, 0f );

		if ( Velocity.LengthSquared < 0.001f )
			Velocity = Vector2.Zero;

		TempWeight *= (1f - dt * 4.7f);

		if ( InputVector.LengthSquared > 0f )
		{
			Velocity += InputVector.Normal * Stats[PlayerStat.MoveSpeed] * BASE_MOVE_SPEED * dt;
			//Log.Info( $"dt: {dt}" );
		}

		HandleBounds();

		Manager.Instance.Camera2D.TargetPos = Position2D;

		if ( Input.UsingController )
		{

		}
		else
		{
			AimDir = (Manager.Instance.MouseWorldPos - (Position2D + new Vector2( 0f, 0.5f ))).Normal;

			if ( Stats[PlayerStat.ReverseControls] > 0f )
				AimDir *= -1f;
		}

		if ( ArrowAimer != null && !Manager.Instance.IsPauseMenuOpen && !IsTimePausedForChoosing )
		{
			ArrowAimer.LocalRotation = new Angles( 0f, MathF.Atan2( AimDir.y, AimDir.x ) * (180f / MathF.PI) - 180f, 0f );
			ArrowAimer.LocalPosition = new Vector2( 0f, 0.4f ) + AimDir * Utils.Map( _timeSinceShoot, 0f, 0.25f, 0.6f, 0.55f, EasingType.QuadOut );
			ArrowAimer.LocalScale = new Vector3( Utils.Map( _timeSinceShoot, 0f, 0.25f, 1.25f, 0.75f, EasingType.QuadOut ), 1f, 1f ) * 0.005f;
			ArrowSprite.Tint = Color.White.WithAlpha( Utils.Map( _timeSinceShoot, 0f, 0.3f, 1f, 0.3f, EasingType.QuadOut ) * Utils.Map( _timeSinceSpawn, 0f, 1f, 0f, 1f, EasingType.Linear ) );
		}

		for ( int dx = -1; dx <= 1; dx++ )
		{
			for ( int dy = -1; dy <= 1; dy++ )
			{
				Manager.Instance.HandleThingCollisionForGridSquare( this, new GridSquare( GridPos.x + dx, GridPos.y + dy ), dt );
			}
		}

		if ( !IsDead )
		{
			HandleDashing( dt );
			HandleStatuses( dt );
			HandleShooting( dt );
			HandleRegen( dt );
		}

		if ( IsChoosingLevelUpReward && !Manager.Instance.IsPauseMenuOpen )
		{
			if ( Input.Pressed( "reload" ) ) UseReroll();
			else if ( Input.Pressed( "Slot1" ) ) UseChoiceHotkey( 1 );
			else if ( Input.Pressed( "Slot2" ) ) UseChoiceHotkey( 2 );
			else if ( Input.Pressed( "Slot3" ) ) UseChoiceHotkey( 3 );
			else if ( Input.Pressed( "Slot4" ) ) UseChoiceHotkey( 4 );
			else if ( Input.Pressed( "Slot5" ) ) UseChoiceHotkey( 5 );
			else if ( Input.Pressed( "Slot6" ) ) UseChoiceHotkey( 6 );
		}

		if ( Input.Pressed( "use" ) )
		{
			//AddExperience( 10 );
		}
	}

	void HandleRegen( float dt )
	{
		if ( Math.Abs( Stats[PlayerStat.HealthRegen] ) > 0f )
			RegenHealth( Stats[PlayerStat.HealthRegen] * dt );

		//if ( Math.Abs( Stats[PlayerStat.HealthDrain] ) > 0f )
		//	RegenHealth( Stats[PlayerStat.HealthDrain] * dt );

		if ( Stats[PlayerStat.HealthRegenStill] > 0f && !IsMoving )
		{
			RegenHealth( Stats[PlayerStat.HealthRegenStill] * dt );

			if ( !IsDashing && Health < Stats[PlayerStat.MaxHp] )
				TimeSinceChangeHP = 0f;
		}
	}

	public void RegenHealth( float amount )
	{
		float maxHp = Stats[PlayerStat.MaxHp];
		float hpMissing = maxHp - Health;

		if ( amount > 0f )
		{
			if ( hpMissing <= 0f )
				return;

			_regenHpAccumulated += amount;

			if ( _regenHpAccumulated < 0.85f && hpMissing > _regenHpAccumulated )
				return;

			float hpRecovered = Math.Min( _regenHpAccumulated, hpMissing );

			Health += hpRecovered;

			//var particle = DamageNumbersLegacy.Create( hpRecovered, Position2D + new Vector2( 0.2f + Game.Random.Float( -0.1f, 0.1f ), Radius * 3f + Game.Random.Float( -0.2f, 0.3f ) ), color: Color.Green, sizeMultiplier: 0.8f );
			//Vector3 velocity = new Vector3( Game.Random.Float(-0.5f, 0.5f), 0f, 0f );
			//Vector3 gravity = new Vector3( 0f, 1.5f, 0f );
			//particle.SetVector( 1, velocity );
			//particle.SetNamedValue( "Gravity", gravity );

			var pos = Position2D + new Vector2( Game.Random.Float( -0.1f, 0.1f ), Radius * 3f + Game.Random.Float( -0.2f, 0.3f ) );
			float size = 1.1f;
			Manager.Instance.SpawnDamageNumber( pos, amount, Color.Green, size, FloaterType.Heal );

			_regenHpAccumulated = 0f;
		}
		else
		{
			_drainHpAccumulated += amount;
			if ( _drainHpAccumulated < -1f )
			{
				var dmgAmount = MathF.Truncate( _drainHpAccumulated );
				_drainHpAccumulated -= dmgAmount;

				Damage( MathF.Abs( dmgAmount ), PlayerDamageType.Self );

				Manager.Instance.PlaySfxNearby( "lava_puddle_03", Position2D, pitch: Game.Random.Float( 1.7f, 1.75f ), volume: 0.15f, maxDist: 4f );
			}
		}
	}

	void HandleDashing( float dt )
	{
		int numDashes = (int)MathF.Round( Stats[PlayerStat.NumDashes] );
		if ( NumDashesAvailable < numDashes )
		{
			DashTimer -= dt;
			DashRechargeProgress = Utils.Map( DashTimer, Stats[PlayerStat.DashCooldown], 0f, 0f, 1f );
			if ( DashTimer <= 0f )
			{
				DashRecharged();
			}
		}

		if ( DashInvulnTimer > 0f )
		{
			DashInvulnTimer -= dt;
			DashProgress = Utils.Map( DashInvulnTimer, Stats[PlayerStat.DashInvulnTime], 0f, 0f, 1f );
			if ( DashInvulnTimer <= 0f )
			{
				IsDashing = false;
				//Sprite.Tint = Color.White;
				Sprite.FlashTint = Color.White.WithAlpha( 0f );

				DashFinished();
			}
			else
			{
				if ( Stats[PlayerStat.DashCharm] > 0f )
				{
					if ( IsInvulnerable )
						Sprite.FlashTint = new Color( Game.Random.Float( 0.8f, 1f ), Game.Random.Float( 0f, 0.1f ), Game.Random.Float( 0.8f, 1f ), 0.9f );
					else if ( !_isFlashing )
						Sprite.FlashTint = new Color( Game.Random.Float( 0.9f, 1f ), Game.Random.Float( 0.1f, 0.3f ), Game.Random.Float( 0.9f, 1f ), 0.8f );
				}
				else
				{
					if ( IsInvulnerable )
						Sprite.FlashTint = new Color( Game.Random.Float( 0f, 0.25f ), Game.Random.Float( 0f, 0.25f ), Game.Random.Float( 0.8f, 1f ), 0.9f );
				}

				if ( _dashCloudTime > Game.Random.Float( 0.1f, 0.2f ) )
				{
					SpawnDashCloudClient();
					_dashCloudTime = 0f;
				}
			}
		}

		if ( Input.Pressed( "Jump" ) || Input.Pressed( "attack1" ) )
		{
			//Position2D = Manager.Instance.MouseWorldPos;
			////Manager.Instance.Camera2D.SetPos( Position2D );
			//Transform.ClearInterpolation();
			//return;

			Dash();
		}
	}

	public void Dash()
	{
		if ( NumDashesAvailable <= 0 || IsTimePausedForChoosing )
			return;

		Vector2 dashDir = Velocity.LengthSquared > 0f ? Velocity.Normal : AimDir;

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

		DashVelocity = dashDir * Stats[PlayerStat.DashStrength];
		TempWeight = 2f;

		if ( NumDashesAvailable == (int)Stats[PlayerStat.NumDashes] )
			DashTimer = Stats[PlayerStat.DashCooldown];

		NumDashesAvailable--;
		IsDashing = true;
		DashInvulnTimer = Stats[PlayerStat.DashInvulnTime];
		DashProgress = 0f;
		DashRechargeProgress = 0f;

		Manager.Instance.PlaySfxNearby( "player.dash", Position2D + dashDir * 0.5f, pitch: Utils.Map( NumDashesAvailable, 0, 5, 1f, 0.9f ), volume: 1f, maxDist: 4f );
		SpawnDashCloudClient();
		_dashCloudTime = 0f;

		ForEachStatus( status => status.OnDashStarted() );
	}

	public void DashFinished()
	{
		ForEachStatus( status => status.OnDashFinished() );
	}

	public void DashRecharged()
	{
		NumDashesAvailable++;
		var numDashes = (int)MathF.Round( Stats[PlayerStat.NumDashes] );
		if ( NumDashesAvailable > numDashes )
			NumDashesAvailable = numDashes;

		if ( NumDashesAvailable < numDashes )
		{
			DashTimer = Stats[PlayerStat.DashCooldown];
			DashRechargeProgress = 0f;
		}
		else
		{
			DashRechargeProgress = 1f;
		}

		ForEachStatus( status => status.OnDashRecharged() );

		Manager.Instance.PlaySfxNearby( "player.dash.recharge", Position2D, pitch: Utils.Map( NumDashesAvailable, 1, numDashes, 1f, 1.2f ), volume: 0.2f, maxDist: 5f );
	}

	void HandleBounds()
	{
		var x_min = Manager.Instance.BOUNDS_MIN.x + Radius;
		var x_max = Manager.Instance.BOUNDS_MAX.x - Radius;
		var y_min = Manager.Instance.BOUNDS_MIN.y;
		var y_max = Manager.Instance.BOUNDS_MAX.y - Radius;

		if ( Position2D.x < x_min )
			Position2D = new Vector2( x_min, Position2D.y );
		else if ( Position2D.x > x_max )
			Position2D = new Vector2( x_max, Position2D.y );

		if ( Position2D.y < y_min )
			Position2D = new Vector2( Position2D.x, y_min );
		else if ( Position2D.y > y_max )
			Position2D = new Vector2( Position2D.x, y_max );
	}

	public int GetExperienceReqForLevel( int level )
	{
		switch ( Manager.Instance.Difficulty )
		{
			case -1:
				return (int)MathF.Round( Utils.Map( level, 1, 150, 3f, 240f, EasingType.SineIn ) );
			case 0:
			default:
				return (int)MathF.Round( Utils.Map( level, 1, 150, 3f, 320f, EasingType.SineIn ) );
		}
	}

	public void Flash( float time )
	{
		if ( _isFlashing )
			return;

		//Sprite.Tint = new Color( 1f, 0f, 0f );
		Sprite.FlashTint = new Color( 1f, 0f, 0f, 1f );
		_isFlashing = true;
		_flashTimer = time;
	}

	public void Heal( float amount, float flashTime )
	{
		//Sprite.Tint = new Color( 0f, 1f, 0f );
		Sprite.FlashTint = new Color( 0f, 1f, 0f, 1f );
		_isFlashing = true;
		_flashTimer = flashTime;

		if ( IsProxy )
			return;

		if ( Health < Stats[PlayerStat.MaxHp] )
			TimeSinceChangeHP = 0f;

		Health += amount;
		if ( Health > Stats[PlayerStat.MaxHp] )
			Health = Stats[PlayerStat.MaxHp];
	}

	void HandleFlashing( float dt )
	{
		if ( _isFlashing )
		{
			_flashTimer -= dt;
			if ( _flashTimer < 0f )
			{
				_isFlashing = false;
				//Sprite.Tint = Color.White;
				Sprite.FlashTint = Color.White.WithAlpha( 0f );
			}
		}
	}

	[ConCmd( "give_status" )]
	public static void GiveStatus( string name )
	{
		// Cheat only works in the editor
		if ( !Game.IsEditor )
			return;

		var type = TypeLibrary.GetType( name );
		if ( type == null )
		{
			Log.Info( $"No status with name '{name}' found!" );
			return;
		}

		Manager.Instance?.GetLocalPlayer()?.AddStatus( type );
	}

	public void AddStatus( TypeDescription type )
	{
		Status status = null;
		var typeIdentity = type.Identity;

		ForEachStatus( status => status.OnAddStatus( typeIdentity ) );

		if ( Statuses.ContainsKey( typeIdentity ) )
		{
			status = Statuses[typeIdentity];
			status.Level++;
		}

		if ( status == null )
		{
			status = StatusManager.CreateStatus( type );
			Statuses.Add( typeIdentity, status );
			status.Init( this );
		}

		//Sandbox.Services.Stats.Increment( Client, "status", 1, $"{type.Name.ToLowerInvariant()}", new { Status = type.Name.ToLowerInvariant(), Level = status.Level } );

		status.Refresh();

		Manager.Instance.PlaySfxNearby( "click", Position2D, 0.9f, 0.75f, 5f );

		LevelUpChoices.Clear();
		IsChoosingLevelUpReward = false;
		RealTimeSinceChoseUpgrade = 0f;

		CheckForLevelUp();
	}

	public bool HasStatus( TypeDescription type )
	{
		return Statuses.ContainsKey( type.Identity );
	}

	public Status GetStatus( TypeDescription type )
	{
		if ( Statuses.ContainsKey( type.Identity ) )
			return Statuses[type.Identity];

		return null;
	}

	public int GetStatusLevel( TypeDescription type )
	{
		if ( Statuses.ContainsKey( type.Identity ) )
			return Statuses[type.Identity].Level;

		return 0;
	}

	public void Modify( Status caller, PlayerStat statType, float value, ModifierType type, float priority = 0f, bool update = true )
	{
		if ( !_modifiers_stat.ContainsKey( caller ) )
			_modifiers_stat.Add( caller, new Dictionary<PlayerStat, ModifierData>() );

		_modifiers_stat[caller][statType] = new ModifierData( value, type, priority );

		if ( update )
			UpdateProperty( statType );
	}

	public void AdjustBaseStat( PlayerStat statType, float amount, bool update = true )
	{
		if ( !_original_properties_stat.ContainsKey( statType ) )
			_original_properties_stat.Add( statType, Stats[statType] );

		_original_properties_stat[statType] += amount;

		if ( update )
			UpdateProperty( statType );
	}

	void UpdateProperty( PlayerStat statType )
	{
		if ( !_original_properties_stat.ContainsKey( statType ) )
		{
			_original_properties_stat.Add( statType, Stats[statType] );
		}

		float curr_value = _original_properties_stat[statType];
		float curr_set = curr_value;
		bool should_set = false;
		float curr_priority = 0f;
		float total_add = 0f;
		float total_mult = 1f;

		foreach ( Status caller in _modifiers_stat.Keys )
		{
			var dict = _modifiers_stat[caller];
			if ( dict.ContainsKey( statType ) )
			{
				var mod_data = dict[statType];
				switch ( mod_data.type )
				{
					case ModifierType.Set:
						if ( mod_data.priority >= curr_priority )
						{
							curr_set = mod_data.value;
							curr_priority = mod_data.priority;
							should_set = true;
						}
						break;
					case ModifierType.Add:
						total_add += mod_data.value;
						break;
					case ModifierType.Mult:
						total_mult *= mod_data.value;
						break;
				}
			}
		}

		if ( should_set )
			curr_value = curr_set;

		curr_value += total_add;
		curr_value *= total_mult;

		Stats[statType] = curr_value;

		if ( statType == PlayerStat.MaxHp )
			Stats[statType] = Math.Max( curr_value, 1f );
	}

	public void AddExperience( int xp )
	{
		ExperienceTotal += xp;
		ExperienceCurrent += xp;

		//var particle = DamageNumbersLegacy.Create( xp, Position2D + new Vector2( 0.2f + Game.Random.Float( -0.1f, 0.1f ), Radius * 3f + Game.Random.Float( -0.2f, 0.3f ) ), color: new Color(0.1f, 0.1f, 1f), sizeMultiplier: 0.8f );
		//Vector3 velocity = new Vector3( 0f, 0f, 0f );
		//Vector3 gravity = new Vector3( 0f, 1f, 0f );
		//particle.SetVector( 1, velocity );
		//particle.SetNamedValue( "Gravity", gravity );

		var pos = Position2D + new Vector2( Game.Random.Float( -0.1f, 0.1f ), Radius * 3f + Game.Random.Float( -0.2f, 0.3f ) );
		float size = Utils.Map( xp, 1f, 4f, 0.95f, 1.1f, EasingType.Linear );
		var color = new Color( 0.4f, 0.4f, 1f );
		Manager.Instance.SpawnDamageNumber( pos, xp, color, size, FloaterType.Xp );

		ForEachStatus( status => status.OnGainExperience( xp ) );
		if ( !IsChoosingLevelUpReward )
			CheckForLevelUp();
	}

	public void LoseExperience( int amount )
	{
		ExperienceCurrent = Math.Max( ExperienceCurrent - amount, 0 );
	}

	public void CheckForLevelUp()
	{
		//Log.Info("CheckForLevelUp: " + ExperienceCurrent + " / " + ExperienceRequired + " IsServer: " + Sandbox.Game.IsServer + " Level: " + Level);
		if ( ExperienceCurrent >= ExperienceRequired && Manager.Instance.ShouldUpdatePlayer )
			LevelUp();
	}

	public void LevelUp()
	{
		ExperienceCurrent -= ExperienceRequired;

		Level++;
		ExperienceRequired = GetExperienceReqForLevel( Level + 1 );

		if ( Manager.Instance.Difficulty < 3 )
			NumRerollAvailable += (int)Stats[PlayerStat.NumRerollsPerLevel];

		Manager.Instance.PlaySfxNearby( "levelup", Position2D, Game.Random.Float( 0.95f, 1.05f ), 0.5f, 5f );

		ForEachStatus( status => status.OnLevelUp() );

		GenerateLevelUpChoices();
		IsChoosingLevelUpReward = true;
		TimeSinceLevelUp = 0f;

		if ( Manager.Instance.Difficulty >= 0 && !_hasUnlockedExperiencedAchievement && Level >= 85 )
		{
			Sandbox.Services.Achievements.Unlock( "experienced" );
			_hasUnlockedExperiencedAchievement = true;
		}
	}

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

		Manager.Instance.PlaySfxNearby( "reroll", Position2D, Utils.Map( NumRerollAvailable, 0, 20, 0.9f, 1.4f, EasingType.QuadIn ), 0.6f, 5f );

		NumRerollAvailable--;

		GenerateLevelUpChoices();

		ForEachStatus( status => status.OnReroll() );
	}

	public void UseChoiceHotkey( int num )
	{
		var index = num - 1;

		if ( !IsChoosingLevelUpReward || index >= LevelUpChoices.Count )
			return;

		AddStatus( TypeLibrary.GetType( LevelUpChoices[index].GetType() ) );
	}

	public float CheckDamageAmount( float damage, DamageType damageType )
	{
		if ( IsInvulnerable )
		{
			return 0f;
		}

		if ( HasStatus( TypeLibrary.GetType( typeof( ShieldStatus ) ) ) && damageType != DamageType.LavaPuddle )
		{
			var shieldStatus = GetStatus( TypeLibrary.GetType( typeof( ShieldStatus ) ) ) as ShieldStatus;
			if ( shieldStatus != null && shieldStatus.IsShielded )
			{
				shieldStatus.LoseShield();
				return 0f;
			}
		}

		if ( Stats[PlayerStat.DamageReductionPercent] > 0f )
			damage *= (1f - MathX.Clamp( Stats[PlayerStat.DamageReductionPercent], 0f, 1f ));

		if ( Stats[PlayerStat.IncreasedDmgTaken] > 0f )
			damage *= (1f + MathX.Clamp( Stats[PlayerStat.IncreasedDmgTaken], 0f, 1f ));

		if ( damageType == DamageType.Explosion && Stats[PlayerStat.ExplosionDamageReductionPercent] > 0f )
			damage *= (1f - MathX.Clamp( Stats[PlayerStat.ExplosionDamageReductionPercent], 0f, 1f ));

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

		if ( Manager.Instance.Difficulty < 0 )
			damage *= 0.571429f; // so zombie's 7 dmg becomes 4 dmg

		return damage;
	}

	public void Damage( float damage, PlayerDamageType playerDamageType = PlayerDamageType.Enemy )
	{
		if ( !Manager.Instance.ShouldUpdatePlayer )
			return;

		TimeSinceHurt = 0f;

		bool isCrit = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.SelfCritChance];

		if ( isCrit )
			damage *= 2f;

		if ( damage > 0f )
		{
			TimeSinceChangeHP = 0f;
			Flash( 0.125f );
		}

		var offset = new Vector2(
			Game.Random.Float( -0.1f, 0.1f ),
			Radius * 3f + Game.Random.Float( -0.2f, 0.3f ) + (Health - damage < 0f ? -0.7f : 0f)
		);

		//DamageNumbers.Add( (int)damage, Position2D + Vector2.Up * Radius * 3f + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 0.2f, color: Color.Red );
		//DamageNumbersLegacy.Create( damage, Position2D + offset, color: isCrit ? Color.Orange : Color.Red );

		var pos = Position2D + offset;

		float size;
		if ( damage < 5f ) size = Utils.Map( damage, 1f, 5f, 1.1f, 1.25f, EasingType.QuadOut );
		else if ( damage < 20f ) size = Utils.Map( damage, 5f, 20f, 1.25f, 1.6f, EasingType.Linear );
		else size = Utils.Map( damage, 20f, 100f, 1.6f, 1.8f, EasingType.Linear );

		var color = isCrit ? Color.Orange : Color.Red;

		Manager.Instance.SpawnDamageNumber( pos, damage, color, size );

		ForEachStatus( status => status.OnHurt( damage ) );

		Health -= damage;

		//if ( damage > 3f )
		//	ShakeCam( Utils.Map( damage, 3f, 25f, 0f, 0.1f ), Utils.Map( damage, 3f, 20f, 0.1f, 0.25f ), EasingType.QuadOut );

		if ( Health <= 0f )
		{
			Die();
			SpawnBlood( damage, sizeMultiplier: Game.Random.Float( 2.5f, 3f ), playbackSpeed: Game.Random.Float( 20f, 25f ), shouldUseRealTime: true );
		}
		else
		{
			SpawnBlood( damage );
		}
	}

	public void AddVelocity( Vector2 vel )
	{
		if ( !Manager.Instance.ShouldUpdatePlayer )
			return;

		Velocity += vel;
	}

	public void SpawnBlood( float damage, float sizeMultiplier = 1f, float playbackSpeed = 0f, bool shouldUseRealTime = false )
	{
		var blood = Manager.Instance.SpawnBloodSplatter( Position2D );

		if ( blood != null )
		{
			blood.LocalScale *= Utils.Map( damage, 1f, 20f, 0.5f, 1.2f, EasingType.QuadIn ) * Game.Random.Float( 0.8f, 1.2f ) * sizeMultiplier;
			blood.Lifetime *= 0.3f;
			blood.ShouldUseRealTime = shouldUseRealTime;

			if ( playbackSpeed > 0f )
				blood.Sprite.PlaybackSpeed = playbackSpeed;
		}
	}

	public void Die()
	{
		if ( IsDead )
			return;

		IsDead = true;
		_hasPlayedDeathSfx = false;
		Sprite.Tint = new Color( 1f, 1f, 1f, 1f );
		Sprite.FlashTint = new Color( 1f, 1f, 1f, 0f );
		//ShadowOpacity = 0.2f;
		_isFlashing = false;
		IsReloading = false;

		RealTimeSinceDeath = 0f;
		_arrowDeathAlphaStart = ArrowSprite.Tint.a;

		var pitch = Game.Random.Float( 1.25f, 1.3f ) * (Manager.Instance.Difficulty < 0 ? 2f : 1f);
		Manager.Instance.PlaySfxNearby( "die", Position2D, pitch, volume: 1.5f, maxDist: 12f );

		Sprite.LocalScale *= 2f;

		Sprite.PlayAnimation( $"death" );
		//Sprite.PlayAnimation( "ghost_idle" );

		ShakeCam( Game.Random.Float( 0.025f, 0.065f ), Game.Random.Float( 0.3f, 0.5f ), EasingType.SineOut, useRealTime: true );

		if ( IsProxy )
			return;

		Manager.Instance.PlayerDied( this );
	}

	public void Revive()
	{
		if ( !IsDead )
			return;

		IsDead = false;
		IsChoosingLevelUpReward = false;
		IsDashing = false;
		IsReloading = true;
		Sprite.Tint = Color.White;
		ShadowOpacity = 0.8f;

		if ( IsProxy )
			return;

		Timer = Stats[PlayerStat.ReloadTime];
		ReloadProgress = 0f;
		DashProgress = 0f;
		ExperienceCurrent = 0;

		Health = Stats[PlayerStat.MaxHp] * 0.33f;
	}

	public void ForEachStatus( Action<Status> action )
	{
		if ( IsProxy )
			return;

		foreach ( var (_, status) in Statuses )
		{
			action( status );
		}
	}

	void HandleStatuses( float dt )
	{
		foreach ( KeyValuePair<int, Status> pair in Statuses )
		{
			Status status = pair.Value;
			if ( status.ShouldUpdate )
				status.Update( dt );
		}
	}

	void HandleShooting( float dt )
	{
		if ( IsReloading )
		{
			ReloadProgress = Utils.Map( Timer, Stats[PlayerStat.ReloadTime], 0f, 0f, 1f );
			Timer -= dt * Stats[PlayerStat.ReloadSpeed];
			if ( Timer <= 0f )
			{
				Reload();
			}
		}
		else
		{
			Timer -= dt * Stats[PlayerStat.AttackSpeed] * (IsMoving ? 1f : Stats[PlayerStat.AttackSpeedStill]);
			if ( Timer <= 0f )
			{
				Shoot( isLastAmmo: AmmoCount == 1 );
				AmmoCount--;

				if ( AmmoCount <= 0 )
				{
					IsReloading = true;

					Timer += Stats[PlayerStat.ReloadTime];
				}
				else
				{
					Timer += Stats[PlayerStat.AttackTime];
				}
			}
		}
	}

	public void Shoot( bool isLastAmmo = false )
	{
		int num_bullets_int = (int)Stats[PlayerStat.NumProjectiles];

		var aimDir = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootRandomDirChance]
			? Utils.GetRandomVector()
			: AimDir;

		var pos = Position2D + AimDir * 0.3f;

		if ( Stats[PlayerStat.MaxBulletSpread] > 0f )
		{
			float increment = 360f / num_bullets_int;

			for ( int i = 0; i < num_bullets_int; i++ )
			{
				var dir = Utils.RotateVector( aimDir, i * increment );
				SpawnBullet( pos, dir, isLastAmmo );
			}
		}
		else
		{
			float start_angle = MathF.Sin( -_shotNum * 2f ) * Stats[PlayerStat.BulletInaccuracy];

			var spread = Stats[PlayerStat.BulletSpread] * num_bullets_int;

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

			for ( int i = 0; i < num_bullets_int; i++ )
			{
				var dir = Utils.RotateVector( aimDir, start_angle + currAngleOffset + increment * i );
				SpawnBullet( pos, dir, isLastAmmo );
			}
		}

		Manager.Instance.PlaySfxNearby( "shoot", pos, pitch: Utils.Map( _shotNum, 0f, (float)Stats[PlayerStat.MaxAmmoCount], 1f, 1.25f ), volume: 1f, maxDist: 4f );

		Velocity -= aimDir * Stats[PlayerStat.Recoil];

		_shotNum++;
		_timeSinceShoot = 0f;
	}

	void SpawnBullet( Vector2 pos, Vector2 dir, bool isLastAmmo = false, float damageMult = 1f )
	{
		var damage = (Stats[PlayerStat.BulletDamage] * Stats[PlayerStat.BulletDamageMultiplier] + Stats[PlayerStat.BulletFlatDamageAddition]) * GetDamageMultiplier() * damageMult;
		if ( isLastAmmo )
			damage *= Stats[PlayerStat.LastAmmoDamageMultiplier];

		if ( Stats[PlayerStat.DamagePerEarlierShot] > 0f )
			damage += _shotNum * Stats[PlayerStat.DamagePerEarlierShot];

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

			if ( IsDashing )
				damage += Stats[PlayerStat.DamageForSpeed] * DashVelocity.Length;
		}

		var bulletObj = BulletPrefab.Clone( (Vector3)pos );
		var bullet = bulletObj.Components.Get<Bullet>();

		bullet.Velocity = dir * Stats[PlayerStat.BulletSpeed];
		bullet.Shooter = this;
		bullet.TempWeight = 3f;

		bullet.Stats[BulletStat.Damage] = damage;
		bullet.Stats[BulletStat.Force] = Stats[PlayerStat.BulletForce];
		bullet.Stats[BulletStat.Lifetime] = Stats[PlayerStat.BulletLifetime];
		bullet.Stats[BulletStat.NumPiercing] = (int)MathF.Round( Stats[PlayerStat.BulletNumPiercing] );
		bullet.Stats[BulletStat.NumBouncing] = (int)MathF.Round( Stats[PlayerStat.BulletNumBouncing] );
		bullet.Stats[BulletStat.WillIgnite] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootFireIgniteChance] ? 1f : 0f;
		bullet.Stats[BulletStat.WillFreeze] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.ShootFreezeChance] ? 1f : 0f;
		bullet.Stats[BulletStat.GrowDamageAmount] = Stats[PlayerStat.BulletDamageGrow];
		bullet.Stats[BulletStat.ShrinkDamageAmount] = Stats[PlayerStat.BulletDamageShrink];
		bullet.Stats[BulletStat.DistanceDamageAmount] = Stats[PlayerStat.BulletDistanceDamage];
		bullet.Stats[BulletStat.HealTeammateAmount] = Stats[PlayerStat.BulletHealTeammateAmount];
		bullet.IsHoming = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.HomingBulletChance];

		if ( Stats[PlayerStat.GrenadesCanCrit] <= 0f )
		{
			bullet.Stats[BulletStat.CriticalChance] = Stats[PlayerStat.CritChance];
			bullet.Stats[BulletStat.CriticalMultiplier] = Stats[PlayerStat.CritMultiplier];
		}

		bullet.Init();

		//bullet.GameObject.NetworkSpawn( Network.Owner );
	}

	void Reload()
	{
		AmmoCount = (int)Stats[PlayerStat.MaxAmmoCount];
		IsReloading = false;
		_shotNum = 0;
		ReloadProgress = 0f;

		ForEachStatus( status => status.OnReload() );
	}

	public float GetDamageMultiplier()
	{
		float damageMultiplier = Stats[PlayerStat.OverallDamageMultiplier];

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

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

		return damageMultiplier;
	}

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

		if ( IsDead )
			return;

		ForEachStatus( status => status.Colliding( other, percent, dt ) );

		if ( other is Enemy enemy && !enemy.IsDying )
		{
			if ( !Position2D.Equals( other.Position2D ) )
			{
				var spawnFactor = Utils.Map( enemy.TimeSinceSpawn, 0f, enemy.SpawnTime, 0f, 1f, EasingType.QuadIn );
				Velocity += (Position2D - other.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 100f ) * (1f + other.TempWeight) * spawnFactor * dt;
			}
		}
		else if ( other is Player player )
		{
			if ( !player.IsDead && !Position2D.Equals( other.Position2D ) )
			{
				Velocity += (Position2D - other.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 100f ) * (1f + other.TempWeight) * dt;
			}
		}
	}

	public void SpawnDashCloudClient()
	{
		Manager.Instance.SpawnCloud( Position2D + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 0.05f );
	}

	public void GenerateLevelUpChoices()
	{
		LevelUpChoices.Clear();

		//if( Level == 1)
		//{
		//	LevelUpChoices.Add( CreateStatus( TypeLibrary.GetType( typeof( PauseWhileChoosingStatus ) ) ) );
		//	LevelUpChoices.Add( CreateStatus( TypeLibrary.GetType( typeof( MysteryBoxStatus ) ) ) );
		//	LevelUpChoices.Add( CreateStatus( GetRandomStartingPerk() ) );

		//	LevelUpChoices.Shuffle();

		//	return;
		//}

		bool offerCurses = false;
		if ( Manager.Instance.Difficulty >= 6 )
			offerCurses = IsLevelCursed( Level );

		int numChoices = Math.Clamp( (int)MathF.Round( Stats[PlayerStat.NumUpgradeChoices] ), 1, 6 );
		List<TypeDescription> statusTypes = StatusManager.GetRandomStatuses( this, numChoices, offerCurses );

		for ( int i = 0; i < statusTypes.Count; i++ )
			LevelUpChoices.Add( CreateStatus( statusTypes[i] ) );

		if ( Level == 1 )
		{
			bool alreadyOffered = false;

			foreach ( var status in LevelUpChoices )
			{
				if ( status is PauseWhileChoosingStatus )
				{
					alreadyOffered = true;
					break;
				}
			}

			if ( !alreadyOffered )
			{
				LevelUpChoices.RemoveAt( 1 );
				LevelUpChoices.Add( CreateStatus( TypeLibrary.GetType( typeof( PauseWhileChoosingStatus ) ) ) );
				LevelUpChoices.Shuffle();
			}
		}

		ChoiceHash++;
	}

	public bool IsLevelCursed( int level )
	{
		if ( Manager.Instance.Difficulty < 6 || level <= 1 )
			return false;

		switch ( Manager.Instance.Difficulty )
		{
			case 6: return level % 10 == 0;
			case 7: return level % 9 == 0;
			case 8: return level % 8 == 0;
			case 9: return level % 7 == 0;
			case 10: return level % 6 == 0;
			case 11: return level % 5 == 0;
			case 12: return level % 4 == 0;
			case 13: return level % 3 == 0;
			case 14: return level % 2 == 0;
			case 15: return level % 3 != 1;
		}

		return false;
	}

	TypeDescription GetRandomStartingPerk()
	{
		List<(TypeDescription Type, float Weight)> perks = new List<(TypeDescription, float)>
		{
			(TypeLibrary.GetType( typeof( DamageStatus ) ), 5f),
			(TypeLibrary.GetType( typeof( MovespeedStatus ) ), 3f),
			(TypeLibrary.GetType( typeof( AttackSpeedStatus ) ), 2f),
			(TypeLibrary.GetType( typeof( NumProjectileStatus ) ), 2f),
			(TypeLibrary.GetType( typeof( PiercingStatus ) ), 3f),
			(TypeLibrary.GetType( typeof( GrenadeShootReloadStatus ) ), 1f),
			(TypeLibrary.GetType( typeof( NumDashesStatus ) ), 2f),
			(TypeLibrary.GetType( typeof( FireIgniteStatus ) ), 2f),
			(TypeLibrary.GetType( typeof( FreezeShootStatus ) ), 2f),
			(TypeLibrary.GetType( typeof( FullHealthDamageStatus ) ), 2f),
			(TypeLibrary.GetType( typeof( MoreRerollsStatus ) ), 4f),
			(TypeLibrary.GetType( typeof( MoreChoicesStatus ) ), 3f),
			(TypeLibrary.GetType( typeof( ReloadSpeedStatus ) ), 1f),
			(TypeLibrary.GetType( typeof( XpDamageStatus ) ), 1f),
			(TypeLibrary.GetType( typeof( HomingBulletStatus ) ), 4f),
			(TypeLibrary.GetType( typeof( ThornsStatus ) ), 1f),
			(TypeLibrary.GetType( typeof( BouncingBulletStatus ) ), 3f),
		};

		TypeDescription chosenPerk = null;

		float totalWeight = perks.Sum( x => x.Weight );
		var rand = Game.Random.Float( 0f, totalWeight );

		for ( int i = perks.Count - 1; i >= 0; i-- )
		{
			var (type, weight) = perks[i];
			rand -= weight;

			if ( rand < 0f )
			{
				chosenPerk = type;
				break;
			}
		}

		return chosenPerk;
	}

	Status CreateStatus( TypeDescription type )
	{
		var status = StatusManager.CreateStatus( type );
		var currLevel = GetStatusLevel( type );
		status.Level = currLevel + 1;
		return status;
	}

	public void Restart()
	{
		Sprite.PlayAnimation( "idle" );
		Sprite.PlaybackSpeed = 0.66f;

		Sprite.Tint = new Color( 1f, 1f, 1f, 1f );

		Sprite.LocalScale = new Vector3( Globals.SPRITE_SCALE );

		if ( IsProxy )
			return;

		Position2D = new Vector3( Game.Random.Float( -3f, 3f ), Game.Random.Float( -3f, 3f ) );
		Manager.Instance.Camera2D.SetPos( Position2D );

		InitializeStats();

		Manager.Instance.PlaySfxNearby( "restart", Position2D, Game.Random.Float( 0.95f, 1.05f ), 0.66f, 4f );
	}

	public void SpawnBulletRing( Vector2 pos, int numBullets, Vector2 aimDir, float damageMultMin = 1f, float damageMultMax = 1f )
	{
		float increment = 360f / numBullets;

		for ( int i = 0; i < numBullets; i++ )
		{
			var dir = Utils.RotateVector( aimDir, i * increment );
			float damageMult = Game.Random.Float( damageMultMin, damageMultMax );
			SpawnBullet( pos, dir, false, damageMult );
		}

		Manager.Instance.PlaySfxNearby( "shoot", pos, pitch: 1f, volume: 1f, maxDist: 3f );
	}

	public Grenade SpawnGrenade( Vector2 pos, Vector2 vel )
	{
		var grenadeObj = Manager.Instance.GrenadePrefab.Clone();

		var grenade = grenadeObj.Components.Get<Grenade>();
		grenade.Velocity = vel;
		grenade.ExplosionSizeMultiplier = Stats[PlayerStat.ExplosionSizeMultiplier];
		grenade.Player = this;
		grenade.StickyPercent = Stats[PlayerStat.GrenadeStickyPercent];
		grenade.FearChance = Stats[PlayerStat.GrenadeFearChance];

		if ( Stats[PlayerStat.GrenadesCanCrit] > 0f )
		{
			grenade.CriticalChance = Stats[PlayerStat.CritChance];
			grenade.CriticalMultiplier = Stats[PlayerStat.CritMultiplier];
		}

		//grenadeObj.NetworkSpawn( Network.Owner );
		grenadeObj.WorldPosition = new Vector3( pos.x, pos.y, Globals.GetZPos( pos.y ) );

		Manager.Instance.AddThing( grenade );

		return grenade;
	}

	public void CreateShieldVfx()
	{
		_shieldVfx = Manager.Instance.ShieldVfxPrefab.Clone( WorldPosition );
		_shieldVfx.Parent = GameObject;
		_shieldVfx.LocalPosition = new Vector3( 0f, 0f, 0.1f );
		_shieldVfx.LocalScale = new Vector3( 1f ) * 1.8f * Globals.SPRITE_SCALE;
		_shieldVfx.LocalRotation = new Angles( 0f, -90f, 0f );
	}

	public void RemoveShieldVfx()
	{
		if ( _shieldVfx != null )
		{
			_shieldVfx.Destroy();
			_shieldVfx = null;
		}
	}

	public void PlaySfx( string name, Vector2 pos, float pitch, float volume )
	{
		var sfx = Sound.Play( name, new Vector3( pos.x, pos.y, Globals.SFX_DEPTH ) );

		if ( sfx != null )
		{
			sfx.Volume = volume;
			sfx.Pitch = pitch;
		}
	}

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

		Manager.Instance.AddPlayer( this );
	}

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

		Manager.Instance.RemovePlayer( this );
	}

	void HandleCamShaking()
	{
		CamShakeAmount = 0f;

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

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

	public void ShakeCam( float strength, float time, EasingType easingType = EasingType.Linear, bool useRealTime = false )
	{
		var timeNow = useRealTime ? RealTime.Now : Time.Now;
		_camShakeDatas.Add( new CamShakeData( strength, timeNow, time, easingType, useRealTime ) );
	}
}