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