Enemy NPC class ZombieElite, subclass of Zombie. Implements dash attack behavior, random walking, movement, rotation, animations, syncing state with RPCs, and difficulty-scaled stats.
using Sandbox;
using System;
using System.Runtime.Intrinsics.X86;
public class ZombieElite : Zombie
{
public override EnemyType EnemyType => EnemyType.ZombieElite;
public override float MeleeForce => 30f;
public override float MeleeRagdollForce => State == ZombieEliteState.Dash ? Game.Random.Float( 2f, 3f ) : Game.Random.Float( 0.6f, 1.3f );
public override float MeleeUpwardForceAmount => State == ZombieEliteState.Dash ? Game.Random.Float( 0f, 0.5f ) : Game.Random.Float( 0f, 0.3f );
public override float GetMaxHealth()
{
switch ( Manager.Instance.Difficulty )
{
case 0: default: return 50f;
case 1: return 55f;
case 2: return 58f;
}
}
[Property] public Material DashFlashMaterial { get; set; }
public override Vector3 SpawnScale => new Vector3( 1.15f );
private float _dashTimer;
private float _dashDelay;
private TimeSince _timeSincePrepareDash;
private float _dashDelayMin;
private float _dashDelayMax;
private const float DASH_PREPARE_TIME = 2f;
private float _dashTime;
private float _dashTimeMin;
private float _dashTimeMax;
private const float DASH_STRENGTH = 2500f;
private bool _isFlashActive;
private TimeSince _timeSinceFlashColor;
private const float FLASH_DELAY_START = 0.075f;
private const float FLASH_DELAY_END = 0.03f;
private float _dashRangeOuter;
private float _dashRangeInner;
private float _dashPrepareAnimSpeed;
//private Vector2 _dashTargetDir;
private float _prepareMinTurnSpeed;
// Random walk configuration
private float _randomWalkIntervalMin = 3f;
private float _randomWalkIntervalMax = 12f;
private float _randomWalkDurationMin = 1f;
private float _randomWalkDurationMax = 10f;
// Random walk state
private float _randomWalkTimer;
private float _randomWalkDuration;
private bool _isRandomWalking;
private Vector2 _randomWalkDirection;
//public override bool CanTurn => State == ZombieEliteState.Default;
public override bool CanAttack => base.CanAttack && (State == ZombieEliteState.Default || State == ZombieEliteState.Dash);
public override bool CanDamageByTouch => !IsDying && !IsStunned && !IsInTheAir && (IsAttacking || State == ZombieEliteState.Dash) && State != ZombieEliteState.DashPrepare;
protected enum ZombieEliteState
{
Default,
DashPrepare,
Dash,
}
[Sync] protected ZombieEliteState State { get; private set; } = ZombieEliteState.Default;
protected override void OnStart()
{
base.OnStart();
CoinValueMin = 2;
CoinValueMax = 3;
CoinChance = 0.7f;
Weight = 1.2f;
PushStrength = 6500f;
_personalSpeedScale = Game.Random.Float( 0.9f, 1.2f );
_personalSpeedFreq = Game.Random.Float( 7f, 12f );
ModelRenderer.SetBodyGroup( 0, 5 );
RefreshMinPrepareTurnSpeed();
if ( IsProxy )
return;
AggroRange = 90f;
DetectTargetRange = 500f;
LoseTargetRange = 900f;
LoseTargetTime = 6f;
MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 6f, 8f, 9f );
DamageTargetDelay = 0.5f;
_personalTurnSpeed = 6f;
//Acceleration = 110f;
//AccelerationAttacking = 140f;
//Deceleration = 2f;
//DecelerationAttacking = 1.65f;
Acceleration = 450f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
AccelerationAttacking = 800f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
Deceleration = 1.95f;
DecelerationAttacking = 1.75f;
_dashRangeOuter = Utils.Select( Manager.Instance.Difficulty, 230f, 240f, 260f );
_dashRangeInner = Utils.Select( Manager.Instance.Difficulty, 155f, 160f, 170f );
_dashDelayMin = Utils.Select( Manager.Instance.Difficulty, 4f, 3.5f, 2.5f );
_dashDelayMax = Utils.Select( Manager.Instance.Difficulty, 8f, 7f, 5.5f );
_dashTimeMin = Utils.Select( Manager.Instance.Difficulty, 0.275f, 0.275f, 0.3f );
_dashTimeMax = Utils.Select( Manager.Instance.Difficulty, 0.55f, 0.7f, 0.8f );
_dashTimer = _dashDelay = Game.Random.Float( 0.5f, _dashDelayMax );
_randomWalkTimer = Game.Random.Float( _randomWalkIntervalMin, _randomWalkIntervalMax );
}
protected override void OnUpdate()
{
base.OnUpdate();
//Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"{_prepareMinTurnSpeed}", new global::Transform( WorldPosition ) );
if ( IsInTheAir || Manager.Instance.IsGameOver )
return;
if ( IsDying )
return;
if ( State == ZombieEliteState.DashPrepare )
{
SetPlaybackRate( Utils.Map( _timeSinceChangeState, 0f, _dashTime, _dashPrepareAnimSpeed, 0f ) );
HandleDashFlashing();
}
if ( IsProxy )
return;
HandleState();
}
void HandleDashFlashing()
{
if ( _timeSinceFlashColor > Utils.Map( _timeSincePrepareDash, DASH_PREPARE_TIME, 0f, FLASH_DELAY_START, FLASH_DELAY_END, EasingType.QuadOut ) )
{
_isFlashActive = !_isFlashActive;
if ( _isFlashActive )
ModelRenderer.SetMaterial( DashFlashMaterial );
else
ResetMaterial();
_timeSinceFlashColor = 0f;
}
}
protected void HandleState()
{
switch ( State )
{
case ZombieEliteState.Default:
if ( HasTarget && TargetUnit.IsValid() && !IsStunned && !IsInTheAir )
{
// todo: increase dash range on higher difficulties
var targetDistSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
if ( targetDistSqr < MathF.Pow( _dashRangeOuter, 2f ) )
{
_dashTimer -= Time.Delta;
if ( _dashTimer < 0f && targetDistSqr < MathF.Pow( _dashRangeInner, 2f ) )
SetState( ZombieEliteState.DashPrepare );
}
}
// Handle random walk behavior
if ( _isRandomWalking )
{
_randomWalkDuration -= Time.Delta;
if ( _randomWalkDuration <= 0f )
{
_isRandomWalking = false;
_randomWalkTimer = Game.Random.Float( _randomWalkIntervalMin, _randomWalkIntervalMax );
}
}
else
{
_randomWalkTimer -= Time.Delta;
if ( _randomWalkTimer <= 0f )
StartRandomWalk();
}
break;
case ZombieEliteState.DashPrepare:
if ( _timeSinceChangeState > _dashTime )
SetState( ZombieEliteState.Dash );
break;
case ZombieEliteState.Dash:
// todo: move less fast when frozen
Velocity += (Vector2)WorldRotation.Forward * Utils.Map( _timeSinceChangeState, 0f, _dashTime, DASH_STRENGTH, 0f, EasingType.QuadIn ) * Time.Delta;
if ( _timeSinceChangeState > _dashTime )
{
Velocity *= 0.2f;
SetState( ZombieEliteState.Default );
}
break;
}
}
private void StartRandomWalk()
{
_isRandomWalking = true;
_randomWalkDuration = Game.Random.Float( _randomWalkDurationMin, _randomWalkDurationMax );
_randomWalkDirection = Utils.GetRandomVector();
}
protected void SetState( ZombieEliteState state )
{
State = state;
_timeSinceChangeState = 0f;
switch ( state )
{
case ZombieEliteState.Default:
EnterDefaultStateRpc();
break;
case ZombieEliteState.DashPrepare:
_isRandomWalking = false; // Cancel random walk when preparing to dash
PrepareDashRpc();
_dashTime = Game.Random.Float( _dashTimeMin, _dashTimeMax ) * Utils.Select( Manager.Instance.Difficulty, 0.7f, 1f, 1.2f );
_timeSincePrepareDash = 0f;
_dashDelay = Game.Random.Float( _dashDelayMin, _dashDelayMax );
_dashTimer = _dashDelay;
Velocity *= 0.2f;
break;
case ZombieEliteState.Dash:
DashRpc();
//_dashTargetDir = HasTarget ? (TargetPos - Position2D).Normal : (Vector2)WorldRotation.Forward;
//_dashTargetDir = (Vector2)WorldRotation.Forward;
break;
}
}
protected override void HandleRotation()
{
// random walking
if( _isRandomWalking && State == ZombieEliteState.Default && !IsAttacking && !ShouldRetreatFromTarget )
{
WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( ((Vector3)_randomWalkDirection).WithZ( 0f ) ), _personalTurnSpeed * Time.Delta * TimeScale );
return;
}
if( State == ZombieEliteState.DashPrepare )
{
var prepareFacingDir = HasTarget ? (TargetPos - Position2D).Normal : (Vector2)WorldRotation.Forward;
var turnEasingType = Manager.Instance.Difficulty switch
{
0 => EasingType.SineOut,
1 => EasingType.QuadIn,
2 => EasingType.ExpoIn,
_ => EasingType.SineOut
};
var prepareTurnSpeedFactor = Utils.Map( _timeSinceChangeState, 0f, _dashTime, 1f, _prepareMinTurnSpeed, turnEasingType );
WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( (Vector3)prepareFacingDir ), _personalTurnSpeed * 1.5f * prepareTurnSpeedFactor * Time.Delta * TimeScale );
return;
}
if( State == ZombieEliteState.Dash )
{
return;
}
base.HandleRotation();
}
protected override void HandleMovement()
{
var moveDir = (Vector2)WorldRotation.Forward;
if ( State == ZombieEliteState.Default )
{
float acceleration = (IsAttacking ? AccelerationAttacking : Acceleration);
Velocity += moveDir * acceleration * Time.Delta * TimeScale * Manager.Instance.GlobalMovespeedModifier;
}
if ( Manager.Instance.IsWindActive )
Velocity += (Manager.Instance.GlobalWindForce / (Weight * 3f)) * Time.Delta;
Velocity *= Math.Max( 1f - Time.Delta * (IsAttacking ? DecelerationAttacking : Deceleration) * Manager.Instance.GlobalFrictionModifier, 0f );
WorldPosition += (Vector3)Velocity * GetMoveSpeedFactor() * Time.Delta;
}
protected override float GetMoveSpeedFactor()
{
if ( State == ZombieEliteState.Dash )
return 1f;
return base.GetMoveSpeedFactor();
}
protected override Vector2 GetTargetOffset()
{
if ( State == ZombieEliteState.Default )
return TargetUnit.Velocity * 2f * Utils.Map( _dashTimer, _dashDelay, 0f, 0f, 1f, EasingType.QuadIn ) * (0.5f + Utils.FastSin( TimeSinceSpawn * 2.1f ) * 0.5f);
return Vector2.Zero;
}
[Rpc.Broadcast]
public void PrepareDashRpc()
{
SetAnim( "Attack" );
CanAnimate = false;
_dashPrepareAnimSpeed = Game.Random.Float( 1f, 2f );
SetPlaybackRate( _dashPrepareAnimSpeed );
Manager.Instance.PlaySfxNearby( "pounce.prepare", Position2D, pitch: Game.Random.Float( 1.2f, 1.3f ), volume: 1.5f, maxDist: 450f );
_timeSincePrepareDash = 0f;
}
[Rpc.Broadcast]
public void DashRpc()
{
SetPlaybackRate( 0f );
ResetMaterial();
Manager.Instance.PlaySfxNearby( "pounce", Position2D, pitch: Game.Random.Float( 0.8f, 0.85f ), volume: 0.4f, maxDist: 450f );
RefreshMinPrepareTurnSpeed();
}
public override void OnStun()
{
base.OnStun();
PlayFlinchAnim();
SetState( ZombieEliteState.Default );
}
[Rpc.Broadcast]
public void EnterDefaultStateRpc()
{
CanAnimate = true;
if ( IsAttacking )
PlayAttackAnim();
else
PlayWalkAnim();
ResetMaterial();
}
protected override void OnOutOfBounds( Direction direction )
{
base.OnOutOfBounds( direction );
if ( State == ZombieEliteState.Dash )
{
var currentFacing = (Vector2)WorldRotation.Forward;
Vector2 reflectedFacing = currentFacing;
var normal = Vector2.Zero;
if ( direction == Direction.Left )
normal = new Vector2( 1f, 0f );
else if ( direction == Direction.Right )
normal = new Vector2( -1f, 0f );
else if ( direction == Direction.Down )
normal = new Vector2( 0f, 1f );
else if ( direction == Direction.Up )
normal = new Vector2( 0f, -1f );
var dot = Vector2.Dot( currentFacing, normal );
if ( dot > 0.2f )
return;
reflectedFacing = Utils.GetReflectedVector( currentFacing, normal );
WorldRotation = Rotation.LookAt( reflectedFacing );
Velocity *= 0.05f;
// todo: sfx
}
else if ( State == ZombieEliteState.Default && _isRandomWalking ) // Reflect random walk direction off bounds
{
if ( direction == Direction.Left )
_randomWalkDirection = new Vector2( -Math.Abs( _randomWalkDirection.x ), _randomWalkDirection.y );
else if ( direction == Direction.Right )
_randomWalkDirection = new Vector2( Math.Abs( _randomWalkDirection.x ), _randomWalkDirection.y );
else if ( direction == Direction.Down )
_randomWalkDirection = new Vector2( _randomWalkDirection.x, Math.Abs( _randomWalkDirection.y ) );
else if ( direction == Direction.Up )
_randomWalkDirection = new Vector2( _randomWalkDirection.x, -Math.Abs( _randomWalkDirection.y ) );
}
}
void RefreshMinPrepareTurnSpeed()
{
_prepareMinTurnSpeed = Manager.Instance.Difficulty switch
{
0 => 0f,
1 => Game.Random.Float( 0f, 0.5f ),
2 => Game.Random.Float( 0f, 1f ),
_ => 0f
};
}
}