things/enemies/Enemy.cs

Enemy component class for game enemies. It manages health, spawning, targeting, movement, attacks, damage processing, gibs/loot, explosions and animations, and contains many gameplay rules and RPCs for networked behavior.

NetworkingFile Access
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 => Health / MaxHealth;

	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 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 );
			}
		}

		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();
	}
}