things/enemies/Enemy.cs

Enemy unit class for the game. Implements enemy behavior including spawning, movement, targeting, attacking, damage handling, death (gibs, loot), exploding, animations, sounds and various status interactions.

NetworkingFile AccessNative Interop
using Sandbox;
using Sandbox.Diagnostics;
using Sandbox.UI;
using System;
using System.Numerics;
using static System.Net.Mime.MediaTypeNames;

[Flags]
public enum DamageResultFlags
{
	None					= 0,
	Backstab				= 1 << 0,
	AlternateDmg			= 1 << 1,
	CoupDeGrace				= 1 << 2,
	FullHealthEnemy			= 1 << 3,
	DodgeHpDamage			= 1 << 4,
	PoisonIncreaseNearby	= 1 << 5,
	Mark					= 1 << 6,
}

public class Enemy : Unit
{
	[Property] public Color TintFullHp { get; set; }
	[Property] public Color TintZeroHp { get; set; }
	[Property, Hide] public float Health { get; set; }
	public int CoinValueMin { get; set; }
	public int CoinValueMax { get; set; }
	public float CoinChance { get; set; }

	public float MaxHealth { get; protected set; }
	public virtual float GetMaxHealth() { return 0f; }
	public override float HpPercent => MaxHealth > 0f ? Health / MaxHealth : 0f;

	public virtual EnemyType EnemyType => EnemyType.None;
	public virtual string GibFolder => Manager.GetStringForEnemyType( EnemyType );
	public virtual float OverrideGibChance => -1f;
	public virtual float HealthPackChanceMultiplier => 1f;
	public virtual float GibScaleMultiplier => 1f;
	public virtual float GibOffsetMultiplier => 1f;
	public virtual float OverrideGibLifetime => 0f;
	public virtual int ExtraDeathBloodSprayAmount => 0;
	protected virtual bool HasLeftArm => true;
	/// <summary>
	/// Which enemy type to count this as for kill stats
	/// </summary>
	public virtual EnemyType KillStatEnemyType => EnemyType;

	private string _currAnimName;

	public bool CanAnimate { get; set; }
	public virtual bool CanHaveTarget => !IsDying && !IsStunned;
	public virtual bool CanAttack => !IsDying && !IsFearful && !IsStunned;
	public virtual bool CanDamageByTouch => !IsDying && IsAttacking && !IsStunned && !IsInTheAir;
	public virtual bool CanTurn => !IsStunned;
	public virtual bool CanBeBackstabbed => true;
	public virtual bool CountsAsKill => true;
	public virtual bool CanMove => !IsDying && !IsStunned;
	public virtual bool CanAccelerate => true;
	public virtual bool ShouldCreateSpawnClouds => true;
	public virtual bool IsMiniboss => false;
	public virtual bool CanCombust => true;

	// Miniboss armor scaling properties (based on difficulty)
	protected float MinibossArmorStartTime
	{
		get
		{
			switch ( Manager.Instance.Difficulty )
			{
				case 0: default: return 13 * 60f;  // 13 minutes for Normal
				case 1: return 6 * 60f;           // 6 minutes for Expert
				case 2: return 4 * 60f;           // 4 minutes for Cursed
			}
		}
	}

	protected float MinibossArmorPerMinute
	{
		get
		{
			switch ( Manager.Instance.Difficulty )
			{
				case 0: default: return 55f;   // Normal
				case 1: return 60f;            // Expert
				case 2: return 65f;            // Cursed
			}
		}
	}

	protected virtual float MinibossBaseHealth
	{
		get
		{
			switch ( Manager.Instance.Difficulty )
			{
				case 0: default: return 420f;
				case 1: return 490f;
				case 2: return 540f;
			}
		}
	}

	protected virtual float MinibossHealthScale => 1f;

	public override bool CanBeStunned => base.CanBeStunned && !IsSpawning;
	public bool HasTarget { get; protected set; }
	public Unit TargetUnit { get; set; }
	[Sync] public float NetworkedPlaybackRate { get; set; } = 1f;
	public Vector2 TargetPos { get; set; }
	public float LoseTargetTime { get; protected set; }
	public float DetectTargetRange { get; protected set; }
	public float LoseTargetRange { get; protected set; }
	private TimeSince _timeSinceCheckTarget;
	private float _checkTargetDelay;
	private const float CHECK_TARGET_TIME_MIN = 0.6f;
	private const float CHECK_TARGET_TIME_MAX = 1.6f;
	//protected TimeSince _timeSinceSawTarget;
	private TimeSince _timeSinceWander;
	private float _wanderTimeout;
	public const float WANDER_TIMEOUT_MIN = 10f;
	public const float WANDER_TIMEOUT_MAX = 20f;
	private float _alwaysTargetPlayerTime;

	public bool IsAttacking { get; protected set; }
	private float _aggroTimer;
	public float AggroRange { get; protected set; }
	protected const float AGGRO_START_TIME = 0.2f;
	protected const float AGGRO_LOSE_TIME = 0.4f;
	protected float _fullMeleeAttackAnimSpeed;

	public float Acceleration { get; set; }
	public float AccelerationAttacking { get; set; }
	public float DecelerationAttacking { get; set; }
	[Sync] public float MoveTimeOffset { get; set; }
	protected float _personalSpeedScale;
	protected float _personalSpeedFreq;
	protected float _personalTurnSpeed;
	protected virtual bool ShouldRetreatFromTarget => IsFearful;

	// Miniboss chest diagnostics: every miniboss death must end with DropLoot spawning a chest, so track
	// the window between StartDying and the chest spawn to expose deaths that stall or bypass Die entirely
	private bool _minibossDeathStarted;
	private bool _minibossChestSpawned;
	private bool _loggedStuckMinibossDeath;
	private TimeSince _timeSinceMinibossDeathStarted;

	protected TimeSince _timeSinceDamageTarget;
	[Sync] public float DamageTargetDelay { get; set; }
	[Sync] public float MeleeDamage { get; protected set; }
	[Sync] public bool SyncedCanDamageByTouch { get; private set; }
	public virtual float MeleeForce => 1f;
	public virtual float MeleeRagdollForce => 1f;
	public virtual float MeleeUpwardForceAmount => 0f;

	public bool IsSpawning { get; set; }
	public float SpawnProgress { get; protected set; }
	public virtual float SpawnTime => 1.75f;
	public virtual float SpawnZPos => -90f;
	public virtual float GroundZPos => 0f;
	public virtual bool AlmostFinishedSpawning => TimeSinceSpawn > SpawnTime * 0.5f * Manager.Instance.EnemySpawnTimeModifier;

	protected virtual bool ShouldSpawnBloodDecal => true;

	[Property, Hide] public bool ShouldSpawnInstantly { get; set; }
	[Property, Hide] public Player PlayerCreator { get; set; }

	private bool _isCelebrating;

	public bool IsExploding { get; set; }
	protected TimeSince _timeSinceExplodeStart;
	protected bool _hasExploded;
	protected float _explodeTime;
	protected Player _playerWhoKilledUs;
	protected bool _explodeFlashActive;
	protected TimeSince _timeSinceExplodeFlash;
	protected const float EXPLODE_BLINK_DELAY_START = 0.1f;
	protected const float EXPLODE_BLINK_DELAY_END = 0.025f;
	protected float _explosionRadius;
	protected float _explosionDamage;
	protected string _explodeAnim;
	protected Vector3 _explodeStartScale;
	protected Color _explosionColor;
	protected bool _explosionDamagesEnemies;
	protected float _explodeForce;

	public Material ExplodeFlashMaterial { get; protected set; }

	private Player _prevPlayerDamagedBy;
	private List<Player> _playersDamagedBy = new();

	protected RealTimeSince _realTimeSinceHurtSfx;
	protected TimeSince _timeSinceInfightingDamageSfx;
	protected TimeSince _timeSinceStartAttackingSfx;

	public virtual DamageType MeleeAttackDamageType => DamageType.Melee;

	private float _jumpFinishDamage;
	private Player _jumpFinishDamagePlayerSource;
	private float _jumpFinishStunTime;

	protected TimeSince _timeSinceChangeState;

	private float _flinchStartAnimProgress;

	protected Vector3 ModelOffset { get; set; }

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

		ShouldCheckBounds = true;

		ResetMaterial();

		float hp = MathF.Round( GetMaxHealth() * Manager.Instance.GetEnemyHealthModifier() );
		Health = MaxHealth = hp;

		// Containers and props (chests, barrels, trees) are excluded from the invisible enemy curse,
		// since they never get hurt unless seen and would stay invisible forever
		var canSpawnInvisible = !IsInanimate && this is not Tree;
		var invisChance = canSpawnInvisible && Manager.Instance.LocalPlayer.IsValid() ? Manager.Instance.LocalPlayer.Stats[PlayerStat.InvisibleEnemyChance] : 0f;
		if ( invisChance > 0f && Game.Random.Float( 0f, 1f ) < invisChance )
			ModelRenderer.Tint = GetCurrentTint().WithAlpha( 0f );
		else
			ModelRenderer.Tint = GetCurrentTint();

		ModelOffset = ModelRenderer.LocalPosition;

		CanAnimate = true;

		if( !ShouldSpawnInstantly )
		{
			IsSpawning = true;
			SpawnProgress = 0f;
			WorldPosition = WorldPosition.WithZ( SpawnZPos );
			PlaySpawnAnim();
		}
		else
		{
			SpawnProgress = 1f;
			PlayWalkAnim();
		}

		_explodeAnim = Game.Random.Float(0f, 1f) < 0.8f ? "Slide_Land" : "Slide_Enter";
		ExplodeFlashMaterial = Manager.Instance.EnemyExplodeFlashMaterial;
		_explosionColor = Color.Red;
		_explosionDamagesEnemies = true;
		_explodeForce = 800f;

		_timeSinceChangeState = 0f;

		_fullMeleeAttackAnimSpeed = 2f;

		if ( IsProxy )
			return;

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

		MoveTimeOffset = Game.Random.Float( 0f, 4f );

		HasTarget = false;
		TargetPos = new Vector2( Game.Random.Float( Manager.Instance.BOUNDS_MIN_SPAWN.x, Manager.Instance.BOUNDS_MAX_SPAWN.x ), Game.Random.Float( Manager.Instance.BOUNDS_MIN_SPAWN.y, Manager.Instance.BOUNDS_MAX_SPAWN.y ) );
		_timeSinceWander = 0f;
		_wanderTimeout = Game.Random.Float( WANDER_TIMEOUT_MIN, WANDER_TIMEOUT_MAX );

		_alwaysTargetPlayerTime = Game.Random.Float( 0f, 1f ) > Utils.Map( Manager.Instance.ElapsedTime, 0f, 5f * 60f, 1f, 0f, EasingType.SineIn )
			? 0f
			: 60f;

		// Miniboss armor scaling based on elapsed time
		if ( IsMiniboss && Manager.Instance.ElapsedTime > MinibossArmorStartTime )
		{
			const float pivotTime = 20f * 60f; // 20 minutes — linear before, exponential after
			const float exponentialRate = 0.07f; // higher = steeper curve after pivot

			float minutesPastStart = (Manager.Instance.ElapsedTime - MinibossArmorStartTime) / 60f;
			float rawArmor;

			if ( Manager.Instance.ElapsedTime <= pivotTime )
			{
				rawArmor = minutesPastStart * MinibossArmorPerMinute;
			}
			else
			{
				float armorAtPivot = (pivotTime - MinibossArmorStartTime) / 60f * MinibossArmorPerMinute;
				float minutesPastPivot = (Manager.Instance.ElapsedTime - pivotTime) / 60f;
				rawArmor = armorAtPivot * MathF.Exp( minutesPastPivot * exponentialRate );
			}

			Armor = (int)MathF.Round( rawArmor / 10f ) * 10;
		}
	}

	protected override void ApplySpawnScale()
	{
		if ( !UseSpawnScale )
			return;

		ModelRenderer.GameObject.LocalScale = SpawnScale;

		if ( Collider is CapsuleCollider capsuleCollider )
		{
			capsuleCollider.Radius *= SpawnScale.x;
			capsuleCollider.Start = capsuleCollider.Start.WithZ( capsuleCollider.Start.z * SpawnScale.z );
			capsuleCollider.End = capsuleCollider.End.WithZ( capsuleCollider.End.z * SpawnScale.z );
		}
	}

	protected virtual void DrawDebug()
	{
		Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"CanAnimate: {CanAnimate}\nPlayback: {ModelRenderer.PlaybackRate}\nAnimMod: {AnimSpeedModifier}\nAnim: {ModelRenderer.Sequence?.Name}", new Transform( WorldPosition ) );
		Gizmo.Draw.Text( $"HitstopActive: {HitstopActive}", new global::Transform( WorldPosition ) );
	}

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

		if ( !IsProxy && _minibossDeathStarted && !_minibossChestSpawned && !_loggedStuckMinibossDeath && _timeSinceMinibossDeathStarted > 10f )
		{
			_loggedStuckMinibossDeath = true;
			Log.Warning( $"[MinibossDeath] {EnemyType} began dying {_timeSinceMinibossDeathStarted.Relative:F1}s ago but never dropped a chest! IsDying={IsDying} IsExploding={IsExploding} hasExploded={_hasExploded} timeSinceExplodeStart={_timeSinceExplodeStart.Relative:F1}s IsInTheAir={IsInTheAir} IsSpawning={IsSpawning} IsStunned={IsStunned} HitstopActive={HitstopActive} Health={Health} pos={Position2D}" );
		}

		if ( !IsProxy )
			SyncedCanDamageByTouch = !IsSpawning && CanDamageByTouch;

		if ( Manager.Instance.ShowDebug )
		{
			DrawDebug();
		}

		if ( IsFlashing )
			HandleFlashing();

		if ( IsFlinching )
			HandleFlinching();

		if ( CanAnimate && !IsFlinching && !HitstopActive && !IsStunned )
			HandleAnimation();

		if ( IsSpawning && !IsDying )
		{
			HandleSpawning();
			return;
		}

		// todo: enemy subclasses shouldn't shoot/jump etc when IsExploding = true?
		if ( IsExploding )
			HandleExploding();

		if ( IsProxy || Manager.Instance.IsGameOver )
			return;

		if ( CanHaveTarget )
			HandleTarget();

		if ( IsInTheAir )
			return;

		if ( CanTurn && !HitstopActive )
			HandleRotation();

		if ( CanMove && !HitstopActive )
			HandleMovement();
	}

	protected virtual void HandleRotation()
	{
		var targetFacingDir = ((Vector3)TargetPos - WorldPosition).Normal.WithZ( 0f ); // todo: optimize?

		if( ShouldRetreatFromTarget )
			targetFacingDir *= -1f;

		WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( targetFacingDir ), _personalTurnSpeed * Time.Delta * TimeScale );
	}

	protected virtual void HandleMovement()
	{
		var moveDir = (Vector2)WorldRotation.Forward;

		if( CanAccelerate )
		{
			float acceleration = (IsAttacking ? AccelerationAttacking : Acceleration);
			Velocity += moveDir * acceleration * Time.Delta * TimeScale * Manager.Instance.GlobalMovespeedModifier;
		}

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

		Velocity *= Math.Max( 1f - Time.Delta * (IsAttacking ? DecelerationAttacking : Deceleration) * Manager.Instance.GlobalFrictionModifier, 0f );

		WorldPosition += (Vector3)Velocity * GetMoveSpeedFactor() * Time.Delta;
	}

	protected virtual float GetMoveSpeedFactor()
	{
		return (0.5f + Utils.FastSin( MoveTimeOffset + Manager.Instance.ElapsedTime * _personalSpeedFreq * (IsAttacking ? 2f : 1f) ) * 0.5f) * _personalSpeedScale; // todo: should "(IsAttacking ? 2f : 1f)" be there?
	}

	protected virtual void HandleSpawning()
	{
		var adjustedSpawnTime = SpawnTime * Manager.Instance.EnemySpawnTimeModifier;
		if ( TimeSinceSpawn > adjustedSpawnTime )
		{
			FinishSpawning();
		}
		else
		{
			//VfxOpacity = Utils.Map( TimeSinceSpawn, 0f, adjustedSpawnTime, 0f, 1f );

			SpawnProgress = Utils.Map( TimeSinceSpawn, 0f, adjustedSpawnTime, 0f, 1f );

			WorldPosition = WorldPosition.WithZ( Utils.Map( TimeSinceSpawn, 0f, adjustedSpawnTime, SpawnZPos, 0f, EasingType.SineOut ) );
		}
	}

	protected virtual void FinishSpawning()
	{
		IsSpawning = false;

		if ( Manager.Instance.IsGameOver )
		{
			if ( !_isCelebrating )
				Celebrate( victory: Manager.Instance.IsBossDead );

			return;
		}

		SpawnProgress = 1f;
		PlayWalkAnim();
		WorldPosition = WorldPosition.WithZ( GroundZPos );
	}

	protected virtual void HandleAnimation()
	{
		//if ( IsAttacking )
		//{
		//	if ( ModelRenderer.SceneModel.CurrentSequence.TimeNormalized > 0.95f )
		//		PlayAttackAnim();
		//}

		if ( !IsSpawning )
		{
			if ( !IsProxy )
				NetworkedPlaybackRate = GetAnimSpeedFactor() * AnimSpeedModifier * (IsStunned ? 0f : 1f);
			SetPlaybackRate( NetworkedPlaybackRate );
		}

		//SetPlaybackRate( AnimSpeedModifier );
	}

	protected virtual float GetAnimSpeedFactor()
	{
		if ( HasTarget && TargetUnit.IsValid() && !TargetUnit.IsInTheAir )
		{
			float distSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
			float attackDistSqr = MathF.Pow( AggroRange, 2f );

			return Utils.Map( distSqr, attackDistSqr, 0f, 1f, _fullMeleeAttackAnimSpeed, EasingType.Linear ) * _personalSpeedScale;
			//return (0.8f + Utils.FastSin( MoveTimeOffset + Manager.Instance.ElapsedTime * _personalSpeedFreq ) * 0.2f) * _personalSpeedScale * Utils.Map( distSqr, attackDistSqr, 0f, 1f, 4f, EasingType.Linear );
		}

		//return 1f;
		return _personalSpeedScale;
		//return (0.6f + Utils.FastSin( MoveTimeOffset + Manager.Instance.ElapsedTime * _personalSpeedFreq ) * 0.4f) * _personalSpeedScale;
	}

	public void SetPlaybackRate( float rate )
	{
		ModelRenderer.PlaybackRate = rate;
	}

	protected virtual void HandleTarget()
	{
		if ( HasTarget )
		{
			if ( !TargetUnit.IsValid() )
			{
				LoseTarget();
				return;
			}
		}

		if ( _timeSinceCheckTarget > _checkTargetDelay * (1f / TimeScale) )
			CheckForTarget();

		if ( HasTarget )
		{
			HandleAttacking( TargetUnit );
			TargetPos = TargetUnit.Position2D + GetTargetOffset();
		}
		else
		{
			if ( TimeSinceSpawn > _alwaysTargetPlayerTime )
			{
				var closestPlayer = Manager.Instance.GetClosestPlayer( Position2D );
				if ( closestPlayer.IsValid() )
					GainTarget( closestPlayer, playSfx: false );
			}
			else
			{
				HandleWandering();
			}
		}
	}

	protected virtual Vector2 GetTargetOffset()
	{
		return Vector2.Zero;
	}

	protected void HandleWandering()
	{
		var wanderDistSqr = (TargetPos - Position2D).LengthSquared;
		if ( wanderDistSqr < 50f * 50f || _timeSinceWander > _wanderTimeout * (1f / TimeScale) )
		{
			var closestPlayer = Manager.Instance.GetClosestPlayer( Position2D );
			if ( closestPlayer != null )
			{
				Vector2 closestPlayerPos = closestPlayer.Position2D;
				float rndOffset = 400f;
				TargetPos = new Vector2(
					MathX.Clamp( closestPlayerPos.x + Game.Random.Float( -rndOffset, rndOffset ), Manager.Instance.BOUNDS_MIN.x, Manager.Instance.BOUNDS_MAX.x ),
					MathX.Clamp( closestPlayerPos.y + Game.Random.Float( -rndOffset, rndOffset ), Manager.Instance.BOUNDS_MIN.y, Manager.Instance.BOUNDS_MAX.y )
				);
			}
			else
			{
				TargetPos = new Vector2( Game.Random.Float( Manager.Instance.BOUNDS_MIN.x, Manager.Instance.BOUNDS_MAX.x ), Game.Random.Float( Manager.Instance.BOUNDS_MIN.y, Manager.Instance.BOUNDS_MAX.y ) );
			}

			_timeSinceWander = 0f;
			_wanderTimeout = Game.Random.Float( WANDER_TIMEOUT_MIN, WANDER_TIMEOUT_MAX );
		}
	}

	public void CheckForTarget()
	{
		var closestPlayer = DetectClosestPlayer( out float closestDistSqr );

		if ( closestPlayer.IsValid() )
		{
			GainTarget( closestPlayer );
		}
		else if ( HasTarget )
		{
			if ( TargetUnit.IsValid() && TargetUnit.IsDying )
			{
				LoseTarget();
			}
		}

		_timeSinceCheckTarget = 0f;
		_checkTargetDelay = Game.Random.Float( CHECK_TARGET_TIME_MIN, CHECK_TARGET_TIME_MAX );
	}

	public Unit DetectClosestPlayer( out float closestDistSqr )
	{
		Unit closestPlayer = null;
		closestDistSqr = float.MaxValue;

		var traceResults = Scene.Trace.Sphere( DetectTargetRange, WorldPosition, WorldPosition ).WithTag( "player" ).HitTriggersOnly().RunAll().ToList();
		foreach ( var tr in traceResults )
		{
			var gameObject = tr.GameObject;
			
			var player = gameObject.GetComponent<Player>();
			if ( !player.IsValid() )
				continue;

			if ( player.IsDying || player.IsInTheAir )
				continue;

			var distSqr = (player.Position2D - Position2D).LengthSquared;
			if ( distSqr < closestDistSqr )
			{
				closestPlayer = player;
				closestDistSqr = distSqr;
			}
		}

		return closestPlayer;
	}

	public virtual void GainTarget( Unit unit, bool playSfx = true )
	{
		if ( !CanHaveTarget )
			return;

		if ( !HasTarget && playSfx && Game.Random.Float(0f, 1f) < 0.25f )
			Manager.Instance.PlaySfxNearbyRpc( "zombie.alert", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 0.65f, maxDist: 300f );

		HasTarget = true;
		TargetUnit = unit;
		//_timeSinceSawTarget = 0f;
	}

	protected virtual void LoseTarget()
	{
		HasTarget = false;
		TargetUnit = null;
		_timeSinceWander = 0f;
		_wanderTimeout = Game.Random.Float( WANDER_TIMEOUT_MIN, WANDER_TIMEOUT_MAX );

		if ( IsAttacking )
			StopAttacking();
	}

	protected virtual void HandleAttacking( Unit target )
	{
		if ( !target.IsValid() )
			return;

		var targetPlayer = target as Player;
		if ( targetPlayer.IsValid() && targetPlayer.IsDead )
			return;

		float distSqr = (target.Position2D - Position2D).LengthSquared;
		float attackDistSqr = MathF.Pow( AggroRange, 2f );

		if ( !IsAttacking )
		{
			if ( CanAttack )
			{
				if ( distSqr < attackDistSqr && !target.IsInTheAir )
				{
					_aggroTimer += Time.Delta;
					if ( _aggroTimer > AGGRO_START_TIME )
					{
						StartAttackingRpc();
						_aggroTimer = 0f;
					}
				}
				else
				{
					_aggroTimer = 0f;
				}
			}
		}
		else
		{
			if ( distSqr > attackDistSqr || target.IsInTheAir )
			{
				_aggroTimer += Time.Delta;
				if ( _aggroTimer > AGGRO_LOSE_TIME )
				{
					StopAttacking();
				}
			}
			else
			{
				//AnimSpeed = Utils.Map(dist_sqr, attack_dist_sqr, 0f, 1f, 4f, EasingType.Linear);
				_aggroTimer = 0f;
			}
		}
	}

	[Rpc.Broadcast]
	public void StartAttackingRpc()
	{
		StartAttacking();
	}

	public virtual void StartAttacking()
	{
		if( CanAnimate )
			PlayAttackAnim();

		IsAttacking = true;

		if ( IsProxy )
			return;
	}

	[Rpc.Broadcast]
	public void StopAttacking()
	{
		IsAttacking = false;

		if ( CanAnimate )
			PlayWalkAnim();

		if ( IsProxy )
			return;
	}

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

		base.Flash( time, flashType );

		if ( !ModelRenderer.IsValid() )
			return;

		Material mat = Manager.Instance.UnitFlashMaterials[flashType];
		ModelRenderer.SetMaterial( mat );

		ModelRenderer.Tint = Color.White;
	}

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

		ResetMaterial();
		ModelRenderer.Tint = GetCurrentTint();
	}

	protected Color GetCurrentTint()
	{
		return Color.Lerp( TintFullHp, TintZeroHp, Utils.Map( Health, MaxHealth, 0f, 0f, 1f, EasingType.SineOut ) );
	}

	protected virtual void ResetMaterial()
	{
		ModelRenderer.ClearMaterialOverrides();
	}

	[Rpc.Host]
	public void NotifyMeleeHitPlayerRpc( Player player )
	{
		OnMeleePlayer( player );
	}

	[Rpc.Broadcast]
	public void DamageRpc( float damage, Player player, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool shouldFlinch = true, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		Damage( damage, player, damageType, hitPos, force, isCrit, shouldFlinch, damageFlags );
	}

	protected virtual void Damage( float damage, Player player, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool shouldFlinch = true, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		var inputDamage = damage;
		var healthBefore = Health;
		var armorBefore = Armor;

		var dir = force.Normal;

		var totalDamage = damage;

		if ( (IsShielded && damageType != DamageType.Self) || IsInvincible )
		{
			damage = 0f;
			totalDamage = 0f;
			force = Vector2.Zero;
			isCrit = false;
		}
		else
		{
			if ( player.IsValid() )
			{
                GetAdditionalDamageFromPlayer( ref damage, ref damageFlags, player, damageType, dir );
				totalDamage = damage;

				if ( !player.IsProxy )
					player.OnDamageEnemy( this, damage, damageType, dir, isCrit );

				_prevPlayerDamagedBy = player;

				if ( !_playersDamagedBy.Contains( player ) )
					_playersDamagedBy.Add( player );
			}

			if ( !IsDying )
			{
				if ( Armor > 0 )
				{
					int armorDamage;

					// If damage has a decimal and armor can absorb all damage, use probabilistic rounding
					float decimalPart = damage - MathF.Floor( damage );
					if ( decimalPart > 0f && Armor > damage )
					{
						// Probabilistic rounding: preserves expected damage value
						if ( Game.Random.Float( 0f, 1f ) > decimalPart )
							armorDamage = (int)MathF.Floor( damage );
						else
							armorDamage = (int)MathF.Ceiling( damage );
					}
					else
					{
						// Armor can't absorb all damage, or damage is whole number - floor it
						armorDamage = (int)MathF.Min( Armor, MathF.Floor( damage ) );
					}

					if ( armorDamage > 0 )
					{
						damage -= armorDamage;
						LoseArmor( armorDamage, -dir );
					}
				}

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

				OnAdjustHealth( -Math.Min( Health, damage ) );
				//Health = Health - damage;

				////if ( Health > 0f )
				//	Flash( 0.12f, UnitFlashType.EnemyDmg );

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

		// Capture large inputs even when blocked, and small inputs amplified by player modifiers.
		// Log before status callbacks/death handling, which can trigger more damage or a shield phase.
		const float LARGE_BOSS_HIT_THRESHOLD = 1000f;
		if ( !IsProxy && this is Boss && (inputDamage >= LARGE_BOSS_HIT_THRESHOLD || totalDamage >= LARGE_BOSS_HIT_THRESHOLD) )
		{
			Log.Info( $"[BossDamage] boss={GameObject.Name} type={damageType} input={inputDamage:F2} calculated={totalDamage:F2} hp={healthBefore:F2}->{Health:F2}/{MaxHealth:F2} armor={armorBefore}->{Armor} crit={isCrit} flags={damageFlags} shielded={IsShielded} invincible={IsInvincible} dying={IsDying} player={(player.IsValid() ? player.GameObject.Name : "none")} t={Manager.Instance.ElapsedTime:F2}s" );
		}

		DamageVfx( totalDamage, damageType, hitPos, dir, shouldFlinch, isCrit, player, damageFlags );

		if ( IsProxy )
			return;

		if ( isCrit && player.IsValid() )
			player.AddResultsStatRpc( ResultStat.Crit, 1 );

		//if( damage > 0f )
		{
			for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
				UnitStatuses.Values.ElementAt( i ).OnHurt( damage, player, enemySource: null, damageType, isSelfInflicted: false );
		}

		//if( totalDamage > 0f )
		{
			for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
				UnitStatuses.Values.ElementAt( i ).OnHit( totalDamage, player, enemySource: null, damageType, isSelfInflicted: false );
		}

		//if( damageType == DamageType.Bullet )
		{
			ExplosionVelocity += force;
		}

		if ( IsDying )
			return;

		if ( Health <= 0f )
		{
			var hitStrength = Utils.Map( damage, 1f, 5f, 0.25f, 1f, EasingType.Linear ) * Utils.Map( damage, 5f, 100f, 1f, 3f, EasingType.SineIn );

			if ( damageType == DamageType.Explosion )
				hitStrength *= 2.5f;

			StartDying( dir, hitStrength, player, damageType );
		}
		else
		{
			if ( player.IsValid() )
			{
				if ( CanHaveTarget )
				{
					if ( !HasTarget || (player.Position2D - Position2D).LengthSquared < (TargetUnit.Position2D - Position2D).LengthSquared * 0.8f )
						GainTarget( player );
				}

				//if ( player.IsValid() )
				//	Stun( player, enemySource: null, 0.4f );

				//BecomeInvincible( 1f );

				//player.Chain( anchorUnit: this, chainPos: Position2D, chainLength: 150f, lifetime: 4f );

				//if( Manager.Instance.Players.Count == 2 )
				//{
				//	if ( player == Manager.Instance.Players[0] )
				//		player.Chain( anchorUnit: Manager.Instance.Players[1], chainPos: Manager.Instance.Players[1].Position2D, chainLength: 150f, lifetime: 4f );
				//	else
				//		player.Chain( anchorUnit: Manager.Instance.Players[0], chainPos: Manager.Instance.Players[0].Position2D, chainLength: 150f, lifetime: 4f );
				//}

				if ( player.IsValid() && damageType == DamageType.Explosion && Game.Random.Float( 0f, 1f ) < player.GetSyncStat(PlayerStat.ExplosionFearChance) )
				{
					//if ( !IsFearful )
					//	PlaySfx( "fear", unit.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 0.7f );

					Fear( player, enemySource: null, lifetime: player.GetSyncStat(PlayerStat.FearLifetime) );
				}
			}
		}
	}

	void DamageVfx( float damage, DamageType damageType, Vector3 hitPos, Vector2 dir, bool shouldFlinch, bool isCrit, Player player, DamageResultFlags damageFlags )
	{
		float size;
		if ( damage < 5f ) size = Utils.Map( damage, 1f, 5f, 1.4f, 1.65f, EasingType.QuadOut );
		else if ( damage < 20f ) size = Utils.Map( damage, 5f, 20f, 1.8f, 2.2f, EasingType.Linear );
		else size = Utils.Map( damage, 20f, 100f, 2.2f, 3f, EasingType.Linear );

		if ( isCrit ) size *= 1.2f;
		//if ( flags.HasFlag(DamageResultFlags.Backstab) ) size *= 1.1f;
		//if ( flags.HasFlag(DamageResultFlags.CoupDeGrace) ) size *= 1.1f;

        // FLOATER
        Color floaterColor;
		if ( damageType == DamageType.Poison ) floaterColor = damageFlags.HasFlag( DamageResultFlags.PoisonIncreaseNearby ) ? new Color( 0f, 0.3f, 0f ) : 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.FrostArmor ) floaterColor = new Color( 0.75f, 0.75f, 1f );
		else if ( damageType == DamageType.OrbitingBlade ) floaterColor = new Color( 0.05f, 0.05f, 0.05f );
		else if ( damageFlags.HasFlag( DamageResultFlags.DodgeHpDamage ) ) floaterColor = new Color( 1f, 0.5f, 0.5f );
		else if ( damageType == DamageType.Radiation ) floaterColor = new Color( 0.65f, 1f, 0.3f );
		else if ( damageFlags.HasFlag( DamageResultFlags.Mark ) ) floaterColor = new Color( 0.75f, 0.4f, 1f );
		else floaterColor = isCrit ? Color.Yellow : Color.White;

		FloaterType floaterType = FloaterType.Damage;
		if ( damageType == DamageType.Poison ) floaterType = FloaterType.Poison;
		else if ( damageType == DamageType.PoisonFinish ) floaterType = FloaterType.PoisonFinish;
		else if ( damageType == DamageType.Shock ) floaterType = FloaterType.Shock;
		else if ( damageType == DamageType.Fire ) floaterType = FloaterType.Fire;
		else if ( damageType == DamageType.FrostArmor ) floaterType = FloaterType.FrostDmg;
		else if ( damageType == DamageType.OrbitingBlade ) floaterType = FloaterType.OrbiterBlade;
		else if ( damageType == DamageType.Thorns ) floaterType = FloaterType.Thorns;
		else if ( damageType == DamageType.Radiation ) floaterType = FloaterType.Radiation;
		else if ( damageFlags.HasFlag( DamageResultFlags.Backstab ) ) floaterType = FloaterType.Backstab;
		else if ( damageFlags.HasFlag( DamageResultFlags.DodgeHpDamage ) ) floaterType = FloaterType.DodgeHpDamage;
		else if ( damageFlags.HasFlag( DamageResultFlags.Mark ) ) floaterType = FloaterType.Mark;

		var showVagueDmgFloater = player.IsValid() && player.GetSyncStat( PlayerStat.DontCauseDmgNumbers ) > 0f;

		if ( showVagueDmgFloater )
		{
			string str = CurseDontCauseDmgNumbers.GetDmgNumberText( this, damage ) + Manager.GetDamageResultExtraText( damageFlags );
			Manager.Instance?.SpawnFloaterText( hitPos, str, floaterColor, size, floaterType );
		}
		else
		{
			Manager.Instance?.SpawnDamageNumber( hitPos, damage, floaterColor, size, floaterType, damageFlags );
		}

		if ( damageType == DamageType.Bullet || damageType == DamageType.Boomerang || damageType == DamageType.Spear || damageType == DamageType.Punch )
		{
			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);
			Color impactEffectColor = isCrit ? Color.Yellow : Color.White;
			Manager.Instance.SpawnBulletImpactParticles( hitPos, -dir, impactEffectColor, scaleMultiplier );
		}

		PlayHurtSfx( damage, damageType, hitPos, player, damageFlags );

		if ( !(damage > 0f) )
		{
			var invincibleShakeStrength = 4f;
			invincibleShakeStrength *= (1f / WorldScale.x);
			Shake( startStrength: invincibleShakeStrength, endStrength: invincibleShakeStrength, time: 0.025f );

			return;
		}

		if( !IsDying )
		{
			Flash( 0.12f, UnitFlashType.EnemyDmg );

			if ( shouldFlinch )
			{
				if ( shouldFlinch && CanAnimate )
					Flinch( time: Game.Random.Float( 0.08f, 0.12f ), dir );
			}

			if( CanHitstop )
			{
				var hitstopTime = damage < 5f
				? Utils.Map( damage, 0f, 5f, 0f, 0.05f, EasingType.Linear )
				: Utils.Map( damage, 5f, 100f, 0.05f, 0.2f, EasingType.QuadIn );
				//Hitstop( hitstopTime * Game.Random.Float( 0.95f, 1.05f ) * HitstopTimeModifier );
				Hitstop( hitstopTime );
			}
		}

		// shake
		var shakeStrength = damage < 5f
			? Utils.Map( damage, 0f, 5f, 0.5f, 3f, EasingType.Linear )
			: Utils.Map( damage, 5f, 100f, 3f, 5f, EasingType.QuadIn );
		shakeStrength *= (1f / WorldScale.x); // shake less the bigger the enemy is

		var shakeTime = damage < 5f
			? Utils.Map( damage, 0f, 5f, 0f, 0.025f, EasingType.Linear )
			: Utils.Map( damage, 5f, 100f, 0.025f, 0.05f, EasingType.QuadIn );
		Shake( startStrength: shakeStrength, endStrength: shakeStrength, time: shakeTime );
	}

	public virtual void PlayHurtSfx( float damage, DamageType damageType, Vector3 hitPos, Player player, DamageResultFlags damageFlags )
	{
		// sfx
		if ( _realTimeSinceHurtSfx > 0.0175f )
		{
			var isFromPlayer = player.IsValid();

			if ( damage == 0f )
			{
				Manager.Instance.PlaySfxNearby( "bullet.impact", hitPos, pitch: Game.Random.Float( 1.4f, 1.5f ), volume: 1f, maxDist: 300f );
			}
			else if( damageFlags.HasFlag( DamageResultFlags.DodgeHpDamage ) )
			{
				Manager.Instance.PlaySfxNearby( "dodge_hp_damage", hitPos, pitch: Game.Random.Float( 0.85f, 0.9f ), volume: 1.4f, maxDist: 400f );
			}
			else if ( damageType == DamageType.Bullet || damageType == DamageType.Boomerang || damageType == DamageType.Spear || damageType == DamageType.Punch )
			{
				if ( damageType == DamageType.Punch )
					Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 1.5f, 1.7f, EasingType.SineIn ), volume: 0.95f, maxDist: 400f );
				else
					Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.9f, 1.25f, EasingType.SineIn ), volume: 0.95f, maxDist: 400f );
			}
			//else if ( Player.IsDamageTypeMelee( damageType ) && !isFromPlayer )
			//{
			//	if ( _timeSinceInfightingDamageSfx > 0.25f )
			//	{
			//		if ( damageType == DamageType.MeleeRunnerBite )
			//			Manager.Instance.PlaySfxNearby( "runner.bite", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 0.6f, maxDist: 400f );
			//		else
			//			Manager.Instance.PlaySfxNearby( "zombie.attack.player", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 0.6f, maxDist: 400f );

			//		_timeSinceInfightingDamageSfx = 0f;
			//	}
			//}
			else if ( damageType == DamageType.DashSlash ) { Manager.Instance.PlaySfxNearby( "player.dash.slash.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.9f, 1.1f, EasingType.SineIn ) * Game.Random.Float( 0.95f, 1.05f ), volume: 0.8f, maxDist: 350f ); }
			else if ( damageType == DamageType.Fire ) { Manager.Instance.PlaySfxNearby( "burn_2", hitPos, pitch: Game.Random.Float( 1.15f, 1.35f ), volume: 0.7f, maxDist: 300f ); }
			else if ( damageType == DamageType.Poison ) { Manager.Instance.PlaySfxNearby( "poisoned", hitPos, pitch: Game.Random.Float( 1.55f, 1.65f ) * (damageFlags.HasFlag(DamageResultFlags.PoisonIncreaseNearby) ? 2f : 1f), volume: 0.35f, maxDist: 300f ); }
			else if ( damageType == DamageType.SpikerHead ) { Manager.Instance.PlaySfxNearby( "spike.stab", hitPos, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.8f, maxDist: 300f ); }
			else if ( damageType == DamageType.SpitterProjectile || damageType == DamageType.SpitterProjectileHoming ) { Manager.Instance.PlaySfxNearby( "splash", hitPos, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f ); }
			else if ( damageType == DamageType.Aoe || damageType == DamageType.BulletSplash ) { /* no sfx */ }
			else if ( damageType == DamageType.Radiation ) { /* no sfx */ }
			else if ( damageType == DamageType.Shock ) { /* no sfx */ }
			else if ( damageType == DamageType.Explosion ) { /* no sfx */ }
			else if ( damageType == DamageType.JumpFinish ) { Manager.Instance.PlaySfxNearby( "slam", hitPos, pitch: Game.Random.Float( 0.85f, 0.95f ), volume: 0.8f, maxDist: 150f ); }
			else if ( damageType == DamageType.OrbitingBlade ) { Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.5f, 0.7f, EasingType.SineIn ), volume: 0.6f, maxDist: 300f ); }
			else { Manager.Instance.PlaySfxNearby( "enemy.hit", hitPos, pitch: Utils.Map( Health, MaxHealth, 0f, 0.9f, 1.25f, EasingType.SineIn ) * Game.Random.Float( 0.95f, 1.05f ), volume: 0.85f, maxDist: 300f ); }
			
			// todo: other hit sfx

			_realTimeSinceHurtSfx = 0f;
		}
	}

    // todo: canBackstab param is redundant, can be determined from dir param
    void GetAdditionalDamageFromPlayer( ref float damage, ref DamageResultFlags flags, Player player, DamageType damageType, Vector2 dir )
	{
		if ( !player.IsValid() )
			return;

		float dmgMult = 1f;
		float dmgAdd = 0f;
		GetAdditionalDamageFromPlayer( ref dmgMult, ref dmgAdd, ref flags, player, damageType, dir );
		damage += dmgAdd;
		damage *= dmgMult;
	}

	void GetAdditionalDamageFromPlayer( ref float mult, ref float add, ref DamageResultFlags flags, Player player, DamageType damageType, Vector2 dir  )
	{
		mult *= player.GetSyncStat( PlayerStat.OverallDamageMultiplier );

		if( damageType != DamageType.Bullet )
			mult *= player.GetSyncStat( PlayerStat.NonBulletDamageMultiplier );

		var maxHp = player.GetSyncStat( PlayerStat.MaxHp );

		var lowHealthMult = player.GetSyncStat( PlayerStat.LowHealthDamageMultiplier );
		if ( lowHealthMult > 1f )
			mult *= Utils.Map( player.Health, maxHp, 0f, 1f, lowHealthMult );

		var fullHealthMult = player.GetSyncStat( PlayerStat.FullHealthDamageMultiplier );
		if ( fullHealthMult > 1f && !(player.Health < maxHp) )
			mult *= fullHealthMult;

		var numBanishMult = player.GetSyncStat( PlayerStat.DamagePercentPerBanished );
		if ( numBanishMult > 0f )
			mult *= (1f + player.NumBanishedPerks * numBanishMult);

		var zeroRerollMult = player.GetSyncStat( PlayerStat.ZeroRerollDmgMult );
		if ( zeroRerollMult > 0f && player.NumRerollAvailable == 0 )
			mult *= (1f + zeroRerollMult);

		if ( IsFrozen )
		{
			if ( damageType == DamageType.Fire )
			{
				mult *= player.GetSyncStat(PlayerStat.FreezeFireDamageMultiplier);
			}
		}

		if ( IsFearful )
			mult *= player.GetSyncStat(PlayerStat.FearDamageMultiplier); // todo: these multipliers will stack too much?

		bool didBackstab = CanBeBackstabbed
			&& dir.LengthSquared > 0f
			&& (damageType == DamageType.Bullet || damageType == DamageType.Punch)
			&& player.GetSyncStat( PlayerStat.BackstabBonusDamagePercent ) > 0f
			&& Vector2.Dot( dir, (Vector2)WorldRotation.Forward ) > 0.1f;
		if ( didBackstab )
		{
			flags |= DamageResultFlags.Backstab;
			mult *= (1f + player.GetSyncStat( PlayerStat.BackstabBonusDamagePercent ));
		}

		if ( !(Health < MaxHealth) && player.GetSyncStat(PlayerStat.HealthyUnitDamagePercent) > 1f )
		{
			flags |= DamageResultFlags.FullHealthEnemy;
			mult *= player.GetSyncStat(PlayerStat.HealthyUnitDamagePercent);
		}

		bool didAlternateDmg = player.GetSyncStat( PlayerStat.AlternatePlayerDamagePercent ) > 0f
			&& _prevPlayerDamagedBy.IsValid()
			&& _prevPlayerDamagedBy != player;
		if ( didAlternateDmg )
		{
			flags |= DamageResultFlags.AlternateDmg;
			mult *= (1f + player.GetSyncStat( PlayerStat.AlternatePlayerDamagePercent ));
			// todo: sfx
		}

		bool didCoupDeGrace = player.GetSyncStat(PlayerStat.CoupDeGracePercent) > 0f && !_playersDamagedBy.Contains( player );
		if ( didCoupDeGrace )
		{
			flags |= DamageResultFlags.CoupDeGrace;
			float dmgAdd = (MaxHealth - Health) * player.GetSyncStat( PlayerStat.CoupDeGracePercent );
			if ( dmgAdd > 0f )
			{
				add += dmgAdd;// * GetDamageMultiplier();
				// todo: sfx
			}
		}

		//if ( IsFrozen && TimeSinceFrozen > 0.1f && player.GetSyncStat( PlayerStat.FreezeDoubleDmg ) > 0f )
		//{
		//	mult *= 2f; // todo: multiplier should be added with other multipliers, instead of deciding if damage is doubled before or after other dmg modifiers?

		//	Manager.Instance.PlaySfxNearbyRpc( "frozen", Position2D, pitch: Game.Random.Float( 1.4f, 1.45f ), volume: 1.2f, maxDist: 400f );


		//	// does this work? needs to be done with a broadcast instead? maybe remove freeze dbl damage perk
		//	if (!IsProxy)
		//		RemoveUnitStatus<UnitStatusFreeze>();
		//}
	}

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

		_flinchStartAnimProgress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;

		if( CanAnimate )
			SetPlaybackRate( Game.Random.Float( 0.5f, 2.5f ) );

		ModelRenderer.Sequence.Blending = false;
		
		PlayFlinchAnim();
	}

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

		ModelRenderer.Sequence.Blending = true;

		if ( !CanAnimate )
			return;

		if ( IsAttacking )
			PlayAttackAnim();
		else
			PlayWalkAnim();

		if ( ModelRenderer.SceneModel.CurrentSequence != null )
			ModelRenderer.SceneModel.CurrentSequence.TimeNormalized = _flinchStartAnimProgress;
	}

	[Rpc.Broadcast]
	public void ExecuteRpc( Vector2 dir, float hitStrength, Player player, DamageType damageType )
	{
		Manager.Instance.PlaySfxNearby( "execute2", Position2D, pitch: Game.Random.Float(1.1f, 1.2f ), volume: 1f, maxDist: 350f );
		GameObject.Clone( "prefabs/effects/execute.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition + Vector3.Up * Height * 1.5f ) } );

		if ( IsProxy )
			return;

		StartDying( dir, hitStrength, player, damageType );
	}

	protected virtual void StartDying( Vector2 dir, float force, Player player, DamageType damageType )
	{
		Assert.True( !IsProxy );

		IsDying = true;
		IsSpawning = false;

		var willCombust = player.IsValid() && player.CombustionActive && CanCombust;
		if ( IsMiniboss )
			StartMinibossDeathWatchdog( willCombust ? "combust" : "direct", player, damageType );

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

		if ( willCombust )
			Combust( player );
		else
			DieRpc( dir, force, player, damageType );
	}

	// Host-side diagnostics for the "miniboss died but no chest" reports: every miniboss death must end in
	// DropLoot spawning a chest, so log when a death starts and warn if it stalls before getting there
	protected void StartMinibossDeathWatchdog( string path, Player player, DamageType damageType )
	{
		_minibossDeathStarted = true;
		_timeSinceMinibossDeathStarted = 0f;
		Log.Info( $"[MinibossDeath] {EnemyType} StartDying ({path}, {damageType}, player={(player.IsValid() ? player.GameObject.Name : "none")}) at {Position2D}, t={Manager.Instance.ElapsedTime:F1}s" );
	}

	public void Combust( Player player )
	{
		StartExplodingRpc( time: 1.5f, player.CombustionRadius, MaxHealth * player.CombustionDamageFactor, player );
		player.DisableCombustion();
	}

	[Rpc.Broadcast]
	public void DieRpc( Vector2 dir, float force, Player player, DamageType damageType )
	{
		Die( dir, force, player, damageType );
	}

	public virtual void Die( Vector2 dir, float force, Player player, DamageType damageType )
	{
		SpawnGibs( dir, force, damageType );

		PlayDeathSfx( Position2D );

		if ( IsProxy )
			return;

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

		if ( player.IsValid() )
			player.KillEnemy( this, damageType );

		if ( damageType == DamageType.Explosion )
			Manager.Instance.NumExplosionKilledEnemies++;

		DropLoot( player );

		Remove();
	}

	public void Remove()
	{
		Manager.Instance.RemoveEnemy( EnemyType );

		GameObject.Destroy();
	}

	protected virtual void PlayDeathSfx( Vector2 pos )
	{
		Manager.Instance.PlayEnemyDeathSfx( Position2D );

		if ( IsMiniboss )
			Manager.Instance.PlaySfxNearby( "miniboss_die", pos, pitch: Game.Random.Float( 1.7f, 1.9f ), volume: 2.5f, maxDist: 1000f );
	}

	protected virtual void DropLoot( Player player )
	{
		if ( IsMiniboss )
		{
			Vector2 chestPos = Manager.Instance.ClampPosToBounds( Position2D + Utils.GetRandomVector() * Game.Random.Float( 1f, 7f ) );
			Log.Info( $"[MinibossDeath] {EnemyType} died at {Position2D}, t={Manager.Instance.ElapsedTime:F1}s — spawning chest at {chestPos}" );
			Manager.Instance.SpawnEnemy( EnemyType.Chest, chestPos, rotAngle: -90f + Game.Random.Float( -30f, 30f ) );
			Log.Info( $"[MinibossDeath] SpawnEnemy(Chest) call completed for {EnemyType}" );
			_minibossChestSpawned = true;
		}

		var coinChance = !player.IsValid() || Manager.Instance.ElapsedTime > 60f
			? CoinChance
			: Utils.Map( player.Level, 0, 3, 1f, CoinChance );

		var dropDir = player.IsValid() ? (player.Position2D - Position2D).Normal : Utils.GetRandomVector();

		if ( Game.Random.Float( 0f, 1f ) <= coinChance )
		{
			var magnetizeChance = player.IsValid() ? player.GetSyncStat( PlayerStat.KillMagnetizeCoinChance ) : 0f;
			Player magnetizePlayer = Game.Random.Float(0f, 1f) < magnetizeChance ? player : null;

			Manager.Instance.SpawnCoin( Position2D, value: Game.Random.Int( CoinValueMin, CoinValueMax ), dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 200f ), magnetizePlayer );
			return;
		}

		var lowestHpPercent = 1f;
		foreach ( Player p in Manager.Instance.AlivePlayers )
			lowestHpPercent = MathF.Min( lowestHpPercent, p.HpPercent );

		var healthPackCount = Scene.GetAllComponents<HealthPack>().Count();
		var healthPackChance = Utils.Map( lowestHpPercent, 1f, 0f, 0f, 0.08f ) * Utils.Map( healthPackCount, 0, 4, 1f, 0f, EasingType.SineOut );
		healthPackChance *= Utils.Select( Manager.Instance.Difficulty, 1.2f, 0.8f, 0.65f );
		healthPackChance *= HealthPackChanceMultiplier;

		if ( Manager.Instance.HasSpawnedBoss )
			healthPackChance *= Utils.Map( Manager.Instance.ElapsedTime, Manager.BOSS_SPAWN_MINUTES_DEFAULT, 40f, 1f, 0.1f );

		if ( Game.Random.Float( 0f, 1f ) < healthPackChance )
		{
			Manager.Instance.SpawnItemRpc( "health_pack", Position2D, dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 200f ) );
			return;
		}

		var minutes = Manager.Instance.ElapsedTime / 60f;
		if ( Game.Random.Float( 0f, 1f ) < Utils.Map( minutes, 0f, 5f, 0.03f, 0.005f ) )
		{
			Manager.Instance.SpawnItemRpc( "reroll_item", Position2D, dir: Utils.GetRandomVectorInCone( dropDir, coneDegrees: 200f ) );
			return;
		}
	}

	public virtual void SetAnim( string name, bool forceRestart = false )
	{
		if( ModelRenderer is null )
			return;

		if ( !forceRestart && _currAnimName == name )
			return;

		ModelRenderer.Sequence.Name = name;
		_currAnimName = name;
	}

	protected virtual void PlaySpawnAnim()
	{
		SetAnim( "Spawn" );
	}

	protected virtual void PlayAttackAnim()
	{
		SetAnim( "Attack" );
	}

	protected virtual void PlayWalkAnim()
	{
		SetAnim( "Walk" );
	}

	protected virtual void PlayFlinchAnim()
	{
		if ( !CanAnimate )
			return;

		SetAnim( "Hurt", forceRestart: true );
	}

	protected virtual void PlayJumpAnim()
	{
		SetAnim( "Jump" );
	}

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

		if ( !IsProxy && IsMiniboss && !_minibossChestSpawned && Manager.Instance.IsValid() && !Manager.Instance.IsGameOver )
			Log.Warning( $"[MinibossDeath] {EnemyType} destroyed without spawning a chest! deathStarted={_minibossDeathStarted} IsDying={IsDying} Health={Health} pos={Position2D} t={Manager.Instance.ElapsedTime:F1}s" );
	}

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

		//if ( IsInTheAir )
		//	return;

		if ( other is Enemy enemy )
		{
			if ( IsSpawning && !enemy.IsSpawning )
				return;

			if ( !Position2D.Equals( enemy.Position2D ) )
			{
				AddRepelVelocity( (Position2D - enemy.Position2D).Normal * enemy.PushStrength * percent * enemy.SpawnProgress * ( enemy.Weight / Weight ) * dt ); // todo: use ( 1f / Weight ) instead, so enemies can have huge weight and not move (like Trees) without pushing other enemies too much?
			}

			// punch impact
			//if (ExplosionVelocity.LengthSquared > 250f * 250f && _timeSinceDamageTarget > 0.25f)
			//{
			//	enemy.DamageRpc( 3f, this, DamageType.Aoe, enemy.Position2D, (enemy.Position2D - Position2D).Normal * ExplosionVelocity.Length * 0.6f, isCrit: false, shouldFlinch: true );
			//	//enemy.Stun( null, null, 0.15f );
			//	_timeSinceDamageTarget = 0f;
			//}
		}
		else if ( other is Player player )
		{
			if ( IsSpawning )
				return;

			if ( !Position2D.Equals( player.Position2D ) )
			{
				if ( !player.IsDead )
				{
					if ( !(player.IgnorePhysicsAmount > 0) )
						AddRepelVelocity( (Position2D - player.Position2D).Normal * player.PushStrength * percent * (player.Weight / Weight) * dt );
				}
				else
				{
					AddRepelVelocity( (Position2D - player.Position2D).Normal * player.PushStrength * percent * (player.Weight / Weight) * 0.2f * dt );
				}
			}
		}
		else
		{
			// obstacle
			AddRepelVelocity( (Position2D - other.Position2D).Normal * other.PushStrength * percent * (other.Weight / Weight) * dt );
		}
	}

	protected virtual void OnMeleePlayer( Player player )
	{

	}

	protected virtual void SpawnGibs( Vector2 dir, float force, DamageType damageType )
	{
		if ( IsBoss )
		{
			var bloodExplosionGo = GameObject.Clone( "prefabs/effects/blood_explosion_miniboss.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( Game.Random.Float( 20f, 40f ) ), Rotation.Identity ) } );
		}
		else
		{
			var bloodExplosionGo = GameObject.Clone( "prefabs/effects/blood_explosion.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( Game.Random.Float( 40f, 60f ) ), Rotation.Identity ) } );
			var bloodSprayer = bloodExplosionGo.GetComponentInChildren<BloodSprayer>();
			bloodSprayer.StopSprayingBlood();

			var sprayBloodChance = Utils.Map( Manager.Instance.NumEnemies, 0, 250, 1f, 0.3f ) * Utils.Map( Manager.Instance.NumDecals, 0, 200, 1f, 0f ) * (damageType == DamageType.Explosion ? Utils.Map( Manager.Instance.NumExplosionKilledEnemies, 0, 9, 1f, 0.15f ) : 1f);
			if ( ExtraDeathBloodSprayAmount > 0 )
				sprayBloodChance = 1f;

			if ( Game.Random.Float( 0f, 1f ) < sprayBloodChance )
			{
				var emitter = bloodSprayer.GetComponent<ParticleEmitter>();
				var particleEffect = bloodSprayer.GetComponent<ParticleEffect>();

				int numSpray = Game.Random.Int( (int)Utils.Map( Manager.Instance.NumDecals, 0, 120, 3, 1 ), (int)Utils.Map( Manager.Instance.NumDecals, 0, 150, 12, 7 ) ) + ExtraDeathBloodSprayAmount;
				//Log.Info( $"numSpray: {numSpray} ExtraDeathBloodSprayAmount: {ExtraDeathBloodSprayAmount}" );

				for ( int i = 0; i < numSpray; i++ )
					emitter.Emit( particleEffect );
			}
			else
			{
				bloodSprayer.GameObject.Enabled = false;
			}
		}
		
		if ( ShouldSpawnBloodDecal )
		{
			var pos = WorldPosition.WithZ( 1f );
			var rot = new Angles( 90f, Game.Random.Float(0f, 360f), 0f );
			var scale = Game.Random.Float( 0.95f, 1.45f );
			//scale = 1f;
			var bloodDecalSplatGo = GameObject.Clone( "prefabs/effects/decal_blood_splat.prefab", new global::Transform( pos, rot, new Vector3( 2f, scale, scale ) ) );
			var bloodDecalSplat = bloodDecalSplatGo.GetComponent<BloodSplatDecal>();
			bloodDecalSplat.Lifetime = Game.Random.Float( 2.5f, 4f ) * Utils.Map(Manager.Instance.NumEnemies, 5, 100, 2f, 0.5f );
			bloodDecalSplat.ColorFadeEasingType = EasingType.SineIn;

			//var bloodDecalGo = GameObject.Clone( "prefabs/effects/decal_blood.prefab", new global::Transform( WorldPosition.WithZ( 1f ), new Angles( 90f, Game.Random.Float( 0f, 360f ), 0f ) ) );
			//bloodDecalGo.GetComponent<BloodDecal>().Lifetime = Game.Random.Float( 5f, 8f ) * Utils.Map( Manager.Instance.NumDecals, 0, 30, 1f, 0.25f );
		}

		// todo: if the damageType is fire, gibs should fall instead of flying away - need to prevent initial overlap from giving force

		if ( HasLeftArm )
		{
			SpawnGoreGib(
				$"{GibFolder}/left_hand",
				localPos: new Vector3( 9.5f, 16f, 30f ) * GibOffsetMultiplier,
				localRot: new Angles( 0f, -60f, 0f ),
				//localScale: new Vector3( 0.8f, 0.347f, 0.147f ),
				scaleMultiplier: GibScaleMultiplier,
				dir,
				force,
				TintZeroHp,
				damageType
			);
		}

		SpawnGoreGib(
			$"{GibFolder}/left_hand",
			localPos: new Vector3( 9.5f, -16f, 30f ) * GibOffsetMultiplier,
			localRot: new Angles( 0f, 60f, 0f ),
			scaleMultiplier: GibScaleMultiplier,
			dir,
			force,
			TintZeroHp,
			damageType,
			isFlipped: true
		);

		SpawnGoreGib(
			$"{GibFolder}/left_foot",
			localPos: new Vector3( 0.6f, 1.8f, 11.5f ) * GibOffsetMultiplier,
			localRot: new Angles( 0f, 0f, -90f ),
			scaleMultiplier: GibScaleMultiplier,
			dir,
			force,
			TintZeroHp,
			damageType
		);

		SpawnGoreGib(
			$"{GibFolder}/left_foot",
			localPos: new Vector3( 0.6f, -1.8f, 11.5f ) * GibOffsetMultiplier,
			localRot: new Angles( 0f, 0f, -90f ),
			scaleMultiplier: GibScaleMultiplier,
			dir,
			force,
			TintZeroHp,
			damageType,
			isFlipped: true
		);

		SpawnGoreGib(
			$"{GibFolder}/head",
			localPos: new Vector3( 0f, 0, 50f ) * GibOffsetMultiplier,
			localRot: new Angles( 0f, 0f, 0f ),
			scaleMultiplier: GibScaleMultiplier,
			dir,
			force,
			TintZeroHp,
			damageType
		);

		SpawnGibOrgans( dir, force, damageType );
	}

	protected virtual void SpawnGibOrgans( Vector2 dir, float force, DamageType damageType )
	{
		SpawnGoreGib(
			"organ",
			localPos: new Vector3( 0f, 0, 28f ) * GibOffsetMultiplier,
			localRot: new Angles( 0f, 0f, 0f ),
			scaleMultiplier: GibScaleMultiplier,
			dir,
			force,
			TintZeroHp,
			damageType
		);

		// todo:

		//SpawnGoreGib(
		//	"organ_2",
		//	localPos: new Vector3( 0f, 0, 24f ),
		//	localRot: new Angles( 0f, 0f, 0f ),
		//	scaleMultiplier: 1f,
		//	dir,
		//	force,
		//	TintZeroHp,
		//	damageType
		//);

		//SpawnGoreGib(
		//	"organ_3",
		//	localPos: new Vector3( 0f, 0, 32f ),
		//	localRot: new Angles( 0f, 0f, 0f ),
		//	scaleMultiplier: 1f,
		//	dir,
		//	force,
		//	TintZeroHp,
		//	damageType
		//);

		//SpawnGoreGib(
		//	"organ_4",
		//	localPos: new Vector3( 0f, 0, 42f ),
		//	localRot: new Angles( 0f, 0f, 0f ),
		//	scaleMultiplier: 1f,
		//	dir,
		//	force,
		//	TintZeroHp,
		//	damageType
		//);

		//int numEyes = 2;
		//for ( int i = 0; i < numEyes; i++ )
		//{
		//	if( Game.Random.Float(0f, 1f) < 0.5f )
		//	{
		//		SpawnGoreGib(
		//			"eyeball",
		//			localPos: new Vector3( 0f, 0, 52f ),
		//			localRot: new Angles( 0f, 0f, 0f ),
		//			scaleMultiplier: 1f,
		//			dir,
		//			force,
		//			TintZeroHp,
		//			damageType
		//		);
		//	}
		//}
	}

	protected void SpawnGoreGib( string name, Vector3 localPos, Rotation localRot, float scaleMultiplier, Vector2 dir, float force, Color color, DamageType damageType, bool isFlipped = false )
	{
		float chance = Utils.Map( Manager.Instance.NumGibs, 0, 30, 1f, 0f, EasingType.SineOut ) * (damageType == DamageType.Explosion ? Utils.Map( Manager.Instance.NumExplosionKilledEnemies, 0, 9, 1f, 0.1f ) : 1f);

		if ( (EnemyType == EnemyType.Zombie || EnemyType == EnemyType.ZombieTemporary) )
			chance *= Utils.Map( Manager.Instance.NumEnemies, 40, 200, 1f, 0.5f );

		if( OverrideGibChance >= 0f )
			chance = OverrideGibChance;

		//Log.Info( $"SpawnGoreGib: {name} chance: {chance}" );
		if ( Game.Random.Float( 0f, 1f ) > chance )
			return;

		if ( !(dir.LengthSquared > 0f) )
			dir = Utils.GetRandomVector();

		// todo: place the gibs properly based on orientation
		var pos = WorldPosition + localPos;
		var rot = localRot;
		var scale = SpawnScale.x * scaleMultiplier;

		//var scale = SpawnScale * 1f;
		//var gib = GameObject.Clone( $"prefabs/gibs/{name}.prefab", new global::Transform( WorldPosition + offset, new Angles( Game.Random.Float( -5f, 5f ), WorldRotation.Yaw(), Game.Random.Float( -5f, 5f ) ), scale ) );
		var gib = GameObject.Clone( $"prefabs/gibs/{name}.prefab", new global::Transform( pos, rot, scale ) );

		if( gib == null )
		{
			Log.Error( $"Failed to spawn gib '{name}' for enemy '{EnemyType}'!" );
			return;
		}

		gib.Name = $"gib - {name}";

		var rigidBody = gib.GetComponent<Rigidbody>();
		var horizontalForceMultiplier = IsExploding ? 2f : 1f;
		var verticalForceMultiplier = IsExploding ? 0.5f : 1f;

		rigidBody.Velocity = new Vector3( dir.x * Game.Random.Float( 10f, 170f ) * horizontalForceMultiplier, dir.y * Game.Random.Float( 10f, 170f ) * horizontalForceMultiplier, Game.Random.Float( 100f, 320f ) * verticalForceMultiplier ) * force;
		rigidBody.AngularVelocity = new Vector3( Game.Random.Float( -15f, 15f ), Game.Random.Float( -15f, 15f ), Game.Random.Float( -15f, 15f ) ) * force;

		var gibFader = gib.GetComponent<GibFader>();
		gibFader.Lifetime = OverrideGibLifetime > 0f ? OverrideGibLifetime : Game.Random.Float( 1f, 3.5f );
		gibFader.Color = color;

		gib.GetComponent<ModelRenderer>().Tint = color;

		//gib.SetParent( null, keepWorldPosition: true );

		if(isFlipped)
			gib.WorldScale = gib.WorldScale.WithY( -gib.WorldScale.y );
	}

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

		PlayJumpAnim();

		//SetAnim( "Airborne_Flail_Movement" );
		CanAnimate = false;

		var playbackRate = Game.Random.Float( 2.5f, 3.5f );
		SetPlaybackRate( playbackRate );
		HitstopOldPlaybackSpeed = playbackRate;
	}

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

		if( Manager.Instance.IsGameOver )
		{
			if( !_isCelebrating )
				Celebrate( victory: Manager.Instance.IsBossDead );

			return;
		}

		CanAnimate = true;
		PlayWalkAnim();

		if ( IsProxy )
			return;

		_timeSinceDamageTarget = 0f;

		if( _jumpFinishDamage > 0f )
		{
			Damage( _jumpFinishDamage, _jumpFinishDamagePlayerSource, DamageType.JumpFinish, Position2D, force: Vector2.Zero, isCrit: false, shouldFlinch: true );
			_jumpFinishDamage = 0f;
			_jumpFinishDamagePlayerSource = null;

			Stun( _jumpFinishDamagePlayerSource, null, lifetime: _jumpFinishStunTime );
		}
	}

	[Rpc.Owner]
	public void DamageOnJumpFinishRpc( float damage, Player playerSource, float stunTime )
	{
		_jumpFinishDamage = damage;
		_jumpFinishDamagePlayerSource = playerSource;
		_jumpFinishStunTime = stunTime;
	}

	public virtual void OnGameOver( bool victory )
	{
		if ( IsDying || IsInTheAir )
			return;

		Celebrate( victory );
	}

	public virtual void Celebrate( bool victory )
	{
		_isCelebrating = true;
		CanAnimate = false;

		WorldRotation = new Angles( 0f, WorldRotation.Yaw(), 0f );
		WorldPosition = WorldPosition.WithZ( GroundZPos );

		ResetMaterial();

		PlayCelebrateAnim( victory );
	}

	protected virtual void PlayCelebrateAnim( bool victory )
	{
		if ( victory )
		{
			
		}
		else
		{
			
		}
	}

	protected virtual void HandleExploding()
	{
		//Gizmo.Draw.Color = Color.Red.WithAlpha(0.6f);
		//Gizmo.Draw.LineSphere( WorldPosition, EXPLOSION_RADIUS );

		float explodeTime = IsFrozen ? _explodeTime / Utils.Map( TimeScale, 1f, 0f, 1f, 0.75f ) : _explodeTime;

		if ( _timeSinceExplodeFlash > Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, EXPLODE_BLINK_DELAY_START, EXPLODE_BLINK_DELAY_END, EasingType.Linear ) )
		{
			_explodeFlashActive = !_explodeFlashActive;

			if ( _explodeFlashActive )
				ModelRenderer.SetMaterial( ExplodeFlashMaterial );
			else
				ResetMaterial();

			_timeSinceExplodeFlash = 0f;
		}

		HandleExplodingPlaybackRate( explodeTime );

		float horizontal = 1f + Utils.FastSin( _timeSinceExplodeStart * 16f ) * Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, 0f, 0.25f, EasingType.SineIn );
		float vertical = 1f + Utils.FastSin( _timeSinceExplodeStart * 16f ) * Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, 0f, -0.25f, EasingType.SineIn );
		WorldScale = new Vector3( _explodeStartScale.x * horizontal, _explodeStartScale.y * horizontal, _explodeStartScale.z * vertical );

		if ( IsProxy )
			return;

		if ( !_hasExploded && _timeSinceExplodeStart > explodeTime && !IsInTheAir )
			Explode();
	}

	protected virtual void HandleExplodingPlaybackRate( float explodeTime )
	{
		SetPlaybackRate( Utils.Map( _timeSinceExplodeStart, 0f, explodeTime, 1.2f, 0.15f, EasingType.SineOut ) * AnimSpeedModifier );
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void StartExplodingRpc( float time, float radius, float damage, Player player )
	{
		StartExploding( time, radius, damage, player );
	}

	protected virtual void StartExploding( float time, float radius, float damage, Player player )
	{
		if ( IsExploding )
			return;

		IsExploding = true;
		_timeSinceExplodeStart = 0f;
		SetAnim( _explodeAnim );
		// todo: randomize playback speed so multiple exploders don't sync up the anim
		CanAnimate = false;
		_timeSinceExplodeFlash = 0f;

		_explodeTime = time;
		_explodeStartScale = WorldScale;

		Manager.Instance.PlaySfxNearby( "exploder_fuse_2", Position2D, pitch: Game.Random.Float( 1.25f, 1.35f ), volume: 1.6f, maxDist: 450f );

		if ( IsProxy )
			return;

		_explosionRadius = radius;
		_explosionDamage = damage;
		_playerWhoKilledUs = player;
	}

	public virtual void Explode()
	{
		RepelOptions options = RepelOptions.RepelPlayers | RepelOptions.DamagePlayers | RepelOptions.RepelEnemies | RepelOptions.RepelItems;
		if(_explosionDamagesEnemies )
			options |= RepelOptions.DamageEnemies;

		//Manager.Instance.CreateExplosion( Position2D, _explosionRadius, _explosionDamage, repelRadius, force, _playerWhoKilledUs, Color.Red );
		//Manager.Instance.CreateExplosionRpc( Position2D, _explosionRadius, _explosionDamage, repelRadius: _explosionRadius * 1.4f, repelForce: _explodeForce, playerSource: _playerWhoKilledUs, enemySource: this, enemyType: EnemyType, color: _explosionColor, options );
		Manager.Instance.CreateExplosionRpc( Position2D, _explosionRadius, _explosionDamage, repelRadius: _explosionRadius * 1.4f, repelForce: _explodeForce, playerSource: null, enemySource: this, enemyType: EnemyType, color: _explosionColor, options );

		if ( _playerWhoKilledUs.IsValid() )
			_playerWhoKilledUs.AddResultsStatRpc( ResultStat.ExplosionsCaused, 1 );

		DieRpc( dir: Vector2.Zero, force: 4f, player: _playerWhoKilledUs, damageType: DamageType.Explosion );
	}

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

		if ( IsAttacking )
			StopAttacking();
	}

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

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

		if ( amount == 0f || Health >= MaxHealth )
			return;

		float hpMissing = MaxHealth - Health;
		float hpRecovered = Math.Min( hpMissing, amount );

		HealVfx( hpRecovered, playSfx );
	}

	[Rpc.Broadcast]
	public void HealVfx( float amount, bool playSfx = false )
	{
		Health += amount;

		OnAdjustHealth( amount );

		float size = Utils.Map( amount, 1, 30, 1.5f, 2.5f, EasingType.QuadOut );
		Manager.Instance.SpawnFloaterNumber( WorldPosition.WithZ( 65f ), amount, new Color( 0.3f, 1f, 0.3f ), size, FloaterType.Heal );

		Flash( 0.12f, UnitFlashType.Heal );

		if( playSfx )
			Manager.Instance.PlaySfxNearby ( "heal", Position2D, pitch: Utils.Map(Health, 0f, MaxHealth, 1.4f, 0.8f), volume: 0.85f, maxDist: 400f );
	}

	protected virtual void OnAdjustHealth( float amount )
	{
		
	}

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

		SetPlaybackRate( 0f );
	}

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

		SetPlaybackRate( 1f );
		_timeSinceDamageTarget = 0f;
	}

	protected override void OnHitstopStart()
	{
		HitstopOldPlaybackSpeed = ModelRenderer.PlaybackRate;
		SetPlaybackRate( 0f );
	}

	protected override void OnHitstopUpdate()
	{
		SetPlaybackRate( 0f );

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{_hitstopTime - _timeSinceHitstop}", new global::Transform( WorldPosition ) );
	}

	protected override void OnHitstopEnd()
	{
		if ( IsStunned )
			return;

		SetPlaybackRate( HitstopOldPlaybackSpeed );
	}

	protected override void UpdateShaking( float strength )
	{
		if ( ModelRenderer.IsValid() )
			ModelRenderer.LocalPosition = ModelOffset + Rotation.Random.Forward * strength;
	}

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

		if ( ModelRenderer.IsValid() )
			ModelRenderer.LocalPosition = ModelOffset;
	}

	protected override void ArmorFlinch( int amount, Vector2 flinchDir )
	{
		var shakeStrength = Utils.Map( amount, 1, 25, 3f, 5f, EasingType.Linear );
		Shake( startStrength: shakeStrength, endStrength: 0f, time: 0.1f );
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void Teleport( Vector2 targetPos )
	{
		targetPos = Manager.Instance.ClampPosToBounds( targetPos );

		CreateTeleportParticle( Position2D, Game.Random.Float( 30f, 35f ), new Color( 0.1f, 0.1f, 1f ), 0.6f );
		CreateTeleportParticle( targetPos, Game.Random.Float( 50f, 55f ), new Color( 0.1f, 0.1f, 1f ), 0.7f );

		Manager.Instance.PlaySfxNearbyRpc( "blink.start", Position2D, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 400f );
		Manager.Instance.PlaySfxNearbyRpc( "blink.end", targetPos, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 400f );

		if ( IsProxy )
			return;

		Position2D = targetPos;
		Transform.ClearInterpolation();
	}
}