things/enemies/MinibossAbsorber.cs

Enemy component for a miniboss called MinibossAbsorber. It defines stats, movement and an absorb state machine where the miniboss can enter prepare/absorb/finish states, heal while absorbing damage, adjust acceleration based on health and broadcast RPCs for state transitions and animations.

Networking
using System;
using Sandbox;

public class MinibossAbsorber : Enemy
{
	public override EnemyType EnemyType => EnemyType.MinibossAbsorber;
	public override string GibFolder => "miniboss_absorber";
	public override float OverrideGibChance => 1f;
	public override int ExtraDeathBloodSprayAmount => 25;

	protected override float MinibossHealthScale => 0.85f;
	public override float GetMaxHealth() => MinibossBaseHealth * MinibossHealthScale;

	public override Vector3 SpawnScale => new Vector3( 1.6f );
	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;

	// todo: should targer player when boss has spawned?
	public override bool CanTurn => base.CanTurn && !IsDying && State == MinibossAbsorberState.Default;
	public override bool CanMove => base.CanMove && State == MinibossAbsorberState.Default;
	public override bool CanAttack => base.CanAttack && State == MinibossAbsorberState.Default;
	public override bool CanDamageByTouch => !IsDying && !IsStunned && !IsInTheAir && State == MinibossAbsorberState.Default;

	private float _absorbDelayTimer;
	private const float ABSORB_DELAY_MIN = 3f;
	private const float ABSORB_DELAY_MAX = 12f;

	private float _absorbDuration;
	private const float ABSORB_DURATION_MIN = 1f;
	private const float ABSORB_DURATION_MAX = 6.75f;

	private const float ACCELERATION_MIN = 350f;
	private const float ACCELERATION_MAX = 450f;

	public override float ParticleYPosOverride => 0.5f;
	public override float StunParticleYPosOverride => 0.8f;

	protected enum MinibossAbsorberState
	{
		Default,
		AbsorbPrepare,
		Absorb,
		AbsorbFinish,
	}

	[Sync] protected MinibossAbsorberState State { get; private set; } = MinibossAbsorberState.Default;

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

		CoinValueMin = 10;
		CoinValueMax = 20;
		CoinChance = 1f;

		PushStrength = 9000f;
		Weight = 1.5f;

		_personalSpeedScale = 1.5f;
		_personalSpeedFreq = 12f;

		if ( IsProxy )
			return;

		AggroRange = 150f;
		DetectTargetRange = 850f;
		LoseTargetRange = 1300f;
		LoseTargetTime = 5f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 12f, 15f, 16f );
		DamageTargetDelay = 0.8f;
		Acceleration = ACCELERATION_MIN * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
		AccelerationAttacking = ACCELERATION_MIN * 1.4f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
		Deceleration = 1.3f;
		DecelerationAttacking = 1.1f;

		_personalTurnSpeed = 4.5f;

		_absorbDelayTimer = Game.Random.Float( ABSORB_DELAY_MIN, ABSORB_DELAY_MAX );
	}

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

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

		if ( Manager.Instance.IsGameOver )
			return;

		_personalSpeedScale = Utils.Map( Health, MaxHealth, 0f, 1.4f, 1.8f, EasingType.Linear );

		if ( IsProxy )
			return;

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

	protected void HandleState()
	{
		switch ( State )
		{
			case MinibossAbsorberState.Default:
				_absorbDelayTimer -= Time.Delta;
				if ( _absorbDelayTimer < 0f && !IsInTheAir )
					SetState( MinibossAbsorberState.AbsorbPrepare );

				break;
			case MinibossAbsorberState.AbsorbPrepare:
				Velocity *= (1f - Time.Delta * 6f * Manager.Instance.GlobalFrictionModifier);

				if ( _timeSinceChangeState > 0.33f )
					SetState( MinibossAbsorberState.Absorb );

				break;
			case MinibossAbsorberState.Absorb:

				if ( _timeSinceChangeState > _absorbDuration )
					SetState( MinibossAbsorberState.AbsorbFinish );

				break;

			case MinibossAbsorberState.AbsorbFinish:
				if ( _timeSinceChangeState > 0.4f )
					SetState( MinibossAbsorberState.Default );

				break;
		}
	}

	protected void SetState( MinibossAbsorberState state )
	{
		State = state;
		_timeSinceChangeState = 0f;

		switch ( state )
		{
			case MinibossAbsorberState.Default:
				EnterDefaultStateRpc();

				break;
			case MinibossAbsorberState.AbsorbPrepare:
				AbsorbPrepareRpc();

				Velocity *= 0.5f;

				_absorbDuration = Game.Random.Float( ABSORB_DURATION_MIN, ABSORB_DURATION_MAX );
				_absorbDelayTimer = Game.Random.Float( ABSORB_DELAY_MIN, ABSORB_DELAY_MAX * Utils.Map( HpPercent, 1f, 0f, 1f, 0.7f ) );

				//SetPlaybackRate( Utils.MapReturn( _prepareSlamTime, 0f, 1f, 0.8f, 0f, EasingType.QuadIn ) );

				break;
			case MinibossAbsorberState.Absorb:
				AbsorbRpc();

				break;
			case MinibossAbsorberState.AbsorbFinish:
				AbsorbFinishRpc();

				break;
		}
	}

	protected override float GetMoveSpeedFactor()
	{
		var progress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;
		return Zombie.GetZombieMoveSpeedFactor( progress );
	}

	[Rpc.Broadcast]
	void AbsorbPrepareRpc()
	{
		CanAnimate = false;
		SetAnim( "Pray" );
		//SS2Game.PlaySfx( "spitter.prepare", Position, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.6f );
	}

	[Rpc.Broadcast]
	void AbsorbRpc()
	{
		//SetAnim( "Pray" );
		//SS2Game.PlaySfx( "spitter.prepare", Position, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.6f );
	}

	[Rpc.Broadcast]
	void AbsorbFinishRpc()
	{
		PlayWalkAnim();
		//SetAnim( "Pray" );
		//SS2Game.PlaySfx( "spitter.prepare", Position, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.6f );
	}

	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( State == MinibossAbsorberState.Absorb )
		{
			if( !IsProxy )
			{
				Heal( damage, playSfx: true );

				// todo: sfx

				RefreshAcceleration();
			}

			var scaleMultiplier = Utils.Map( damage, 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( damage, 5f, 30f, 1f, 1.5f, EasingType.Linear );
			Manager.Instance.SpawnBulletImpactParticlesRpc( hitPos, Vector3.Up, Color.Green, scaleMultiplier ); // todo: different impact effect

			return;
		}

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

		if ( IsProxy )
			return;

		RefreshAcceleration();
	}

	void RefreshAcceleration()
	{
		Acceleration = Utils.Map( Health, MaxHealth, 0f, ACCELERATION_MIN, ACCELERATION_MAX, EasingType.Linear ) * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
		AccelerationAttacking = Acceleration * 1.25f;
	}

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

		PlayFlinchAnim();

		if( State != MinibossAbsorberState.Absorb )
			SetState( MinibossAbsorberState.Default );
	}

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