things/enemies/Runner.cs

Enemy subclass for a Runner NPC. Controls stats, movement state machine (default, jump prepare, jump), evasion/retreat behavior, jump logic, sounds, animations and gore spawns.

NetworkingFile Access
using System;
using Sandbox;

public class Runner : Enemy
{
	public override EnemyType EnemyType => EnemyType.Runner;
	public override float MeleeForce => 20f;
	public override float MeleeRagdollForce => Game.Random.Float( 0.5f, 1.5f );
	public override float MeleeUpwardForceAmount => Game.Random.Float( 0f, 0.75f );

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

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

	protected bool _isRetreating;
	protected float _retreatTimer;
	protected TimeSince _timeSinceRetreat;
	protected float _retreatVelocityMin;
	protected float _retreatVelocityMax;

	protected TimeSince _timeSinceEvade;
	protected float _evadeDelay;
	protected float _evadeDelayMin;
	protected float _evadeDelayMax;
	protected float _evadeVelocityMin;
	protected float _evadeVelocityMax;

	public override bool CanAttack => base.CanAttack && State == RunnerState.Default;
	public override bool CanMove => base.CanMove && State == RunnerState.Default;
	public override bool CanTurn => base.CanTurn && State == RunnerState.Default;
	//public override bool CanDamageTarget => !IsDying && IsAttacking && !IsStunned; // can damage while in the air
	protected override bool ShouldRetreatFromTarget => IsFearful || _isRetreating;

	protected float _prepareJumpTime;
	protected TimeSince _timeSinceJumping;
	protected float _delayUntilNextJump;
	protected Vector2 _jumpTargetPos;
	protected float _nextJumpDelayMin;
	protected float _nextJumpDelayMax;
	protected float _maxJumpDist;

	public override DamageType MeleeAttackDamageType => DamageType.MeleeRunnerBite;

	protected enum RunnerState
	{
		Default,
		JumpPrepare,
		Jump,
	}

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

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

		CoinValueMin = 2;
		CoinValueMax = 3;
		CoinChance = 1f;

		PushStrength = 4000f;
		Weight = 0.8f;

		_personalSpeedScale = Game.Random.Float( 0.95f, 1.05f );
		_personalSpeedFreq = Game.Random.Float( 8f, 10f );

		if ( IsProxy )
			return;

		AggroRange = 100f;
		DetectTargetRange = 475f;
		LoseTargetRange = 800f;
		LoseTargetTime = 5f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 5f, 6f, 7f );
		DamageTargetDelay = 0.5f;
		_personalTurnSpeed = Game.Random.Float( 8f, 10f );
		Acceleration = 170f * Utils.Select( Manager.Instance.Difficulty, 0.85f, 1f, 1.12f );
		AccelerationAttacking = 180f * Utils.Select( Manager.Instance.Difficulty, 0.85f, 1f, 1.12f );
		Deceleration = 1.65f * Utils.Select( Manager.Instance.Difficulty, 1f, 1f, 0.95f );
		DecelerationAttacking = 1.55f * Utils.Select( Manager.Instance.Difficulty, 1f, 1f, 0.95f );

		_evadeDelayMin = 1.1f;
		_evadeDelayMax = 4.5f;
		_evadeDelay = Game.Random.Float( _evadeDelayMin, _evadeDelayMax );
		_evadeVelocityMin = 150f;
		_evadeVelocityMax = 300f;

		_nextJumpDelayMin = 0.6f;
		_nextJumpDelayMax = 8f;
		_delayUntilNextJump = Game.Random.Float( _nextJumpDelayMin, _nextJumpDelayMax );
		_maxJumpDist = 400f;

		_retreatVelocityMin = 100f;
		_retreatVelocityMax = 250f;
	}

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

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

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

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

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

	protected void HandleState()
	{
		switch ( State )
		{
			case RunnerState.Default:
				if ( _timeSinceEvade > _evadeDelay * (1f / TimeScale) && TargetUnit != null && (TargetUnit.Position2D - Position2D).LengthSquared < MathF.Pow( 120f, 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;
					}
				}

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

				if ( HasTarget && !IsInTheAir && _timeSinceJumping > _delayUntilNextJump * (IsAttacking ? 2f : 1f) )
					SetState( RunnerState.JumpPrepare );

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

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

				break;
		}
	}

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

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

				break;
			case RunnerState.JumpPrepare:
				PrepareJumpRpc();

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

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

				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 RunnerState.Jump:
				SetState( RunnerState.Default );

				var height = Game.Random.Float( 50f, 100f );
				var time = Utils.Map( (_jumpTargetPos - Position2D).Length, 0f, _maxJumpDist, 0.65f, 1.15f, EasingType.SineIn ) * Game.Random.Float( 0.85f, 1.1f );
				JumpRpc( _jumpTargetPos, height, time );

				break;
		}
	}

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

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

		if (_isRetreating)
			WorldRotation = Rotation.Lerp( WorldRotation, Rotation.FromYaw( WorldRotation.Yaw() + Utils.FastSin( TimeSinceSpawn * 0.7f ) * 40f ), _personalTurnSpeed * Time.Delta * TimeScale );
	}

	protected override Vector2 GetTargetOffset()
	{
		if ( IsAttacking || _isRetreating )
			return Vector2.Zero;

		return TargetUnit.Velocity * (0.7f + Utils.FastSin( TimeSinceSpawn * 1.9f ) * 0.45f) * (TargetUnit.Position2D - Position2D).Length * 0.02f;
	}

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

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

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

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

		Manager.Instance.PlaySfxNearby( "runner.bark", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 1f, maxDist: 150f );

		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 )
	{
		base.Damage( damage, player, damageType, hitPos, force, isCrit, shouldFlinch, damageFlags );

		if ( IsProxy || IsDying )
			return;

		if ( !IsSpawning && !IsAttacking && !_isRetreating && !IsInTheAir && player.IsValid() && _timeSinceRetreat > Game.Random.Float( 0.5f, 5f ) && Game.Random.Float( 0f, 1f ) < 0.2f )
		{
			_isRetreating = true;
			_retreatTimer = Game.Random.Float( 0.8f, 3f );
			_timeSinceRetreat = 0f;

			Vector2 impulseDir = (Position2D - player.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
				? (Position2D - player.Position2D).Normal
				: Utils.GetRandomVector();

			Velocity += impulseDir * Game.Random.Float( _retreatVelocityMin, _retreatVelocityMax) * TimeScale;
		}
	}

	[Rpc.Broadcast]
	public void PrepareJumpRpc()
	{
		SetAnim( "JumpPrepare" );

		CanAnimate = false;
	}

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

		PlayJumpSfx();
	}

	protected virtual void PlayJumpSfx()
	{
		Manager.Instance.PlaySfxNearby( "jump_whoosh", Position2D, pitch: Game.Random.Float( 1.55f, 1.6f ), volume: 0.7f, maxDist: 300f );
	}

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

		Manager.Instance.PlaySfxNearby( "jump_thud", Position2D, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.5f, maxDist: 220f );

		if ( IsProxy ) 
			return;

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

		_timeSinceDamageTarget = 999f; // so we can attack immediately
	}

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

		PlayFlinchAnim();

		SetState( RunnerState.Default );
	}

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

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

		var gibFolderName = GibFolder;

		SpawnGoreGib(
			$"{gibFolderName}/tail",
			localPos: new Vector3( 9.5f, 16f, 30f ),
			localRot: new Angles( 0f, -60f, 0f ),
			//localScale: new Vector3( 0.8f, 0.347f, 0.147f ),
			scaleMultiplier: 1f,
			dir,
			force,
			TintZeroHp,
			damageType
		);

		for(int i = 0; i < 3; i++ )
		{
			SpawnGoreGib(
				$"{gibFolderName}/tooth",
				localPos: new Vector3( 9.5f, 16f, 30f ),
				localRot: new Angles( 0f, -60f, 0f ),
				//localScale: new Vector3( 0.8f, 0.347f, 0.147f ),
				scaleMultiplier: 1f,
				dir,
				force,
				TintZeroHp,
				damageType
			);
		}
	}
}