things/enemies/MinibossZoner.cs

Enemy subclass for a miniboss type called MinibossZoner. Implements movement, jump/evade state machine, zone radius that shrinks with health, animations and effects for jump, and overrides damage to ignore hits from outside its zone.

NetworkingFile Access
using System;
using Sandbox;

public class MinibossZoner : Enemy
{
	public override EnemyType EnemyType => EnemyType.MinibossZoner;
	public override string GibFolder => "miniboss_zoner";
	public override float OverrideGibChance => 1f;
	public override int ExtraDeathBloodSprayAmount => 25;
	public override float GetMaxHealth() => MinibossBaseHealth;

	public override Vector3 SpawnScale => new Vector3( 1.75f );
	public override bool ShowHealthbar => true;
	public override float HealthbarOffset => 100f;
	public override float HealthbarOpacity => Utils.EasePercent( SpawnProgress, EasingType.QuadIn );
	public override float HealthbarArmorOpacity => Utils.EasePercent( SpawnProgress, EasingType.QuadIn );

	public override bool IsBoss => true;
	public override bool IsMiniboss => true;

	[Property] public Decal Decal { get; set; }

	public const float ZONE_SCALE_FACTOR = 0.13f;

	public float ZoneRadius { get; set; }

	public float ZoneRadiusMax { get; set; }
	public float ZoneRadiusMin { get; set; }

	public override float ParticleYPosOverride => 0.6f;
	public override float StunParticleYPosOverride => 1.1f;

	// State machine
	private enum ZonerState { Default, JumpPrepare, Jump }
	private ZonerState State { get; set; } = ZonerState.Default;

	// Jump
	private float _prepareJumpTime;
	private TimeSince _timeSinceJumping;
	private float _delayUntilNextJump;
	private Vector2 _jumpTargetPos;
	private float _nextJumpDelayMin;
	private float _nextJumpDelayMax;
	private float _maxJumpDist;

	// Evade
	private TimeSince _timeSinceEvade;
	private float _evadeDelay;
	private float _evadeDelayMin;
	private float _evadeDelayMax;
	private float _evadeVelocityMin;
	private float _evadeVelocityMax;

	public override bool CanAttack => base.CanAttack && State == ZonerState.Default;
	public override bool CanMove => base.CanMove && State == ZonerState.Default;
	public override bool CanTurn => base.CanTurn && State == ZonerState.Default;

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

		CoinValueMin = 11;
		CoinValueMax = 21;
		CoinChance = 1f;

		PushStrength = 9000f;
		Weight = 1.5f;

		_personalSpeedScale = 1.75f;
		_personalSpeedFreq = 12f;

		AnimSpeedModifier = 0.7f;

		ZoneRadiusMax = Utils.Select( Manager.Instance.Difficulty, 175f, 150f, 140f );
		ZoneRadiusMin = Utils.Select( Manager.Instance.Difficulty, 130f, 90f, 80f );

		ZoneRadius = ZoneRadiusMax;
		Decal.Size = new Vector2( ZoneRadius * ZONE_SCALE_FACTOR );

		if ( IsProxy )
			return;

		AggroRange = 85f;
		DetectTargetRange = 850f;
		LoseTargetRange = 1300f;
		LoseTargetTime = 7f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 8f, 10f, 12f );
		DamageTargetDelay = 0.8f;
		Acceleration = 100f;
		AccelerationAttacking = 120f;
		Deceleration = 0.45f;
		DecelerationAttacking = 0.75f;

		_personalTurnSpeed = 4.5f;

		_evadeDelayMin = 1.5f;
		_evadeDelayMax = 6.5f;
		_evadeDelay = Game.Random.Float( _evadeDelayMin, _evadeDelayMax );
		_evadeVelocityMin = 120f;
		_evadeVelocityMax = 350f;

		_nextJumpDelayMin = 6.0f;
		_nextJumpDelayMax = 14.0f;
		_delayUntilNextJump = Game.Random.Float( _nextJumpDelayMin, _nextJumpDelayMax );
		_maxJumpDist = 650f;
	}

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

		if ( IsProxy )
			return;

		if ( !IsStunned && !IsDying )
			HandleState();
	}

	private void HandleState()
	{
		switch ( State )
		{
			case ZonerState.Default:
				// Evade: perpendicular dodge when player is close and facing us
				if ( _timeSinceEvade > _evadeDelay * (1f / TimeScale) &&
					 TargetUnit != null &&
					 (TargetUnit.Position2D - Position2D).LengthSquared < MathF.Pow( 150f, 2f ) )
				{
					Vector2 forwardDir = (Vector2)WorldRotation.Forward;
					var dot = Vector2.Dot( forwardDir, (Vector2)TargetUnit.WorldRotation.Forward );
					if ( dot < -0.92f )
					{
						Vector2 toTarget = (TargetUnit.Position2D - Position2D).Normal;
						Vector2 evadeDir = new Vector2( toTarget.y, -toTarget.x ) *
										   (Game.Random.Float( 0f, 1f ) < 0.5f ? 1f : -1f);
						Velocity += evadeDir * Game.Random.Float( _evadeVelocityMin, _evadeVelocityMax );

						_evadeDelay = Game.Random.Float( _evadeDelayMin, _evadeDelayMax ) * TimeScale;
						_timeSinceEvade = 0f;
					}
				}

				// Jump trigger
				if ( HasTarget && !IsInTheAir &&
					 _timeSinceJumping > _delayUntilNextJump * (IsAttacking ? 2f : 1f) )
					SetZonerState( ZonerState.JumpPrepare );

				break;

			case ZonerState.JumpPrepare:
				var dir = (_jumpTargetPos - Position2D).Normal;
				WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( dir ),
											   10f * Time.Delta * TimeScale );

				if ( _timeSinceChangeState > _prepareJumpTime )
					SetZonerState( ZonerState.Jump );

				break;
		}
	}

	private void SetZonerState( ZonerState state )
	{
		State = state;
		_timeSinceChangeState = 0f;

		switch ( state )
		{
			case ZonerState.Default:
				EnterDefaultStateRpc();
				break;

			case ZonerState.JumpPrepare:
				PrepareJumpRpc();

				_timeSinceJumping = 0f;
				_delayUntilNextJump = Game.Random.Float( _nextJumpDelayMin, _nextJumpDelayMax );
				_delayUntilNextJump *= Utils.Map(
					HpPercent, 1f, 0f,
					Utils.Select( Manager.Instance.Difficulty, 1.4f, 1.2f, 1f ),
					Utils.Select( Manager.Instance.Difficulty, 1f, 0.9f, 0.85f ) );

				_prepareJumpTime = Game.Random.Float( 0.4f, 0.75f );

				IsAttacking = false;

				_jumpTargetPos = TargetUnit.IsValid()
					? TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 4f ) +
					  Utils.GetRandomVector() * Game.Random.Float( 0f, 150f )
					: Position2D + Utils.GetRandomVector() * Game.Random.Float( 50f, 150f );

				if ( (_jumpTargetPos - Position2D).LengthSquared > MathF.Pow( _maxJumpDist, 2f ) )
					_jumpTargetPos = Position2D + (_jumpTargetPos - Position2D).Normal * _maxJumpDist;

				_jumpTargetPos = Manager.Instance.ClampPosToBounds( _jumpTargetPos );
				break;

			case ZonerState.Jump:
				SetZonerState( ZonerState.Default );

				var height = Game.Random.Float( 70f, 130f );
				var time = Utils.Map( (_jumpTargetPos - Position2D).Length, 0f, _maxJumpDist,
									  0.75f, 1.3f, EasingType.SineIn ) *
						   Game.Random.Float( 0.85f, 1.1f );
				JumpRpc( _jumpTargetPos, height, time );
				break;
		}
	}

	[Rpc.Broadcast]
	public void PrepareJumpRpc()
	{
		SetAnim( "JumpPrepare" );
		CanAnimate = false;
	}

	[Rpc.Broadcast]
	public void EnterDefaultStateRpc()
	{
		CanAnimate = true;
		PlayWalkAnim();
	}

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

		GameObject.Clone( "prefabs/effects/cloud.prefab",
			new CloneConfig { StartEnabled = true,
							  Transform = new Transform( WorldPosition.WithZ( 10f ) ) } );

		Manager.Instance.PlaySfxNearby( "jump_whoosh", Position2D,
			pitch: Game.Random.Float( 1.3f, 1.45f ), volume: 0.8f, maxDist: 380f );
	}

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

		Manager.Instance.PlaySfxNearby( "jump_thud", Position2D,
			pitch: Game.Random.Float( 0.82f, 0.92f ), volume: 0.7f, maxDist: 280f );

		if ( IsProxy )
			return;

		var dir = (Position2D - JumpStartPos).Normal;
		Velocity += dir * Game.Random.Float( 50f, 150f ) * TimeScale;

		_timeSinceDamageTarget = 999f;
	}

	public override void OnStun()
	{
		base.OnStun();
		SetZonerState( ZonerState.Default );
	}

	protected override Vector2 GetTargetOffset()
	{
		return TargetUnit.Velocity * (1f + Utils.FastSin( TimeSinceSpawn * 0.4f ) * 0.6f);
	}

	protected override float GetMoveSpeedFactor()
	{
		return 1f;
	}

	protected override void Damage( float damage, Player player, DamageType damageType, Vector3 hitPos, Vector2 force, bool isCrit = false, bool shouldFlinch = true, DamageResultFlags damageFlags = DamageResultFlags.None )
	{
		if ( player.IsValid() )
		{
			var distSqr = (player.Position2D - Position2D).LengthSquared;

			//Log.Info( $"Distance to player: {(player.Position2D - Position2D).Length}, ZoneRadius: {ZoneRadius}" );

			if ( distSqr > MathF.Pow( ZoneRadius, 2f ) )
			{
				damage = 0f;
				isCrit = false;
				force = Vector2.Zero;
				shouldFlinch = false;

				// todo: flash zone decal
			}

			base.Damage( damage, player, damageType, hitPos, force, isCrit, shouldFlinch, damageFlags );
		}
		else
		{
			base.Damage( damage, player, damageType, hitPos, force, isCrit, shouldFlinch, damageFlags );
		}

		// todo: don't get ignited, frozen, poisoned, etc by bullets shot from outside the zone
	}

	protected override void OnAdjustHealth( float amount )
	{
		base.OnAdjustHealth( amount );

		ZoneRadius = Utils.Map( HpPercent, 1f, 0f, ZoneRadiusMax, ZoneRadiusMin );
		Decal.Size = new Vector2( ZoneRadius * ZONE_SCALE_FACTOR );

		Acceleration = Utils.Map( HpPercent, 1f, 0f, 120f, 140f );
		AccelerationAttacking = Acceleration * 1.3f;
	}
}