things/enemies/Boss.cs

Boss enemy component. Extends Enemy, defines boss-specific stats, states and behaviors (shooting, charging, jumping, barrage, chain, sword, invincibility), handles state transitions, movement, animation velocity syncing and death/loot hooks.

Networking
using System;
using Sandbox;
using Sandbox.Citizen;
using Sandbox.UI;

public partial class Boss : Enemy
{
	public override EnemyType EnemyType => EnemyType.Boss;
	public override float MeleeForce => 30f;
	public override float MeleeRagdollForce => Game.Random.Float( 1f, 2.5f );
	public override float MeleeUpwardForceAmount => State == BossState.Charge
		? Game.Random.Float( 0.25f, 2f )
		: Game.Random.Float( 0f, 0.75f );

	public override float GetMaxHealth()
	{
		switch ( Manager.Instance.Difficulty )
		{
			case 0: default: return 4000f;
			case 1: return 5000f;
			case 2: return 7000f;
		}
	}

	//[Property] public CitizenAnimationHelper AnimationHelper { get; set; }

	public override Vector3 SpawnScale => new Vector3( 1f );

	public override float SpawnZPos => -250f;
	public override float OverrideGibChance => 1f;
	//override public float GibScaleMultiplier => 3f;
	public override float GibOffsetMultiplier => 2f;
	public override float OverrideGibLifetime => 9999f;

	public override bool CanAttack => base.CanAttack && State == BossState.Default;
	public override bool CanMove => base.CanMove && (State == BossState.Default || State == BossState.Charge) ;
	public override bool CanTurn => base.CanTurn && (State == BossState.Default || State == BossState.Charge || State == BossState.ShootPrepare || State == BossState.Shoot );
	public override bool CanDamageByTouch => !IsDying && !IsStunned && (IsAttacking || State == BossState.Charge) && !IsInTheAir;
	public override bool CanBeStunned => base.CanBeStunned && !( State == BossState.InvinciblePrepare );
	protected override bool ShouldRetreatFromTarget => IsFearful || (_isRetreating && !IsAttacking && !(State == BossState.ShootPrepare || State == BossState.Shoot || State == BossState.ChainPrepare || State == BossState.ChainThrow));
	protected virtual bool ShouldCircleTarget => !IsAttacking && !_isRetreating && _timeSinceJumpingAway < _circleTargetTime;
	protected bool _moveClockwise;
	private float _circleTargetTime;
	public bool IsReadyForAction => State == BossState.Default && !IsInTheAir && !IsSpawning && !IsStunned;

	private bool _isRetreating;
	private float _retreatTimer;
	private TimeSince _timeSinceRetreat;
	private const float RETREAT_TIME_MIN = 1f;
	private const float RETREAT_TIME_MAX = 6f;

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

	protected float _baseWeight;

	public override bool IsBoss => true;

	public override float ParticleYPosOverride => 0.65f;
	public override float StunParticleYPosOverride => 1.4f;
	public override bool CanCombust => false;

	protected enum BossState
	{
		Default,
		ShootPrepare,
		Shoot,
		ShootFinish,
		ChargePrepare,
		Charge,
		ChargeFinish,
		JumpPrepare,
		Jump,
		JumpFinish,
		JumpAwayPrepare,
		JumpAway,
		BarragePrepare,
		Barrage,
		BarrageFinish,
		InvinciblePrepare,
		Invincible,
		ChainPrepare,
		ChainThrow,
		ChainFinish,
		SwordPrepare,
		Sword,
		SwordFinish,

		// todo: new behaviour when shield generators destroyed
		// PuddlePrepare,
	}

	protected BossState State { get; private set; } = BossState.Default;

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

		//CanAnimate = false;

		CoinValueMin = 0;
		CoinValueMax = 0;
		CoinChance = 0f;

		PushStrength = 15000f;
		Weight = _baseWeight = 10f;

		_personalSpeedScale = 1f;
		_personalSpeedFreq = 1f;

		InitShooting();
		InitCharging();
		InitJumping();
		InitBarrage();
		InitInvincible();
		InitChain();
		InitSword();

		//ModelRenderer.Morphs.Set( "openjawL", 1f );
		//ModelRenderer.Morphs.Set( "openjawR", 1f );
		//ModelRenderer.Morphs.Set( "browlowererL", 1f );
		//ModelRenderer.Morphs.Set( "browlowererR", 1f );
		//AnimationHelper.Target.Set( "face_override", 1 );
		//ModelRenderer.Morphs.Clear( "openjawL" );
		//ModelRenderer.Morphs.Clear( "openjawR" );

		//AnimationHelper.DuckLevel = 1f;

		Manager.Instance.Boss = this;

		ShouldCheckBounds = false; 

		if ( IsProxy )
			return;

		AggroRange = 80f;
		DetectTargetRange = 1500f;
		LoseTargetRange = 1100f;
		LoseTargetTime = 12f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 8f, 12f, 13f );
		DamageTargetDelay = 0.55f;
		_personalTurnSpeed = 5f;
		Acceleration = Utils.Select( Manager.Instance.Difficulty, 250f, 310f, 335f );
		AccelerationAttacking = Utils.Select( Manager.Instance.Difficulty, 280f, 330f, 355f );
		Deceleration = 1.7f;
		DecelerationAttacking = 1.6f;

		_moveClockwise = Game.Random.Int( 0, 1 ) == 0;
	}

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

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"_jumpTargetPos: {_jumpTargetPos}", new global::Transform( WorldPosition ) );
		//Gizmo.Draw.Text( $"{ModelRenderer.SceneModel.CurrentSequence.Name}\nprogress: {ModelRenderer.SceneModel.CurrentSequence.TimeNormalized.ToString( "N2" )}\nspeed: {GetMoveSpeedFactor().ToString( "N2" )}", new global::Transform( WorldPosition ) );

		if ( Manager.Instance.IsGameOver )
			return;

		HandleAnimation();

		if ( IsProxy )
			return;

		if ( !IsStunned )
			HandleState();

		HandleRetreating();

		AnimVelocity = (IsSpawning || State != BossState.Default) ? Vector2.Zero : Velocity * 0.75f;
	}

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

		ShouldCheckBounds = true;

		if ( IsProxy )
			return;

		var pos = new Vector2( Game.Random.Float( -300f, 300f ), Game.Random.Float( -300f, 300f ) );

		WorldRotation = Rotation.LookAt( ( (Vector3)pos - WorldPosition).Normal, Vector3.Up );

		JumpRpc( pos, height: Game.Random.Float( 250f, 400f ), lifetime: Game.Random.Float( 1.3f, 1.5f ) );
	}

	protected void HandleState()
	{
		HandleInvincible();

		switch ( State )
		{
			case BossState.Default:
				HandleShooting();
				HandleCharging();
				HandleJumping();
				HandleBarrage();
				HandleChain();
				HandleSword();

				break;
			case BossState.ShootPrepare:
				if ( _timeSinceChangeState > 1f ) // todo: add more variation
					SetState( BossState.Shoot );
				break;
			case BossState.Shoot:
				if ( _timeSinceChangeState > 0.6f )
					SetState( BossState.ShootFinish );
				break;
			case BossState.ShootFinish:
				
				break;
			case BossState.ChargePrepare:
				if ( _timeSinceChangeState > 0.75f )
					SetState( BossState.Charge );

				break;
			case BossState.Charge:
				if ( _timeSinceRedirect > _nextRedirectTime )
				{
					Player closestPlayer = Manager.Instance.GetClosestPlayer( Position2D );
					if ( closestPlayer.IsValid() )
					{
						var targetPos = closestPlayer.Position2D + closestPlayer.Velocity * Game.Random.Float( 0.2f, 1f ) + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 100f;
						Vector2 targetDir = (targetPos - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
							? (targetPos - Position2D).Normal
							: Utils.GetRandomVector();

						_chargeDir = Utils.RotateVector( targetDir, Game.Random.Float( -10f, 10f ) );
					}

					_nextRedirectTime = Game.Random.Float( 0.25f, 1f );
					_timeSinceRedirect = 0f;
				}

				_chargeVel += (Vector2)WorldRotation.Forward * 650f * Utils.MapReturn( _timeSinceChangeState, 0f, _chargeTime, 0.5f, 1f, EasingType.QuadOut ) * Time.Delta;

				if ( Manager.Instance.IsWindActive )
					Velocity += Manager.Instance.GlobalWindForce * Time.Delta;

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

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

				_chargeVel *= Math.Max( 1f - Time.Delta * 1.5f, 0f );

				// todo: wind?

				if ( _timeSinceChangeState > _chargeTime )
					SetState( BossState.ChargeFinish );

				break;
			case BossState.ChargeFinish:
				SetState( BossState.Default );

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

				if ( _timeSinceChangeState > _prepareJumpTime )
					SetState( BossState.Jump );

				break;
			case BossState.Jump:

				break;
			case BossState.JumpFinish:
				if ( _timeSinceChangeState > 0.5f )
					SetState( BossState.Default );
				break;
			case BossState.JumpAwayPrepare:
				var dirAway = (_jumpTargetPos - Position2D).Normal;
				WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( dirAway ), 10f * Time.Delta * TimeScale );

				if ( _timeSinceChangeState > _prepareJumpTime )
					SetState( BossState.JumpAway );

				break;
			case BossState.JumpAway:

				break;
			case BossState.BarragePrepare:
				if ( _timeSinceChangeState > 1f )
					SetState( BossState.Barrage );
				break;
			case BossState.Barrage:
				if(_timeSinceBarrageShoot > _barrageEmitDelay )
				{
					BarrageEmitRpc();
					_timeSinceBarrageShoot = 0f;
				}

				if ( _timeSinceChangeState > _barrageTotalTime )
					SetState( BossState.BarrageFinish);

				break;
			case BossState.BarrageFinish :

				break;
			case BossState.InvinciblePrepare:
				//AnimationHelper.WithLook( Vector3.Random );

				if ( _timeSinceChangeState > 2.5f )
					SetState( BossState.Invincible );

				break;
			case BossState.Invincible:

				break;
			case BossState.ChainPrepare:
				if ( _timeSinceChangeState > 1.5f )
					SetState( BossState.ChainThrow );
				break;
			case BossState.ChainThrow:
				if ( _timeSinceChangeState > 1.75f )
					SetState( BossState.ChainFinish );

				break;
			case BossState.ChainFinish:

				break;
			case BossState.SwordPrepare:
				if ( _timeSinceChangeState > 1f )
					SetState( BossState.Sword );
				break;
			case BossState.Sword:
				if ( _timeSinceSwordShoot > _swordEmitDelay )
				{
					SwordEmitRpc();
					_timeSinceSwordShoot = 0f;
					_swordEmitDelay = Game.Random.Float( _swordEmitDelayMin, _swordEmitDelayMax ) * Utils.Map( HpPercent, 1f, 0f, 1f, 0.75f ) * Utils.Select( Manager.Instance.Difficulty, 1.25f, 1f, 0.925f );
				}

				if ( _timeSinceChangeState > _swordTotalTime )
					SetState( BossState.SwordFinish );

				break;
			case BossState.SwordFinish:

				break;
		}
	}

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

		switch ( state )
		{
			case BossState.Default:
				EnterDefaultStateRpc();
				break;
			case BossState.ShootPrepare:
				_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax ) * Utils.Select( Manager.Instance.Difficulty, 1.25f, 1f, 0.925f );

				StartShootingRpc();

				_isRetreating = false;

				break;
			case BossState.Shoot:
				ShootRpc();

				break;
			case BossState.ShootFinish:
				Velocity = Vector2.Zero;

				SetState( BossState.Default );

				break;
			case BossState.ChargePrepare:
				_chargeDelayTimer = Game.Random.Float( _chargeDelayMin, _chargeDelayMax ) * Utils.Select( Manager.Instance.Difficulty, 1.25f, 1f, 0.925f );

				PrepareToChargeRpc();

				Velocity *= 0.7f;

				break;
			case BossState.Charge:
				ChargeRpc();

				break;
			case BossState.ChargeFinish:
				SetState( BossState.Default );

				break;
			case BossState.JumpPrepare:
				PrepareJumpRpc();

				_timeSinceJumping = 0f;
				_delayUntilNextJump = Game.Random.Float( _nextJumpDelayMin, _nextJumpDelayMax ) * Utils.Map( HpPercent, 1f, 0f, Utils.Select( Manager.Instance.Difficulty, 2.3f, 2.1f, 2f ), 1f ) * Utils.Select( Manager.Instance.Difficulty, 1.75f, 1.4f, 1f );
				_delayUntilNextJumpAway += Game.Random.Float( 2f, 3f );

				_prepareJumpTime = Game.Random.Float( 0.3f, 0.6f );

				_jumpTargetPos = Manager.Instance.ClampPosToBounds( TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 4f ) + Utils.GetRandomVector() * Game.Random.Float( 0f, 250f ) );

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

				_jumpTargetPos = Manager.Instance.ClampPosToBounds( _jumpTargetPos );

				IsAttacking = false;

				break;
			case BossState.Jump:
				//SetState( BossState.Default );

				JumpRpc( 
					_jumpTargetPos, 
					height: Game.Random.Float( 250f, 300f ), 
					lifetime: Utils.Map( (_jumpTargetPos - Position2D).Length, 0f, _maxJumpDist, 1f, 2f, EasingType.SineIn ) * Game.Random.Float( 0.85f, 1.1f )
				);
				break;
			case BossState.JumpFinish:
				
				break;
			case BossState.JumpAwayPrepare:
				PrepareJumpRpc();

				_timeSinceJumpingAway  = 0f;
				_delayUntilNextJumpAway = Game.Random.Float( _nextJumpAwayDelayMin, _nextJumpAwayDelayMax ) * Utils.Map( HpPercent, 1f, 0f, 2f, 1f ) * Utils.Select( Manager.Instance.Difficulty, 1.25f, 1.1f, 0.95f );
				_delayUntilNextJump += 2f;

				_prepareJumpTime = Game.Random.Float( 0.3f, 0.6f );

				_jumpTargetPos = Manager.Instance.GetRandomSpawnPos( buffer: 20f );

				int numTries = 0;
				while ( numTries < 40 )
				{
					if ( (_jumpTargetPos - TargetUnit.Position2D).LengthSquared > MathF.Pow( Utils.Map( numTries, 0, 40, 1500f, 800f ), 2f ) )
						break;

					var jumpAwayPos = Manager.Instance.GetRandomSpawnPos( buffer: 20f );

					if ( (jumpAwayPos - TargetUnit.Position2D).LengthSquared > (_jumpTargetPos - TargetUnit.Position2D).LengthSquared )
						_jumpTargetPos = jumpAwayPos;

					numTries++;
				}

				_jumpTargetPos = Manager.Instance.ClampPosToBounds( _jumpTargetPos );

				IsAttacking = false;

				break;
			case BossState.JumpAway:
				//SetState( BossState.Default );

				JumpRpc(
					_jumpTargetPos,
					height: Game.Random.Float( 350f, 400f ),
					lifetime: Utils.Map( (_jumpTargetPos - Position2D).Length, 0f, 2000f, 1f, 2.5f, EasingType.SineIn ) * Game.Random.Float( 0.85f, 1.1f )
				);

				_moveClockwise = !_moveClockwise;
				_circleTargetTime = Game.Random.Float( 1f, 10f );

				break;
			case BossState.BarragePrepare:
				_barrageDelayTimer = Game.Random.Float( _barrageDelayMin, _barrageDelayMax ) * Utils.Select( Manager.Instance.Difficulty, 1.45f, 1f, 0.925f );

				StartBarrageRpc();
				break;
			case BossState.Barrage:
				BarrageRpc();

				break;
			case BossState.BarrageFinish:
				SetState( BossState.Default );

				break;
			case BossState.InvinciblePrepare:
				StartInvincibleRpc();

				break;
			case BossState.Invincible:
				FinishInvincibleRpc();

				SetState( BossState.Default );

				break;
			case BossState.ChainPrepare:
				_chainDelayTimer = Game.Random.Float( _chainDelayMin, _chainDelayMax ) * Utils.Select( Manager.Instance.Difficulty, 3f, 1.1f, 0.95f );

				StartChainRpc();

				_isRetreating = false;

				break;
			case BossState.ChainThrow:
				ThrowChainRpc();

				break;
			case BossState.ChainFinish:
				Velocity = Vector2.Zero;

				SetState( BossState.Default );

				break;
			case BossState.SwordPrepare:
				_swordDelayTimer = Game.Random.Float( _swordDelayMin, _swordDelayMax ) * Utils.Select( Manager.Instance.Difficulty, 1.3f, 1f, 0.925f );

				StartSwordRpc();
				break;
			case BossState.Sword:
				SwordRpc();

				break;
			case BossState.SwordFinish:
				SetState( BossState.Default );

				break;
		}
	}

	protected override float GetMoveSpeedFactor()
	{
		var leftFootStart = 0.30f;
		var leftFootEnd = 0.70f;
		var rightFootStart = 0.70f;
		var rightFootEnd = 0.25f;

		var progress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;

		if ( progress > rightFootStart || progress < rightFootEnd )
		{
			var totalProgress = 1f + rightFootEnd;
			var offsetProgress = progress < rightFootEnd
				? 1f + progress
				: progress;

			return Utils.Map( offsetProgress, rightFootStart, totalProgress, 0f, 1f, EasingType.QuadOut );
		}
		else if ( progress > leftFootStart && progress < leftFootEnd )
		{
			return Utils.Map( progress, leftFootStart, leftFootEnd, 0f, 1f, EasingType.QuadOut );
		}

		return 0f;
	}

	protected override Vector2 GetTargetOffset()
	{
		var offset = TargetUnit.Velocity * (0.5f + Utils.FastSin( TimeSinceSpawn * 3f ) * 0.5f) * (TargetUnit.Position2D - Position2D).Length * 0.012f;

		//if ( !IsShooting && !_isRetreating )
		//	offset += _personalTargetOffset + new Vector2( Utils.FastSin( TimeSinceSpawn * 0.7f ), Utils.FastSin( TimeSinceSpawn * 1.1f ) ) * 50f;

		return offset;
	}

	protected override void HandleRotation()
	{
		if ( State == BossState.Charge )
		{
			if ( !HitstopActive )
				WorldRotation = Rotation.Lerp( WorldRotation, Rotation.From( new Angles( 0f, -Utils.GetAngleDegreesFromVector( _chargeDir ), 0f ) ), _chargeRotateSpeed * Time.Delta * TimeScale );
		}
		else
		{
			if ( !HasTarget || State != BossState.Default || !ShouldCircleTarget )
			{
				base.HandleRotation();
				return;
			}

			Vector2 toTarget = TargetPos - Position2D;
			toTarget = new Vector2( toTarget.y, -toTarget.x );

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

	public override void Die( Vector2 dir, float force, Player player, DamageType damageType )
	{
		base.Die( dir, force, player, damageType );

		Manager.Instance.PlaySfxNearby( "boss.die", Position2D, pitch: Game.Random.Float( 0.75f, 0.8f ), volume: 1.3f, maxDist: 2500f );

		if ( IsProxy )
			return;

		Manager.Instance.BossDied();
	}

	protected override void DropLoot( Player player )
	{
		// todo: drop crown?
	}

	public override void Flash( float time, UnitFlashType flashType )
	{
		if ( flashType == UnitFlashType.EnemyDmg )
			flashType = UnitFlashType.BossDmg;

		base.Flash( time, flashType );
	}

	void HandleRetreating()
	{
		// todo: don't retreat if at stage bounds (player walking you into the edge of arena)

		if ( _isRetreating )
		{
			_retreatTimer -= Time.Delta;
			if ( _retreatTimer <= 0f )
				_isRetreating = false;
		}
	}

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

		if ( IsProxy )
			return;

		_isRetreating = false;
		_timeSinceRetreat = 0f;
	}

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

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

		if ( IsProxy || IsDying )
			return;

		if ( (_timeSinceRetreat > Game.Random.Float( 3f, 10f ) * Utils.Map( HpPercent, 1f, 0f, 1.5f, 0.8f )) && Game.Random.Float( 0f, 1f ) < Utils.Map( HpPercent, 1f, 0f, 0f, 0.5f ) )
		{
			_isRetreating = true;
			_retreatTimer = Game.Random.Float( RETREAT_TIME_MIN, RETREAT_TIME_MAX );
			_timeSinceRetreat = 0f;

			Vector2 impulseDir;
			
			if( player.IsValid() )
			{
				impulseDir = (Position2D - player.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (Position2D - player.Position2D).Normal
					: Utils.GetRandomVector();
			}
			else
			{
				impulseDir = force.Normal;
			}

			if ( impulseDir.LengthSquared > 0f )
				Velocity += impulseDir * Game.Random.Float( 50f, 200f ) * TimeScale;
		}
	}

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

		PlayFlinchAnim();

		SetState( BossState.Default );
	}

	protected override void PlayFlinchAnim()
	{
		
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void EnterDefaultStateRpc()
	{
		Weight = _baseWeight;

		CanAnimate = true;
		PlayWalkAnim();

		//AnimationHelper.DuckLevel = 0f;
		SetPlaybackRate( 1f );
		//AnimationHelper.Sitting = CitizenAnimationHelper.SittingStyle.None;
		//AnimationHelper.SpecialMove = CitizenAnimationHelper.SpecialMoveStyle.None;
		//AnimationHelper.HoldType = CitizenAnimationHelper.HoldTypes.None;
		//AnimationHelper.WithLook( WorldRotation.Forward );


	}

	protected override void SpawnGibs( Vector2 dir, float force, DamageType damageType )
	{
		base.SpawnGibs( dir, force, damageType );

		SpawnGoreGib(
			$"{GibFolder}/crown",
			localPos: new Vector3( 0f, 0, 60f ) * GibOffsetMultiplier,
			localRot: new Angles( 0f, 0f, 0f ),
			scaleMultiplier: GibScaleMultiplier,
			dir,
			force,
			Color.White,
			damageType
		);
	}

	public override void Celebrate( bool victory )
	{
		SetState( BossState.Default );
		AnimVelocity = Vector2.Zero;

		base.Celebrate( victory );
	}
}